From 2399cc39360582e85249f6220b2b2dff7266a5dd Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 7 Oct 2026 05:27:05 -0400 Subject: [PATCH] feat(cutting): precheck emitted entry leads with the shared validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContourEntryFeasibility: one adapter over the EXISTING LeadPathValidator for one owned candidate — emit through the S06 diagnostic seam, read at the placement position, certify emitted lead-in AND lead-out against ALL placed material. Only complete+clear qualifies; blocked and incomplete keep their reasons as distinct verdicts (an incomplete check never reads as clear). Not a new collision implementation and not a plan approval: NoLeadIn passes vacuously and the complete-plan missing-lead check still runs later; rapids and pierce clearance stay with their existing checkers. One instance is one captured planning attempt: verdicts cache per exact owned choice + node context (settings/placement are fixed per instance), never static, never across instances. Lazy: only the checked candidate is emitted; EvaluationCount counts validator executions for cost tests. Foreign/malformed emissions are Incomplete verdicts with reasons, never crashes; cancellation propagates without poisoning the cache. No search changes and no broad-phase skips. --- .../ContourEntryFeasibility.cs | 116 ++++++++++ .../ContourEntryFeasibilityTests.cs | 213 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs create mode 100644 OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs new file mode 100644 index 0000000..6c192fb --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Engine.CuttingPlanning; + +/// The three verdicts a candidate entry can receive from the shared lead validator. +public enum ContourFeasibilityStatus +{ + /// Every emitted lead of this contour is complete AND clear. + Clear, + + /// A complete check found a lead contact/overlap — the reason is preserved. + Blocked, + + /// The check could not complete (incomplete material, malformed emission) — never clear. + Incomplete, +} + +/// Verdict for one candidate entry. Complete/blocked/incomplete stay distinct. +public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status, string? Reason) +{ + public bool IsClear => Status == ContourFeasibilityStatus.Clear; +} + +/// +/// Feasibility adapter over the EXISTING : emits one owned +/// candidate contour through the S06 diagnostic seam, reads it at the placement position, +/// and certifies the emitted lead-in and lead-out against ALL placed material. It is not a +/// new collision implementation and not a plan approval: a Clear verdict certifies this +/// contour's emitted leads only — the missing-lead (NoLeadIn) check still runs later on the +/// complete plan, and rapids/pierce clearance belong to their existing checkers. A +/// candidate rejected here is not proven infeasible by anything else: this adapter only +/// reports what the validator reported. +/// One instance is one captured planning attempt: verdicts cache per exact choice and node +/// context for the instance's lifetime — settings and placement are fixed per instance — +/// and nothing is cached across instances or statically. Evaluation is lazy: only the +/// choice handed to is ever emitted or validated, and +/// counts validator executions (not cache hits) for cost tests. +/// +public sealed class ContourEntryFeasibility +{ + private readonly PreparedContours prepared; + private readonly Vector location; + private readonly LeadMaterialSnapshot ownMaterial; + private readonly LeadMaterialSnapshot[] materials; + private readonly Dictionary cache = new(); + + public ContourEntryFeasibility(PreparedContours prepared, Vector location, + LeadMaterialSnapshot ownMaterial, IReadOnlyList otherMaterials) + { + this.prepared = prepared ?? throw new ArgumentException("Prepared contours are required.", nameof(prepared)); + this.ownMaterial = ownMaterial ?? throw new ArgumentException("Own material snapshot is required.", nameof(ownMaterial)); + if (otherMaterials == null) + throw new ArgumentException("Placed-material snapshots are required.", nameof(otherMaterials)); + this.location = location; + // Immutable copy; the validator's own-material-first convention is preserved. + var all = new List { this.ownMaterial }; + foreach (var material in otherMaterials) + { + if (material == null) + throw new ArgumentException("Placed-material snapshots must not be null.", nameof(otherMaterials)); + if (!ReferenceEquals(material, this.ownMaterial)) + all.Add(material); + } + materials = all.ToArray(); + } + + /// Validator executions performed (cache misses only) during this attempt. + public int EvaluationCount { get; private set; } + + /// + /// The verdict for one owned candidate. Same choice + node context within this attempt + /// is answered from cache. Cancellation propagates; a malformed emission is an + /// Incomplete verdict with the emission's own reason, never a crash and never Clear. + /// + public ContourFeasibilityVerdict Check(ContourChoice choice, string nodeContext = "", + CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + if (choice == null) + throw new ArgumentException("A contour choice is required.", nameof(choice)); + var key = new Key(choice.ContourOrdinal, choice.EntityOrdinal, + choice.Point.X, choice.Point.Y, nodeContext ?? string.Empty); + if (cache.TryGetValue(key, out var known)) + return known; + var verdict = Probe(choice, token); + cache[key] = verdict; + EvaluationCount++; + return verdict; + } + + private ContourFeasibilityVerdict Probe(ContourChoice choice, CancellationToken token) + { + try + { + var program = prepared.EmitCandidateForValidation(choice); + var execution = ExecutionMotionReader.Read(program, location, null, token); + var result = LeadPathValidator.Check(execution, ownMaterial, materials, token); + if (!result.IsComplete) + return new(ContourFeasibilityStatus.Incomplete, result.Reason); + return result.IsClear + ? new(ContourFeasibilityStatus.Clear, null) + : new(ContourFeasibilityStatus.Blocked, result.Reason); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + // Malformed emission or foreign choice: refused, reason preserved, never cached as clear. + return new(ContourFeasibilityStatus.Incomplete, ex.Message); + } + } + + private readonly record struct Key(int ContourOrdinal, int EntityOrdinal, double X, double Y, string NodeContext); +} diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs new file mode 100644 index 0000000..0b7ed95 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs @@ -0,0 +1,213 @@ +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// S07: the feasibility adapter certifies EXACTLY what LeadPathValidator certifies for one +/// emitted candidate contour — no more (NoLeadIn is not a plan approval) and no less +/// (incomplete checks never read as clear), cached per captured attempt only. +/// +public class ContourEntryFeasibilityTests +{ + private static readonly Vector At = Vector.Zero; + + private static (PreparedContours Prepared, ContourChoice Choice, LeadMaterialSnapshot Own) + PreparedSquare(string style = "line") + { + var clean = ExplicitContourTests.Square(false); + var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters(style)); + var choice = prepared.Entry(0, 0, new Vector(0, 5)); // left edge, mid-side + return (prepared, choice, LeadMaterialSnapshot.Capture(clean, At)); + } + + private static LeadMaterialSnapshot Box(double x1, double y1, double x2, double y2) + { + var p = new Program(); + p.MoveTo(x1, y1); + p.LineTo(x2, y1); p.LineTo(x2, y2); p.LineTo(x1, y2); p.LineTo(x1, y1); + var snapshot = LeadMaterialSnapshot.Capture(p, Vector.Zero); + Assert.True(snapshot.IsComplete, snapshot.Reason); + return snapshot; + } + + [Fact] + public void FittingStraightLeadIsClear() + { + var (prepared, choice, own) = PreparedSquare(); + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + + var verdict = feasibility.Check(choice); + + Assert.True(verdict.IsClear, verdict.Reason); + Assert.Equal(ContourFeasibilityStatus.Clear, verdict.Status); + Assert.Equal(1, feasibility.EvaluationCount); + } + + [Fact] + public void ArcLeadCandidateVerdictsComeFromTheSharedValidator() + { + var (prepared, choice, own) = PreparedSquare("arc"); + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + Assert.True(feasibility.Check(choice).IsClear); + } + + [Fact] + public void NeighbourContactRejectsKeepingReason() + { + var (prepared, choice, own) = PreparedSquare(); + // A neighbouring sheet covering the lead-in approach region. + var neighbour = Box(-1.5, 4.4, -0.05, 5.6); + var feasibility = new ContourEntryFeasibility(prepared, At, own, [neighbour]); + + var verdict = feasibility.Check(choice); + + Assert.Equal(ContourFeasibilityStatus.Blocked, verdict.Status); + Assert.False(verdict.IsClear); + Assert.False(string.IsNullOrEmpty(verdict.Reason)); + } + + [Fact] + public void OwnMaterialCrossingRejects() + { + var clean = ExplicitContourTests.Square(false); + var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters()); + var choice = prepared.Entry(0, 0, new Vector(0, 5)); + // Own snapshot is where the sheet actually is; the placement drifts 0.4 east, so the + // emitted external lead (approach x=-0.21..0 relative, entry at x=0.4 absolute) + // crosses into the sheet's own material. + var own = LeadMaterialSnapshot.Capture(clean, Vector.Zero); + var feasibility = new ContourEntryFeasibility(prepared, new Vector(0.4, 0), own, []); + + var verdict = feasibility.Check(choice); + + Assert.Equal(ContourFeasibilityStatus.Blocked, verdict.Status); + Assert.False(verdict.IsClear); + } + + [Fact] + public void IncompleteMaterialNeverReadsAsClear() + { + var (prepared, choice, _) = PreparedSquare(); + // Two touching rings: the capture itself refuses, producing an incomplete snapshot. + var joined = ExplicitContourTests.Square(false); + var touching = new Program(); + touching.MoveTo(0, 10); + touching.LineTo(10, 10); touching.LineTo(10, 20); touching.LineTo(0, 20); touching.LineTo(0, 10); + joined.Codes.AddRange(touching.Codes); + var incomplete = LeadMaterialSnapshot.Capture(joined, Vector.Zero); + Assert.False(incomplete.IsComplete); + Assert.NotNull(incomplete.Reason); + + var ownVerdict = new ContourEntryFeasibility(prepared, At, incomplete, []).Check(choice); + Assert.Equal(ContourFeasibilityStatus.Incomplete, ownVerdict.Status); + var otherVerdict = new ContourEntryFeasibility(prepared, At, + LeadMaterialSnapshot.Capture(ExplicitContourTests.Square(false), At), [incomplete]).Check(choice); + Assert.Equal(ContourFeasibilityStatus.Incomplete, otherVerdict.Status); + } + + [Fact] + public void NoLeadInClearVerdictIsNotAPlanApproval() + { + var parameters = ExplicitContourTests.Parameters(); + parameters.ExternalLeadIn = new NoLeadIn(); + parameters.InternalLeadIn = new NoLeadIn(); + parameters.ArcCircleLeadIn = new NoLeadIn(); + var clean = ExplicitContourTests.Square(false); + var prepared = PreparedContours.Capture(clean, parameters); + var choice = prepared.Entry(0, 0, new Vector(0, 5)); + var feasibility = new ContourEntryFeasibility(prepared, At, LeadMaterialSnapshot.Capture(clean, At), []); + + var verdict = feasibility.Check(choice); + + // Nothing to certify means the validator passes vacuously — the emitted program + // carries no lead motion at all, so this verdict certifies no lead and the + // complete-plan missing-lead check still has to run later. The adapter must not + // silently drop the distinction: consumers can see it is vacuous by counting leads. + Assert.True(verdict.IsClear); + var motions = ExecutionMotionReader.Read(prepared.Emit(new[] { choice }), At, null, default).Motions; + Assert.Empty(motions.Where(m => !m.Rapid + && (m.Layer == LayerType.Leadin || m.Layer == LayerType.Leadout))); + } + + [Fact] + public void MalformedEmissionRefusesWithReasonInsteadOfCrashing() + { + var (prepared, choice, own) = PreparedSquare(); + var (other, _, _) = PreparedSquare(); + var forgedContour = choice with { Owner = other, ContourOrdinal = 99 }; + var forgedEntity = choice with { Owner = prepared, EntityOrdinal = 99 }; + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + + var verdict = feasibility.Check(forgedContour); + Assert.Equal(ContourFeasibilityStatus.Incomplete, verdict.Status); + Assert.False(string.IsNullOrEmpty(verdict.Reason)); + Assert.Equal(ContourFeasibilityStatus.Incomplete, feasibility.Check(forgedEntity).Status); + } + + [Fact] + public void CancellationPropagatesAndPoisonsNothing() + { + var (prepared, choice, own) = PreparedSquare(); + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + Assert.ThrowsAny(() => feasibility.Check(choice, token: cancelled.Token)); + Assert.Equal(0, feasibility.EvaluationCount); + Assert.True(feasibility.Check(choice).IsClear); + } + + [Fact] + public void SameChoiceAndNodeContextIsCachedWithinTheAttemptOnly() + { + var (prepared, choice, own) = PreparedSquare(); + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + + Assert.True(feasibility.Check(choice, "A").IsClear); + Assert.True(feasibility.Check(choice, "A").IsClear); + Assert.Equal(1, feasibility.EvaluationCount); + + // A different node context is a different key (the cache is deliberately conservative). + Assert.True(feasibility.Check(choice, "B").IsClear); + Assert.Equal(2, feasibility.EvaluationCount); + + // Nothing survives into a fresh planning attempt: no static or cross-instance cache. + var fresh = new ContourEntryFeasibility(prepared, At, own, []); + Assert.True(fresh.Check(choice, "A").IsClear); + Assert.Equal(1, fresh.EvaluationCount); + } + + [Fact] + public void EvaluationIsLazyPerCandidate() + { + var (prepared, choice, own) = PreparedSquare(); + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + var other = prepared.Entry(0, 1, new Vector(5, 10)); + + // One Check evaluates exactly that candidate — never every point of every contour. + Assert.True(feasibility.Check(other).IsClear); + Assert.Equal(1, feasibility.EvaluationCount); + Assert.NotSame(choice, other); + } + + [Fact] + public void VerdictsAreTheValidatorsVerdictsExactly() + { + // Adapter verdict == LeadPathValidator outcome for the same emitted program. + var (prepared, choice, own) = PreparedSquare(); + var materials = new[] { own }; + var execution = ExecutionMotionReader.Read( + prepared.EmitCandidateForValidation(choice), At, null, default); + var direct = LeadPathValidator.Check(execution, own, materials, default); + + var verdict = new ContourEntryFeasibility(prepared, At, own, materials).Check(choice); + + Assert.Equal(direct.IsClear, verdict.IsClear); + Assert.Equal(direct.Reason, verdict.Reason); + } +}