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
@@ -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}."));
}
[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)
{
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);
}
}
}