diff --git a/OpenNest.Core/CNC/CuttingPlanning/ExecutionMotionReader.cs b/OpenNest.Core/CNC/CuttingPlanning/ExecutionMotionReader.cs new file mode 100644 index 0000000..7393597 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/ExecutionMotionReader.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingPlanning; + +/// Guarded read of stable placed programs; never clones or mutates their graphs. +public static class ExecutionMotionReader +{ + public static OwnedExecution Read(Program program, Vector origin, Vector? previous, + CancellationToken token) + { + var moves = new List(); + var visiting = new HashSet(ReferenceEqualityComparer.Instance); + var budget = 1000000; + Walk(program, origin, previous); + return new OwnedExecution(moves); + + Vector Walk(Program current, Vector frame, Vector? arrival) + { + token.ThrowIfCancellationRequested(); + PostVerificationGeometry.Validate(frame); + if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64) + throw new ArgumentException("Missing, recursive or excessively nested program."); + var pos = frame; + var first = true; + var countBefore = moves.Count; + foreach (var code in current.Codes) + { + token.ThrowIfCancellationRequested(); + if (--budget < 0) + throw new ArgumentException("Program expansion exceeds the verification limit."); + if (code == null) + throw new ArgumentException("Program contains a missing instruction."); + if (code is SubProgramCall call) + { + if (!double.IsFinite(call.Rotation)) + throw new ArgumentException("Subprogram rotation is not finite."); + // Call rotation is baked into the shared program by its setter. Do not + // rotate again; offsets are frame-relative even in incremental mode. + pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos); + first = false; + continue; + } + if (code is not Motion motion) + { + if (code is not (Comment or Feedrate or Kerf)) + throw new NotSupportedException("Unsupported program instruction."); + continue; + } + // Posts disagree about incremental position after suppressed instructions. + // Never silently certify a trajectory whose semantics are ambiguous. + if (motion.Suppressed) + throw new NotSupportedException("Suppressed motion requires post-specific verification."); + if (motion is not (RapidMove or LinearMove or ArcMove)) + throw new NotSupportedException("Unsupported motion."); + var reference = current.Mode == Mode.Incremental ? pos : frame; + var end = reference + motion.EndPoint; + PostVerificationGeometry.Validate(end); + var rapid = motion is RapidMove; + if (first && !rapid) + moves.Add(new(arrival, frame, true, LayerType.Display, null)); + var start = first && rapid ? arrival : pos; + var layer = motion switch + { + LinearMove line => line.Layer, + ArcMove arc => arc.Layer, + _ => LayerType.Display + }; + if (!Enum.IsDefined(layer)) + throw new NotSupportedException("Unsupported motion layer."); + if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation)) + throw new NotSupportedException("Unsupported arc direction."); + var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end, + motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null, + motion is ArcMove { Rotation: RotationType.CW }); + moves.Add(new(start, end, rapid, layer, curve)); + pos = end; + first = false; + } + visiting.Remove(current); + if (moves.Count == countBefore) + throw new ArgumentException("Program has no motions."); + return pos; + } + } + +} diff --git a/OpenNest.Core/CNC/CuttingPlanning/OwnedExecution.cs b/OpenNest.Core/CNC/CuttingPlanning/OwnedExecution.cs new file mode 100644 index 0000000..e3fef39 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/OwnedExecution.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingPlanning; + +/// Owned value motions; no references to live programs, instructions or settings. +public sealed class OwnedExecution +{ + internal OwnedExecution(IEnumerable motions) + { + Motions = Array.AsReadOnly(motions.ToArray()); + } + + public IReadOnlyList Motions { get; } + public Vector DeparturePoint => Motions[^1].End; + public bool HasCuttingContour => Motions.Any(move => !move.Rapid + && move.Layer is LayerType.Cut or LayerType.Display + && move.Length > PostVerificationGeometry.Epsilon); + + public double RapidDistanceFrom(Vector arrival) + { + PostVerificationGeometry.Validate(arrival); + var total = 0.0; + for (var index = 0; index < Motions.Count; index++) + { + var move = Motions[index]; + if (move.Rapid && (index == 0 ? arrival : move.Start) is { } start) + total += start.DistanceTo(move.End); + } + return total; + } +} + +/// An immutable motion value; native line/arc geometry stays private to Core. +public sealed class ExecutionMotion +{ + internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer, + PostVerificationGeometry.Curve curve) + { + Start = start; + End = end; + Rapid = rapid; + Layer = layer; + Curve = curve; + } + + public Vector? Start { get; } + public Vector End { get; } + public bool Rapid { get; } + public LayerType Layer { get; } + public double Length => Curve?.Length ?? 0; + internal PostVerificationGeometry.Curve Curve { get; } + internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve); +} diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs new file mode 100644 index 0000000..8d1ab15 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingPlanning; + +/// Direct XY completed-contour checker. This is not physical machine safety. +public sealed class ReleasedContourState +{ + private readonly List obstacles = new(); + + public ReleasedContourState Copy() + { + var copy = new ReleasedContourState(); + copy.obstacles.AddRange(obstacles); + return copy; + } + + /// Consumes one whole owned program; part numbers are caller identity keys. + public IReadOnlyList Check(OwnedExecution execution, Vector? arrival, + int partNumber, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(execution); + if (arrival is { } point) + PostVerificationGeometry.Validate(point); + var moves = execution.Motions.ToArray(); + moves[0] = moves[0].WithStart(arrival); + var findings = new List(); + AnalyzeMoves(moves, false, findings, 1, partNumber, token); + return findings.AsReadOnly(); + } + + internal void AnalyzeMoves(IReadOnlyList moves, bool cutoff, + List findings, int plate, int part, CancellationToken token) + { + var contour = new List(); + var unfinished = new List(); + var hasLead = false; + var contourNumber = 0; + foreach (var move in moves) + { + token.ThrowIfCancellationRequested(); + if (move.Rapid) + { + Finish(); + hasLead = false; + if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon) + continue; + foreach (var obstacle in obstacles) + { + token.ThrowIfCancellationRequested(); + if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token)) + findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, + $"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + + $"of part {obstacle.Part}.")); + } + } + else if (move.Layer == LayerType.Leadin) + { + Finish(); + hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon; + } + else if (move.Layer is LayerType.Leadout or LayerType.Scribe) + { + if (move.Layer == LayerType.Leadout && contour.Count > 0 + && !PostVerificationGeometry.Closed(contour) + && move.Curve.Length > PostVerificationGeometry.Epsilon) + findings.Add(new(PostVerificationKind.Incomplete, plate, part, null, + "A lead-out follows an open cutting contour and may cut through its retention gap. " + + "Rapid safety for that contour requires manual review.")); + Finish(); + hasLead = false; + } + else if (move.Curve.Length > PostVerificationGeometry.Epsilon) + { + if (contour.Count == 0) + { + contourNumber++; + if (!cutoff && !hasLead) + findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null, + $"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")); + hasLead = false; + // A rapid can pause/reposition without leaving any material gap. + // Retain already-cut fragments, but do not turn them into obstacles + // until an actually continuous chain closes. + var previous = unfinished.FindIndex(chain => + chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon); + if (previous >= 0) + { + contour.AddRange(unfinished[previous]); + unfinished.RemoveAt(previous); + } + } + contour.Add(move.Curve); + // A completed contour becomes an obstacle immediately, not at part end. + if (PostVerificationGeometry.Closed(contour)) + Finish(); + } + } + Finish(); + if (unfinished.Count > 1) + findings.Add(new(PostVerificationKind.Incomplete, plate, part, null, + "Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " + + "they cannot be assumed to be retained by tabs. Review rapid travel manually.")); + + void Finish() + { + if (contour.Count == 0) + return; + // A real uncut gap leaves the contour attached. CuttingParameters can be stale; + // no flag or tab configuration is used as evidence of retention. + if (!cutoff && PostVerificationGeometry.Closed(contour)) + obstacles.Add(new(part, contourNumber, contour.ToArray())); + else if (!cutoff) + unfinished.Add(contour.ToArray()); + contour.Clear(); + } + } + + private sealed record Obstacle(int Part, int Contour, IReadOnlyList Curves); +} diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs index 134238d..aa5097f 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; using OpenNest.Geometry; namespace OpenNest.Diagnostics; @@ -27,9 +28,14 @@ public static class PostVerificationAnalyzer return new PostVerificationReport(findings); } - public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default) + public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default) => + Analyze(nest, Vector.Zero, cancellationToken); + + public static PostVerificationReport Analyze(Nest nest, Vector startPoint, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(nest); + PostVerificationGeometry.Validate(startPoint); cancellationToken.ThrowIfCancellationRequested(); var findings = new List(); if (nest.Plates == null) @@ -50,8 +56,8 @@ public static class PostVerificationAnalyzer } var materialParts = new List(); var indices = new List(); - var obstacles = new List(); - Vector? position = Vector.Zero; + var obstacles = new ReleasedContourState(); + Vector? position = startPoint; for (var index = 0; index < plate.Parts.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); @@ -65,7 +71,7 @@ public static class PostVerificationAnalyzer { try { - var clean = Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken); + var clean = ExecutionMotionReader.Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken).Motions; expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe); if (!clean.Any(move => move.Layer == LayerType.Scribe) || expectsCuts) @@ -83,12 +89,12 @@ public static class PostVerificationAnalyzer { if (part == null || !double.IsFinite(part.Rotation)) throw new ArgumentException("Missing part or invalid rotation."); - var moves = Read(part.Program, part.Location, position, cancellationToken); + var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions; if (expectsCuts && !moves.Any(move => !move.Rapid && move.Layer is LayerType.Cut or LayerType.Display && move.Curve.Length > PostVerificationGeometry.Epsilon)) Incomplete("Placed program has no cutting contour motions for this drawing."); - AnalyzeMoves(moves, cutoff, obstacles, findings, plateNumber, partNumber, cancellationToken); + obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken); position = moves[^1].End; } catch (Exception exception) when (IsInvalid(exception)) @@ -119,172 +125,4 @@ public static class PostVerificationAnalyzer private static bool IsInvalid(Exception exception) => exception is ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException; - private static List Read(Program program, Vector origin, Vector? previous, - CancellationToken token) - { - var moves = new List(); - var visiting = new HashSet(ReferenceEqualityComparer.Instance); - var budget = 1000000; - Walk(program, origin, previous); - return moves; - - Vector Walk(Program current, Vector frame, Vector? arrival) - { - token.ThrowIfCancellationRequested(); - PostVerificationGeometry.Validate(frame); - if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64) - throw new ArgumentException("Missing, recursive or excessively nested program."); - var pos = frame; - var first = true; - var countBefore = moves.Count; - foreach (var code in current.Codes) - { - token.ThrowIfCancellationRequested(); - if (--budget < 0) - throw new ArgumentException("Program expansion exceeds the verification limit."); - if (code == null) - throw new ArgumentException("Program contains a missing instruction."); - if (code is SubProgramCall call) - { - if (!double.IsFinite(call.Rotation)) - throw new ArgumentException("Subprogram rotation is not finite."); - // Call rotation is baked into the shared program by its setter. Do not - // rotate again; offsets are frame-relative even in incremental mode. - pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos); - first = false; - continue; - } - if (code is not Motion motion) - { - if (code is not (Comment or Feedrate or Kerf)) - throw new NotSupportedException("Unsupported program instruction."); - continue; - } - // Posts disagree about incremental position after suppressed instructions. - // Never silently certify a trajectory whose semantics are ambiguous. - if (motion.Suppressed) - throw new NotSupportedException("Suppressed motion requires post-specific verification."); - if (motion is not (RapidMove or LinearMove or ArcMove)) - throw new NotSupportedException("Unsupported motion."); - var reference = current.Mode == Mode.Incremental ? pos : frame; - var end = reference + motion.EndPoint; - PostVerificationGeometry.Validate(end); - var rapid = motion is RapidMove; - if (first && !rapid) - moves.Add(new(arrival, frame, true, LayerType.Display, null)); - var start = first && rapid ? arrival : pos; - var layer = motion switch - { - LinearMove line => line.Layer, - ArcMove arc => arc.Layer, - _ => LayerType.Display - }; - if (!Enum.IsDefined(layer)) - throw new NotSupportedException("Unsupported motion layer."); - if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation)) - throw new NotSupportedException("Unsupported arc direction."); - var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end, - motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null, - motion is ArcMove { Rotation: RotationType.CW }); - moves.Add(new(start, end, rapid, layer, curve)); - pos = end; - first = false; - } - visiting.Remove(current); - if (moves.Count == countBefore) - throw new ArgumentException("Program has no motions."); - return pos; - } - } - - private static void AnalyzeMoves(List moves, bool cutoff, List obstacles, - List findings, int plate, int part, CancellationToken token) - { - var contour = new List(); - var unfinished = new List(); - var hasLead = false; - var contourNumber = 0; - foreach (var move in moves) - { - token.ThrowIfCancellationRequested(); - if (move.Rapid) - { - Finish(); - hasLead = false; - if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon) - continue; - foreach (var obstacle in obstacles) - { - token.ThrowIfCancellationRequested(); - if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token)) - findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, - $"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + - $"of part {obstacle.Part}.")); - } - } - else if (move.Layer == LayerType.Leadin) - { - Finish(); - hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon; - } - else if (move.Layer is LayerType.Leadout or LayerType.Scribe) - { - if (move.Layer == LayerType.Leadout && contour.Count > 0 - && !PostVerificationGeometry.Closed(contour) - && move.Curve.Length > PostVerificationGeometry.Epsilon) - findings.Add(new(PostVerificationKind.Incomplete, plate, part, null, - "A lead-out follows an open cutting contour and may cut through its retention gap. " + - "Rapid safety for that contour requires manual review.")); - Finish(); - hasLead = false; - } - else if (move.Curve.Length > PostVerificationGeometry.Epsilon) - { - if (contour.Count == 0) - { - contourNumber++; - if (!cutoff && !hasLead) - findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null, - $"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")); - hasLead = false; - // A rapid can pause/reposition without leaving any material gap. - // Retain already-cut fragments, but do not turn them into obstacles - // until an actually continuous chain closes. - var previous = unfinished.FindIndex(chain => - chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon); - if (previous >= 0) - { - contour.AddRange(unfinished[previous]); - unfinished.RemoveAt(previous); - } - } - contour.Add(move.Curve); - // A completed contour becomes an obstacle immediately, not at part end. - if (PostVerificationGeometry.Closed(contour)) - Finish(); - } - } - Finish(); - if (unfinished.Count > 1) - findings.Add(new(PostVerificationKind.Incomplete, plate, part, null, - "Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " + - "they cannot be assumed to be retained by tabs. Review rapid travel manually.")); - - void Finish() - { - if (contour.Count == 0) - return; - // A real uncut gap leaves the contour attached. CuttingParameters can be stale; - // no flag or tab configuration is used as evidence of retention. - if (!cutoff && PostVerificationGeometry.Closed(contour)) - obstacles.Add(new(part, contourNumber, contour.ToArray())); - else if (!cutoff) - unfinished.Add(contour.ToArray()); - contour.Clear(); - } - } - - private sealed record Move(Vector? Start, Vector End, bool Rapid, LayerType Layer, - PostVerificationGeometry.Curve Curve); - private sealed record Obstacle(int Part, int Contour, IReadOnlyList Curves); } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs new file mode 100644 index 0000000..9d451ec --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC.CuttingPlanning; +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. +/// +public sealed class CuttingPlanRequest +{ + public CuttingPlanRequest(IEnumerable parts, Vector startPoint = default, int expansionBudget = 20000) + { + Parts = parts == null ? null : Array.AsReadOnly(parts.ToArray()); + StartPoint = startPoint; + ExpansionBudget = expansionBudget; + } + + public IReadOnlyList Parts { get; } + public Vector StartPoint { get; } + public int ExpansionBudget { get; } +} + +/// Owned worker input. SourcePart references are identity handles only, never worker data. +public sealed class CuttingPlanSnapshot +{ + internal CuttingPlanSnapshot(IEnumerable placements, Vector startPoint, + int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable findings = null) + { + Placements = Array.AsReadOnly(placements.ToArray()); + StartPoint = startPoint; + ExpansionBudget = expansionBudget; + Failure = failure; + Findings = Array.AsReadOnly((findings ?? []).ToArray()); + } + + public IReadOnlyList Placements { get; } + public Vector StartPoint { get; } + public int ExpansionBudget { get; } + internal CuttingPlanStatus? Failure { get; } + internal IReadOnlyList Findings { get; } +} + +public sealed class FixedProgramPlacement +{ + internal FixedProgramPlacement(Part sourcePart, int sourceOrdinal, Vector location, + double rotation, bool leadInsLocked, OwnedExecution execution) + { + SourcePart = sourcePart; + SourceOrdinal = sourceOrdinal; + Location = location; + Rotation = rotation; + LeadInsLocked = leadInsLocked; + Execution = execution; + } + + public Part SourcePart { get; } + public int SourceOrdinal { get; } + public Vector Location { get; } + public double Rotation { get; } + public bool LeadInsLocked { get; } + public OwnedExecution Execution { get; } +} + +public enum CuttingPlanStatus +{ + Ready, + ConstraintConflict, + UnsupportedGeometry, + InvalidInput, + NoSolutionWithinBudget, + Cancelled +} + +/// Ordinals are zero-based source positions, not proposed sequence positions. +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. +/// +public sealed class CuttingPlanResult +{ + internal CuttingPlanResult(CuttingPlanStatus status, IEnumerable order = null, + IEnumerable findings = null, int expansions = 0, + double rapidDistance = 0, bool independentlyReplayed = false) + { + Status = status; + ProposedOrder = Array.AsReadOnly((order ?? []).ToArray()); + Findings = Array.AsReadOnly((findings ?? []).ToArray()); + Expansions = expansions; + RapidDistance = rapidDistance; + IndependentlyReplayed = independentlyReplayed; + } + + public CuttingPlanStatus Status { get; } + public IReadOnlyList ProposedOrder { get; } + public IReadOnlyList Findings { get; } + public int Expansions { get; } + public double RapidDistance { get; } + public bool IndependentlyReplayed { get; } +} diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs new file mode 100644 index 0000000..f00663b --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +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. + /// + public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default) + { + var placements = new List(); + Part source = null; + int? ordinal = null; + try + { + token.ThrowIfCancellationRequested(); + if (request?.Parts == null || request.Parts.Count == 0 || request.ExpansionBudget <= 0) + throw new ArgumentException("A nonempty source list and positive expansion budget are required."); + // 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++) + { + token.ThrowIfCancellationRequested(); + source = request.Parts[index]; + ordinal = index; + 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."); + var clean = ExecutionMotionReader.Read(source.BaseDrawing.Program, Vector.Zero, null, token); + if (!clean.HasCuttingContour) + throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice."); + 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)); + } + // 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); + } + catch (OperationCanceledException) + { + return Failure(CuttingPlanStatus.Cancelled, "Capture cancelled."); + } + catch (NotSupportedException exception) + { + return Failure(CuttingPlanStatus.UnsupportedGeometry, exception.Message); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or ArithmeticException) + { + return Failure(CuttingPlanStatus.InvalidInput, exception.Message); + } + + CuttingPlanSnapshot Failure(CuttingPlanStatus status, string message) => + new(placements, request?.StartPoint ?? Vector.Zero, request?.ExpansionBudget ?? 0, status, + [new(ordinal, source, null, null, null, message)]); + } + + public static CuttingPlanResult Plan(CuttingPlanRequest request, CancellationToken token = default) => + Plan(Capture(request, token), token); + + /// Worker-only planning uses owned values; live identities are never dereferenced. + public static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token = default) + { + if (token.IsCancellationRequested) + return new(CuttingPlanStatus.Cancelled); + if (snapshot == null) + return new(CuttingPlanStatus.InvalidInput); + if (snapshot.Failure is { } failure) + return new(failure, findings: snapshot.Findings); + try + { + var fixedFindings = new List(); + foreach (var placement in snapshot.Placements) + { + token.ThrowIfCancellationRequested(); + // Ignore only the unknown incoming rapid. Every fixed internal motion is checked. + var findings = new ReleasedContourState().Check(placement.Execution, null, + placement.SourceOrdinal + 1, token); + fixedFindings.AddRange(Map(snapshot, findings)); + } + if (fixedFindings.Count != 0) + return new(fixedFindings.Any(f => f.Kind == PostVerificationKind.Incomplete) + ? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict, + findings: fixedFindings); + + var search = FixedProgramSearch.Run(snapshot, token); + if (search.Status != CuttingPlanStatus.Ready) + return new(search.Status, findings: Map(snapshot, search.Findings), expansions: search.Expansions); + return Replay(snapshot, search.Order, search.Expansions, token); + } + catch (OperationCanceledException) + { + return new(CuttingPlanStatus.Cancelled); + } + } + + // Fresh checker and full sequence replay: no cached branch state is accepted as evidence. + internal static CuttingPlanResult Replay(CuttingPlanSnapshot snapshot, IReadOnlyList order, + int expansions, CancellationToken token) + { + 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); + var checker = new ReleasedContourState(); + var position = snapshot.StartPoint; + var distance = 0.0; + var findings = new List(); + foreach (var index in order) + { + token.ThrowIfCancellationRequested(); + var placement = snapshot.Placements[index]; + findings.AddRange(checker.Check(placement.Execution, position, placement.SourceOrdinal + 1, token)); + distance += placement.Execution.RapidDistanceFrom(position); + position = placement.Execution.DeparturePoint; + } + token.ThrowIfCancellationRequested(); + if (findings.Count != 0) + return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete) + ? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict, + findings: Map(snapshot, findings), expansions: expansions); + return new(CuttingPlanStatus.Ready, order.Select(index => snapshot.Placements[index]), + expansions: expansions, rapidDistance: distance, independentlyReplayed: true); + } + + private static IEnumerable Map(CuttingPlanSnapshot snapshot, + IEnumerable findings) => findings.Select(finding => + { + var source = finding.PartNumber is { } part ? snapshot.Placements[part - 1] : null; + var other = finding.OtherPartNumber is { } otherPart ? snapshot.Placements[otherPart - 1] : null; + return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart, + other?.SourceOrdinal, other?.SourcePart, finding.Kind, finding.Message); + }); +} diff --git a/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs b/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs new file mode 100644 index 0000000..00a555e --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.Engine.CuttingPlanning; + +/// Bounded forward depth-first search, nearest feasible branch then source ordinal. +internal static class FixedProgramSearch +{ + internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList Order, + IReadOnlyList Findings, int Expansions); + + internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token) + { + var stack = new Stack(); + stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState())); + var rejected = new HashSet(); + var expansions = 0; + while (stack.Count != 0) + { + token.ThrowIfCancellationRequested(); + var frame = stack.Peek(); + if (frame.Order.Length == snapshot.Placements.Count) + return new(CuttingPlanStatus.Ready, frame.Order, [], expansions); + if (frame.Next == frame.Candidates.Length) + { + stack.Pop(); + continue; + } + if (expansions == snapshot.ExpansionBudget) + return new(CuttingPlanStatus.NoSolutionWithinBudget, [], rejected.ToArray(), expansions); + var candidate = frame.Candidates[frame.Next++]; + expansions++; + var placement = snapshot.Placements[candidate]; + var checker = frame.Checker.Copy(); + var findings = checker.Check(placement.Execution, frame.Position, placement.SourceOrdinal + 1, token); + if (findings.Count != 0) + { + rejected.UnionWith(findings); + continue; + } + stack.Push(Create([.. frame.Order, candidate], placement.Execution.DeparturePoint, checker)); + } + 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)) + .OrderBy(index => snapshot.Placements[index].Execution.RapidDistanceFrom(position)) + .ThenBy(index => snapshot.Placements[index].SourceOrdinal).ToArray()); + } + + private sealed class Frame(int[] order, Vector position, ReleasedContourState checker, int[] candidates) + { + internal int[] Order { get; } = order; + internal Vector Position { get; } = position; + internal ReleasedContourState Checker { get; } = checker; + internal int[] Candidates { get; } = candidates; + internal int Next { get; set; } + } +} diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs new file mode 100644 index 0000000..ec4313c --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs @@ -0,0 +1,413 @@ +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 CuttingPlanServiceTests +{ + [Fact] + public void FixedPrograms_OriginalOrderCrossesA_ReorderedWholeProgramsDoNot() + { + var parts = Fixture(); + var original = Analyze(parts); + Assert.Equal(new int?[] { 2, 3 }, original.Findings.Select(f => f.PartNumber)); + Assert.All(original.Findings, crossing => + { + Assert.Equal(PostVerificationKind.RapidCrossing, crossing.Kind); + Assert.Equal(1, crossing.OtherPartNumber); + }); + var reordered = Analyze(parts[1], parts[0], parts[2]); + Assert.Empty(reordered.Findings); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Plan_ReordersWholeFixedPrograms_AndIndependentlyReplaysEveryPlacement(bool locked) + { + var parts = Fixture(); + foreach (var part in parts) + part.LeadInsLocked = locked; + var unchanged = Unchanged(parts); + var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts)); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart)); + Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + Assert.Equal(parts.Length, result.ProposedOrder.Select(p => p.SourcePart).Distinct().Count()); + Assert.Empty(result.Findings); + Assert.Empty(Analyze(result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings); + Assert.Equal(new Vector(12.25, 2), result.ProposedOrder[^1].Execution.DeparturePoint); + unchanged(); + } + + [Fact] + public void Plan_BacktracksRatherThanReturningUnsafeNearestOrder() + { + var parts = Fixture(); + var start = new Vector(8.25, 2); // A is nearest but no continuation after A can reach B safely. + var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, start)); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart)); + Assert.True(result.Expansions > parts.Length); + Assert.Empty(AnalyzeFrom(start, result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings); + } + + [Theory] + [InlineData(Mode.Absolute)] + [InlineData(Mode.Incremental)] + public void Plan_LockedFixedInternalCrossingIsConflictWithExactSourceIdentity(Mode mode) + { + var part = Rectangle("holes", 100, 50, 20, 20); + var hole = new Program(); + hole.MoveTo(0.5, 0); + hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin }); + hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW }); + hole.Mode = mode; + var placed = new Program(); + placed.SubPrograms[-7] = hole; + placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(5, 5), Id = -7 }); + placed.MoveTo(8, 5); + placed.MoveTo(2, 5); + placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(15, 5), Id = -7 }); + placed.Mode = mode; + Assert.True(part.RestoreLeadInProgram(placed, true)); + var parts = new[] { Fixture()[0], part }; + var unchanged = Unchanged(parts); + Assert.Contains(Analyze(part).Findings, f => f.Kind == PostVerificationKind.RapidCrossing); + var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts)); + Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status); + Assert.Empty(result.ProposedOrder); + Assert.NotEmpty(result.Findings); + Assert.All(result.Findings, finding => + { + Assert.Equal(PostVerificationKind.RapidCrossing, finding.Kind); + Assert.Equal(1, finding.SourceOrdinal); + Assert.Equal(1, finding.OtherSourceOrdinal); + Assert.Same(part, finding.SourcePart); + Assert.Same(part, finding.OtherSourcePart); + }); + unchanged(); + } + + [Theory] + [InlineData("recursive")] + [InlineData("missing-call")] + [InlineData("null-code")] + [InlineData("nonfinite")] + [InlineData("bad-arc")] + [InlineData("empty")] + [InlineData("motionless")] + [InlineData("null-codes")] + public void Plan_MalformedOrEmptyCuttingStateNeverReady(string fault) + { + var part = Fixture()[0]; + var program = part.Program; + switch (fault) + { + case "recursive": program.Codes.Add(new SubProgramCall { Program = program }); break; + case "missing-call": program.Codes.Add(new SubProgramCall()); break; + case "null-code": program.Codes.Add(null!); break; + case "nonfinite": program.MoveTo(double.NaN, 0); break; + case "bad-arc": program.ArcTo(1, 1, 0, 0, RotationType.CW); break; + case "empty": program.Codes.Clear(); break; + case "motionless": program.Codes.Clear(); program.MoveTo(1, 1); break; + case "null-codes": program.Codes = null!; break; + } + var beforeCodes = program.Codes?.ToArray(); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part])); + Assert.Equal(CuttingPlanStatus.InvalidInput, result.Status); + Assert.Empty(result.ProposedOrder); + Assert.Same(program, part.Program); + Assert.Equal(beforeCodes, program.Codes?.ToArray()); + } + + [Theory] + [InlineData("suppressed")] + [InlineData("missing-lead")] + [InlineData("retention-unknown")] + [InlineData("cutoff")] + public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault) + { + var part = Fixture()[0]; + var expected = CuttingPlanStatus.UnsupportedGeometry; + switch (fault) + { + case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break; + case "missing-lead": + part.Program.Codes.RemoveAt(1); + ((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2); + expected = CuttingPlanStatus.ConstraintConflict; break; + case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break; + case "cutoff": part.BaseDrawing.IsCutOff = true; break; + } + var unchanged = Unchanged([part]); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part])); + Assert.Equal(expected, result.Status); + Assert.Empty(result.ProposedOrder); + Assert.NotEmpty(result.Findings); + unchanged(); + } + + [Fact] + public void Plan_BudgetExhaustionHasNoFallbackOrMutation() + { + var parts = Fixture(); + var unchanged = Unchanged(parts); + var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 1)); + Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status); + Assert.Equal(1, result.Expansions); + Assert.Empty(result.ProposedOrder); + Assert.False(result.IndependentlyReplayed); + unchanged(); + } + + [Fact] + public void Plan_CancelledCaptureAndWorkerHaveNoMutation() + { + var parts = Fixture(); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts)); + var unchanged = Unchanged(parts); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + Assert.Equal(CuttingPlanStatus.Cancelled, + CuttingPlanService.Plan(new CuttingPlanRequest(parts), cancellation.Token).Status); + Assert.Equal(CuttingPlanStatus.Cancelled, CuttingPlanService.Plan(snapshot, cancellation.Token).Status); + unchanged(); + } + + [Fact] + public void Snapshot_OwnsMotionsPosesLocksAndOrder_NotLiveProgramsOrSettings() + { + var parts = Fixture(); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts)); + var ownedEnds = snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)).ToArray(); + foreach (var part in parts) + { + part.Program.Codes.Clear(); + part.BaseDrawing.Program.Codes.Clear(); + part.Location = new Vector(999, 999); + part.CuttingParameters.TabsEnabled = true; + part.LeadInsLocked = true; + } + Array.Reverse(parts); + var result = CuttingPlanService.Plan(snapshot); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + Assert.Equal(ownedEnds, snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End))); + Assert.Equal(new Vector(4, 0), snapshot.Placements[0].Location); + Assert.All(snapshot.Placements, p => Assert.False(p.LeadInsLocked)); + Assert.True(((ICollection)snapshot.Placements).IsReadOnly); + Assert.True(((ICollection)snapshot.Placements[0].Execution.Motions).IsReadOnly); + Assert.True(((ICollection)result.ProposedOrder).IsReadOnly); + } + + [Fact] + public void Plan_DeterministicReferenceIdentity_NotDrawingNamesOrNameEquality() + { + var parts = Fixture(); + foreach (var part in parts) + part.BaseDrawing.Name = "same"; + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts)); + for (var repeat = 0; repeat < 5; repeat++) + Assert.Equal(new[] { parts[1], parts[0], parts[2] }, + CuttingPlanService.Plan(snapshot).ProposedOrder.Select(p => p.SourcePart)); + Assert.Equal(CuttingPlanStatus.InvalidInput, + CuttingPlanService.Plan(new CuttingPlanRequest([parts[0], parts[0]])).Status); + Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(new CuttingPlanRequest([])).Status); + Assert.Equal(CuttingPlanStatus.InvalidInput, + CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 0)).Status); + Assert.Equal(CuttingPlanStatus.InvalidInput, + CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(double.NaN, 0))).Status); + } + + [Fact] + public void ExplicitStart_UsesActualLeadoutDeparture_AndDefaultRemainsZero() + { + var part = Fixture()[1]; + var defaultResult = CuttingPlanService.Plan(new CuttingPlanRequest([part])); + var explicitResult = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(2.25, 2))); + Assert.Equal(CuttingPlanStatus.Ready, defaultResult.Status); + Assert.Equal(CuttingPlanStatus.Ready, explicitResult.Status); + Assert.Equal(Vector.Zero.DistanceTo(new Vector(2.25, 2)), defaultResult.RapidDistance, 10); + Assert.Equal(0, explicitResult.RapidDistance); + Assert.Equal(new Vector(2.25, 2), explicitResult.ProposedOrder[0].Execution.DeparturePoint); + Assert.Empty(Analyze(part).Findings); + Assert.Empty(AnalyzeFrom(Vector.Zero, part).Findings); + } + + [Theory] + [InlineData("unsafe")] + [InlineData("duplicate")] + [InlineData("omitted")] + [InlineData("unknown")] + public void IndependentReplay_RefusesUnsafeOrIncompleteProposals(string fault) + { + var parts = Fixture(); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts)); + var unchanged = Unchanged(parts); + var order = fault switch + { + "unsafe" => new[] { 0, 1, 2 }, + "duplicate" => [1, 0, 0], + "omitted" => [1, 0], + _ => [1, 0, 3] + }; + var result = CuttingPlanService.Replay(snapshot, order, 0, default); + Assert.Equal(fault == "unsafe" ? CuttingPlanStatus.ConstraintConflict : CuttingPlanStatus.InvalidInput, + result.Status); + Assert.Empty(result.ProposedOrder); + Assert.False(result.IndependentlyReplayed); + if (fault == "unsafe") + { + Assert.Equal(new int?[] { 1, 2 }, result.Findings.Select(f => f.SourceOrdinal)); + Assert.All(result.Findings, f => Assert.Same(parts[0], f.OtherSourcePart)); + } + unchanged(); + } + + [Fact] + public void EqualRapidDistance_TiesKeepSourceOrdinal() + { + var parts = new[] { Rectangle("same", 10, -3, 2, 2), Rectangle("same", 10, 1, 2, 2) }; + var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts)); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.Equal(parts, result.ProposedOrder.Select(p => p.SourcePart)); + Assert.Empty(Analyze(parts).Findings); + } + + [Theory] + [InlineData(Mode.Absolute)] + [InlineData(Mode.Incremental)] + public void OwnedReader_SharedSignedSubcallsPreserveTrueLeadoutDepartureAndOwnership(Mode mode) + { + var sub = new Program(); + sub.MoveTo(1.25, 0); + sub.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin }); + sub.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW }); + sub.Codes.Add(new LinearMove(1.25, 0) { Layer = LayerType.Leadout }); + sub.Mode = mode; + var program = new Program(); + program.SubPrograms[-7] = sub; + program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(5, 5) }); + program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(15, 5) }); + var before = sub.ToString(); + var owned = ExecutionMotionReader.Read(program, new Vector(100, 50), Vector.Zero, default); + Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint); + Assert.Equal(new Vector(106.25, 55), owned.Motions[3].End); + Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1)); + sub.Codes.Clear(); + program.Codes.Clear(); + Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint); + Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1)); + Assert.NotEmpty(before); + } + + [Fact] + public void Check_CopiedReleasedStateDoesNotLeakBranchObstacles() + { + var parts = Fixture(); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts)); + var parent = new ReleasedContourState(); + var branch = parent.Copy(); + Assert.Empty(branch.Check(snapshot.Placements[0].Execution, Vector.Zero, 1)); + Assert.NotEmpty(branch.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2)); + Assert.Empty(parent.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2)); + } + + private static Action Unchanged(Part[] parts) + { + var order = parts.ToArray(); + var programs = parts.Select(p => p.Program).ToArray(); + var codeRefs = parts.Select(p => p.Program.Codes.ToArray()).ToArray(); + var text = parts.Select(p => p.Program.ToString()).ToArray(); + var locations = parts.Select(p => p.Location).ToArray(); + var rotations = parts.Select(p => p.Rotation).ToArray(); + var bounds = parts.Select(p => p.BoundingBox).ToArray(); + var parameters = parts.Select(p => p.CuttingParameters).ToArray(); + var locks = parts.Select(p => p.LeadInsLocked).ToArray(); + var manual = parts.Select(p => p.HasManualLeadIns).ToArray(); + var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray(); + var subs = parts.SelectMany(p => p.Program.Codes.OfType()) + .Select(call => (call, call.Program, text: call.Program.ToString(), call.Offset, call.Rotation, call.Id)).ToArray(); + return () => + { + Assert.Equal(order, parts); + for (var index = 0; index < parts.Length; index++) + { + var part = parts[index]; + Assert.Same(programs[index], part.Program); + Assert.Equal(codeRefs[index], part.Program.Codes); + Assert.Equal(text[index], part.Program.ToString()); + Assert.Equal(locations[index], part.Location); + Assert.Equal(rotations[index], part.Rotation); + Assert.Same(bounds[index], part.BoundingBox); + Assert.Same(parameters[index], part.CuttingParameters); + Assert.Equal(locks[index], part.LeadInsLocked); + Assert.Equal(manual[index], part.HasManualLeadIns); + Assert.Equal(quantities[index], part.BaseDrawing.Quantity.Nested); + } + foreach (var sub in subs) + { + Assert.Same(sub.Program, sub.call.Program); + Assert.Equal(sub.text, sub.call.Program.ToString()); + Assert.Equal(sub.Offset, sub.call.Offset); + Assert.Equal(sub.Rotation, sub.call.Rotation); + Assert.Equal(sub.Id, sub.call.Id); + } + }; + } + + private static Part[] Fixture() => + [ + Rectangle("A", 4, 0, 4, 4), + Rectangle("B", 0, 1, 2, 2), + Rectangle("C", 10, 1, 2, 2) + ]; + + private static Part Rectangle(string name, 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(name, 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)); + part.CuttingParameters = new CuttingParameters(); + 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); + } + + // Independent replay uses private placements/drawings, never an event-wired plate + // around the source drawings (which would change their quantity accounting). + private static PostVerificationReport Analyze(params Part[] parts) => AnalyzeFrom(Vector.Zero, parts); + + private static PostVerificationReport AnalyzeFrom(Vector start, params Part[] parts) + { + var nest = new Nest(); + var plate = nest.CreatePlate(); + foreach (var source in parts) + { + var copy = new Part(new Drawing("replay", (Program)source.BaseDrawing.Program.Clone()), source.Location); + Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked)); + plate.Parts.Add(copy); + } + return PostVerificationAnalyzer.Analyze(nest, start); + } +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md new file mode 100644 index 0000000..edff6df --- /dev/null +++ b/docs/cutting-planner.md @@ -0,0 +1,84 @@ +# Fixed-program cutting-route foundation + +`OpenNest.Engine.CuttingPlanning.CuttingPlanService` currently plans a direct-XY +route through fixed, contiguous whole-part programs. This is the first foundation +of the unified cutting planner, not a replacement desktop command or an Apply API. +Existing desktop sequencing, lead assignment and per-attempt posting review are +unchanged. + +## Calling the service + +Create a `CuttingPlanRequest` from source placements and an explicit modeled start +point, then call `Capture` while their order, programs and poses are stable. Pass +the resulting `CuttingPlanSnapshot` to `Plan` on a worker. `Plan(request)` combines +both steps for synchronous callers. The default start is `Vector.Zero`; this is +not a discovered controller position. The diagnostic overload +`PostVerificationAnalyzer.Analyze(nest, startPoint)` uses the same modeled start. + +Capture reads absolute/incremental instructions and shared subprogram calls into +owned immutable motion values. Placed rotations are already baked into programs; +placement translation is applied once. It does not clone recursive graphs, rebind +subcalls through rotating setters, or attach live drawings to preview plates. +Source `Part` references are identity handles only; worker planning never reads +their mutable state. A captured snapshot is deliberately historical, not a +freshness check against later edits. + +Both locked and unlocked programs remain fixed. Search changes only the proposed +whole-part order and never edits source order, programs, settings, locks, poses or +quantity accounting. Branches are ranked by modeled rapid distance and source +ordinal, with bounded deterministic backtracking. The expansion budget defaults +to 20,000 attempted placements. Actual departure motions, including lead-outs and +subprograms, determine the next approach. + +## Results and refusal + +`Ready` means only that every captured placement occurs once and the full +fixed-program route was replayed with a fresh completed-contour checker without +rapid, missing-lead or incomplete-motion findings. `IndependentlyReplayed` records +that replay; it does not certify final NC or machine safety. Result ordinals and +finding identities refer to the original zero-based source list, not the proposed +sequence positions. + +The checker uses the same native line/arc contact, contour completion and actual +gap semantics as pre-post diagnostics. Future contours are not yet obstacles; +completed holes in the same part are obstacles immediately. Stale tab settings +are not evidence of retention. + +- `ConstraintConflict`: a fixed internal program or all explored whole-part orders + violate the modeled route constraints. Reordering cannot repair a fixed rapid + crossing its own completed hole. +- `UnsupportedGeometry`: unsupported motion semantics or incomplete retention + checks. Cutoff dependencies and scribe-only drawings are outside this slice. +- `InvalidInput`: malformed/missing/duplicate placements, empty input, invalid + geometry or a nonpositive budget. +- `NoSolutionWithinBudget`: search reached its bound, not proof that no route exists. +- `Cancelled`: capture or worker cancellation, with no live mutation. + +Every non-ready result has no proposed order and no unsafe fallback. + +## Limits and next hardening + +This service does not check clean-material overlap, enclosing-hole/insert +release dependencies, contour coverage against clean geometry, lead paths through +other material, cutoff order, or physical retention strength. It does not generate +entries/leads, change internal contour order, install programs atomically, check +staleness at Apply, write CNC, or set posting consent. A `Ready` fixed-program route +can still be unsuitable for cutting. Do not apply it as a complete cutting plan. + +Next slices must add explicit contour emission and lead validation, containment +and cutoff dependencies, exact freshness/atomic application, then desktop caller +migration and legacy retirement. Native Windows interaction, supplied-job routing +coverage and actual posted order remain separate acceptance gates. Fresh +[pre-post verification](post-verification.md) is still required at posting +boundaries, and it is not a physical safety qualification. + +## Portable regression gate + +```sh +dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~CuttingPlanning|FullyQualifiedName~PostVerificationAnalyzerTests' +``` + +The synthetic three-part fixture starts with A,B,C crossing completed A; B,A,C +replays without findings. Controls cover a locked internal-hole crossing, +backtracking, native shared-hole motions, bounded refusal, cancellation, immutable +ownership, source-reference identity and complete-proposal replay rejection. diff --git a/docs/post-verification.md b/docs/post-verification.md index e53deb1..704a621 100644 --- a/docs/post-verification.md +++ b/docs/post-verification.md @@ -84,7 +84,9 @@ The overlap diagnostic's documented numeric/tessellation limits also apply. Future hardening should inspect a shared post-specific emitted motion stream, including controller retracts and final parking, rather than pretending direct XY -geometry proves physical head clearance. +geometry proves physical head clearance. The [fixed-program route foundation](cutting-planner.md) +reuses these motion/completion checks but is not yet a desktop planner or an Apply +API; its route-only `Ready` result does not replace fresh posting review. ## Verification on Windows