fix(expand): separate parts touching cutout walls

This commit is contained in:
aj committed 2026-10-13 21:31:57 -04:00
1 parent 6c76617905
commit dc4b07ddf5
3 files changed
+103 -55

No files matched your search

@@ -479,6 +479,63 @@ public class ExpanderTests
Assert.Equal(4, moving.Location.X);
}
[Fact]
public void Separate_TouchingHoleWall_UsesOppositeDirectionToOpenGap()
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), new Vector(0, 0));
plate.Parts.Add(host);
var insert = AddSquare(plate, 13, 9, 2);
var (converged, poses, violations) = Expander.Separate(
new List<Part> { insert }, plate, spacing: 1
);
Assert.True(converged, $"contact still blocked: {violations.Count}");
Assert.True(poses[0].X <= 12 + 1e-4, $"insert at x={poses[0].X}");
Assert.True(poses[0].X >= 5 + 1 - 1e-4);
Assert.Equal(13, insert.Location.X);
Assert.Equal(0, host.Location.X);
}
[Theory]
[InlineData(13, 9)]
[InlineData(5, 9)]
[InlineData(9, 13)]
[InlineData(9, 5)]
[InlineData(12.9, 9)] // Positive gap uses its ordinary nearest-boundary direction.
public void Separate_FourHoleSides_ReachARealUnitGap(double x, double y)
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), Vector.Zero);
plate.Parts.Add(host);
var insert = AddSquare(plate, x, y, 2);
var (converged, poses, violations) = Expander.Separate(new List<Part> { insert }, plate, 1);
Assert.True(converged, $"{x},{y}: {violations.Count} violations");
Assert.InRange(poses[0].X, 6 - 1e-4, 12 + 1e-4);
Assert.InRange(poses[0].Y, 6 - 1e-4, 12 + 1e-4);
Assert.Equal(x, insert.Location.X);
Assert.Equal(y, insert.Location.Y);
}
[Fact]
public void Expand_TouchingHoleInsertWithSecondSelectedPart_StaysInsideCutout()
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), Vector.Zero);
plate.Parts.Add(host);
var insert = AddSquare(plate, 13, 9, 2);
var other = AddSquare(plate, 30, 9, 2);
var result = Expander.Expand(new List<Part> { insert, other }, plate,
new Expander.Options { MaxSpacing = 1 });
Assert.False(result.Cancelled);
Assert.True(result.AchievedSpacing >= 0.99);
Assert.InRange(insert.Location.X, 6 - 1e-4, 12 + 1e-4);
Assert.False(MateriallyOverlaps(insert, host));
Assert.Equal(0, host.Location.X);
}
[Fact]
public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance()
{
+45 -54
View File
@@ -317,63 +317,22 @@ namespace OpenNest.Engine.Fill
// the B side travels the opposite way.
var direction = moverIndex == pair.IndexA ? directionA : -directionA;
var edgeRoom = context.ClipToWorkArea(
moverIndex,
positions[moverIndex],
direction,
need
);
var room = context.ClipToMaterial(moverIndex, positions, direction, edgeRoom);
// 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)
// A zero-distance hole contact can have an ambiguous nearest-
// 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);
if (attempt.Applied <= 0 && distance <= 1e-7)
{
var trial = positions[moverIndex] + direction * room;
if (context.MaintainsValidity(moverIndex, trial, positions, pair, distance))
{
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,
pair,
distance
)
)
lo = mid;
else
hi2 = mid;
}
if (lo > epsMove)
{
positions[moverIndex] = positions[moverIndex] + direction * lo;
applied = lo;
}
}
blockedByEdge = applied < need - epsMove;
moved += applied;
var opposite = ProposeMove(context, positions, pair, distance, -direction, need, epsMove);
if (opposite.Applied > attempt.Applied)
attempt = opposite;
}
else
var applied = attempt.Applied;
if (applied > 0)
{
blockedByEdge = true;
positions[moverIndex] = attempt.Proposal;
moved += applied;
}
// A pair fully separated to the user target (the internal margin
@@ -432,6 +391,38 @@ namespace OpenNest.Engine.Fill
return (violations.Count == 0, positions, violations);
}
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)
{
var mover = pair.Mover;
var start = positions[mover];
var edgeRoom = context.ClipToWorkArea(mover, start, direction, need);
var room = context.ClipToMaterial(mover, positions, direction, edgeRoom);
var edgeLimited = edgeRoom < need - epsMove;
var partLimited = room < edgeRoom - epsMove;
if (room <= epsMove)
return (0, start, edgeLimited, partLimited);
var trial = start + direction * room;
if (context.MaintainsValidity(mover, trial, positions, pair, distance))
return (room, trial, edgeLimited, partLimited);
var lo = 0.0;
var hi = room;
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))
lo = mid;
else
hi = mid;
}
return lo > epsMove
? (lo, start + direction * lo, edgeLimited, true)
: (0, start, edgeLimited, true);
}
/// <summary>One constrained part↔part pair with its anchor mover.</summary>
private sealed class Pair
{
+1 -1
View File
@@ -4,4 +4,4 @@
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. Temporarily blocked pairs are retried after another part moves, while a pass without positive movement stops the relaxation.
Probes run on scratch poses. Each trial translation is clipped against blocking material along its entire straight path, including cutout walls. Initially overlapping convex line profiles can take a certified direct exit; unsupported or ambiguous overlap paths stay put and remain reported. Tiny clamp keep-outs use a conservative native-bound motion limit when tolerant geometry cannot certify them. Cancellation before application leaves the live layout unchanged; `Separate` never changes it. The desktop command is synchronous and has no Cancel dialog or automatic save. A bounded search reports the spacing it found, not an optimum. See `docs/geometry/directional-slides.md` for directional contact behavior.
Probes run on scratch poses. Each trial translation is clipped against blocking material along its entire straight path, including cutout walls. Initially overlapping convex line profiles can take a certified direct exit; unsupported or ambiguous overlap paths stay put and remain reported. 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 a conservative native-bound motion limit when tolerant geometry cannot certify them. Cancellation before application leaves the live layout unchanged; `Separate` never changes it. The desktop command is synchronous and has no Cancel dialog or automatic save. A bounded search reports the spacing it found, not an optimum. See `docs/geometry/directional-slides.md` for directional contact behavior.