diff --git a/AGENTS.md b/AGENTS.md index c061d81..49c97fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`, `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`. diff --git a/OpenNest.Core/Diagnostics/OverlapMaterial.cs b/OpenNest.Core/Diagnostics/OverlapMaterial.cs new file mode 100644 index 0000000..37f6615 --- /dev/null +++ b/OpenNest.Core/Diagnostics/OverlapMaterial.cs @@ -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; + +/// Request-local, validated single-outer material. Never exposed to report consumers. +internal sealed record OverlapMaterial(Polygon Outer, List Holes) +{ + internal static OverlapMaterial Read(List 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(); + 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); + + /// Translation-stable unsigned shoelace area; accepts an explicit closing vertex. + internal static double Area(IReadOnlyList 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); +} diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs new file mode 100644 index 0000000..24737c9 --- /dev/null +++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs @@ -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; + +/// +/// 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. +/// +public static class PlateOverlapAnalyzer +{ + public const double ChordTolerance = 0.001; + + /// + /// 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. + /// + public static PlateOverlapSnapshot Capture(IReadOnlyList parts, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(parts); + cancellationToken.ThrowIfCancellationRequested(); + var captured = new List(); + var issues = new List(); + var sources = new Dictionary(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(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); + } + + /// Convenience synchronous capture and analysis of a group of parts. + public static PlateOverlapReport Analyze(IReadOnlyList parts, + CancellationToken cancellationToken = default) => + Analyze(Capture(parts, cancellationToken), cancellationToken); + + /// + /// Returns deterministic pair reports containing closed world-coordinate overlap fragments. + /// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear. + /// + public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(snapshot); + cancellationToken.ThrowIfCancellationRequested(); + var issues = snapshot.Issues.ToList(); + var pairs = new List(); + var prepared = new List(); + var sources = new Dictionary, 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 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 Entities, string Error); + private sealed record PreparedSource(OverlapMaterial Material, string Error); + private sealed record PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material); +} diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs new file mode 100644 index 0000000..891f8d7 --- /dev/null +++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Diagnostics; + +/// An owned diagnostic result. An empty Pairs list is clear only if IsComplete is true. +public sealed class PlateOverlapReport +{ + internal PlateOverlapReport(List pairs, List issues) + { + Pairs = pairs.AsReadOnly(); + Issues = issues.AsReadOnly(); + } + + public IReadOnlyList Pairs { get; } + public IReadOnlyList Issues { get; } + public bool IsComplete => Issues.Count == 0; + public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance; +} + +/// Shared material for two input positions, ordered by zero-based input index. +public sealed class PlateOverlapPair +{ + private readonly Box bounds; + + internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName, + List 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; } + /// Convex fragments, not connected islands; no mutable kernel polygons are exposed. + public IReadOnlyList Regions { get; } + public double Area { get; } + /// A fresh world-coordinate bounds copy. + public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width); +} + +/// A positive-area, hole-subtracted convex polygon in world coordinates. +public sealed class PlateOverlapRegion +{ + internal PlateOverlapRegion(IEnumerable vertices, double area) + { + Vertices = System.Array.AsReadOnly(vertices.ToArray()); + Area = area; + } + + /// Read-only vertices with an exactly repeated closing vertex. + public IReadOnlyList Vertices { get; } + public double Area { get; } +} + +/// An input or pair that could not be checked. IDs are zero-based input positions. +public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message); + +/// +/// Owned clean geometry and poses. Capture while inputs are stable, then analyze on a worker. +/// No live Part, Drawing, Program, or subprogram is retained. +/// +public sealed class PlateOverlapSnapshot +{ + internal PlateOverlapSnapshot(List parts, List issues) + { + Parts = parts.AsReadOnly(); + Issues = issues.AsReadOnly(); + } + + internal IReadOnlyList Parts { get; } + internal IReadOnlyList Issues { get; } +} + +internal sealed record CapturedOverlapPart(int Id, string Name, List Entities, + double Rotation, Vector Location); diff --git a/OpenNest.Core/Geometry/ConvexDecomposition.cs b/OpenNest.Core/Geometry/ConvexDecomposition.cs index f2e51a6..6c3e078 100644 --- a/OpenNest.Core/Geometry/ConvexDecomposition.cs +++ b/OpenNest.Core/Geometry/ConvexDecomposition.cs @@ -148,14 +148,12 @@ namespace OpenNest.Geometry /// private static double SignedArea(List verts) { + // World-coordinate products can erase the sign of a small polygon's area + // far from the origin, leaving CW outlines/holes untriangulated. Measure + // relative to a vertex, just as the clipping kernel measures its fragments. var area = 0.0; - - for (var i = 0; i < verts.Count; i++) - { - var j = (i + 1) % verts.Count; - area += verts[i].X * verts[j].Y; - area -= verts[j].X * verts[i].Y; - } + for (var i = 1; i + 1 < verts.Count; i++) + area += Cross(verts[0], verts[i], verts[i + 1]); return area * 0.5; } diff --git a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs new file mode 100644 index 0000000..0cd5a64 --- /dev/null +++ b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs @@ -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(), 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 { 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(() => ((IList)report.Pairs).Clear()); + Assert.Throws(() => ((IList)pair.Regions).Clear()); + var region = pair.Regions[0]; + Assert.Throws(() => ((IList)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()) + 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(() => PlateOverlapAnalyzer.Capture(null!)); + Assert.Throws(() => 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(() => PlateOverlapAnalyzer.Capture(parts, cancellation.Token)); + Assert.Throws(() => 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)); + } +} diff --git a/OpenNest.Tests/Geometry/CollisionTranslationTests.cs b/OpenNest.Tests/Geometry/CollisionTranslationTests.cs new file mode 100644 index 0000000..501ea14 --- /dev/null +++ b/OpenNest.Tests/Geometry/CollisionTranslationTests.cs @@ -0,0 +1,70 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests.Geometry; + +public class CollisionTranslationTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Check_ContainedClockwisePolygonFarFromOriginDoesNotDependOnOperandOrder(bool swap) + { + var outer = Square(0, 0, 1e9); + var small = Square(999999998, 999999998, 1); + small.Reverse(); + + var result = swap ? Collision.Check(small, outer) : Collision.Check(outer, small); + + Assert.True(result.Overlaps); + // Do not use Polygon.Area here: this test isolates triangulation, not that legacy API. + Assert.Equal(1, result.OverlapRegions.Sum(StableArea), 8); + } + + [Fact] + public void Check_ClockwiseHoleFarFromOriginIsSubtracted() + { + var outer = Square(0, 0, 1e9); + var hole = Square(999999998, 999999998, 1); + hole.Reverse(); + var insert = Square(999999998.25, 999999998.25, 0.5); + + var result = Collision.Check(outer, insert, new List { hole }); + + Assert.False(result.Overlaps); + Assert.Empty(result.OverlapRegions); + } + + [Theory] + [InlineData(1000000000)] + [InlineData(-1000000000)] + public void Triangulate_ClockwiseTranslatedUnitSquareHasFullArea(double offset) + { + var polygon = Square(offset, offset, 1); + polygon.Reverse(); + var triangles = ConvexDecomposition.Triangulate(polygon); + Assert.Equal(2, triangles.Count); + Assert.Equal(1, triangles.Sum(StableArea), 8); + } + + private static Polygon Square(double x, double y, double size) + { + var polygon = new Polygon(); + polygon.Vertices.AddRange(new[] { new Vector(x, y), new Vector(x + size, y), + new Vector(x + size, y + size), new Vector(x, y + size), new Vector(x, y) }); + polygon.UpdateBounds(); + return polygon; + } + + private static double StableArea(Polygon polygon) + { + var area = 0.0; + var origin = polygon.Vertices[0]; + for (var i = 1; i + 1 < polygon.Vertices.Count; i++) + { + var a = polygon.Vertices[i] - origin; + var b = polygon.Vertices[i + 1] - origin; + area += a.X * b.Y - a.Y * b.X; + } + return System.Math.Abs(area) / 2; + } +} diff --git a/README.md b/README.md index 02aa9d7..1666e6b 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md new file mode 100644 index 0000000..bbf99e6 --- /dev/null +++ b/docs/geometry/visual-overlap-check.md @@ -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 +```