#nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Threading; using Clipper2Lib; using OpenNest.CNC.CuttingPlanning; using OpenNest.Geometry; namespace OpenNest.Engine.CuttingPlanning; /// Where a perimeter can be pierced in open scrap, in plate coordinates. /// Points that keep the zone from every captured material, nearest the targets first. /// Set when no scrap near the part keeps the zone: the point with the most clearance. /// The best-effort point's exact distance to the nearest part material. internal sealed record ScrapPierces(IReadOnlyList Pierces, Vector? BestEffort, double Clearance) { internal static ScrapPierces None { get; } = new(Array.Empty(), null, 0); } /// /// The pierce-zone fallback for perimeters. When no configured lead-in of a perimeter keeps /// its pierce clear, the planner looks for a pierce in the open scrap around the part: the /// window zone radii around the part, minus every part's clean /// material grown by the zone radius plus a margin (, conservative /// flattening). Each target (a ranked entry of the perimeter) gets the clear point nearest it. /// When nothing is clear, the pole of inaccessibility of the window, with the parts as holes, /// is the best effort. These are proposals only: the planner emits a straight lead from each /// to its nearest perimeter point and the lead validator stays the authority on it. /// One instance serves one planning walk and remembers each part's scrap; the materials must not /// change during it. /// internal sealed class ScrapPierceSearch { /// The window reaches this many zone radii beyond the part's extent. internal const double WindowMargin = 4; private const int PoleSearchCells = 4096; private readonly LeadMaterialSnapshot[] materials; private readonly double radius; private readonly double tolerance; private readonly Region?[] regions; private readonly Dictionary scrap = new(ReferenceEqualityComparer.Instance); internal ScrapPierceSearch(IReadOnlyList materials, double radius) { if (!(radius > 0) || !double.IsFinite(radius)) throw new ArgumentOutOfRangeException(nameof(radius)); this.materials = materials.ToArray(); this.radius = radius; // Flattening and offset chords stay within a hundredth of the zone (Clipper keeps 1e-4). tolerance = System.Math.Max(radius / 100, System.Math.Pow(10, 1 - ClipperBridge.Precision)); regions = new Region?[this.materials.Length]; } /// /// Up to distinct clear pierces for 's /// perimeter, the one nearest each of in turn, or a best effort /// when the window has none. Nothing when a material is incomplete. /// internal ScrapPierces Find(LeadMaterialSnapshot own, IReadOnlyList targets, int max, CancellationToken token) { token.ThrowIfCancellationRequested(); if (!own.IsComplete || materials.Any(m => !m.IsComplete) || targets.Count == 0 || max <= 0) return ScrapPierces.None; if (!scrap.TryGetValue(own, out var around)) scrap[own] = around = Around(own, token); if (around.Clear.Count == 0) return around.BestEffort; var pierces = new List(); foreach (var target in targets) { token.ThrowIfCancellationRequested(); var pierce = Nearest(around.Clear, target); if (pierces.All(p => p.DistanceTo(pierce) > tolerance)) pierces.Add(pierce); if (pierces.Count == max) break; } return new(pierces, null, 0); } // The clear scrap around one part, or its best effort when there is none. private Scrap Around(LeadMaterialSnapshot own, CancellationToken token) { var ownRegion = Outline(own, includeHoles: false); var margin = WindowMargin * radius; var window = new Rect(ownRegion.Bounds.MinX - margin, ownRegion.Bounds.MinY - margin, ownRegion.Bounds.MaxX + margin, ownRegion.Bounds.MaxY + margin); var nearby = new PathsD(ownRegion.Paths); for (var i = 0; i < materials.Length; i++) { token.ThrowIfCancellationRequested(); if (ReferenceEquals(materials[i], own)) continue; var region = regions[i] ??= Outline(materials[i], includeHoles: true); if (region.Bounds.Overlaps(window)) nearby.AddRange(region.Paths); } var windowPath = new PathsD { Clipper.MakePath(new[] { window.MinX, window.MinY, window.MaxX, window.MinY, window.MaxX, window.MaxY, window.MinX, window.MaxY }) }; // Clear pierce points: the window outside every material grown by the zone. The extra // tolerance covers the rounding of the difference itself; the validator checks exactly. var blocked = ClipperBridge.Offset(nearby, radius + tolerance, tolerance, circumscribe: true); var clip = new PathsD(blocked.Outers.Select(p => ClipperBridge.ToPath(p, positive: true)) .Concat(blocked.Holes.Select(p => ClipperBridge.ToPath(p, positive: false)))); var clear = Clipper.Difference(windowPath, clip, FillRule.NonZero, ClipperBridge.Precision) .Where(p => p.Count >= 3).ToList(); token.ThrowIfCancellationRequested(); if (clear.Count != 0) return new(clear, ScrapPierces.None); // No clear scrap: the pole of the scrap in the window (the window with the parts as // holes) that keeps the most clearance from part material. var free = Clipper.Difference(windowPath, nearby, FillRule.NonZero, ClipperBridge.Precision) .Where(p => p.Count >= 3).ToList(); var holes = free.Where(p => !Clipper.IsPositive(p)).Select(ClipperBridge.ToPolygon).ToList(); Vector? best = null; var bestClearance = double.NegativeInfinity; foreach (var path in free.Where(Clipper.IsPositive)) { token.ThrowIfCancellationRequested(); var outer = ClipperBridge.ToPolygon(path); var inside = holes.Where(h => outer.ContainsPoint(h.Vertices[0])).ToList(); var pole = PolyLabel.Find(outer, inside, tolerance, PoleSearchCells); if (!double.IsFinite(pole.X) || !double.IsFinite(pole.Y) || !outer.ContainsPoint(pole) || inside.Any(h => h.ContainsPoint(pole)) || materials.Any(m => m.ContainsMaterial(pole, token))) continue; var budget = int.MaxValue; var clearance = materials.Min(m => LeadPathValidator.MaterialDistance(pole, m, ref budget, token)); if (clearance > bestClearance) { best = pole; bestClearance = clearance; } } return new(clear, best == null ? ScrapPierces.None : new(Array.Empty(), best, bestClearance)); } // The closest point of the clear region's boundary; targets lie on material, outside it. private static Vector Nearest(List paths, Vector target) { var best = default(Vector); var bestDistance = double.PositiveInfinity; foreach (var path in paths) for (var i = 0; i < path.Count; i++) { var a = new Vector(path[i].x, path[i].y); var b = new Vector(path[(i + 1) % path.Count].x, path[(i + 1) % path.Count].y); var point = new Line(a, b).ClosestPointTo(target); var distance = point.DistanceTo(target); if (distance < bestDistance) { bestDistance = distance; best = point; } } return best; } // Outlines flattened as conservatively as ClipperBridge.ToRegion: the perimeter never inside // its curves, holes never outside theirs. A part's own holes are not scrap for its perimeter. private Region Outline(LeadMaterialSnapshot material, bool includeHoles) { var paths = new PathsD(); double minX = double.PositiveInfinity, minY = double.PositiveInfinity, maxX = double.NegativeInfinity, maxY = double.NegativeInfinity; for (var ring = 0; ring < (includeHoles ? material.Rings.Count : 1); ring++) { var shape = new Shape(); shape.Entities.AddRange(material.Rings[ring].Select(c => c.ToEntity())); var polygon = ClipperBridge.Flatten(shape, tolerance, circumscribe: ring == 0); paths.Add(ClipperBridge.ToPath(polygon, positive: ring == 0)); if (ring != 0) continue; foreach (var vertex in polygon.Vertices) { minX = System.Math.Min(minX, vertex.X); minY = System.Math.Min(minY, vertex.Y); maxX = System.Math.Max(maxX, vertex.X); maxY = System.Math.Max(maxY, vertex.Y); } } return new(paths, new(minX, minY, maxX, maxY)); } private sealed record Region(PathsD Paths, Rect Bounds); private sealed record Scrap(List Clear, ScrapPierces BestEffort); private readonly record struct Rect(double MinX, double MinY, double MaxX, double MaxY) { internal bool Overlaps(Rect other) => MinX <= other.MaxX && other.MinX <= MaxX && MinY <= other.MaxY && other.MinY <= MaxY; } }