perf(fill): avoid redundant bounds recomputation

This commit is contained in:
aj
2026-09-25 21:45:03 -04:00
parent 4ec92c95ec
commit 4553f8afad
10 changed files with 483 additions and 12 deletions
+2 -2
View File
@@ -33,7 +33,7 @@ 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. PowerShell: set `$env:OPENNEST_RUN_FILL_PERF = '1'`, run `dotnet test`, then `Remove-Item Env:OPENNEST_RUN_FILL_PERF`. Only the exact value `1` enables these tests; otherwise they skip. PowerShell: set `$env:OPENNEST_RUN_FILL_PERF = '1'`, run `dotnet test`, then `Remove-Item Env:OPENNEST_RUN_FILL_PERF`.
The category covers comparer, group-pattern, and extents-column workloads. Individual filters: `FullyQualifiedName~GroupPattern_ReportsDefaultAndCustomComparer` or `FullyQualifiedName~Extents_ReportsRepeatedColumnRebuilds`. 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](docs/performance/fill-performance.md). The category covers comparer, group-pattern, rotated-pattern, and extents-column 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](docs/performance/fill-performance.md).
Debug behavior/skipped-work checks: Debug behavior/skipped-work checks:
@@ -42,7 +42,7 @@ 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'
``` ```
`PerfCounters.FillScoreComputations` and `PartBoundaryPreparations` 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`, and `PartBoundsUpdates` 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.
## Architecture ## Architecture
+1
View File
@@ -206,6 +206,7 @@ namespace OpenNest
/// </summary> /// </summary>
public void UpdateBounds() public void UpdateBounds()
{ {
PerfCounters.CountPartBoundsUpdate();
BoundingBox = Program.BoundingBox(); BoundingBox = Program.BoundingBox();
BoundingBox.Offset(Location); BoundingBox.Offset(Location);
} }
+6
View File
@@ -14,12 +14,14 @@ namespace OpenNest
private static long partIntersects; private static long partIntersects;
private static long fillScoreComputations; private static long fillScoreComputations;
private static long partBoundaryPreparations; private static long partBoundaryPreparations;
private static long partBoundsUpdates;
public static long FindBestFits => Interlocked.Read(ref findBestFits); public static long FindBestFits => Interlocked.Read(ref findBestFits);
public static long OffsetPerimeterEntities => Interlocked.Read(ref offsetPerimeterEntities); public static long OffsetPerimeterEntities => Interlocked.Read(ref offsetPerimeterEntities);
public static long PartIntersects => Interlocked.Read(ref partIntersects); public static long PartIntersects => Interlocked.Read(ref partIntersects);
public static long FillScoreComputations => Interlocked.Read(ref fillScoreComputations); public static long FillScoreComputations => Interlocked.Read(ref fillScoreComputations);
public static long PartBoundaryPreparations => Interlocked.Read(ref partBoundaryPreparations); public static long PartBoundaryPreparations => Interlocked.Read(ref partBoundaryPreparations);
public static long PartBoundsUpdates => Interlocked.Read(ref partBoundsUpdates);
[Conditional("DEBUG")] [Conditional("DEBUG")]
public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits); public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits);
@@ -37,6 +39,9 @@ namespace OpenNest
[Conditional("DEBUG")] [Conditional("DEBUG")]
public static void CountPartBoundaryPreparation() => Interlocked.Increment(ref partBoundaryPreparations); public static void CountPartBoundaryPreparation() => Interlocked.Increment(ref partBoundaryPreparations);
[Conditional("DEBUG")]
public static void CountPartBoundsUpdate() => Interlocked.Increment(ref partBoundsUpdates);
public static void Reset() public static void Reset()
{ {
Interlocked.Exchange(ref findBestFits, 0); Interlocked.Exchange(ref findBestFits, 0);
@@ -44,6 +49,7 @@ namespace OpenNest
Interlocked.Exchange(ref partIntersects, 0); Interlocked.Exchange(ref partIntersects, 0);
Interlocked.Exchange(ref fillScoreComputations, 0); Interlocked.Exchange(ref fillScoreComputations, 0);
Interlocked.Exchange(ref partBoundaryPreparations, 0); Interlocked.Exchange(ref partBoundaryPreparations, 0);
Interlocked.Exchange(ref partBoundsUpdates, 0);
} }
} }
} }
+5 -4
View File
@@ -80,8 +80,8 @@ namespace OpenNest.Engine.Fill
// Position part2 to the right of part1 at bounding box width distance. // Position part2 to the right of part1 at bounding box width distance.
var startOffset = part1.BoundingBox.Length + part2.BoundingBox.Length + partSpacing; var startOffset = part1.BoundingBox.Length + part2.BoundingBox.Length + partSpacing;
part2.Offset(startOffset, 0); part2.Offset(startOffset, 0);
part2.UpdateBounds();
// Slide uses locations, not cached bounds; Offset already translates the box.
// Slide part2 left toward part1. // Slide part2 left toward part1.
var movingLines = boundary2.GetLines(part2.Location, PushDirection.Left); var movingLines = boundary2.GetLines(part2.Location, PushDirection.Left);
var stationaryLines = boundary1.GetLines(part1.Location, PushDirection.Right); var stationaryLines = boundary1.GetLines(part1.Location, PushDirection.Right);
@@ -94,7 +94,6 @@ namespace OpenNest.Engine.Fill
if (dist < double.MaxValue && dist > 0) if (dist < double.MaxValue && dist > 0)
{ {
part2.Offset(-dist, 0); part2.Offset(-dist, 0);
part2.UpdateBounds();
} }
var pair = AnchorToWorkArea(part1, part2); var pair = AnchorToWorkArea(part1, part2);
@@ -318,9 +317,9 @@ namespace OpenNest.Engine.Fill
// Separate: shift part2 right so bounding boxes don't touch. // Separate: shift part2 right so bounding boxes don't touch.
p2.Offset(partSpacing, 0); p2.Offset(partSpacing, 0);
p2.UpdateBounds();
// Apply the vertical shift. // Apply the vertical shift. Recompute once after both offsets: Compactor's
// box-based thresholds require the original rounding, not accumulated translates.
p2.Offset(0, verticalShift); p2.Offset(0, verticalShift);
p2.UpdateBounds(); p2.UpdateBounds();
@@ -361,6 +360,8 @@ namespace OpenNest.Engine.Fill
var anchor = new Vector(workArea.X - bbox.Left, workArea.Y - bbox.Bottom); var anchor = new Vector(workArea.X - bbox.Left, workArea.Y - bbox.Bottom);
part1.Offset(anchor); part1.Offset(anchor);
part2.Offset(anchor); part2.Offset(anchor);
// Keep these recomputations: translating the cached boxes can differ by an
// ulp, changing exact layouts and the pair-fit/column-tiling thresholds.
part1.UpdateBounds(); part1.UpdateBounds();
part2.UpdateBounds(); part2.UpdateBounds();
@@ -18,6 +18,8 @@ namespace OpenNest.Engine.Strategies
foreach (var part in groupParts) foreach (var part in groupParts)
{ {
var clone = (Part)part.Clone(); var clone = (Part)part.Clone();
// Keep the recompute: at angle 0, accumulated source translations can
// leave a different cached box than Program.BoundingBox() + Location.
clone.UpdateBounds(); clone.UpdateBounds();
if (!angle.IsEqualTo(0)) if (!angle.IsEqualTo(0))
+187 -3
View File
@@ -64,6 +64,160 @@ public class FillExtentsTests
Assert.Equal(before, Snapshot(drawing)); Assert.Equal(before, Snapshot(drawing));
} }
[Theory]
[MemberData(nameof(DifferentialCases))]
public void Fill_Thresholds_MatchPreChangePairFitAndColumnBranches(string shape, double spacing, bool rotated)
{
var drawing = MakeFixture(shape);
var angle = rotated ? System.Math.PI / 6 : 0;
var before = Snapshot(drawing);
var roomy = new Box(3.1, 5.3, 100, 100);
var seed = Invoke(new LegacyFillExtents(roomy, spacing), "BuildPair", drawing, angle);
var bbox = PairBounds(seed);
// The reference BuildPair is identical to pre-Task-3 production. Task 2's
// finite/nonnegative pitch shortcut gives the same column, not different poses.
foreach (var axis in new[] { "length", "width", "column" })
{
foreach (var fits in new[] { false, true })
{
var delta = fits ? 1e-9 : -1e-9;
var length = axis == "length" ? bbox.Length - OpenNest.Math.Tolerance.Epsilon + delta : 100;
var width = axis == "width" ? bbox.Width - OpenNest.Math.Tolerance.Epsilon + delta
: axis == "column" ? 2 * bbox.Width + spacing - OpenNest.Math.Tolerance.Epsilon + delta : 100;
var area = new Box(roomy.X, roomy.Y, length, width);
var areaBefore = Bounds(area);
var legacy = new LegacyFillExtents(area, spacing);
var filler = new FillExtents(area, spacing);
var expectedPair = Invoke(legacy, "BuildPair", drawing, angle);
var actualPair = Invoke(filler, "BuildPair", drawing, angle);
Assert.Equal(axis == "column" || fits, expectedPair != null);
Assert.Equal(expectedPair == null, actualPair == null);
if (expectedPair != null)
{
AssertPairEqual(expectedPair, actualPair!);
var expectedColumn = (List<Part>)Invoke(legacy, "BuildColumn", expectedPair);
var actualColumn = (List<Part>)Invoke(filler, "BuildColumn", actualPair!);
if (axis == "column")
Assert.Equal(fits ? 4 : 2, expectedColumn.Count);
AssertSameLayout(expectedColumn, actualColumn);
}
var expectedProgress = new List<List<Part>>();
var actualProgress = new List<List<Part>>();
var expected = legacy.Fill(drawing, angle, reportProgress: (parts, _) => expectedProgress.Add(parts));
var actual = filler.Fill(drawing, angle, reportProgress: (parts, _) => actualProgress.Add(parts));
AssertSameLayout(expected, actual);
Assert.Equal(expectedProgress.Count, actualProgress.Count);
for (var i = 0; i < expectedProgress.Count; i++)
AssertSameLayout(expectedProgress[i], actualProgress[i]);
Assert.Equal(areaBefore, Bounds(area));
Assert.Equal(before, Snapshot(drawing));
}
}
}
[Theory]
[MemberData(nameof(DifferentialCases))]
public void Fill_AdjacentDoubleThresholds_PreservePairAndColumnDecisions(string shape, double spacing, bool rotated)
{
var drawing = MakeFixture(shape);
var angle = rotated ? System.Math.PI / 6 : 0;
var roomy = new Box(3.1, 5.3, 100, 100);
var seed = Invoke(new LegacyFillExtents(roomy, spacing), "BuildPair", drawing, angle);
var bbox = PairBounds(seed);
foreach (var axis in new[] { "length", "width", "column" })
{
var threshold = (axis == "length" ? bbox.Length : axis == "width" ? bbox.Width
: 2 * bbox.Width + spacing) - OpenNest.Math.Tolerance.Epsilon;
var decisions = new HashSet<bool>();
foreach (var size in AdjacentDoubles(threshold))
{
var area = new Box(roomy.X, roomy.Y, axis == "length" ? size : 100,
axis == "length" ? 100 : size);
var legacy = new LegacyFillExtents(area, spacing);
var filler = new FillExtents(area, spacing);
var expectedPair = Invoke(legacy, "BuildPair", drawing, angle);
var actualPair = Invoke(filler, "BuildPair", drawing, angle);
Assert.Equal(expectedPair == null, actualPair == null);
if (axis != "column")
decisions.Add(expectedPair != null);
if (expectedPair == null)
continue;
AssertPairEqual(expectedPair, actualPair!);
var expected = (List<Part>)Invoke(legacy, "BuildColumn", expectedPair);
var actual = (List<Part>)Invoke(filler, "BuildColumn", actualPair!);
AssertSameLayout(expected, actual);
if (axis == "column")
decisions.Add(expected.Count == 4);
}
Assert.Contains(false, decisions);
Assert.Contains(true, decisions);
}
}
[Theory]
[MemberData(nameof(DifferentialCases))]
public void TryShiftDirection_AdjacentWidthThresholds_PreserveAcceptance(string shape, double spacing, bool rotated)
{
var drawing = MakeFixture(shape);
var angle = rotated ? System.Math.PI / 6 : 0;
var area = new Box(3.1, 5.3, 100, 100);
var legacy = new LegacyFillExtents(area, spacing);
var filler = new FillExtents(area, spacing);
var expectedPair = Invoke(legacy, "BuildPair", drawing, angle);
var actualPair = Invoke(filler, "BuildPair", drawing, angle);
foreach (var shift in new[] { -0.3, 0.3 })
{
// Locate adjacent rejected/accepted doubles using only the frozen pre-change
// path; do not derive the oracle from the implementation under test.
var rejected = 0.0;
var accepted = 100.0;
for (var i = 0; i < 64; i++)
{
var middle = (rejected + accepted) / 2;
if (Invoke(legacy, "TryShiftDirection", expectedPair, shift, middle) == null)
rejected = middle;
else
accepted = middle;
}
Assert.Equal(accepted, System.Math.BitIncrement(rejected));
foreach (var width in new[] { rejected, accepted })
{
var expected = Invoke(legacy, "TryShiftDirection", expectedPair, shift, width);
var actual = Invoke(filler, "TryShiftDirection", actualPair, shift, width);
Assert.Equal(width == accepted, expected != null);
Assert.Equal(expected == null, actual == null);
if (expected != null)
AssertPairEqual(expected, actual!);
}
}
}
private static IEnumerable<double> AdjacentDoubles(double value)
{
yield return value;
var lower = value;
var upper = value;
for (var i = 0; i < 4; i++)
{
lower = System.Math.BitDecrement(lower);
upper = System.Math.BitIncrement(upper);
yield return lower;
yield return upper;
}
}
private static Box PairBounds(object pair) => (Box)pair.GetType().GetProperty("Bbox")!.GetValue(pair)!;
private static void AssertPairEqual(object expected, object actual)
{
Assert.Equal(Bounds(PairBounds(expected)), Bounds(PairBounds(actual)));
var expectedParts = new[] { "Part1", "Part2" }.Select(name =>
(Part)expected.GetType().GetProperty(name)!.GetValue(expected)!).ToList();
var actualParts = new[] { "Part1", "Part2" }.Select(name =>
(Part)actual.GetType().GetProperty(name)!.GetValue(actual)!).ToList();
AssertSameLayout(expectedParts, actualParts);
}
[Theory] [Theory]
[InlineData(5, 5)] [InlineData(5, 5)]
[InlineData(15, 10)] // One rectangle fits, the pair does not. [InlineData(15, 10)] // One rectangle fits, the pair does not.
@@ -185,6 +339,35 @@ public class FillExtentsTests
} }
#if DEBUG #if DEBUG
[Theory]
[InlineData(0.0)]
[InlineData(0.5)]
public void Fill_BoundsWork_PerformsFewerUpdates(double spacing)
{
var area = new Box(3, 5, 45, 27);
var drawing = MakeFixture("triangle");
PerfCounters.Reset();
try
{
// Task 2 removed boundary preparation, not Part.UpdateBounds calls, so
// this frozen baseline has the pre-Task-3 full-fill bounds-update count.
var expected = new LegacyFillExtents(area, spacing).Fill(drawing);
var before = PerfCounters.PartBoundsUpdates;
PerfCounters.Reset();
var actual = new FillExtents(area, spacing).Fill(drawing);
var after = PerfCounters.PartBoundsUpdates;
output.WriteLine($"bounds spacing={spacing}: before={before}, actual={after}, parts={actual.Count}");
AssertSameLayout(expected, actual);
Assert.Equal(13, before);
Assert.True(after < before, $"Expected fewer than {before} part bounds updates, actual {after}.");
Assert.Equal(10, after);
}
finally
{
PerfCounters.Reset();
}
}
[Theory] [Theory]
[InlineData(0.0)] [InlineData(0.0)]
[InlineData(0.5)] [InlineData(0.5)]
@@ -284,7 +467,8 @@ public class FillExtentsTests
for (var i = 0; i < expected.Count; i++) for (var i = 0; i < expected.Count; i++)
{ {
Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing); Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing);
Assert.Equal(expected[i].Location, actual[i].Location); Assert.Equal((expected[i].Location.X, expected[i].Location.Y),
(actual[i].Location.X, actual[i].Location.Y));
Assert.Equal(expected[i].Rotation, actual[i].Rotation); Assert.Equal(expected[i].Rotation, actual[i].Rotation);
Assert.Equal(Bounds(expected[i].BoundingBox), Bounds(actual[i].BoundingBox)); Assert.Equal(Bounds(expected[i].BoundingBox), Bounds(actual[i].BoundingBox));
Assert.Equal(ProgramValues(expected[i].Program), ProgramValues(actual[i].Program)); Assert.Equal(ProgramValues(expected[i].Program), ProgramValues(actual[i].Program));
@@ -315,12 +499,12 @@ public class FillExtentsTests
{ {
values.Add(code.GetType()); values.Add(code.GetType());
if (code is Motion motion) if (code is Motion motion)
values.Add(motion.EndPoint); values.Add((motion.EndPoint.X, motion.EndPoint.Y));
if (code is LinearMove line) if (code is LinearMove line)
values.Add(line.Layer); values.Add(line.Layer);
if (code is ArcMove arc) if (code is ArcMove arc)
{ {
values.Add(arc.CenterPoint); values.Add((arc.CenterPoint.X, arc.CenterPoint.Y));
values.Add(arc.Rotation); values.Add(arc.Rotation);
values.Add(arc.Layer); values.Add(arc.Layer);
} }
@@ -139,6 +139,66 @@ public class FillPerformanceTests
ReportGroupSummary("custom", customSamples, callsPerBatch); ReportGroupSummary("custom", customSamples, callsPerBatch);
} }
[SkippableFact]
public void RotatedPattern_ReportsBoundsConstruction()
{
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 group = Enumerable.Range(0, 32).Select(i =>
new Part(drawing, new Vector(11.25 + i % 8 * 12, 13.5 + i / 8 * 10))).ToList();
var angles = new[] { 0.0, 0.37 };
var builds = angles.Select(angle => new Func<List<Part>>(() =>
FillHelpers.BuildRotatedPattern(group, angle).Parts)).ToArray();
var expected = angles.Select(angle =>
OpenNest.Tests.Strategies.FillHelpersTests.PreChangeRotatedPattern(group, angle).Parts).ToArray();
var warmupCalls = 1_000;
var callsPerBatch = 5_000;
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($"rotated-pattern: 32 native-arc parts on an 8-column 12x10 grid from (11.25,13.5); "
+ $"angle=0 or 0.37; warmup=2 x {warmupCalls}; measured={repetitions} x {callsPerBatch}; "
+ "angle batch order alternates. Real synchronous production construction only; setup, reference, "
+ "assertions/output excluded; clone/rotation/aggregate bounds, GC and count consumption included. "
+ "Warm drawing/JIT; no forced GC/cache reset. Current-thread allocations, not RSS or a whole-job benchmark.");
for (var mode = 0; mode < builds.Length; mode++)
FillExtentsTests.AssertSameLayout(expected[mode], builds[mode]());
for (var batch = 0; batch < 2; batch++)
for (var slot = 0; slot < builds.Length; slot++)
MeasureExtents(builds[(slot + batch) % builds.Length], warmupCalls);
var samples = angles.Select(_ => new ExtentsSample[repetitions]).ToArray();
for (var batch = 0; batch < repetitions; batch++)
{
for (var slot = 0; slot < builds.Length; slot++)
{
var mode = (slot + batch) % builds.Length;
samples[mode][batch] = MeasureExtents(builds[mode], callsPerBatch);
}
for (var mode = 0; mode < builds.Length; mode++)
{
var sample = samples[mode][batch];
Assert.Equal((long)callsPerBatch * group.Count, sample.PartCount);
FillExtentsTests.AssertSameLayout(expected[mode], sample.LastResult);
output.WriteLine(FormattableString.Invariant(
$"rotated-pattern angle={angles[mode]} batch={batch + 1}: ms={sample.Milliseconds:F6}; bytes={sample.AllocatedBytes}; parts={sample.PartCount}."));
}
}
for (var mode = 0; mode < builds.Length; mode++)
{
var times = samples[mode].Select(s => s.Milliseconds).OrderBy(t => t).ToArray();
var bytes = samples[mode].Select(s => s.AllocatedBytes).OrderBy(b => b).ToArray();
output.WriteLine(FormattableString.Invariant(
$"rotated-pattern angle={angles[mode]}: batch ms min/median/max={times[0]:F6}/{times[repetitions / 2]:F6}/{times[^1]:F6}; us/call min/median/max={times[0] * 1000 / callsPerBatch:F3}/{times[repetitions / 2] * 1000 / callsPerBatch:F3}/{times[^1] * 1000 / callsPerBatch:F3}; batch bytes min/median/max={bytes[0]}/{bytes[repetitions / 2]}/{bytes[^1]}; B/call={(double)bytes[repetitions / 2] / callsPerBatch:F3}."));
}
}
[SkippableFact] [SkippableFact]
public void Extents_ReportsRepeatedColumnRebuilds() public void Extents_ReportsRepeatedColumnRebuilds()
{ {
+114 -3
View File
@@ -11,6 +11,86 @@ namespace OpenNest.Tests.Strategies;
[Collection(nameof(FillCacheCollection))] [Collection(nameof(FillCacheCollection))]
public class FillHelpersTests public class FillHelpersTests
{ {
[Theory]
[InlineData("accumulated-translation", 0.0)]
[InlineData("accumulated-translation", 0.37)]
[InlineData("translated", 0.0)]
[InlineData("translated", 0.37)]
[InlineData("pre-rotated", 0.0)]
[InlineData("pre-rotated", 0.37)]
[InlineData("canonical", 0.0)]
[InlineData("canonical", 0.37)]
[InlineData("arc", 0.0)]
[InlineData("arc", 0.37)]
[InlineData("nonzero-program-origin", 0.0)]
[InlineData("nonzero-program-origin", 0.37)]
public void BuildRotatedPattern_MatchesPreChangePosesBoundsAndOwnership(string scenario, double angle)
{
var drawing = OpenNest.Tests.Fill.FillExtentsTests.MakeFixture(scenario == "arc" ? "arc" : "concave");
if (scenario is "pre-rotated" or "canonical")
drawing.Program.Rotate(0.6);
double canonicalAngle = 0;
if (scenario == "canonical")
{
// Program.Rotate mutates in place; refresh the canonical angle the same way
// a rotated CAD import carries it, or AsCanonicalCopy takes the zero-angle path.
drawing.RecomputeCanonicalAngle();
canonicalAngle = drawing.Source.Angle;
Assert.False(OpenNest.Math.Tolerance.IsEqualTo(canonicalAngle, 0));
drawing = CanonicalFrame.AsCanonicalCopy(drawing);
Assert.Equal(0.0, drawing.Source.Angle);
}
if (scenario == "accumulated-translation")
drawing.Program.Offset(new Vector(0.1, 0.1));
if (scenario == "nonzero-program-origin")
drawing.Program.Offset(new Vector(0.125, -0.375));
var group = new List<Part>
{
new(drawing, new Vector(11.25, 13.5)),
new(drawing, new Vector(31.75, 27.25)),
};
if (scenario == "translated")
foreach (var part in group)
part.Offset(3.5, -2.25);
if (scenario == "accumulated-translation")
{
group = new List<Part> { new(drawing, new Vector(0.1, 0.1)) };
group[0].Offset(1.1, 1.1);
}
var before = Snapshot(group);
var expected = PreChangeRotatedPattern(group, angle);
var actual = FillHelpers.BuildRotatedPattern(group, angle);
OpenNest.Tests.Fill.FillExtentsTests.AssertSameLayout(expected.Parts, actual.Parts);
Assert.Equal(Bounds(expected.BoundingBox), Bounds(actual.BoundingBox));
AssertNoInputPartsReturned(group, actual.Parts);
Assert.Equal(before, Snapshot(group));
for (var i = 0; i < group.Count; i++)
{
Assert.NotSame(group[i].Program, actual.Parts[i].Program);
// Clone must not reapply the baked drawing rotation before adding the angle.
// Normalize both sides: a baked canonical rotation can already sit at exactly 2pi.
Assert.Equal(OpenNest.Math.Angle.NormalizeRad(group[i].Rotation + angle),
OpenNest.Math.Angle.NormalizeRad(actual.Parts[i].Rotation));
}
}
// The exact pre-Task-3 helper, kept local to characterization, never used for timings.
internal static Pattern PreChangeRotatedPattern(List<Part> group, double angle)
{
var pattern = new Pattern();
var center = ((IEnumerable<IBoundable>)group).GetBoundingBox().Center;
foreach (var part in group)
{
var clone = (Part)part.Clone();
clone.UpdateBounds();
if (!OpenNest.Math.Tolerance.IsEqualTo(angle, 0))
clone.Rotate(angle, center);
pattern.Parts.Add(clone);
}
pattern.UpdateBounds();
return pattern;
}
[Theory] [Theory]
[InlineData(5, 9, 8, 7)] [InlineData(5, 9, 8, 7)]
[InlineData(10, 4, 7, 8)] [InlineData(10, 4, 7, 8)]
@@ -185,6 +265,30 @@ public class FillHelpersTests
} }
#if DEBUG #if DEBUG
[Theory]
[InlineData(0.0, 2)]
[InlineData(0.37, 4)]
public void BuildRotatedPattern_BoundsWork_RetainsRequiredCloneRecomputation(double angle, long expectedUpdates)
{
var group = MakeGroup();
var expected = PreChangeRotatedPattern(group, angle);
PerfCounters.Reset();
try
{
var pattern = FillHelpers.BuildRotatedPattern(group, angle);
// Removing the clone recompute changes accumulated-translation boxes at angle 0.
// This site was retained, not optimized. Pattern.UpdateBounds aggregates boxes;
// it does not call Part.UpdateBounds and remains outside this counter.
Assert.Equal(expectedUpdates, PerfCounters.PartBoundsUpdates);
AssertSameLayout(expected.Parts, pattern.Parts);
Assert.Equal(Bounds(expected.BoundingBox), Bounds(pattern.BoundingBox));
}
finally
{
PerfCounters.Reset();
}
}
[Theory] [Theory]
[InlineData(5, 9, 0, 2)] [InlineData(5, 9, 0, 2)]
[InlineData(5, 9, 1, 0)] [InlineData(5, 9, 1, 0)]
@@ -231,7 +335,8 @@ public class FillHelpersTests
for (var i = 0; i < expected.Count; i++) for (var i = 0; i < expected.Count; i++)
{ {
Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing); Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing);
Assert.Equal(expected[i].Location, actual[i].Location); Assert.Equal((expected[i].Location.X, expected[i].Location.Y),
(actual[i].Location.X, actual[i].Location.Y));
Assert.Equal(expected[i].Rotation, actual[i].Rotation); Assert.Equal(expected[i].Rotation, actual[i].Rotation);
Assert.Equal(Bounds(expected[i].BoundingBox), Bounds(actual[i].BoundingBox)); Assert.Equal(Bounds(expected[i].BoundingBox), Bounds(actual[i].BoundingBox));
} }
@@ -270,7 +375,7 @@ public class FillHelpersTests
values.Add(part); values.Add(part);
values.Add(part.BaseDrawing); values.Add(part.BaseDrawing);
values.Add(part.BaseDrawing.Area); values.Add(part.BaseDrawing.Area);
values.Add(part.Location); values.Add((part.Location.X, part.Location.Y));
values.Add(part.Rotation); values.Add(part.Rotation);
values.Add(Bounds(part.BoundingBox)); values.Add(Bounds(part.BoundingBox));
foreach (var program in new[] { part.Program, part.BaseDrawing.Program }) foreach (var program in new[] { part.Program, part.BaseDrawing.Program })
@@ -282,7 +387,13 @@ public class FillHelpersTests
{ {
values.Add(code); values.Add(code);
if (code is Motion motion) if (code is Motion motion)
values.Add(motion.EndPoint); values.Add((motion.EndPoint.X, motion.EndPoint.Y));
if (code is ArcMove arc)
{
values.Add((arc.CenterPoint.X, arc.CenterPoint.Y));
values.Add(arc.Rotation);
values.Add(arc.Layer);
}
} }
} }
} }
+2
View File
@@ -49,6 +49,8 @@ The group-pattern measurement also compares default scoring with a custom compar
The extents measurement exercises repeated column rebuilding with a closed triangle at zero and positive spacing. It times the synchronous production fill and reports current-thread allocations; the frozen legacy implementation is used only for correctness checks outside timing. Run only that case with `--filter "FullyQualifiedName~Extents_ReportsRepeatedColumnRebuilds"`. Extents behavior, overlap-fallback, and Debug boundary-preparation checks use `--filter "FullyQualifiedName~FillExtentsTests|FullyQualifiedName~StrategyOverlapTests"`. Finite nonnegative spacing uses the equivalent bounding-box pitch without discarded vertical boundary preparation; negative/nonfinite spacing retains the legacy calculation rather than adding validation. The extents measurement exercises repeated column rebuilding with a closed triangle at zero and positive spacing. It times the synchronous production fill and reports current-thread allocations; the frozen legacy implementation is used only for correctness checks outside timing. Run only that case with `--filter "FullyQualifiedName~Extents_ReportsRepeatedColumnRebuilds"`. Extents behavior, overlap-fallback, and Debug boundary-preparation checks use `--filter "FullyQualifiedName~FillExtentsTests|FullyQualifiedName~StrategyOverlapTests"`. Finite nonnegative spacing uses the equivalent bounding-box pitch without discarded vertical boundary preparation; negative/nonfinite spacing retains the legacy calculation rather than adding validation.
The synchronous rotated-pattern construction control uses a 32-part native-arc group at angles 0 and 0.37. Run it with `--filter "FullyQualifiedName~RotatedPattern_ReportsBoundsConstruction"`. Task 3 reuses the extents benchmark above; Debug `--filter "FullyQualifiedName~BoundsWork"` checks part-bounds work. Only three extents recomputations were removable: anchor, vertical-shift, and group-clone recomputations remain because removing them changes exact floating-point layouts. See the measured report for the partial-delivery evidence and inconclusive timing results.
### Quick start ### Quick start
1. File > New Nest 1. File > New Nest
+104
View File
@@ -365,3 +365,107 @@ dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \
For an independent before/after comparison, keep the delivered tests/diagnostics identical in two disposable trees and use the baseline `FillExtents.cs` only in the before tree. Rebuild each configuration and verify the source/harness hashes. Do not change production spacing policy to reproduce invalid-domain cases or substitute the frozen test type for the measured production method. For an independent before/after comparison, keep the delivered tests/diagnostics identical in two disposable trees and use the baseline `FillExtents.cs` only in the before tree. Rebuild each configuration and verify the source/harness hashes. Do not change production spacing policy to reproduce invalid-domain cases or substitute the frozen test type for the measured production method.
Final logs, TRX files, source restoration snapshot, manifests, and parsed rows were held in `/home/aj/.hermes/cache/scratch/opennest-task2-resume-20260925/` through review. The report preserves all final timing rows, allocation totals, source hashes, runtime and test summaries; temporary evidence and the superseded resumed exploration directory are removed before commit. Next planned hardening is the separately reviewable redundant-bounds slice with threshold-fit and canonical-frame safety, not a geometry repair bundled into this change. Final logs, TRX files, source restoration snapshot, manifests, and parsed rows were held in `/home/aj/.hermes/cache/scratch/opennest-task2-resume-20260925/` through review. The report preserves all final timing rows, allocation totals, source hashes, runtime and test summaries; temporary evidence and the superseded resumed exploration directory are removed before commit. Next planned hardening is the separately reviewable redundant-bounds slice with threshold-fit and canonical-frame safety, not a geometry repair bundled into this change.
## Task 3 — remove provably redundant bounds walks — 2026-09-25
### Scope and per-site decisions
`Part.Offset` translates the cached `BoundingBox` arithmetically and `Part.Clone` copies it exactly, so several `UpdateBounds()` calls in the extents/pattern paths recompute a box that just changed by pure translation. Recomputation is mathematically equivalent but not bitwise identical to accumulated translates, and the extents pipeline compares boxes at `Tolerance.Epsilon` fit thresholds. Each candidate removal therefore had to keep every characterized layout, box, and threshold branch bitwise identical (the differential harness compares `(X, Y, Length, Width)` and location tuples with exact `double` equality, not tolerance — `Vector.Equals` itself is tolerance-based, so location/coordinate snapshots were switched to scalar tuples to expose ulp deltas).
| Site | Decision | Evidence |
| --- | --- | --- |
| `BuildPair` after `Offset(startOffset, 0)` | Removed | All characterization green; slide uses locations, not cached bounds. |
| `BuildPair` after `Offset(-dist, 0)` | Removed | All characterization green. |
| `TryShiftDirection` after `Offset(partSpacing, 0)` | Removed | The retained post-vertical-shift recompute covers both offsets. |
| `TryShiftDirection` after `Offset(0, verticalShift)` | Retained | Rotated triangle spacing 0.5: box Y `5.6025660004937174` → `5.6025660004937183`. `Compactor.Push` box thresholds consume this box. |
| `AnchorToWorkArea` part1/part2 recompute | Retained | Part2-only removal changed rectangle differential X `13.499999999999998` → `13.5` and arc pair height `8.0000000000000036` → `8`; part1 removal changed X `3.1000000000000005` → `3.100000000000001`. Part1-only removal failed 6 of 101 characterization cases; part2-only removal failed 25 of 101, including overlap fallback. The anchor `workArea.X - bbox.Left` round-trip lands a ulp away from `X += anchor`. |
| `BuildRotatedPattern` after `Clone()` | Retained | Accumulated-translation input at angle 0: box `1.3000000000000003` → `1.3`. Source comments now record why each retained site stays. |
No change to `Part.Offset`, `Part.Clone`, `Part.Rotate`, `Part.UpdateBounds`, `PairBbox`, `pattern.UpdateBounds()`, the Task 2 pitch guard, or `LegacyFillExtents.cs` (frozen SHA-256 `b50b3b64014446d9688facc0b711b59cf126b64037fdf667603ce3d85859a324` unchanged). `PerfCounters.PartBoundsUpdates` (Debug-only, incremented at the top of `Part.UpdateBounds`) was added per the plan.
### Source and measurement provenance
- Base: `4ec92c95ec10c5c877e9fb0f34f197d7e23b87b0` on `master`. Final implementation is delivered with this report; resolve with `git log -1 --format=%H -- OpenNest.Engine/Fill/FillExtents.cs`.
- `FillExtents.cs` SHA-256 before `efaacfca83a6d94bb7fdc65ba4da4be09c97c14c9c4e161491d48d64a69d1be3` (Task 2 delivered) → after `13be137690ad5f60e0fe3874ec0cb0b93e9864b97d7eee7f3aa65c9b962053a4`.
- `FillHelpers.cs` before `a1c8a1bc155dbe62439f8345a3a54e06147a5404a6a46835a30b66d52cceccfa` → after `0a35075a573c7f17f9c8095a5a7bd56d0a4ea935ec1c9d8da2c4c9d78e0a9af0` (comment-only; site retained).
- Identical before/after harness `FillPerformanceTests.cs` SHA-256 `93ec06c5b59aecfe5cb9311380afea507cc134e77df7535036fb451ab0f63e29`.
- Before timings ran the actual pre-change production files (restored temporarily, restored back in `finally`); after timings the delivered files. Same machine, serial Release: `hermes`, Ubuntu 24.04.5 LTS x64, KVM, four vCPUs presented as AMD Ryzen 9 5900X, SDK 10.0.112, .NET 8.0.31, 1 GHz stopwatch. Shared VM, no pinning.
### Work-removal evidence (genuine red/green)
New Debug counter tests (serialized in `FillCacheCollection`, reset in `finally`) failed first on the unmodified production code: `Fill_BoundsWork_PerformsFewerUpdates` expected fewer than 13 part-bounds updates, actual 13 (both spacings); the group-construction work assertion expected 0/2 updates at angles 0/0.37, actual 2/4. After the change: full triangle fill 13 → 10 updates with 24 bitwise-identical parts (both spacings). The group assertions were rewritten to pin the retained counts (2 at angle 0, 4 at 0.37 = retained clone recompute + rotate recompute per part) because removing the clone recompute failed behavior characterization — disclosed partial delivery, not an optimized site. `Pattern.UpdateBounds` aggregates boxes and does not call `Part.UpdateBounds`, so it is outside this counter. Per fill the three removals save three `UpdateBounds` calls (three `Box` allocations — 144 bytes) reproduced exactly in every measured batch.
### Characterization added (all passing pre-change, 133 targeted Release)
- 16 extents threshold cases: pair-fit, column-tiling, and column-count branches on both sides of `Tolerance.Epsilon`, nonzero work-area origin, progress layouts, input snapshots.
- 16 adjacent-double pair/column cases (`BitIncrement`/`BitDecrement` neighborhoods with asserted both-side decisions) and 16 adjacent-double `TryShiftDirection` width-acceptance cases (binary-searched rejected/accepted doubles located using only the frozen pre-change path, ±shifts).
- 12 `BuildRotatedPattern` cases: translated, accumulated-translation, pre-rotated, canonical-frame, native-arc, and nonzero-program-origin groups at angles 0 and 0.37, checking exact poses/bounds against a local pre-change reference, input non-mutation, clone program ownership, and no double rotation of pre-rotated inputs.
- Frozen-legacy differentials, threshold branch equality, and the retained-site RED logs (`task3-site-*.log`) reconcile the per-site table above.
### Tests (parent independently reran the full suites on the final tree)
| Suite | Passed | Skipped | Failed |
| --- | ---: | ---: | ---: |
| Targeted Release (`FillExtentsTests|FillHelpersTests|CanonicalFrameTests|StripeFillerTests`) | 133 | 0 | 0 |
| AGENTS.md Debug work filter | 137 | 0 | 0 |
| Full main Release | 1266 | 16 | 0 |
| Full main Debug | 1283 | 16 | 0 |
| Full engine Release | 300 | 0 | 0 |
Skips are the 12 optional CHR-font fixtures plus four opt-in benchmarks (the new rotated-pattern case adds one). Gate verified: unset and `0` skip all category tests. `.NET 8.0.31` adapters confirmed. Changed-file `dotnet format whitespace --verify-no-changes` and `git diff --check` passed; no new production warnings.
### Reviews
Implementation, spec review, and quality/integration review ran as three independent agent sessions (the earlier same-agent serial constraint was lifted by the user for this session). Spec review initially found one Important issue — the canonical fixture consumed a stale `Source.Angle` and duplicated the pre-rotated scenario — plus one Minor report-wording error; both were fixed (fixture now refreshes the canonical angle and asserts a nonzero source-to-canonical transformation; the failure counts are stated as 6/101 and 25/101), and the strengthened fixture additionally surfaced a benign test-assertion normalization issue at a baked 2π rotation, fixed by normalizing both sides. Subsequent independent quality/integration review returned PASS with no Critical, Important, or Minor findings: production diff traced through slide/Compactor consumers, counter isolation, adjacent-double oracle independence, hash/median/allocation reconciliation against raw logs, integration sweep across fill-strategy and jobs callers, and disclosure honesty were all verified by that reviewer. Pre-existing DXF-fixture-dependent tests still return early without their Windows fixture; no Windows UI runtime test or ONNX inference was run.
### Results
All before/after timing ranges overlap: elapsed-time improvement is inconclusive, consistent with three saved `Program.BoundingBox()` walks being small against full fills. Allocation savings are exact and reproduce in every batch: extents spacing 0 `839,624` → `839,480` bytes/fill and spacing 0.5 `1,028,544` → `1,028,400` bytes/fill (−144 B/fill). Rotated-pattern construction is an unchanged-code control: identical allocations per call at both angles.
Extents: milliseconds per 200 production fills, seven batches each, alternating spacing order, warmup excluded. Rotated-pattern: milliseconds per 5,000 constructions of a 32-part native-arc group.
| Workload | Batch | Before ms | After ms |
| --- | ---: | ---: | ---: |
| extents spacing 0 | 1 | 461.646777 | 502.378600 |
| extents spacing 0 | 2 | 439.442203 | 446.609639 |
| extents spacing 0 | 3 | 450.272109 | 457.012036 |
| extents spacing 0 | 4 | 433.095759 | 454.402604 |
| extents spacing 0 | 5 | 443.489319 | 447.382522 |
| extents spacing 0 | 6 | 433.504166 | 448.477156 |
| extents spacing 0 | 7 | 441.588374 | 439.879594 |
| extents spacing 0.5 | 1 | 460.691045 | 439.973796 |
| extents spacing 0.5 | 2 | 457.895526 | 443.225746 |
| extents spacing 0.5 | 3 | 444.315207 | 451.898487 |
| extents spacing 0.5 | 4 | 436.733945 | 447.851680 |
| extents spacing 0.5 | 5 | 451.266260 | 431.428815 |
| extents spacing 0.5 | 6 | 438.338321 | 455.657180 |
| extents spacing 0.5 | 7 | 448.995034 | 438.982789 |
| rotated-pattern angle 0 | 1 | 92.121779 | 95.085965 |
| rotated-pattern angle 0 | 2 | 85.115166 | 83.764479 |
| rotated-pattern angle 0 | 3 | 84.692335 | 82.859009 |
| rotated-pattern angle 0 | 4 | 90.233547 | 85.003329 |
| rotated-pattern angle 0 | 5 | 85.081651 | 83.281808 |
| rotated-pattern angle 0 | 6 | 78.809908 | 77.355562 |
| rotated-pattern angle 0 | 7 | 78.280977 | 80.372130 |
| rotated-pattern angle 0.37 | 1 | 184.337007 | 186.875744 |
| rotated-pattern angle 0.37 | 2 | 170.493990 | 155.509393 |
| rotated-pattern angle 0.37 | 3 | 153.992846 | 155.681698 |
| rotated-pattern angle 0.37 | 4 | 155.207434 | 156.663232 |
| rotated-pattern angle 0.37 | 5 | 158.498327 | 149.814054 |
| rotated-pattern angle 0.37 | 6 | 157.148604 | 150.090405 |
| rotated-pattern angle 0.37 | 7 | 150.252399 | 150.547539 |
Medians: extents spacing 0 2207.942 → 2242.386 µs/call, spacing 0.5 2244.975 → 2216.129 µs/call; rotated-pattern angle 0 17.016 → 16.656 µs/call, angle 0.37 31.430 → 31.102 µs/call. Every batch produced the expected part counts (extents 4,800; rotated-pattern 160,000) and layout assertions ran on the timed path's output outside timing. Current-thread synchronous allocations only; no whole-job or RSS claims.
### Reproduction and limitations
```bash
OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \
--filter 'Category=FillPerformance' --logger 'console;verbosity=detailed'
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \
--filter 'FullyQualifiedName~FillExtentsTests|FullyQualifiedName~FillHelpersTests|FullyQualifiedName~CanonicalFrameTests|FullyQualifiedName~StripeFillerTests'
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \
--filter 'FullyQualifiedName~DefaultFillComparerWorkTests|FullyQualifiedName~FillHelpersTests|FullyQualifiedName~FillExtentsTests|FullyQualifiedName~StrategyOverlapTests'
```
This is a partial delivery by design: three of six candidate recomputations were removable; the three retained sites are documented with their observed ulp deltas, and their `UpdateBounds` calls must not be removed without re-running the threshold and overlap-fallback characterization. Timing is inconclusive; only allocation and work-counter removal are demonstrated. No representative production corpus, Windows UI runtime test, or ONNX inference was run. Task 4 and gated follow-ups are not included.