From a5fddec0e935bcf4582d446fc121499421ca3dd9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 22:18:53 -0400 Subject: [PATCH] fix(cutoffs): account for bounds translation roundoff safely --- OpenNest.Core/AutomaticCutOffPlanner.cs | 20 ++-- OpenNest.Core/CutOff.cs | 26 +++++ OpenNest.Tests/CutOffs/CutOffRoundoffTests.cs | 108 ++++++++++++++++++ docs/automatic-scrap-cutoffs.md | 2 +- 4 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 OpenNest.Tests/CutOffs/CutOffRoundoffTests.cs diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs index d6c0c3b..876dd68 100644 --- a/OpenNest.Core/AutomaticCutOffPlanner.cs +++ b/OpenNest.Core/AutomaticCutOffPlanner.cs @@ -99,8 +99,7 @@ public static class AutomaticCutOffPlanner if (part.BaseDrawing.IsCutOff) continue; - var partBounds = ValidatePart(part, bounds); - occupied = System.Math.Max(occupied, sign > 0 ? partBounds.Right : -partBounds.Left); + occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign)); hasParts = true; } if (!hasParts) @@ -232,7 +231,7 @@ public static class AutomaticCutOffPlanner "Plate parts and cut-off collections are required.", nameof(plate)); } - private static Box ValidatePart(Part part, Box sheet) + private static double ValidatePart(Part part, Box sheet, int sign) { Require(Finite(part.Location) && double.IsFinite(part.Rotation), "Part pose must be finite.", "plate"); @@ -250,6 +249,8 @@ public static class AutomaticCutOffPlanner // 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 entityBox = entity.BoundingBox; @@ -257,17 +258,18 @@ public static class AutomaticCutOffPlanner if (!SpecialLayers.IsMaterial(entity.Layer)) continue; entityBox = entityBox.Translate(part.Location); - // Conversion can refit arc centers beyond the raw-program/cached bounds. - // CutOff's broad phase uses cached bounds without tolerance: even a smaller - // protrusion can be crossed by a repeated line, so require strict containment. - Require(entityBox.Left >= cached.Left && entityBox.Right <= cached.Right && - entityBox.Bottom >= cached.Bottom && entityBox.Top <= cached.Top, + // Permit only bounded floating-point roundoff, with the same conservative + // padding in CutOff's broad phase and fallback. Geometry-scale protrusions + // (including refitted arc centers) are still rejected, even below epsilon. + 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"); + occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left); hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0; } Require(hasMaterial, "Real parts must contain material geometry.", "plate"); - return box; + return occupied; } private static void ValidateProgram(Program program, HashSet path) diff --git a/OpenNest.Core/CutOff.cs b/OpenNest.Core/CutOff.cs index 364036d..6e5db06 100644 --- a/OpenNest.Core/CutOff.cs +++ b/OpenNest.Core/CutOff.cs @@ -135,8 +135,15 @@ namespace OpenNest ) { var bb = part.BoundingBox; + var roundoff = GetBoundsRoundoff(part); var (partMin, partMax) = AxisBounds(bb, clearance); var (partStart, partEnd) = CrossAxisBounds(bb, clearance); + // Match the planner's representational allowance in BOTH pruning and + // fallback exclusions. Tolerating it only in validation could cut an edge. + partMin -= roundoff; + partMax += roundoff; + partStart -= roundoff; + partEnd += roundoff; if (cutPosition < partMin || cutPosition > partMax) return EmptyExclusions; @@ -157,6 +164,25 @@ namespace OpenNest return new List<(double Start, double End)> { (partStart, partEnd) }; } + /// + /// Bounds reconstructed as (local minimum + placement) + size can differ + /// from translated material endpoints by a few floating-point steps. This + /// is not a geometry tolerance: cap it well below epsilon so inconsistent + /// arcs still require repair. Do not change shared Part bounds or programs. + /// + internal static double GetBoundsRoundoff(Part part) + { + var bb = part.BoundingBox; + var magnitude = System.Math.Max(System.Math.Abs(bb.Left), System.Math.Abs(bb.Right)); + magnitude = System.Math.Max(magnitude, + System.Math.Max(System.Math.Abs(bb.Bottom), System.Math.Abs(bb.Top))); + magnitude = System.Math.Max(magnitude, System.Math.Max(bb.Length, bb.Width)); + magnitude = System.Math.Max(magnitude, + System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y))); + var step = System.Math.BitIncrement(magnitude) - magnitude; + return double.IsFinite(step) ? System.Math.Min(8 * step, Math.Tolerance.Epsilon / 4) : 0; + } + private List<(double Start, double End)> IntersectPerimeter( Entity perimeter, double cutPosition, diff --git a/OpenNest.Tests/CutOffs/CutOffRoundoffTests.cs b/OpenNest.Tests/CutOffs/CutOffRoundoffTests.cs new file mode 100644 index 0000000..7101273 --- /dev/null +++ b/OpenNest.Tests/CutOffs/CutOffRoundoffTests.cs @@ -0,0 +1,108 @@ +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.CutOffs; + +public class CutOffRoundoffTests +{ + // Like P260805-10: adding the local minimum and size after placement rounds + // one representational step below translating a material endpoint directly. + private static (Nest Nest, Plate Plate, Part Part) MakeNest(CutOffAxis axis) + { + Vector Point(double x, double y) => axis == CutOffAxis.Vertical ? new(x, y) : new(y, x); + var program = new Program(); + program.Codes.Add(new RapidMove(Point(-0.54907, 2))); + program.Codes.Add(new LinearMove(Point(2.80679, 2))); + program.Codes.Add(new LinearMove(Point(2.80679, 6))); + program.Codes.Add(new LinearMove(Point(-0.54907, 6))); + program.Codes.Add(new LinearMove(Point(-0.54907, 2))); + var drawing = new Drawing("translated rectangle", program); + var part = new Part(drawing, Point(13.65687, 10)); + var plate = new Plate(60, 120) { PartSpacing = 0 }; + var nest = new Nest("roundoff"); + nest.Drawings.Add(drawing); + nest.Plates.Add(plate); + plate.Parts.Add(part); + return (nest, plate, part); + } + + [Theory] + [InlineData(CutOffAxis.Vertical)] + [InlineData(CutOffAxis.Horizontal)] + public void RoundoffOnlyBoundsMismatch_PlansAppliesAndRoundTripsWithoutChangingParts(CutOffAxis axis) + { + var (nest, plate, part) = MakeNest(axis); + var program = part.Program; + var text = NestWriter.GetProgramText(program); + var box = part.BoundingBox; + var location = part.Location; + var quantity = part.BaseDrawing.Quantity.Nested; + var options = new AutomaticCutOffOptions { Spacing = 5 }; + var settings = new CutOffSettings(); + var plan = AutomaticCutOffPlanner.Create(plate, options, settings); + Assert.NotEmpty(plan.Definitions); + Assert.True(plan.HasSeparatedTail); + Assert.False(plan.HasBlockingDiagnostics); + Assert.Same(part, Assert.Single(plate.Parts)); + Assert.Empty(plate.CutOffs); + foreach (var definition in plan.Definitions) + plate.CutOffs.Add(definition); + plate.RegenerateCutOffs(settings); + Assert.Same(program, part.Program); + Assert.Same(box, part.BoundingBox); + Assert.Equal(location, part.Location); + Assert.Equal(text, NestWriter.GetProgramText(part.Program)); + Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested); + Assert.Empty(AutomaticCutOffPlanner.Create(plate, options, settings).Definitions); + + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + var loaded = new NestReader(stream).Read(); + var loadedPlate = Assert.Single(loaded.Plates); + Assert.Equal(plate.CutOffs.Count, loadedPlate.CutOffs.Count); + Assert.Empty(AutomaticCutOffPlanner.Create(loadedPlate, options, settings).Definitions); + } + + [Theory] + [InlineData(CutOffAxis.Vertical, false, 0)] + [InlineData(CutOffAxis.Vertical, true, 0)] + [InlineData(CutOffAxis.Horizontal, false, 0)] + [InlineData(CutOffAxis.Horizontal, true, 0)] + [InlineData(CutOffAxis.Vertical, false, 0.02)] + [InlineData(CutOffAxis.Vertical, true, 0.02)] + [InlineData(CutOffAxis.Horizontal, false, 0.02)] + [InlineData(CutOffAxis.Horizontal, true, 0.02)] + public void LineBeyondRoundedCache_DoesNotSkipMaterialOrClearance(CutOffAxis axis, bool useCache, double clearance) + { + var (_, plate, part) = MakeNest(axis); + var farEdge = 2.80679 + 13.65687; + var cachedEdge = axis == CutOffAxis.Vertical ? part.BoundingBox.Right : part.BoundingBox.Top; + Assert.True(farEdge > cachedEdge); + var cutPosition = farEdge + clearance; + Assert.True(cutPosition > cachedEdge + clearance); + var cut = new CutOff(new Vector(cutPosition, cutPosition), axis); + cut.Regenerate(plate, new CutOffSettings { PartClearance = clearance }, + useCache ? Plate.BuildPerimeterCache(plate) : null); + + // Independent rectangle oracle: even a line on the true edge must not cut + // along that edge or through its clearance band. Both gaps must remain. + var segments = ConvertProgram.ToGeometry(cut.Drawing.Program).OfType() + .Where(e => SpecialLayers.IsMaterial(e.Layer)).ToArray(); + Assert.Equal(2, segments.Length); + foreach (var segment in segments) + { + var from = axis == CutOffAxis.Vertical ? segment.StartPoint.Y : segment.StartPoint.X; + var to = axis == CutOffAxis.Vertical ? segment.EndPoint.Y : segment.EndPoint.X; + var low = System.Math.Min(from, to); + var high = System.Math.Max(from, to); + Assert.True(high <= 12 || low >= 16); + var alongGap = System.Math.Max(12 - high, low - 16); + var acrossGap = cutPosition - farEdge; + var distance = System.Math.Sqrt(acrossGap * acrossGap + alongGap * alongGap); + Assert.True(distance >= clearance - 1e-12); + } + } +} diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md index ff509aa..398fdbe 100644 --- a/docs/automatic-scrap-cutoffs.md +++ b/docs/automatic-scrap-cutoffs.md @@ -8,7 +8,7 @@ Finish nesting the active plate, then choose **Plate > Automatic Scrap Cutoffs** 2. Opening the modal dialog previews the initial proposal. After changing spacing, choose **Preview** to refresh it. Cuts run across the active sheet's actual width, advancing along X from the origin-side edge in the plate's quadrant. Review the used span, proposed separator, retained tail dimensions, and diagnostics; the sheet cannot be edited while the dialog is open. 3. Choose **Apply** to recalculate against the current parts/settings and add ordinary cutoff definitions at the end of the cutting sequence. Blocked or empty proposals add nothing. Existing parts and manual cutoff definitions are not moved or reordered. **Cancel**, Escape, and closing the dialog discard the detached preview. -An empty sheet is left untouched. Invalid input or out-of-sheet part geometry prevents automatic planning. Excessively small spacing is rejected rather than allocating an unbounded number of cuts. If converted material extends outside the part's cached bounds (for example, an inconsistent arc center that is corrected during conversion), planning refuses it rather than risking a cut through material; repair the geometry before retrying. +An empty sheet is left untouched. Invalid input or out-of-sheet part geometry prevents automatic planning. Excessively small spacing is rejected rather than allocating an unbounded number of cuts. Ordinary floating-point roundoff between translated material and cached bounds is accepted without changing the part or its program; cutoff obstacle checks and fallback exclusions receive the same conservative numerical padding. Larger discrepancies (for example, an inconsistent arc center corrected during conversion) still prevent planning rather than risking a cut through material; repair the geometry before retrying. ## Preserving the unused tail