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); /// /// One lead motion of a fixed emission that failed validation. indexes the /// other materials passed to the check, or is null when the lead meets its own part's material /// or could not be checked. /// internal sealed record FixedLeadFailure(int Motion, LeadPathValidationResult Result, int? Obstacle); /// 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) => Check(execution, target, otherMaterials, null, null, token); /// /// As the public check, and every lead-in pierce must also keep /// from all of the materials (see ). /// Zero checks paths only. /// public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target, IReadOnlyList otherMaterials, double pierceRadius, CancellationToken token = default) => Check(execution, target, otherMaterials, null, null, token, pierceRadius: pierceRadius); /// As the public check, remembering other-material results in . internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target, IReadOnlyList otherMaterials, LeadContactMemo memo, CancellationToken token, double pierceRadius = 0) => Check(execution, target, otherMaterials, null, null, token, memo, pierceRadius); /// /// As the public check, but a lead motion that owns (by motion /// index) is part of a fixed emission: its failure is added to /// and checking continues. Any other failure is returned exactly as the public check returns it. /// A positive also requires each lead-in pierce to keep that /// distance from every material; a contour pierced directly, without a lead-in, is not checked. /// internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target, IReadOnlyList otherMaterials, Func isFixed, List fixedFailures, CancellationToken token, LeadContactMemo memo = null, double pierceRadius = 0) { token.ThrowIfCancellationRequested(); if (execution == null || target == null || otherMaterials == null) return new(false, false, "Missing execution or material snapshots."); if (!double.IsFinite(pierceRadius) || pierceRadius < 0) return new(false, false, "Pierce zone radius must be finite and nonnegative."); 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.Rapid && move.Layer == LayerType.CommonLine) { // A shared cut runs in the kerf between two parts: it may touch no material at all. var (shared, sharedObstacle) = CheckSharedCut(i); if (shared == null) continue; if (isFixed?.Invoke(i) != true || fixedFailures == null) return shared; fixedFailures.Add(new(i, shared, sharedObstacle)); continue; } if (move.Layer is not (LayerType.Leadin or LayerType.Leadout)) continue; var (failure, obstacle) = CheckMotion(i); if (failure == null) continue; if (isFixed?.Invoke(i) != true || fixedFailures == null) return failure; fixedFailures.Add(new(i, failure, obstacle)); } // No lead motion is valid for direct contour piercing. The caller still // checks rapid travel and contour accounting for the complete program. return new(true, true, null); (LeadPathValidationResult Failure, int? Obstacle) CheckSharedCut(int i) { var move = execution.Motions[i]; if (move.Curve == null || !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon) return (new(false, false, "A common-line shared cut is missing, degenerate or inconsistent."), null); if (MaterialContact(move.Curve, target, null, ref budget, token) is { } own) return (new(true, false, $"Common-line shared cut {i} contacts its own part's material ({own}). " + "Rejoin the parts one kerf apart, then replan."), null); for (var other = 0; other < otherMaterials.Count; other++) { token.ThrowIfCancellationRequested(); var material = otherMaterials[other]; if (ReferenceEquals(material, target) || move.Curve.Extent.IsClearOfQueriesFrom(material.Extent)) continue; if (MaterialContact(move.Curve, material, null, ref budget, token) != null) return (new(true, false, "A common-line shared cut contacts or enters another placed material. " + "Rejoin the parts one kerf apart at the current kerf, then replan."), other); } return (null, null); } (LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i) { var move = execution.Motions[i]; 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."), null); 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; var breakLead = false; var sharedLead = false; if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count) { var adjacent = execution.Motions[adjacentIndex]; // A break cut lies inside its part's material, so its lead joins no boundary: // the joint is the cut's own start (or end for a lead-out). if (!adjacent.Rapid && adjacent.Layer == LayerType.Break && adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon) { var edge = execution.Motions[groupEdge]; var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start; var cutJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End; if (joint.DistanceTo(cutJoint) <= PostVerificationGeometry.Epsilon) { genuineJoint = true; breakLead = true; if (groupEdge == i) allowed = joint; } } // A shared cut lies in the kerf between two parts, so its lead joins no boundary // and may touch none: its joint is the cut's own start (or end for a lead-out). else if (!adjacent.Rapid && adjacent.Layer == LayerType.CommonLine && adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon) { var edge = execution.Motions[groupEdge]; var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start; var cutJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End; if (joint.DistanceTo(cutJoint) <= PostVerificationGeometry.Epsilon) { genuineJoint = true; sharedLead = true; } } else 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; // A boundary clear of the contour's first motion contains none of its points. foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent)) { 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."), null); 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."), null); // A break cut's lead runs through its own breakoff scrap: entering the target's // material is expected there, but touching any of its boundaries is not. var failure = breakLead ? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true) : CheckMaterial(target, sharedLead ? null : allowed); if (failure != null) return (new(true, false, breakLead ? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs." : sharedLead ? $"Common-line lead motion {i} contacts its part's material ({failure}). The lead runs along the shared line: shorten the lead-in or lead-out, or cut the common lines twice." : $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null); var reach = move.Curve.Extent; for (var other = 0; other < otherMaterials.Count; other++) { token.ThrowIfCancellationRequested(); var material = otherMaterials[other]; if (ReferenceEquals(material, target)) continue; if (reach.IsClearOfQueriesFrom(material.Extent) ? FarInside(material) : OtherContact(material)) return (new(true, false, "Lead contacts or enters another placed material. " + "Try spacing the parts farther apart or reducing the lead-in/lead-out length, then replan. " + "For locked parts, edit the leads or unlock the part before replanning."), other); } return PierceZone(); // A lead-in reached by a rapid (or starting the program) starts at its pierce. A break // cut's pierce lies in its own breakoff scrap, inside the part's material, so only // others are checked. (LeadPathValidationResult, int?) PierceZone() { if (pierceRadius <= 0 || move.Layer != LayerType.Leadin || i > 0 && !execution.Motions[i - 1].Rapid) return (null, null); if (!breakLead && Clearance(target) is { } own) return (Short(own, "its own part's material"), null); for (var other = 0; other < otherMaterials.Count; other++) { token.ThrowIfCancellationRequested(); var material = otherMaterials[other]; if (ReferenceEquals(material, target)) continue; // Every boundary of material this far away is farther than the zone. if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y) .IsClearOfQueriesFrom(material.Extent, pierceRadius)) continue; if (Clearance(material) is { } distance) return (Short(distance, "another placed part"), other); } return (null, null); } // The pierce's distance to the material when it is inside the zone, else null. double? Clearance(LeadMaterialSnapshot material) { var distance = MaterialDistance(start, material, ref budget, token); return IsInsideZone(distance, pierceRadius) ? distance : null; } LeadPathValidationResult Short(double distance, string what) => new(true, false, PierceZoneMessage(start, distance, what, pierceRadius) + " Try a longer lead-in, more part spacing or a smaller pierce diameter, then replan."); string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint) => MaterialContact(move.Curve, material, permittedJoint, ref budget, token); // Exactly the unmemoized check: a remembered scan charges the queries it ran // and throws where running it again would exhaust the budget. bool OtherContact(LeadMaterialSnapshot material) { if (memo == null) return CheckMaterial(material, null) != null; if (memo.TryGet(move.Curve, material, out var scan)) { Charge(scan.Queries); return scan.Contact; } var before = budget; var contact = CheckMaterial(material, null) != null; memo.Add(move.Curve, material, new(contact, before - budget)); return contact; } // Material too far from the lead for any native boundary query to report a contact // (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run // are charged, so many far parts or holes do not exhaust the budget. Ray parity is // still asked natively unless the lead lies beside the material's rows. bool FarInside(LeadMaterialSnapshot material) { var midpoint = move.Curve.Midpoint; return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token); } void Charge(int queries) { if (budget - queries < 0) throw new NotSupportedException("Lead validation exceeds the native query limit."); budget -= queries; } } } catch (Exception ex) when (ex is ArgumentException or NotSupportedException) { return new(false, false, ex.Message); } } /// /// The exact native distance from to the nearest boundary of complete /// material. Exceeding the budget throws. /// internal static double MaterialDistance(Vector point, LeadMaterialSnapshot material, ref int budget, CancellationToken token) { var nearest = double.PositiveInfinity; foreach (var boundary in material.Rings.SelectMany(r => r)) { token.ThrowIfCancellationRequested(); if (--budget < 0) throw new NotSupportedException("Lead validation exceeds the native query limit."); nearest = System.Math.Min(nearest, boundary.DistanceTo(point)); } return nearest; } /// True when material away lies inside a pierce zone. internal static bool IsInsideZone(double distance, double pierceRadius) => pierceRadius > 0 && distance < pierceRadius - PostVerificationGeometry.Epsilon; internal static string PierceZoneMessage(Vector pierce, double distance, string what, double pierceRadius) => FormattableString.Invariant( $"The pierce at ({pierce.X:0.####}, {pierce.Y:0.####}) is {distance:0.####} from {what}; ") + FormattableString.Invariant($"the {pierceRadius * 2:0.####} pierce diameter needs {pierceRadius:0.####}."); /// /// How a native lead curve contacts or enters complete material, or null when it stays clear. /// A contact at is allowed. Exceeding the budget throws. /// internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false) { // Boundaries no native query from the curve can reach report no contact; only the others run. foreach (var boundary in material.BoundariesNear(curve.Extent)) { token.ThrowIfCancellationRequested(); if (--budget < 0) throw new NotSupportedException("Lead validation exceeds the native query limit."); var contacts = curve.Contacts(boundary, out var overlap); if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary))) 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 !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null; // A lead tangent to the boundary it joins (an arc lead-in meeting a straight edge) has a // double root at the joint that rounding splits into two contacts a few 1e-8 apart; one // within JointTolerance of a joint the boundary itself passes through is still the joint. static bool AtJoint(Vector point, Vector joint, PostVerificationGeometry.Curve boundary) { var distance = point.DistanceTo(joint); return distance <= PostVerificationGeometry.Epsilon || distance <= JointTolerance && boundary.Contains(joint); } } // As SlideContact's: far below any kerf, far above a tangent root's rounding drift. private const double JointTolerance = 1e-6; }