From 27f8c9799b42b3299996e07072d5e0cbce3c370e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 22:11:12 -0400 Subject: [PATCH] fix(expand): measure open cutoff segments and refuse uncertain curved gaps --- OpenNest.Core/Geometry/Clearance.cs | 10 ++ .../Fill/ExpanderCutoffDeltaTests.cs | 121 ++++++++++++++++++ .../Fill/ExpanderCutoffQuadrantTests.cs | 94 ++++++++++++++ .../Fill/ExpanderFinalQualityTests.cs | 12 ++ OpenNest.Engine/Fill/Expander.cs | 32 +++++ OpenNest.Tests/Geometry/ClearanceTests.cs | 22 ++++ docs/geometry/expand-spacing.md | 2 +- 7 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs create mode 100644 OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs diff --git a/OpenNest.Core/Geometry/Clearance.cs b/OpenNest.Core/Geometry/Clearance.cs index ca90a4f3..d37eab9a 100644 --- a/OpenNest.Core/Geometry/Clearance.cs +++ b/OpenNest.Core/Geometry/Clearance.cs @@ -80,6 +80,16 @@ namespace OpenNest.Geometry return Separation(linesA, linesB); } + /// Distance from a closed boundary's segments to every segment + /// of an intentional open cutting path. Unlike polygonizing that path, + /// this retains segment interiors and disjoint subpaths. + internal static ClearanceResult BoundaryDistance(List closed, List open) + { + if (closed.Count == 0 || open.Count == 0) + return new ClearanceResult(0, new Vector(1, 0)); + return Separation(closed, open); + } + /// /// Minimum boundary distance between two non-overlapping rings and the /// direction that translates away from diff --git a/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs new file mode 100644 index 00000000..2d669327 --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs @@ -0,0 +1,121 @@ +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Fill; + +public class ExpanderCutoffDeltaTests +{ + private static Plate Sheet() => new(new Size(30, 30)) { EdgeSpacing = new Spacing(0, 0) }; + + private static Program Rectangle(double width = 2, double height = 2) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(width, 0); + program.LineTo(width, height); + program.LineTo(0, height); + program.LineTo(0, 0); + return program; + } + + private static Part Add(Plate plate, Program program, double x, double y) + { + var part = new Part(new Drawing("neutral", program), new Vector(x, y)); + plate.Parts.Add(part); + return part; + } + + private static Program Disk(double radius) => + ConvertGeometry.ToProgram(new List { new Circle(0, 0, radius) }); + + [Theory] + [InlineData(CutOffAxis.Horizontal)] + [InlineData(CutOffAxis.Vertical)] + public void Expand_RegeneratedCutoff_MustPreserveNativeEntryGap(CutOffAxis axis) + { + var plate = Sheet(); + var anchor = Add(plate, Rectangle(), 1, 1); + var mover = Add(plate, Rectangle(), 4, 4); + plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis)); + var settings = new CutOffSettings(); + plate.RegenerateCutOffs(settings); + var before = axis == CutOffAxis.Horizontal + ? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right; + + var result = Expander.Expand(new List { anchor, mover }, plate, + new Expander.Options { MaxSpacing = 3 }); + var after = axis == CutOffAxis.Horizontal + ? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right; + Assert.True(after >= before - 1.1e-6, + $"Cutoff entry gap shrank {before:R}->{after:R}, mover=({mover.Location.X:R},{mover.Location.Y:R}), achieved={result.AchievedSpacing:R}, violations={result.Violations.Count}"); + plate.RegenerateCutOffs(settings); + Assert.Single(plate.CutOffs); + } + + [Fact] + public void Expand_RemoteOpenU_MustNotMoveTowardNativeCutoff() + { + var plate = Sheet(); + var anchor = Add(plate, Rectangle(), 1, 4); + var mover = Add(plate, Rectangle(), 4, 4); + var program = new Program(); + program.MoveTo(0, 10); + program.LineTo(30, 10); + program.LineTo(30, 0); + program.LineTo(0, 0); + plate.Parts.Add(new Part(new Drawing("neutral-cutoff", program) { IsCutOff = true }, Vector.Zero)); + + var result = Expander.Expand(new List { anchor, mover }, plate, + new Expander.Options { MaxSpacing = 3 }); + // The three real segments are at least four units from the selected + // square at entry; an invented closing chord is not a real obstacle. + var gap = System.Math.Min(mover.BoundingBox.Bottom, + System.Math.Min(10 - mover.BoundingBox.Top, 30 - mover.BoundingBox.Right)); + Assert.True(gap >= 4 - 1.1e-6, + $"Open path gap shrank to {gap:R}; mover=({mover.Location.X:R},{mover.Location.Y:R}); spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Separate_ShallowDiskOrRingVsRectangle_MustNotClaimClear(bool annulus) + { + var plate = Sheet(); + var entities = new List { new Circle(0, 0, 2) }; + if (annulus) + entities.Add(new Circle(0, 0, 1)); + Add(plate, ConvertGeometry.ToProgram(entities), 10, 10); + const double angle = 0.017; + const double depth = 0.0005; + var mover = Add(plate, Rectangle(0.01, 0.01), + 10 + (2 - depth) * System.Math.Cos(angle), + 10 + (2 - depth) * System.Math.Sin(angle)); + + var result = Expander.Separate(new List { mover }, plate, 0); + var nearest = result.Positions[0].DistanceTo(new Vector(10, 10)); + Assert.True(nearest >= 2 - 1e-8 || result.Violations.Any(v => v.Achieved < 0), + $"Native material overlap remains: nearest={nearest:R}, converged={result.Converged}, violations={result.Violations.Count}"); + } + + [Theory] + [InlineData(CutOffAxis.Horizontal)] + [InlineData(CutOffAxis.Vertical)] + public void Separate_RegeneratedCutoff_MustNotCrossEvenAtFarClearEndpoint(CutOffAxis axis) + { + var plate = Sheet(); + var horizontal = axis == CutOffAxis.Horizontal; + var anchor = Add(plate, Rectangle(), 1, 1); + var mover = Add(plate, Rectangle(), horizontal ? 1 : 6, horizontal ? 6 : 1); + plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis)); + plate.RegenerateCutOffs(new CutOffSettings()); + + var result = Expander.Separate(new List { anchor, mover }, plate, 12); + var trailingEdge = (horizontal ? result.Positions[1].Y : result.Positions[1].X) + 2; + Assert.True(trailingEdge <= 10 + 1e-6, + $"Crossed native cutoff, trailing edge={trailingEdge:R}"); + Assert.False(result.Converged); + } +} diff --git a/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs new file mode 100644 index 00000000..51cefcba --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs @@ -0,0 +1,94 @@ +using OpenNest.CNC; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Fill; + +public class ExpanderCutoffQuadrantTests +{ + private static Program Square() + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(2, 0); + program.LineTo(2, 2); + program.LineTo(0, 2); + program.LineTo(0, 0); + return program; + } + + public static IEnumerable Cases() + { + foreach (var quadrant in new[] { 1, 2, 3, 4 }) + foreach (var verticalMotion in new[] { false, true }) + foreach (var cutoff in new[] { false, true }) + foreach (var expand in new[] { false, true }) + yield return new object[] { quadrant, verticalMotion, cutoff, expand }; + } + + [Theory] + [MemberData(nameof(Cases))] + public void RemoteCutoffMustNotPinNegativeParallelMotion( + int quadrant, bool verticalMotion, bool cutoff, bool expand) + { + var plate = new Plate(new Size(80, 80)) + { + Quadrant = quadrant, + EdgeSpacing = new Spacing(0, 0), + }; + var origin = new Vector( + quadrant == 2 || quadrant == 3 ? -80 : 0, + quadrant == 3 || quadrant == 4 ? -80 : 0); + var anchorPose = origin + new Vector(30, 30); + var moverPose = anchorPose + (verticalMotion ? new Vector(0, -3) : new Vector(-3, 0)); + var anchor = new Part(new Drawing("anchor", Square()), anchorPose); + var mover = new Part(new Drawing("mover", Square()), moverPose); + plate.Parts.Add(anchor); + plate.Parts.Add(mover); + + if (cutoff) + { + plate.CutOffs.Add(new CutOff( + origin + (verticalMotion ? new Vector(40, 0) : new Vector(0, 40)), + verticalMotion ? CutOffAxis.Vertical : CutOffAxis.Horizontal)); + plate.RegenerateCutOffs(new CutOffSettings()); + Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff)); + } + + Vector end; + int violations; + if (expand) + { + var result = Expander.Expand(new List { anchor, mover }, plate, + new Expander.Options { MaxSpacing = 3 }); + end = mover.Location; + violations = result.Violations.Count; + Assert.True(result.AchievedSpacing >= 2.99, + $"spacing={result.AchievedSpacing:R}; end=({end.X:R},{end.Y:R})"); + } + else + { + var result = Expander.Separate(new List { anchor, mover }, plate, 3); + end = result.Positions[1]; + violations = result.Violations.Count; + Assert.True(result.Converged, + $"unchanged=({end.X:R},{end.Y:R}), violations={violations}"); + Assert.Equal(moverPose.X, mover.Location.X); + Assert.Equal(moverPose.Y, mover.Location.Y); + } + + // Two units of negative-axis motion opens the selected gap 1 -> 3. + // The parallel open cutoff remains eight units away throughout. + Assert.True(verticalMotion ? end.Y <= moverPose.Y - 1.999 : end.X <= moverPose.X - 1.999); + Assert.Equal(0, violations); + Assert.Equal(anchorPose.X, anchor.Location.X); + Assert.Equal(anchorPose.Y, anchor.Location.Y); + if (cutoff) + { + plate.RegenerateCutOffs(new CutOffSettings()); + Assert.Single(plate.CutOffs); + Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff)); + } + } +} diff --git a/OpenNest.Engine.Tests/Fill/ExpanderFinalQualityTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderFinalQualityTests.cs index 8fdbf095..021dd796 100644 --- a/OpenNest.Engine.Tests/Fill/ExpanderFinalQualityTests.cs +++ b/OpenNest.Engine.Tests/Fill/ExpanderFinalQualityTests.cs @@ -51,6 +51,18 @@ public class ExpanderFinalQualityTests $"Native disks still overlap by {-nativeGap:R} but spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}; mover=({mover.Location.X:R},{mover.Location.Y:R})"); } + [Fact] + public void Separate_CurvedExactContact_CanStillGrowARealGap() + { + var plate = Sheet(); + Add(plate, Disk(2), 10, 6); + var mover = Add(plate, Rectangle(1, 1), 12, 5.5); + var result = Expander.Separate(new List { mover }, plate, spacing: 1); + Assert.True(result.Converged, $"curved contact pinned: {result.Violations.Count} violations"); + Assert.True(result.Positions[0].X >= 13 - 1e-3); + Assert.Equal(12, mover.Location.X); + } + [Fact] public void Separate_SmallConcaveOverlap_MustNotClaimConvergedAtZero() { diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs index f4e50cb2..c018b590 100644 --- a/OpenNest.Engine/Fill/Expander.cs +++ b/OpenNest.Engine/Fill/Expander.cs @@ -573,6 +573,7 @@ namespace OpenNest.Engine.Fill public SlideContactGeometry Contact; public Box NativeBounds; // perimeter's native-entity envelope public ShapeProfile MaterialProfile; // immutable local native loops + public bool HasCurves; } private SeparationContext( @@ -691,6 +692,7 @@ namespace OpenNest.Engine.Fill prepared.Boundaries.AddRange(cutout.Entities); } prepared.Contact = SlideContactGeometry.Prepare(prepared.Boundaries); + prepared.HasCurves = prepared.Boundaries.Any(e => e is Arc || e is Circle); } } @@ -772,6 +774,30 @@ namespace OpenNest.Engine.Fill var offsetA = positions[indexA]; var offsetB = positions[indexB]; + if (parts[indexB].BaseDrawing.IsCutOff) + { + // A regenerated cutoff is a set of open native segments. + // Polygon.Close would invent an endpoint chord and miss the + // interior witness that sets the immutable entry floor. + if (setB.Boundaries.Count == 0) + return (double.MaxValue, new Vector(1, 0)); + if (setA.Outer == null || setB.Boundaries.Any(e => e is not Line)) + return (-1e-4, new Vector(1, 0)); + var lines = CloneEntitiesAt(setB.Boundaries, offsetB).Cast().ToList(); + var cutoffBest = double.MaxValue; + var cutoffDirection = new Vector(1, 0); + foreach (var ring in setA.Rings) + { + var clearance = Clearance.BoundaryDistance(CloneAt(ring, offsetA).ToLines(), lines); + if (clearance.Distance < cutoffBest) + { + cutoffBest = clearance.Distance; + cutoffDirection = clearance.Direction; + } + } + return (cutoffBest, cutoffDirection); + } + if (indexB < clampStart && !parts[indexB].BaseDrawing.IsCutOff && (setA.Contact?.IsResolved != true || setB.Contact?.IsResolved != true)) { @@ -855,6 +881,12 @@ namespace OpenNest.Engine.Fill } } + // Each prepared arc can sit up to 0.001 outside its inscribed + // chord. A positive polygon gap inside the combined chord band + // is not proof of native clearance. Keep the input but report + // uncertainty as a violation rather than a false clear. + if ((setA.HasCurves || setB.HasCurves) && best >= 0 && best < 0.002) + return (-1e-4, bestDir); return (best, bestDir); } diff --git a/OpenNest.Tests/Geometry/ClearanceTests.cs b/OpenNest.Tests/Geometry/ClearanceTests.cs index c7708d70..a070fdbe 100644 --- a/OpenNest.Tests/Geometry/ClearanceTests.cs +++ b/OpenNest.Tests/Geometry/ClearanceTests.cs @@ -35,6 +35,28 @@ public class ClearanceTests return p; } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OpenPathBoundaryDistanceUsesSegmentInterior_NotFirstEndpoint(bool vertical) + { + var part = Square(0, 0, 2, 2); + var path = vertical + ? new Line(new Vector(10, -20), new Vector(10, 20)) + : new Line(new Vector(-20, 10), new Vector(20, 10)); + var lines = new List { path }; + + var initial = Clearance.BoundaryDistance(part.ToLines(), lines); + Assert.Equal(8, initial.Distance, 6); // Independent axis-aligned box gap. + Assert.Equal(vertical ? -1 : 0, initial.Direction.X, 6); + Assert.Equal(vertical ? 0 : -1, initial.Direction.Y, 6); + + // A parallel translation leaves the true gap unchanged even though + // distance to the first endpoint changes substantially. + part.Offset(vertical ? new Vector(0, 3) : new Vector(3, 0)); + Assert.Equal(8, Clearance.BoundaryDistance(part.ToLines(), lines).Distance, 6); + } + // ---- Separation ---- [Fact] diff --git a/docs/geometry/expand-spacing.md b/docs/geometry/expand-spacing.md index 3d986f06..9a0af65a 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. 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. 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. 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. 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 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. 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. 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.