fix(planning): refuse misplaced display perimeters by name

Review found three blanks that slipped past or failed silently. Enclosure
was checked against the outline's bounding box, so a cutout in the notch of
an L-shaped blank reached an unverified plan that warning acceptance could
apply; every cut point is now tested inside the outline itself, and the
material capture names the display perimeter when no ring encloses the
rest. A blank whose only cutouts are tabbed left the search with nothing
to choose and ended without a finding; it is now refused with its reason,
as is a blank with nothing to cut, which used to get only the generic
noncutting message. None of these offers even an unverified plan.

Clean-bounds containment, seam overcut trimming and incremental closure
drift now treat the display perimeter like the other material boundaries.
This commit is contained in:
aj committed 2026-10-22 19:56:14 -04:00
1 parent b45e9df593
commit 73e54fbd67
7 files changed
+86 -16

No files matched your search

@@ -49,8 +49,8 @@ internal static class IncrementalClosureDrift
// The chains material capture closes: cut, display and tab moves between rapids and other layers.
private static bool IsContour(Motion motion) => !motion.Suppressed
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab };
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter };
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
{
@@ -134,7 +134,9 @@ public sealed class LeadMaterialSnapshot
}
}
if (outer < 0)
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
throw new ArgumentException(ringDisplay.Contains(true)
? "A display perimeter must enclose every other contour."
: "Material must have exactly one enclosing perimeter.");
if (ringTabs[outer].Any(tab => tab))
throw new ArgumentException("A perimeter cannot have tabs.");
if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
@@ -54,7 +54,7 @@ internal static class NestedPartContainment
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null)
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
/// <summary>Whether a host's bounds admit an inner part's bounds as a containment candidate.</summary>
@@ -293,15 +293,16 @@ public sealed class PreparedContours
throw new ArgumentException("Preparation requires nonempty closed contours.");
if (contours.Count == 0 && tabbedCutouts.Count == 0 && breakCuts.Count == 0)
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
var outline = displayPerimeter.BoundingBox;
// Every cut must lie on the blank: a non-rectangular blank's box is not enough.
var outline = displayPerimeter.ToPolygon();
foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts))
{
shape.UpdateBounds();
var box = shape.BoundingBox;
if (box.Left < outline.Left - PostVerificationGeometry.Epsilon || box.Right > outline.Right + PostVerificationGeometry.Epsilon
|| box.Bottom < outline.Bottom - PostVerificationGeometry.Epsilon || box.Top > outline.Top + PostVerificationGeometry.Epsilon)
throw new ArgumentException("A display perimeter must enclose every other contour.");
}
foreach (var entity in shape.Entities)
if (!outline.ContainsPoint(entity is Circle circle ? circle.Center + new Vector(circle.Radius, 0) : Start(entity)))
throw new ArgumentException("A display perimeter must enclose every other contour.");
// The search plans a part through its closed contours; tabbed cutouts and break cuts
// alone leave it nothing to choose.
if (contours.Count == 0)
throw new ArgumentException("A display perimeter needs at least one closed cutout without tabs.");
foreach (var cut in breakCuts)
cut.UpdateBounds();
// Never a contour: nothing can choose, emit or lead it. Every contour is a hole.
@@ -72,8 +72,8 @@ internal static class SeamOvercut
// Incremental coordinates only need a consistent origin within one contour.
var reference = incremental ? position ?? new Vector() : new Vector();
var end = reference + motion.EndPoint;
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display }
or ArcMove { Layer: LayerType.Cut or LayerType.Display };
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter };
if (!cut || position == null)
{
Finish();
@@ -105,7 +105,9 @@ public static class CuttingPlanService
}
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
if (!clean.HasCuttingContour)
throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice.");
throw new NotSupportedException(clean.Motions.Any(m => m.Layer == LayerType.DisplayPerimeter)
? "A display perimeter with no cutouts has nothing to cut."
: "Scribe-only or noncutting source drawings are outside this route slice.");
var execution = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
if (!execution.HasCuttingContour)
throw new ArgumentException("Placed program has no nonzero cutting contour motions.");
@@ -90,13 +90,19 @@ public class DisplayPerimeterTests
[Theory]
[InlineData("mixed", "one whole closed contour")]
[InlineData("hole", "enclose every other contour")]
[InlineData("nothing", "noncutting")]
[InlineData("nothing", "display perimeter with no cutouts")]
[InlineData("notch", "enclose every other contour")]
[InlineData("second", "enclose every other contour")]
[InlineData("tabbedOnly", "closed cutout without tabs")]
public void InvalidDisplayPerimeters_AreRefusedWithTheirReason(string layout, string reason)
{
var program = layout switch
{
"mixed" => Door(lastEdge: LayerType.Cut),
"nothing" => Door(cutouts: false),
"notch" => NotchedDoor(),
"second" => SecondDisplayRing(),
"tabbedOnly" => TabbedOnly(),
_ => HoleOnDisplay(),
};
var plate = DoorPlate(program);
@@ -106,6 +112,38 @@ public class DisplayPerimeterTests
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
Assert.Contains(result.Findings, f => f.Message.Contains(reason, StringComparison.OrdinalIgnoreCase));
// Not even an unverified plan: nothing about a refused blank may be applied.
var proposal = CuttingPlanBatch.Capture([DoorPlate(program)], ExplicitContourTests.Parameters(), false).Plan();
Assert.False(proposal.CanApply);
Assert.False(proposal.CanApplyWithWarnings);
// An L-shaped blank with a cutout in its notch: inside the outline's box, outside the blank.
static Program NotchedDoor()
{
var program = new Program();
Ring(program, LayerType.DisplayPerimeter, (0, 0), (0, 20), (15, 20), (15, 10), (30, 10), (30, 0));
Ring(program, LayerType.Cut, (2, 2), (2, 6), (6, 6), (6, 2));
Ring(program, LayerType.Cut, (20, 14), (20, 18), (25, 18), (25, 14));
return program;
}
static Program SecondDisplayRing()
{
var program = Door();
Ring(program, LayerType.DisplayPerimeter, (26, 14), (26, 18), (28, 18), (28, 14));
return program;
}
static Program TabbedOnly()
{
var program = Door(cutouts: false);
program.Codes.Add(new RapidMove(5, 5));
program.Codes.Add(new LinearMove(5, 10) { Layer = LayerType.Cut });
program.Codes.Add(new LinearMove(10, 10) { Layer = LayerType.Tab });
program.Codes.Add(new LinearMove(10, 5) { Layer = LayerType.Cut });
program.Codes.Add(new LinearMove(5, 5) { Layer = LayerType.Cut });
return program;
}
static Program HoleOnDisplay()
{
@@ -117,6 +155,33 @@ public class DisplayPerimeterTests
}
}
private static void Ring(Program program, LayerType layer, params (double X, double Y)[] points)
{
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
foreach (var (x, y) in points.Skip(1).Append(points[0]))
program.Codes.Add(new LinearMove(x, y) { Layer = layer });
}
[Fact]
public void Preparation_HasNoCutPerimeterAndTreatsEveryContourAsAHole()
{
var prepared = PreparedContours.Capture(Door(), ExplicitContourTests.Parameters());
Assert.False(prepared.HasCutPerimeter);
Assert.Equal(3, prepared.Count);
Assert.Equal(prepared.Count, prepared.PerimeterOrdinal);
Assert.True(PreparedContours.Capture(Door(LayerType.Cut), ExplicitContourTests.Parameters()).HasCutPerimeter);
}
[Fact]
public void CleanBounds_IncludeTheDisplayPerimeter()
{
var bounds = NestedPartContainment.CleanBounds(Door(), Vector.Zero, default);
Assert.Equal(30, bounds.Length, 9);
Assert.Equal(20, bounds.Width, 9);
}
[Fact]
public void Replay_RefusesAProgramThatCutsTheDisplayPerimeter()
{