Files
OpenNest-Engines/OpenNest.Engine.Qwen38FlashNext/Engine/SheetPacker.cs
T
0cdef009f8 fix(qwen38flashnext): make layouts deterministic and use host services
The gap-fill pass stopped on a 120 ms stopwatch and QWEN_* environment
variables switched strategies, so the same job could nest differently
with machine load or environment. Gap fill now stops after eight failed
insertion sweeps, and the switches are internal properties with the
old defaults. Part reading, work area, rotation angles, scoring and
result assembly now use the host APIs; its collision gate is unchanged.

Synthetic benchmark (5 jobs, salvage 0.5): all valid, cost 7660.01 ->
7572.05, time 839 -> 553 ms. Not yet calibrated on production-size jobs.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:29:28 -04:00

929 lines
35 KiB
C#

using System;
using System.Collections.Generic;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
using Math = System.Math;
/// <summary>A committed placement: model, orientation, and origin position on the sheet.</summary>
internal readonly struct PlacedPart
{
public PlacedPart(PartModel model, OrientationModel orientation, double x, double y)
{
Model = model;
Orientation = orientation;
X = x;
Y = y;
}
public PartModel Model { get; }
public OrientationModel Orientation { get; }
public double X { get; }
public double Y { get; }
}
internal readonly struct PlacementResult
{
public PlacementResult(PlacedPart part)
{
Part = part;
}
public PlacedPart Part { get; }
}
/// <summary>
/// One sheet packed by this engine's own algorithm: bottom-left greedy insertion of
/// convex NFP corner candidates.
/// <para>
/// For each candidate orientation the packer builds a convex No-Fit-Polygon per
/// already-placed part as placedHull (+) disk(spacing) (+) reflect(candidateHull) -
/// a superset of the true NFP because hulls ignore concavities and cutouts, so an
/// anchor outside every NFP plus inside the anchor work-box is always legal ("strict"
/// certification). An anchor inside an NFP is still accepted when the exact material
/// gate says the parts clear: that gate inflates the placed part's material by the
/// spacing (holes shrunk, closed holes treated solid) and tests it against the
/// candidate's raw material with holes subtracted - the same inflation rule the
/// benchmark validator uses, so interlocking concave parts are recovered without ever
/// accepting an overlap or a spacing violation.
/// </para>
/// <para>
/// Candidate anchors are the corner points of the feasible region: the four anchor
/// work-box corners, every NFP vertex, and every NFP-edge/box-line crossing (slides).
/// Candidates are tried in ascending bottom-left order and the first legal one wins.
/// </para>
/// </summary>
internal sealed class SheetPacker
{
private const int MaxHullVertices = 40;
private readonly double _workLeft;
private readonly double _workBottom;
private readonly double _workRight;
private readonly double _workTop;
// Per-placed-part caches (indexed by placement order).
private readonly List<Bounds> _inflatedBounds = new();
private readonly List<(Polygon Perimeter, List<Polygon> Holes)> _placedGate = new();
/// <summary>
/// Fast-path outlines of the placed parts' gate material in world coordinates,
/// parallel to <see cref="_placedGate"/> (O(1) translation of the shared per-
/// orientation template). A <see cref="FastPoly.Clears"/> hit certifies the two
/// outer shells - hence both materials - are clear and skips the exact
/// <see cref="Collision"/> gate, which at fine flattening triangulates thousands
/// of edges per call. Null when the outline has no usable ring.
/// </summary>
private readonly List<FastPoly?> _placedGateFast = new();
// NFP caches: (placedIndex, orientationId) -> forbidden-anchor contour.
private readonly Dictionary<(int, int), ConvexContour?> _nfpCache = new();
private readonly Dictionary<OrientationModel, int> _orientationIds = new();
/// <summary>
/// Per-orientation cache of NFP/NFP valley anchors (anchors touching two placed
/// parts at once). A committed part's NFP never changes and <see cref="Placed"/>
/// only grows, so each (i, j) pair is intersected exactly once per orientation
/// instead of once per candidate enumeration - the re-sweep was the dominant cost
/// on crowded sheets (O(placed^2 * edges^2) per insert attempt).
/// </summary>
private sealed class ValleyCache
{
public int BuiltThrough;
public readonly List<(double X, double Y)> Valleys = new();
}
private readonly Dictionary<OrientationModel, ValleyCache> _valleyCaches = new();
private readonly Dictionary<OrientationModel, ConvexContour> _reflectedHulls = new();
private readonly Dictionary<int, ConvexContour> _placedHullDisk = new();
private ConvexContour? _disk;
// Uniform spatial grid over placed parts' inflated bounds: IsLegal only tests the
// parts whose cells touch the candidate's cells, so legality stays near-constant
// as a sheet fills instead of scanning every placed part.
private readonly double _cellSize;
private readonly int _gridCols;
private readonly int _gridRows;
private readonly List<int>[] _grid;
private SheetPacker(NestPlateStock stock, PartPreparation prep, int stockIndex)
{
Stock = stock;
StockIndex = stockIndex;
Preparation = prep;
Spacing = stock.PartSpacing;
var work = stock.WorkArea;
_workLeft = work.Left;
_workBottom = work.Bottom;
_workRight = work.Right;
_workTop = work.Top;
WorkWidth = _workRight - _workLeft;
WorkHeight = _workTop - _workBottom;
// Cells roughly the size of a mid-range part: a candidate usually touches 2-6.
_cellSize = System.Math.Max(1.0, System.Math.Min(WorkWidth, WorkHeight) / 12.0);
_gridCols = System.Math.Max(1, (int)System.Math.Ceiling(WorkWidth / _cellSize));
_gridRows = System.Math.Max(1, (int)System.Math.Ceiling(WorkHeight / _cellSize));
_grid = new List<int>[_gridCols * _gridRows];
for (var i = 0; i < _grid.Length; i++)
_grid[i] = new List<int>();
}
public static SheetPacker Create(NestPlateStock stock, PartPreparation prep, int stockIndex) =>
new(stock, prep, stockIndex);
public NestPlateStock Stock { get; }
public int StockIndex { get; }
public PartPreparation Preparation { get; }
public double Spacing { get; }
public double WorkWidth { get; }
public double WorkHeight { get; }
public List<PlacedPart> Placed { get; } = new();
public bool IsFull => Placed.Count >= MaxPartsPerSheet;
/// <summary>
/// Safety cap on parts per sheet: real sheets never exceed this, and it bounds
/// per-insert NFP work and the validator's area budget on pathological jobs.
/// </summary>
public const int MaxPartsPerSheet = 500;
/// <summary>True when the part's bounds can never fit this sheet in any orientation.</summary>
public bool CanEverFit(PartModel model)
{
foreach (var angle in PartPreparation.CandidateAngles(model))
{
var orientation = Preparation.Oriented(model, angle, 0);
if (Stock.Fits(orientation.Width, orientation.Height))
return true;
}
return false;
}
internal long DiagInsertAttempts;
internal long DiagCandidateChecks;
internal long DiagGateCalls;
internal long DiagConvexRejections;
internal long DiagFastClears;
internal long DiagFastNull;
internal long DiagFastOverlapWithHoles;
internal long DiagFastOverlaps;
internal long DiagFastUnknowns;
public string DiagStats() =>
$"inserts={DiagInsertAttempts} checks={DiagCandidateChecks} gates={DiagGateCalls} " +
$"convexRej={DiagConvexRejections} fastClear={DiagFastClears} fastOver={DiagFastOverlaps} fastUnk={DiagFastUnknowns} fastNull={DiagFastNull} fastOverHoles={DiagFastOverlapWithHoles} triNull={DiagTriNull} triNullOut={DiagTriNullOut} triFallback={DiagTriFallback}";
/// <summary>
/// Greedily insert an instance: best (bottom-left) legal corner over all candidate
/// orientations. Returns false (and changes nothing) when no legal position exists.
/// </summary>
public bool TryInsert(PartModel model, out PlacementResult result)
{
result = default;
var bestScore = double.MaxValue;
PlacedPart? best = null;
DiagInsertAttempts++;
foreach (var angle in PartPreparation.CandidateAngles(model))
{
var orientation = Preparation.Oriented(model, angle, Spacing);
if (orientation.Width > WorkWidth + 1e-9 || orientation.Height > WorkHeight + 1e-9)
continue;
foreach (var (x, y) in OrderedCandidates(orientation))
{
var score = Score(orientation, x, y);
if (score >= bestScore)
continue; // no later candidate (same sort) can beat it
if (!IsLegal(orientation, x, y))
continue;
bestScore = score;
best = new PlacedPart(model, orientation, x, y);
break; // first legal in ascending-score order is this orientation's best
}
}
if (best == null)
return false;
Commit(best.Value);
result = new PlacementResult(best.Value);
return true;
}
private double Score(OrientationModel orientation, double x, double y) =>
x + orientation.MinX + (y + orientation.MinY) * 1.0001;
/// <summary>
/// Corner candidates in deterministic ascending bottom-left order: anchor work-box
/// corners, NFP vertices, and NFP-edge/box-line crossings.
/// </summary>
private List<(double x, double y)> OrderedCandidates(OrientationModel orientation)
{
var boxLeft = _workLeft - orientation.MinX;
var boxRight = _workRight - orientation.MaxX;
var boxBottom = _workBottom - orientation.MinY;
var boxTop = _workTop - orientation.MaxY;
var seen = new HashSet<(long, long)>();
var candidates = new List<(double, double)>(128);
// Math.Clamp throws when min > max, and a part that fits the work area to
// within floating-point noise can invert the anchor box by ~1e-14. Order the
// bounds so a degenerate box collapses to its single legal point.
var anchorMinX = Math.Min(boxLeft, boxRight);
var anchorMaxX = Math.Max(boxLeft, boxRight);
var anchorMinY = Math.Min(boxBottom, boxTop);
var anchorMaxY = Math.Max(boxBottom, boxTop);
void Add(double x, double y)
{
if (x < anchorMinX - 1e-9 || x > anchorMaxX + 1e-9 || y < anchorMinY - 1e-9 || y > anchorMaxY + 1e-9)
return;
x = Math.Clamp(x, anchorMinX, anchorMaxX);
y = Math.Clamp(y, anchorMinY, anchorMaxY);
if (!seen.Add(((long)Math.Round(x * 1e6), (long)Math.Round(y * 1e6))))
return;
candidates.Add((x, y));
}
Add(boxLeft, boxBottom);
Add(boxRight, boxBottom);
Add(boxLeft, boxTop);
Add(boxRight, boxTop);
for (var i = 0; i < Placed.Count; i++)
{
var nfp = NfpFor(i, orientation);
if (nfp == null)
continue;
var n = nfp.Count;
for (var v = 0; v < n; v++)
Add(nfp.X(v), nfp.Y(v));
// Slides: NFP edges crossing the anchor box border lines.
for (var v = 0; v < n; v++)
{
var ax = nfp.X(v);
var ay = nfp.Y(v);
var bx = nfp.X((v + 1) % n);
var by = nfp.Y((v + 1) % n);
CrossLine(ax, ay, bx, by, boxLeft, true, Add);
CrossLine(ax, ay, bx, by, boxRight, true, Add);
CrossLine(ax, ay, bx, by, boxBottom, false, Add);
CrossLine(ax, ay, bx, by, boxTop, false, Add);
}
}
// Valleys between two neighbors: NFP/NFP edge intersections are the anchors
// where the candidate touches two placed parts at once - the classic
// bottom-left stable corners the single-NFP candidates cannot produce.
foreach (var (vx, vy) in ValleysFor(orientation))
Add(vx, vy);
candidates.Sort(
(p, q) =>
{
var byY = p.Item2.CompareTo(q.Item2);
return byY != 0 ? byY : p.Item1.CompareTo(q.Item1);
}
);
return candidates;
}
/// <summary>
/// Cached NFP/NFP valley anchors for one orientation, extended in place with the
/// pairs involving placements committed since the last call. Each (i, j) pair is
/// intersected once per orientation for the packer's lifetime.
/// </summary>
private List<(double X, double Y)> ValleysFor(OrientationModel orientation)
{
if (!_valleyCaches.TryGetValue(orientation, out var cache))
{
cache = new ValleyCache();
_valleyCaches[orientation] = cache;
}
var count = Placed.Count;
for (var j = cache.BuiltThrough; j < count; j++)
{
var nfpB = NfpFor(j, orientation);
if (nfpB == null)
continue;
for (var i = 0; i < j; i++)
{
var nfpA = NfpFor(i, orientation);
if (nfpA == null || !nfpA.Bounds.Intersects(nfpB.Bounds))
continue;
var na = nfpA.Count;
var nb = nfpB.Count;
for (var va = 0; va < na; va++)
{
var a0x = nfpA.X(va);
var a0y = nfpA.Y(va);
var a1x = nfpA.X((va + 1) % na);
var a1y = nfpA.Y((va + 1) % na);
for (var vb = 0; vb < nb; vb++)
{
var b0x = nfpB.X(vb);
var b0y = nfpB.Y(vb);
var b1x = nfpB.X((vb + 1) % nb);
var b1y = nfpB.Y((vb + 1) % nb);
if (
Math.Max(a0x, a1x) < Math.Min(b0x, b1x)
|| Math.Max(b0x, b1x) < Math.Min(a0x, a1x)
|| Math.Max(a0y, a1y) < Math.Min(b0y, b1y)
|| Math.Max(b0y, b1y) < Math.Min(a0y, a1y)
)
continue;
var r = SegmentIntersect(
a0x, a0y, a1x, a1y,
b0x, b0y, b1x, b1y
);
if (r.HasValue)
cache.Valleys.Add((r.Value.X, r.Value.Y));
}
}
}
}
cache.BuiltThrough = count;
return cache.Valleys;
}
/// <summary>Proper or endpoint intersection of two segments, if any.</summary>
private static Vector? SegmentIntersect(
double ax,
double ay,
double bx,
double by,
double cx,
double cy,
double dx,
double dy
)
{
var rx = bx - ax;
var ry = by - ay;
var sx = dx - cx;
var sy = dy - cy;
var denom = rx * sy - ry * sx;
if (Math.Abs(denom) < 1e-12)
return null; // parallel
var t = ((cx - ax) * sy - (cy - ay) * sx) / denom;
var u = ((cx - ax) * ry - (cy - ay) * rx) / denom;
if (t < -1e-9 || t > 1 + 1e-9 || u < -1e-9 || u > 1 + 1e-9)
return null;
return new Vector(ax + t * rx, ay + t * ry);
}
private static void CrossLine(
double ax,
double ay,
double bx,
double by,
double at,
bool vertical,
Action<double, double> add
)
{
var (ua, ub) = vertical ? (ax, bx) : (ay, by);
if (ua == ub)
return;
var t = (at - ua) / (ub - ua);
if (t < 0 || t > 1)
return;
var along = vertical ? ay + (by - ay) * t : ax + (bx - ax) * t;
if (vertical)
add(at, along);
else
add(along, at);
}
private void Commit(PlacedPart part)
{
Placed.Add(part);
var pad = Spacing;
_inflatedBounds.Add(
new Bounds(
part.X + part.Orientation.MinX - pad,
part.Y + part.Orientation.MinY - pad,
part.X + part.Orientation.MaxX + pad,
part.Y + part.Orientation.MaxY + pad
)
);
// Gate geometry: material inflated by spacing (holes shrunk) when positive,
// raw material at zero spacing; already in world coordinates.
var gatePerimeter = part.Orientation.InflatedPerimeter ?? part.Orientation.Perimeter;
var gateHoles = part.Orientation.InflatedPerimeter != null
? part.Orientation.InflatedHoles
: part.Orientation.Holes;
var worldPerimeter = (Polygon)gatePerimeter.Clone();
worldPerimeter.Offset(part.X, part.Y);
worldPerimeter.UpdateBounds();
var worldHoles = new List<Polygon>(gateHoles.Count);
foreach (var hole in gateHoles)
{
var h = (Polygon)hole.Clone();
h.Offset(part.X, part.Y);
h.UpdateBounds();
worldHoles.Add(h);
}
_placedGate.Add((worldPerimeter, worldHoles));
_placedGateFast.Add(part.Orientation.GateFast?.Translated(part.X, part.Y));
GridAdd(Placed.Count - 1, _inflatedBounds[^1]);
}
// ---- uniform spatial grid (cell -> placed indices) --------------------------
private void GridAdd(int placedIndex, in Bounds bounds)
{
var c0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var c1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var r0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
var r1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
_grid[r * _gridCols + c].Add(placedIndex);
}
private readonly HashSet<int> _nearScratch = new();
private HashSet<int> Near(in Bounds bounds)
{
_nearScratch.Clear();
var c0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var c1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var r0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
var r1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
foreach (var index in _grid[r * _gridCols + c])
_nearScratch.Add(index);
return _nearScratch;
}
/// <summary>
/// Legality of one anchor. Outside every overlapping NFP is strict certification;
/// inside one still passes when the exact material gate clears (interlocking
/// concaves and cutouts that the convex NFP cannot represent).
/// </summary>
private bool IsLegal(OrientationModel orientation, double x, double y)
{
if (
x + orientation.MinX < _workLeft - 1e-9
|| x + orientation.MaxX > _workRight + 1e-9
|| y + orientation.MinY < _workBottom - 1e-9
|| y + orientation.MaxY > _workTop + 1e-9
)
return false;
var pad = Spacing;
var candidate = new Bounds(
x + orientation.MinX - pad,
y + orientation.MinY - pad,
x + orientation.MaxX + pad,
y + orientation.MaxY + pad
);
// World-space candidate material, built at most once per anchor and only when
// a convex-NFP hit actually needs the exact gate; freed with the anchor.
(Polygon Perimeter, List<Polygon> Holes)? gate = null;
foreach (var i in Near(candidate))
{
var bounds = _inflatedBounds[i];
if (
candidate.MinX >= bounds.MaxX
|| candidate.MaxX <= bounds.MinX
|| candidate.MinY >= bounds.MaxY
|| candidate.MaxY <= bounds.MinY
)
continue;
// Fast rejection: outside the hull-based NFP the placed and candidate
// HULLS are at least spacing apart, and hulls contain materials, so the
// materials clear - a valid certification for any shape, holed or
// concave. Inside the NFP decides nothing by itself (the hull sum
// over-approximates for concaves and holes), but when both materials are
// convex solids with uncapped hulls the sum is exact (modulo the
// circumscribed disk's chord error, which only ever rejects a hair too
// much), so interior means overlap. Everything else pays the exact
// material gate.
DiagCandidateChecks++;
var nfp = NfpFor(i, orientation);
if (nfp == null)
{
if (!TryPairVerdict(orientation, x, y, i, out var nullNfpOverlap))
{
DiagGateCalls++;
gate ??= BuildCandidateGate(orientation, x, y);
nullNfpOverlap = MaterialOverlap(gate.Value, orientation, x, y, i);
}
if (nullNfpOverlap)
return false;
continue;
}
if (!nfp.ContainsPoint(x, y))
continue; // outside the conservative forbidden sum: certified clear
if (
orientation.IsConvexSolid
&& Placed[i].Orientation.IsConvexSolid
&& orientation.Hull.Count <= MaxHullVertices
&& Placed[i].Orientation.Hull.Count <= MaxHullVertices
)
{
DiagConvexRejections++;
return false; // exact convex-convex NFP interior: overlap
}
// Cheap world-bbox test against the placed gate material before paying
// for candidate gate construction or the clipper.
if (
!_placedGate[i]
.Perimeter.BoundingBox
.Intersects(orientation.Perimeter.BoundingBox.Translate(x, y))
)
continue;
// Fast shell relation against the placed gate outline: a certified clear
// skips the exact gate entirely (no Polygon clones, no triangulation), a
// certified overlap rejects without it. Hole-bearing pairs and touches fall
// through to the exact gate.
if (TryPairVerdict(orientation, x, y, i, out var fastOverlap))
{
if (fastOverlap)
return false;
continue;
}
DiagGateCalls++;
gate ??= BuildCandidateGate(orientation, x, y);
if (MaterialOverlap(gate.Value, orientation, x, y, i))
return false;
}
return true;
}
/// <summary>
/// Fast outer-shell relation for one (candidate, placed) pair against the placed
/// part's spacing-inflated gate outline, deciding whether the exact material gate
/// must run. A CERTIFIED verdict skips it: disjoint shells mean no material overlap
/// (holes only remove material), and a shell crossing/containment between two
/// hole-free polygons IS a positive-area material overlap. Hole-bearing pairs whose
/// shells overlap and near-degenerate touches fall through to the exact gate.
/// </summary>
private bool TryPairVerdict(
OrientationModel orientation,
double x,
double y,
int placedIndex,
out bool overlap
)
{
overlap = false;
var placedFast = _placedGateFast[placedIndex];
var candidateFast = orientation.PerimeterFast;
if (placedFast == null || candidateFast == null)
{
DiagFastNull++;
return false;
}
var relation = FastPoly.Relate(candidateFast.Translated(x, y), placedFast);
if (relation == FastPoly.FastRelation.Overlap)
DiagFastOverlapWithHoles++;
switch (relation)
{
case FastPoly.FastRelation.Clear:
DiagFastClears++;
return true; // certified clear (spacing included in the placed gate)
case FastPoly.FastRelation.Overlap
when orientation.Holes.Count == 0 && _placedGate[placedIndex].Holes.Count == 0:
DiagFastOverlaps++;
overlap = true; // certified overlap: shells share area, nothing to subtract
return true;
default:
DiagFastUnknowns++;
return false; // exact gate must decide
}
}
internal static bool VerifyFastClear { get; set; }
internal long DiagFastClearMismatch;
internal long DiagTriMismatch;
internal long DiagTriNull;
internal long DiagTriNullOut;
internal long DiagTriFallback;
/// <summary>
/// Exact clearance gate against one placed part: placed gate material (inflated by
/// spacing when positive) versus the candidate's raw material with holes subtracted.
/// </summary>
private bool MaterialOverlap(
(Polygon Perimeter, List<Polygon> Holes) gate,
OrientationModel orientation,
double x,
double y,
int placedIndex
)
{
var placed = _placedGate[placedIndex];
if (!placed.Perimeter.BoundingBox.Intersects(orientation.Perimeter.BoundingBox.Translate(x, y)))
return false;
if (!gate.Perimeter.BoundingBox.Intersects(placed.Perimeter.BoundingBox))
return false;
var placedPart = Placed[placedIndex];
// Allocation-free path: both sides carry cached triangulations in their local
// frames, so the test clips triangles with the anchors as plain translations.
var candTris = orientation.MaterialTris;
var placedTris = placedPart.Orientation.GateTris;
if (candTris == null || placedTris == null)
DiagTriNull++;
if (candTris != null && placedTris != null)
{
var cached = candTris.HasOverlap(
placedTris, x, y, placedPart.X, placedPart.Y
);
if (cached.HasValue)
{
if (VerifyFastClear)
{
var truth = Collision.HasOverlap(
gate.Perimeter, placed.Perimeter, gate.Holes, placed.Holes
);
if (truth != cached.Value)
{
DiagTriMismatch++;
System.IO.File.AppendAllText(
"/tmp/triset_mismatch.log",
$"cached={cached.Value} truth={truth} candAng={orientation.Angle:F4} at ({x:F8},{y:F8}) " +
$"placedAng={placedPart.Orientation.Angle:F4} at ({placedPart.X:F8},{placedPart.Y:F8}) " +
$"candTris={candTris} candVerts={orientation.Perimeter.Vertices.Count} holes={orientation.Holes.Count} " +
$"placedVerts={placedPart.Orientation.Perimeter.Vertices.Count} placedHoles={placedPart.Orientation.Holes.Count}\n"
);
}
}
return cached.Value;
}
DiagTriNullOut++;
// Scratch overflow: fall through to the Polygon gate.
}
DiagTriFallback++;
return Preparation.MaterialOverlapMemo(
placedPart.Orientation,
placedPart.X,
placedPart.Y,
orientation,
x,
y,
() => Collision.HasOverlap(
gate.Perimeter,
placed.Perimeter,
gate.Holes,
placed.Holes
)
);
}
private (Polygon, List<Polygon>) BuildCandidateGate(OrientationModel orientation, double x, double y)
{
var perimeter = (Polygon)orientation.Perimeter.Clone();
perimeter.Offset(x, y);
perimeter.UpdateBounds();
var holes = new List<Polygon>(orientation.Holes.Count);
foreach (var hole in orientation.Holes)
{
var h = (Polygon)hole.Clone();
h.Offset(x, y);
h.UpdateBounds();
holes.Add(h);
}
return (perimeter, holes);
}
private int OrientationId(OrientationModel orientation)
{
if (!_orientationIds.TryGetValue(orientation, out var id))
{
id = _orientationIds.Count;
_orientationIds[orientation] = id;
}
return id;
}
/// <summary>
/// Convex NFP of forbidden anchors: placedHull (+) disk(spacing) (+) reflect(candidateHull).
/// </summary>
private ConvexContour? NfpFor(int placedIndex, OrientationModel orientation)
{
var key = (placedIndex, OrientationId(orientation));
if (_nfpCache.TryGetValue(key, out var cached))
return cached;
ConvexContour? result;
try
{
if (!_placedHullDisk.TryGetValue(placedIndex, out var placedDisk))
{
var placed = Placed[placedIndex];
var hull = placed.Orientation.Hull;
var capped = CapHull(hull, placed.X, placed.Y);
var placedHull = ConvexContour.FromVertices(capped);
placedDisk = Spacing > Tolerance.Epsilon
? NfpGeometry.Minkowski(placedHull, Disk())
: placedHull;
_placedHullDisk[placedIndex] = placedDisk;
}
if (!_reflectedHulls.TryGetValue(orientation, out var reflected))
{
var capped = CapHull(orientation.Hull, 0, 0);
reflected = NfpGeometry.Reflect(ConvexContour.FromVertices(capped));
_reflectedHulls[orientation] = reflected;
}
result = NfpGeometry.Minkowski(placedDisk, reflected);
}
catch (Exception)
{
// A degenerate Minkowski sum removes the fast rejection for this pair;
// the material gate still enforces correctness.
result = null;
}
_nfpCache[key] = result;
return result;
}
/// <summary>
/// Bounded-size convex SUPERSET of <paramref name="hull"/> (translated by dx/dy).
/// When the hull is dense, keep every k-th vertex, then shift each chord's
/// supporting line outward by the chord's maximum sagitta (the largest distance of
/// any dropped vertex to its chord). Every dropped vertex lies within the sagitta
/// of its chord, so the offset half-plane intersection contains the original hull
/// and the NFP built from it stays a conservative superset of the forbidden anchors.
/// </summary>
private static List<Vector> CapHull(List<Vector> hull, double dx, double dy)
{
var n = hull.Count;
var shifted = new List<Vector>(n);
for (var i = 0; i < n; i++)
shifted.Add(new Vector(hull[i].X + dx, hull[i].Y + dy));
if (n <= MaxHullVertices)
return shifted;
// Chord (v_i, v_{i+k}) for i in steps of k, with each chord's outward shift:
// the max perpendicular distance from any vertex it spans to the chord line.
var k = (int)Math.Ceiling(n / (double)MaxHullVertices);
var lines = new List<(double ax, double ay, double bx, double by, double shift)>();
for (var i = 0; i < n; i += k)
{
var a = shifted[i];
var b = shifted[(i + k) % n];
var span = Math.Min(k, n - i);
var sagitta = 0.0;
var length = Math.Sqrt((b.X - a.X) * (b.X - a.X) + (b.Y - a.Y) * (b.Y - a.Y));
if (length > 1e-12)
for (var j = 1; j < span; j++)
{
var p = shifted[i + j];
var distance = Math.Abs(Cross(a.X, a.Y, b.X, b.Y, p)) / length;
if (distance > sagitta)
sagitta = distance;
}
lines.Add((a.X, a.Y, b.X, b.Y, sagitta));
}
// Sutherland-Hodgman from a generous bounding box; the interior of each chord
// is the CCW left side, shifted outward (left) by the sagitta.
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var v in shifted)
{
if (v.X < minX)
minX = v.X;
if (v.X > maxX)
maxX = v.X;
if (v.Y < minY)
minY = v.Y;
if (v.Y > maxY)
maxY = v.Y;
}
var margin = Math.Max(1.0, Math.Max(maxX - minX, maxY - minY));
var polygon = new List<Vector>
{
new(minX - margin, minY - margin),
new(maxX + margin, minY - margin),
new(maxX + margin, maxY + margin),
new(minX - margin, maxY + margin),
};
foreach (var (ax, ay, bx, by, shift) in lines)
{
if (polygon.Count == 0)
return shifted; // degenerate; fall back to full hull
// Shift the line perpendicular away from the interior (CCW: interior is left).
var edgeX = bx - ax;
var edgeY = by - ay;
var length = Math.Sqrt(edgeX * edgeX + edgeY * edgeY);
if (length <= 1e-12)
continue;
var nx = edgeY / length;
var ny = -edgeX / length;
var ox = ax + nx * shift;
var oy = ay + ny * shift;
var input = polygon;
polygon = new List<Vector>();
for (var i = 0; i < input.Count; i++)
{
var current = input[i];
var next = input[(i + 1) % input.Count];
var currentInside = Cross(ox, oy, ox + edgeX, oy + edgeY, current) >= 0;
var nextInside = Cross(ox, oy, ox + edgeX, oy + edgeY, next) >= 0;
if (currentInside)
{
polygon.Add(current);
if (!nextInside)
polygon.Add(Intersect(ox, oy, ox + edgeX, oy + edgeY, current, next));
}
else if (nextInside)
{
polygon.Add(Intersect(ox, oy, ox + edgeX, oy + edgeY, current, next));
}
}
}
return polygon.Count >= 3 ? polygon : shifted;
}
private static double Cross(double ax, double ay, double bx, double by, Vector p) =>
(bx - ax) * (p.Y - ay) - (by - ay) * (p.X - ax);
private static Vector Intersect(
double ax,
double ay,
double bx,
double by,
Vector p,
Vector q
)
{
var dx1 = bx - ax;
var dy1 = by - ay;
var dx2 = q.X - p.X;
var dy2 = q.Y - p.Y;
var cross = dx1 * dy2 - dy1 * dx2;
if (Math.Abs(cross) < 1e-300)
return p;
var t = ((p.X - ax) * dy2 - (p.Y - ay) * dx2) / cross;
return new Vector(ax + t * dx1, ay + t * dy1);
}
private ConvexContour Disk() =>
// Circumscribed so the polygon contains the true spacing disk: the NFP stays a
// conservative superset of the forbidden-anchor region.
_disk ??= ConvexContour.Disk(Spacing / Math.Cos(Math.PI / 24), 24);
}