fix(expand): prune clearance checks and let wide gaps close to the target

Expand Spacing measured every ring pair of every part pair for each trial
move, so a packed layout of parts with holes took minutes per spacing
probe. Skip ring pairs, far part pairs and off-path parts whose bounds
already settle the answer.

Every pair was also held at its full starting gap, so parts could not
move toward anything and most layouts stayed at their entry spacing.
Part pairs may now close to the spacing being tried, never below a
smaller starting gap. Cut-offs and clamps keep their full entry gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-31 20:59:33 -04:00
1 parent 01e2238258
commit 970d2442d5
4 files changed
+207 -24

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
@@ -120,7 +120,7 @@ namespace OpenNest.Geometry
/// <returns></returns>
public List<Line> ToLines()
{
var list = new List<Line>();
var list = new List<Line>(Vertices.Count);
if (Vertices.Count < 2)
return list;
+80 -7
View File
@@ -335,25 +335,56 @@ public class ExpanderTests
}
[Fact]
public void Separate_PreservesEachEntryGap_EvenWhenTargetIsSmaller()
public void Separate_WideEntryGap_ClosesOnlyToTheTarget()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 1, 4, 2);
var mover = AddSquare(plate, 4, 4, 2);
var wall = AddSquare(plate, 12, 4, 2);
var initialWallGap = wall.Location.X - (mover.Location.X + 2);
var (converged, positions, _) = Expander.Separate(
new List<Part> { anchor, mover }, plate, spacing: 3
);
// The wall is six units away at entry. Merely clearing the requested
// three-unit spacing is not permission to consume that original gap.
Assert.True(positions[1].X + 2 <= wall.Location.X - initialWallGap + 1e-6,
$"entry gap {initialWallGap}, proposed gap {wall.Location.X - positions[1].X - 2}");
// The wall is six units away at entry, so the mover may spend the three
// units above the target; it needs only two of them.
Assert.True(converged);
Assert.True(wall.Location.X - (positions[1].X + 2) >= 3);
Assert.Equal(new Vector(4, 4), mover.Location);
Assert.Equal(new Vector(12, 4), wall.Location);
Assert.False(converged); // This direct translation cannot reach 3 safely.
}
[Fact]
public void Separate_EntryGapBelowTheTarget_StaysAFloor()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 1, 4, 2);
var mover = AddSquare(plate, 4, 4, 2);
AddSquare(plate, 7, 4, 2);
var (converged, positions, _) = Expander.Separate(
new List<Part> { anchor, mover }, plate, spacing: 3
);
// One unit from the wall at entry: closing it to help the other pair
// would trade one gap for another.
Assert.False(converged);
Assert.Equal(new Vector(4, 4), positions[1]);
}
[Fact]
public void Expand_MiddlePart_SettlesBetweenItsNeighbors()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 1, 4, 2);
var mover = AddSquare(plate, 4, 4, 2);
var wall = AddSquare(plate, 12, 4, 2);
var result = Expander.Expand(new List<Part> { anchor, mover }, plate);
// Seven units of free travel divide evenly across both sides of the mover.
Assert.True(result.AchievedSpacing >= 3.4, $"achieved {result.AchievedSpacing}");
Assert.Equal(new Vector(12, 4), wall.Location);
}
[Fact]
@@ -668,4 +699,46 @@ public class ExpanderTests
Assert.True(a.Intersects(w1, out _) == false);
Assert.True(b.Intersects(w2, out _) == false);
}
private static Program RectangleWithRoundHoles(double length, double width, int holes)
{
var program = new Program();
for (var i = 1; i <= holes; i++)
{
var center = new Vector(i * length / (holes + 1), width / 2);
var start = new Vector(center.X + 0.5, center.Y);
program.MoveTo(start);
program.ArcTo(start, center, RotationType.CW);
}
program.MoveTo(0, 0);
program.LineTo(length, 0);
program.LineTo(length, width);
program.LineTo(0, width);
program.LineTo(0, 0);
return program;
}
[Fact]
public void Expand_PackedPartsWithManyHoles_FinishesWellBeforeTheBudget()
{
// Every floor check on a packed grid measures all its neighbors. Without
// pruning far pairs and hole rings this takes minutes, not seconds.
var plate = MakePlate(120, 60, edge: 0.25);
var drawing = new Drawing("holes", RectangleWithRoundHoles(26, 6, 12));
var selected = new List<Part>();
for (var row = 0; row < 3; row++)
for (var column = 0; column < 4; column++)
{
var part = new Part(drawing, new Vector(0.25 + column * 26.25, 0.25 + row * 6.25));
plate.Parts.Add(part);
selected.Add(part);
}
using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var result = Expander.Expand(selected, plate, null, budget.Token);
Assert.False(result.Canceled);
Assert.True(result.AchievedSpacing >= 0.25 - 1e-3, $"achieved {result.AchievedSpacing}");
AssertNoOverlaps(plate);
}
}
+124 -14
View File
@@ -260,7 +260,7 @@ namespace OpenNest.Engine.Fill
var positions = final.Positions;
// Floors belong to this public operation, not to any individual probe.
// A failed final check leaves every live part untouched.
if (!context.MeetsEntryFloors(positions, entry))
if (!context.MeetsEntryFloors(positions, entry, System.Math.Max(0, sGood)))
throw new InvalidOperationException("Expand proposal violates an entry clearance floor.");
double measured;
@@ -341,6 +341,8 @@ namespace OpenNest.Engine.Fill
foreach (var pair in context.Pairs)
{
token.ThrowIfCancellationRequested();
if (context.ProvesClearance(pair, positions, min))
continue;
var (distance, _) = context.PairClearance(pair.IndexA, pair.IndexB, positions);
if (distance < min)
min = distance;
@@ -381,6 +383,8 @@ namespace OpenNest.Engine.Fill
{
if (token.IsCancellationRequested)
return (false, start, violations);
if (context.ProvesClearance(pair, positions, spacing))
continue;
var (distance, directionA) = context.PairClearance(
pair.IndexA,
pair.IndexB,
@@ -413,10 +417,10 @@ namespace OpenNest.Engine.Fill
// boundary fallback. Only if the normal candidate makes no
// safe progress, retry its opposite through the same path,
// floor and active-improvement checks. Never move the anchor.
var attempt = ProposeMove(context, positions, pair, distance, direction, need, epsMove);
var attempt = ProposeMove(context, positions, pair, distance, direction, need, spacing, epsMove);
if (attempt.Applied <= 0 && distance <= 1e-7)
{
var opposite = ProposeMove(context, positions, pair, distance, -direction, need, epsMove);
var opposite = ProposeMove(context, positions, pair, distance, -direction, need, spacing, epsMove);
if (opposite.Applied > attempt.Applied)
attempt = opposite;
}
@@ -467,6 +471,8 @@ namespace OpenNest.Engine.Fill
{
if (token.IsCancellationRequested)
return (false, start, violations);
if (context.ProvesClearance(pair, positions, spacing))
continue;
var (distance, directionA) = context.PairClearance(
pair.IndexA,
pair.IndexB,
@@ -478,10 +484,10 @@ namespace OpenNest.Engine.Fill
var direction = pair.Mover == pair.IndexA ? directionA : -directionA;
var need = target - distance;
var attempt = ProposeMove(context, positions, pair, distance, direction, need, epsMove);
var attempt = ProposeMove(context, positions, pair, distance, direction, need, spacing, epsMove);
if (attempt.Applied <= 0 && distance <= 1e-7)
{
var opposite = ProposeMove(context, positions, pair, distance, -direction, need, epsMove);
var opposite = ProposeMove(context, positions, pair, distance, -direction, need, spacing, epsMove);
if (opposite.Applied > attempt.Applied)
attempt = opposite;
}
@@ -507,7 +513,7 @@ namespace OpenNest.Engine.Fill
private static (double Applied, Vector Proposal, bool EdgeLimited, bool PartLimited) ProposeMove(
SeparationContext context, List<Vector> positions, Pair pair,
double distance, Vector direction, double need, double epsMove)
double distance, Vector direction, double need, double spacing, double epsMove)
{
var mover = pair.Mover;
var start = positions[mover];
@@ -519,7 +525,7 @@ namespace OpenNest.Engine.Fill
return (0, start, edgeLimited, partLimited);
var trial = start + direction * room;
if (context.MaintainsValidity(mover, trial, positions, pair, distance))
if (context.MaintainsValidity(mover, trial, positions, pair, distance, spacing))
return (room, trial, edgeLimited, partLimited);
var lo = 0.0;
@@ -527,7 +533,7 @@ namespace OpenNest.Engine.Fill
for (var bisect = 0; bisect < 24 && hi - lo > 1e-6; bisect++)
{
var mid = (lo + hi) / 2;
if (context.MaintainsValidity(mover, start + direction * mid, positions, pair, distance))
if (context.MaintainsValidity(mover, start + direction * mid, positions, pair, distance, spacing))
lo = mid;
else
hi = mid;
@@ -544,6 +550,15 @@ namespace OpenNest.Engine.Fill
public int IndexB;
public int Mover;
public double EntryFloor;
/// <summary>True for a pair of parts, whose wide entry gap may close
/// to the target. Cut-offs and clamps always keep their entry gap.</summary>
public bool Yields;
/// <summary>Lowest clearance this pair may reach at a target spacing.</summary>
public double FloorAt(double spacing) => Yields
? System.Math.Min(EntryFloor, System.Math.Max(0, spacing))
: EntryFloor;
}
/// <summary>
@@ -569,6 +584,7 @@ namespace OpenNest.Engine.Fill
public Polygon Outer;
public List<Polygon> Rings = new(); // outer + cutout rings, local frame
public List<Polygon> Holes = new();
public Box Bounds; // union of Rings, local frame; null without a perimeter
public List<Entity> Boundaries = new(); // native outer + hole loops, local frame
public SlideContactGeometry Contact;
public Box NativeBounds; // perimeter's native-entity envelope
@@ -647,6 +663,9 @@ namespace OpenNest.Engine.Fill
{
token.ThrowIfCancellationRequested();
pair.EntryFloor = context.PairClearance(pair.IndexA, pair.IndexB, entry).Distance;
pair.Yields = pair.IndexB < clampStart
&& !context.parts[pair.IndexA].BaseDrawing.IsCutOff
&& !context.parts[pair.IndexB].BaseDrawing.IsCutOff;
}
return context;
}
@@ -691,6 +710,7 @@ namespace OpenNest.Engine.Fill
prepared.Holes.Add(hole);
prepared.Boundaries.AddRange(cutout.Entities);
}
prepared.Bounds = PrepareBounds(prepared.Rings);
prepared.Contact = SlideContactGeometry.Prepare(prepared.Boundaries);
prepared.HasCurves = prepared.Boundaries.Any(e => e is Arc || e is Circle);
}
@@ -700,6 +720,26 @@ namespace OpenNest.Engine.Fill
return prepared;
}
/// <summary>Gives each prepared ring local bounds, for pruning before
/// it is cloned to a scratch pose, and returns their union.</summary>
private static Box PrepareBounds(List<Polygon> rings)
{
var left = double.PositiveInfinity;
var right = double.NegativeInfinity;
var bottom = double.PositiveInfinity;
var top = double.NegativeInfinity;
foreach (var ring in rings)
{
ring.UpdateBounds();
var box = ring.BoundingBox;
left = System.Math.Min(left, box.Left);
right = System.Math.Max(right, box.Right);
bottom = System.Math.Min(bottom, box.Bottom);
top = System.Math.Max(top, box.Top);
}
return new Box(left, bottom, right - left, top - bottom);
}
private static Polygon ExactLineRing(List<Entity> entities)
{
var polygon = new Polygon();
@@ -755,6 +795,58 @@ namespace OpenNest.Engine.Fill
/// <summary>Current world positions of every part in pair-index order.</summary>
public List<Vector> Positions() => parts.Select(p => p.Location).ToList();
// Native arcs can sit 0.001 outside each part's rings, and a measured
// gap under 0.002 reads as uncertain; a wider box gap rules out both.
private const double BoundsSlack = 0.003;
/// <summary>
/// True when the parts' bounds alone prove <see cref="PairClearance"/>
/// is positive and at least <paramref name="floor"/>. A far pair can
/// then skip the measurement; open cut-offs and clamp keep-outs have
/// their own material tests and are always measured.
/// </summary>
public bool ProvesClearance(Pair pair, List<Vector> positions, double floor)
{
if (pair.IndexB >= clampStart
|| parts[pair.IndexA].BaseDrawing.IsCutOff
|| parts[pair.IndexB].BaseDrawing.IsCutOff)
return false;
var boundsA = shapes[pair.IndexA].Bounds;
var boundsB = shapes[pair.IndexB].Bounds;
if (boundsA == null || boundsB == null)
return false;
var proven = BoxGap(boundsA, positions[pair.IndexA], boundsB, positions[pair.IndexB])
- BoundsSlack;
return double.IsFinite(proven) && proven > 0 && proven >= floor;
}
private static double BoxGap(Box a, Vector offsetA, Box b, Vector offsetB)
{
var gapX = System.Math.Max(
b.Left + offsetB.X - (a.Right + offsetA.X),
a.Left + offsetA.X - (b.Right + offsetB.X));
var gapY = System.Math.Max(
b.Bottom + offsetB.Y - (a.Top + offsetA.Y),
a.Bottom + offsetA.Y - (b.Top + offsetB.Y));
if (gapX <= 0)
return System.Math.Max(gapY, 0);
if (gapY <= 0)
return gapX;
return System.Math.Sqrt(gapX * gapX + gapY * gapY);
}
/// <summary>Bounds the mover covers over a straight translation.</summary>
private static Box SweptBounds(Box bounds, Vector start, Vector direction, double room)
{
var travelX = direction.X * room;
var travelY = direction.Y * room;
return new Box(
bounds.Left + start.X + System.Math.Min(travelX, 0),
bounds.Bottom + start.Y + System.Math.Min(travelY, 0),
bounds.Length + System.Math.Abs(travelX),
bounds.Width + System.Math.Abs(travelY));
}
/// <summary>
/// Signed material clearance between two parts at the given scratch
/// positions. Material overlap (Collision oracle with hole subtraction)
@@ -872,10 +964,16 @@ namespace OpenNest.Engine.Fill
foreach (var ringA in setA.Rings)
{
var worldA = CloneAt(ringA, offsetA);
Polygon worldA = null;
foreach (var ringB in setB.Rings)
{
// Boundaries are never closer than their boxes, so a ring
// pair whose box gap reaches the best distance cannot win.
if (BoxGap(ringA.BoundingBox, offsetA, ringB.BoundingBox, offsetB) - 1e-9 >= best)
continue;
worldA ??= CloneAt(ringA, offsetA);
var worldB = CloneAt(ringB, offsetB);
var clearance = Clearance.BoundaryDistance(worldA, worldB);
@@ -993,6 +1091,11 @@ namespace OpenNest.Engine.Fill
return 0; // Unknown boundary is not empty space.
var origin = positions[index];
// Nothing outside the swept corridor can limit this move.
if (BoxGap(SweptBounds(moving.Bounds, origin, direction, room), Vector.Zero,
stationary.Bounds, positions[i]) > BoundsSlack)
continue;
var alreadyOverlapping = Collision.HasOverlap(
CloneAt(moving.Outer, origin), CloneAt(stationary.Outer, positions[i]),
moving.Holes.Count == 0 ? null : CloneAll(moving.Holes, origin),
@@ -1233,7 +1336,7 @@ namespace OpenNest.Engine.Fill
// prevent this comparison tolerance accumulating over iterations.
private const double FloorTolerance = 1e-6;
public bool MeetsEntryFloors(List<Vector> positions, List<Vector> entry)
public bool MeetsEntryFloors(List<Vector> positions, List<Vector> entry, double spacing)
{
for (var i = 0; i < moverCount; i++)
if (!InsideWorkArea(i, positions[i]) &&
@@ -1241,8 +1344,11 @@ namespace OpenNest.Engine.Fill
return false;
foreach (var pair in Pairs)
{
var floor = pair.FloorAt(spacing) - FloorTolerance;
if (ProvesClearance(pair, positions, floor))
continue;
var clearance = PairClearance(pair.IndexA, pair.IndexB, positions).Distance;
if (!double.IsFinite(clearance) || clearance < pair.EntryFloor - FloorTolerance)
if (!double.IsFinite(clearance) || clearance < floor)
return false;
}
return true;
@@ -1262,9 +1368,10 @@ namespace OpenNest.Engine.Fill
}
/// <summary>Check active improvement and every affected pair against
/// its immutable operation-entry floor on scratch positions.</summary>
/// its floor at the target spacing on scratch positions.</summary>
public bool MaintainsValidity(
int index, Vector trial, List<Vector> positions, Pair active, double activeDistance)
int index, Vector trial, List<Vector> positions, Pair active, double activeDistance,
double spacing)
{
if (!InsideWorkArea(index, trial))
return false;
@@ -1280,8 +1387,11 @@ namespace OpenNest.Engine.Fill
{
if (pair.IndexA != index && pair.IndexB != index)
continue;
var floor = pair.FloorAt(spacing) - FloorTolerance;
if (ProvesClearance(pair, positions, floor))
continue;
var clearance = PairClearance(pair.IndexA, pair.IndexB, positions).Distance;
if (!double.IsFinite(clearance) || clearance < pair.EntryFloor - FloorTolerance)
if (!double.IsFinite(clearance) || clearance < floor)
return false;
// Once an initial overlap is cleared, never reintroduce it.
if (clearance < 0)
+1 -1
View File
@@ -2,7 +2,7 @@
`Plate > Expand Spacing` moves selected parts by translation only. The first selected part is the anchor for selected pairs; the later selected member moves, and nonselected parts remain fixed. The plate retains its own `EdgeSpacing` floor; the command does not change `PartSpacing` or `EdgeSpacing`.
Every selected/selected and selected/nonselected pair has an operation-entry clearance floor, measured against material with cutouts subtracted. A later move may reuse slack opened by an earlier move, but it cannot reduce a pair below its own starting gap. Existing overlaps are accepted for attempted separation; a remaining overlap is reported as a violation, not a successful positive gap. Geometry whose material side is unresolved at sub-scale is conservatively reported as a violation when native extents intersect; that negative marker is not a measured penetration depth. An unchanged selected part already outside the work area also yields an edge violation and zero credited spacing. Temporarily blocked pairs are retried after another part moves, while a pass without positive movement stops the relaxation. An individual violation's `BlockedByEdge` flag is true only when the work-area edge is the first limiting cause; the desktop status currently displays a blocked-pair count, not those individual causes.
Every selected/selected and selected/nonselected pair has a clearance floor, measured against material with cutouts subtracted. A pair of parts may close to the spacing being tried but never below its own starting gap when that gap is already smaller, so a wide gap can be spent evenly across a layout and a tight one cannot shrink. A cut-off or clamp always keeps its full starting gap. A later move may reuse slack opened by an earlier move. Existing overlaps are accepted for attempted separation; a remaining overlap is reported as a violation, not a successful positive gap. Geometry whose material side is unresolved at sub-scale is conservatively reported as a violation when native extents intersect; that negative marker is not a measured penetration depth. An unchanged selected part already outside the work area also yields an edge violation and zero credited spacing. Temporarily blocked pairs are retried after another part moves, while a pass without positive movement stops the relaxation. An individual violation's `BlockedByEdge` flag is true only when the work-area edge is the first limiting cause; the desktop status currently displays a blocked-pair count, not those individual causes.
Probes run on scratch poses. Each trial translation is clipped against blocking material along its entire straight path, including cutout walls and regenerated open cut-off paths; a remote cut-off does not pin an unrelated move. Entry floors for open cut-offs use nearest points on every native segment, not a program endpoint or a fictional closing chord. Open paths that strictly enter part material stay as violations even at zero target; boundary contact and paths wholly inside holes remain legal. An open path's broad box is not a blocker when its real segments miss the part. Initially overlapping convex line profiles and complete native disks can take a certified direct separating path, including a safe partial move when the plate edge prevents a full exit; unsupported or ambiguous overlap paths stay put and remain reported. Complete native disks use analytic clearance rather than interpreting a shallow arc overlap as a positive polygon gap. For other curved pairs, a polygon gap inside the combined 0.002-unit chord band is uncertain and conservatively reported as a violation rather than proof of clear material. When a part touches a cutout wall and the usual nearest-boundary direction makes no safe progress, the tool tries the opposite direction through the same checks. Tiny clamp keep-outs use tolerance-free material occupancy and a conservative native-bound motion limit when tolerant geometry cannot certify them. Cancellation before application leaves the live layout unchanged; `Separate` never changes it. A bounded search reports the spacing it found, not an optimum. See [directional slides](directional-slides.md) for directional contact behavior.