diff --git a/OpenNest.Core/Geometry/Clearance.cs b/OpenNest.Core/Geometry/Clearance.cs index 0e8afba..ca90a4f 100644 --- a/OpenNest.Core/Geometry/Clearance.cs +++ b/OpenNest.Core/Geometry/Clearance.cs @@ -62,6 +62,24 @@ namespace OpenNest.Geometry return Separation(linesA, linesB); } + /// + /// Non-negative minimum boundary (edge-to-edge) distance between two rings + /// and the direction that translates away from + /// at the closest contact. Never tests overlap: a ring + /// contained in another (a part inside a cutout ring) still reports its true + /// gap. For signed material clearance use . + /// + public static ClearanceResult BoundaryDistance(Polygon a, Polygon b) + { + var linesA = a.ToLines(); + var linesB = b.ToLines(); + + if (linesA.Count == 0 || linesB.Count == 0) + return new ClearanceResult(0, new Vector(1, 0)); + + return Separation(linesA, linesB); + } + /// /// Minimum boundary distance between two non-overlapping rings and the /// direction that translates away from diff --git a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs new file mode 100644 index 0000000..b396d2e --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Fill; + +/// +/// PlateView spacing expander: grows part-to-part spacing with the work area +/// and non-selected parts as hard boundaries. +/// +public class ExpanderTests +{ + private static Program Rectangle(double width = 4, double length = 4) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(width, 0); + program.LineTo(width, length); + program.LineTo(0, length); + program.LineTo(0, 0); + return program; + } + + private static Part AddSquare(Plate plate, double x, double y, double size = 4) + { + var part = new Part(new Drawing($"sq{plate.Parts.Count}", Rectangle(size, size)), new Vector(x, y)); + plate.Parts.Add(part); + return part; + } + + private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5) + { + var plate = new Plate(new Size(widthY, lengthX)); + plate.EdgeSpacing = new Spacing(edge, edge); + return plate; + } + + /// Independent clearance oracle: naive vertex/segment min distance over raw part lines. + private static double BruteClearance(Part a, Part b) + { + var linesA = PartGeometry.GetPartLines(a); + var linesB = PartGeometry.GetPartLines(b); + + double min = double.MaxValue; + foreach (var la in linesA) + foreach (var lb in linesB) + { + min = System.Math.Min(min, PointSegment(a, la.StartPoint, lb)); + min = System.Math.Min(min, PointSegment(a, la.EndPoint, lb)); + min = System.Math.Min(min, PointSegment(b, lb.StartPoint, la)); + min = System.Math.Min(min, PointSegment(b, lb.EndPoint, la)); + } + return min; + } + + private static double PointSegment(Part owner, Vector pt, Line seg) + { + var d = seg.EndPoint - seg.StartPoint; + var len2 = d.DotProduct(d); + var t = len2 <= 1e-12 ? 0 : System.Math.Clamp((pt - seg.StartPoint).DotProduct(d) / len2, 0, 1); + return pt.DistanceTo(seg.StartPoint + d * t); + } + + private static void AssertNoOverlaps(Plate plate) + { + for (var i = 0; i < plate.Parts.Count; i++) + for (var j = i + 1; j < plate.Parts.Count; j++) + Assert.False( + plate.Parts[i].Intersects(plate.Parts[j], out _), + $"{plate.Parts[i].BaseDrawing.Name} overlaps {plate.Parts[j].BaseDrawing.Name}" + ); + } + + [Fact] + public void Expand_TwoSquares_GrowUntilEdgeFloor_AndAnchorStaysPut() + { + var plate = MakePlate(24, 24); + var a = AddSquare(plate, 6, 10); + var b = AddSquare(plate, 14, 10); + + var result = Expander.Expand(new List { a, b }, plate); + + // Max gap: B flush against the right edge floor (23.5): 23.5 - 14 - 4 + gap base... + // A stays (anchor); B slides to x=19.5 -> gap 9.5. + Assert.Equal(6, a.Location.X, 6); + Assert.Equal(10, a.Location.Y, 6); + Assert.Equal(9.5, b.Location.X - (a.Location.X + 4), 1); + Assert.True(result.AchievedSpacing >= 9.4, $"achieved {result.AchievedSpacing}"); + Assert.True(result.AchievedSpacing <= 9.6, $"achieved {result.AchievedSpacing}"); + AssertNoOverlaps(plate); + Assert.True(b.BoundingBox.Right <= 23.5 + 1e-6); + } + + [Fact] + public void Expand_SandwichedBetweenWalls_ConvergesOnlyToInitialGaps_AndKeepsWalls() + { + var plate = MakePlate(26, 10, edge: 0.0); + var wallL = AddSquare(plate, 0, 3); + var wallR = AddSquare(plate, 18, 3); + var a = AddSquare(plate, 6, 3); + var b = AddSquare(plate, 12, 3); + + var result = Expander.Expand(new List { a, b }, plate); + + // Every gap starts at exactly 2.0; straight separation moves cannot open + // the row (opening one gap costs another), so the run stays at ~2.0. + Assert.Equal(0, wallL.Location.X, 6); + Assert.Equal(18, wallR.Location.X, 6); + Assert.True( + result.AchievedSpacing >= 1.9 && result.AchievedSpacing <= 2.05, + $"achieved {result.AchievedSpacing}" + ); + AssertNoOverlaps(plate); + } + + [Fact] + public void Expand_OverlappingPair_SeparatesAndClearsOverlap() + { + var plate = MakePlate(30, 12); + var a = AddSquare(plate, 5, 4); + var b = AddSquare(plate, 7, 4); // 2.0 overlap in X + + var result = Expander.Expand(new List { a, b }, plate, new Expander.Options + { + InitialStep = 0.5, + MaxSpacing = 3, + }); + + Assert.False(a.Intersects(b, out _)); + var gap = b.Location.X - (a.Location.X + 4); + Assert.True(gap >= 2.99, $"gap {gap}"); + Assert.True(result.AchievedSpacing >= 2.9); + AssertNoOverlaps(plate); + } + + [Fact] + public void Separate_PinnedPart_ReportsViolationsWithoutOverlap() + { + var plate = MakePlate(20, 20, edge: 0.0); + var pinned = AddSquare(plate, 8, 8); + // Walls 0.2 clear on all four sides. + var left = AddSquare(plate, 3.8, 8); + var right = AddSquare(plate, 12.2, 8); + var bottom = AddSquare(plate, 8, 3.8); + var top = AddSquare(plate, 8, 12.2); + + var (converged, positions, violations) = Expander.Separate( + new List { pinned }, + plate, + spacing: 1.0 + ); + + Assert.False(converged); + Assert.NotEmpty(violations); + // The pinned part may slide into the walls but never through them. + AssertNoOverlaps(plate); + Assert.Equal(3.8, left.Location.X, 6); + Assert.Equal(12.2, right.Location.X, 6); + Assert.Equal(3.8, bottom.Location.Y, 6); + Assert.Equal(12.2, top.Location.Y, 6); + } + + [Fact] + public void Expand_CancelledBeforeRun_LeavesEverythingInPlace() + { + var plate = MakePlate(24, 24); + var a = AddSquare(plate, 6, 10); + var b = AddSquare(plate, 14, 10); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var result = Expander.Expand( + new List { a, b }, + plate, + token: cts.Token + ); + + Assert.True(result.Cancelled); + Assert.Equal(6, a.Location.X, 6); + Assert.Equal(14, b.Location.X, 6); + } + + [Fact] + public void Expand_ThreeInRow_FirstSelectedNeverMoves_AndOracleConfirmsSpacing() + { + var plate = MakePlate(60, 14); + var a = AddSquare(plate, 5, 5); + var b = AddSquare(plate, 10, 5); + var c = AddSquare(plate, 15, 5); + + var result = Expander.Expand( + new List { a, b, c }, + plate, + new Expander.Options { MaxSpacing = 8 } + ); + + Assert.Equal(5, a.Location.X, 6); // anchor: never the later index of any pair + Assert.True(result.AchievedSpacing >= 7.9); + + // Independent oracle: every pair clears the reported spacing. + var parts = new List { a, b, c }; + for (var i = 0; i < parts.Count; i++) + for (var j = i + 1; j < parts.Count; j++) + { + var clearance = BruteClearance(parts[i], parts[j]); + Assert.True( + clearance >= result.AchievedSpacing - 0.01, + $"{parts[i].BaseDrawing.Name}/{parts[j].BaseDrawing.Name}: oracle {clearance} < reported {result.AchievedSpacing}" + ); + } + AssertNoOverlaps(plate); + } + + private static Program RectangleWithHole( + double width, + double length, + double hx, + double hy, + double hw, + double hh + ) + { + var program = Rectangle(width, length); + program.MoveTo(hx, hy); + program.LineTo(hx + hw, hy); + program.LineTo(hx + hw, hy + hh); + program.LineTo(hx, hy + hh); + program.LineTo(hx, hy); + return program; + } + + /// + /// Hole-subtracting overlap check matching NestValidator semantics (a part in + /// a cutout is legal). Part.Intersects is perimeter-only, so it cannot + /// certify part-in-cutout layouts. + /// + private static bool MateriallyOverlaps(Part a, Part b) + { + var (outerA, holesA) = Rings(a); + var (outerB, holesB) = Rings(b); + return Collision.HasOverlap(outerA, outerB, holesA, holesB); + } + + private static (Polygon Outer, List Holes) Rings(Part part) + { + var entities = OpenNest.Converters.ConvertProgram + .ToGeometry(part.Program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + var profile = new ShapeProfile(entities); + + var outer = profile.Perimeter.ToPolygonWithTolerance(0.001); + outer.Offset(part.Location); + + var holes = new List(); + foreach (var cutout in profile.Cutouts) + { + var hole = cutout.ToPolygonWithTolerance(0.001); + hole.Offset(part.Location); + holes.Add(hole); + } + + return (outer, holes.Count == 0 ? null : holes); + } + + [Fact] + public void Expand_PartInsideCutout_KeepsLegalAndClearsHoleWalls() + { + var plate = MakePlate(40, 24); + + // Wall part with a 10x10 cutout; a small selected part sits inside it. + var wall = new Part( + new Drawing( + "wall", + RectangleWithHole(20, 20, 5, 5, 10, 10) + ), + new Vector(0, 0) + ); + plate.Parts.Add(wall); + + var inside = new Part(new Drawing("inside", Rectangle(2, 2)), new Vector(9, 9)); + var other = new Part(new Drawing("other", Rectangle(2, 2)), new Vector(30, 9)); + plate.Parts.Add(inside); + plate.Parts.Add(other); + + var result = Expander.Expand( + new List { inside, other }, + plate, + new Expander.Options { MaxSpacing = 2 } + ); + + // Part-in-cutout is legal, never a material overlap. + Assert.False(MateriallyOverlaps(inside, wall)); + Assert.False(MateriallyOverlaps(other, wall)); + Assert.False(MateriallyOverlaps(inside, other)); + Assert.True(result.AchievedSpacing >= 1.9); + + // The part that started in the cutout must clear the hole walls too. + var holeLeft = 5; + var holeRight = 15; + var gapLeft = inside.Location.X - holeLeft; + var gapRight = holeRight - (inside.Location.X + 2); + var gapBottom = inside.Location.Y - holeLeft; + var gapTop = holeRight - (inside.Location.Y + 2); + var minGap = System.Math.Min( + System.Math.Min(gapLeft, gapRight), + System.Math.Min(gapBottom, gapTop) + ); + Assert.True(minGap >= 1.9, $"closest hole-wall gap {minGap}"); + } + + [Fact] + public void Expand_DegenerateInputs_Throw() + { + var plate = MakePlate(10, 10); + var a = AddSquare(plate, 1, 1); + var stranger = new Part(new Drawing("stranger", Rectangle()), new Vector(50, 50)); + + Assert.Throws(() => Expander.Expand(new List(), plate)); + Assert.Throws(() => Expander.Expand(new List { a }, plate)); + Assert.Throws(() => Expander.Expand(new List { a, stranger }, plate)); + Assert.Throws(() => Expander.Expand(new List { a, a }, null)); + } + + [Fact] + public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance() + { + // Selection must not be pushed to open gaps between parts it excludes. + var plate = MakePlate(40, 12); + var w1 = AddSquare(plate, 2, 4); + var w2 = AddSquare(plate, 6.5, 4); // 0.5 apart from w1, both unselected + var a = AddSquare(plate, 14, 4); + var b = AddSquare(plate, 20, 4); + + Expander.Expand(new List { a, b }, plate); + + Assert.Equal(2, w1.Location.X, 6); + Assert.Equal(6.5, w2.Location.X, 6); + Assert.True(a.Intersects(w1, out _) == false); + Assert.True(b.Intersects(w2, out _) == false); + } +} diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs new file mode 100644 index 0000000..d378658 --- /dev/null +++ b/OpenNest.Engine/Fill/Expander.cs @@ -0,0 +1,771 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Fill +{ + /// + /// Pushes a group of selected parts apart until every constrained pair + /// (selected↔selected and selected↔obstacle) reaches a target part-to-part + /// spacing, with the plate work area and all non-selected parts as hard + /// boundaries. The inverse of : it grows gaps instead + /// of closing them. + /// + /// Input with overlaps is accepted: an overlapping pair is a pair with + /// negative clearance and is separated along the minimum-translation + /// direction. Moves are straight-line only — a pair that could separate only + /// by routing around a blocker is reported as a violation instead. Final + /// positions are always overlap-free; nothing moves off the work area. + /// + /// + /// Anchor policy: within a violated pair the later-indexed selected part + /// moves; the counterpart moves only as a fallback when the anchor mover is + /// fully blocked by a work-area edge. Walls (non-selected parts) never move. + /// + /// + public static class Expander + { + public sealed class Options + { + /// First spacing probed by the search; also the floor for the doubling step. + public double InitialStep = 1.0; + + /// Bisection stops once the achievable spacing is known within this tolerance. + public double Tolerance = 0.01; + + /// Relaxation iteration cap per separation run. + public int MaxIterations = 100; + + /// Upper bound for the spacing search. 0 = auto (work-area diagonal). + public double MaxSpacing = 0; + } + + public sealed class Violation + { + public Part A; + public Part B; + + /// Clearance actually reached (may be negative for unresolvable overlaps). + public double Achieved; + + /// True when a work-area edge, not a part, blocked the last needed move. + public bool BlockedByEdge; + } + + public sealed class Result + { + /// + /// Minimum part-to-part clearance the returned layout satisfies, + /// never below zero. 0 with violations means the layout was only + /// cleaned up as far as possible, not opened up. + /// + public double AchievedSpacing; + + /// True when the run was cancelled; no positions were changed. + public bool Cancelled; + + /// Pairs that could not reach . + public List Violations = new(); + } + + /// + /// Raises the part-to-part spacing of the selection as far as the plate + /// and its other parts allow, applying the best spacing found. Plate + /// PartSpacing/EdgeSpacing are not modified; edges keep their own + /// EdgeSpacing floor while only part-to-part clearance chases the target. + /// Mutates locations; probing never touches + /// them, so a failed or cancelled run leaves the layout unchanged. + /// + public static Result Expand( + List selected, + Plate plate, + Options options = null, + CancellationToken token = default + ) + { + if (plate == null) + throw new ArgumentNullException(nameof(plate)); + if (selected == null || selected.Count < 2) + throw new ArgumentException( + "Expand requires at least two selected parts.", + nameof(selected) + ); + if (selected.Any(p => !plate.Parts.Contains(p))) + throw new ArgumentException( + "All selected parts must belong to the plate.", + nameof(selected) + ); + + var opt = options ?? new Options(); + var context = SeparationContext.Prepare(selected, plate); + var entry = context.Positions(); // all parts; movers are [0, count) + var result = new Result(); + + var sMin = MinimumPairClearance(context, entry); + result.AchievedSpacing = System.Math.Max(0, sMin); + + if (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } + + var sGood = sMin; + var cap = opt.MaxSpacing > 0 ? opt.MaxSpacing : context.SpacingCap; + double hi; // first spacing that failed; double.NaN = none yet + + (bool Converged, List Positions) Probe(double spacing) + { + var attempt = Separate(context, entry, spacing, opt.MaxIterations, token); + return (attempt.Converged, attempt.Positions); + } + + // Doubling phase: commit every spacing that converges. The cap gets + // its own probe even when the step jumps past it, and no spacing is + // probed twice. + var s = System.Math.Max(opt.InitialStep, 2 * System.Math.Max(0, sGood)); + hi = double.NaN; + + while (true) + { + if (s > cap) + { + if (cap > sGood + opt.Tolerance) + s = cap; + else + break; + } + + if (s <= sGood + opt.Tolerance) + break; + + if (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } + + var probe = Probe(s); + + if (probe.Converged) + { + sGood = s; + if (sGood >= cap) + break; + s = System.Math.Max(s * 2, sGood + opt.Tolerance); + } + else + { + hi = s; + break; + } + } + + // Bisection between the last spacing that converged and the first that failed. + if (!double.IsNaN(hi)) + { + var lo = System.Math.Max(0, sGood); + while (hi - lo > opt.Tolerance) + { + if (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } + + var mid = (lo + hi) / 2; + if (Probe(mid).Converged) + lo = mid; + else + hi = mid; + } + sGood = lo; + } + + // Finalize: re-separate at the applied spacing from the entry state. + // Deterministic, so this reproduces any committed probe exactly; when + // sGood was never probed (overlapping entry) it still performs the + // best-effort cleanup and yields the violation report. + var final = Separate(context, entry, System.Math.Max(0, sGood), opt.MaxIterations, token); + + if (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } + + var positions = final.Positions; + + Apply(selected, entry, positions); + + var measured = MinimumPairClearance(context, positions); + result.AchievedSpacing = System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured)); + result.Violations = final.Violations; + return result; + } + + /// + /// Relaxes the given parts apart to a fixed target spacing against the + /// plate. Works on scratch positions; the caller applies them. Exposed + /// for testing and for callers that manage their own spacing search. + /// + public static (bool Converged, List Positions, List Violations) Separate( + List selected, + Plate plate, + double spacing, + int maxIterations = 100, + CancellationToken token = default + ) + { + var context = SeparationContext.Prepare(selected, plate); + var positions = context.Positions(); + return Separate(context, positions, spacing, maxIterations, token); + } + + private static void Apply(List selected, List from, List to) + { + // Only movers occupy [0, selected.Count); walls never move. + for (var i = 0; i < selected.Count; i++) + { + var delta = to[i] - from[i]; + if (delta.X != 0 || delta.Y != 0) + selected[i].Offset(delta); + } + } + + private static double MinimumPairClearance(SeparationContext context, List positions) + { + var min = double.MaxValue; + + foreach (var pair in context.Pairs) + { + var (distance, _) = context.PairClearance(pair.IndexA, pair.IndexB, positions); + if (distance < min) + min = distance; + } + + return min == double.MaxValue ? 0 : min; + } + + private static (bool Converged, List Positions, List Violations) Separate( + SeparationContext context, + List start, + double spacing, + int maxIterations, + CancellationToken token + ) + { + var positions = new List(start); + var stuck = new HashSet(); + var violations = new List(); + var epsMove = 1e-4; + + // Internal margin absorbs the clearance kernel's tessellation error so + // the applied spacing holds against the production validators. + var target = spacing + 0.002; + + var iterationLimit = maxIterations < 1 ? 1 : maxIterations; + + for (var iteration = 0; iteration < iterationLimit; iteration++) + { + if (token.IsCancellationRequested) + return (false, start, violations); + + var moved = 0.0; + var stuckChanged = false; + + foreach (var pair in context.Pairs) + { + if (stuck.Contains(pair.Id)) + continue; + + var (distance, directionA) = context.PairClearance( + pair.IndexA, + pair.IndexB, + positions + ); + + // Trigger at the user spacing, not the internal margin: a + // pair already at the requested spacing must not be nudged, + // or feasible layouts at the ceiling (every pair exactly at + // spacing) would oscillate forever. The margin only sets how + // far past the trigger a push carries, absorbing tessellation + // error in the measurement. + if (distance >= spacing) + continue; + + var need = target - distance; + + // Anchor policy (decided): only the later-indexed selected + // part of a violated pair moves. Counterparts and walls + // never do — a pair whose anchor mover cannot reach the + // target is a violation, not an invitation to drift the + // anchor. + var moverIndex = pair.Mover; + + // Clearance direction translates A away from B; a mover on + // the B side travels the opposite way. + var direction = moverIndex == pair.IndexA ? directionA : -directionA; + + var room = context.ClipToWorkArea( + moverIndex, + positions[moverIndex], + direction, + need + ); + + // Take the largest valid step up to `room`: partial moves let + // a blocked mover advance again once its own blockers move + // away in later iterations (a wave separates a chain). + var applied = 0.0; + var blockedByEdge = false; + + if (room > 0) + { + var trial = positions[moverIndex] + direction * room; + + if (context.MaintainsValidity(moverIndex, trial, positions)) + { + positions[moverIndex] = trial; + applied = room; + } + else + { + var lo = 0.0; + var hi2 = room; + for (var bisect = 0; bisect < 24 && hi2 - lo > 1e-6; bisect++) + { + var mid = (lo + hi2) / 2; + if ( + context.MaintainsValidity( + moverIndex, + positions[moverIndex] + direction * mid, + positions + ) + ) + lo = mid; + else + hi2 = mid; + } + + if (lo > epsMove) + { + positions[moverIndex] = positions[moverIndex] + direction * lo; + applied = lo; + } + } + + blockedByEdge = applied < need - epsMove; + moved += applied; + } + else + { + blockedByEdge = true; + } + + // A pair fully separated to the user target (the internal margin + // absorbs tessellation slack) is satisfied even if not to target. + if (applied > 0) + { + var (finalDistance, _) = context.PairClearance( + pair.IndexA, + pair.IndexB, + positions + ); + if (finalDistance >= spacing) + continue; + // Partial progress: keep the pair live — its blockers may + // move away in later iterations and unblock the rest. + continue; + } + + // Zero progress twice in a row parks the pair; the final sweep + // re-measures everything, so mid-loop bookkeeping never lies. + if (stuck.Contains(pair.Id)) + continue; + + stuck.Add(pair.Id); + stuckChanged = true; + } + + if (moved < epsMove && !stuckChanged) + break; + } + + // Honest verdict: stuck bookkeeping and the internal margin can both + // let a pair read as satisfied mid-loop while a later pair move + // un-does it (oscillation). Re-measure every constrained pair at the + // final positions once; the violations this sweep finds are the + // report, and any violation makes the run non-converged. + violations.Clear(); + foreach (var pair in context.Pairs) + { + var (distance, _) = context.PairClearance( + pair.IndexA, + pair.IndexB, + positions + ); + + if (distance >= spacing) + continue; + + violations.Add( + new Violation + { + A = context.PartOf(pair.IndexA), + B = context.PartOf(pair.IndexB), + Achieved = distance, + BlockedByEdge = stuck.Contains(pair.Id), + } + ); + } + + return (violations.Count == 0, positions, violations); + } + + /// One constrained part↔part pair with its anchor mover. + private sealed class Pair + { + public int Id; + public int IndexA; + public int IndexB; + public int Mover; + } + + /// + /// Per-run prepared geometry. Rings are local-frame polygons (world = local + /// + scratch position), prepared once per distinct Program by reference, + /// mirroring 's caching but translatable. + /// + private sealed class SeparationContext + { + private readonly List parts; // movers [0, moverCount) then walls + private readonly List shapes; // per part + private readonly Box[] localBoxes; // per part, local frame + private readonly int moverCount; + private readonly Box workArea; + + public readonly List Pairs = new(); + public readonly double SpacingCap; + + private sealed class RingSet + { + public Polygon Outer; + public List Rings = new(); // outer + cutout rings, local frame + public List Holes = new(); + } + + private SeparationContext( + List parts, + List shapes, + Box[] localBoxes, + int moverCount, + Box workArea, + double spacingCap + ) + { + this.parts = parts; + this.shapes = shapes; + this.localBoxes = localBoxes; + this.moverCount = moverCount; + this.workArea = workArea; + SpacingCap = spacingCap; + } + + public static SeparationContext Prepare(List selected, Plate plate) + { + var movers = new List(selected); + var walls = plate.Parts.Where(p => !movers.Contains(p)).ToList(); + + var parts = new List(movers.Count + walls.Count); + parts.AddRange(movers); + parts.AddRange(walls); + + var programs = new Dictionary( + ReferenceEqualityComparer.Instance + ); + var shapes = new List(parts.Count); + var localBoxes = new Box[parts.Count]; + + for (var i = 0; i < parts.Count; i++) + { + shapes.Add(PrepareProgram(programs, parts[i].Program)); + localBoxes[i] = LocalBox(parts[i]); + } + + var workArea = plate.WorkArea(); + var spacingCap = System.Math.Sqrt( + workArea.Length * workArea.Length + workArea.Width * workArea.Width + ); + + var context = new SeparationContext( + parts, + shapes, + localBoxes, + movers.Count, + workArea, + spacingCap + ); + context.BuildPairs(); + return context; + } + + private static RingSet PrepareProgram( + Dictionary programs, + CNC.Program program + ) + { + if (programs.TryGetValue(program, out var existing)) + return existing; + + var prepared = new RingSet(); + var entities = ConvertProgram + .ToGeometry(program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + + if (entities.Count > 0) + { + var profile = new ShapeProfile(entities); + + if (profile.Perimeter != null) + { + prepared.Outer = profile.Perimeter.ToPolygonWithTolerance(0.001); + prepared.Rings.Add(prepared.Outer); + + foreach (var cutout in profile.Cutouts) + { + var hole = cutout.ToPolygonWithTolerance(0.001); + prepared.Rings.Add(hole); + prepared.Holes.Add(hole); + } + } + } + + programs.Add(program, prepared); + return prepared; + } + + private static Box LocalBox(Part part) + { + var box = part.BoundingBox; + return new Box( + box.Left - part.Location.X, + box.Bottom - part.Location.Y, + box.Length, + box.Width + ); + } + + private void BuildPairs() + { + var id = 0; + + for (var a = 0; a < parts.Count; a++) + { + for (var b = a + 1; b < parts.Count; b++) + { + var aMover = a < moverCount; + var bMover = b < moverCount; + + if (!aMover && !bMover) + continue; + + Pairs.Add( + new Pair + { + Id = id++, + IndexA = a, + IndexB = b, + // Anchor policy: the later-index mover moves. + Mover = bMover ? b : a, + } + ); + } + } + } + + public bool IsMover(int index) => index < moverCount; + + public Part PartOf(int index) => parts[index]; + + /// Current world positions of every part in pair-index order. + public List Positions() => parts.Select(p => p.Location).ToList(); + + /// + /// Signed material clearance between two parts at the given scratch + /// positions. Material overlap (Collision oracle with hole subtraction) + /// reports negative penetration through the outer rings; otherwise the + /// clearance is the minimum boundary distance over all ring pairs, so a + /// part inside another's cutout measures its true gap to the hole ring + /// instead of a bogus outer-ring penetration. + /// + public (double Distance, Vector Direction) PairClearance( + int indexA, + int indexB, + List positions + ) + { + var setA = shapes[indexA]; + var setB = shapes[indexB]; + + if (setA.Outer == null || setB.Outer == null) + return (0, new Vector(1, 0)); + + var offsetA = positions[indexA]; + var offsetB = positions[indexB]; + + var outerA = CloneAt(setA.Outer, offsetA); + var outerB = CloneAt(setB.Outer, offsetB); + + var holesA = setA.Holes.Count == 0 ? null : CloneAll(setA.Holes, offsetA); + var holesB = setB.Holes.Count == 0 ? null : CloneAll(setB.Holes, offsetB); + + if (Collision.HasOverlap(outerA, outerB, holesA, holesB)) + { + var penetration = Clearance.Between(outerA, outerB); + if (penetration.Distance < 0) + return (penetration.Distance, penetration.Direction); + // Hole subtraction resolved what the outers overlap: touching. + return (0, penetration.Direction); + } + + double best = double.MaxValue; + var bestDir = new Vector(1, 0); + + foreach (var ringA in setA.Rings) + { + var worldA = CloneAt(ringA, offsetA); + + foreach (var ringB in setB.Rings) + { + var worldB = CloneAt(ringB, offsetB); + var clearance = Clearance.BoundaryDistance(worldA, worldB); + + if (clearance.Distance < best) + { + best = clearance.Distance; + bestDir = clearance.Direction; + } + } + } + + return (best, bestDir); + } + + /// + /// Largest α ≤ need such that translating the part by direction·α keeps + /// its AABB inside the work area. + /// + public double ClipToWorkArea(int index, Vector position, Vector direction, double need) + { + var box = localBoxes[index]; + var left = position.X + box.Left - workArea.Left; + var right = workArea.Right - (position.X + box.Right); + var bottom = position.Y + box.Bottom - workArea.Bottom; + var top = workArea.Top - (position.Y + box.Top); + + var max = need; + if (direction.X > 0) + max = System.Math.Min(max, right / direction.X); + else if (direction.X < 0) + max = System.Math.Min(max, left / -direction.X); + + if (direction.Y > 0) + max = System.Math.Min(max, top / direction.Y); + else if (direction.Y < 0) + max = System.Math.Min(max, bottom / -direction.Y); + + return max < 0 ? 0 : max; + } + + /// + /// True when the part at stays inside the work + /// area and keeps no material overlap with any other part (Collision + /// oracle with hole subtraction, so part-in-cutout stays legal). + /// + public bool MaintainsValidity(int index, Vector trial, List positions) + { + var box = localBoxes[index]; + var movedBox = box.Translate(trial); + + if ( + movedBox.Left < workArea.Left - 1e-9 + || movedBox.Right > workArea.Right + 1e-9 + || movedBox.Bottom < workArea.Bottom - 1e-9 + || movedBox.Top > workArea.Top + 1e-9 + ) + return false; + + var outer = shapes[index].Outer; + if (outer == null) + return true; + + var worldOuter = CloneAt(outer, trial); + var worldHoles = shapes[index].Holes.Count == 0 + ? null + : CloneAll(shapes[index].Holes, trial); + + for (var i = 0; i < parts.Count; i++) + { + if (i == index) + continue; + + var otherOuter = shapes[i].Outer; + if (otherOuter == null) + continue; + + if (!BoxOverlap(movedBox, localBoxes[i].Translate(positions[i]), 0.002)) + continue; + + var worldOther = CloneAt(otherOuter, positions[i]); + var worldOtherHoles = shapes[i].Holes.Count == 0 + ? null + : CloneAll(shapes[i].Holes, positions[i]); + + if ( + Collision.HasOverlap( + worldOuter, + worldOther, + worldHoles, + worldOtherHoles + ) + ) + return false; + } + + return true; + } + + private static List CloneAll(List polygons, Vector offset) + { + var list = new List(polygons.Count); + foreach (var polygon in polygons) + list.Add(CloneAt(polygon, offset)); + return list; + } + + /// Clone with world bounds applied — prepared rings are never mutated. + private static Polygon CloneAt(Polygon polygon, Vector offset) + { + var clone = (Polygon)polygon.Clone(); + clone.UpdateBounds(); + clone.Offset(offset); + return clone; + } + + private static bool BoxOverlap(Box a, Box b, double slack) + { + return !( + a.Right + slack < b.Left + || b.Right + slack < a.Left + || a.Top + slack < b.Bottom + || b.Top + slack < a.Bottom + ); + } + } + } +}