diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs
new file mode 100644
index 0000000..1faca8f
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs
@@ -0,0 +1,186 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+using Curve = OpenNest.Diagnostics.PostVerificationGeometry.Curve;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+/// Independently constructed expected geometry, frozen before final replay.
+/// It is not a search verdict or the payload returned to the caller.
+internal sealed class SelectedContourProgram
+{
+ private readonly PreparedContours owner;
+ private readonly ContourChoice[] choices;
+ internal OwnedExecution Expected { get; }
+ internal double TabSize { get; }
+
+ internal SelectedContourProgram(PreparedContours owner, IReadOnlyList choices,
+ OwnedExecution expected, double tabSize)
+ {
+ this.owner = owner;
+ this.choices = choices.ToArray();
+ Expected = expected;
+ TabSize = tabSize;
+ }
+
+ internal bool Matches(PreparedContours prepared, IReadOnlyList selected)
+ {
+ if (!ReferenceEquals(owner, prepared) || !choices.SequenceEqual(selected)) return false;
+ prepared.ValidateCompleteChoices(selected);
+ return true;
+ }
+}
+
+/// Directed native boundary accounting and selected-emission correspondence.
+/// No tessellation, topology inference, live source reads or final emission.
+internal static class ContourProgramVerifier
+{
+ private const double Epsilon = PostVerificationGeometry.Epsilon;
+
+ internal static bool Verify(OwnedExecution actual, LeadMaterialSnapshot material,
+ SelectedContourProgram selected, CancellationToken token)
+ {
+ var budget = 1000000;
+ if (material == null || !material.IsComplete) return false;
+ if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
+ var visited = new HashSet();
+ foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
+ {
+ token.ThrowIfCancellationRequested();
+ if (run.Curves.Count == 0) return false;
+ var first = run.Curves[0];
+ var ringIndex = -1;
+ var entityIndex = -1;
+ for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
+ for (var e = 0; e < material.Rings[r].Length; e++)
+ {
+ Query(token, ref budget);
+ var curve = material.Rings[r][e];
+ if (curve.SameDirection(first) && curve.Contains(first.Start)
+ && curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
+ {
+ ringIndex = r;
+ entityIndex = e;
+ break;
+ }
+ }
+ if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
+ var ring = material.Rings[ringIndex];
+ var nominalLength = ring.Sum(c => c.Length);
+ var cutLength = run.Curves.Sum(c => c.Length);
+ var missing = nominalLength - cutLength;
+ if (missing < -Epsilon) return false; // Never a second circuit/retrace.
+ if (missing > Epsilon)
+ {
+ // Fixed legacy payloads have no certified selected tab/entry metadata.
+ // A merely open contour or a caller's current tab switch proves nothing.
+ if (selected == null || ringIndex != 0 || selected.TabSize <= 0
+ || System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > Epsilon)
+ return false;
+ // SameEmission has already bound this exact terminal gap to the owned
+ // settings' independently emitted, rounded/clamped selected geometry.
+ }
+ if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
+ true, token, ref budget)) return false;
+ }
+ return visited.Count == material.Rings.Count;
+ }
+
+ private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
+ CancellationToken token, ref int budget)
+ {
+ var left = Runs(actual);
+ var right = Runs(expected);
+ if (left.Count != right.Count) return false;
+ for (var i = 0; i < left.Count; i++)
+ {
+ Query(token, ref budget);
+ var a = left[i];
+ var b = right[i];
+ if (a.Kind != b.Kind || a.Rapid != b.Rapid) return false;
+ if (a.Rapid)
+ {
+ // Arrival is supplied anew by replay, not captured expected geometry.
+ if (a.End.DistanceTo(b.End) > Epsilon) return false;
+ }
+ else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
+ || !Follows(a.Curves, b.Curves, 0, 0, false, token, ref budget)) return false;
+ }
+ return true;
+ }
+
+ // Consume directed native arc-length from both streams. Subdivision, merged
+ // collinear moves and a cyclic ring's reindexing do not change coverage.
+ private static bool Follows(IReadOnlyList actual, IReadOnlyList expected,
+ int index, double offset, bool cyclic, CancellationToken token, ref int budget)
+ {
+ foreach (var curve in actual)
+ {
+ var consumed = 0.0;
+ while (consumed < curve.Length - Epsilon)
+ {
+ Query(token, ref budget);
+ if (index >= expected.Count)
+ {
+ if (!cyclic) return false;
+ index = 0;
+ }
+ var nominal = expected[index];
+ var remaining = nominal.Length - offset;
+ if (remaining <= 0)
+ {
+ index++;
+ offset = 0;
+ continue;
+ }
+ var length = System.Math.Min(remaining, curve.Length - consumed);
+ if (!nominal.SameDirection(curve)
+ || nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon
+ || nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon)
+ return false;
+ consumed += length;
+ offset += length;
+ if (offset >= nominal.Length - Epsilon)
+ {
+ index++;
+ offset = 0;
+ }
+ }
+ }
+ return cyclic || index == expected.Count || index == expected.Count - 1
+ && expected[index].Length - offset <= Epsilon;
+ }
+
+ private static List Runs(OwnedExecution execution)
+ {
+ var runs = new List();
+ foreach (var motion in execution.Motions)
+ {
+ var kind = motion.Layer is LayerType.Cut or LayerType.Display ? LayerType.Cut : motion.Layer;
+ if (motion.Rapid)
+ {
+ runs.Add(new Run(true, kind, motion.End));
+ continue;
+ }
+ if (motion.Length <= Epsilon) continue; // Zero travel contributes no coverage.
+ if (runs.Count == 0 || runs[^1].Rapid || runs[^1].Kind != kind)
+ runs.Add(new Run(false, kind, motion.End));
+ runs[^1].Curves.Add(motion.Curve);
+ }
+ return runs;
+ }
+
+ private static void Query(CancellationToken token, ref int budget)
+ {
+ token.ThrowIfCancellationRequested();
+ if (--budget < 0) throw new NotSupportedException("Replay contour accounting exceeds the native query limit.");
+ }
+
+ private sealed record Run(bool Rapid, LayerType Kind, Vector End)
+ {
+ internal List Curves { get; } = new();
+ }
+}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index 4af4da9..377ba77 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -171,6 +171,18 @@ public sealed class PreparedContours
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices);
}
+ internal void ValidateCompleteChoices(IReadOnlyList choices) => Validate(choices, true);
+
+ // Build expected-emission metadata independently of the selected payload BEFORE
+ // replay. Final verification only reads this immutable execution and clean geometry.
+ internal SelectedContourProgram CaptureSelectedProgram(IReadOnlyList choices,
+ Vector location, CancellationToken token)
+ {
+ token.ThrowIfCancellationRequested();
+ var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
+ return new(this, choices, expected, parameters.TabsEnabled ? parameters.TabConfig.Size : 0);
+ }
+
private void Validate(IReadOnlyList choices, bool complete)
{
if (choices == null || (complete && choices.Count != Count) || choices.Count > Count)
diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
index dc5798a..697395f 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
@@ -99,6 +99,26 @@ internal static class PostVerificationGeometry
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon;
}
+ // Directed native arc-length coordinates for contour replay, not collision queries.
+ internal bool SameDirection(Curve other) => SameSupport(other)
+ && (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
+ : Dot(End - Start, other.End - other.Start) > 0);
+
+ internal double DistanceAlong(Vector point) => Center is { } center
+ ? point.DistanceTo(Start) <= Epsilon ? 0
+ : Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)) * Radius
+ : Dot(point - Start, (End - Start) * (1 / Length));
+
+ internal Vector PointAtLength(double distance)
+ {
+ if (distance <= 0) return Start;
+ if (distance >= Length) return End;
+ return Center is { } center
+ ? center + new Vector(System.Math.Cos(StartAngle + System.Math.Sign(Sweep) * distance / Radius),
+ System.Math.Sin(StartAngle + System.Math.Sign(Sweep) * distance / Radius)) * Radius
+ : Start + (End - Start) * (distance / Length);
+ }
+
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
internal IReadOnlyList Contacts(Curve other, out bool overlap)
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
index 81823d3..e272b29 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
@@ -94,8 +94,27 @@ public sealed class FixedProgramPlacement
public bool IsRegenerated => ContourChoices.Count != 0;
/// Returns an independent deep copy; never an alias to captured/proposed code.
public Program CopyProgram() => program == null ? null : OwnedProgramCopy.Copy(program);
- internal FixedProgramPlacement Propose(Program proposed, OwnedExecution execution, IReadOnlyList choices) =>
- new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution, OwnedProgramCopy.Copy(proposed), Prepared, Material, choices);
+ internal SelectedContourProgram SelectedProgram { get; private init; }
+
+ internal FixedProgramPlacement Propose(Program proposed, OwnedExecution execution, IReadOnlyList choices,
+ System.Threading.CancellationToken token = default)
+ {
+ SelectedContourProgram selected = null;
+ try
+ {
+ // The expected program is constructed from owned choices/settings, NEVER
+ // from proposed or its cached execution. Counterfeit payloads remain subject
+ // to independent final checks, including callers of this internal test seam.
+ selected = Prepared?.CaptureSelectedProgram(choices, Location, token);
+ }
+ catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException or ArithmeticException)
+ {
+ // Invalid metadata is a refused proposal, not an exception from final replay.
+ }
+ return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
+ OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
+ { SelectedProgram = selected };
+ }
public Part SourcePart { get; }
public int SourceOrdinal { get; }
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
index 89cefb7..758d334 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
@@ -201,18 +201,15 @@ public static class CuttingPlanService
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)
+ else if (proposal.SelectedProgram == null
+ || !proposal.SelectedProgram.Matches(source.Prepared, proposal.ContourChoices))
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
OwnedExecution execution;
+ bool complete;
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);
+ complete = ContourProgramVerifier.Verify(execution, source.Material, proposal.SelectedProgram, token);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
@@ -225,6 +222,11 @@ public static class CuttingPlanService
if (!lead.IsComplete || !lead.IsClear)
findings.Add(JointCuttingPlanSearch.Finding(source,
lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
+ if (!complete && findings.Count == 0)
+ return new(CuttingPlanStatus.InvalidInput,
+ findings: [JointCuttingPlanSearch.Finding(source, null,
+ "Selected program does not preserve complete directed contours, certified tabs or selected entry/style geometry.")],
+ expansions: expansions);
distance += execution.RapidDistanceFrom(position);
position = execution.DeparturePoint;
}
@@ -237,42 +239,6 @@ public static class CuttingPlanService
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 bool SameBits(double source, double proposed) =>
BitConverter.DoubleToInt64Bits(source) == BitConverter.DoubleToInt64Bits(proposed);
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 6dd713a..ea3fa85 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -105,7 +105,7 @@ internal static class JointCuttingPlanSearch
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, source.Propose(program, execution, prefix, token)], 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);
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingReplayContractTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingReplayContractTests.cs
new file mode 100644
index 0000000..a7ee1c8
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CuttingReplayContractTests.cs
@@ -0,0 +1,287 @@
+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 CuttingReplayContractTests
+{
+ private static readonly Vector Start = new(-2, 5);
+
+ [Theory]
+ [InlineData("partial")]
+ [InlineData("retrace")]
+ [InlineData("reverse")]
+ [InlineData("twice")]
+ [InlineData("wrong-point")]
+ [InlineData("wrong-index")]
+ [InlineData("different-entry")]
+ [InlineData("short-lead")]
+ public void Replay_RejectsCounterfeitSelectedPayload(string fault)
+ {
+ var (part, settings) = Fixture();
+ var before = ExplicitContourTests.Fingerprint(part.Program);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
+ var source = snapshot.Placements[0];
+ var choices = new[] { source.Prepared.ClosestEntry(0, Start) };
+ var program = source.Prepared.Emit(choices);
+ switch (fault)
+ {
+ case "partial": program = Path(new Vector(0, 10)); break;
+ case "retrace": program = Path(new(0, 8), new(0, 7), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)); break;
+ case "reverse": program = Path(new(0, 0), new(10, 0), new(10, 10), new(0, 10), new(0, 5)); break;
+ case "twice": program = Path(new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)); break;
+ case "wrong-point": choices[0] = choices[0] with { Point = new Vector(999, 999) }; break;
+ case "wrong-index": choices[0] = choices[0] with { EntityOrdinal = 999 }; break;
+ case "different-entry": choices[0] = source.Prepared.Entry(0, 0, new Vector(0, 6)); break;
+ case "short-lead":
+ program.Mode = Mode.Absolute;
+ var rapid = program.Codes.OfType().First();
+ var lead = program.Codes.OfType().First(m => m.Layer == LayerType.Leadin);
+ rapid.EndPoint = lead.EndPoint + (rapid.EndPoint - lead.EndPoint) * 0.5;
+ break;
+ }
+ var result = CuttingPlanService.ReplayPrograms(snapshot, [source.Propose(program, Read(program), choices)], 0, default);
+ Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
+ Assert.Empty(result.ProposedOrder);
+ Assert.False(result.IndependentlyReplayed);
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program));
+ }
+
+ [Theory]
+ [InlineData(true, "partial")]
+ [InlineData(false, "partial")]
+ [InlineData(true, "reverse")]
+ [InlineData(false, "retrace")]
+ public void ConfirmedFixedProgram_RejectsIncompleteOrWrongDirectedCoverageWithoutRepair(bool locked, string fault)
+ {
+ var (part, settings) = Fixture();
+ var program = fault switch
+ {
+ "reverse" => Path(new(0, 0), new(10, 0), new(10, 10), new(0, 10), new(0, 5)),
+ "retrace" => Path(new(0, 8), new(0, 7), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)),
+ _ => Path(new Vector(0, 10))
+ };
+ Assert.True(part.RestoreLeadInProgram(program, locked));
+ var before = ExplicitContourTests.Fingerprint(part.Program);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings,
+ eligibleParts: locked ? null : []));
+ var result = CuttingPlanService.Plan(snapshot);
+ Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
+ Assert.NotEqual(CuttingPlanStatus.Ready, CuttingPlanService.ReplayPrograms(snapshot, snapshot.Placements, 0, default).Status);
+ Assert.Empty(result.ProposedOrder);
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program));
+ }
+
+ [Fact]
+ public void Replay_RejectsDifferentActualContourOrderWithUnchangedChoices()
+ {
+ var settings = ExplicitContourTests.Parameters();
+ var part = new Part(new Drawing("holes", PreparedContourTests.Holes()));
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
+ var source = snapshot.Placements[0];
+ var choices = Enumerable.Range(0, 3).Select(i => source.Prepared.ClosestEntry(i, Start)).ToArray();
+ var swapped = new[] { choices[1], choices[0], choices[2] };
+ var program = source.Prepared.Emit(swapped);
+ Assert.NotEqual(CuttingPlanStatus.Ready,
+ CuttingPlanService.ReplayPrograms(snapshot, [source.Propose(program, Read(program), choices)], 0, default).Status);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Replay_PreservesFullAndExactConfiguredTabWithSubdivisions(bool tabbed)
+ {
+ var (part, settings) = Fixture();
+ settings.TabsEnabled = tabbed;
+ settings.TabConfig = new NormalTab { Size = 0.2 };
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
+ var ready = CuttingPlanService.Plan(snapshot);
+ Assert.True(ready.Status == CuttingPlanStatus.Ready, string.Join("; ", ready.Findings.Select(f => f.Message)));
+ var source = snapshot.Placements[0];
+ var proposal = ready.ProposedOrder[0];
+ Assert.Equal(tabbed ? 39.8 : 40, proposal.Execution.Motions.Where(IsCut).Sum(m => m.Length), 8);
+ var subdivided = Subdivide(proposal.CopyProgram());
+ var replay = CuttingPlanService.ReplayPrograms(snapshot,
+ [source.Propose(subdivided, Read(subdivided), proposal.ContourChoices)], 0, default);
+ Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
+ Assert.True(replay.IndependentlyReplayed);
+ // A different retained gap is not the selected tab, even though it remains open.
+ if (tabbed)
+ {
+ var counterfeit = proposal.CopyProgram();
+ counterfeit.Mode = Mode.Absolute;
+ var last = counterfeit.Codes.OfType().Last(m => m.Layer == LayerType.Display);
+ last.EndPoint = new Vector(0, 4.9);
+ Assert.NotEqual(CuttingPlanStatus.Ready, CuttingPlanService.ReplayPrograms(snapshot,
+ [source.Propose(counterfeit, Read(counterfeit), proposal.ContourChoices)], 0, default).Status);
+ }
+ }
+
+ [Fact]
+ public void FixedUnknownTabMetadata_IsRefusedButCompatibilityRouteRemainsUnchanged()
+ {
+ var (part, settings) = Fixture();
+ Assert.True(part.RestoreLeadInProgram(Path(new Vector(0, 10)), true));
+ Assert.Equal(CuttingPlanStatus.Ready, CuttingPlanService.Plan(new CuttingPlanRequest([part], Start)).Status);
+ settings.TabsEnabled = true;
+ settings.TabConfig = new NormalTab { Size = 0.2 };
+ var prepared = PreparedContours.Capture(part.BaseDrawing.Program, settings);
+ var tabbed = prepared.Emit([prepared.ClosestEntry(0, Start)]);
+ Assert.True(part.RestoreLeadInProgram(tabbed, true));
+ Assert.NotEqual(CuttingPlanStatus.Ready, CuttingPlanService.Plan(new CuttingPlanRequest([part], Start, confirmedParameters: settings)).Status);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void CompleteFixedProgram_RetainsAuthoredEntryAndDimensions(bool locked)
+ {
+ var (part, settings) = Fixture();
+ part.LeadInsLocked = locked;
+ ((LineLeadIn)settings.ExternalLeadIn).Length = 0.7;
+ var before = ExplicitContourTests.Fingerprint(part.Program);
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], Start, confirmedParameters: settings,
+ eligibleParts: locked ? null : []));
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(result.ProposedOrder[0].CopyProgram()));
+ }
+
+ [Theory]
+ [InlineData("circles")]
+ [InlineData("mixed-circles")]
+ [InlineData("arcs")]
+ public void NativeCurves_SelectedRoundingClampingAndSubdivisionRemainValid(string shape)
+ {
+ var settings = ExplicitContourTests.Parameters();
+ settings.RoundLeadInAngles = true;
+ settings.LeadInAngleIncrement = 90;
+ settings.ArcCircleLeadIn = new LineLeadIn { Length = 10 };
+ var clean = PreparedContourTests.Holes();
+ if (shape == "mixed-circles") clean.Codes.OfType().Last().Rotation = RotationType.CW;
+ if (shape == "arcs")
+ {
+ clean = ExplicitContourTests.Square(false);
+ clean.MoveTo(6, 3);
+ clean.ArcTo(4, 3, 5, 3, RotationType.CCW);
+ clean.LineTo(6, 3);
+ }
+ var part = new Part(new Drawing("native", clean));
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
+ var ready = CuttingPlanService.Plan(snapshot);
+ Assert.True(ready.Status == CuttingPlanStatus.Ready, string.Join("; ", ready.Findings.Select(f => f.Message)));
+ var source = snapshot.Placements[0];
+ var selected = ready.ProposedOrder[0];
+ if (shape != "arcs")
+ {
+ // Search may legitimately select cardinal candidates. Force off-angle
+ // choices to exercise rounding, right hole before left for clear departure.
+ var choices = new[] { source.Prepared.ClosestEntry(1, Start),
+ source.Prepared.ClosestEntry(0, Start), source.Prepared.ClosestEntry(2, Start) };
+ var explicitProgram = source.Prepared.Emit(choices);
+ selected = source.Propose(explicitProgram, Read(explicitProgram), choices);
+ Assert.Equal(CuttingPlanStatus.Ready,
+ CuttingPlanService.ReplayPrograms(snapshot, [selected], 0, default).Status);
+ var cuts = selected.Execution.Motions.Where(IsCut).Take(2).ToArray();
+ Assert.Contains(Enumerable.Range(0, 2), i => cuts[i].Start!.Value.DistanceTo(selected.ContourChoices[i].Point) > 0.1);
+ Assert.All(selected.Execution.Motions.Where(m => m.Layer == LayerType.Leadin).Take(2), m => Assert.True(m.Length < 2));
+ }
+ var program = Subdivide(selected.CopyProgram());
+ Assert.Equal(CuttingPlanStatus.Ready, CuttingPlanService.ReplayPrograms(snapshot,
+ [source.Propose(program, Read(program), selected.ContourChoices)], 0, default).Status);
+ }
+
+ [Fact]
+ public void SelectedContract_IsFrozenBeforeReplayAndNeverTrustsCachedExecution()
+ {
+ var (part, settings) = Fixture();
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
+ var source = snapshot.Placements[0];
+ var choices = new[] { source.Prepared.ClosestEntry(0, Start) };
+ var program = source.Prepared.Emit(choices);
+ var cachedExecution = Read(program);
+ // Supply stale complete execution alongside actual partial code. This must
+ // refuse even when proposal storage defensively copies its input program.
+ program.Mode = Mode.Absolute;
+ program.Codes.RemoveRange(3, program.Codes.Count - 3);
+ var proposal = source.Propose(program, cachedExecution, choices);
+ ((LineLeadIn)settings.ExternalLeadIn).Length = 900;
+ part.BaseDrawing.Program.Codes.Clear();
+ var replay = CuttingPlanService.ReplayPrograms(snapshot, [proposal], 0, default);
+ Assert.NotEqual(CuttingPlanStatus.Ready, replay.Status);
+ Assert.Empty(replay.ProposedOrder);
+ }
+
+ [Fact]
+ public void FixedReindexedMergedNativeIntervals_PreserveCompleteCoverageAndPayload()
+ {
+ var settings = ExplicitContourTests.Parameters();
+ var clean = ExplicitContourTests.Square(false);
+ clean.Codes.Insert(1, new LinearMove(0, 7));
+ var part = new Part(new Drawing("subdivided clean", clean));
+ var program = new Program();
+ program.MoveTo(5, 10.3);
+ program.Codes.Add(new LinearMove(5, 10) { Layer = LayerType.Leadin });
+ program.LineTo(10, 10); program.LineTo(10, 0); program.LineTo(0, 0);
+ program.LineTo(0, 10); program.LineTo(5, 10);
+ Assert.True(part.RestoreLeadInProgram(program, true));
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(5, 12), confirmedParameters: settings));
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ Assert.Equal(ExplicitContourTests.Fingerprint(program), ExplicitContourTests.Fingerprint(result.ProposedOrder[0].CopyProgram()));
+ }
+
+ private static (Part, CuttingParameters) Fixture()
+ {
+ var settings = ExplicitContourTests.Parameters();
+ var clean = ExplicitContourTests.Square(false);
+ var prepared = PreparedContours.Capture(clean, settings);
+ var part = new Part(new Drawing("square", clean));
+ Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, Start)]), false));
+ return (part, settings);
+ }
+
+ private static Program Path(params Vector[] points)
+ {
+ var program = new Program();
+ program.MoveTo(-0.3, 5);
+ program.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin });
+ foreach (var point in points) program.LineTo(point);
+ return program;
+ }
+
+ private static OwnedExecution Read(Program program) => ExecutionMotionReader.Read(program, Vector.Zero, null, default);
+ private static bool IsCut(ExecutionMotion motion) => !motion.Rapid && motion.Layer is LayerType.Cut or LayerType.Display;
+
+ private static Program Subdivide(Program program)
+ {
+ // Flatten executed frames through the existing native reader, not tessellation.
+ var copy = new Program();
+ foreach (var motion in Read(program).Motions)
+ {
+ if (motion.Rapid) { copy.MoveTo(motion.End); continue; }
+ var midpoint = motion.Curve.Midpoint;
+ if (motion.Curve.ToEntity() is Arc arc)
+ {
+ copy.Codes.Add(new ArcMove(midpoint, arc.Center, arc.Rotation) { Layer = motion.Layer });
+ copy.Codes.Add(new ArcMove(motion.End, arc.Center, arc.Rotation) { Layer = motion.Layer });
+ }
+ else if (motion.Curve.ToEntity() is Circle circle)
+ {
+ var clockwise = PostVerificationGeometry.Curve.Create(motion.Start!.Value, motion.End, circle.Center, true);
+ var original = motion.Curve.SameDirection(clockwise) ? RotationType.CW : RotationType.CCW;
+ copy.Codes.Add(new ArcMove(midpoint, circle.Center, original) { Layer = motion.Layer });
+ copy.Codes.Add(new ArcMove(motion.End, circle.Center, original) { Layer = motion.Layer });
+ }
+ else
+ {
+ copy.Codes.Add(new LinearMove(midpoint) { Layer = motion.Layer });
+ copy.Codes.Add(new LinearMove(motion.End) { Layer = motion.Layer });
+ }
+ }
+ return copy;
+ }
+
+}
diff --git a/OpenNest.Tests/CuttingPlanning/ReplayPoseBindingTests.cs b/OpenNest.Tests/CuttingPlanning/ReplayPoseBindingTests.cs
index e8c9151..04d2c13 100644
--- a/OpenNest.Tests/CuttingPlanning/ReplayPoseBindingTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/ReplayPoseBindingTests.cs
@@ -37,7 +37,8 @@ public class ReplayPoseBindingTests
&& BitConverter.DoubleToInt64Bits(source.Rotation) == BitConverter.DoubleToInt64Bits(rotation);
Assert.Equal(fault == "matching-bits", sameBits);
var selected = new FixedProgramPlacement(source.SourcePart, source.SourceOrdinal, new Vector(x, y), rotation,
- source.LeadInsLocked, proposal.Execution, proposal.CopyProgram(), source.Prepared, source.Material, proposal.ContourChoices);
+ source.LeadInsLocked, proposal.Execution, proposal.CopyProgram(), source.Prepared, source.Material, proposal.ContourChoices)
+ .Propose(proposal.CopyProgram(), proposal.Execution, proposal.ContourChoices);
var replay = CuttingPlanService.ReplayPrograms(snapshot, [selected], 0, default);
Assert.Equal(sameBits ? CuttingPlanStatus.Ready : CuttingPlanStatus.InvalidInput, replay.Status);
Assert.Equal(sameBits, replay.IndependentlyReplayed);