From 062c7fa08f5538979d6b40dcafe10236fba88078 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 10:40:59 -0400 Subject: [PATCH] fix(geometry): tighten circumscribed flattening; stop padding the validator The Clipper validator flagged valid Opus55 and PEP layouts (P260805-03, P260626-03). Two causes: - Arc.ToPoints(circumscribe) scales every vertex out by 1/cos(step/2), endpoints included, so a 0.03125 corner fillet flattened at 0.01 poked 0.013 past the straight edges it meets. ClipperBridge now flattens itself: circumscribed arcs keep their endpoints on the arc and put interior vertices on tangent intersections, with the segment count chosen so the outward error stays within the tolerance. Arc.ToPoints is unchanged for its other callers. - The conservative padding made a layout exactly at the spacing fail. NestValidator now uses OffsetForValidation: the same conservative flattening, round joins at a tenth of the tolerance, no padding. Its only leniency is that join chord error at convex corners. With both, Opus55 is valid on all 26 benchmark jobs (25 before the Clipper migration; the old failure was a spike artifact). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- OpenNest.Benchmark/NestValidator.cs | 12 +- OpenNest.Core/Geometry/ClipperBridge.cs | 136 +++++++++++++++++- .../Geometry/ClipperBridgeFlattenTests.cs | 90 ++++++++++++ 4 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 OpenNest.Tests/Geometry/ClipperBridgeFlattenTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 5118387..435956f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,7 +134,7 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe - Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion). - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. - Nesting uses async progress/cancellation: `IProgress` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`. -- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. Clipper is allowed only for cached CPU preparation, never in per-pair hot loops. +- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, so a layout exactly at the spacing passes. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. Clipper is allowed only for cached CPU preparation, never in per-pair hot loops. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. - **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load). diff --git a/OpenNest.Benchmark/NestValidator.cs b/OpenNest.Benchmark/NestValidator.cs index d8f4276..486fe5b 100644 --- a/OpenNest.Benchmark/NestValidator.cs +++ b/OpenNest.Benchmark/NestValidator.cs @@ -329,9 +329,10 @@ namespace OpenNest.Benchmark /// outward, cutouts offset inward, in one Clipper region offset). A cutout /// that closes up under the offset is dropped, which treats it as solid: /// conservative, since it has no room for another part at the required - /// spacing anyway. The flattening is conservative too (perimeter arcs - /// circumscribed, cutout arcs inscribed), so the check never passes a - /// layout that is closer than the spacing. + /// spacing anyway. Arcs are flattened conservatively (perimeter arcs + /// circumscribed, cutout arcs inscribed) but nothing is padded, so a layout + /// exactly at the spacing passes; the only leniency is the round-join chord + /// error at convex corners (OutlineTolerance / 10). /// part.Program is already rotated; only a Location offset is needed. /// private static PartOutline Outline(Part part, double inflateBy) @@ -352,11 +353,10 @@ namespace OpenNest.Benchmark // Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 // segments per arc) - arc-heavy real parts otherwise produce thousands // of vertices, which is needlessly slow for a spacing check. - var region = ClipperBridge.Offset( + var region = ClipperBridge.OffsetForValidation( profile, inflateBy > Tolerance.Epsilon ? inflateBy : 0, - OutlineTolerance, - circumscribe: true + OutlineTolerance ); var perimeter = region.LargestOuter(); diff --git a/OpenNest.Core/Geometry/ClipperBridge.cs b/OpenNest.Core/Geometry/ClipperBridge.cs index 37e470b..137f9cb 100644 --- a/OpenNest.Core/Geometry/ClipperBridge.cs +++ b/OpenNest.Core/Geometry/ClipperBridge.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Clipper2Lib; +using OpenNest.Math; namespace OpenNest.Geometry { @@ -20,6 +21,8 @@ namespace OpenNest.Geometry private const double ConservativeJoinFactor = 0.25; + private const double ValidationJoinFactor = 0.1; + /// /// Converts a polygon to a Clipper path, dropping the closing vertex and /// orienting it positive (CCW) or negative (CW). @@ -118,7 +121,7 @@ namespace OpenNest.Geometry bool circumscribe = false ) { - var polygon = perimeter.ToPolygonWithTolerance(tolerance, circumscribe); + var polygon = Flatten(perimeter, tolerance, circumscribe); return OffsetPerimeter(polygon, distance, tolerance, circumscribe); } @@ -160,6 +163,28 @@ namespace OpenNest.Geometry delta += joinTolerance + 0.5 * System.Math.Pow(10, -Precision); } + return Inflate(region, delta, joinTolerance); + } + + /// + /// Offset for checking a finished layout against its spacing. Arcs are flattened + /// as in conservative mode (perimeter arcs circumscribed, cutout arcs inscribed), + /// but round joins use a tenth of the tolerance and nothing is padded, so a layout + /// exactly at the spacing passes. The only under-estimate is the join chord error + /// at convex corners, at most a tenth of . + /// + public static OffsetRegion OffsetForValidation( + ShapeProfile profile, + double distance, + double tolerance + ) + { + var region = ToRegion(profile, tolerance, circumscribe: true); + return Inflate(region, distance, tolerance * ValidationJoinFactor); + } + + private static OffsetRegion Inflate(PathsD region, double delta, double joinTolerance) + { var inflated = delta <= 0 ? Union(region) @@ -227,6 +252,113 @@ namespace OpenNest.Geometry return largest == null ? null : ToPolygon(largest); } + /// + /// Flattens a closed shape to a polygon whose chords stay within + /// of every arc. Inscribed, the vertices lie on the + /// arcs. Circumscribed, arc endpoints stay on the arc and the interior vertices sit + /// on tangent intersections, so the polygon never falls inside the curve and never + /// pokes past the straight edges an arc meets. + /// + public static Polygon Flatten(Shape shape, double tolerance, bool circumscribe) + { + var polygon = new Polygon(); + + foreach (var entity in shape.Entities) + { + switch (entity) + { + case Line line: + polygon.Vertices.Add(line.StartPoint); + polygon.Vertices.Add(line.EndPoint); + break; + + case Arc arc: + AddArc(polygon.Vertices, arc, tolerance, circumscribe); + break; + + case Circle circle: + AddCircle(polygon.Vertices, circle, tolerance, circumscribe); + break; + } + } + + polygon.Close(); + polygon.Cleanup(); + polygon.UpdateBounds(); + return polygon; + } + + private static void AddArc(List points, Arc arc, double tolerance, bool circumscribe) + { + if (!circumscribe) + { + points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance))); + return; + } + + var sweep = arc.SweepAngle(); + var segments = CircumscribedSegments(arc.Radius, sweep, tolerance); + var step = (arc.IsReversed ? -sweep : sweep) / segments; + var r = arc.Radius / System.Math.Cos(System.Math.Abs(step) / 2); + + points.Add(arc.StartPoint()); + + for (var i = 0; i < segments; i++) + { + var angle = arc.StartAngle + step * (i + 0.5); + points.Add( + new Vector( + arc.Center.X + r * System.Math.Cos(angle), + arc.Center.Y + r * System.Math.Sin(angle) + ) + ); + } + + points.Add(arc.EndPoint()); + } + + private static void AddCircle( + List points, + Circle circle, + double tolerance, + bool circumscribe + ) + { + if (!circumscribe) + { + points.AddRange(circle.ToPoints(circle.SegmentsForTolerance(tolerance))); + return; + } + + var segments = CircumscribedSegments(circle.Radius, Angle.TwoPI, tolerance); + var step = Angle.TwoPI / segments; + var r = circle.Radius / System.Math.Cos(step / 2); + + for (var i = 0; i < segments; i++) + { + points.Add( + new Vector( + circle.Center.X + r * System.Math.Cos(step * i), + circle.Center.Y + r * System.Math.Sin(step * i) + ) + ); + } + } + + /// + /// Segments for a circumscribed arc: a tangent-intersection vertex sits + /// radius / cos(step / 2) from the center, so keep that within the tolerance, and + /// keep each step at 90 degrees or less so the tangents meet close to the arc. + /// + private static int CircumscribedSegments(double radius, double sweep, double tolerance) + { + var maxHalfStep = System.Math.Acos(radius / (radius + tolerance)); + var segments = (int)System.Math.Ceiling(System.Math.Abs(sweep) / (2 * maxHalfStep)); + var quarters = (int)System.Math.Ceiling(System.Math.Abs(sweep) / Angle.HalfPI); + + return System.Math.Max(1, System.Math.Max(segments, quarters)); + } + private static PathsD Union(PathsD region) { var clipper = new ClipperD(Precision); @@ -245,7 +377,7 @@ namespace OpenNest.Geometry bool positive ) { - AddPolygon(region, shape.ToPolygonWithTolerance(tolerance, circumscribe), positive); + AddPolygon(region, Flatten(shape, tolerance, circumscribe), positive); } private static void AddPolygon(PathsD region, Polygon polygon, bool positive) diff --git a/OpenNest.Tests/Geometry/ClipperBridgeFlattenTests.cs b/OpenNest.Tests/Geometry/ClipperBridgeFlattenTests.cs new file mode 100644 index 0000000..64885e2 --- /dev/null +++ b/OpenNest.Tests/Geometry/ClipperBridgeFlattenTests.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Benchmark; +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.Geometry; + +public class ClipperBridgeFlattenTests +{ + private const double Fillet = 0.03125; + + [Theory] + [InlineData(0.01)] + [InlineData(0.001)] + public void Flatten_Circumscribed_StaysWithinStraightEdgesAndTolerance(double tolerance) + { + // Circumscribing used to push arc endpoints outward too, so a small corner fillet + // poked 0.013 past the straight edges it meets. + var polygon = ClipperBridge.Flatten(FilletedRectangle(), tolerance, circumscribe: true); + + Assert.Equal(0, polygon.BoundingBox.Left, 9); + Assert.Equal(0, polygon.BoundingBox.Bottom, 9); + Assert.Equal(2, polygon.BoundingBox.Right, 9); + Assert.Equal(4, polygon.BoundingBox.Top, 9); + + // Every vertex is on or outside the true outline, by no more than the tolerance. + var shape = FilletedRectangle(); + + foreach (var v in polygon.Vertices) + { + var d = shape.Entities.Min(e => e.ClosestPointTo(v).DistanceTo(v)); + Assert.True(d <= tolerance + 1e-9, $"Vertex {v.X},{v.Y} is {d} from the outline."); + } + } + + [Theory] + [InlineData(0.25, true)] // Exactly at the spacing. + [InlineData(0.2505, true)] + [InlineData(0.245, false)] + public void NestValidator_FilletedPartsAtSpacing(double gap, bool valid) + { + var drawing = new Drawing("filleted", FilletedRectangleProgram()); + var plate = new Plate(100, 100) { PartSpacing = 0.25 }; + + // Off-grid locations, so Clipper's 1e-4 rounding cannot line things up exactly. + var a = new Part(drawing) { Location = new Vector(10.123456, 10.654321) }; + var b = new Part(drawing) { Location = new Vector(10.123456 + 2 + gap, 10.654321) }; + + var result = NestValidator.Validate( + new List<(Plate, List)> { (plate, new List { a, b }) }, + new Dictionary { [drawing] = ("filleted", 2) } + ); + + Assert.True(valid == result.Valid, string.Join("; ", result.Violations)); + } + + /// 2 x 4 rectangle with 0.03125 corner fillets, CCW from the origin. + private static Shape FilletedRectangle() + { + var f = Fillet; + var shape = new Shape(); + shape.Entities.Add(new Line(f, 0, 2 - f, 0)); + shape.Entities.Add(new Arc(2 - f, f, f, -Angle.HalfPI, 0)); + shape.Entities.Add(new Line(2, f, 2, 4 - f)); + shape.Entities.Add(new Arc(2 - f, 4 - f, f, 0, Angle.HalfPI)); + shape.Entities.Add(new Line(2 - f, 4, f, 4)); + shape.Entities.Add(new Arc(f, 4 - f, f, Angle.HalfPI, System.Math.PI)); + shape.Entities.Add(new Line(0, 4 - f, 0, f)); + shape.Entities.Add(new Arc(f, f, f, System.Math.PI, 3 * Angle.HalfPI)); + return shape; + } + + private static Program FilletedRectangleProgram() + { + var f = Fillet; + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(f, 0))); + pgm.Codes.Add(new LinearMove(new Vector(2 - f, 0))); + pgm.Codes.Add(new ArcMove(new Vector(2, f), new Vector(2 - f, f))); + pgm.Codes.Add(new LinearMove(new Vector(2, 4 - f))); + pgm.Codes.Add(new ArcMove(new Vector(2 - f, 4), new Vector(2 - f, 4 - f))); + pgm.Codes.Add(new LinearMove(new Vector(f, 4))); + pgm.Codes.Add(new ArcMove(new Vector(0, 4 - f), new Vector(f, 4 - f))); + pgm.Codes.Add(new LinearMove(new Vector(0, f))); + pgm.Codes.Add(new ArcMove(new Vector(f, 0), new Vector(f, f))); + return pgm; + } +}