feat(cutting): order cutoffs and nested parts before their hosts

The cutting planner now accepts cutoffs on plate-scoped requests and
plans whole-part prerequisites captured from owned values:

- A cutoff precedes every part its nominal span crosses, using the same
  rule and drawing-reference matching as automatic sequencing; a cutoff
  without a definition precedes every part. Cutoffs stay fixed programs,
  need no lead-in and never become rapid obstacles; rapids into and out
  of them are still checked.
- A part proven, on native clean material, to lie inside a cutout of
  another part precedes that host. Touching or crossing boundaries are
  ambiguous and refuse; a part in a concave pocket has no dependency.
- Both searches only expand ready parts, a preserved order that breaks a
  prerequisite is a constraint conflict, and final replay rechecks the
  captured prerequisites instead of trusting the search.
This commit is contained in:
aj committed 2026-10-05 00:01:29 -04:00
1 parent f3bca26fb9
commit 4bbd8454b5
8 files changed
+589 -34

No files matched your search

@@ -21,7 +21,14 @@ public sealed class ReleasedContourState
/// <summary>Consumes one whole owned program; part numbers are caller identity keys.</summary>
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
int partNumber, CancellationToken token = default)
int partNumber, CancellationToken token = default) => Check(execution, arrival, partNumber, false, token);
/// <summary>
/// As the analyzer does for cutoffs: rapids are checked, but cutoff cuts need no lead-in and,
/// being open, never become obstacles.
/// </summary>
public IReadOnlyList<PostVerificationFinding> 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<PostVerificationFinding>();
AnalyzeMoves(moves, false, findings, 1, partNumber, token);
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token);
return findings.AsReadOnly();
}
@@ -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;
/// <summary>Whole-part cutting prerequisites shared by automatic sequencing and cutting planning.</summary>
internal static class CuttingDependencyGraph
/// <summary>
/// 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.
/// </summary>
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<int>()).ToArray());
/// <summary>Exact prerequisites, for tests and callers that already proved them.</summary>
internal static CuttingDependencyGraph FromPrerequisites(IReadOnlyList<int[]> 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<int> PrerequisitesOf(int ordinal) => prerequisites[ordinal];
internal bool IsReady(int ordinal, IEnumerable<int> done)
{
if (prerequisites[ordinal].Length == 0)
return true;
var finished = done as ICollection<int> ?? done.ToHashSet();
return prerequisites[ordinal].All(finished.Contains);
}
/// <summary>First (part, missing prerequisite) in a proposed complete order, or null.</summary>
internal (int Part, int Prerequisite)? FirstViolation(IReadOnlyList<int> 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;
}
/// <summary>An ordinal on a prerequisite cycle, or null when the graph is acyclic.</summary>
internal int? FindCycle()
{
var remaining = prerequisites.Select(p => p.Length).ToArray();
var dependents = Enumerable.Range(0, prerequisites.Length).Select(_ => new List<int>()).ToArray();
for (var part = 0; part < prerequisites.Length; part++)
foreach (var prerequisite in prerequisites[part])
dependents[prerequisite].Add(part);
var ready = new Queue<int>(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<DependencyNode> nodes, Box plate,
CancellationToken token)
{
var edges = nodes.Select(_ => new SortedSet<int>()).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;
}
/// <summary>
/// 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<DependencyNode> 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;
}
/// <summary>Owned copy of a cutoff definition's nominal line.</summary>
internal sealed record CutOffDefinition(CutOffAxis Axis, Vector Position, double? StartLimit, double? EndLimit);
/// <summary>Owned per-placement dependency input; material is captured only when a pair needs it.</summary>
internal sealed class DependencyNode
{
private readonly Func<LeadMaterialSnapshot> materialFactory;
private LeadMaterialSnapshot material;
internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds,
Func<LeadMaterialSnapshot> 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);
}
}
/// <summary>A dependency refusal naming the part and, when relevant, the other part.</summary>
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;
}
@@ -60,11 +60,12 @@ public sealed class CuttingPlanSnapshot
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
Action<int> 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;
}
/// <summary>Whole-part prerequisites by source ordinal (cutoffs, inner parts before hosts).</summary>
internal CuttingDependencyGraph Dependencies { get; }
/// <summary>Exact captured plate state for plate-scoped requests; null for detached part lists.</summary>
internal PlateCuttingState PlateState { get; }
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
@@ -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 };
}
/// <summary>A cutoff: always a fixed open-cut program, never material or regenerated.</summary>
public bool IsCutOff { get; internal init; }
public Part SourcePart { get; }
public int SourceOrdinal { get; }
public Vector Location { get; }
@@ -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<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
foreach (var cutOff in request.Plate?.CutOffs ?? [])
if (cutOff?.Drawing != null)
definitions[cutOff.Drawing] = cutOff;
var nodes = new List<DependencyNode>();
// Reuse the reader's coordinate validation without publishing its native kernel.
var identities = new HashSet<Part>(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<CuttingPlanFinding>();
@@ -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<CuttingPlanFinding>();
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);
@@ -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());
@@ -19,7 +19,7 @@ internal static class JointCuttingPlanSearch
{
var expansions = 0;
var rejected = new List<CuttingPlanFinding>();
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<Frame>();
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;
@@ -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));
}
+28 -8
View File
@@ -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.