fix: add overlap detection safety net for pair tiling
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Part>(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.
|
||||
|
||||
@@ -287,6 +287,65 @@ namespace OpenNest.Engine.Fill
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback tiling using bounding-box spacing when geometry-aware tiling
|
||||
/// produces overlapping parts.
|
||||
/// </summary>
|
||||
private List<Part> TilePatternBbox(Pattern basePattern, NestDirection direction)
|
||||
{
|
||||
var copyDistance = GetDimension(basePattern.BoundingBox, direction) + PartSpacing;
|
||||
|
||||
if (copyDistance <= 0)
|
||||
return new List<Part>();
|
||||
|
||||
var dim = GetDimension(basePattern.BoundingBox, direction);
|
||||
var start = GetStart(basePattern.BoundingBox, direction);
|
||||
var limit = GetLimit(direction);
|
||||
|
||||
var result = new List<Part>();
|
||||
var count = 1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var nextPos = start + copyDistance * count;
|
||||
|
||||
if (nextPos + dim > limit + Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
var offset = MakeOffset(direction, copyDistance * count);
|
||||
|
||||
foreach (var part in basePattern.Parts)
|
||||
result.Add(part.CloneAtOffset(offset));
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool HasOverlappingParts(List<Part> parts)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a seed pattern containing a single part positioned at the work area origin.
|
||||
/// Returns an empty pattern if the part does not fit.
|
||||
@@ -325,10 +384,25 @@ namespace OpenNest.Engine.Fill
|
||||
var row = new List<Part>(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<Part>(pattern.Parts);
|
||||
row.AddRange(TilePatternBbox(pattern, direction));
|
||||
}
|
||||
|
||||
// If primary tiling didn't produce copies, just tile along perpendicular
|
||||
if (row.Count <= pattern.Parts.Count)
|
||||
{
|
||||
row.AddRange(TilePattern(pattern, perpAxis, boundaries));
|
||||
|
||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row))
|
||||
{
|
||||
row = new List<Part>(pattern.Parts);
|
||||
row.AddRange(TilePatternBbox(pattern, perpAxis));
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Part> 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}");
|
||||
|
||||
@@ -244,28 +244,29 @@ public class StripeFiller
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
|
||||
try
|
||||
var filler = new FillLinear(remnantBox, spacing);
|
||||
List<Part> 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(
|
||||
|
||||
Reference in New Issue
Block a user