From 4bbd8454b5c7cc9cb8f555332ea80cf1250e393a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 5 Oct 2026 00:01:29 -0400 Subject: [PATCH] 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. --- .../CuttingPlanning/ReleasedContourState.cs | 11 +- .../CuttingPlanning/CuttingDependencyGraph.cs | 185 +++++++++++- .../CuttingPlanning/CuttingPlanModels.cs | 10 +- .../CuttingPlanning/CuttingPlanService.cs | 97 +++++-- .../CuttingPlanning/FixedProgramSearch.cs | 4 +- .../CuttingPlanning/JointCuttingPlanSearch.cs | 9 +- .../CuttingPlanning/CuttingDependencyTests.cs | 271 ++++++++++++++++++ docs/cutting-planner.md | 36 ++- 8 files changed, 589 insertions(+), 34 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs 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.