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