fix(expand): distinguish open-path material entry from bounds

This commit is contained in:
aj committed 2026-10-13 22:34:21 -04:00
1 parent 27f8c9799b
commit 6424512e8c
6 files changed
+365 -8

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
namespace OpenNest.Geometry;
/// <summary>
/// Strict positive-length entry of an open native line path into polygon material.
/// Boundary-only contact is not entry; holes subtract material. This deliberately
/// does not infer material from a path's bounding box or close the open path.
/// Curved contours are flattened by the caller, which must handle chord uncertainty.
/// </summary>
internal static class OpenPathMaterial
{
public static bool Enters(Polygon outer, IReadOnlyList<Polygon> holes, IReadOnlyList<Line> path)
{
if (outer?.Vertices.Count < 4 || path == null)
return true; // Unresolved geometry is not a certificate of clear material.
foreach (var line in path)
{
var start = line.StartPoint;
var end = line.EndPoint;
var travel = end - start;
var lengthSquared = travel.DotProduct(travel);
if (!double.IsFinite(lengthSquared) || lengthSquared <= 0)
return true;
var cuts = new List<double> { 0, 1 };
CollectCuts(outer, start, travel, lengthSquared, cuts);
if (holes != null)
foreach (var hole in holes)
CollectCuts(hole, start, travel, lengthSquared, cuts);
cuts.Sort();
for (var i = 1; i < cuts.Count; i++)
{
if (cuts[i] <= cuts[i - 1])
continue;
var midpoint = start + travel * ((cuts[i - 1] + cuts[i]) / 2);
if (!StrictlyInside(outer, midpoint))
continue;
var inHole = false;
if (holes != null)
foreach (var hole in holes)
if (InsideOrOnBoundary(hole, midpoint))
{
inHole = true;
break;
}
if (!inHole)
return true;
}
}
return false;
}
private static void CollectCuts(Polygon ring, Vector start, Vector travel,
double lengthSquared, List<double> cuts)
{
if (ring?.Vertices.Count < 4)
{
// A degenerate hole cannot be subtracted reliably. The caller's
// unresolved-topology guard handles this case before this helper.
return;
}
var vertices = ring.Vertices;
for (var i = 1; i < vertices.Count; i++)
{
var a = vertices[i - 1];
var edge = vertices[i] - a;
var difference = a - start;
var denominator = Cross(travel, edge);
if (denominator == 0)
{
if (Cross(difference, travel) != 0)
continue;
AddParameter(difference.DotProduct(travel) / lengthSquared, cuts);
AddParameter((vertices[i] - start).DotProduct(travel) / lengthSquared, cuts);
continue;
}
var t = Cross(difference, edge) / denominator;
var u = Cross(difference, travel) / denominator;
if (u >= -1e-12 && u <= 1 + 1e-12)
AddParameter(t, cuts);
}
}
private static void AddParameter(double t, List<double> cuts)
{
if (double.IsFinite(t) && t >= -1e-12 && t <= 1 + 1e-12)
cuts.Add(System.Math.Clamp(t, 0, 1));
}
private static bool StrictlyInside(Polygon ring, Vector point) =>
Contains(ring, point, boundaryIsInside: false);
private static bool InsideOrOnBoundary(Polygon ring, Vector point) =>
Contains(ring, point, boundaryIsInside: true);
private static bool Contains(Polygon ring, Vector point, bool boundaryIsInside)
{
var vertices = ring.Vertices;
var inside = false;
for (var i = 1; i < vertices.Count; i++)
{
var a = vertices[i - 1];
var b = vertices[i];
var edge = b - a;
var relative = point - a;
if (Cross(edge, relative) == 0 &&
point.X >= System.Math.Min(a.X, b.X) && point.X <= System.Math.Max(a.X, b.X) &&
point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y))
return boundaryIsInside;
if ((a.Y > point.Y) != (b.Y > point.Y) &&
point.X < a.X + (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y))
inside = !inside;
}
return inside;
}
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderIntersectingOpenPathTests
{
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 transpose in new[] { false, true })
foreach (var expand in new[] { false, true })
yield return new object[] { quadrant, transpose, expand };
}
[Theory]
[MemberData(nameof(Cases))]
public void IntersectingOpenPathMustRemainAViolation(int quadrant, bool transpose, bool expand)
{
var origin = new Vector(quadrant is 2 or 3 ? -100 : 0,
quadrant is 3 or 4 ? -100 : 0);
Vector Swap(double x, double y) => transpose ? new Vector(y, x) : new Vector(x, y);
var plate = new Plate(new Size(100, 100))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0, 0),
};
var expectedA = origin + Swap(40, 40);
var expectedB = origin + Swap(43, 40);
var anchor = new Part(new Drawing("anchor", Square()), expectedA);
var mover = new Part(new Drawing("mover", Square()), expectedB);
plate.Parts.Add(anchor);
plate.Parts.Add(mover);
var path = new Program();
var start = Swap(20, 40);
var across = Swap(70, 42);
var high = Swap(70, 60);
var tail = Swap(75, 60);
path.MoveTo(start.X, start.Y);
path.LineTo(across.X, across.Y);
path.LineTo(high.X, high.Y);
path.LineTo(tail.X, tail.Y);
var cutoff = new Part(new Drawing("neutral-open-cut", path) { IsCutOff = true }, origin);
plate.Parts.Add(cutoff);
// First segment is y=40+0.04*(x-20). At x=41 and x=44,
// y=40.84 and 40.96 are strictly INSIDE both squares. Signed origins
// and transposition preserve these independent interior witnesses.
List<Expander.Violation> violations;
if (expand)
{
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 0.1 });
violations = result.Violations;
Assert.Equal(0, result.AchievedSpacing);
}
else
{
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 0);
violations = result.Violations;
Assert.False(result.Converged,
$"Native path intersects both squares but reports converged with {violations.Count} violations");
}
Assert.Contains(violations, v => ReferenceEquals(v.B, cutoff) && v.Achieved < 0);
Assert.Equal(expectedA.X, anchor.Location.X);
Assert.Equal(expectedA.Y, anchor.Location.Y);
Assert.Equal(expectedB.X, mover.Location.X);
Assert.Equal(expectedB.Y, mover.Location.Y);
}
}
@@ -0,0 +1,86 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderRemoteObliquePathTests
{
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 transpose in new[] { false, true })
foreach (var cutoff in new[] { false, true })
foreach (var expand in new[] { false, true })
yield return new object[] { quadrant, transpose, cutoff, expand };
}
[Theory]
[MemberData(nameof(Cases))]
public void RemoteObliqueOpenPathMustNotPinSafeParallelGrowth(
int quadrant, bool transpose, bool cutoff, bool expand)
{
var origin = new Vector(quadrant is 2 or 3 ? -100 : 0,
quadrant is 3 or 4 ? -100 : 0);
Vector Swap(double x, double y) => transpose ? new Vector(y, x) : new Vector(x, y);
var plate = new Plate(new Size(100, 100))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0, 0),
};
var anchorPose = origin + Swap(40, 40);
var moverPose = origin + Swap(43, 40);
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)
{
var path = new Program();
var start = Swap(18, 32);
var middle = Swap(68, 64);
var high = Swap(68, 80);
var tail = Swap(75, 80);
path.MoveTo(start.X, start.Y);
path.LineTo(middle.X, middle.Y);
path.LineTo(high.X, high.Y);
path.LineTo(tail.X, tail.Y);
plate.Parts.Add(new Part(new Drawing("neutral-path", path) { IsCutOff = true }, origin));
}
// First line is y=.64*x+20.48. The anchor's nearest corner (40,42)
// has exact gap 4.08/sqrt(1+.64^2)>3; mover (43,42) >5.
Vector end;
if (expand)
{
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 2 });
end = mover.Location;
Assert.True(result.AchievedSpacing >= 1.99,
$"Remote cutoff pinned mover: spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}");
}
else
{
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 2);
end = result.Positions[1];
Assert.True(result.Converged,
$"Remote cutoff pinned mover: end=({end.X:R},{end.Y:R}), violations={result.Violations.Count}");
Assert.Equal(moverPose.X, mover.Location.X);
Assert.Equal(moverPose.Y, mover.Location.Y);
}
Assert.True(transpose ? end.Y >= moverPose.Y + 0.999 : end.X >= moverPose.X + 0.999);
Assert.Equal(anchorPose.X, anchor.Location.X);
Assert.Equal(anchorPose.Y, anchor.Location.Y);
}
}
+12 -7
View File
@@ -784,6 +784,10 @@ namespace OpenNest.Engine.Fill
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 worldOuter = CloneAt(setA.Outer, offsetA);
var worldHoles = setA.Holes.Count == 0 ? null : CloneAll(setA.Holes, offsetA);
if (OpenPathMaterial.Enters(worldOuter, worldHoles, lines))
return (-1e-4, new Vector(1, 0));
var cutoffBest = double.MaxValue;
var cutoffDirection = new Vector(1, 0);
foreach (var ring in setA.Rings)
@@ -795,6 +799,8 @@ namespace OpenNest.Engine.Fill
cutoffDirection = clearance.Direction;
}
}
if (setA.HasCurves && cutoffBest >= 0 && cutoffBest < 0.002)
return (-1e-4, cutoffDirection);
return (cutoffBest, cutoffDirection);
}
@@ -946,7 +952,6 @@ namespace OpenNest.Engine.Fill
continue;
if (stationary.Boundaries.Any(e => e is not Line))
return 0;
var movingBox = moving.NativeBounds.Translate(positions[index]);
foreach (var entity in stationary.Boundaries)
{
var line = (Line)entity;
@@ -954,13 +959,13 @@ namespace OpenNest.Engine.Fill
|| !double.IsFinite(line.EndPoint.X) || !double.IsFinite(line.EndPoint.Y)
|| line.StartPoint.X == line.EndPoint.X && line.StartPoint.Y == line.EndPoint.Y)
return 0;
// An already-intersecting open path has no certified
// exit; refuse uncertain motion, not the input itself.
var segmentBox = line.BoundingBox.Translate(positions[i]);
if (movingBox.Right > segmentBox.Left && movingBox.Left < segmentBox.Right
&& movingBox.Top >= segmentBox.Bottom && movingBox.Bottom <= segmentBox.Top)
return 0;
}
// A segment box can overlap a concave/oblique part box
// while its actual open path remains far away. Use the
// same strict material-entry verdict as entry floors
// and final reporting, not the box as a blocker.
if (PairClearance(index, i, positions).Distance < 0)
return 0;
var hit = SpatialQuery.DirectionalDistance(
CloneEntitiesAt(moving.Boundaries, positions[index]),
CloneEntitiesAt(stationary.Boundaries, positions[i]), direction,
@@ -0,0 +1,61 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Tests.Geometry;
public class OpenPathMaterialTests
{
private static Polygon Rectangle(double left, double bottom, double right, double top)
{
var polygon = new Polygon();
polygon.Vertices.AddRange(new[]
{
new Vector(left, bottom), new Vector(right, bottom),
new Vector(right, top), new Vector(left, top), new Vector(left, bottom),
});
polygon.UpdateBounds();
return polygon;
}
[Fact]
public void SlopedPathThroughSquareMaterialHasPositiveInteriorLength()
{
var square = Rectangle(40, 40, 42, 42);
var path = new List<Line> { new(new Vector(20, 40), new Vector(70, 42)) };
// At x=41, y=40.84 is strictly inside [40,42]^2.
Assert.True(OpenPathMaterial.Enters(square, null, path));
}
[Fact]
public void WhollyContainedSegmentIsNotMisreadAsClearBoundaryGap()
{
var square = Rectangle(0, 0, 10, 10);
var path = new List<Line> { new(new Vector(3, 5), new Vector(7, 5)) };
Assert.True(OpenPathMaterial.Enters(square, null, path));
}
[Fact]
public void BoundaryContactAndHoleInteriorRemainClear()
{
var square = Rectangle(0, 0, 10, 10);
var alongBoundary = new List<Line> { new(new Vector(-1, 10), new Vector(11, 10)) };
Assert.False(OpenPathMaterial.Enters(square, null, alongBoundary));
var hole = Rectangle(4, 4, 6, 6);
var insideHole = new List<Line> { new(new Vector(4.5, 5), new Vector(5.5, 5)) };
Assert.False(OpenPathMaterial.Enters(square, new List<Polygon> { hole }, insideHole));
var crossingHoleWall = new List<Line> { new(new Vector(5, 5), new Vector(7, 5)) };
Assert.True(OpenPathMaterial.Enters(square, new List<Polygon> { hole }, crossingHoleWall));
}
[Fact]
public void ObliquePathCanHaveOverlappingBoxesWithoutEnteringMaterial()
{
var square = Rectangle(40, 40, 42, 42);
var oblique = new List<Line> { new(new Vector(18, 32), new Vector(68, 64)) };
// At x=40 the line is y=46.08, not inside this square.
Assert.True(oblique[0].BoundingBox.Left < square.BoundingBox.Right);
Assert.True(oblique[0].BoundingBox.Bottom < square.BoundingBox.Top);
Assert.False(OpenPathMaterial.Enters(square, null, oblique));
}
}
+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. 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. 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.
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. Open paths that strictly enter part material stay as violations even at zero target; boundary contact and paths wholly inside holes remain legal. An open path's broad box is not a blocker when its real segments miss the part. 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.