fix(diagnostics): read absolute subprogram holes and refuse unknown codes

PlateOverlapAnalyzer converted clean programs directly with
ConvertProgram.ToGeometry, which adds call offsets only to
incremental moves, so holes in absolute-mode subprograms were read at
their frame origin. A square with two absolute holes reported
"Native material contours cross or touch", and the Plan Cutting
overlap gate blocked it while the incremental twin passed. Convert an
owned incremental-mode copy instead (PreparedContours.CopyForGeometry,
now internal, the same normalization the cutting planner uses).

The analyzer also threw on a program whose Codes list was null and
cast or cloned instructions it does not know. It now refuses a
missing list and anything other than the exact built-in instruction
types as an incomplete check, before any copy or conversion runs.
Pre-post verification and the plate overlap overlay share this path.
This commit is contained in:
aj committed 2026-10-05 21:52:30 -04:00
1 parent faf3c9bcc4
commit 1b422ef99a
5 files changed
+127 -6

No files matched your search

@@ -215,7 +215,12 @@ public sealed class PreparedContours
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
private static Program CopyForGeometry(Program source, CancellationToken token)
/// <summary>
/// Owned copy of a validated graph with every program in incremental mode, so
/// <see cref="ConvertProgram.ToGeometry"/> keeps absolute subprogram frame offsets. Shared
/// subprograms stay shared. Validate exact instruction types first: this clones every code.
/// </summary>
internal static Program CopyForGeometry(Program source, CancellationToken token)
{
var copies = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
return Copy(source);
@@ -43,7 +43,7 @@ internal sealed class OverlapSource
internal OverlapSource(Program program, List<Entity> entities, string error)
{
codeCount = program.Codes.Count;
codeCount = CodeCount(program);
rotation = BitConverter.DoubleToInt64Bits(program.Rotation);
Entities = entities;
Error = error;
@@ -54,7 +54,10 @@ internal sealed class OverlapSource
internal string Error { get; }
internal bool Matches(Program program) =>
program.Codes.Count == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
CodeCount(program) == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
// Program.Codes is a writable field; a missing list is a refused source, not a crash.
private static int CodeCount(Program program) => program.Codes?.Count ?? -1;
/// <summary>
/// Prepares once and shares the result. A geometry failure is cached like a success;
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Converters;
using OpenNest.Geometry;
@@ -72,9 +73,11 @@ public static class PlateOverlapAnalyzer
try
{
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
// Conversion creates fresh geometry, including expanded shared hole calls;
// no cloning/rotation of a live program or subprogram is necessary.
return new OverlapSource(program, ConvertProgram.ToGeometry(program)
// Convert an incremental-mode copy: the converter adds call offsets to incremental moves
// only, so absolute subprogram holes would otherwise land at their frame origin. The
// copy is owned; the live program is neither converted in place nor rotated.
var geometry = PreparedContours.CopyForGeometry(program, CancellationToken.None);
return new OverlapSource(program, ConvertProgram.ToGeometry(geometry)
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
&& entity.Layer != SpecialLayers.Leadin
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
@@ -267,14 +270,26 @@ public static class PlateOverlapAnalyzer
internal static bool IsGeometryFailure(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
// Exact built-in instruction types. Anything else, subclasses included, is refused before the
// graph is copied or converted, so no unknown Clone or cast runs.
private static readonly HashSet<Type> SupportedCodeTypes =
[
typeof(RapidMove), typeof(LinearMove), typeof(ArcMove), typeof(SubProgramCall),
typeof(Comment), typeof(Feedrate), typeof(Kerf),
];
private static void ValidateProgram(Program program, HashSet<Program> visiting)
{
if (program == null || !visiting.Add(program) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive, or excessively nested subprogram.");
if (program.Codes == null)
throw new ArgumentException("Program has no instruction list.");
foreach (var code in program.Codes)
{
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (!SupportedCodeTypes.Contains(code.GetType()))
throw new NotSupportedException("Program contains an unsupported instruction.");
if (code is Motion motion && !OverlapMaterial.IsFinite(motion.EndPoint)
|| code is ArcMove arc && !OverlapMaterial.IsFinite(arc.CenterPoint))
throw new ArgumentException("Program coordinates must be finite.");
@@ -95,6 +95,46 @@ public class CuttingPlanBatchTests
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Plan_SubprogramHolesInEitherMode_AreNotFalseOverlaps(Mode mode)
{
var hole = new Program();
hole.MoveTo(1, 0);
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
hole.Mode = mode;
var clean = ExplicitContourTests.Square(false);
clean.SubPrograms[-1] = hole;
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(3, 3), Id = -1 });
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(7, 3), Id = -1 });
clean.Mode = mode;
var nest = new Nest();
var plate = Plate(nest, new Part(new Drawing("holes", clean), new Vector(1, 1)));
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
var planned = Assert.Single(proposal.Plates);
Assert.True(planned.IsOverlapClear, string.Join("; ", planned.Overlap.Issues.Select(i => i.Message)));
Assert.True(planned.IsReady);
}
[Fact]
public void Capture_RefusedGraphs_BlockThePlateInsteadOfThrowing()
{
var nest = new Nest();
var missing = Clean("missing", 1, 1);
missing.BaseDrawing.Program.Codes = null;
var plate = Plate(nest, missing);
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
var planned = Assert.Single(proposal.Plates);
Assert.False(planned.IsReady);
Assert.False(proposal.CanApply);
Assert.Null(proposal.BuildPreview(0));
}
[Fact]
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
{
@@ -501,6 +501,64 @@ public class PlateOverlapAnalyzerTests
Assert.Equal(y, pair.Centroid.Y, 7);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Analyze_SubprogramHolesKeepTheirCallOffsetsInEitherMode(Mode mode)
{
var hole = new Program();
hole.MoveTo(1, 0);
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
hole.Mode = mode;
var clean = new Program();
clean.MoveTo(0, 0);
clean.LineTo(0, 10);
clean.LineTo(10, 10);
clean.LineTo(10, 0);
clean.LineTo(0, 0);
clean.SubPrograms[-1] = hole;
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(3, 3), Id = -1 });
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(7, 3), Id = -1 });
clean.Mode = mode;
var host = new Part(new Drawing("host", clean));
var insert = Rectangle(2.75, 2.75, 0.5, 0.5); // Inside the hole called at (3, 3).
var report = PlateOverlapAnalyzer.Analyze(new[] { host, insert });
Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
Assert.Empty(report.Pairs);
}
[Fact]
public void Analyze_NullOrUnsupportedInstructionsAreIncompleteWithoutRunningThem()
{
var missing = Rectangle(0, 0, 1, 1);
missing.BaseDrawing.Program.Codes = null;
var unsupported = Rectangle(5, 0, 1, 1);
var code = new CountingCode();
unsupported.BaseDrawing.Program.Codes.Add(code);
var clear = Rectangle(10, 0, 1, 1);
var report = PlateOverlapAnalyzer.Analyze(new[] { missing, unsupported, clear });
Assert.Equal(new[] { 0, 1 }, report.Issues.Select(issue => issue.PartAId).OrderBy(id => id));
Assert.Empty(report.Pairs);
Assert.Equal(0, code.Clones);
}
// Reports a built-in type but is not one: conversion must refuse it rather than cast or clone it.
private sealed class CountingCode : ICode
{
public int Clones { get; private set; }
public CodeType Type => CodeType.LinearMove;
public ICode Clone()
{
Clones++;
return this;
}
}
private static Part Rectangle(double x, double y, double width, double height) =>
new(WithContours(new[] { new Vector(0, 0), new Vector(width, 0),
new Vector(width, height), new Vector(0, height) }).BaseDrawing, new Vector(x, y));