diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
index 8d1ab15..e92028c 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
@@ -21,7 +21,14 @@ public sealed class ReleasedContourState
/// Consumes one whole owned program; part numbers are caller identity keys.
public IReadOnlyList Check(OwnedExecution execution, Vector? arrival,
- int partNumber, CancellationToken token = default)
+ int partNumber, CancellationToken token = default) => Check(execution, arrival, partNumber, false, token);
+
+ ///
+ /// As the analyzer does for cutoffs: rapids are checked, but cutoff cuts need no lead-in and,
+ /// being open, never become obstacles.
+ ///
+ public IReadOnlyList Check(OwnedExecution execution, Vector? arrival,
+ int partNumber, bool cutoff, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(execution);
if (arrival is { } point)
@@ -29,7 +36,7 @@ public sealed class ReleasedContourState
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List();
- AnalyzeMoves(moves, false, findings, 1, partNumber, token);
+ AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token);
return findings.AsReadOnly();
}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs b/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs
index 3d14d0f..634ff90 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs
@@ -1,11 +1,114 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.CuttingPlanning;
-/// Whole-part cutting prerequisites shared by automatic sequencing and cutting planning.
-internal static class CuttingDependencyGraph
+///
+/// Whole-part cutting prerequisites for one captured plate. A cutoff precedes every part its
+/// nominal span crosses (an orphaned cutoff, whose definition is missing, precedes every part),
+/// and a part proven to lie inside a cutout of another part precedes that host. Built on the
+/// caller thread from owned values; ambiguous containment refuses instead of guessing from bounds.
+///
+internal sealed class CuttingDependencyGraph
{
+ private const int ContactBudget = 1000000;
+ private readonly int[][] prerequisites;
+
+ private CuttingDependencyGraph(int[][] prerequisites) => this.prerequisites = prerequisites;
+
+ internal static CuttingDependencyGraph Empty(int count) =>
+ new(Enumerable.Range(0, count).Select(_ => Array.Empty()).ToArray());
+
+ /// Exact prerequisites, for tests and callers that already proved them.
+ internal static CuttingDependencyGraph FromPrerequisites(IReadOnlyList prerequisites)
+ {
+ var copy = prerequisites.Select(p => p.Distinct().Order().ToArray()).ToArray();
+ if (copy.SelectMany(p => p).Any(i => i < 0 || i >= copy.Length))
+ throw new ArgumentException("Prerequisite ordinal out of range.");
+ return new(copy);
+ }
+
+ internal int Count => prerequisites.Length;
+
+ internal IReadOnlyList PrerequisitesOf(int ordinal) => prerequisites[ordinal];
+
+ internal bool IsReady(int ordinal, IEnumerable done)
+ {
+ if (prerequisites[ordinal].Length == 0)
+ return true;
+ var finished = done as ICollection ?? done.ToHashSet();
+ return prerequisites[ordinal].All(finished.Contains);
+ }
+
+ /// First (part, missing prerequisite) in a proposed complete order, or null.
+ internal (int Part, int Prerequisite)? FirstViolation(IReadOnlyList order)
+ {
+ var position = Enumerable.Repeat(-1, prerequisites.Length).ToArray();
+ for (var i = 0; i < order.Count; i++)
+ position[order[i]] = i;
+ foreach (var part in order)
+ foreach (var prerequisite in prerequisites[part])
+ if (position[prerequisite] < 0 || position[prerequisite] > position[part])
+ return (part, prerequisite);
+ return null;
+ }
+
+ /// An ordinal on a prerequisite cycle, or null when the graph is acyclic.
+ internal int? FindCycle()
+ {
+ var remaining = prerequisites.Select(p => p.Length).ToArray();
+ var dependents = Enumerable.Range(0, prerequisites.Length).Select(_ => new List()).ToArray();
+ for (var part = 0; part < prerequisites.Length; part++)
+ foreach (var prerequisite in prerequisites[part])
+ dependents[prerequisite].Add(part);
+ var ready = new Queue(Enumerable.Range(0, prerequisites.Length).Where(i => remaining[i] == 0));
+ while (ready.Count != 0)
+ foreach (var dependent in dependents[ready.Dequeue()])
+ if (--remaining[dependent] == 0)
+ ready.Enqueue(dependent);
+ var cyclic = Array.FindIndex(remaining, r => r != 0);
+ return cyclic < 0 ? null : cyclic;
+ }
+
+ internal static CuttingDependencyGraph Build(IReadOnlyList nodes, Box plate,
+ CancellationToken token)
+ {
+ var edges = nodes.Select(_ => new SortedSet()).ToArray();
+ for (var cut = 0; cut < nodes.Count; cut++)
+ {
+ if (!nodes[cut].IsCutOff)
+ continue;
+ var definition = nodes[cut].CutOff;
+ for (var part = 0; part < nodes.Count; part++)
+ {
+ token.ThrowIfCancellationRequested();
+ if (!nodes[part].IsCutOff && (definition == null || CutOffCrosses(definition.Axis,
+ definition.Position, definition.StartLimit, definition.EndLimit, nodes[part].PlacedBounds, plate)))
+ edges[part].Add(cut);
+ }
+ }
+ for (var inner = 0; inner < nodes.Count; inner++)
+ for (var host = 0; host < nodes.Count; host++)
+ {
+ token.ThrowIfCancellationRequested();
+ // Bounds only select candidates; containment itself is proven on native material.
+ if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff
+ && Within(nodes[inner].CleanBounds, nodes[host].HostBounds)
+ && InsideCutout(nodes, inner, host, token))
+ edges[host].Add(inner);
+ }
+ var graph = new CuttingDependencyGraph(edges.Select(e => e.ToArray()).ToArray());
+ if (graph.FindCycle() is { } cyclic)
+ throw new CuttingDependencyException(CuttingPlanStatus.ConstraintConflict, cyclic, null,
+ $"Part {cyclic + 1} is on a cutting dependency cycle.");
+ return graph;
+ }
+
///
/// Whether a cutoff's nominal line crosses a part's bounds within the cutoff's span. Uses the
/// nominal line, not its trimmed cutting segments (which deliberately skip the parts). Bounds
@@ -28,4 +131,82 @@ internal static class CuttingDependencyGraph
|| System.Math.Max(start, end) < alongMin - Tolerance.Epsilon
|| System.Math.Min(start, end) > alongMax + Tolerance.Epsilon);
}
+
+ // True when the inner part's perimeter lies strictly inside one cutout of the host. Any
+ // boundary contact or crossing between them, or incomplete material, is ambiguous.
+ private static bool InsideCutout(IReadOnlyList nodes, int inner, int host,
+ CancellationToken token)
+ {
+ var part = nodes[inner].Material;
+ var enclosing = nodes[host].Material;
+ if (!part.IsComplete || !enclosing.IsComplete)
+ throw Ambiguous(inner, host, part.Reason ?? enclosing.Reason);
+ var budget = ContactBudget;
+ foreach (var curve in part.Rings[0])
+ foreach (var ring in enclosing.Rings)
+ foreach (var boundary in ring)
+ {
+ token.ThrowIfCancellationRequested();
+ if (--budget < 0)
+ throw Ambiguous(inner, host, "the containment check exceeds the native query limit");
+ if (curve.Contacts(boundary, out var overlap).Count != 0 || overlap)
+ throw Ambiguous(inner, host, "their boundaries touch or cross");
+ }
+ var probe = part.Rings[0][0].Start;
+ return enclosing.Rings.Skip(1).Any(hole => LeadMaterialSnapshot.Inside(probe, hole, token));
+ }
+
+ private static CuttingDependencyException Ambiguous(int inner, int host, string reason) =>
+ new(CuttingPlanStatus.UnsupportedGeometry, inner, host,
+ $"Containment of part {inner + 1} relative to part {host + 1} cannot be established: {reason}.");
+
+ private static bool Within(Box inner, Box outer) =>
+ inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
+ && inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
+}
+
+/// Owned copy of a cutoff definition's nominal line.
+internal sealed record CutOffDefinition(CutOffAxis Axis, Vector Position, double? StartLimit, double? EndLimit);
+
+/// Owned per-placement dependency input; material is captured only when a pair needs it.
+internal sealed class DependencyNode
+{
+ private readonly Func materialFactory;
+ private LeadMaterialSnapshot material;
+
+ internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds,
+ Func materialFactory)
+ {
+ IsCutOff = isCutOff;
+ CutOff = cutOff;
+ PlacedBounds = Copy(placedBounds);
+ CleanBounds = cleanBounds == null ? null : Copy(cleanBounds);
+ HostBounds = cleanBounds == null ? PlacedBounds : Union(PlacedBounds, CleanBounds);
+ this.materialFactory = materialFactory;
+ }
+
+ internal bool IsCutOff { get; }
+ internal CutOffDefinition CutOff { get; }
+ internal Box PlacedBounds { get; }
+ internal Box CleanBounds { get; }
+ internal Box HostBounds { get; }
+ internal LeadMaterialSnapshot Material => material ??= materialFactory();
+
+ private static Box Copy(Box box) => new(box.X, box.Y, box.Length, box.Width);
+
+ private static Box Union(Box a, Box b)
+ {
+ var left = System.Math.Min(a.Left, b.Left);
+ var bottom = System.Math.Min(a.Bottom, b.Bottom);
+ return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
+ }
+}
+
+/// A dependency refusal naming the part and, when relevant, the other part.
+internal sealed class CuttingDependencyException(CuttingPlanStatus status, int ordinal, int? other, string message)
+ : Exception(message)
+{
+ internal CuttingPlanStatus Status { get; } = status;
+ internal int Ordinal { get; } = ordinal;
+ internal int? Other { get; } = other;
}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
index 58128b2..e3f3d98 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
@@ -60,11 +60,12 @@ public sealed class CuttingPlanSnapshot
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable findings = null,
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
Action expansionObserver = null, PlateCuttingState plateState = null,
- CuttingParameters ownedParameters = null)
+ CuttingParameters ownedParameters = null, CuttingDependencyGraph dependencies = null)
{
PlateState = plateState;
OwnedParameters = ownedParameters;
Placements = Array.AsReadOnly(placements.ToArray());
+ Dependencies = dependencies ?? CuttingDependencyGraph.Empty(Placements.Count);
StartPoint = startPoint;
ExpansionBudget = expansionBudget;
Failure = failure;
@@ -75,6 +76,8 @@ public sealed class CuttingPlanSnapshot
ExpansionObserver = expansionObserver;
}
+ /// Whole-part prerequisites by source ordinal (cutoffs, inner parts before hosts).
+ internal CuttingDependencyGraph Dependencies { get; }
/// Exact captured plate state for plate-scoped requests; null for detached part lists.
internal PlateCuttingState PlateState { get; }
/// Owned copy of the confirmed parameters taken at capture; never the caller's object.
@@ -135,9 +138,12 @@ public sealed class FixedProgramPlacement
}
return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
- { SelectedProgram = selected };
+ { SelectedProgram = selected, IsCutOff = IsCutOff };
}
+ /// A cutoff: always a fixed open-cut program, never material or regenerated.
+ public bool IsCutOff { get; internal init; }
+
public Part SourcePart { get; }
public int SourceOrdinal { get; }
public Vector Location { get; }
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
index 684a707..2e5a758 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
@@ -44,6 +44,12 @@ public static class CuttingPlanService
if (part == null || !request.Parts.Any(p => ReferenceEquals(p, part)) || !eligible.Add(part))
throw new ArgumentException("Foreign or duplicate eligible placement.");
}
+ // Cutoff definitions by drawing reference, as automatic sequencing matches them.
+ var definitions = new Dictionary(ReferenceEqualityComparer.Instance);
+ foreach (var cutOff in request.Plate?.CutOffs ?? [])
+ if (cutOff?.Drawing != null)
+ definitions[cutOff.Drawing] = cutOff;
+ var nodes = new List();
// Reuse the reader's coordinate validation without publishing its native kernel.
var identities = new HashSet(ReferenceEqualityComparer.Instance);
for (var index = 0; index < request.Parts.Count; index++)
@@ -54,12 +60,28 @@ public static class CuttingPlanService
if (source?.BaseDrawing == null || source.Program == null || !double.IsFinite(source.Rotation)
|| !identities.Add(source))
throw new ArgumentException("Missing/duplicate source placement or invalid pose.");
- if (source.BaseDrawing.IsCutOff)
- throw new NotSupportedException("Cutoff dependency ordering is outside the fixed-program route slice.");
// Validate both original graphs before Clone or any virtual transform can
// erase unsupported runtime semantics, including fixed/ineligible targets.
OwnedProgramCopy.Validate(source.BaseDrawing.Program, token);
OwnedProgramCopy.Validate(source.Program, token);
+ if (source.BaseDrawing.IsCutOff)
+ {
+ // A cutoff is a fixed open cut: never material, never regenerated.
+ if (request.Plate == null)
+ throw new NotSupportedException("Cutoff dependencies require a plate-scoped request.");
+ if (eligible.Contains(source))
+ throw new ArgumentException("A cutoff cannot be eligible for regeneration.");
+ var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
+ if (!cut.HasCuttingContour)
+ throw new ArgumentException("Placed cutoff program has no nonzero cutting motions.");
+ nodes.Add(new(true, definitions.TryGetValue(source.BaseDrawing, out var definition)
+ ? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null,
+ source.BoundingBox, null, null));
+ placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
+ cut, OwnedProgramCopy.Copy(source.Program, token))
+ { IsCutOff = true });
+ continue;
+ }
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
if (!clean.HasCuttingContour)
throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice.");
@@ -69,35 +91,52 @@ public static class CuttingPlanService
var ownedProgram = OwnedProgramCopy.Copy(source.Program, token);
PreparedContours prepared = null;
LeadMaterialSnapshot material = null;
+ // Geometry-only transform: original graphs were strictly validated and
+ // clone expansion bounded above. Legacy Rotate visits per parent, so
+ // retain Clone's per-parent sharing here instead of restoring diamonds.
+ // Exact placed/proposed payloads still use lossless OwnedProgramCopy.
+ var ownedClean = (Program)source.BaseDrawing.Program.Clone();
+ ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
+ var cleanBounds = ownedClean.BoundingBox();
+ cleanBounds.Offset(source.Location);
+ var location = source.Location;
if (request.ConfirmedParameters != null)
{
- // Geometry-only transform: original graphs were strictly validated and
- // clone expansion bounded above. Legacy Rotate visits per parent, so
- // retain Clone's per-parent sharing here instead of restoring diamonds.
- // Exact placed/proposed payloads still use lossless OwnedProgramCopy.
- var ownedClean = (Program)source.BaseDrawing.Program.Clone();
- ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
- material = LeadMaterialSnapshot.Capture(ownedClean, source.Location, token);
+ material = LeadMaterialSnapshot.Capture(ownedClean, location, token);
if (!material.IsComplete)
throw new NotSupportedException(material.Reason);
if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source)))
prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token);
}
+ var captured = material;
+ // Fixed-route material is captured only for a pair whose bounds admit containment.
+ nodes.Add(new(false, null, source.BoundingBox, cleanBounds,
+ () => captured ?? LeadMaterialSnapshot.Capture(ownedClean, location, token)));
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
execution, ownedProgram, prepared, material));
}
// The start can be invalid even when the first rapid has no cutting geometry.
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
+ source = null;
+ ordinal = null;
+ var dependencies = CuttingDependencyGraph.Build(nodes, request.Plate?.BoundingBox(includeParts: false), token);
return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null,
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
expansionObserver: request.ExpansionObserver, plateState: plateState,
ownedParameters: request.ConfirmedParameters == null ? null
- : OwnedCuttingParameters.Copy(request.ConfirmedParameters));
+ : OwnedCuttingParameters.Copy(request.ConfirmedParameters),
+ dependencies: dependencies);
}
catch (OperationCanceledException)
{
return Failure(CuttingPlanStatus.Cancelled, "Capture cancelled.");
}
+ catch (CuttingDependencyException exception)
+ {
+ var other = exception.Other is { } index ? request.Parts[index] : null;
+ return new(placements, request.StartPoint, request.ExpansionBudget, exception.Status,
+ [new(exception.Ordinal, request.Parts[exception.Ordinal], exception.Other, other, null, exception.Message)]);
+ }
catch (NotSupportedException exception)
{
return Failure(CuttingPlanStatus.UnsupportedGeometry, exception.Message);
@@ -172,6 +211,10 @@ public static class CuttingPlanService
if (snapshot.Placements.Count == 0)
return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput)
: new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op.
+ if (snapshot.PreservePartOrder
+ && snapshot.Dependencies.FirstViolation(Enumerable.Range(0, snapshot.Placements.Count).ToArray()) is { } manual)
+ return new(CuttingPlanStatus.ConstraintConflict,
+ findings: [DependencyFinding(snapshot, manual, "The preserved part order")]);
try
{
var fixedFindings = new List();
@@ -182,7 +225,7 @@ public static class CuttingPlanService
continue; // An eligible old crossing is precisely what regeneration may repair.
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
var findings = new ReleasedContourState().Check(placement.Execution, null,
- placement.SourceOrdinal + 1, token);
+ placement.SourceOrdinal + 1, placement.IsCutOff, token);
fixedFindings.AddRange(Map(snapshot, findings));
}
if (fixedFindings.Count != 0)
@@ -215,6 +258,10 @@ public static class CuttingPlanService
if (order.Count != snapshot.Placements.Count || order.Distinct().Count() != order.Count
|| order.Any(index => index < 0 || index >= snapshot.Placements.Count))
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
+ // Dependencies are rechecked from the captured graph, not trusted from the search.
+ if (snapshot.Dependencies.FirstViolation(order) is { } violation)
+ return new(CuttingPlanStatus.InvalidInput,
+ findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState();
var position = snapshot.StartPoint;
var distance = 0.0;
@@ -223,7 +270,8 @@ public static class CuttingPlanService
{
token.ThrowIfCancellationRequested();
var placement = snapshot.Placements[index];
- findings.AddRange(checker.Check(placement.Execution, position, placement.SourceOrdinal + 1, token));
+ findings.AddRange(checker.Check(placement.Execution, position, placement.SourceOrdinal + 1,
+ placement.IsCutOff, token));
distance += placement.Execution.RapidDistanceFrom(position);
position = placement.Execution.DeparturePoint;
}
@@ -245,11 +293,14 @@ public static class CuttingPlanService
|| order.Any(p => p == null || p.SourceOrdinal < 0 || p.SourceOrdinal >= snapshot.Placements.Count)
|| order.Select(p => p.SourceOrdinal).Distinct().Count() != order.Count)
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
+ if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
+ return new(CuttingPlanStatus.InvalidInput,
+ findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState();
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List();
- var materials = snapshot.Placements.Select(p => p.Material).ToArray();
+ var materials = snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
foreach (var proposal in order)
{
token.ThrowIfCancellationRequested();
@@ -258,6 +309,7 @@ public static class CuttingPlanService
|| !SameBits(source.Location.X, proposal.Location.X) || !SameBits(source.Location.Y, proposal.Location.Y)
|| !SameBits(source.Rotation, proposal.Rotation) || source.LeadInsLocked != proposal.LeadInsLocked
|| !ReferenceEquals(source.Prepared, proposal.Prepared) || !ReferenceEquals(source.Material, proposal.Material)
+ || source.IsCutOff != proposal.IsCutOff
|| snapshot.PreservePartOrder && proposal.SourceOrdinal != order.TakeWhile(p => !ReferenceEquals(p, proposal)).Count())
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
if (source.Prepared == null)
@@ -273,7 +325,9 @@ public static class CuttingPlanService
try
{
execution = ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, position, token);
- complete = ContourProgramVerifier.Verify(execution, source.Material, proposal.SelectedProgram, token);
+ // A cutoff is an open cut with no material; it is retained exactly (checked above).
+ complete = source.IsCutOff
+ || ContourProgramVerifier.Verify(execution, source.Material, proposal.SelectedProgram, token);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
@@ -281,8 +335,9 @@ public static class CuttingPlanService
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
}
findings.AddRange(JointCuttingPlanSearch.Map(snapshot,
- checker.Check(execution, position, source.SourceOrdinal + 1, token)));
- var lead = LeadPathValidator.Check(execution, source.Material, materials, token);
+ checker.Check(execution, position, source.SourceOrdinal + 1, source.IsCutOff, token)));
+ var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
+ : LeadPathValidator.Check(execution, source.Material, materials, token);
if (!lead.IsComplete || !lead.IsClear)
findings.Add(JointCuttingPlanSearch.Finding(source,
lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
@@ -303,6 +358,16 @@ public static class CuttingPlanService
rapidDistance: distance, independentlyReplayed: true);
}
+ private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
+ (int Part, int Prerequisite) violation, string subject)
+ {
+ var part = snapshot.Placements[violation.Part];
+ var prerequisite = snapshot.Placements[violation.Prerequisite];
+ var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout";
+ return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
+ $"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1}).");
+ }
+
private static bool SameBits(double source, double proposed) =>
BitConverter.DoubleToInt64Bits(source) == BitConverter.DoubleToInt64Bits(proposed);
diff --git a/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs b/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs
index 156d636..95208a7 100644
--- a/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs
@@ -36,7 +36,8 @@ internal static class FixedProgramSearch
expansions++;
var placement = snapshot.Placements[candidate];
var checker = frame.Checker.Copy();
- var findings = checker.Check(placement.Execution, frame.Position, placement.SourceOrdinal + 1, token);
+ var findings = checker.Check(placement.Execution, frame.Position, placement.SourceOrdinal + 1,
+ placement.IsCutOff, token);
if (findings.Count != 0)
{
rejected.UnionWith(findings);
@@ -48,6 +49,7 @@ internal static class FixedProgramSearch
Frame Create(int[] order, Vector position, ReleasedContourState checker) => new(order, position, checker,
Enumerable.Range(0, snapshot.Placements.Count).Where(index => !order.Contains(index)
+ && snapshot.Dependencies.IsReady(index, order)
&& (!snapshot.PreservePartOrder || index == order.Length))
.OrderBy(index => snapshot.Placements[index].Execution.RapidDistanceFrom(position))
.ThenBy(index => snapshot.Placements[index].SourceOrdinal).ToArray());
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index ea3fa85..fa4c732 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -19,7 +19,7 @@ internal static class JointCuttingPlanSearch
{
var expansions = 0;
var rejected = new List();
- var materials = snapshot.Placements.Select(p => p.Material).ToArray();
+ var materials = snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
var stack = new Stack();
stack.Push(new(new([], snapshot.StartPoint, new ReleasedContourState(), null)));
try
@@ -60,6 +60,7 @@ internal static class JointCuttingPlanSearch
{
var sources = node.Active is { } active ? new[] { active.Source }
: snapshot.Placements.Where(p => !node.Order.Any(o => o.SourceOrdinal == p.SourceOrdinal)
+ && snapshot.Dependencies.IsReady(p.SourceOrdinal, node.Order.Select(o => o.SourceOrdinal).ToArray())
&& (!snapshot.PreservePartOrder || p.SourceOrdinal == node.Order.Length));
foreach (var source in sources)
{
@@ -129,9 +130,11 @@ internal static class JointCuttingPlanSearch
bool Check(FixedProgramPlacement source, OwnedExecution execution, Vector arrival, ReleasedContourState checker)
{
- var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, token);
+ var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, source.IsCutOff, token);
rejected.AddRange(Map(snapshot, findings));
- var lead = LeadPathValidator.Check(execution, source.Material, materials, token);
+ // A fixed cutoff has no material or leads to certify; its rapids are still checked.
+ var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
+ : LeadPathValidator.Check(execution, source.Material, materials, token);
if (!lead.IsComplete || !lead.IsClear)
rejected.Add(Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
return findings.Count == 0 && lead.IsComplete && lead.IsClear;
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs
new file mode 100644
index 0000000..3432d5f
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs
@@ -0,0 +1,271 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class CuttingDependencyTests
+{
+ // Started just above P, nearest-first cuts P before the far cutoff start. P leaves upward
+ // and the cutoff's trimmed cut stops short of P, so P, cutoff, Q crosses nothing: only the
+ // nominal-span dependency puts the cutoff first.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Plan_CutOffWhoseNominalSpanCrossesAPart_IsCutFirstAndAppliedThatWay(bool regenerate)
+ {
+ var parameters = ExplicitContourTests.Parameters();
+ var (_, plate, p, q, cut) = CutOffPlate(parameters, orphan: false);
+ var request = new CuttingPlanRequest(plate, new Vector(11, 12.5),
+ confirmedParameters: regenerate ? parameters : null);
+ var snapshot = CuttingPlanService.Capture(request);
+ Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(0));
+ Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
+ Assert.Empty(snapshot.Dependencies.PrerequisitesOf(2));
+
+ var result = CuttingPlanService.Plan(snapshot);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ var order = result.ProposedOrder.Select(o => o.SourcePart).ToList();
+ Assert.True(order.IndexOf(cut) < order.IndexOf(p), string.Join(",", result.ProposedOrder.Select(o => o.SourceOrdinal)));
+ Assert.Equal(regenerate, result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, p)).IsRegenerated);
+ Assert.False(result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, cut)).IsRegenerated);
+
+ Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
+ Assert.Equal(order, plate.Parts);
+ Assert.Contains(q, plate.Parts);
+ }
+
+ [Fact]
+ public void Plan_OrphanedCutOff_PrecedesEveryPart()
+ {
+ var (_, plate, p, q, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: true);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(plate, new Vector(11, 12.5)));
+ Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(0));
+ Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(1));
+
+ var result = CuttingPlanService.Plan(snapshot);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.Same(cut, result.ProposedOrder[0].SourcePart);
+ }
+
+ [Fact]
+ public void Plan_PreservedOrderCuttingAPartBeforeItsCutOff_IsAConstraintConflict()
+ {
+ var (_, plate, p, _, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate, new Vector(11, 12.5), preservePartOrder: true));
+
+ Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
+ Assert.Empty(result.ProposedOrder);
+ var finding = Assert.Single(result.Findings);
+ Assert.Same(p, finding.SourcePart);
+ Assert.Same(cut, finding.OtherSourcePart);
+ Assert.Contains("cutoff", finding.Message);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Replay_RechecksDependenciesInsteadOfTrustingTheSearch(bool regenerate)
+ {
+ var parameters = ExplicitContourTests.Parameters();
+ var (_, plate, _, _, _) = CutOffPlate(parameters, orphan: false);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(plate, new Vector(11, 12.5),
+ confirmedParameters: regenerate ? parameters : null));
+ var ready = CuttingPlanService.Plan(snapshot);
+ Assert.Equal(CuttingPlanStatus.Ready, ready.Status);
+ // Same verified placements, counterfeit order: crossed part before its cutoff.
+ var counterfeit = ready.ProposedOrder.OrderBy(o => o.SourceOrdinal).ToArray();
+
+ var replayed = regenerate
+ ? CuttingPlanService.ReplayPrograms(snapshot, counterfeit, 0, default)
+ : CuttingPlanService.Replay(snapshot, counterfeit.Select(o => o.SourceOrdinal).ToArray(), 0, default);
+
+ Assert.Equal(CuttingPlanStatus.InvalidInput, replayed.Status);
+ Assert.Empty(replayed.ProposedOrder);
+ Assert.Contains(replayed.Findings, f => f.SourceOrdinal == 0 && f.OtherSourceOrdinal == 2);
+ }
+
+ [Fact]
+ public void Capture_DetachedListWithACutOff_IsUnsupported()
+ {
+ var (_, plate, _, _, _) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate.Parts.ToArray()));
+
+ Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
+ Assert.Contains("plate-scoped", Assert.Single(result.Findings).Message);
+ }
+
+ [Fact]
+ public void Plan_PartInsideAHostCutout_IsCutBeforeTheHost()
+ {
+ var (plate, host, inner) = NestedPlate(Rectangle(2.7, 2.7, 0.6, 0.6));
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(plate));
+ Assert.Equal(new[] { 1 }, snapshot.Dependencies.PrerequisitesOf(0));
+ Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
+
+ var result = CuttingPlanService.Plan(snapshot);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.Equal(new[] { inner, host }, result.ProposedOrder.Select(o => o.SourcePart));
+
+ var preserved = CuttingPlanService.Plan(new CuttingPlanRequest(plate, preservePartOrder: true));
+ Assert.Equal(CuttingPlanStatus.ConstraintConflict, preserved.Status);
+ var finding = Assert.Single(preserved.Findings);
+ Assert.Same(host, finding.SourcePart);
+ Assert.Same(inner, finding.OtherSourcePart);
+ Assert.Contains("nested in its cutout", finding.Message);
+ }
+
+ [Fact]
+ public void Capture_PartStraddlingAHostCutoutEdge_IsAmbiguousContainment()
+ {
+ var (plate, host, inner) = NestedPlate(Rectangle(3.6, 2.8, 0.8, 0.4));
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate));
+
+ Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
+ var finding = Assert.Single(result.Findings);
+ Assert.Same(inner, finding.SourcePart);
+ Assert.Same(host, finding.OtherSourcePart);
+ Assert.Contains("Containment", finding.Message);
+ }
+
+ [Fact]
+ public void Capture_PartInAConcavePocketOutsideTheHost_HasNoDependency()
+ {
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ var host = Pocketed();
+ var inner = Rectangle(4.5, 5, 1, 1);
+ plate.Parts.Add(host);
+ plate.Parts.Add(inner);
+
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(plate));
+
+ Assert.Null(snapshot.Findings.FirstOrDefault()?.Message);
+ Assert.Empty(snapshot.Dependencies.PrerequisitesOf(0));
+ Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
+ Assert.Equal(CuttingPlanStatus.Ready, CuttingPlanService.Plan(snapshot).Status);
+ }
+
+ [Fact]
+ public void DependencyGraph_ReportsCyclesAndFirstViolation()
+ {
+ Assert.NotNull(CuttingDependencyGraph.FromPrerequisites([[1], [0], []]).FindCycle());
+ var chain = CuttingDependencyGraph.FromPrerequisites([[], [0], [1]]);
+ Assert.Null(chain.FindCycle());
+ Assert.Null(chain.FirstViolation([0, 1, 2]));
+ Assert.Equal((2, 1), chain.FirstViolation([0, 2, 1]));
+ Assert.True(chain.IsReady(1, [0]));
+ Assert.False(chain.IsReady(2, [0]));
+ }
+
+ private static (Nest, Plate, Part P, Part Q, Part Cut) CutOffPlate(CuttingParameters parameters, bool orphan)
+ {
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ var p = Emitted(parameters, new Vector(10, 10));
+ var q = Emitted(parameters, new Vector(40, 20));
+ var cutOff = new CutOff(new Vector(11, 0), CutOffAxis.Vertical) { StartLimit = 0, EndLimit = 30 };
+ var program = new Program();
+ program.Codes.Add(new RapidMove(11, 30));
+ program.Codes.Add(new LinearMove(11, 12.5) { Layer = LayerType.Cut });
+ cutOff.Drawing.Program = program;
+ if (!orphan)
+ plate.CutOffs.Add(cutOff);
+ var cut = new Part(cutOff.Drawing);
+ plate.Parts.Add(p);
+ plate.Parts.Add(q);
+ plate.Parts.Add(cut);
+ return (nest, plate, p, q, cut);
+ }
+
+ // A regenerable 2x2 square with its emitted lead at the top-edge midpoint.
+ private static Part Emitted(CuttingParameters parameters, Vector location)
+ {
+ var clean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
+ var prepared = PreparedContours.Capture(clean, parameters);
+ var part = new Part(new Drawing("same", clean), location);
+ Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(1, 3))]), false));
+ part.CuttingParameters = parameters;
+ return part;
+ }
+
+ // Host: 10x10 square with circular cutouts of radius 1 at (3,3) and (7,3), cut hole, hole,
+ // perimeter from the right so its own program has no completed-contour crossing.
+ private static (Plate, Part Host, Part Inner) NestedPlate(Part inner)
+ {
+ var clean = PreparedContourTests.Holes();
+ var parameters = ExplicitContourTests.Parameters();
+ var prepared = PreparedContours.Capture(clean, parameters);
+ var program = prepared.Emit(
+ [
+ prepared.Entry(0, 0, new Vector(4, 3)),
+ prepared.Entry(1, 0, new Vector(8, 3)),
+ prepared.ClosestEntry(2, new Vector(12, 3))
+ ]);
+ var host = new Part(new Drawing("same", clean));
+ Assert.True(host.RestoreLeadInProgram(program, false));
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ plate.Parts.Add(host);
+ plate.Parts.Add(inner);
+ return (plate, host, inner);
+ }
+
+ // A U-shaped part open at the top between x 3 and 7, lead on its right edge.
+ private static Part Pocketed()
+ {
+ var outline = new[] { (10.0, 0.0), (0.0, 0.0), (0.0, 10.0), (3.0, 10.0), (3.0, 3.0), (7.0, 3.0),
+ (7.0, 10.0), (10.0, 10.0), (10.0, 5.0) };
+ var clean = new Program();
+ clean.MoveTo(10, 5);
+ foreach (var (x, y) in outline)
+ clean.LineTo(x, y);
+ var placed = new Program();
+ placed.MoveTo(10.25, 5);
+ placed.Codes.Add(new LinearMove(10, 5) { Layer = LayerType.Leadin });
+ foreach (var (x, y) in outline)
+ placed.LineTo(x, y);
+ placed.Codes.Add(new LinearMove(10.25, 5) { Layer = LayerType.Leadout });
+ var part = new Part(new Drawing("same", clean));
+ Assert.True(part.RestoreLeadInProgram(placed, false));
+ return part;
+ }
+
+ // Fixed-program rectangle at (x, y), contour starting at its right-edge midpoint with a
+ // 0.25 straight lead-in and lead-out to the right.
+ private static Part Rectangle(double x, double y, double width, double height)
+ {
+ var clean = new Program();
+ clean.MoveTo(width, height / 2);
+ Contour(clean, width, height);
+ var part = new Part(new Drawing("same", clean), new Vector(x, y));
+ var placed = new Program();
+ placed.MoveTo(width + 0.25, height / 2);
+ placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
+ Contour(placed, width, height);
+ placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
+ Assert.True(part.RestoreLeadInProgram(placed, false));
+ return part;
+ }
+
+ private static void Contour(Program program, double width, double height)
+ {
+ program.LineTo(width, 0);
+ program.LineTo(0, 0);
+ program.LineTo(0, height);
+ program.LineTo(width, height);
+ program.LineTo(width, height / 2);
+ }
+
+ private static string Describe(CuttingPlanResult r) =>
+ $"{r.Status}, expanded {r.Expansions}: " + string.Join("; ", r.Findings.Select(f => f.Message));
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 4187723..c33fd6e 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -50,6 +50,27 @@ flags, symbolic bindings and shared subprogram identity; unsupported graphs are
refused. The geometry-only clean transform uses per-parent copies so legacy rotation
does not visit a globally shared descendant twice; it never changes the fixed payload.
+## Cutting dependencies
+
+Capture builds whole-part prerequisites from owned values, and both the search and
+the final replay enforce them:
+
+- A cutoff precedes every part its nominal line crosses within its span, using the
+ same rule as automatic sequencing: nominal position and limits against the part's
+ placed bounds, matched by drawing reference, never by name or trimmed segments. A
+ cutoff whose definition is missing precedes every part. Cutoffs need a
+ plate-scoped request; they are always fixed programs, need no lead-in, are not
+ material for lead validation and, being open cuts, never become rapid obstacles.
+ Rapids into and out of them are still checked.
+- A part whose perimeter lies strictly inside a cutout of another part precedes
+ that host. Bounds only select candidate pairs; containment is proven on native
+ clean material. Touching or crossing boundaries, or material that cannot be
+ captured, refuse as `UnsupportedGeometry` naming both parts. A part in a concave
+ pocket outside the host's material has no dependency.
+- A preserved manual order that violates a prerequisite, or a cycle, is a
+ `ConstraintConflict`. Replay rechecks the captured prerequisites and refuses a
+ violating order rather than trusting the search.
+
## Search and exact output
With regeneration, the bounded deterministic search considers whole-part order,
@@ -107,8 +128,8 @@ sequence positions. A non-ready result contains no proposed order or unsafe fall
- `ConstraintConflict`: fixed programs or explored fixed routing violate the
modeled constraints. Locked internal crossings cannot be repaired by regeneration.
- `UnsupportedGeometry`: unsupported motion/material semantics or an incomplete
- check. Open nominal outlines, ambiguous release states, cutoff dependencies and
- scribe-only source drawings are not silently accepted.
+ check. Open nominal outlines, ambiguous release states or containment, cutoffs
+ in a detached part list and scribe-only source drawings are not silently accepted.
- `InvalidInput`: malformed/missing/duplicate placements or settings, invalid
geometry, empty input, invalid eligibility or nonpositive bounds.
- `NoSolutionWithinBudget`: the bounded/capped search found no complete proposal;
@@ -154,13 +175,12 @@ on an `Applied` result, not as a rollback.
## Remaining integration boundaries
-The service does not establish clean-material non-overlap, inner-part-before-host
-release dependencies, cutoff order or physical retention strength. It does not
-write CNC or set posting consent. A `Ready` proposal can still be unsuitable for
-cutting.
+The service does not establish clean-material non-overlap, scrap release by open
+cutoff cuts or sheet edges, or physical retention strength. It does not write CNC
+or set posting consent. A `Ready` proposal can still be unsuitable for cutting.
-Later slices add containment/cutoff dependencies, then desktop integration
-(including `PartsReordered` refresh hooks) and legacy automatic-path retirement. Windows interaction,
+Later slices add desktop integration (including `PartsReordered` refresh hooks)
+and legacy automatic-path retirement. Windows interaction,
supplied-job coverage and actual posted order remain separate acceptance gates.
Fresh [pre-post verification](post-verification.md) is still required; it is not a
physical safety qualification.