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:
@@ -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).
|
||||
|
||||
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
""";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user