fix(engine): allow 0.0005 spacing slack in layout validation

Layouts placed exactly at the part spacing can land ~1e-4 short once
rotated, rounded (e.g. PEP's 4-decimal exports) and snapped to the
Clipper grid, so both validators rejected layouts that were correct in
practice. NestTolerances.SpacingSlack (0.0005, far below anything a
cutting machine resolves) is now subtracted from the spacing by
NestLayoutCheck's inflation and NestJobPlacementValidator's edge-distance
check. The frozen LegacyNestValidator takes the same rule so the
equivalence tests keep comparing like with like.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 14:19:04 -04:00
co-authored by Claude Opus 5.5
parent d1f3907505
commit 1b862dc1a8
6 changed files with 47 additions and 10 deletions
+1 -1
View File
@@ -137,7 +137,7 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion). - Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`. - Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, so a layout exactly at the spacing passes. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. Clipper is allowed only for cached CPU preparation, never in per-pair hot loops. - **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, inflated by the spacing less `NestTolerances.SpacingSlack` (0.0005), so a layout exactly at the spacing passes even after rotation and coordinate rounding leave it ~1e-4 short. `NestJobPlacementValidator` applies the same slack to its edge-distance check. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. Clipper is allowed only for cached CPU preparation, never in per-pair hot loops.
- **Marks are not material**: scribe/etch moves are marked on the surface, never cut through, so they are left out of nesting. `SpecialLayers.IsMaterial(layer)` (excludes `Rapid` and `Scribe`) is the filter for every consumer that builds part material from a program: drawing area, canonical angle, part collision, `PartGeometry`, plate perimeters, best-fit/pair evaluation, rotation analysis, the GPU evaluators, and both validators (`NestJobPlacementValidator`, benchmark `NestValidator`). Cutting time, on-screen display, splitting, and post-processors still see marks. Older `.nest` files (e.g. `tools/PepNestExport` output) saved etch as cut moves while their source entities kept the `SCRIBE` layer; `NestReader` runs `ScribeLayerRepair` on load to move matching program moves back to `Scribe`. - **Marks are not material**: scribe/etch moves are marked on the surface, never cut through, so they are left out of nesting. `SpecialLayers.IsMaterial(layer)` (excludes `Rapid` and `Scribe`) is the filter for every consumer that builds part material from a program: drawing area, canonical angle, part collision, `PartGeometry`, plate perimeters, best-fit/pair evaluation, rotation analysis, the GPU evaluators, and both validators (`NestJobPlacementValidator`, benchmark `NestValidator`). Cutting time, on-screen display, splitting, and post-processors still see marks. Older `.nest` files (e.g. `tools/PepNestExport` output) saved etch as cut moves while their source entities kept the `SCRIBE` layer; `NestReader` runs `ScribeLayerRepair` on load to move matching program moves back to `Scribe`.
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
@@ -15,7 +15,6 @@ namespace OpenNest.Engine.Tests.Jobs;
public class NestJobSpacingValidationTests public class NestJobSpacingValidationTests
{ {
private const double Spacing = 0.25; private const double Spacing = 0.25;
private const double Epsilon = 0.0000001;
private const double Origin = 50; private const double Origin = 50;
public static IEnumerable<object[]> Shapes() => public static IEnumerable<object[]> Shapes() =>
@@ -98,6 +97,27 @@ public class NestJobSpacingValidationTests
)); ));
} }
[Theory]
[InlineData(0.0001, true)]
[InlineData(0.0004, true)]
[InlineData(0.0010, false)]
public void GapsJustUnderSpacingPassWithinTheSlack(double shortfall, bool expected)
{
// A layout placed at the spacing can land ~1e-4 short once rotated and rounded.
var program = TestDrawingFactory.Rectangle(4, 3);
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 2);
var job = new NestJob(
new[] { part },
new[] { new NestPlateStock("stock", new Size(200, 200), 1, Spacing) }
);
var first = new NestJobPlacement("part", 0, Origin, Origin, 0);
var second = new NestJobPlacement("part", 1, Origin + 4 + Spacing - shortfall, Origin, 0);
Assert.Equal(expected, IsValid(job, first, second));
var geometry = JobPartGeometry.Read(part.Geometry);
Assert.Equal(expected, NestLayoutCheck.Clears(geometry, first, geometry, second, Spacing));
}
private static bool IsValid(NestJob job, params NestJobPlacement[] placements) private static bool IsValid(NestJob job, params NestJobPlacement[] placements)
{ {
try try
@@ -123,7 +143,7 @@ public class NestJobSpacingValidationTests
if (Collision.HasOverlap(a[0], b[0], a.Skip(1).ToList(), b.Skip(1).ToList())) if (Collision.HasOverlap(a[0], b[0], a.Skip(1).ToList(), b.Skip(1).ToList()))
return true; return true;
var limit = Spacing - Epsilon; var limit = Spacing - NestTolerances.SpacingSlack;
foreach (var left in a) foreach (var left in a)
{ {
var leftLines = left.ToLines(); var leftLines = left.ToLines();
@@ -66,7 +66,8 @@ internal static class NestJobPlacementValidator
continue; continue;
if (Overlaps(shape, other)) if (Overlaps(shape, other))
throw new InvalidOperationException("Candidate placements overlap."); throw new InvalidOperationException("Candidate placements overlap.");
if (stock.PartSpacing > 0 && Distance(shape, other) < stock.PartSpacing - Epsilon) if (stock.PartSpacing > 0
&& Distance(shape, other) < stock.PartSpacing - NestTolerances.SpacingSlack)
throw new InvalidOperationException( throw new InvalidOperationException(
"Candidate placements violate required part spacing." "Candidate placements violate required part spacing."
); );
+12 -5
View File
@@ -52,7 +52,8 @@ public static class NestLayoutCheck
(al, bl) = (bl, al); (al, bl) = (bl, al);
(ar, br) = (br, ar); (ar, br) = (br, ar);
} }
var inflated = spacing > Tolerance.Epsilon ? Outline(ap, al, spacing) : ar; var inflateBy = InflationFor(spacing);
var inflated = inflateBy > Tolerance.Epsilon ? Outline(ap, al, inflateBy) : ar;
return !BoxesTouch(inflated.Perimeter.BoundingBox, br.Perimeter.BoundingBox) return !BoxesTouch(inflated.Perimeter.BoundingBox, br.Perimeter.BoundingBox)
|| !Overlaps(inflated, br); || !Overlaps(inflated, br);
} }
@@ -289,11 +290,12 @@ public static class NestLayoutCheck
{ {
var raw = new PartOutline[parts.Count]; var raw = new PartOutline[parts.Count];
var inflated = new PartOutline[parts.Count]; var inflated = new PartOutline[parts.Count];
var inflateBy = InflationFor(spacing);
for (var i = 0; i < parts.Count; i++) for (var i = 0; i < parts.Count; i++)
{ {
raw[i] = Outline(parts[i], 0); raw[i] = Outline(parts[i], 0);
inflated[i] = spacing > Tolerance.Epsilon ? Outline(parts[i], spacing) : raw[i]; inflated[i] = inflateBy > Tolerance.Epsilon ? Outline(parts[i], inflateBy) : raw[i];
} }
var order = Enumerable var order = Enumerable
@@ -339,6 +341,10 @@ public static class NestLayoutCheck
.GetBoundingBox() .GetBoundingBox()
.Translate(part.Location); .Translate(part.Location);
/// <summary>Inflation that enforces <paramref name="spacing"/> less the shared slack.</summary>
private static double InflationFor(double spacing) =>
System.Math.Max(0, spacing - NestTolerances.SpacingSlack);
private static bool BoxesTouch(Box a, Box b) => private static bool BoxesTouch(Box a, Box b) =>
a.Left <= b.Right + Tolerance.Epsilon a.Left <= b.Right + Tolerance.Epsilon
&& b.Left <= a.Right + Tolerance.Epsilon && b.Left <= a.Right + Tolerance.Epsilon
@@ -398,9 +404,10 @@ public static class NestLayoutCheck
/// that closes up under the offset is dropped, which treats it as solid: /// that closes up under the offset is dropped, which treats it as solid:
/// conservative, since it has no room for another part at the required /// conservative, since it has no room for another part at the required
/// spacing anyway. Arcs are flattened conservatively (perimeter arcs /// spacing anyway. Arcs are flattened conservatively (perimeter arcs
/// circumscribed, cutout arcs inscribed) but nothing is padded, so a layout /// circumscribed, cutout arcs inscribed) but nothing is padded. Callers inflate
/// exactly at the spacing passes; the only leniency is the round-join chord /// by the spacing less NestTolerances.SpacingSlack, so a layout exactly at the
/// error at convex corners (OutlineTolerance / 10). /// spacing passes despite rounding; the only other leniency is the round-join
/// chord error at convex corners (OutlineTolerance / 10).
/// part.Program is already rotated; only a Location offset is needed. /// part.Program is already rotated; only a Location offset is needed.
/// </summary> /// </summary>
private static PartOutline Outline(Part part, double inflateBy) private static PartOutline Outline(Part part, double inflateBy)
+6
View File
@@ -12,6 +12,12 @@ public static class NestTolerances
/// perimeter arcs and inscribes cutouts; the placement validator uses inscribed arcs.</summary> /// perimeter arcs and inscribes cutouts; the placement validator uses inscribed arcs.</summary>
public const double ValidationOutline = 0.001; public const double ValidationOutline = 0.001;
/// <summary>How far under the part spacing a gap may fall and still pass both validators.
/// Layouts placed exactly at the spacing land up to ~1e-4 short once rotated, rounded
/// coordinates (e.g. PEP's 4-decimal exports) meet the Clipper grid; 0.0005 covers that
/// with margin and is far below anything a cutting machine can resolve.</summary>
public const double SpacingSlack = 0.0005;
/// <summary>Clipper decimal precision (a 1e-4 coordinate grid).</summary> /// <summary>Clipper decimal precision (a 1e-4 coordinate grid).</summary>
public const int ClipperPrecision = ClipperBridge.Precision; public const int ClipperPrecision = ClipperBridge.Precision;
@@ -251,10 +251,13 @@ namespace OpenNest.Tests.Benchmark
var raw = new PartOutline[parts.Count]; var raw = new PartOutline[parts.Count];
var inflated = new PartOutline[parts.Count]; var inflated = new PartOutline[parts.Count];
// The one intended rule change since the freeze: the shared spacing slack.
var inflateBy = System.Math.Max(0, spacing - NestTolerances.SpacingSlack);
for (var i = 0; i < parts.Count; i++) for (var i = 0; i < parts.Count; i++)
{ {
raw[i] = Outline(parts[i], 0); raw[i] = Outline(parts[i], 0);
inflated[i] = spacing > Tolerance.Epsilon ? Outline(parts[i], spacing) : raw[i]; inflated[i] = inflateBy > Tolerance.Epsilon ? Outline(parts[i], inflateBy) : raw[i];
} }
var order = Enumerable var order = Enumerable