diff --git a/AGENTS.md b/AGENTS.md index c3cd07b..72d5dda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. - Nesting uses async progress/cancellation: `IProgress` 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`. - `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. diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index 5d99e87..27bf791 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using OpenNest.CNC; using OpenNest.Converters; @@ -238,14 +238,8 @@ namespace OpenNest PerfCounters.CountPartIntersects(); pts = new List(); - var entities1 = ConvertProgram - .ToGeometry(Program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)) - .ToList(); - var entities2 = ConvertProgram - .ToGeometry(part.Program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)) - .ToList(); + var entities1 = MaterialEntities(Program); + var entities2 = MaterialEntities(part.Program); if (entities1.Count == 0 || entities2.Count == 0) return false; @@ -256,8 +250,8 @@ namespace OpenNest if (perimeter1 == null || perimeter2 == null) return false; - var polygon1 = perimeter1.ToPolygonWithTolerance(IntersectsChordTolerance); - var polygon2 = perimeter2.ToPolygonWithTolerance(IntersectsChordTolerance); + var polygon1 = BuildOverlapPolygon(perimeter1); + var polygon2 = BuildOverlapPolygon(perimeter2); if (polygon1 == null || polygon2 == null) return false; @@ -270,6 +264,27 @@ namespace OpenNest return result.Overlaps; } + /// + /// Material (cut) entities of in the program's local frame: the + /// first stage of overlap preparation, shared by and + /// . + /// + internal static List MaterialEntities(CNC.Program program) + { + PerfCounters.CountOverlapPolygonPreparation(); + return ConvertProgram + .ToGeometry(program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + } + + /// + /// Local-frame overlap polygon of a material perimeter: the last stage of overlap + /// preparation, shared by and . + /// + internal static Polygon BuildOverlapPolygon(Shape perimeter) => + perimeter.ToPolygonWithTolerance(IntersectsChordTolerance); + public double Left { get { return BoundingBox.Left; } diff --git a/OpenNest.Core/PartOverlapChecker.cs b/OpenNest.Core/PartOverlapChecker.cs new file mode 100644 index 0000000..83ec434 --- /dev/null +++ b/OpenNest.Core/PartOverlapChecker.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// Overlap-only form of for one pass over a fixed set of parts. + /// Verdicts match Intersects(other, out _). Each distinct + /// (by reference) is prepared once, each part's world polygon is built once, and crossing + /// points are not computed. Tiled copies from 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. + /// + public sealed class PartOverlapChecker + { + private readonly Dictionary programs = new( + ReferenceEqualityComparer.Instance + ); + + private readonly Dictionary worldPolygons = new( + ReferenceEqualityComparer.Instance + ); + + /// + /// Same verdict as part1.Intersects(part2, out _). Preparation stages run in the + /// same order, with the same early exits, the first time each Program is needed. + /// + 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 material) => Material = material; + + public List Material { get; } + + public Shape GetPerimeter() + { + if (!perimeterReady) + { + perimeter = new ShapeProfile(Material).Perimeter; + perimeterReady = true; + } + + return perimeter; + } + + /// Only called after returned non-null. + public Polygon GetLocalPolygon() + { + if (!polygonReady) + { + localPolygon = Part.BuildOverlapPolygon(perimeter); + polygonReady = true; + } + + return localPolygon; + } + } + } +} diff --git a/OpenNest.Core/PerfCounters.cs b/OpenNest.Core/PerfCounters.cs index a1b449b..5728402 100644 --- a/OpenNest.Core/PerfCounters.cs +++ b/OpenNest.Core/PerfCounters.cs @@ -17,6 +17,7 @@ namespace OpenNest private static long partBoundsUpdates; private static long featureBitmaskCells; private static long crossingPointScans; + private static long overlapPolygonPreparations; public static long FindBestFits => Interlocked.Read(ref findBestFits); 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 FeatureBitmaskCells => Interlocked.Read(ref featureBitmaskCells); public static long CrossingPointScans => Interlocked.Read(ref crossingPointScans); + public static long OverlapPolygonPreparations => Interlocked.Read(ref overlapPolygonPreparations); [Conditional("DEBUG")] public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits); @@ -52,6 +54,10 @@ namespace OpenNest [Conditional("DEBUG")] public static void CountCrossingPointScan() => Interlocked.Increment(ref crossingPointScans); + [Conditional("DEBUG")] + public static void CountOverlapPolygonPreparation() => + Interlocked.Increment(ref overlapPolygonPreparations); + public static void Reset() { Interlocked.Exchange(ref findBestFits, 0); @@ -62,6 +68,7 @@ namespace OpenNest Interlocked.Exchange(ref partBoundsUpdates, 0); Interlocked.Exchange(ref featureBitmaskCells, 0); Interlocked.Exchange(ref crossingPointScans, 0); + Interlocked.Exchange(ref overlapPolygonPreparations, 0); } } } diff --git a/OpenNest.Engine/Fill/FillLinear.cs b/OpenNest.Engine/Fill/FillLinear.cs index 794777c..ec4a4c7 100644 --- a/OpenNest.Engine/Fill/FillLinear.cs +++ b/OpenNest.Engine/Fill/FillLinear.cs @@ -295,6 +295,8 @@ namespace OpenNest.Engine.Fill out int overlapB ) { + var checker = new PartOverlapChecker(); + for (var i = 0; i < parts.Count; i++) { var b1 = parts[i].BoundingBox; @@ -311,7 +313,7 @@ namespace OpenNest.Engine.Fill if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon) continue; - if (parts[i].Intersects(parts[j], out _)) + if (checker.Overlaps(parts[i], parts[j])) { overlapA = i; overlapB = j; diff --git a/OpenNest.Engine/Strategies/FillHelpers.cs b/OpenNest.Engine/Strategies/FillHelpers.cs index 71ea34a..45d97fc 100644 --- a/OpenNest.Engine/Strategies/FillHelpers.cs +++ b/OpenNest.Engine/Strategies/FillHelpers.cs @@ -170,6 +170,8 @@ namespace OpenNest.Engine.Strategies /// internal static bool HasOverlappingParts(List parts) { + var checker = new PartOverlapChecker(); + for (var i = 0; i < parts.Count; i++) { var b1 = parts[i].BoundingBox; @@ -186,7 +188,7 @@ namespace OpenNest.Engine.Strategies if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon) continue; - if (parts[i].Intersects(parts[j], out _)) + if (checker.Overlaps(parts[i], parts[j])) return true; } } diff --git a/OpenNest.Tests/Fill/LegacyPartOverlap.cs b/OpenNest.Tests/Fill/LegacyPartOverlap.cs new file mode 100644 index 0000000..ff79194 --- /dev/null +++ b/OpenNest.Tests/Fill/LegacyPartOverlap.cs @@ -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 pts) + { + pts = new List(); + + 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; + } + + /// World polygon exactly as the pre-change Part.Intersects built it. + 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 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 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; + } +} diff --git a/OpenNest.Tests/Fill/PartOverlapCheckerTests.cs b/OpenNest.Tests/Fill/PartOverlapCheckerTests.cs new file mode 100644 index 0000000..a224eae --- /dev/null +++ b/OpenNest.Tests/Fill/PartOverlapCheckerTests.cs @@ -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; + +/// +/// 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. +/// +[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 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(pattern.Parts); + raw.AddRange((List)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 { 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 { 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 { 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 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 { 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 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(Func 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 parts) + { + var hit = Invoke(parts, out var a, out var b); + return (hit, a, b); + } + + private static bool Invoke(List 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 parts, Action 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)field.GetValue(checker)!)[part]; + } + + /// Every other part replaced by a copy moved by (-shift, -shift). + private static List Shifted(List grid, double shift) + { + var result = new List(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 { 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()) + .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(); +} diff --git a/docs/performance/fill-verification.md b/docs/performance/fill-verification.md index a821d08..24def1e 100644 --- a/docs/performance/fill-verification.md +++ b/docs/performance/fill-verification.md @@ -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. -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: ```bash 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.