mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-15 21:31:37 -04:00
fix(expand): certify disk and cutoff paths and report invalid input
This commit is contained in:
1 parent
cad1083287
commit
775ece5d21
3 files changed
+259
-10
No files matched your search
@@ -0,0 +1,137 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Fill;
|
||||
|
||||
public class ExpanderFinalQualityTests
|
||||
{
|
||||
private static Plate Sheet() => new(new Size(12, 30)) { EdgeSpacing = new Spacing(0, 0) };
|
||||
|
||||
private static Program Disk(double radius) =>
|
||||
ConvertGeometry.ToProgram(new List<Entity> { new Circle(0, 0, radius) });
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.73, 0.001)]
|
||||
[InlineData(0.017, 0.001)]
|
||||
[InlineData(0.73, 0.0001)]
|
||||
public void Expand_NativeDisks_MustReportOrRemoveRemainingOverlap(double angle, double depth)
|
||||
{
|
||||
var plate = Sheet();
|
||||
var anchor = Add(plate, Disk(2), 7, 6);
|
||||
var mover = Add(plate, Disk(2),
|
||||
7 + (4 - depth) * System.Math.Cos(angle),
|
||||
6 + (4 - depth) * System.Math.Sin(angle));
|
||||
|
||||
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||
new Expander.Options { MaxSpacing = 3 });
|
||||
var nativeGap = anchor.Location.DistanceTo(mover.Location) - 4;
|
||||
Assert.True(nativeGap >= -1e-8 || result.Violations.Any(v => v.Achieved < 0),
|
||||
$"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_SmallConcaveOverlap_MustNotClaimConvergedAtZero()
|
||||
{
|
||||
var plate = Sheet();
|
||||
var program = new Program();
|
||||
const double k = 0.0001;
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(6 * k, 0);
|
||||
program.LineTo(6 * k, 6 * k);
|
||||
program.LineTo(4 * k, 6 * k);
|
||||
program.LineTo(4 * k, 2 * k);
|
||||
program.LineTo(2 * k, 2 * k);
|
||||
program.LineTo(2 * k, 6 * k);
|
||||
program.LineTo(0, 6 * k);
|
||||
program.LineTo(0, 0);
|
||||
Add(plate, program, 10, 5);
|
||||
var mover = Add(plate, Rectangle(2 * k, 2 * k), 10 + k, 5 + 3 * k);
|
||||
// Left finger [10,10.0002] × [5.0002,5.0006] intersects the
|
||||
// mover [10.0001,10.0003] × [5.0003,5.0005] in positive area.
|
||||
var result = Expander.Separate(new List<Part> { mover }, plate, 0);
|
||||
Assert.False(result.Converged,
|
||||
$"Known material overlap; pose={result.Positions[0]}, violations={result.Violations.Count}");
|
||||
Assert.Contains(result.Violations, v => v.Achieved < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_RemoteRegeneratedCutoff_MustNotPinUnrelatedGrowth()
|
||||
{
|
||||
var plate = Sheet();
|
||||
var anchor = Add(plate, Rectangle(2, 2), 1, 4);
|
||||
var mover = Add(plate, Rectangle(2, 2), 4, 4);
|
||||
plate.CutOffs.Add(new CutOff(new Vector(0, 10), CutOffAxis.Horizontal));
|
||||
var settings = new CutOffSettings();
|
||||
plate.RegenerateCutOffs(settings);
|
||||
var cutPart = Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
|
||||
|
||||
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||
new Expander.Options { MaxSpacing = 3 });
|
||||
Assert.True(mover.Location.X >= 6 - 1e-4 && result.AchievedSpacing >= 2.99,
|
||||
$"Remote cutoff pinned horizontal move: x={mover.Location.X:R}, achieved={result.AchievedSpacing:R}, violations={result.Violations.Count}");
|
||||
Assert.Equal(0, cutPart.Location.X);
|
||||
Assert.Equal(0, cutPart.Location.Y);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
Assert.Single(plate.CutOffs);
|
||||
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_RegeneratedCutoffBlocksCrossingEvenWithClearFarEndpoint()
|
||||
{
|
||||
var plate = new Plate(new Size(20, 30)) { EdgeSpacing = new Spacing(0, 0) };
|
||||
var anchor = Add(plate, Rectangle(2, 2), 1, 1);
|
||||
var mover = Add(plate, Rectangle(2, 2), 1, 6);
|
||||
plate.CutOffs.Add(new CutOff(new Vector(0, 10), CutOffAxis.Horizontal));
|
||||
var settings = new CutOffSettings();
|
||||
plate.RegenerateCutOffs(settings);
|
||||
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
|
||||
|
||||
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, spacing: 12);
|
||||
Assert.False(result.Converged);
|
||||
Assert.True(result.Positions[1].Y + 2 <= 10 + 1e-6,
|
||||
$"Mover crossed regenerated cut path: y={result.Positions[1].Y:R}");
|
||||
Assert.Equal(6, mover.Location.Y);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
Assert.Single(plate.CutOffs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_OutsideUnchangedMover_MustNotReturnUnqualifiedSuccess()
|
||||
{
|
||||
var plate = Sheet();
|
||||
var anchor = Add(plate, Rectangle(2, 2), 1, 4);
|
||||
var mover = Add(plate, Rectangle(2, 2), 35, 4);
|
||||
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||
new Expander.Options { MaxSpacing = 3 });
|
||||
|
||||
Assert.True(mover.BoundingBox.Right > 30); // The invalid entry stays unchanged.
|
||||
Assert.Equal(35, mover.Location.X);
|
||||
Assert.Equal(0, result.AchievedSpacing);
|
||||
var edge = Assert.Single(result.Violations.Where(v => ReferenceEquals(v.A, mover)));
|
||||
Assert.Null(edge.B);
|
||||
Assert.True(edge.BlockedByEdge);
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,11 @@ namespace OpenNest.Engine.Fill
|
||||
public sealed class Violation
|
||||
{
|
||||
public Part A;
|
||||
public Part B;
|
||||
public Part B; // null for an invalid work-area pose
|
||||
|
||||
/// <summary>Clearance reached (negative for unresolvable overlaps;
|
||||
/// sub-grid clamp overlaps use a negative marker, not a measured depth).</summary>
|
||||
/// <summary>Signed clearance for a pair, or negative overshoot
|
||||
/// for an invalid work-area pose. Sub-grid uncertain material uses a
|
||||
/// negative marker, not a measured penetration depth.</summary>
|
||||
public double Achieved;
|
||||
|
||||
/// <summary>True when a work-area edge, not a part, blocked the last needed move.</summary>
|
||||
@@ -60,15 +61,17 @@ namespace OpenNest.Engine.Fill
|
||||
/// <summary>
|
||||
/// Minimum part-to-part clearance the returned layout satisfies,
|
||||
/// never below zero. 0 with violations means the layout was only
|
||||
/// cleaned up as far as possible, not opened up.
|
||||
/// cleaned up as far as possible or still has invalid input (such as
|
||||
/// a selected part outside the work area), not opened up.
|
||||
/// </summary>
|
||||
public double AchievedSpacing;
|
||||
|
||||
/// <summary>True when the run was cancelled; no positions were changed.</summary>
|
||||
public bool Cancelled;
|
||||
|
||||
/// <summary>Pairs still below the attempted spacing, including
|
||||
/// unresolved negative clearances when no positive target was feasible.</summary>
|
||||
/// <summary>Pairs still below the attempted spacing and invalid
|
||||
/// work-area poses, including unresolved negative clearances when no
|
||||
/// positive target was feasible.</summary>
|
||||
public List<Violation> Violations = new();
|
||||
}
|
||||
|
||||
@@ -277,7 +280,9 @@ namespace OpenNest.Engine.Fill
|
||||
}
|
||||
Apply(selected, entry, positions);
|
||||
|
||||
result.AchievedSpacing = System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured));
|
||||
result.AchievedSpacing = final.Violations.Any(v => v.B == null)
|
||||
? 0
|
||||
: System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured));
|
||||
result.Violations = final.Violations;
|
||||
return result;
|
||||
}
|
||||
@@ -496,6 +501,7 @@ namespace OpenNest.Engine.Fill
|
||||
);
|
||||
}
|
||||
|
||||
violations.AddRange(context.WorkAreaViolations(positions));
|
||||
return (violations.Count == 0, positions, violations);
|
||||
}
|
||||
|
||||
@@ -766,6 +772,38 @@ namespace OpenNest.Engine.Fill
|
||||
var offsetA = positions[indexA];
|
||||
var offsetB = positions[indexB];
|
||||
|
||||
if (indexB < clampStart && !parts[indexB].BaseDrawing.IsCutOff &&
|
||||
(setA.Contact?.IsResolved != true || setB.Contact?.IsResolved != true))
|
||||
{
|
||||
// Unresolved or sub-scale material cannot be pronounced
|
||||
// clear by a tolerant overlap kernel. Native envelopes
|
||||
// intersecting at positive area yield an honest unresolved
|
||||
// violation; disjoint envelopes may still use the usual
|
||||
// boundary distance. Never infer a penetration depth here.
|
||||
var boxA = setA.NativeBounds?.Translate(offsetA);
|
||||
var boxB = setB.NativeBounds?.Translate(offsetB);
|
||||
if (boxA == null || boxB == null ||
|
||||
!double.IsFinite(boxA.Left) || !double.IsFinite(boxB.Left) ||
|
||||
boxA.Right > boxB.Left && boxB.Right > boxA.Left &&
|
||||
boxA.Top > boxB.Bottom && boxB.Top > boxA.Bottom)
|
||||
return (-1e-4, new Vector(1, 0));
|
||||
}
|
||||
|
||||
if (setA.Holes.Count == 0 && setB.Holes.Count == 0 &&
|
||||
TryNativeDisk(setA, out var centerA, out var radiusA) &&
|
||||
TryNativeDisk(setB, out var centerB, out var radiusB))
|
||||
{
|
||||
// Flattened polygons can miss a shallow arc overlap and
|
||||
// report a positive boundary gap. Full disks admit an exact
|
||||
// signed clearance and radial direction at every scratch pose.
|
||||
var delta = centerA + offsetA - centerB - offsetB;
|
||||
var length = System.Math.Sqrt(delta.DotProduct(delta));
|
||||
if (!double.IsFinite(length))
|
||||
return (0, new Vector(1, 0));
|
||||
return (length - radiusA - radiusB,
|
||||
length > 1e-12 ? delta / length : new Vector(1, 0));
|
||||
}
|
||||
|
||||
if (indexB >= clampStart &&
|
||||
KeepOutOverlap.NeedsToleranceFreeTest(clampBoxes[indexB - clampStart]))
|
||||
{
|
||||
@@ -865,6 +903,43 @@ namespace OpenNest.Engine.Fill
|
||||
if (i == index)
|
||||
continue;
|
||||
var stationary = shapes[i];
|
||||
if (parts[i].BaseDrawing.IsCutOff)
|
||||
{
|
||||
// Regenerated cut-offs are intentional open cut paths,
|
||||
// not malformed closed material. The unresolved contact
|
||||
// classifier blocks their first native line contact;
|
||||
// remote paths with no events do not pin every mover.
|
||||
// Never treat an unknown/nonlinear cutoff as empty space.
|
||||
if (stationary.Boundaries.Count == 0)
|
||||
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;
|
||||
if (!double.IsFinite(line.StartPoint.X) || !double.IsFinite(line.StartPoint.Y)
|
||||
|| !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;
|
||||
}
|
||||
var hit = SpatialQuery.DirectionalDistance(
|
||||
CloneEntitiesAt(moving.Boundaries, positions[index]),
|
||||
CloneEntitiesAt(stationary.Boundaries, positions[i]), direction,
|
||||
new SlideContactClassifier(moving.Contact, positions[index],
|
||||
stationary.Contact, positions[i]));
|
||||
if (double.IsNaN(hit) || hit < 0)
|
||||
return 0;
|
||||
if (hit < double.MaxValue)
|
||||
room = System.Math.Min(room, hit);
|
||||
continue;
|
||||
}
|
||||
if (i >= clampStart && KeepOutOverlap.NeedsToleranceFreeTest(clampBoxes[i - clampStart]))
|
||||
{
|
||||
// Slide contact and polygon overlap have tolerances larger
|
||||
@@ -885,6 +960,17 @@ namespace OpenNest.Engine.Fill
|
||||
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]));
|
||||
if (!alreadyOverlapping && moving.Holes.Count == 0 && stationary.Holes.Count == 0 &&
|
||||
TryNativeDisk(moving, out var mc, out var mr) &&
|
||||
TryNativeDisk(stationary, out var sc, out var sr))
|
||||
{
|
||||
var delta = mc + origin - sc - positions[i];
|
||||
var distanceSquared = delta.DotProduct(delta);
|
||||
var contactSquared = (mr + sr) * (mr + sr);
|
||||
if (!double.IsFinite(distanceSquared) || !double.IsFinite(contactSquared))
|
||||
return 0;
|
||||
alreadyOverlapping = distanceSquared < contactSquared;
|
||||
}
|
||||
if (alreadyOverlapping)
|
||||
{
|
||||
// An initially overlapping convex pair needs a direct
|
||||
@@ -1084,6 +1170,28 @@ namespace OpenNest.Engine.Fill
|
||||
return sign != 0;
|
||||
}
|
||||
|
||||
public List<Violation> WorkAreaViolations(List<Vector> positions)
|
||||
{
|
||||
var violations = new List<Violation>();
|
||||
for (var i = 0; i < moverCount; i++)
|
||||
{
|
||||
if (InsideWorkArea(i, positions[i]))
|
||||
continue;
|
||||
var box = localBoxes[i].Translate(positions[i]);
|
||||
var overshoot = System.Math.Max(
|
||||
System.Math.Max(workArea.Left - box.Left, box.Right - workArea.Right),
|
||||
System.Math.Max(workArea.Bottom - box.Bottom, box.Top - workArea.Top));
|
||||
violations.Add(new Violation
|
||||
{
|
||||
A = parts[i],
|
||||
B = null,
|
||||
Achieved = double.IsFinite(overshoot) ? -System.Math.Max(0, overshoot) : -1e-4,
|
||||
BlockedByEdge = true,
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
// Smaller than the 0.002 tessellation margin; immutable entry floors
|
||||
// prevent this comparison tolerance accumulating over iterations.
|
||||
private const double FloorTolerance = 1e-6;
|
||||
@@ -1106,7 +1214,11 @@ namespace OpenNest.Engine.Fill
|
||||
private bool InsideWorkArea(int index, Vector position)
|
||||
{
|
||||
var box = localBoxes[index].Translate(position);
|
||||
return box.Left >= workArea.Left - 1e-9
|
||||
return double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
|
||||
&& double.IsFinite(workArea.Left) && double.IsFinite(workArea.Right)
|
||||
&& double.IsFinite(workArea.Bottom) && double.IsFinite(workArea.Top)
|
||||
&& box.Left >= workArea.Left - 1e-9
|
||||
&& box.Right <= workArea.Right + 1e-9
|
||||
&& box.Bottom >= workArea.Bottom - 1e-9
|
||||
&& box.Top <= workArea.Top + 1e-9;
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
`Plate > Expand Spacing` moves selected parts by translation only. The first selected part is the anchor for selected pairs; the later selected member moves, and nonselected parts remain fixed. The plate retains its own `EdgeSpacing` floor; the command does not change `PartSpacing` or `EdgeSpacing`.
|
||||
|
||||
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.
|
||||
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. 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.
|
||||
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.
|
||||
Reference in new issue
Block a user