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) => Capture(parts, new OverlapMaterialCache(), cancellationToken); /// /// As , but reuses converted /// and prepared drawing material from across requests. Clear the /// cache before any in-place clean-program edit (see ). /// public static PlateOverlapSnapshot Capture(IReadOnlyList parts, OverlapMaterialCache cache, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(parts); ArgumentNullException.ThrowIfNull(cache); cancellationToken.ThrowIfCancellationRequested(); var captured = new List(); var issues = new List(); 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."); var source = cache.Get(program, CaptureSource); if (source.Error != null) throw new ArgumentException(source.Error); captured.Add(new CapturedOverlapPart(id, part.BaseDrawing.Name, source, rotation, location)); } catch (Exception exception) when (IsGeometryFailure(exception)) { issues.Add(new PlateOverlapIssue(id, null, exception.Message)); } } cancellationToken.ThrowIfCancellationRequested(); return new PlateOverlapSnapshot(captured, issues); } private static OverlapSource CaptureSource(Program program) { 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. return new OverlapSource(program, 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)) { return new OverlapSource(program, null, exception.Message); } } /// 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) => Analyze(snapshot, null, cancellationToken); /// /// Incremental recheck: identical to a full analysis of , but a /// pair whose two parts are unchanged since (same captured source /// and bit-identical pose, in the same relative order) reuses that report's result instead /// of being clipped again. After moving one part only its own neighbors are recomputed. /// Reuse needs sources shared through one ; otherwise every /// pair is recomputed. Null performs a full analysis. /// public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot, PlateOverlapReport previous, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(snapshot); cancellationToken.ThrowIfCancellationRequested(); var reuse = PairReuse.Create(previous, snapshot); var issues = snapshot.Issues.ToList(); var pairs = new List(); var prepared = new List(); foreach (var part in snapshot.Parts) { cancellationToken.ThrowIfCancellationRequested(); try { // Prepared material is shared by every part and request using this source. var source = part.Source.Prepare(cancellationToken); 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; if (reuse != null && reuse.TryReuse(a.Input, b.Input, pairs, issues)) continue; 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; // Evaluate every hole-subtracted fragment before restoring world space. // A failed moment must make this pair incomplete, never an origin marker. var moments = new List(); var regions = new List(); foreach (var region in result.OverlapRegions) { cancellationToken.ThrowIfCancellationRequested(); if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment)) throw new ArithmeticException("Overlap fragment area moments are invalid."); moments.Add(fragment); regions.Add(new PlateOverlapRegion(region.Vertices.Select(point => point + origin), fragment.Area)); } if (!PolygonAreaMoments.TryCombine(moments, out var combined)) throw new ArithmeticException("Combined overlap area moments are invalid."); var centroid = combined.Centroid + origin; if (!OverlapMaterial.IsFinite(centroid)) throw new ArithmeticException("Overlap centroid is not finite in world coordinates."); pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id, a.Input.Name, b.Input.Name, regions, centroid)); } 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(), snapshot); } /// /// Maps unchanged parts to their previous input positions and looks up previous pair results. /// A part is unchanged when its captured source object and exact pose bits match; identical /// duplicates are matched in input order, which is safe because their inputs are bit-identical. /// private sealed class PairReuse { private readonly Dictionary previousIds; private readonly Dictionary<(int, int), PlateOverlapPair> pairs = new(); private readonly Dictionary<(int, int), PlateOverlapIssue> issues = new(); private PairReuse(Dictionary previousIds, PlateOverlapReport previous) { this.previousIds = previousIds; foreach (var pair in previous.Pairs) pairs[(pair.PartAId, pair.PartBId)] = pair; foreach (var issue in previous.Issues) if (issue.PartBId.HasValue) issues[(issue.PartAId, issue.PartBId.Value)] = issue; } public static PairReuse Create(PlateOverlapReport previous, PlateOverlapSnapshot snapshot) { if (previous?.Snapshot == null) return null; var available = new Dictionary>(); foreach (var part in previous.Snapshot.Parts) { var key = PoseKey.Of(part); if (!available.TryGetValue(key, out var ids)) available.Add(key, ids = new Queue()); ids.Enqueue(part.Id); } var previousIds = new Dictionary(); foreach (var part in snapshot.Parts) if (available.TryGetValue(PoseKey.Of(part), out var ids) && ids.Count > 0) previousIds.Add(part.Id, ids.Dequeue()); return previousIds.Count < 2 ? null : new PairReuse(previousIds, previous); } /// /// Every bounding-box candidate pair among prepared parts was evaluated by the previous /// analysis, and unchanged parts have identical bounds, so absence there means clear. /// The previous pair must have had the same operand order: clipping is order-sensitive. /// public bool TryReuse(CapturedOverlapPart a, CapturedOverlapPart b, List pairOutput, List issueOutput) { if (!previousIds.TryGetValue(a.Id, out var oldA) || !previousIds.TryGetValue(b.Id, out var oldB) || oldA >= oldB) return false; if (pairs.TryGetValue((oldA, oldB), out var pair)) pairOutput.Add(pair.Renumber(a.Id, b.Id, a.Name, b.Name)); else if (issues.TryGetValue((oldA, oldB), out var issue)) issueOutput.Add(issue with { PartAId = a.Id, PartBId = b.Id }); return true; } } private readonly record struct PoseKey(OverlapSource Source, long Rotation, long X, long Y) { public static PoseKey Of(CapturedOverlapPart part) => new(part.Source, BitConverter.DoubleToInt64Bits(part.Rotation), BitConverter.DoubleToInt64Bits(part.Location.X), BitConverter.DoubleToInt64Bits(part.Location.Y)); } internal 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 PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material); }