diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index 377ba77..e43b24c 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -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)
+ ///
+ /// Owned copy of a validated graph with every program in incremental mode, so
+ /// keeps absolute subprogram frame offsets. Shared
+ /// subprograms stay shared. Validate exact instruction types first: this clones every code.
+ ///
+ internal static Program CopyForGeometry(Program source, CancellationToken token)
{
var copies = new Dictionary(ReferenceEqualityComparer.Instance);
return Copy(source);
diff --git a/OpenNest.Core/Diagnostics/OverlapMaterialCache.cs b/OpenNest.Core/Diagnostics/OverlapMaterialCache.cs
index 6f23fd2..75d2391 100644
--- a/OpenNest.Core/Diagnostics/OverlapMaterialCache.cs
+++ b/OpenNest.Core/Diagnostics/OverlapMaterialCache.cs
@@ -43,7 +43,7 @@ internal sealed class OverlapSource
internal OverlapSource(Program program, List 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;
///
/// Prepares once and shares the result. A geometry failure is cached like a success;
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
index 410b6a2..eb191ec 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
@@ -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(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 SupportedCodeTypes =
+ [
+ typeof(RapidMove), typeof(LinearMove), typeof(ArcMove), typeof(SubProgramCall),
+ typeof(Comment), typeof(Feedrate), typeof(Kerf),
+ ];
+
private static void ValidateProgram(Program program, HashSet 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.");
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
index 7bf3e8c..b5568e6 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
@@ -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()
{
diff --git a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs
index 7a53123..82c53fa 100644
--- a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs
+++ b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs
@@ -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));