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; + } +}