diff --git a/AGENTS.md b/AGENTS.md
index c25c16b8..35126636 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,7 +44,7 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
## Geometry and ownership safeguards
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
-- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check and the pre-post part-spacing review share Core's `SpacingOutline` for candidates; the review then confirms each one on finely flattened native contours, so it never warns where Auto Nest's check would pass. Do not loosen tolerances to hide failures.
+- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check, the runner's candidate validation and the pre-post part-spacing review all find spacing candidates with Core's `SpacingOutline` and confirm them on `NativeContours` (the outline circumscribes arcs by more than the slack), so they agree; `NestLayoutCheck.Clears` skips the confirmation and stays stricter for engine certification. Do not loosen tolerances to hide failures.
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
diff --git a/OpenNest.Core/Diagnostics/PartSpacingReview.cs b/OpenNest.Core/Diagnostics/PartSpacingReview.cs
index 0ccb9c00..6d1cf27c 100644
--- a/OpenNest.Core/Diagnostics/PartSpacingReview.cs
+++ b/OpenNest.Core/Diagnostics/PartSpacingReview.cs
@@ -15,20 +15,16 @@ namespace OpenNest.Diagnostics;
/// Nest's layout check finds them: each part's clean nesting material
/// (, leads excluded) outlined by ,
/// the leftmost of a pair inflated by . That outline
-/// circumscribes arcs by up to its chord tolerance, more than the slack, and engines add a margin
-/// for it; interactive fills place native arcs exactly at the spacing. So each candidate is
-/// confirmed on the native contours flattened at and reported only when
-/// that gap is short of the spacing by more than the slack. Zero spacing enforces nothing, so
-/// contact stays valid.
+/// circumscribes arcs by up to its chord tolerance, more than the slack, and interactive fills
+/// place native arcs exactly at the spacing. So each candidate is confirmed on
+/// , as the layout check confirms it, and reported only when that gap
+/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
+/// valid.
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
/// that check. The sheet edge and clamps are not spacing-checked here.
///
internal static class PartSpacingReview
{
- /// Chord tolerance of the confirming measurement. Circumscribed on both sides, it
- /// understates a gap by at most twice this, a tenth of .
- internal const double FineTolerance = SpacingOutline.SpacingSlack / 20;
-
internal static void Add(Plate plate, int plateNumber, IReadOnlyList parts,
IReadOnlyList partNumbers, PlateOverlapReport overlap, List findings,
CancellationToken cancellationToken)
@@ -49,7 +45,7 @@ internal static class PartSpacingReview
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
var profiles = new Dictionary<(Program, long), ShapeProfile>();
var shapes = new ShapeProfile[parts.Count];
- var fine = new FineRings[parts.Count];
+ var fine = new NativeContours[parts.Count];
var raw = new SpacingOutline[parts.Count];
var inflated = new SpacingOutline[parts.Count];
for (var i = 0; i < parts.Count; i++)
@@ -99,9 +95,9 @@ internal static class PartSpacingReview
Vector marker;
try
{
- fine[i] ??= FineRings.Of(shapes[i], parts[i].Location);
- fine[j] ??= FineRings.Of(shapes[j], parts[j].Location);
- gap = Gap(fine[i], fine[j], spacing, out marker);
+ fine[i] ??= NativeContours.Of(shapes[i], parts[i].Location);
+ fine[j] ??= NativeContours.Of(shapes[j], parts[j].Location);
+ gap = fine[i].GapTo(fine[j], spacing, out marker);
}
catch (Exception exception) when (IsInvalid(exception))
{
@@ -148,73 +144,6 @@ internal static class PartSpacingReview
return profile;
}
- /// Closest boundary approach between two parts, including a part inside the other's
- /// cutout; is the middle of that gap. Only edges within
- /// of the other part's bounds can be closer than it, so the rest are
- /// skipped. Infinity when no edges are that near.
- private static double Gap(FineRings a, FineRings b, double reach, out Vector marker)
- {
- var best = double.PositiveInfinity;
- var middle = a.Bounds.Center;
- var perimeterA = Near(a.Perimeter, b.Bounds, reach);
- var perimeterB = Near(b.Perimeter, a.Bounds, reach);
- Measure(perimeterA, perimeterB);
- foreach (var hole in a.Holes)
- Measure(Near(hole, b.Bounds, reach), perimeterB);
- foreach (var hole in b.Holes)
- Measure(perimeterA, Near(hole, a.Bounds, reach));
- marker = middle;
- return best;
-
- void Measure(List ringA, List ringB)
- {
- if (ringA.Count == 0 || ringB.Count == 0)
- return;
- var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
- if (distance < best)
- {
- best = distance;
- middle = (onA + onB) * 0.5;
- }
- }
- }
-
- private static List Near(List lines, Box other, double reach) =>
- lines.Where(line =>
- System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
- && System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
- && System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
- && System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
-
- /// A part's native contours in world space, flattened at .
- /// Circumscribed, so the measured gap never exceeds the true one by more than rounding.
- private sealed class FineRings
- {
- public List Perimeter { get; private init; }
- public List> Holes { get; private init; }
- public Box Bounds { get; private init; }
-
- public static FineRings Of(ShapeProfile profile, Vector location)
- {
- var perimeter = World(profile.Perimeter, location, out var bounds);
- return new FineRings
- {
- Perimeter = perimeter.ToLines(),
- Holes = profile.Cutouts.Select(cutout => World(cutout, location, out _).ToLines()).ToList(),
- Bounds = bounds,
- };
- }
-
- private static Polygon World(Shape shape, Vector location, out Box bounds)
- {
- var polygon = ClipperBridge.Flatten(shape, FineTolerance, true);
- polygon.Offset(location);
- polygon.UpdateBounds();
- bounds = polygon.BoundingBox;
- return polygon;
- }
- }
-
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
private static string Name(Part part) => part.BaseDrawing.Name;
diff --git a/OpenNest.Core/Geometry/NativeContours.cs b/OpenNest.Core/Geometry/NativeContours.cs
new file mode 100644
index 00000000..7c6bc722
--- /dev/null
+++ b/OpenNest.Core/Geometry/NativeContours.cs
@@ -0,0 +1,107 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace OpenNest.Geometry
+{
+ ///
+ /// A part's native contours in world space, flattened at , to
+ /// confirm a part-spacing candidate that found. The outline
+ /// circumscribes arcs by up to its chord tolerance, more than ,
+ /// so two convex arcs facing each other exactly at the spacing can overlap there; these
+ /// contours measure that gap to within a tenth of the slack. Every spacing verdict (Auto
+ /// Nest's layout check, the whole-job runner's candidate validation and the pre-post review)
+ /// confirms with them, so none can disagree.
+ ///
+ public sealed class NativeContours
+ {
+ /// Chord tolerance of the confirming measurement. Circumscribed on both sides, it
+ /// understates a gap by at most twice this, a tenth of .
+ public const double ChordTolerance = SpacingOutline.SpacingSlack / 20;
+
+ private Polygon perimeter;
+ private List holes;
+ private List perimeterLines;
+ private List> holeLines;
+
+ private NativeContours() { }
+
+ public Box Bounds { get; private set; }
+
+ /// Flattens (already rotated) at .
+ public static NativeContours Of(ShapeProfile profile, Vector location)
+ {
+ var perimeter = World(profile.Perimeter, location);
+ var holes = profile.Cutouts.Select(cutout => World(cutout, location)).ToList();
+ return new NativeContours
+ {
+ perimeter = perimeter,
+ holes = holes,
+ perimeterLines = perimeter.ToLines(),
+ holeLines = holes.Select(hole => hole.ToLines()).ToList(),
+ Bounds = perimeter.BoundingBox,
+ };
+ }
+
+ ///
+ /// True when the two parts are closer than less
+ /// , or share material: a boundary within that
+ /// gap, or one part wholly inside the other's material rather than in a cutout.
+ ///
+ public bool TooClose(NativeContours other, double spacing)
+ {
+ if (GapTo(other, spacing, out _) < spacing - SpacingOutline.SpacingSlack)
+ return true;
+ // No boundaries meet, so either part is wholly inside or outside the other's material.
+ return other.InMaterial(perimeter.Vertices[0]) || InMaterial(other.perimeter.Vertices[0]);
+ }
+
+ /// Closest boundary approach between two parts, including a part inside the other's
+ /// cutout; is the middle of that gap. Only edges within
+ /// of the other part's bounds can be closer than it, so the rest are
+ /// skipped. Infinity when no edges are that near.
+ public double GapTo(NativeContours other, double reach, out Vector marker)
+ {
+ var best = double.PositiveInfinity;
+ var middle = Bounds.Center;
+ var perimeterA = Near(perimeterLines, other.Bounds, reach);
+ var perimeterB = Near(other.perimeterLines, Bounds, reach);
+ Measure(perimeterA, perimeterB);
+ foreach (var hole in holeLines)
+ Measure(Near(hole, other.Bounds, reach), perimeterB);
+ foreach (var hole in other.holeLines)
+ Measure(perimeterA, Near(hole, Bounds, reach));
+ marker = middle;
+ return best;
+
+ void Measure(List ringA, List ringB)
+ {
+ if (ringA.Count == 0 || ringB.Count == 0)
+ return;
+ var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
+ if (distance < best)
+ {
+ best = distance;
+ middle = (onA + onB) * 0.5;
+ }
+ }
+ }
+
+ private bool InMaterial(Vector point) =>
+ perimeter.ContainsPoint(point) && !holes.Any(hole => hole.ContainsPoint(point));
+
+ private static List Near(List lines, Box other, double reach) =>
+ lines.Where(line =>
+ System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
+ && System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
+ && System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
+ && System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
+
+ private static Polygon World(Shape shape, Vector location)
+ {
+ var polygon = ClipperBridge.Flatten(shape, ChordTolerance, true);
+ polygon.Offset(location);
+ polygon.UpdateBounds();
+ return polygon;
+ }
+ }
+}
diff --git a/OpenNest.Engine.Tests/Jobs/ArcSpacingAgreementTests.cs b/OpenNest.Engine.Tests/Jobs/ArcSpacingAgreementTests.cs
new file mode 100644
index 00000000..3c42cdbc
--- /dev/null
+++ b/OpenNest.Engine.Tests/Jobs/ArcSpacingAgreementTests.cs
@@ -0,0 +1,99 @@
+using OpenNest.CNC;
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.Jobs.Placement;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Engine.Tests.Jobs;
+
+///
+/// The layout check and the runner's candidate validation must judge part spacing alike. Two
+/// D-shaped parts face each other arc to arc, as Fill interlocks them: their spacing outlines
+/// circumscribe the arcs and overlap even when the native arcs are exactly at the spacing.
+///
+public class ArcSpacingAgreementTests
+{
+ private const double Spacing = 0.5;
+ private const double Radius = 2.5;
+
+ /// 3 x 5 block closed by a semicircular arc centered at (3, 2.5).
+ private static Program DShape()
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(3, 0);
+ program.ArcTo(3, 5, 3, Radius, RotationType.CCW);
+ program.LineTo(0, 5);
+ program.LineTo(0, 0);
+ return program;
+ }
+
+ private static NestJob Job() =>
+ new(
+ new[] { new NestJobPart("arc", PartGeometrySnapshot.FromProgram(DShape()), 2) },
+ new[] { new NestPlateStock("stock", new Size(30, 50), 1, Spacing, new Spacing(1, 1, 1, 1)) },
+ new NestJobOptions("Fill")
+ );
+
+ ///
+ /// The pair Fill placed in the irregular-job pins: arcs at 95 and 275 degrees, centers on a
+ /// line inside both arcs' spans, so the native gap is the center distance less both radii.
+ ///
+ private static NestJobPlacement[] FacingArcs(double gap)
+ {
+ var first = new Vector(20, 10);
+ var direction = new Vector(0.566512327859782, 5.470746181498729);
+ var length = System.Math.Sqrt(direction.X * direction.X + direction.Y * direction.Y);
+ var distance = 2 * Radius + gap;
+ var second = new Vector(first.X + direction.X / length * distance,
+ first.Y + direction.Y / length * distance);
+ return [Pose(0, first, Angle.ToRadians(95)), Pose(1, second, Angle.ToRadians(275))];
+ }
+
+ private static NestJobPlacement Pose(int instance, Vector arcCenter, double rotation)
+ {
+ var (c, s) = (System.Math.Cos(rotation), System.Math.Sin(rotation));
+ // The arc's local center (3, 2.5), rotated, lands on arcCenter.
+ var x = arcCenter.X - (3 * c - Radius * s);
+ var y = arcCenter.Y - (3 * s + Radius * c);
+ return new NestJobPlacement("arc", instance, x, y, rotation);
+ }
+
+ private static IReadOnlyList Violations(NestJob job, NestJobPlacement[] poses)
+ {
+ var stock = job.Plates[0];
+ var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
+ new[] { new NestJobPlateResult(0, stock, poses) },
+ new[] { new PartFulfillment("arc", 2, 2, 0) }, new[] { new StockUsage(stock.Id, 1, null) });
+ return NestLayoutCheck.Violations(job, result);
+ }
+
+ private static void Validate(NestJob job, NestJobPlacement[] poses) =>
+ NestJobValidator.ValidateCandidate(new PlateCandidate(poses), job.Plates[0],
+ new Dictionary { ["arc"] = 2 }, job.Parts.ToDictionary(p => p.Id));
+
+ [Fact]
+ public void ArcsExactlyAtSpacing_PassBothValidators()
+ {
+ var job = Job();
+ var poses = FacingArcs(Spacing);
+ var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
+
+ // The circumscribed outlines alone put this pair too close; only the native contours clear it.
+ Assert.False(NestLayoutCheck.Clears(geometry, poses[0], geometry, poses[1], Spacing));
+ Assert.Empty(Violations(job, poses));
+ Validate(job, poses);
+ }
+
+ [Fact]
+ public void ArcsCloserThanSpacingLessSlack_FailBothValidators()
+ {
+ var job = Job();
+ // Short by three slacks: the inscribed arcs the runner used to measure hid up to 0.002.
+ var poses = FacingArcs(Spacing - 3 * NestTolerances.SpacingSlack);
+
+ Assert.Single(Violations(job, poses));
+ var error = Assert.Throws(() => Validate(job, poses));
+ Assert.Contains("spacing", error.Message);
+ }
+}
diff --git a/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs b/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs
index eb2da25d..e2a61bde 100644
--- a/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs
@@ -402,9 +402,7 @@ public class FillStackCharacterizationTests
var job = IrregularJob();
var result = new FixedStrategyNestingEngine(strategy).Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
- // Not LayoutAssert.Valid: today the Fill and Vertical Remnant layouts of this job
- // fail NestLayoutCheck's arc-to-arc spacing check on an interlocked arc pair, though
- // the runner's placement validator accepts them. These tests pin current behavior.
+ LayoutAssert.Valid(job, result);
return Layout(result);
});
diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
index cadaf846..a7dcba71 100644
--- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
+++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
@@ -9,9 +9,9 @@ namespace OpenNest.Engine.Jobs;
internal static class NestJobPlacementValidator
{
private const double Epsilon = 0.0000001;
- // Flattening for placement overlap/spacing checks: the same 0.001 the benchmark's
- // NestValidator and Part.Intersects use. Arcs are inscribed, so a layout placed exactly at
- // the spacing passes; outward arcs may come up to this much closer than the spacing.
+ // Flattening for the placement overlap check: the same 0.001 Part.Intersects uses. Part
+ // spacing is judged by NestLayoutCheck.KeepsSpacing instead, so this validator and the
+ // layout check accept exactly the same pairs.
private const double PlacementChordTolerance = NestTolerances.ValidationOutline;
internal static void ValidateCandidate(
@@ -25,6 +25,10 @@ internal static class NestJobPlacementValidator
throw new InvalidOperationException("The plate nester returned a null candidate.");
var counts = new Dictionary(StringComparer.Ordinal);
var placed = new List();
+ // Spacing outlines per placement, prepared only once a neighbour comes near.
+ var poses = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
+ var spaced = new List();
+ var geometries = new Dictionary(StringComparer.Ordinal);
var sources = new Dictionary(StringComparer.Ordinal);
foreach (var placement in candidate.Placements)
{
@@ -51,14 +55,22 @@ internal static class NestJobPlacementValidator
);
if (!sources.TryGetValue(placement.PartId, out var source))
- sources[placement.PartId] = source = CreateShape(part.Geometry);
+ {
+ var geometry = JobPartGeometry.Read(part.Geometry);
+ geometries[placement.PartId] = geometry;
+ sources[placement.PartId] = source = new ShapeTopology(geometry.Perimeter,
+ new List(geometry.Cutouts));
+ }
var shape = Transform(source, placement);
if (!FitsWorkArea(shape, stock))
throw new InvalidOperationException(
"Candidate placement falls outside the usable stock area."
);
- foreach (var other in placed)
+ poses.Add((geometries[placement.PartId], placement));
+ spaced.Add(null);
+ for (var k = 0; k < placed.Count; k++)
{
+ var other = placed[k];
// Analytic contour bounds give a conservative lower bound on clearance.
// Do not polygonize or compare every hole edge for distant placements.
if (BoundsDistance(shape.Perimeter.BoundingBox, other.Perimeter.BoundingBox)
@@ -66,8 +78,9 @@ internal static class NestJobPlacementValidator
continue;
if (Overlaps(shape, other))
throw new InvalidOperationException("Candidate placements overlap.");
+ // Earlier placement first, so ties inflate it as the layout check's sweep does.
if (stock.PartSpacing > 0
- && Distance(shape, other) < stock.PartSpacing - NestTolerances.SpacingSlack)
+ && !NestLayoutCheck.KeepsSpacing(Spaced(k), Spaced(placed.Count)))
throw new InvalidOperationException(
"Candidate placements violate required part spacing."
);
@@ -76,12 +89,10 @@ internal static class NestJobPlacementValidator
placed.Add(shape);
counts[placement.PartId] = count + 1;
}
- }
- private static ShapeTopology CreateShape(PartGeometrySnapshot geometry)
- {
- var shape = JobPartGeometry.Read(geometry);
- return new ShapeTopology(shape.Perimeter, shape.Profile.Cutouts);
+ NestLayoutCheck.PreparedPlacement Spaced(int index) =>
+ spaced[index] ??= NestLayoutCheck.Prepare(poses[index].Geometry, poses[index].Pose,
+ stock.PartSpacing);
}
private static ShapeTopology Transform(ShapeTopology source, NestJobPlacement placement)
@@ -147,49 +158,6 @@ internal static class NestJobPlacementValidator
return System.Math.Sqrt(x * x + y * y);
}
- private static double Distance(ShapeTopology left, ShapeTopology right)
- {
- var result = double.PositiveInfinity;
- foreach (var leftContour in left.Contours)
- foreach (var rightContour in right.Contours)
- if (BoundsDistance(leftContour.Bounds, rightContour.Bounds) < result)
- result = System.Math.Min(
- result,
- BoundaryDistance(leftContour.Lines, rightContour.Lines)
- );
- return result;
- }
-
- private static double BoundaryDistance(List left, List right)
- {
- var result = double.PositiveInfinity;
- foreach (var leftLine in left)
- {
- foreach (var rightLine in right)
- {
- if (leftLine.Intersects(rightLine))
- return 0;
- result = System.Math.Min(
- result,
- leftLine.ClosestPointTo(rightLine.StartPoint).DistanceTo(rightLine.StartPoint)
- );
- result = System.Math.Min(
- result,
- leftLine.ClosestPointTo(rightLine.EndPoint).DistanceTo(rightLine.EndPoint)
- );
- result = System.Math.Min(
- result,
- rightLine.ClosestPointTo(leftLine.StartPoint).DistanceTo(leftLine.StartPoint)
- );
- result = System.Math.Min(
- result,
- rightLine.ClosestPointTo(leftLine.EndPoint).DistanceTo(leftLine.EndPoint)
- );
- }
- }
- return result;
- }
-
private sealed class ShapeTopology(Shape perimeter, List cutouts)
{
private Contour[] contours;
@@ -228,20 +196,16 @@ internal static class NestJobPlacementValidator
}
///
- /// A contour flattened once, at , for the overlap and
- /// spacing checks against every other placement.
+ /// A contour flattened once, at , for the overlap check
+ /// against every other placement.
///
private sealed class Contour
{
internal Contour(Shape shape)
{
Polygon = shape.ToPolygonWithTolerance(PlacementChordTolerance);
- Bounds = Polygon.BoundingBox;
- Lines = Polygon.ToLines();
}
- internal Box Bounds { get; }
internal Polygon Polygon { get; }
- internal List Lines { get; }
}
}
diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
index 5c51c0aa..55f459f9 100644
--- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs
+++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
@@ -127,7 +127,7 @@ public static class NestLayoutCheck
{
var ap = Transform(a, pa.Rotation);
var al = new Vector(pa.X, pa.Y);
- return new PreparedPlacement(ap, al, Outline(ap, al, 0), InflationFor(spacing));
+ return new PreparedPlacement(ap, al, Outline(ap, al, 0), spacing);
}
/// Same answer as Clears(a, pa, b, pb, spacing) for the part and spacing
@@ -148,6 +148,25 @@ public static class NestLayoutCheck
|| !Overlaps(a.Inflated, br);
}
+ ///
+ /// The layout check's spacing verdict for two prepared parts, prepared with the same positive
+ /// spacing: true unless the leftmost part's inflated outline overlaps the other's raw outline
+ /// (ties inflate ) and their confirm the gap
+ /// is short of the spacing by more than the slack. The whole-job runner validates candidates
+ /// with it, so it accepts exactly the pairs
+ /// accepts. Unlike ,
+ /// it does not reject arcs the outline alone puts too close.
+ ///
+ internal static bool KeepsSpacing(PreparedPlacement a, PreparedPlacement b)
+ {
+ var (left, right) = a.Raw.Perimeter.BoundingBox.Left > b.Raw.Perimeter.BoundingBox.Left
+ ? (b, a)
+ : (a, b);
+ return !BoxesTouch(left.Inflated.Perimeter.BoundingBox, right.Raw.Perimeter.BoundingBox)
+ || !Overlaps(left.Inflated, right.Raw)
+ || !left.Native.TooClose(right.Native, left.Spacing);
+ }
+
/// A part at a pose with its outlines prepared once; see
/// .
public sealed class PreparedPlacement
@@ -155,20 +174,25 @@ public static class NestLayoutCheck
private readonly ShapeProfile profile;
private readonly Vector location;
private SpacingOutline inflated;
+ private NativeContours native;
- internal PreparedPlacement(ShapeProfile profile, Vector location, SpacingOutline raw, double inflateBy)
+ internal PreparedPlacement(ShapeProfile profile, Vector location, SpacingOutline raw, double spacing)
{
this.profile = profile;
this.location = location;
Raw = raw;
- InflateBy = inflateBy;
+ Spacing = spacing;
+ InflateBy = InflationFor(spacing);
}
internal SpacingOutline Raw { get; }
+ internal double Spacing { get; }
internal double InflateBy { get; }
internal SpacingOutline Inflated =>
inflated ??= InflateBy > Tolerance.Epsilon ? Outline(profile, location, InflateBy) : Raw;
+
+ internal NativeContours Native => native ??= NativeContours.Of(profile, location);
}
private static ShapeProfile Transform(JobPartGeometry geometry, double rotation)
@@ -497,7 +521,8 @@ public static class NestLayoutCheck
/// nested inside another part's cutout (part-in-part) is legal as long as
/// it clears the cutout's edge by the spacing. Pairs are pruned with an
/// X-sorted sweep over bounding boxes so only neighbours reach the
- /// polygon clipper.
+ /// polygon clipper. A pair the outlines flag at a positive spacing is
+ /// reported only when confirm it.
///
private static void ValidateSpacing(
List parts,
@@ -506,14 +531,21 @@ public static class NestLayoutCheck
List result
)
{
+ var profiles = new ShapeProfile[parts.Count];
var raw = new SpacingOutline[parts.Count];
var inflated = new SpacingOutline[parts.Count];
+ var native = new NativeContours[parts.Count];
var inflateBy = InflationFor(spacing);
for (var i = 0; i < parts.Count; i++)
{
- raw[i] = Outline(parts[i], 0);
- inflated[i] = inflateBy > Tolerance.Epsilon ? Outline(parts[i], inflateBy) : raw[i];
+ profiles[i] = Profile(parts[i]);
+ if (profiles[i] == null)
+ continue;
+ raw[i] = Outline(profiles[i], parts[i].Location, 0);
+ inflated[i] = inflateBy > Tolerance.Epsilon
+ ? Outline(profiles[i], parts[i].Location, inflateBy)
+ : raw[i];
}
var order = Enumerable
@@ -542,12 +574,22 @@ public static class NestLayoutCheck
// Inflating one side by the full spacing covers both cases: part j
// inside part i's (shrunk) cutout, or part i's inflated outline
// inside part j's raw cutout.
- if (Overlaps(inflated[i], raw[j]))
+ if (!Overlaps(inflated[i], raw[j]))
+ continue;
+
+ // The outline circumscribes arcs by more than the slack, so confirm a spacing
+ // candidate on the native contours. Zero spacing has no gap to confirm.
+ if (inflateBy > Tolerance.Epsilon)
{
- result.Add(
- $"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})"
- );
+ native[i] ??= NativeContours.Of(profiles[i], parts[i].Location);
+ native[j] ??= NativeContours.Of(profiles[j], parts[j].Location);
+ if (!native[i].TooClose(native[j], spacing))
+ continue;
}
+
+ result.Add(
+ $"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})"
+ );
}
}
}
@@ -585,12 +627,22 @@ public static class NestLayoutCheck
/// conservative, since it has no room for another part at the required
/// spacing anyway. Arcs are flattened conservatively (perimeter arcs
/// circumscribed, cutout arcs inscribed) but nothing is padded. Callers inflate
- /// by the spacing less NestTolerances.SpacingSlack, so a layout exactly at the
- /// spacing passes despite rounding; the only other leniency is the round-join
- /// chord error at convex corners (SpacingOutline.ChordTolerance / 10).
+ /// by the spacing less NestTolerances.SpacingSlack, so straight edges exactly at
+ /// the spacing pass despite rounding; the only other leniency is the round-join
+ /// chord error at convex corners (SpacingOutline.ChordTolerance / 10). Arcs at
+ /// the spacing can overlap by up to twice the chord tolerance, so the spacing
+ /// check confirms what this outline flags on NativeContours.
/// part.Program is already rotated; only a Location offset is needed.
///
private static SpacingOutline Outline(Part part, double inflateBy)
+ {
+ var profile = Profile(part);
+ return profile == null ? null : Outline(profile, part.Location, inflateBy);
+ }
+
+ /// A part's clean nesting material, already rotated; null when it has no closed
+ /// perimeter.
+ private static ShapeProfile Profile(Part part)
{
var entities = ConvertProgram
.ToGeometry(part.Program)
@@ -602,10 +654,7 @@ public static class NestLayoutCheck
var profile = new ShapeProfile(entities);
- if (profile.Perimeter == null)
- return null;
-
- return Outline(profile, part.Location, inflateBy);
+ return profile.Perimeter == null ? null : profile;
}
private static SpacingOutline Outline(ShapeProfile profile, Vector location, double inflateBy) =>
diff --git a/OpenNest.Engine/Jobs/NestTolerances.cs b/OpenNest.Engine/Jobs/NestTolerances.cs
index f2f15b17..03b9237f 100644
--- a/OpenNest.Engine/Jobs/NestTolerances.cs
+++ b/OpenNest.Engine/Jobs/NestTolerances.cs
@@ -9,8 +9,9 @@ namespace OpenNest.Engine.Jobs;
/// Scribe/etch marks never affect these checks or salvage envelopes, even outside the sheet.
public static class NestTolerances
{
- /// Arc chord tolerance used by both validators. The layout check circumscribes
- /// perimeter arcs and inscribes cutouts; the placement validator uses inscribed arcs.
+ /// Arc chord tolerance of the spacing outline (perimeter arcs circumscribed,
+ /// cutouts inscribed) and of the placement validator's overlap check (arcs inscribed).
+ /// Spacing candidates the outline flags are confirmed on .
public const double ValidationOutline = SpacingOutline.ChordTolerance;
/// How far under the part spacing a gap may fall and still pass both validators
diff --git a/OpenNest.Tests/BestFit/NativeUClearanceTests.cs b/OpenNest.Tests/BestFit/NativeUClearanceTests.cs
index 22ce6d4c..d92ccce0 100644
--- a/OpenNest.Tests/BestFit/NativeUClearanceTests.cs
+++ b/OpenNest.Tests/BestFit/NativeUClearanceTests.cs
@@ -62,11 +62,10 @@ public class NativeUClearanceTests
new Dictionary { [drawing] = (drawing.Name, parts.Count) });
output.WriteLine($"Validator violations={violations.Count}");
AssertClearance(parts, plate.PartSpacing);
- // The complete grid currently triggers offset-validator reports even though its
- // independently measured raw boundaries clear. Do not change validator tolerance
- // here: the fine-boundary oracle above checks EVERY nearby pair in either mode.
- if (quantity == 2)
- Assert.Empty(violations);
+ // The complete grid's arcs sit exactly at the spacing; the layout check confirms
+ // what its circumscribed outline flags on native contours, so it agrees with the
+ // fine-boundary oracle above.
+ Assert.Empty(violations);
}
private void AssertClearance(List parts, double spacing)
diff --git a/docs/geometry/pair-spacing.md b/docs/geometry/pair-spacing.md
index 257b920b..fcfc821e 100644
--- a/docs/geometry/pair-spacing.md
+++ b/docs/geometry/pair-spacing.md
@@ -25,14 +25,14 @@ Measurements use raw material outlines tessellated at 1e-6 chord tolerance and i
## Remaining limitations — not a complete spacing fix
- Other angled candidates remain physically too close. Of 568 kept candidates in the corrected real-DXF probe, 49 had boundary distance below 0.25 minus 2e-6, including 48 below the existing 0.2495 spacing-slack threshold. The worst measured boundary gap was about 0.205618. Their cause is not established by this repair; CPU projection filtering and missing arc/line interior contact are separate suspects.
-- The existing offset-based validator still reports 28 spacing violations in each corrected 70-part grid despite the independent fine-outline measurement above. Do not loosen validator tolerance or declare these reports resolved without isolating the discrepancy. This report verifies physical outline clearance for that fixture, not validator acceptance of the whole grid.
+- The offset-based layout check used to report 28 spacing violations in each corrected 70-part grid despite the independent fine-outline measurement above. The cause was its spacing outline, which circumscribes arcs by up to 0.001 each, more than the 0.0005 slack, so two arcs exactly at the spacing could overlap there. It now confirms each pair the outline flags on native contours flattened at a twentieth of the slack (`NativeContours`), as the pre-post review does, and both grids pass with no tolerance changed.
- Across the complete candidate probe, 42 tessellated polygons were invalid. Their boundary distances were recorded without silently repairing them, but intersection-area verdicts were omitted for those invalid polygons. This caveat does not apply to the top pair or the two reported grids.
- Kept-pair acceptance still checks raw overlap, not requested clearance. Interactive fill/group paths can trust an invalid seed. This repair does not add a final spacing gate or repair existing user placements.
- Serialized best-fit results have no algorithm-version invalidation. Loading previously saved candidates can retain old poses. The measurements above use newly imported drawings/fresh computation, not restored caches. Reimport/recompute when evaluating this fix; existing nests and DXFs are not rewritten.
- A zero-demand `PlateFillService.Nest` probe returns no parts and is **not** evidence of a valid whole-job nest. The nonempty cases verified here are pair generation, pair fill, interactive fill, and the quantity-two shortcut.
- GPU behavior and Windows desktop execution were not verified on this Linux host.
-Next hardening should isolate the remaining angular contact defect with a first-contact regression, then address clearance-aware seed acceptance/cache compatibility and the grid-validator disagreement as separate changes. Do not conflate those with the unrelated unchanged-row validation-reuse performance work.
+Next hardening should isolate the remaining angular contact defect with a first-contact regression, then address clearance-aware seed acceptance and cache compatibility as separate changes. Do not conflate those with the unrelated unchanged-row validation-reuse performance work.
## Regression verification
@@ -41,7 +41,7 @@ dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \
--filter 'FullyQualifiedName~CurveContactDistanceTests|FullyQualifiedName~NativeUClearanceTests'
```
-The 36 targeted cases cover both callers, swapped curves, reflected/translated/rotated directions, both roots, arc-span rejection, external-circle contact, zero spacing, point/equal-radius degeneracies, the real-shaped closed U fixture, top cached-pair source materialization, and nonempty interactive fill with quantity zero/two. The test clearance oracle uses fine raw line segments and independent point/segment distances, plus overlap checks; it does not use native offset curves or the slide solver. The complete-grid validator result is logged, not represented as passing.
+The 36 targeted cases cover both callers, swapped curves, reflected/translated/rotated directions, both roots, arc-span rejection, external-circle contact, zero spacing, point/equal-radius degeneracies, the real-shaped closed U fixture, top cached-pair source materialization, and nonempty interactive fill with quantity zero/two. The test clearance oracle uses fine raw line segments and independent point/segment distances, plus overlap checks; it does not use native offset curves or the slide solver. The complete-grid layout check must report no violations.
Baseline `bc5fd86996da3abb98bb3b1da5f81de82692bf5e` failed 18 of the original 22 geometry regression cases and all three integration cases before the repair. Final scoped formatter and `--verify-no-changes` runs succeeded with `EnableWindowsTargeting=true`; all suites below passed on the repaired tree:
diff --git a/docs/post-verification.md b/docs/post-verification.md
index 5e336206..e4c5fa1c 100644
--- a/docs/post-verification.md
+++ b/docs/post-verification.md
@@ -107,10 +107,11 @@ shows the first three check categories, even when there are no findings:
clean nesting material (leads excluded) tested exactly as Auto Nest's layout check tests
it, with the shared `SpacingOutline` and `SpacingSlack`, so a layout Auto Nest accepted
stays clear. That outline circumscribes arcs by more than the slack, so each candidate is
- confirmed on its native contours flattened 20 times finer than the slack. The review warns
- only when that gap falls short of the spacing by more than `SpacingSlack`, which lets
- interactive fills that place arcs exactly at the spacing pass. On curves it is therefore
- slightly more lenient than Auto Nest's check, never stricter. Zero spacing allows contact.
+ confirmed on its native contours flattened 20 times finer than the slack (Core's
+ `NativeContours`). The review warns only when that gap falls short of the spacing by more
+ than `SpacingSlack`, which lets interactive fills that place arcs exactly at the spacing
+ pass. Auto Nest's layout check and the whole-job runner's candidate validation confirm the
+ same way, so all three agree. Zero spacing allows contact.
A part inside another's cutout is held to the spacing from the hole edge. Cut-offs, the
plate edge and clamps are not spacing-checked. The finding gives the measured gap; this
line appears in the summary only when there is such a finding.