Merge branch 'refactor/clipper-geometry'

Spacing offsets move onto Clipper (ClipperBridge) for CPU preparation,
fixing spikes and inverted loops where features are narrower than the
spacing; Collision stays hand-rolled for the GPU path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-23 11:09:17 -04:00
co-authored by Claude Opus 5.5
24 changed files with 1526 additions and 675 deletions
+3 -2
View File
@@ -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<NestProgress>` 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).
+27 -46
View File
@@ -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
/// <summary>
/// Extracts a part's material as world-space polygons - the perimeter and
/// its cutouts - grown by <paramref name="inflateBy"/> (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.
/// </summary>
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<Polygon>();
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;
}
}
}
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
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)
+39 -12
View File
@@ -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<Entity> { 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<Vector>();
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)
/// <summary>
/// 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.
/// </summary>
private static List<Entity> 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<Entity>().ToList();
}
private Vector MakePoint(double cutCoord, double lineCoord) =>
+14 -10
View File
@@ -443,19 +443,23 @@ namespace OpenNest.Geometry
boundingBox.Width = maxY - minY;
}
/// <summary>
/// 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.
/// </summary>
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)
+5 -2
View File
@@ -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 };
}
}
+423
View File
@@ -0,0 +1,423 @@
using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// 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 (<see cref="Collision"/>) stay hand-rolled so they can
/// be ported to a GPU kernel.
/// </summary>
public static class ClipperBridge
{
/// <summary>
/// Decimal places Clipper keeps (1e-4 in either inches or mm).
/// </summary>
public const int Precision = 4;
private const double MiterLimit = 2.0;
private const double ConservativeJoinFactor = 0.25;
private const double ValidationJoinFactor = 0.1;
/// <summary>
/// Converts a polygon to a Clipper path, dropping the closing vertex and
/// orienting it positive (CCW) or negative (CW).
/// </summary>
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;
}
/// <summary>
/// Converts a polygon to a Clipper path with an optional offset, dropping the
/// closing vertex and keeping the polygon's own winding.
/// </summary>
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;
}
/// <summary>
/// Converts a Clipper path to a closed polygon with updated bounds.
/// </summary>
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;
}
/// <summary>
/// Flattens a profile into a Clipper region: perimeter positive, cutouts negative.
/// </summary>
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;
}
/// <summary>
/// Offsets a part region outward by <paramref name="distance"/>: 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 <paramref name="tolerance"/> from the true arc.
/// </summary>
/// <param name="circumscribe">
/// 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.
/// </param>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Offsets a closed polygon outward, whatever its winding.
/// </summary>
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);
}
/// <summary>
/// Offsets an already-flattened region (outers positive, holes negative).
/// A distance of zero only unions the region, with no conservative padding.
/// </summary>
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);
}
/// <summary>
/// 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 <paramref name="tolerance"/>.
/// </summary>
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<Polygon>(), new List<Polygon>());
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;
}
/// <summary>
/// Miter-offsets a closed polygon by <paramref name="delta"/> (positive grows it,
/// negative shrinks it). Returns the largest resulting polygon (CCW), or null
/// when the polygon collapses.
/// </summary>
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);
}
/// <summary>
/// Flattens a closed shape to a polygon whose chords stay within
/// <paramref name="tolerance"/> 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.
/// </summary>
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<Vector> 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<Vector> 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)
)
);
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Result of <see cref="ClipperBridge.Offset(ShapeProfile, double, double, bool)"/>:
/// outer boundaries (CCW) and holes (CW), as closed polygons.
/// </summary>
public sealed record OffsetRegion(List<Polygon> Outers, List<Polygon> Holes)
{
/// <summary>
/// The outer boundary with the largest area, or null when the region is empty.
/// </summary>
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;
}
}
}
+16
View File
@@ -3,6 +3,22 @@ using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// 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 <see cref="ClipperBridge"/>
/// for the CPU preparation that feeds it).
/// <para>
/// GPU-port contract. Per-polygon preparation, done once per drawing and rotation,
/// then cached and uploaded: the spacing offset (<see cref="ClipperBridge"/>),
/// triangulation (<see cref="ConvexDecomposition.Triangulate"/>) 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 (<c>ClipConvex</c>), and
/// subtraction of hole triangles from the clipped regions (<c>SubtractTriangles</c>).
/// Inputs are closed, lines-only polygons; winding is normalized by triangulation.
/// </para>
/// </summary>
public static class Collision
{
public static CollisionResult Check(
+2 -4
View File
@@ -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)
+2 -127
View File
@@ -1,5 +1,4 @@
using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Math;
namespace OpenNest.Geometry
@@ -11,21 +10,9 @@ namespace OpenNest.Geometry
/// </summary>
public static class NoFitPolygon
{
private const double ClipperScale = 1000.0;
/// <summary>
/// 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).
/// </summary>
public static Polygon Compute(Polygon stationary, Polygon orbiting)
{
var reflected = Reflect(orbiting);
return MinkowskiSum(stationary, reflected);
}
/// <summary>
/// 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).
/// </summary>
public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting)
{
@@ -48,42 +35,6 @@ namespace OpenNest.Geometry
return result;
}
/// <summary>
/// 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.
/// </summary>
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<Polygon>();
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);
}
/// <summary>
/// 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;
}
/// <summary>
/// Unions multiple polygons using Clipper2.
/// Returns the outer boundary of the union as a single polygon.
/// </summary>
internal static Polygon UnionPolygons(List<Polygon> 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);
}
/// <summary>
/// Converts an OpenNest Polygon to a Clipper2 PathD, with an optional offset.
/// </summary>
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;
}
/// <summary>
/// Converts a Clipper2 PathD to an OpenNest Polygon.
/// </summary>
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;
}
}
}
+12 -198
View File
@@ -328,66 +328,29 @@ namespace OpenNest.Geometry
boundingBox.Width = maxY - minY;
}
/// <summary>
/// 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.
/// </summary>
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; }
}
/// <summary>
/// Removes self-intersecting loops from the polygon by finding non-adjacent
/// edge crossings and keeping the larger contour at each crossing.
/// </summary>
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<Vector> 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<Vector> { 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<Vector> 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--)
+184 -59
View File
@@ -463,80 +463,60 @@ namespace OpenNest.Geometry
boundingBox = Entities.Select(geo => geo.BoundingBox).ToList().GetBoundingBox();
}
/// <summary>
/// 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.
/// <para>
/// 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 <see cref="ClipperBridge"/> when a
/// clean region is needed.
/// </para>
/// </summary>
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<OffsetPiece>();
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;
}
}
/// <summary>
/// Direction of travel at the start and end of a line or arc.
/// </summary>
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.
/// </summary>
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.
/// </summary>
public Shape OffsetInward(double distance)
internal Shape OffsetInward(double distance)
{
var poly = ToPolygon();
+1 -154
View File
@@ -49,7 +49,7 @@ namespace OpenNest
/// <summary>
/// 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.
/// </summary>
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
{
@@ -149,75 +149,6 @@ namespace OpenNest
return result;
}
public static List<Line> 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<Line>();
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<Line> 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<Line>();
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<Line> GetPartLines(
Part part,
Vector facingDirection,
@@ -240,40 +171,6 @@ namespace OpenNest
return lines;
}
public static List<Line> 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<Line>();
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;
}
/// <summary>
/// Returns only polygon edges whose outward normal faces the specified direction vector.
/// </summary>
@@ -353,55 +250,5 @@ namespace OpenNest
return lines;
}
private static void AddOffsetLines(
List<Line> 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<Line> 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<Line> 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));
}
}
}
+8 -9
View File
@@ -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.
+10 -13
View File
@@ -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(
@@ -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<Arc>().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<Arc>().Single();
Assert.Equal(1.0, arc.Center.X, 12);
Assert.Equal(0.0, arc.Center.Y, 12);
Assert.Equal(1.0, arc.Radius, 12);
}
}
+43
View File
@@ -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<Motion>()
.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()
{
@@ -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<Part>)> { (plate, new List<Part> { a, b }) },
new Dictionary<Drawing, (string, int)> { [drawing] = ("filleted", 2) }
);
Assert.True(valid == result.Valid, string.Join("; ", result.Violations));
}
/// <summary>2 x 4 rectangle with 0.03125 corner fillets, CCW from the origin.</summary>
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;
}
}
@@ -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
""";
}
+79
View File
@@ -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<Polygon>()));
}
// 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();
+155
View File
@@ -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<Arc>().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<Entity> 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<Vector> Samples(List<Entity> 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,
};
}
-11
View File
@@ -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);
}
+11 -27
View File
@@ -223,43 +223,27 @@ namespace OpenNest
private List<PointF[]> ComputeOffsetPolygons(double spacing, double tolerance)
{
var result = new List<PointF[]>();
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<PointF[]>(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<PointF[]> 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();
+1 -1
View File
@@ -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). |