perf(fill): remove discarded extents pitch geometry
This commit is contained in:
@@ -1,11 +1,336 @@
|
||||
using System.Reflection;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Tests.BestFit;
|
||||
using Xunit.Abstractions;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Fill;
|
||||
|
||||
[Collection(nameof(FillCacheCollection))]
|
||||
public class FillExtentsTests
|
||||
{
|
||||
private readonly ITestOutputHelper output;
|
||||
|
||||
public FillExtentsTests(ITestOutputHelper output) => this.output = output;
|
||||
|
||||
public static IEnumerable<object[]> DifferentialCases()
|
||||
{
|
||||
foreach (var shape in new[] { "rectangle", "triangle", "concave", "arc" })
|
||||
foreach (var spacing in new[] { 0.0, 0.5 })
|
||||
foreach (var rotated in new[] { false, true })
|
||||
yield return new object[] { shape, spacing, rotated };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(DifferentialCases))]
|
||||
public void Fill_MatchesFrozenLegacy_OrderedLayoutAndInputOwnership(string shape, double spacing, bool rotated)
|
||||
{
|
||||
var drawing = MakeFixture(shape);
|
||||
if (rotated)
|
||||
drawing.Program.Rotate(System.Math.PI / 2);
|
||||
var before = Snapshot(drawing);
|
||||
var workArea = new Box(3, 5, 45, 27);
|
||||
var areaBefore = Bounds(workArea);
|
||||
var angle = rotated ? System.Math.PI / 2 : 0;
|
||||
var expectedProgress = new List<(List<Part> Parts, string Message)>();
|
||||
var actualProgress = new List<(List<Part> Parts, string Message)>();
|
||||
var expected = new LegacyFillExtents(workArea, spacing).Fill(drawing, angle,
|
||||
reportProgress: (parts, message) => expectedProgress.Add((parts, message)));
|
||||
var actual = new FillExtents(workArea, spacing).Fill(drawing, angle,
|
||||
reportProgress: (parts, message) => actualProgress.Add((parts, message)));
|
||||
|
||||
AssertSameLayout(expected, actual);
|
||||
Assert.Equal(expectedProgress.Count, actualProgress.Count);
|
||||
for (var i = 0; i < expectedProgress.Count; i++)
|
||||
{
|
||||
Assert.Equal(expectedProgress[i].Message, actualProgress[i].Message);
|
||||
AssertSameLayout(expectedProgress[i].Parts, actualProgress[i].Parts);
|
||||
}
|
||||
AssertValidLayout(actual, workArea);
|
||||
Assert.Equal(before, Snapshot(drawing));
|
||||
Assert.Equal(areaBefore, Bounds(workArea));
|
||||
Assert.All(actual, part => Assert.NotSame(drawing.Program, part.Program));
|
||||
// Offset clones intentionally share programs until rotation takes ownership.
|
||||
for (var i = 0; i < actual.Count; i++)
|
||||
for (var j = 0; j < actual.Count; j++)
|
||||
Assert.Equal(ReferenceEquals(expected[i].Program, expected[j].Program),
|
||||
ReferenceEquals(actual[i].Program, actual[j].Program));
|
||||
var siblingPrograms = actual.Skip(1).Select(part => ProgramValues(part.Program)).ToArray();
|
||||
actual[0].Rotate(0.125);
|
||||
for (var i = 1; i < actual.Count; i++)
|
||||
Assert.Equal(siblingPrograms[i - 1], ProgramValues(actual[i].Program));
|
||||
Assert.Equal(before, Snapshot(drawing));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5, 5)]
|
||||
[InlineData(15, 10)] // One rectangle fits, the pair does not.
|
||||
public void Fill_NoFit_MatchesFrozenLegacy(double length, double width)
|
||||
{
|
||||
var drawing = MakeRect(10, 8);
|
||||
var before = Snapshot(drawing);
|
||||
var area = new Box(3, 5, length, width);
|
||||
var expected = new LegacyFillExtents(area, 0.5).Fill(drawing);
|
||||
var actual = new FillExtents(area, 0.5).Fill(drawing);
|
||||
Assert.Empty(expected);
|
||||
AssertSameLayout(expected, actual);
|
||||
Assert.Equal(before, Snapshot(drawing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_PreCancelled_MatchesFrozenLegacyRatherThanThrowing()
|
||||
{
|
||||
var drawing = MakeFixture("triangle");
|
||||
var before = Snapshot(drawing);
|
||||
var area = new Box(3, 5, 45, 27);
|
||||
var token = new CancellationToken(true);
|
||||
var expected = new LegacyFillExtents(area, 0.5).Fill(drawing, token: token);
|
||||
var actual = new FillExtents(area, 0.5).Fill(drawing, token: token);
|
||||
Assert.NotEmpty(expected);
|
||||
AssertSameLayout(expected, actual);
|
||||
AssertValidLayout(actual, area);
|
||||
Assert.Equal(before, Snapshot(drawing));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("rectangle", 0.0, false)]
|
||||
[InlineData("triangle", 0.5, false)]
|
||||
[InlineData("concave", 0.5, false)]
|
||||
[InlineData("arc", 0.5, false)]
|
||||
[InlineData("triangle", 0.0, true)]
|
||||
[InlineData("triangle", 0.5, true)]
|
||||
public void ColumnAdjustment_UsesAdjustedOrOverlapFallbackWithinBounds(string shape, double spacing, bool fallback)
|
||||
{
|
||||
var drawing = MakeFixture(shape);
|
||||
var area = new Box(3, 5, 45, 27);
|
||||
var legacy = new LegacyFillExtents(area, spacing);
|
||||
var angle = fallback ? System.Math.PI / 6 : 0;
|
||||
var pair = Invoke(legacy, "BuildPair", drawing, angle);
|
||||
var initial = (List<Part>)Invoke(legacy, "BuildColumn", pair);
|
||||
var adjusted = (List<Part>)Invoke(legacy, "AdjustColumn", pair, initial, CancellationToken.None);
|
||||
var overlap = FillHelpers.HasOverlappingParts(adjusted);
|
||||
Assert.NotSame(initial, adjusted);
|
||||
Assert.Equal(fallback, overlap);
|
||||
output.WriteLine($"{shape}, spacing={spacing}: initial={initial.Count}, adjusted={adjusted.Count}, "
|
||||
+ $"same={ReferenceEquals(initial, adjusted)}, overlap={overlap}");
|
||||
var progress = new List<List<Part>>();
|
||||
var actual = new FillExtents(area, spacing).Fill(drawing, angle,
|
||||
reportProgress: (parts, _) => progress.Add(parts));
|
||||
AssertSameLayout(overlap ? initial : adjusted, progress[1]);
|
||||
AssertValidLayout(progress[1], area);
|
||||
AssertValidLayout(actual, area);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("rectangle")]
|
||||
[InlineData("arc")]
|
||||
public void Fill_KnownLegacyTightWidthOverrun_RemainsDifferentialOnly(string shape)
|
||||
{
|
||||
// Baseline already extends ~1e-5 beyond this exactly tiled width at zero spacing.
|
||||
// Record that limitation separately; this optimization does not fix geometry.
|
||||
var area = new Box(3, 5, 40, 27);
|
||||
var drawing = MakeFixture(shape);
|
||||
var expected = new LegacyFillExtents(area, 0).Fill(drawing);
|
||||
var actual = new FillExtents(area, 0).Fill(drawing);
|
||||
AssertSameLayout(expected, actual);
|
||||
Assert.Contains(expected, part => part.Right > area.Right + 1e-6);
|
||||
Assert.InRange(expected.Max(part => part.Right) - area.Right, 9e-6, 11e-6);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-0.25)]
|
||||
[InlineData(-0.5)]
|
||||
public void Fill_NegativeSpacing_PreservesLegacyResultsAndInputs(double spacing)
|
||||
{
|
||||
var area = new Box(3, 5, 45, 27);
|
||||
var drawing = MakeFixture("rectangle");
|
||||
var before = Snapshot(drawing);
|
||||
var expected = new LegacyFillExtents(area, spacing).Fill(drawing);
|
||||
var actual = new FillExtents(area, spacing).Fill(drawing);
|
||||
AssertSameLayout(expected, actual);
|
||||
Assert.Equal(before, Snapshot(drawing));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
[InlineData(double.NegativeInfinity)]
|
||||
public void Fill_NonfiniteSpacing_NoFitStillReturnsEmptyWithoutValidation(double spacing)
|
||||
{
|
||||
var area = new Box(3, 5, 1, 1);
|
||||
var drawing = MakeFixture("rectangle");
|
||||
Assert.Empty(new LegacyFillExtents(area, spacing).Fill(drawing));
|
||||
Assert.Empty(new FillExtents(area, spacing).Fill(drawing));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-0.5)]
|
||||
[InlineData(-10.0)]
|
||||
[InlineData(double.NegativeInfinity)]
|
||||
public void BuildColumn_UnsupportedNegativeSpacing_RetainsLegacyPitch(double spacing)
|
||||
{
|
||||
var area = new Box(3, 5, 45, 27);
|
||||
var drawing = MakeFixture("rectangle");
|
||||
// Prepare finite valid pairs independently of unsupported spacing. This isolates
|
||||
// the private column calculation without risking legacy nonfinite public tiling.
|
||||
var legacyPair = Invoke(new LegacyFillExtents(area, 0), "BuildPair", drawing, 0.0);
|
||||
var pair = Invoke(new FillExtents(area, 0), "BuildPair", drawing, 0.0);
|
||||
var expected = (List<Part>)Invoke(new LegacyFillExtents(area, spacing), "BuildColumn", legacyPair);
|
||||
var actual = (List<Part>)Invoke(new FillExtents(area, spacing), "BuildColumn", pair);
|
||||
Assert.Equal(6, expected.Count);
|
||||
AssertSameLayout(expected, actual);
|
||||
Assert.Equal(8.0, actual[2].Bottom - actual[0].Bottom, 10);
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(0.5)]
|
||||
public void BuildColumn_RepeatedCalls_DoNotPrepareBoundaries(double spacing)
|
||||
{
|
||||
var area = new Box(3, 5, 45, 27);
|
||||
var drawing = MakeFixture("triangle");
|
||||
var legacy = new LegacyFillExtents(area, spacing);
|
||||
var filler = new FillExtents(area, spacing);
|
||||
PerfCounters.Reset();
|
||||
try
|
||||
{
|
||||
var legacyPair = Invoke(legacy, "BuildPair", drawing, 0.0);
|
||||
PerfCounters.Reset();
|
||||
var pair = Invoke(filler, "BuildPair", drawing, 0.0);
|
||||
Assert.Equal(2, PerfCounters.PartBoundaryPreparations); // BuildPair must retain geometry.
|
||||
PerfCounters.Reset();
|
||||
var expected = new List<List<Part>>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
expected.Add((List<Part>)Invoke(legacy, "BuildColumn", legacyPair));
|
||||
Assert.Equal(8, PerfCounters.PartBoundaryPreparations);
|
||||
PerfCounters.Reset();
|
||||
for (var i = 0; i < 4; i++)
|
||||
AssertSameLayout(expected[i], (List<Part>)Invoke(filler, "BuildColumn", pair));
|
||||
Assert.Equal(0, PerfCounters.PartBoundaryPreparations);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PerfCounters.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(0.5)]
|
||||
public void Fill_RebuildsColumns_OnlyBuildPairPreparesBoundaries(double spacing)
|
||||
{
|
||||
var area = new Box(3, 5, 45, 27);
|
||||
var drawing = MakeFixture("triangle");
|
||||
PerfCounters.Reset();
|
||||
try
|
||||
{
|
||||
var expected = new LegacyFillExtents(area, spacing).Fill(drawing);
|
||||
var legacyPreparations = PerfCounters.PartBoundaryPreparations;
|
||||
// Only BuildPair and BuildColumn construct PartBoundary in this pipeline.
|
||||
// More than four proves AdjustColumn called BuildColumn again.
|
||||
Assert.True(legacyPreparations > 4);
|
||||
PerfCounters.Reset();
|
||||
var actual = new FillExtents(area, spacing).Fill(drawing);
|
||||
output.WriteLine($"spacing={spacing}: legacy boundaries={legacyPreparations}, "
|
||||
+ $"BuildColumn calls={(legacyPreparations - 2) / 2}, actual boundaries={PerfCounters.PartBoundaryPreparations}, parts={actual.Count}");
|
||||
AssertSameLayout(expected, actual);
|
||||
AssertValidLayout(actual, area);
|
||||
Assert.Equal(2, PerfCounters.PartBoundaryPreparations);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PerfCounters.Reset();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
internal static Drawing MakeFixture(string shape)
|
||||
{
|
||||
if (shape == "rectangle")
|
||||
return MakeRect(10, 8);
|
||||
if (shape == "triangle")
|
||||
return MakeRightTriangle(10, 8);
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
if (shape == "concave")
|
||||
{
|
||||
foreach (var point in new[] { new Vector(10, 0), new Vector(10, 3),
|
||||
new Vector(4, 3), new Vector(4, 8), new Vector(0, 8), new Vector(0, 0) })
|
||||
program.Codes.Add(new LinearMove(point));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Native CCW quarter arcs, not a polygonized or self-crossing approximation.
|
||||
program.Codes.Add(new LinearMove(new Vector(9, 0)));
|
||||
program.Codes.Add(new ArcMove(new Vector(10, 1), new Vector(9, 1), RotationType.CCW));
|
||||
program.Codes.Add(new LinearMove(new Vector(10, 7)));
|
||||
program.Codes.Add(new ArcMove(new Vector(9, 8), new Vector(9, 7), RotationType.CCW));
|
||||
program.Codes.Add(new LinearMove(new Vector(0, 8)));
|
||||
program.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
}
|
||||
return new Drawing(shape, program);
|
||||
}
|
||||
|
||||
internal static object Invoke(object target, string method, params object[] args) =>
|
||||
target.GetType().GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.Invoke(target, args)!;
|
||||
|
||||
internal static void AssertSameLayout(List<Part> expected, List<Part> actual)
|
||||
{
|
||||
Assert.Equal(expected.Count, actual.Count);
|
||||
for (var i = 0; i < expected.Count; i++)
|
||||
{
|
||||
Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing);
|
||||
Assert.Equal(expected[i].Location, actual[i].Location);
|
||||
Assert.Equal(expected[i].Rotation, actual[i].Rotation);
|
||||
Assert.Equal(Bounds(expected[i].BoundingBox), Bounds(actual[i].BoundingBox));
|
||||
Assert.Equal(ProgramValues(expected[i].Program), ProgramValues(actual[i].Program));
|
||||
}
|
||||
}
|
||||
|
||||
internal static void AssertValidLayout(List<Part> parts, Box area)
|
||||
{
|
||||
Assert.NotEmpty(parts);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
Assert.True(part.BaseDrawing.Area > 0);
|
||||
Assert.True(part.Left >= area.Left - 1e-6 && part.Right <= area.Right + 1e-6
|
||||
&& part.Bottom >= area.Bottom - 1e-6 && part.Top <= area.Top + 1e-6);
|
||||
Assert.All(new[] { part.Left, part.Right, part.Bottom, part.Top, part.Rotation },
|
||||
value => Assert.True(double.IsFinite(value)));
|
||||
}
|
||||
Assert.False(FillHelpers.HasOverlappingParts(parts));
|
||||
}
|
||||
|
||||
private static (double X, double Y, double Length, double Width) Bounds(Box box) =>
|
||||
(box.X, box.Y, box.Length, box.Width);
|
||||
|
||||
private static object[] ProgramValues(Program program)
|
||||
{
|
||||
var values = new List<object> { program.Mode, program.Rotation, Bounds(program.BoundingBox()) };
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
values.Add(code.GetType());
|
||||
if (code is Motion motion)
|
||||
values.Add(motion.EndPoint);
|
||||
if (code is LinearMove line)
|
||||
values.Add(line.Layer);
|
||||
if (code is ArcMove arc)
|
||||
{
|
||||
values.Add(arc.CenterPoint);
|
||||
values.Add(arc.Rotation);
|
||||
values.Add(arc.Layer);
|
||||
}
|
||||
}
|
||||
return values.ToArray();
|
||||
}
|
||||
|
||||
private static object[] Snapshot(Drawing drawing) => new object[] { drawing, drawing.Area, drawing.Program }
|
||||
.Concat(drawing.Program.Codes.Cast<object>()).Concat(ProgramValues(drawing.Program)).ToArray();
|
||||
|
||||
private static Drawing MakeRightTriangle(double w, double h)
|
||||
{
|
||||
var pgm = new Program();
|
||||
|
||||
@@ -139,6 +139,101 @@ public class FillPerformanceTests
|
||||
ReportGroupSummary("custom", customSamples, callsPerBatch);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void Extents_ReportsRepeatedColumnRebuilds()
|
||||
{
|
||||
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_FILL_PERF") == "1",
|
||||
"Set OPENNEST_RUN_FILL_PERF=1 to run opt-in fill microbenchmarks.");
|
||||
|
||||
var area = new Box(3, 5, 45, 27);
|
||||
var drawing = FillExtentsTests.MakeFixture("triangle");
|
||||
var spacings = new[] { 0.0, 0.5 };
|
||||
var fills = spacings.Select(spacing =>
|
||||
{
|
||||
var filler = new FillExtents(area, spacing);
|
||||
return new Func<List<Part>>(() => filler.Fill(drawing));
|
||||
}).ToArray();
|
||||
var expected = spacings.Select(spacing => new LegacyFillExtents(area, spacing).Fill(drawing)).ToArray();
|
||||
for (var i = 0; i < fills.Length; i++)
|
||||
{
|
||||
Assert.Equal(24, expected[i].Count);
|
||||
FillExtentsTests.AssertSameLayout(expected[i], fills[i]());
|
||||
FillExtentsTests.AssertValidLayout(expected[i], area);
|
||||
}
|
||||
var warmupCalls = 50;
|
||||
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("extents: synthetic closed right triangle (0,0)-(10,0)-(0,8)-(0,0); "
|
||||
+ "area=(3,5,45,27); angle=0; spacing=0 or 0.5; 24 parts/fill. "
|
||||
+ "The matching Debug work test proves 2 BuildColumn calls/fill (initial + adjustment). "
|
||||
+ "Real synchronous production Fill, no reflection/reference inside timing. "
|
||||
+ "Setup, assertions and output excluded; geometry, tiling, adjustment, overlap fallback, GC, "
|
||||
+ "delegate/loop/count consumption included. Allocations=GC.GetAllocatedBytesForCurrentThread "
|
||||
+ "around synchronous calls, not RSS. Warm source drawing/JIT, no forced GC or cache reset. "
|
||||
+ "Not a timing gate, isolated BuildColumn latency, or whole-job benchmark.");
|
||||
output.WriteLine($"extents: warmup=2 batches x {warmupCalls} calls per spacing; "
|
||||
+ $"measured={repetitions} batches x {callsPerBatch} calls per spacing; "
|
||||
+ "spacing order alternates in warmup and measurement.");
|
||||
|
||||
for (var batch = 0; batch < 2; batch++)
|
||||
for (var slot = 0; slot < fills.Length; slot++)
|
||||
MeasureExtents(fills[(slot + batch) % fills.Length], warmupCalls);
|
||||
|
||||
var samples = spacings.Select(_ => new ExtentsSample[repetitions]).ToArray();
|
||||
for (var batch = 0; batch < repetitions; batch++)
|
||||
{
|
||||
for (var slot = 0; slot < fills.Length; slot++)
|
||||
{
|
||||
var mode = (slot + batch) % 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);
|
||||
FillExtentsTests.AssertValidLayout(sample.LastResult, area);
|
||||
output.WriteLine(FormattableString.Invariant(
|
||||
$"extents spacing={spacings[mode]} batch={batch + 1}: 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).OrderBy(t => t).ToArray();
|
||||
var bytes = samples[mode].Select(s => s.AllocatedBytes).OrderBy(b => b).ToArray();
|
||||
var median = repetitions / 2;
|
||||
output.WriteLine(FormattableString.Invariant(
|
||||
$"extents spacing={spacings[mode]}: 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}; batch bytes min/median/max={bytes[0]}/{bytes[median]}/{bytes[^1]}; B/call min/median/max={(double)bytes[0] / callsPerBatch:F3}/{(double)bytes[median] / callsPerBatch:F3}/{(double)bytes[^1] / callsPerBatch:F3}."));
|
||||
}
|
||||
}
|
||||
|
||||
private static ExtentsSample MeasureExtents(Func<List<Part>> fill, int calls)
|
||||
{
|
||||
var partCount = 0L;
|
||||
var last = new List<Part>();
|
||||
var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
for (var i = 0; i < calls; i++)
|
||||
{
|
||||
last = fill();
|
||||
partCount += last.Count;
|
||||
}
|
||||
var elapsed = Stopwatch.GetTimestamp() - start;
|
||||
var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;
|
||||
return new ExtentsSample(elapsed * 1000.0 / Stopwatch.Frequency, allocated, partCount, last);
|
||||
}
|
||||
|
||||
private readonly record struct ExtentsSample(double Milliseconds, long AllocatedBytes,
|
||||
long PartCount, List<Part> LastResult);
|
||||
|
||||
private static GroupPatternSample MeasureGroupPattern(Func<List<Part>> fill, int calls)
|
||||
{
|
||||
var partCount = 0L;
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
internal class LegacyFillExtents
|
||||
{
|
||||
private const int MaxIterations = 10;
|
||||
|
||||
private readonly Box workArea;
|
||||
private readonly double partSpacing;
|
||||
private readonly double halfSpacing;
|
||||
|
||||
public LegacyFillExtents(Box workArea, double partSpacing)
|
||||
{
|
||||
this.workArea = workArea;
|
||||
this.partSpacing = partSpacing;
|
||||
halfSpacing = partSpacing / 2;
|
||||
}
|
||||
|
||||
public List<Part> Fill(
|
||||
Drawing drawing,
|
||||
double rotationAngle = 0,
|
||||
CancellationToken token = default,
|
||||
Action<List<Part>, string> reportProgress = null
|
||||
)
|
||||
{
|
||||
var pair = BuildPair(drawing, rotationAngle);
|
||||
if (pair == null)
|
||||
return new List<Part>();
|
||||
|
||||
var column = BuildColumn(pair.Value);
|
||||
if (column.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
reportProgress?.Invoke(column, $"Extents: initial column {column.Count} parts");
|
||||
|
||||
var adjusted = AdjustColumn(pair.Value, column, token);
|
||||
|
||||
// The iterative pair adjustment can shift parts enough to cause
|
||||
// genuine overlap. Fall back to the unadjusted column when this happens.
|
||||
if (HasOverlappingParts(adjusted))
|
||||
{
|
||||
Debug.WriteLine("[FillExtents] Adjusted column has overlaps, using unadjusted");
|
||||
adjusted = column;
|
||||
}
|
||||
|
||||
reportProgress?.Invoke(adjusted, $"Extents: column {adjusted.Count} parts");
|
||||
|
||||
var result = RepeatColumns(adjusted, token);
|
||||
|
||||
reportProgress?.Invoke(result, $"Extents: {result.Count} parts total");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Step 1: Pair Construction ---
|
||||
|
||||
private PartPair? BuildPair(Drawing drawing, double rotationAngle)
|
||||
{
|
||||
var part1 = Part.CreateAtOrigin(drawing, rotationAngle);
|
||||
var part2 = Part.CreateAtOrigin(drawing, rotationAngle + System.Math.PI);
|
||||
|
||||
// Check that each part fits in the work area individually.
|
||||
if (
|
||||
part1.BoundingBox.Width > workArea.Width + Tolerance.Epsilon
|
||||
|| part1.BoundingBox.Length > workArea.Length + Tolerance.Epsilon
|
||||
)
|
||||
return null;
|
||||
|
||||
// Slide part2 toward part1 from the right using geometry-aware distance.
|
||||
var boundary1 = new PartBoundary(part1, halfSpacing);
|
||||
var boundary2 = new PartBoundary(part2, halfSpacing);
|
||||
|
||||
// Position part2 to the right of part1 at bounding box width distance.
|
||||
var startOffset = part1.BoundingBox.Length + part2.BoundingBox.Length + partSpacing;
|
||||
part2.Offset(startOffset, 0);
|
||||
part2.UpdateBounds();
|
||||
|
||||
// Slide part2 left toward part1.
|
||||
var movingLines = boundary2.GetLines(part2.Location, PushDirection.Left);
|
||||
var stationaryLines = boundary1.GetLines(part1.Location, PushDirection.Right);
|
||||
var dist = SpatialQuery.DirectionalDistance(
|
||||
movingLines,
|
||||
stationaryLines,
|
||||
PushDirection.Left
|
||||
);
|
||||
|
||||
if (dist < double.MaxValue && dist > 0)
|
||||
{
|
||||
part2.Offset(-dist, 0);
|
||||
part2.UpdateBounds();
|
||||
}
|
||||
|
||||
var pair = AnchorToWorkArea(part1, part2);
|
||||
if (pair == null)
|
||||
return null;
|
||||
|
||||
// Verify pair fits in work area.
|
||||
if (
|
||||
pair.Value.Bbox.Width > workArea.Width + Tolerance.Epsilon
|
||||
|| pair.Value.Bbox.Length > workArea.Length + Tolerance.Epsilon
|
||||
)
|
||||
return null;
|
||||
|
||||
return pair;
|
||||
}
|
||||
|
||||
// --- Step 2: Build Column (tile vertically) ---
|
||||
|
||||
private List<Part> BuildColumn(PartPair pair)
|
||||
{
|
||||
var column = new List<Part> { (Part)pair.Part1.Clone(), (Part)pair.Part2.Clone() };
|
||||
|
||||
// Find geometry-aware copy distance for the pair vertically.
|
||||
var boundary1 = new PartBoundary(pair.Part1, halfSpacing);
|
||||
var boundary2 = new PartBoundary(pair.Part2, halfSpacing);
|
||||
|
||||
// Compute vertical copy distance using bounding boxes as starting point,
|
||||
// then slide down to find true geometry distance.
|
||||
var pairHeight = pair.Bbox.Width;
|
||||
var testOffset = new Vector(0, pairHeight);
|
||||
|
||||
// Create test parts for slide distance measurement.
|
||||
var testPart1 = pair.Part1.CloneAtOffset(testOffset);
|
||||
var testPart2 = pair.Part2.CloneAtOffset(testOffset);
|
||||
|
||||
// Find minimum distance from test pair sliding down toward original pair.
|
||||
var copyDistance = FindVerticalCopyDistance(
|
||||
pair.Part1,
|
||||
pair.Part2,
|
||||
testPart1,
|
||||
testPart2,
|
||||
boundary1,
|
||||
boundary2,
|
||||
pairHeight
|
||||
);
|
||||
|
||||
if (copyDistance <= 0)
|
||||
return column;
|
||||
|
||||
var count = 1;
|
||||
while (true)
|
||||
{
|
||||
var nextBottom = pair.Bbox.Bottom + copyDistance * count;
|
||||
if (nextBottom + pairHeight > workArea.Top + Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
var offset = new Vector(0, copyDistance * count);
|
||||
column.Add(pair.Part1.CloneAtOffset(offset));
|
||||
column.Add(pair.Part2.CloneAtOffset(offset));
|
||||
count++;
|
||||
}
|
||||
|
||||
return column;
|
||||
}
|
||||
|
||||
private double FindVerticalCopyDistance(
|
||||
Part origPart1,
|
||||
Part origPart2,
|
||||
Part testPart1,
|
||||
Part testPart2,
|
||||
PartBoundary boundary1,
|
||||
PartBoundary boundary2,
|
||||
double pairHeight
|
||||
)
|
||||
{
|
||||
// Check all 4 combinations: test parts sliding down toward original parts.
|
||||
var slidePairs = new[]
|
||||
{
|
||||
(
|
||||
moving: boundary1,
|
||||
movingLoc: testPart1.Location,
|
||||
stationary: boundary1,
|
||||
stationaryLoc: origPart1.Location
|
||||
),
|
||||
(
|
||||
moving: boundary1,
|
||||
movingLoc: testPart1.Location,
|
||||
stationary: boundary2,
|
||||
stationaryLoc: origPart2.Location
|
||||
),
|
||||
(
|
||||
moving: boundary2,
|
||||
movingLoc: testPart2.Location,
|
||||
stationary: boundary1,
|
||||
stationaryLoc: origPart1.Location
|
||||
),
|
||||
(
|
||||
moving: boundary2,
|
||||
movingLoc: testPart2.Location,
|
||||
stationary: boundary2,
|
||||
stationaryLoc: origPart2.Location
|
||||
),
|
||||
};
|
||||
|
||||
var minSlide = double.MaxValue;
|
||||
foreach (var (moving, movingLoc, stationary, stationaryLoc) in slidePairs)
|
||||
{
|
||||
var d = SlideDistance(
|
||||
moving,
|
||||
movingLoc,
|
||||
stationary,
|
||||
stationaryLoc,
|
||||
PushDirection.Down
|
||||
);
|
||||
if (d < minSlide)
|
||||
minSlide = d;
|
||||
}
|
||||
|
||||
if (minSlide >= double.MaxValue || minSlide < 0)
|
||||
return pairHeight + partSpacing;
|
||||
|
||||
// Match FillLinear.ComputeCopyDistance: copyDist = startOffset - slide,
|
||||
// clamped so it never goes below pairHeight + partSpacing to prevent
|
||||
// bounding-box overlap from spurious slide values.
|
||||
var copyDist = pairHeight - minSlide;
|
||||
|
||||
return System.Math.Max(copyDist, pairHeight + partSpacing);
|
||||
}
|
||||
|
||||
private static double SlideDistance(
|
||||
PartBoundary movingBoundary,
|
||||
Vector movingLocation,
|
||||
PartBoundary stationaryBoundary,
|
||||
Vector stationaryLocation,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var opposite = SpatialQuery.OppositeDirection(direction);
|
||||
var movingEdges = movingBoundary.GetEdges(direction);
|
||||
var stationaryEdges = stationaryBoundary.GetEdges(opposite);
|
||||
|
||||
return SpatialQuery.DirectionalDistance(
|
||||
movingEdges,
|
||||
movingLocation,
|
||||
stationaryEdges,
|
||||
stationaryLocation,
|
||||
direction
|
||||
);
|
||||
}
|
||||
|
||||
// --- Step 3: Iterative Adjustment ---
|
||||
|
||||
private List<Part> AdjustColumn(PartPair pair, List<Part> column, CancellationToken token)
|
||||
{
|
||||
var originalPairWidth = pair.Bbox.Length;
|
||||
|
||||
for (var iteration = 0; iteration < MaxIterations; iteration++)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
// Measure current gap.
|
||||
var topEdge = double.MinValue;
|
||||
foreach (var p in column)
|
||||
if (p.BoundingBox.Top > topEdge)
|
||||
topEdge = p.BoundingBox.Top;
|
||||
|
||||
var gap = workArea.Top - topEdge;
|
||||
|
||||
if (gap <= Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
var pairCount = column.Count / 2;
|
||||
if (pairCount <= 0)
|
||||
break;
|
||||
|
||||
var adjustment = gap / pairCount;
|
||||
if (adjustment <= Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
// Try adjusting the pair and rebuilding the column.
|
||||
var adjusted = TryAdjustPair(pair, adjustment, originalPairWidth);
|
||||
if (adjusted == null)
|
||||
break;
|
||||
|
||||
var newColumn = BuildColumn(adjusted.Value);
|
||||
if (newColumn.Count == 0)
|
||||
break;
|
||||
|
||||
column = newColumn;
|
||||
pair = adjusted.Value;
|
||||
}
|
||||
|
||||
return column;
|
||||
}
|
||||
|
||||
private PartPair? TryAdjustPair(PartPair pair, double adjustment, double originalPairWidth)
|
||||
{
|
||||
// Try shifting part2 up first.
|
||||
var result = TryShiftDirection(pair, adjustment, originalPairWidth);
|
||||
|
||||
if (result != null)
|
||||
return result;
|
||||
|
||||
// Up made the pair wider — try down instead.
|
||||
return TryShiftDirection(pair, -adjustment, originalPairWidth);
|
||||
}
|
||||
|
||||
private PartPair? TryShiftDirection(
|
||||
PartPair pair,
|
||||
double verticalShift,
|
||||
double originalPairWidth
|
||||
)
|
||||
{
|
||||
// Clone parts so we don't mutate the originals.
|
||||
var p1 = (Part)pair.Part1.Clone();
|
||||
var p2 = (Part)pair.Part2.Clone();
|
||||
|
||||
// Separate: shift part2 right so bounding boxes don't touch.
|
||||
p2.Offset(partSpacing, 0);
|
||||
p2.UpdateBounds();
|
||||
|
||||
// Apply the vertical shift.
|
||||
p2.Offset(0, verticalShift);
|
||||
p2.UpdateBounds();
|
||||
|
||||
// Compact part2 left toward part1.
|
||||
var moving = new List<Part> { p2 };
|
||||
var obstacles = new List<Part> { p1 };
|
||||
Compactor.Push(moving, obstacles, workArea, partSpacing, PushDirection.Left);
|
||||
|
||||
// Check if the pair got wider.
|
||||
var newBbox = PairBbox(p1, p2);
|
||||
|
||||
if (newBbox.Length > originalPairWidth + Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
return AnchorToWorkArea(p1, p2);
|
||||
}
|
||||
|
||||
// --- Step 4: Horizontal Repetition ---
|
||||
|
||||
private List<Part> RepeatColumns(List<Part> column, CancellationToken token)
|
||||
{
|
||||
if (column.Count == 0)
|
||||
return column;
|
||||
|
||||
var pattern = new Pattern();
|
||||
pattern.Parts.AddRange(column);
|
||||
pattern.UpdateBounds();
|
||||
|
||||
var linear = new FillLinear(workArea, partSpacing);
|
||||
return linear.Fill(pattern, NestDirection.Horizontal);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private PartPair? AnchorToWorkArea(Part part1, Part part2)
|
||||
{
|
||||
var bbox = PairBbox(part1, part2);
|
||||
var anchor = new Vector(workArea.X - bbox.Left, workArea.Y - bbox.Bottom);
|
||||
part1.Offset(anchor);
|
||||
part2.Offset(anchor);
|
||||
part1.UpdateBounds();
|
||||
part2.UpdateBounds();
|
||||
|
||||
bbox = PairBbox(part1, part2);
|
||||
return new PartPair(part1, part2, bbox);
|
||||
}
|
||||
|
||||
private static Box PairBbox(Part part1, Part part2) =>
|
||||
((IEnumerable<IBoundable>)new IBoundable[] { part1, part2 }).GetBoundingBox();
|
||||
|
||||
private static bool HasOverlappingParts(List<Part> parts) =>
|
||||
FillHelpers.HasOverlappingParts(parts);
|
||||
|
||||
private readonly record struct PartPair(Part Part1, Part Part2, Box Bbox);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user