feat(core): contour cut pins

A library drawing can say how individual contours must be cut. A
`ContourCutPin` holds the entry point in the drawing's source frame plus
the lead-in, lead-out and optional tab used there; `Drawing.CutPins`
lists them. Nothing plans with pins yet (Slice 5).

- Pins accept only the exact built-in lead and tab types, copied through
  the same owned copiers the planner uses, so a pin cannot carry a type
  the emitter would not certify. Properties return copies: the pin is
  immutable.
- A tab requires no lead-out, because the planner refuses a lead-out
  after an open contour.
- The source frame is the frame of the source entities. It does not move
  when BuildDrawing re-origins the program at its first rapid, so pins
  survive converter edits that change Source.Offset.

`CutPinResolver` maps pins to (contour ordinal, entity ordinal, local
point) for a placement rotation, using the same `PreparedContours`
ordinals Plan Cutting will use. It never guesses: a pin must lie within
the contour closure tolerance of exactly one contour that no other pin
claims, otherwise it is reported unresolved with a typed reason. A
drawing program rotated in place (a canonical copy) is not the source
frame and resolves nothing; canonical copies and split pieces carry no
pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-16 15:33:20 -04:00
1 parent 2aecafb356
commit 1bd0b258af
7 files changed
+502 -3

No files matched your search

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
public enum CutPinStatus
{
/// <summary>The pin lies on exactly one contour, which no other pin claims.</summary>
Resolved,
/// <summary>The pin no longer lies on any contour of the drawing.</summary>
NotOnContour,
/// <summary>The pin lies on more than one contour, so which one it pins is unknown.</summary>
OnSeveralContours,
/// <summary>Another pin lies on the same contour; neither is used.</summary>
ContourPinnedTwice,
/// <summary>The drawing's program cannot be prepared, or is not in the frame its source offset describes.</summary>
UnsupportedGeometry,
}
/// <summary>
/// Where one drawing pin lands on a placed contour. <see cref="Point"/> is in the placed
/// part's local frame (add the part's location for plate coordinates): the exact native point
/// when resolved, otherwise where the pin maps to (<see cref="Vector.Invalid"/> when the drawing
/// frame is unknown). Ordinals match <see cref="PreparedContours"/> captured from the same
/// rotated clean program.
/// </summary>
public sealed record CutPinResolution(int PinIndex, ContourCutPin Pin, CutPinStatus Status, int ContourOrdinal,
int EntityOrdinal, Vector Point)
{
public bool IsResolved => Status == CutPinStatus.Resolved;
}
/// <summary>
/// Maps a drawing's source-frame pins onto its program contours at a placement rotation. A
/// pin never resolves by guessing: it must lie within the contour closure tolerance of exactly
/// one contour that no other pin claims, or it is reported unresolved with the reason.
/// </summary>
public static class CutPinResolver
{
/// <summary>
/// Resolves the drawing's pins for a part rotated by <paramref name="rotation"/> radians
/// about the drawing program's origin, as <see cref="Part.Rotate(double)"/> does.
/// </summary>
public static IReadOnlyList<CutPinResolution> Resolve(Drawing drawing, double rotation, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(drawing);
if (!double.IsFinite(rotation))
throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Rotation must be finite.");
var pins = drawing.CutPins ?? new List<ContourCutPin>();
if (pins.Count == 0)
return Array.Empty<CutPinResolution>();
if (drawing.Program == null || !IsSourceFrame(drawing))
return Unsupported(pins);
var program = (Program)drawing.Program.Clone();
if (!Math.Tolerance.IsEqualTo(rotation, 0))
program.Rotate(rotation);
PreparedContours contours;
try
{
contours = PreparedContours.Capture(program, new CuttingParameters(), token);
}
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException)
{
return Unsupported(pins);
}
return Resolve(drawing, rotation, contours, token);
}
/// <summary>Resolves the pins of a placed part at its current rotation.</summary>
public static IReadOnlyList<CutPinResolution> Resolve(Part part, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(part);
return Resolve(part.BaseDrawing, part.Rotation, token);
}
/// <summary>
/// Resolves against contours the caller already captured. <paramref name="contours"/> must
/// come from the drawing's clean program rotated by <paramref name="rotation"/>.
/// </summary>
public static IReadOnlyList<CutPinResolution> Resolve(Drawing drawing, double rotation, PreparedContours contours,
CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(drawing);
ArgumentNullException.ThrowIfNull(contours);
if (!double.IsFinite(rotation))
throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Rotation must be finite.");
var pins = drawing.CutPins ?? new List<ContourCutPin>();
if (pins.Count == 0)
return Array.Empty<CutPinResolution>();
if (!IsSourceFrame(drawing))
return Unsupported(pins);
var offset = drawing.Source?.Offset ?? Vector.Zero;
var results = new CutPinResolution[pins.Count];
for (var i = 0; i < pins.Count; i++)
{
token.ThrowIfCancellationRequested();
var pin = pins[i] ?? throw new ArgumentException($"Drawing pin {i + 1} is missing.", nameof(drawing));
var local = (pin.Point - offset).Rotate(rotation);
var near = contours.ContoursNear(local, NominalContourClosure.Tolerance);
results[i] = near.Count switch
{
0 => new(i, pin, CutPinStatus.NotOnContour, -1, -1, local),
1 => new(i, pin, CutPinStatus.Resolved, near[0].ContourOrdinal, near[0].EntityOrdinal, near[0].Point),
_ => new(i, pin, CutPinStatus.OnSeveralContours, -1, -1, local),
};
}
// At most one pin per contour. Choosing between two would be a guess, so neither is used.
foreach (var claimed in results.Where(r => r.IsResolved).GroupBy(r => r.ContourOrdinal).Where(g => g.Count() > 1))
foreach (var result in claimed)
results[result.PinIndex] = result with { Status = CutPinStatus.ContourPinnedTwice };
return results;
}
// Pins are stored relative to the source entities; the program is that frame moved by
// -Source.Offset and never rotated in place. A rotated program (a canonical copy) is not.
private static bool IsSourceFrame(Drawing drawing) =>
drawing.Program != null && Math.Tolerance.IsEqualTo(drawing.Program.Rotation, 0);
private static CutPinResolution[] Unsupported(List<ContourCutPin> pins) =>
pins.Select((pin, i) => new CutPinResolution(i, pin, CutPinStatus.UnsupportedGeometry, -1, -1, Vector.Invalid))
.ToArray();
}
@@ -65,7 +65,7 @@ internal static class OwnedCuttingParameters
};
}
private static LeadIn CopyLeadIn(LeadIn source) => source switch
internal 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
@@ -86,7 +86,7 @@ internal static class OwnedCuttingParameters
_ => throw new NotSupportedException("Unsupported lead-in runtime type.")
};
private static LeadOut CopyLeadOut(LeadOut source) => source switch
internal 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
@@ -96,7 +96,7 @@ internal static class OwnedCuttingParameters
_ => throw new NotSupportedException("Unsupported lead-out runtime type.")
};
private static Tab CopyTab(Tab source)
internal static Tab CopyTab(Tab source)
{
Tab copy = source switch
{
@@ -149,6 +149,25 @@ public sealed class PreparedContours
}
}
/// <summary>
/// Every contour with a native entity within <paramref name="tolerance"/> of
/// <paramref name="point"/>, in ordinal order, each with its closest entity and the exact
/// closest native point on it. A locating aid for drawing pins, not an entry choice.
/// </summary>
internal IReadOnlyList<(int ContourOrdinal, int EntityOrdinal, Vector Point)> ContoursNear(Vector point, double tolerance)
{
PostVerificationGeometry.Validate(point);
var found = new List<(int, int, Vector)>();
for (var ordinal = 0; ordinal < Count; ordinal++)
{
var shape = shapes[ordinal];
var closest = shape.ClosestPointTo(point, out var entity);
if (closest.DistanceTo(point) <= tolerance)
found.Add((ordinal, shape.Entities.IndexOf(entity), closest));
}
return found;
}
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
{
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
@@ -0,0 +1,55 @@
using System;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// How one contour of a drawing must be cut: where it is entered and the lead-in,
/// lead-out and optional tab used there. <see cref="Point"/> is in the drawing's source
/// frame (the frame of <see cref="Drawing.SourceEntities"/>), which does not move when the
/// program is re-origined; <see cref="CutPinResolver"/> maps it to a placed contour.
/// Immutable: only the exact built-in lead and tab types are accepted, they are copied
/// in, and every property returns a fresh copy.
/// </summary>
public sealed class ContourCutPin
{
private readonly LeadIn leadIn;
private readonly LeadOut leadOut;
private readonly Tab tab;
/// <exception cref="ArgumentException">A missing or invalid lead, a nonfinite point, or a tab with a lead-out.</exception>
/// <exception cref="NotSupportedException">A lead or tab that is not an exact built-in type.</exception>
public ContourCutPin(Vector point, LeadIn leadIn, LeadOut leadOut, Tab tab = null)
{
PostVerificationGeometry.Validate(point);
var ownedLeadIn = OwnedCuttingParameters.CopyLeadIn(leadIn);
var ownedLeadOut = OwnedCuttingParameters.CopyLeadOut(leadOut);
var ownedTab = tab == null ? null : OwnedCuttingParameters.CopyTab(tab);
// A tab leaves the contour open at its end; a lead-out after an open contour is
// refused by the planner, so the pin cannot ask for both.
if (ownedTab != null && ownedLeadOut.GetType() != typeof(NoLeadOut))
throw new ArgumentException("A pin with a tab cannot have a lead-out.", nameof(leadOut));
Point = point;
this.leadIn = ownedLeadIn;
this.leadOut = ownedLeadOut;
this.tab = ownedTab;
}
/// <summary>The contour entry in the drawing's source frame.</summary>
public Vector Point { get; }
/// <summary>A copy of the pinned lead-in.</summary>
public LeadIn LeadIn => OwnedCuttingParameters.CopyLeadIn(leadIn);
/// <summary>A copy of the pinned lead-out; always <see cref="NoLeadOut"/> when <see cref="HasTab"/>.</summary>
public LeadOut LeadOut => OwnedCuttingParameters.CopyLeadOut(leadOut);
/// <summary>A copy of the pinned tab, or null for a closed contour.</summary>
public Tab Tab => tab == null ? null : OwnedCuttingParameters.CopyTab(tab);
public bool HasTab => tab != null;
}
}
+7
View File
@@ -118,6 +118,13 @@ namespace OpenNest
/// </summary>
public DrawingLibraryLink Library { get; set; }
/// <summary>
/// Contours this drawing says must be cut a fixed way, in the source frame. At most one
/// pin per contour is used; <see cref="CNC.CuttingPlanning.CutPinResolver"/> maps them to
/// a placement. Pins do not follow geometry into new drawings (split pieces).
/// </summary>
public List<CNC.CuttingStrategy.ContourCutPin> CutPins { get; set; } = new List<CNC.CuttingStrategy.ContourCutPin>();
/// <summary>
/// Complete set of source entities with stable GUIDs.
/// Null when the drawing was created from G-code or an older nest file.