feat(core): report hole-aware part overlap polygons
This commit is contained in:
@@ -43,6 +43,7 @@ Domain model, geometry, and CNC primitives organized into namespaces:
|
||||
- **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.
|
||||
- **Diagnostics** (`Diagnostics/`, `namespace OpenNest.Diagnostics`): `PlateOverlapAnalyzer.Capture` owns clean drawing entities and poses; `Analyze` returns read-only world-coordinate, hole-subtracted overlap fragments by input-index pair, with explicit issues for uncheckable inputs. It reuses `Collision.Check` without changing engine overlap semantics. Check `IsComplete` before treating an empty report as clear. See [material-overlap diagnostics](docs/geometry/visual-overlap-check.md) for the API, snapshot ownership, and numeric limits.
|
||||
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
|
||||
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
|
||||
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>Request-local, validated single-outer material. Never exposed to report consumers.</summary>
|
||||
internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
{
|
||||
internal static OverlapMaterial Read(List<Entity> entities, CancellationToken cancellationToken)
|
||||
{
|
||||
// ShapeBuilder can reverse entities while chaining. Own a fresh copy for each analysis.
|
||||
var shapes = ShapeBuilder.GetShapes(entities.Select(entity => entity.Clone()));
|
||||
if (shapes.Count == 0)
|
||||
throw new ArgumentException("Drawing has no closed material contour.");
|
||||
var polygons = new List<Polygon>();
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ValidateChain(shape);
|
||||
var polygon = shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance);
|
||||
// The analytic chain was validated above. Normalize its sampled seam (e.g.
|
||||
// sin(2*pi) is not exactly zero), rather than adding a spurious microscopic edge.
|
||||
if (polygon.IsClosed())
|
||||
polygon.Vertices[^1] = polygon.Vertices[0];
|
||||
ValidatePolygon(polygon, cancellationToken);
|
||||
polygons.Add(polygon);
|
||||
}
|
||||
// Sampling can hide a crossing or tangency between curves. Reject native
|
||||
// contour contact before asking the polygon approximation about containment.
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
shapes[i].Intersects(shapes[j], out var intersections);
|
||||
if (intersections.Count > 0)
|
||||
throw new ArgumentException("Native material contours cross or touch.");
|
||||
}
|
||||
}
|
||||
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
|
||||
var outer = ordered[0];
|
||||
var holes = ordered.Skip(1).ToList();
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (BoundariesTouch(outer, holes[i], cancellationToken)
|
||||
|| !Inside(outer, holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|
||||
|| Inside(holes[i], holes[j].Vertices[0])
|
||||
|| Inside(holes[j], holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
|
||||
}
|
||||
}
|
||||
return new OverlapMaterial(outer, holes);
|
||||
}
|
||||
|
||||
internal OverlapMaterial Transform(double rotation, Vector offset) =>
|
||||
new(TransformPolygon(Outer, rotation, offset),
|
||||
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
|
||||
|
||||
private static Polygon TransformPolygon(Polygon polygon, double rotation, Vector offset)
|
||||
{
|
||||
var transformed = new Polygon();
|
||||
transformed.Vertices.AddRange(polygon.Vertices.Select(point =>
|
||||
(rotation == 0 ? point : point.Rotate(rotation)) + offset));
|
||||
if (transformed.Vertices.Any(point => !IsFinite(point)))
|
||||
throw new ArithmeticException("Transformed contour has nonfinite coordinates.");
|
||||
transformed.UpdateBounds();
|
||||
if (!double.IsFinite(transformed.BoundingBox.Length)
|
||||
|| !double.IsFinite(transformed.BoundingBox.Width))
|
||||
throw new ArithmeticException("Transformed contour bounds overflowed.");
|
||||
var sourceArea = Area(polygon.Vertices);
|
||||
var transformedArea = Area(transformed.Vertices);
|
||||
if (!double.IsFinite(transformedArea) || transformedArea <= Tolerance.Epsilon
|
||||
|| System.Math.Abs(sourceArea - transformedArea)
|
||||
> System.Math.Max(Tolerance.Epsilon, sourceArea * 1e-8))
|
||||
throw new ArithmeticException("Coordinate precision cannot preserve the contour area at this pose.");
|
||||
for (var i = 0; i + 1 < transformed.Vertices.Count; i++)
|
||||
{
|
||||
var a = transformed.Vertices[i];
|
||||
var b = transformed.Vertices[i + 1];
|
||||
if (a.X == b.X && a.Y == b.Y)
|
||||
throw new ArithmeticException("Coordinate precision collapsed a contour edge at this pose.");
|
||||
}
|
||||
return transformed;
|
||||
}
|
||||
|
||||
internal static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
|
||||
/// <summary>Translation-stable unsigned shoelace area; accepts an explicit closing vertex.</summary>
|
||||
internal static double Area(IReadOnlyList<Vector> vertices)
|
||||
{
|
||||
var twiceArea = 0.0;
|
||||
for (var i = 1; i + 1 < vertices.Count; i++)
|
||||
twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]);
|
||||
return System.Math.Abs(twiceArea) * 0.5;
|
||||
}
|
||||
|
||||
private static void ValidateChain(Shape shape)
|
||||
{
|
||||
if (!shape.IsClosed())
|
||||
throw new ArgumentException("Material contour is open.");
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (!double.IsFinite(entity.Length) || entity.Length <= 0)
|
||||
throw new ArgumentException("Material contour has a nonfinite or zero-length edge.");
|
||||
}
|
||||
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
|
||||
{
|
||||
if (!IsFinite(circle.Center) || !double.IsFinite(circle.Radius) || circle.Radius <= 0)
|
||||
throw new ArgumentException("Material circle is invalid.");
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < shape.Entities.Count; i++)
|
||||
{
|
||||
var end = Endpoints(shape.Entities[i]).End;
|
||||
var start = Endpoints(shape.Entities[(i + 1) % shape.Entities.Count]).Start;
|
||||
// Do not let ShapeBuilder's larger chain tolerance silently repair a broken cut.
|
||||
if (!IsFinite(start) || !IsFinite(end) || end.DistanceTo(start) > Tolerance.Epsilon)
|
||||
throw new ArgumentException("Material contour has a gap or invalid endpoint.");
|
||||
}
|
||||
}
|
||||
|
||||
private static (Vector Start, Vector End) Endpoints(Entity entity) => entity switch
|
||||
{
|
||||
Line line => (line.StartPoint, line.EndPoint),
|
||||
Arc arc => (arc.StartPoint(), arc.EndPoint()),
|
||||
_ => throw new ArgumentException("Unsupported material entity."),
|
||||
};
|
||||
|
||||
private static void ValidatePolygon(Polygon polygon, CancellationToken cancellationToken)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
var area = Area(vertices);
|
||||
if (vertices.Count < 4 || vertices.Any(point => !IsFinite(point))
|
||||
|| !double.IsFinite(area) || area <= Tolerance.Epsilon)
|
||||
throw new ArgumentException("Material contour is degenerate or nonfinite.");
|
||||
var count = vertices.Count - 1;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var previous = vertices[(i + count - 1) % count];
|
||||
var current = vertices[i];
|
||||
var next = vertices[i + 1];
|
||||
if (current.X == next.X && current.Y == next.Y)
|
||||
throw new ArgumentException("Material polygon has a zero-length edge.");
|
||||
if (Cross(previous, current, next) == 0
|
||||
&& (previous.X - current.X) * (next.X - current.X)
|
||||
+ (previous.Y - current.Y) * (next.Y - current.Y) > 0)
|
||||
throw new ArgumentException("Material polygon has a retraced edge.");
|
||||
for (var j = i + 2; j < count; j++)
|
||||
{
|
||||
if (i == 0 && j == count - 1)
|
||||
continue;
|
||||
if (SegmentsTouch(vertices[i], vertices[i + 1], vertices[j], vertices[j + 1]))
|
||||
throw new ArgumentException("Material contour self-intersects or touches itself.");
|
||||
}
|
||||
}
|
||||
// Ear clipping can stop early on unusable geometry. Do not certify that as clear.
|
||||
var local = TransformPolygon(polygon, 0, vertices[0] * -1);
|
||||
var triangulatedArea = Collision.Triangulate(local).Sum(triangle => Area(triangle.Vertices));
|
||||
if (!double.IsFinite(triangulatedArea)
|
||||
|| System.Math.Abs(triangulatedArea - area) > System.Math.Max(Tolerance.Epsilon, area * 1e-9))
|
||||
throw new ArgumentException("Material contour could not be completely triangulated.");
|
||||
}
|
||||
|
||||
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var i = 0; i + 1 < a.Vertices.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var j = 0; j + 1 < b.Vertices.Count; j++)
|
||||
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool SegmentsTouch(Vector a, Vector b, Vector c, Vector d)
|
||||
{
|
||||
var ac = Cross(a, b, c);
|
||||
var ad = Cross(a, b, d);
|
||||
var ca = Cross(c, d, a);
|
||||
var cb = Cross(c, d, b);
|
||||
return ac == 0 && OnSegment(a, b, c) || ad == 0 && OnSegment(a, b, d)
|
||||
|| ca == 0 && OnSegment(c, d, a) || cb == 0 && OnSegment(c, d, b)
|
||||
|| (ac < 0 && ad > 0 || ac > 0 && ad < 0)
|
||||
&& (ca < 0 && cb > 0 || ca > 0 && cb < 0);
|
||||
}
|
||||
|
||||
private static bool OnSegment(Vector a, Vector b, Vector point) =>
|
||||
point.X >= System.Math.Min(a.X, b.X) && point.X <= System.Math.Max(a.X, b.X)
|
||||
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
|
||||
|
||||
// Boundary contact is rejected before this winding-number test is used for topology.
|
||||
private static bool Inside(Polygon polygon, Vector point)
|
||||
{
|
||||
var winding = 0;
|
||||
for (var i = 0; i + 1 < polygon.Vertices.Count; i++)
|
||||
{
|
||||
var a = polygon.Vertices[i];
|
||||
var b = polygon.Vertices[i + 1];
|
||||
if (a.Y <= point.Y && b.Y > point.Y && Cross(a, b, point) > 0)
|
||||
winding++;
|
||||
else if (a.Y > point.Y && b.Y <= point.Y && Cross(a, b, point) < 0)
|
||||
winding--;
|
||||
}
|
||||
return winding != 0;
|
||||
}
|
||||
|
||||
private static double Cross(Vector a, Vector b, Vector point) =>
|
||||
(b.X - a.X) * (point.Y - a.Y) - (b.Y - a.Y) * (point.X - a.X);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only, hole-aware material overlap diagnostics, separate from the engine's boolean checks.
|
||||
/// Uses clean drawing outlines, not placed lead-in/tab toolpaths or spacing offsets.
|
||||
/// </summary>
|
||||
public static class PlateOverlapAnalyzer
|
||||
{
|
||||
public const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Captures poses and converts each distinct clean source program to owned entities once.
|
||||
/// Inputs must not change during capture. Later analysis never reads live domain objects.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parts);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var captured = new List<CapturedOverlapPart>();
|
||||
var issues = new List<PlateOverlapIssue>();
|
||||
var sources = new Dictionary<Program, CapturedSource>(ReferenceEqualityComparer.Instance);
|
||||
for (var id = 0; id < parts.Count; id++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var part = parts[id];
|
||||
if (part?.BaseDrawing?.IsCutOff == true)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (part?.BaseDrawing?.Program == null)
|
||||
throw new ArgumentException("Part has no clean drawing program.");
|
||||
var program = part.BaseDrawing.Program;
|
||||
var rotation = part.Rotation - program.Rotation;
|
||||
var location = part.Location;
|
||||
if (!double.IsFinite(rotation) || !OverlapMaterial.IsFinite(location))
|
||||
throw new ArgumentException("Part pose must be finite.");
|
||||
if (!sources.TryGetValue(program, out var source))
|
||||
{
|
||||
try
|
||||
{
|
||||
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
|
||||
// Conversion creates fresh geometry, including expanded shared hole calls;
|
||||
// no cloning/rotation of a live program or subprogram is necessary.
|
||||
source = new CapturedSource(ConvertProgram.ToGeometry(program)
|
||||
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
|
||||
&& entity.Layer != SpecialLayers.Leadin
|
||||
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
source = new CapturedSource(null, exception.Message);
|
||||
}
|
||||
sources.Add(program, source);
|
||||
}
|
||||
if (source.Error != null)
|
||||
throw new ArgumentException(source.Error);
|
||||
captured.Add(new CapturedOverlapPart(id, part.BaseDrawing.Name,
|
||||
source.Entities, rotation, location));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapSnapshot(captured, issues);
|
||||
}
|
||||
|
||||
/// <summary>Convenience synchronous capture and analysis of a group of parts.</summary>
|
||||
public static PlateOverlapReport Analyze(IReadOnlyList<Part> parts,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Analyze(Capture(parts, cancellationToken), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns deterministic pair reports containing closed world-coordinate overlap fragments.
|
||||
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
|
||||
/// </summary>
|
||||
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var issues = snapshot.Issues.ToList();
|
||||
var pairs = new List<PlateOverlapPair>();
|
||||
var prepared = new List<PreparedPart>();
|
||||
var sources = new Dictionary<List<Entity>, PreparedSource>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var part in snapshot.Parts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
if (!sources.TryGetValue(part.Entities, out var source))
|
||||
{
|
||||
try
|
||||
{
|
||||
source = new PreparedSource(OverlapMaterial.Read(part.Entities, cancellationToken), null);
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
source = new PreparedSource(null, exception.Message);
|
||||
}
|
||||
sources.Add(part.Entities, source);
|
||||
}
|
||||
if (source.Error != null)
|
||||
throw new ArgumentException(source.Error);
|
||||
var material = source.Material.Transform(part.Rotation, part.Location);
|
||||
prepared.Add(new PreparedPart(part, material));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(part.Id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = prepared.OrderBy(part => part.Material.Outer.BoundingBox.Left)
|
||||
.ThenBy(part => part.Input.Id).ToArray();
|
||||
for (var i = 0; i < sorted.Length; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var first = sorted[i];
|
||||
var bounds = first.Material.Outer.BoundingBox;
|
||||
for (var j = i + 1; j < sorted.Length; j++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var second = sorted[j];
|
||||
var otherBounds = second.Material.Outer.BoundingBox;
|
||||
if (otherBounds.Left >= bounds.Right)
|
||||
break;
|
||||
if (otherBounds.Bottom >= bounds.Top || bounds.Bottom >= otherBounds.Top)
|
||||
continue;
|
||||
var a = first.Input.Id < second.Input.Id ? first : second;
|
||||
var b = first.Input.Id < second.Input.Id ? second : first;
|
||||
try
|
||||
{
|
||||
// Keep pair clipping arithmetic near the parts where possible, then
|
||||
// restore output to world space. Triangulation itself also uses stable
|
||||
// local-origin winding so tiny holes in a huge part remain correct.
|
||||
var origin = a.Material.Outer.Vertices[0];
|
||||
var localA = a.Material.Transform(0, origin * -1);
|
||||
var localB = b.Material.Transform(0, origin * -1);
|
||||
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
|
||||
if (!result.Overlaps)
|
||||
continue;
|
||||
var regions = result.OverlapRegions.Select(region => new PlateOverlapRegion(
|
||||
region.Vertices.Select(point => point + origin),
|
||||
OverlapMaterial.Area(region.Vertices))).ToList();
|
||||
var area = regions.Sum(region => region.Area);
|
||||
if (!double.IsFinite(area) || area <= 0)
|
||||
throw new ArithmeticException("Overlap area is not finite and positive.");
|
||||
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
|
||||
a.Input.Name, b.Input.Name, regions));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(a.Input.Id, b.Input.Id, exception.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
||||
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
||||
.ThenBy(issue => issue.PartBId).ToList());
|
||||
}
|
||||
|
||||
private static bool IsGeometryFailure(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
private static void ValidateProgram(Program program, HashSet<Program> visiting)
|
||||
{
|
||||
if (program == null || !visiting.Add(program) || visiting.Count > 64)
|
||||
throw new ArgumentException("Missing, recursive, or excessively nested subprogram.");
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
if (code == null)
|
||||
throw new ArgumentException("Program contains a missing instruction.");
|
||||
if (code is Motion motion && !OverlapMaterial.IsFinite(motion.EndPoint)
|
||||
|| code is ArcMove arc && !OverlapMaterial.IsFinite(arc.CenterPoint))
|
||||
throw new ArgumentException("Program coordinates must be finite.");
|
||||
if (code is SubProgramCall call)
|
||||
{
|
||||
if (!OverlapMaterial.IsFinite(call.Offset) || !double.IsFinite(call.Rotation))
|
||||
throw new ArgumentException("Subprogram pose must be finite.");
|
||||
ValidateProgram(call.Program, visiting);
|
||||
}
|
||||
}
|
||||
visiting.Remove(program);
|
||||
}
|
||||
|
||||
private sealed record CapturedSource(List<Entity> Entities, string Error);
|
||||
private sealed record PreparedSource(OverlapMaterial Material, string Error);
|
||||
private sealed record PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>An owned diagnostic result. An empty Pairs list is clear only if IsComplete is true.</summary>
|
||||
public sealed class PlateOverlapReport
|
||||
{
|
||||
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues)
|
||||
{
|
||||
Pairs = pairs.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
}
|
||||
|
||||
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
|
||||
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
public bool IsComplete => Issues.Count == 0;
|
||||
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
||||
}
|
||||
|
||||
/// <summary>Shared material for two input positions, ordered by zero-based input index.</summary>
|
||||
public sealed class PlateOverlapPair
|
||||
{
|
||||
private readonly Box bounds;
|
||||
|
||||
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
||||
List<PlateOverlapRegion> regions)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
PartAName = partAName;
|
||||
PartBName = partBName;
|
||||
Regions = regions.AsReadOnly();
|
||||
Area = regions.Sum(region => region.Area);
|
||||
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
||||
var left = points.Min(point => point.X);
|
||||
var bottom = points.Min(point => point.Y);
|
||||
bounds = new Box(left, bottom, points.Max(point => point.X) - left,
|
||||
points.Max(point => point.Y) - bottom);
|
||||
}
|
||||
|
||||
public int PartAId { get; }
|
||||
public int PartBId { get; }
|
||||
public string PartAName { get; }
|
||||
public string PartBName { get; }
|
||||
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
|
||||
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
|
||||
public double Area { get; }
|
||||
/// <summary>A fresh world-coordinate bounds copy.</summary>
|
||||
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||
}
|
||||
|
||||
/// <summary>A positive-area, hole-subtracted convex polygon in world coordinates.</summary>
|
||||
public sealed class PlateOverlapRegion
|
||||
{
|
||||
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area)
|
||||
{
|
||||
Vertices = System.Array.AsReadOnly(vertices.ToArray());
|
||||
Area = area;
|
||||
}
|
||||
|
||||
/// <summary>Read-only vertices with an exactly repeated closing vertex.</summary>
|
||||
public IReadOnlyList<Vector> Vertices { get; }
|
||||
public double Area { get; }
|
||||
}
|
||||
|
||||
/// <summary>An input or pair that could not be checked. IDs are zero-based input positions.</summary>
|
||||
public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Owned clean geometry and poses. Capture while inputs are stable, then analyze on a worker.
|
||||
/// No live Part, Drawing, Program, or subprogram is retained.
|
||||
/// </summary>
|
||||
public sealed class PlateOverlapSnapshot
|
||||
{
|
||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
|
||||
{
|
||||
Parts = parts.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
}
|
||||
|
||||
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
}
|
||||
|
||||
internal sealed record CapturedOverlapPart(int Id, string Name, List<Entity> Entities,
|
||||
double Rotation, Vector Location);
|
||||
@@ -0,0 +1,444 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Shapes;
|
||||
|
||||
namespace OpenNest.Tests.Diagnostics;
|
||||
|
||||
public class PlateOverlapAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Analyze_ReturnsOwnedWorldPolygonsForEachOverlappingPair()
|
||||
{
|
||||
var parts = new[] { Rectangle(0, 0, 1, 1), Rectangle(0.5, 0, 1, 1) };
|
||||
|
||||
var report = PlateOverlapAnalyzer.Analyze(parts);
|
||||
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Empty(report.Issues);
|
||||
var pair = Assert.Single(report.Pairs);
|
||||
Assert.Equal((0, 1), (pair.PartAId, pair.PartBId));
|
||||
Assert.Equal(0.5, pair.Area, 9);
|
||||
Assert.NotEmpty(pair.Regions);
|
||||
Assert.All(pair.Regions, region =>
|
||||
{
|
||||
Assert.True(region.Area > 0);
|
||||
Assert.Equal(region.Vertices[0], region.Vertices[^1]);
|
||||
Assert.All(region.Vertices, point =>
|
||||
{
|
||||
Assert.InRange(point.X, 0.5, 1);
|
||||
Assert.InRange(point.Y, 0, 1);
|
||||
});
|
||||
});
|
||||
Assert.Equal(pair.Area, pair.Regions.Sum(r => r.Area), 9);
|
||||
Assert.Equal((0.5, 0.0, 1.0, 1.0),
|
||||
(pair.Bounds.Left, pair.Bounds.Bottom, pair.Bounds.Right, pair.Bounds.Top));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_FullContainmentDoesNotRequireCrossingPoints()
|
||||
{
|
||||
var pair = Assert.Single(PlateOverlapAnalyzer.Analyze(new[]
|
||||
{
|
||||
Rectangle(0, 0, 4, 4), Rectangle(1, 1, 1, 1)
|
||||
}).Pairs);
|
||||
Assert.Equal(1, pair.Area, 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2, 0)]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(1, 1)]
|
||||
public void Analyze_DisjointAndBoundaryOnlyContactAreClear(double x, double y)
|
||||
{
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[]
|
||||
{
|
||||
Rectangle(0, 0, 1, 1), Rectangle(x, y, 1, 1)
|
||||
});
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Empty(report.Pairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_EmptyAndSinglePartAreComplete()
|
||||
{
|
||||
foreach (var parts in new[] { Array.Empty<Part>(), new[] { Rectangle(0, 0, 1, 1) } })
|
||||
{
|
||||
var report = PlateOverlapAnalyzer.Analyze(parts);
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Empty(report.Pairs);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Analyze_SubtractsHolesOfEitherOperand(bool swap)
|
||||
{
|
||||
var frame = WithContours(Square(0, 0, 4), Square(1, 1, 2));
|
||||
var insert = Rectangle(1.5, 1.5, 1, 1);
|
||||
var report = PlateOverlapAnalyzer.Analyze(swap ? new[] { insert, frame } : new[] { frame, insert });
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Empty(report.Pairs);
|
||||
|
||||
insert.Location = new Vector(0.5, 1.5);
|
||||
report = PlateOverlapAnalyzer.Analyze(swap ? new[] { insert, frame } : new[] { frame, insert });
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Equal(0.5, Assert.Single(report.Pairs).Area, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_SubtractsBothPartsHolesWithoutDuplicatingArea()
|
||||
{
|
||||
var a = WithContours(Square(0, 0, 4), Square(0.5, 0.5, 1));
|
||||
var b = WithContours(Square(0, 0, 4), Square(2, 2, 1));
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Equal(14, Assert.Single(report.Pairs).Area, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_ConcaveIntersectionKeepsDisconnectedFragments()
|
||||
{
|
||||
var u = WithContours(new[]
|
||||
{
|
||||
new Vector(0, 0), new Vector(3, 0), new Vector(3, 3), new Vector(2, 3),
|
||||
new Vector(2, 1), new Vector(1, 1), new Vector(1, 3), new Vector(0, 3)
|
||||
});
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { u, Rectangle(0, 2, 3, 1) });
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
var pair = Assert.Single(report.Pairs);
|
||||
Assert.Equal(2, pair.Area, 8);
|
||||
Assert.All(pair.Regions, region => Assert.True(
|
||||
region.Vertices.All(p => p.X <= 1) || region.Vertices.All(p => p.X >= 2)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(1000000000, 1000000000)]
|
||||
[InlineData(-1000000000, -1000000000)]
|
||||
public void Analyze_ReversedWindingAndLargeTranslationPreserveArea(double x, double y)
|
||||
{
|
||||
var a = WithContours(Square(0, 0, 1).Reverse().ToArray());
|
||||
var b = Rectangle(0, 0, 1, 1);
|
||||
a.Location = new Vector(x, y);
|
||||
b.Location = new Vector(x + 0.5, y);
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
var pair = Assert.Single(report.Pairs);
|
||||
Assert.Equal(0.5, pair.Area, 7);
|
||||
Assert.Equal(x + 0.5, pair.Bounds.Left, 7);
|
||||
Assert.Equal(y + 1, pair.Bounds.Top, 7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_UsesBaselineAdjustedCleanDrawingPose()
|
||||
{
|
||||
var drawing = Rectangle(0, 0, 2, 1).BaseDrawing;
|
||||
drawing.Program.Rotate(System.Math.PI / 2);
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(System.Math.PI / 2);
|
||||
part.Location = new Vector(10, 10);
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { part, Rectangle(8, 9, 2, 1) });
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Equal(2, Assert.Single(report.Pairs).Area, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_CircularHoleIsNotSolidMaterial()
|
||||
{
|
||||
var ring = new Part(new RingShape { OuterDiameter = 10, InnerDiameter = 7 }.GetDrawing());
|
||||
var disk = new Part(new CircleShape { Diameter = 6 }.GetDrawing());
|
||||
ring.Location = new Vector(-36.8, 5.4);
|
||||
disk.Location = new Vector(-36.7, 5.4);
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { ring, disk });
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Empty(report.Pairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_KeepsInputIndicesAndDeterministicOrderWhileSkippingCutoffs()
|
||||
{
|
||||
var cutoff = Rectangle(0, 0, 1, 1);
|
||||
cutoff.BaseDrawing.IsCutOff = true;
|
||||
var drawing = Rectangle(0, 0, 1, 1).BaseDrawing;
|
||||
var parts = new[] { new Part(drawing), cutoff, new Part(drawing), new Part(drawing) };
|
||||
var report = PlateOverlapAnalyzer.Analyze(parts);
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Equal(new[] { (0, 2), (0, 3), (2, 3) },
|
||||
report.Pairs.Select(p => (p.PartAId, p.PartBId)));
|
||||
Assert.All(report.Pairs, p => Assert.Equal(1, p.Area, 9));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_SweepMatchesAnalyticalExhaustiveRectanglePairs()
|
||||
{
|
||||
var random = new Random(928);
|
||||
var parts = Enumerable.Range(0, 40)
|
||||
.Select(_ => Rectangle(random.Next(-10, 10), random.Next(-10, 10), 3, 2)).ToArray();
|
||||
var expected = new List<(int, int, double)>();
|
||||
for (var a = 0; a < parts.Length; a++)
|
||||
for (var b = a + 1; b < parts.Length; b++)
|
||||
{
|
||||
var dx = System.Math.Min(parts[a].Right, parts[b].Right)
|
||||
- System.Math.Max(parts[a].Left, parts[b].Left);
|
||||
var dy = System.Math.Min(parts[a].Top, parts[b].Top)
|
||||
- System.Math.Max(parts[a].Bottom, parts[b].Bottom);
|
||||
if (dx > 0 && dy > 0)
|
||||
expected.Add((a, b, dx * dy));
|
||||
}
|
||||
var report = PlateOverlapAnalyzer.Analyze(parts);
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Equal(expected.Select(pair => (pair.Item1, pair.Item2)),
|
||||
report.Pairs.Select(pair => (pair.PartAId, pair.PartBId)));
|
||||
foreach (var (expectedPair, actualPair) in expected.Zip(report.Pairs))
|
||||
Assert.Equal(expectedPair.Item3, actualPair.Area, 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("open")]
|
||||
[InlineData("empty")]
|
||||
[InlineData("nan")]
|
||||
[InlineData("infinite-pose")]
|
||||
[InlineData("disjoint-outers")]
|
||||
[InlineData("nested-island")]
|
||||
[InlineData("crossing-holes")]
|
||||
[InlineData("self-crossing")]
|
||||
public void Analyze_InvalidPartMakesReportIncompleteButRetainsKnownOverlaps(string invalid)
|
||||
{
|
||||
var bad = Rectangle(0, 0, 1, 1);
|
||||
switch (invalid)
|
||||
{
|
||||
case "open": bad.BaseDrawing.Program.Codes.RemoveAt(bad.BaseDrawing.Program.Codes.Count - 1); break;
|
||||
case "empty": bad.BaseDrawing.Program.Codes.Clear(); break;
|
||||
case "nan": ((LinearMove)bad.BaseDrawing.Program.Codes[1]).EndPoint = new Vector(double.NaN, 0); break;
|
||||
case "infinite-pose": bad.Location = new Vector(double.PositiveInfinity, 0); break;
|
||||
case "disjoint-outers": bad = WithContours(Square(0, 0, 1), Square(3, 3, 1)); break;
|
||||
case "nested-island": bad = WithContours(Square(0, 0, 6), Square(1, 1, 4), Square(2, 2, 1)); break;
|
||||
case "crossing-holes": bad = WithContours(Square(0, 0, 6), Square(1, 1, 3), Square(2, 2, 3)); break;
|
||||
case "self-crossing": bad = WithContours(new[] { new Vector(0, 0), new Vector(3, 3), new Vector(0, 3), new Vector(2, 0) }); break;
|
||||
}
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(0, 0, 1, 1), bad, Rectangle(0, 0, 1, 1) });
|
||||
Assert.False(report.IsComplete);
|
||||
var issue = Assert.Single(report.Issues);
|
||||
Assert.Equal(1, issue.PartAId);
|
||||
Assert.Null(issue.PartBId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(issue.Message));
|
||||
var pair = Assert.Single(report.Pairs);
|
||||
Assert.Equal((0, 2), (pair.PartAId, pair.PartBId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_IsIndependentOfLaterDrawingPoseAndCollectionChanges()
|
||||
{
|
||||
var a = Rectangle(0, 0, 1, 1);
|
||||
var b = new Part(a.BaseDrawing, new Vector(0.5, 0));
|
||||
var parts = new List<Part> { a, b };
|
||||
var snapshot = PlateOverlapAnalyzer.Capture(parts);
|
||||
a.BaseDrawing.Program.Codes.Clear();
|
||||
b.Location = new Vector(100, 100);
|
||||
parts.Clear();
|
||||
var first = PlateOverlapAnalyzer.Analyze(snapshot);
|
||||
var second = PlateOverlapAnalyzer.Analyze(snapshot);
|
||||
Assert.True(first.IsComplete);
|
||||
Assert.Equal(0.5, Assert.Single(first.Pairs).Area, 9);
|
||||
Assert.Equal(0.5, Assert.Single(second.Pairs).Area, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_IgnoresPlacedCuttingProgramAndDoesNotMutateParts()
|
||||
{
|
||||
var a = Rectangle(0, 0, 1, 1);
|
||||
var b = Rectangle(0.5, 0, 1, 1);
|
||||
Assert.True(a.RestoreLeadInProgram(Rectangle(100, 100, 4, 4).Program, locked: true));
|
||||
var program = a.Program;
|
||||
var source = a.BaseDrawing.Program;
|
||||
var sourceText = source.ToString();
|
||||
var placedText = program.ToString();
|
||||
var bounds = a.BoundingBox;
|
||||
var location = a.Location;
|
||||
var rotation = a.Rotation;
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Equal(0.5, Assert.Single(report.Pairs).Area, 9);
|
||||
Assert.Same(program, a.Program);
|
||||
Assert.Same(source, a.BaseDrawing.Program);
|
||||
Assert.Equal(sourceText, source.ToString());
|
||||
Assert.Equal(placedText, program.ToString());
|
||||
Assert.Same(bounds, a.BoundingBox);
|
||||
Assert.Equal(location, a.Location);
|
||||
Assert.Equal(rotation, a.Rotation);
|
||||
Assert.True(a.HasManualLeadIns);
|
||||
Assert.True(a.LeadInsLocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_IgnoresScribeRapidAndLeadPathsInCleanSource()
|
||||
{
|
||||
var a = Rectangle(0, 0, 1, 1);
|
||||
foreach (var layer in new[] { LayerType.Scribe, LayerType.Leadin, LayerType.Leadout })
|
||||
{
|
||||
a.BaseDrawing.Program.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
a.BaseDrawing.Program.Codes.Add(new LinearMove(new Vector(10, 10)) { Layer = layer });
|
||||
}
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { a, Rectangle(5, 5, 1, 1) });
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Empty(report.Pairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_ReportCollectionsAndBoundsCannotBeMutated()
|
||||
{
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(0, 0, 1, 1), Rectangle(0, 0, 1, 1) });
|
||||
var pair = Assert.Single(report.Pairs);
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<PlateOverlapPair>)report.Pairs).Clear());
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<PlateOverlapRegion>)pair.Regions).Clear());
|
||||
var region = pair.Regions[0];
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<Vector>)region.Vertices)[0] = new Vector(99, 99));
|
||||
pair.Bounds.X = 99;
|
||||
Assert.Equal(0, pair.Bounds.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_SharedHoleSubprogramsAreOwnedAndReusableAcrossWorkers()
|
||||
{
|
||||
var source = Rectangle(0, 0, 10, 10).BaseDrawing;
|
||||
var hole = Rectangle(0, 0, 1, 1).BaseDrawing.Program;
|
||||
hole.Mode = Mode.Incremental;
|
||||
source.Program.SubPrograms[-1] = hole;
|
||||
source.Program.Codes.Add(new SubProgramCall { Id = -1, Program = hole, Offset = new Vector(2, 2) });
|
||||
source.Program.Codes.Add(new SubProgramCall { Id = -1, Program = hole, Offset = new Vector(6, 2) });
|
||||
source.Program.Rotate(System.Math.PI / 2);
|
||||
var placed = new Part(source);
|
||||
placed.Rotate(System.Math.PI / 2);
|
||||
placed.Location = new Vector(20, 20);
|
||||
var parts = new[] { placed, Rectangle(10, 10, 10, 10) };
|
||||
var before = source.Program.Codes.Select(code => code.ToString()).ToArray();
|
||||
var holeBefore = hole.Codes.Select(code => code.ToString()).ToArray();
|
||||
var snapshot = PlateOverlapAnalyzer.Capture(parts);
|
||||
Assert.Equal(before, source.Program.Codes.Select(code => code.ToString()));
|
||||
Assert.Equal(holeBefore, hole.Codes.Select(code => code.ToString()));
|
||||
foreach (var call in source.Program.Codes.OfType<SubProgramCall>())
|
||||
call.Offset = new Vector(100, 100);
|
||||
hole.Codes.Clear();
|
||||
source.Program.Codes.Clear();
|
||||
var reports = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ =>
|
||||
Task.Run(() => PlateOverlapAnalyzer.Analyze(snapshot))));
|
||||
foreach (var report in reports)
|
||||
{
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Equal(98, Assert.Single(report.Pairs).Area, 8);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Capture_InvalidSubprogramGraphIsIncompleteRatherThanRecursive(bool missing)
|
||||
{
|
||||
var part = Rectangle(0, 0, 1, 1);
|
||||
var call = new SubProgramCall();
|
||||
if (!missing)
|
||||
call.Program = part.BaseDrawing.Program;
|
||||
part.BaseDrawing.Program.Codes.Add(call);
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { part });
|
||||
Assert.False(report.IsComplete);
|
||||
Assert.Single(report.Issues);
|
||||
Assert.Empty(report.Pairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_NullArgumentsThrowAndNullEntriesAreIssues()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => PlateOverlapAnalyzer.Capture(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => PlateOverlapAnalyzer.Analyze((PlateOverlapSnapshot)null!));
|
||||
var report = PlateOverlapAnalyzer.Analyze(new Part[] { null! });
|
||||
Assert.False(report.IsComplete);
|
||||
Assert.Single(report.Issues);
|
||||
Assert.Empty(report.Pairs);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Analyze_HugeOuterWithSmallClockwisePartIsOrderIndependent(bool swap)
|
||||
{
|
||||
var outer = Rectangle(0, 0, 1e9, 1e9);
|
||||
var small = WithContours(Square(0, 0, 1).Reverse().ToArray());
|
||||
small.Location = new Vector(999999998, 999999998);
|
||||
var report = PlateOverlapAnalyzer.Analyze(swap ? new[] { small, outer } : new[] { outer, small });
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
Assert.Equal(1, Assert.Single(report.Pairs).Area, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_TranslationThatCollapsesMaterialIsIncompleteNotClear()
|
||||
{
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[]
|
||||
{
|
||||
Rectangle(1e16, 1e16, 1, 1), Rectangle(1e16, 1e16, 1, 1)
|
||||
});
|
||||
Assert.False(report.IsComplete);
|
||||
Assert.Equal(new[] { 0, 1 }, report.Issues.Select(issue => issue.PartAId));
|
||||
Assert.Empty(report.Pairs);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(9.0)]
|
||||
[InlineData(9.0002)]
|
||||
public void Analyze_NativeCircularHoleContactCannotBeHiddenByTessellation(double y)
|
||||
{
|
||||
var source = Rectangle(0, 0, 10, 10).BaseDrawing;
|
||||
source.Program.Codes.Add(new RapidMove(new Vector(6, y)));
|
||||
source.Program.Codes.Add(new ArcMove(new Vector(6, y), new Vector(5, y), RotationType.CCW));
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { new Part(source) });
|
||||
Assert.False(report.IsComplete);
|
||||
Assert.Single(report.Issues);
|
||||
Assert.Empty(report.Pairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_SubChordThinRingIsExplicitlyUncheckableNotClear()
|
||||
{
|
||||
const double radius = 4.995098381203606;
|
||||
var ring = new Part(new RingShape
|
||||
{
|
||||
OuterDiameter = 2 * (radius + 0.0001),
|
||||
InnerDiameter = 2 * (radius - 0.0001)
|
||||
}.GetDrawing());
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { ring });
|
||||
Assert.False(report.IsComplete);
|
||||
Assert.Single(report.Issues);
|
||||
Assert.Empty(report.Pairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_CancellationThrowsInsteadOfPublishingPartialClear()
|
||||
{
|
||||
var parts = new[] { Rectangle(0, 0, 1, 1), Rectangle(0, 0, 1, 1) };
|
||||
var snapshot = PlateOverlapAnalyzer.Capture(parts);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
Assert.Throws<OperationCanceledException>(() => PlateOverlapAnalyzer.Capture(parts, cancellation.Token));
|
||||
Assert.Throws<OperationCanceledException>(() => PlateOverlapAnalyzer.Analyze(snapshot, cancellation.Token));
|
||||
}
|
||||
|
||||
private static Part Rectangle(double x, double y, double width, double height) =>
|
||||
new(WithContours(new[] { new Vector(0, 0), new Vector(width, 0),
|
||||
new Vector(width, height), new Vector(0, height) }).BaseDrawing, new Vector(x, y));
|
||||
|
||||
private static Vector[] Square(double x, double y, double size) =>
|
||||
new[] { new Vector(x, y), new Vector(x + size, y), new Vector(x + size, y + size), new Vector(x, y + size) };
|
||||
|
||||
private static Part WithContours(params Vector[][] contours)
|
||||
{
|
||||
var program = new Program(Mode.Absolute);
|
||||
foreach (var contour in contours)
|
||||
{
|
||||
program.Codes.Add(new RapidMove(contour[0]));
|
||||
foreach (var point in contour.Skip(1).Append(contour[0]))
|
||||
program.Codes.Add(new LinearMove(point));
|
||||
}
|
||||
return new Part(new Drawing("same name", program));
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ Layouts are validated (bounds, spacing, quantity, rotation, stock match); invali
|
||||
| Project | Purpose |
|
||||
|---------|---------|
|
||||
| **OpenNest** | WinForms desktop app |
|
||||
| **OpenNest.Core** | Domain model, geometry, CNC primitives |
|
||||
| **OpenNest.Core** | Domain model, geometry, CNC primitives; [material-overlap diagnostics](docs/geometry/visual-overlap-check.md) |
|
||||
| **OpenNest.Engine** | Nesting algorithms and whole-job contracts |
|
||||
| **OpenNest.IO** | DXF/DWG, `.nest`, G-code, BOM I/O; CAD import |
|
||||
| **OpenNest.Console** | Headless batch nesting |
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Material-overlap polygon diagnostics
|
||||
|
||||
`OpenNest.Diagnostics.PlateOverlapAnalyzer` in OpenNest.Core checks a group of placed
|
||||
parts and returns the shared polygon areas for each overlapping pair. This is the
|
||||
cross-platform analysis foundation for a future PlateView overlay; it does not add
|
||||
a menu command, painting, centroids, or freshness management yet. Existing
|
||||
`Part.Intersects`, `PartOverlapChecker`, `Plate.HasOverlappingParts`, engine
|
||||
validators, and CLI entry points are unchanged. A separate shared-triangulator fix
|
||||
uses translation-stable winding, correcting missed clockwise outlines/holes far
|
||||
from the origin without changing contact or fragment-area tolerance policies.
|
||||
|
||||
## Synchronous use
|
||||
|
||||
```csharp
|
||||
using OpenNest.Diagnostics;
|
||||
|
||||
var report = PlateOverlapAnalyzer.Analyze(parts, cancellationToken);
|
||||
var areas = report.Pairs.SelectMany(pair => pair.Regions).ToList();
|
||||
|
||||
foreach (var pair in report.Pairs)
|
||||
{
|
||||
// IDs are zero-based positions in the original input list, including skipped cutoffs.
|
||||
Console.WriteLine($"{pair.PartAId} / {pair.PartBId}: {pair.Area}");
|
||||
foreach (var region in pair.Regions)
|
||||
{
|
||||
// region.Vertices: closed, read-only world-coordinate polygon
|
||||
// region.Area: positive shared material area, in model units squared
|
||||
}
|
||||
}
|
||||
|
||||
// An empty list alone does not mean the whole group was checked successfully.
|
||||
if (!report.IsComplete)
|
||||
foreach (var issue in report.Issues)
|
||||
Console.WriteLine($"Uncheckable input/pair {issue.PartAId} / {issue.PartBId}: {issue.Message}");
|
||||
```
|
||||
|
||||
`Pairs` is ordered by `(PartAId, PartBId)`, with `PartAId < PartBId`. Drawing names
|
||||
are captured as labels, not used as identity. Repeated instances and different
|
||||
drawings with the same name remain distinct. Callers should pass each physical
|
||||
instance once; duplicate input entries are distinct positions in the group.
|
||||
`pair.Bounds` returns a fresh world-coordinate bounding box; collections and
|
||||
vertices cannot mutate the snapshot, report, or live geometry.
|
||||
|
||||
## Capture once, analyze off-thread
|
||||
|
||||
```csharp
|
||||
// UI thread, while parts/drawings are stable:
|
||||
var snapshot = PlateOverlapAnalyzer.Capture(parts, cancellationToken);
|
||||
|
||||
// Worker thread, no access to live Part/Drawing/Program objects:
|
||||
var report = await Task.Run(
|
||||
() => PlateOverlapAnalyzer.Analyze(snapshot, cancellationToken),
|
||||
cancellationToken);
|
||||
```
|
||||
|
||||
Capture converts each distinct clean source program by reference identity once
|
||||
into owned entities, including expanded shared hole-subprogram calls, and copies
|
||||
input IDs, names, locations, and baseline-adjusted rotations. This is intentionally
|
||||
not a `Program.Clone` dependency: conversion itself creates fresh geometry without
|
||||
mutating the source or re-aligning shared subprograms. Capture has synchronous
|
||||
conversion cost; callers must not mutate inputs while capture runs.
|
||||
|
||||
Analysis clones captured entities before chaining, prepares polygons once per
|
||||
source, then prepares each pose. An X-sorted bounds sweep prunes separated pairs.
|
||||
It invokes the existing hole-aware `Collision.Check` once per candidate pair,
|
||||
without an earlier boolean collision pass. Pairs are rebased near the origin for
|
||||
clipping/triangulation and restored to world coordinates; area calculation uses
|
||||
translated-origin products to avoid cancellation far from the origin. Snapshots
|
||||
can be reused and analyzed concurrently. Callers own freshness checks and must
|
||||
not publish results after geometry changes or after a newer request supersedes them.
|
||||
|
||||
Cancellation throws `OperationCanceledException`; it never returns a partial
|
||||
all-clear. Checks occur between source/pose preparation, validation loops, and
|
||||
candidate pairs, and before return. An individual conversion, polygonization,
|
||||
triangulation, or `Collision.Check` call is not internally interruptible.
|
||||
|
||||
## Material contract
|
||||
|
||||
- Material comes from `Part.BaseDrawing.Program`, transformed by
|
||||
`part.Rotation - drawing.Program.Rotation`, then `part.Location`. Applied or
|
||||
restored lead-ins, lead-outs, and tabs in `Part.Program` do not redefine material.
|
||||
- Cutoff parts are skipped. Scribe, rapid, lead-in, and lead-out layers in the clean
|
||||
source do not define material; ordinary cut/default/display contours do.
|
||||
- The caller chooses the group. Preview parts are not intrinsically distinguishable
|
||||
from committed parts here; a PlateView caller must supply committed parts only.
|
||||
- Valid material has one simple closed outer contour and strictly internal,
|
||||
mutually disjoint holes. Open, empty, degenerate, self-intersecting, nonfinite,
|
||||
disconnected-outer, touching-hole, intersecting-hole, or nested-island geometry
|
||||
produces an issue. Native contour intersections are checked before polygonal
|
||||
containment, so sampling cannot hide a circular-hole crossing or tangency.
|
||||
A gap above `Tolerance.Epsilon` is not silently welded closed.
|
||||
Open cut marks are conservatively uncheckable; mark them as Scribe instead.
|
||||
- Some valid curved geometry, such as sub-chord-width thin rings whose sampled
|
||||
contours cross, is uncheckable at this fixed tolerance and returns incomplete
|
||||
rather than clear. No automatic healing or adaptive refinement is performed.
|
||||
Poses that collapse edges or materially change area through floating-point
|
||||
rounding are also incomplete, even when all coordinates remain finite.
|
||||
- Expected geometry failures produce issues with original input indices and
|
||||
preserve overlaps found among other valid parts. Unexpected failures propagate.
|
||||
A report with any issue has `IsComplete == false`, even if `Pairs` is empty.
|
||||
- No part, drawing, quantity, pose, cutting state, or selection is modified.
|
||||
|
||||
## Interpretation and limits
|
||||
|
||||
Regions are the kernel's convex, hole-subtracted fragments, not merged connected
|
||||
islands. Fill the fragments for a visual overlay; do not outline triangulation
|
||||
seams as physical boundaries. Areas within one pair may be summed. Areas across
|
||||
pairs are not a union: three coincident parts produce three overlapping pairs,
|
||||
so summing all pair areas double-counts shared plate locations.
|
||||
|
||||
Full containment and coincident parts are detected without relying on crossing
|
||||
points. Edge/corner contact with no positive shared material is not overlap.
|
||||
There are no spacing offsets, plate-edge checks, cut-path crossing checks, automatic
|
||||
repairs, export blocks, or machining-validity guarantees.
|
||||
|
||||
Arc/circle flattening uses a chord tolerance of `0.001` model units (also exposed
|
||||
as `report.ChordTolerance`). Curved overlaps and topology are therefore polygonal
|
||||
approximations. The unchanged collision kernel applies dimensional bounds and
|
||||
fragment-area thresholds using `Tolerance.Epsilon` (`0.00001`); sufficiently small
|
||||
slivers are below its reporting policy. Floating-point coordinates still have
|
||||
finite resolution. Contact and fragment thresholds are unchanged; only the shared
|
||||
triangulator's winding arithmetic was stabilized in the prerequisite fix.
|
||||
|
||||
Next integration/hardening: add PlateView request generations and stale-result
|
||||
invalidation before rendering; measure real-plate capture/analysis cost and
|
||||
cancellation latency before adding cached triangulations or background capture;
|
||||
add area-weighted centroids separately if the UI needs them.
|
||||
|
||||
## Verification
|
||||
|
||||
`OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs` exercises analytical
|
||||
rectangle regions/areas, containment and contact, both operands' holes, concave
|
||||
and disconnected intersections, curves, baseline rotation, large translations,
|
||||
deterministic pair ordering against an exhaustive rectangle oracle, invalid
|
||||
inputs, snapshot isolation, read-only output, cutting-program independence, and
|
||||
cancellation. Run:
|
||||
|
||||
```sh
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter FullyQualifiedName~PlateOverlapAnalyzerTests
|
||||
```
|
||||
Reference in New Issue
Block a user