From 80e8693da3e2ae6b2e53e5017e7c81364e1d4429 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Mar 2026 23:52:50 -0400 Subject: [PATCH] fix: add overlap detection safety net for pair tiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shape.OffsetOutward produces inward offsets for certain rotated polygons, causing geometry-aware copy distances to be too small and placing overlapping parts. Root cause is in the offset winding direction detection — this commit adds safety nets while that is investigated. - FillLinear.FillGrid: detect bbox overlaps after geometry-aware tiling, fall back to bbox-based spacing when overlaps found - FillExtents.RepeatColumns: detect overlaps after Compactor computes copy distance, fall back to columnWidth + spacing - PairFiller/StripeFiller remnant fills: use FillLinear directly instead of spawning full engine pipeline (avoids strategies with the bug) - Add PairOverlapDiagnosticTests reproducing the issue - MCP config: use shadow-copy wrapper for dev hot-reload Co-Authored-By: Claude Opus 4.6 (1M context) --- .mcp.json | 4 +- OpenNest.Engine/Fill/FillExtents.cs | 16 ++ OpenNest.Engine/Fill/FillLinear.cs | 74 ++++++ OpenNest.Engine/Fill/PairFiller.cs | 16 +- OpenNest.Engine/Fill/StripeFiller.cs | 35 +-- OpenNest.Tests/PairOverlapDiagnosticTests.cs | 238 +++++++++++++++++++ 6 files changed, 361 insertions(+), 22 deletions(-) create mode 100644 OpenNest.Tests/PairOverlapDiagnosticTests.cs diff --git a/.mcp.json b/.mcp.json index be4d04b..633400b 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,8 +1,8 @@ { "mcpServers": { "opennest": { - "command": "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe", - "args": [] + "command": "cmd", + "args": ["/c", "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd"] } } } diff --git a/OpenNest.Engine/Fill/FillExtents.cs b/OpenNest.Engine/Fill/FillExtents.cs index 14ea616..76380b4 100644 --- a/OpenNest.Engine/Fill/FillExtents.cs +++ b/OpenNest.Engine/Fill/FillExtents.cs @@ -3,6 +3,7 @@ using OpenNest.Math; using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading; namespace OpenNest.Engine.Fill @@ -349,6 +350,21 @@ namespace OpenNest.Engine.Fill if (copyDistance <= Tolerance.Epsilon) copyDistance = columnWidth + partSpacing; + // Safety: if the compacted test column overlaps the original column, + // fall back to bbox-based spacing. + var probe = new List(column); + probe.AddRange(testColumn.Where(IsWithinWorkArea)); + if (HasOverlappingParts(probe)) + { + Debug.WriteLine($"[FillExtents] Compacted column overlaps, falling back to bbox spacing"); + copyDistance = columnWidth + partSpacing; + + // Rebuild test column at safe distance. + testColumn.Clear(); + foreach (var part in column) + testColumn.Add(part.CloneAtOffset(new Vector(copyDistance, 0))); + } + Debug.WriteLine($"[FillExtents] Column copy distance: {copyDistance:F2} (bbox width: {columnWidth:F2}, spacing: {partSpacing:F2})"); // Build all columns. diff --git a/OpenNest.Engine/Fill/FillLinear.cs b/OpenNest.Engine/Fill/FillLinear.cs index 97b5dc3..31682db 100644 --- a/OpenNest.Engine/Fill/FillLinear.cs +++ b/OpenNest.Engine/Fill/FillLinear.cs @@ -287,6 +287,65 @@ namespace OpenNest.Engine.Fill return result; } + /// + /// Fallback tiling using bounding-box spacing when geometry-aware tiling + /// produces overlapping parts. + /// + private List TilePatternBbox(Pattern basePattern, NestDirection direction) + { + var copyDistance = GetDimension(basePattern.BoundingBox, direction) + PartSpacing; + + if (copyDistance <= 0) + return new List(); + + var dim = GetDimension(basePattern.BoundingBox, direction); + var start = GetStart(basePattern.BoundingBox, direction); + var limit = GetLimit(direction); + + var result = new List(); + 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 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) + return true; + } + } + + return false; + } + /// /// Creates a seed pattern containing a single part positioned at the work area origin. /// Returns an empty pattern if the part does not fit. @@ -325,10 +384,25 @@ namespace OpenNest.Engine.Fill var row = new List(pattern.Parts); row.AddRange(TilePattern(pattern, direction, boundaries)); + // Safety: if geometry-aware spacing produced overlapping parts, + // fall back to bbox-based spacing for this axis. + if (pattern.Parts.Count > 1 && HasOverlappingParts(row)) + { + row = new List(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, boundaries)); + + if (pattern.Parts.Count > 1 && HasOverlappingParts(row)) + { + row = new List(pattern.Parts); + row.AddRange(TilePatternBbox(pattern, perpAxis)); + } + return row; } diff --git a/OpenNest.Engine/Fill/PairFiller.cs b/OpenNest.Engine/Fill/PairFiller.cs index 32c71dd..791ad6c 100644 --- a/OpenNest.Engine/Fill/PairFiller.cs +++ b/OpenNest.Engine/Fill/PairFiller.cs @@ -321,9 +321,19 @@ namespace OpenNest.Engine.Fill return cachedResult; } - var remnantEngine = NestEngineRegistry.Create(plate); - var item = new NestItem { Drawing = drawing }; - var parts = remnantEngine.Fill(item, remnantBox, null, token); + var filler = new FillLinear(remnantBox, partSpacing); + List parts = null; + + foreach (var angle in new[] { 0.0, Angle.HalfPI }) + { + token.ThrowIfCancellationRequested(); + var result = FillHelpers.FillWithDirectionPreference( + dir => filler.Fill(drawing, angle, dir), + null, comparer, remnantBox); + + if (result != null && result.Count > (parts?.Count ?? 0)) + parts = result; + } Debug.WriteLine($"[PairFiller] Remnant: {parts?.Count ?? 0} parts in " + $"{remnantBox.Width:F2}x{remnantBox.Length:F2}"); diff --git a/OpenNest.Engine/Fill/StripeFiller.cs b/OpenNest.Engine/Fill/StripeFiller.cs index 9f322d2..f8eb126 100644 --- a/OpenNest.Engine/Fill/StripeFiller.cs +++ b/OpenNest.Engine/Fill/StripeFiller.cs @@ -244,28 +244,29 @@ public class StripeFiller return cachedResult; } - FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear"); - try + var filler = new FillLinear(remnantBox, spacing); + List best = null; + + foreach (var angle in new[] { 0.0, Angle.HalfPI }) { - var engine = CreateRemnantEngine(_context.Plate); - var item = new NestItem { Drawing = drawing }; - var parts = engine.Fill(item, remnantBox, _context.Progress, _context.Token); + _context.Token.ThrowIfCancellationRequested(); + var result = FillHelpers.FillWithDirectionPreference( + dir => filler.Fill(drawing, angle, dir), + null, _comparer, remnantBox); - Debug.WriteLine($"[StripeFiller] Remnant engine ({engine.Name}): {parts?.Count ?? 0} parts, " + - $"winner={engine.WinnerPhase}"); - - if (parts != null && parts.Count > 0) - { - FillResultCache.Store(drawing, remnantBox, spacing, parts); - return parts; - } - - return null; + if (result != null && result.Count > (best?.Count ?? 0)) + best = result; } - finally + + Debug.WriteLine($"[StripeFiller] Remnant linear: {best?.Count ?? 0} parts"); + + if (best != null && best.Count > 0) { - FillStrategyRegistry.SetEnabled(null); + FillResultCache.Store(drawing, remnantBox, spacing, best); + return best; } + + return null; } public static double FindAngleForTargetSpan( diff --git a/OpenNest.Tests/PairOverlapDiagnosticTests.cs b/OpenNest.Tests/PairOverlapDiagnosticTests.cs new file mode 100644 index 0000000..82fabf7 --- /dev/null +++ b/OpenNest.Tests/PairOverlapDiagnosticTests.cs @@ -0,0 +1,238 @@ +using OpenNest.CNC; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using OpenNest.Math; +using Xunit.Abstractions; + +namespace OpenNest.Tests; + +public class PairOverlapDiagnosticTests +{ + private readonly ITestOutputHelper _output; + + public PairOverlapDiagnosticTests(ITestOutputHelper output) => _output = output; + + /// + /// Creates a 5x3.31 rectangle with rounded corners on the top-right and bottom-right + /// (radius 0.5), similar to "4526 A14 PT13". + /// + private static Drawing MakeRoundedRect(double w = 5.0, double h = 3.31, double r = 0.5) + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + // Bottom edge + pgm.Codes.Add(new LinearMove(new Vector(w - r, 0))); + // Bottom-right rounded corner + pgm.Codes.Add(new ArcMove(new Vector(w, r), new Vector(w - r, r), RotationType.CW)); + // Right edge + pgm.Codes.Add(new LinearMove(new Vector(w, h - r))); + // Top-right rounded corner + pgm.Codes.Add(new ArcMove(new Vector(w - r, h), new Vector(w - r, h - r), RotationType.CW)); + // Top edge + pgm.Codes.Add(new LinearMove(new Vector(0, h))); + // Left edge back to start + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + return new Drawing("rounded-rect", pgm); + } + + private static Drawing MakeSimpleRect(double w = 5.0, double h = 3.31) + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(w, 0))); + pgm.Codes.Add(new LinearMove(new Vector(w, h))); + pgm.Codes.Add(new LinearMove(new Vector(0, h))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + return new Drawing("rect", pgm); + } + + [Theory] + [InlineData(0)] // 0 degrees + [InlineData(90)] // 90 degrees + [InlineData(180)] // 180 degrees + [InlineData(270)] // 270 degrees + public void PartBoundary_HasEdgesAtAllRotations_RoundedRect(double angleDeg) + { + var drawing = MakeRoundedRect(); + var part = new Part(drawing); + if (angleDeg != 0) + part.Rotate(Angle.ToRadians(angleDeg)); + + var boundary = new PartBoundary(part, 0.125); + + var left = boundary.GetEdges(PushDirection.Left); + var right = boundary.GetEdges(PushDirection.Right); + var up = boundary.GetEdges(PushDirection.Up); + var down = boundary.GetEdges(PushDirection.Down); + + _output.WriteLine($"Rotation: {angleDeg}°"); + _output.WriteLine($" Left edges: {left.Length}"); + _output.WriteLine($" Right edges: {right.Length}"); + _output.WriteLine($" Up edges: {up.Length}"); + _output.WriteLine($" Down edges: {down.Length}"); + + Assert.True(left.Length > 0, $"No left edges at {angleDeg}°"); + Assert.True(right.Length > 0, $"No right edges at {angleDeg}°"); + Assert.True(up.Length > 0, $"No up edges at {angleDeg}°"); + Assert.True(down.Length > 0, $"No down edges at {angleDeg}°"); + } + + [Theory] + [InlineData(0)] + [InlineData(90)] + [InlineData(180)] + [InlineData(270)] + public void PartBoundary_HasEdgesAtAllRotations_SimpleRect(double angleDeg) + { + var drawing = MakeSimpleRect(); + var part = new Part(drawing); + if (angleDeg != 0) + part.Rotate(Angle.ToRadians(angleDeg)); + + var boundary = new PartBoundary(part, 0.125); + + var left = boundary.GetEdges(PushDirection.Left); + var right = boundary.GetEdges(PushDirection.Right); + var up = boundary.GetEdges(PushDirection.Up); + var down = boundary.GetEdges(PushDirection.Down); + + _output.WriteLine($"Rotation: {angleDeg}°"); + _output.WriteLine($" Left edges: {left.Length}"); + _output.WriteLine($" Right edges: {right.Length}"); + _output.WriteLine($" Up edges: {up.Length}"); + _output.WriteLine($" Down edges: {down.Length}"); + + Assert.True(left.Length > 0, $"No left edges at {angleDeg}°"); + Assert.True(right.Length > 0, $"No right edges at {angleDeg}°"); + Assert.True(up.Length > 0, $"No up edges at {angleDeg}°"); + Assert.True(down.Length > 0, $"No down edges at {angleDeg}°"); + } + + [Theory] + [InlineData(false)] // simple rect + [InlineData(true)] // rounded rect + public void FillExtents_NoPairOverlap_At90Degrees(bool rounded) + { + var drawing = rounded ? MakeRoundedRect() : MakeSimpleRect(); + var workArea = new Box(0, 0, 20, 20); + var partSpacing = 0.25; + + var filler = new FillExtents(workArea, partSpacing); + var parts = filler.Fill(drawing, Angle.ToRadians(90)); + + _output.WriteLine($"Shape: {(rounded ? "rounded rect" : "simple rect")}"); + _output.WriteLine($"Parts: {parts.Count}"); + + for (var i = 0; i < parts.Count; i++) + { + var p = parts[i]; + _output.WriteLine($" [{i}] rot={Angle.ToDegrees(p.Rotation):F1}° " + + $"bbox=({p.BoundingBox.Left:F2},{p.BoundingBox.Bottom:F2})-({p.BoundingBox.Right:F2},{p.BoundingBox.Top:F2})"); + } + + // Check for overlapping bounding boxes + 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 > 0.01 && overlapY > 0.01) + _output.WriteLine($" OVERLAP: [{i}] and [{j}] overlap by ({overlapX:F3}, {overlapY:F3})"); + + Assert.False(overlapX > 0.01 && overlapY > 0.01, + $"Parts [{i}] and [{j}] have overlapping bounding boxes " + + $"({overlapX:F3} x {overlapY:F3})"); + } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FillLinear_PairPattern_NoPairOverlap_At90Degrees(bool rounded) + { + var drawing = rounded ? MakeRoundedRect() : MakeSimpleRect(); + var workArea = new Box(0, 0, 20, 20); + var partSpacing = 0.25; + + // Build a pair at 90°/270° + var part1 = Part.CreateAtOrigin(drawing, Angle.ToRadians(90)); + var part2 = Part.CreateAtOrigin(drawing, Angle.ToRadians(270)); + + // Slide part2 right of part1 + var offset = part1.BoundingBox.Width + part2.BoundingBox.Width + partSpacing; + part2.Offset(offset, 0); + part2.UpdateBounds(); + + // Slide part2 left toward part1 using geometry + var b1 = new PartBoundary(part1, partSpacing / 2); + var b2 = new PartBoundary(part2, partSpacing / 2); + + _output.WriteLine($"Part1 (90°) boundary edges: L={b1.GetEdges(PushDirection.Left).Length} R={b1.GetEdges(PushDirection.Right).Length}"); + _output.WriteLine($"Part2 (270°) boundary edges: L={b2.GetEdges(PushDirection.Left).Length} R={b2.GetEdges(PushDirection.Right).Length}"); + + var movingLines = b2.GetLines(part2.Location, PushDirection.Left); + var stationaryLines = b1.GetLines(part1.Location, PushDirection.Right); + + _output.WriteLine($"Part1 loc: ({part1.Location.X:F4},{part1.Location.Y:F4})"); + _output.WriteLine($"Part2 loc: ({part2.Location.X:F4},{part2.Location.Y:F4})"); + + _output.WriteLine($"Moving lines (part2 left): {movingLines.Count}"); + foreach (var l in movingLines) + _output.WriteLine($" ({l.pt1.X:F4},{l.pt1.Y:F4})->({l.pt2.X:F4},{l.pt2.Y:F4})"); + + _output.WriteLine($"Stationary lines (part1 right): {stationaryLines.Count}"); + foreach (var l in stationaryLines) + _output.WriteLine($" ({l.pt1.X:F4},{l.pt1.Y:F4})->({l.pt2.X:F4},{l.pt2.Y:F4})"); + + var slideDist = SpatialQuery.DirectionalDistance(movingLines, stationaryLines, PushDirection.Left); + _output.WriteLine($"Slide distance: {slideDist:F4}"); + + if (slideDist < double.MaxValue && slideDist > 0) + { + part2.Offset(-slideDist, 0); + part2.UpdateBounds(); + } + + _output.WriteLine($"Part1 bbox: ({part1.BoundingBox.Left:F2},{part1.BoundingBox.Bottom:F2})-({part1.BoundingBox.Right:F2},{part1.BoundingBox.Top:F2})"); + _output.WriteLine($"Part2 bbox: ({part2.BoundingBox.Left:F2},{part2.BoundingBox.Bottom:F2})-({part2.BoundingBox.Right:F2},{part2.BoundingBox.Top:F2})"); + + // Now tile this pair pattern + var pattern = new Pattern(); + pattern.Parts.Add(part1); + pattern.Parts.Add(part2); + pattern.UpdateBounds(); + + _output.WriteLine($"Pattern bbox width: {pattern.BoundingBox.Width:F2}"); + + var engine = new FillLinear(workArea, partSpacing); + var parts = engine.Fill(pattern, NestDirection.Horizontal); + + _output.WriteLine($"Total parts: {parts.Count}"); + for (var i = 0; i < parts.Count; i++) + { + var p = parts[i]; + _output.WriteLine($" [{i}] rot={Angle.ToDegrees(p.Rotation):F1}° " + + $"bbox=({p.BoundingBox.Left:F2},{p.BoundingBox.Bottom:F2})-({p.BoundingBox.Right:F2},{p.BoundingBox.Top:F2})"); + } + + // Check for overlaps + for (var i = 0; i < parts.Count; i++) + { + var bi = parts[i].BoundingBox; + for (var j = i + 1; j < parts.Count; j++) + { + var bj = parts[j].BoundingBox; + var ox = System.Math.Min(bi.Right, bj.Right) - System.Math.Max(bi.Left, bj.Left); + var oy = System.Math.Min(bi.Top, bj.Top) - System.Math.Max(bi.Bottom, bj.Bottom); + + Assert.False(ox > 0.01 && oy > 0.01, + $"Parts [{i}] and [{j}] overlap ({ox:F3} x {oy:F3})"); + } + } + } +}