diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index bf47da7..836bc29 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -46,19 +46,21 @@ public static class CuttingPlanService throw new ArgumentException("Missing/duplicate source placement or invalid pose."); if (source.BaseDrawing.IsCutOff) throw new NotSupportedException("Cutoff dependency ordering is outside the fixed-program route slice."); - var clean = ExecutionMotionReader.Read(source.BaseDrawing.Program, Vector.Zero, null, token); + // Validate both original graphs before Clone or any virtual transform can + // erase unsupported runtime semantics, including fixed/ineligible targets. + ValidateCloneGraph(source.BaseDrawing.Program, token); + ValidateCloneGraph(source.Program, token); + var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token); if (!clean.HasCuttingContour) throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice."); - var execution = ExecutionMotionReader.Read(source.Program, source.Location, request.StartPoint, token); + var execution = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token); if (!execution.HasCuttingContour) throw new ArgumentException("Placed program has no nonzero cutting contour motions."); - ValidateCloneGraph(source.Program, token); var ownedProgram = (Program)source.Program.Clone(); PreparedContours prepared = null; LeadMaterialSnapshot material = null; if (request.ConfirmedParameters != null) { - ValidateCloneGraph(source.BaseDrawing.Program, token); var ownedClean = (Program)source.BaseDrawing.Program.Clone(); ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation); material = LeadMaterialSnapshot.Capture(ownedClean, source.Location, token); @@ -274,6 +276,7 @@ public static class CuttingPlanService { var active = new HashSet(ReferenceEqualityComparer.Instance); var done = new HashSet(ReferenceEqualityComparer.Instance); + var budget = 1000000; Visit(program); void Visit(Program current) { @@ -281,13 +284,34 @@ public static class CuttingPlanService if (current == null || active.Contains(current) || active.Count >= 64) throw new ArgumentException("Missing, recursive or excessively nested clone graph."); if (done.Contains(current)) return; - // The reader already validated the executed graph. Inactive registered - // subprograms may be motionless; preserve them exactly, but guard Clone traversal. - if (current.Codes == null || current.Codes.Any(c => c == null)) + // Inactive registered subprograms may be motionless, but Clone still + // traverses them. Refuse unknown semantics without invoking virtual Clone. + if (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode)) + throw new NotSupportedException("Unsupported program runtime type or mode."); + if (current.Codes == null) throw new ArgumentException("Missing clone graph instructions."); active.Add(current); - foreach (var child in current.SubPrograms.Values.Concat(current.Codes.OfType().Select(c => c.Program))) + foreach (var code in current.Codes) + { + token.ThrowIfCancellationRequested(); + if (--budget < 0) + throw new ArgumentException("Clone graph exceeds the verification limit."); + if (code == null) + throw new ArgumentException("Missing clone graph instruction."); + var type = code.GetType(); + if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove) + && type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf)) + throw new NotSupportedException("Unsupported instruction runtime type."); + if (code is SubProgramCall call) + Visit(call.Program); + } + foreach (var child in current.SubPrograms.Values) + { + token.ThrowIfCancellationRequested(); + if (--budget < 0) + throw new ArgumentException("Clone graph exceeds the verification limit."); Visit(child); + } active.Remove(current); done.Add(current); } diff --git a/OpenNest.Tests/CuttingPlanning/CuttingCaptureFidelityTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingCaptureFidelityTests.cs new file mode 100644 index 0000000..3c475f2 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/CuttingCaptureFidelityTests.cs @@ -0,0 +1,107 @@ +using System.Reflection; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class CuttingCaptureFidelityTests +{ + public static IEnumerable UnsupportedOriginals() + { + foreach (var kind in new[] { "linear", "program", "mode" }) + foreach (var nested in new[] { false, true }) + foreach (var target in new[] { "eligible", "locked", "ineligible", "placed", "compatibility" }) + yield return new object[] { kind, nested, target }; + } + + [Theory] + [MemberData(nameof(UnsupportedOriginals))] + public void Service_RefusesOriginalUnsupportedSemanticsBeforeClone(string kind, bool nested, string target) + { + var original = Rectangle(); + if (kind == "linear") + original.Codes[1] = new CustomLinear { EndPoint = ((Motion)original.Codes[1]).EndPoint }; + if (kind == "program") + { + var custom = new CustomProgram(); + custom.Codes.AddRange(original.Codes); + original = custom; + } + var invalid = original; + if (nested) + { + var root = new Program(); + root.Codes.Add(new SubProgramCall(original, 0) { Id = -3 }); + original = root; + } + // Part construction uses legacy Clone, so original subclass information is lost. + var part = new Part(new Drawing("original", original)); + if (kind == "linear") + { + var placed = nested ? ((SubProgramCall)part.Program.Codes[0]).Program : part.Program; + Assert.IsType(placed.Codes[1]); + } + if (target == "placed") + { + part = new Part(new Drawing("supported", Rectangle())); + Assert.True(part.RestoreLeadInProgram(original, false)); + } + if (kind == "mode") + typeof(Program).GetField("mode", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(invalid, (Mode)100); + part.LeadInsLocked = target == "locked"; + var clean = part.BaseDrawing.Program; + var placedProgram = part.Program; + var codes = original.Codes.ToArray(); + var invalidCodes = invalid.Codes.ToArray(); + var bounds = part.BoundingBox; + var quantity = part.BaseDrawing.Quantity.Nested; + var cloneCalls = invalid.Codes.OfType().Sum(c => c.CloneCalls); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], + confirmedParameters: target == "compatibility" ? null : ExplicitContourTests.Parameters(), + eligibleParts: target == "ineligible" ? [] : null)); + Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status); + Assert.Empty(result.ProposedOrder); + Assert.False(result.IndependentlyReplayed); + Assert.Contains(result.Findings, f => ReferenceEquals(f.SourcePart, part) && f.SourceOrdinal == 0); + Assert.Same(clean, part.BaseDrawing.Program); + Assert.Same(placedProgram, part.Program); + Assert.Equal(codes, original.Codes); + Assert.Equal(invalidCodes, invalid.Codes); + Assert.Same(bounds, part.BoundingBox); + Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested); + Assert.Equal(cloneCalls, invalid.Codes.OfType().Sum(c => c.CloneCalls)); + } + + [Theory] + [InlineData("linear")] + [InlineData("program")] + [InlineData("mode")] + public void Service_RefusesUnsupportedInactiveRegisteredGraphBeforeClone(string kind) + { + var part = new Part(new Drawing("registered", Rectangle())); + Program inactive = kind == "program" ? new CustomProgram() : new Program(); + if (kind == "linear") inactive.Codes.Add(new CustomLinear()); + if (kind == "mode") + typeof(Program).GetField("mode", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(inactive, (Mode)100); + part.Program.SubPrograms[-27] = inactive; + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part])); + Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status); + Assert.Empty(result.ProposedOrder); + Assert.Same(inactive, part.Program.SubPrograms[-27]); + Assert.All(inactive.Codes.OfType(), c => Assert.Equal(0, c.CloneCalls)); + } + + private static Program Rectangle() => LeadPathValidationTests.Rectangle(0, 0, 10, 10); + private sealed class CustomProgram : Program { } + private sealed class CustomLinear : LinearMove + { + public int CloneCalls { get; private set; } + public override ICode Clone() + { + CloneCalls++; + return base.Clone(); + } + } +}