mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 13:51:06 -04:00
Strip lines now stop at a full-length horizontal cut-off that clears the parts instead of slicing the band beyond it. The desktop dialog and Plan Cutting also propose their own horizontal separator when that band beats the X tail (opt-in for API callers via KeepHorizontalRemnant); a kept X tail beside the band becomes a corner piece whose separator stops there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
728 lines
38 KiB
C#
728 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.EdgeSpacing.Bottom && y <= zone.Bounds.Top + plate.EdgeSpacing.Bottom);
|
|
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 bottom-edge 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.EdgeSpacing.Bottom && x <= zone.Bounds.Right + plate.EdgeSpacing.Bottom;
|
|
|
|
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));
|
|
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
|
|
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
|
|
Nonnegative(plate.EdgeSpacing.Bottom)),
|
|
"Clamp zones and bottom-edge 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);
|
|
}
|
|
}
|