Files
OpenNest/OpenNest.Core/AutomaticCutOffPlanner.cs
T
ajandClaude Opus 5.5 0719476618 feat(clamps): give clamps their own clearance from parts
A clamp rail can now carry its own Clearance, the gap parts keep from
every clamp footprint, independent of part spacing. Unset, it keeps the
sheet's bottom edge spacing as before. Clamp blocks drawn under the
sheet can use close to zero so the laser only stays off them.

Plate.ClampClearance and NestPlateStock.ClampClearance resolve it once;
the engines, interactive fill/pack/push, the layout check, Expander walls
and automatic cut-off placement all read it. The interactive push now
holds clamps at their square clearance keep-out (the layout check's
shape) instead of the part spacing, so parts no longer tuck into its
corners; the clamped fill characterization digests are re-pinned after
checking the new layouts validate.

Persisted as an optional `clearance` on the .nest clamp rail and the
machine clamp settings; edited in Nest > Clamps and the machine's
Clamps tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:25:27 -04:00

730 lines
38 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest;
public sealed class AutomaticCutOffOptions
{
/// <summary>
/// Nominal distance between vertical cut lines in model units. Must be finite and
/// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count.
/// </summary>
public double Spacing { get; set; }
/// <summary>
/// Minimum retained-tail length along X in model units. Zero permits any positive tail.
/// The desktop default is 12 inches (304.8 mm).
/// </summary>
public double MinimumTailLength { get; set; }
/// <summary>
/// Minimum strip length in model units. Null or equal to Spacing preserves the fixed grid.
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
/// </summary>
public double? MinimumStripLength { get; set; }
/// <summary>
/// Whether to propose a full-length horizontal separator that keeps the unused band beyond the
/// parts along Y (away from the origin) when that band is a larger piece than the X tail.
/// The band must be at least MinimumTailLength wide. False preserves vertical-only proposals.
/// An existing full-length horizontal cut-off clear of the parts is honored either way.
/// </summary>
public bool KeepHorizontalRemnant { get; set; }
}
public enum AutomaticCutOffDiagnosticCode
{
ExistingCutOff,
LimitedCutOffConflict,
EmptyCut,
SegmentedCut,
ClampBlockedCut,
NoSafeTailSeparator,
TailBelowMinimum,
ShiftedCut,
HorizontalRemnant,
NoSafeHorizontalSeparator,
}
public sealed record AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode Code, string Message, bool IsBlocking = false, double? X = null);
/// <summary>
/// Detached proposal. Collections are read-only; contained definitions/preview parts belong
/// to the caller. Never accept a blocked plan or a preview made for an older layout/settings.
/// </summary>
public sealed class AutomaticCutOffPlan
{
/// <summary>Only new, usable definitions; existing equivalent definitions are not returned.</summary>
public IReadOnlyList<CutOff> Definitions { get; internal set; } = Array.Empty<CutOff>();
/// <summary>Detached display parts, in the same order as Definitions. Not for acceptance.</summary>
public IReadOnlyList<Part> PreviewParts { get; internal set; } = Array.Empty<Part>();
public IReadOnlyList<AutomaticCutOffDiagnostic> Diagnostics { get; internal set; } =
Array.Empty<AutomaticCutOffDiagnostic>();
/// <summary>Furthest real-part X distance from the origin, excluding cut-off parts.</summary>
public double OccupiedSpan { get; internal set; }
/// <summary>
/// Distance to the verified separator, or full sheet length when no separated tail can
/// be claimed. Zero on empty sheets. This is not a disconnected-scrap-size guarantee.
/// </summary>
public double UsedSpan { get; internal set; }
/// <summary>Length beyond a verified separator, otherwise zero (also on empty sheets).</summary>
public double TailLength { get; internal set; }
/// <summary>Signed X coordinate of a verified new or equivalent existing separator.</summary>
public double? TailSeparatorX { get; internal set; }
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
public bool HasSeparatedTail => TailSeparatorX.HasValue;
/// <summary>
/// Signed Y coordinate of the new or existing full-length horizontal separator the vertical
/// lines stop at, keeping the band beyond it; null when the lines cross the full sheet width.
/// </summary>
public double? HorizontalSeparatorY { get; internal set; }
/// <summary>Width along Y of the band kept beyond HorizontalSeparatorY, otherwise zero.</summary>
public double HorizontalRemnantWidth { get; internal set; }
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
public double StripCutLength { get; internal set; }
/// <summary>Length of the nominal new strip lines in the same gaps. Equal to StripCutLength on the fixed-grid path.</summary>
public double FixedGridCutLength { get; internal set; }
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
}
/// <summary>Pure proposals for vertical scrap cuts, measured in the plate's model units.</summary>
public static class AutomaticCutOffPlanner
{
public const double GeometryTolerance = Tolerance.Epsilon;
/// <summary>
/// How far a display perimeter (a supplied blank's never-cut outline) may extend past the
/// physical sheet, in model units. Imported blanks equal their sheet but carry the source CAM's
/// rounding (PEP: a 0.000006 outline skew plus placements rounded to 0.001), a few hundred-
/// thousandths of an inch. Nothing cuts that outline, so it is accepted; anything cut, or a
/// larger overhang that signals a real placement error, is still refused.
/// </summary>
public const double DisplayPerimeterSheetAllowance = 1e-4;
public const double MinimumSpacing = 2 * GeometryTolerance;
public const int MaximumCandidateCount = 10000;
public const int MaximumShiftCandidateCount = 20000;
/// <summary>
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
/// Invalid inputs throw ArgumentException. Blocking diagnostics return no definitions.
/// Accept by adding Definitions to Plate.CutOffs and calling RegenerateCutOffs with the
/// same settings, only while the layout is unchanged. Do not add PreviewParts to the plate.
/// Nominal spacing is not a guarantee of fully disconnected, hopper-sized scrap.
/// </summary>
public static AutomaticCutOffPlan Create(Plate plate, AutomaticCutOffOptions options,
CutOffSettings settings)
{
ArgumentNullException.ThrowIfNull(plate);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(settings);
ValidateInputs(plate, options, settings);
// Stored zones use the sheet's lower-left frame; cut positions are signed.
var clamps = ClampZoneFrame.ToPlacement(plate);
var bounds = plate.BoundingBox(false);
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
var ySign = plate.Quadrant is 3 or 4 ? -1 : 1;
var occupied = 0.0;
var occupiedY = 0.0;
var hasParts = false;
foreach (var part in plate.Parts)
{
Require(part?.BaseDrawing != null, "Every part must have a drawing.", nameof(plate));
if (part.BaseDrawing.IsCutOff)
continue;
var (partX, partY) = ValidatePart(part, bounds, sign, ySign);
occupied = System.Math.Max(occupied, partX);
occupiedY = System.Math.Max(occupiedY, partY);
hasParts = true;
}
if (!hasParts)
return new AutomaticCutOffPlan();
// Use distances from the coordinate origin, not the sheet's lower-left corner.
var length = plate.Size.Length;
var width = plate.Size.Width;
occupied = System.Math.Min(occupied, length);
occupiedY = System.Math.Min(occupiedY, width);
var margin = System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance;
var separator = occupied + margin;
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
var diagnostics = new List<AutomaticCutOffDiagnostic>();
// A band beyond the parts along Y is kept by a full-length horizontal separator; the
// vertical lines then stop at it instead of slicing the band. A user's line clear of the
// parts is honored; a new one is proposed only when its band beats the X tail.
var horizontalDistance = plate.CutOffs
.Where(c => c?.Axis == CutOffAxis.Horizontal && Finite(c.Position) && FullLengthLimits(c, bounds, settings))
.Select(c => ySign * c.Position.Y)
.Where(distance => distance > occupiedY + GeometryTolerance && distance < width - GeometryTolerance
&& width - distance >= options.MinimumTailLength)
.Select(distance => (double?)distance)
.Min();
var horizontalExists = horizontalDistance.HasValue;
if (!horizontalExists && options.KeepHorizontalRemnant)
{
var proposed = occupiedY + margin;
var tailArea = hasTailCandidate && length - separator >= options.MinimumTailLength
? (length - separator) * width : 0;
if (double.IsFinite(proposed) && proposed < width - GeometryTolerance
&& width - proposed >= options.MinimumTailLength && (width - proposed) * length > tailArea)
horizontalDistance = proposed;
}
if (horizontalDistance.HasValue && !horizontalExists)
{
var y = ySign * horizontalDistance.Value;
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
var clamp = clamps.FirstOrDefault(zone =>
y >= zone.Bounds.Bottom - plate.ClampClearance && y <= zone.Bounds.Top + plate.ClampClearance);
if (limited || clamp != null)
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
(clamp != null ? $"A horizontal separator at Y = {y:G8} would cross clamp '{clamp.Id}'"
: $"A same-line manual cut at Y = {y:G8} has different limits")
+ "; vertical lines cross the full sheet width instead."));
horizontalDistance = null;
}
}
// A tail too short to keep stays attached to the skeleton, so the strip grid must
// continue to the sheet end; stopping at the skipped separator would leave a final
// strip longer than the spacing. Beside a kept horizontal band the tail is a corner
// piece, which must also be wide enough to keep.
bool KeepsTail(double? band) => hasTailCandidate && length - separator >= options.MinimumTailLength
&& (band == null || band.Value >= options.MinimumTailLength);
var keepsTail = KeepsTail(horizontalDistance);
var usedSpan = keepsTail ? separator : length;
// Bound the count for either outcome: the horizontal separator is verified only below.
var candidateCount = System.Math.Ceiling(System.Math.Max(usedSpan, KeepsTail(null) ? separator : length)
/ options.Spacing);
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
// Validate and bound the candidate count before preparing geometry or allocating lines.
ValidateExisting(plate, bounds, settings);
var cache = Plate.BuildPerimeterCache(plate);
var definitions = new List<CutOff>();
CutOff horizontalSeparator = null;
if (horizontalDistance.HasValue && !horizontalExists)
{
horizontalSeparator = new CutOff(new Vector(0, ySign * horizontalDistance.Value), CutOffAxis.Horizontal);
horizontalSeparator.Regenerate(plate, settings, cache);
if (!HasUsableSegments(horizontalSeparator.Drawing.Program, false) ||
!IsFullSpanProgram(horizontalSeparator.Drawing.Program, false, bounds.Left, bounds.Right))
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
"No full-length horizontal separator survives the current settings; "
+ "vertical lines cross the full sheet width instead."));
horizontalSeparator = null;
horizontalDistance = null;
keepsTail = KeepsTail(null);
usedSpan = keepsTail ? separator : length;
candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
}
}
// Vertical lines run from the origin-side edge up to a kept band's separator.
var horizontalY = horizontalDistance.HasValue ? ySign * horizontalDistance.Value : (double?)null;
var startLimit = ySign < 0 ? horizontalY : null;
var endLimit = ySign > 0 ? horizontalY : null;
var spanLow = startLimit ?? bounds.Bottom;
var spanHigh = endLimit ?? bounds.Top;
var separated = false;
var separatorX = (double?)null;
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
var stripCutLength = 0.0;
var fixedGridCutLength = 0.0;
var measurements = new Dictionary<double, (double Length, int SegmentCount)>();
var candidates = new HashSet<double>();
if (shifted)
{
// Full-span definitions fix the strip boundaries, even when their emitted
// programs are segmented. Partial cuts remain obstacles, not boundaries.
var boundaries = plate.CutOffs
.Where(c => c.Axis == CutOffAxis.Vertical && Spans(c))
.Select(c => sign * c.Position.X)
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
.Distinct().OrderBy(distance => distance).Append(usedSpan);
var gaps = new List<(double Start, double End, int Count)>();
var start = 0.0;
var nominalCount = 0;
foreach (var end in boundaries)
{
var count = 0;
for (var index = 1; index <= (int)candidateCount; index++)
{
if (start + index * options.Spacing >= end - GeometryTolerance)
break;
count++;
}
gaps.Add((start, end, count));
nominalCount += count;
start = end;
}
// The origin-based lattice alone fits within 20,000 positions, but its
// off-lattice nominal lines need reserved slots too. Budget both before
// constructing any row so a valid, dense grid is never refused halfway.
Require(nominalCount < MaximumShiftCandidateCount,
"Too many nominal strip lines for the shift search.", nameof(options));
var step = System.Math.Max(options.Spacing / 700,
usedSpan / (MaximumShiftCandidateCount - nominalCount));
foreach (var gap in gaps)
AddShiftedGap(gap.Start, gap.End, gap.Count, step);
}
else
{
// Keep the legacy loop and certification unchanged when shifting is disabled.
for (var index = 1; index <= (int)candidateCount; index++)
{
var distance = index * options.Spacing;
if (distance >= usedSpan - GeometryTolerance)
break;
AddCandidate(sign * distance, false);
}
}
if (keepsTail)
AddCandidate(sign * separator, true);
else if (hasTailCandidate)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
"The proposed tail is shorter than the minimum tail length; the final separator was skipped "
+ "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.",
X: sign * separator));
if (horizontalSeparator != null)
definitions.Add(horizontalSeparator);
if (horizontalY.HasValue)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.HorizontalRemnant,
$"Vertical lines stop at the {(horizontalExists ? "existing" : "new")} horizontal separator at "
+ $"Y = {horizontalY.Value:G8}, keeping a {width - horizontalDistance.Value:G8}-wide band "
+ "along the sheet length."));
if (diagnostics.Any(d => d.IsBlocking))
{
// A partial proposal must not accidentally be accepted around a manual conflict.
definitions.Clear();
stripCutLength = 0;
separated = false;
separatorX = null;
horizontalY = null;
}
// The legacy placement loop stays untouched. Its detached programs are already
// regenerated, so reporting their actual new strip cutting needs no new search.
if (!shifted)
fixedGridCutLength = stripCutLength;
// Lines are planned and measured without the head-locked overcut: it only extends the
// segment reaching the far edge past the sheet, where there is nothing to separate.
if (settings.NewCutOffHeadLockOvercut > 0)
{
foreach (var definition in definitions)
{
definition.HeadLockOvercut = settings.NewCutOffHeadLockOvercut;
definition.Regenerate(plate, settings, cache);
}
}
return new AutomaticCutOffPlan
{
Definitions = definitions.AsReadOnly(),
PreviewParts = definitions.Select(c => new Part(c.Drawing)).ToList().AsReadOnly(),
Diagnostics = diagnostics.AsReadOnly(),
OccupiedSpan = occupied,
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
TailSeparatorX = separatorX,
StripCutLength = stripCutLength,
FixedGridCutLength = fixedGridCutLength,
HorizontalSeparatorY = horizontalY,
HorizontalRemnantWidth = horizontalY.HasValue ? width - horizontalDistance.Value : 0,
};
CutOff NewVertical(double x) => new(new Vector(x, 0), CutOffAxis.Vertical)
{
StartLimit = startLimit,
EndLimit = endLimit,
};
// A full-span line, or one with the limits this plan gives its own vertical lines.
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
horizontalY.HasValue &&
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
void AddShiftedGap(double start, double end, int count, double step)
{
var nominal = new List<double>(count);
for (var index = 1; index <= count; index++)
{
var distance = start + index * options.Spacing;
nominal.Add(distance);
fixedGridCutLength += Measure(distance).Length;
}
if (count == 0)
return;
var positions = AutomaticCutOffStripSearch.FindPositions(start, end, count,
options.Spacing, options.MinimumStripLength.Value, step, Allowed, Measure) ?? nominal.ToArray();
for (var index = 0; index < positions.Length; index++)
{
var distance = positions[index];
AddCandidate(sign * distance, false);
if (!Near(distance, nominal[index]))
{
var difference = Measure(nominal[index]).Length - Measure(distance).Length;
var comparison = difference > 0 ? $"{difference:G8} model units less" :
difference < 0 ? $"{-difference:G8} model units more" : "the same length";
diagnostics.Add(new AutomaticCutOffDiagnostic(AutomaticCutOffDiagnosticCode.ShiftedCut,
$"Moved from {nominal[index]:G8} to {distance:G8} model units; "
+ $"this line cuts {comparison}.", X: sign * distance));
}
}
}
bool Allowed(double distance)
{
RegisterCandidate(distance);
// Use the certification path's exact Near predicate, including its ULP
// allowance, so a selected line cannot rediscover a limited-cut conflict.
var x = sign * distance;
return !plate.CutOffs.Any(c => c.Axis == CutOffAxis.Vertical && Near(c.Position.X, x)) &&
!clamps.Any(zone => CrossesClamp(zone, x));
}
void RegisterCandidate(double distance)
{
if (candidates.Add(distance))
Require(candidates.Count <= MaximumShiftCandidateCount,
$"Shift search would generate more than {MaximumShiftCandidateCount} distinct positions. "
+ "Increase spacing or the minimum strip length, or use the fixed grid.", nameof(options));
}
(double Length, int SegmentCount) Measure(double distance)
{
RegisterCandidate(distance);
if (!measurements.TryGetValue(distance, out var measured))
{
measured = NewVertical(sign * distance).MeasureSegments(plate, settings, cache);
measurements.Add(distance, measured);
}
return measured;
}
void AddCandidate(double x, bool isSeparator)
{
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
Near(c.Position.X, x)).ToList();
if (matches.Any(c => !Spans(c)))
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
"A same-line manual cut has different limits. Manual review is required; no cuts may be applied.",
true, x));
if (isSeparator)
WarnNoSeparator(x);
return;
}
// A full-width cut crosses every active front-edge clamp at its X. Test the
// proposed X and every equivalent existing definition, because a tolerance-close
// existing line is regenerated and certified at its own X. Keep the whole
// footprint when it hangs off the sheet. A same-line manual limit conflict above
// still blocks the whole plan, clamp or not.
var blocked = clamps.FirstOrDefault(zone =>
matches.Select(c => c.Position.X).Prepend(x).Any(position => CrossesClamp(zone, position)));
if (blocked != null)
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.ClampBlockedCut,
$"The proposed cut crosses clamp '{blocked.Id}' (including its clearance); it was skipped.",
X: x));
if (isSeparator)
WarnNoSeparator(x);
return;
}
// Even a duplicate must be regenerated detached with CURRENT settings. Its live
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
var existing = matches.FirstOrDefault();
var candidate = existing == null
? NewVertical(x)
: new CutOff(existing.Position, existing.Axis)
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
candidate.Regenerate(plate, settings, cache);
var program = candidate.Drawing.Program;
var usable = HasUsableSegments(program);
if (existing != null)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.ExistingCutOff,
"An equivalent full-span cut-off already exists; no duplicate was added.", X: x));
if (!usable)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.EmptyCut,
"The line has no usable cut segments after part clearance and minimum-length filtering.",
X: x));
if (isSeparator)
{
if (!usable || !IsFullSpanProgram(program, true, spanLow, spanHigh))
{
WarnNoSeparator(x);
return;
}
separated = true;
separatorX = candidate.Position.X;
}
else if (usable && !IsFullSpanProgram(program, true, spanLow, spanHigh))
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.SegmentedCut,
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
X: x));
if (usable && existing == null)
{
definitions.Add(candidate);
if (!isSeparator)
{
if (shifted)
stripCutLength += Measure(System.Math.Abs(x)).Length;
else
for (var index = 0; index < program.Codes.Count; index += 2)
stripCutLength += System.Math.Abs(
((LinearMove)program.Codes[index + 1]).EndPoint.Y -
((RapidMove)program.Codes[index]).EndPoint.Y);
}
}
}
// Share the exact placement-frame predicate with candidate exclusion and final
// certification. Infeasible gaps still use nominal lines through AddCandidate.
bool CrossesClamp(ClampZone zone, double x) =>
x >= zone.Bounds.Left - plate.ClampClearance && x <= zone.Bounds.Right + plate.ClampClearance;
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
"No safe full-width tail separator survives the current settings. Review the tail manually.",
X: x));
}
private static void ValidateInputs(Plate plate, AutomaticCutOffOptions options, CutOffSettings settings)
{
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
Require(!options.MinimumStripLength.HasValue ||
(double.IsFinite(options.MinimumStripLength.Value) && options.MinimumStripLength.Value > MinimumSpacing &&
options.MinimumStripLength.Value <= options.Spacing),
$"Minimum strip length must be finite, greater than {MinimumSpacing} and no greater than spacing.", nameof(options));
Require(Nonnegative(options.MinimumTailLength),
"Minimum tail length must be finite and nonnegative.", nameof(options));
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
"Sheet length and width must be positive and finite.", nameof(plate));
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
// Strips are planned across a rectangle's known span; an outlined sheet gets manual cut-offs only.
Require(plate.Outline == null, "Automatic cut-offs need a rectangular sheet; this plate has an outline.", nameof(plate));
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
Nonnegative(plate.ClampClearance)),
"Clamp zones and clamp clearance must be finite and valid.", nameof(plate));
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
"A head-locked overcut must be finite and positive.", nameof(settings));
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
Require(plate.Parts != null && plate.CutOffs != null,
"Plate parts and cut-off collections are required.", nameof(plate));
}
private static (double X, double Y) ValidatePart(Part part, Box sheet, int sign, int ySign)
{
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
"Part pose must be finite.", "plate");
ValidateProgram(part.Program, new HashSet<Program>());
var box = part.Program.BoundingBox();
box.Offset(part.Location);
Require(ValidBox(box) && box.Length > 0 && box.Width > 0,
"Real parts must have finite, nonempty geometry.", "plate");
var cached = part.BoundingBox;
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
"Part geometry has stale bounds; update it before planning.", "plate");
var hasDisplayPerimeter = DisplayPerimeterTail.HasDisplayPerimeterMotion(part.Program);
Require(Inside(box, sheet, hasDisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
"Part geometry extends outside the physical sheet.", "plate");
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
// Checking converted entities catches overflowing incremental moves and curve bounds.
var hasMaterial = false;
var occupied = sign > 0 ? box.Right : -box.Left;
var occupiedY = ySign > 0 ? box.Top : -box.Bottom;
var roundoff = CutOff.GetBoundsRoundoff(part);
var entities = ConvertProgram.ToGeometry(part.Program);
var outline = entities.Where(e => e.Layer == SpecialLayers.DisplayPerimeter).ToList();
foreach (var entity in entities)
{
var entityBox = entity.BoundingBox;
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
if (!SpecialLayers.IsMaterial(entity.Layer))
{
// Travel and marks (a scribe is lasered) keep the ordinary tolerance. Only a rapid
// ending on the outline (its first corner, or an applied program's display tail)
// shares the outline's allowance.
Require(Inside(entityBox.Translate(part.Location), sheet)
|| hasDisplayPerimeter && IsRapidOntoOutline(entity, outline, part.Location, sheet),
"Part geometry extends outside the physical sheet.", "plate");
continue;
}
entityBox = entityBox.Translate(part.Location);
// Permit only bounded floating-point roundoff, with the same conservative
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
// (including refitted arc centers) are still rejected, even below epsilon.
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
Require(Inside(entityBox, sheet,
entity.Layer == SpecialLayers.DisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
"Part geometry extends outside the physical sheet.", "plate");
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
occupiedY = System.Math.Max(occupiedY, ySign > 0 ? entityBox.Top : -entityBox.Bottom);
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
}
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
return (occupied, occupiedY);
}
private static void ValidateProgram(Program program, HashSet<Program> path)
{
Require(program?.Codes != null && path.Count < 64 && path.Add(program),
"Part program is missing, recursive, or nested too deeply.", "plate");
foreach (var code in program.Codes)
{
Require(code != null, "Part program contains a missing instruction.", "plate");
if (code is Motion motion)
Require(Finite(motion.EndPoint), "Part motion coordinates must be finite.", "plate");
if (code is ArcMove arc)
Require(Finite(arc.CenterPoint) && Enum.IsDefined(arc.Rotation),
"Part arc geometry must be finite with a valid direction.", "plate");
if (code is SubProgramCall call)
{
Require(Finite(call.Offset) && double.IsFinite(call.Rotation),
"Part sub-program pose must be finite.", "plate");
ValidateProgram(call.Program, path);
}
}
path.Remove(program);
}
private static void ValidateExisting(Plate plate, Box bounds, CutOffSettings settings)
{
foreach (var cut in plate.CutOffs)
{
Require(cut != null && Enum.IsDefined(cut.Axis) && Finite(cut.Position) &&
(!cut.StartLimit.HasValue || double.IsFinite(cut.StartLimit.Value)) &&
(!cut.EndLimit.HasValue || double.IsFinite(cut.EndLimit.Value)),
"Existing cut-off definitions must be finite with a valid axis.", nameof(plate));
var start = cut.StartLimit ?? (cut.Axis == CutOffAxis.Vertical ? bounds.Bottom : bounds.Left);
var end = cut.EndLimit ?? ((cut.Axis == CutOffAxis.Vertical ? bounds.Top : bounds.Right)
+ settings.Overtravel);
Require(double.IsFinite(end) && start < end,
"Existing cut-off limits must be finite and ordered.", nameof(plate));
}
}
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
private static bool HasUsableSegments(Program program, bool vertical = true)
{
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
return false;
for (var i = 0; i < program.Codes.Count; i += 2)
{
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
(vertical ? !Near(from.EndPoint.X, to.EndPoint.X) : !Near(from.EndPoint.Y, to.EndPoint.Y)) ||
(vertical ? System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y)
: System.Math.Abs(from.EndPoint.X - to.EndPoint.X)) <= GeometryTolerance)
return false;
}
return true;
}
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
private static bool IsFullSpanProgram(Program program, bool vertical, double low, double high)
{
// No gaps, bridges, or filtered middle segments can separate the tail.
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
program.Codes[1] is not LinearMove to)
return false;
var start = vertical ? from.EndPoint.Y : from.EndPoint.X;
var end = vertical ? to.EndPoint.Y : to.EndPoint.X;
return System.Math.Min(start, end) <= low + GeometryTolerance &&
System.Math.Max(start, end) >= high - GeometryTolerance;
}
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
Nonnegative(box.Length) && Nonnegative(box.Width) &&
double.IsFinite(box.Right) && double.IsFinite(box.Top);
// A rapid that leaves the sheet only at an end lying on the display perimeter, within its allowance.
private static bool IsRapidOntoOutline(Entity entity, List<Entity> outline, Vector location, Box sheet)
{
if (entity.Layer != SpecialLayers.Rapid || entity is not Line rapid
|| !Inside(rapid.BoundingBox.Translate(location), sheet, DisplayPerimeterSheetAllowance))
return false;
bool Allowed(Vector point) =>
Inside(new Box(point.X + location.X, point.Y + location.Y, 0, 0), sheet)
|| outline.Any(e => e.ClosestPointTo(point).DistanceTo(point) <= GeometryTolerance);
return Allowed(rapid.StartPoint) && Allowed(rapid.EndPoint);
}
private static bool Inside(Box box, Box sheet, double tolerance = GeometryTolerance) =>
box.Left >= sheet.Left - tolerance && box.Right <= sheet.Right + tolerance &&
box.Bottom >= sheet.Bottom - tolerance && box.Top <= sheet.Top + tolerance;
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
private static bool Near(double a, double b)
{
// An exact tolerance-sized offset can round just above epsilon after subtraction.
// Allow two representational steps, not a geometry-scale relative tolerance.
var magnitude = System.Math.Max(System.Math.Abs(a), System.Math.Abs(b));
var step = System.Math.BitIncrement(magnitude) - magnitude;
return System.Math.Abs(a - b) <= GeometryTolerance + (double.IsFinite(step) ? 2 * step : 0);
}
private static void Require(bool valid, string message, string parameter)
{
if (!valid)
throw new ArgumentException(message, parameter);
}
}