mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-24 12:41:06 -04:00
feat(cutouts): compare measured ratio routes and certify leftovers
This commit is contained in:
1 parent
962ae8c825
commit
1d50c6ce60
3 files changed
+138
-31
No files matched your search
@@ -67,6 +67,45 @@ public class CutoutRouterTests
|
||||
AssertPhysical(frame, new[] { insert }, poses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NfpAddsOneMoreCopyToPartialShiftedLattice()
|
||||
{
|
||||
// Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted
|
||||
// lattice keeps 11; an independently certified NFP pose occupies its leftover.
|
||||
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
|
||||
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12);
|
||||
var lattice = CutoutLatticeFill.Fill(frame, 0, insert, 12, 0.25);
|
||||
Assert.Equal(11, lattice.Count);
|
||||
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||
Assert.Equal(12, poses.Count);
|
||||
Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex));
|
||||
AssertPhysical(frame, new[] { insert }, poses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy()
|
||||
{
|
||||
// 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy.
|
||||
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3);
|
||||
Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio);
|
||||
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||
Assert.Equal(2, poses.Count);
|
||||
AssertPhysical(frame, new[] { insert }, poses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice()
|
||||
{
|
||||
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
|
||||
var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0));
|
||||
Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio);
|
||||
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25));
|
||||
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||
Assert.Single(poses);
|
||||
AssertPhysical(frame, new[] { insert }, poses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateRequirementIdsAreRejectedBeforePlacement()
|
||||
{
|
||||
@@ -149,8 +188,34 @@ public class CutoutRouterTests
|
||||
Assert.InRange(ry, 5.25, 14.75);
|
||||
}
|
||||
else
|
||||
Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= 4.75 + 0.00001);
|
||||
{
|
||||
var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right);
|
||||
Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Independent rectangle-vs-rectangle distance, not the production Clears predicate.
|
||||
for (var i = 0; i < poses.Count; i++)
|
||||
for (var j = i + 1; j < poses.Count; j++)
|
||||
{
|
||||
var a = Bounds(poses[i]);
|
||||
var b = Bounds(poses[j]);
|
||||
var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right));
|
||||
var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top));
|
||||
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001,
|
||||
$"independent material gap too small: {poses[i]} vs {poses[j]}");
|
||||
}
|
||||
|
||||
(double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose)
|
||||
{
|
||||
var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds;
|
||||
var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom),
|
||||
(box.Right, box.Top), (box.Left, box.Top) };
|
||||
var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation)
|
||||
- c.Item2 * System.Math.Sin(pose.Rotation)).ToArray();
|
||||
var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation)
|
||||
+ c.Item2 * System.Math.Cos(pose.Rotation)).ToArray();
|
||||
return (x.Min(), x.Max(), y.Min(), y.Max());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,17 @@ using System.Threading;
|
||||
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
/// <summary>Internal frame-local cutout proposal. Never changes demand, stock or a live plate.</summary>
|
||||
/// <remarks>Tries shifted partial Fill for a type with at least three requested copies,
|
||||
/// prefers it only when it beats a single NFP pose, then fills residual demand
|
||||
/// with NFP poses. No tuned ratio cutoff is claimed: the
|
||||
/// historical 0.10/0.20 routing thresholds still require measured acceptance.
|
||||
/// Fill's equal-score pose nondeterminism is tracked as PM c98c21bd.</remarks>
|
||||
/// <remarks>Area ratio selects search order, not placement permission. The small-insert path
|
||||
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
|
||||
/// the large-insert path tries NFP first. All accepted poses are clearance-checked.
|
||||
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
|
||||
internal static class CutoutRouter
|
||||
{
|
||||
// Calibrated as search-order hints, not hard geometry cutoffs. Round-hole neutral tests
|
||||
// show a Fill win at 0.204, so the original proposed >0.20 NFP-only rule loses copies.
|
||||
internal const double SmallRatio = 0.10;
|
||||
internal const double LargeRatio = 0.35;
|
||||
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default)
|
||||
{
|
||||
@@ -28,8 +32,7 @@ internal static class CutoutRouter
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
|
||||
var hole = frameGeometry.Cutouts[cutoutIndex];
|
||||
if (!hole.IsClosed() || !Supported(frameGeometry.Bounds)
|
||||
|| !Supported(hole.BoundingBox))
|
||||
if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) || !Supported(hole.BoundingBox))
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
if (inserts.Any(p => p == null || !SupportedGeometry(p)))
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
@@ -40,42 +43,75 @@ internal static class CutoutRouter
|
||||
|
||||
var accepted = new List<NestJobPlacement>();
|
||||
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
|
||||
var holeArea = System.Math.Abs(hole.Area());
|
||||
// Preserve caller requirement order: priorities are a whole-job concern, not inferred here.
|
||||
foreach (var part in inserts)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var geometry = JobPartGeometry.Read(part.Geometry);
|
||||
if (part.Quantity >= 3)
|
||||
var ratio = geometry.MaterialArea / holeArea;
|
||||
// Unsupported or degenerate material cannot establish a useful routing ratio;
|
||||
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
|
||||
var tryLattice = part.Quantity >= 3 && double.IsFinite(ratio) && ratio > 0;
|
||||
var nfpFirst = !tryLattice || ratio >= LargeRatio;
|
||||
var compareBoth = tryLattice && ratio >= SmallRatio && ratio < LargeRatio;
|
||||
var nfp = nfpFirst ? Nfp(Array.Empty<NestJobPlacement>()) : null;
|
||||
var lattice = tryLattice && (!nfpFirst || nfp!.Count < part.Quantity)
|
||||
? CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, spacing,
|
||||
CutoutLatticeFill.DefaultShiftSteps, occupied, token)
|
||||
: Array.Empty<NestJobPlacement>();
|
||||
IReadOnlyList<NestJobPlacement> chosen;
|
||||
if (compareBoth || (nfpFirst && lattice.Count > 0))
|
||||
{
|
||||
// Geometric work is bounded inside Fill. Do not infer a ratio threshold
|
||||
// from material area before measuring cutout jobs; try both proposals.
|
||||
var usable = CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity,
|
||||
spacing, CutoutLatticeFill.DefaultShiftSteps, occupied, token);
|
||||
// A one-copy lattice adds no value over the geometry-aware NFP search;
|
||||
// retain the latter's stable candidate order for that tie.
|
||||
if (usable.Count > 1 || (usable.Count == 1
|
||||
&& CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, occupied, token) == null))
|
||||
foreach (var pose in usable)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
Accept(pose);
|
||||
}
|
||||
nfp ??= Nfp(Array.Empty<NestJobPlacement>());
|
||||
var fromLattice = Complete(lattice);
|
||||
chosen = fromLattice.Count > nfp.Count ? fromLattice : nfp;
|
||||
}
|
||||
while (accepted.Count(p => p.PartId == part.Id) < part.Quantity)
|
||||
else if (nfpFirst)
|
||||
chosen = nfp!;
|
||||
else
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, occupied, token);
|
||||
if (pose == null)
|
||||
break;
|
||||
Accept(pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) });
|
||||
// A single Fill copy ties one NFP pose; prefer NFP's stable candidate
|
||||
// order, but retain the lattice copy if bounded NFP found nothing.
|
||||
var seed = lattice.Count > 1 ? lattice : Array.Empty<NestJobPlacement>();
|
||||
chosen = Complete(seed);
|
||||
if (chosen.Count == 0 && lattice.Count == 1)
|
||||
chosen = lattice;
|
||||
}
|
||||
|
||||
void Accept(NestJobPlacement pose)
|
||||
foreach (var pose in chosen)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) };
|
||||
accepted.Add(indexed);
|
||||
occupied.Add((geometry, indexed));
|
||||
}
|
||||
|
||||
IReadOnlyList<NestJobPlacement> Complete(IReadOnlyList<NestJobPlacement> seed)
|
||||
{
|
||||
if (seed.Count >= part.Quantity)
|
||||
return seed;
|
||||
var result = new List<NestJobPlacement>(seed);
|
||||
result.AddRange(Nfp(seed));
|
||||
return result;
|
||||
}
|
||||
|
||||
IReadOnlyList<NestJobPlacement> Nfp(IReadOnlyList<NestJobPlacement> seed)
|
||||
{
|
||||
var result = new List<NestJobPlacement>();
|
||||
var local = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(occupied);
|
||||
local.AddRange(seed.Select(p => (geometry, p)));
|
||||
while (seed.Count + result.Count < part.Quantity)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, local, token);
|
||||
if (pose == null)
|
||||
break;
|
||||
result.Add(pose);
|
||||
local.Add((geometry, pose));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return accepted.ToArray();
|
||||
|
||||
@@ -89,8 +89,14 @@ original requirement IDs, reindexes accepted copies and checks clearance against
|
||||
and every previously accepted insert. Lattice shifts account for occupied poses before
|
||||
quantity trimming; a pre-fill work limit declines giant grids and lets bounded NFP sampling
|
||||
try instead. A null NFP proposal is not proof of geometric impossibility. No material-area
|
||||
ratio cutoff is enabled: the proposed 0.10/0.20 routing thresholds still need measured
|
||||
neutral and sanitized-job evidence. The router has no pipeline caller and does not change
|
||||
ratio cutoff rejects a possible placement: the measured 0.10 and 0.35 ratios guide
|
||||
search order only. Below 0.10 it starts with Fill for three or more copies; between
|
||||
0.10 and 0.35 it compares Fill-plus-NFP with NFP-only counts; above 0.35 it tries
|
||||
NFP first, then Fill if demand remains, taking the higher-count valid proposal.
|
||||
An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole.
|
||||
Six geometry-only, anonymized real-job probes and neutral ring fixtures informed
|
||||
the search-order hints; these limited cases are not a global density guarantee.
|
||||
The router has no pipeline caller and does not change
|
||||
stock, live demand, cutting order or posting safety; in-hole production use remains held.
|
||||
|
||||
## Renamed engines
|
||||
|
||||
Reference in new issue
Block a user