perf(fill): reuse offset geometry for translated copies

FillLinear re-prepared offset perimeter geometry (ConvertProgram ->
ShapeProfile -> OffsetOutward) for every part it measured, although
tiled copies share one Program and differ only by Location. A CPU
profile of a 169-part Default job put 62% of wall time there.

Prepare each distinct Program (reference identity) once per public
Fill/FillRow call in local frame, then clone and translate for each
location. The cache is created per call and passed down privately
because FillHelpers.FillPattern calls Fill concurrently on one
instance. PartGeometry gains a local-frame Program overload that the
Part overload now delegates to.

Evaluation order, lazy preparation, fallbacks and tiling are
unchanged. Differential tests against a frozen copy of the previous
FillLinear check bitwise equality, including concurrent calls; Debug
work tests pin preparation counts. With the thread pool capped at one
worker, before/after whole-job layouts are byte-identical. The
Default corpus job median drops from 40,715 to 18,810 ms.
This commit is contained in:
aj
2026-09-26 20:24:34 -04:00
parent 094c4c196b
commit 0df2587cf2
8 changed files with 1155 additions and 36 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). - 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, 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. - **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. 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. 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. - `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.
+13 -6
View File
@@ -52,9 +52,21 @@ namespace OpenNest
/// without tessellation, which keeps arc-heavy parts fast in directional-distance loops. /// without tessellation, which keeps arc-heavy parts fast in directional-distance loops.
/// </summary> /// </summary>
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing) public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
{
var entities = GetOffsetPerimeterEntities(part.Program, spacing);
foreach (var entity in entities)
entity.Offset(part.Location);
return entities;
}
/// <summary>
/// Prepares a fresh offset perimeter in the program's local frame, without translation.
/// </summary>
public static List<Entity> GetOffsetPerimeterEntities(CNC.Program program, double spacing)
{ {
PerfCounters.CountOffsetPerimeterEntities(); PerfCounters.CountOffsetPerimeterEntities();
var geoEntities = ConvertProgram.ToGeometry(part.Program); var geoEntities = ConvertProgram.ToGeometry(program);
var profile = new ShapeProfile( var profile = new ShapeProfile(
geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList() geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
); );
@@ -63,11 +75,6 @@ namespace OpenNest
if (offsetShape == null) if (offsetShape == null)
return new List<Entity>(); return new List<Entity>();
// Offset the shape's entities to the part's location.
// OffsetOutward creates a new Shape, so mutating is safe.
foreach (var entity in offsetShape.Entities)
entity.Offset(part.Location);
return offsetShape.Entities; return offsetShape.Entities;
} }
+48 -26
View File
@@ -1,6 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Threading.Tasks;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
@@ -8,6 +7,35 @@ namespace OpenNest.Engine.Fill
{ {
public class FillLinear public class FillLinear
{ {
// Owned by one public call: FillHelpers can use this filler concurrently.
// Cached local entities never escape; only translated clones reach spatial queries.
private sealed class OffsetPerimeterCache
{
private readonly Dictionary<CNC.Program, List<Entity>> perimeters =
new Dictionary<CNC.Program, List<Entity>>(ReferenceEqualityComparer.Instance);
private readonly double spacing;
public OffsetPerimeterCache(double spacing) => this.spacing = spacing;
public List<Entity> AtLocation(CNC.Program program, Vector location)
{
if (!perimeters.TryGetValue(program, out var local))
{
local = PartGeometry.GetOffsetPerimeterEntities(program, spacing);
perimeters.Add(program, local);
}
var result = new List<Entity>(local.Count);
foreach (var entity in local)
{
var clone = entity.Clone();
clone.Offset(location);
result.Add(clone);
}
return result;
}
}
public FillLinear(Box workArea, double partSpacing) public FillLinear(Box workArea, double partSpacing)
{ {
PartSpacing = partSpacing; PartSpacing = partSpacing;
@@ -64,18 +92,15 @@ namespace OpenNest.Engine.Fill
/// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled /// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled
/// exactly without polygon sampling error. /// exactly without polygon sampling error.
/// </summary> /// </summary>
private double FindCopyDistance(Part partA, NestDirection direction) private double FindCopyDistance(Part partA, NestDirection direction, OffsetPerimeterCache cache)
{ {
var bboxDim = GetDimension(partA.BoundingBox, direction); var bboxDim = GetDimension(partA.BoundingBox, direction);
var pushDir = GetPushDirection(direction); var pushDir = GetPushDirection(direction);
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon; var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
var offset = MakeOffset(direction, startOffset); var offset = MakeOffset(direction, startOffset);
var stationaryEntities = PartGeometry.GetOffsetPerimeterEntities(partA, HalfSpacing); var stationaryEntities = cache.AtLocation(partA.Program, partA.Location);
var movingEntities = PartGeometry.GetOffsetPerimeterEntities( var movingEntities = cache.AtLocation(partA.Program, partA.Location + offset);
partA.CloneAtOffset(offset),
HalfSpacing
);
var slideDistance = SpatialQuery.DirectionalDistance( var slideDistance = SpatialQuery.DirectionalDistance(
movingEntities, movingEntities,
@@ -96,10 +121,10 @@ namespace OpenNest.Engine.Fill
/// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs /// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs
/// are exact and no bbox clamp is needed. /// are exact and no bbox clamp is needed.
/// </summary> /// </summary>
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction) private double FindPatternCopyDistance(Pattern patternA, NestDirection direction, OffsetPerimeterCache cache)
{ {
if (patternA.Parts.Count == 1) if (patternA.Parts.Count == 1)
return FindCopyDistance(patternA.Parts[0], direction); return FindCopyDistance(patternA.Parts[0], direction, cache);
var bboxDim = GetDimension(patternA.BoundingBox, direction); var bboxDim = GetDimension(patternA.BoundingBox, direction);
var pushDir = GetPushDirection(direction); var pushDir = GetPushDirection(direction);
@@ -142,14 +167,8 @@ namespace OpenNest.Engine.Fill
if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec)) if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec))
continue; continue;
stationaryEntities[i] ??= PartGeometry.GetOffsetPerimeterEntities( stationaryEntities[i] ??= cache.AtLocation(parts[i].Program, parts[i].Location);
parts[i], movingEntities[j] ??= cache.AtLocation(parts[j].Program, parts[j].Location + offset);
HalfSpacing
);
movingEntities[j] ??= PartGeometry.GetOffsetPerimeterEntities(
parts[j].CloneAtOffset(offset),
HalfSpacing
);
var slideDistance = SpatialQuery.DirectionalDistance( var slideDistance = SpatialQuery.DirectionalDistance(
movingEntities[j], movingEntities[j],
@@ -176,9 +195,9 @@ namespace OpenNest.Engine.Fill
/// patterns, also adds individual parts from the next incomplete copy /// patterns, also adds individual parts from the next incomplete copy
/// that still fit within the work area. /// that still fit within the work area.
/// </summary> /// </summary>
private List<Part> TilePattern(Pattern basePattern, NestDirection direction) private List<Part> TilePattern(Pattern basePattern, NestDirection direction, OffsetPerimeterCache cache)
{ {
var copyDistance = FindPatternCopyDistance(basePattern, direction); var copyDistance = FindPatternCopyDistance(basePattern, direction, cache);
if (copyDistance <= 0) if (copyDistance <= 0)
return new List<Part>(); return new List<Part>();
@@ -337,13 +356,13 @@ namespace OpenNest.Engine.Fill
/// a row, then tiling that row along the perpendicular axis to form a grid. /// a row, then tiling that row along the perpendicular axis to form a grid.
/// After the grid is formed, fills the remaining strip with individual parts. /// After the grid is formed, fills the remaining strip with individual parts.
/// </summary> /// </summary>
private List<Part> FillGrid(Pattern pattern, NestDirection direction) private List<Part> FillGrid(Pattern pattern, NestDirection direction, OffsetPerimeterCache cache)
{ {
var perpAxis = PerpendicularAxis(direction); var perpAxis = PerpendicularAxis(direction);
// Step 1: Tile along primary axis // Step 1: Tile along primary axis
var row = new List<Part>(pattern.Parts); var row = new List<Part>(pattern.Parts);
row.AddRange(TilePattern(pattern, direction)); row.AddRange(TilePattern(pattern, direction, cache));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1)) if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1))
{ {
@@ -355,7 +374,7 @@ namespace OpenNest.Engine.Fill
// If primary tiling didn't produce copies, just tile along perpendicular // If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count) if (row.Count <= pattern.Parts.Count)
{ {
row.AddRange(TilePattern(pattern, perpAxis)); row.AddRange(TilePattern(pattern, perpAxis, cache));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2)) if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
{ {
@@ -373,7 +392,7 @@ namespace OpenNest.Engine.Fill
rowPattern.UpdateBounds(); rowPattern.UpdateBounds();
var gridResult = new List<Part>(rowPattern.Parts); var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis)); gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache));
if (HasOverlappingParts(gridResult, out var a3, out var b3)) if (HasOverlappingParts(gridResult, out var a3, out var b3))
{ {
@@ -435,6 +454,7 @@ namespace OpenNest.Engine.Fill
/// </summary> /// </summary>
public Pattern FillRow(Drawing drawing, double rotationAngle, NestDirection direction) public Pattern FillRow(Drawing drawing, double rotationAngle, NestDirection direction)
{ {
var cache = new OffsetPerimeterCache(HalfSpacing);
var seed = MakeSeedPattern(drawing, rotationAngle); var seed = MakeSeedPattern(drawing, rotationAngle);
if (seed.Parts.Count == 0) if (seed.Parts.Count == 0)
@@ -442,7 +462,7 @@ namespace OpenNest.Engine.Fill
var template = seed.Parts[0]; var template = seed.Parts[0];
var copyDistance = FindCopyDistance(template, direction); var copyDistance = FindCopyDistance(template, direction, cache);
if (copyDistance <= 0) if (copyDistance <= 0)
return seed; return seed;
@@ -474,6 +494,7 @@ namespace OpenNest.Engine.Fill
/// </summary> /// </summary>
public List<Part> Fill(Pattern pattern, NestDirection primaryAxis) public List<Part> Fill(Pattern pattern, NestDirection primaryAxis)
{ {
var cache = new OffsetPerimeterCache(HalfSpacing);
if (pattern.Parts.Count == 0) if (pattern.Parts.Count == 0)
return new List<Part>(); return new List<Part>();
@@ -486,7 +507,7 @@ namespace OpenNest.Engine.Fill
) )
return new List<Part>(); return new List<Part>();
return FillGrid(basePattern, primaryAxis); return FillGrid(basePattern, primaryAxis, cache);
} }
/// <summary> /// <summary>
@@ -495,12 +516,13 @@ namespace OpenNest.Engine.Fill
/// </summary> /// </summary>
public List<Part> Fill(Drawing drawing, double rotationAngle, NestDirection primaryAxis) public List<Part> Fill(Drawing drawing, double rotationAngle, NestDirection primaryAxis)
{ {
var cache = new OffsetPerimeterCache(HalfSpacing);
var seed = MakeSeedPattern(drawing, rotationAngle); var seed = MakeSeedPattern(drawing, rotationAngle);
if (seed.Parts.Count == 0) if (seed.Parts.Count == 0)
return new List<Part>(); return new List<Part>();
return FillGrid(seed, primaryAxis); return FillGrid(seed, primaryAxis, cache);
} }
} }
} }
@@ -0,0 +1,358 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Shapes;
using OpenNest.Tests.BestFit;
using Xunit.Abstractions;
namespace OpenNest.Tests.Fill;
[Collection(nameof(FillCacheCollection))]
public class FillLinearGeometryReuseTests
{
private readonly ITestOutputHelper output;
public FillLinearGeometryReuseTests(ITestOutputHelper output) => this.output = output;
public static IEnumerable<object[]> Cases()
{
foreach (var shape in new[] { "rectangle", "concave", "arc", "circle", "ring" })
foreach (var spacing in new[] { 0.0, 0.5 })
foreach (var angle in new[] { 0.0, System.Math.PI / 2, System.Math.PI, 0.37 })
foreach (var direction in new[] { NestDirection.Horizontal, NestDirection.Vertical })
yield return new object[] { shape, spacing, angle, direction };
}
[Theory]
[MemberData(nameof(Cases))]
public void DrawingAndRow_MatchFrozenLegacy(string shape, double spacing, double angle, NestDirection direction)
{
var drawing = Fixture(shape);
var before = ProgramValues(drawing.Program);
var area = new Box(3.1, -5.3, 42, 29);
var filler = new FillLinear(area, spacing);
var legacy = new LegacyFillLinear(area, spacing);
var inputs = new[] { drawing.Program };
var expected = legacy.Fill(drawing, angle, direction);
Assert.NotEmpty(expected);
AssertLayout(expected, filler.Fill(drawing, angle, direction), inputs);
AssertLayout(expected, filler.Fill(drawing, angle, direction), inputs);
var expectedRow = legacy.FillRow(drawing, angle, direction);
var actualRow = filler.FillRow(drawing, angle, direction);
AssertLayout(expectedRow.Parts, actualRow.Parts, inputs);
Assert.Equal(BoxBits(expectedRow.BoundingBox), BoxBits(actualRow.BoundingBox));
Assert.Equal(before, ProgramValues(drawing.Program));
}
[Theory]
[InlineData("shared")]
[InlineData("rotated")]
[InlineData("built-pair")]
public void Pattern_MatchesFrozenLegacy_IdentityAndInputOwnership(string kind)
{
foreach (var spacing in new[] { 0.0, 0.5 })
foreach (var direction in new[] { NestDirection.Horizontal, NestDirection.Vertical })
{
var pattern = MakePattern(kind);
var before = Snapshot(pattern);
var filler = new FillLinear(new Box(-7.1, 11.3, 52, 39), spacing);
var legacy = new LegacyFillLinear(filler.WorkArea, spacing);
var inputs = pattern.Parts.Select(p => p.Program).ToArray();
var expected = legacy.Fill(pattern, direction);
Assert.NotEmpty(expected);
AssertLayout(expected, filler.Fill(pattern, direction), inputs);
AssertLayout(expected, filler.Fill(pattern, direction), inputs);
Assert.Equal(before, Snapshot(pattern));
}
}
[Theory]
[InlineData(NestDirection.Horizontal)]
[InlineData(NestDirection.Vertical)]
public void BoundaryFit_MatchesBothSidesOfLastCopyThreshold(NestDirection direction)
{
var drawing = Fixture("rectangle");
var spacing = 0.5;
var dim = direction == NestDirection.Horizontal ? 10.0 : 8.0;
// Locate the adjacent-double threshold with the frozen path: the legacy
// slide can leave an epsilon in the pitch, so nominal rectangle arithmetic is not an oracle.
var rejected = 2 * dim + spacing;
var accepted = 4 * dim + 3 * spacing;
for (var i = 0; i < 64; i++)
{
var middle = (rejected + accepted) / 2;
var probeArea = new Box(3.1, 5.3, direction == NestDirection.Horizontal ? middle : 10,
direction == NestDirection.Vertical ? middle : 8);
if (new LegacyFillLinear(probeArea, spacing).FillRow(drawing, 0, direction).Parts.Count < 3)
rejected = middle;
else
accepted = middle;
}
Assert.Equal(System.Math.BitIncrement(rejected), accepted);
var counts = new HashSet<int>();
foreach (var size in new[] { rejected, accepted })
{
var area = new Box(3.1, 5.3, direction == NestDirection.Horizontal ? size : 10,
direction == NestDirection.Vertical ? size : 8);
var legacy = new LegacyFillLinear(area, spacing);
var filler = new FillLinear(area, spacing);
var expected = legacy.FillRow(drawing, 0, direction).Parts;
counts.Add(expected.Count);
AssertLayout(expected, filler.FillRow(drawing, 0, direction).Parts, new[] { drawing.Program });
AssertLayout(legacy.Fill(drawing, 0, direction), filler.Fill(drawing, 0, direction), new[] { drawing.Program });
}
Assert.Equal(new[] { 2, 3 }, counts.OrderBy(n => n));
}
[Fact]
public void OverlappingSeeds_ExerciseLegacyBoundingBoxFallback()
{
// The legacy fallback does not repair an already-overlapping input pair.
// This deliberately invalid seed deterministically exercises that branch.
var first = new Part(Fixture("rectangle"));
var pattern = new Pattern();
pattern.Parts.AddRange(new[] { first, first.CloneAtOffset(new Vector(0.25, 0.25)) });
pattern.UpdateBounds();
var area = new Box(0, 0, 35, 8.25);
var legacy = new LegacyFillLinear(area, 0.5);
var raw = new List<Part>(pattern.Parts);
raw.AddRange((List<Part>)FillExtentsTests.Invoke(legacy, "TilePattern", pattern, NestDirection.Horizontal));
Assert.True(FillHelpers.HasOverlappingParts(raw));
var fallback = new List<Part>(pattern.Parts);
fallback.AddRange((List<Part>)FillExtentsTests.Invoke(legacy, "TilePatternBbox", pattern, NestDirection.Horizontal));
var inputs = pattern.Parts.Select(p => p.Program).ToArray();
AssertLayout(fallback, legacy.Fill(pattern, NestDirection.Horizontal), inputs);
AssertLayout(fallback, new FillLinear(area, 0.5).Fill(pattern, NestDirection.Horizontal), inputs);
}
[Fact]
public void EmptyAndNoFit_MatchFrozenLegacy()
{
var area = new Box(3, 5, 1, 1);
var filler = new FillLinear(area, 0.5);
var legacy = new LegacyFillLinear(area, 0.5);
var drawing = Fixture("rectangle");
var pattern = MakePattern("rotated");
Assert.Empty(filler.Fill(new Pattern(), NestDirection.Horizontal));
Assert.Empty(legacy.Fill(new Pattern(), NestDirection.Horizontal));
Assert.Empty(filler.Fill(pattern, NestDirection.Vertical));
Assert.Empty(legacy.Fill(pattern, NestDirection.Vertical));
Assert.Empty(filler.Fill(drawing, 0, NestDirection.Horizontal));
Assert.Empty(legacy.Fill(drawing, 0, NestDirection.Horizontal));
Assert.Empty(filler.FillRow(drawing, 0, NestDirection.Vertical).Parts);
Assert.Empty(legacy.FillRow(drawing, 0, NestDirection.Vertical).Parts);
}
[Fact]
public void ConcurrentCalls_OneFiller_MatchSequentialAndPreserveInputs()
{
var patterns = new[] { MakePattern("shared"), MakePattern("rotated"), MakePattern("built-pair") };
var before = patterns.Select(Snapshot).ToArray();
var filler = new FillLinear(new Box(3.1, 5.3, 52, 39), 0.5);
var expected = patterns.Select(p => new[] { filler.Fill(p, NestDirection.Horizontal),
filler.Fill(p, NestDirection.Vertical) }).ToArray();
Parallel.For(0, 48, new ParallelOptions { MaxDegreeOfParallelism = 4 }, i =>
{
var index = i % patterns.Length;
var direction = i % 2 == 0 ? NestDirection.Horizontal : NestDirection.Vertical;
AssertLayout(expected[index][i % 2], filler.Fill(patterns[index], direction),
patterns[index].Parts.Select(p => p.Program).ToArray());
});
for (var i = 0; i < patterns.Length; i++)
Assert.Equal(before[i], Snapshot(patterns[i]));
}
[Theory]
[InlineData("rectangle")]
[InlineData("concave")]
[InlineData("arc")]
[InlineData("circle")]
[InlineData("ring")]
public void LocalEntities_CloneThenTranslate_MatchIndependentPreChangeOracle(string shape)
{
var boxesDifferent = 0;
var entitiesCompared = 0;
foreach (var spacing in new[] { 0.0, 0.125, 0.5 })
foreach (var angle in new[] { 0.0, 0.37, System.Math.PI / 2 })
foreach (var location in new[] { new Vector(0, 0), new Vector(3.1, -5.3), new Vector(100000.1, -0.001) })
{
var part = new Part(Fixture(shape));
part.Rotate(angle);
part.Offset(location);
var expected = PreChangeEntities(part, spacing);
var wrapper = PartGeometry.GetOffsetPerimeterEntities(part, spacing);
var local = PartGeometry.GetOffsetPerimeterEntities(part.Program, spacing);
var before = local.SelectMany(EntityValues).ToArray();
var beforeBoxes = local.SelectMany(e => BoxBits(e.BoundingBox)).ToArray();
var translated = local.Select(e =>
{
var clone = e.Clone();
clone.Offset(part.Location);
return clone;
}).ToList();
Assert.Equal(expected.Count, translated.Count);
Assert.Equal(expected.SelectMany(EntityValues), wrapper.SelectMany(EntityValues));
Assert.Equal(expected.SelectMany(e => BoxBits(e.BoundingBox)), wrapper.SelectMany(e => BoxBits(e.BoundingBox)));
for (var i = 0; i < expected.Count; i++)
{
Assert.Equal(EntityValues(expected[i]), EntityValues(translated[i]));
Assert.NotSame(local[i], translated[i]);
entitiesCompared++;
if (!BoxBits(expected[i].BoundingBox).SequenceEqual(BoxBits(translated[i].BoundingBox)))
{
boxesDifferent++;
if (boxesDifferent <= 3)
output.WriteLine($"bbox divergence: shape={shape}; spacing={spacing:R}; angle={angle:R}; location={location}; entity={i} {expected[i].GetType().Name}; old bits={string.Join(',', BoxBits(expected[i].BoundingBox))}; clone bits={string.Join(',', BoxBits(translated[i].BoundingBox))}");
}
}
Assert.Equal(before, local.SelectMany(EntityValues));
Assert.Equal(beforeBoxes, local.SelectMany(e => BoxBits(e.BoundingBox)));
// Verify the consumer on both axes even when clone boxes differ.
foreach (var direction in new[] { PushDirection.Left, PushDirection.Down })
{
var other = part.CloneAtOffset(new Vector(20.1, 17.3));
var stationary = PreChangeEntities(other, spacing);
Assert.Equal(Bits(SpatialQuery.DirectionalDistance(expected, stationary, direction)),
Bits(SpatialQuery.DirectionalDistance(translated, stationary, direction)));
}
}
output.WriteLine($"{shape}: entities={entitiesCompared}; bitwise different bounding boxes={boxesDifferent}");
}
[Fact]
public void EmptyProgram_PreservesPreChangeException()
{
// ShapeProfile indexes shapes[0]; an empty program throws, rather than returning an empty perimeter.
var part = new Part(new Drawing("empty", new Program()));
Assert.Throws<ArgumentOutOfRangeException>(() => PreChangeEntities(part, 0.5));
Assert.Throws<ArgumentOutOfRangeException>(() => PartGeometry.GetOffsetPerimeterEntities(part, 0.5));
Assert.Throws<ArgumentOutOfRangeException>(() => PartGeometry.GetOffsetPerimeterEntities(part.Program, 0.5));
}
#if DEBUG
[Theory]
[InlineData("row", 2, 1)]
[InlineData("drawing", 8, 1)]
[InlineData("shared", 12, 1)]
[InlineData("rotated", 12, 2)]
public void Work_PerCallPreparesEachDistinctProgramOnce(string mode, long oldCount, long newCount)
{
var drawing = Fixture("rectangle");
var area = mode is "row" or "drawing" ? new Box(3, 5, 31.5, 17) : new Box(3, 5, 42.5, 25);
var pattern = MakePattern(mode == "shared" ? "shared" : "rotated", drawing);
var legacy = new LegacyFillLinear(area, 0.5);
var filler = new FillLinear(area, 0.5);
PerfCounters.Reset();
try
{
var expected = mode == "row" ? legacy.FillRow(drawing, 0, NestDirection.Horizontal).Parts
: mode == "drawing" ? legacy.Fill(drawing, 0, NestDirection.Horizontal)
: legacy.Fill(pattern, NestDirection.Horizontal);
var before = PerfCounters.OffsetPerimeterEntities;
PerfCounters.Reset();
var actual = mode == "row" ? filler.FillRow(drawing, 0, NestDirection.Horizontal).Parts
: mode == "drawing" ? filler.Fill(drawing, 0, NestDirection.Horizontal)
: filler.Fill(pattern, NestDirection.Horizontal);
var after = PerfCounters.OffsetPerimeterEntities;
output.WriteLine($"work {mode}: legacy={before}; actual={after}; parts={actual.Count}");
AssertLayout(expected, actual, pattern.Parts.Select(p => p.Program).Append(drawing.Program).ToArray());
Assert.Equal(oldCount, before);
Assert.Equal(newCount, after);
}
finally
{
PerfCounters.Reset();
}
}
#endif
private static Drawing Fixture(string shape) => shape switch
{
"circle" => new RingShape { OuterDiameter = 8, InnerDiameter = 0 }.GetDrawing(),
"ring" => new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing(),
_ => FillExtentsTests.MakeFixture(shape),
};
private static Pattern MakePattern(string kind, Drawing? drawing = null)
{
drawing ??= Fixture("concave");
var first = Part.CreateAtOrigin(drawing, 0);
first.Offset(new Vector(11.1, -3.3));
var second = kind == "shared" ? first.CloneAtOffset(new Vector(10.5, 0))
: Part.CreateAtOrigin(drawing, System.Math.PI / 2);
if (kind != "shared")
second.Offset(new Vector(first.Right + 0.5, first.Bottom));
if (kind == "built-pair")
return FillHelpers.BuildRotatedPattern(new List<Part> { first, second }, 0.37);
var pattern = new Pattern();
pattern.Parts.AddRange(new[] { first, second });
pattern.UpdateBounds();
Assert.Equal(kind == "shared", ReferenceEquals(first.Program, second.Program));
return pattern;
}
// Independent 094c4c1 Part overload, not the new overload or delegating wrapper.
private static List<Entity> PreChangeEntities(Part part, double spacing)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList());
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
if (offsetShape == null)
return new List<Entity>();
foreach (var entity in offsetShape.Entities)
entity.Offset(part.Location);
return offsetShape.Entities;
}
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
private static long[] BoxBits(Box box) => new[] { Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width) };
private static object[] EntityValues(Entity entity)
{
var values = new List<object> { entity.GetType(), entity.Layer };
if (entity is Line line)
values.AddRange(new object[] { Bits(line.pt1.X), Bits(line.pt1.Y), Bits(line.pt2.X), Bits(line.pt2.Y) });
else if (entity is Arc arc)
values.AddRange(new object[] { Bits(arc.Center.X), Bits(arc.Center.Y), Bits(arc.Radius),
Bits(arc.StartAngle), Bits(arc.EndAngle), arc.IsReversed });
else if (entity is Circle circle)
values.AddRange(new object[] { Bits(circle.Center.X), Bits(circle.Center.Y), Bits(circle.Radius), circle.Rotation });
else
Assert.Fail($"Unexpected entity {entity.GetType()}");
return values.ToArray();
}
private static object[] ProgramValues(Program program) =>
new object[] { program, program.Codes.Count, Bits(program.Rotation), program.Mode }
.Concat(program.Codes.Cast<object>()).Concat(ConvertProgram.ToGeometry(program).SelectMany(EntityValues)).ToArray();
private static object[] Snapshot(Pattern pattern) => BoxBits(pattern.BoundingBox).Cast<object>()
.Concat(pattern.Parts.SelectMany(p => new object[] { p, p.BaseDrawing, Bits(p.Rotation), Bits(p.Location.X), Bits(p.Location.Y) }
.Concat(BoxBits(p.BoundingBox).Cast<object>()).Concat(ProgramValues(p.Program)))).ToArray();
private static void AssertLayout(List<Part> expected, List<Part> actual, Program[] inputs)
{
Assert.Equal(expected.Count, actual.Count);
for (var i = 0; i < expected.Count; i++)
{
Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing);
Assert.Equal(Bits(expected[i].Location.X), Bits(actual[i].Location.X));
Assert.Equal(Bits(expected[i].Location.Y), Bits(actual[i].Location.Y));
Assert.Equal(Bits(expected[i].Rotation), Bits(actual[i].Rotation));
Assert.Equal(BoxBits(expected[i].BoundingBox), BoxBits(actual[i].BoundingBox));
Assert.Equal(expected[i].Program.Codes.Count, actual[i].Program.Codes.Count);
Assert.Equal(ConvertProgram.ToGeometry(expected[i].Program).SelectMany(EntityValues),
ConvertProgram.ToGeometry(actual[i].Program).SelectMany(EntityValues));
foreach (var input in inputs)
Assert.Equal(ReferenceEquals(expected[i].Program, input), ReferenceEquals(actual[i].Program, input));
for (var j = 0; j < expected.Count; j++)
Assert.Equal(ReferenceEquals(expected[i].Program, expected[j].Program),
ReferenceEquals(actual[i].Program, actual[j].Program));
}
}
}
@@ -407,6 +407,73 @@ public class FillPerformanceTests
$"no-model-angles: batch ms min/median/max={times[0]:F6}/{times[median]:F6}/{times[^1]:F6}; us/call min/median/max={times[0] * 1000 / callsPerBatch:F3}/{times[median] * 1000 / callsPerBatch:F3}/{times[^1] * 1000 / callsPerBatch:F3}; B/call min/median/max={(double)bytes[0] / callsPerBatch:F3}/{(double)bytes[median] / callsPerBatch:F3}/{(double)bytes[^1] / callsPerBatch:F3}.")); $"no-model-angles: batch ms min/median/max={times[0]:F6}/{times[median]:F6}/{times[^1]:F6}; us/call min/median/max={times[0] * 1000 / callsPerBatch:F3}/{times[median] * 1000 / callsPerBatch:F3}/{times[^1] * 1000 / callsPerBatch:F3}; B/call min/median/max={(double)bytes[0] / callsPerBatch:F3}/{(double)bytes[median] / callsPerBatch:F3}/{(double)bytes[^1] / callsPerBatch:F3}."));
} }
[SkippableFact]
public void LinearGeometryReuse_ReportsPatternAndDrawing()
{
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_FILL_PERF") == "1",
"Set OPENNEST_RUN_FILL_PERF=1 to run opt-in fill microbenchmarks.");
var drawing = FillExtentsTests.MakeFixture("arc");
var first = Part.CreateAtOrigin(drawing, 0);
var second = Part.CreateAtOrigin(drawing, System.Math.PI);
second.Offset(new Vector(10.5, 0));
var pattern = FillHelpers.BuildRotatedPattern(new List<Part> { first, second }, 0.37);
var filler = new FillLinear(new Box(3.1, 5.3, 96, 48), 0.5);
var fills = new Func<List<Part>>[]
{
() => filler.Fill(pattern, NestDirection.Horizontal),
() => filler.Fill(drawing, 0.37, NestDirection.Horizontal),
};
var names = new[] { "pattern", "drawing" };
var expected = fills.Select(f => f()).ToArray();
Assert.All(expected, parts => Assert.NotEmpty(parts));
var warmupCalls = 100;
var callsPerBatch = 200;
var repetitions = 7;
#if DEBUG
output.WriteLine("Configuration=Debug (diagnostic only; use Release for measurements).");
#else
output.WriteLine("Configuration=Release.");
#endif
output.WriteLine($"Runtime={RuntimeInformation.FrameworkDescription}; OS={RuntimeInformation.OSDescription}; "
+ $"architecture={RuntimeInformation.ProcessArchitecture}; processors={Environment.ProcessorCount}; "
+ $"Stopwatch.Frequency={Stopwatch.Frequency} ticks/s.");
output.WriteLine("linear-reuse: closed 10x8 part with native radius-1 right corner arcs; "
+ "pair at 0/PI radians, second at (10.5,0), BuildRotatedPattern angle=0.37; "
+ "single drawing angle=0.37; area=(3.1,5.3,96,48), spacing=0.5, Horizontal. "
+ $"warmup=2 x {warmupCalls} calls/mode; measured={repetitions} x {callsPerBatch}; "
+ "mode order alternates including warmup; real production Fill only. "
+ "Setup/assertions/output excluded; geometry, tiling, overlap checks, GC, delegate/loop/count consumption included. "
+ "Synchronous current-thread allocation bytes, not RSS. No forced GC or cross-call geometry cache; warm JIT/drawing. "
+ "Not a timing gate or whole-job estimate.");
for (var batch = 0; batch < 2; batch++)
for (var slot = 0; slot < fills.Length; slot++)
MeasureExtents(fills[(batch + slot) % fills.Length], warmupCalls);
var samples = names.Select(_ => new ExtentsSample[repetitions]).ToArray();
for (var batch = 0; batch < repetitions; batch++)
{
for (var slot = 0; slot < fills.Length; slot++)
{
var mode = (batch + slot) % fills.Length;
samples[mode][batch] = MeasureExtents(fills[mode], callsPerBatch);
}
for (var mode = 0; mode < fills.Length; mode++)
{
var sample = samples[mode][batch];
Assert.Equal((long)callsPerBatch * expected[mode].Count, sample.PartCount);
FillExtentsTests.AssertSameLayout(expected[mode], sample.LastResult);
output.WriteLine(FormattableString.Invariant(
$"linear-reuse mode={names[mode]} batch={batch + 1}: us/call={sample.Milliseconds * 1000 / callsPerBatch:F6}; B/call={(double)sample.AllocatedBytes / callsPerBatch:F3}; ms={sample.Milliseconds:F6}; bytes={sample.AllocatedBytes}; parts={sample.PartCount}."));
}
}
for (var mode = 0; mode < fills.Length; mode++)
{
var times = samples[mode].Select(s => s.Milliseconds * 1000 / callsPerBatch).OrderBy(x => x).ToArray();
var bytes = samples[mode].Select(s => (double)s.AllocatedBytes / callsPerBatch).OrderBy(x => x).ToArray();
output.WriteLine(FormattableString.Invariant(
$"linear-reuse {names[mode]}: us/call min/median/max={times[0]:F6}/{times[repetitions / 2]:F6}/{times[^1]:F6}; B/call min/median/max={bytes[0]:F3}/{bytes[repetitions / 2]:F3}/{bytes[^1]:F3}."));
}
}
private static AngleSample MeasureAngles(Func<List<double>> build, int calls) private static AngleSample MeasureAngles(Func<List<double>> build, int calls)
{ {
var count = 0L; var count = 0L;
+509
View File
@@ -0,0 +1,509 @@
// Frozen from 094c4c1 for differential tests. Keep this reference unchanged.
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests.Fill
{
internal class LegacyFillLinear
{
public LegacyFillLinear(Box workArea, double partSpacing)
{
PartSpacing = partSpacing;
WorkArea = new Box(workArea.X, workArea.Y, workArea.Length, workArea.Width);
}
public Box WorkArea { get; }
public double PartSpacing { get; }
public double HalfSpacing => PartSpacing / 2;
/// <summary>
/// Diagnostic label set by callers to identify the engine/context in overlap logs.
/// </summary>
public string Label { get; set; }
private static Vector MakeOffset(NestDirection direction, double distance)
{
return direction == NestDirection.Horizontal
? new Vector(distance, 0)
: new Vector(0, distance);
}
private static PushDirection GetPushDirection(NestDirection direction)
{
return direction == NestDirection.Horizontal ? PushDirection.Left : PushDirection.Down;
}
private static double GetDimension(Box box, NestDirection direction)
{
return direction == NestDirection.Horizontal ? box.Length : box.Width;
}
private static double GetStart(Box box, NestDirection direction)
{
return direction == NestDirection.Horizontal ? box.Left : box.Bottom;
}
private double GetLimit(NestDirection direction)
{
return direction == NestDirection.Horizontal ? WorkArea.Right : WorkArea.Top;
}
private static NestDirection PerpendicularAxis(NestDirection direction)
{
return direction == NestDirection.Horizontal
? NestDirection.Vertical
: NestDirection.Horizontal;
}
/// <summary>
/// Finds the geometry-aware copy distance between two identical parts along an axis.
/// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled
/// exactly without polygon sampling error.
/// </summary>
private double FindCopyDistance(Part partA, NestDirection direction)
{
var bboxDim = GetDimension(partA.BoundingBox, direction);
var pushDir = GetPushDirection(direction);
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
var offset = MakeOffset(direction, startOffset);
var stationaryEntities = PartGeometry.GetOffsetPerimeterEntities(partA, HalfSpacing);
var movingEntities = PartGeometry.GetOffsetPerimeterEntities(
partA.CloneAtOffset(offset),
HalfSpacing
);
var slideDistance = SpatialQuery.DirectionalDistance(
movingEntities,
stationaryEntities,
pushDir
);
if (slideDistance >= double.MaxValue || slideDistance < 0)
return bboxDim + PartSpacing;
return startOffset - slideDistance;
}
/// <summary>
/// Finds the geometry-aware copy distance between two identical patterns along an axis.
/// Checks every pair of parts across adjacent pattern copies so multi-part patterns
/// (e.g. interlocking pairs) maintain spacing between ALL parts. Uses native entity
/// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs
/// are exact and no bbox clamp is needed.
/// </summary>
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction)
{
if (patternA.Parts.Count == 1)
return FindCopyDistance(patternA.Parts[0], direction);
var bboxDim = GetDimension(patternA.BoundingBox, direction);
var pushDir = GetPushDirection(direction);
var opposite = SpatialQuery.OppositeDirection(pushDir);
var dirVec = SpatialQuery.DirectionToOffset(pushDir, 1.0);
// bboxDim already spans max(upper) - min(lower) across all parts,
// so the start offset just needs to push beyond that plus spacing.
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
var offset = MakeOffset(direction, startOffset);
var parts = patternA.Parts;
var stationaryBoxes = new Box[parts.Count];
var movingBoxes = new Box[parts.Count];
var stationaryEntities = new List<Entity>[parts.Count];
var movingEntities = new List<Entity>[parts.Count];
for (var i = 0; i < parts.Count; i++)
{
stationaryBoxes[i] = parts[i].BoundingBox;
movingBoxes[i] = stationaryBoxes[i].Translate(offset);
}
var maxCopyDistance = 0.0;
for (var j = 0; j < parts.Count; j++)
{
var movingBox = movingBoxes[j];
for (var i = 0; i < parts.Count; i++)
{
var stationaryBox = stationaryBoxes[i];
// Skip if stationary is already ahead of moving in the push direction
// (sliding forward would take them further apart).
if (SpatialQuery.DirectionalGap(movingBox, stationaryBox, opposite) > 0)
continue;
// Skip if bboxes can't overlap along the axis perpendicular to the push.
if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec))
continue;
stationaryEntities[i] ??= PartGeometry.GetOffsetPerimeterEntities(
parts[i],
HalfSpacing
);
movingEntities[j] ??= PartGeometry.GetOffsetPerimeterEntities(
parts[j].CloneAtOffset(offset),
HalfSpacing
);
var slideDistance = SpatialQuery.DirectionalDistance(
movingEntities[j],
stationaryEntities[i],
pushDir
);
if (slideDistance >= double.MaxValue || slideDistance < 0)
continue;
var copyDist = startOffset - slideDistance;
if (copyDist > maxCopyDistance)
maxCopyDistance = copyDist;
}
}
return maxCopyDistance;
}
/// <summary>
/// Tiles a pattern along the given axis, returning the cloned parts
/// (does not include the original pattern's parts). For multi-part
/// patterns, also adds individual parts from the next incomplete copy
/// that still fit within the work area.
/// </summary>
private List<Part> TilePattern(Pattern basePattern, NestDirection direction)
{
var copyDistance = FindPatternCopyDistance(basePattern, direction);
if (copyDistance <= 0)
return new List<Part>();
var dim = GetDimension(basePattern.BoundingBox, direction);
var start = GetStart(basePattern.BoundingBox, direction);
var limit = GetLimit(direction);
var estimatedCopies = (int)((limit - start - dim) / copyDistance);
var result = new List<Part>(estimatedCopies * basePattern.Parts.Count);
var count = 1;
while (true)
{
var nextPos = start + copyDistance * count;
if (nextPos + dim > limit + Tolerance.Epsilon)
break;
var offset = MakeOffset(direction, copyDistance * count);
foreach (var part in basePattern.Parts)
result.Add(part.CloneAtOffset(offset));
count++;
}
// For multi-part patterns, try to place individual parts from the
// next copy that didn't fit as a whole. This handles cases where
// e.g. a 2-part pair only partially fits — one part may still be
// within the work area even though the full pattern exceeds it.
if (basePattern.Parts.Count > 1)
{
var offset = MakeOffset(direction, copyDistance * count);
foreach (var basePart in basePattern.Parts)
{
var part = basePart.CloneAtOffset(offset);
if (
part.BoundingBox.Right <= WorkArea.Right + Tolerance.Epsilon
&& part.BoundingBox.Top <= WorkArea.Top + Tolerance.Epsilon
&& part.BoundingBox.Left >= WorkArea.Left - Tolerance.Epsilon
&& part.BoundingBox.Bottom >= WorkArea.Bottom - Tolerance.Epsilon
)
{
result.Add(part);
}
}
}
return result;
}
/// <summary>
/// Fallback tiling using bounding-box spacing when geometry-aware tiling
/// produces overlapping parts.
/// </summary>
private List<Part> TilePatternBbox(Pattern basePattern, NestDirection direction)
{
var copyDistance = GetDimension(basePattern.BoundingBox, direction) + PartSpacing;
if (copyDistance <= 0)
return new List<Part>();
var dim = GetDimension(basePattern.BoundingBox, direction);
var start = GetStart(basePattern.BoundingBox, direction);
var limit = GetLimit(direction);
var result = new List<Part>();
var count = 1;
while (true)
{
var nextPos = start + copyDistance * count;
if (nextPos + dim > limit + Tolerance.Epsilon)
break;
var offset = MakeOffset(direction, copyDistance * count);
foreach (var part in basePattern.Parts)
result.Add(part.CloneAtOffset(offset));
count++;
}
return result;
}
private static bool HasOverlappingParts(
List<Part> parts,
out int overlapA,
out int overlapB
)
{
for (var i = 0; i < parts.Count; i++)
{
var b1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var b2 = parts[j].BoundingBox;
var overlapX =
System.Math.Min(b1.Right, b2.Right) - System.Math.Max(b1.Left, b2.Left);
var overlapY =
System.Math.Min(b1.Top, b2.Top) - System.Math.Max(b1.Bottom, b2.Bottom);
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
continue;
if (parts[i].Intersects(parts[j], out _))
{
overlapA = i;
overlapB = j;
return true;
}
}
}
overlapA = -1;
overlapB = -1;
return false;
}
/// <summary>
/// Creates a seed pattern containing a single part positioned at the work area origin.
/// Returns an empty pattern if the part does not fit.
/// </summary>
private Pattern MakeSeedPattern(Drawing drawing, double rotationAngle)
{
var pattern = new Pattern();
var template = new Part(drawing);
if (!rotationAngle.IsEqualTo(0))
template.Rotate(rotationAngle);
template.Offset(WorkArea.Location - template.BoundingBox.Location);
if (
template.BoundingBox.Width > WorkArea.Width + Tolerance.Epsilon
|| template.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon
)
return pattern;
pattern.Parts.Add(template);
pattern.UpdateBounds();
return pattern;
}
/// <summary>
/// Fills the work area by tiling the pattern along the primary axis to form
/// a row, then tiling that row along the perpendicular axis to form a grid.
/// After the grid is formed, fills the remaining strip with individual parts.
/// </summary>
private List<Part> FillGrid(Pattern pattern, NestDirection direction)
{
var perpAxis = PerpendicularAxis(direction);
// Step 1: Tile along primary axis
var row = new List<Part>(pattern.Parts);
row.AddRange(TilePattern(pattern, direction));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1))
{
LogOverlap("Step1-Primary", direction, pattern, row, a1, b1);
row = new List<Part>(pattern.Parts);
row.AddRange(TilePatternBbox(pattern, direction));
}
// If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count)
{
row.AddRange(TilePattern(pattern, perpAxis));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
{
LogOverlap("Step1-PerpOnly", perpAxis, pattern, row, a2, b2);
row = new List<Part>(pattern.Parts);
row.AddRange(TilePatternBbox(pattern, perpAxis));
}
return row;
}
// Step 2: Build row pattern and tile along perpendicular axis
var rowPattern = new Pattern();
rowPattern.Parts.AddRange(row);
rowPattern.UpdateBounds();
var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis));
if (HasOverlappingParts(gridResult, out var a3, out var b3))
{
LogOverlap("Step2-Perp", perpAxis, rowPattern, gridResult, a3, b3);
gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePatternBbox(rowPattern, perpAxis));
}
return gridResult;
}
private void LogOverlap(
string step,
NestDirection tilingDir,
Pattern pattern,
List<Part> parts,
int idxA,
int idxB
)
{
var pa = parts[idxA];
var pb = parts[idxB];
var ba = pa.BoundingBox;
var bb = pb.BoundingBox;
Debug.WriteLine($"[FillLinear] OVERLAP FALLBACK ({Label ?? "unknown"})");
Debug.WriteLine($" Step: {step}, TilingDir: {tilingDir}");
Debug.WriteLine(
$" WorkArea: ({WorkArea.X:F4},{WorkArea.Y:F4}) {WorkArea.Width:F4}x{WorkArea.Length:F4}, Spacing: {PartSpacing}"
);
Debug.WriteLine(
$" Pattern: {pattern.Parts.Count} parts, bbox {pattern.BoundingBox.Width:F4}x{pattern.BoundingBox.Length:F4}"
);
Debug.WriteLine($" Total parts after tiling: {parts.Count}");
Debug.WriteLine($" Overlapping pair [{idxA}] vs [{idxB}]:");
Debug.WriteLine(
$" [{idxA}]: drawing={pa.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(pa.Rotation):F2}° "
+ $"loc=({pa.Location.X:F4},{pa.Location.Y:F4}) bbox=({ba.Left:F4},{ba.Bottom:F4})-({ba.Right:F4},{ba.Top:F4})"
);
Debug.WriteLine(
$" [{idxB}]: drawing={pb.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(pb.Rotation):F2}° "
+ $"loc=({pb.Location.X:F4},{pb.Location.Y:F4}) bbox=({bb.Left:F4},{bb.Bottom:F4})-({bb.Right:F4},{bb.Top:F4})"
);
// Log all pattern seed parts for reproduction
Debug.WriteLine($" Pattern seed parts:");
for (var i = 0; i < pattern.Parts.Count; i++)
{
var p = pattern.Parts[i];
Debug.WriteLine(
$" [{i}]: drawing={p.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(p.Rotation):F2}° "
+ $"loc=({p.Location.X:F4},{p.Location.Y:F4}) bbox={p.BoundingBox.Width:F4}x{p.BoundingBox.Length:F4}"
);
}
}
/// <summary>
/// Fills a single row of identical parts along one axis using geometry-aware spacing.
/// </summary>
public Pattern FillRow(Drawing drawing, double rotationAngle, NestDirection direction)
{
var seed = MakeSeedPattern(drawing, rotationAngle);
if (seed.Parts.Count == 0)
return seed;
var template = seed.Parts[0];
var copyDistance = FindCopyDistance(template, direction);
if (copyDistance <= 0)
return seed;
var dim = GetDimension(template.BoundingBox, direction);
var start = GetStart(template.BoundingBox, direction);
var limit = GetLimit(direction);
var count = 1;
while (true)
{
var nextPos = start + copyDistance * count;
if (nextPos + dim > limit + Tolerance.Epsilon)
break;
var clone = template.CloneAtOffset(MakeOffset(direction, copyDistance * count));
seed.Parts.Add(clone);
count++;
}
seed.UpdateBounds();
return seed;
}
/// <summary>
/// Fills the work area by tiling a pre-built pattern along both axes.
/// </summary>
public List<Part> Fill(Pattern pattern, NestDirection primaryAxis)
{
if (pattern.Parts.Count == 0)
return new List<Part>();
var offset = WorkArea.Location - pattern.BoundingBox.Location;
var basePattern = pattern.Clone(offset);
if (
basePattern.BoundingBox.Width > WorkArea.Width + Tolerance.Epsilon
|| basePattern.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon
)
return new List<Part>();
return FillGrid(basePattern, primaryAxis);
}
/// <summary>
/// Fills the work area by creating a seed part, then recursively tiling
/// along the primary axis and then the perpendicular axis.
/// </summary>
public List<Part> Fill(Drawing drawing, double rotationAngle, NestDirection primaryAxis)
{
var seed = MakeSeedPattern(drawing, rotationAngle);
if (seed.Parts.Count == 0)
return new List<Part>();
return FillGrid(seed, primaryAxis);
}
}
}
+156
View File
@@ -710,3 +710,159 @@ dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll \
- Removed work is demonstrated by the deterministic Debug counters and red/green evidence in each task section; correctness is carried by the final-tree 1,335/1,356/300 passing suites; measurable local gains exist where allocation or scanning work was actually eliminated (Tasks 2, 3, 4a, 4b), with timing claims bounded to their same-harness pairs. - Removed work is demonstrated by the deterministic Debug counters and red/green evidence in each task section; correctness is carried by the final-tree 1,335/1,356/300 passing suites; measurable local gains exist where allocation or scanning work was actually eliminated (Tasks 2, 3, 4a, 4b), with timing claims bounded to their same-harness pairs.
- The whole-job corpus measurement above confirms identical valid classification, placed/requested counts, and cost on one small real-drawing job, with timing differences inside baseline run-to-run spread — inconclusive timing, not a measured performance equivalence, and not a speedup claim. A representative production `.nest` corpus at scale, Windows UI runtime testing, and actual ONNX-model inference remain untested. - The whole-job corpus measurement above confirms identical valid classification, placed/requested counts, and cost on one small real-drawing job, with timing differences inside baseline run-to-run spread — inconclusive timing, not a measured performance equivalence, and not a speedup claim. A representative production `.nest` corpus at scale, Windows UI runtime testing, and actual ONNX-model inference remain untested.
- Gated follow-ups A (offset-geometry reuse), B (sorted overlap broad phase), and C remain unauthorized and were not started. - Gated follow-ups A (offset-geometry reuse), B (sorted overlap broad phase), and C remain unauthorized and were not started.
## Follow-up A — reuse FillLinear offset geometry — 2026-09-26
### Status and exact-layout acceptance
Accepted after resolving an initial exact-layout stop. The first whole-job comparison (normal thread pool, `whole-*` runs below) showed different placements between before and after runs, so implementation work stopped at that gate. Acceptance rests on the serialized corpus comparison below, the frozen-reference differential tests (including concurrent calls on one instance), and inspection that the cache is call-local and never shared. Under normal scheduling, the observed differences are most plausibly pre-existing tie-breaking nondeterminism; the evidence:
- Under the normal thread pool the before tree alone produced two distinct exact layouts across its three runs (`whole-before-1` differs from the identical `whole-before-2`/`-3`), and one after run (`whole-after-2`) reproduced the dominant before layout bit-for-bit. The first observed difference, `whole-before-1` versus `whole-after-1` at `Plates[0].Placements[0]` (`1214 A02 PT04`, before X/Y/rotation `8.312499999999998`/`19.093722188575505`/`4.71238898038469`, after `2.5000000000000013`/`8.864574062858505`/`1.5707963267948966`), is also exactly the difference between `whole-before-1` and `whole-before-2`.
- A plausible mechanism (not proven here) is that parallel producers (`FillHelpers.FillPattern`, `PairEvaluator.EvaluateAll`, `BestFitFinder` strategy fan-out) collect into `ConcurrentBag` and later selection keeps the first of equal-scoring results, so ties follow thread completion order.
- Determinism probe: a small console program (source and project files retained with the evidence) calls `DxfManifestLoader.Load` and `NestingEngineRegistry.Create("Default").Solve(...)` directly with `DOTNET_PROCESSOR_COUNT=1` and the thread pool capped at one worker (`SetMinThreads(1,1)`/`SetMaxThreads(1,1)`, verified before solving), then writes every placement's X/Y/rotation as IEEE-754 bits. Serial runs before-1, after-1, before-2, after-2 produced byte-identical files (SHA-256 `62b5a8d065c889e3599e3bd00e3f591502553c24e81962ccdcb06c4b06159708` for all four; 169 placements, two plates). The probe referenced each tree's own `OpenNest.Benchmark` project; its `OpenNest.Engine.dll` hashes matched the corresponding benchmark builds (`a43d9db5…` before, `ff2bc016…` after). That serialized layout also matches the dominant normal-pool layout, as a placement multiset ignoring order and instance numbering, seen in `whole-before-2`, `whole-before-3` and `whole-after-2`.
- An earlier probe variant that ran `Solve` on a one-thread custom `TaskScheduler` did not serialize the engine (`Parallel.For`/`Parallel.Invoke` do not inherit a custom scheduler) and gave identical before layouts (`3f00c783…`) but two different after layouts (`f571c52e…`, `2c9b9c35…`); it is superseded, and its output files were not retained.
Conclusion: with scheduling held fixed, the optimized tree's whole-job output is bit-for-bit identical to the base tree's on this corpus job. Serialized runs cannot exclude a difference that appears only under concurrency; that risk is covered by the concurrent differential test and the per-call cache ownership, not by this probe. Under normal parallel scheduling, neither tree gives reproducible exact layouts, so that comparison cannot serve as an exact-layout oracle. The scheduling nondeterminism is pre-existing and out of this slice's scope; it is recorded here, not fixed or tuned around. Evidence: `/home/aj/extracted/2026-09-26/followup-a/determinism-probe/` (probe source, projects, placement TSVs, and `det1-run-stdout.txt` with the exact invocation and per-run output) and `layout-comparison-blocker.json`. An independent spec review rebuilt the probe against isolated trees and reproduced the same SHA-256 in four fresh processes.
### Scope, design and preservation contract
`PartGeometry.GetOffsetPerimeterEntities(CNC.Program, spacing)` prepares a fresh local-frame perimeter and increments `OffsetPerimeterEntities` once. The Part overload delegates then translates that fresh list in place, preserving the old arithmetic/list semantics. No other PartGeometry method changed.
Each public `FillLinear.Fill` / `FillRow` entry owns a fresh private cache keyed by Program **reference identity**, with fixed half-spacing. Private calls thread that cache through the grid/tile/distance chain. Cached entities never escape: each world-space result clones then offsets each entity. Moving locations remain `part.Location + offset`, in the same addition order as `CloneAtOffset`. Lazy `??=` preparation follows unchanged bbox prefilters. Pair order, DirectionalDistance arguments, invalid-distance fallback, max-copy logic, tiling and overlap/bbox fallback are unchanged. No shared instance/static cache, overlap algorithm, Compactor, RotationSlideStrategy or StripeFiller change.
`LegacyFillLinear` was copied before production edits from `094c4c1`; undoing only the header, added imports, namespace, visibility and class/constructor names reproduces the base file exactly (formatter sorted the added imports). It is test-only and frozen. The independent entity oracle recomputes ConvertProgram → material filter → ShapeProfile → OffsetOutward → location translation, rather than comparing two delegating wrappers.
Rechecked `SpatialQuery.cs` lines 627–960: DirectionalDistance, RayEntityDistance, ExtractEntityVertices, ArcToLineClosestDistance and AddArcExtremeVertices use points/centers/radii/angles, not entity BoundingBox/Left/Right/Top/Bottom. Line.Offset incrementally translates its box, while Line.Clone recomputes it; the new code clones **local** prepared entities before world translation. Across the delivered entity oracle's 783 entity comparisons, type/order/layer/geometric fields and bounding boxes were bitwise equal (rectangle 216, concave 297, arc 216, circle 27, ring 27; zero bbox divergences). This fixture evidence is not a universal box-equivalence proof. Full-fill differential tests additionally compare bitwise output locations/boxes/rotations, drawing identity and Program-sharing equivalence. Empty programs preserve the pre-existing ArgumentOutOfRangeException; no valid nonnegative-spacing null-offset fixture was established.
Coverage includes both directions, zero/positive spacing, nonzero origins, orthogonal/non-orthogonal rotations, Drawing/Pattern/FillRow, shared-Program patterns, distinct rotations of one drawing, BuildRotatedPattern, adjacent-double last-copy thresholds, repeated calls and 48 bounded concurrent calls on one filler. The fallback fixture deliberately starts with overlapping seeds and proves legacy bbox fallback without claiming it repairs invalid seeds. Input geometry/program/location ownership is checked. The initial nominal rectangle threshold assumption failed two tests; it was replaced with adjacent-double threshold discovery using only the frozen reference, then passed.
### Provenance
Base `094c4c196bc793ef184fae15380a7c94544697de`, branch master. Ubuntu 24.04.5 LTS x64, shared KVM VM with four processors, SDK 10.0.112; measurement output reports .NET 8.0.31 and Stopwatch frequency 1,000,000,000 ticks/s. Before is the detached baseline worktree with **only** the identical benchmark test file copied in. The original base harness did not contain this new test. The manifest and all four referenced archive DXFs were reachable.
| Source | Before SHA-256 | Delivered/measurement SHA-256 |
| --- | --- | --- |
| `OpenNest.Engine/Fill/FillLinear.cs` | `c7dc33a67d1571e671b2a90dd0b9c585dce9c7e94d532891ed7b646189918b80` | `3a0dca084aa9b8f4e61e3ed836e49ed2cfbf311c92d5076460983c573cdcc061` |
| `OpenNest.Core/PartGeometry.cs` | `fb54f33fd029df2f76bfb30590c976f49995eb041a74c7863d1feafc6fddfc4c` | `5e3c8f9493afffb4b0db69ca6dcf0a738a591e6a636df476ed3b0d8cb6658894` |
| `OpenNest.Tests/Fill/FillPerformanceTests.cs` | `dd33805a723b2fa9068177490f0211ac707b555c46db7ce3e1c9d161c06beba7` | `dd33805a723b2fa9068177490f0211ac707b555c46db7ce3e1c9d161c06beba7` |
| Corpus manifest | `3f13c7a674b2a33a26f31dcbf8fec3fc70ab7ef7f10bd801fb784d3d9179987b` | same file/hash |
The benchmark depends only on APIs/helpers already present at the base. No harness mode was redirected to a reference implementation. Production and harness files were restored after mutation testing before measurements; their final hashes match measurement provenance.
### Genuine red/green and test evidence
Counters are Debug-only; Release zero counters prove nothing. All work assertions use FillCacheCollection and reset in finally.
| Workload | Legacy preparations | Always-miss RED actual / expected | Restored GREEN actual | Output parts |
| --- | ---: | --- | ---: | ---: |
| FillRow rectangle | 2 | 2 / 1 | 1 | 3 |
| Drawing Fill rectangle | 8 | 8 / 1 | 1 | 6 |
| Shared-Program pattern | 12 | 12 / 1 | 1 | 8 |
| Two rotated Programs from one drawing | 12 | 12 / 2 | 2 | 8 |
For RED, replaced the cache lookup/add block with an unconditional preparation in the delivered source: four failures, process exit 1. Restored in finally. Separately changed the key to BaseDrawing, passing Part into AtLocation: shared pattern passed, rotated and built-pair cases failed (2 failures, exit 1). Rotated expected 18 parts, actual 15; built-pair location bits expected 4626892942313738295, actual 4626987413290037556. Restored in finally, byte-for-byte source check passed. Then all 98 targeted Debug cases passed (exit 0).
Exact commands (each with `--logger 'console;verbosity=detailed' --logger 'trx;LogFileName=<name>.trx' --results-directory <evidence>`):
```bash
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --filter FullyQualifiedName~FillLinearGeometryReuseTests.Work_
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --filter FullyQualifiedName~FillLinearGeometryReuseTests.Pattern_Matches
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --filter FullyQualifiedName~FillLinearGeometryReuseTests
```
| Suite / configuration | Passed | Skipped | Failed |
| --- | ---: | ---: | ---: |
| Unmodified baseline main Release | 1337 | 18 | 0 |
| Unmodified baseline engine Release | 300 | 0 | 0 |
| Final-source targeted Debug | 98 | 0 | 0 |
| Always-miss mutation Debug | 0 | 0 | 4 |
| Wrong-drawing-key mutation Debug | 1 | 0 | 2 |
| Each of four enabled microbenchmark Release processes | 1 | 0 | 0 |
| Final main Release | 1431 | 19 | 0 |
| Final main Debug | 1456 | 19 | 0 |
| Final engine Release | 300 | 0 | 0 |
| Final targeted Release (`FillLinearGeometryReuseTests`) | 94 | 0 | 0 |
| `Category=FillPerformance`, variable unset / `=0` | 0 / 0 | 7 / 7 | 0 / 0 |
Counts come from individual TRX UnitTestResult outcomes, not aggregate notExecuted. Baseline skips: 12 optional CHR fixtures and six opt-in performance cases; final skips add the new opt-in `LinearGeometryReuse_ReportsPatternAndDrawing`. Main Release delta +94 passed equals the targeted Release count; Debug additionally runs the four `#if DEBUG` work cases (targeted Debug 98). Both disabled gates skip all seven benchmarks. Final-tree suites ran after the determinism investigation on unchanged production/test sources (hashes above).
### Same-harness Release microbenchmark observations
Production Fill only: closed 10×8 native-radius-1 right-corner-arc part; pair seeded at 0/π, second at (10.5,0), then BuildRotatedPattern at 0.37 radians; single Drawing at 0.37. Area `(3.1,5.3,96,48)`, spacing 0.5, Horizontal. Two warmup batches ×100 calls per mode, seven measured batches ×200 calls, alternating mode order including warmup. Four serial fresh processes: before-1, after-1, after-2, before-2. Setup/assertions/output excluded; geometry, tiling, overlap checks, GC, delegate/loop and nonallocating count consumption included. Current-thread synchronous allocations, not RSS. No forced GC. Every pattern batch consumes 7,200 parts (36/call); drawing consumes 8,000 (40/call).
Raw batches, µs/call / B/call:
| Process | Batch | Pattern | Drawing |
| --- | ---: | --- | --- |
| before-1 | 1 | 13890.929840 / 11978904 | 22417.643430 / 15334152 |
| before-1 | 2 | 13156.240410 / 11978904 | 22520.495810 / 15334152 |
| before-1 | 3 | 13412.731580 / 11978904 | 22479.693845 / 15334152 |
| before-1 | 4 | 13284.240955 / 11978904 | 22318.351345 / 15334152 |
| before-1 | 5 | 13139.308525 / 11978904 | 22309.902190 / 15334152 |
| before-1 | 6 | 13262.782455 / 11978904 | 22268.902850 / 15334152 |
| before-1 | 7 | 13018.847040 / 11978904 | 22367.989855 / 15334152 |
| after-1 | 1 | 12025.202410 / 8640400 | 20573.556690 / 12179776 |
| after-1 | 2 | 11512.053070 / 8640400 | 20487.712005 / 12179776 |
| after-1 | 3 | 11656.403895 / 8640400 | 20300.006810 / 12179776 |
| after-1 | 4 | 11628.209575 / 8640400 | 20608.516540 / 12179776 |
| after-1 | 5 | 11590.578130 / 8640400 | 20591.376225 / 12179776 |
| after-1 | 6 | 11741.157625 / 8640400 | 20960.479975 / 12179776 |
| after-1 | 7 | 11603.496715 / 8640400 | 20470.246675 / 12179776 |
| after-2 | 1 | 12322.696725 / 8640400 | 20581.793035 / 12179776 |
| after-2 | 2 | 11570.155470 / 8640400 | 20518.170995 / 12179776 |
| after-2 | 3 | 11512.081200 / 8640400 | 20538.707185 / 12179776 |
| after-2 | 4 | 11700.329105 / 8640400 | 20246.447735 / 12179776 |
| after-2 | 5 | 12010.912020 / 8640400 | 20708.860945 / 12179776 |
| after-2 | 6 | 11509.263120 / 8640400 | 20560.583720 / 12179776 |
| after-2 | 7 | 11499.249420 / 8640400 | 20658.498240 / 12179776 |
| before-2 | 1 | 14218.554935 / 11978904 | 22026.400520 / 15334152 |
| before-2 | 2 | 13131.679745 / 11978904 | 22372.373875 / 15334152 |
| before-2 | 3 | 13206.351480 / 11978904 | 22058.792355 / 15334152 |
| before-2 | 4 | 13092.448795 / 11978904 | 21892.855215 / 15334152 |
| before-2 | 5 | 13108.256065 / 11978904 | 22404.148505 / 15334152 |
| before-2 | 6 | 13450.579065 / 11978904 | 22287.616975 / 15334152 |
| before-2 | 7 | 13139.747450 / 11978904 | 21835.373995 / 15334152 |
| Process / mode | µs/call min / median / max | B/call min / median / max |
| --- | --- | --- |
| before-1 / pattern | 13018.847040 / 13262.782455 / 13890.929840 | 11978904 / 11978904 / 11978904 |
| before-1 / drawing | 22268.902850 / 22367.989855 / 22520.495810 | 15334152 / 15334152 / 15334152 |
| after-1 / pattern | 11512.053070 / 11628.209575 / 12025.202410 | 8640400 / 8640400 / 8640400 |
| after-1 / drawing | 20300.006810 / 20573.556690 / 20960.479975 | 12179776 / 12179776 / 12179776 |
| after-2 / pattern | 11499.249420 / 11570.155470 / 12322.696725 | 8640400 / 8640400 / 8640400 |
| after-2 / drawing | 20246.447735 / 20560.583720 / 20708.860945 | 12179776 / 12179776 / 12179776 |
| before-2 / pattern | 13092.448795 / 13139.747450 / 14218.554935 | 11978904 / 11978904 / 11978904 |
| before-2 / drawing | 21835.373995 / 22058.792355 / 22404.148505 | 15334152 / 15334152 / 15334152 |
### Whole-job measurement
Same real 169-part, four-DXF corpus manifest as Task 5; Default only, `--parallel 1`, six fresh processes in B,A,B,A,B,A order. No StockLadder run. DXF import precedes timing. With `--output`, Time(ms) includes BuildNestJob, Solve, materialization, validation/scoring preparation **and output .nest/JSON serialization** through Stopwatch.Stop in BenchmarkRunner. No whole-job allocation measurements. Both builds lacked an installed angle model.
| Run | Time (ms) | Valid | Placed / requested | Cost | Plates |
| --- | ---: | --- | --- | ---: | ---: |
| before-1 | 40636 | True | 169 / 169 | 9216.00 | 2 |
| after-1 | 18904 | True | 169 / 169 | 9216.00 | 2 |
| before-2 | 40932 | True | 169 / 169 | 9216.00 | 2 |
| after-2 | 18810 | True | 169 / 169 | 9216.00 | 2 |
| before-3 | 40715 | True | 169 / 169 | 9216.00 | 2 |
| after-3 | 18451 | True | 169 / 169 | 9216.00 | 2 |
Before min/median/max: 40636 / 40715 / 40932 ms; after: 18451 / 18810 / 18904 ms. All runs are valid, fully placed and have empty validation notes. Ranges do not overlap: after-tree median is 53.8% lower than before (−21,905 ms) on this one corpus job, on a shared four-vCPU VM. That is a single-job observation, not a general speedup guarantee. The separate one-worker determinism probe (Status section) shows the same direction: 47,328/47,446 ms before versus 24,095/23,182 ms after. Its timings are only indicative because it runs on a single worker.
Exact comparison reads each JSON's ordered Plates/Placements and compares double IEEE-754 bytes; PartId maps to names from saved .nest/nest.json. The first pair's JSON and .nest poses agree within each run. Additionally compared placement multisets without instance index/order. No tolerance or coordinate rounding was used. Exact layout classes over the six normal-pool runs, as placement multisets: {before-2, before-3, after-2} identical; before-1, after-1 and after-3 each distinct. Because the before tree alone yields multiple classes, exact-layout acceptance rests on the serialized determinism probe, not on these runs. Evidence: `layout-comparison-blocker.json` plus every raw output directory.
### Reproduction, limitations and remaining work
```bash
# Prefix both scoped formatter commands with EnableWindowsTargeting=true on Linux.
dotnet format OpenNest.sln --include OpenNest.Core/PartGeometry.cs OpenNest.Engine/Fill/FillLinear.cs OpenNest.Tests/Fill/LegacyFillLinear.cs OpenNest.Tests/Fill/FillLinearGeometryReuseTests.cs OpenNest.Tests/Fill/FillPerformanceTests.cs
# Repeat exactly with --verify-no-changes.
OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release --filter FullyQualifiedName~LinearGeometryReuse_ReportsPatternAndDrawing --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> --output <directory>
```
Unqualified solution formatter and verify each initially exited 1 (`Restore operation failed`); explicit restore diagnosed NETSDK1100, Windows targeting disabled. With environment `EnableWindowsTargeting=true`, the same solution/include formatter and verify each exited 0. No project files or broader source formatting changed. `git diff --check` exited 0. AGENTS.md gained one clause on the per-call `FillLinear` Program cache and its lifetime; CLAUDE.md is unchanged. Combined size is 31,610 bytes (AGENTS.md 31,599 + CLAUDE.md 11), under the 32,768-byte limit. README and the pre-existing untracked planning files were untouched.
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/`.
+3 -3
View File
@@ -9,16 +9,16 @@ OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Rel
Only the exact value `1` enables these tests; otherwise they skip; [README](../../README.md) documents the PowerShell equivalent. Only the exact value `1` enables these tests; otherwise they skip; [README](../../README.md) documents the PowerShell equivalent.
The category covers comparer, group-pattern, rotated-pattern, extents-column, feature-extraction, and no-model angle workloads; individual filters match benchmark method names in `FillPerformanceTests.cs`. Keep harness, inputs, warmups and batches identical before/after; exclude setup/assertions from timing. Comparer/extents allocations are synchronous and current-thread only; parallel group fills omit allocation totals. No timing CI gates or whole-job speedup claims. Preserve evidence in [the measured report](fill-performance.md). The category covers comparer, group-pattern, rotated-pattern, extents-column, feature-extraction, no-model angle, and FillLinear offset-geometry workloads; individual filters match benchmark method names in `FillPerformanceTests.cs`. Keep harness, inputs, warmups and batches identical before/after; exclude setup/assertions from timing. Comparer/extents allocations are synchronous and current-thread only; parallel group fills omit allocation totals. No timing CI gates or whole-job speedup claims. Preserve evidence in [the measured report](fill-performance.md).
Debug behavior/skipped-work checks: Debug behavior/skipped-work checks:
```bash ```bash
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \ dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \
--filter 'FullyQualifiedName~DefaultFillComparerWorkTests|FullyQualifiedName~FillHelpersTests|FullyQualifiedName~FillExtentsTests|FullyQualifiedName~StrategyOverlapTests' --filter 'FullyQualifiedName~DefaultFillComparerWorkTests|FullyQualifiedName~FillHelpersTests|FullyQualifiedName~FillExtentsTests|FullyQualifiedName~StrategyOverlapTests|FullyQualifiedName~FillLinearGeometryReuseTests'
``` ```
`PerfCounters.FillScoreComputations`, `PartBoundaryPreparations`, `PartBoundsUpdates`, and `FeatureBitmaskCells` increments compile away in Release: zero Release counters prove nothing. Serialize counter assertions in `FillCacheCollection` and reset in `finally`. Keep `OpenNest.Tests/Fill/LegacyFillExtents.cs` frozen for differential tests, not production or before timings; measure the actual baseline production code. `PerfCounters.FillScoreComputations`, `PartBoundaryPreparations`, `PartBoundsUpdates`, `OffsetPerimeterEntities`, and `FeatureBitmaskCells` increments compile away in Release: zero Release counters prove nothing. Serialize counter assertions in `FillCacheCollection` and reset in `finally`. Keep `OpenNest.Tests/Fill/LegacyFillExtents.cs` and `OpenNest.Tests/Fill/LegacyFillLinear.cs` frozen for differential tests, 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. 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.