diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index 9d451ec..cbc43a3 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -1,25 +1,38 @@ using System; using System.Collections.Generic; using System.Linq; +using OpenNest.CNC; using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; using OpenNest.Diagnostics; using OpenNest.Geometry; namespace OpenNest.Engine.CuttingPlanning; /// -/// Caller-side input for a fixed-program, direct-XY route only. Keep all sources stable -/// during Capture. Both locked and unlocked programs are fixed; this is not an Apply request. +/// Caller-side direct-XY input. Regeneration requires explicit confirmed parameters. +/// Keep sources/settings stable during Capture. This is not an Apply request. /// public sealed class CuttingPlanRequest { - public CuttingPlanRequest(IEnumerable parts, Vector startPoint = default, int expansionBudget = 20000) + public CuttingPlanRequest(IEnumerable parts, Vector startPoint = default, int expansionBudget = 20000, + CuttingParameters confirmedParameters = null, IEnumerable eligibleParts = null, + bool preservePartOrder = false, int maxEntries = 16) { Parts = parts == null ? null : Array.AsReadOnly(parts.ToArray()); StartPoint = startPoint; ExpansionBudget = expansionBudget; + ConfirmedParameters = confirmedParameters; + EligibleParts = eligibleParts == null ? null : Array.AsReadOnly(eligibleParts.ToArray()); + PreservePartOrder = preservePartOrder; + MaxEntries = maxEntries; } + public CuttingParameters ConfirmedParameters { get; } + public IReadOnlyList EligibleParts { get; } + public bool PreservePartOrder { get; } + public int MaxEntries { get; } + internal Action ExpansionObserver { get; init; } public IReadOnlyList Parts { get; } public Vector StartPoint { get; } public int ExpansionBudget { get; } @@ -29,15 +42,25 @@ public sealed class CuttingPlanRequest public sealed class CuttingPlanSnapshot { internal CuttingPlanSnapshot(IEnumerable placements, Vector startPoint, - int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable findings = null) + int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable findings = null, + bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16, + Action expansionObserver = null) { Placements = Array.AsReadOnly(placements.ToArray()); StartPoint = startPoint; ExpansionBudget = expansionBudget; Failure = failure; Findings = Array.AsReadOnly((findings ?? []).ToArray()); + Regeneration = regeneration; + PreservePartOrder = preservePartOrder; + MaxEntries = maxEntries; + ExpansionObserver = expansionObserver; } + internal bool Regeneration { get; } + internal bool PreservePartOrder { get; } + internal int MaxEntries { get; } + internal Action ExpansionObserver { get; } public IReadOnlyList Placements { get; } public Vector StartPoint { get; } public int ExpansionBudget { get; } @@ -48,7 +71,9 @@ public sealed class CuttingPlanSnapshot public sealed class FixedProgramPlacement { internal FixedProgramPlacement(Part sourcePart, int sourceOrdinal, Vector location, - double rotation, bool leadInsLocked, OwnedExecution execution) + double rotation, bool leadInsLocked, OwnedExecution execution, Program program = null, + PreparedContours prepared = null, LeadMaterialSnapshot material = null, + IReadOnlyList choices = null) { SourcePart = sourcePart; SourceOrdinal = sourceOrdinal; @@ -56,8 +81,22 @@ public sealed class FixedProgramPlacement Rotation = rotation; LeadInsLocked = leadInsLocked; Execution = execution; + this.program = program; + Prepared = prepared; + Material = material; + ContourChoices = Array.AsReadOnly((choices ?? []).ToArray()); } + private readonly Program program; + internal PreparedContours Prepared { get; } + internal LeadMaterialSnapshot Material { get; } + public IReadOnlyList ContourChoices { get; } + public bool IsRegenerated => ContourChoices.Count != 0; + /// Returns an independent deep copy; never an alias to captured/proposed code. + public Program CopyProgram() => program == null ? null : (Program)program.Clone(); + internal FixedProgramPlacement Propose(Program proposed, OwnedExecution execution, IReadOnlyList choices) => + new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution, proposed, Prepared, Material, choices); + public Part SourcePart { get; } public int SourceOrdinal { get; } public Vector Location { get; } @@ -81,8 +120,8 @@ public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart, int? OtherSourceOrdinal, Part OtherSourcePart, PostVerificationKind? Kind, string Message); /// -/// A checked fixed-program route, not full cutting-plan readiness, posting consent, -/// physical safety, or an atomic Apply payload. Failures contain no proposed order. +/// A replayed direct-XY proposal, optionally with regenerated programs. Not physical +/// safety, posting consent, dependency readiness or an atomic Apply payload. Failures contain no proposals. /// public sealed class CuttingPlanResult { diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index f00663b..bf47da7 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using OpenNest.CNC; using OpenNest.CNC.CuttingPlanning; using OpenNest.Diagnostics; using OpenNest.Geometry; @@ -11,8 +12,8 @@ namespace OpenNest.Engine.CuttingPlanning; public static class CuttingPlanService { /// - /// Read stable caller-owned sources once, before dispatching worker work. No Program.Clone, - /// private Plates, settings aliases, quantity updates, or subcall rebinding are involved. + /// Read stable caller-owned sources once into privately owned programs and geometry. + /// No private Plates, settings aliases, quantity updates or source subcall rebinding. /// public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default) { @@ -22,8 +23,17 @@ public static class CuttingPlanService try { token.ThrowIfCancellationRequested(); - if (request?.Parts == null || request.Parts.Count == 0 || request.ExpansionBudget <= 0) + if (request?.Parts == null || request.Parts.Count == 0 || request.ExpansionBudget <= 0 || request.MaxEntries <= 0) throw new ArgumentException("A nonempty source list and positive expansion budget are required."); + var eligible = new HashSet(ReferenceEqualityComparer.Instance); + if (request.EligibleParts != null) + { + if (request.ConfirmedParameters == null) + throw new ArgumentException("Eligibility requires confirmed cutting parameters."); + foreach (var part in request.EligibleParts) + if (part == null || !request.Parts.Any(p => ReferenceEquals(p, part)) || !eligible.Add(part)) + throw new ArgumentException("Foreign or duplicate eligible placement."); + } // 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++) @@ -42,11 +52,29 @@ public static class CuttingPlanService var execution = ExecutionMotionReader.Read(source.Program, source.Location, request.StartPoint, token); if (!execution.HasCuttingContour) throw new ArgumentException("Placed program has no nonzero cutting contour motions."); - placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked, execution)); + ValidateCloneGraph(source.Program, token); + var ownedProgram = (Program)source.Program.Clone(); + PreparedContours prepared = null; + LeadMaterialSnapshot material = null; + if (request.ConfirmedParameters != null) + { + ValidateCloneGraph(source.BaseDrawing.Program, token); + var ownedClean = (Program)source.BaseDrawing.Program.Clone(); + ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation); + material = LeadMaterialSnapshot.Capture(ownedClean, source.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); + } + 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); - return new(placements, request.StartPoint, request.ExpansionBudget); + return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null, + preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries, + expansionObserver: request.ExpansionObserver); } catch (OperationCanceledException) { @@ -84,6 +112,8 @@ public static class CuttingPlanService foreach (var placement in snapshot.Placements) { token.ThrowIfCancellationRequested(); + if (placement.Prepared != null) + 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); @@ -94,6 +124,13 @@ public static class CuttingPlanService ? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict, findings: fixedFindings); + if (snapshot.Regeneration) + { + var joint = JointCuttingPlanSearch.Run(snapshot, token); + if (joint.Status != CuttingPlanStatus.Ready) + return new(joint.Status, findings: joint.Findings, expansions: joint.Expansions); + return ReplayPrograms(snapshot, joint.Order, joint.Expansions, token); + } var search = FixedProgramSearch.Run(snapshot, token); if (search.Status != CuttingPlanStatus.Ready) return new(search.Status, findings: Map(snapshot, search.Findings), expansions: search.Expansions); @@ -133,6 +170,129 @@ public static class CuttingPlanService expansions: expansions, rapidDistance: distance, independentlyReplayed: true); } + // Re-read EXACT selected programs with a fresh checker and native lead validation. + // No emission/regeneration or cached branch verdict is used here. + internal static CuttingPlanResult ReplayPrograms(CuttingPlanSnapshot snapshot, + IReadOnlyList order, int expansions, CancellationToken token) + { + if (order == null || order.Count != snapshot.Placements.Count + || 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); + 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(); + foreach (var proposal in order) + { + token.ThrowIfCancellationRequested(); + var source = snapshot.Placements[proposal.SourceOrdinal]; + if (!ReferenceEquals(source.SourcePart, proposal.SourcePart) || source.Location != proposal.Location + || source.Rotation != proposal.Rotation || source.LeadInsLocked != proposal.LeadInsLocked + || !ReferenceEquals(source.Prepared, proposal.Prepared) || !ReferenceEquals(source.Material, proposal.Material) + || snapshot.PreservePartOrder && proposal.SourceOrdinal != order.TakeWhile(p => !ReferenceEquals(p, proposal)).Count()) + return new(CuttingPlanStatus.InvalidInput, expansions: expansions); + if (source.Prepared == null) + { + if (!ReferenceEquals(source, proposal)) + return new(CuttingPlanStatus.InvalidInput, expansions: expansions); + } + else if (proposal.ContourChoices.Count != source.Prepared.Count + || proposal.ContourChoices.Any(c => !ReferenceEquals(c.Owner, source.Prepared) + || c.ContourOrdinal < 0 || c.ContourOrdinal >= source.Prepared.Count) + || proposal.ContourChoices.Select(c => c.ContourOrdinal).Distinct().Count() != source.Prepared.Count + || proposal.ContourChoices[^1].ContourOrdinal != source.Prepared.PerimeterOrdinal) + return new(CuttingPlanStatus.InvalidInput, expansions: expansions); + OwnedExecution execution; + try + { + execution = ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, position, token); + if (source.Prepared != null && !HasCompleteContourAccounting(execution, source.Material, token)) + return new(CuttingPlanStatus.InvalidInput, expansions: expansions); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + return new(CuttingPlanStatus.UnsupportedGeometry, + 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); + if (!lead.IsComplete || !lead.IsClear) + findings.Add(JointCuttingPlanSearch.Finding(source, + lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason)); + distance += execution.RapidDistanceFrom(position); + position = execution.DeparturePoint; + } + token.ThrowIfCancellationRequested(); + if (findings.Count != 0) + return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete) + ? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict, + findings: findings, expansions: expansions); + return new(CuttingPlanStatus.Ready, order, expansions: expansions, + rapidDistance: distance, independentlyReplayed: true); + } + + private static bool HasCompleteContourAccounting(OwnedExecution execution, LeadMaterialSnapshot material, CancellationToken token) + { + var visited = new HashSet(); + var current = -1; + var budget = 1000000; + foreach (var move in execution.Motions) + { + token.ThrowIfCancellationRequested(); + if (move.Rapid || move.Layer is not (LayerType.Cut or LayerType.Display)) + { + current = -1; + continue; + } + var ring = -1; + for (var i = 0; i < material.Rings.Count; i++) + if (material.Rings[i].Any(c => Matches(c, move))) + { + ring = i; + break; + } + if (ring < 0 || current >= 0 && ring != current || current < 0 && !visited.Add(ring)) + return false; + current = ring; + } + return visited.Count == material.Rings.Count; + + bool Matches(PostVerificationGeometry.Curve curve, ExecutionMotion move) + { + token.ThrowIfCancellationRequested(); + if (--budget < 0) throw new NotSupportedException("Replay contour accounting exceeds the native query limit."); + return curve.SameSupport(move.Curve) && curve.Contains(move.Curve.Start) + && curve.Contains(move.End) && curve.Contains(move.Curve.Midpoint) + && move.Length <= curve.Length + PostVerificationGeometry.Epsilon; + } + } + + private static void ValidateCloneGraph(Program program, CancellationToken token) + { + var active = new HashSet(ReferenceEqualityComparer.Instance); + var done = new HashSet(ReferenceEqualityComparer.Instance); + Visit(program); + void Visit(Program current) + { + token.ThrowIfCancellationRequested(); + if (current == null || active.Contains(current) || active.Count >= 64) + throw new ArgumentException("Missing, recursive or excessively nested clone graph."); + if (done.Contains(current)) return; + // The reader already validated the executed graph. Inactive registered + // subprograms may be motionless; preserve them exactly, but guard Clone traversal. + if (current.Codes == null || current.Codes.Any(c => c == null)) + throw new ArgumentException("Missing clone graph instructions."); + active.Add(current); + foreach (var child in current.SubPrograms.Values.Concat(current.Codes.OfType().Select(c => c.Program))) + Visit(child); + active.Remove(current); + done.Add(current); + } + } + private static IEnumerable Map(CuttingPlanSnapshot snapshot, IEnumerable findings) => findings.Select(finding => { diff --git a/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs b/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs index 00a555e..156d636 100644 --- a/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs @@ -47,7 +47,8 @@ internal static class FixedProgramSearch return new(CuttingPlanStatus.ConstraintConflict, [], rejected.ToArray(), expansions); Frame Create(int[] order, Vector position, ReleasedContourState checker) => new(order, position, checker, - Enumerable.Range(0, snapshot.Placements.Count).Where(index => !order.Contains(index)) + Enumerable.Range(0, snapshot.Placements.Count).Where(index => !order.Contains(index) + && (!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 new file mode 100644 index 0000000..6dd713a --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.Engine.CuttingPlanning; + +/// One forward DFS over whole-part selection and emitted contour prefixes. +internal static class JointCuttingPlanSearch +{ + internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList Order, + IReadOnlyList Findings, int Expansions); + + internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token) + { + var expansions = 0; + var rejected = new List(); + var materials = snapshot.Placements.Select(p => p.Material).ToArray(); + var stack = new Stack(); + stack.Push(new(new([], snapshot.StartPoint, new ReleasedContourState(), null))); + try + { + while (stack.Count != 0) + { + token.ThrowIfCancellationRequested(); + var frame = stack.Peek(); + var node = frame.Node; + if (node.Order.Length == snapshot.Placements.Count) + return new(CuttingPlanStatus.Ready, node.Order, [], expansions); + frame.Children ??= Expand(node).OrderBy(c => c.Distance) + .ThenBy(c => c.Ordinal).ThenBy(c => c.Contour).ThenBy(c => c.Entry).ToArray(); + if (frame.Next == frame.Children.Length) + { + stack.Pop(); + continue; + } + stack.Push(new(frame.Children[frame.Next++].Node)); + } + // Entries are capped; exhaustion is not a proof over all possible entries. + var status = snapshot.Placements.Any(p => p.Prepared != null) + ? CuttingPlanStatus.NoSolutionWithinBudget + : rejected.Any(f => f.Kind == PostVerificationKind.Incomplete) + ? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict; + return new(status, [], rejected.Distinct().ToArray(), expansions); + } + catch (BudgetExceededException) + { + return new(CuttingPlanStatus.NoSolutionWithinBudget, [], rejected.Distinct().ToArray(), expansions); + } + catch (OperationCanceledException) + { + return new(CuttingPlanStatus.Cancelled, [], [], expansions); + } + + IEnumerable Expand(Node node) + { + var sources = node.Active is { } active ? new[] { active.Source } + : snapshot.Placements.Where(p => !node.Order.Any(o => o.SourceOrdinal == p.SourceOrdinal) + && (!snapshot.PreservePartOrder || p.SourceOrdinal == node.Order.Length)); + foreach (var source in sources) + { + token.ThrowIfCancellationRequested(); + if (source.Prepared == null) + { + CountExpansion(source); + var checker = node.Checker.Copy(); + if (Check(source, source.Execution, node.Position, checker)) + yield return new(new([.. node.Order, source], source.Execution.DeparturePoint, checker, null), + source.Execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, -1); + continue; + } + var prepared = source.Prepared; + var choices = node.Active?.Choices ?? []; + var arrival = node.Active?.Arrival ?? node.Position; + var before = node.Active?.Before ?? node.Checker; + var contours = choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal } + : Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c)); + foreach (var contour in contours) + { + token.ThrowIfCancellationRequested(); + var entries = prepared.Entries(contour, node.Position - source.Location, snapshot.MaxEntries, token); + for (var entry = 0; entry < entries.Count; entry++) + { + CountExpansion(source); // Before emission/native queries, including rejected candidates. + var prefix = choices.Append(entries[entry]).ToArray(); + Program program; + OwnedExecution execution; + try + { + program = prefix.Length == prepared.Count ? prepared.Emit(prefix) : prepared.EmitPrefix(prefix); + execution = ExecutionMotionReader.Read(program, source.Location, arrival, token); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException) + { + rejected.Add(Finding(source, PostVerificationKind.Incomplete, + $"Contour {contour}, entry {entry} emission refused: {ex.Message}")); + continue; // Retain emitter semantics, never repair/shorten invalid lead styles. + } + // Prefix includes all earlier cuts and scribes. Replay from BEFORE the whole part. + var checker = before.Copy(); + if (!Check(source, execution, arrival, checker)) continue; + var distance = execution.RapidDistanceFrom(arrival); + var next = prefix.Length == prepared.Count + ? new Node([.. node.Order, source.Propose(program, execution, prefix)], execution.DeparturePoint, checker, null) + : new Node(node.Order, execution.DeparturePoint, checker, + new(source, prefix, arrival, before, distance)); + yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, contour, entry); + } + } + } + } + + void CountExpansion(FixedProgramPlacement source) + { + token.ThrowIfCancellationRequested(); + if (expansions == snapshot.ExpansionBudget) + { + rejected.Add(Finding(source, null, $"Expansion budget {snapshot.ExpansionBudget} reached before the next candidate.")); + throw new BudgetExceededException(); + } + expansions++; + snapshot.ExpansionObserver?.Invoke(expansions); + token.ThrowIfCancellationRequested(); + } + + bool Check(FixedProgramPlacement source, OwnedExecution execution, Vector arrival, ReleasedContourState checker) + { + var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, token); + rejected.AddRange(Map(snapshot, findings)); + var lead = 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; + } + } + + internal static CuttingPlanFinding Finding(FixedProgramPlacement source, PostVerificationKind? kind, string message) => + new(source.SourceOrdinal, source.SourcePart, null, null, kind, message); + + internal static IEnumerable Map(CuttingPlanSnapshot snapshot, IEnumerable findings) => + findings.Select(f => + { + var source = f.PartNumber is { } p ? snapshot.Placements[p - 1] : null; + var other = f.OtherPartNumber is { } o ? snapshot.Placements[o - 1] : null; + return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart, + other?.SourceOrdinal, other?.SourcePart, f.Kind, f.Message); + }); + + private sealed class BudgetExceededException : Exception; + private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival, + ReleasedContourState Before, double Distance); + private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker, ActivePart Active); + private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry); + private sealed class Frame(Node node) + { + internal Node Node { get; } = node; + internal Edge[] Children { get; set; } + internal int Next { get; set; } + } +} diff --git a/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs b/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs new file mode 100644 index 0000000..740d980 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs @@ -0,0 +1,439 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Diagnostics; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class JointCuttingPlanTests +{ + [Fact] + public void JointSearch_RepairsActualCompletedHoleCrossing_WithExactReplayAndNoMutation() + { + var (part, parameters) = Crossing(); + var unchanged = Unchanged(part, parameters); + Assert.Contains(new ReleasedContourState().Check(Read(part.Program, part.Location), Vector.Zero, 1), + f => f.Kind == PostVerificationKind.RapidCrossing); + Assert.Equal(CuttingPlanStatus.ConstraintConflict, + CuttingPlanService.Plan(new CuttingPlanRequest([part])).Status); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters)); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + var proposal = Assert.Single(result.ProposedOrder); + Assert.True(proposal.IsRegenerated); + Assert.Equal(new[] { 0, 1, 2 }, proposal.ContourChoices.Select(c => c.ContourOrdinal).Order()); + Assert.Equal(2, proposal.ContourChoices[^1].ContourOrdinal); + Assert.NotEqual(ExplicitContourTests.Fingerprint(part.Program), ExplicitContourTests.Fingerprint(proposal.CopyProgram())); + var actual = Read(proposal.CopyProgram(), proposal.Location); + Assert.Empty(new ReleasedContourState().Check(actual, Vector.Zero, 1)); + var material = LeadMaterialSnapshot.Capture(part.BaseDrawing.Program, part.Location); + var leads = LeadPathValidator.Check(actual, material, []); + Assert.True(leads.IsComplete && leads.IsClear, leads.Reason); + var nest = new Nest(); + var plate = nest.CreatePlate(); + var copy = new Part(new Drawing("replay", (Program)part.BaseDrawing.Program.Clone()), proposal.Location); + Assert.True(copy.RestoreLeadInProgram(proposal.CopyProgram(), false)); + plate.Parts.Add(copy); + var report = PostVerificationAnalyzer.Analyze(nest, Vector.Zero); + Assert.Empty(report.Findings); + var detached = proposal.CopyProgram(); detached.Codes.Clear(); detached.SubPrograms.Clear(); + Assert.NotEmpty(proposal.CopyProgram().Codes); + unchanged(); + } + + [Theory] + [InlineData("locked")] + [InlineData("ineligible")] + [InlineData("no-valid-entry")] + [InlineData("unsupported")] + public void JointSearch_RefusesWithoutInstallingFallbackOrMutating(string fault) + { + var (part, parameters) = Crossing(); + Part[]? eligible = null; + var expected = CuttingPlanStatus.ConstraintConflict; + if (fault == "locked") part.LeadInsLocked = true; + if (fault == "ineligible") eligible = []; + if (fault == "no-valid-entry") + { + parameters.ArcCircleLeadIn = new NoLeadIn(); + parameters.InternalLeadIn = parameters.ArcCircleLeadIn; + expected = CuttingPlanStatus.NoSolutionWithinBudget; + } + if (fault == "unsupported") + { + part.BaseDrawing.Program.Codes.AddRange(LeadPathValidationTests.Rectangle(30, 30, 40, 40).Codes); + expected = CuttingPlanStatus.UnsupportedGeometry; + } + var unchanged = Unchanged(part, parameters); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters, eligibleParts: eligible)); + Assert.Equal(expected, result.Status); + Assert.Empty(result.ProposedOrder); + Assert.False(result.IndependentlyReplayed); + unchanged(); + } + + [Fact] + public void JointSearch_BacktracksInternalEntryWhenLaterWholePartFails() + { + var parameters = ExplicitContourTests.Parameters(); + var first = SimplePart(Vector.Zero, parameters); + var second = SimplePart(new Vector(20, 0), parameters); + second.LeadInsLocked = true; + var start = new Vector(-2, 5); + var solo = CuttingPlanService.Plan(new CuttingPlanRequest([first], start, confirmedParameters: parameters)); + Assert.Equal(CuttingPlanStatus.Ready, solo.Status); + var fixedLater = CuttingPlanService.Capture(new CuttingPlanRequest([second])).Placements[0]; + var state = new ReleasedContourState(); + Assert.Empty(state.Check(solo.ProposedOrder[0].Execution, start, 1)); + Assert.Contains(state.Check(fixedLater.Execution, solo.ProposedOrder[0].Execution.DeparturePoint, 2), + f => f.Kind == PostVerificationKind.RapidCrossing); + var joint = CuttingPlanService.Plan(new CuttingPlanRequest([first, second], start, + confirmedParameters: parameters, preservePartOrder: true)); + Assert.True(joint.Status == CuttingPlanStatus.Ready, Describe(joint)); + Assert.Equal(new[] { first, second }, joint.ProposedOrder.Select(p => p.SourcePart)); + Assert.NotEqual(solo.ProposedOrder[0].ContourChoices[0].Point, joint.ProposedOrder[0].ContourChoices[0].Point); + Assert.True(joint.IndependentlyReplayed); + } + + [Fact] + public void Snapshot_CallerMutationAndDuplicateNames_DoNotChangeDeterministicOwnedProposals() + { + var (first, parameters) = Crossing(); + var (second, _) = Crossing(); + second.Location = new Vector(30, 0); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, second], confirmedParameters: parameters)); + var baseline = CuttingPlanService.Plan(snapshot); + Assert.True(baseline.Status == CuttingPlanStatus.Ready, Describe(baseline)); + var fingerprints = baseline.ProposedOrder.Select(p => ExplicitContourTests.Fingerprint(p.CopyProgram())).ToArray(); + foreach (var part in new[] { first, second }) + { + foreach (var call in part.Program.Codes.OfType()) call.Program.Codes.Clear(); + part.Program.Codes.Clear(); part.BaseDrawing.Program.Codes.Clear(); + part.Location = new Vector(999, 999); part.LeadInsLocked = true; + part.CuttingParameters = new CuttingParameters(); + } + ((LineLeadIn)parameters.ExternalLeadIn).Length = 900; + parameters.TabsEnabled = true; + for (var repeat = 0; repeat < 3; repeat++) + { + var result = CuttingPlanService.Plan(snapshot); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.Equal(baseline.Expansions, result.Expansions); + Assert.Equal(baseline.RapidDistance, result.RapidDistance); + Assert.Equal(baseline.ProposedOrder.Select(p => p.SourcePart), result.ProposedOrder.Select(p => p.SourcePart)); + Assert.Equal(fingerprints, result.ProposedOrder.Select(p => ExplicitContourTests.Fingerprint(p.CopyProgram()))); + } + Assert.Equal(new[] { first, second }, baseline.ProposedOrder.Select(p => p.SourcePart)); + } + + [Fact] + public void BudgetOneAndCancellationDuringExpansion_HaveExactCountsNoFallbackNoMutation() + { + var (part, parameters) = Crossing(); + var unchanged = Unchanged(part, parameters); + var observed = 0; + var budget = CuttingPlanService.Plan(new CuttingPlanRequest([part], expansionBudget: 1, + confirmedParameters: parameters) + { ExpansionObserver = n => observed = n }); + Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, budget.Status); + Assert.Equal(1, budget.Expansions); Assert.Equal(1, observed); Assert.Empty(budget.ProposedOrder); + using var cancellation = new CancellationTokenSource(); + var cancelled = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters) + { + ExpansionObserver = n => { if (n == 3) cancellation.Cancel(); } + }, cancellation.Token); + Assert.Equal(CuttingPlanStatus.Cancelled, cancelled.Status); + Assert.Equal(3, cancelled.Expansions); Assert.Empty(cancelled.ProposedOrder); + unchanged(); + } + + [Theory] + [InlineData("foreign")] + [InlineData("duplicate")] + [InlineData("without-settings")] + [InlineData("zero-cap")] + public void EligibilityAndEntryBounds_InvalidInputIsNotIgnored(string fault) + { + var (part, parameters) = Crossing(); + var other = SimplePart(new Vector(30, 0), parameters); + var request = new CuttingPlanRequest([part], confirmedParameters: fault == "without-settings" ? null : parameters, + eligibleParts: fault == "foreign" ? [other] : fault == "duplicate" ? [part, part] : [part], + maxEntries: fault == "zero-cap" ? 0 : 16); + Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(request).Status); + } + + [Theory] + [InlineData(0.0, 1.5707963267948966)] + [InlineData(0.37, 0.91)] + public void RotatedCapture_UsesDeltaFromAlreadyRotatedBase_AndLocationOnce(double baseAngle, double delta) + { + var parameters = ExplicitContourTests.Parameters(); + var clean = PreparedContourTests.Holes(); clean.Rotate(baseAngle); + var part = new Part(new Drawing("rotated", clean)); part.Rotate(delta); + part.Location = new Vector(30, 40); + var expected = (Program)clean.Clone(); expected.Rotate(delta); + var target = LeadMaterialSnapshot.Capture(expected, part.Location); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(20, 30), confirmedParameters: parameters)); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + var proposal = Assert.Single(result.ProposedOrder); + Assert.Equal(part.Rotation, proposal.Rotation); + Assert.Equal(part.Location, proposal.Location); + var lead = LeadPathValidator.Check(Read(proposal.CopyProgram(), part.Location), target, []); + Assert.True(lead.IsComplete && lead.IsClear, lead.Reason); + } + + [Fact] + public void GeneratedLeadoutDeparture_IsUsedForArrivalAndReplayDistance() + { + var parameters = ExplicitContourTests.Parameters(); + parameters.ExternalLeadOut = new LineLeadOut { Length = 0.2, ApproachAngle = 45 }; + var parts = new[] { SimplePart(Vector.Zero, parameters), SimplePart(new Vector(20, 0), parameters) }; + var start = new Vector(-2, 5); + var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, start, + confirmedParameters: parameters, preservePartOrder: true)); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + var distance = 0.0; var arrival = start; + foreach (var p in result.ProposedOrder) + { + var actual = Read(p.CopyProgram(), p.Location); + Assert.Equal(LayerType.Leadout, actual.Motions[^1].Layer); + Assert.NotEqual(actual.Motions[^1].Start, actual.DeparturePoint); + Assert.Equal(actual.DeparturePoint, p.Execution.DeparturePoint); + distance += actual.RapidDistanceFrom(arrival); arrival = actual.DeparturePoint; + } + Assert.Equal(distance, result.RapidDistance); + } + + [Theory] + [InlineData("unsafe")] + [InlineData("duplicate-choice")] + [InlineData("omitted-contour")] + [InlineData("duplicate-placement")] + [InlineData("omitted-placement")] + [InlineData("wrong-pose")] + public void ExactReplay_RejectsUnsafeOrIncompleteSelectedPrograms(string fault) + { + var (part, parameters) = Crossing(); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], confirmedParameters: parameters)); + var ready = CuttingPlanService.Plan(snapshot); + Assert.Equal(CuttingPlanStatus.Ready, ready.Status); + var proposal = ready.ProposedOrder[0]; + var source = snapshot.Placements[0]; + IReadOnlyList order = [proposal]; + if (fault == "unsafe") order = [source.Propose((Program)part.Program.Clone(), Read(part.Program, part.Location), proposal.ContourChoices)]; + if (fault == "duplicate-choice") order = [source.Propose(proposal.CopyProgram(), proposal.Execution, + [proposal.ContourChoices[0], proposal.ContourChoices[0], proposal.ContourChoices[2]])]; + if (fault == "omitted-contour") + { + var prefix = source.Prepared.EmitPrefix(proposal.ContourChoices.Take(2).ToArray()); + order = [source.Propose(prefix, Read(prefix, source.Location), proposal.ContourChoices)]; + } + if (fault == "duplicate-placement") order = [proposal, proposal]; + if (fault == "omitted-placement") order = []; + if (fault == "wrong-pose") order = [new FixedProgramPlacement(part, 0, new Vector(99, 99), source.Rotation, + false, proposal.Execution, proposal.CopyProgram(), source.Prepared, source.Material, proposal.ContourChoices)]; + var replay = CuttingPlanService.ReplayPrograms(snapshot, order, 0, default); + Assert.Equal(fault == "unsafe" ? CuttingPlanStatus.ConstraintConflict : CuttingPlanStatus.InvalidInput, replay.Status); + Assert.Empty(replay.ProposedOrder); Assert.False(replay.IndependentlyReplayed); + } + + [Fact] + public void FixedLeads_SearchAndReplayRejectNativeTargetMaterialCrossing() + { + var parameters = ExplicitContourTests.Parameters(); + var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10); + var part = new Part(new Drawing("fixed", clean)); + var unsafeProgram = new Program(); unsafeProgram.MoveTo(5, 5); + unsafeProgram.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin }); + unsafeProgram.LineTo(0, 10); unsafeProgram.LineTo(10, 10); unsafeProgram.LineTo(10, 0); + unsafeProgram.LineTo(0, 0); unsafeProgram.LineTo(0, 5); + Assert.True(part.RestoreLeadInProgram(unsafeProgram, true)); + Assert.Empty(new ReleasedContourState().Check(Read(unsafeProgram, Vector.Zero), Vector.Zero, 1)); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], confirmedParameters: parameters)); + var search = JointCuttingPlanSearch.Run(snapshot, default); + Assert.Equal(CuttingPlanStatus.ConstraintConflict, search.Status); + Assert.Empty(search.Order); + var replay = CuttingPlanService.ReplayPrograms(snapshot, snapshot.Placements, 0, default); + Assert.Equal(CuttingPlanStatus.ConstraintConflict, replay.Status); + Assert.Contains(replay.Findings, f => f.Message.Contains("target material")); + } + + [Fact] + public void InvalidTabbedArcCandidate_IsRefusedWithoutRepairOrException() + { + var parameters = ExplicitContourTests.Parameters(); + parameters.TabsEnabled = true; parameters.TabConfig = new NormalTab { Size = 0.2 }; + parameters.ExternalLeadOut = new ArcLeadOut { Radius = 0.2 }; + var part = new Part(new Drawing("tabbed", LeadPathValidationTests.Rectangle(0, 0, 10, 10))); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(-2, 5), confirmedParameters: parameters)); + Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status); + Assert.Empty(result.ProposedOrder); Assert.True(result.Expansions > 0); + Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.Incomplete); + } + + [Fact] + public void FixedLead_SearchChecksOtherUncutHoleAwareMaterial_NotOnlyReleasedContours() + { + var parameters = ExplicitContourTests.Parameters(); + var first = SimplePart(Vector.Zero, parameters); first.LeadInsLocked = true; + var clean = LeadPathValidationTests.Rectangle(-0.3, 4.5, -0.1, 5.5); + var obstacle = new Part(new Drawing("uncut", clean)); + var prepared = PreparedContours.Capture(clean, parameters); + Assert.True(obstacle.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(-1, 5))]), true)); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, obstacle], + new Vector(-2, 5), confirmedParameters: parameters, preservePartOrder: true)); + var direct = LeadPathValidator.Check(snapshot.Placements[0].Execution, snapshot.Placements[0].Material, + snapshot.Placements.Select(p => p.Material).ToArray()); + Assert.True(direct.IsComplete); Assert.False(direct.IsClear); + Assert.Contains("another placed material", direct.Reason); + var search = JointCuttingPlanSearch.Run(snapshot, default); + Assert.Equal(CuttingPlanStatus.ConstraintConflict, search.Status); + Assert.Contains(search.Findings, f => f.Message.Contains("another placed material")); + } + + [Fact] + public void UnsupportedMaterialOfLockedIneligiblePlacement_BlocksCompleteRegeneration() + { + var (part, parameters) = Crossing(); + var locked = SimplePart(new Vector(30, 0), parameters); locked.LeadInsLocked = true; + locked.BaseDrawing.Program.Codes.RemoveAt(locked.BaseDrawing.Program.Codes.Count - 1); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part, locked], confirmedParameters: parameters, + eligibleParts: [part])); + var result = CuttingPlanService.Plan(snapshot); + Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status); + Assert.Empty(result.ProposedOrder); + Assert.Contains(result.Findings, f => ReferenceEquals(f.SourcePart, locked) && f.SourceOrdinal == 1); + } + + [Fact] + public void JointSearch_SelectsWholePartOrderAndCanPreserveCallerOrder() + { + var (first, parameters) = Crossing(); first.Location = new Vector(30, 0); + var (second, _) = Crossing(); + var free = CuttingPlanService.Plan(new CuttingPlanRequest([first, second], confirmedParameters: parameters)); + var preserved = CuttingPlanService.Plan(new CuttingPlanRequest([first, second], confirmedParameters: parameters, + preservePartOrder: true)); + Assert.True(free.Status == CuttingPlanStatus.Ready, Describe(free)); + Assert.True(preserved.Status == CuttingPlanStatus.Ready, Describe(preserved)); + Assert.Equal(new[] { second, first }, free.ProposedOrder.Select(p => p.SourcePart)); + Assert.Equal(new[] { first, second }, preserved.ProposedOrder.Select(p => p.SourcePart)); + } + + [Fact] + public void FixedProgramCopy_PreservesSignedSubcallsCommentsAndVariables_WithoutMutableAliases() + { + var (part, _) = Crossing(); + part.Program.Codes.Insert(0, new Comment("fixed text")); + part.Program.Variables["value"] = new VariableDefinition("value", "2+3", 5); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part])); + var placement = snapshot.Placements[0]; + var copy = placement.CopyProgram(); + Assert.Equal(part.Program.ToString(), copy.ToString()); + Assert.Equal(part.Program.Variables["value"].Expression, copy.Variables["value"].Expression); + Assert.Equal(part.Program.Codes.OfType().Select(c => (c.Id, c.Offset, c.Rotation)), + copy.Codes.OfType().Select(c => (c.Id, c.Offset, c.Rotation))); + Assert.Equal(part.Program.Codes.OfType().Select(c => c.ToString()), copy.Codes.OfType().Select(c => c.ToString())); + foreach (var call in copy.Codes.OfType()) call.Program.Codes.Clear(); + copy.Codes.Clear(); copy.Variables.Clear(); + Assert.NotEmpty(placement.CopyProgram().Codes); + Assert.NotEmpty(part.Program.Codes); + } + + [Fact] + public void Regeneration_CompletesOriginallyOmittedHoles_AndPrefixesKeepScribesOnce() + { + var parameters = ExplicitContourTests.Parameters(); + var clean = PreparedContourTests.Holes(); clean.MoveTo(-2, -2); + clean.Codes.Add(new LinearMove(-1, -1) { Layer = LayerType.Scribe }); + var part = new Part(new Drawing("incomplete old program", clean)); + var original = SimplePart(Vector.Zero, parameters).Program; + Assert.True(part.RestoreLeadInProgram((Program)original.Clone(), false)); + part.CuttingParameters = parameters; + var unchanged = Unchanged(part, parameters); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters)); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + var proposal = Assert.Single(result.ProposedOrder); + Assert.Equal(new[] { 0, 1, 2 }, proposal.ContourChoices.Select(c => c.ContourOrdinal).Order()); + var actual = Read(proposal.CopyProgram(), Vector.Zero); + Assert.Single(actual.Motions.Where(m => m.Layer == LayerType.Scribe)); + Assert.Empty(new ReleasedContourState().Check(actual, Vector.Zero, 1)); + unchanged(); + } + + [Fact] + public void FixedProgramCapture_PreservesInactiveMotionlessRegisteredSubprogram() + { + var parameters = ExplicitContourTests.Parameters(); + var part = SimplePart(Vector.Zero, parameters); + part.Program.SubPrograms[-27] = new Program(); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part])); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.Empty(result.ProposedOrder[0].CopyProgram().SubPrograms[-27].Codes); + } + + private static Part SimplePart(Vector location, CuttingParameters parameters) + { + var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10); + var part = new Part(new Drawing("same", clean), location); + var prepared = PreparedContours.Capture(clean, parameters); + var emitted = prepared.Emit([prepared.ClosestEntry(0, new Vector(-2, 5))]); + Assert.True(part.RestoreLeadInProgram(emitted, false)); part.CuttingParameters = parameters; + return part; + } + + private static (Part, CuttingParameters) Crossing() + { + var clean = PreparedContourTests.Holes(); + var parameters = ExplicitContourTests.Parameters(); + var prepared = PreparedContours.Capture(clean, parameters); + var choices = new[] + { + prepared.Entry(0, 0, new Vector(2, 3)), + prepared.Entry(1, 0, new Vector(8, 3)), + prepared.ClosestEntry(2, new Vector(-2, 3)) + }; + var original = prepared.Emit(choices); + var part = new Part(new Drawing("same", clean)); + Assert.True(part.RestoreLeadInProgram(original, false)); + part.CuttingParameters = parameters; + return (part, parameters); + } + + private static Action Unchanged(Part part, CuttingParameters parameters) + { + var program = part.Program; + var clean = part.BaseDrawing.Program; + var fingerprint = ExplicitContourTests.Fingerprint(program); + var cleanText = clean.ToString(); + var codes = program.Codes.ToArray(); + var subs = program.Codes.OfType() + .Select(c => (c, c.Program, text: c.Program.ToString(), c.Id, c.Offset, c.Rotation)).ToArray(); + var location = part.Location; + var rotation = part.Rotation; + var locked = part.LeadInsLocked; + var manual = part.HasManualLeadIns; + var bounds = part.BoundingBox; + var quantity = part.BaseDrawing.Quantity.Nested; + var length = ((LineLeadIn)parameters.ExternalLeadIn).Length; + return () => + { + Assert.Same(program, part.Program); Assert.Same(clean, part.BaseDrawing.Program); + Assert.Equal(fingerprint, ExplicitContourTests.Fingerprint(program)); Assert.Equal(cleanText, clean.ToString()); + Assert.Equal(codes, program.Codes); Assert.Equal(location, part.Location); Assert.Equal(rotation, part.Rotation); + Assert.Equal(locked, part.LeadInsLocked); Assert.Equal(manual, part.HasManualLeadIns); + Assert.Same(bounds, part.BoundingBox); Assert.Same(parameters, part.CuttingParameters); + Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested); + Assert.Equal(length, ((LineLeadIn)parameters.ExternalLeadIn).Length); + foreach (var sub in subs) + { + Assert.Same(sub.Program, sub.c.Program); Assert.Equal(sub.text, sub.c.Program.ToString()); + Assert.Equal(sub.Id, sub.c.Id); Assert.Equal(sub.Offset, sub.c.Offset); Assert.Equal(sub.Rotation, sub.c.Rotation); + } + }; + } + + private static OwnedExecution Read(Program p, Vector location) => ExecutionMotionReader.Read(p, location, null, default); + private static string Describe(CuttingPlanResult r) => $"{r.Status}, expanded {r.Expansions}: " + string.Join("; ", r.Findings.Select(f => f.Message)); +}