fix(cut-offs): accept a blank's outline a rounding step past the sheet

Automatic cut-offs refused any part reaching past the physical sheet by
more than Tolerance.Epsilon. Imported supplied blanks (panel doors,
remnants) equal their sheet but carry PEP's rounding: a 0.000006 outline
skew plus placements rounded to 0.001 put the never-cut outline 0.00001
to 0.00003 in past the sheet, so Plan Cutting refused every such nest.

A display perimeter may now extend up to DisplayPerimeterSheetAllowance
(0.0001 model units) past the sheet. Nothing cuts it, and a blank that
fills its sheet gets no cut-offs. Everything else on the part keeps the
ordinary tolerance: cuts, leads, scribes and travel, except a rapid that
ends on the outline itself (the move to its first corner, or an applied
program's display tail). A larger overhang is still refused.
This commit is contained in:
aj committed 2026-10-23 05:55:32 -04:00
1 parent c188014a30
commit bbadd6aa75
2 files changed
+131 -6

No files matched your search

+41 -6
View File
@@ -90,6 +90,15 @@ public sealed class AutomaticCutOffPlan
public static class AutomaticCutOffPlanner
{
public const double GeometryTolerance = Tolerance.Epsilon;
/// <summary>
/// How far a display perimeter (a supplied blank's never-cut outline) may extend past the
/// physical sheet, in model units. Imported blanks equal their sheet but carry the source CAM's
/// rounding (PEP: a 0.000006 outline skew plus placements rounded to 0.001), a few hundred-
/// thousandths of an inch. Nothing cuts that outline, so it is accepted; anything cut, or a
/// larger overhang that signals a real placement error, is still refused.
/// </summary>
public const double DisplayPerimeterSheetAllowance = 1e-4;
public const double MinimumSpacing = 2 * GeometryTolerance;
public const int MaximumCandidateCount = 10000;
public const int MaximumShiftCandidateCount = 20000;
@@ -432,19 +441,31 @@ public static class AutomaticCutOffPlanner
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
"Part geometry has stale bounds; update it before planning.", "plate");
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
var hasDisplayPerimeter = DisplayPerimeterTail.HasDisplayPerimeterMotion(part.Program);
Require(Inside(box, sheet, hasDisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
"Part geometry extends outside the physical sheet.", "plate");
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
// Checking converted entities catches overflowing incremental moves and curve bounds.
var hasMaterial = false;
var occupied = sign > 0 ? box.Right : -box.Left;
var roundoff = CutOff.GetBoundsRoundoff(part);
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
var entities = ConvertProgram.ToGeometry(part.Program);
var outline = entities.Where(e => e.Layer == SpecialLayers.DisplayPerimeter).ToList();
foreach (var entity in entities)
{
var entityBox = entity.BoundingBox;
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
if (!SpecialLayers.IsMaterial(entity.Layer))
{
// Travel and marks (a scribe is lasered) keep the ordinary tolerance. Only a rapid
// ending on the outline (its first corner, or an applied program's display tail)
// shares the outline's allowance.
Require(Inside(entityBox.Translate(part.Location), sheet)
|| hasDisplayPerimeter && IsRapidOntoOutline(entity, outline, part.Location, sheet),
"Part geometry extends outside the physical sheet.", "plate");
continue;
}
entityBox = entityBox.Translate(part.Location);
// Permit only bounded floating-point roundoff, with the same conservative
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
@@ -452,7 +473,9 @@ public static class AutomaticCutOffPlanner
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
Require(Inside(entityBox, sheet,
entity.Layer == SpecialLayers.DisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
"Part geometry extends outside the physical sheet.", "plate");
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
}
@@ -531,9 +554,21 @@ public static class AutomaticCutOffPlanner
Nonnegative(box.Length) && Nonnegative(box.Width) &&
double.IsFinite(box.Right) && double.IsFinite(box.Top);
private static bool Inside(Box box, Box sheet) =>
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
// A rapid that leaves the sheet only at an end lying on the display perimeter, within its allowance.
private static bool IsRapidOntoOutline(Entity entity, List<Entity> outline, Vector location, Box sheet)
{
if (entity.Layer != SpecialLayers.Rapid || entity is not Line rapid
|| !Inside(rapid.BoundingBox.Translate(location), sheet, DisplayPerimeterSheetAllowance))
return false;
bool Allowed(Vector point) =>
Inside(new Box(point.X + location.X, point.Y + location.Y, 0, 0), sheet)
|| outline.Any(e => e.ClosestPointTo(point).DistanceTo(point) <= GeometryTolerance);
return Allowed(rapid.StartPoint) && Allowed(rapid.EndPoint);
}
private static bool Inside(Box box, Box sheet, double tolerance = GeometryTolerance) =>
box.Left >= sheet.Left - tolerance && box.Right <= sheet.Right + tolerance &&
box.Bottom >= sheet.Bottom - tolerance && box.Top <= sheet.Top + tolerance;
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
@@ -234,6 +234,96 @@ public class DisplayPerimeterTests
Assert.All(motions.Where(m => !m.Rapid), m => Assert.True(InsideACutout(m.End), $"({m.End.X}, {m.End.Y}) leaves the cutouts."));
}
// PEP draws a supplied blank a few hundred-thousandths of an inch past its sheet (an outline
// skew of 0.000006 plus placements rounded to 0.001). The outline is never cut, so automatic
// cut-offs accept it within DisplayPerimeterSheetAllowance; anything larger, or the same overhang
// on geometry that is cut, is still refused.
[Theory]
[InlineData(LayerType.DisplayPerimeter, 3e-5, 0, true)]
[InlineData(LayerType.DisplayPerimeter, 0, 3e-5, true)]
[InlineData(LayerType.DisplayPerimeter, 2e-4, 0, false)]
[InlineData(LayerType.Cut, 3e-5, 0, false)]
public void AutomaticCutOffs_AcceptOnlyADisplayPerimeterWithinTheAllowancePastTheSheet(
LayerType outline, double overRight, double overBottom, bool accepted)
{
var plate = new Plate(new Size(20, 30 - overRight));
plate.Parts.Add(new Part(new Drawing("door", Door(outline)), new Vector(0, -overBottom)));
var create = () => AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumStripLength = 17.5, MinimumTailLength = 12 },
new CutOffSettings());
if (!accepted)
{
var refusal = Assert.Throws<ArgumentException>(create);
Assert.Contains("outside the physical sheet", refusal.Message);
return;
}
// The blank fills the sheet: nothing is left to cut off.
Assert.Empty(create().Definitions);
}
// Cut-offs are staged before planning checks the door, so a malformed one can still carry a
// cut past the sheet while its outline is inside: the allowance covers the outline only.
[Fact]
public void AutomaticCutOffs_RefuseACutPastTheSheetEvenOnADisplayPerimeterPart()
{
var program = Door();
program.MoveTo(29, 1); program.LineTo(30.00003, 1); program.LineTo(30.00003, 2); program.LineTo(29, 2); program.LineTo(29, 1);
var plate = new Plate(new Size(20, 30));
plate.Parts.Add(new Part(new Drawing("door", program), Vector.Zero));
var refusal = Assert.Throws<ArgumentException>(() => AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumStripLength = 17.5, MinimumTailLength = 12 },
new CutOffSettings()));
Assert.Contains("outside the physical sheet", refusal.Message);
}
private static AutomaticCutOffPlan StageCutOffs(Plate plate) => AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumStripLength = 17.5, MinimumTailLength = 12 },
new CutOffSettings());
// The allowance belongs to the outline alone: a scribe (lasered) or a rapid past the sheet on a
// door is refused at the ordinary tolerance, except a rapid ending on the outline itself.
[Theory]
[InlineData("scribe")]
[InlineData("rapid")]
public void AutomaticCutOffs_RefuseAScribeOrRapidPastTheSheetOnADisplayPerimeterPart(string motion)
{
var program = Door();
if (motion == "scribe")
{
program.Codes.Add(new RapidMove(29, 1));
program.Codes.Add(new LinearMove(30.00003, 1) { Layer = LayerType.Scribe });
program.Codes.Add(new RapidMove(29, 2));
}
else
program.Codes.Add(new RapidMove(30.00005, 1));
var plate = new Plate(new Size(20, 30));
plate.Parts.Add(new Part(new Drawing("door", program), Vector.Zero));
var refusal = Assert.Throws<ArgumentException>(() => StageCutOffs(plate));
Assert.Contains("outside the physical sheet", refusal.Message);
}
// The door's rapid to its outline's first corner, in the drawing and again in an applied
// program's display tail, ends on the outline, so it shares the outline's allowance.
[Fact]
public void AutomaticCutOffs_AcceptTheRapidToAnOverhangingOutlineBeforeAndAfterApply()
{
var plate = new Plate(new Size(20, 30 - 3e-5));
var part = new Part(new Drawing("door", Door()), new Vector(-3e-5, 0));
plate.Parts.Add(part);
Assert.Empty(StageCutOffs(plate).Definitions);
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status);
Assert.True(DisplayPerimeterTail.HasTail(part.Program));
Assert.Empty(StageCutOffs(plate).Definitions);
}
[Fact]
public void Door_AutomaticCutOffsAddNoLineAcrossTheBlank()
{