From dc4b07ddf57efa2473f874ceb3c808c7cc98bb67 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 21:06:51 -0400 Subject: [PATCH] fix(expand): separate parts touching cutout walls --- OpenNest.Engine.Tests/Fill/ExpanderTests.cs | 57 ++++++++++++ OpenNest.Engine/Fill/Expander.cs | 99 ++++++++++----------- docs/geometry/expand-spacing.md | 2 +- 3 files changed, 103 insertions(+), 55 deletions(-) diff --git a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs index 0dd9ec5..6e488b4 100644 --- a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs +++ b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs @@ -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 { 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 { 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 { 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() { diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs index 3a80568..4d730a7 100644 --- a/OpenNest.Engine/Fill/Expander.cs +++ b/OpenNest.Engine/Fill/Expander.cs @@ -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 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); + } + /// One constrained part↔part pair with its anchor mover. private sealed class Pair { diff --git a/docs/geometry/expand-spacing.md b/docs/geometry/expand-spacing.md index 01bbca8..df46a36 100644 --- a/docs/geometry/expand-spacing.md +++ b/docs/geometry/expand-spacing.md @@ -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.