perf(fill): prepare overlap polygons once per check

Both HasOverlappingParts loops rebuilt each part's polygon from its
Program on every pair. PartOverlapChecker prepares each distinct Program
(reference identity) once and each part's world polygon once per call,
then uses the overlap-only Collision.HasOverlap. Loop order, bounding-box
prefilter, early exit and returned indices are unchanged; Part.Intersects
shares the material/polygon recipe and still returns crossing points.

Verification:
- Frozen LegacyPartOverlap differential (original Intersects and both
  loops): verdicts, indices and world polygons bit-identical across fill
  grids, patterns, touching/epsilon gaps, scribe/rapid/empty programs.
- Debug OverlapPolygonPreparations: 246 -> 1 and 64 -> 2 per check.
- Corpus job (169 parts, --engines Default --parallel 1, with 1a):
  median 18,885 -> 13,464 ms over 4+4 alternating runs, identical
  outcomes; serialized layout byte-identical to the base.
This commit is contained in:
aj
2026-09-27 13:34:04 -04:00
parent f44a1a3d2c
commit a27290a29c
9 changed files with 672 additions and 17 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ Always keep `README.md` and `AGENTS.md` up to date when making changes that affe
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion). - Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`. - Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, inflated by the spacing less `NestTolerances.SpacingSlack` (0.0005), so a layout exactly at the spacing passes even after rotation and coordinate rounding leave it ~1e-4 short. `NestJobPlacementValidator` applies the same slack to its edge-distance check. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. `FillLinear` prepares each distinct `Program` (reference identity) once per public `Fill`/`FillRow` call and translates clones; never share that cache across calls or threads. Clipper is allowed only for cached CPU preparation, never in per-pair hot loops. - **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, inflated by the spacing less `NestTolerances.SpacingSlack` (0.0005), so a layout exactly at the spacing passes even after rotation and coordinate rounding leave it ~1e-4 short. `NestJobPlacementValidator` applies the same slack to its edge-distance check. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. `FillLinear` prepares each distinct `Program` (reference identity) once per public `Fill`/`FillRow` call and translates clones; never share that cache across calls or threads. Both `HasOverlappingParts` loops use one `PartOverlapChecker` per call (same keying; parts and programs must not change while it is in use). Clipper is allowed only for cached CPU preparation, never in per-pair hot loops.
- **Marks are not material**: scribe/etch moves are marked on the surface, never cut through, so they are left out of nesting. `SpecialLayers.IsMaterial(layer)` (excludes `Rapid` and `Scribe`) is the filter for every consumer that builds part material from a program: drawing area, canonical angle, part collision, `PartGeometry`, plate perimeters, best-fit/pair evaluation, rotation analysis, the GPU evaluators, and both validators (`NestJobPlacementValidator`, benchmark `NestValidator`). Cutting time, on-screen display, splitting, and post-processors still see marks. Older `.nest` files (e.g. `tools/PepNestExport` output) saved etch as cut moves while their source entities kept the `SCRIBE` layer; `NestReader` runs `ScribeLayerRepair` on load to move matching program moves back to `Scribe`. - **Marks are not material**: scribe/etch moves are marked on the surface, never cut through, so they are left out of nesting. `SpecialLayers.IsMaterial(layer)` (excludes `Rapid` and `Scribe`) is the filter for every consumer that builds part material from a program: drawing area, canonical angle, part collision, `PartGeometry`, plate perimeters, best-fit/pair evaluation, rotation analysis, the GPU evaluators, and both validators (`NestJobPlacementValidator`, benchmark `NestValidator`). Cutting time, on-screen display, splitting, and post-processors still see marks. Older `.nest` files (e.g. `tools/PepNestExport` output) saved etch as cut moves while their source entities kept the `SCRIBE` layer; `NestReader` runs `ScribeLayerRepair` on load to move matching program moves back to `Scribe`.
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring. - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring.
+26 -11
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Converters; using OpenNest.Converters;
@@ -238,14 +238,8 @@ namespace OpenNest
PerfCounters.CountPartIntersects(); PerfCounters.CountPartIntersects();
pts = new List<Vector>(); pts = new List<Vector>();
var entities1 = ConvertProgram var entities1 = MaterialEntities(Program);
.ToGeometry(Program) var entities2 = MaterialEntities(part.Program);
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
var entities2 = ConvertProgram
.ToGeometry(part.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
if (entities1.Count == 0 || entities2.Count == 0) if (entities1.Count == 0 || entities2.Count == 0)
return false; return false;
@@ -256,8 +250,8 @@ namespace OpenNest
if (perimeter1 == null || perimeter2 == null) if (perimeter1 == null || perimeter2 == null)
return false; return false;
var polygon1 = perimeter1.ToPolygonWithTolerance(IntersectsChordTolerance); var polygon1 = BuildOverlapPolygon(perimeter1);
var polygon2 = perimeter2.ToPolygonWithTolerance(IntersectsChordTolerance); var polygon2 = BuildOverlapPolygon(perimeter2);
if (polygon1 == null || polygon2 == null) if (polygon1 == null || polygon2 == null)
return false; return false;
@@ -270,6 +264,27 @@ namespace OpenNest
return result.Overlaps; return result.Overlaps;
} }
/// <summary>
/// Material (cut) entities of <paramref name="program"/> in the program's local frame: the
/// first stage of overlap preparation, shared by <see cref="Intersects"/> and
/// <see cref="PartOverlapChecker"/>.
/// </summary>
internal static List<Entity> MaterialEntities(CNC.Program program)
{
PerfCounters.CountOverlapPolygonPreparation();
return ConvertProgram
.ToGeometry(program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
}
/// <summary>
/// Local-frame overlap polygon of a material perimeter: the last stage of overlap
/// preparation, shared by <see cref="Intersects"/> and <see cref="PartOverlapChecker"/>.
/// </summary>
internal static Polygon BuildOverlapPolygon(Shape perimeter) =>
perimeter.ToPolygonWithTolerance(IntersectsChordTolerance);
public double Left public double Left
{ {
get { return BoundingBox.Left; } get { return BoundingBox.Left; }
+120
View File
@@ -0,0 +1,120 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// Overlap-only form of <see cref="Part.Intersects"/> for one pass over a fixed set of parts.
/// Verdicts match <c>Intersects(other, out _)</c>. Each distinct <see cref="CNC.Program"/>
/// (by reference) is prepared once, each part's world polygon is built once, and crossing
/// points are not computed. Tiled copies from <see cref="Part.CloneAtOffset"/> share one
/// Program, so a fill grid prepares its pattern's programs only once.
/// Parts must not move, rotate or change Program while an instance is in use. Instances are
/// not thread-safe: create one per check.
/// </summary>
public sealed class PartOverlapChecker
{
private readonly Dictionary<CNC.Program, PreparedProgram> programs = new(
ReferenceEqualityComparer.Instance
);
private readonly Dictionary<Part, Polygon> worldPolygons = new(
ReferenceEqualityComparer.Instance
);
/// <summary>
/// Same verdict as <c>part1.Intersects(part2, out _)</c>. Preparation stages run in the
/// same order, with the same early exits, the first time each Program is needed.
/// </summary>
public bool Overlaps(Part part1, Part part2)
{
PerfCounters.CountPartIntersects();
var prepared1 = Prepare(part1.Program);
var prepared2 = Prepare(part2.Program);
if (prepared1.Material.Count == 0 || prepared2.Material.Count == 0)
return false;
var perimeter1 = prepared1.GetPerimeter();
var perimeter2 = prepared2.GetPerimeter();
if (perimeter1 == null || perimeter2 == null)
return false;
var polygon1 = WorldPolygon(part1, prepared1);
var polygon2 = WorldPolygon(part2, prepared2);
if (polygon1 == null || polygon2 == null)
return false;
return Collision.HasOverlap(polygon1, polygon2);
}
private PreparedProgram Prepare(CNC.Program program)
{
if (!programs.TryGetValue(program, out var prepared))
{
prepared = new PreparedProgram(Part.MaterialEntities(program));
programs.Add(program, prepared);
}
return prepared;
}
private Polygon WorldPolygon(Part part, PreparedProgram prepared)
{
if (worldPolygons.TryGetValue(part, out var polygon))
return polygon;
var local = prepared.GetLocalPolygon();
if (local != null)
{
// Clone copies the vertices but not the bounds. Recomputing the bounds from the
// same vertices and then offsetting reproduces Part.Intersects' polygon bit for bit.
polygon = (Polygon)local.Clone();
polygon.UpdateBounds();
polygon.Offset(part.Location);
}
worldPolygons.Add(part, polygon);
return polygon;
}
private sealed class PreparedProgram
{
private bool perimeterReady;
private Shape perimeter;
private bool polygonReady;
private Polygon localPolygon;
public PreparedProgram(List<Entity> material) => Material = material;
public List<Entity> Material { get; }
public Shape GetPerimeter()
{
if (!perimeterReady)
{
perimeter = new ShapeProfile(Material).Perimeter;
perimeterReady = true;
}
return perimeter;
}
/// <summary>Only called after <see cref="GetPerimeter"/> returned non-null.</summary>
public Polygon GetLocalPolygon()
{
if (!polygonReady)
{
localPolygon = Part.BuildOverlapPolygon(perimeter);
polygonReady = true;
}
return localPolygon;
}
}
}
}
+7
View File
@@ -17,6 +17,7 @@ namespace OpenNest
private static long partBoundsUpdates; private static long partBoundsUpdates;
private static long featureBitmaskCells; private static long featureBitmaskCells;
private static long crossingPointScans; private static long crossingPointScans;
private static long overlapPolygonPreparations;
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);
@@ -26,6 +27,7 @@ namespace OpenNest
public static long PartBoundsUpdates => Interlocked.Read(ref partBoundsUpdates); public static long PartBoundsUpdates => Interlocked.Read(ref partBoundsUpdates);
public static long FeatureBitmaskCells => Interlocked.Read(ref featureBitmaskCells); public static long FeatureBitmaskCells => Interlocked.Read(ref featureBitmaskCells);
public static long CrossingPointScans => Interlocked.Read(ref crossingPointScans); public static long CrossingPointScans => Interlocked.Read(ref crossingPointScans);
public static long OverlapPolygonPreparations => Interlocked.Read(ref overlapPolygonPreparations);
[Conditional("DEBUG")] [Conditional("DEBUG")]
public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits); public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits);
@@ -52,6 +54,10 @@ namespace OpenNest
[Conditional("DEBUG")] [Conditional("DEBUG")]
public static void CountCrossingPointScan() => Interlocked.Increment(ref crossingPointScans); public static void CountCrossingPointScan() => Interlocked.Increment(ref crossingPointScans);
[Conditional("DEBUG")]
public static void CountOverlapPolygonPreparation() =>
Interlocked.Increment(ref overlapPolygonPreparations);
public static void Reset() public static void Reset()
{ {
Interlocked.Exchange(ref findBestFits, 0); Interlocked.Exchange(ref findBestFits, 0);
@@ -62,6 +68,7 @@ namespace OpenNest
Interlocked.Exchange(ref partBoundsUpdates, 0); Interlocked.Exchange(ref partBoundsUpdates, 0);
Interlocked.Exchange(ref featureBitmaskCells, 0); Interlocked.Exchange(ref featureBitmaskCells, 0);
Interlocked.Exchange(ref crossingPointScans, 0); Interlocked.Exchange(ref crossingPointScans, 0);
Interlocked.Exchange(ref overlapPolygonPreparations, 0);
} }
} }
} }
+3 -1
View File
@@ -295,6 +295,8 @@ namespace OpenNest.Engine.Fill
out int overlapB out int overlapB
) )
{ {
var checker = new PartOverlapChecker();
for (var i = 0; i < parts.Count; i++) for (var i = 0; i < parts.Count; i++)
{ {
var b1 = parts[i].BoundingBox; var b1 = parts[i].BoundingBox;
@@ -311,7 +313,7 @@ namespace OpenNest.Engine.Fill
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon) if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
continue; continue;
if (parts[i].Intersects(parts[j], out _)) if (checker.Overlaps(parts[i], parts[j]))
{ {
overlapA = i; overlapA = i;
overlapB = j; overlapB = j;
+3 -1
View File
@@ -170,6 +170,8 @@ namespace OpenNest.Engine.Strategies
/// </summary> /// </summary>
internal static bool HasOverlappingParts(List<Part> parts) internal static bool HasOverlappingParts(List<Part> parts)
{ {
var checker = new PartOverlapChecker();
for (var i = 0; i < parts.Count; i++) for (var i = 0; i < parts.Count; i++)
{ {
var b1 = parts[i].BoundingBox; var b1 = parts[i].BoundingBox;
@@ -186,7 +188,7 @@ namespace OpenNest.Engine.Strategies
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon) if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
continue; continue;
if (parts[i].Intersects(parts[j], out _)) if (checker.Overlaps(parts[i], parts[j]))
return true; return true;
} }
} }
+132
View File
@@ -0,0 +1,132 @@
#nullable disable
// Frozen, test-only copies from commit 82feb78b0fefdffc9ef9306205eeaf44e80e1d8d, taken before
// fill overlap checks used PartOverlapChecker: the Part.Intersects body (on LegacyCollision),
// FillLinear.HasOverlappingParts and FillHelpers.HasOverlappingParts. Only the member access
// (Program/Location -> self.*), the PerfCounters call and the collision type differ from the
// originals. Do not edit: differential tests use them as the independent pre-change oracle.
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Tests.Geometry;
namespace OpenNest.Tests.Fill;
internal static class LegacyPartOverlap
{
private const double IntersectsChordTolerance = 0.001;
public static bool Intersects(Part self, Part part, out List<Vector> pts)
{
pts = new List<Vector>();
var entities1 = ConvertProgram
.ToGeometry(self.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
var entities2 = ConvertProgram
.ToGeometry(part.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
if (entities1.Count == 0 || entities2.Count == 0)
return false;
var perimeter1 = new ShapeProfile(entities1).Perimeter;
var perimeter2 = new ShapeProfile(entities2).Perimeter;
if (perimeter1 == null || perimeter2 == null)
return false;
var polygon1 = perimeter1.ToPolygonWithTolerance(IntersectsChordTolerance);
var polygon2 = perimeter2.ToPolygonWithTolerance(IntersectsChordTolerance);
if (polygon1 == null || polygon2 == null)
return false;
polygon1.Offset(self.Location);
polygon2.Offset(part.Location);
var result = LegacyCollision.Check(polygon1, polygon2);
pts = result.IntersectionPoints.ToList();
return result.Overlaps;
}
/// <summary>World polygon exactly as the pre-change Part.Intersects built it.</summary>
public static Polygon WorldPolygon(Part part)
{
var entities = ConvertProgram
.ToGeometry(part.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
if (entities.Count == 0)
return null;
var perimeter = new ShapeProfile(entities).Perimeter;
if (perimeter == null)
return null;
var polygon = perimeter.ToPolygonWithTolerance(IntersectsChordTolerance);
polygon.Offset(part.Location);
return polygon;
}
public static bool FillLinearHasOverlappingParts(
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 (Intersects(parts[i], parts[j], out _))
{
overlapA = i;
overlapB = j;
return true;
}
}
}
overlapA = -1;
overlapB = -1;
return false;
}
public static bool FillHelpersHasOverlappingParts(List<Part> parts)
{
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 (Intersects(parts[i], parts[j], out _))
return true;
}
}
return false;
}
}
@@ -0,0 +1,377 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
using OpenNest.Shapes;
using OpenNest.Tests.BestFit;
using Xunit.Abstractions;
namespace OpenNest.Tests.Fill;
/// <summary>
/// PartOverlapChecker, and both in-fill HasOverlappingParts checks built on it, must reproduce
/// the frozen pre-change nested loop exactly: the same verdict and the same first overlapping
/// (a, b) indices. The only change is that each distinct Program is prepared once per check.
/// </summary>
[Collection(nameof(FillCacheCollection))]
public class PartOverlapCheckerTests
{
private static readonly string[] Shapes = { "rectangle", "concave", "arc", "circle", "ring" };
private readonly ITestOutputHelper output;
public PartOverlapCheckerTests(ITestOutputHelper output) => this.output = output;
public static IEnumerable<object[]> GridCases()
{
foreach (var shape in Shapes)
foreach (var spacing in new[] { 0.0, 0.5 })
foreach (var angle in new[] { 0.0, System.Math.PI / 2, 0.37 })
yield return new object[] { shape, spacing, angle };
}
[Theory]
[MemberData(nameof(GridCases))]
public void FillLinearGrids_MatchFrozenLoops(string shape, double spacing, double angle)
{
var area = new Box(3.1, 5.3, 61, 37);
var filler = new FillLinear(area, spacing);
var drawing = Fixture(shape);
foreach (var direction in new[] { NestDirection.Horizontal, NestDirection.Vertical })
{
var grid = filler.Fill(drawing, angle, direction);
Assert.NotEmpty(grid);
AssertMatchesLegacy(grid);
// Nudge copies onto their neighbours so overlapping verdicts are exercised too.
foreach (var shift in new[] { 0.05, 1e-9, -1e-9 })
AssertMatchesLegacy(Shifted(grid, shift));
}
}
[Theory]
[InlineData("shared")]
[InlineData("rotated")]
[InlineData("built-pair")]
public void PatternGrids_MatchFrozenLoops(string kind)
{
foreach (var shape in Shapes)
{
var pattern = MakePattern(kind, Fixture(shape));
var filler = new FillLinear(new Box(3.1, 5.3, 61, 37), 0.5);
foreach (var direction in new[] { NestDirection.Horizontal, NestDirection.Vertical })
{
var grid = filler.Fill(pattern, direction);
AssertMatchesLegacy(grid);
AssertMatchesLegacy(Shifted(grid, 0.05));
}
}
}
[Fact]
public void OverlappingSeedTiling_MatchesFrozenLoops()
{
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 legacy = new LegacyFillLinear(new Box(0, 0, 35, 8.25), 0.5);
var raw = new List<Part>(pattern.Parts);
raw.AddRange((List<Part>)FillExtentsTests.Invoke(legacy, "TilePattern", pattern, NestDirection.Horizontal));
Assert.True(LegacyPartOverlap.FillHelpersHasOverlappingParts(raw));
AssertMatchesLegacy(raw);
}
[Theory]
[InlineData(0.0)]
[InlineData(1e-12)]
[InlineData(1e-9)]
[InlineData(-1e-9)]
[InlineData(1e-7)]
[InlineData(-1e-7)]
public void TouchingAndEpsilonEdgeOffsets_MatchFrozenLoops(double gap)
{
foreach (var shape in Shapes)
{
var drawing = Fixture(shape);
var a = Part.CreateAtOrigin(drawing, 0);
var right = a.CloneAtOffset(new Vector(a.BoundingBox.Length + gap, 0));
var above = a.CloneAtOffset(new Vector(0, a.BoundingBox.Width + gap));
var diagonal = a.CloneAtOffset(new Vector(a.BoundingBox.Length + gap, a.BoundingBox.Width + gap));
AssertMatchesLegacy(new List<Part> { a, right, above, diagonal });
}
}
[Fact]
public void NonMaterialAndEmptyPrograms_MatchFrozenLoops()
{
var square = Part.CreateAtOrigin(Fixture("rectangle"), 0);
var scribeOnly = new Program();
scribeOnly.Codes.Add(new RapidMove(new Vector(0, 0)));
scribeOnly.Codes.Add(new LinearMove(new Vector(4, 0)) { Layer = LayerType.Scribe });
scribeOnly.Codes.Add(new LinearMove(new Vector(4, 4)) { Layer = LayerType.Scribe });
scribeOnly.Codes.Add(new LinearMove(new Vector(0, 0)) { Layer = LayerType.Scribe });
var rapidOnly = new Program();
rapidOnly.Codes.Add(new RapidMove(new Vector(0, 0)));
rapidOnly.Codes.Add(new RapidMove(new Vector(5, 5)));
foreach (var program in new[] { scribeOnly, rapidOnly })
{
var odd = new Part(new Drawing("odd", program));
Assert.Empty(Part.MaterialEntities(odd.Program));
var parts = new List<Part> { square, odd, square.CloneAtOffset(new Vector(2, 2)) };
AssertMatchesLegacy(parts);
Assert.False(new PartOverlapChecker().Overlaps(square, odd));
Assert.False(new PartOverlapChecker().Overlaps(odd, odd));
}
// An empty program has no material entities, so both paths return false before ShapeProfile.
var empty = new Part(new Drawing("empty", new Program()));
Assert.Empty(Part.MaterialEntities(empty.Program));
Assert.Equal(Capture(() => LegacyPartOverlap.Intersects(square, empty, out _)),
Capture(() => new PartOverlapChecker().Overlaps(square, empty)));
Assert.Equal(Capture(() => LegacyPartOverlap.Intersects(empty, square, out _)),
Capture(() => new PartOverlapChecker().Overlaps(empty, square)));
AssertMatchesLegacy(new List<Part> { square, empty, square.CloneAtOffset(new Vector(20, 0)) });
}
[Fact]
public void SharedProgram_PartsTestedAgainstEachOtherAndItself()
{
var a = Part.CreateAtOrigin(Fixture("concave"), 0);
var b = a.CloneAtOffset(new Vector(1, 1));
var c = a.CloneAtOffset(new Vector(30, 0));
Assert.Same(a.Program, b.Program);
var checker = new PartOverlapChecker();
Assert.Equal(LegacyPartOverlap.Intersects(a, b, out _), checker.Overlaps(a, b));
Assert.Equal(LegacyPartOverlap.Intersects(a, c, out _), checker.Overlaps(a, c));
Assert.Equal(LegacyPartOverlap.Intersects(b, a, out _), checker.Overlaps(b, a));
Assert.Equal(LegacyPartOverlap.Intersects(a, a, out _), checker.Overlaps(a, a));
Assert.True(checker.Overlaps(a, b));
Assert.False(checker.Overlaps(a, c));
}
[Fact]
public void WorldPolygons_MatchPreChangeBitwise()
{
foreach (var shape in Shapes)
{
var part = Part.CreateAtOrigin(Fixture(shape), 0.37);
part.Offset(new Vector(11.1, -3.3));
var clone = part.CloneAtOffset(new Vector(7.77, 1.234567));
var checker = new PartOverlapChecker();
// Prepare the shared Program through the clone first, then build the original.
checker.Overlaps(clone, part);
foreach (var p in new[] { part, clone })
{
var expected = LegacyPartOverlap.WorldPolygon(p);
var actual = WorldPolygon(checker, p);
Assert.Equal(PolygonBits(expected), PolygonBits(actual));
}
}
}
[Fact]
public void Check_DoesNotMutateParts()
{
var pattern = MakePattern("rotated", Fixture("arc"));
var grid = new FillLinear(new Box(3.1, 5.3, 61, 37), 0.5).Fill(pattern, NestDirection.Horizontal);
var before = grid.Select(Snapshot).ToArray();
FillHelpers.HasOverlappingParts(grid);
Invoke(grid, out _, out _);
var checker = new PartOverlapChecker();
for (var i = 0; i + 1 < grid.Count; i++)
checker.Overlaps(grid[i], grid[i + 1]);
Assert.Equal(before, grid.Select(Snapshot).ToArray());
}
[Fact]
public void ConcurrentChecks_MatchSequential()
{
var grids = Shapes.SelectMany(shape => new[]
{
Shifted(new FillLinear(new Box(3.1, 5.3, 61, 37), 0.5).Fill(Fixture(shape), 0.37, NestDirection.Horizontal), 0.05),
new FillLinear(new Box(3.1, 5.3, 61, 37), 0.5).Fill(MakePattern("built-pair", Fixture(shape)), NestDirection.Vertical),
}).ToArray();
var expected = grids.Select(g => (Helpers: FillHelpers.HasOverlappingParts(g), Linear: Indices(g))).ToArray();
Parallel.For(0, grids.Length * 4, new ParallelOptions { MaxDegreeOfParallelism = 4 }, i =>
{
var grid = grids[i % grids.Length];
Assert.Equal(expected[i % grids.Length].Helpers, FillHelpers.HasOverlappingParts(grid));
Assert.Equal(expected[i % grids.Length].Linear, Indices(grid));
});
}
#if DEBUG
[Theory]
[InlineData("single", 1)]
[InlineData("pair", 2)]
public void Work_PreparesEachDistinctProgramOncePerCheck(string kind, long distinctPrograms)
{
// Rotated grids: neighbouring boxes overlap but parts do not, so every box-overlapping
// pair reaches an exact test and there is no early exit.
var filler = new FillLinear(new Box(3.1, 5.3, 96, 48), 0.5);
List<Part> grid;
if (kind == "single")
grid = filler.Fill(Fixture("arc"), 0.37, NestDirection.Horizontal);
else
{
var first = Part.CreateAtOrigin(Fixture("concave"), 0);
var second = Part.CreateAtOrigin(Fixture("concave"), System.Math.PI);
second.Offset(new Vector(first.Right + 0.5, first.Bottom));
grid = filler.Fill(FillHelpers.BuildRotatedPattern(new List<Part> { first, second }, 0.37),
NestDirection.Horizontal);
}
Assert.Equal(distinctPrograms, grid.Select(g => g.Program).Distinct(ReferenceEqualityComparer.Instance).Count());
PerfCounters.Reset();
try
{
var legacyVerdict = LegacyPartOverlap.FillHelpersHasOverlappingParts(grid);
Assert.Equal(0, PerfCounters.OverlapPolygonPreparations);
// Old path: two preparations per exact test, through Part.Intersects.
var exactTests = 0;
var legacyPreparations = 0L;
PerfCounters.Reset();
ForEachBoxOverlappingPair(grid, (a, b) =>
{
exactTests++;
a.Intersects(b, out _);
});
legacyPreparations = PerfCounters.OverlapPolygonPreparations;
PerfCounters.Reset();
var verdict = FillHelpers.HasOverlappingParts(grid);
var preparations = PerfCounters.OverlapPolygonPreparations;
output.WriteLine($"work {kind}: parts={grid.Count}; exact tests={exactTests}; old preparations={legacyPreparations}; new={preparations}; verdict={verdict}");
Assert.False(legacyVerdict);
Assert.False(verdict);
Assert.True(exactTests > 2);
Assert.Equal(2L * exactTests, legacyPreparations);
Assert.Equal(distinctPrograms, preparations);
}
finally
{
PerfCounters.Reset();
}
}
#endif
private static void AssertMatchesLegacy(List<Part> parts)
{
var legacy = Capture(() => LegacyPartOverlap.FillHelpersHasOverlappingParts(parts));
var actual = Capture(() => FillHelpers.HasOverlappingParts(parts));
Assert.Equal(legacy, actual);
var legacyIndices = Capture(() =>
{
var hit = LegacyPartOverlap.FillLinearHasOverlappingParts(parts, out var a, out var b);
return (hit, a, b);
});
var actualIndices = Capture(() => Indices(parts));
Assert.Equal(legacyIndices, actualIndices);
}
private static (T Value, Type? Exception) Capture<T>(Func<T> run)
{
try
{
return (run(), null);
}
catch (Exception exception)
{
return (default!, (exception as System.Reflection.TargetInvocationException)?.InnerException?.GetType()
?? exception.GetType());
}
}
private static (bool Hit, int A, int B) Indices(List<Part> parts)
{
var hit = Invoke(parts, out var a, out var b);
return (hit, a, b);
}
private static bool Invoke(List<Part> parts, out int a, out int b)
{
var method = typeof(FillLinear).GetMethod("HasOverlappingParts",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
var args = new object[] { parts, -1, -1 };
var hit = (bool)method.Invoke(null, args)!;
a = (int)args[1];
b = (int)args[2];
return hit;
}
private static void ForEachBoxOverlappingPair(List<Part> parts, Action<Part, Part> action)
{
for (var i = 0; i < parts.Count; i++)
for (var j = i + 1; j < parts.Count; j++)
{
var b1 = parts[i].BoundingBox;
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 > OpenNest.Math.Tolerance.Epsilon && overlapY > OpenNest.Math.Tolerance.Epsilon)
action(parts[i], parts[j]);
}
}
private static Polygon WorldPolygon(PartOverlapChecker checker, Part part)
{
var field = typeof(PartOverlapChecker).GetField("worldPolygons",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
return ((Dictionary<Part, Polygon>)field.GetValue(checker)!)[part];
}
/// <summary>Every other part replaced by a copy moved by (-shift, -shift).</summary>
private static List<Part> Shifted(List<Part> grid, double shift)
{
var result = new List<Part>(grid.Count);
for (var i = 0; i < grid.Count; i++)
result.Add(i % 2 == 0 ? grid[i] : grid[i].CloneAtOffset(new Vector(-shift, -shift)));
return result;
}
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)
{
var first = Part.CreateAtOrigin(drawing, 0);
first.Offset(new Vector(11.1, -3.3));
var second = kind == "shared" ? first.CloneAtOffset(new Vector(first.BoundingBox.Length + 0.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;
}
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
private static long[] PolygonBits(Polygon polygon) => new[]
{
Bits(polygon.BoundingBox.X), Bits(polygon.BoundingBox.Y),
Bits(polygon.BoundingBox.Length), Bits(polygon.BoundingBox.Width),
}
.Concat(polygon.Vertices.SelectMany(v => new[] { Bits(v.X), Bits(v.Y) }))
.ToArray();
private static object[] Snapshot(Part part) => new object[]
{
part, part.Program, part.Program.Codes.Count, Bits(part.Location.X), Bits(part.Location.Y),
Bits(part.Rotation), Bits(part.BoundingBox.X), Bits(part.BoundingBox.Y),
Bits(part.BoundingBox.Length), Bits(part.BoundingBox.Width),
}
.Concat(part.Program.Codes.Cast<object>())
.Concat(ConvertProgram.ToGeometry(part.Program).SelectMany(e => new object[] { e.GetType(), e.Layer,
Bits(e.BoundingBox.X), Bits(e.BoundingBox.Y), Bits(e.BoundingBox.Length), Bits(e.BoundingBox.Width) }))
.ToArray();
}
+3 -3
View File
@@ -9,16 +9,16 @@ OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Rel
Only the exact value `1` enables these tests; otherwise they skip; [README](../../README.md) documents the PowerShell equivalent. Only the exact value `1` enables these tests; otherwise they skip; [README](../../README.md) documents the PowerShell equivalent.
The category covers comparer, group-pattern, rotated-pattern, extents-column, feature-extraction, no-model angle, and FillLinear offset-geometry workloads; individual filters match benchmark method names in `FillPerformanceTests.cs`. Keep harness, inputs, warmups and batches identical before/after; exclude setup/assertions from timing. Comparer/extents allocations are synchronous and current-thread only; parallel group fills omit allocation totals. No timing CI gates or whole-job speedup claims. Preserve evidence in [the measured report](fill-performance.md). The category covers comparer, group-pattern, rotated-pattern, extents-column, feature-extraction, no-model angle, and FillLinear offset-geometry workloads; individual filters match benchmark method names in `FillPerformanceTests.cs`. Overlap checks are measured separately by `OverlapCheck_ReportsPolygonPairsAndGridChecks` in `OpenNest.Tests/Fill/OverlapCheckPerformanceTests.cs` (same category). Keep harness, inputs, warmups and batches identical before/after; exclude setup/assertions from timing. Comparer/extents allocations are synchronous and current-thread only; parallel group fills omit allocation totals. No timing CI gates or whole-job speedup claims. Preserve evidence in [the measured report](fill-performance.md).
Debug behavior/skipped-work checks: Debug behavior/skipped-work checks:
```bash ```bash
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \ dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \
--filter 'FullyQualifiedName~DefaultFillComparerWorkTests|FullyQualifiedName~FillHelpersTests|FullyQualifiedName~FillExtentsTests|FullyQualifiedName~StrategyOverlapTests|FullyQualifiedName~FillLinearGeometryReuseTests' --filter 'FullyQualifiedName~DefaultFillComparerWorkTests|FullyQualifiedName~FillHelpersTests|FullyQualifiedName~FillExtentsTests|FullyQualifiedName~StrategyOverlapTests|FullyQualifiedName~FillLinearGeometryReuseTests|FullyQualifiedName~CollisionOverlapOnlyTests|FullyQualifiedName~PartOverlapCheckerTests'
``` ```
`PerfCounters.FillScoreComputations`, `PartBoundaryPreparations`, `PartBoundsUpdates`, `OffsetPerimeterEntities`, and `FeatureBitmaskCells` increments compile away in Release: zero Release counters prove nothing. Serialize counter assertions in `FillCacheCollection` and reset in `finally`. Keep `OpenNest.Tests/Fill/LegacyFillExtents.cs` and `OpenNest.Tests/Fill/LegacyFillLinear.cs` frozen for differential tests, not production or before timings; measure the actual baseline production code. `PerfCounters.FillScoreComputations`, `PartBoundaryPreparations`, `PartBoundsUpdates`, `OffsetPerimeterEntities`, `FeatureBitmaskCells`, `CrossingPointScans`, and `OverlapPolygonPreparations` increments compile away in Release: zero Release counters prove nothing. `OverlapPolygonPreparations` counts overlap-preparation starts (material extraction), not completed polygons: `Part.Intersects` counts both parts on every call, `PartOverlapChecker` counts once per distinct `Program`. Serialize counter assertions in `FillCacheCollection` and reset in `finally`. Keep `OpenNest.Tests/Fill/LegacyFillExtents.cs`, `OpenNest.Tests/Fill/LegacyFillLinear.cs`, `OpenNest.Tests/Geometry/LegacyCollision.cs`, and `OpenNest.Tests/Fill/LegacyPartOverlap.cs` frozen for differential tests (never route them through production helpers), not production or before timings; measure the actual baseline production code.
Task 4b checks: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release --filter "FullyQualifiedName~AngleCandidateBuilderTests|FullyQualifiedName~AnglePredictorTests|FullyQualifiedName~FeatureExtractorTests"` (repeat in Debug for bitmap counters). `IrregularAngles_ReportsWarmNoModelPath` measures the public builder with a missing model and skips when a model is installed; never remove real model files to benchmark. `FeatureExtraction_ReportsFullAndScalarOnly` measures extraction separately. Task 4b checks: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release --filter "FullyQualifiedName~AngleCandidateBuilderTests|FullyQualifiedName~AnglePredictorTests|FullyQualifiedName~FeatureExtractorTests"` (repeat in Debug for bitmap counters). `IrregularAngles_ReportsWarmNoModelPath` measures the public builder with a missing model and skips when a model is installed; never remove real model files to benchmark. `FeatureExtraction_ReportsFullAndScalarOnly` measures extraction separately.