From 018e4e3dd4498308cba960242488cd6f611d2512 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 4 Oct 2026 20:50:43 -0400 Subject: [PATCH] feat(cutting): validate actual leads against owned material --- .../CuttingPlanning/LeadMaterialSnapshot.cs | 126 +++++++ .../CNC/CuttingPlanning/LeadPathValidator.cs | 122 +++++++ .../Diagnostics/PostVerificationGeometry.cs | 83 +++++ .../LeadPathValidationTests.cs | 328 ++++++++++++++++++ 4 files changed, 659 insertions(+) create mode 100644 OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs create mode 100644 OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs create mode 100644 OpenNest.Tests/CuttingPlanning/LeadPathValidationTests.cs diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs new file mode 100644 index 0000000..1c4e8da --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingPlanning; + +/// Owned nominal material: one simple perimeter minus disjoint simple holes. +public sealed class LeadMaterialSnapshot +{ + private LeadMaterialSnapshot(IReadOnlyList rings, string reason) + { + Rings = rings; + Reason = reason; + } + + public bool IsComplete => Reason == null; + public string Reason { get; } + internal IReadOnlyList Rings { get; } + + /// Capture a stable, clean, rotation-baked program, applying location once. + /// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws. + public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location, + CancellationToken token = default) + { + try + { + var execution = ExecutionMotionReader.Read(cleanProgram, location, null, token); + var rings = new List(); + var chain = new List(); + foreach (var motion in execution.Motions) + { + token.ThrowIfCancellationRequested(); + if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout) + { + Finish(); + continue; + } + if (motion.Layer is not (LayerType.Cut or LayerType.Display)) + throw new ArgumentException("Material capture contains an unsupported layer."); + var curve = motion.Curve; + if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon) + throw new ArgumentException("Material contains a degenerate motion."); + if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon) + throw new ArgumentException("Material contour is discontinuous."); + chain.Add(curve); + if (PostVerificationGeometry.Closed(chain)) + Finish(); + } + Finish(); + if (rings.Count == 0) + throw new ArgumentException("Material has no closed contour."); + var budget = 1000000; + // Certify simple rings and mutually disjoint boundaries before containment. + for (var r = 0; r < rings.Count; r++) + for (var s = r; s < rings.Count; s++) + for (var i = 0; i < rings[r].Length; i++) + for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++) + { + token.ThrowIfCancellationRequested(); + if (--budget < 0) + throw new NotSupportedException("Material validation exceeds the native query limit."); + var a = rings[r][i]; + var b = rings[s][j]; + var contacts = a.Contacts(b, out var overlap); + var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1)); + if (overlap || contacts.Any(p => !adjacent + || !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon + && p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon) + && !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon + && p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon))) + throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap})."); + } + var outer = -1; + for (var i = 0; i < rings.Count; i++) + { + token.ThrowIfCancellationRequested(); + if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token))) + { + if (outer >= 0) + throw new ArgumentException("Material perimeter is ambiguous."); + outer = i; + } + } + if (outer < 0) + throw new ArgumentException("Material must have exactly one enclosing perimeter."); + var holes = rings.Where((_, i) => i != outer).ToArray(); + for (var i = 0; i < holes.Length; i++) + for (var j = i + 1; j < holes.Length; j++) + if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token)) + throw new ArgumentException("Nested cutouts are not supported material."); + return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(), null); + + void Finish() + { + if (chain.Count == 0) + return; + if (!PostVerificationGeometry.Closed(chain)) + throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly."); + rings.Add(chain.ToArray()); + chain.Clear(); + } + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + return new LeadMaterialSnapshot(Array.Empty(), ex.Message); + } + } + + internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token) + && !Rings.Skip(1).Any(hole => Inside(point, hole, token)); + + internal static bool Inside(Vector point, IReadOnlyList ring, CancellationToken token) + { + var inside = false; + foreach (var curve in ring) + { + token.ThrowIfCancellationRequested(); + if (curve.CrossesRay(point)) + inside = !inside; + } + return inside; + } +} diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs new file mode 100644 index 0000000..c354417 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingPlanning; + +/// A complete unsafe result is distinct from an incomplete/unsupported check. +public sealed record LeadPathValidationResult(bool IsComplete, bool IsClear, string Reason); + +/// Certifies actual emitted native lead paths against owned nominal material. +public static class LeadPathValidator +{ + public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target, + IReadOnlyList otherMaterials, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + if (execution == null || target == null || otherMaterials == null) + return new(false, false, "Missing execution or material snapshots."); + if (!target.IsComplete || otherMaterials.Any(m => m == null || !m.IsComplete)) + return new(false, false, "Material snapshot is incomplete."); + try + { + var budget = 1000000; + for (var i = 0; i < execution.Motions.Count; i++) + { + token.ThrowIfCancellationRequested(); + var move = execution.Motions[i]; + if (move.Layer is not (LayerType.Leadin or LayerType.Leadout)) + continue; + if (move.Rapid || move.Start is not { } start || move.Curve == null + || !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon + || start.DistanceTo(move.Curve.Start) > PostVerificationGeometry.Epsilon + || move.End.DistanceTo(move.Curve.End) > PostVerificationGeometry.Epsilon) + return new(false, false, "Lead motion is missing, degenerate or inconsistent."); + Vector? allowed = null; + var groupEdge = i; + var step = move.Layer == LayerType.Leadin ? 1 : -1; + while (groupEdge + step >= 0 && groupEdge + step < execution.Motions.Count + && execution.Motions[groupEdge + step].Layer == move.Layer) + { + token.ThrowIfCancellationRequested(); + if (--budget < 0) + throw new NotSupportedException("Lead validation exceeds the native query limit."); + groupEdge += step; + } + var adjacentIndex = groupEdge + step; + var genuineJoint = false; + if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count) + { + var adjacent = execution.Motions[adjacentIndex]; + if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display + && adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon) + { + var edge = execution.Motions[groupEdge]; + var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start; + var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End; + foreach (var boundary in target.Rings.SelectMany(r => r)) + { + token.ThrowIfCancellationRequested(); + if (--budget < 0) + throw new NotSupportedException("Lead validation exceeds the native query limit."); + if (joint.DistanceTo(contourJoint) <= PostVerificationGeometry.Epsilon + && boundary.SameSupport(adjacent.Curve) + && boundary.Contains(adjacent.Curve.Start) && boundary.Contains(adjacent.End) + && boundary.Contains(adjacent.Curve.Midpoint) + && adjacent.Length <= boundary.Length + PostVerificationGeometry.Epsilon) + { + genuineJoint = true; + if (groupEdge == i) + allowed = joint; + break; + } + } + } + } + if (!genuineJoint) + return new(true, false, "Lead chain has no genuine adjacent target contour entry or exit."); + if (allowed is { } jointPoint + && (move.Layer == LayerType.Leadin ? start : move.End).DistanceTo(jointPoint) <= PostVerificationGeometry.Epsilon) + return new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only."); + var failure = CheckMaterial(target, allowed); + if (failure != null) + return new(true, false, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."); + foreach (var material in otherMaterials) + { + token.ThrowIfCancellationRequested(); + if (ReferenceEquals(material, target)) + continue; + if (CheckMaterial(material, null) != null) + return new(true, false, "Lead contacts or enters another placed material."); + } + + string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint) + { + foreach (var boundary in material.Rings.SelectMany(r => r)) + { + token.ThrowIfCancellationRequested(); + if (--budget < 0) + throw new NotSupportedException("Lead validation exceeds the native query limit."); + var contacts = move.Curve.Contacts(boundary, out var overlap); + if (overlap || contacts.Any(p => permittedJoint is not { } joint + || p.DistanceTo(joint) > PostVerificationGeometry.Epsilon)) + return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}"; + } + // With all other boundary contacts excluded, the connected open path + // has constant material membership. Use the native arc midpoint, not + // the chord midpoint or endpoints (which can both lie in scrap). + return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null; + } + } + // Missing leads are the ReleasedContourState check's responsibility. + return new(true, true, null); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + return new(false, false, ex.Message); + } + } +} diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index c3c64e9..4205a57 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -75,6 +75,89 @@ internal static class PostVerificationGeometry private double Sweep { get; } internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End); + // Native entities are freshly allocated; the immutable curve never exposes state. + internal Entity ToEntity() => Center is { } center + ? System.Math.Abs(Sweep) >= TwoPi + ? new Circle(center, Radius) + : new Arc(center, Radius, Normalize(StartAngle), Normalize(StartAngle + Sweep), Sweep < 0) + : new Line(Start, End); + + internal Vector Midpoint => Center is { } center + ? new Vector(center.X + Radius * System.Math.Cos(StartAngle + Sweep / 2), + center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2)) + : (Start + End) * 0.5; + + internal bool SameSupport(Curve other) + { + if (Center is { } center) + return other.Center is { } c && center.DistanceTo(c) <= Epsilon + && System.Math.Abs(Radius - other.Radius) <= Epsilon; + if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon) + return false; + var direction = (End - Start) * (1 / Length); + return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon + && System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon; + } + + internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon; + + internal IReadOnlyList Contacts(Curve other, out bool overlap) + { + var entity = ToEntity(); + var candidate = other.ToEntity(); + List points; + bool intersects; + switch (candidate) + { + case Line line: intersects = entity.Intersects(line, out points); break; + case Arc arc: intersects = entity.Intersects(arc, out points); break; + case Circle circle: intersects = entity.Intersects(circle, out points); break; + default: throw new NotSupportedException("Unsupported native boundary."); + } + if (!intersects) + points.Clear(); + // Coincident circles produce NaNs in the native discrete-contact query. + // Their support overlap is handled separately, without inventing crossings. + overlap = SameSupport(other) && (InteriorWitness(Midpoint, other) + || InteriorWitness(other.Midpoint, this) + || InteriorWitness(Start, other) || InteriorWitness(End, other) + || InteriorWitness(other.Start, this) || InteriorWitness(other.End, this)); + if (SameSupport(other)) + points.Clear(); + foreach (var point in points) + Validate(point); + var nativeContactCount = points.Count; + if (Center is { } c && other.Center is { } oc && SameSupport(other) + && (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius)) + overlap = true; // Nearly coincident supports are uncertain, never clear. + foreach (var point in new[] { Start, End, other.Start, other.End }) + if (Contains(point) && other.Contains(point) + && !points.Exists(p => p.DistanceTo(point) <= Epsilon)) + points.Add(point); + // Existing exact line/ray contact semantics guard native queries which + // suppress very short or nearly parallel intersections. Uncertainty refuses. + if (Center is null && !SameSupport(other)) + { + var direction = (End - Start) * (1 / Length); + if ((other.ContactAfterStart(Start, direction, Length) + || other.ContactAfterStart(End, direction * -1, Length)) && nativeContactCount == 0) + throw new NotSupportedException("Native contact query is numerically uncertain."); + } + if (other.Center is null && Center.HasValue) + { + var direction = (other.End - other.Start) * (1 / other.Length); + if ((ContactAfterStart(other.Start, direction, other.Length) + || ContactAfterStart(other.End, direction * -1, other.Length)) && nativeContactCount == 0) + throw new NotSupportedException("Native contact query is numerically uncertain."); + } + return points; + + static bool InteriorWitness(Vector point, Curve curve) => curve.Contains(point) + && (curve.Start.DistanceTo(curve.End) <= Epsilon + || (point.DistanceTo(curve.Start) > Epsilon && point.DistanceTo(curve.End) > Epsilon)); + } + + internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise) { if (center is not { } c) diff --git a/OpenNest.Tests/CuttingPlanning/LeadPathValidationTests.cs b/OpenNest.Tests/CuttingPlanning/LeadPathValidationTests.cs new file mode 100644 index 0000000..1619455 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/LeadPathValidationTests.cs @@ -0,0 +1,328 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class LeadPathValidationTests +{ + [Fact] + public void ExternalStraightLead_SafeButRejectsCrossingPlacedMaterial() + { + var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero); + Assert.True(target.IsComplete, target.Reason); + var execution = Read(ExternalLead()); + Assert.True(LeadPathValidator.Check(execution, target, [target]).IsClear); + var obstacle = LeadMaterialSnapshot.Capture(Rectangle(-2, 4, -1, 6), Vector.Zero); + var result = LeadPathValidator.Check(execution, target, [obstacle]); + Assert.True(result.IsComplete, result.Reason); + Assert.False(result.IsClear); + } + + [Fact] + public void HoleScrap_SafeStraightAndArcButRejectsBulgingArc() + { + var clean = Rectangle(0, 0, 10, 10); + clean.Codes.AddRange(Rectangle(2, 2, 8, 8).Codes); + var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero); + Assert.True(target.IsComplete, target.Reason); + var straight = new Program(); + straight.MoveTo(5, 5); + straight.Codes.Add(new LinearMove(2, 5) { Layer = LayerType.Leadin }); + straight.LineTo(2, 8); + AssertClear(straight, target); + var curved = new Program(); + curved.MoveTo(3, 4); + curved.Codes.Add(new ArcMove(new Vector(2, 5), new Vector(3, 5), RotationType.CW) { Layer = LayerType.Leadin }); + curved.LineTo(2, 8); + AssertClear(curved, target); + var bulge = new Program(); + bulge.MoveTo(4, 3); + bulge.Codes.Add(new ArcMove(new Vector(2, 5), new Vector(3, 4), RotationType.CW) { Layer = LayerType.Leadin }); + bulge.LineTo(2, 8); + AssertUnsafe(bulge, target); + straight.Codes[0] = new RapidMove(1, 5); // Begins in material, not the hole. + AssertUnsafe(straight, target); + } + + [Fact] + public void OtherHoleIsScrap_AndLineArcTangenciesReject() + { + var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero); + var ring = Rectangle(-4, 2, 2, 8); + ring.Codes.AddRange(Rectangle(-3.5, 3, 1, 7).Codes); + var hole = LeadMaterialSnapshot.Capture(ring, Vector.Zero); + Assert.True(hole.IsComplete, hole.Reason); + AssertClear(ExternalLead(), target, hole); + var tangent = LeadMaterialSnapshot.Capture(Circle(-1.5, 6, 1), Vector.Zero); + Assert.True(tangent.IsComplete, tangent.Reason); + AssertUnsafe(ExternalLead(), target, tangent); + var arc = new Program(); + arc.MoveTo(-2, 5); + arc.Codes.Add(new ArcMove(new Vector(0, 5), new Vector(-1, 5), RotationType.CW) { Layer = LayerType.Leadin }); + arc.LineTo(0, 10); + AssertClear(arc, target); + AssertUnsafe(arc, target, LeadMaterialSnapshot.Capture(Circle(-1, 7, 1), Vector.Zero)); + } + + [Fact] + public void CoincidentBoundariesAndArbitraryJointsAreNotClear() + { + var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero); + var along = new Program(); + along.MoveTo(0, 2); + along.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin }); + along.LineTo(0, 10); + AssertUnsafe(along, target); + var arbitrary = ExternalLead(); + arbitrary.Codes[2] = new LinearMove(5, 5); // Adjacent move is NOT nominal contour. + AssertUnsafe(arbitrary, target); + var circular = LeadMaterialSnapshot.Capture(Circle(0, 0, 2), Vector.Zero); + var coincident = new Program(); + coincident.MoveTo(2, 0); + coincident.Codes.Add(new ArcMove(new Vector(-2, 0), Vector.Zero) { Layer = LayerType.Leadin }); + coincident.Codes.Add(new ArcMove(new Vector(2, 0), Vector.Zero)); + AssertUnsafe(coincident, circular); + } + + [Fact] + public void FullCircleLeadCannotReuseItsJointAsAnArbitraryPierceEndpoint() + { + var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero); + var p = new Program(); p.MoveTo(0, 5); + p.Codes.Add(new ArcMove(new Vector(0, 5), new Vector(-1, 5)) { Layer = LayerType.Leadin }); + p.LineTo(0, 10); + AssertUnsafe(p, target); + } + + [Fact] + public void TinyNativeUncertainContactRefusesRatherThanApproves() + { + var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero); + var obstacle = LeadMaterialSnapshot.Capture(Circle(-0.000005, 5, 0.000001), Vector.Zero); + Assert.True(obstacle.IsComplete, obstacle.Reason); + var p = ExternalLead(); p.Codes[0] = new RapidMove(-0.00001, 5); + var check = LeadPathValidator.Check(Read(p), target, [obstacle]); + Assert.False(check.IsComplete); Assert.False(check.IsClear); + } + + [Fact] + public void LeadoutUsesActualTabbedCutEndpoint_NotNominalClosure() + { + var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero); + var p = new Program(); + p.MoveTo(0, 5); p.LineTo(0, 10); p.LineTo(10, 10); p.LineTo(10, 0); p.LineTo(0, 0); p.LineTo(0, 4.8); + p.Codes.Add(new LinearMove(-2, 4.8) { Layer = LayerType.Leadout }); + AssertClear(p, target); + p.Codes[^1] = new LinearMove(-2, 5) { Layer = LayerType.Leadout }; + AssertClear(p, target); // Departure is still (0,4.8), not the entry (0,5). + p.Codes.Insert(p.Codes.Count - 1, new RapidMove(0, 5)); + AssertUnsafe(p, target); // No adjacent cut: a rapid cannot bridge the tab. + } + + [Fact] + public void ActualArcLeadoutChecksFullSweep_NotOnlyScrapEndpoints() + { + var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero); + var p = new Program(); p.MoveTo(0, 10); p.LineTo(0, 5); + p.Codes.Add(new ArcMove(new Vector(-1, 4), new Vector(-1, 5), RotationType.CW) { Layer = LayerType.Leadout }); + AssertClear(p, target); + AssertUnsafe(p, target, LeadMaterialSnapshot.Capture(Circle(-1, 3, 1), Vector.Zero)); + p.Codes[^1] = new ArcMove(new Vector(-1, 4), new Vector(0, 4), RotationType.CW) { Layer = LayerType.Leadout }; + AssertUnsafe(p, target); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void RealStrategyLeadouts_SafePathsOrRefusedMalformedTabbedArc(bool tabs, bool arc) + { + var clean = ExplicitContourTests.Square(false); + var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero); + var parameters = ExplicitContourTests.Parameters(); + parameters.TabsEnabled = tabs; + parameters.TabConfig = new NormalTab { Size = 0.2 }; + parameters.ExternalLeadOut = arc ? new ArcLeadOut { Radius = 0.2 } : new LineLeadOut { Length = 0.2 }; + var emitted = new ContourCuttingStrategy { Parameters = parameters }.Apply(clean, new Vector(-2, 5)); + if (tabs && arc) + { + // The retained emitter generates this arc from nominal closure, not the + // actual tab endpoint. Do not fit a different center or bridge the gap. + Assert.Throws(() => Read(emitted.Program)); + return; + } + var execution = Read(emitted.Program); + Assert.NotEmpty(execution.Motions.Where(m => m.Layer == LayerType.Leadout)); + var result = LeadPathValidator.Check(execution, target, []); + Assert.True(result.IsComplete, result.Reason); Assert.True(result.IsClear, result.Reason); + if (tabs) + { + var cuts = execution.Motions.Where(m => m.Layer == LayerType.Display && !m.Rapid).ToArray(); + var leadout = Assert.Single(execution.Motions.Where(m => m.Layer == LayerType.Leadout)); + Assert.True(cuts[0].Start!.Value.DistanceTo(cuts[^1].End) > 0.1); + Assert.Equal(cuts[^1].End, leadout.Start); + } + } + + [Fact] + public void SourceMutationDoesNotAffectSnapshotOrExecution() + { + var clean = Rectangle(0, 0, 10, 10); + var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero); + var emitted = ExternalLead(); + var execution = Read(emitted); + clean.Codes.Clear(); emitted.Codes.Clear(); + Assert.True(LeadPathValidator.Check(execution, target, []).IsClear); + Assert.Empty(typeof(LeadMaterialSnapshot).GetProperties().Where(p => p.PropertyType == typeof(Program) + || typeof(Entity).IsAssignableFrom(p.PropertyType))); + } + + [Fact] + public void RotatedIncrementalSubprogramActualEmission_AppliesLocationOnce() + { + var clean = Rectangle(0, 0, 10, 10); + var emitted = ExternalLead(); + clean.Rotate(System.Math.PI / 2); emitted.Rotate(System.Math.PI / 2); + clean.Mode = Mode.Incremental; emitted.Mode = Mode.Incremental; + var cleanRoot = new Program(); + cleanRoot.Codes.Add(new SubProgramCall(clean, 90) { Offset = new Vector(4, 6) }); + var emittedRoot = new Program(); + emittedRoot.Codes.Add(new SubProgramCall(emitted, 90) { Offset = new Vector(4, 6) }); + var location = new Vector(20, 30); + var target = LeadMaterialSnapshot.Capture(cleanRoot, location); + Assert.True(target.IsComplete, target.Reason); + var execution = Read(emittedRoot, location); + var lead = Assert.Single(execution.Motions.Where(m => m.Layer == LayerType.Leadin)); + Assert.Equal(19, lead.End.X, 8); Assert.Equal(36, lead.End.Y, 8); + Assert.True(LeadPathValidator.Check(execution, target, []).IsClear); + var obstacle = LeadMaterialSnapshot.Capture(Rectangle(18, 33.5, 20, 34.5), Vector.Zero); + Assert.False(LeadPathValidator.Check(execution, target, [obstacle]).IsClear); + } + + [Theory] + [InlineData("open")] + [InlineData("self")] + [InlineData("disjoint")] + [InlineData("nested")] + [InlineData("nonfinite")] + [InlineData("zero")] + [InlineData("suppressed")] + [InlineData("lead")] + [InlineData("recursive")] + public void MalformedMaterialRefuses(string kind) + { + var p = Rectangle(0, 0, 10, 10); + switch (kind) + { + case "open": p.Codes.RemoveAt(p.Codes.Count - 1); break; + case "self": p = new Program(); p.MoveTo(0, 0); p.LineTo(10, 10); p.LineTo(0, 10); p.LineTo(10, 0); p.LineTo(0, 0); break; + case "disjoint": p.Codes.AddRange(Rectangle(20, 20, 30, 30).Codes); break; + case "nested": p.Codes.AddRange(Rectangle(2, 2, 8, 8).Codes); p.Codes.AddRange(Rectangle(3, 3, 7, 7).Codes); break; + case "nonfinite": p.Codes[1] = new LinearMove(double.NaN, 2); break; + case "zero": p.Codes.Insert(1, new LinearMove(0, 0)); break; + case "suppressed": ((Motion)p.Codes[1]).Suppressed = true; break; + case "lead": ((LinearMove)p.Codes[1]).Layer = LayerType.Leadin; break; + case "recursive": p.Codes.Add(new SubProgramCall(p, 0)); break; + } + var target = LeadMaterialSnapshot.Capture(p, Vector.Zero); + Assert.False(target.IsComplete); + var result = LeadPathValidator.Check(Read(ExternalLead()), target, []); + Assert.False(result.IsComplete); Assert.False(result.IsClear); + } + + [Fact] + public void ZeroLeadRefuses_MissingLeadIsDelegated_MarksAreNotMaterial_CancellationThrows() + { + var clean = Rectangle(0, 0, 10, 10); + clean.MoveTo(20, 20); + clean.Codes.Add(new LinearMove(30, 30) { Layer = LayerType.Scribe }); + var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero); + Assert.True(target.IsComplete, target.Reason); + AssertClear(Rectangle(0, 0, 10, 10), target); + var p = ExternalLead(); p.Codes[0] = new RapidMove(0, 5); + var result = LeadPathValidator.Check(Read(p), target, []); + Assert.False(result.IsComplete); Assert.False(result.IsClear); + var token = new CancellationToken(true); + Assert.Throws(() => LeadMaterialSnapshot.Capture(clean, Vector.Zero, token)); + Assert.Throws(() => LeadPathValidator.Check(Read(ExternalLead()), target, [], token)); + p.Codes[1] = new ArcMove(new Vector(0, 5), new Vector(0, 5)) { Layer = LayerType.Leadin }; + Assert.Throws(() => Read(p)); + p.Codes[1] = new LinearMove(double.PositiveInfinity, 5) { Layer = LayerType.Leadin }; + Assert.Throws(() => Read(p)); + } + + [Theory] + [InlineData("line")] + [InlineData("arc")] + [InlineData("lineline")] + [InlineData("linearc")] + public void RealStrategyEmission_ClearExternalAndInternalLeads(string style) + { + var clean = ExplicitContourTests.Square(false); + clean.Codes.AddRange(Circle(5, 5, 2).Codes); + var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero); + Assert.True(target.IsComplete, target.Reason); + var parameters = ExplicitContourTests.Parameters(style); + if (style == "linearc") + ((LineArcLeadIn)parameters.ExternalLeadIn).ApproachAngle = 90; + var result = new ContourCuttingStrategy { Parameters = parameters }.Apply(clean, new Vector(-2, 5)); + var execution = Read(result.Program); + Assert.NotEmpty(execution.Motions.Where(m => m.Layer == LayerType.Leadin)); + var check = LeadPathValidator.Check(execution, target, []); + Assert.True(check.IsComplete, check.Reason); + Assert.True(check.IsClear, check.Reason + "\n" + string.Join("\n", execution.Motions.Select(m => $"{m.Layer} {m.Start} -> {m.End}, {m.Length}"))); + } + + [Fact] + public void RealStrategyEmission_UnsafeCompositePierceRefusesWithoutRepair() + { + var clean = ExplicitContourTests.Square(false); + clean.Codes.AddRange(Circle(5, 5, 2).Codes); + var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero); + var emitted = new ContourCuttingStrategy { Parameters = ExplicitContourTests.Parameters("linearc") }.Apply(clean, new Vector(-2, 5)); + AssertUnsafe(emitted.Program, target); + var orphan = new Program(); orphan.MoveTo(-5, 5); + orphan.Codes.Add(new LinearMove(-3, 5) { Layer = LayerType.Leadin }); + AssertUnsafe(orphan, target); + } + + private static Program Circle(double x, double y, double radius) + { + var p = new Program(); p.MoveTo(x + radius, y); p.ArcTo(x + radius, y, x, y, RotationType.CCW); return p; + } + + private static void AssertClear(Program p, LeadMaterialSnapshot target, params LeadMaterialSnapshot[] others) + { + var result = LeadPathValidator.Check(Read(p), target, others); + Assert.True(result.IsComplete, result.Reason); Assert.True(result.IsClear, result.Reason); + } + + private static void AssertUnsafe(Program p, LeadMaterialSnapshot target, params LeadMaterialSnapshot[] others) + { + var result = LeadPathValidator.Check(Read(p), target, others); + Assert.True(result.IsComplete, result.Reason); Assert.False(result.IsClear); + } + + internal static Program Rectangle(double x1, double y1, double x2, double y2) + { + var p = new Program(); + p.MoveTo(x1, y1); p.LineTo(x1, y2); p.LineTo(x2, y2); + p.LineTo(x2, y1); p.LineTo(x1, y1); + return p; + } + + private static Program ExternalLead() + { + var p = new Program(); + p.MoveTo(-3, 5); + p.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin }); + p.LineTo(0, 10); + return p; + } + + private static OwnedExecution Read(Program p, Vector? location = null) => + ExecutionMotionReader.Read(p, location ?? Vector.Zero, null, default); +}