mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-22 06:21:37 -04:00
fix(expand): measure open cutoff segments and refuse uncertain curved gaps
This commit is contained in:
1 parent
775ece5d21
commit
27f8c9799b
7 files changed
+292
-1
No files matched your search
@@ -80,6 +80,16 @@ namespace OpenNest.Geometry
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
internal static ClearanceResult BoundaryDistance(List<Line> closed, List<Line> open)
|
||||
{
|
||||
if (closed.Count == 0 || open.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
return Separation(closed, open);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum boundary distance between two non-overlapping rings and the
|
||||
/// direction that translates <paramref name="linesA"/> away from
|
||||
|
||||
@@ -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<Entity> { 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<Part> { 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<Part> { 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<Entity> { 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<Part> { 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<Part> { 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);
|
||||
}
|
||||
}
|
||||
@@ -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<object[]> 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<Part> { 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<Part> { 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Part> { 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()
|
||||
{
|
||||
|
||||
@@ -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<Line>().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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Line> { 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]
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user