mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 23:45:34 -05:00
On an enclosed machine a cutout slug that tips once cut free can stand up above the sheet and catch the enclosure door when the pallet changes. The head already keeps off cut cutouts, so this is about clearing the door, not the head. Cutting Settings > Cutouts > "Slice slugs the support grid can't hold level" (CuttingParameters.SliceTippingSlugs, off by default, saved per machine thickness and written only when on) makes Plan Cutting cut each such slug along the bed's contact lines (grid rows and columns, slat center lines) before the cutout, so no piece keeps a contact and the pieces drop between the supports. Each slice stops a kerf short of the cutout, so the pieces stay attached until the cutout's own kerf frees them together. A slug the bed holds level, or one on no contact, is left whole; a cutout holding another part or another of its part's cuts is never sliced. Slices are break cuts emitted right after the drawing's own break cuts, pierced on themselves and led straight back to their start. The released-contour proof now leaves out straight break cuts lying wholly inside one of the part's closed holes, since the grid cells they free are scrap; break cuts in material still take part. The plan fingerprint only changes when the setting is on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1269 lines
68 KiB
C#
1269 lines
68 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using OpenNest.CNC.CuttingStrategy;
|
|
using OpenNest.Diagnostics;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Math;
|
|
using OpenNest.Support;
|
|
|
|
namespace OpenNest.CNC.CuttingPlanning;
|
|
|
|
/// <summary>A nominal native entry owned by one preparation, not an actual emitted pierce.</summary>
|
|
public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point)
|
|
{
|
|
internal PreparedContours Owner { get; init; }
|
|
|
|
/// <summary>
|
|
/// A pierce the planner placed off the contour: a perimeter's in open scrap for the pierce
|
|
/// zone (see <see cref="PreparedContours.ScrapPierceEntry"/>), or a cutout's where eroding it
|
|
/// leaves room (see <see cref="PreparedContours.ErodedPierceEntries"/>). The lead-in runs
|
|
/// straight from here to <see cref="Point"/> instead of the configured lead-in; a cutout so
|
|
/// pierced has no lead-out. Null for every other choice.
|
|
/// </summary>
|
|
public Vector? Pierce { get; internal init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// How a common-line member's outside is cut once (see docs/common-line-cutting.md), with the
|
|
/// group's kerf. The member cuts its outside compensated, so each shared edge it cuts lands on the
|
|
/// kerf centerline: that edge is the common line. Spans an earlier member cut carry the Tab layer
|
|
/// and are never cut again. A member that cuts a common line itself (<see cref="Entry"/> set)
|
|
/// starts there: at <see cref="Entry"/> (part coordinates), the vertex where its shared edge
|
|
/// begins, with a straight lead-in from <see cref="Pierce"/> along that edge's axis, and runs on
|
|
/// round its perimeter with no lead-out, closing at the entry or stopping in an earlier member's
|
|
/// kerf. Its other stretches, and every stretch of a member without an entry, are remainder pieces
|
|
/// entered in the kerf before them.
|
|
/// </summary>
|
|
public sealed record CommonLinePerimeter(double Kerf, Vector? Entry = null, Vector? Pierce = null)
|
|
{
|
|
/// <summary>Whether the member starts its outside on a common line it cuts.</summary>
|
|
public bool StartsOnCommonLine => Entry != null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class PreparedContours
|
|
{
|
|
private readonly Shape[] shapes;
|
|
private readonly CuttingParameters parameters;
|
|
private readonly List<Entity> scribes;
|
|
// Open break cuts in program order, each travel-ordered with its scrap on the left. They are
|
|
// not contours: no choices, no material; every full or prefix emission cuts them first.
|
|
private readonly List<Shape> breakCuts;
|
|
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
|
|
// every full or prefix emission cuts their pieces after the break cuts.
|
|
private readonly List<Shape> tabbedCutouts;
|
|
// Slices of the slugs the support bed cannot hold level, in cutting order: open cuts inside
|
|
// their cutouts, cut right after the break cuts so every slug is sliced before its cutout.
|
|
private readonly IReadOnlyList<SlugSlice> slugSlices;
|
|
// Pierce-zone resolutions by contour, shared with every copy: the contours are the same by ordinal.
|
|
private readonly PierceZoneMemo pierceZones;
|
|
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
|
|
// its own owned settings (the pin's lead-in, lead-out and tab).
|
|
private readonly ContourChoice[] pinned;
|
|
private readonly CuttingParameters[] contourParameters;
|
|
private readonly int[] pinNumbers;
|
|
// Set when the support bed cannot hold the part level: the perimeter is tabbed and its
|
|
// automatic entries keep to points where the tab steadies the part. World = local + origin.
|
|
private readonly PartSupport support;
|
|
private readonly Vector supportOrigin;
|
|
// The outside when it is a display perimeter: shown and material, never cut. It is not one of
|
|
// the contours, so every contour is a hole; a complete emission only appends it as the
|
|
// never-executed DisplayPerimeterTail. Null for an ordinary cut perimeter.
|
|
private readonly Shape displayOutline;
|
|
private bool hasCutPerimeter => displayOutline == null;
|
|
// Set for a common-line member, cut from one fixed entry: its whole perimeter from where its
|
|
// first shared edge begins, or its remainder pieces between the Tab spans earlier members cut.
|
|
private readonly CommonLinePerimeter commonLine;
|
|
private readonly ContourChoice commonLineEntry;
|
|
|
|
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
|
CuttingParameters parameters)
|
|
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, displayOutline: null)
|
|
{
|
|
}
|
|
|
|
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
|
CuttingParameters parameters, Shape displayOutline, PierceZoneMemo pierceZones = null,
|
|
CommonLinePerimeter commonLine = null, IReadOnlyList<SlugSlice> slugSlices = null)
|
|
{
|
|
this.slugSlices = slugSlices ?? Array.Empty<SlugSlice>();
|
|
this.pierceZones = pierceZones ?? new PierceZoneMemo();
|
|
this.displayOutline = displayOutline;
|
|
this.commonLine = commonLine;
|
|
this.shapes = shapes;
|
|
this.scribes = scribes;
|
|
this.breakCuts = breakCuts;
|
|
this.tabbedCutouts = tabbedCutouts;
|
|
this.parameters = parameters;
|
|
pinned = new ContourChoice[shapes.Length];
|
|
pinNumbers = new int[shapes.Length];
|
|
if (commonLine is { Entry: { } entry })
|
|
{
|
|
// The outside starts where a shared edge it cuts begins, pierced off it.
|
|
var perimeter = shapes[^1];
|
|
var entity = perimeter.Entities.FindIndex(e => e.Layer != SpecialLayers.Tab
|
|
&& Start(e).DistanceTo(entry) <= PostVerificationGeometry.Epsilon);
|
|
commonLineEntry = new ContourChoice(shapes.Length - 1, entity, Start(perimeter.Entities[entity]))
|
|
{
|
|
Owner = this,
|
|
Pierce = commonLine.Pierce,
|
|
};
|
|
}
|
|
else if (commonLine != null)
|
|
{
|
|
// The remainder's first piece starts where a shared span ends: the one fixed entry.
|
|
var perimeter = shapes[^1];
|
|
var first = ContourCuttingStrategy.TabbedCutoutPieces(perimeter)[0].Entities[0];
|
|
var entity = perimeter.Entities.IndexOf(first);
|
|
commonLineEntry = new ContourChoice(shapes.Length - 1, entity, Start(first)) { Owner = this };
|
|
}
|
|
}
|
|
|
|
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
|
|
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
|
|
unpinned.displayOutline, unpinned.pierceZones, unpinned.commonLine, unpinned.slugSlices)
|
|
{
|
|
contourParameters = new CuttingParameters[shapes.Length];
|
|
var rejected = new List<CutPinResolution>();
|
|
foreach (var pin in pins)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (pin == null)
|
|
throw new ArgumentException("A pin resolution is missing.", nameof(pins));
|
|
if (!pin.IsResolved)
|
|
continue; // The caller reports why; the contour stays automatic.
|
|
var contour = pin.ContourOrdinal;
|
|
if (contour < 0 || contour >= Count || pin.EntityOrdinal < 0
|
|
|| pin.EntityOrdinal >= shapes[contour].Entities.Count)
|
|
throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
|
|
if (pinned[contour] != null)
|
|
throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
|
|
// The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned. A
|
|
// common-line member's outside is cut from its shared spans, never from a pin.
|
|
if (pin.Pin.HasTab && contour != PerimeterOrdinal || commonLine != null && contour == PerimeterOrdinal)
|
|
{
|
|
rejected.Add(pin);
|
|
continue;
|
|
}
|
|
var choice = new ContourChoice(contour, pin.EntityOrdinal,
|
|
SnapToVertex(shapes[contour], pin.EntityOrdinal, pin.Point))
|
|
{ Owner = this };
|
|
ValidateChoice(choice);
|
|
pinned[contour] = choice;
|
|
pinNumbers[contour] = pin.PinIndex + 1;
|
|
contourParameters[contour] = PinParameters(parameters, pin.Pin);
|
|
}
|
|
RejectedPins = rejected.AsReadOnly();
|
|
}
|
|
|
|
// A copy that tabs the perimeter for support; every pin keeps its entry, now owned here, and
|
|
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
|
|
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
|
|
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
|
source.displayOutline, source.pierceZones, source.commonLine, source.slugSlices)
|
|
{
|
|
for (var contour = 0; contour < Count; contour++)
|
|
{
|
|
pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null;
|
|
pinNumbers[contour] = source.pinNumbers[contour];
|
|
}
|
|
contourParameters = source.contourParameters == null
|
|
? new CuttingParameters[Count]
|
|
: (CuttingParameters[])source.contourParameters.Clone();
|
|
if (pinned[PerimeterOrdinal] == null)
|
|
{
|
|
var perimeter = OwnedCuttingParameters.Copy(source.ParametersFor(PerimeterOrdinal));
|
|
perimeter.TabsEnabled = true;
|
|
perimeter.TabConfig = OwnedCuttingParameters.CopyTab(tab);
|
|
contourParameters[PerimeterOrdinal] = perimeter;
|
|
}
|
|
RejectedPins = source.RejectedPins;
|
|
this.support = support;
|
|
supportOrigin = location;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A copy whose perimeter is tabbed with <paramref name="tab"/> where it steadies the part
|
|
/// on its support bed: <paramref name="support"/> analyzed this part's material placed at
|
|
/// <paramref name="location"/>, and the perimeter's automatic entries keep to points it
|
|
/// accepts (<see cref="PartSupport.AcceptsTabAt"/>) plus its best tab point. A pinned
|
|
/// perimeter keeps its pin's entry, leads and tab (the drawing wins); the copy only keeps
|
|
/// the analysis, so replay can report a pin that leaves the part unsteady.
|
|
/// </summary>
|
|
public PreparedContours WithSupportTab(PartSupport support, Vector location, Tab tab)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(support);
|
|
ArgumentNullException.ThrowIfNull(tab);
|
|
PostVerificationGeometry.Validate(location);
|
|
if (!support.IsComplete || support.BestTabPoint == null)
|
|
throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support));
|
|
if (!hasCutPerimeter)
|
|
throw new ArgumentException("A display perimeter is never cut, so it cannot hold a support tab.", nameof(support));
|
|
if (commonLine != null)
|
|
throw new ArgumentException("A common-line member is never support-tabbed.", nameof(support));
|
|
return new PreparedContours(this, support, location, tab);
|
|
}
|
|
|
|
// A copy that slices slugs; everything else, pins and support analysis included, is kept.
|
|
private PreparedContours(PreparedContours source, IReadOnlyList<SlugSlice> slices)
|
|
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
|
source.displayOutline, source.pierceZones, source.commonLine, slices)
|
|
{
|
|
for (var contour = 0; contour < Count; contour++)
|
|
{
|
|
pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null;
|
|
pinNumbers[contour] = source.pinNumbers[contour];
|
|
}
|
|
contourParameters = (CuttingParameters[])source.contourParameters?.Clone();
|
|
RejectedPins = source.RejectedPins;
|
|
support = source.support;
|
|
supportOrigin = source.supportOrigin;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A copy that slices the slug of each cutout <paramref name="bed"/> cannot hold level
|
|
/// (<see cref="SlugSlicer.Tips"/>), with the part placed at <paramref name="location"/> on a
|
|
/// sheet in <paramref name="quadrant"/>, along the bed's contact lines, each slice stopping the
|
|
/// kerf short of its cutout (docs/support-tabs.md#slug-slices). Every full or prefix emission
|
|
/// cuts the slices right after the break cuts, so each slug is sliced before its cutout.
|
|
/// A cutout that holds another of this part's cuts, or whose box holds one of
|
|
/// <paramref name="otherParts"/> (world boxes of the plate's other parts), is not sliced:
|
|
/// a slice would cut what is nested in it. Without a bed or a kerf, or with nothing to slice,
|
|
/// this preparation is returned unchanged.
|
|
/// </summary>
|
|
internal PreparedContours WithSlugSlices(SupportBed bed, int quadrant, Vector location,
|
|
IReadOnlyList<Box> otherParts, CancellationToken token = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(otherParts);
|
|
PostVerificationGeometry.Validate(location);
|
|
var standoff = parameters.Kerf;
|
|
if (bed == null || !(standoff > 0) || !double.IsFinite(standoff))
|
|
return this;
|
|
// Pierced on the slice where the cutouts' own lead-in would pierce, and at least as far
|
|
// from the cutout as any pierce keeps from its contour.
|
|
var clearance = parameters.GetEffectivePierceClearance();
|
|
var reach = System.Math.Max(clearance,
|
|
ContourCuttingStrategy.CommonLineLeadInReach(parameters.InternalLeadIn ?? new NoLeadIn()));
|
|
var slices = new List<SlugSlice>();
|
|
for (var ordinal = 0; ordinal < PerimeterOrdinal; ordinal++)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var hole = shapes[ordinal];
|
|
var box = hole.BoundingBox;
|
|
var world = new Box(box.Left + location.X, box.Bottom + location.Y, box.Length, box.Width);
|
|
if (otherParts.Any(other => other != null && world.Contains(other)))
|
|
continue;
|
|
var polygon = hole.ToPolygonWithTolerance(SupportMaterial.ChordTolerance);
|
|
if (EnclosesOtherCuts(ordinal, polygon)
|
|
|| !SlugSlicer.Tips(bed, quadrant, SupportMaterial.FromPolygons(polygon.Vertices.Select(v => v + location))))
|
|
continue;
|
|
slices.AddRange(SlugSlicer.Slices(bed, quadrant, polygon.Vertices, location, standoff, reach, clearance));
|
|
}
|
|
return slices.Count == 0 ? this : new PreparedContours(this, slices.AsReadOnly());
|
|
}
|
|
|
|
// Whether another contour, break cut or tabbed cutout of this part lies inside the cutout.
|
|
private bool EnclosesOtherCuts(int ordinal, Polygon cutout)
|
|
{
|
|
var others = shapes.Where((_, index) => index != ordinal && index != PerimeterOrdinal)
|
|
.Concat(breakCuts).Concat(tabbedCutouts);
|
|
return others.Any(other => other.Entities.Count > 0 && cutout.ContainsPoint(other.Entities[0] switch
|
|
{
|
|
Circle circle => circle.Center + new Vector(circle.Radius, 0),
|
|
var entity => Start(entity),
|
|
}));
|
|
}
|
|
|
|
/// <summary>The slug slices this preparation cuts after its break cuts and before its contours.</summary>
|
|
public IReadOnlyList<SlugSlice> SlugSlices => slugSlices;
|
|
|
|
/// <summary>
|
|
/// The analysis of a part its support bed cannot hold level (see <see cref="WithSupportTab"/>),
|
|
/// or null; <see cref="SupportOrigin"/> places its local contours in its world frame.
|
|
/// </summary>
|
|
public PartSupport Support => support;
|
|
|
|
/// <summary>The part's location: world = local + this, for <see cref="Support"/>.</summary>
|
|
public Vector SupportOrigin => supportOrigin;
|
|
|
|
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
|
|
internal bool PerimeterTabbed => hasCutPerimeter && commonLine == null && ParametersFor(PerimeterOrdinal).TabsEnabled;
|
|
|
|
/// <summary>
|
|
/// True for a common-line member (see <see cref="CommonLinePerimeter"/>): its perimeter has one
|
|
/// fixed entry and is emitted whole from its common line, or as its remainder pieces.
|
|
/// </summary>
|
|
public bool IsCommonLinePerimeter => commonLine != null;
|
|
|
|
/// <summary>How the member's outside is cut once, or null for any other part.</summary>
|
|
public CommonLinePerimeter CommonLine => commonLine;
|
|
|
|
public int Count => shapes.Length;
|
|
|
|
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
|
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
|
|
|
/// <summary>The pierce-zone resolutions these contours' emissions share; for tests.</summary>
|
|
internal PierceZoneMemo PierceZones => pierceZones;
|
|
/// <summary>
|
|
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
|
|
/// is a display perimeter, so every contour below it is a hole.
|
|
/// </summary>
|
|
public int PerimeterOrdinal => hasCutPerimeter ? Count - 1 : Count;
|
|
|
|
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
|
|
public bool HasCutPerimeter => hasCutPerimeter;
|
|
|
|
/// <summary>Resolved pins this preparation could not honor: a tab on an internal contour.</summary>
|
|
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
|
|
|
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
|
public bool IsPinned(int contourOrdinal) => !(contourOrdinal == PerimeterOrdinal && !hasCutPerimeter)
|
|
&& GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
|
|
|
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
|
|
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
|
|
|
|
/// <summary>The pinned contour's only entry, or null for an automatic contour.</summary>
|
|
public ContourChoice PinnedEntry(int contourOrdinal) => IsPinned(contourOrdinal) ? pinned[contourOrdinal] : null;
|
|
|
|
/// <summary>
|
|
/// As <see cref="Capture(Program, CuttingParameters, CancellationToken)"/>, then fixes every
|
|
/// resolved pin's contour: that contour's only entry is the pin's point and it is emitted with
|
|
/// the pin's own lead-in, lead-out and tab through the same emitter, so its tab gap is genuine
|
|
/// and certifiable. <paramref name="pins"/> must come from <see cref="CutPinResolver"/> against
|
|
/// contours of this same program; unresolved pins are skipped (the caller reports them), a pin
|
|
/// with a tab on an internal contour is listed in <see cref="RejectedPins"/>, and every other
|
|
/// contour keeps the automatic selection with <paramref name="parameters"/>.
|
|
/// </summary>
|
|
public static PreparedContours Capture(Program program, CuttingParameters parameters,
|
|
IReadOnlyList<CutPinResolution> pins, CancellationToken token = default, CommonLinePerimeter commonLine = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(pins);
|
|
var unpinned = Capture(program, parameters, token, commonLine);
|
|
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
|
|
}
|
|
|
|
// The pin's leads replace both lead sets, since the type the emitter detects is
|
|
// not the caller's concern; angle rounding is off because a pinned entry never moves.
|
|
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
|
|
{
|
|
var owned = OwnedCuttingParameters.Copy(shared);
|
|
owned.ExternalLeadIn = pin.LeadIn;
|
|
owned.InternalLeadIn = pin.LeadIn;
|
|
owned.ExternalLeadOut = pin.LeadOut;
|
|
owned.InternalLeadOut = pin.LeadOut;
|
|
owned.RoundLeadInAngles = false;
|
|
owned.TabsEnabled = pin.HasTab;
|
|
if (pin.HasTab)
|
|
owned.TabConfig = pin.Tab;
|
|
return owned;
|
|
}
|
|
|
|
private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
|
|
|
|
/// <param name="commonLine">For a common-line member: how its outside is cut once. With an
|
|
/// entry, the program's perimeter is closed and starts there. Without one, it must carry the
|
|
/// spans earlier members cut on the Tab layer (CommonLineCutting.MarkSharedSpans); it is then
|
|
/// the perimeter, cut in pieces between them, rather than a tabbed cutout.</param>
|
|
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default,
|
|
CommonLinePerimeter commonLine = null)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var ownedParameters = OwnedCuttingParameters.Copy(parameters);
|
|
ExecutionMotionReader.ReadSupported(program, Vector.Zero, null, 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<Shape>();
|
|
var scribes = new List<Entity>();
|
|
var breakCuts = new List<Shape>();
|
|
var tabbedCutouts = new List<Shape>();
|
|
Shape displayPerimeter = null;
|
|
Shape breakCut = null;
|
|
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.Break)
|
|
{
|
|
if (current.Entities.Count != 0)
|
|
throw new ArgumentException("Preparation requires closed execution contours.");
|
|
if (entity is Circle || entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
|
throw new ArgumentException("Degenerate break cut entity.");
|
|
if (breakCut == null || End(breakCut.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
|
{
|
|
breakCut = new Shape();
|
|
breakCuts.Add(breakCut);
|
|
}
|
|
breakCut.Entities.Add(entity);
|
|
continue;
|
|
}
|
|
breakCut = null;
|
|
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 && entity.Layer != SpecialLayers.Tab
|
|
&& entity.Layer != SpecialLayers.DisplayPerimeter)
|
|
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 || entity.Layer == SpecialLayers.Tab)
|
|
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 (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
|
FinishContour();
|
|
}
|
|
if (commonLine != null)
|
|
{
|
|
if (displayPerimeter != null || current.Entities.Count != 0)
|
|
throw new ArgumentException("A common-line member needs one closed cut perimeter.");
|
|
if (!double.IsFinite(commonLine.Kerf) || commonLine.Kerf <= 0
|
|
|| (commonLine.Entry == null) != (commonLine.Pierce == null))
|
|
throw new ArgumentException("A common-line member needs a positive kerf, and an entry with its pierce.");
|
|
var outside = tabbedCutouts.Concat(contours).MaxBy(shape => shape.BoundingBox.Width * shape.BoundingBox.Length);
|
|
if (commonLine is { Entry: { } entry, Pierce: { } pierce })
|
|
{
|
|
// Cut from its entry vertex on a common line; every other contour lies inside it.
|
|
PostVerificationGeometry.Validate(entry);
|
|
PostVerificationGeometry.Validate(pierce);
|
|
if (outside == null
|
|
|| !outside.Entities.Exists(e => e.Layer != SpecialLayers.Tab
|
|
&& Start(e).DistanceTo(entry) <= PostVerificationGeometry.Epsilon)
|
|
|| pierce.DistanceTo(entry) <= PostVerificationGeometry.Epsilon)
|
|
throw new ArgumentException("A common-line member's perimeter does not start at its common line.");
|
|
if (contours.Contains(outside))
|
|
{
|
|
contours.Remove(outside);
|
|
contours.Add(outside);
|
|
foreach (var cut in breakCuts)
|
|
cut.UpdateBounds();
|
|
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, null, null,
|
|
commonLine);
|
|
}
|
|
}
|
|
// The outside carries the shared spans earlier members cut as tabs; every other contour lies inside it.
|
|
else if (outside == null || !tabbedCutouts.Contains(outside)
|
|
|| ContourCuttingStrategy.TabbedCutoutPieces(outside).Count == 0)
|
|
throw new ArgumentException("A common-line member's perimeter does not carry its shared spans.");
|
|
// Each remainder piece is pierced in the kerf of the span before it, the external
|
|
// lead-in's reach back along it: the span must be that long. Never shortened.
|
|
var reach = ContourCuttingStrategy.CommonLineLeadInReach(ownedParameters.ExternalLeadIn ?? new NoLeadIn());
|
|
if (reach <= OpenNest.Math.Tolerance.Epsilon)
|
|
throw new NotSupportedException("A common-line member's remainder needs a lead-in with length: "
|
|
+ "its compensation starts in the shared kerf along the lead-in. Choose an external lead-in "
|
|
+ "with length, or cut the common lines twice.");
|
|
foreach (var piece in ContourCuttingStrategy.TabbedCutoutPieces(outside))
|
|
{
|
|
var index = outside.Entities.IndexOf(piece.Entities[0]);
|
|
var span = outside.Entities[(index + outside.Entities.Count - 1) % outside.Entities.Count];
|
|
if (span is not Line line || line.Length < reach + PostVerificationGeometry.Epsilon)
|
|
throw new NotSupportedException(FormattableString.Invariant(
|
|
$"One of its shared spans is {span.Length:0.####} long, shorter than the {reach:0.####} external lead-in its remainder starts along in that span's kerf. Shorten the lead-in, or cut the common lines twice."));
|
|
}
|
|
tabbedCutouts.Remove(outside);
|
|
contours.Add(outside);
|
|
foreach (var cut in breakCuts)
|
|
cut.UpdateBounds();
|
|
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, null, null,
|
|
commonLine);
|
|
}
|
|
if (displayPerimeter != null)
|
|
{
|
|
if (current.Entities.Count != 0)
|
|
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
|
if (contours.Count == 0 && tabbedCutouts.Count == 0 && breakCuts.Count == 0)
|
|
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
|
// Every cut must lie on the blank: a non-rectangular blank's box is not enough.
|
|
// Each cut is sampled along its arcs and must not cross or touch the outline anywhere.
|
|
var outline = displayPerimeter.ToPolygon();
|
|
foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts))
|
|
if (shape.ToPolygon().Vertices.Any(vertex => !outline.ContainsPoint(vertex))
|
|
|| displayPerimeter.Intersects(shape))
|
|
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
|
// The search plans a part through its closed contours; tabbed cutouts and break cuts
|
|
// alone leave it nothing to choose.
|
|
if (contours.Count == 0)
|
|
throw new ArgumentException("A display perimeter needs at least one closed cutout without tabs.");
|
|
foreach (var cut in breakCuts)
|
|
cut.UpdateBounds();
|
|
// Never a contour: nothing can choose, emit or lead it. Every contour is a hole.
|
|
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, displayPerimeter);
|
|
}
|
|
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);
|
|
foreach (var cut in breakCuts)
|
|
cut.UpdateBounds();
|
|
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters);
|
|
|
|
void FinishContour()
|
|
{
|
|
current.UpdateBounds();
|
|
if (current.Entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
|
{
|
|
if (!current.Entities.TrueForAll(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
|
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
|
if (displayPerimeter != null)
|
|
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
|
displayPerimeter = current;
|
|
}
|
|
else if (ContourCuttingStrategy.IsTabbedCutout(current))
|
|
{
|
|
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
|
|
throw new ArgumentException("A tabbed cutout has nothing to cut.");
|
|
tabbedCutouts.Add(current);
|
|
}
|
|
else
|
|
contours.Add(current);
|
|
current = new Shape();
|
|
}
|
|
}
|
|
|
|
/// <summary>The closest native entry to <paramref name="approach"/>; a pinned contour's pinned entry.</summary>
|
|
public ContourChoice ClosestEntry(int contourOrdinal, Vector approach)
|
|
{
|
|
PostVerificationGeometry.Validate(approach);
|
|
var shape = GetShape(contourOrdinal);
|
|
if (pinned[contourOrdinal] is { } pin)
|
|
return pin;
|
|
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
|
return fixedEntry;
|
|
var point = shape.ClosestPointTo(approach, out var entity);
|
|
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// A pinned contour has its pinned entry only.
|
|
/// </summary>
|
|
public IReadOnlyList<ContourChoice> 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);
|
|
if (pinned[contourOrdinal] is { } pin)
|
|
return new[] { pin };
|
|
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
|
return new[] { fixedEntry };
|
|
var entries = new List<ContourChoice>();
|
|
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(PortableMath.Cos(angle * System.Math.PI / 4),
|
|
PortableMath.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)
|
|
return;
|
|
// Compare the entry as emitted: snapping can bring two raw points together.
|
|
var entry = Entry(contourOrdinal, entityOrdinal, point);
|
|
if (!entries.Any(e => e.Point.DistanceTo(entry.Point) <= PostVerificationGeometry.Epsilon))
|
|
entries.Add(entry);
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
point = SnapToVertex(GetShape(contourOrdinal), entityOrdinal, point);
|
|
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
|
|
ValidateChoice(choice);
|
|
return choice;
|
|
}
|
|
|
|
/// <summary>
|
|
/// An automatic perimeter entry pierced at <paramref name="pierce"/> (local coordinates),
|
|
/// entering at the closest native point of the perimeter along a straight lead-in. The
|
|
/// planner's pierce-zone fallback chooses the pierce from the free scrap around the part;
|
|
/// emission reads only this choice and the perimeter, and the lead validator checks the
|
|
/// lead as usual. A pinned perimeter keeps its pin's entry.
|
|
/// </summary>
|
|
internal ContourChoice ScrapPierceEntry(Vector pierce) => PiercedEntry(PerimeterOrdinal, pierce);
|
|
|
|
/// <summary>
|
|
/// Entries of cutout <paramref name="contourOrdinal"/> pierced where eroding it leaves room
|
|
/// (see <see cref="ErodedPierce"/>): the planner's fallback when no configured lead-in fits the
|
|
/// cutout. For each of the first <paramref name="max"/> <paramref name="targets"/> (its own
|
|
/// entries, usually ranked) the pierce is the eroded point nearest it, and the lead-in runs
|
|
/// straight from there to the target itself, then, as an alternative, to the cutout's native
|
|
/// point closest to the pierce. Neither has a lead-out. Nothing for the perimeter or a pinned
|
|
/// cutout; the lead validator still checks each one.
|
|
/// </summary>
|
|
internal IReadOnlyList<ContourChoice> ErodedPierceEntries(int contourOrdinal, IReadOnlyList<ContourChoice> targets,
|
|
int max, CancellationToken token = default)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var shape = GetShape(contourOrdinal);
|
|
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
|
|
return Array.Empty<ContourChoice>();
|
|
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
|
|
var depth = System.Math.Max(ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
|
|
ApplicableLeadInLength(contourOrdinal));
|
|
var found = ErodedPierce.Find(shape, depth,
|
|
targets.Select(t => t.Point).ToArray(), max);
|
|
var entries = new List<ContourChoice>();
|
|
for (var i = 0; i < found.Pierces.Count; i++)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var pierce = found.Pierces[i];
|
|
Add(() => targets[i] with { Pierce = pierce });
|
|
Add(() => PiercedEntry(contourOrdinal, pierce));
|
|
}
|
|
return entries;
|
|
|
|
void Add(Func<ContourChoice> entry)
|
|
{
|
|
try
|
|
{
|
|
var choice = entry();
|
|
ValidateChoice(choice);
|
|
if (!entries.Any(e => e.Point == choice.Point && e.Pierce == choice.Pierce))
|
|
entries.Add(choice);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
// A pierce on the wall itself has no lead; the others still stand.
|
|
}
|
|
}
|
|
}
|
|
|
|
// A contour entry at the closest native point to a pierce off it, along a straight lead-in.
|
|
private ContourChoice PiercedEntry(int contourOrdinal, Vector pierce)
|
|
{
|
|
PostVerificationGeometry.Validate(pierce);
|
|
var shape = GetShape(contourOrdinal);
|
|
var point = shape.ClosestPointTo(pierce, out var entity);
|
|
point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
|
|
var choice = new ContourChoice(contourOrdinal, shape.Entities.IndexOf(entity), point)
|
|
{
|
|
Owner = this,
|
|
Pierce = pierce,
|
|
};
|
|
ValidateChoice(choice);
|
|
return choice;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The uncapped preferred automatic start catalog for one contour, in preference then
|
|
/// contour-travel order: convex corners of the contour's own winding, then straight-edge
|
|
/// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
|
|
/// line/line splits, circles and interior points never appear; each geometric point is
|
|
/// reported once, keeping the most preferred kind. A pure-arc contour can have no
|
|
/// preferred point at all — <see cref="AutomaticEntryCandidatesWithFallbacks"/> supplies
|
|
/// those. Manual entry through <see cref="Entry"/> / <see cref="ClosestEntry"/> is
|
|
/// unaffected.
|
|
/// </summary>
|
|
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidates(int contourOrdinal, CancellationToken token = default)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var shape = GetShape(contourOrdinal);
|
|
if (pinned[contourOrdinal] is { } pin)
|
|
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
|
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
|
return new[] { new ContourEntryCandidate(fixedEntry, AutomaticEntryKind.Pinned) };
|
|
if (IsSingleCircle(shape))
|
|
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalog.");
|
|
return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The complete uncapped automatic start catalog: the preferred points of
|
|
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
|
|
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a
|
|
/// whole circle, and the exact target-facing closest point toward
|
|
/// <paramref name="lookAhead"/> (pass the arrival point there when there is no next cut).
|
|
/// A pure-circle contour therefore yields compass points instead of refusing. Every
|
|
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
|
|
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A
|
|
/// pinned contour's catalog is its pinned entry alone: nothing may relocate it.
|
|
/// </summary>
|
|
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
|
int contourOrdinal,
|
|
Vector? lookAhead = null,
|
|
CancellationToken token = default,
|
|
SupportEntries supportEntries = SupportEntries.Steadying)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var shape = GetShape(contourOrdinal);
|
|
if (pinned[contourOrdinal] is { } pin)
|
|
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
|
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
|
|
return new[] { new ContourEntryCandidate(fixedEntry, AutomaticEntryKind.Pinned) };
|
|
var all = IsSingleCircle(shape)
|
|
? new List<ContourEntryCandidate>()
|
|
: PreferredCandidates(shape, contourOrdinal, token);
|
|
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
|
|
return SupportFiltered(contourOrdinal, all, supportEntries);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
|
|
/// its automatic catalog then keeps to steadying points unless asked for a fallback.
|
|
/// </summary>
|
|
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
|
|
|
|
/// <summary>
|
|
/// The support margin with this perimeter's tab at <paramref name="choice"/> (see
|
|
/// <see cref="PartSupport.MarginWith"/>); negative infinity when it is not tabbed for support.
|
|
/// </summary>
|
|
internal double SupportMarginAt(ContourChoice choice) =>
|
|
support?.MarginWith(choice.Point + supportOrigin) ?? double.NegativeInfinity;
|
|
|
|
/// <summary>Whether a support tab at <paramref name="choice"/> holds the part level (see <see cref="PartSupport.HoldsLevelWithTabAt"/>).</summary>
|
|
internal bool HoldsLevelAt(ContourChoice choice) =>
|
|
support != null && support.HoldsLevelWithTabAt(choice.Point + supportOrigin);
|
|
|
|
/// <summary>Whether a support tab at <paramref name="choice"/> keeps the inside-corner clearance.</summary>
|
|
internal bool IsClearOfInsideCorners(ContourChoice choice) =>
|
|
support == null || support.IsClearOfInsideCorners(choice.Point + supportOrigin);
|
|
|
|
// A perimeter tabbed for support keeps the candidates whose tab steadies the part, and gains
|
|
// the outline's best tab point. The search's fallbacks, when none of them has a lead-in that
|
|
// fits, keep every candidate clear of inside corners and then every candidate, still tabbed.
|
|
private IReadOnlyList<ContourEntryCandidate> SupportFiltered(int contourOrdinal,
|
|
List<ContourEntryCandidate> candidates, SupportEntries entries)
|
|
{
|
|
if (support == null || contourOrdinal != PerimeterOrdinal || entries == SupportEntries.Any)
|
|
return MergeByGeometry(candidates);
|
|
if (entries == SupportEntries.ClearOfInsideCorners)
|
|
return MergeByGeometry(candidates.Where(c => IsClearOfInsideCorners(c.Choice)).ToList());
|
|
var kept = candidates.Where(c => support.AcceptsTabAt(c.Choice.Point + supportOrigin)).ToList();
|
|
var best = ClosestEntry(PerimeterOrdinal, support.BestTabPoint!.Value - supportOrigin);
|
|
if (!IsForbiddenVertex(GetShape(PerimeterOrdinal), best.Point))
|
|
kept.Add(new ContourEntryCandidate(best, AutomaticEntryKind.SupportTab));
|
|
return MergeByGeometry(kept);
|
|
}
|
|
|
|
private static bool IsSingleCircle(Shape shape) =>
|
|
shape.Entities.Count == 1 && shape.Entities[0] is Circle;
|
|
|
|
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
|
|
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
|
|
=> CatalogPoints(shape, token)
|
|
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
|
|
.ToList();
|
|
|
|
/// <summary>
|
|
/// Tier-3 fallbacks for one contour, each already run through the reflex/cusp exclusion:
|
|
/// native arc midpoints, the eight compass points of whole circles, near-convex-corner
|
|
/// insets on straight edges, and the exact target-facing closest point toward
|
|
/// <paramref name="lookAhead"/>. No ranking and no lead-safety verdict here.
|
|
/// </summary>
|
|
private List<ContourEntryCandidate> FallbackCandidates(
|
|
Shape shape,
|
|
int contourOrdinal,
|
|
Vector? lookAhead,
|
|
CancellationToken token)
|
|
{
|
|
var fallbacks = new List<ContourEntryCandidate>();
|
|
var lead = ApplicableLeadInLength(contourOrdinal);
|
|
var count = shape.Entities.Count;
|
|
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
switch (shape.Entities[i])
|
|
{
|
|
case Arc arc:
|
|
// The native midpoint of an arc (exact native API, never tessellation).
|
|
fallbacks.Add(Fallback(i, arc.MidPoint(), AutomaticEntryKind.ArcMidpoint));
|
|
break;
|
|
case Circle circle:
|
|
// The legacy eight compass points, same native construction.
|
|
for (var angle = 0; angle < 8; angle++)
|
|
fallbacks.Add(Fallback(i, circle.Center + new Vector(
|
|
PortableMath.Cos(angle * System.Math.PI / 4),
|
|
PortableMath.Sin(angle * System.Math.PI / 4)) * circle.Radius,
|
|
AutomaticEntryKind.CircleCompass));
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Near-convex-corner fallbacks: about twice the applicable lead-in length back from
|
|
// each convex corner along each incident STRAIGHT edge, only when strictly inside
|
|
// that edge. Short edges simply omit the point; it never extrapolates past an edge
|
|
// endpoint and so never lands on the reflex/cusp vertex at the far end.
|
|
if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle))
|
|
{
|
|
foreach (var corner in CatalogPoints(shape, token)
|
|
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
// Back INTO each incident edge from the corner: the edge ending at the
|
|
// corner retreats against its own travel, the edge starting at the corner
|
|
// advances along its own travel.
|
|
AddInset(corner.EntityOrdinal, inward: false);
|
|
AddInset((corner.EntityOrdinal + 1) % count, inward: true);
|
|
}
|
|
}
|
|
|
|
if (lookAhead != null)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
PostVerificationGeometry.Validate(lookAhead.Value);
|
|
var facing = ClosestEntry(contourOrdinal, lookAhead.Value);
|
|
// A raw closest point may land exactly on a reflex/cusp vertex; automatic
|
|
// selection must never sneak a forbidden inside corner back in, so drop it.
|
|
if (!IsForbiddenVertex(shape, facing.Point))
|
|
fallbacks.Add(Fallback(facing.EntityOrdinal, facing.Point, AutomaticEntryKind.TargetFacing));
|
|
}
|
|
token.ThrowIfCancellationRequested();
|
|
return fallbacks;
|
|
|
|
void AddInset(int entityOrdinal, bool inward)
|
|
{
|
|
// `inward` selects along the edge's own travel from its start; without it the
|
|
// point retreats against travel. Both ways move BACK INTO the edge from the
|
|
// corner, which sits at the edge's end (inward=false) or start (inward=true).
|
|
if (shape.Entities[entityOrdinal] is not Line line)
|
|
return;
|
|
var direction = (line.EndPoint - line.StartPoint).Normalize();
|
|
var offset = direction * (2 * lead);
|
|
// Adjacent native endpoints may differ within contour-continuity tolerance.
|
|
// Anchor to the selected edge itself, never the preceding edge's vertex.
|
|
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
|
|
// Strictly inside the edge by projection parameter (distance alone loses the
|
|
// sign when a short edge is overshoot): never the corner, never the far
|
|
// endpoint (where a reflex vertex might sit). Short edges omit the point. The
|
|
// margin is the vertex snap distance, so Entry cannot snap it onto either end.
|
|
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
|
if (t <= OpenNest.Math.Tolerance.Epsilon || t >= line.Length - OpenNest.Math.Tolerance.Epsilon)
|
|
return;
|
|
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
|
}
|
|
|
|
ContourEntryCandidate Fallback(int entityOrdinal, Vector point, AutomaticEntryKind kind)
|
|
=> new(Entry(contourOrdinal, entityOrdinal, point), kind);
|
|
}
|
|
|
|
/// <summary>
|
|
/// At equal geometric points the most preferred kind wins, independent of which entity
|
|
/// supplied it; the winner keeps its own ordinal and point. Result order: preference,
|
|
/// then entity ordinal.
|
|
/// </summary>
|
|
private static IReadOnlyList<ContourEntryCandidate> MergeByGeometry(List<ContourEntryCandidate> candidates)
|
|
{
|
|
var byPoint = new Dictionary<(long, long), ContourEntryCandidate>();
|
|
var order = new List<(long, long)>();
|
|
foreach (var candidate in candidates)
|
|
{
|
|
var key = candidate.GeometryKey;
|
|
if (!byPoint.TryGetValue(key, out var existing))
|
|
{
|
|
byPoint[key] = candidate;
|
|
order.Add(key);
|
|
}
|
|
else if (candidate.Kind < existing.Kind)
|
|
byPoint[key] = candidate;
|
|
}
|
|
return order
|
|
.OrderBy(key => byPoint[key].Kind)
|
|
.ThenBy(key => byPoint[key].Choice.EntityOrdinal)
|
|
.Select(key => byPoint[key])
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="point"/> sits on a vertex of the contour that is reflex or
|
|
/// cusp from either travel direction — a point automatic selection must never emit.
|
|
/// </summary>
|
|
private bool IsForbiddenVertex(Shape shape, Vector point)
|
|
{
|
|
for (var i = 0; i < shape.Entities.Count; i++)
|
|
{
|
|
if (shape.Entities[i] is Circle)
|
|
continue; // A whole circle has no vertex.
|
|
var vertex = End(shape.Entities[i]);
|
|
if (vertex.DistanceTo(point) > PostVerificationGeometry.Epsilon)
|
|
continue;
|
|
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, shape.Entities[i], out var corner)
|
|
&& corner.Kind is ContourCuttingStrategy.CornerKind.Reflex or ContourCuttingStrategy.CornerKind.Cusp)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The applicable lead-in length for a contour following the emitter's own selection:
|
|
/// the perimeter (last shape) is external, anything else internal; non-length lead-in
|
|
/// styles contribute 0, which omits the near-corner fallback instead of approximating.
|
|
/// </summary>
|
|
private double ApplicableLeadInLength(int contourOrdinal)
|
|
=> (contourOrdinal == PerimeterOrdinal
|
|
? parameters.ExternalLeadIn
|
|
: parameters.InternalLeadIn) switch
|
|
{
|
|
LineLeadIn line => line.Length,
|
|
LineLineLeadIn lineLine => lineLine.Length1,
|
|
_ => 0,
|
|
};
|
|
|
|
/// <summary>
|
|
/// The preferred catalog points for one contour: for each entity, its convex corner
|
|
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
|
|
/// plus tangent line/arc joints. The joints of a corner fillet (see <see cref="FilletReach"/>)
|
|
/// are that corner's convex corners. Reflex, cusp and collinear-split vertices contribute
|
|
/// nothing; a single whole circle yields no points at all.
|
|
/// </summary>
|
|
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> CatalogPoints(
|
|
Shape shape, CancellationToken token)
|
|
{
|
|
var found = new List<(int, Vector, AutomaticEntryKind, double)>();
|
|
var count = shape.Entities.Count;
|
|
if (count == 1 && shape.Entities[0] is Circle)
|
|
{
|
|
// A whole circle has no corners or joints; only the fallback tier applies.
|
|
return found;
|
|
}
|
|
|
|
if (count < 2)
|
|
throw new ArgumentException("Contour has no vertex to classify.");
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var entity = shape.Entities[i];
|
|
|
|
// Vertex reached by traveling along entity i (its end point), reported under
|
|
// entity i. The closed contour guarantees every vertex appears exactly once
|
|
// this way; each is classified from the contour's own winding.
|
|
var vertex = End(entity);
|
|
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner))
|
|
{
|
|
switch (corner.Kind)
|
|
{
|
|
case ContourCuttingStrategy.CornerKind.Convex:
|
|
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
|
|
break;
|
|
case ContourCuttingStrategy.CornerKind.Smooth
|
|
when entity is Line && Next(i) is Arc:
|
|
// A line leaving into an arc: the tangent joint. The reverse travel
|
|
// order classifies the same joint from the arc, matched below.
|
|
found.Add(Joint(i, vertex, (i + 1) % count));
|
|
break;
|
|
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
|
// of a plain straight edge are not preferred automatic starts here.
|
|
case ContourCuttingStrategy.CornerKind.Smooth
|
|
when entity is Arc && Next(i) is Line:
|
|
found.Add(Joint(i, vertex, i));
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (entity is Line line)
|
|
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
|
|
}
|
|
return found;
|
|
|
|
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
|
|
|
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
|
|
FilletReach(shape, arcOrdinal) is { } reach
|
|
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
|
|
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
|
|
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
|
|
/// than a half circle the convex way, and reaching back along each edge no further than the
|
|
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
|
|
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
|
|
/// </summary>
|
|
private static double? FilletReach(Shape shape, int arcOrdinal)
|
|
{
|
|
var count = shape.Entities.Count;
|
|
if (shape.Entities[arcOrdinal] is not Arc arc
|
|
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|
|
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
|
|
return null;
|
|
var sweep = arc.SweepAngle();
|
|
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
|
|
return null;
|
|
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
|
|
return null;
|
|
var winding = ContourCuttingStrategy.DetermineWinding(shape);
|
|
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
|
|
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
|
|
return null;
|
|
var reach = arc.Radius * PortableMath.Tan(sweep / 2);
|
|
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
|
|
|
|
bool IsSmooth(Vector vertex, Entity entity) =>
|
|
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
|
|
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
|
|
}
|
|
|
|
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
|
public Program Emit(IReadOnlyList<ContourChoice> choices)
|
|
{
|
|
Validate(choices, true);
|
|
return EmitOwned(choices, complete: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Diagnostic seam: emit exactly ONE owned contour — with its normal lead-in and
|
|
/// lead-out and its ORIGINAL contour type (the perimeter keeps External even when no
|
|
/// holes precede it) — so a candidate's emitted leads can be validated before the hole
|
|
/// choices exist. The result is a throwaway probe, not a plan: it must never be
|
|
/// installed on a Part or accepted as complete output. Normal <see cref="Emit"/> and
|
|
/// <see cref="EmitPrefix"/> keep the perimeter-last rule untouched.
|
|
/// </summary>
|
|
internal Program EmitCandidateForValidation(ContourChoice choice)
|
|
{
|
|
if (choice == null || !ReferenceEquals(choice.Owner, this))
|
|
throw new ArgumentException("Foreign contour choice.");
|
|
ValidateChoice(choice);
|
|
// Same owned clones as a real emission; the source shapes/settings are never used
|
|
// directly, so the probe cannot drift the preparation or mutate it. Isolated emission
|
|
// reads only the chosen contour and the contour count, so only that contour is cloned.
|
|
var owned = new Shape[shapes.Length];
|
|
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
|
|
return new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitCandidateIsolated(
|
|
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal, commonLine);
|
|
}
|
|
|
|
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
|
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
|
|
{
|
|
Validate(choices, false);
|
|
return EmitOwned(choices, complete: false);
|
|
}
|
|
|
|
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
|
|
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
|
|
new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitPrepared(
|
|
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
|
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
|
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
|
|
complete ? (Shape)displayOutline?.Clone() : null, commonLine, slugSlices);
|
|
|
|
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
|
|
public int BreakCutCount => breakCuts.Count;
|
|
|
|
/// <summary>Tabbed cutouts this preparation cuts, as pieces, after its break cuts and before its contours.</summary>
|
|
public int TabbedCutoutCount => tabbedCutouts.Count;
|
|
|
|
/// <summary>Open pieces the tabbed cutouts are cut in, each a lead-in and cut group of its own.</summary>
|
|
public int TabbedPieceCount => tabbedCutouts.Sum(c => ContourCuttingStrategy.TabbedCutoutPieces(c).Count);
|
|
|
|
internal void ValidateCompleteChoices(IReadOnlyList<ContourChoice> choices) => Validate(choices, true);
|
|
|
|
// Build expected-emission metadata independently of the selected payload BEFORE
|
|
// replay. Final verification only reads this immutable execution and clean geometry.
|
|
internal SelectedContourProgram CaptureSelectedProgram(IReadOnlyList<ContourChoice> choices,
|
|
Vector location, CancellationToken token)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
|
// Only the outside is tabbed, with its own pin's tab when it is pinned. A common-line
|
|
// member's shared spans are accounted as the material's tabs, never as a tab gap.
|
|
if (!hasCutPerimeter || commonLine != null)
|
|
return new(this, choices, expected, 0);
|
|
var perimeter = ParametersFor(PerimeterOrdinal);
|
|
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
|
|
}
|
|
|
|
private void Validate(IReadOnlyList<ContourChoice> 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<int>();
|
|
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.");
|
|
// A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
|
|
if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
|
|
throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
|
|
// A common-line perimeter is cut from its one fixed entry.
|
|
if (commonLineEntry != null && choice.ContourOrdinal == PerimeterOrdinal
|
|
&& (choice.EntityOrdinal != commonLineEntry.EntityOrdinal || choice.Point != commonLineEntry.Point
|
|
|| choice.Pierce != commonLineEntry.Pierce))
|
|
throw new ArgumentException("A common-line member's perimeter is cut from its fixed entry.");
|
|
if (choice.Pierce is { } pierce)
|
|
{
|
|
PostVerificationGeometry.Validate(pierce);
|
|
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
|
|
throw new ArgumentException("A planner-placed pierce must lie off its contour.");
|
|
}
|
|
}
|
|
|
|
private ContourChoice FixedCommonLineEntry(int contourOrdinal) =>
|
|
commonLineEntry != null && contourOrdinal == PerimeterOrdinal ? commonLineEntry : null;
|
|
|
|
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
|
|
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
|
|
|
|
/// <summary>
|
|
/// Representative POINTS per contour ordinal (bounding-box center) for hole routing
|
|
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
|
|
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
|
|
/// </summary>
|
|
internal IReadOnlyList<Vector?> HoleCenters(CancellationToken token = default)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var centers = new Vector?[Count];
|
|
for (var contour = 0; contour < PerimeterOrdinal; contour++)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
centers[contour] = GetShape(contour).BoundingBox.Center;
|
|
}
|
|
return centers;
|
|
}
|
|
|
|
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
|
|
internal Box ContourBounds(int contourOrdinal) => GetShape(contourOrdinal).BoundingBox;
|
|
|
|
/// <summary>
|
|
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
|
|
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
|
|
/// </summary>
|
|
internal bool HoleContains(int contourOrdinal, Vector point)
|
|
{
|
|
var shape = GetShape(contourOrdinal);
|
|
return contourOrdinal != PerimeterOrdinal && shape.BoundingBox.Contains(point)
|
|
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emission splits a contour at its entry with <see cref="Arc.SplitAt"/>/<see cref="Line.SplitAt"/>,
|
|
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
|
|
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
|
|
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
|
|
/// stored center sits beside the chord's midpoint) is never cut.
|
|
/// </summary>
|
|
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
|
|
{
|
|
if (entityOrdinal < 0 || entityOrdinal >= shape.Entities.Count
|
|
|| shape.Entities[entityOrdinal] is not (Line or Arc))
|
|
return point;
|
|
var entity = shape.Entities[entityOrdinal];
|
|
// Within the verifier's epsilon the entry already cuts the contour whole: leave it, so
|
|
// programs emitted before this snap (saved pinned entries) re-emit unchanged.
|
|
bool Snaps(Vector vertex)
|
|
{
|
|
var distance = point.DistanceTo(vertex);
|
|
return distance > PostVerificationGeometry.Epsilon && distance < OpenNest.Math.Tolerance.Epsilon;
|
|
}
|
|
var start = Start(entity);
|
|
if (point.DistanceTo(start) < OpenNest.Math.Tolerance.Epsilon)
|
|
return Snaps(start) ? start : point;
|
|
var end = End(entity);
|
|
return Snaps(end) ? end : point;
|
|
}
|
|
|
|
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<Program, Program>(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;
|
|
}
|
|
}
|
|
|
|
}
|