mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-25 05:51:06 -04:00
Merge branch 'master' of https://github.com/ajisaacs/OpenNest
This commit is contained in:
commit
ef6ca80e6d
38 files changed
+2896
-186
No files matched your search
@@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch
|
||||
/// </summary>
|
||||
public const int ExpansionsPerPart = 400;
|
||||
|
||||
// Extra bounded work on hard/refused plates; the full-cap search still gets its whole budget.
|
||||
internal const int FirstPassBudget = 1000;
|
||||
internal const int FirstPassEntries = 8;
|
||||
|
||||
private readonly Entry[] entries;
|
||||
private readonly CuttingParameters ownedParameters;
|
||||
|
||||
@@ -142,7 +146,7 @@ public sealed class CuttingPlanBatch
|
||||
if (entry.Reorder != null)
|
||||
{
|
||||
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
|
||||
reorder = CuttingPlanService.Plan(entry.Reorder, token);
|
||||
reorder = PlanReorder(entry.Reorder, token);
|
||||
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
|
||||
{
|
||||
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
|
||||
@@ -156,6 +160,22 @@ public sealed class CuttingPlanBatch
|
||||
return new(plans, ownedParameters);
|
||||
}
|
||||
|
||||
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
|
||||
int firstPassBudget = FirstPassBudget)
|
||||
{
|
||||
// A failed first pass is not a proof of impossibility: retain all original
|
||||
// full-cap alternatives and the complete original budget for the retry.
|
||||
if (snapshot.Failure == null && snapshot.Regeneration && snapshot.MaxEntries > FirstPassEntries
|
||||
&& snapshot.ExpansionBudget > firstPassBudget)
|
||||
{
|
||||
var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token);
|
||||
if ((first.Status == CuttingPlanStatus.Ready && first.IndependentlyReplayed)
|
||||
|| first.Status == CuttingPlanStatus.Cancelled)
|
||||
return first;
|
||||
}
|
||||
return CuttingPlanService.Plan(snapshot, token);
|
||||
}
|
||||
|
||||
private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token)
|
||||
{
|
||||
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
|
||||
@@ -216,6 +236,10 @@ public sealed class CuttingPlanPlateResult
|
||||
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
|
||||
}
|
||||
|
||||
/// <summary>A diagnostic tied to captured plate and source part positions, not display text.</summary>
|
||||
public sealed record CuttingPlanDiagnosticRow(int PlateIndex, int PlateNumber, int? PartNumber,
|
||||
int? OtherPartNumber, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
|
||||
/// </summary>
|
||||
@@ -243,6 +267,35 @@ public sealed class CuttingPlanProposal
|
||||
|
||||
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
|
||||
|
||||
/// <summary>All findings, including ones hidden by a blocked plate's abbreviated summary.</summary>
|
||||
public IReadOnlyList<CuttingPlanDiagnosticRow> DiagnosticRows()
|
||||
{
|
||||
var rows = new List<CuttingPlanDiagnosticRow>();
|
||||
for (var index = 0; index < Plates.Count; index++)
|
||||
{
|
||||
var plate = Plates[index];
|
||||
if (plate.Overlap != null)
|
||||
{
|
||||
foreach (var pair in plate.Overlap.Pairs)
|
||||
rows.Add(new(index, plate.PlateNumber, pair.PartAId + 1, pair.PartBId + 1,
|
||||
$"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part "
|
||||
+ $"{pair.PartBId + 1}{Name(pair.PartBName)}."));
|
||||
foreach (var issue in plate.Overlap.Issues)
|
||||
rows.Add(new(index, plate.PlateNumber, issue.PartAId + 1,
|
||||
issue.PartBId is int other ? other + 1 : null,
|
||||
$"Overlap check incomplete for part {issue.PartAId + 1}"
|
||||
+ (issue.PartBId is int second ? $" and part {second + 1}" : string.Empty)
|
||||
+ $": {issue.Message}"));
|
||||
}
|
||||
foreach (var finding in plate.Result.Findings)
|
||||
rows.Add(new(index, plate.PlateNumber,
|
||||
finding.SourceOrdinal is int part ? part + 1 : null,
|
||||
finding.OtherSourceOrdinal is int other ? other + 1 : null,
|
||||
DescribeFinding(finding)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
|
||||
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
|
||||
|
||||
@@ -93,6 +93,13 @@ public sealed class CuttingPlanSnapshot
|
||||
public int ExpansionBudget { get; }
|
||||
internal CuttingPlanStatus? Failure { get; }
|
||||
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
||||
|
||||
// A worker-only search variant over the same owned capture. Never recapture the live plate:
|
||||
// both proposals retain the identical freshness record and confirmed settings.
|
||||
internal CuttingPlanSnapshot WithSearchLimits(int expansionBudget, int maxEntries) =>
|
||||
new(Placements, StartPoint, expansionBudget, Failure, Findings, Regeneration,
|
||||
PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters,
|
||||
Dependencies, BestEffort);
|
||||
}
|
||||
|
||||
public sealed class FixedProgramPlacement
|
||||
|
||||
@@ -196,29 +196,39 @@ internal static class JointCuttingPlanSearch
|
||||
var progressExpansions = Expansions;
|
||||
var stack = new Stack<Frame>();
|
||||
stack.Push(new(root));
|
||||
while (stack.Count != 0)
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var frame = stack.Peek();
|
||||
var node = frame.Node;
|
||||
if (node.Order.Length == snapshot.Placements.Count)
|
||||
while (stack.Count != 0)
|
||||
{
|
||||
attempt.Order = node.Order;
|
||||
return attempt;
|
||||
token.ThrowIfCancellationRequested();
|
||||
var frame = stack.Peek();
|
||||
var node = frame.Node;
|
||||
if (node.Order.Length == snapshot.Placements.Count)
|
||||
{
|
||||
attempt.Order = node.Order;
|
||||
return attempt;
|
||||
}
|
||||
if (attempt.Advance(node))
|
||||
progressExpansions = Expansions;
|
||||
else if (stall is int limit && Expansions - progressExpansions > limit)
|
||||
return attempt;
|
||||
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly).GetEnumerator();
|
||||
if (!frame.Children.MoveNext())
|
||||
{
|
||||
stack.Pop().Children.Dispose();
|
||||
continue;
|
||||
}
|
||||
stack.Push(new(frame.Children.Current.Node));
|
||||
}
|
||||
if (attempt.Advance(node))
|
||||
progressExpansions = Expansions;
|
||||
else if (stall is int limit && Expansions - progressExpansions > limit)
|
||||
return attempt;
|
||||
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly);
|
||||
if (frame.Next == frame.Children.Length)
|
||||
{
|
||||
stack.Pop();
|
||||
continue;
|
||||
}
|
||||
stack.Push(new(frame.Children[frame.Next++].Node));
|
||||
return attempt;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Ready, stalled, cancelled and budget-exhausted searches can all leave
|
||||
// suspended siblings. Release their captured prefixes on every exit.
|
||||
foreach (var frame in stack)
|
||||
frame.Children?.Dispose();
|
||||
}
|
||||
return attempt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -259,8 +269,14 @@ internal static class JointCuttingPlanSearch
|
||||
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
|
||||
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
|
||||
/// </summary>
|
||||
private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
|
||||
private IEnumerable<Edge> OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
|
||||
{
|
||||
// A prescribed next part (or an active part's next contour) has a single
|
||||
// source. Expand already yields its contour/entry rank order, so emit and
|
||||
// validate a sibling only when DFS reaches it. Free whole-part selection
|
||||
// still needs every source's distances before it can rank them.
|
||||
if (sequence != null || node.Active != null)
|
||||
return Expand(node, sequence, attempt, preferredOnly);
|
||||
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
|
||||
if (edges.Count <= 1)
|
||||
return edges.ToArray();
|
||||
@@ -334,8 +350,10 @@ internal static class JointCuttingPlanSearch
|
||||
// First try one ranked hole chain per endpoint. A later-part failure then
|
||||
// changes the endpoint before replaying all earlier hole combinations.
|
||||
// The retained pass below still searches every entry and hole order.
|
||||
if (preference != null)
|
||||
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c);
|
||||
if (preferredOnly && preference != null)
|
||||
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1);
|
||||
contours = contours.Take(1);
|
||||
foreach (var contour in contours)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -593,7 +611,6 @@ internal static class JointCuttingPlanSearch
|
||||
private sealed class Frame(Node node)
|
||||
{
|
||||
internal Node Node { get; } = node;
|
||||
internal Edge[] Children { get; set; }
|
||||
internal int Next { get; set; }
|
||||
internal IEnumerator<Edge> Children { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -22,20 +22,24 @@ namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement.
|
||||
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Not wired into
|
||||
/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order.
|
||||
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Used by the
|
||||
/// internal pipeline cutout preview, but not enabled for normal whole-job calls;
|
||||
/// production placement waits on cutting/post safety.
|
||||
/// </remarks>
|
||||
internal static class CutoutLatticeFill
|
||||
{
|
||||
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
|
||||
internal const int DefaultShiftSteps = 8;
|
||||
|
||||
private const double FlattenTolerance = 0.001;
|
||||
internal const double FlattenTolerance = 0.001;
|
||||
|
||||
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
|
||||
private const double Margin = FlattenTolerance + 0.001;
|
||||
|
||||
private const int Precision = NestTolerances.ClipperPrecision;
|
||||
// Fill materializes an entire grid before quantity trimming. Bound work even for a
|
||||
// tiny insert with demand of only three, rather than allocating millions of clones.
|
||||
private const int MaxLatticePositions = 1000;
|
||||
|
||||
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
|
||||
/// or none when no copy fits.</summary>
|
||||
@@ -45,9 +49,17 @@ internal static class CutoutLatticeFill
|
||||
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
|
||||
=> Fill(frame, cutoutIndex, insert, maxQuantity, spacing, shiftSteps,
|
||||
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token);
|
||||
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps,
|
||||
IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CancellationToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(insert);
|
||||
ArgumentNullException.ThrowIfNull(occupied);
|
||||
token.ThrowIfCancellationRequested();
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
|
||||
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
@@ -69,6 +81,10 @@ internal static class CutoutLatticeFill
|
||||
var bounds = cutout.BoundingBox;
|
||||
var insertBounds = insertGeometry.Bounds;
|
||||
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
|
||||
var estimated = (System.Math.Ceiling(bounds.Length / step) + 4)
|
||||
* (System.Math.Ceiling(bounds.Width / step) + 4);
|
||||
if (!(step > 0) || !double.IsFinite(estimated) || estimated > MaxLatticePositions)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
|
||||
var lattice = Lattice(insert, bounds, step, spacing, token);
|
||||
if (lattice.Count == 0)
|
||||
@@ -83,7 +99,8 @@ internal static class CutoutLatticeFill
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var (dx, dy) = Shift(step, shiftSteps, i, j);
|
||||
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy));
|
||||
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy)
|
||||
&& ClearsOccupied(p, dx, dy));
|
||||
if (Better(count, i, j, best))
|
||||
best = (count, i, j);
|
||||
}
|
||||
@@ -91,13 +108,33 @@ internal static class CutoutLatticeFill
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
|
||||
var (sx, sy) = Shift(step, shiftSteps, best.I, best.J);
|
||||
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy))
|
||||
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy)
|
||||
&& ClearsOccupied(p, sx, sy))
|
||||
.Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8),
|
||||
System.Math.Round(p.Y + sy, 8), p.Rotation))
|
||||
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
|
||||
.ToList();
|
||||
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
|
||||
.Select((p, index) => p with { InstanceIndex = index }).ToArray();
|
||||
|
||||
bool ClearsOccupied(NestJobPlacement pose, double dx, double dy)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (occupied.Count == 0)
|
||||
return true;
|
||||
var moved = pose with
|
||||
{
|
||||
X = System.Math.Round(pose.X + dx, 8),
|
||||
Y = System.Math.Round(pose.Y + dy, 8)
|
||||
};
|
||||
foreach (var other in occupied)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, moved, spacing))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fill over the cutout's bounds grown by one step on every side, in frame
|
||||
@@ -134,7 +171,7 @@ internal static class CutoutLatticeFill
|
||||
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
|
||||
/// never meets the cutout boundary (t outside boundary + reflected outline).
|
||||
/// </summary>
|
||||
private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
|
||||
internal static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
|
||||
{
|
||||
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
|
||||
foreach (var entity in entities)
|
||||
@@ -203,7 +240,7 @@ internal static class CutoutLatticeFill
|
||||
}
|
||||
|
||||
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
|
||||
private static bool Inside(PathsD region, double x, double y)
|
||||
internal static bool Inside(PathsD region, double x, double y)
|
||||
{
|
||||
var point = new PointD(x, y);
|
||||
var inside = false;
|
||||
@@ -218,7 +255,7 @@ internal static class CutoutLatticeFill
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static PathD Positive(PathD path)
|
||||
internal static PathD Positive(PathD path)
|
||||
{
|
||||
if (path.Count >= 3 && !Clipper.IsPositive(path))
|
||||
path.Reverse();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
/// <summary>Test-only single-insert inner-fit proposal; never changes a job or a live plate.</summary>
|
||||
internal static class CutoutNfpProposal
|
||||
{
|
||||
// Fixed chord tolerance in the shared round offset otherwise makes extremely large
|
||||
// spacing values allocate an unbounded number of arc vertices. This conservative
|
||||
// test-only kernel declines scales it cannot process safely.
|
||||
private const double MaxCoordinate = 1e8;
|
||||
private const double MaxSpacing = 100;
|
||||
|
||||
/// <summary>Returns one frame-local pose inside a closed cutout, or null if no pose is certified.
|
||||
/// A null result is not a proof that the insert cannot fit elsewhere in this cutout.</summary>
|
||||
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
|
||||
double spacing, CancellationToken token = default) =>
|
||||
Find(frame, cutoutIndex, insert, spacing,
|
||||
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token);
|
||||
|
||||
/// <summary>Find a pose clearing already accepted inserts as well as the enclosing frame.</summary>
|
||||
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
|
||||
double spacing, IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(insert);
|
||||
ArgumentNullException.ThrowIfNull(occupied);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
|
||||
return null;
|
||||
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
|
||||
var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
|
||||
if (frameGeometry == null || insertGeometry == null
|
||||
|| !WithinRange(frameGeometry.Bounds) || !WithinRange(insertGeometry.Bounds))
|
||||
return null;
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
|
||||
var cutout = frameGeometry.Cutouts[cutoutIndex];
|
||||
if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox)
|
||||
|| spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width))
|
||||
return null;
|
||||
var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath(
|
||||
ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false),
|
||||
new Vector()));
|
||||
if (hole.Count < 3)
|
||||
return null;
|
||||
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||
foreach (var rotation in insert.Rotation.EnumerateAngles())
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var region = CutoutLatticeFill.InnerFit(insertGeometry, rotation, hole, spacing);
|
||||
token.ThrowIfCancellationRequested();
|
||||
foreach (var (x, y) in Candidates(region))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!CutoutLatticeFill.Inside(region, x, y))
|
||||
continue;
|
||||
var pose = new NestJobPlacement(insert.Id, 0, x, y, rotation);
|
||||
if (!NestLayoutCheck.Clears(frameGeometry, framePose, insertGeometry, pose, spacing))
|
||||
continue;
|
||||
var clears = true;
|
||||
foreach (var other in occupied)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, pose, spacing))
|
||||
continue;
|
||||
clears = false;
|
||||
break;
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (clears)
|
||||
return pose;
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool WithinRange(Box box) =>
|
||||
double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
|
||||
&& System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
|
||||
&& System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
|
||||
|
||||
/// <summary>Sample component interiors, then points just inside each boundary toward
|
||||
/// its component's center. This bounded search is not a complete inner-fit solver.</summary>
|
||||
private static IEnumerable<(double X, double Y)> Candidates(PathsD region)
|
||||
{
|
||||
foreach (var path in region.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
|
||||
{
|
||||
var bounds = Clipper.GetBounds(new PathsD { path });
|
||||
var cx = (bounds.left + bounds.right) / 2;
|
||||
var cy = (bounds.top + bounds.bottom) / 2;
|
||||
yield return (cx, cy);
|
||||
foreach (var point in path)
|
||||
yield return (point.x * 0.99 + cx * 0.01, point.y * 0.99 + cy * 0.01);
|
||||
// Bounded interior samples let a residual insert land beside a partial lattice;
|
||||
// a center/vertex-only sample often lands directly on an earlier copy.
|
||||
for (var y = 1; y <= 9; y++)
|
||||
for (var x = 1; x <= 9; x++)
|
||||
yield return (bounds.left + (bounds.right - bounds.left) * x / 10,
|
||||
bounds.top + (bounds.bottom - bounds.top) * y / 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
/// <summary>Internal-only frame bundles. No normal pipeline caller enables this.</summary>
|
||||
internal sealed class CutoutPipelinePrepass
|
||||
{
|
||||
// A job with thousands of repeats still offers the residual as ordinary demand;
|
||||
// do not multiply expensive Clipper/Fill preparation by every physical instance.
|
||||
private const int MaxBundlesPerJob = 32;
|
||||
private sealed record Bundle(string FrameId, IReadOnlyList<NestJobPlacement> LocalInserts);
|
||||
private readonly IReadOnlyDictionary<string, Bundle> bundles;
|
||||
|
||||
private CutoutPipelinePrepass(NestJob engineJob, IReadOnlyDictionary<string, Bundle> bundles)
|
||||
{
|
||||
EngineJob = engineJob;
|
||||
this.bundles = bundles;
|
||||
}
|
||||
|
||||
internal NestJob EngineJob { get; }
|
||||
|
||||
internal static CutoutPipelinePrepass? Prepare(NestJob original, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (original.Plates.Count == 0)
|
||||
return null;
|
||||
// One reservation vector works for every offered stock. Current jobs cannot
|
||||
// represent stock-conditional insert quantities; use the largest spacing.
|
||||
var spacing = original.Plates.Max(p => p.PartSpacing);
|
||||
var remaining = original.Parts.ToDictionary(p => p.Id, p => p.Quantity);
|
||||
var geometry = original.Parts.ToDictionary(p => p.Id, p => JobPartGeometry.TryRead(p.Geometry));
|
||||
var bundles = new Dictionary<string, Bundle>(StringComparer.Ordinal);
|
||||
var byFrame = new Dictionary<string, List<NestJobPart>>(StringComparer.Ordinal);
|
||||
foreach (var frame in original.Parts)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (bundles.Count >= MaxBundlesPerJob)
|
||||
break;
|
||||
var profile = geometry[frame.Id];
|
||||
if (profile == null || profile.Cutouts.Count == 0)
|
||||
continue;
|
||||
var proxies = new List<NestJobPart>();
|
||||
byFrame[frame.Id] = proxies;
|
||||
for (var instance = 0; instance < frame.Quantity; instance++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (bundles.Count >= MaxBundlesPerJob)
|
||||
break;
|
||||
IReadOnlyList<NestJobPlacement>? chosen = null;
|
||||
double frameAngle = 0;
|
||||
// Outer-perimeter symmetry cannot deduplicate asymmetric cutouts.
|
||||
foreach (var angle in frame.Rotation.EnumerateAngles(maxSamples: 16))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!FitsOfferedStock(frame, angle, original.Plates))
|
||||
continue;
|
||||
var local = new List<NestJobPlacement>();
|
||||
var available = new Dictionary<string, int>(remaining);
|
||||
for (var hole = 0; hole < profile.Cutouts.Count; hole++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Flat bundles: frames cannot simultaneously be inserts in another bundle.
|
||||
var candidates = original.Parts
|
||||
.Where(p => p.Id != frame.Id && available[p.Id] > 0
|
||||
&& p.Priority == frame.Priority
|
||||
&& geometry[p.Id] is { Cutouts.Count: 0 })
|
||||
.Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id],
|
||||
p.Priority, LocalRotation(p.Rotation, angle)))
|
||||
.ToArray();
|
||||
if (candidates.Length == 0)
|
||||
break;
|
||||
foreach (var pose in CutoutRouter.Fill(frame, hole, candidates, spacing, token))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var source = geometry[pose.PartId]!;
|
||||
var policy = original.Parts.Single(p => p.Id == pose.PartId).Rotation;
|
||||
if (!policy.Allows(angle + pose.Rotation)
|
||||
|| local.Any(other => !NestLayoutCheck.Clears(
|
||||
geometry[other.PartId]!, other, source, pose, spacing)))
|
||||
continue;
|
||||
local.Add(pose);
|
||||
available[pose.PartId]--;
|
||||
}
|
||||
}
|
||||
if (local.Count <= (chosen?.Count ?? 0))
|
||||
continue;
|
||||
chosen = local.ToArray();
|
||||
frameAngle = angle;
|
||||
}
|
||||
if (chosen == null || chosen.Count == 0)
|
||||
break;
|
||||
var proxyId = $"__cutout-proxy-{frame.Id}-{instance}";
|
||||
if (remaining.ContainsKey(proxyId) || bundles.ContainsKey(proxyId))
|
||||
throw new InvalidOperationException("Cutout proxy ID collides with a requirement.");
|
||||
bundles.Add(proxyId, new Bundle(frame.Id, chosen));
|
||||
proxies.Add(new NestJobPart(proxyId, frame.Geometry, 1, frame.Priority,
|
||||
RotationPolicy.Fixed(frameAngle)));
|
||||
remaining[frame.Id]--;
|
||||
foreach (var pose in chosen)
|
||||
remaining[pose.PartId]--;
|
||||
}
|
||||
}
|
||||
if (bundles.Count == 0)
|
||||
return null;
|
||||
var transformed = new List<NestJobPart>();
|
||||
foreach (var part in original.Parts)
|
||||
{
|
||||
if (byFrame.TryGetValue(part.Id, out var proxies))
|
||||
transformed.AddRange(proxies);
|
||||
if (remaining[part.Id] > 0)
|
||||
transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining[part.Id],
|
||||
part.Priority, part.Rotation));
|
||||
}
|
||||
return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates,
|
||||
original.Options), bundles);
|
||||
}
|
||||
|
||||
private static RotationPolicy LocalRotation(RotationPolicy original, double frameAngle) =>
|
||||
original.Kind switch
|
||||
{
|
||||
RotationPolicyKind.Automatic => RotationPolicy.Automatic,
|
||||
RotationPolicyKind.Fixed => RotationPolicy.Fixed(original.Start - frameAngle,
|
||||
original.Allow180Equivalent),
|
||||
_ => RotationPolicy.BoundedSweep(original.Start - frameAngle,
|
||||
original.End - frameAngle, original.Step, original.Allow180Equivalent),
|
||||
};
|
||||
|
||||
private static bool FitsOfferedStock(NestJobPart frame, double angle,
|
||||
IReadOnlyList<NestPlateStock> stock)
|
||||
{
|
||||
var part = new Part(DrawingJobMapper.CreateDrawing(frame));
|
||||
part.Rotate(angle);
|
||||
var bounds = NestLayoutCheck.MaterialBounds(part);
|
||||
return stock.Any(s => s.Quantity != 0 && s.Fits(bounds.Length, bounds.Width,
|
||||
NestTolerances.WorkAreaSlack));
|
||||
}
|
||||
|
||||
/// <summary>Reject untrusted accounting before expansion. A failed trial is never bindable,
|
||||
/// including with allowInvalid. Only actually placed proxies consume reserved inserts.</summary>
|
||||
internal bool TryExpand(NestJob original, NestJobResult result, CancellationToken token,
|
||||
out NestJobResult? expanded,
|
||||
out string failure)
|
||||
{
|
||||
expanded = null;
|
||||
failure = "Invalid cutout engine accounting";
|
||||
var parts = EngineJob.Parts.ToDictionary(p => p.Id);
|
||||
var counts = parts.ToDictionary(p => p.Key, _ => 0);
|
||||
var indices = parts.ToDictionary(p => p.Key, _ => new HashSet<int>());
|
||||
var stockById = EngineJob.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal);
|
||||
var used = stockById.ToDictionary(s => s.Key, _ => 0, StringComparer.Ordinal);
|
||||
var expectedIndex = 0;
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0
|
||||
|| !used.ContainsKey(sheet.StockId))
|
||||
return false;
|
||||
used[sheet.StockId]++;
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
if (pose.PartId == null || !parts.ContainsKey(pose.PartId)
|
||||
|| pose.InstanceIndex < 0 || !indices[pose.PartId].Add(pose.InstanceIndex)
|
||||
|| ++counts[pose.PartId] > parts[pose.PartId].Quantity)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
foreach (var row in indices)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (row.Value.Any(index => index >= counts[row.Key]))
|
||||
return false;
|
||||
}
|
||||
if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count)
|
||||
return false;
|
||||
var seenParts = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var row in result.Fulfillment)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part)
|
||||
|| !seenParts.Add(row.PartId)
|
||||
|| row.Requested != part.Quantity || row.Placed != counts[row.PartId]
|
||||
|| row.Unplaced != part.Quantity - counts[row.PartId])
|
||||
return false;
|
||||
}
|
||||
var seenStock = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var row in result.StockUsage)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (row.StockId == null || !stockById.TryGetValue(row.StockId, out var stock)
|
||||
|| !seenStock.Add(row.StockId)
|
||||
|| row.Used != used[row.StockId]
|
||||
|| row.Remaining != stock.Quantity - row.Used)
|
||||
return false;
|
||||
}
|
||||
var complete = parts.Values.All(p => counts[p.Id] == p.Quantity);
|
||||
if (result.Status != (complete ? NestJobStatus.Complete : NestJobStatus.Incomplete)
|
||||
|| (complete && result.StopReason != NestJobStopReason.Completed)
|
||||
|| (!complete && result.StopReason == NestJobStopReason.Completed))
|
||||
return false;
|
||||
|
||||
var builder = new NestJobResultBuilder(original);
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var poses = new List<(string PartId, double X, double Y, double Rotation)>();
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
if (!bundles.TryGetValue(pose.PartId, out var bundle))
|
||||
{
|
||||
poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation));
|
||||
continue;
|
||||
}
|
||||
poses.Add((bundle.FrameId, pose.X, pose.Y, pose.Rotation));
|
||||
var sine = System.Math.Sin(pose.Rotation);
|
||||
var cosine = System.Math.Cos(pose.Rotation);
|
||||
foreach (var local in bundle.LocalInserts)
|
||||
poses.Add((local.PartId,
|
||||
pose.X + local.X * cosine - local.Y * sine,
|
||||
pose.Y + local.X * sine + local.Y * cosine,
|
||||
pose.Rotation + local.Rotation));
|
||||
}
|
||||
// Canonical stock reference, not an engine-supplied object with altered settings.
|
||||
var stock = original.Plates.Single(s => s.Id == sheet.StockId);
|
||||
try
|
||||
{
|
||||
builder.AddSheet(stock, poses);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
expanded = builder.Build(result.StopReason);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
/// <summary>Internal frame-local cutout proposal. Never changes demand, stock or a live plate.</summary>
|
||||
/// <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) =>
|
||||
Fill(frame, cutoutIndex, inserts, spacing, null, token);
|
||||
|
||||
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
|
||||
/// after indexing; true marks a retained lattice seed, false a residual NFP pose.</summary>
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
IReadOnlyList<NestJobPart> inserts, double spacing,
|
||||
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(inserts);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!double.IsFinite(spacing) || spacing < 0 || spacing > 100)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
|
||||
if (frameGeometry == null)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
|
||||
var hole = frameGeometry.Cutouts[cutoutIndex];
|
||||
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>();
|
||||
if (inserts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != inserts.Count
|
||||
|| inserts.Any(p => p.Id == frame.Id))
|
||||
throw new ArgumentException("Requirement IDs must be distinct from the frame and each other.",
|
||||
nameof(inserts));
|
||||
|
||||
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);
|
||||
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;
|
||||
var retainedLattice = 0;
|
||||
if (compareBoth || (nfpFirst && lattice.Count > 0))
|
||||
{
|
||||
nfp ??= Nfp(Array.Empty<NestJobPlacement>());
|
||||
var fromLattice = Complete(lattice);
|
||||
chosen = fromLattice.Count > nfp.Count ? fromLattice : nfp;
|
||||
if (ReferenceEquals(chosen, fromLattice))
|
||||
retainedLattice = lattice.Count;
|
||||
}
|
||||
else if (nfpFirst)
|
||||
chosen = nfp!;
|
||||
else
|
||||
{
|
||||
// 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;
|
||||
retainedLattice = ReferenceEquals(chosen, lattice) ? lattice.Count : seed.Count;
|
||||
}
|
||||
|
||||
var localIndex = 0;
|
||||
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));
|
||||
observeAccepted?.Invoke(indexed, localIndex++ < retainedLattice);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private static bool SupportedGeometry(NestJobPart part)
|
||||
{
|
||||
var geometry = JobPartGeometry.TryRead(part.Geometry);
|
||||
return geometry != null && Supported(geometry.Bounds);
|
||||
}
|
||||
|
||||
private static bool Supported(OpenNest.Geometry.Box box) =>
|
||||
double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
|
||||
&& System.Math.Abs(box.Left) <= 1e8 && System.Math.Abs(box.Right) <= 1e8
|
||||
&& System.Math.Abs(box.Bottom) <= 1e8 && System.Math.Abs(box.Top) <= 1e8;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
@@ -36,7 +37,8 @@ public sealed class NestPipelineResult
|
||||
IReadOnlyList<string> violations,
|
||||
bool canKeep,
|
||||
TimeSpan solveTime,
|
||||
TimeSpan validationTime
|
||||
TimeSpan validationTime,
|
||||
IReadOnlyDictionary<string, Drawing> drawingsByPartId
|
||||
)
|
||||
{
|
||||
EngineName = engineName;
|
||||
@@ -47,6 +49,7 @@ public sealed class NestPipelineResult
|
||||
CanKeep = canKeep;
|
||||
SolveTime = solveTime;
|
||||
ValidationTime = validationTime;
|
||||
DrawingsByPartId = drawingsByPartId;
|
||||
}
|
||||
|
||||
public string EngineName { get; }
|
||||
@@ -62,6 +65,7 @@ public sealed class NestPipelineResult
|
||||
public NestJobStopReason StopReason => Raw.StopReason;
|
||||
public TimeSpan SolveTime { get; }
|
||||
public TimeSpan ValidationTime { get; }
|
||||
internal IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -72,6 +76,15 @@ public sealed class NestPipelineResult
|
||||
/// </summary>
|
||||
public static class NestPipeline
|
||||
{
|
||||
private sealed class PreviewProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
|
||||
{
|
||||
public void Report(NestJobProgress value)
|
||||
{
|
||||
if (value != null && value.Stage == NestJobStage.EvaluatingCandidate)
|
||||
inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
|
||||
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
|
||||
/// Engine cancellation propagates unchanged.</summary>
|
||||
@@ -93,6 +106,32 @@ public static class NestPipeline
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false);
|
||||
|
||||
/// <summary>Internal test-only integration trial. Normal Run never prepares cutouts;
|
||||
/// this entry point must not be called by production front ends before cutting/post gates.</summary>
|
||||
internal static NestPipelineResult RunCutoutPreview(
|
||||
INestingEngine engine,
|
||||
string engineName,
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true);
|
||||
|
||||
internal static NestPipelineResult RunCutoutPreview(
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
) => RunCutoutPreview(NestingEngineRegistry.Create(request.EngineName),
|
||||
request.EngineName, request, progress, token);
|
||||
|
||||
private static NestPipelineResult RunCore(
|
||||
INestingEngine engine,
|
||||
string engineName,
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress,
|
||||
CancellationToken token,
|
||||
bool cutoutPreview
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(engine);
|
||||
@@ -115,10 +154,15 @@ public static class NestPipeline
|
||||
|
||||
var job = new NestJob(parts, request.Stock, request.Options);
|
||||
NestJobValidator.Validate(job);
|
||||
var prepass = cutoutPreview ? CutoutPipelinePrepass.Prepare(job, token) : null;
|
||||
var engineJob = prepass?.EngineJob ?? job;
|
||||
if (prepass != null)
|
||||
NestJobValidator.Validate(engineJob);
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
var raw =
|
||||
engine.Solve(job, progress, token)
|
||||
engine.Solve(engineJob, prepass == null || progress == null
|
||||
? progress : new PreviewProgress(progress), token)
|
||||
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
|
||||
var solveTime = clock.Elapsed;
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -129,7 +173,33 @@ public static class NestPipeline
|
||||
kv => kv.Value.Name ?? kv.Key,
|
||||
StringComparer.Ordinal
|
||||
);
|
||||
var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep);
|
||||
var checkedNames = prepass == null ? names : engineJob.Parts.ToDictionary(p => p.Id,
|
||||
p => names.GetValueOrDefault(p.Id, p.Id), StringComparer.Ordinal);
|
||||
var violations = NestLayoutCheck.Violations(engineJob, raw, checkedNames, out var canKeep).ToList();
|
||||
if (prepass != null)
|
||||
{
|
||||
// A compound may be physically invalid even when its envelope is legal.
|
||||
// Refuse every unverified trial, including with explicit invalid-result consent.
|
||||
var failure = "Cutout engine output failed reconciliation";
|
||||
if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure))
|
||||
{
|
||||
raw = expanded;
|
||||
violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep));
|
||||
if (raw.Status != NestJobStatus.Complete)
|
||||
violations.Add("Cutout engine did not complete the transformed job; unplaced frames' reserved inserts remain unplaced in the original job");
|
||||
canKeep = physicalKeep && violations.Count == 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (violations.Count == 0)
|
||||
violations.Add(failure);
|
||||
canKeep = false;
|
||||
}
|
||||
}
|
||||
var freshness = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId);
|
||||
violations.AddRange(freshness);
|
||||
if (freshness.Count > 0)
|
||||
canKeep = false;
|
||||
var validationTime = clock.Elapsed;
|
||||
if (canKeep && violations.Count == 0)
|
||||
_ = NestJobCost.Evaluate(job, raw);
|
||||
@@ -143,6 +213,31 @@ public static class NestPipeline
|
||||
.ToList()
|
||||
: new List<ProposedPlate>();
|
||||
|
||||
// Binding clones the caller's current Program; a concurrent edit during binding
|
||||
// must not return parts whose bytes differ from the already validated snapshot.
|
||||
if (canKeep)
|
||||
{
|
||||
var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId).ToList();
|
||||
changes.AddRange(NestPipelineDrawingFreshness.BoundChanges(job, raw, plates, drawingsByPartId));
|
||||
if (changes.Count > 0)
|
||||
{
|
||||
violations.AddRange(changes);
|
||||
plates.Clear();
|
||||
canKeep = false;
|
||||
}
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (prepass != null && canKeep)
|
||||
{
|
||||
var count = 0;
|
||||
for (var i = 0; i < raw.Plates.Count; i++)
|
||||
{
|
||||
count += raw.Plates[i].Placements.Count;
|
||||
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
|
||||
raw.Plates[i].StockId, i, i + 1, count));
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new NestPipelineResult(
|
||||
engineName,
|
||||
@@ -152,7 +247,8 @@ public static class NestPipeline
|
||||
violations,
|
||||
canKeep,
|
||||
solveTime,
|
||||
validationTime
|
||||
validationTime,
|
||||
drawingsByPartId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,11 @@ public static class NestPipelineCommit
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!result.CanKeep || (!result.IsValid && !allowInvalid))
|
||||
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
|
||||
if (NestPipelineDrawingFreshness.Changes(result.Job, result.DrawingsByPartId).Count > 0
|
||||
|| NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
|
||||
result.Plates, result.DrawingsByPartId).Count > 0)
|
||||
throw new InvalidOperationException("A drawing or proposed part changed after the nesting proposal was validated; run Auto Nest again.");
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var applied = new List<Plate>();
|
||||
// Commit is synchronous on the caller's owning thread. Cancellation is checked
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>Refuse a proposal when a caller drawing no longer matches its checked snapshot.</summary>
|
||||
internal static class NestPipelineDrawingFreshness
|
||||
{
|
||||
internal static IReadOnlyList<string> Changes(NestJob job,
|
||||
IReadOnlyDictionary<string, Drawing> drawings)
|
||||
{
|
||||
var changed = new List<string>();
|
||||
foreach (var requirement in job.Parts)
|
||||
{
|
||||
if (!drawings.TryGetValue(requirement.Id, out var drawing)
|
||||
|| drawing?.Program == null)
|
||||
{
|
||||
changed.Add($"Drawing for '{requirement.Id}' changed after the nesting snapshot");
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
var current = PartGeometrySnapshot.FromProgram(drawing.Program);
|
||||
if (Same(requirement.Geometry, current))
|
||||
continue;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
|
||||
or InvalidOperationException)
|
||||
{
|
||||
// A now-unsupported program is not the geometry that was checked.
|
||||
}
|
||||
changed.Add($"Drawing '{drawing.Name ?? requirement.Id}' changed after the nesting snapshot");
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>Check the actual cloned programs, not only the drawing before/after
|
||||
/// binding. A transient edit during Clone may leave the drawing unchanged.</summary>
|
||||
internal static IReadOnlyList<string> BoundChanges(NestJob job, NestJobResult raw,
|
||||
IReadOnlyList<ProposedPlate> plates, IReadOnlyDictionary<string, Drawing> drawings)
|
||||
{
|
||||
var changed = new List<string>();
|
||||
var requirements = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
||||
if (plates.Count != raw.Plates.Count)
|
||||
return new[] { "Bound plate count changed after validation" };
|
||||
for (var sheetIndex = 0; sheetIndex < plates.Count; sheetIndex++)
|
||||
{
|
||||
var poses = raw.Plates[sheetIndex].Placements;
|
||||
var bound = plates[sheetIndex].Parts;
|
||||
if (bound.Count != poses.Count)
|
||||
return new[] { "Bound part count changed after validation" };
|
||||
for (var index = 0; index < poses.Count; index++)
|
||||
{
|
||||
var pose = poses[index];
|
||||
var actual = bound[index];
|
||||
if (pose.PartId == null || !requirements.TryGetValue(pose.PartId, out var requirement)
|
||||
|| !drawings.TryGetValue(pose.PartId, out var drawing)
|
||||
|| !ReferenceEquals(actual.BaseDrawing, drawing))
|
||||
return new[] { "A bound part no longer matches its requirement" };
|
||||
try
|
||||
{
|
||||
var expected = new Part(DrawingJobMapper.CreateDrawing(requirement));
|
||||
expected.Rotate(pose.Rotation);
|
||||
expected.Location = new Vector(pose.X, pose.Y);
|
||||
if (Same(PartGeometrySnapshot.FromProgram(expected.Program),
|
||||
PartGeometrySnapshot.FromProgram(actual.Program))
|
||||
&& Bits(expected.Location.X) == Bits(actual.Location.X)
|
||||
&& Bits(expected.Location.Y) == Bits(actual.Location.Y)
|
||||
&& Bits(expected.Rotation) == Bits(actual.Rotation))
|
||||
continue;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
|
||||
or InvalidOperationException)
|
||||
{
|
||||
// A changed/unsupported program cannot represent the checked pose.
|
||||
}
|
||||
changed.Add($"Bound part for '{drawing.Name ?? requirement.Id}' changed after validation");
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static bool Same(PartGeometrySnapshot a, PartGeometrySnapshot b) =>
|
||||
a.Mode == b.Mode && a.Motions.Count == b.Motions.Count
|
||||
&& a.Motions.Zip(b.Motions).All(pair =>
|
||||
pair.First.Type == pair.Second.Type
|
||||
&& Bits(pair.First.X) == Bits(pair.Second.X)
|
||||
&& Bits(pair.First.Y) == Bits(pair.Second.Y)
|
||||
&& Bits(pair.First.CenterX) == Bits(pair.Second.CenterX)
|
||||
&& Bits(pair.First.CenterY) == Bits(pair.Second.CenterY)
|
||||
&& pair.First.Rotation == pair.Second.Rotation
|
||||
&& pair.First.Layer == pair.Second.Layer
|
||||
&& pair.First.Suppressed == pair.Second.Suppressed);
|
||||
|
||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||
}
|
||||
Reference in new issue
Block a user