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);