Files
OpenNest/OpenNest.Core/CutOffTrim.cs
T
ajandClaude Opus 5.5 4028dafb26 feat(cutoffs): trim a middle piece into a gap in the line
Trim refused the piece between two crossing cut-offs because a cut-off had
one start and one end, so a line could not be stopped short around a pocket
of parts without deleting it and drawing two. A cut-off now carries Gaps,
stretches between its limits left uncut: segment generation excludes them
like parts, so the line splits into segments that each take their own place
in the cut sequence. Trimming a middle piece adds a gap from one crossing
centre to the other (merged with any it touches), Extend near a gap's edge
closes it, and Swap Through-Cut refuses a junction a gap touches rather
than guess which side of the gap should run through.

Every reader of the limits treats a gap as uncut, so nothing waits for,
warns about or credits a cut that never happens: segment spans stop at a
gap, the cutting dependencies skip a part inside one, the clamp check
ignores a clamp inside one, the cutting-state and overlap stamps go stale
when gaps change, and a gapped line is never full span for the expected
remnant or automatic cut-offs. Gaps persist as an optional gaps array;
older versions ignore it and would cut straight through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:59:46 -04:00

413 lines
21 KiB
C#

using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>
/// The limits and gaps one cut-off takes when a <see cref="CutOffEdit"/> is applied; null
/// <paramref name="Gaps"/> keeps the cut-off's gaps.
/// </summary>
public sealed record CutOffLimitChange(CutOff CutOff, double? StartLimit, double? EndLimit,
IReadOnlyList<CutOffGap> Gaps = null)
{
/// <summary>The gaps the cut-off has once the change is applied.</summary>
public IReadOnlyList<CutOffGap> NewGaps => Gaps ?? CutOff.Gaps;
}
/// <summary>
/// A worked-out trim, extend or swap: the new limits and gaps of each cut-off it changes, or
/// why it was refused. Nothing changes until <see cref="Apply"/>; the caller then regenerates
/// the plate's cut-offs and places their segments in the cutting order.
/// </summary>
public sealed class CutOffEdit
{
private CutOffEdit(IReadOnlyList<CutOffLimitChange> changes, string refusal, string notice)
{
Changes = changes;
Refusal = refusal;
Notice = notice;
}
public IReadOnlyList<CutOffLimitChange> Changes { get; }
/// <summary>Why the edit cannot be made; null when it can.</summary>
public string Refusal { get; }
/// <summary>Something the operator should know once it is made, such as a head-locked overcut turning off.</summary>
public string Notice { get; }
public bool Succeeded => Refusal == null;
internal static CutOffEdit Refused(string reason) => new(System.Array.Empty<CutOffLimitChange>(), reason, null);
internal static CutOffEdit Of(IReadOnlyList<CutOffLimitChange> changes, string notice) => new(changes, null, notice);
public void Apply()
{
if (!Succeeded)
throw new System.InvalidOperationException(Refusal);
foreach (var change in Changes)
{
change.CutOff.StartLimit = change.StartLimit;
change.CutOff.EndLimit = change.EndLimit;
if (change.Gaps != null)
change.CutOff.Gaps = change.Gaps;
}
}
}
/// <summary>
/// Trims and extends cut-offs against the cut-offs that cross them, by changing only their
/// start and end limits and their gaps (see docs/automatic-scrap-cutoffs.md, "Trim and
/// extend"). A trim stops on the crossing line's centre, inside its kerf; trimming a piece
/// between two crossing lines leaves a gap from one centre to the other. An extend closes the
/// gap at the picked end, or runs the line's end to the next crossing line beyond it, or to
/// the sheet edge, which clears the limit.
/// </summary>
public static class CutOffTrim
{
public const string NotOnPlate = "That cut-off is not on this plate.";
public const string NothingToTrimTo = "No cut-off crosses this line to trim it to; delete it instead.";
public const string InGap = "That stretch of the line is already uncut.";
public const string TooShort = "Trimming here would leave a line shorter than the minimum segment length.";
public const string AtSheetEdge = "This end already runs to the sheet edge.";
public const string NotAJunction = "These cut-offs do not meet with one running through and the other stopping at it.";
public const string GapAtJunction = "A gap in one of these lines meets the junction; extend to close it before swapping.";
public const string OvercutOff = "The head-locked overcut is off while this line stops short of the far edge.";
public const string OvercutOn = "The head-locked overcut is back on at the far edge.";
/// <summary>
/// Removes the piece of <paramref name="target"/> picked at <paramref name="pick"/>, between
/// the crossing cut-offs on either side of it, or a gap or the line's end where there is no
/// crossing on that side. An end piece moves that end's limit; a piece between two crossing
/// lines becomes a gap, merged with any gap it touches.
/// </summary>
public static CutOffEdit Trim(Plate plate, CutOff target, Vector pick, CutOffSettings settings)
{
if (!plate.CutOffs.Contains(target))
return CutOffEdit.Refused(NotOnPlate);
var (start, end) = NominalSpan(plate, target);
var at = System.Math.Clamp(Along(target, pick), start, end);
var pieces = target.Pieces(start, end);
var piece = pieces.Where(p => at >= p.Start - Tolerance.Epsilon && at <= p.End + Tolerance.Epsilon)
.Cast<(double Start, double End)?>().FirstOrDefault();
if (piece is not var (pieceStart, pieceEnd))
return CutOffEdit.Refused(InGap);
var crossings = Crossings(plate, target)
.Where(c => c > pieceStart + Tolerance.Epsilon && c < pieceEnd - Tolerance.Epsilon)
.ToList();
var low = crossings.Where(c => c <= at).DefaultIfEmpty(pieceStart).Max();
var high = crossings.Where(c => c > at).DefaultIfEmpty(pieceEnd).Min();
if (pieces.Count == 1 && low == start && high == end)
return CutOffEdit.Refused(NothingToTrimTo);
var remaining = new List<(double Start, double End)>();
foreach (var (from, to) in pieces)
{
if (from < low)
remaining.Add((from, System.Math.Min(to, low)));
if (to > high)
remaining.Add((System.Math.Max(from, high), to));
}
var minimum = System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon);
if (remaining.Count == 0
|| remaining.Any(p => (p.End == low || p.Start == high) && p.End - p.Start < minimum))
return CutOffEdit.Refused(TooShort);
return Single(plate, Change(target, start, end, remaining));
}
/// <summary>
/// Lengthens <paramref name="target"/> at the end of the piece nearer <paramref name="pick"/>:
/// a gap there closes; the line's own end runs to the next crossing cut-off beyond it, or to
/// the sheet edge when there is none. A cut-off sitting at the current end is skipped, so
/// picking again extends one more step. A pick inside a gap closes that gap.
/// </summary>
public static CutOffEdit Extend(Plate plate, CutOff target, Vector pick)
{
if (!plate.CutOffs.Contains(target))
return CutOffEdit.Refused(NotOnPlate);
var (start, end) = NominalSpan(plate, target);
var at = Along(target, pick);
var gaps = target.Gaps;
var inside = gaps.Where(g => at > g.Start && at < g.End).ToList();
if (inside.Count == 0)
{
var pieces = target.Pieces(start, end);
var piece = pieces.OrderBy(p => Distance(at, p)).First();
var high = System.Math.Abs(at - piece.End) <= System.Math.Abs(at - piece.Start);
var edge = high ? piece.End : piece.Start;
inside = gaps.Where(g => System.Math.Abs((high ? g.Start : g.End) - edge) <= Tolerance.Epsilon).ToList();
if (inside.Count == 0)
{
return ExtendEnd(plate, target, high, out var limit)
? Single(plate, new CutOffLimitChange(target,
high ? target.StartLimit : limit, high ? limit : target.EndLimit))
: CutOffEdit.Refused(AtSheetEdge);
}
}
return Single(plate, new CutOffLimitChange(target, target.StartLimit, target.EndLimit,
gaps.Except(inside).ToList()));
}
/// <summary>
/// Stops a new <paramref name="target"/> at the first crossing cut-off from its start edge,
/// the origin side unless <paramref name="settings"/> cut toward the origin. It stays full
/// length, with no changes, when nothing crosses it or the line left would be shorter than
/// the minimum segment length. <paramref name="target"/> need not be on the plate yet.
/// </summary>
public static CutOffEdit StopAtFirstCrossing(Plate plate, CutOff target, CutOffSettings settings)
{
var (start, end) = NominalSpan(plate, target);
var crossings = Crossings(plate, target)
.Where(c => c > start + Tolerance.Epsilon && c < end - Tolerance.Epsilon)
.ToList();
if (crossings.Count == 0)
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
var towardOrigin = settings?.CutDirection == CutDirection.TowardOrigin;
var startsHigh = towardOrigin == CutOff.FarEdgeIsHigh(target.Axis, plate.Quadrant);
var stop = startsHigh ? crossings.Max() : crossings.Min();
var remaining = startsHigh ? end - stop : stop - start;
if (remaining < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
return startsHigh
? Single(plate, new CutOffLimitChange(target, stop, target.EndLimit))
: Single(plate, new CutOffLimitChange(target, target.StartLimit, stop));
}
/// <summary>
/// Swaps which of two meeting cut-offs runs through: the one that stopped at the junction
/// extends past it (as <see cref="Extend"/> does), and the one that ran through is trimmed
/// there, losing the side away from the plate's origin and any gaps on it. Only the
/// origin-side sheet edge is certain, so the trimmed line keeps starting there. Refused when
/// a gap in either line meets the junction.
/// </summary>
public static CutOffEdit Swap(Plate plate, CutOff first, CutOff second, CutOffSettings settings)
{
if (!plate.CutOffs.Contains(first) || !plate.CutOffs.Contains(second))
return CutOffEdit.Refused(NotOnPlate);
if (first != second && first.Axis != second.Axis
&& (TouchesGap(first, Line(second)) || TouchesGap(second, Line(first))))
return CutOffEdit.Refused(GapAtJunction);
if (Junction(plate, first, second) is not var (through, stopper, point))
return CutOffEdit.Refused(NotAJunction);
var (start, end) = NominalSpan(plate, through);
var farHigh = CutOff.FarEdgeIsHigh(through.Axis, plate.Quadrant);
var kept = through.Pieces(start, end)
.Select(p => farHigh ? (p.Start, System.Math.Min(p.End, point)) : (System.Math.Max(p.Start, point), p.End))
.Where(p => p.Item2 > p.Item1)
.ToList();
var adjacent = kept.FirstOrDefault(p => (farHigh ? p.Item2 : p.Item1) == point);
if (kept.Count == 0
|| adjacent.Item2 - adjacent.Item1 < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
return CutOffEdit.Refused(TooShort);
var throughChange = farHigh
? new CutOffLimitChange(through, through.StartLimit, point, GapsBetween(kept))
: new CutOffLimitChange(through, point, through.EndLimit, GapsBetween(kept));
var (stopStart, stopEnd) = NominalSpan(plate, stopper);
var stopperPoint = Line(through);
var high = System.Math.Abs(stopperPoint - stopEnd) <= System.Math.Abs(stopperPoint - stopStart);
if (!ExtendEnd(plate, stopper, high, out var limit))
return CutOffEdit.Refused(NotAJunction);
var stopperChange = high
? new CutOffLimitChange(stopper, stopper.StartLimit, limit)
: new CutOffLimitChange(stopper, limit, stopper.EndLimit);
var notices = new[] { Notice(plate, throughChange), Notice(plate, stopperChange) }
.Where(n => n != null).Distinct().ToList();
return CutOffEdit.Of(new[] { stopperChange, throughChange },
notices.Count == 0 ? null : string.Join(" ", notices));
}
/// <summary>
/// The two cut-offs meeting within <paramref name="tolerance"/> of <paramref name="point"/>,
/// one running through the junction and the other stopping at it; null when there is none.
/// </summary>
public static (CutOff Through, CutOff Stopper)? JunctionAt(Plate plate, Vector point, double tolerance)
{
if (plate?.CutOffs == null)
return null;
(CutOff, CutOff)? best = null;
var bestDistance = double.MaxValue;
foreach (var vertical in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical))
foreach (var horizontal in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Horizontal))
{
if (Junction(plate, vertical, horizontal) is not var (through, stopper, _))
continue;
var distance = new Vector(vertical.Position.X, horizontal.Position.Y).DistanceTo(point);
if (distance <= tolerance && distance < bestDistance)
{
best = (through, stopper);
bestDistance = distance;
}
}
return best;
}
/// <summary>
/// Where <paramref name="cutOff"/>'s line runs along its axis: its limits, or the sheet
/// edges where it has none. Overtravel is not part of the nominal line, and neither does it
/// take out the gaps (see <see cref="CutOff.Pieces(double, double)"/>).
/// </summary>
public static (double Start, double End) NominalSpan(Plate plate, CutOff cutOff)
{
var sheet = plate.BoundingBox(includeParts: false);
var vertical = cutOff.Axis == CutOffAxis.Vertical;
return (cutOff.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
cutOff.EndLimit ?? (vertical ? sheet.Top : sheet.Right));
}
/// <summary>
/// The stretches of the nominal line a change removes and adds, along the axis, in ascending
/// order: what a preview marks.
/// </summary>
public static (IReadOnlyList<(double Start, double End)> Removed, IReadOnlyList<(double Start, double End)> Added)
Difference(Plate plate, CutOffLimitChange change)
{
var cutOff = change.CutOff;
var sheet = plate.BoundingBox(includeParts: false);
var vertical = cutOff.Axis == CutOffAxis.Vertical;
var before = cutOff.Pieces(NominalSpan(plate, cutOff).Start, NominalSpan(plate, cutOff).End);
var after = CutOff.Pieces(change.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
change.EndLimit ?? (vertical ? sheet.Top : sheet.Right), change.NewGaps);
return (Subtract(before, after), Subtract(after, before));
}
private static List<(double Start, double End)> Subtract(
IReadOnlyList<(double Start, double End)> from, IReadOnlyList<(double Start, double End)> remove)
{
var result = new List<(double Start, double End)>();
foreach (var piece in from)
{
var current = piece.Start;
foreach (var (start, end) in remove.Where(r => r.End > piece.Start && r.Start < piece.End))
{
if (start > current + Tolerance.Epsilon)
result.Add((current, start));
current = System.Math.Max(current, end);
}
if (piece.End > current + Tolerance.Epsilon)
result.Add((current, piece.End));
}
return result;
}
private static (CutOff Through, CutOff Stopper, double Point)? Junction(Plate plate, CutOff first, CutOff second)
{
if (first == second || first.Axis == second.Axis)
return null;
var firstAt = Line(second);
var secondAt = Line(first);
if (TouchesGap(first, firstAt) || TouchesGap(second, secondAt))
return null;
var (firstStart, firstEnd) = NominalSpan(plate, first);
var (secondStart, secondEnd) = NominalSpan(plate, second);
if (!Within(firstAt, firstStart, firstEnd) || !Within(secondAt, secondStart, secondEnd))
return null;
var firstStops = AtEnd(firstAt, firstStart, firstEnd);
var secondStops = AtEnd(secondAt, secondStart, secondEnd);
if (firstStops == secondStops)
return null;
return firstStops ? (second, first, secondAt) : (first, second, firstAt);
}
// Whether one of the cut-off's gaps contains or ends at the value along its axis.
private static bool TouchesGap(CutOff cutOff, double along) =>
cutOff.Gaps.Any(g => Within(along, g.Start, g.End));
// The limit an end takes when extended: the next crossing line beyond it, or null for the
// sheet edge. False when that end already runs to the sheet edge.
private static bool ExtendEnd(Plate plate, CutOff target, bool high, out double? limit)
{
limit = null;
if ((high ? target.EndLimit : target.StartLimit) == null)
return false;
var (start, end) = NominalSpan(plate, target);
var crossings = Crossings(plate, target);
if (high)
{
var next = crossings.Where(c => c > end + Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Min();
limit = double.IsNaN(next) ? null : next;
}
else
{
var next = crossings.Where(c => c < start - Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Max();
limit = double.IsNaN(next) ? null : next;
}
return true;
}
// Where the perpendicular cut-offs whose cut pieces reach this one cross it, along its axis.
private static List<double> Crossings(Plate plate, CutOff target)
{
var line = Line(target);
return plate.CutOffs
.Where(other => other != target && other.Axis != target.Axis)
.Where(other =>
{
var (start, end) = NominalSpan(plate, other);
return other.Pieces(start, end).Any(p => Within(line, p.Start, p.End));
})
.Select(Line)
.ToList();
}
// The change that leaves exactly the given pieces of the nominal line from start to end: a
// limit stays null while its end stays at the sheet edge.
private static CutOffLimitChange Change(CutOff target, double start, double end,
IReadOnlyList<(double Start, double End)> pieces)
{
var first = pieces[0].Start;
var last = pieces[^1].End;
return new CutOffLimitChange(target,
target.StartLimit == null && first == start ? null : first,
target.EndLimit == null && last == end ? null : last,
GapsBetween(pieces));
}
private static List<CutOffGap> GapsBetween(IReadOnlyList<(double Start, double End)> pieces)
{
var gaps = new List<CutOffGap>();
for (var i = 1; i < pieces.Count; i++)
if (pieces[i].Start > pieces[i - 1].End)
gaps.Add(new CutOffGap(pieces[i - 1].End, pieces[i].Start));
return gaps;
}
private static CutOffEdit Single(Plate plate, CutOffLimitChange change) =>
CutOffEdit.Of(new[] { change }, Notice(plate, change));
private static string Notice(Plate plate, CutOffLimitChange change)
{
var cutOff = change.CutOff;
if (!(cutOff.HeadLockOvercut > 0))
return null;
var high = CutOff.FarEdgeIsHigh(cutOff.Axis, plate.Quadrant);
var before = (high ? cutOff.EndLimit : cutOff.StartLimit) == null;
var after = (high ? change.EndLimit : change.StartLimit) == null;
return before == after ? null : after ? OvercutOn : OvercutOff;
}
// The cut-off's own coordinate across its axis: X for a vertical line, Y for a horizontal one.
private static double Line(CutOff cutOff) =>
cutOff.Axis == CutOffAxis.Vertical ? cutOff.Position.X : cutOff.Position.Y;
private static double Along(CutOff cutOff, Vector point) =>
cutOff.Axis == CutOffAxis.Vertical ? point.Y : point.X;
private static double Distance(double value, (double Start, double End) piece) =>
value < piece.Start ? piece.Start - value : value > piece.End ? value - piece.End : 0;
private static bool Within(double value, double start, double end) =>
value >= start - Tolerance.Epsilon && value <= end + Tolerance.Epsilon;
private static bool AtEnd(double value, double start, double end) =>
System.Math.Abs(value - start) <= Tolerance.Epsilon || System.Math.Abs(value - end) <= Tolerance.Epsilon;
}
}