diff --git a/CLAUDE.md b/CLAUDE.md index 7565efa..435956f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows witho Cross-platform CAD import tests: `dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj`. These synthetic-DXF and bend-repair tests target `net8.0`, require no external fixtures, and are included in the solution. Build the headless console independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`. -NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training). +NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `Clipper2` 2.0.0 (region offsetting, in OpenNest.Core), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training). ## Architecture @@ -31,7 +31,7 @@ Domain model, geometry, and CNC primitives organized into namespaces: - **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`. - **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output. -- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction). +- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon` (convex NFP only), `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, `ClipperBridge` (Clipper2 region offsetting for CPU preparation only; see Key Patterns), and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction; deliberately hand-rolled as the reference for a future GPU kernel, with the port contract in its class summary). - **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental). - **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed. - **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration. @@ -134,6 +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 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 bb1dea3..5771059 100644 --- a/OpenNest.Benchmark/NestValidator.cs +++ b/OpenNest.Benchmark/NestValidator.cs @@ -315,6 +315,8 @@ namespace OpenNest.Benchmark ? requirement.Name : part.BaseDrawing.Name; + private const double OutlineTolerance = 0.001; + private sealed class PartOutline { public Polygon Perimeter { get; init; } @@ -324,9 +326,13 @@ namespace OpenNest.Benchmark /// /// Extracts a part's material as world-space polygons - the perimeter and /// its cutouts - grown by (perimeter offset - /// outward, cutouts offset inward). 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. + /// 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. 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) @@ -344,57 +350,32 @@ namespace OpenNest.Benchmark if (profile.Perimeter == null) return null; - var perimeter = profile.Perimeter; - - if (inflateBy > Tolerance.Epsilon) - perimeter = perimeter.OffsetOutward(inflateBy) ?? perimeter; - - var polygon = ToWorldPolygon(perimeter, part.Location); - - if (polygon == null) - return null; - - var holes = new List(); - - foreach (var cutout in profile.Cutouts) - { - var hole = cutout; - - if (inflateBy > Tolerance.Epsilon) - { - hole = cutout.OffsetInward(inflateBy); - - // An offset that collapsed or flipped inside-out leaves no usable room. - if ( - hole == null - || hole.Area() <= Tolerance.Epsilon - || hole.Area() >= cutout.Area() - ) - continue; - } - - var holePolygon = ToWorldPolygon(hole, part.Location); - - if (holePolygon != null) - holes.Add(holePolygon); - } - - return new PartOutline { Perimeter = polygon, Holes = holes }; - } - - private static Polygon ToWorldPolygon(Shape shape, Vector location) - { // 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 polygon = shape.ToPolygonWithTolerance(0.01, circumscribe: true); + var region = ClipperBridge.OffsetForValidation( + profile, + inflateBy > Tolerance.Epsilon ? inflateBy : 0, + OutlineTolerance + ); - if (polygon == null) + var perimeter = region.LargestOuter(); + + if (perimeter == null) return null; + ToWorld(perimeter, part.Location); + + foreach (var hole in region.Holes) + ToWorld(hole, part.Location); + + return new PartOutline { Perimeter = perimeter, Holes = region.Holes }; + } + + private static void ToWorld(Polygon polygon, Vector location) + { polygon.Offset(location); polygon.UpdateBounds(); - return polygon; } } } diff --git a/OpenNest.Core/Converters/ConvertProgram.cs b/OpenNest.Core/Converters/ConvertProgram.cs index 76f805a..e59dde1 100644 --- a/OpenNest.Core/Converters/ConvertProgram.cs +++ b/OpenNest.Core/Converters/ConvertProgram.cs @@ -121,6 +121,8 @@ namespace OpenNest.Converters center += curpos; } + center = FitCenterToEndpoints(center, curpos, endpt); + var startAngle = center.AngleTo(curpos); var endAngle = center.AngleTo(endpt); @@ -157,6 +159,35 @@ namespace OpenNest.Converters curpos = endpt; } + /// + /// Programs can carry arc centers that are not quite equidistant from the + /// start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from + /// the end radius alone then leaves its start point off the previous move's + /// end, which breaks contour chaining. Project the center onto the chord's + /// perpendicular bisector so the arc passes through both endpoints exactly. + /// + private static Vector FitCenterToEndpoints(Vector center, Vector start, Vector end) + { + var startRadius = center.DistanceTo(start); + var endRadius = center.DistanceTo(end); + + if (startRadius.IsEqualTo(endRadius)) + return center; + + var chord = end - start; + var chordLengthSq = chord.X * chord.X + chord.Y * chord.Y; + + // Full circle (start == end): no chord to fit against. + if (chordLengthSq < Tolerance.Epsilon * Tolerance.Epsilon) + return center; + + var mid = new Vector((start.X + end.X) * 0.5, (start.Y + end.Y) * 0.5); + var normal = new Vector(-chord.Y, chord.X); + var t = ((center.X - mid.X) * normal.X + (center.Y - mid.Y) * normal.Y) / chordLengthSq; + + return new Vector(mid.X + normal.X * t, mid.Y + normal.Y * t); + } + private static Layer ConvertLayer(LayerType layer) { switch (layer) diff --git a/OpenNest.Core/CutOff.cs b/OpenNest.Core/CutOff.cs index e7f513f..db9c5a9 100644 --- a/OpenNest.Core/CutOff.cs +++ b/OpenNest.Core/CutOff.cs @@ -13,6 +13,8 @@ namespace OpenNest public class CutOff { + private const double OffsetTolerance = 0.001; + public Vector Position { get; set; } public CutOffAxis Axis { get; set; } public double? StartLimit { get; set; } @@ -163,14 +165,23 @@ namespace OpenNest double clearance ) { - var target = OffsetOutward(perimeter, clearance) ?? perimeter; - var usedOffset = target != perimeter; + var offset = OffsetOutward(perimeter, clearance); + var usedOffset = offset != null; + var targets = offset ?? new List { perimeter }; var cutLine = new Line( MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd) ); - if (!target.Intersects(cutLine, out var pts) || pts.Count < 2) + var pts = new List(); + + foreach (var target in targets) + { + if (target.Intersects(cutLine, out var targetPts)) + pts.AddRange(targetPts); + } + + if (pts.Count < 2) return null; var coords = pts.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X) @@ -188,21 +199,37 @@ namespace OpenNest return result; } - private static Entity OffsetOutward(Entity perimeter, double clearance) + /// + /// Grows the perimeter by the clearance as one Clipper region offset, so slots + /// narrower than twice the clearance close up instead of leaving a gap the cut + /// could run into. Holes appear only where the perimeter curls back on itself. + /// + private static List OffsetOutward(Entity perimeter, double clearance) { if (clearance <= 0) return null; - try - { - var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left); - offset?.UpdateBounds(); - return offset; - } - catch + var offset = perimeter switch { + Shape shape => ClipperBridge.OffsetPerimeter( + shape, + clearance, + OffsetTolerance, + circumscribe: true + ), + Polygon polygon => ClipperBridge.OffsetPerimeter( + polygon, + clearance, + OffsetTolerance, + circumscribe: true + ), + _ => null, + }; + + if (offset == null || offset.Outers.Count == 0) return null; - } + + return offset.Outers.Concat(offset.Holes).Cast().ToList(); } private Vector MakePoint(double cutCoord, double lineCoord) => diff --git a/OpenNest.Core/Geometry/Arc.cs b/OpenNest.Core/Geometry/Arc.cs index b361763..5a3c4a7 100644 --- a/OpenNest.Core/Geometry/Arc.cs +++ b/OpenNest.Core/Geometry/Arc.cs @@ -443,19 +443,23 @@ namespace OpenNest.Geometry boundingBox.Width = maxY - minY; } + /// + /// Offsets the arc to the given side of its travel direction. The center lies to + /// the left of a CCW arc and to the right of a CW (reversed) one, so the arc grows + /// on the other side and shrinks toward its center. Returns null when it shrinks + /// to nothing. + /// public override Entity OffsetEntity(double distance, OffsetSide side) { - if (side == OffsetSide.Left && reversed) - { - return new Arc(center, radius + distance, startAngle, endAngle, reversed); - } - else - { - if (distance >= radius) - return null; + var grows = (side == OffsetSide.Left) == reversed; - return new Arc(center, radius - distance, startAngle, endAngle, reversed); - } + if (grows) + return new Arc(center, radius + distance, startAngle, endAngle, reversed); + + if (distance >= radius) + return null; + + return new Arc(center, radius - distance, startAngle, endAngle, reversed); } public override Entity OffsetEntity(double distance, Vector pt) diff --git a/OpenNest.Core/Geometry/Circle.cs b/OpenNest.Core/Geometry/Circle.cs index e89a62b..7c890f0 100644 --- a/OpenNest.Core/Geometry/Circle.cs +++ b/OpenNest.Core/Geometry/Circle.cs @@ -273,7 +273,10 @@ namespace OpenNest.Geometry public override Entity OffsetEntity(double distance, OffsetSide side) { - if (side == OffsetSide.Left && Rotation == RotationType.CCW) + // The center lies to the left of a CCW circle and to the right of a CW one. + var shrinks = (side == OffsetSide.Left) == (Rotation == RotationType.CCW); + + if (shrinks) { return Radius <= distance ? null @@ -281,7 +284,7 @@ namespace OpenNest.Geometry } else { - return new Circle(center, Radius + distance) { Layer = Layer }; + return new Circle(center, Radius + distance) { Layer = Layer, Rotation = Rotation }; } } diff --git a/OpenNest.Core/Geometry/ClipperBridge.cs b/OpenNest.Core/Geometry/ClipperBridge.cs new file mode 100644 index 0000000..137f9cb --- /dev/null +++ b/OpenNest.Core/Geometry/ClipperBridge.cs @@ -0,0 +1,423 @@ +using System.Collections.Generic; +using Clipper2Lib; +using OpenNest.Math; + +namespace OpenNest.Geometry +{ + /// + /// Region offsetting through Clipper2, for CPU-side preparation only: work done + /// once per drawing, rotation or spacing whose output is cached and fed to hot + /// loops. Per-pair tests () stay hand-rolled so they can + /// be ported to a GPU kernel. + /// + public static class ClipperBridge + { + /// + /// Decimal places Clipper keeps (1e-4 in either inches or mm). + /// + public const int Precision = 4; + + private const double MiterLimit = 2.0; + + 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). + /// + public static PathD ToPath(Polygon polygon, bool positive) + { + var path = ToPath(polygon, new Vector()); + + if (path.Count >= 3 && Clipper.IsPositive(path) != positive) + path.Reverse(); + + return path; + } + + /// + /// Converts a polygon to a Clipper path with an optional offset, dropping the + /// closing vertex and keeping the polygon's own winding. + /// + public static PathD ToPath(Polygon polygon, Vector offset) + { + var verts = polygon.Vertices; + var n = verts.Count; + + if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y) + n--; + + var path = new PathD(n); + + for (var i = 0; i < n; i++) + path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y)); + + return path; + } + + /// + /// Converts a Clipper path to a closed polygon with updated bounds. + /// + public static Polygon ToPolygon(PathD path) + { + var polygon = new Polygon(); + + foreach (var pt in path) + polygon.Vertices.Add(new Vector(pt.x, pt.y)); + + polygon.Close(); + polygon.UpdateBounds(); + return polygon; + } + + /// + /// Flattens a profile into a Clipper region: perimeter positive, cutouts negative. + /// + public static PathsD ToRegion(ShapeProfile profile, double tolerance, bool circumscribe) + { + var region = new PathsD(profile.Cutouts.Count + 1); + AddShape(region, profile.Perimeter, tolerance, circumscribe, positive: true); + + // A cutout is flattened the opposite way: circumscribing it would shrink the + // material around it, so inscribe instead to keep the region conservative. + foreach (var cutout in profile.Cutouts) + AddShape(region, cutout, tolerance, !circumscribe, positive: false); + + return region; + } + + /// + /// Offsets a part region outward by : the perimeter + /// grows and the cutouts shrink. Features narrower than twice the distance + /// collapse, and cutouts that close up disappear. Joins are round, with chords + /// no more than from the true arc. + /// + /// + /// When true, the result never under-estimates the offset: perimeter arcs are + /// flattened outside the true curve, cutout arcs inside it, and the inflation is + /// padded by the round-join chord error and Clipper's rounding. + /// + public static OffsetRegion Offset( + ShapeProfile profile, + double distance, + double tolerance, + bool circumscribe = false + ) + { + var region = ToRegion(profile, tolerance, circumscribe); + return Offset(region, distance, tolerance, circumscribe); + } + + /// + /// Offsets a single closed shape outward, ignoring any cutouts. A perimeter that + /// curls back on itself (a C shape with a narrow mouth) can gain holes. + /// + public static OffsetRegion OffsetPerimeter( + Shape perimeter, + double distance, + double tolerance, + bool circumscribe = false + ) + { + var polygon = Flatten(perimeter, tolerance, circumscribe); + return OffsetPerimeter(polygon, distance, tolerance, circumscribe); + } + + /// + /// Offsets a closed polygon outward, whatever its winding. + /// + public static OffsetRegion OffsetPerimeter( + Polygon perimeter, + double distance, + double tolerance, + bool circumscribe = false + ) + { + var region = new PathsD(1); + AddPolygon(region, perimeter, positive: true); + return Offset(region, distance, tolerance, circumscribe); + } + + /// + /// Offsets an already-flattened region (outers positive, holes negative). + /// A distance of zero only unions the region, with no conservative padding. + /// + public static OffsetRegion Offset( + PathsD region, + double distance, + double tolerance, + bool circumscribe = false + ) + { + // Round joins put their vertices on the true arc, so each chord sits inside + // it by up to the join tolerance. In conservative mode, joins use a finer + // tolerance and the inflation is padded by it (plus Clipper's rounding). + var delta = distance; + var joinTolerance = tolerance; + + if (circumscribe && distance > 0) + { + joinTolerance = tolerance * ConservativeJoinFactor; + 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) + : Clipper.InflatePaths( + region, + delta, + JoinType.Round, + EndType.Polygon, + MiterLimit, + Precision, + joinTolerance + ); + + var result = new OffsetRegion(new List(), new List()); + + foreach (var path in inflated) + { + if (path.Count < 3) + continue; + + if (Clipper.IsPositive(path)) + result.Outers.Add(ToPolygon(path)); + else + result.Holes.Add(ToPolygon(path)); + } + + return result; + } + + /// + /// Miter-offsets a closed polygon by (positive grows it, + /// negative shrinks it). Returns the largest resulting polygon (CCW), or null + /// when the polygon collapses. + /// + public static Polygon OffsetMiter(Polygon polygon, double delta) + { + var path = ToPath(polygon, positive: true); + + if (path.Count < 3) + return null; + + var inflated = Clipper.InflatePaths( + new PathsD { path }, + delta, + JoinType.Miter, + EndType.Polygon, + MiterLimit, + Precision + ); + + PathD largest = null; + var largestArea = 0.0; + + foreach (var candidate in inflated) + { + var area = Clipper.Area(candidate); + + if (area > largestArea) + { + largest = candidate; + largestArea = area; + } + } + + 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); + clipper.AddSubject(region); + + var solution = new PathsD(); + clipper.Execute(ClipType.Union, FillRule.NonZero, solution); + return solution; + } + + private static void AddShape( + PathsD region, + Shape shape, + double tolerance, + bool circumscribe, + bool positive + ) + { + AddPolygon(region, Flatten(shape, tolerance, circumscribe), positive); + } + + private static void AddPolygon(PathsD region, Polygon polygon, bool positive) + { + if (polygon.Vertices.Count < 3) + return; + + var path = ToPath(polygon, positive); + + if (path.Count >= 3) + region.Add(path); + } + } + + /// + /// Result of : + /// outer boundaries (CCW) and holes (CW), as closed polygons. + /// + public sealed record OffsetRegion(List Outers, List Holes) + { + /// + /// The outer boundary with the largest area, or null when the region is empty. + /// + public Polygon LargestOuter() + { + Polygon best = null; + var bestArea = 0.0; + + foreach (var outer in Outers) + { + var area = outer.Area(); + + if (best == null || area > bestArea) + { + best = outer; + bestArea = area; + } + } + + return best; + } + } +} diff --git a/OpenNest.Core/Geometry/Collision.cs b/OpenNest.Core/Geometry/Collision.cs index 331f04d..4e639b4 100644 --- a/OpenNest.Core/Geometry/Collision.cs +++ b/OpenNest.Core/Geometry/Collision.cs @@ -3,6 +3,22 @@ using OpenNest.Math; namespace OpenNest.Geometry { + /// + /// Polygon overlap test with hole subtraction. This is the reference implementation + /// for a future GPU kernel, so it deliberately stays hand-rolled instead of using + /// Clipper (which is CPU-only and allocation-heavy; see + /// for the CPU preparation that feeds it). + /// + /// GPU-port contract. Per-polygon preparation, done once per drawing and rotation, + /// then cached and uploaded: the spacing offset (), + /// triangulation () of the outline and + /// each hole, and the bounding box of every polygon and triangle. Per-pair work, + /// kernel-shaped (fixed-size, loop-only, no recursion): the bounding-box rejects, + /// Sutherland-Hodgman clipping of convex triangle pairs (ClipConvex), and + /// subtraction of hole triangles from the clipped regions (SubtractTriangles). + /// Inputs are closed, lines-only polygons; winding is normalized by triangulation. + /// + /// public static class Collision { public static CollisionResult Check( diff --git a/OpenNest.Core/Geometry/Line.cs b/OpenNest.Core/Geometry/Line.cs index 5477cec..1fb4ef8 100644 --- a/OpenNest.Core/Geometry/Line.cs +++ b/OpenNest.Core/Geometry/Line.cs @@ -398,11 +398,9 @@ namespace OpenNest.Geometry var x = System.Math.Cos(angle) * distance; var y = System.Math.Sin(angle) * distance; - var pt = new Vector(x, y); + var pt = side == OffsetSide.Left ? new Vector(x, y) : new Vector(-x, -y); - return side == OffsetSide.Left - ? new Line(StartPoint + pt, EndPoint + pt) - : new Line(EndPoint + pt, StartPoint + pt); + return new Line(StartPoint + pt, EndPoint + pt); } public override Entity OffsetEntity(double distance, Vector pt) diff --git a/OpenNest.Core/Geometry/NoFitPolygon.cs b/OpenNest.Core/Geometry/NoFitPolygon.cs index d519f12..aebdad8 100644 --- a/OpenNest.Core/Geometry/NoFitPolygon.cs +++ b/OpenNest.Core/Geometry/NoFitPolygon.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using Clipper2Lib; using OpenNest.Math; namespace OpenNest.Geometry @@ -11,21 +10,9 @@ namespace OpenNest.Geometry /// public static class NoFitPolygon { - private const double ClipperScale = 1000.0; - /// - /// Computes the NFP between a stationary polygon A and an orbiting polygon B. - /// NFP(A, B) = Minkowski sum of A and -B (B reflected through its reference point). - /// - public static Polygon Compute(Polygon stationary, Polygon orbiting) - { - var reflected = Reflect(orbiting); - return MinkowskiSum(stationary, reflected); - } - - /// - /// Optimized version of Compute for polygons known to be convex. - /// Bypasses expensive triangulation and Clipper unions. + /// Computes the NFP between a convex stationary polygon A and a convex orbiting + /// polygon B: the Minkowski sum of A and -B (B reflected through its reference point). /// public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting) { @@ -48,42 +35,6 @@ namespace OpenNest.Geometry return result; } - /// - /// Computes the Minkowski sum of two polygons using convex decomposition. - /// For convex polygons, uses the direct O(n+m) merge-sort of edge vectors. - /// For concave polygons, decomposes into triangles, computes pairwise - /// convex Minkowski sums, and unions the results with Clipper2. - /// - private static Polygon MinkowskiSum(Polygon a, Polygon b) - { - var trisA = ConvexDecomposition.Triangulate(a); - var trisB = ConvexDecomposition.Triangulate(b); - - if (trisA.Count == 0 || trisB.Count == 0) - return new Polygon(); - - var partialSums = new List(); - - foreach (var ta in trisA) - { - foreach (var tb in trisB) - { - var sum = ConvexMinkowskiSum(ta, tb); - - if (sum.Vertices.Count >= 3) - partialSums.Add(sum); - } - } - - if (partialSums.Count == 0) - return new Polygon(); - - if (partialSums.Count == 1) - return partialSums[0]; - - return UnionPolygons(partialSums); - } - /// /// Computes the Minkowski sum of two convex polygons by merging their /// edge vectors sorted by angle. O(n+m) where n and m are vertex counts. @@ -230,81 +181,5 @@ namespace OpenNest.Geometry return result; } - - /// - /// Unions multiple polygons using Clipper2. - /// Returns the outer boundary of the union as a single polygon. - /// - internal static Polygon UnionPolygons(List polygons) - { - var paths = new PathsD(); - - foreach (var poly in polygons) - { - var path = ToClipperPath(poly); - - if (path.Count >= 3) - paths.Add(path); - } - - if (paths.Count == 0) - return new Polygon(); - - var result = Clipper.Union(paths, FillRule.NonZero); - - if (result.Count == 0) - return new Polygon(); - - // Find the largest polygon (by area) as the outer boundary. - var largest = result[0]; - var largestArea = System.Math.Abs(Clipper.Area(largest)); - - for (var i = 1; i < result.Count; i++) - { - var area = System.Math.Abs(Clipper.Area(result[i])); - - if (area > largestArea) - { - largest = result[i]; - largestArea = area; - } - } - - return FromClipperPath(largest); - } - - /// - /// Converts an OpenNest Polygon to a Clipper2 PathD, with an optional offset. - /// - public static PathD ToClipperPath(Polygon polygon, Vector offset = default) - { - var path = new PathD(); - var verts = polygon.Vertices; - var n = verts.Count; - - // Skip closing vertex if present. - if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y) - n--; - - for (var i = 0; i < n; i++) - path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y)); - - return path; - } - - /// - /// Converts a Clipper2 PathD to an OpenNest Polygon. - /// - public static Polygon FromClipperPath(PathD path) - { - var polygon = new Polygon(); - - foreach (var pt in path) - polygon.Vertices.Add(new Vector(pt.x, pt.y)); - - polygon.Close(); - polygon.UpdateBounds(); - return polygon; - } } } diff --git a/OpenNest.Core/Geometry/Polygon.cs b/OpenNest.Core/Geometry/Polygon.cs index dec134e..a8e66dd 100644 --- a/OpenNest.Core/Geometry/Polygon.cs +++ b/OpenNest.Core/Geometry/Polygon.cs @@ -328,66 +328,29 @@ namespace OpenNest.Geometry boundingBox.Width = maxY - minY; } + /// + /// Miter-offsets the closed polygon to the given side, keeping its winding. + /// Corners sharper than the miter limit are squared off, and features that + /// collapse under the offset are dropped. When the offset splits the polygon, + /// the largest piece is returned. + /// public override Entity OffsetEntity(double distance, OffsetSide side) { if (Vertices.Count < 3) return null; - var isClosed = IsClosed(); - var count = isClosed ? Vertices.Count - 1 : Vertices.Count; - if (count < 3) - return null; - var ccw = CalculateArea() > 0; var outward = ccw ? OffsetSide.Left : OffsetSide.Right; - var sign = side == outward ? 1.0 : -1.0; - var d = distance * sign; + var delta = side == outward ? distance : -distance; - var normals = new Vector[count]; - for (var i = 0; i < count; i++) - { - var next = (i + 1) % count; - var dx = Vertices[next].X - Vertices[i].X; - var dy = Vertices[next].Y - Vertices[i].Y; - var len = System.Math.Sqrt(dx * dx + dy * dy); - if (len < Tolerance.Epsilon) - return null; - normals[i] = new Vector(-dy / len * d, dx / len * d); - } + var result = ClipperBridge.OffsetMiter(this, delta); - var result = new Polygon(); - for (var i = 0; i < count; i++) - { - var prev = (i - 1 + count) % count; + if (result == null) + return null; - var a1 = new Vector( - Vertices[prev].X + normals[prev].X, - Vertices[prev].Y + normals[prev].Y - ); - var a2 = new Vector( - Vertices[i].X + normals[prev].X, - Vertices[i].Y + normals[prev].Y - ); - var b1 = new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y); - var b2 = new Vector( - Vertices[(i + 1) % count].X + normals[i].X, - Vertices[(i + 1) % count].Y + normals[i].Y - ); + if (!ccw) + result.Reverse(); - var edgeA = new Line(a1, a2); - var edgeB = new Line(b1, b2); - - if (edgeA.Intersects(edgeB, out var pt) && pt.IsValid()) - result.Vertices.Add(pt); - else - result.Vertices.Add( - new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y) - ); - } - - result.Close(); - result.RemoveSelfIntersections(); - result.UpdateBounds(); return result; } @@ -556,155 +519,6 @@ namespace OpenNest.Geometry get { return EntityType.Polygon; } } - /// - /// Removes self-intersecting loops from the polygon by finding non-adjacent - /// edge crossings and keeping the larger contour at each crossing. - /// - public void RemoveSelfIntersections() - { - if (!IsClosed() || Vertices.Count < 5) - return; - - while (FindCrossing(out var edgeI, out var edgeJ, out var pt)) - { - Vertices = SplitAtCrossing(edgeI, edgeJ, pt); - } - } - - private bool FindCrossing(out int edgeI, out int edgeJ, out Vector pt) - { - var n = Vertices.Count - 1; - - // Pre-calculate edge bounding boxes to speed up intersection checks. - var edgeBounds = new (double minX, double maxX, double minY, double maxY)[n]; - for (var i = 0; i < n; i++) - { - var v1 = Vertices[i]; - var v2 = Vertices[i + 1]; - edgeBounds[i] = ( - System.Math.Min(v1.X, v2.X) - Tolerance.Epsilon, - System.Math.Max(v1.X, v2.X) + Tolerance.Epsilon, - System.Math.Min(v1.Y, v2.Y) - Tolerance.Epsilon, - System.Math.Max(v1.Y, v2.Y) + Tolerance.Epsilon - ); - } - - for (var i = 0; i < n; i++) - { - var bi = edgeBounds[i]; - for (var j = i + 2; j < n; j++) - { - if (i == 0 && j == n - 1) - continue; - - var bj = edgeBounds[j]; - - // Prune with bounding box check. - if ( - bi.maxX < bj.minX - || bj.maxX < bi.minX - || bi.maxY < bj.minY - || bj.maxY < bi.minY - ) - { - continue; - } - - if ( - SegmentsIntersect( - Vertices[i], - Vertices[i + 1], - Vertices[j], - Vertices[j + 1], - out pt - ) - ) - { - edgeI = i; - edgeJ = j; - return true; - } - } - } - - edgeI = edgeJ = -1; - pt = Vector.Zero; - return false; - } - - private List SplitAtCrossing(int edgeI, int edgeJ, Vector pt) - { - var n = Vertices.Count - 1; - - var loopA = Vertices.GetRange(0, edgeI + 1); - loopA.Add(pt); - loopA.AddRange(Vertices.GetRange(edgeJ + 1, n - edgeJ - 1)); - loopA.Add(loopA[0]); - - var loopB = new List { pt }; - loopB.AddRange(Vertices.GetRange(edgeI + 1, edgeJ - edgeI)); - loopB.Add(pt); - - var areaA = System.Math.Abs(CalculateArea(loopA)); - var areaB = System.Math.Abs(CalculateArea(loopB)); - - return areaA >= areaB ? loopA : loopB; - } - - private static bool SegmentsIntersect( - Vector a1, - Vector a2, - Vector b1, - Vector b2, - out Vector pt - ) - { - var da = a2 - a1; - var db = b2 - b1; - var cross = da.X * db.Y - da.Y * db.X; - - if (cross.IsEqualTo(0.0)) - { - pt = Vector.Zero; - return false; - } - - var dc = b1 - a1; - var t = (dc.X * db.Y - dc.Y * db.X) / cross; - var u = (dc.X * da.Y - dc.Y * da.X) / cross; - - if ( - t > Tolerance.Epsilon - && t < 1.0 - Tolerance.Epsilon - && u > Tolerance.Epsilon - && u < 1.0 - Tolerance.Epsilon - ) - { - pt = new Vector(a1.X + t * da.X, a1.Y + t * da.Y); - return true; - } - - pt = Vector.Zero; - return false; - } - - private static double CalculateArea(List vertices) - { - double xsum = 0; - double ysum = 0; - - for (int i = 0; i < vertices.Count - 1; i++) - { - var current = vertices[i]; - var next = vertices[i + 1]; - - xsum += current.X * next.Y; - ysum += current.Y * next.X; - } - - return (xsum - ysum) * 0.5; - } - internal void Cleanup() { for (int i = Vertices.Count - 1; i > 0; i--) diff --git a/OpenNest.Core/Geometry/Shape.cs b/OpenNest.Core/Geometry/Shape.cs index ab32d66..2146351 100644 --- a/OpenNest.Core/Geometry/Shape.cs +++ b/OpenNest.Core/Geometry/Shape.cs @@ -463,80 +463,60 @@ namespace OpenNest.Geometry boundingBox = Entities.Select(geo => geo.BoundingBox).ToList().GetBoundingBox(); } + /// + /// Offsets each perimeter entity to the given side and joins the pieces into a + /// closed chain: line-line corners get a round join (convex) or a miter (concave), + /// other convex corners get a round join, and any remaining gap (a concave corner + /// involving an arc, or an entity that collapsed under the offset) is bridged + /// with a line. Cutouts are offset the same way. + /// + /// Where a feature is narrower than twice the distance, the result keeps zero-area + /// spikes and inverted loops. They lie inside the true offset envelope, so they are + /// harmless to directional-distance queries, which only need a closed boundary + /// that never falls inside the envelope. Use when a + /// clean region is needed. + /// + /// public override Entity OffsetEntity(double distance, OffsetSide side) { var offsetShape = new Shape(); var definedShape = new ShapeProfile(this); - Entity firstEntity = null; - Entity firstOffsetEntity = null; - Entity lastEntity = null; - Entity lastOffsetEntity = null; + var pieces = new List(); + var collapsed = false; foreach (var entity in definedShape.Perimeter.Entities) { var offsetEntity = entity.OffsetEntity(distance, side); if (offsetEntity == null) + { + collapsed = true; continue; - - if (firstEntity == null) - { - firstEntity = entity; - firstOffsetEntity = offsetEntity; } - switch (entity.Type) - { - case EntityType.Line: - { - var line = (Line)entity; - var offsetLine = (Line)offsetEntity; - - if (lastOffsetEntity != null && lastOffsetEntity.Type == EntityType.Line) - { - JoinOffsetLines( - (Line)lastEntity, - (Line)lastOffsetEntity, - line, - offsetLine, - distance, - side, - offsetShape - ); - } - - offsetShape.Entities.Add(offsetLine); - break; - } - - default: - offsetShape.Entities.Add(offsetEntity); - break; - } - - lastOffsetEntity = offsetEntity; - lastEntity = entity; + pieces.Add(new OffsetPiece(entity, offsetEntity, collapsed)); + collapsed = false; } - // Close the shape: join last offset entity back to first - if ( - lastOffsetEntity != null - && firstOffsetEntity != null - && lastOffsetEntity != firstOffsetEntity - && lastOffsetEntity.Type == EntityType.Line - && firstOffsetEntity.Type == EntityType.Line - ) + // Entities that collapsed at the end of the loop sit before the first piece. + if (collapsed && pieces.Count > 0) + pieces[0] = pieces[0] with { CollapsedBefore = true }; + + for (var i = 0; i < pieces.Count; i++) { - JoinOffsetLines( - (Line)lastEntity, - (Line)lastOffsetEntity, - (Line)firstEntity, - (Line)firstOffsetEntity, - distance, - side, - offsetShape - ); + offsetShape.Entities.Add(pieces[i].Offset); + + if (pieces.Count > 1) + { + JoinOffsetPieces( + pieces[i], + pieces[(i + 1) % pieces.Count], + distance, + side, + offsetShape + ); + } } foreach (var cutout in definedShape.Cutouts) @@ -547,6 +527,151 @@ namespace OpenNest.Geometry return offsetShape; } + private readonly record struct OffsetPiece( + Entity Source, + Entity Offset, + bool CollapsedBefore + ); + + private static void JoinOffsetPieces( + OffsetPiece last, + OffsetPiece next, + double distance, + OffsetSide side, + Shape offsetShape + ) + { + // Lines meeting across a collapsed fillet are concave, so a miter trims both at + // their intersection. Parallel ones (a round-bottomed slot) fall through to + // the bridge below. + if ( + next.CollapsedBefore + && last.Offset is Line lastOffsetLine + && next.Offset is Line nextOffsetLine + && Intersect.IntersectsUnbounded(nextOffsetLine, lastOffsetLine, out var miter) + ) + { + lastOffsetLine.EndPoint = miter; + nextOffsetLine.StartPoint = miter; + return; + } + + if (!next.CollapsedBefore && last.Source is Line lastLine && next.Source is Line nextLine) + { + JoinOffsetLines( + lastLine, + (Line)last.Offset, + nextLine, + (Line)next.Offset, + distance, + side, + offsetShape + ); + return; + } + + if ( + !TryGetEnds(last.Offset, out _, out var gapStart) + || !TryGetEnds(next.Offset, out var gapEnd, out _) + ) + return; + + if (gapStart.DistanceTo(gapEnd) <= OpenNest.Math.Tolerance.Epsilon) + return; + + if ( + !next.CollapsedBefore + && IsConvexCorner(last.Source, next.Source, side, out var corner) + ) + { + offsetShape.Entities.Add( + new Arc( + corner, + distance, + corner.AngleTo(gapStart), + corner.AngleTo(gapEnd), + side == OffsetSide.Left + ) + ); + return; + } + + // Concave corner or collapsed entity: the neighbors' offsets overlap, so a + // straight bridge stays inside the offset envelope and closes the chain. + offsetShape.Entities.Add(new Line(gapStart, gapEnd)); + } + + private static bool IsConvexCorner( + Entity last, + Entity next, + OffsetSide side, + out Vector corner + ) + { + corner = default; + + if ( + !TryGetEnds(last, out _, out corner) + || !TryGetTangents(last, out _, out var d1) + || !TryGetTangents(next, out var d2, out _) + ) + return false; + + var cross = d1.X * d2.Y - d1.Y * d2.X; + + return (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon) + || (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon); + } + + private static bool TryGetEnds(Entity entity, out Vector start, out Vector end) + { + switch (entity) + { + case Line line: + start = line.StartPoint; + end = line.EndPoint; + return true; + + case Arc arc: + start = arc.StartPoint(); + end = arc.EndPoint(); + return true; + + default: + start = end = default; + return false; + } + } + + /// + /// Direction of travel at the start and end of a line or arc. + /// + private static bool TryGetTangents(Entity entity, out Vector start, out Vector end) + { + switch (entity) + { + case Line line: + start = end = line.EndPoint - line.StartPoint; + return true; + + case Arc arc: + start = ArcTangent(arc, arc.StartAngle); + end = ArcTangent(arc, arc.EndAngle); + return true; + + default: + start = end = default; + return false; + } + } + + private static Vector ArcTangent(Arc arc, double angle) + { + var sin = System.Math.Sin(angle); + var cos = System.Math.Cos(angle); + return arc.IsReversed ? new Vector(sin, -cos) : new Vector(-sin, cos); + } + private static void JoinOffsetLines( Line lastLine, Line lastOffsetLine, @@ -611,7 +736,7 @@ namespace OpenNest.Geometry /// Normalizes to CW winding before offsetting Left (which is outward for CW), /// making the method independent of the original contour winding direction. /// - public Shape OffsetOutward(double distance) + internal Shape OffsetOutward(double distance) { var poly = ToPolygon(); @@ -660,7 +785,7 @@ namespace OpenNest.Geometry /// Normalizes to CCW winding before offsetting Left (which is inward for CCW), /// making the method independent of the original contour winding direction. /// - public Shape OffsetInward(double distance) + internal Shape OffsetInward(double distance) { var poly = ToPolygon(); diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs index a0b0a69..a91c4ca 100644 --- a/OpenNest.Core/PartGeometry.cs +++ b/OpenNest.Core/PartGeometry.cs @@ -49,7 +49,7 @@ namespace OpenNest /// /// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied, - /// without tessellation. Much faster than GetOffsetPartLines for parts with many arcs. + /// without tessellation, which keeps arc-heavy parts fast in directional-distance loops. /// public static List GetOffsetPerimeterEntities(Part part, double spacing) { @@ -149,75 +149,6 @@ namespace OpenNest return result; } - public static List GetOffsetPartLines( - Part part, - double spacing, - double chordTolerance = 0.001, - bool perimeterOnly = false - ) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var profile = new ShapeProfile( - entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() - ); - var lines = new List(); - var totalSpacing = spacing; - - AddOffsetLines( - lines, - profile.Perimeter.OffsetOutward(totalSpacing), - chordTolerance, - part.Location - ); - - if (!perimeterOnly) - { - foreach (var cutout in profile.Cutouts) - AddOffsetLines( - lines, - cutout.OffsetInward(totalSpacing), - chordTolerance, - part.Location - ); - } - - return lines; - } - - public static List GetOffsetPartLines( - Part part, - double spacing, - PushDirection facingDirection, - double chordTolerance = 0.001 - ) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var profile = new ShapeProfile( - entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() - ); - var lines = new List(); - var totalSpacing = spacing; - - AddOffsetDirectionalLines( - lines, - profile.Perimeter.OffsetOutward(totalSpacing), - chordTolerance, - part.Location, - facingDirection - ); - - foreach (var cutout in profile.Cutouts) - AddOffsetDirectionalLines( - lines, - cutout.OffsetInward(totalSpacing), - chordTolerance, - part.Location, - facingDirection - ); - - return lines; - } - public static List GetPartLines( Part part, Vector facingDirection, @@ -240,40 +171,6 @@ namespace OpenNest return lines; } - public static List GetOffsetPartLines( - Part part, - double spacing, - Vector facingDirection, - double chordTolerance = 0.001 - ) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var profile = new ShapeProfile( - entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() - ); - var lines = new List(); - var totalSpacing = spacing; - - AddOffsetDirectionalLines( - lines, - profile.Perimeter.OffsetOutward(totalSpacing), - chordTolerance, - part.Location, - facingDirection - ); - - foreach (var cutout in profile.Cutouts) - AddOffsetDirectionalLines( - lines, - cutout.OffsetInward(totalSpacing), - chordTolerance, - part.Location, - facingDirection - ); - - return lines; - } - /// /// Returns only polygon edges whose outward normal faces the specified direction vector. /// @@ -353,55 +250,5 @@ namespace OpenNest return lines; } - - private static void AddOffsetLines( - List lines, - Shape offsetEntity, - double chordTolerance, - Vector location - ) - { - if (offsetEntity == null) - return; - - var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance); - polygon.RemoveSelfIntersections(); - polygon.Offset(location); - lines.AddRange(polygon.ToLines()); - } - - private static void AddOffsetDirectionalLines( - List lines, - Shape offsetEntity, - double chordTolerance, - Vector location, - PushDirection facingDirection - ) - { - if (offsetEntity == null) - return; - - var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance); - polygon.RemoveSelfIntersections(); - polygon.Offset(location); - lines.AddRange(GetDirectionalLines(polygon, facingDirection)); - } - - private static void AddOffsetDirectionalLines( - List lines, - Shape offsetEntity, - double chordTolerance, - Vector location, - Vector facingDirection - ) - { - if (offsetEntity == null) - return; - - var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance); - polygon.RemoveSelfIntersections(); - polygon.Offset(location); - lines.AddRange(GetDirectionalLines(polygon, facingDirection)); - } } } diff --git a/OpenNest.Engine/BestFit/PolygonHelper.cs b/OpenNest.Engine/BestFit/PolygonHelper.cs index 386b6ea..865dadd 100644 --- a/OpenNest.Engine/BestFit/PolygonHelper.cs +++ b/OpenNest.Engine/BestFit/PolygonHelper.cs @@ -26,16 +26,15 @@ namespace OpenNest.Engine.BestFit if (perimeter == null) return new PolygonExtractionResult(null, Vector.Zero); - // Ensure CW winding for correct outward offset direction. - definedShape.NormalizeWinding(); + // Circumscribe so the polygon never under-estimates the part (or its offset). + var polygon = + halfSpacing > 0 + ? ClipperBridge + .OffsetPerimeter(perimeter, halfSpacing, 0.01, circumscribe: true) + .LargestOuter() + : perimeter.ToPolygonWithTolerance(0.01, circumscribe: true); - var inflated = - halfSpacing > 0 ? (perimeter.OffsetOutward(halfSpacing) ?? perimeter) : perimeter; - - // Convert to polygon with circumscribed arcs for tight nesting. - var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true); - - if (polygon.Vertices.Count < 3) + if (polygon == null || polygon.Vertices.Count < 3) return new PolygonExtractionResult(null, Vector.Zero); // Normalize: move polygon to origin. diff --git a/OpenNest.Engine/Fill/PartBoundary.cs b/OpenNest.Engine/Fill/PartBoundary.cs index 3a6952e..b510f75 100644 --- a/OpenNest.Engine/Fill/PartBoundary.cs +++ b/OpenNest.Engine/Fill/PartBoundary.cs @@ -34,19 +34,16 @@ namespace OpenNest.Engine.Fill if (perimeter != null) { - var offsetEntity = perimeter.OffsetOutward(spacing); - - if (offsetEntity != null) - { - // Circumscribe arcs so polygon vertices are always outside - // the true arc — guarantees the boundary never under-estimates. - var polygon = offsetEntity.ToPolygonWithTolerance( - PolygonTolerance, - circumscribe: true - ); - polygon.RemoveSelfIntersections(); - _polygons.Add(polygon); - } + // Conservative offset: the boundary never under-estimates the spacing. + // Holes appear only where the perimeter curls back on itself. + var offset = ClipperBridge.OffsetPerimeter( + perimeter, + spacing, + PolygonTolerance, + circumscribe: true + ); + _polygons.AddRange(offset.Outers); + _polygons.AddRange(offset.Holes); } PrecomputeDirectionalEdges( diff --git a/OpenNest.Tests/Converters/ConvertProgramArcTests.cs b/OpenNest.Tests/Converters/ConvertProgramArcTests.cs new file mode 100644 index 0000000..1b5259c --- /dev/null +++ b/OpenNest.Tests/Converters/ConvertProgramArcTests.cs @@ -0,0 +1,64 @@ +using System.Linq; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Converters; + +public class ConvertProgramArcTests +{ + [Fact] + public void ArcWithCenterNotEquidistant_StartsAtPreviousEndpoint() + { + // PEP-exported notch: I0.03 on a 0.0598 chord puts the center 0.0300 from + // the start but 0.0298 from the end. + var pgm = new Program(Mode.Incremental); + pgm.Codes.Add(new RapidMove(0, 0)); + pgm.Codes.Add(new LinearMove(0, -0.3573)); + pgm.Codes.Add(new ArcMove(0.0598, 0, 0.03, 0, RotationType.CCW)); + pgm.Codes.Add(new LinearMove(0, 0.3573)); + + var arc = ConvertProgram.ToGeometry(pgm).OfType().Single(); + + Assert.True(arc.StartPoint().DistanceTo(new Vector(0, -0.3573)) < 1e-9); + Assert.True(arc.EndPoint().DistanceTo(new Vector(0.0598, -0.3573)) < 1e-9); + Assert.Equal(0.0299, arc.Radius, 9); + } + + [Fact] + public void ClosedContourWithInconsistentArc_ChainsIntoSinglePerimeter() + { + var pgm = new Program(Mode.Incremental); + pgm.Codes.Add(new RapidMove(0, 0)); + pgm.Codes.Add(new LinearMove(4, 0)); + pgm.Codes.Add(new LinearMove(0, 2)); + pgm.Codes.Add(new LinearMove(-1.9701, 0)); + pgm.Codes.Add(new LinearMove(0, -0.5)); + pgm.Codes.Add(new ArcMove(-0.0598, 0, -0.03, 0, RotationType.CW)); + pgm.Codes.Add(new LinearMove(0, 0.5)); + pgm.Codes.Add(new LinearMove(-1.9701, 0)); + pgm.Codes.Add(new LinearMove(0, -2)); + + var entities = ConvertProgram.ToGeometry(pgm) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + var profile = new ShapeProfile(entities); + + Assert.Empty(profile.Cutouts); + Assert.Equal(entities.Count, profile.Perimeter.Entities.Count); + } + + [Fact] + public void ConsistentArc_IsUnchanged() + { + var pgm = new Program(Mode.Incremental); + pgm.Codes.Add(new RapidMove(0, 0)); + pgm.Codes.Add(new ArcMove(2, 0, 1, 0, RotationType.CCW)); + + var arc = ConvertProgram.ToGeometry(pgm).OfType().Single(); + + Assert.Equal(1.0, arc.Center.X, 12); + Assert.Equal(0.0, arc.Center.Y, 12); + Assert.Equal(1.0, arc.Radius, 12); + } +} diff --git a/OpenNest.Tests/CutOffs/CutOffTests.cs b/OpenNest.Tests/CutOffs/CutOffTests.cs index 35f8f9a..7bfd590 100644 --- a/OpenNest.Tests/CutOffs/CutOffTests.cs +++ b/OpenNest.Tests/CutOffs/CutOffTests.cs @@ -121,6 +121,49 @@ public class CutOffTests Assert.Equal(4, codes.Count); } + [Theory] + [InlineData(0.3, 19.0)] // Narrower than twice the clearance: the slot closes up. + [InlineData(4.0, 24.0)] // Wide slot: the cut runs in to 1 short of the slot's end. + public void CutOff_UpASlot_KeepsClearanceFromPart(double slotWidth, double firstEnd) + { + // 10x10 part at (20,20) with a 5-deep slot up from the bottom edge, centered + // on the cut line. + var h = slotWidth / 2; + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(5 - h, 0))); + pgm.Codes.Add(new LinearMove(new Vector(5 - h, 5))); + pgm.Codes.Add(new LinearMove(new Vector(5 + h, 5))); + pgm.Codes.Add(new LinearMove(new Vector(5 + h, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + + var plate = new Plate(50, 50); + var part = Part.CreateAtOrigin(new Drawing("slot", pgm)); + part.Location = new Vector(20, 20); + plate.Parts.Add(part); + + var settings = new CutOffSettings { PartClearance = 1.0 }; + var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical); + cutoff.Regenerate(plate, settings, Plate.BuildPerimeterCache(plate)); + + var ys = cutoff + .Drawing.Program.Codes.OfType() + .Select(m => m.EndPoint.Y) + .OrderBy(y => y) + .ToList(); + + Assert.Equal(4, ys.Count); + Assert.Equal(0, ys[0], 6); + // A closed slot leaves a shallow dent at its mouth: the cut stops 1 from the + // mouth corners, at 20 - sqrt(1 - 0.15^2) = 19.011. + Assert.InRange(ys[1], firstEnd - 0.02, firstEnd + 0.02); + Assert.InRange(ys[2], 30.999, 31.02); + Assert.Equal(50, ys[3], 6); + } + [Fact] public void CutOff_ShortSegment_FilteredByMinLength() { 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; + } +} diff --git a/OpenNest.Tests/Geometry/ClipperBridgeTests.cs b/OpenNest.Tests/Geometry/ClipperBridgeTests.cs new file mode 100644 index 0000000..0aeaa71 --- /dev/null +++ b/OpenNest.Tests/Geometry/ClipperBridgeTests.cs @@ -0,0 +1,306 @@ +using System.IO; +using System.Linq; +using System.Text; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.Geometry; + +public class ClipperBridgeTests +{ + [Fact] + public void Offset_NotchNarrowerThanTwiceSpacing_ClosesNotch() + { + // 10x10 square with a 0.3-wide, 3-deep slot down from the top edge. + var profile = Profile( + Poly( + (0, 0), + (10, 0), + (10, 10), + (5.15, 10), + (5.15, 7), + (4.85, 7), + (4.85, 10), + (0, 10) + ) + ); + + var result = ClipperBridge.Offset(profile, 0.25, 0.001); + + var outer = Assert.Single(result.Outers); + Assert.Empty(result.Holes); + + // The slot fills in. Only a shallow dent is left where the round joins of the + // two mouth corners meet: 10 + sqrt(0.25^2 - 0.15^2) = 10.2. + Assert.DoesNotContain(outer.Vertices, v => v.X > 4.85 && v.X < 5.15 && v.Y < 10.199); + + var fullSquare = 10 * 10 + 4 * 10 * 0.25 + System.Math.PI * 0.25 * 0.25; + Assert.InRange(outer.Area(), fullSquare - 0.01, fullSquare); + } + + [Fact] + public void Offset_HoleSmallerThanTwiceSpacing_DropsHole() + { + var profile = Profile(Poly((0, 0), (10, 0), (10, 10), (0, 10)), Circle(5, 5, 0.2)); + + var result = ClipperBridge.Offset(profile, 0.25, 0.001); + + Assert.Single(result.Outers); + Assert.Empty(result.Holes); + } + + [Fact] + public void Offset_HoleWithThinNeck_SplitsIntoTwoHoles() + { + // Two 2x2 pockets joined by a 2-long, 0.3-wide channel. + var hole = Poly( + (2, 4), + (4, 4), + (4, 4.85), + (6, 4.85), + (6, 4), + (8, 4), + (8, 6), + (6, 6), + (6, 5.15), + (4, 5.15), + (4, 6), + (2, 6) + ); + var profile = Profile(Poly((0, 0), (10, 0), (10, 10), (0, 10)), hole); + + var result = ClipperBridge.Offset(profile, 0.25, 0.001); + + Assert.Single(result.Outers); + Assert.Equal(2, result.Holes.Count); + + // Each pocket shrinks to 1.5x1.5, plus a small lobe toward the channel mouth + // where the round joins of the channel corners meet. + Assert.All(result.Holes, h => Assert.InRange(h.Area(), 2.25, 2.26)); + } + + [Fact] + public void Offset_WindingOfInputDoesNotMatter() + { + var ccw = Poly((0, 0), (10, 0), (10, 10), (0, 10)); + var cw = Poly((0, 0), (0, 10), (10, 10), (10, 0)); + var hole = Circle(5, 5, 2); + + var a = ClipperBridge.Offset(Profile(ccw, hole), 0.25, 0.001); + var b = ClipperBridge.Offset(Profile(cw, hole), 0.25, 0.001); + + Assert.Equal(a.Outers.Count, b.Outers.Count); + Assert.Equal(a.Holes.Count, b.Holes.Count); + Assert.Equal(a.Outers[0].Area(), b.Outers[0].Area(), 6); + Assert.Equal(a.Holes[0].Area(), b.Holes[0].Area(), 6); + } + + [Fact] + public void Offset_Circumscribe_NeverUnderestimatesDistance() + { + const double spacing = 0.25; + var profile = Profile(Circle(0, 0, 5), Circle(0, 0, 3)); + + var result = ClipperBridge.Offset(profile, spacing, 0.05, circumscribe: true); + + var outer = Assert.Single(result.Outers); + var hole = Assert.Single(result.Holes); + + for (var i = 0; i < 360; i++) + { + var a = i * System.Math.PI / 180; + var onPerimeter = new Vector(5 * System.Math.Cos(a), 5 * System.Math.Sin(a)); + var onCutout = new Vector(3 * System.Math.Cos(a), 3 * System.Math.Sin(a)); + + Assert.True(outer.ContainsPoint(onPerimeter)); + Assert.True( + outer.ClosestPointTo(onPerimeter).DistanceTo(onPerimeter) >= spacing, + $"Perimeter sample at {i} deg is closer than the spacing." + ); + + Assert.False(hole.ContainsPoint(onCutout)); + Assert.True( + hole.ClosestPointTo(onCutout).DistanceTo(onCutout) >= spacing, + $"Cutout sample at {i} deg is closer than the spacing." + ); + } + } + + [Fact] + public void Offset_PepNotchedPart_HasNoSpikes() + { + // 1.nest (PEP P260417-06): rounded-square hole, perimeter with 0.0598-wide + // notches and 0.015 fillets, all narrower than twice the 0.25 spacing. + var program = ReadProgram(PepNotchedPart); + var entities = ConvertProgram.ToGeometry(program) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + + var result = ClipperBridge.Offset(new ShapeProfile(entities), 0.25, 0.001); + + var outer = Assert.Single(result.Outers); + Assert.Single(result.Holes); + + var verts = outer.Vertices; + var n = verts.Count - 1; + + for (var i = 0; i < n; i++) + { + for (var j = i + 2; j < n; j++) + { + if (i == 0 && j == n - 1) + continue; + + Assert.False( + SegmentsCross(verts[i], verts[i + 1], verts[j], verts[j + 1]), + $"Edges {i} and {j} cross." + ); + } + } + + for (var i = 0; i < n; i++) + { + var prev = verts[(i + n - 1) % n]; + var cur = verts[i]; + var next = verts[(i + 1) % n]; + + var inDir = Unit(cur - prev); + var outDir = Unit(next - cur); + var dot = inDir.X * outDir.X + inDir.Y * outDir.Y; + + Assert.True(dot > -0.99, $"Spike at vertex {i} ({cur.X:F4}, {cur.Y:F4})."); + } + } + + [Theory] + [InlineData(true, OffsetSide.Left, 12 * 12)] + [InlineData(true, OffsetSide.Right, 8 * 8)] + [InlineData(false, OffsetSide.Left, 8 * 8)] + [InlineData(false, OffsetSide.Right, 12 * 12)] + public void PolygonOffsetEntity_MitersToSideAndKeepsWinding( + bool ccw, + OffsetSide side, + double expectedArea + ) + { + var square = new Polygon(); + square.Vertices.AddRange(new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10) }); + + if (!ccw) + square.Vertices.Reverse(); + + square.Close(); + + var result = (Polygon)square.OffsetEntity(1, side); + + Assert.Equal(expectedArea, result.Area(), 6); + Assert.Equal(square.RotationDirection(), result.RotationDirection()); + } + + private static bool SegmentsCross(Vector a, Vector b, Vector c, Vector d) + { + static double Cross(Vector o, Vector p, Vector q) => + (p.X - o.X) * (q.Y - o.Y) - (p.Y - o.Y) * (q.X - o.X); + + return Cross(c, d, a) * Cross(c, d, b) < 0 && Cross(a, b, c) * Cross(a, b, d) < 0; + } + + private static Vector Unit(Vector v) + { + var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y); + return new Vector(v.X / len, v.Y / len); + } + + private static Shape Poly(params (double X, double Y)[] pts) + { + var shape = new Shape(); + + for (var i = 0; i < pts.Length; i++) + { + var a = pts[i]; + var b = pts[(i + 1) % pts.Length]; + shape.Entities.Add(new Line(a.X, a.Y, b.X, b.Y)); + } + + return shape; + } + + private static Shape Circle(double x, double y, double r) + { + var shape = new Shape(); + shape.Entities.Add(new Circle(x, y, r)); + return shape; + } + + private static ShapeProfile Profile(params Shape[] shapes) => + new(shapes.SelectMany(s => s.Entities).ToList()); + + private static Program ReadProgram(string gcode) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(gcode)); + return new ProgramReader(stream).Read(); + } + + private const string PepNotchedPart = """ + G91 + G00X-8.003411Y12.354904 + G01X0Y5.03125 + G03X-2.3125Y2.3125I-2.3125J0 + G01X-10.0625Y0 + G03X-2.3125Y-2.3125I0J-2.3125 + G01X0Y-10.0625 + G03X2.3125Y-2.3125I2.3125J0 + G01X10.0625Y0 + G03X2.3125Y2.3125I0J2.3125 + G01X0Y5.03125 + G00X10.200865Y-12.347161 + G01X-2.182454Y0 + G03X-0.015Y-0.015I0J-0.015 + G01X0Y-1.457646 + G02X-0.015Y-0.015I-0.015J0 + G01X-30.664322Y0 + G02X-0.015Y0.015I0J0.015 + G01X0Y1.1725 + G01X0.072967Y0.149903 + G02X0.013487Y0.008435I0.013487J-0.006565 + G01X0.620707Y0 + G03X0.015Y0.015I0J0.015 + G01X0Y0.396469 + G03X-0.015Y0.015I-0.015J0 + G01X-0.4225Y0 + G01X0Y0.095339 + G01X-2.419615Y0 + G02X-0.0625Y0.0625I0J0.0625 + G01X0Y23.809322 + G02X0.0625Y0.0625I0.0625J0 + G01X2.405015Y0 + G03X0.015Y0.015I0J0.015 + G01X0Y1.837647 + G02X0.015Y0.015I0.015J0 + G01X4.005139Y0 + G02X0.015Y-0.015I0J-0.015 + G01X0Y-0.3573 + G03X0.0598Y0I0.03J0 + G01X0Y0.974934 + G02X0.0625Y0.0625I0.0625J0 + G01X25.420246Y0 + G02X0.0625Y-0.0625I0J-0.0625 + G01X0Y-0.974934 + G03X0.0598Y0I0.03J0 + G01X0Y0.3573 + G02X0.015Y0.015I0.015J0 + G01X0.679276Y0 + G02X0.015Y-0.015I0J-0.015 + G01X0Y-1.457647 + G03X0.015Y-0.015I0.015J0 + G01X1.145147Y0 + G02X0.015Y-0.015I0J-0.015 + G01X0Y-0.709988 + G03X0.015Y-0.015I0.015J0 + G01X0.944807Y0 + G02X0.0625Y-0.0625I0J-0.0625 + G01X0Y-23.891834 + """; +} diff --git a/OpenNest.Tests/Geometry/CollisionTests.cs b/OpenNest.Tests/Geometry/CollisionTests.cs index 488107c..a716184 100644 --- a/OpenNest.Tests/Geometry/CollisionTests.cs +++ b/OpenNest.Tests/Geometry/CollisionTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using OpenNest.Geometry; using OpenNest.Math; @@ -204,6 +205,84 @@ public class CollisionTests Assert.False(Collision.HasAnyOverlap(new List())); } + // The cases below feed Collision with ClipperBridge offsets, the way the spacing + // checks prepare their inputs: lines only, round joins, 1e-4 precision. + + [Theory] + [InlineData(4.9, 5.1, true)] // Inside the collapsed slot: 0.05 from its walls. + [InlineData(10.3, 12, false)] // Beside the part, 0.3 away. + public void HasOverlap_NeighborOfPartWithCollapsedSlot( + double left, + double right, + bool expected + ) + { + // 10x10 part with a 0.3-wide slot down from the top, inflated by 0.25. + var part = MakeProfile( + MakePolygon((0, 0), (10, 0), (10, 10), (5.15, 10), (5.15, 7), (4.85, 7), (4.85, 10), (0, 10)) + ); + var inflated = ClipperBridge.Offset(part, 0.25, 0.001); + var neighbor = MakeSquare(left, 8, right, 10); + + Assert.Equal( + expected, + Collision.HasOverlap(inflated.LargestOuter(), neighbor, inflated.Holes) + ); + } + + [Theory] + [InlineData(5.5, 14.5, false)] // 0.5 from the hole's edges. + [InlineData(5.1, 14.9, true)] // 0.1 from the hole's edges. + public void HasOverlap_PartInsideHoleShrunkBySpacing(double min, double max, bool expected) + { + var part = MakeProfile( + MakePolygon((0, 0), (20, 0), (20, 20), (0, 20)), + MakePolygon((5, 5), (15, 5), (15, 15), (5, 15)) + ); + var inflated = ClipperBridge.Offset(part, 0.25, 0.001); + var inner = MakeSquare(min, min, max, max); + + Assert.Single(inflated.Holes); + Assert.Equal( + expected, + Collision.HasOverlap(inflated.LargestOuter(), inner, inflated.Holes) + ); + } + + [Fact] + public void HasOverlap_ZeroSpacingEdgeContact_ReturnsFalse() + { + var a = ClipperBridge.Offset( + MakeProfile(MakePolygon((0, 0), (10, 0), (10, 10), (0, 10))), + 0, + 0.001 + ); + var b = ClipperBridge.Offset( + MakeProfile(MakePolygon((10, 0), (20, 0), (20, 10), (10, 10))), + 0, + 0.001 + ); + + Assert.False(Collision.HasOverlap(a.LargestOuter(), b.LargestOuter())); + } + + private static Shape MakePolygon(params (double X, double Y)[] pts) + { + var shape = new Shape(); + + for (var i = 0; i < pts.Length; i++) + { + var from = pts[i]; + var to = pts[(i + 1) % pts.Length]; + shape.Entities.Add(new Line(from.X, from.Y, to.X, to.Y)); + } + + return shape; + } + + private static ShapeProfile MakeProfile(params Shape[] shapes) => + new(shapes.SelectMany(s => s.Entities).ToList()); + private static Polygon MakeSquare(double left, double bottom, double right, double top) { var p = new Polygon(); diff --git a/OpenNest.Tests/Geometry/ShapeOffsetTests.cs b/OpenNest.Tests/Geometry/ShapeOffsetTests.cs new file mode 100644 index 0000000..ce5ce53 --- /dev/null +++ b/OpenNest.Tests/Geometry/ShapeOffsetTests.cs @@ -0,0 +1,155 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.Geometry; + +public class ShapeOffsetTests +{ + [Theory] + [InlineData(false, OffsetSide.Left, 4)] // CCW: center on the left, shrinks. + [InlineData(false, OffsetSide.Right, 6)] + [InlineData(true, OffsetSide.Left, 6)] // CW: center on the right, grows. + [InlineData(true, OffsetSide.Right, 4)] + public void ArcOffset_GrowsAwayFromCenter(bool reversed, OffsetSide side, double radius) + { + var arc = new Arc(0, 0, 5, 0, Angle.HalfPI, reversed); + + var offset = (Arc)arc.OffsetEntity(1, side); + + Assert.Equal(radius, offset.Radius, 9); + Assert.Equal(reversed, offset.IsReversed); + } + + [Theory] + [InlineData(RotationType.CCW, OffsetSide.Left, 4)] + [InlineData(RotationType.CCW, OffsetSide.Right, 6)] + [InlineData(RotationType.CW, OffsetSide.Left, 6)] + [InlineData(RotationType.CW, OffsetSide.Right, 4)] + public void CircleOffset_GrowsAwayFromCenter(RotationType rotation, OffsetSide side, double radius) + { + var circle = new Circle(0, 0, 5) { Rotation = rotation }; + + var offset = (Circle)circle.OffsetEntity(1, side); + + Assert.Equal(radius, offset.Radius, 9); + Assert.Equal(rotation, offset.Rotation); + } + + [Theory] + [InlineData(OffsetSide.Left, 1)] + [InlineData(OffsetSide.Right, -1)] + public void LineOffset_MovesToSideAndKeepsDirection(OffsetSide side, double y) + { + var line = new Line(0, 0, 10, 0); + + var offset = (Line)line.OffsetEntity(1, side); + + Assert.True(offset.StartPoint.DistanceTo(new Vector(0, y)) < 1e-9); + Assert.True(offset.EndPoint.DistanceTo(new Vector(10, y)) < 1e-9); + } + + [Fact] + public void OffsetOutward_NonTangentLineArcCorners_GetRoundJoins() + { + // D shape: right half of an r=5 circle closed by the Y axis. Both corners are + // convex and not tangent, so the offset needs a round join at each. + var shape = new Shape(); + shape.Entities.Add(new Arc(0, 0, 5, -Angle.HalfPI, Angle.HalfPI)); + shape.Entities.Add(new Line(0, 5, 0, -5)); + + var offset = shape.OffsetOutward(1); + + AssertClosedChain(offset.Entities); + Assert.Equal(2, offset.Entities.OfType().Count(a => a.Radius.IsEqualTo(1))); + Assert.All(Samples(offset.Entities), p => Assert.True(DistanceTo(shape, p) > 1 - 1e-6)); + } + + [Fact] + public void OffsetOutward_CollapsedFillet_ClosesTheChain() + { + // 10x4 part with a 0.2-wide slot down from the top, with a round (r=0.1) bottom. + // Offsetting outward by 0.25 collapses the slot's end arc. + var shape = new Shape(); + shape.Entities.Add(new Line(0, 0, 10, 0)); + shape.Entities.Add(new Line(10, 0, 10, 4)); + shape.Entities.Add(new Line(10, 4, 5.1, 4)); + shape.Entities.Add(new Line(5.1, 4, 5.1, 2)); + shape.Entities.Add(new Arc(5, 2, 0.1, 0, System.Math.PI, reversed: true)); + shape.Entities.Add(new Line(4.9, 2, 4.9, 4)); + shape.Entities.Add(new Line(4.9, 4, 0, 4)); + shape.Entities.Add(new Line(0, 4, 0, 0)); + + var offset = shape.OffsetOutward(0.25); + + AssertClosedChain(offset.Entities); + Assert.All( + Samples(offset.Entities), + p => Assert.True(DistanceTo(shape, p) > 0.25 - 1e-6 || IsInsideSlot(p)) + ); + } + + // The collapsed slot leaves a line bridging its walls' offsets, which lies inside + // the offset envelope (closer than the spacing) by design. + private static bool IsInsideSlot(Vector p) => p.X > 4.8 && p.X < 5.2 && p.Y > 1.7; + + private static void AssertClosedChain(List entities) + { + for (var i = 0; i < entities.Count; i++) + { + var end = End(entities[i]); + var start = Start(entities[(i + 1) % entities.Count]); + + Assert.True( + end.DistanceTo(start) < 1e-6, + $"Gap of {end.DistanceTo(start)} after entity {i} ({entities[i].Type})." + ); + } + } + + private static IEnumerable Samples(List entities) + { + foreach (var entity in entities) + { + for (var t = 0.0; t <= 1.0; t += 0.1) + { + yield return entity switch + { + Line l => l.StartPoint + (l.EndPoint - l.StartPoint) * t, + Arc a => ArcPoint(a, t), + _ => Start(entity), + }; + } + } + } + + private static Vector ArcPoint(Arc arc, double t) + { + var sweep = arc.SweepAngle(); + var angle = arc.StartAngle + (arc.IsReversed ? -sweep : sweep) * t; + return new Vector( + arc.Center.X + arc.Radius * System.Math.Cos(angle), + arc.Center.Y + arc.Radius * System.Math.Sin(angle) + ); + } + + private static double DistanceTo(Shape shape, Vector p) => + shape.Entities.Min(e => e.ClosestPointTo(p).DistanceTo(p)); + + private static Vector Start(Entity e) => + e switch + { + Line l => l.StartPoint, + Arc a => a.StartPoint(), + _ => default, + }; + + private static Vector End(Entity e) => + e switch + { + Line l => l.EndPoint, + Arc a => a.EndPoint(), + _ => default, + }; +} diff --git a/OpenNest/Controls/EntityView.cs b/OpenNest/Controls/EntityView.cs index 4870a0e..8163e4c 100644 --- a/OpenNest/Controls/EntityView.cs +++ b/OpenNest/Controls/EntityView.cs @@ -165,17 +165,6 @@ namespace OpenNest.Controls DrawArc(e.Graphics, SimplifierPreview, previewPen); } -#if DRAW_OFFSET - - var offsetShape = new Shape(); - offsetShape.Entities.AddRange(Entities); - - foreach ( - var entity in ((Shape)offsetShape.OffsetEntity(0.25, OffsetSide.Left)).Entities - ) - DrawEntity(e.Graphics, entity, Pens.RoyalBlue); -#endif - PaintOverlay?.Invoke(e.Graphics); } diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index f5571fa..a5d9c09 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -223,43 +223,27 @@ namespace OpenNest private List ComputeOffsetPolygons(double spacing, double tolerance) { - var result = new List(); var entities = ConvertProgram.ToGeometry(BasePart.Program); var profile = new ShapeProfile( entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() ); - AddOffsetPolygon(result, profile.Perimeter.OffsetOutward(spacing), tolerance); + var offset = ClipperBridge.Offset(profile, spacing, tolerance); + var result = new List(offset.Outers.Count + offset.Holes.Count); - foreach (var cutout in profile.Cutouts) - AddOffsetPolygon(result, cutout.OffsetInward(spacing), tolerance); + foreach (var polygon in offset.Outers.Concat(offset.Holes)) + { + var pts = new PointF[polygon.Vertices.Count]; + + for (var j = 0; j < pts.Length; j++) + pts[j] = new PointF((float)polygon.Vertices[j].X, (float)polygon.Vertices[j].Y); + + result.Add(pts); + } return result; } - private static void AddOffsetPolygon( - List result, - Shape offsetEntity, - double tolerance - ) - { - if (offsetEntity == null) - return; - - var polygon = offsetEntity.ToPolygonWithTolerance(tolerance); - polygon.RemoveSelfIntersections(); - - if (polygon.Vertices.Count < 2) - return; - - var pts = new PointF[polygon.Vertices.Count]; - - for (var j = 0; j < pts.Length; j++) - pts[j] = new PointF((float)polygon.Vertices[j].X, (float)polygon.Vertices[j].Y); - - result.Add(pts); - } - private void RebuildOffsetPath(Matrix matrix) { OffsetPath?.Dispose(); diff --git a/README.md b/README.md index 35c627e..520a105 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,7 @@ OpenNest.sln |---------|-------------| | **OpenNest** | The app you run. WinForms MDI interface with plate viewer, drawing list, CAD converter, and dialogs. | | **OpenNest.Console** | Command-line interface for batch nesting, scripting, and automation. | -| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation, bend lines, cut-offs, and drawing splitting. | +| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation, bend lines, cut-offs, and drawing splitting. Spacing offsets use Clipper2 (`ClipperBridge`) for CPU-side preparation; the per-pair `Collision` test stays hand-rolled so it can move to the GPU. | | **OpenNest.Engine** | The brains — fill strategies (linear, pairs, rect best-fit, extents), NFP-based pair evaluation, gravity compaction, and a pluggable engine registry. | | **OpenNest.IO** | Reads and writes files — DXF/DWG (via ACadSharp), G-code, the `.nest` ZIP format, BOM spreadsheets (via ClosedXML), and bend detection from CAD files. | | **OpenNest.Api** | High-level API for running the full nesting pipeline programmatically (import, nest, export). |