From bd3f529ccd19f399f68d0737313128f9d14d05d0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 7 Oct 2026 04:28:09 -0400 Subject: [PATCH] refactor(cutting): isolate candidate emission for lead validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmitCandidateForValidation emits exactly one owned contour with its normal lead-in and lead-out and its ORIGINAL contour type — the perimeter keeps External even with no holes before it — through a small shared EmitChosenContours seam, so a perimeter candidate's emitted leads can be validated before the hole choices exist. The probe is a throwaway program: never installed on a Part, never a complete plan. Normal Emit and EmitPrefix keep the perimeter-last gate untouched (still throwing for Emit([perimeter]) on a holed part); foreign and re-stamped choices still fail ownership/geometry validation and source shapes/settings are never used directly. Differential tests pin per-contour block fidelity (External-vs-hole lead geometry, five lead styles, two hole choice/orders, scribes once, reversed winding, rotated capture, circle rounding/clamping without cross-contour state) — contour lead geometry is choice-independent, only absolute accumulated coordinates drift by ulps with program head. --- .../CNC/CuttingPlanning/PreparedContours.cs | 19 ++ .../CuttingStrategy/ContourCuttingStrategy.cs | 28 +- .../ContourCandidateEmissionTests.cs | 303 ++++++++++++++++++ 3 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/ContourCandidateEmissionTests.cs diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index 4283f3f..777cd33 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -425,6 +425,25 @@ public sealed class PreparedContours return EmitPrefix(choices); } + /// + /// Diagnostic seam: emit exactly ONE owned contour — with its normal lead-in and + /// lead-out and its ORIGINAL contour type (the perimeter keeps External even when no + /// holes precede it) — so a candidate's emitted leads can be validated before the hole + /// choices exist. The result is a throwaway probe, not a plan: it must never be + /// installed on a Part or accepted as complete output. Normal and + /// keep the perimeter-last rule untouched. + /// + internal Program EmitCandidateForValidation(ContourChoice choice) + { + if (choice == null || !ReferenceEquals(choice.Owner, this)) + throw new ArgumentException("Foreign contour choice."); + ValidateChoice(choice); + // Same owned clones as a real emission; the source shapes/settings are never used + // directly, so the probe cannot drift the preparation or mutate it. + return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated( + shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice); + } + // Each prefix is a standalone owned program, including the same scribes once. internal Program EmitPrefix(IReadOnlyList choices) { diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index dfcd375..450906b 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -239,6 +239,32 @@ namespace OpenNest.CNC.CuttingStrategy { var result = new Program(Mode.Absolute); EmitScribeContours(result, scribes); + EmitChosenContours(result, shapes, choices); + result.Mode = Mode.Incremental; + return result; + } + + // Diagnostic seam for candidate lead validation (PreparedContours + // .EmitCandidateForValidation): emit a single already-validated choice with its + // ORIGINAL contour type — the last shape is External even when emitted alone, so a + // perimeter never degrades to a hole because it was isolated. Output must not be + // installed on a Part or accepted as a complete plan. + internal Program EmitCandidateIsolated(Shape[] shapes, List scribes, + CuttingPlanning.ContourChoice choice) + { + var result = new Program(Mode.Absolute); + EmitScribeContours(result, scribes); + EmitChosenContours(result, shapes, new[] { choice }); + result.Mode = Mode.Incremental; + return result; + } + + // One contour per choice in order; the perimeter (last shape) is always External. + // Contour emission itself reads only the contour's own geometry and settings, never + // prior choices — the differential tests in ContourCandidateEmissionTests pin this. + private void EmitChosenContours(Program result, Shape[] shapes, + IReadOnlyList choices) + { foreach (var choice in choices) { var shape = shapes[choice.ContourOrdinal]; @@ -246,8 +272,6 @@ namespace OpenNest.CNC.CuttingStrategy choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null, exactCirclePrograms: true); } - result.Mode = Mode.Incremental; - return result; } private void EmitRawContour(Program program, Shape shape) diff --git a/OpenNest.Tests/CuttingPlanning/ContourCandidateEmissionTests.cs b/OpenNest.Tests/CuttingPlanning/ContourCandidateEmissionTests.cs new file mode 100644 index 0000000..52d23f1 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourCandidateEmissionTests.cs @@ -0,0 +1,303 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// The single-contour diagnostic emitter: identical emitted lead groups in isolation and +/// inside a valid complete program (holes first), with the perimeter keeping its External +/// identity — and the normal perimeter-last gate left untouched. +/// +public class ContourCandidateEmissionTests +{ + private static PreparedContours Capture(Program program, CuttingParameters? parameters = null) => + PreparedContours.Capture(program, parameters ?? ExplicitContourTests.Parameters()); + + // --- fixtures --------------------------------------------------------------- + + /// Square perimeter with two circular holes; Capture lists holes 0-1, perimeter 2. + private static Program TwoHoles(bool reversed = false) + { + var p = ExplicitContourTests.Square(reversed); + foreach (var center in new[] { new Vector(3, 3), new Vector(7, 7) }) + { + p.MoveTo(center.X + 0.5, center.Y); + p.ArcTo(center.X + 0.5, center.Y, center.X, center.Y, RotationType.CCW); + } + return p; + } + + private static Program WithScribe(Program p) + { + p.MoveTo(1, 1); + p.Codes.Add(new LinearMove(new Vector(2, 1)) { Layer = LayerType.Scribe }); + return p; + } + + private static ContourChoice PerimeterCorner(PreparedContours prepared) + { + var corner = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal) + .First(c => c.Kind == AutomaticEntryKind.ConvexCorner).Choice; + return corner; + } + + private static Program CompleteWithHolesFirst(PreparedContours prepared, ContourChoice perimeterChoice) => + prepared.Emit(new[] + { + prepared.ClosestEntry(0, new Vector(3, 3)), + prepared.ClosestEntry(1, new Vector(7, 7)), + perimeterChoice, + }); + + // --- differential fidelity ------------------------------------------------------ + + [Fact] + public void PerimeterInIsolation_MatchesCompleteProgramWithHolesFirst() + { + // Distinct External/Internal leads: if isolation misclassified the perimeter as a + // hole, its emitted lead geometry would differ from the complete program's. + var parameters = ExplicitContourTests.Parameters(); + parameters.ExternalLeadIn = new LineLeadIn { Length = 1.5, ApproachAngle = 45 }; + parameters.InternalLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }; + var prepared = Capture(TwoHoles(), parameters); + var corner = PerimeterCorner(prepared); + + var isolated = prepared.EmitCandidateForValidation(corner); + var complete = CompleteWithHolesFirst(prepared, corner); + + // The perimeter is the LAST contour of the complete program and alone in the probe. + AssertIsolatedMatchesTrailing(isolated, complete); + } + + [Theory] + [InlineData("line")] + [InlineData("arc")] + [InlineData("linearc")] + [InlineData("lineline")] + [InlineData("clean")] + public void EveryLeadStyle_StillIsolatedEqualComplete(string style) + { + var parameters = ExplicitContourTests.Parameters(style); + var prepared = Capture(TwoHoles(), parameters); + var corner = PerimeterCorner(prepared); + + var isolated = prepared.EmitCandidateForValidation(corner); + var complete = CompleteWithHolesFirst(prepared, corner); + AssertIsolatedMatchesTrailing(isolated, complete); + } + + [Fact] + public void HoleInIsolation_MatchesItsSliceOfTheCompleteProgram() + { + var prepared = Capture(TwoHoles()); + var hole = prepared.ClosestEntry(0, new Vector(3, 3)); + + var isolated = prepared.EmitCandidateForValidation(hole); + var complete = CompleteWithHolesFirst(prepared, PerimeterCorner(prepared)); + Assert.Equal(BlockHashes(isolated).Single(), BlockHashes(complete).First()); + } + + [Fact] + public void EarlierHoleChoices_DoNotChangePerimeterEmission() + { + // Two different hole-entry choices/orders: the perimeter block is invariant. + var prepared = Capture(TwoHoles()); + var corner = PerimeterCorner(prepared); + + var orderA = prepared.Emit(new[] + { + prepared.ClosestEntry(0, new Vector(3, 3)), + prepared.ClosestEntry(1, new Vector(7, 7)), + corner, + }); + var orderB = prepared.Emit(new[] + { + prepared.ClosestEntry(1, new Vector(7, 7.5)), + prepared.ClosestEntry(0, new Vector(3, 3.5)), + corner, + }); + var isolated = prepared.EmitCandidateForValidation(corner); + + Assert.Equal(BlockHashes(orderA)[^1..], BlockHashes(orderB)[^1..]); + AssertIsolatedMatchesTrailing(isolated, orderA); + } + + [Fact] + public void Scribes_AppearOnceInIsolation() + { + var prepared = Capture(WithScribe(TwoHoles())); + var corner = PerimeterCorner(prepared); + + var isolated = prepared.EmitCandidateForValidation(corner); + var scribes = NonRapid(isolated).Where(m => m.Layer == LayerType.Scribe).ToList(); + Assert.Single(scribes); + } + + [Fact] + public void ReversedWinding_IsolatedMatchesComplete() + { + var prepared = Capture(TwoHoles(reversed: true)); + var corner = PerimeterCorner(prepared); + + var isolated = prepared.EmitCandidateForValidation(corner); + var complete = CompleteWithHolesFirst(prepared, corner); + AssertIsolatedMatchesTrailing(isolated, complete); + } + + [Fact] + public void RotatedCapture_IsolatedMatchesComplete() + { + var source = TwoHoles(); + source.Rotate(System.Math.PI / 7); + var prepared = Capture(source); + var corner = PerimeterCorner(prepared); + + var isolated = prepared.EmitCandidateForValidation(corner); + var complete = prepared.Emit(new[] + { + prepared.ClosestEntry(0, new Vector(3, 3)), + prepared.ClosestEntry(1, new Vector(7, 7)), + corner, + }); + AssertIsolatedMatchesTrailing(isolated, complete); + } + + [Fact] + public void CircleRoundingAndClamping_AreNotSharedState() + { + // Two circles at different normals with 90-degree rounding and clamping: the seam + // must not leak rounded/clamped state between contours. + var parameters = ExplicitContourTests.Parameters(); + parameters.RoundLeadInAngles = true; + parameters.LeadInAngleIncrement = 90; + parameters.ArcCircleLeadIn = new LineLeadIn { Length = 2 }; + var p = ExplicitContourTests.Square(false); + foreach (var center in new[] { new Vector(3, 3), new Vector(7, 7) }) + { + p.MoveTo(center.X + 0.5, center.Y); + p.ArcTo(center.X + 0.5, center.Y, center.X, center.Y, RotationType.CCW); + } + var prepared = Capture(p, parameters); + var hole0 = prepared.ClosestEntry(0, new Vector(3.5, 3)); + var hole1 = prepared.ClosestEntry(1, new Vector(7, 6.5)); + + var isolated0 = prepared.EmitCandidateForValidation(hole0); + var isolated1 = prepared.EmitCandidateForValidation(hole1); + var complete = prepared.Emit(new[] { hole0, hole1, PerimeterCorner(prepared) }); + + var blocks = BlockHashes(complete); + // Each isolated circle matches its own slice; different normals give different + // emitted circle programs — no cross-contour sharing of rounded state. + Assert.Equal(blocks[0], BlockHashes(isolated0).Single()); + Assert.Equal(blocks[1], BlockHashes(isolated1).Single()); + Assert.NotEqual(blocks[0], blocks[1]); + } + + // --- safety gates --------------------------------------------------------------- + + [Fact] + public void NormalEmit_StillRefusesPerimeterBeforeHoles() + { + var prepared = Capture(TwoHoles()); + var corner = PerimeterCorner(prepared); + + // The diagnostic seam does not loosen the ordinary perimeter-last gate. + Assert.Throws(() => prepared.Emit(new[] { corner })); + Assert.Throws(() => prepared.EmitPrefix(new[] { corner })); + } + + [Fact] + public void ForeignOrForgedChoice_IsRejected() + { + var prepared = Capture(TwoHoles()); + var other = Capture(TwoHoles()); + var foreign = other.Entry(other.PerimeterOrdinal, 0, new Vector(0, 0)); + Assert.Throws(() => prepared.EmitCandidateForValidation(foreign)); + + // A copy re-stamped with this preparation as owner is still rejected when its + // contour or point does not exist on this preparation: ownership alone is not enough. + var forgedContour = other.Entry(0, 0, new Vector(3.5, 3)) with { Owner = prepared, ContourOrdinal = 99 }; + Assert.Throws(() => prepared.EmitCandidateForValidation(forgedContour)); + var forgedEntity = other.Entry(0, 0, new Vector(3.5, 3)) with { Owner = prepared, EntityOrdinal = 99 }; + Assert.Throws(() => prepared.EmitCandidateForValidation(forgedEntity)); + } + + [Fact] + public void SourceProgramAndSettings_AreNotMutated() + { + var source = TwoHoles(); + var before = ExplicitContourTests.Fingerprint(source); + var parameters = ExplicitContourTests.Parameters(); + var prepared = Capture(source, parameters); + var corner = PerimeterCorner(prepared); + + prepared.EmitCandidateForValidation(corner); + + Assert.Equal(before, ExplicitContourTests.Fingerprint(source)); + Assert.Equal(ExplicitContourTests.Parameters().ExternalLeadIn.GetType(), + parameters.ExternalLeadIn.GetType()); + } + + // --- helpers -------------------------------------------------------------------- + + private static List NonRapid(Program program) => + ExecutionMotionReader.Read(program, Vector.Zero, null, default).Motions + .Where(m => !m.Rapid).ToList(); + + /// + /// One hash per emitted contour block: cut/lead motion runs in emission order, split at + /// each first lead-in that follows cut motion (every lead style in Parameters() emits + /// lead-in motions; scribe motions precede all blocks and are excluded). Each block is + /// normalised to its own first motion's start and quantised to 1e-9 because the emitted + /// programs are incremental — the reader resolves absolute positions by accumulating + /// from the program head, so the same code chain differs from a fresh head by ulps. + /// + /// The complete program's LAST contour is the perimeter: assert the isolated + /// probe's blocks equal the trailing block sequence of the complete program. + private static void AssertIsolatedMatchesTrailing(Program isolated, Program complete) + { + var iso = BlockHashes(isolated); + var blocks = BlockHashes(complete); + Assert.True(blocks.Count >= iso.Count); + Assert.Equal(iso, blocks.Skip(blocks.Count - iso.Count).ToList()); + } + + private static List BlockHashes(Program program) + { + var motions = NonRapid(program).Where(m => m.Layer != LayerType.Scribe).ToList(); + var blocks = new List>(); + var current = new List(); + var sawCut = true; + foreach (var motion in motions) + { + var isLead = motion.Layer == LayerType.Leadin || motion.Layer == LayerType.Leadout; + if (motion.Layer == LayerType.Leadin && sawCut && current.Count > 0) + { + blocks.Add(current); + current = new List(); + } + current.Add(motion); + if (!isLead) + sawCut = true; + } + if (current.Count > 0) + blocks.Add(current); + return blocks.Select(Hash).ToList(); + } + + private static string Hash(List block) + { + var origin = block[0].Start ?? block[0].End; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\n", block.Select(m => + string.Join("|", m.Layer, + Q((m.Start ?? m.End).X - origin.X), Q((m.Start ?? m.End).Y - origin.Y), + Q(m.End.X - origin.X), Q(m.End.Y - origin.Y))))))); + + static string Q(double value) => (value / 1e-9).ToString("F0", CultureInfo.InvariantCulture); + } +}