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));