perf(fill): reuse part triangulations within an overlap check

After 1b, triangulating both polygons on every pair was the largest
remaining overlap cost (27% of main-thread samples on the corpus job).
PartOverlapChecker now triangulates each part at most once per check,
lazily after the bounding-box gate, and passes the triangles to a new
internal Collision.HasOverlap overload that runs the unchanged
OverlapRegions body. Triangles are only read by clipping and hole
subtraction, so reuse gives identical verdicts.

Verification:
- 49,000 seeded decisions with reused triangles match LegacyCollision;
  triangles stay bit-identical to a fresh triangulation afterwards.
- Debug PolygonTriangulations: 246 -> 40 and 64 -> 36 per grid check;
  sharing triangles per Program instead fails 23 tests.
- Corpus job (169 parts, --engines Default --parallel 1): median
  13,398 -> 12,702 ms over 4+4 alternating runs vs 1b, identical
  outcomes; serialized layout byte-identical to the base.

Also records the Follow-up B' (Slices 1a, 1b, 2a) measurements in
docs/performance/fill-performance.md.
This commit is contained in:
aj
2026-09-27 13:49:46 -04:00
parent a27290a29c
commit 1b23ad79f2
8 changed files with 445 additions and 34 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ Always keep `README.md` and `AGENTS.md` up to date when making changes that affe
- 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.
- 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, 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. `FillLinear` prepares each distinct `Program` (reference identity) once per public `Fill`/`FillRow` call and translates clones; never share that cache across calls or threads. Both `HasOverlappingParts` loops use one `PartOverlapChecker` per call (same keying; parts and programs must not change while it is in use). 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. `FillLinear` prepares each distinct `Program` (reference identity) once per public `Fill`/`FillRow` call and translates clones; never share that cache across calls or threads. Both `HasOverlappingParts` loops use one `PartOverlapChecker` per call (same keying; it also caches each part's triangles; parts and programs must not change while it is in use). 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`.
- `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. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring.
+37 -3
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using OpenNest.Math;
@@ -58,6 +59,34 @@ namespace OpenNest.Geometry
return OverlapRegions(a, b, holesA, holesB).Count > 0;
}
/// <summary>
/// <see cref="HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/> with the
/// per-polygon triangulation supplied by the caller. Triangulations are resolved lazily,
/// only after the bounding boxes overlap, and must come from <see cref="Triangulate"/> on
/// the same polygon; the verdict is then identical. The triangles are only read.
/// </summary>
internal static bool HasOverlap(
Polygon a,
Func<List<Polygon>> trianglesA,
Polygon b,
Func<List<Polygon>> trianglesB,
List<Polygon> holesA = null,
List<Polygon> holesB = null
)
{
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
return false;
return OverlapRegions(trianglesA(), trianglesB(), holesA, holesB).Count > 0;
}
/// <summary>
/// The triangulation <see cref="Check"/> and <see cref="HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/>
/// use for <paramref name="polygon"/>: ear-clipped triangles with bounds updated. Callers
/// that reuse it must not mutate the polygon or the triangles.
/// </summary>
internal static List<Polygon> Triangulate(Polygon polygon) => TriangulateWithBounds(polygon);
public static List<CollisionResult> CheckAll(
List<Polygon> polygons,
List<List<Polygon>> holes = null
@@ -108,11 +137,15 @@ namespace OpenNest.Geometry
Polygon b,
List<Polygon> holesA,
List<Polygon> holesB
) => OverlapRegions(TriangulateWithBounds(a), TriangulateWithBounds(b), holesA, holesB);
private static List<Polygon> OverlapRegions(
List<Polygon> trisA,
List<Polygon> trisB,
List<Polygon> holesA,
List<Polygon> holesB
)
{
var trisA = TriangulateWithBounds(a);
var trisB = TriangulateWithBounds(b);
var regions = new List<Polygon>();
foreach (var triA in trisA)
@@ -186,6 +219,7 @@ namespace OpenNest.Geometry
/// </summary>
private static List<Polygon> TriangulateWithBounds(Polygon polygon)
{
PerfCounters.CountPolygonTriangulation();
var tris = ConvexDecomposition.Triangulate(polygon);
foreach (var tri in tris)
tri.UpdateBounds();
+41 -12
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
@@ -6,8 +7,8 @@ namespace OpenNest
/// <summary>
/// Overlap-only form of <see cref="Part.Intersects"/> for one pass over a fixed set of parts.
/// Verdicts match <c>Intersects(other, out _)</c>. Each distinct <see cref="CNC.Program"/>
/// (by reference) is prepared once, each part's world polygon is built once, and crossing
/// points are not computed. Tiled copies from <see cref="Part.CloneAtOffset"/> share one
/// (by reference) is prepared once, each part's world polygon is built and triangulated at
/// most once, and crossing points are not computed. Tiled copies from <see cref="Part.CloneAtOffset"/> share one
/// Program, so a fill grid prepares its pattern's programs only once.
/// Parts must not move, rotate or change Program while an instance is in use. Instances are
/// not thread-safe: create one per check.
@@ -18,7 +19,7 @@ namespace OpenNest
ReferenceEqualityComparer.Instance
);
private readonly Dictionary<Part, Polygon> worldPolygons = new(
private readonly Dictionary<Part, PreparedPart> preparedParts = new(
ReferenceEqualityComparer.Instance
);
@@ -42,13 +43,18 @@ namespace OpenNest
if (perimeter1 == null || perimeter2 == null)
return false;
var polygon1 = WorldPolygon(part1, prepared1);
var polygon2 = WorldPolygon(part2, prepared2);
var world1 = PreparePart(part1, prepared1);
var world2 = PreparePart(part2, prepared2);
if (polygon1 == null || polygon2 == null)
if (world1.Polygon == null || world2.Polygon == null)
return false;
return Collision.HasOverlap(polygon1, polygon2);
return Collision.HasOverlap(
world1.Polygon,
world1.Triangles,
world2.Polygon,
world2.Triangles
);
}
private PreparedProgram Prepare(CNC.Program program)
@@ -62,11 +68,12 @@ namespace OpenNest
return prepared;
}
private Polygon WorldPolygon(Part part, PreparedProgram prepared)
private PreparedPart PreparePart(Part part, PreparedProgram prepared)
{
if (worldPolygons.TryGetValue(part, out var polygon))
return polygon;
if (preparedParts.TryGetValue(part, out var world))
return world;
Polygon polygon = null;
var local = prepared.GetLocalPolygon();
if (local != null)
@@ -78,8 +85,30 @@ namespace OpenNest
polygon.Offset(part.Location);
}
worldPolygons.Add(part, polygon);
return polygon;
world = new PreparedPart(polygon);
preparedParts.Add(part, world);
return world;
}
/// <summary>
/// A part's world polygon and, once a pair first needs it, its triangulation. Both are
/// shared by every later pair in this check and are never mutated.
/// </summary>
private sealed class PreparedPart
{
private List<Polygon> triangles;
public PreparedPart(Polygon polygon)
{
Polygon = polygon;
Triangles = GetTriangles;
}
public Polygon Polygon { get; }
public Func<List<Polygon>> Triangles { get; }
private List<Polygon> GetTriangles() => triangles ??= Collision.Triangulate(Polygon);
}
private sealed class PreparedProgram
+6
View File
@@ -18,6 +18,7 @@ namespace OpenNest
private static long featureBitmaskCells;
private static long crossingPointScans;
private static long overlapPolygonPreparations;
private static long polygonTriangulations;
public static long FindBestFits => Interlocked.Read(ref findBestFits);
public static long OffsetPerimeterEntities => Interlocked.Read(ref offsetPerimeterEntities);
@@ -28,6 +29,7 @@ namespace OpenNest
public static long FeatureBitmaskCells => Interlocked.Read(ref featureBitmaskCells);
public static long CrossingPointScans => Interlocked.Read(ref crossingPointScans);
public static long OverlapPolygonPreparations => Interlocked.Read(ref overlapPolygonPreparations);
public static long PolygonTriangulations => Interlocked.Read(ref polygonTriangulations);
[Conditional("DEBUG")]
public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits);
@@ -58,6 +60,9 @@ namespace OpenNest
public static void CountOverlapPolygonPreparation() =>
Interlocked.Increment(ref overlapPolygonPreparations);
[Conditional("DEBUG")]
public static void CountPolygonTriangulation() => Interlocked.Increment(ref polygonTriangulations);
public static void Reset()
{
Interlocked.Exchange(ref findBestFits, 0);
@@ -69,6 +74,7 @@ namespace OpenNest
Interlocked.Exchange(ref featureBitmaskCells, 0);
Interlocked.Exchange(ref crossingPointScans, 0);
Interlocked.Exchange(ref overlapPolygonPreparations, 0);
Interlocked.Exchange(ref polygonTriangulations, 0);
}
}
}
+74 -17
View File
@@ -167,6 +167,12 @@ public class PartOverlapCheckerTests
var expected = LegacyPartOverlap.WorldPolygon(p);
var actual = WorldPolygon(checker, p);
Assert.Equal(PolygonBits(expected), PolygonBits(actual));
// Cached triangles equal a fresh triangulation of the pre-change world polygon.
var expectedTriangles = Collision.Triangulate(expected);
var actualTriangles = WorldTriangles(checker, p);
Assert.Equal(expectedTriangles.Count, actualTriangles.Count);
for (var t = 0; t < expectedTriangles.Count; t++)
Assert.Equal(PolygonBits(expectedTriangles[t]), PolygonBits(actualTriangles[t]));
}
}
}
@@ -208,20 +214,7 @@ public class PartOverlapCheckerTests
[InlineData("pair", 2)]
public void Work_PreparesEachDistinctProgramOncePerCheck(string kind, long distinctPrograms)
{
// Rotated grids: neighbouring boxes overlap but parts do not, so every box-overlapping
// pair reaches an exact test and there is no early exit.
var filler = new FillLinear(new Box(3.1, 5.3, 96, 48), 0.5);
List<Part> grid;
if (kind == "single")
grid = filler.Fill(Fixture("arc"), 0.37, NestDirection.Horizontal);
else
{
var first = Part.CreateAtOrigin(Fixture("concave"), 0);
var second = Part.CreateAtOrigin(Fixture("concave"), System.Math.PI);
second.Offset(new Vector(first.Right + 0.5, first.Bottom));
grid = filler.Fill(FillHelpers.BuildRotatedPattern(new List<Part> { first, second }, 0.37),
NestDirection.Horizontal);
}
var grid = WorkGrid(kind);
Assert.Equal(distinctPrograms, grid.Select(g => g.Program).Distinct(ReferenceEqualityComparer.Instance).Count());
PerfCounters.Reset();
try
@@ -253,8 +246,65 @@ public class PartOverlapCheckerTests
PerfCounters.Reset();
}
}
[Theory]
[InlineData("single")]
[InlineData("pair")]
public void Work_TriangulatesEachPartAtMostOncePerCheck(string kind)
{
var grid = WorkGrid(kind);
// Parts that reach an exact test: their part boxes and their world polygon boxes overlap.
var reaching = new HashSet<Part>(ReferenceEqualityComparer.Instance);
var exactTests = 0;
ForEachBoxOverlappingPair(grid, (a, b) =>
{
if (!PolygonBoxesOverlap(LegacyPartOverlap.WorldPolygon(a), LegacyPartOverlap.WorldPolygon(b)))
return;
exactTests++;
reaching.Add(a);
reaching.Add(b);
});
PerfCounters.Reset();
try
{
ForEachBoxOverlappingPair(grid, (a, b) => a.Intersects(b, out _));
var legacy = PerfCounters.PolygonTriangulations;
PerfCounters.Reset();
Assert.False(FillHelpers.HasOverlappingParts(grid));
var actual = PerfCounters.PolygonTriangulations;
output.WriteLine($"triangulations {kind}: parts={grid.Count}; exact tests={exactTests}; parts reaching={reaching.Count}; old={legacy}; new={actual}");
Assert.True(exactTests > 2);
Assert.Equal(2L * exactTests, legacy);
Assert.Equal(reaching.Count, actual);
}
finally
{
PerfCounters.Reset();
}
}
#endif
private static List<Part> WorkGrid(string kind)
{
// Rotated grids: neighbouring boxes overlap but parts do not, so every box-overlapping
// pair reaches an exact test and there is no early exit.
var filler = new FillLinear(new Box(3.1, 5.3, 96, 48), 0.5);
if (kind == "single")
return filler.Fill(Fixture("arc"), 0.37, NestDirection.Horizontal);
var first = Part.CreateAtOrigin(Fixture("concave"), 0);
var second = Part.CreateAtOrigin(Fixture("concave"), System.Math.PI);
second.Offset(new Vector(first.Right + 0.5, first.Bottom));
return filler.Fill(FillHelpers.BuildRotatedPattern(new List<Part> { first, second }, 0.37),
NestDirection.Horizontal);
}
private static bool PolygonBoxesOverlap(Polygon a, Polygon b)
{
var overlapX = System.Math.Min(a.BoundingBox.Right, b.BoundingBox.Right) - System.Math.Max(a.BoundingBox.Left, b.BoundingBox.Left);
var overlapY = System.Math.Min(a.BoundingBox.Top, b.BoundingBox.Top) - System.Math.Max(a.BoundingBox.Bottom, b.BoundingBox.Bottom);
return overlapX > OpenNest.Math.Tolerance.Epsilon && overlapY > OpenNest.Math.Tolerance.Epsilon;
}
private static void AssertMatchesLegacy(List<Part> parts)
{
var legacy = Capture(() => LegacyPartOverlap.FillHelpersHasOverlappingParts(parts));
@@ -314,11 +364,18 @@ public class PartOverlapCheckerTests
}
}
private static Polygon WorldPolygon(PartOverlapChecker checker, Part part)
private static Polygon WorldPolygon(PartOverlapChecker checker, Part part) =>
(Polygon)PreparedPartMember(checker, part, "Polygon");
private static List<Polygon> WorldTriangles(PartOverlapChecker checker, Part part) =>
((Func<List<Polygon>>)PreparedPartMember(checker, part, "Triangles"))();
private static object PreparedPartMember(PartOverlapChecker checker, Part part, string property)
{
var field = typeof(PartOverlapChecker).GetField("worldPolygons",
var field = typeof(PartOverlapChecker).GetField("preparedParts",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
return ((Dictionary<Part, Polygon>)field.GetValue(checker)!)[part];
var prepared = ((System.Collections.IDictionary)field.GetValue(checker)!)[part]!;
return prepared.GetType().GetProperty(property)!.GetValue(prepared)!;
}
/// <summary>Every other part replaced by a copy moved by (-shift, -shift).</summary>
@@ -59,6 +59,87 @@ public class CollisionOverlapOnlyTests
Assert.True(boxOverlapClear > 5000);
}
/// <summary>
/// Supplied triangulations, each prepared once and reused across many pairs as
/// PartOverlapChecker does, give the same verdicts as the legacy per-call path and are
/// never mutated by clipping or hole subtraction.
/// </summary>
[Fact]
public void SeededPreparedTriangles_ReusedAcrossPairs_MatchLegacyVerdicts()
{
var random = new Random(28092026);
var decisions = 0;
var overlaps = 0;
var boxOverlapClear = 0;
for (var group = 0; group < 40; group++)
{
var polygons = new List<Polygon>();
var shapeA = Make(random, group % 6);
var shapeB = Make(random, (group / 6) % 6);
for (var sample = 0; sample < 25; sample++)
{
// Offsets gives contact/containment placements within each (A, B) pair. Pulling
// every pair into one ~8-unit window makes most cross-pair tests reach clipping
// too, so each cached triangulation is reused against many near neighbours.
var (ax, ay, bx, by) = Offsets(random, shapeA, shapeB, sample);
var dx = random.NextDouble() * 4 - ax;
var dy = random.NextDouble() * 4 - ay;
polygons.Add(Move(shapeA, ax + dx, ay + dy));
polygons.Add(Move(shapeB, bx + dx, by + dy));
}
var holes = group % 4 == 0
? polygons.Select(p => new List<Polygon> { Move(Square(0.5), p.BoundingBox.Left + 0.2, p.BoundingBox.Bottom + 0.2) }).ToList()
: null;
var triangles = polygons.Select(p => new Lazy<List<Polygon>>(() => Collision.Triangulate(p))).ToArray();
for (var i = 0; i < polygons.Count; i++)
for (var j = i + 1; j < polygons.Count; j++)
{
var expected = LegacyCollision.HasOverlap(polygons[i], polygons[j], holes?[i], holes?[j]);
var ti = triangles[i];
var tj = triangles[j];
var actual = Collision.HasOverlap(polygons[i], () => ti.Value, polygons[j], () => tj.Value, holes?[i], holes?[j]);
Assert.True(expected == actual, $"group={group} i={i} j={j} expected={expected}");
decisions++;
if (expected)
overlaps++;
else if (BoxesOverlap(polygons[i].BoundingBox, polygons[j].BoundingBox))
boxOverlapClear++;
}
for (var i = 0; i < polygons.Count; i++)
{
if (!triangles[i].IsValueCreated)
continue;
// Reused triangles must still equal a fresh triangulation, bit for bit.
Assert.Equal(TriangleBits(Collision.Triangulate(polygons[i])), TriangleBits(triangles[i].Value));
}
}
output.WriteLine($"prepared-triangle decisions={decisions}; overlaps={overlaps}; bbox-overlap but clear={boxOverlapClear}");
Assert.Equal(40 * 50 * 49 / 2, decisions);
Assert.True(overlaps > 10000);
Assert.True(boxOverlapClear > 2000);
}
[Fact]
public void PreparedTriangles_AreNotResolvedWhenBoundingBoxesMiss()
{
var a = Square(2);
var b = Move(Square(2), 10, 10);
Assert.False(Collision.HasOverlap(a, () => throw new InvalidOperationException("A"), b,
() => throw new InvalidOperationException("B")));
var c = Move(Square(2), 1, 1);
var resolved = new List<string>();
Assert.True(Collision.HasOverlap(a, () => { resolved.Add("a"); return Collision.Triangulate(a); }, c,
() => { resolved.Add("c"); return Collision.Triangulate(c); }));
Assert.Equal(new[] { "a", "c" }, resolved);
}
private static long[] TriangleBits(List<Polygon> triangles) => triangles
.SelectMany(t => new[] { t.BoundingBox.X, t.BoundingBox.Y, t.BoundingBox.Length, t.BoundingBox.Width }
.Concat(t.Vertices.SelectMany(v => new[] { v.X, v.Y })))
.Select(BitConverter.DoubleToInt64Bits)
.Prepend(triangles.Count)
.ToArray();
[Fact]
public void SeededCheck_MatchesLegacyBitwise()
{
@@ -155,11 +236,17 @@ public class CollisionOverlapOnlyTests
Assert.ThrowsAny<Exception>(() => LegacyCollision.Check(a, b));
Assert.ThrowsAny<Exception>(() => Collision.HasOverlap(a, b));
Assert.ThrowsAny<Exception>(() => Collision.Check(a, b));
Assert.ThrowsAny<Exception>(() => Collision.HasOverlap(a, () => Collision.Triangulate(a), b,
() => Collision.Triangulate(b)));
var far = Move(Make(new Random(5), 0), 100, 100);
var broken = nullFirst ? a : b;
Assert.False(LegacyCollision.Check(broken, far).Overlaps);
Assert.False(Collision.HasOverlap(broken, far));
var resolved = 0;
Assert.False(Collision.HasOverlap(broken, () => { resolved++; return Collision.Triangulate(broken); }, far,
() => { resolved++; return Collision.Triangulate(far); }));
Assert.Equal(0, resolved);
}
#if DEBUG
+198
View File
@@ -866,3 +866,201 @@ Unqualified solution formatter and verify each initially exited 1 (`Restore oper
Timings are from a shared four-vCPU VM, one synthetic micro workload and one corpus; not general latency guarantees. When ranges overlap, timing is inconclusive, never evidence of unchanged performance. No Windows runtime tests or ONNX accuracy/inference checks, no optional StockLadder measurement. The normal-pool nondeterminism was characterized (serializing removes it and it appears in the before tree alone) but its source was not pinned down, and no scheduler or tie-break change was made.
Raw logs, TRX, CSV, all saved .nest/JSON layouts, provenance and computed measurement summary are retained under `/home/aj/extracted/2026-09-26/followup-a/`. `micro-commands.json` and `whole-commands.json` record exact process commands/cwds. Final suite logs and TRX are under `final/`, and the determinism probe under `determinism-probe/`.
## Follow-up B′ — overlap-check preparation reuse (Slices 1a, 1b, 2a) — 2026-09-27
### Status, scope and exact-layout acceptance
This replaces the original Follow-up B (X-sorted overlap broad phase). A profile of `82feb78` on the corpus job showed that overlap checks took about 53% of main-thread wall time, but the bounding-box loop itself had almost no self time. The cost was per-pair preparation inside `Part.Intersects`: rebuilding each polygon from its `Program`, triangulating both polygons, and computing crossing points that overlap-only callers discard. Three slices remove that work without changing any verdict:
| Slice | Commit | Change |
| --- | --- | --- |
| 1a | `2b5485f` (harness `a98a49c`) | `Collision.HasOverlap` skips crossing points; `Check` is unchanged. |
| 1b | `a27290a` | `PartOverlapChecker`: each distinct `Program` (reference identity) and each part's world polygon is prepared once per `HasOverlappingParts` call; both loops use it. |
| 2a | this change | The checker also triangulates each part at most once per call, lazily after the bounding-box gate, and reuses those triangles for later pairs. |
Loop order, bounding-box prefilter, early exit, returned indices and `Part.Intersects` (which still returns crossing points) are unchanged. Frozen test-only oracles hold the pre-change code: `OpenNest.Tests/Geometry/LegacyCollision.cs` and `OpenNest.Tests/Fill/LegacyPartOverlap.cs`.
Exact-layout acceptance follows Follow-up A. The serialized probe (`DOTNET_PROCESSOR_COUNT=1`, one pool thread) produced the same placement file on base, 1a, 1b and 2a: SHA-256 `62b5a8d065c889e3599e3bd00e3f591502553c24e81962ccdcb06c4b06159708` (base ×2, 1a ×1, 1b ×2, 2a ×2).
Under the normal thread pool, base alone produced three distinct placement multisets in three untimed runs. 4 of 6 untimed 1b/2a runs match one of them bit for bit. The other two share one class not seen in this small base sample. That class is not proven equivalent, but it is consistent with base producing a new class on every run. All nine runs were valid, placed 169/169 on 2 plates at the same cost, and had no violations.
Step 2b is not implemented. It would triangulate per `Program` in the local frame, and a different frame can move triangle bounds by ulps and flip near-contact verdicts. "Validate on promotion only" and StripeFiller span reuse remain listed follow-ups.
### Provenance
- **Environment:** Ubuntu 24.04 x64 on a shared four-vCPU KVM VM; SDK 10.0.112; .NET 8.0.31 runtime.
- **Before trees (detached worktrees):**
- For 1a and 1b: base `82feb78`, with the then-uncommitted harness byte-copied in.
- For 2a: the 1b tree.
- **2a after tree:** `2b5485f` + 1b + 2a. Its Core and Engine are byte-identical to `a27290a` + 2a.
- **Harness:** `OpenNest.Tests/Fill/OverlapCheckPerformanceTests.cs`, SHA-256 `efde386b150614c8ce21c101df1f3435e0de80f1aa2936e1161044cb2653bdea`, identical in every before and after tree.
- **Corpus manifest:** SHA-256 `3f13c7a674b2a33a26f31dcbf8fec3fc70ab7ef7f10bd801fb784d3d9179987b`, the same file as Task 5 and Follow-up A.
- **Delivered 2a sources:**
- `Collision.cs`: `aac902bfbb55c4ce0c4e95f2e17d53aad84b95e3695459f6ed8f9eeaec27abe8`
- `PartOverlapChecker.cs`: `606a540407df8ed5c47834070cc6aa712da419a7808ef7b96dc9cb1c6a509e46`
Task 0 work counts (instrumented copy of base, corpus job):
| Work | Base | After 1b | After 2a |
| --- | ---: | ---: | ---: |
| Exact pair tests | 65,410 | 65,410 | 65,410 |
| Polygon builds | 65,410 | 3,250 (sum of distinct Programs per call) | 3,250 |
| Triangulations | 65,410 | 65,410 | 45,476 |
Triangulations fall less than polygon builds because most exact-tested parts meet only one neighbour.
The corpus in-fill checks ran 32,705 exact tests and found zero overlaps. They still decide local fallbacks, so they were made cheaper, not removed.
### Genuine red/green, mutations and tests
Counters are Debug-only (`CrossingPointScans`, `OverlapPolygonPreparations`, `PolygonTriangulations`), so Release zeros prove nothing.
| Evidence | Result |
| --- | --- |
| 1a RED: `HasOverlap` pointed back at the full path | work test fails, 5 crossing-point scans vs 0 expected |
| 1b RED: loops back on uncached `Part.Intersects` | preparations 246 vs 1 (single-Program grid), 64 vs 2 (pair grid) |
| 1b mutant: world polygon offset +1e-6 | bitwise world-polygon test fails |
| 1b mutant: world polygon cached per Program, not per part | 22 failures, including the existing FillLinear layout differentials |
| 2a RED: triangles not cached | triangulations 246 vs 40, 64 vs 36 (= parts reaching an exact test) |
| 2a mutant: triangles shared per Program | 23 failures, including the FillLinear layout differentials |
Differentials:
- **Collision vs `LegacyCollision`:** 50,000 seeded `HasOverlap` decisions and 2,400 bitwise `Check` comparisons.
- **Cached triangles:** 49,000 decisions (22,027 overlaps; 4,146 bounding-box hits that are clear), with triangles cached once per polygon and reused across all pairs. Afterwards the triangles are still bit-identical to a fresh triangulation.
- **Fill grids vs `LegacyPartOverlap`:** verdicts, indices and world polygons are bit-identical across 5 shapes × 2 spacings × 3 angles × 2 directions, with ±1e-9 and 0.05 shifts. Also covered: shared, rotated and built-pair patterns; an overlapping seed; touching and epsilon gaps; scribe-only, rapid-only and empty programs; concurrent calls.
The review audited every clipping and hole-subtraction path in `Collision.cs` and `ConvexDecomposition.cs`: cached triangles and their vertex lists are only read.
Invalid-input carve-out (1a onward): a polygon whose `Vertices` is null still throws when bounding boxes overlap. The exception type changed from `NullReferenceException` (thrown in `ToLines`) to `ArgumentNullException` (thrown in triangulation), including through the prepared-triangles overload. No production code creates such polygons or catches either exception type. Separated boxes still return false without touching vertices or resolving triangles.
| Suite / configuration (TRX outcomes) | Passed | Skipped | Failed |
| --- | ---: | ---: | ---: |
| Base main Release (`82feb78`) | 1439 | 19 | 0 |
| 1a main Release / Debug | 1451 / 1475 | 20 / 20 | 0 |
| 1b main Release / Debug | 1496 / 1524 | 20 / 20 | 0 |
| 2a main Release / Debug | 1496 / 1526 | 22 / 22 | 0 |
| Engine Release (each slice) | 300 | 0 | 0 |
| IO Release (each slice) | 41 | 0 | 0 |
| 2a targeted Release / Debug | 325 / 346 | 1 / 1 | 0 |
The two extra 2a skips are the CI Fiber sample regressions. They skip because the gitignored `OpenNest.Tests/test-config.json` was absent in the 2a worktree. For 1b and 2a, clean `EnableWindowsTargeting=true` solution builds give 47 warnings and 0 errors, identical to their bases (independent reviews).
### Same-harness Release microbenchmark observations
The benchmark is `OverlapCheck_ReportsPolygonPairsAndGridChecks`. Setup: area (3.1, 5.3, 96, 48), spacing 0.5, Horizontal. Modes:
- **`grid-single`:** `FillLinear.Fill` of the arc fixture at 0.37 rad (40 parts, one Program), checked through `FillHelpers.HasOverlappingParts`.
- **`grid-pair`:** a 0/π concave pair via `BuildRotatedPattern` at 0.37 rad (36 parts), checked the same way.
- **`polygon-pairs`:** `Collision.HasOverlap` timed over the 155 bounding-box-overlapping neighbour pairs, with polygons prepared once.
Every verdict is clear. Each process warms up with 2 × 20 calls per mode, then measures 7 batches × 50 calls, rotating mode order per batch. Processes ran in the order before-1, after-1, after-2, before-2.
The grids are smaller than the planned ~100–200 parts. Grid size only scales the pair count, and the corpus job is the size-representative measurement.
Slice 1a (before = base plus harness):
`polygon-pairs` (µs/call per batch 1–7; B/call is constant across batches):
| Process | µs/call, batches 1–7 | B/call |
| --- | --- | ---: |
| before-1 | 19176.5 / 18973.2 / 18913.7 / 18944.3 / 18040.0 / 17797.8 / 17851.1 | 10,030,640 |
| after-1 | 10399.9 / 11020.1 / 10887.9 / 10221.8 / 10459.4 / 10185.3 / 10285.5 | 7,921,168 |
| after-2 | 10462.4 / 10669.8 / 10346.5 / 10498.0 / 10651.5 / 10547.5 / 10413.3 | 7,921,168 |
| before-2 | 19033.7 / 18849.8 / 18593.8 / 18340.6 / 18633.5 / 17674.8 / 17724.0 | 10,030,640 |
Slice 1b (before = base plus harness; after = 1a plus 1b):
`grid-single` (µs/call per batch 1–7; B/call is constant across batches):
| Process | µs/call, batches 1–7 | B/call |
| --- | --- | ---: |
| before-1 | 21360.5 / 21387.9 / 20873.0 / 21314.1 / 20192.0 / 20093.7 / 19978.9 | 11,897,168 |
| after-1 | 10425.0 / 10207.0 / 10164.8 / 10432.8 / 10418.8 / 10170.6 / 10457.9 | 7,860,696 |
| after-2 | 10398.3 / 10480.3 / 10418.6 / 10599.3 / 10750.0 / 10839.8 / 10677.2 | 7,860,696 |
| before-2 | 21816.8 / 20794.4 / 21098.9 / 20600.5 / 20408.2 / 20278.8 / 20122.4 | 11,897,168 |
`grid-pair` (µs/call per batch 1–7; B/call is constant across batches):
| Process | µs/call, batches 1–7 | B/call |
| --- | --- | ---: |
| before-1 | 1023.5 / 1027.3 / 1019.3 / 989.2 / 1017.6 / 992.6 / 989.4 | 465,408 |
| after-1 | 272.0 / 280.1 / 278.1 / 284.2 / 272.7 / 284.6 / 273.7 | 133,144 |
| after-2 | 282.5 / 270.1 / 303.8 / 282.4 / 276.9 / 286.7 / 286.6 | 133,144 |
| before-2 | 1046.7 / 1042.3 / 1017.0 / 995.5 / 1110.9 / 1026.7 / 1022.5 | 465,408 |
Slice 2a (before = 1b):
`grid-single` (µs/call per batch 1–7; B/call is constant across batches):
| Process | µs/call, batches 1–7 | B/call |
| --- | --- | ---: |
| before-1 | 10625.9 / 10874.6 / 10452.9 / 10305.0 / 10455.8 / 10542.9 / 10581.2 | 7,860,696 |
| after-1 | 3867.9 / 3787.2 / 3462.5 / 3544.0 / 3443.5 / 3414.5 / 3776.1 | 5,308,808 |
| after-2 | 3812.0 / 3488.3 / 3704.7 / 3464.9 / 3585.1 / 3678.8 / 3585.7 | 5,308,808 |
| before-2 | 10566.4 / 10763.5 / 10646.8 / 10641.7 / 10476.5 / 10780.6 / 10749.1 | 7,860,696 |
`grid-pair` (µs/call per batch 1–7; B/call is constant across batches):
| Process | µs/call, batches 1–7 | B/call |
| --- | --- | ---: |
| before-1 | 284.5 / 263.5 / 279.4 / 284.0 / 272.8 / 278.6 / 281.3 | 133,144 |
| after-1 | 250.5 / 232.8 / 188.1 / 177.4 / 179.7 / 181.4 / 184.6 | 97,016 |
| after-2 | 182.1 / 184.7 / 189.7 / 176.1 / 192.2 / 239.9 / 182.2 | 97,016 |
| before-2 | 275.7 / 297.5 / 286.6 / 280.5 / 277.6 / 275.4 / 290.8 | 133,144 |
Medians per process (before-1 / after-1 / after-2 / before-2, µs):
| Slice | Mode | Medians |
| --- | --- | --- |
| 1a | `polygon-pairs` | 18,914 / 10,400 / 10,498 / 18,594 |
| 1b | `grid-single` | 20,873 / 10,419 / 10,599 / 20,600 |
| 1b | `grid-pair` | 1,018 / 278 / 283 / 1,027 |
| 2a | `grid-single` | 10,543 / 3,544 / 3,586 / 10,647 |
| 2a | `grid-pair` | 279 / 185 / 185 / 280 |
In every mode, the after and before batch ranges do not overlap.
### Whole-job measurement
This is the same corpus job as Task 5 and Follow-up A: Default engine only, `--parallel 1`, Release, alternating fresh processes, and timing runs without `--output`. The CSV `ElapsedMs` covers the engine run, not DXF import.
| Comparison | Before runs (ms) | After runs (ms) | Median before → after |
| --- | --- | --- | --- |
| Base `82feb78` → 1a+1b | 18926, 18734, 18844, 19061 | 13572, 13175, 13459, 13468 | 18,885 → 13,464 (−28.7%) |
| 1b → 1b+2a | 13402, 13544, 13353, 13394 | 12788, 13113, 12615, 12554 | 13,398 → 12,702 (−5.2%) |
- **Run order:** B,A,B,A,B,A,A,B. Before and after ranges do not overlap in either comparison.
- **Outcomes:** all 16 runs were valid and none crashed. Each placed 169/169 on 2 plates, with utilization 0.5346 and cost 9,216.
- **Cumulative figure:** the two comparisons ran in separate batches, so the cumulative base → 2a improvement (about −33%) is indicative only.
- **Serialized probe times** (one process each) are not timing claims: base 23,518/23,607 ms; 1a 23,286; 1b 17,762/17,651; 2a 16,389/16,945.
Profile after 1b (dotnet-trace, main-thread root samples):
| Frame | Share |
| --- | ---: |
| `PartOverlapChecker.Overlaps` (total) | 35.0% |
| — `TriangulateWithBounds` | 27.1% |
| — clipping | about 4.4% |
| `ConvertProgram.AddProgram` (Program and polygon preparation) | 2.1% |
Triangulation dominating is what justified 2a. After 2a, the remaining overlap cost is mostly triangulating each part once, plus clipping.
### Reproduction and limitations
```bash
# Linux: prefix both scoped formatter commands with EnableWindowsTargeting=true.
dotnet format OpenNest.sln --verify-no-changes --include <changed .cs files>
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --filter 'FullyQualifiedName~CollisionOverlapOnlyTests|FullyQualifiedName~PartOverlapCheckerTests'
OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release --filter FullyQualifiedName~OverlapCheck_ReportsPolygonPairsAndGridChecks --logger 'console;verbosity=detailed'
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll /home/aj/extracted/2026-09-26/pep-archive-benchmark-manifest.json --engines Default --parallel 1 --csv <path>
```
Limitations:
- The timings come from one shared VM, one synthetic micro workload and one real corpus job; they are not general latency guarantees.
- Serialized identity covers one thread schedule. Concurrency-only differences are covered by per-call cache ownership and the concurrent differential tests, not by the probe.
- No Windows runtime tests were run.
Raw logs, TRX files, CSVs, placement files, profiles, probe sources and reviews are retained under `/home/aj/extracted/2026-09-27/followup-b/` (`task0/`, `slice1a/`, `slice1b/`, `slice2a/`, `layout-classes/`).
+1 -1
View File
@@ -18,7 +18,7 @@ dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \
--filter 'FullyQualifiedName~DefaultFillComparerWorkTests|FullyQualifiedName~FillHelpersTests|FullyQualifiedName~FillExtentsTests|FullyQualifiedName~StrategyOverlapTests|FullyQualifiedName~FillLinearGeometryReuseTests|FullyQualifiedName~CollisionOverlapOnlyTests|FullyQualifiedName~PartOverlapCheckerTests'
```
`PerfCounters.FillScoreComputations`, `PartBoundaryPreparations`, `PartBoundsUpdates`, `OffsetPerimeterEntities`, `FeatureBitmaskCells`, `CrossingPointScans`, and `OverlapPolygonPreparations` increments compile away in Release: zero Release counters prove nothing. `OverlapPolygonPreparations` counts overlap-preparation starts (material extraction), not completed polygons: `Part.Intersects` counts both parts on every call, `PartOverlapChecker` counts once per distinct `Program`. Serialize counter assertions in `FillCacheCollection` and reset in `finally`. Keep `OpenNest.Tests/Fill/LegacyFillExtents.cs`, `OpenNest.Tests/Fill/LegacyFillLinear.cs`, `OpenNest.Tests/Geometry/LegacyCollision.cs`, and `OpenNest.Tests/Fill/LegacyPartOverlap.cs` frozen for differential tests (never route them through production helpers), not production or before timings; measure the actual baseline production code.
`PerfCounters.FillScoreComputations`, `PartBoundaryPreparations`, `PartBoundsUpdates`, `OffsetPerimeterEntities`, `FeatureBitmaskCells`, `CrossingPointScans`, `OverlapPolygonPreparations`, and `PolygonTriangulations` increments compile away in Release: zero Release counters prove nothing. `OverlapPolygonPreparations` counts overlap-preparation starts (material extraction), not completed polygons: `Part.Intersects` counts both parts on every call, `PartOverlapChecker` counts once per distinct `Program`. `PolygonTriangulations` counts `Collision` triangulations; the checker triangulates a part at most once per check, and only after a bounding-box hit. Serialize counter assertions in `FillCacheCollection` and reset in `finally`. Keep `OpenNest.Tests/Fill/LegacyFillExtents.cs`, `OpenNest.Tests/Fill/LegacyFillLinear.cs`, `OpenNest.Tests/Geometry/LegacyCollision.cs`, and `OpenNest.Tests/Fill/LegacyPartOverlap.cs` frozen for differential tests (never route them through production helpers), not production or before timings; measure the actual baseline production code.
Task 4b checks: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release --filter "FullyQualifiedName~AngleCandidateBuilderTests|FullyQualifiedName~AnglePredictorTests|FullyQualifiedName~FeatureExtractorTests"` (repeat in Debug for bitmap counters). `IrregularAngles_ReportsWarmNoModelPath` measures the public builder with a missing model and skips when a model is installed; never remove real model files to benchmark. `FeatureExtraction_ReportsFullAndScalarOnly` measures extraction separately.