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
+ );
+ }
+ }
+ }
+}