fix(expand): retain convex exits and report tiny clamp overlap

This commit is contained in:
aj committed 2026-10-13 21:31:57 -04:00
1 parent 73be301ebf
commit 4183af4142
4 files changed
+265 -22

No files matched your search

@@ -0,0 +1,101 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderQualityReviewTests
{
private static Program Rectangle(double width, double height)
{
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 AddSquare(Plate plate, double x, double y, double size)
{
var part = new Part(new Drawing("neutral", Rectangle(size, size)), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Plate MakePlate(double length) =>
new(new Size(12, length)) { EdgeSpacing = new Spacing(0, 0) };
[Fact]
public void TinyZoneContainedInMover_MustNotReturnClearPositiveSpacing()
{
var plate = MakePlate(30);
plate.ClampZones.Add(new ClampZone("tiny", new Box(5, 5, 1e-5, 1e-5)));
var anchor = AddSquare(plate, 1, 4, 2);
var mover = AddSquare(plate, 4, 4, 2);
// Independent oracle: [5,5.00001]^2 lies strictly inside [4,6]^2.
var result = Expander.Expand(
new List<Part> { anchor, mover },
plate,
new Expander.Options { InitialStep = 3, MaxSpacing = 3 }
);
Assert.True(
result.Violations.Count > 0,
$"Physical clamp is still inside mover but report is clear: spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}"
);
Assert.True(result.Violations[0].Achieved < 0);
}
[Fact]
public void TinyZoneInsideCutoutIsNotReportedAsMaterialOverlap()
{
var plate = MakePlate(30);
plate.ClampZones.Add(new ClampZone("tiny", new Box(5, 5, 1e-5, 1e-5)));
var frameProgram = Rectangle(10, 10);
frameProgram.MoveTo(4, 4);
frameProgram.LineTo(6, 4);
frameProgram.LineTo(6, 6);
frameProgram.LineTo(4, 6);
frameProgram.LineTo(4, 4);
var frame = new Part(new Drawing("neutral-frame", frameProgram), Vector.Zero);
plate.Parts.Add(frame);
var result = Expander.Separate(new List<Part> { frame }, plate, spacing: 0.5);
Assert.True(result.Converged, $"zone inside cutout reported as material: {result.Violations.Count}");
Assert.Empty(result.Violations);
Assert.Equal(0, frame.Location.X);
}
[Fact]
public void EdgeLimitedConvexOverlap_ShouldRetainCertifiedPartialImprovement()
{
var plate = MakePlate(11.5);
var anchor = AddSquare(plate, 5, 4, 4);
var mover = AddSquare(plate, 7, 4, 4);
var result = Expander.Expand(
new List<Part> { anchor, mover }, plate, new Expander.Options { MaxSpacing = 1 }
);
// Along x in [7,7.5], penetration is 9-x and strictly decreases.
Assert.True(
mover.Location.X >= 7.49,
$"Lost safe partial-overlap improvement: x={mover.Location.X:R}; expected edge at 7.5"
);
Assert.NotEmpty(result.Violations);
Assert.True(mover.Location.X + 4 <= 11.5 + 1e-9);
}
[Fact]
public void NegativeTarget_ConvexOverlapCanReachLessNegativeClearance()
{
var plate = MakePlate(30);
var anchor = AddSquare(plate, 5, 4, 4);
var mover = AddSquare(plate, 7, 4, 4);
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, -1);
Assert.True(result.Converged, $"Signed target -1 was not reached; x={result.Positions[1].X:R}");
Assert.True(result.Positions[1].X >= 8);
Assert.Equal(7, mover.Location.X);
}
}
@@ -0,0 +1,77 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderSpecRegressionTests
{
private static Program Disk(double radius)
{
var p = new Program();
p.MoveTo(radius, 0);
p.ArcTo(-radius, 0, 0, 0, RotationType.CCW);
p.ArcTo(radius, 0, 0, 0, RotationType.CCW);
return p;
}
private static (Plate Plate, Part A, Part B) Fixture()
{
var plate = new Plate(new Size(12, 30)) { EdgeSpacing = new Spacing(0, 0) };
var a = new Part(new Drawing("disk-a", Disk(2)), new Vector(7, 6));
var b = new Part(new Drawing("disk-b", Disk(2)), new Vector(9, 6));
plate.Parts.Add(a);
plate.Parts.Add(b);
return (plate, a, b);
}
[Fact]
public void Separate_OverlappingNativeDisks_RetainsCertifiedConvexExit()
{
var (plate, a, b) = Fixture();
var result = Expander.Separate(new List<Part> { a, b }, plate, spacing: 3);
Assert.True(result.Converged,
$"remaining violations={result.Violations.Count}; mover=({result.Positions[1].X:R},{result.Positions[1].Y:R})");
// Independent analytic disk material clearance, not production polygons.
Assert.True(result.Positions[1].DistanceTo(result.Positions[0]) - 4 >= 2.99);
Assert.Equal(7, a.Location.X);
Assert.Equal(9, b.Location.X); // scratch-only
}
[Fact]
public void Separate_OverlappingNativeDisks_CannotPassThroughThirdWall()
{
var (plate, a, b) = Fixture();
var wallProgram = new Program();
wallProgram.MoveTo(0, 0);
wallProgram.LineTo(0.2, 0);
wallProgram.LineTo(0.2, 12);
wallProgram.LineTo(0, 12);
wallProgram.LineTo(0, 0);
var wall = new Part(new Drawing("fixed-wall", wallProgram), new Vector(12, 0));
plate.Parts.Add(wall);
var result = Expander.Separate(new List<Part> { a, b }, plate, spacing: 3);
Assert.False(result.Converged);
Assert.True(result.Positions[1].X + 2 <= 12 + 1e-6,
$"native disk crossed third wall: centre={result.Positions[1].X:R}");
Assert.Equal(9, b.Location.X);
Assert.Equal(12, wall.Location.X);
}
[Fact]
public void Expand_OverlappingNativeDisks_RetainsExistingSeparation()
{
var (plate, a, b) = Fixture();
var result = Expander.Expand(new List<Part> { a, b }, plate,
new Expander.Options { InitialStep = 0.5, MaxSpacing = 3 });
var nativeGap = a.Location.DistanceTo(b.Location) - 4;
Assert.True(nativeGap >= 2.99,
$"native gap={nativeGap:R}; achieved={result.AchievedSpacing:R}; violations={result.Violations.Count}");
Assert.True(result.AchievedSpacing >= 2.9);
Assert.Empty(result.Violations);
Assert.Equal(7, a.Location.X);
}
}
+86 -21
View File
@@ -47,7 +47,8 @@ namespace OpenNest.Engine.Fill
public Part A;
public Part B;
/// <summary>Clearance actually reached (may be negative for unresolvable overlaps).</summary>
/// <summary>Clearance reached (negative for unresolvable overlaps;
/// sub-grid clamp overlaps use a negative marker, not a measured depth).</summary>
public double Achieved;
/// <summary>True when a work-area edge, not a part, blocked the last needed move.</summary>
@@ -552,6 +553,7 @@ namespace OpenNest.Engine.Fill
public List<Entity> Boundaries = new(); // native outer + hole loops, local frame
public SlideContactGeometry Contact;
public Box NativeBounds; // perimeter's native-entity envelope
public ShapeProfile MaterialProfile; // immutable local native loops
}
private SeparationContext(
@@ -646,6 +648,7 @@ namespace OpenNest.Engine.Fill
if (entities.Count > 0)
{
var profile = new ShapeProfile(entities);
prepared.MaterialProfile = profile;
if (profile.Perimeter != null)
{
@@ -747,13 +750,25 @@ namespace OpenNest.Engine.Fill
{
var setA = shapes[indexA];
var setB = shapes[indexB];
var offsetA = positions[indexA];
var offsetB = positions[indexB];
if (indexB >= clampStart &&
KeepOutOverlap.NeedsToleranceFreeTest(clampBoxes[indexB - clampStart]))
{
// Tolerant polygon collision can miss a sub-grid zone even
// when it is wholly contained in part material. The native
// profile test is hole-aware and fail-closed. Its negative
// marker means unresolved overlap, not a measured depth.
var zone = clampBoxes[indexB - clampStart];
if (setA.MaterialProfile?.Perimeter == null ||
KeepOutOverlap.ProfileEnters(setA.MaterialProfile, offsetA, zone))
return (-1e-4, new Vector(1, 0));
}
if (setA.Outer == null || setB.Outer == null)
return (0, new Vector(1, 0));
var offsetA = positions[indexA];
var offsetB = positions[indexB];
var outerA = CloneAt(setA.Outer, offsetA);
var outerB = CloneAt(setB.Outer, offsetB);
@@ -853,36 +868,32 @@ namespace OpenNest.Engine.Fill
return 0; // Unknown boundary is not empty space.
var origin = positions[index];
var start = origin;
var alreadyOverlapping = Collision.HasOverlap(
CloneAt(moving.Outer, origin), CloneAt(stationary.Outer, positions[i]),
moving.Holes.Count == 0 ? null : CloneAll(moving.Holes, origin),
stationary.Holes.Count == 0 ? null : CloneAll(stationary.Holes, positions[i]));
var exit = 0.0;
if (alreadyOverlapping)
{
// The ordinary slide query only certifies initially clear
// geometry. A convex line-only overlap can exit directly
// along a separating support normal; other overlaps remain
// accepted input but cannot be moved without a path proof.
if (!TryConvexDirectExit(moving, stationary, origin, positions[i], direction, out exit)
|| exit > room)
return 0;
start = origin + direction * exit;
if (Collision.HasOverlap(
CloneAt(moving.Outer, start), CloneAt(stationary.Outer, positions[i])))
// An initially overlapping convex pair needs a direct
// separating-path certificate; the normal slide query
// is only for initially clear geometry. A certified
// prefix may still overlap when the edge clips its exit.
// Convex material cannot re-enter along the certified
// separating ray after its single exit.
if (!TryConvexDirectExit(moving, stationary, origin, positions[i], direction, out _))
return 0;
continue;
}
var query = SpatialQuery.DirectionalDistance(
CloneEntitiesAt(moving.Boundaries, start),
CloneEntitiesAt(moving.Boundaries, origin),
CloneEntitiesAt(stationary.Boundaries, positions[i]),
direction,
new SlideContactClassifier(moving.Contact, start, stationary.Contact, positions[i]));
new SlideContactClassifier(moving.Contact, origin, stationary.Contact, positions[i]));
if (double.IsNaN(query) || query < 0)
return 0;
if (query < double.MaxValue)
room = System.Math.Min(room, exit + query);
room = System.Math.Min(room, query);
}
return room;
}
@@ -937,8 +948,28 @@ namespace OpenNest.Engine.Fill
Vector direction, out double exit)
{
exit = 0;
if (moving.Holes.Count != 0 || stationary.Holes.Count != 0
|| moving.Boundaries.Count == 0 || stationary.Boundaries.Count == 0
if (moving.Holes.Count != 0 || stationary.Holes.Count != 0)
return false;
if (TryNativeDisk(moving, out var movingCenter, out var movingRadius) &&
TryNativeDisk(stationary, out var fixedCenter, out var fixedRadius))
{
// Full circles are convex. The relative centre distance
// must increase from the start; its ray intersects the
// summed-radius disk at most once in forward travel.
var relative = movingCenter + movePos - fixedCenter - fixedPos;
var approach = relative.DotProduct(direction);
var radius = movingRadius + fixedRadius;
var discriminant = approach * approach + radius * radius
- relative.DotProduct(relative);
if (!double.IsFinite(approach) || approach < -1e-8
|| !double.IsFinite(discriminant) || discriminant <= 0)
return false;
exit = -approach + System.Math.Sqrt(discriminant);
return double.IsFinite(exit) && exit > 0;
}
if (moving.Boundaries.Count == 0 || stationary.Boundaries.Count == 0
|| moving.Boundaries.Any(e => e is not Line)
|| stationary.Boundaries.Any(e => e is not Line)
|| !IsConvex(moving.Outer) || !IsConvex(stationary.Outer))
@@ -969,6 +1000,40 @@ namespace OpenNest.Engine.Fill
return double.IsFinite(exit);
}
private static bool TryNativeDisk(RingSet shape, out Vector center, out double radius)
{
center = Vector.Zero;
radius = 0;
var boundaries = shape.Boundaries;
if (boundaries.Count == 1 && boundaries[0] is Circle circle)
{
center = circle.Center;
radius = circle.Radius;
}
else if (boundaries.Count > 0 && boundaries[0] is Arc first)
{
center = first.Center;
radius = first.Radius;
var reversed = first.IsReversed;
var sweep = 0.0;
foreach (var entity in boundaries)
{
if (entity is not Arc arc || arc.Center.X != center.X
|| arc.Center.Y != center.Y || arc.Radius != radius
|| arc.IsReversed != reversed)
return false;
sweep += arc.SweepAngle();
}
if (System.Math.Abs(sweep - 2 * System.Math.PI) > 1e-7)
return false;
}
else
return false;
return double.IsFinite(center.X) && double.IsFinite(center.Y)
&& double.IsFinite(radius) && radius > 0;
}
private static (double Min, double Max) Project(Polygon polygon, Vector origin, Vector axis)
{
var min = double.PositiveInfinity;
+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. 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. 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.
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 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. 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 (a negative overlap marker is not a measured penetration depth) 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.