feat(core): report hole-aware part overlap polygons

This commit is contained in:
aj
2026-09-28 20:44:51 -04:00
parent fb32d508f8
commit 19c3a89c32
7 changed files with 1095 additions and 1 deletions
+1
View File
@@ -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));
}
}
+1 -1
View File
@@ -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 |
+140
View File
@@ -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
```