Files
OpenNest-Engines/OpenNest.Engine.Qwen38FlashNext/Engine/PartPreparation.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

419 lines
15 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 job requirement prepared once per solve: snapshot motions rebuilt into an owned
/// closed contour topology (perimeter + cutouts; rapid/layer-mark geometry dropped),
/// flattened collision polygons, and material area.
/// </summary>
internal sealed class PartModel
{
private PartModel(
string id,
int quantity,
int priority,
RotationPolicy rotation,
ShapeProfile profile,
Shape perimeterShape,
List<Shape> cutoutShapes,
double area
)
{
Id = id;
Quantity = quantity;
Priority = priority;
Rotation = rotation;
Profile = profile;
PerimeterShape = perimeterShape;
CutoutShapes = cutoutShapes;
Area = area;
}
public string Id { get; }
public int Quantity { get; }
public int Priority { get; }
public RotationPolicy Rotation { get; }
/// <summary>Closed contour topology (perimeter CCW, cutouts) used for region offsets.</summary>
public ShapeProfile Profile { get; }
/// <summary>Analytic closed perimeter (arcs preserved) for conservative flattening.</summary>
public Shape PerimeterShape { get; }
public List<Shape> CutoutShapes { get; }
/// <summary>Material area (perimeter minus holes), from the analytic shapes.</summary>
public double Area { get; }
internal List<double>? Angles { get; set; }
/// <summary>
/// Chord tolerance for the engine's internal collision polygons. Must stay FINER
/// than NestTolerances.ValidationOutline (0.001): any chord cuts the cap off a
/// concave arc, and a coarser polygon cuts MORE - so a coarse flattening is a
/// subset of the validator's material in notched regions and admits real spacing
/// violations (observed on arc-heavy PEP parts at 0.02). Finer than the validator,
/// every engine polygon contains the validator's, so a cleared gate is conservative.
/// </summary>
public const double CollisionTolerance = 0.0005;
/// <summary>
/// Returns null when the snapshot has no usable closed contour - such a part can
/// never be placed and is reported unplaced rather than failing the whole job.
/// </summary>
public static PartModel? TryCreate(NestJobPart part)
{
var geometry = JobPartGeometry.TryRead(part.Geometry);
if (geometry == null || geometry.MaterialArea <= Tolerance.Epsilon) return null;
// Read normalizes winding before the collision and offset preparation below.
return new PartModel(part.Id, part.Quantity, part.Priority, part.Rotation,
geometry.Profile, geometry.Perimeter, geometry.Cutouts.ToList(), geometry.MaterialArea);
}
}
/// <summary>
/// One part contour rotated about the snapshot origin - exactly the frame a
/// <see cref="NestJobPlacement"/> produces (rotate, then translate by X/Y). Bounds,
/// convex hull, and the spacing-inflated outline are computed once and reused.
/// </summary>
internal sealed class OrientationModel
{
internal OrientationModel(
double angle,
Polygon perimeter,
List<Polygon> holes,
Polygon? inflatedPerimeter,
List<Polygon> inflatedHoles,
double spacing
)
{
Angle = angle;
Perimeter = perimeter;
Holes = holes;
InflatedPerimeter = inflatedPerimeter;
InflatedHoles = inflatedHoles;
Spacing = spacing;
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var v in perimeter.Vertices)
{
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;
}
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
var hullPoints = new List<Vector>();
try
{
var hull = ConvexHull.Compute(perimeter.Vertices);
foreach (var v in hull.Vertices)
{
if (hullPoints.Count > 0 && v.Equals(hullPoints[^1]))
continue;
hullPoints.Add(v);
}
if (hullPoints.Count > 1 && hullPoints[0].Equals(hullPoints[^1]))
hullPoints.RemoveAt(hullPoints.Count - 1);
}
catch (Exception)
{
hullPoints.Clear();
}
Hull = hullPoints.Count >= 3 ? hullPoints : perimeter.Vertices;
// True when the flattened perimeter is itself convex (no concavities) and the
// part has no cutouts: for two such parts the convex NFP is EXACT - material
// equals hull - so an anchor inside it is forbidden with no material test.
var convex = holes.Count == 0;
if (convex)
{
var verts = perimeter.Vertices;
var m = verts.Count;
if (m > 2 && verts[0].Equals(verts[m - 1]))
m--;
for (var i = 0; i < m && convex; i++)
{
var ax = verts[i].X;
var ay = verts[i].Y;
var bx = verts[(i + 1) % m].X;
var by = verts[(i + 1) % m].Y;
var cx = verts[(i + 2) % m].X;
var cy = verts[(i + 2) % m].Y;
if ((bx - ax) * (cy - by) - (by - ay) * (cx - bx) < -1e-9)
convex = false;
}
}
IsConvexSolid = convex;
}
/// <summary>No cutouts and a convex perimeter: material equals hull.</summary>
public bool IsConvexSolid { get; }
public double Angle { get; }
/// <summary>Circumscribed flattened perimeter in the rotated frame (pre-translation).</summary>
public Polygon Perimeter { get; }
public List<Polygon> Holes { get; }
/// <summary>Material outline inflated by <see cref="Spacing"/> (null when spacing is zero).</summary>
public Polygon? InflatedPerimeter { get; }
/// <summary>Cutouts shrunk by <see cref="Spacing"/>; holes that close up are dropped (treated solid).</summary>
public List<Polygon> InflatedHoles { get; }
public double Spacing { get; }
public double MinX { get; }
public double MinY { get; }
public double MaxX { get; }
public double MaxY { get; }
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
/// <summary>Convex hull of the perimeter (open vertex list, at least 3 points).</summary>
public List<Vector> Hull { get; }
/// <summary>
/// Fast clearance outline of the raw perimeter in this orientation's local frame
/// (lazily built; translated per anchor in O(1) via <see cref="FastPoly.Translated"/>).
/// </summary>
public FastPoly? PerimeterFast => _perimeterFast ??= FastPoly.From(Perimeter);
private FastPoly? _perimeterFast;
/// <summary>
/// Fast clearance outline of the gate material (spacing-inflated when positive) in
/// this orientation's local frame.
/// </summary>
public FastPoly? GateFast =>
_gateFast ??= FastPoly.From(InflatedPerimeter ?? Perimeter);
private FastPoly? _gateFast;
/// <summary>
/// Cached triangulation of the raw material (perimeter + holes) in this
/// orientation's local frame for the allocation-free exact gate.
/// </summary>
public TriSet? MaterialTris => _materialTris ??= TriSet.Build(Perimeter, Holes);
private TriSet? _materialTris;
/// <summary>
/// Cached triangulation of the gate material (spacing-inflated perimeter with
/// shrunk holes) in this orientation's local frame.
/// </summary>
public TriSet? GateTris =>
_gateTris ??= TriSet.Build(InflatedPerimeter ?? Perimeter, InflatedPerimeter != null ? InflatedHoles : Holes);
private TriSet? _gateTris;
}
/// <summary>Builds and caches per-(part, orientation, spacing) geometry for one engine run.</summary>
internal sealed class PartPreparation
{
private readonly List<PartModel> models = new();
private readonly Dictionary<string, int> indexById = new(StringComparer.Ordinal);
private readonly Dictionary<(string, double, double), OrientationModel> orientations = new();
/// <summary>
/// Cross-packer memo of exact material overlap: (placed orientation, placed anchor,
/// candidate orientation, candidate anchor) -> overlap. Sheet trials rebuild greedy
/// placement deterministically, so identical world poses recur across trials and
/// across sheets; the memo collapses the repeated polygon-clipping work. Bounded so
/// it can never grow unboundedly on pathological jobs.
/// </summary>
private readonly Dictionary<OverlapKey, bool> overlaps = new();
internal sealed class OverlapKey : IEquatable<OverlapKey>
{
private readonly int _placedHash;
private readonly long _px;
private readonly long _py;
private readonly int _candHash;
private readonly long _cx;
private readonly long _cy;
public OverlapKey(int placedHash, double px, double py, int candHash, double cx, double cy)
{
_placedHash = placedHash;
_px = (long)Math.Round(px * 1e6);
_py = (long)Math.Round(py * 1e6);
_candHash = candHash;
_cx = (long)Math.Round(cx * 1e6);
_cy = (long)Math.Round(cy * 1e6);
}
public bool Equals(OverlapKey? other) =>
other != null
&& _placedHash == other._placedHash
&& _px == other._px
&& _py == other._py
&& _candHash == other._candHash
&& _cx == other._cx
&& _cy == other._cy;
public override bool Equals(object? obj) => Equals(obj as OverlapKey);
public override int GetHashCode()
{
var hash = _placedHash;
hash = unchecked(hash * 397 + _px.GetHashCode());
hash = unchecked(hash * 397 + _py.GetHashCode());
hash = unchecked(hash * 397 + _candHash);
hash = unchecked(hash * 397 + _cx.GetHashCode());
hash = unchecked(hash * 397 + _cy.GetHashCode());
return hash;
}
}
private const int OverlapMemoCap = 500_000;
public bool MaterialOverlapMemo(
OrientationModel placed,
double placedX,
double placedY,
OrientationModel candidate,
double candidateX,
double candidateY,
Func<bool> compute
)
{
var key = new OverlapKey(
System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(placed),
placedX,
placedY,
System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(candidate),
candidateX,
candidateY
);
if (overlaps.TryGetValue(key, out var known))
return known;
if (overlaps.Count >= OverlapMemoCap)
overlaps.Clear();
var value = compute();
overlaps[key] = value;
return value;
}
public IReadOnlyList<PartModel> Models => models;
public PartPreparation(IReadOnlyList<NestJobPart> parts)
{
foreach (var part in parts)
{
var model = PartModel.TryCreate(part);
if (model == null)
{
InvalidIds.Add(part.Id);
continue;
}
indexById[model.Id] = models.Count;
models.Add(model);
}
}
/// <summary>Requirements whose snapshot geometry could not be interpreted at all.</summary>
public List<string> InvalidIds { get; } = new();
public bool TryGetModel(string partId, out PartModel model)
{
model = null!;
if (!indexById.TryGetValue(partId, out var index))
return false;
model = models[index];
return true;
}
public OrientationModel Oriented(PartModel model, double angle, double spacing)
{
// Round keys so policy-equivalent angles (0 vs 2pi) share one cached orientation.
var key = (model.Id, Math.Round(angle, 9), Math.Round(spacing, 9));
if (orientations.TryGetValue(key, out var cached))
return cached;
var perimeterShape = (Shape)model.PerimeterShape.Clone();
perimeterShape.Rotate(angle);
var perimeter = perimeterShape.ToPolygonWithTolerance(
PartModel.CollisionTolerance,
circumscribe: true
);
var holes = new List<Polygon>(model.CutoutShapes.Count);
foreach (var cutout in model.CutoutShapes)
{
var shape = (Shape)cutout.Clone();
shape.Rotate(angle);
holes.Add(
shape.ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true)
);
}
Polygon? inflated = null;
var inflatedHoles = new List<Polygon>();
if (spacing > Tolerance.Epsilon)
{
// Conservative (circumscribed, padded) region offset: a superset of the
// validator's inflation, so accepted clearances never fall short. The
// offset commutes with rotation, so inflate the unrotated profile once and
// rotate the result into this orientation's frame - an unrotated inflation
// would test the candidate against the material of a different angle.
var region = ClipperBridge.Offset(model.Profile, spacing, 0.02, circumscribe: true);
var outer = region.LargestOuter();
if (outer != null)
{
outer.Rotate(angle);
outer.UpdateBounds();
inflated = outer;
}
foreach (var hole in region.Holes)
if (hole != null)
{
hole.Rotate(angle);
hole.UpdateBounds();
inflatedHoles.Add(hole);
}
}
var result = new OrientationModel(angle, perimeter, holes, inflated, inflatedHoles, spacing);
orientations[key] = result;
return result;
}
/// <summary>
/// Legal orientations for a requirement: exactly the policy angles when the policy
/// enumerates them, otherwise 0/90/180/270 degrees plus the minimum-area bounding
/// rectangle angle (rotating-calipers), with 180-degree equivalents included.
/// </summary>
public static List<double> CandidateAngles(PartModel model)
{
if (model.Angles != null) return model.Angles;
var angles = model.Rotation.Kind == RotationPolicyKind.Automatic
? RotationCandidates.ForShape(model.Rotation, model.PerimeterShape)
: model.Rotation.EnumerateAngles(maxSamples: 4000);
// Perimeter symmetry does not establish symmetry of the cutouts.
return model.Angles = (model.CutoutShapes.Count == 0
? RotationCandidates.DistinctOutlines(model.PerimeterShape, angles)
: angles).ToList();
}
}