mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 03:14:14 -04:00
feat(cutting): precheck emitted entry leads with the shared validator
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.
This commit is contained in:
1 parent
bd3f529ccd
commit
2399cc3936
2 files changed
+329
No files matched your search
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>The three verdicts a candidate entry can receive from the shared lead validator.</summary>
|
||||
public enum ContourFeasibilityStatus
|
||||
{
|
||||
/// <summary>Every emitted lead of this contour is complete AND clear.</summary>
|
||||
Clear,
|
||||
|
||||
/// <summary>A complete check found a lead contact/overlap — the reason is preserved.</summary>
|
||||
Blocked,
|
||||
|
||||
/// <summary>The check could not complete (incomplete material, malformed emission) — never clear.</summary>
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
/// <summary>Verdict for one candidate entry. Complete/blocked/incomplete stay distinct.</summary>
|
||||
public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status, string? Reason)
|
||||
{
|
||||
public bool IsClear => Status == ContourFeasibilityStatus.Clear;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feasibility adapter over the EXISTING <see cref="LeadPathValidator"/>: 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 <see cref="Check"/> is ever emitted or validated, and
|
||||
/// <see cref="EvaluationCount"/> counts validator executions (not cache hits) for cost tests.
|
||||
/// </summary>
|
||||
public sealed class ContourEntryFeasibility
|
||||
{
|
||||
private readonly PreparedContours prepared;
|
||||
private readonly Vector location;
|
||||
private readonly LeadMaterialSnapshot ownMaterial;
|
||||
private readonly LeadMaterialSnapshot[] materials;
|
||||
private readonly Dictionary<Key, ContourFeasibilityVerdict> cache = new();
|
||||
|
||||
public ContourEntryFeasibility(PreparedContours prepared, Vector location,
|
||||
LeadMaterialSnapshot ownMaterial, IReadOnlyList<LeadMaterialSnapshot> 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<LeadMaterialSnapshot> { 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();
|
||||
}
|
||||
|
||||
/// <summary>Validator executions performed (cache misses only) during this attempt.</summary>
|
||||
public int EvaluationCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<OperationCanceledException>(() => 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user