diff --git a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs
new file mode 100644
index 0000000..a2c83d7
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs
@@ -0,0 +1,139 @@
+using System;
+using OpenNest.CNC.CuttingStrategy;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+/// Copies only supported, exact setting types; never slices custom subclasses.
+internal static class OwnedCuttingParameters
+{
+ internal static CuttingParameters Copy(CuttingParameters source)
+ {
+ Exact(source);
+ Exact(source.Sequencing);
+ Exact(source.Assignment);
+ var sequence = source.Sequencing;
+ var assignment = source.Assignment;
+ if (!Enum.IsDefined(sequence.Method) || !Enum.IsDefined(assignment.Method))
+ throw new ArgumentException("Unsupported sequence method.");
+ if (assignment.Preference == null)
+ throw new ArgumentException("Missing assignment preference.");
+ if (!double.IsFinite(source.LeadInAngleIncrement) || source.LeadInAngleIncrement <= 0
+ || source.LeadInAngleIncrement > 360)
+ throw new ArgumentException("Angle increment must be in (0, 360] degrees.");
+ return new CuttingParameters
+ {
+ Id = source.Id,
+ MachineName = source.MachineName,
+ MaterialName = source.MaterialName,
+ Grade = source.Grade,
+ Thickness = Dimension(source.Thickness),
+ Kerf = Dimension(source.Kerf),
+ PartSpacing = Dimension(source.PartSpacing),
+ PierceClearance = Dimension(source.PierceClearance),
+ ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
+ InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
+ ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
+ ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
+ InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
+ ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
+ RoundLeadInAngles = source.RoundLeadInAngles,
+ LeadInAngleIncrement = source.LeadInAngleIncrement,
+ AutoTabMinSize = Dimension(source.AutoTabMinSize),
+ AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
+ TabConfig = source.TabConfig == null
+ ? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
+ : CopyTab(source.TabConfig),
+ TabsEnabled = source.TabsEnabled,
+ Sequencing = new SequenceParameters
+ {
+ Method = sequence.Method,
+ SmallCutoutWidth = Dimension(sequence.SmallCutoutWidth),
+ SmallCutoutHeight = Dimension(sequence.SmallCutoutHeight),
+ MediumCutoutWidth = Dimension(sequence.MediumCutoutWidth),
+ MediumCutoutHeight = Dimension(sequence.MediumCutoutHeight),
+ DistanceMediumSmall = Dimension(sequence.DistanceMediumSmall),
+ AlternateRowsColumns = sequence.AlternateRowsColumns,
+ AlternateCutoutsWithinRowColumn = sequence.AlternateCutoutsWithinRowColumn,
+ MinDistanceBetweenRowsColumns = Dimension(sequence.MinDistanceBetweenRowsColumns)
+ },
+ Assignment = new AssignmentParameters
+ {
+ Method = assignment.Method,
+ Preference = assignment.Preference,
+ MinGeometryLength = Dimension(assignment.MinGeometryLength)
+ }
+ };
+ }
+
+ private static LeadIn CopyLeadIn(LeadIn source) => source switch
+ {
+ NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
+ LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
+ { Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
+ ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) },
+ LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
+ { LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
+ LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
+ {
+ Length1 = Dimension(n.Length1),
+ Length2 = Dimension(n.Length2),
+ ApproachAngle1 = Angle(n.ApproachAngle1),
+ ApproachAngle2 = Angle(n.ApproachAngle2)
+ },
+ CleanHoleLeadIn n when n.GetType() == typeof(CleanHoleLeadIn) => new CleanHoleLeadIn
+ { LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), Kerf = Dimension(n.Kerf) },
+ null => throw new ArgumentException("Missing lead-in settings."),
+ _ => throw new NotSupportedException("Unsupported lead-in runtime type.")
+ };
+
+ private static LeadOut CopyLeadOut(LeadOut source) => source switch
+ {
+ NoLeadOut n when n.GetType() == typeof(NoLeadOut) => new NoLeadOut(),
+ LineLeadOut n when n.GetType() == typeof(LineLeadOut) => new LineLeadOut
+ { Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
+ ArcLeadOut n when n.GetType() == typeof(ArcLeadOut) => new ArcLeadOut { Radius = Dimension(n.Radius) },
+ null => throw new ArgumentException("Missing lead-out settings."),
+ _ => throw new NotSupportedException("Unsupported lead-out runtime type.")
+ };
+
+ private static Tab CopyTab(Tab source)
+ {
+ Tab copy = source switch
+ {
+ NormalTab n when n.GetType() == typeof(NormalTab) => new NormalTab
+ {
+ CutoutMinWidth = Dimension(n.CutoutMinWidth),
+ CutoutMinHeight = Dimension(n.CutoutMinHeight),
+ CutoutMaxWidth = Dimension(n.CutoutMaxWidth),
+ CutoutMaxHeight = Dimension(n.CutoutMaxHeight)
+ },
+ BreakerTab n when n.GetType() == typeof(BreakerTab) => new BreakerTab
+ {
+ BreakerDepth = Dimension(n.BreakerDepth),
+ BreakerLeadInLength = Dimension(n.BreakerLeadInLength),
+ BreakerAngle = Angle(n.BreakerAngle)
+ },
+ MachineTab n when n.GetType() == typeof(MachineTab) => new MachineTab { MachineTabId = n.MachineTabId },
+ _ => throw new NotSupportedException("Unsupported tab runtime type.")
+ };
+ copy.Size = Dimension(source.Size);
+ copy.TabLeadIn = source.TabLeadIn == null ? null : CopyLeadIn(source.TabLeadIn);
+ copy.TabLeadOut = source.TabLeadOut == null ? null : CopyLeadOut(source.TabLeadOut);
+ return copy;
+ }
+
+ private static void Exact(T value) where T : class
+ {
+ if (value == null)
+ throw new ArgumentException("Missing cutting settings.");
+ if (value.GetType() != typeof(T))
+ throw new NotSupportedException("Unsupported settings runtime type.");
+ }
+
+ private static double Dimension(double value) => double.IsFinite(value) && value >= 0
+ ? value : throw new ArgumentException("Dimensions must be finite and nonnegative.");
+
+ // Approach angles are degrees, not lengths. Signed and periodic angles are valid.
+ private static double Angle(double value) => double.IsFinite(value)
+ ? value : throw new ArgumentException("Angles must be finite.");
+}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
new file mode 100644
index 0000000..14e15ee
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -0,0 +1,248 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+/// A nominal native entry owned by one preparation, not an actual emitted pierce.
+public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point)
+{
+ internal PreparedContours Owner { get; init; }
+}
+
+///
+/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
+/// program and keep it stable during Capture. This is not a topology or lead-path verdict.
+///
+public sealed class PreparedContours
+{
+ private readonly Shape[] shapes;
+ private readonly CuttingParameters parameters;
+ private readonly List scribes;
+
+ private PreparedContours(Shape[] shapes, List scribes, CuttingParameters parameters)
+ {
+ this.shapes = shapes;
+ this.scribes = scribes;
+ this.parameters = parameters;
+ }
+
+ public int Count => shapes.Length;
+ public int PerimeterOrdinal => Count - 1;
+
+ public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
+ {
+ token.ThrowIfCancellationRequested();
+ var ownedParameters = OwnedCuttingParameters.Copy(parameters);
+ ExecutionMotionReader.Read(program, Vector.Zero, null, token);
+ ValidateProgramTypes(program, token);
+ // ToGeometry constructs fresh native entities. Normalize an owned motion graph
+ // to incremental mode so absolute subprograms retain their frame offsets too.
+ // Retain execution boundaries: ShapeBuilder can join duplicate closed contours.
+ var geometryProgram = CopyForGeometry(program, token);
+ var contours = new List();
+ var scribes = new List();
+ var current = new Shape();
+ foreach (var entity in geometryProgram.ToGeometry())
+ {
+ token.ThrowIfCancellationRequested();
+ if (entity.GetType() != typeof(Line) && entity.GetType() != typeof(Arc) && entity.GetType() != typeof(Circle))
+ throw new NotSupportedException("Unsupported native geometry.");
+ if (entity.Layer == SpecialLayers.Rapid || entity.Layer == SpecialLayers.Scribe)
+ {
+ if (current.Entities.Count != 0)
+ throw new ArgumentException("Preparation requires closed execution contours.");
+ if (entity.Layer == SpecialLayers.Scribe)
+ scribes.Add(entity);
+ continue;
+ }
+ if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display)
+ throw new ArgumentException("Preparation requires clean cut/display contours.");
+ if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
+ throw new ArgumentException("Degenerate contour entity.");
+ if (entity is Circle)
+ {
+ if (current.Entities.Count != 0)
+ throw new ArgumentException("Circle interrupts an open contour.");
+ current.Entities.Add(entity);
+ FinishContour();
+ continue;
+ }
+ if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
+ throw new ArgumentException("Discontinuous native contour.");
+ current.Entities.Add(entity);
+ if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
+ FinishContour();
+ }
+ if (current.Entities.Count != 0 || contours.Count == 0)
+ throw new ArgumentException("Preparation requires nonempty closed contours.");
+ var perimeter = 0;
+ for (var i = 1; i < contours.Count; i++)
+ {
+ var box = contours[i].BoundingBox;
+ var best = contours[perimeter].BoundingBox;
+ if (box.Width * box.Length > best.Width * best.Length)
+ perimeter = i;
+ }
+ var outer = contours[perimeter];
+ contours.RemoveAt(perimeter);
+ contours.Add(outer);
+ return new(contours.ToArray(), scribes, ownedParameters);
+
+ void FinishContour()
+ {
+ current.UpdateBounds();
+ contours.Add(current);
+ current = new Shape();
+ }
+ }
+
+ public ContourChoice ClosestEntry(int contourOrdinal, Vector approach)
+ {
+ PostVerificationGeometry.Validate(approach);
+ var shape = GetShape(contourOrdinal);
+ var point = shape.ClosestPointTo(approach, out var entity);
+ return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
+ }
+
+ ///
+ /// Closest native point first, then source-order vertices/midpoints or eight circle
+ /// angles (0 through 315 degrees). Geometrical duplicates keep their first entity.
+ /// Emit before validating actual paths: circle rounding/clamping may move this point.
+ ///
+ public IReadOnlyList Entries(int contourOrdinal, Vector approach, int maxEntries = 16, CancellationToken token = default)
+ {
+ token.ThrowIfCancellationRequested();
+ if (maxEntries <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxEntries));
+ var shape = GetShape(contourOrdinal);
+ var entries = new List();
+ entries.Add(ClosestEntry(contourOrdinal, approach));
+ for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
+ {
+ token.ThrowIfCancellationRequested();
+ var entity = shape.Entities[i];
+ if (entity is Circle circle)
+ {
+ for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
+ Add(i, circle.Center + new Vector(System.Math.Cos(angle * System.Math.PI / 4),
+ System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius);
+ }
+ else
+ {
+ Add(i, Start(entity));
+ Add(i, entity is Line line ? line.MidPoint : ((Arc)entity).MidPoint());
+ Add(i, End(entity));
+ }
+ }
+ token.ThrowIfCancellationRequested();
+ return entries.AsReadOnly();
+
+ void Add(int entityOrdinal, Vector point)
+ {
+ token.ThrowIfCancellationRequested();
+ if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
+ entries.Add(Entry(contourOrdinal, entityOrdinal, point));
+ }
+ }
+
+ public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
+ {
+ var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
+ ValidateChoice(choice);
+ return choice;
+ }
+
+ /// Emits every contour once in caller order, holes before perimeter, with scribes once.
+ public Program Emit(IReadOnlyList choices)
+ {
+ Validate(choices, true);
+ return EmitPrefix(choices);
+ }
+
+ // Each prefix is a standalone owned program, including the same scribes once.
+ internal Program EmitPrefix(IReadOnlyList choices)
+ {
+ Validate(choices, false);
+ return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
+ shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices);
+ }
+
+ private void Validate(IReadOnlyList choices, bool complete)
+ {
+ if (choices == null || (complete && choices.Count != Count) || choices.Count > Count)
+ throw new ArgumentException("Every contour must be represented exactly once.");
+ var visited = new HashSet();
+ foreach (var choice in choices)
+ {
+ ValidateChoice(choice);
+ if (!visited.Add(choice.ContourOrdinal) || choice.ContourOrdinal == PerimeterOrdinal && visited.Count != Count)
+ throw new ArgumentException("Duplicate contour or perimeter before its internal contours.");
+ }
+ }
+
+ private void ValidateChoice(ContourChoice choice)
+ {
+ if (choice == null || !ReferenceEquals(choice.Owner, this))
+ throw new ArgumentException("Foreign contour choice.");
+ PostVerificationGeometry.Validate(choice.Point);
+ var shape = GetShape(choice.ContourOrdinal);
+ if (choice.EntityOrdinal < 0 || choice.EntityOrdinal >= shape.Entities.Count)
+ throw new ArgumentException("Foreign entity ordinal.");
+ var distance = shape.Entities[choice.EntityOrdinal].ClosestPointTo(choice.Point).DistanceTo(choice.Point);
+ if (!double.IsFinite(distance) || distance > PostVerificationGeometry.Epsilon)
+ throw new ArgumentException("Entry is not on the selected native entity.");
+ }
+
+ private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
+ ? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
+
+ 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)
+ {
+ var copies = new Dictionary(ReferenceEqualityComparer.Instance);
+ return Copy(source);
+
+ Program Copy(Program current)
+ {
+ token.ThrowIfCancellationRequested();
+ if (copies.TryGetValue(current, out var existing))
+ return existing;
+ var copy = new Program(current.Mode);
+ copies.Add(current, copy);
+ foreach (var code in current.Codes)
+ {
+ token.ThrowIfCancellationRequested();
+ var ownedCode = code.Clone();
+ if (ownedCode is SubProgramCall call)
+ call.BindProgram(Copy(((SubProgramCall)code).Program));
+ copy.Codes.Add(ownedCode);
+ }
+ copy.Mode = Mode.Incremental;
+ return copy;
+ }
+ }
+
+ private static void ValidateProgramTypes(Program program, CancellationToken token)
+ {
+ token.ThrowIfCancellationRequested();
+ if (program.GetType() != typeof(Program) || !Enum.IsDefined(program.Mode))
+ throw new NotSupportedException("Unsupported program runtime type or mode.");
+ foreach (var code in program.Codes)
+ {
+ token.ThrowIfCancellationRequested();
+ 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)
+ ValidateProgramTypes(call.Program, token);
+ }
+ }
+}
diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
index ffc8c9e..a5584e5 100644
--- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
+++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
@@ -234,6 +234,21 @@ namespace OpenNest.CNC.CuttingStrategy
return null;
}
+ internal Program EmitPrepared(Shape[] shapes, List scribes,
+ IReadOnlyList choices)
+ {
+ var result = new Program(Mode.Absolute);
+ EmitScribeContours(result, scribes);
+ foreach (var choice in choices)
+ {
+ var shape = shapes[choice.ContourOrdinal];
+ EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
+ choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null);
+ }
+ result.Mode = Mode.Incremental;
+ return result;
+ }
+
private void EmitRawContour(Program program, Shape shape)
{
var startPoint = GetShapeStartPoint(shape);
diff --git a/OpenNest.Core/OpenNest.Core.csproj b/OpenNest.Core/OpenNest.Core.csproj
index 57f00bd..45d022c 100644
--- a/OpenNest.Core/OpenNest.Core.csproj
+++ b/OpenNest.Core/OpenNest.Core.csproj
@@ -6,6 +6,7 @@
+
diff --git a/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs b/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs
new file mode 100644
index 0000000..5276e9b
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs
@@ -0,0 +1,170 @@
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class OwnedCuttingParameterTests
+{
+ public static IEnumerable