diff --git a/OpenNest.Core/AppliedCuttingWork.cs b/OpenNest.Core/AppliedCuttingWork.cs
new file mode 100644
index 0000000..0a1acc9
--- /dev/null
+++ b/OpenNest.Core/AppliedCuttingWork.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace OpenNest;
+
+/// Existing cutting work on a set of plates: applied lead-ins and cut-offs.
+/// Real parts carrying applied lead-ins (locked ones included).
+/// Of those, parts whose lead-ins are locked.
+/// Cut-off definitions.
+/// Display numbers of the plates holding any of it, ascending.
+public sealed record AppliedCuttingWorkSummary(int LeadInParts, int LockedLeadInParts, int CutOffs,
+ IReadOnlyList PlateNumbers)
+{
+ public bool HasExistingWork => LeadInParts > 0 || CutOffs > 0;
+
+ /// Operator text for the cutting planner's warning banner; empty when nothing exists.
+ public string Describe()
+ {
+ if (!HasExistingWork)
+ return string.Empty;
+ var found = new List();
+ if (LeadInParts > 0)
+ found.Add($"lead-ins on {Count(LeadInParts, "part")}"
+ + (LockedLeadInParts > 0 ? $" ({LockedLeadInParts} locked)" : string.Empty));
+ if (CutOffs > 0)
+ found.Add(Count(CutOffs, "cut-off"));
+ var plates = PlateNumbers.Count == 1 ? "plate " : "plates ";
+ var kept = new List();
+ if (LockedLeadInParts > 0)
+ kept.Add("locked lead-ins");
+ if (CutOffs > 0)
+ kept.Add("existing cut-offs");
+ return $"ALREADY APPLIED: {string.Join(" and ", found)} on {plates}{string.Join(", ", PlateNumbers)}."
+ + (LeadInParts > LockedLeadInParts ? " Planning recalculates unlocked lead-ins." : string.Empty)
+ + (kept.Count > 0 ? $" Planning keeps {string.Join(" and ", kept)} unchanged." : string.Empty)
+ + " Clear them to recalculate everything from scratch.";
+ }
+
+ private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}";
+}
+
+/// Finds and clears applied lead-ins and cut-offs so a plan can recalculate them.
+public static class AppliedCuttingWork
+{
+ ///
+ /// Counts applied lead-ins on real parts and cut-off definitions. Definitions in
+ /// (for example ones staged by the caller) are not counted.
+ ///
+ public static AppliedCuttingWorkSummary Summarize(IReadOnlyList plates,
+ IReadOnlyList plateNumbers = null, IEnumerable ignoredCutOffs = null)
+ {
+ ArgumentNullException.ThrowIfNull(plates);
+ if (plateNumbers != null && plateNumbers.Count != plates.Count)
+ throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers));
+ var ignored = new HashSet(ignoredCutOffs ?? [], ReferenceEqualityComparer.Instance);
+ int leads = 0, locked = 0, cutOffs = 0;
+ var numbers = new SortedSet();
+ for (var index = 0; index < plates.Count; index++)
+ {
+ var plate = plates[index] ?? throw new ArgumentException("Plates must not be null.", nameof(plates));
+ var plateLeads = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray();
+ var plateCutOffs = plate.CutOffs.Count(c => !ignored.Contains(c));
+ leads += plateLeads.Length;
+ locked += plateLeads.Count(p => p.LeadInsLocked);
+ cutOffs += plateCutOffs;
+ if (plateLeads.Length > 0 || plateCutOffs > 0)
+ numbers.Add(plateNumbers?[index] ?? index + 1);
+ }
+ return new(leads, locked, cutOffs, numbers.ToArray());
+ }
+
+ ///
+ /// Removes every applied lead-in (locked ones too) from real parts and every cut-off with
+ /// its cut-off parts, and forgets each plate's recorded cutting parameters. Part poses,
+ /// order and quantities are unchanged. Returns what was cleared.
+ ///
+ public static AppliedCuttingWorkSummary Clear(IReadOnlyList plates, IReadOnlyList plateNumbers = null)
+ {
+ var cleared = Summarize(plates, plateNumbers);
+ foreach (var plate in plates)
+ {
+ foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray())
+ part.RemoveLeadIns();
+ plate.CutOffs.Clear();
+ for (var index = plate.Parts.Count - 1; index >= 0; index--)
+ if (plate.Parts[index].BaseDrawing.IsCutOff)
+ plate.Parts.RemoveAt(index);
+ plate.CuttingParameters = null;
+ }
+ return cleared;
+ }
+}
diff --git a/OpenNest.Core/AutomaticCutOffBatch.cs b/OpenNest.Core/AutomaticCutOffBatch.cs
index 1c04ae3..6e42950 100644
--- a/OpenNest.Core/AutomaticCutOffBatch.cs
+++ b/OpenNest.Core/AutomaticCutOffBatch.cs
@@ -42,11 +42,19 @@ public static class AutomaticCutOffBatch
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
///
public static IReadOnlyList Apply(IReadOnlyList plates,
+ AutomaticCutOffOptions options, CutOffSettings settings) => Stage(plates, options, settings).Plans;
+
+ ///
+ /// Applies like , but keeps the recovery state so the caller can take the
+ /// new cut-offs back off again () when the work they
+ /// were added for is abandoned. A blocked batch changes nothing and has nothing to revert.
+ ///
+ public static AutomaticCutOffStaging Stage(IReadOnlyList plates,
AutomaticCutOffOptions options, CutOffSettings settings)
{
var plans = Create(plates, options, settings);
if (plans.Any(plan => plan.HasBlockingDiagnostics))
- return plans;
+ return new AutomaticCutOffStaging(plans, []);
var restoreActions = new List<(int PlateNumber, Action Restore)>();
try
@@ -66,21 +74,7 @@ public static class AutomaticCutOffBatch
}
catch (Exception applyError)
{
- var rollbackErrors = new List();
- for (var index = restoreActions.Count - 1; index >= 0; index--)
- {
- var saved = restoreActions[index];
- try
- {
- saved.Restore();
- }
- catch (Exception rollbackError)
- {
- // A broken observer on one plate must not prevent recovery of the others.
- rollbackErrors.Add(new InvalidOperationException(
- $"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
- }
- }
+ var rollbackErrors = RestoreAll(restoreActions);
if (rollbackErrors.Count > 0)
throw new InvalidOperationException(
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
@@ -91,7 +85,27 @@ public static class AutomaticCutOffBatch
throw new InvalidOperationException(
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
}
- return plans;
+ return new AutomaticCutOffStaging(plans, restoreActions);
+ }
+
+ internal static List RestoreAll(IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
+ {
+ var errors = new List();
+ for (var index = restoreActions.Count - 1; index >= 0; index--)
+ {
+ var saved = restoreActions[index];
+ try
+ {
+ saved.Restore();
+ }
+ catch (Exception rollbackError)
+ {
+ // A broken observer on one plate must not prevent recovery of the others.
+ errors.Add(new InvalidOperationException(
+ $"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
+ }
+ }
+ return errors;
}
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
@@ -117,3 +131,52 @@ public static class AutomaticCutOffBatch
};
}
}
+
+///
+/// Cut-offs added by that are still provisional: either
+/// them once the work they belong to is committed, or them.
+/// Owner-thread only, like the plates it changed.
+///
+public sealed class AutomaticCutOffStaging
+{
+ private IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions;
+
+ internal AutomaticCutOffStaging(IReadOnlyList plans,
+ IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
+ {
+ Plans = plans;
+ this.restoreActions = restoreActions;
+ Definitions = plans.SelectMany(plan => plan.HasBlockingDiagnostics ? [] : plan.Definitions).ToArray();
+ }
+
+ /// The fresh plans, one per plate in the order given.
+ public IReadOnlyList Plans { get; }
+
+ /// True when any plate blocked the batch; then nothing was added.
+ public bool IsBlocked => Plans.Any(plan => plan.HasBlockingDiagnostics);
+
+ /// The definitions this staging added (empty when blocked or nothing was needed).
+ public IReadOnlyList Definitions { get; }
+
+ /// True while the added cut-offs can still be reverted.
+ public bool CanRevert => restoreActions.Count > 0;
+
+ /// Makes the added cut-offs permanent; a later does nothing.
+ public void Keep() => restoreActions = [];
+
+ ///
+ /// Removes the added cut-offs and restores each touched plate's previous cut-off programs and
+ /// sequence. Real parts are not touched. Runs once; throws if any plate could not be restored.
+ ///
+ public void Revert()
+ {
+ var actions = restoreActions;
+ restoreActions = [];
+ var errors = AutomaticCutOffBatch.RestoreAll(actions);
+ if (errors.Count > 0)
+ throw new InvalidOperationException(
+ "Removing the added cut-offs failed: " + string.Join("; ", errors.Select(e => e.Message))
+ + "\nThe nest may be incomplete; review it before saving or cutting.",
+ new AggregateException(errors));
+ }
+}
diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs
index be1f594..ce5e0f7 100644
--- a/OpenNest.Core/AutomaticCutOffPlanner.cs
+++ b/OpenNest.Core/AutomaticCutOffPlanner.cs
@@ -21,6 +21,12 @@ public sealed class AutomaticCutOffOptions
/// The desktop default is 12 inches (304.8 mm).
///
public double MinimumTailLength { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public double? MinimumStripLength { get; set; }
}
public enum AutomaticCutOffDiagnosticCode
@@ -29,8 +35,10 @@ public enum AutomaticCutOffDiagnosticCode
LimitedCutOffConflict,
EmptyCut,
SegmentedCut,
+ ClampBlockedCut,
NoSafeTailSeparator,
TailBelowMinimum,
+ ShiftedCut,
}
public sealed record AutomaticCutOffDiagnostic(
@@ -69,6 +77,12 @@ public sealed class AutomaticCutOffPlan
/// True only for a verified full-width separator, never just a nominal boundary.
public bool HasSeparatedTail => TailSeparatorX.HasValue;
+ /// Total cut length of proposed new strip lines (excludes the tail separator).
+ public double StripCutLength { get; internal set; }
+
+ /// Length of the nominal new strip lines in the same gaps. Equal to StripCutLength on the fixed-grid path.
+ public double FixedGridCutLength { get; internal set; }
+
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
}
@@ -78,6 +92,7 @@ public static class AutomaticCutOffPlanner
public const double GeometryTolerance = Tolerance.Epsilon;
public const double MinimumSpacing = 2 * GeometryTolerance;
public const int MaximumCandidateCount = 10000;
+ public const int MaximumShiftCandidateCount = 20000;
///
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
@@ -93,6 +108,8 @@ public static class AutomaticCutOffPlanner
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));
@@ -130,14 +147,57 @@ public static class AutomaticCutOffPlanner
var diagnostics = new List();
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();
+ var candidates = new HashSet();
- // Multiplication by an integer avoids drift from repeated floating-point addition.
- for (var index = 1; index <= (int)candidateCount; index++)
+ if (shifted)
{
- var distance = index * options.Spacing;
- if (distance >= usedSpan - GeometryTolerance)
- break;
- AddCandidate(sign * distance, false);
+ // 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 && FullSpanLimits(c, bounds, settings))
+ .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 (hasTailCandidate)
{
@@ -155,9 +215,14 @@ public static class AutomaticCutOffPlanner
{
// A partial proposal must not accidentally be accepted around a manual conflict.
definitions.Clear();
+ stripCutLength = 0;
separated = false;
separatorX = 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;
return new AutomaticCutOffPlan
{
@@ -168,12 +233,74 @@ public static class AutomaticCutOffPlanner
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
TailSeparatorX = separatorX,
+ StripCutLength = stripCutLength,
+ FixedGridCutLength = fixedGridCutLength,
};
+ void AddShiftedGap(double start, double end, int count, double step)
+ {
+ var nominal = new List(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 = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
+ .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 => !FullSpanLimits(c, bounds, settings)))
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
@@ -185,6 +312,24 @@ public static class AutomaticCutOffPlanner
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();
@@ -222,9 +367,26 @@ public static class AutomaticCutOffPlanner
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.",
@@ -235,6 +397,10 @@ public static class AutomaticCutOffPlanner
{
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 &&
@@ -242,6 +408,9 @@ public static class AutomaticCutOffPlanner
"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(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
diff --git a/OpenNest.Core/AutomaticCutOffStripSearch.cs b/OpenNest.Core/AutomaticCutOffStripSearch.cs
new file mode 100644
index 0000000..3607560
--- /dev/null
+++ b/OpenNest.Core/AutomaticCutOffStripSearch.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Numerics;
+
+namespace OpenNest;
+
+/// Exact layered search over sorted positions; each transition uses a sliding minimum.
+internal static class AutomaticCutOffStripSearch
+{
+ internal static double[] FindPositions(double start, double end, int count, double spacing,
+ double minimum, double step, Func allowed,
+ Func measure)
+ {
+ var rows = new List(count);
+ var nominal = new double[count];
+ for (var line = 1; line <= count; line++)
+ {
+ var today = start + line * spacing;
+ nominal[line - 1] = today;
+ var low = System.Math.Max(start + line * minimum, end - (count - line + 1) * spacing);
+ var high = System.Math.Min(today, end - AutomaticCutOffPlanner.GeometryTolerance);
+ var positions = new List();
+ // Integer indices keep candidates on the origin-based lattice without cumulative drift.
+ var first = (long)System.Math.Ceiling(low / step);
+ var last = (long)System.Math.Floor(high / step);
+ for (var index = first; index <= last; index++)
+ {
+ var position = index * step;
+ if (position >= low && position <= high && allowed(position))
+ positions.Add(position);
+ }
+ if (allowed(today))
+ positions.Add(today);
+ positions.Sort();
+ for (var index = positions.Count - 1; index > 0; index--)
+ if (positions[index] == positions[index - 1])
+ positions.RemoveAt(index);
+ if (positions.Count == 0)
+ return null;
+ rows.Add(positions.ToArray());
+ }
+ return Solve(start, rows, nominal, spacing, minimum, measure);
+ }
+
+ // Kept independent of geometry so tests can compare against exhaustive, non-greedy oracles.
+ internal static double[] Solve(double start, IReadOnlyList rows,
+ IReadOnlyList nominal, double spacing, double minimum,
+ Func measure)
+ {
+ var previous = new List { new() { Position = start } };
+ for (var line = 0; line < rows.Count; line++)
+ {
+ var current = new List(rows[line].Length);
+ var deque = new int[previous.Count];
+ var head = 0;
+ var tail = 0;
+ var added = 0;
+ foreach (var position in rows[line])
+ {
+ var upper = position - minimum + AutomaticCutOffPlanner.GeometryTolerance;
+ var lower = position - spacing - AutomaticCutOffPlanner.GeometryTolerance;
+ while (added < previous.Count && previous[added].Position <= upper)
+ {
+ while (tail > head && Compare(previous[added], previous[deque[tail - 1]]) < 0)
+ tail--;
+ deque[tail++] = added++;
+ }
+ while (head < tail && previous[deque[head]].Position < lower)
+ head++;
+ if (head == tail)
+ continue;
+ var parent = previous[deque[head]];
+ var cost = measure(position);
+ current.Add(new State
+ {
+ Position = position,
+ Length = parent.Length + RoundedMicroUnits(cost.Length),
+ Pierces = parent.Pierces + cost.SegmentCount,
+ Deviation = parent.Deviation + System.Math.Abs(nominal[line] - position),
+ Parent = parent,
+ });
+ }
+ if (current.Count == 0)
+ return null;
+ AssignLexicographicRanks(current, previous.Count);
+ previous = current;
+ }
+ State best = null;
+ foreach (var state in previous)
+ if (best == null || Compare(state, best) < 0)
+ best = state;
+ var result = new double[rows.Count];
+ for (var line = result.Length - 1; line >= 0; line--)
+ {
+ result[line] = best.Position;
+ best = best.Parent;
+ }
+ return result;
+ }
+
+ private static BigInteger RoundedMicroUnits(double length)
+ {
+ if (!double.IsFinite(length) || length < 0)
+ throw new ArgumentException("Cut-off segment length must be finite and nonnegative.", nameof(length));
+ var rounded = System.Math.Round(length, 6);
+ // A shortest round-trip decimal may spell a different integer near 1e16
+ // (for example 26833294515438512d prints as 26833294515438510).
+ // An already-integral double is exact; never round it through text.
+ if (rounded == System.Math.Truncate(rounded))
+ return new BigInteger(rounded) * 1_000_000;
+ // Compare sums of the specified decimal-rounded model units, not binary
+ // approximations such as 0.1 + 0.2 versus 0.3. The round-trip string
+ // retains micro-unit digits for fractional scores.
+ var value = decimal.Parse(rounded.ToString("R", CultureInfo.InvariantCulture),
+ NumberStyles.Float, CultureInfo.InvariantCulture);
+ return new BigInteger(decimal.Round(value * 1_000_000m, 0));
+ }
+
+ private static int Compare(State left, State right)
+ {
+ var comparison = left.Length.CompareTo(right.Length);
+ if (comparison == 0)
+ comparison = left.Pierces.CompareTo(right.Pierces);
+ if (comparison == 0)
+ comparison = left.Deviation.CompareTo(right.Deviation);
+ return comparison != 0 ? comparison : left.LexicographicRank.CompareTo(right.LexicographicRank);
+ }
+
+ private static void AssignLexicographicRanks(List states, int parentCount)
+ {
+ // Stable counting sort by prefix rank. Positions are already sorted, so within a
+ // prefix their order is lexicographic. Ranking and transitions are linear per row.
+ var offsets = new int[parentCount];
+ foreach (var state in states)
+ offsets[state.Parent.LexicographicRank]++;
+ var total = 0;
+ for (var index = 0; index < offsets.Length; index++)
+ {
+ var count = offsets[index];
+ offsets[index] = total;
+ total += count;
+ }
+ foreach (var state in states)
+ state.LexicographicRank = offsets[state.Parent.LexicographicRank]++;
+ }
+
+ private sealed class State
+ {
+ public double Position;
+ public BigInteger Length;
+ public int Pierces;
+ public double Deviation;
+ public State Parent;
+ public int LexicographicRank;
+ }
+}
diff --git a/OpenNest.Core/ClampRail.cs b/OpenNest.Core/ClampRail.cs
new file mode 100644
index 0000000..65ac565
--- /dev/null
+++ b/OpenNest.Core/ClampRail.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// The machine's fixed clamp rail: identical rectangular clamps whose X centers are
+ /// measured from the plate origin along the origin-side (operator) edge. Plates always
+ /// start at the origin, so the same rail resolves onto every sheet size without
+ /// re-scaling. A clamp whose footprint lies wholly beyond a sheet is skipped; one that
+ /// still reaches onto it is kept whole: the zone is an avoidance area, so its full
+ /// footprint applies even where it hangs past the sheet.
+ ///
+ public sealed class ClampRail : IEquatable
+ {
+ ///
+ /// Planner default minutes to remove one clamp and put it back after cutting (D9),
+ /// used when a caller does not supply its own value.
+ ///
+ public const double DefaultRemovalMinutes = 5.0;
+
+ public ClampRail(
+ double width,
+ double depth,
+ IEnumerable centers,
+ double removalMinutes = DefaultRemovalMinutes
+ )
+ {
+ ArgumentNullException.ThrowIfNull(centers);
+ if (!double.IsFinite(width) || width <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(width),
+ "Clamp width must be finite and positive."
+ );
+ if (!double.IsFinite(depth) || depth <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(depth),
+ "Clamp depth must be finite and positive."
+ );
+ if (!double.IsFinite(removalMinutes) || removalMinutes < 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(removalMinutes),
+ "Clamp removal time must be finite and zero or more."
+ );
+
+ var sorted = new SortedSet();
+ foreach (var center in centers)
+ {
+ if (!double.IsFinite(center) || center < 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(centers),
+ "Clamp centers must be finite and measured from the origin (zero or more)."
+ );
+ sorted.Add(center);
+ }
+
+ Width = width;
+ Depth = depth;
+ RemovalMinutes = removalMinutes;
+ Centers = sorted.ToArray();
+ }
+
+ /// Clamp extent along the edge (X).
+ public double Width { get; }
+
+ /// Clamp intrusion into the sheet from the origin-side edge (Y).
+ public double Depth { get; }
+
+ ///
+ /// Minutes to remove one clamp and put it back after cutting (D9). Added to a sheet's
+ /// cut-time estimate once per clamp lists there.
+ ///
+ public double RemovalMinutes { get; }
+
+ /// Distinct clamp centers along X from the origin, ascending.
+ public IReadOnlyList Centers { get; }
+
+ ///
+ /// The zones this rail places on a sheet, in plate coordinates (the frame of
+ /// : origin at the sheet's lower-left corner). The
+ /// quadrant locates the plate origin, and with it the origin-side edge, on the sheet.
+ /// Zones are whole clamp footprints, never clipped, so one reaching past a sheet end
+ /// (or deeper than a narrow sheet) extends beyond the sheet. Ids are clamp-N by
+ /// rail position, so one physical clamp keeps its id on every sheet even when others
+ /// are skipped.
+ ///
+ public IReadOnlyList ResolveFor(Size size, int quadrant)
+ {
+ var length = size.Length;
+ var sheetWidth = size.Width;
+ if (!(length > 0) || !(sheetWidth > 0) || !double.IsFinite(length) || !double.IsFinite(sheetWidth))
+ return Array.Empty();
+
+ var extendsLeft = quadrant is 2 or 3;
+ var extendsDown = quadrant is 3 or 4;
+ // The origin-side edge is y = 0 of the sheet, or its top when it extends to -Y.
+ var y = extendsDown ? sheetWidth - Depth : 0;
+ var half = Width / 2;
+
+ var zones = new List(Centers.Count);
+ for (var i = 0; i < Centers.Count; i++)
+ {
+ // Distances from the origin along the edge.
+ var near = Centers[i] - half;
+ var far = Centers[i] + half;
+ if (near >= length)
+ continue; // wholly beyond this sheet
+
+ // Plate coordinates are measured from the lower-left corner; a sheet extending
+ // to -X from the origin has its origin edge at x = length.
+ var x = extendsLeft ? length - far : near;
+ zones.Add(new ClampZone($"clamp-{i + 1}", new Box(x, y, Width, Depth)));
+ }
+
+ return zones;
+ }
+
+ /// True when the plate's zones are exactly (ids and bits) this rail's for its sheet.
+ public bool Matches(Plate plate)
+ {
+ ArgumentNullException.ThrowIfNull(plate);
+ return SameZones(plate.ClampZones, ResolveFor(plate.Size, plate.Quadrant));
+ }
+
+ ///
+ /// True when the plate's zones are exactly this rail's resolution for its sheet minus
+ /// the clamps named in (D8 last-resort removal, C13).
+ /// A null or empty set is the same check as .
+ ///
+ public bool Matches(Plate plate, IReadOnlyCollection removedClamps)
+ {
+ ArgumentNullException.ThrowIfNull(plate);
+ if (removedClamps == null || removedClamps.Count == 0)
+ return Matches(plate);
+ var expected = ResolveFor(plate.Size, plate.Quadrant)
+ .Where(z => !removedClamps.Contains(z.Id))
+ .ToList();
+ return SameZones(plate.ClampZones, expected);
+ }
+
+ internal static bool SameZones(IList current, IReadOnlyList expected)
+ {
+ if (current.Count != expected.Count)
+ return false;
+ for (var i = 0; i < current.Count; i++)
+ {
+ var a = current[i];
+ var b = expected[i];
+ if (
+ !string.Equals(a.Id, b.Id, StringComparison.Ordinal)
+ || a.Bounds.X != b.Bounds.X
+ || a.Bounds.Y != b.Bounds.Y
+ || a.Bounds.Length != b.Bounds.Length
+ || a.Bounds.Width != b.Bounds.Width
+ )
+ return false;
+ }
+ return true;
+ }
+
+ public bool Equals(ClampRail other) =>
+ other is not null
+ && Width.Equals(other.Width)
+ && Depth.Equals(other.Depth)
+ && RemovalMinutes.Equals(other.RemovalMinutes)
+ && Centers.SequenceEqual(other.Centers);
+
+ public override bool Equals(object obj) => Equals(obj as ClampRail);
+
+ public override int GetHashCode()
+ {
+ var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Centers.Count);
+ foreach (var center in Centers)
+ hash = HashCode.Combine(hash, center);
+ return hash;
+ }
+
+ public override string ToString() =>
+ $"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Centers.Select(c => c.ToString("R")))}]";
+ }
+}
diff --git a/OpenNest.Core/ClampZone.cs b/OpenNest.Core/ClampZone.cs
new file mode 100644
index 0000000..43f15fb
--- /dev/null
+++ b/OpenNest.Core/ClampZone.cs
@@ -0,0 +1,52 @@
+using System;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame:
+ /// origin at the plate corner, X along the edge, Y depth from it). Machine edge clamps
+ /// are the first users; a future "don't cut here" tag is another entry of this shape.
+ /// Zones are exclusion and display only - never material, quantity, utilization,
+ /// cut sequence or CNC output. Rectangles only: axis-aligned by construction.
+ ///
+ public sealed class ClampZone
+ {
+ public ClampZone(string id, Box bounds)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(id);
+ ArgumentNullException.ThrowIfNull(bounds);
+ if (
+ !double.IsFinite(bounds.X)
+ || !double.IsFinite(bounds.Y)
+ || !double.IsFinite(bounds.Length)
+ || !double.IsFinite(bounds.Width)
+ )
+ throw new ArgumentException(
+ $"Clamp zone '{id}' bounds must have finite coordinates and size.",
+ nameof(bounds)
+ );
+ if (bounds.Length <= 0 || bounds.Width <= 0)
+ throw new ArgumentException(
+ $"Clamp zone '{id}' must have a positive length (X) and width (Y).",
+ nameof(bounds)
+ );
+
+ Id = id;
+ // Detached copy: the zone owns its rectangle, callers may keep mutating theirs.
+ Bounds = new Box(bounds.X, bounds.Y, bounds.Length, bounds.Width);
+ }
+
+ /// Stable identifier used by persistence and diagnostics.
+ public string Id { get; }
+
+ ///
+ /// The rectangle in plate coordinates: Length is the extent along X, Width the
+ /// extent along Y (depth from the y = 0 operator-side edge for machine clamps).
+ ///
+ public Box Bounds { get; }
+
+ public override string ToString() =>
+ $"ClampZone '{Id}' [{Bounds.Left:R}, {Bounds.Bottom:R}] x {Bounds.Length:R} x {Bounds.Width:R}";
+ }
+}
diff --git a/OpenNest.Core/ClampZoneFrame.cs b/OpenNest.Core/ClampZoneFrame.cs
new file mode 100644
index 0000000..70f90d4
--- /dev/null
+++ b/OpenNest.Core/ClampZoneFrame.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest;
+
+///
+/// Maps a plate's , stored in the sheet's lower-left frame
+/// (), into the signed placement frame of its parts,
+/// cut-offs and motions. The translation matches the sheet itself and the engine stock:
+/// -Length on X in quadrants 2 and 3, -Width on Y in quadrants 3 and 4. Whole footprints are
+/// kept, never clipped. Every plate uses the sheet frame, including plates the engine builds
+/// from a NestPlateStock, so this is the only translation any consumer applies.
+///
+public static class ClampZoneFrame
+{
+ ///
+ /// Detached placement-frame copies in the plate's zone order; empty when there are none.
+ /// Throws for a missing or malformed zone: zone bounds stay
+ /// publicly mutable, so construction-time validation is not proof of current geometry.
+ ///
+ public static IReadOnlyList ToPlacement(Plate plate)
+ {
+ ArgumentNullException.ThrowIfNull(plate);
+ var zones = plate.ClampZones;
+ if (zones == null || zones.Count == 0)
+ return Array.Empty();
+ if (plate.Quadrant is < 1 or > 4)
+ throw new ArgumentException("Quadrant must be 1 through 4.", nameof(plate));
+ var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
+ var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
+ if (!double.IsFinite(left) || !double.IsFinite(bottom))
+ throw new ArgumentException("Sheet size must be finite.", nameof(plate));
+
+ var mapped = new ClampZone[zones.Count];
+ for (var i = 0; i < mapped.Length; i++)
+ {
+ var zone = zones[i];
+ if (zone?.Bounds == null)
+ throw new ArgumentException($"Clamp zone {i + 1} is missing.", nameof(plate));
+ var box = zone.Bounds;
+ var x = box.X + left;
+ var y = box.Y + bottom;
+ if (!(box.Length > 0) || !(box.Width > 0) || !double.IsFinite(box.Length)
+ || !double.IsFinite(box.Width) || !double.IsFinite(box.X) || !double.IsFinite(box.Y)
+ || !double.IsFinite(x) || !double.IsFinite(y)
+ || !double.IsFinite(x + box.Length) || !double.IsFinite(y + box.Width))
+ throw new ArgumentException(
+ $"Clamp zone '{zone.Id}' must have finite bounds and a positive size.", nameof(plate));
+ mapped[i] = new ClampZone(zone.Id, new Box(x, y, box.Length, box.Width));
+ }
+ return mapped;
+ }
+}
diff --git a/OpenNest.Core/Converters/DrawingMarkRecovery.cs b/OpenNest.Core/Converters/DrawingMarkRecovery.cs
new file mode 100644
index 0000000..df92fc1
--- /dev/null
+++ b/OpenNest.Core/Converters/DrawingMarkRecovery.cs
@@ -0,0 +1,170 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Converters;
+
+///
+/// Puts etch marks back on drawings that lost them. Nests converted from PEP kept etch marks
+/// only in each placed part's saved cutting program; the drawing itself had none, so removing
+/// lead-ins (which rebuilds the part from its drawing) silently removed the marks too.
+///
+public static class DrawingMarkRecovery
+{
+ private const double MatchTolerance = 0.001;
+
+ ///
+ /// For each drawing without etch marks whose saved placed programs all carry the same marks,
+ /// adds those marks to the drawing in its own frame and refreshes the drawing's clean placed
+ /// parts. Saved programs are never changed. Drawings whose saved programs disagree, or that
+ /// already have marks, are left alone. Returns the number of drawings that were given marks.
+ ///
+ public static int Apply(Nest nest)
+ {
+ if (nest == null)
+ return 0;
+
+ var parts = nest.Plates.SelectMany(plate => plate.Parts).ToList();
+ var recovered = 0;
+ foreach (var group in parts.GroupBy(part => part.BaseDrawing, ReferenceEqualityComparer.Instance))
+ {
+ var drawing = (Drawing)group.Key;
+ if (Apply(drawing, group.ToList()))
+ recovered++;
+ }
+ return recovered;
+ }
+
+ /// Recovers marks for one drawing from its placed parts; true when marks were added.
+ public static bool Apply(Drawing drawing, IReadOnlyList placedParts)
+ {
+ if (drawing?.Program == null || drawing.IsCutOff || placedParts == null)
+ return false;
+ // An incremental call's end position is not tracked by Program.EndPoint, so only
+ // flat drawings (which is what conversion produces) can be extended safely.
+ if (HasMarks(drawing.Program) || drawing.Program.Codes.Any(code => code is SubProgramCall))
+ return false;
+
+ var saved = placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing) && part.HasManualLeadIns)
+ .ToList();
+ if (saved.Count == 0)
+ return false;
+
+ var marks = MarksInDrawingFrame(saved[0]);
+ if (marks.Count == 0 || saved.Skip(1).Any(part => !SameMarks(marks, MarksInDrawingFrame(part))))
+ return false;
+
+ AppendMarks(drawing.Program, marks);
+ drawing.UpdateArea();
+ drawing.RecomputeCanonicalAngle();
+
+ foreach (var part in placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing)
+ && !part.HasManualLeadIns))
+ part.Update();
+ return true;
+ }
+
+ private static bool HasMarks(Program program) =>
+ program.Codes.Any(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe }
+ || code is SubProgramCall { Program: not null } call && HasMarks(call.Program));
+
+ ///
+ /// The part's marks as they would sit in its drawing:
+ /// rebuilds the part as the drawing turned by , so undo that turn.
+ ///
+ private static List MarksInDrawingFrame(Part part)
+ {
+ var marks = ConvertProgram.ToGeometry(part.Program)
+ .Where(entity => entity.Layer == SpecialLayers.Scribe)
+ .ToList();
+ if (!Tolerance.IsEqualTo(part.Rotation, 0))
+ foreach (var mark in marks)
+ mark.Rotate(-part.Rotation);
+ return marks;
+ }
+
+ private static bool SameMarks(List expected, List actual)
+ {
+ if (expected.Count != actual.Count)
+ return false;
+ var unmatched = actual.ToList();
+ foreach (var mark in expected)
+ {
+ var index = unmatched.FindIndex(other => SameMark(mark, other));
+ if (index < 0)
+ return false;
+ unmatched.RemoveAt(index);
+ }
+ return true;
+ }
+
+ // Direction does not matter for an etch: both marks must lie on each other.
+ private static bool SameMark(Entity a, Entity b) =>
+ a.Type == b.Type && LiesOn(Samples(a), b) && LiesOn(Samples(b), a);
+
+ private static bool LiesOn(List points, Entity entity) =>
+ points.Count > 0 && points.All(point => entity.ClosestPointTo(point).DistanceTo(point) <= MatchTolerance);
+
+ private static List Samples(Entity entity) => entity switch
+ {
+ Line line => new List
+ {
+ line.StartPoint,
+ line.EndPoint,
+ new((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2),
+ },
+ Arc arc => new List { arc.StartPoint(), arc.EndPoint(), arc.MidPoint() },
+ Circle circle => new List
+ {
+ new(circle.Center.X + circle.Radius, circle.Center.Y),
+ new(circle.Center.X - circle.Radius, circle.Center.Y),
+ new(circle.Center.X, circle.Center.Y + circle.Radius),
+ new(circle.Center.X, circle.Center.Y - circle.Radius),
+ },
+ _ => new List(),
+ };
+
+ ///
+ /// Appends the marks after the drawing's existing motion, so the existing codes and the
+ /// drawing's first-rapid origin stay exactly as they were.
+ ///
+ private static void AppendMarks(Program program, List marks)
+ {
+ var shape = new Shape();
+ shape.Entities.AddRange(marks);
+ var markProgram = ConvertGeometry.ToProgram(shape);
+
+ // Start from the drawing's end point, with an explicit rapid onto the first mark.
+ var start = EndOf(program);
+ var first = markProgram.Codes.OfType().First();
+ var positioned = new Program(Mode.Absolute);
+ positioned.Codes.Add(new RapidMove(start));
+ if (first is not RapidMove)
+ positioned.Codes.Add(new RapidMove(StartOf(marks[0])));
+ positioned.Codes.AddRange(markProgram.Codes);
+
+ if (program.Mode == Mode.Incremental)
+ positioned.Mode = Mode.Incremental;
+ // Drop the move onto the drawing's end point; it is where the drawing already stops.
+ program.Codes.AddRange(positioned.Codes.Skip(1));
+ }
+
+ // Where a flat program leaves the head (callers exclude sub-program calls).
+ private static Vector EndOf(Program program)
+ {
+ var position = Vector.Zero;
+ foreach (var motion in program.Codes.OfType())
+ position = program.Mode == Mode.Incremental ? position + motion.EndPoint : motion.EndPoint;
+ return position;
+ }
+
+ private static Vector StartOf(Entity entity) => entity switch
+ {
+ Line line => line.StartPoint,
+ Arc arc => arc.StartPoint(),
+ Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
+ _ => Vector.Zero,
+ };
+}
diff --git a/OpenNest.Core/CutOff.cs b/OpenNest.Core/CutOff.cs
index b7d375a..5cc8861 100644
--- a/OpenNest.Core/CutOff.cs
+++ b/OpenNest.Core/CutOff.cs
@@ -39,6 +39,72 @@ namespace OpenNest
Drawing.Program = program;
}
+ /// Measures the same trimmed, filtered segments as regeneration without building a program.
+ internal (double Length, int SegmentCount) MeasureSegments(
+ Plate plate,
+ CutOffSettings settings,
+ Dictionary cache = null
+ )
+ {
+ var segments = ComputeSegments(plate, settings, cache);
+ return (segments.Sum(s => s.End - s.Start), segments.Count);
+ }
+
+ ///
+ /// True when , the placed part of , cuts
+ /// one continuous straight line on the definition's axis across the whole sheet, so it
+ /// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
+ /// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
+ ///
+ internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
+ {
+ if (part?.Program == null || definition == null || sheet == null
+ || part.Program.Mode != Mode.Absolute)
+ return false;
+ var vertical = definition.Axis == CutOffAxis.Vertical;
+ var line = vertical ? definition.Position.X : definition.Position.Y;
+ var acrossMin = vertical ? sheet.Left : sheet.Bottom;
+ var acrossMax = vertical ? sheet.Right : sheet.Top;
+ var alongMin = vertical ? sheet.Bottom : sheet.Left;
+ var alongMax = vertical ? sheet.Top : sheet.Right;
+ if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
+ return false;
+
+ Vector? position = null;
+ double? low = null, high = null;
+ var cutting = false;
+ foreach (var code in part.Program.Codes)
+ {
+ switch (code)
+ {
+ case RapidMove rapid:
+ if (cutting)
+ return false; // A second pierce leaves an uncut gap.
+ position = rapid.EndPoint + part.Location;
+ break;
+ case LinearMove move when position != null:
+ var end = move.EndPoint + part.Location;
+ if (!OnLine(position.Value) || !OnLine(end))
+ return false;
+ foreach (var value in new[] { Along(position.Value), Along(end) })
+ {
+ low = low == null ? value : System.Math.Min(low.Value, value);
+ high = high == null ? value : System.Math.Max(high.Value, value);
+ }
+ position = end;
+ cutting = true;
+ break;
+ case Motion:
+ return false;
+ }
+ }
+ return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon;
+
+ bool OnLine(Vector point) =>
+ System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
+ double Along(Vector point) => vertical ? point.Y : point.X;
+ }
+
private string GetName()
{
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
diff --git a/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs b/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs
new file mode 100644
index 0000000..559483d
--- /dev/null
+++ b/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest.Diagnostics;
+
+/// A nominal manual cut-off line meeting an active, physical clamp footprint.
+public sealed record CutOffClampFinding(int CutOffIndex, string ClampId, Vector Location);
+
+///
+/// Read-only axis-aligned clamp contact checks. Nominal cut-off definitions are checked
+/// even if their current regenerated program is stale or trimmed by nearby parts.
+/// A removed clamp is absent from Plate.ClampZones and therefore cannot be reported.
+/// Zones are compared in the plate's signed placement frame ().
+///
+public static class ClampCutDiagnostics
+{
+ public static IReadOnlyList Find(Plate plate)
+ {
+ ArgumentNullException.ThrowIfNull(plate);
+ var findings = new List();
+ if (plate.ClampZones.Count == 0 || plate.CutOffs.Count == 0)
+ return findings.AsReadOnly();
+ var zones = ClampZoneFrame.ToPlacement(plate);
+ var sheet = plate.BoundingBox(false);
+ for (var index = 0; index < plate.CutOffs.Count; index++)
+ {
+ var cut = plate.CutOffs[index];
+ if (cut == null || !Enum.IsDefined(cut.Axis))
+ throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
+ var start = cut.Axis == CutOffAxis.Vertical
+ ? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom)
+ : new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
+ var end = cut.Axis == CutOffAxis.Vertical
+ ? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
+ : new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y);
+ foreach (var zone in zones)
+ if (Intersects(start, end, zone.Bounds, out var location))
+ findings.Add(new CutOffClampFinding(index, zone.Id, location));
+ }
+ return findings.AsReadOnly();
+ }
+
+ /// True for contact with the whole rectangle, including a boundary touch.
+ public static bool Intersects(Vector from, Vector to, Box box, out Vector location)
+ {
+ location = default;
+ if (box == null || !Finite(from) || !Finite(to) || !Finite(box.Location)
+ || !double.IsFinite(box.Right) || !double.IsFinite(box.Top)
+ || box.Length <= 0 || box.Width <= 0)
+ throw new ArgumentException("Clamp contact geometry must be finite and nonempty.");
+ var dx = to.X - from.X;
+ var dy = to.Y - from.Y;
+ if (!double.IsFinite(dx) || !double.IsFinite(dy)
+ || !double.IsFinite(from.X - box.Left) || !double.IsFinite(box.Right - from.X)
+ || !double.IsFinite(from.Y - box.Bottom) || !double.IsFinite(box.Top - from.Y))
+ throw new ArgumentException("Clamp contact motion is too large.");
+ var enter = 0.0;
+ var exit = 1.0;
+ if (!Clip(-dx, from.X - box.Left, ref enter, ref exit)
+ || !Clip(dx, box.Right - from.X, ref enter, ref exit)
+ || !Clip(-dy, from.Y - box.Bottom, ref enter, ref exit)
+ || !Clip(dy, box.Top - from.Y, ref enter, ref exit))
+ return false;
+ var midpoint = (enter + exit) * 0.5;
+ location = new Vector(from.X + dx * midpoint, from.Y + dy * midpoint);
+ return true;
+ }
+
+ private static bool Clip(double p, double q, ref double enter, ref double exit)
+ {
+ if (p == 0)
+ return q >= 0;
+ var ratio = q / p;
+ if (p < 0)
+ enter = System.Math.Max(enter, ratio);
+ else
+ exit = System.Math.Min(exit, ratio);
+ return enter <= exit;
+ }
+
+ private static bool Finite(Vector value) => double.IsFinite(value.X) && double.IsFinite(value.Y);
+}
diff --git a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs
index fb7f33f..ceae55b 100644
--- a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs
+++ b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs
@@ -11,20 +11,71 @@ public sealed class OverlapGeometryStamp
{
private readonly Plate plate;
private readonly Entry[] entries;
+ private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
+ private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs;
+ private readonly (long Width, long Length, int Quadrant) sheet;
private OverlapGeometryStamp(Plate plate)
{
this.plate = plate;
+ sheet = SheetState(plate);
entries = new Entry[plate.Parts.Count];
for (var i = 0; i < entries.Length; i++)
entries[i] = new Entry(plate.Parts[i]);
+ zones = new (string, long, long, long, long)[plate.ClampZones.Count];
+ for (var i = 0; i < zones.Length; i++)
+ zones[i] = ZoneState(plate.ClampZones[i]);
+ cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count];
+ for (var i = 0; i < cutoffs.Length; i++)
+ cutoffs[i] = CutOffState(plate.CutOffs[i]);
}
+ // Missing entries are malformed input the analysis itself reports; freshness must not
+ // throw first (the desktop captures it before its error boundary).
+ private static (string, long, long, long, long) ZoneState(ClampZone zone) =>
+ zone?.Bounds is not { } box ? default
+ : (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
+
+ private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) =>
+ cut == null ? default
+ : (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
+ cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
+ cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null);
+
+ private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
+
+ private static (long, long, int) SheetState(Plate plate) =>
+ (Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
+
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
+ internal bool SameZones(Plate current)
+ {
+ // Stored zones are sheet-relative: a size or quadrant change moves them in the
+ // parts' frame without changing their bits.
+ if (!ReferenceEquals(plate, current) || current.ClampZones.Count != zones.Length
+ || sheet != SheetState(current))
+ return false;
+ for (var i = 0; i < zones.Length; i++)
+ if (zones[i] != ZoneState(current.ClampZones[i]))
+ return false;
+ return true;
+ }
+
+ internal bool SameCutOffs(Plate current)
+ {
+ if (!ReferenceEquals(plate, current) || current.CutOffs.Count != cutoffs.Length
+ || sheet != SheetState(current))
+ return false;
+ for (var i = 0; i < cutoffs.Length; i++)
+ if (cutoffs[i] != CutOffState(current.CutOffs[i]))
+ return false;
+ return true;
+ }
+
public bool Matches(Plate current)
{
- if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length)
+ if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current))
return false;
for (var i = 0; i < entries.Length; i++)
if (!entries[i].Matches(current.Parts[i]))
diff --git a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs
index 36669a8..ee4fc47 100644
--- a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs
+++ b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs
@@ -9,12 +9,19 @@ namespace OpenNest.Diagnostics;
/// Read-only pair presentation; no geometry preparation or collision queries.
public static class OverlapPairPresentation
{
- public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
+ /// True for part-vs-clamp findings carried on the report (negative clamp slot).
+ public static bool IsClampFinding(PlateOverlapPair pair) => pair.PartBId < 0;
+
+ public static string Label(PlateOverlapPair pair) =>
+ IsClampFinding(pair) ? $"{pair.PartAId + 1}/clamp" : $"{pair.PartAId + 1}/{pair.PartBId + 1}";
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
{
var units = UnitsHelper.GetShortString(capturedUnits);
- return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n"
+ var head = IsClampFinding(pair)
+ ? $"Clamp overlap: {pair.PartAName} / {pair.PartBName}"
+ : $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}";
+ return head + "\n"
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
}
diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs
index 3da9309..c5f99a1 100644
--- a/OpenNest.Core/Diagnostics/OverlapReportState.cs
+++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs
@@ -21,14 +21,22 @@ public sealed class OverlapReportState
public PlateOverlapReport Report { get; private set; }
/// Known overlap pairs safe to draw, even while the full layout needs a recheck.
public IReadOnlyList DisplayPairs { get; private set; } = Array.Empty();
+ public IReadOnlyList DisplayCutOffClampFindings { get; private set; } =
+ Array.Empty();
public bool IsRunning => Status == OverlapCheckStatus.Checking;
public string Message => Status switch
{
OverlapCheckStatus.Checking => "Checking overlaps…",
- OverlapCheckStatus.Current => Report.Pairs.Count == 0
- ? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs",
+ OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
+ ? (Report.Pairs.Count == 0 ? "No material overlaps detected"
+ : $"Overlaps: {Report.Pairs.Count} pairs")
+ : $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
+ + $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts",
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
+ + (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
+ + (Report.CutOffClampFindings.Count == 0 ? ""
+ : $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
+ $"{uncheckedPartCount} parts could not be checked",
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
OverlapCheckStatus.Canceled => "Overlap check canceled",
@@ -55,7 +63,9 @@ public sealed class OverlapReportState
if (!CanComplete(generation, plate))
return false;
Report = report;
- DisplayPairs = report.Pairs;
+ DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
+ : report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
+ DisplayCutOffClampFindings = report.CutOffClampFindings;
displayStamp = stamp;
observedDisplayStamp = stamp;
uncheckedPartCount = CountUncheckedParts(report.Issues);
@@ -106,10 +116,14 @@ public sealed class OverlapReportState
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
return;
var unchanged = displayStamp.UnchangedSlots(plate);
- var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
+ var sameZones = displayStamp.SameZones(plate);
+ if (!sameZones || !displayStamp.SameCutOffs(plate))
+ DisplayCutOffClampFindings = Array.Empty();
+ var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId]
+ && (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList();
if (retained.Count != DisplayPairs.Count)
DisplayPairs = retained.AsReadOnly();
- if (DisplayPairs.Count == 0)
+ if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0)
ClearDisplayPairs();
else
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
@@ -118,6 +132,7 @@ public sealed class OverlapReportState
private void ClearDisplayPairs()
{
DisplayPairs = Array.Empty();
+ DisplayCutOffClampFindings = Array.Empty();
displayStamp = null;
observedDisplayStamp = null;
}
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
index 607b30e..a602a97 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
@@ -17,13 +17,41 @@ public static class PlateOverlapAnalyzer
{
public const double ChordTolerance = 0.001;
+ ///
+ /// Capture a whole plate including active clamps and nominal cut-off definitions.
+ /// Use this entry for the desktop overlap check so manual cut-off contacts
+ /// cannot be mistaken for a clear plate merely because cut-offs are not material.
+ /// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
+ /// placement frame; a malformed zone throws .
+ ///
+ public static PlateOverlapSnapshot CapturePlate(Plate plate, OverlapMaterialCache cache = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(plate);
+ var material = Capture(plate.Parts.ToArray(), ClampZoneFrame.ToPlacement(plate).ToArray(),
+ cache ?? new OverlapMaterialCache(), cancellationToken);
+ var cutoffs = ClampCutDiagnostics.Find(plate);
+ cancellationToken.ThrowIfCancellationRequested();
+ return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
+ material.Clamps.ToList(), cutoffs.ToList());
+ }
+
///
/// Captures poses and converts each distinct clean source program to owned entities once.
/// Inputs must not change during capture. Later analysis never reads live domain objects.
///
public static PlateOverlapSnapshot Capture(IReadOnlyList parts,
CancellationToken cancellationToken = default) =>
- Capture(parts, new OverlapMaterialCache(), cancellationToken);
+ Capture(parts, null, new OverlapMaterialCache(), cancellationToken);
+
+ ///
+ /// As , additionally capturing
+ /// the plate's clamp keep-out rectangles: Analyze then reports part-vs-clamp findings
+ /// (C1) alongside part pairs. Null means no zones.
+ ///
+ public static PlateOverlapSnapshot Capture(IReadOnlyList parts,
+ IReadOnlyList clampZones, CancellationToken cancellationToken = default) =>
+ Capture(parts, clampZones, new OverlapMaterialCache(), cancellationToken);
///
/// As , but reuses converted
@@ -31,6 +59,12 @@ public static class PlateOverlapAnalyzer
/// cache before any in-place clean-program edit (see ).
///
public static PlateOverlapSnapshot Capture(IReadOnlyList parts, OverlapMaterialCache cache,
+ CancellationToken cancellationToken = default) =>
+ Capture(parts, null, cache, cancellationToken);
+
+ ///
+ public static PlateOverlapSnapshot Capture(IReadOnlyList parts,
+ IReadOnlyList clampZones, OverlapMaterialCache cache,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(parts);
@@ -64,8 +98,21 @@ public static class PlateOverlapAnalyzer
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
}
}
+ var clamps = new List();
+ if (clampZones != null)
+ {
+ for (var ordinal = 0; ordinal < clampZones.Count; ordinal++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var zone = clampZones[ordinal];
+ // ClampZone validates finiteness and positive size at construction; the
+ // snapshot still owns its own copy so later plate edits never move it.
+ clamps.Add(new CapturedClamp(ordinal, zone.Id,
+ new Box(zone.Bounds.X, zone.Bounds.Y, zone.Bounds.Length, zone.Bounds.Width)));
+ }
+ }
cancellationToken.ThrowIfCancellationRequested();
- return new PlateOverlapSnapshot(captured, issues);
+ return new PlateOverlapSnapshot(captured, issues, clamps);
}
private static OverlapSource CaptureSource(Program program)
@@ -194,9 +241,120 @@ public static class PlateOverlapAnalyzer
}
}
cancellationToken.ThrowIfCancellationRequested();
+ var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
- .ThenBy(issue => issue.PartBId).ToList(), snapshot);
+ .ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings
+ .OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
+ snapshot.CutOffClampFindings.ToList());
+ }
+
+ ///
+ /// C1 part-vs-clamp findings: the prepared hole-aware material against each captured
+ /// zone rectangle with the same exact Collision oracle as part pairs. Deterministic in
+ /// (part input id, clamp ordinal) order; failures become part issues like any other
+ /// pair geometry failure. Empty zone lists return without touching the material.
+ ///
+ private static List FindClampOverlaps(List prepared,
+ PlateOverlapSnapshot snapshot, List issues,
+ CancellationToken cancellationToken)
+ {
+ var findings = new List();
+ if (snapshot.Clamps.Count == 0)
+ return findings;
+
+ foreach (var part in prepared)
+ {
+ var partBounds = part.Material.Outer.BoundingBox;
+ foreach (var clamp in snapshot.Clamps)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var zone = clamp.Bounds;
+ // The prepared material is inscribed at ChordTolerance, so real material can
+ // reach that far past its bounds; a tiny clamp there must still be checked.
+ var reach = KeepOutOverlap.NeedsToleranceFreeTest(zone) ? ChordTolerance : 0;
+ if (partBounds.Right + reach <= zone.Left || zone.Right <= partBounds.Left - reach
+ || partBounds.Top + reach <= zone.Bottom || zone.Top <= partBounds.Bottom - reach)
+ continue;
+ try
+ {
+ // A clamp below the polygon tolerance collapses in Collision.Check and would
+ // read as clear (PM aa995123): judge it tolerance-free and report the whole
+ // (negligibly small) clamp rectangle as the overlap fragment.
+ if (KeepOutOverlap.NeedsToleranceFreeTest(zone))
+ {
+ // Re-read the native contours conservatively and finely enough for
+ // this clamp: the shared 0.001 chords can hide material between an
+ // arc and its chord.
+ var profile = new ShapeProfile(part.Input.Source.Entities.Select(e => e.Clone()).ToList());
+ if (profile.Perimeter == null)
+ throw new ArgumentException("Drawing has no closed material contour.");
+ if (KeepOutOverlap.ConservativePolygons(profile, zone)
+ .Select(polygons => new OverlapMaterial(polygons.Perimeter, polygons.Holes)
+ .Transform(part.Input.Rotation, part.Input.Location))
+ .Any(fine => KeepOutOverlap.MaterialEnters(fine.Outer, fine.Holes, zone)))
+ findings.Add(TinyClampFinding(part, clamp));
+ continue;
+ }
+ var origin = part.Material.Outer.Vertices[0];
+ var local = part.Material.Transform(0, origin * -1);
+ var rect = RectanglePolygon(zone, origin * -1);
+ var result = Collision.Check(local.Outer, rect, local.Holes, null);
+ if (!result.Overlaps)
+ continue;
+ var moments = new List();
+ var regions = new List();
+ foreach (var region in result.OverlapRegions)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
+ throw new ArithmeticException("Clamp overlap fragment area moments are invalid.");
+ moments.Add(fragment);
+ regions.Add(new PlateOverlapRegion(
+ region.Vertices.Select(point => point + origin), fragment.Area));
+ }
+ if (!PolygonAreaMoments.TryCombine(moments, out var combined))
+ throw new ArithmeticException("Combined clamp overlap area moments are invalid.");
+ var centroid = combined.Centroid + origin;
+ if (!OverlapMaterial.IsFinite(centroid))
+ throw new ArithmeticException("Clamp overlap centroid is not finite in world coordinates.");
+ findings.Add(new PlateOverlapPair(part.Input.Id,
+ ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
+ $"Clamp '{clamp.Id}'", regions, centroid));
+ }
+ catch (Exception exception) when (IsGeometryFailure(exception))
+ {
+ issues.Add(new PlateOverlapIssue(part.Input.Id, null, exception.Message));
+ }
+ }
+ }
+
+ return findings;
+ }
+
+ private static PlateOverlapPair TinyClampFinding(PreparedPart part, CapturedClamp clamp)
+ {
+ var zone = clamp.Bounds;
+ var corners = new[]
+ {
+ new Vector(zone.Left, zone.Bottom), new Vector(zone.Right, zone.Bottom),
+ new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
+ };
+ var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
+ return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
+ $"Clamp '{clamp.Id}'", new List { new(corners, zone.Length * zone.Width) }, centre);
+ }
+
+ private static Polygon RectanglePolygon(Box box, Vector offset)
+ {
+ var polygon = new Polygon();
+ polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Bottom + offset.Y));
+ polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Bottom + offset.Y));
+ polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Top + offset.Y));
+ polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Top + offset.Y));
+ polygon.Close();
+ polygon.UpdateBounds();
+ return polygon;
}
///
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
index 900483d..a5fd5b4 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
@@ -8,11 +8,14 @@ namespace OpenNest.Diagnostics;
public sealed class PlateOverlapReport
{
internal PlateOverlapReport(List pairs, List issues,
- PlateOverlapSnapshot snapshot)
+ PlateOverlapSnapshot snapshot, List clampFindings = null,
+ List cutOffClampFindings = null)
{
Pairs = pairs.AsReadOnly();
Issues = issues.AsReadOnly();
Snapshot = snapshot;
+ ClampFindings = (clampFindings ?? new List()).AsReadOnly();
+ CutOffClampFindings = (cutOffClampFindings ?? new List()).AsReadOnly();
}
/// The owned input this report was computed from; the baseline for incremental rechecks.
@@ -20,6 +23,16 @@ public sealed class PlateOverlapReport
public IReadOnlyList Pairs { get; }
public IReadOnlyList Issues { get; }
+
+ ///
+ /// Part-vs-clamp-zone findings (C1): the same owned-fragment shape as part pairs, with a
+ /// negative slot encoding the zone ordinal. Findings
+ /// are reported like other overlap findings; they do not change .
+ ///
+ public IReadOnlyList ClampFindings { get; }
+ /// Nominal cut-off/clamp contacts. Lines have no material area or part pair.
+ public IReadOnlyList CutOffClampFindings { get; }
+
public bool IsComplete => Issues.Count == 0;
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
}
@@ -102,15 +115,32 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message
///
public sealed class PlateOverlapSnapshot
{
- internal PlateOverlapSnapshot(List parts, List issues)
+ internal PlateOverlapSnapshot(List parts, List issues,
+ List clamps = null, List cutOffClampFindings = null)
{
Parts = parts.AsReadOnly();
Issues = issues.AsReadOnly();
+ Clamps = (clamps ?? new List()).AsReadOnly();
+ CutOffClampFindings = (cutOffClampFindings ?? new List()).AsReadOnly();
}
internal IReadOnlyList Parts { get; }
internal IReadOnlyList Issues { get; }
+
+ /// Owned clamp keep-out rectangles in plate coordinates, capture order.
+ internal IReadOnlyList Clamps { get; }
+ internal IReadOnlyList CutOffClampFindings { get; }
}
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
double Rotation, Vector Location);
+
+/// A clamp keep-out rectangle owned by the snapshot; Ordinal is its capture index.
+internal sealed record CapturedClamp(int Ordinal, string Id, Box Bounds);
+
+/// Deterministic negative PartBId for a part-vs-clamp finding's clamp slot.
+internal static class ClampFindingSlot
+{
+ public static int IdFor(int ordinal) => -2 - ordinal;
+ public static int OrdinalOf(int pairPartBId) => -2 - pairPartBId;
+}
diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
index 4110b56..8cb64cf 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
@@ -56,6 +56,51 @@ public static class PostVerificationAnalyzer
}
var materialParts = new List();
var indices = new List();
+ // Stored zones use the sheet's lower-left frame; motions and parts are signed.
+ // Validate them before any early exit: zone bounds are publicly mutable.
+ IReadOnlyList clamps;
+ try
+ {
+ clamps = ClampZoneFrame.ToPlacement(plate);
+ }
+ catch (Exception exception) when (IsInvalid(exception))
+ {
+ clamps = null;
+ findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
+ "Clamp check: " + exception.Message));
+ }
+ // One clamp warning per cut-off part and clamp: the placed-program check below
+ // reports only what the nominal definition check has not already reported.
+ var cutOffClampContacts = new HashSet<(int PartNumber, string ClampId)>();
+ if (clamps != null)
+ {
+ try
+ {
+ foreach (var contact in ClampCutDiagnostics.Find(plate))
+ {
+ var cut = plate.CutOffs[contact.CutOffIndex];
+ var partIndex = -1;
+ for (var partSlot = 0; partSlot < plate.Parts.Count; partSlot++)
+ if (ReferenceEquals(plate.Parts[partSlot]?.BaseDrawing, cut.Drawing))
+ {
+ partIndex = partSlot;
+ break;
+ }
+ if (partIndex >= 0)
+ cutOffClampContacts.Add((partIndex + 1, contact.ClampId));
+ findings.Add(new(PostVerificationKind.Overlap, plateNumber,
+ partIndex < 0 ? null : partIndex + 1, null,
+ $"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. "
+ + "Review the clamp and cut before posting; this warning is not a repair.")
+ { Location = contact.Location, ClampId = contact.ClampId });
+ }
+ }
+ catch (Exception exception) when (IsInvalid(exception))
+ {
+ findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
+ "Clamp/cut-off check: " + exception.Message));
+ }
+ }
var obstacles = new ReleasedContourState();
Vector? position = startPoint;
for (var index = 0; index < plate.Parts.Count; index++)
@@ -95,6 +140,46 @@ public static class PostVerificationAnalyzer
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
Incomplete("Placed program has no cutting contour motions for this drawing.");
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
+ if (clamps is { Count: > 0 })
+ foreach (var move in moves)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!move.Rapid || move.Start is not { } from
+ || from.X == move.End.X && from.Y == move.End.Y)
+ continue;
+ foreach (var zone in clamps)
+ if (ClampCutDiagnostics.Intersects(from, move.End, zone.Bounds, out var contact))
+ findings.Add(new(PostVerificationKind.RapidCrossing, plateNumber,
+ partNumber, null,
+ $"Direct XY rapid crosses active clamp '{zone.Id}'. "
+ + "Head height is not modeled; review the posted route and clamp setup.")
+ {
+ Location = (from + move.End) * 0.5,
+ RapidStart = from,
+ RapidEnd = move.End,
+ ContactPoints = new[] { contact },
+ ClampId = zone.Id
+ });
+ }
+ // The placed program is what posts: a definition edited without
+ // regenerating it can still cut through a clamp its nominal line misses.
+ if (cutoff && clamps is { Count: > 0 })
+ foreach (var move in moves)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (move.Rapid || move.Start is not { } from)
+ continue;
+ foreach (var zone in clamps)
+ if (!cutOffClampContacts.Contains((partNumber, zone.Id))
+ && CutTouches(move, from, zone.Bounds, out var contact))
+ {
+ cutOffClampContacts.Add((partNumber, zone.Id));
+ findings.Add(new(PostVerificationKind.Overlap, plateNumber, partNumber, null,
+ $"The placed cut-off program crosses active clamp '{zone.Id}'. "
+ + "Regenerate or move the cut-off before posting; this warning is not a repair.")
+ { Location = contact, ClampId = zone.Id });
+ }
+ }
position = moves[^1].End;
}
catch (Exception exception) when (IsInvalid(exception))
@@ -110,11 +195,17 @@ public static class PostVerificationAnalyzer
}
cancellationToken.ThrowIfCancellationRequested();
var overlap = PlateOverlapAnalyzer.Analyze(
- PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
+ PlateOverlapAnalyzer.Capture(materialParts, (clamps ?? Array.Empty()).ToArray(),
+ cancellationToken),
+ cancellationToken);
foreach (var pair in overlap.Pairs)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")
{ Location = pair.Centroid, Overlap = pair });
+ foreach (var pair in overlap.ClampFindings)
+ findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
+ null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).")
+ { Location = pair.Centroid, Overlap = pair });
foreach (var issue in overlap.Issues)
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
@@ -123,6 +214,19 @@ public static class PostVerificationAnalyzer
return new PostVerificationReport(findings);
}
+ // Lines are tested exactly; an arc conservatively by its whole supporting extent.
+ private static bool CutTouches(ExecutionMotion move, Vector from, Box zone, out Vector contact)
+ {
+ if (move.Curve == null || move.Curve.IsLine)
+ return ClampCutDiagnostics.Intersects(from, move.End, zone, out contact);
+ var extent = move.Curve.Extent;
+ contact = new Vector(
+ System.Math.Clamp(move.Curve.Midpoint.X, zone.Left, zone.Right),
+ System.Math.Clamp(move.Curve.Midpoint.Y, zone.Bottom, zone.Top));
+ return !(extent.MinX > zone.Right || extent.MaxX < zone.Left
+ || extent.MinY > zone.Top || extent.MaxY < zone.Bottom);
+ }
+
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
index 5e78685..5689a51 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
@@ -147,6 +147,7 @@ internal static class PostVerificationGeometry
private double Radius { get; }
private double Sweep { get; }
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
+ internal bool IsLine => !Center.HasValue;
// Native entities are freshly allocated; the immutable curve never exposes state.
internal Entity ToEntity() => Center is { } center
diff --git a/OpenNest.Core/Diagnostics/PostVerificationReport.cs b/OpenNest.Core/Diagnostics/PostVerificationReport.cs
index b399f83..8b1e6b3 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationReport.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationReport.cs
@@ -24,6 +24,8 @@ public sealed record PostVerificationFinding(PostVerificationKind Kind, int Plat
/// Rapid boundary contacts in travel order; collinear contact spans use their endpoints.
public IReadOnlyList ContactPoints { get; init; } = Array.Empty();
public PlateOverlapPair Overlap { get; init; }
+ /// Active clamp contacted by a cut-off or direct XY rapid, if applicable.
+ public string ClampId { get; init; }
}
/// Owned, immutable findings. Consent is evaluated afresh, never stored.
diff --git a/OpenNest.Core/Geometry/Clearance.cs b/OpenNest.Core/Geometry/Clearance.cs
index ca90a4f..d37eab9 100644
--- a/OpenNest.Core/Geometry/Clearance.cs
+++ b/OpenNest.Core/Geometry/Clearance.cs
@@ -80,6 +80,16 @@ namespace OpenNest.Geometry
return Separation(linesA, linesB);
}
+ /// Distance from a closed boundary's segments to every segment
+ /// of an intentional open cutting path. Unlike polygonizing that path,
+ /// this retains segment interiors and disjoint subpaths.
+ internal static ClearanceResult BoundaryDistance(List closed, List open)
+ {
+ if (closed.Count == 0 || open.Count == 0)
+ return new ClearanceResult(0, new Vector(1, 0));
+ return Separation(closed, open);
+ }
+
///
/// Minimum boundary distance between two non-overlapping rings and the
/// direction that translates away from
diff --git a/OpenNest.Core/Geometry/KeepOutOverlap.cs b/OpenNest.Core/Geometry/KeepOutOverlap.cs
new file mode 100644
index 0000000..3264436
--- /dev/null
+++ b/OpenNest.Core/Geometry/KeepOutOverlap.cs
@@ -0,0 +1,161 @@
+using System.Collections.Generic;
+
+namespace OpenNest.Geometry;
+
+///
+/// Tolerance-free test of material against an axis-aligned keep-out rectangle, for
+/// rectangles too small for the tolerance-based overlap machinery (validation offsets,
+/// Clipper grids, triangulation and vector equality work at 1e-3 to 1e-5 scale and collapse
+/// such a rectangle, PM 6859db53). Positive-area overlap only: flush contact is clear, as
+/// everywhere else.
+///
+public static class KeepOutOverlap
+{
+ /// The scale below which tolerance-based overlap tests are not trusted for a
+ /// keep-out rectangle (the validation outline chord tolerance).
+ public const double ToleranceFreeBelow = 0.001;
+
+ /// A keep-out narrower than on either axis.
+ public static bool NeedsToleranceFreeTest(Box keepOut) =>
+ keepOut.Length < ToleranceFreeBelow || keepOut.Width < ToleranceFreeBelow;
+
+ ///
+ /// Chord tolerance for flattening real part material against :
+ /// a quarter of its smaller side, at most .
+ ///
+ public static double FlatteningTolerance(Box keepOut) =>
+ System.Math.Min(ToleranceFreeBelow, System.Math.Min(keepOut.Length, keepOut.Width) / 4);
+
+ ///
+ /// A part's native contours flattened two ways for a tiny keep-out, never through a
+ /// grid-rounding offset: lines exact, every arc (perimeter and cutouts) inscribed in the
+ /// first and circumscribed in the second, at . Along a
+ /// convex arc the circumscribed version holds all real material, along a concave arc
+ /// (a notch, a round hole) the inscribed one does, so together they hold all of it and
+ /// testing both finds any real overlap. The price is a possible report within that
+ /// tolerance of an arc, never a false clear.
+ ///
+ public static IEnumerable<(Polygon Perimeter, List Holes)> ConservativePolygons(
+ ShapeProfile profile, Box keepOut)
+ {
+ var tolerance = FlatteningTolerance(keepOut);
+ foreach (var circumscribe in new[] { false, true })
+ {
+ var perimeter = profile.Perimeter.ToPolygonWithTolerance(tolerance, circumscribe);
+ var holes = new List(profile.Cutouts.Count);
+ foreach (var cutout in profile.Cutouts)
+ holes.Add(cutout.ToPolygonWithTolerance(tolerance, circumscribe));
+ yield return (perimeter, holes);
+ }
+ }
+
+ ///
+ /// True when either of the , translated by
+ /// , enters .
+ ///
+ public static bool ProfileEnters(ShapeProfile profile, Vector offset, Box keepOut)
+ {
+ foreach (var (perimeter, holes) in ConservativePolygons(profile, keepOut))
+ {
+ perimeter.Offset(offset);
+ foreach (var hole in holes)
+ hole.Offset(offset);
+ if (MaterialEnters(perimeter, holes, keepOut))
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// True when the material (perimeter minus holes) shares positive area with the open
+ /// rectangle: some boundary edge runs through its interior, or its interior lies wholly
+ /// inside the material (tested at its centre).
+ ///
+ public static bool MaterialEnters(Polygon perimeter, IReadOnlyList holes, Box box)
+ {
+ if (RingEnters(perimeter, box))
+ return true;
+ if (holes != null)
+ foreach (var hole in holes)
+ if (RingEnters(hole, box))
+ return true;
+
+ var centre = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
+ if (!Contains(perimeter, centre))
+ return false;
+ if (holes != null)
+ foreach (var hole in holes)
+ if (Contains(hole, centre))
+ return false;
+ return true;
+ }
+
+ private static bool RingEnters(Polygon ring, Box box)
+ {
+ var vertices = ring.Vertices;
+ var count = vertices.Count;
+ for (var i = 0; i < count; i++)
+ {
+ var next = vertices[(i + 1) % count];
+ if (SegmentEntersOpenBox(vertices[i], next, box))
+ return true;
+ }
+ return false;
+ }
+
+ /// Liang-Barsky clip against the open rectangle; a positive-length part of the
+ /// segment must lie strictly inside it.
+ private static bool SegmentEntersOpenBox(Vector p, Vector q, Box box)
+ {
+ var dx = q.X - p.X;
+ var dy = q.Y - p.Y;
+ var t0 = 0.0;
+ var t1 = 1.0;
+ return Clip(-dx, p.X - box.Left, ref t0, ref t1)
+ && Clip(dx, box.Right - p.X, ref t0, ref t1)
+ && Clip(-dy, p.Y - box.Bottom, ref t0, ref t1)
+ && Clip(dy, box.Top - p.Y, ref t0, ref t1)
+ && t0 < t1;
+ }
+
+ /// Restricts [t0, t1] to the parameters where denominator * t < numerator.
+ private static bool Clip(double denominator, double numerator, ref double t0, ref double t1)
+ {
+ if (denominator == 0)
+ return numerator > 0;
+ var r = numerator / denominator;
+ if (denominator < 0)
+ {
+ if (r >= t1)
+ return false;
+ if (r > t0)
+ t0 = r;
+ }
+ else
+ {
+ if (r <= t0)
+ return false;
+ if (r < t1)
+ t1 = r;
+ }
+ return true;
+ }
+
+ /// Even-odd ray cast; exact for a point that is not on the ring (callers only
+ /// ask after ruling out any edge through the rectangle containing the point).
+ private static bool Contains(Polygon ring, Vector point)
+ {
+ var inside = false;
+ var vertices = ring.Vertices;
+ var count = vertices.Count;
+ for (int i = 0, j = count - 1; i < count; j = i++)
+ {
+ var a = vertices[i];
+ var b = vertices[j];
+ if ((a.Y > point.Y) != (b.Y > point.Y)
+ && point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
+ inside = !inside;
+ }
+ return inside;
+ }
+}
diff --git a/OpenNest.Core/Geometry/OpenPathMaterial.cs b/OpenNest.Core/Geometry/OpenPathMaterial.cs
new file mode 100644
index 0000000..2f2ad8c
--- /dev/null
+++ b/OpenNest.Core/Geometry/OpenPathMaterial.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+
+namespace OpenNest.Geometry;
+
+///
+/// Strict positive-length entry of an open native line path into polygon material.
+/// Boundary-only contact is not entry; holes subtract material. This deliberately
+/// does not infer material from a path's bounding box or close the open path.
+/// Curved contours are flattened by the caller, which must handle chord uncertainty.
+///
+internal static class OpenPathMaterial
+{
+ public static bool Enters(Polygon outer, IReadOnlyList holes, IReadOnlyList path)
+ {
+ if (outer?.Vertices.Count < 4 || path == null)
+ return true; // Unresolved geometry is not a certificate of clear material.
+
+ foreach (var line in path)
+ {
+ var start = line.StartPoint;
+ var end = line.EndPoint;
+ var travel = end - start;
+ var lengthSquared = travel.DotProduct(travel);
+ if (!double.IsFinite(lengthSquared) || lengthSquared <= 0)
+ return true;
+
+ var cuts = new List { 0, 1 };
+ CollectCuts(outer, start, travel, lengthSquared, cuts);
+ if (holes != null)
+ foreach (var hole in holes)
+ CollectCuts(hole, start, travel, lengthSquared, cuts);
+ cuts.Sort();
+ for (var i = 1; i < cuts.Count; i++)
+ {
+ if (cuts[i] <= cuts[i - 1])
+ continue;
+ var midpoint = start + travel * ((cuts[i - 1] + cuts[i]) / 2);
+ if (!StrictlyInside(outer, midpoint))
+ continue;
+ var inHole = false;
+ if (holes != null)
+ foreach (var hole in holes)
+ if (InsideOrOnBoundary(hole, midpoint))
+ {
+ inHole = true;
+ break;
+ }
+ if (!inHole)
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void CollectCuts(Polygon ring, Vector start, Vector travel,
+ double lengthSquared, List cuts)
+ {
+ if (ring?.Vertices.Count < 4)
+ {
+ // A degenerate hole cannot be subtracted reliably. The caller's
+ // unresolved-topology guard handles this case before this helper.
+ return;
+ }
+ var vertices = ring.Vertices;
+ for (var i = 1; i < vertices.Count; i++)
+ {
+ var a = vertices[i - 1];
+ var edge = vertices[i] - a;
+ var difference = a - start;
+ var denominator = Cross(travel, edge);
+ if (denominator == 0)
+ {
+ if (Cross(difference, travel) != 0)
+ continue;
+ AddParameter(difference.DotProduct(travel) / lengthSquared, cuts);
+ AddParameter((vertices[i] - start).DotProduct(travel) / lengthSquared, cuts);
+ continue;
+ }
+ var t = Cross(difference, edge) / denominator;
+ var u = Cross(difference, travel) / denominator;
+ if (u >= -1e-12 && u <= 1 + 1e-12)
+ AddParameter(t, cuts);
+ }
+ }
+
+ private static void AddParameter(double t, List cuts)
+ {
+ if (double.IsFinite(t) && t >= -1e-12 && t <= 1 + 1e-12)
+ cuts.Add(System.Math.Clamp(t, 0, 1));
+ }
+
+ private static bool StrictlyInside(Polygon ring, Vector point) =>
+ Contains(ring, point, boundaryIsInside: false);
+
+ private static bool InsideOrOnBoundary(Polygon ring, Vector point) =>
+ Contains(ring, point, boundaryIsInside: true);
+
+ private static bool Contains(Polygon ring, Vector point, bool boundaryIsInside)
+ {
+ var vertices = ring.Vertices;
+ var inside = false;
+ for (var i = 1; i < vertices.Count; i++)
+ {
+ var a = vertices[i - 1];
+ var b = vertices[i];
+ var edge = b - a;
+ var relative = point - a;
+ if (Cross(edge, relative) == 0 &&
+ point.X >= System.Math.Min(a.X, b.X) && point.X <= System.Math.Max(a.X, b.X) &&
+ point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y))
+ return boundaryIsInside;
+ if ((a.Y > point.Y) != (b.Y > point.Y) &&
+ point.X < a.X + (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y))
+ inside = !inside;
+ }
+ return inside;
+ }
+
+ private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
+}
diff --git a/OpenNest.Core/Nest.cs b/OpenNest.Core/Nest.cs
index 2f9d1ca..6d288c8 100644
--- a/OpenNest.Core/Nest.cs
+++ b/OpenNest.Core/Nest.cs
@@ -65,6 +65,21 @@ namespace OpenNest
return plate;
}
+ ///
+ /// Makes this nest's machine clamp rail: new plates follow it
+ /// () and every existing plate re-resolves its
+ /// zones from it. Null, or a rail without clamps, removes the rail and every plate's
+ /// zones; the rail is nest-wide, so per-plate zones are replaced either way.
+ ///
+ public void SetClampRail(ClampRail rail)
+ {
+ if (rail != null && rail.Centers.Count == 0)
+ rail = null;
+ PlateDefaults.ClampRail = rail;
+ foreach (var plate in Plates)
+ plate.SetClampRail(rail);
+ }
+
public void UpdateDrawingQuantities()
{
foreach (var drawing in Drawings)
@@ -114,6 +129,12 @@ namespace OpenNest
set { plate.PartSpacing = value; }
}
+ ///
+ /// The machine clamp rail new plates follow, or null for none. Set through
+ /// to also update existing plates.
+ ///
+ public ClampRail ClampRail { get; set; }
+
public void SetFromExisting(Plate plate)
{
Quadrant = plate.Quadrant;
@@ -124,7 +145,7 @@ namespace OpenNest
public Plate CreateNew()
{
- return new Plate()
+ var created = new Plate()
{
Size = Size,
EdgeSpacing = EdgeSpacing,
@@ -132,6 +153,9 @@ namespace OpenNest
Quadrant = Quadrant,
Quantity = 1,
};
+ if (ClampRail != null)
+ created.SetClampRail(ClampRail);
+ return created;
}
}
}
diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs
index 0f3cf96..31f9228 100644
--- a/OpenNest.Core/Plate.cs
+++ b/OpenNest.Core/Plate.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Collections.Immutable;
using System.Linq;
using OpenNest.Collections;
using OpenNest.Geometry;
@@ -55,6 +56,10 @@ namespace OpenNest
Parts.ItemAdded += Parts_PartAdded;
Parts.ItemRemoved += Parts_PartRemoved;
CutOffs = new ObservableList();
+ ClampZones = new ObservableList();
+ ClampZones.ItemAdded += (_, _) => OnClampZonesChanged();
+ ClampZones.ItemRemoved += (_, _) => OnClampZonesChanged();
+ ClampZones.ItemChanged += (_, _) => OnClampZonesChanged();
Quadrant = 1;
}
@@ -81,9 +86,20 @@ namespace OpenNest
public Spacing EdgeSpacing;
///
- /// The size of the plate.
+ /// The size of the plate. A plate following a re-resolves
+ /// its clamp zones for the new size.
///
- public Size Size { get; set; }
+ public Size Size
+ {
+ get { return size; }
+ set
+ {
+ size = value;
+ ResolveClampRail();
+ }
+ }
+
+ private Size size;
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
@@ -102,6 +118,106 @@ namespace OpenNest
///
public ObservableList CutOffs { get; set; }
+ ///
+ /// Fixed rectangular machine-clamp keep-outs in plate coordinates
+ /// (). Never parts: excluded from quantity, utilization,
+ /// cut sequence and CNC output; placement rules and display consume them. Mutate
+ /// through this list; fires for every change.
+ ///
+ public ObservableList ClampZones { get; }
+
+ ///
+ /// Raised after content changes so views can refresh
+ /// the zone overlay. Carries no geometry arguments.
+ ///
+ public event EventHandler? ClampZonesChanged;
+
+ private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
+
+ ///
+ /// The machine clamp rail this plate follows, or null when its
+ /// are explicit (engine sheets, files without a rail). While
+ /// set, the zones are derived: re-resolved whenever or
+ /// changes, because the clamps are fixed to the machine.
+ ///
+ public ClampRail ClampRail { get; private set; }
+
+ private static readonly IReadOnlySet NoRemovedClamps = ImmutableHashSet.Empty;
+
+ ///
+ /// Rail clamp ids taken off this sheet as a last resort (D8) so a part that could not
+ /// be nested anywhere with every clamp in place still fits. Empty unless
+ /// is set; already excludes them, so
+ /// nothing else needs to consult this list to avoid them. Ids stay listed across a
+ /// resize/rotate even when the matching clamp is temporarily beyond the sheet, so it
+ /// re-appears removed if a later resize brings it back onto the sheet (C13).
+ ///
+ public IReadOnlySet RemovedClamps { get; private set; } = NoRemovedClamps;
+
+ ///
+ /// Minutes this sheet's cut-time estimate gains from (D9):
+ /// each listed clamp the rail places on this sheet adds
+ /// once; a listed clamp that is beyond
+ /// the sheet (or unknown to the rail) is not pulled and costs nothing. Zero when there
+ /// is no rail (removal is meaningless without one to remove clamps from).
+ ///
+ public double ClampRemovalMinutes =>
+ ClampRail == null || RemovedClamps.Count == 0
+ ? 0
+ : ClampRail.ResolveFor(Size, Quadrant).Count(zone => RemovedClamps.Contains(zone.Id))
+ * ClampRail.RemovalMinutes;
+
+ ///
+ /// Follows and replaces the zones with its resolution for
+ /// this sheet; null detaches and removes every zone. Unchanged zones raise no events.
+ ///
+ public void SetClampRail(ClampRail rail) => SetClampRail(rail, null);
+
+ ///
+ /// Follows with the clamps named in
+ /// taken off this sheet (D8): zones become the rail's resolution for this sheet minus
+ /// those ids. Null detaches, clears every zone and forgets the
+ /// removed set (removal only means something while following a rail).
+ ///
+ public void SetClampRail(ClampRail rail, IEnumerable removedClamps)
+ {
+ ClampRail = rail;
+ RemovedClamps = rail == null || removedClamps == null
+ ? NoRemovedClamps
+ : removedClamps.ToImmutableHashSet(StringComparer.Ordinal);
+ ReplaceClampZones(ResolveRailZones());
+ }
+
+ private void ResolveClampRail()
+ {
+ // Called from the constructor's Size assignment before the zone list exists.
+ if (ClampRail != null)
+ ReplaceClampZones(ResolveRailZones());
+ }
+
+ private IReadOnlyList ResolveRailZones()
+ {
+ if (ClampRail == null)
+ return Array.Empty();
+ var resolved = ClampRail.ResolveFor(Size, Quadrant);
+ if (RemovedClamps.Count == 0)
+ return resolved;
+ var kept = new List(resolved.Count);
+ foreach (var zone in resolved)
+ if (!RemovedClamps.Contains(zone.Id))
+ kept.Add(zone);
+ return kept;
+ }
+
+ private void ReplaceClampZones(IReadOnlyList zones)
+ {
+ if (ClampRail.SameZones(ClampZones, zones))
+ return;
+ ClampZones.Clear();
+ foreach (var zone in zones)
+ ClampZones.Add(zone);
+ }
+
///
/// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is
@@ -236,7 +352,11 @@ namespace OpenNest
public int Quadrant
{
get { return quadrant; }
- set { quadrant = value <= 4 && value > 0 ? value : 1; }
+ set
+ {
+ quadrant = value <= 4 && value > 0 ? value : 1;
+ ResolveClampRail();
+ }
}
///
diff --git a/OpenNest.Core/PlateManager.cs b/OpenNest.Core/PlateManager.cs
index 01d4cd8..a70ca2d 100644
--- a/OpenNest.Core/PlateManager.cs
+++ b/OpenNest.Core/PlateManager.cs
@@ -119,6 +119,50 @@ namespace OpenNest
SubscribeToTailPlates();
}
+ ///
+ /// Applies a nest-defaults edit, moving only an untouched trailing empty sheet
+ /// that matched the old defaults to the new defaults.
+ ///
+ public void ChangePlateDefaults(Action changeDefaults)
+ {
+ ArgumentNullException.ThrowIfNull(changeDefaults);
+ var defaults = nest.PlateDefaults;
+ var trailing = Count > 0 ? nest.Plates[^1] : null;
+ // Exact configuration equality, not geometric tolerance: even a small edit
+ // makes this configured stock rather than the untouched workspace sentinel.
+ var followsDefaults = trailing != null
+ && trailing.Parts.Count == 0
+ && trailing.CutOffs.Count == 0
+ && trailing.Size.Width == defaults.Size.Width
+ && trailing.Size.Length == defaults.Size.Length
+ && trailing.Quadrant == defaults.Quadrant
+ && trailing.EdgeSpacing.Left == defaults.EdgeSpacing.Left
+ && trailing.EdgeSpacing.Bottom == defaults.EdgeSpacing.Bottom
+ && trailing.EdgeSpacing.Right == defaults.EdgeSpacing.Right
+ && trailing.EdgeSpacing.Top == defaults.EdgeSpacing.Top
+ && trailing.PartSpacing == defaults.PartSpacing
+ && trailing.RemovedClamps.Count == 0
+ && Equals(trailing.ClampRail, defaults.ClampRail)
+ && (defaults.ClampRail == null
+ ? trailing.ClampZones.Count == 0
+ : defaults.ClampRail.Matches(trailing));
+
+ changeDefaults();
+
+ if (!followsDefaults)
+ return;
+
+ defaults = nest.PlateDefaults;
+ trailing.Size = defaults.Size;
+ trailing.Quadrant = defaults.Quadrant;
+ trailing.EdgeSpacing = defaults.EdgeSpacing;
+ trailing.PartSpacing = defaults.PartSpacing;
+ trailing.SetClampRail(defaults.ClampRail);
+ PlateListChanged?.Invoke(this, EventArgs.Empty);
+ if (ReferenceEquals(CurrentPlate, trailing))
+ FireCurrentPlateChanged();
+ }
+
public void BeginBatch()
{
batching = true;
diff --git a/OpenNest.Core/Timing.cs b/OpenNest.Core/Timing.cs
index 0d6e628..338bde9 100644
--- a/OpenNest.Core/Timing.cs
+++ b/OpenNest.Core/Timing.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Linq;
using OpenNest.Api;
using OpenNest.CNC;
@@ -43,6 +43,10 @@ namespace OpenNest
pos = endpt;
}
+ // D9: once per physical sheet, before the quantity multiply below scales it with
+ // everything else cut on that sheet.
+ info.ClampRemovalMinutes = plate.ClampRemovalMinutes;
+
info *= plate.Quantity;
return info;
@@ -91,6 +95,9 @@ namespace OpenNest
time += TimeSpan.FromTicks(info.PierceCount * cutParams.PierceTime.Ticks);
+ // D9: a flat per-removed-clamp time, already in minutes regardless of cutParams.Units.
+ time += TimeSpan.FromMinutes(info.ClampRemovalMinutes);
+
return time;
}
}
diff --git a/OpenNest.Core/TimingInfo.cs b/OpenNest.Core/TimingInfo.cs
index b2bdf81..8a3e97c 100644
--- a/OpenNest.Core/TimingInfo.cs
+++ b/OpenNest.Core/TimingInfo.cs
@@ -10,6 +10,13 @@ namespace OpenNest
public double CutDistance;
+ ///
+ /// Minutes added by last-resort clamp removal (D9): once per physical sheet, for
+ /// every clamp lists. Zero for a plate with no
+ /// removal, so existing timing is unaffected.
+ ///
+ public double ClampRemovalMinutes;
+
public static TimingInfo operator +(TimingInfo info1, TimingInfo info2)
{
return new TimingInfo
@@ -18,6 +25,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount + info2.IntersectionCount,
TravelDistance = info1.TravelDistance + info2.TravelDistance,
PierceCount = info1.PierceCount + info2.PierceCount,
+ ClampRemovalMinutes = info1.ClampRemovalMinutes + info2.ClampRemovalMinutes,
};
}
@@ -29,6 +37,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount - info2.IntersectionCount,
TravelDistance = info1.TravelDistance - info2.TravelDistance,
PierceCount = info1.PierceCount - info2.PierceCount,
+ ClampRemovalMinutes = info1.ClampRemovalMinutes - info2.ClampRemovalMinutes,
};
}
@@ -40,6 +49,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount * info2.IntersectionCount,
TravelDistance = info1.TravelDistance * info2.TravelDistance,
PierceCount = info1.PierceCount * info2.PierceCount,
+ ClampRemovalMinutes = info1.ClampRemovalMinutes * info2.ClampRemovalMinutes,
};
}
@@ -51,6 +61,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount * factor,
TravelDistance = info1.TravelDistance * factor,
PierceCount = info1.PierceCount * factor,
+ ClampRemovalMinutes = info1.ClampRemovalMinutes * factor,
};
}
}
diff --git a/OpenNest.Data/ClampRailEditor.cs b/OpenNest.Data/ClampRailEditor.cs
new file mode 100644
index 0000000..5f210f8
--- /dev/null
+++ b/OpenNest.Data/ClampRailEditor.cs
@@ -0,0 +1,132 @@
+namespace OpenNest.Data;
+
+///
+/// Decision logic behind the desktop Clamps dialog: edits one nest's clamp rail in that
+/// nest's units. Three explicit boundaries: only fills
+/// the editor, changes the nest (the dialog's OK), and
+/// is what "Save as Machine Defaults" writes immediately.
+///
+public sealed class ClampRailEditor
+{
+ private readonly List _centers = new();
+
+ /// Starts from the nest's rail, or the default clamp size with no clamps.
+ public ClampRailEditor(ClampRail? rail, Units units, bool applyToNewNests)
+ {
+ Units = units;
+ ApplyToNewNests = applyToNewNests;
+ var initial = ClampSettings.FromRail(rail, units, applyToNewNests);
+ Width = initial.Width;
+ Depth = initial.Depth;
+ RemovalMinutes = initial.RemovalMinutes;
+ _centers.AddRange(initial.Centers);
+ }
+
+ /// The nest's units; every value is in these units.
+ public Units Units { get; }
+
+ /// Clamp extent along the edge (X).
+ public double Width { get; set; }
+
+ /// Clamp intrusion into the sheet (Y).
+ public double Depth { get; set; }
+
+ /// Minutes to remove one clamp and put it back after cutting (D9); not a
+ /// length, so it is never converted between units.
+ public double RemovalMinutes { get; set; }
+
+ /// Whether saved machine defaults give new nests these clamps.
+ public bool ApplyToNewNests { get; set; }
+
+ /// Clamp centers along X from the plate origin, ascending and distinct.
+ public IReadOnlyList Centers => _centers;
+
+ ///
+ /// Adds a clamp center, keeping the list sorted. Refuses (with an operator-facing
+ /// reason) values that are not finite, negative, or already listed.
+ ///
+ public bool TryAddCenter(double center, out string? error)
+ {
+ if (!double.IsFinite(center) || center < 0)
+ {
+ error = "Enter a clamp center measured from the plate origin (zero or more).";
+ return false;
+ }
+
+ var index = _centers.BinarySearch(center);
+ if (index >= 0)
+ {
+ error = $"A clamp is already centered at {center:0.####}.";
+ return false;
+ }
+
+ _centers.Insert(~index, center);
+ error = null;
+ return true;
+ }
+
+ public void RemoveCenterAt(int index)
+ {
+ if (index >= 0 && index < _centers.Count)
+ _centers.RemoveAt(index);
+ }
+
+ ///
+ /// Replaces the editor's values with the machine defaults converted to this nest's
+ /// units. Nothing is applied or saved.
+ ///
+ public void LoadMachineDefaults(ClampSettings settings)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ var converted = settings.ConvertedTo(Units);
+ Width = converted.Width;
+ Depth = converted.Depth;
+ RemovalMinutes = converted.RemovalMinutes;
+ ApplyToNewNests = settings.Enabled;
+ _centers.Clear();
+ _centers.AddRange(converted.Centers.Distinct().OrderBy(c => c));
+ }
+
+ ///
+ /// The edited rail, or null when no clamp is listed. Throws
+ /// for a non-positive width or depth, or a
+ /// negative/non-finite removal time.
+ ///
+ public ClampRail? BuildRail() =>
+ _centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers, RemovalMinutes);
+
+ /// The dialog's OK: makes the edited rail the nest's, re-resolving every plate.
+ public void ApplyTo(Nest nest)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ nest.SetClampRail(BuildRail());
+ }
+
+ ///
+ /// The machine settings "Save as Machine Defaults" writes: these values in this nest's
+ /// units, enabled for new nests only when requested and at least one clamp is listed.
+ ///
+ public ClampSettings ToMachineDefaults()
+ {
+ // Validate before anything is persisted.
+ if (!double.IsFinite(Width) || Width <= 0 || !double.IsFinite(Depth) || Depth <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(Width),
+ "Clamp width and depth must be positive."
+ );
+ if (!double.IsFinite(RemovalMinutes) || RemovalMinutes < 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(RemovalMinutes),
+ "Clamp removal time must be finite and zero or more."
+ );
+ return new ClampSettings
+ {
+ Enabled = ApplyToNewNests && _centers.Count > 0,
+ Units = Units,
+ Width = Width,
+ Depth = Depth,
+ RemovalMinutes = RemovalMinutes,
+ Centers = _centers.ToArray(),
+ };
+ }
+}
diff --git a/OpenNest.Data/ClampSettings.cs b/OpenNest.Data/ClampSettings.cs
new file mode 100644
index 0000000..eb71bd0
--- /dev/null
+++ b/OpenNest.Data/ClampSettings.cs
@@ -0,0 +1,334 @@
+using System.Text.Json;
+
+namespace OpenNest.Data;
+
+/// Outcome of .
+public enum ClampSettingsStatus
+{
+ /// Every value present in the file was valid.
+ Ok,
+
+ /// No file exists at the path; clamps stay off.
+ Missing,
+
+ ///
+ /// The file loaded, but some values were invalid and were ignored (an invalid clamp
+ /// center is dropped, other fields fall back individually). Callers should warn: the
+ /// resulting rail may lack a physical clamp.
+ ///
+ Partial,
+
+ /// The file exists but could not be read or parsed; clamps stay off.
+ Invalid,
+}
+
+///
+/// The machine's fixed edge-clamp rail, persisted to a single JSON file (by default
+/// %APPDATA%\OpenNest\clamps.json). Off by default: until is set
+/// with at least one center, new nests get no clamps and behave exactly as before.
+/// Loading never throws; values are validated field by field.
+///
+public sealed class ClampSettings
+{
+ public const int CurrentVersion = 1;
+
+ /// Default clamp extent along the edge, in (inches).
+ public const double DefaultWidth = 1.5;
+
+ /// Default clamp intrusion into the sheet, in (inches).
+ public const double DefaultDepth = 0.625;
+
+ private const double MillimetersPerInch = 25.4;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ /// Whether new nests receive this rail. Default off.
+ public bool Enabled { get; set; }
+
+ /// Units of , and .
+ public Units Units { get; set; } = Units.Inches;
+
+ public double Width { get; set; } = DefaultWidth;
+
+ public double Depth { get; set; } = DefaultDepth;
+
+ ///
+ /// Minutes to remove one clamp and put it back after cutting (D9), stored and edited in
+ /// the machine clamp settings alongside the rail's physical size.
+ ///
+ public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
+
+ /// Clamp centers along X, measured from the plate origin.
+ public IReadOnlyList Centers { get; set; } = Array.Empty();
+
+ /// The built-in settings used for a missing or unreadable file: clamps off.
+ public static ClampSettings Fallback => new();
+
+ /// %APPDATA%\OpenNest\clamps.json.
+ public static string DefaultPath =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenNest",
+ "clamps.json"
+ );
+
+ ///
+ /// Captures a nest's rail (or the default clamp size and no centers when it has none)
+ /// in that nest's units, e.g. for "save as machine defaults".
+ ///
+ public static ClampSettings FromRail(ClampRail? rail, Units units, bool enabled) =>
+ new()
+ {
+ Enabled = enabled,
+ Units = units,
+ Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
+ Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
+ RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
+ Centers = rail?.Centers.ToArray() ?? Array.Empty(),
+ };
+
+ ///
+ /// The rail a new nest in receives, or null when clamps are
+ /// disabled or no center is configured. Values are converted between inches and
+ /// millimeters as needed; is a time, so it is never scaled.
+ ///
+ public ClampRail? RailFor(Units units)
+ {
+ if (!Enabled || Centers.Count == 0)
+ return null;
+
+ var converted = ConvertedTo(units);
+ return new ClampRail(converted.Width, converted.Depth, converted.Centers, converted.RemovalMinutes);
+ }
+
+ ///
+ /// A copy with width, depth and centers expressed in
+ /// (inches and millimeters convert at 25.4); and
+ /// (a time, not a length) are kept unscaled.
+ ///
+ public ClampSettings ConvertedTo(Units units)
+ {
+ var scale = Scale(Units, units);
+ return new ClampSettings
+ {
+ Enabled = Enabled,
+ Units = units,
+ Width = Width * scale,
+ Depth = Depth * scale,
+ RemovalMinutes = RemovalMinutes,
+ Centers = Centers.Select(c => c * scale).ToArray(),
+ };
+ }
+
+ ///
+ /// Gives a newly created nest this machine rail when enabled (after the nest's units are
+ /// set); otherwise leaves it untouched.
+ ///
+ public void ApplyToNewNest(Nest nest)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ var rail = RailFor(nest.Units);
+ if (rail != null)
+ nest.SetClampRail(rail);
+ }
+
+ public static ClampSettings Load(string path) => Load(path, out _);
+
+ ///
+ /// Loads the settings, never throwing. A missing or unreadable file yields
+ /// (clamps off); a readable file merges valid values over it and
+ /// reports if anything was ignored.
+ ///
+ public static ClampSettings Load(string path, out ClampSettingsStatus status)
+ {
+ var settings = Fallback;
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ {
+ status = ClampSettingsStatus.Missing;
+ return settings;
+ }
+
+ // Read as a raw document so a missing field (fallback, Ok) is distinguishable from a
+ // present but unusable one, including an explicit null (fallback, Partial).
+ Dictionary values;
+ try
+ {
+ using var document = JsonDocument.Parse(File.ReadAllText(path));
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ status = ClampSettingsStatus.Invalid;
+ return settings;
+ }
+ values = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var property in document.RootElement.EnumerateObject())
+ values[property.Name] = property.Value.Clone();
+ }
+ catch (Exception ex) when (IsUnreadableFile(ex))
+ {
+ status = ClampSettingsStatus.Invalid;
+ return settings;
+ }
+
+ var ignored = false;
+
+ if (values.TryGetValue("enabled", out var enabled))
+ {
+ if (enabled.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ settings.Enabled = enabled.GetBoolean();
+ else
+ ignored = true;
+ }
+
+ if (values.TryGetValue("units", out var units))
+ {
+ if (TryParseUnits(units, out var parsed))
+ settings.Units = parsed;
+ else
+ ignored = true;
+ }
+
+ // Fallback sizes are the physical default clamp, expressed in the file's units.
+ settings.Width = ConvertDefault(DefaultWidth, settings.Units);
+ settings.Depth = ConvertDefault(DefaultDepth, settings.Units);
+
+ if (values.TryGetValue("width", out var width))
+ {
+ if (TryLength(width, out var value) && value > 0)
+ settings.Width = value;
+ else
+ ignored = true;
+ }
+
+ if (values.TryGetValue("depth", out var depth))
+ {
+ if (TryLength(depth, out var value) && value > 0)
+ settings.Depth = value;
+ else
+ ignored = true;
+ }
+
+ if (values.TryGetValue("removalMinutes", out var removalMinutes))
+ {
+ if (TryLength(removalMinutes, out var value) && value >= 0)
+ settings.RemovalMinutes = value;
+ else
+ ignored = true;
+ }
+
+ if (values.TryGetValue("centers", out var centers))
+ {
+ if (centers.ValueKind == JsonValueKind.Array)
+ {
+ var valid = new List();
+ foreach (var center in centers.EnumerateArray())
+ {
+ if (TryLength(center, out var x) && x >= 0)
+ valid.Add(x);
+ else
+ ignored = true;
+ }
+ settings.Centers = valid.Distinct().OrderBy(x => x).ToArray();
+ }
+ else
+ {
+ ignored = true;
+ }
+ }
+
+ status = ignored ? ClampSettingsStatus.Partial : ClampSettingsStatus.Ok;
+ return settings;
+ }
+
+ ///
+ /// Writes the file (creating the parent directory), retrying briefly on IO collisions
+ /// the same way does.
+ ///
+ public void Save(string path, int maxRetries = 3)
+ {
+ var dto = new
+ {
+ version = CurrentVersion,
+ enabled = Enabled,
+ units = Units.ToString().ToLowerInvariant(),
+ width = Width,
+ depth = Depth,
+ removalMinutes = RemovalMinutes,
+ centers = Centers,
+ };
+ var json = JsonSerializer.Serialize(dto, JsonOptions);
+
+ var directory = Path.GetDirectoryName(Path.GetFullPath(path));
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ for (var attempt = 0; attempt < maxRetries; attempt++)
+ {
+ try
+ {
+ File.WriteAllText(path, json);
+ return;
+ }
+ catch (IOException) when (attempt < maxRetries - 1)
+ {
+ Thread.Sleep(100);
+ }
+ }
+ }
+
+ private static double Scale(Units from, Units to) =>
+ from == to ? 1
+ : from == Units.Inches ? MillimetersPerInch
+ : 1 / MillimetersPerInch;
+
+ private static double ConvertDefault(double inches, Units units) =>
+ inches * Scale(Units.Inches, units);
+
+ ///
+ /// A finite JSON number that stays finite in either unit, so applying it to an inch or a
+ /// millimeter nest can never overflow.
+ ///
+ private static bool TryLength(JsonElement element, out double value)
+ {
+ value = 0;
+ return element.ValueKind == JsonValueKind.Number
+ && element.TryGetDouble(out value)
+ && double.IsFinite(value)
+ && double.IsFinite(value * MillimetersPerInch);
+ }
+
+ ///
+ /// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept
+ /// numeric strings and comma-joined names.
+ ///
+ private static bool TryParseUnits(JsonElement element, out Units units)
+ {
+ units = default;
+ if (element.ValueKind != JsonValueKind.String)
+ return false;
+
+ var text = element.GetString();
+ foreach (var candidate in Enum.GetValues())
+ {
+ if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
+ {
+ units = candidate;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool IsUnreadableFile(Exception ex) =>
+ ex
+ is JsonException
+ or IOException
+ or UnauthorizedAccessException
+ or NotSupportedException
+ or System.Security.SecurityException;
+}
diff --git a/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs b/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs
new file mode 100644
index 0000000..b84acfd
--- /dev/null
+++ b/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs
@@ -0,0 +1,197 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.Engine.Fill;
+using OpenNest.Geometry;
+using Xunit;
+
+namespace OpenNest.Engine.Tests.Fill;
+
+///
+/// Slice 4: interactive pushes and spacing expansion never target poses inside a
+/// clamp keep-out. The plate's zones act as static obstacles for Compactor.Push,
+/// and as static walls for Expander — including the Expander edge inputs (clusters
+/// that already overlap or are pinned to an edge), which must degrade to a
+/// violation report instead of a crash.
+///
+public class ClampZonePushTests
+{
+ // Plate: 24 x 24, 0.5 uniform edge spacing, part spacing 1.0. Zones on the
+ // front edge; validation clearance (bottom edge spacing) is 0.5.
+ private const double Edge = 0.5;
+
+ private static readonly Box ZoneA = new(3, 0, 1.5, 0.625);
+ private static readonly Box ZoneB = new(8, 0, 1.5, 0.625);
+
+ private static Plate MakePlate(params ClampZone[] zones)
+ {
+ var plate = new Plate(new Size(24, 24));
+ plate.EdgeSpacing = new Spacing(Edge, Edge);
+ plate.PartSpacing = 1.0;
+ foreach (var zone in zones)
+ plate.ClampZones.Add(zone);
+ return plate;
+ }
+
+ private static Program SquareProgram(double size)
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(size, 0);
+ program.LineTo(size, size);
+ program.LineTo(0, size);
+ program.LineTo(0, size);
+ program.LineTo(0, 0);
+ return program;
+ }
+
+ private static Part AddSquare(Plate plate, double x, double y, double size = 3)
+ {
+ var part = new Part(new Drawing($"sq{plate.Parts.Count}", SquareProgram(size)), new Vector(x, y));
+ plate.Parts.Add(part);
+ return part;
+ }
+
+ private static Box Inflated(Box zone, double clearance) =>
+ new(zone.X - clearance, zone.Y - clearance, zone.Length + 2 * clearance, zone.Width + 2 * clearance);
+
+ private static bool Overlaps(Box a, Box b) =>
+ a.Left < b.Right - 1e-6 && b.Left < a.Right - 1e-6 && a.Bottom < b.Top - 1e-6 && b.Bottom < a.Top - 1e-6;
+
+ private static void AssertClear(Plate plate, params Part[] parts)
+ {
+ foreach (var part in parts)
+ foreach (var zone in plate.ClampZones)
+ Assert.False(
+ Overlaps(part.BoundingBox, Inflated(zone.Bounds, Edge)),
+ $"'{part.BaseDrawing.Name}' at ({part.Location.X:R},{part.Location.Y:R}) enters clamp '{zone.Id}'"
+ );
+ }
+
+ [Fact]
+ public void PushDownTowardTheClampEdgeStopsClearOfTheKeepOut()
+ {
+ var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
+ var mover = AddSquare(plate, 3, 8); // column crosses zone front-1
+
+ var moved = Compactor.Push(new List { mover }, plate, PushDirection.Down);
+
+ Assert.True(moved > 0.1, $"expected a downward move, got {moved}");
+ AssertClear(plate, mover);
+ }
+
+ [Fact]
+ public void PushDownBetweenTheClampsStillReachesTheEdge()
+ {
+ // Material between clamps stays usable (D6): the free column pushes to the edge.
+ var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
+ var mover = AddSquare(plate, 14, 8);
+
+ var moved = Compactor.Push(new List { mover }, plate, PushDirection.Down);
+
+ Assert.True(moved > 7, $"free column should reach near the edge, moved {moved}");
+ AssertClear(plate, mover);
+ }
+
+ [Fact]
+ public void SettleDoesNotMoveGroupThroughAnOccupiedPart()
+ {
+ var plate = new Plate(new Size(20, 20))
+ {
+ EdgeSpacing = new Spacing(0.25, 0.25),
+ PartSpacing = 0.1,
+ };
+ plate.ClampZones.Add(new ClampZone("remote", new Box(15, 0, 1.5, 0.625)));
+ var occupant = AddSquare(plate, 0.25, 0.25, 4);
+ var first = AddSquare(plate, 6, 7);
+ var second = AddSquare(plate, 11, 7);
+ Compactor.Settle(new List { first, second }, plate, plate.WorkArea());
+ Assert.False(Overlaps(first.BoundingBox, occupant.BoundingBox));
+ Assert.False(Overlaps(second.BoundingBox, occupant.BoundingBox));
+ AssertClear(plate, first, second);
+ }
+
+ [Fact]
+ public void SettleKeepsGroupOutsideClampsInsideItsFillArea()
+ {
+ var plate = MakePlate(new ClampZone("front-1", ZoneA));
+ var first = AddSquare(plate, 3, 8);
+ var second = AddSquare(plate, 12, 8);
+ Compactor.Settle(new List { first, second }, plate, plate.WorkArea());
+ AssertClear(plate, first, second);
+ }
+
+ [Fact]
+ public void PushWithAlreadyOverlappingMemberDoesNotMoveClearMemberIntoClamp()
+ {
+ var plate = MakePlate(new ClampZone("front-1", ZoneA));
+ var straddler = AddSquare(plate, 3, 0.5);
+ var clear = AddSquare(plate, 3, 8);
+ Compactor.Push(new List { straddler, clear }, plate, PushDirection.Down);
+ AssertClear(plate, clear);
+ }
+
+ [Fact]
+ public void ExpanderPreservesBottomEdgeClearanceWhenItsAchievedSpacingIsSmaller()
+ {
+ var plate = new Plate(new Size(12, 12))
+ {
+ EdgeSpacing = new Spacing(2, 2),
+ PartSpacing = 0.1,
+ };
+ plate.ClampZones.Add(new ClampZone("front", new Box(4, 0, 2, 1)));
+ var upper = AddSquare(plate, 4, 5, 2);
+ var lower = AddSquare(plate, 4, 3.1, 2);
+ var result = Expander.Expand(new List { upper, lower }, plate);
+ Assert.False(result.Cancelled);
+ Assert.True(lower.BoundingBox.Bottom >= 3 - 1e-6,
+ $"Achieved={result.AchievedSpacing}, lower at {lower.Location.Y}");
+ }
+
+ [Fact]
+ public void ExpanderNeverTargetsAPoseInsideAClampWhenGrowthWouldEnterOne()
+ {
+ var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
+ // Overlapping pair directly above zone front-2 (validation keep-out top y = 1.125):
+ // separating them drives the lower square down through the keep-out boundary.
+ var upper = AddSquare(plate, 8, 4.0, 3);
+ var lower = AddSquare(plate, 8, 1.2, 3);
+
+ var result = Expander.Expand(new List { upper, lower }, plate);
+
+ Assert.False(result.Cancelled);
+ AssertClear(plate, upper, lower);
+ }
+
+ [Fact]
+ public void ExpanderDegradesToAReportWhenAMemberAlreadyOverlapsAClamp()
+ {
+ var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
+ var straddler = AddSquare(plate, 3, 0.3, 3); // starts inside the clamp keep-out
+ var clear = AddSquare(plate, 15, 0.3, 3);
+
+ var result = Expander.Expand(new List { straddler, clear }, plate);
+
+ Assert.False(result.Cancelled);
+ // The clear member must not be driven into any clamp while its partner is stuck.
+ AssertClear(plate, clear);
+ }
+
+ [Fact]
+ public void ExpanderOnEdgePinnedClusterWithClampsReportsInsteadOfCrashing()
+ {
+ var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
+ // Three tight squares away from the clamp edge (the Expander edge-input class:
+ // an overlapping, mutually blocked cluster). All start clear of the zones.
+ var a = AddSquare(plate, 15, 0.5, 3);
+ var b = AddSquare(plate, 17.75, 0.5, 3);
+ var c = AddSquare(plate, 15, 3.25, 3);
+
+ var result = Expander.Expand(new List { a, b, c }, plate);
+
+ Assert.False(result.Cancelled);
+ // Whatever the achieved spacing or reported violations, no clear member may
+ // end inside a clamp keep-out.
+ AssertClear(plate, a, b, c);
+ }
+}
diff --git a/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs
new file mode 100644
index 0000000..2d66932
--- /dev/null
+++ b/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs
@@ -0,0 +1,121 @@
+using OpenNest.CNC;
+using OpenNest.Converters;
+using OpenNest.Engine.Fill;
+using OpenNest.Geometry;
+using Xunit;
+
+namespace OpenNest.Engine.Tests.Fill;
+
+public class ExpanderCutoffDeltaTests
+{
+ private static Plate Sheet() => new(new Size(30, 30)) { EdgeSpacing = new Spacing(0, 0) };
+
+ private static Program Rectangle(double width = 2, double height = 2)
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(width, 0);
+ program.LineTo(width, height);
+ program.LineTo(0, height);
+ program.LineTo(0, 0);
+ return program;
+ }
+
+ private static Part Add(Plate plate, Program program, double x, double y)
+ {
+ var part = new Part(new Drawing("neutral", program), new Vector(x, y));
+ plate.Parts.Add(part);
+ return part;
+ }
+
+ private static Program Disk(double radius) =>
+ ConvertGeometry.ToProgram(new List { new Circle(0, 0, radius) });
+
+ [Theory]
+ [InlineData(CutOffAxis.Horizontal)]
+ [InlineData(CutOffAxis.Vertical)]
+ public void Expand_RegeneratedCutoff_MustPreserveNativeEntryGap(CutOffAxis axis)
+ {
+ var plate = Sheet();
+ var anchor = Add(plate, Rectangle(), 1, 1);
+ var mover = Add(plate, Rectangle(), 4, 4);
+ plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis));
+ var settings = new CutOffSettings();
+ plate.RegenerateCutOffs(settings);
+ var before = axis == CutOffAxis.Horizontal
+ ? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right;
+
+ var result = Expander.Expand(new List { anchor, mover }, plate,
+ new Expander.Options { MaxSpacing = 3 });
+ var after = axis == CutOffAxis.Horizontal
+ ? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right;
+ Assert.True(after >= before - 1.1e-6,
+ $"Cutoff entry gap shrank {before:R}->{after:R}, mover=({mover.Location.X:R},{mover.Location.Y:R}), achieved={result.AchievedSpacing:R}, violations={result.Violations.Count}");
+ plate.RegenerateCutOffs(settings);
+ Assert.Single(plate.CutOffs);
+ }
+
+ [Fact]
+ public void Expand_RemoteOpenU_MustNotMoveTowardNativeCutoff()
+ {
+ var plate = Sheet();
+ var anchor = Add(plate, Rectangle(), 1, 4);
+ var mover = Add(plate, Rectangle(), 4, 4);
+ var program = new Program();
+ program.MoveTo(0, 10);
+ program.LineTo(30, 10);
+ program.LineTo(30, 0);
+ program.LineTo(0, 0);
+ plate.Parts.Add(new Part(new Drawing("neutral-cutoff", program) { IsCutOff = true }, Vector.Zero));
+
+ var result = Expander.Expand(new List { anchor, mover }, plate,
+ new Expander.Options { MaxSpacing = 3 });
+ // The three real segments are at least four units from the selected
+ // square at entry; an invented closing chord is not a real obstacle.
+ var gap = System.Math.Min(mover.BoundingBox.Bottom,
+ System.Math.Min(10 - mover.BoundingBox.Top, 30 - mover.BoundingBox.Right));
+ Assert.True(gap >= 4 - 1.1e-6,
+ $"Open path gap shrank to {gap:R}; mover=({mover.Location.X:R},{mover.Location.Y:R}); spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Separate_ShallowDiskOrRingVsRectangle_MustNotClaimClear(bool annulus)
+ {
+ var plate = Sheet();
+ var entities = new List { new Circle(0, 0, 2) };
+ if (annulus)
+ entities.Add(new Circle(0, 0, 1));
+ Add(plate, ConvertGeometry.ToProgram(entities), 10, 10);
+ const double angle = 0.017;
+ const double depth = 0.0005;
+ var mover = Add(plate, Rectangle(0.01, 0.01),
+ 10 + (2 - depth) * System.Math.Cos(angle),
+ 10 + (2 - depth) * System.Math.Sin(angle));
+
+ var result = Expander.Separate(new List { mover }, plate, 0);
+ var nearest = result.Positions[0].DistanceTo(new Vector(10, 10));
+ Assert.True(nearest >= 2 - 1e-8 || result.Violations.Any(v => v.Achieved < 0),
+ $"Native material overlap remains: nearest={nearest:R}, converged={result.Converged}, violations={result.Violations.Count}");
+ }
+
+ [Theory]
+ [InlineData(CutOffAxis.Horizontal)]
+ [InlineData(CutOffAxis.Vertical)]
+ public void Separate_RegeneratedCutoff_MustNotCrossEvenAtFarClearEndpoint(CutOffAxis axis)
+ {
+ var plate = Sheet();
+ var horizontal = axis == CutOffAxis.Horizontal;
+ var anchor = Add(plate, Rectangle(), 1, 1);
+ var mover = Add(plate, Rectangle(), horizontal ? 1 : 6, horizontal ? 6 : 1);
+ plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis));
+ plate.RegenerateCutOffs(new CutOffSettings());
+
+ var result = Expander.Separate(new List { anchor, mover }, plate, 12);
+ var trailingEdge = (horizontal ? result.Positions[1].Y : result.Positions[1].X) + 2;
+ Assert.True(trailingEdge <= 10 + 1e-6,
+ $"Crossed native cutoff, trailing edge={trailingEdge:R}");
+ Assert.False(result.Converged);
+ }
+}
diff --git a/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs
new file mode 100644
index 0000000..51cefcb
--- /dev/null
+++ b/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs
@@ -0,0 +1,94 @@
+using OpenNest.CNC;
+using OpenNest.Engine.Fill;
+using OpenNest.Geometry;
+using Xunit;
+
+namespace OpenNest.Engine.Tests.Fill;
+
+public class ExpanderCutoffQuadrantTests
+{
+ private static Program Square()
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(2, 0);
+ program.LineTo(2, 2);
+ program.LineTo(0, 2);
+ program.LineTo(0, 0);
+ return program;
+ }
+
+ public static IEnumerable
public static class Compactor
{
+ ///
+ /// The plate's clamp zones as static push obstacles (C1). Unlike ordinary
+ /// already-overlapping parts, a clamp stays a wall even when a selected member
+ /// straddles it: dropping it would let other clear members move into the zone.
+ /// Empty zone lists change every caller's arithmetic by nothing (C2).
+ ///
+ private static List ZoneObstacles(Plate plate) =>
+ Jobs.InteractiveClamps.ObstacleParts(plate);
public static double Push(List movingParts, Plate plate, PushDirection direction)
{
- var obstacleParts = plate
- .Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
- .ToList();
+ var obstacleParts = ZoneObstacles(plate);
+ obstacleParts.InsertRange(
+ 0,
+ plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
+ .ToList()
+ );
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
}
@@ -26,9 +37,12 @@ namespace OpenNest.Engine.Fill
///
public static double Push(List movingParts, Plate plate, double angle)
{
- var obstacleParts = plate
- .Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
- .ToList();
+ var obstacleParts = ZoneObstacles(plate);
+ obstacleParts.InsertRange(
+ 0,
+ plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
+ .ToList()
+ );
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
@@ -214,9 +228,12 @@ namespace OpenNest.Engine.Fill
PushDirection direction
)
{
- var obstacleParts = plate
- .Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
- .ToList();
+ var obstacleParts = ZoneObstacles(plate);
+ obstacleParts.InsertRange(
+ 0,
+ plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
+ .ToList()
+ );
return PushBoundingBox(
movingParts,
@@ -374,5 +391,46 @@ namespace OpenNest.Engine.Fill
break;
}
}
+
+ ///
+ /// As , settling inside the
+ /// plate's work area with its clamp zones as static obstacles: settling never parks
+ /// a part inside a keep-out (C1), and a clear cluster still compacts against zones
+ /// like any other obstacle. With no zones this behaves exactly like settling in the
+ /// plate's work area (C2).
+ ///
+ public static void Settle(
+ List parts,
+ Plate plate,
+ double threshold = 0.01,
+ int maxIterations = 20
+ ) => Settle(parts, plate, plate.WorkArea(), threshold, maxIterations);
+
+ /// Keep a fill group's own work-area bounds while excluding plate clamps.
+ public static void Settle(
+ List parts,
+ Plate plate,
+ Box workArea,
+ double threshold = 0.01,
+ int maxIterations = 20
+ )
+ {
+ if (parts.Count < 2)
+ return;
+
+ var obstacles = Jobs.InteractiveClamps.ObstacleParts(plate);
+ obstacles.AddRange(plate.Parts.Where(part => !parts.Contains(part)
+ && !part.BaseDrawing.IsCutOff));
+
+ for (var i = 0; i < maxIterations; i++)
+ {
+ var moved = 0.0;
+ moved += Push(parts, obstacles, workArea, plate.PartSpacing, PushDirection.Left);
+ moved += Push(parts, obstacles, workArea, plate.PartSpacing, PushDirection.Down);
+
+ if (moved < threshold)
+ break;
+ }
+ }
}
}
diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs
index d378658..b426b51 100644
--- a/OpenNest.Engine/Fill/Expander.cs
+++ b/OpenNest.Engine/Fill/Expander.cs
@@ -14,16 +14,15 @@ namespace OpenNest.Engine.Fill
/// boundaries. The inverse of : it grows gaps instead
/// of closing them.
///
- /// Input with overlaps is accepted: an overlapping pair is a pair with
- /// negative clearance and is separated along the minimum-translation
- /// direction. Moves are straight-line only — a pair that could separate only
- /// by routing around a blocker is reported as a violation instead. Final
- /// positions are always overlap-free; nothing moves off the work area.
+ /// Input with overlaps is accepted for safe direct separation; unresolved
+ /// overlaps remain in the violation report. Moves are straight-line only —
+ /// a pair that requires routing around a blocker remains unresolved.
+ /// Plate edges retain their EdgeSpacing floor.
///
///
- /// Anchor policy: within a violated pair the later-indexed selected part
- /// moves; the counterpart moves only as a fallback when the anchor mover is
- /// fully blocked by a work-area edge. Walls (non-selected parts) never move.
+ /// Anchor policy: within a violated selected pair only its later-indexed
+ /// selected part moves. Nonselected parts never move; there is no counterpart
+ /// fallback when the prescribed mover is blocked.
///
///
public static class Expander
@@ -46,9 +45,11 @@ namespace OpenNest.Engine.Fill
public sealed class Violation
{
public Part A;
- public Part B;
+ public Part B; // null for an invalid work-area pose
- /// Clearance actually reached (may be negative for unresolvable overlaps).
+ /// Signed clearance for a pair, or negative overshoot
+ /// for an invalid work-area pose. Sub-grid uncertain material uses a
+ /// negative marker, not a measured penetration depth.
public double Achieved;
/// True when a work-area edge, not a part, blocked the last needed move.
@@ -60,30 +61,57 @@ namespace OpenNest.Engine.Fill
///
/// Minimum part-to-part clearance the returned layout satisfies,
/// never below zero. 0 with violations means the layout was only
- /// cleaned up as far as possible, not opened up.
+ /// cleaned up as far as possible or still has invalid input (such as
+ /// a selected part outside the work area), not opened up.
///
public double AchievedSpacing;
/// True when the run was cancelled; no positions were changed.
public bool Cancelled;
- /// Pairs that could not reach .
+ /// Pairs still below the attempted spacing and invalid
+ /// work-area poses, including unresolved negative clearances when no
+ /// positive target was feasible.
public List Violations = new();
}
+ /// Per-call test checkpoints; no process-global mutable hook.
+ internal enum Checkpoint
+ {
+ AfterScratchMove,
+ AfterSuccessfulProbe,
+ AfterPairPass,
+ BeforeApply,
+ }
+
+ internal static Result ExpandObserved(
+ List selected, Plate plate, Options options,
+ Action observer, CancellationToken token = default
+ ) => ExpandCore(selected, plate, options, token, observer);
+
///
/// Raises the part-to-part spacing of the selection as far as the plate
/// and its other parts allow, applying the best spacing found. Plate
/// PartSpacing/EdgeSpacing are not modified; edges keep their own
/// EdgeSpacing floor while only part-to-part clearance chases the target.
- /// Mutates locations; probing never touches
- /// them, so a failed or cancelled run leaves the layout unchanged.
+ /// Mutates locations only after the final
+ /// proposal passes validation; failed individual probes and cancellation
+ /// leave all live locations unchanged. A completed run can apply safe
+ /// partial separation and report any remaining violations.
///
public static Result Expand(
List selected,
Plate plate,
Options options = null,
CancellationToken token = default
+ ) => ExpandCore(selected, plate, options, token, null);
+
+ private static Result ExpandCore(
+ List selected,
+ Plate plate,
+ Options options,
+ CancellationToken token,
+ Action observer
)
{
if (plate == null)
@@ -100,11 +128,29 @@ namespace OpenNest.Engine.Fill
);
var opt = options ?? new Options();
- var context = SeparationContext.Prepare(selected, plate);
- var entry = context.Positions(); // all parts; movers are [0, count)
var result = new Result();
+ SeparationContext context;
+ try
+ {
+ context = SeparationContext.Prepare(selected, plate, token);
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ result.Cancelled = true;
+ return result;
+ }
+ var entry = context.Positions(); // all parts; movers are [0, count)
- var sMin = MinimumPairClearance(context, entry);
+ double sMin;
+ try
+ {
+ sMin = MinimumPairClearance(context, entry, token);
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ result.Cancelled = true;
+ return result;
+ }
result.AchievedSpacing = System.Math.Max(0, sMin);
if (token.IsCancellationRequested)
@@ -119,7 +165,9 @@ namespace OpenNest.Engine.Fill
(bool Converged, List Positions) Probe(double spacing)
{
- var attempt = Separate(context, entry, spacing, opt.MaxIterations, token);
+ var attempt = Separate(context, entry, spacing, opt.MaxIterations, token, observer);
+ if (attempt.Converged)
+ observer?.Invoke(Checkpoint.AfterSuccessfulProbe);
return (attempt.Converged, attempt.Positions);
}
@@ -149,6 +197,11 @@ namespace OpenNest.Engine.Fill
}
var probe = Probe(s);
+ if (token.IsCancellationRequested)
+ {
+ result.Cancelled = true;
+ return result;
+ }
if (probe.Converged)
{
@@ -177,7 +230,13 @@ namespace OpenNest.Engine.Fill
}
var mid = (lo + hi) / 2;
- if (Probe(mid).Converged)
+ var midProbe = Probe(mid);
+ if (token.IsCancellationRequested)
+ {
+ result.Cancelled = true;
+ return result;
+ }
+ if (midProbe.Converged)
lo = mid;
else
hi = mid;
@@ -189,8 +248,9 @@ namespace OpenNest.Engine.Fill
// Deterministic, so this reproduces any committed probe exactly; when
// sGood was never probed (overlapping entry) it still performs the
// best-effort cleanup and yields the violation report.
- var final = Separate(context, entry, System.Math.Max(0, sGood), opt.MaxIterations, token);
+ var final = Separate(context, entry, System.Math.Max(0, sGood), opt.MaxIterations, token, observer);
+ observer?.Invoke(Checkpoint.BeforeApply);
if (token.IsCancellationRequested)
{
result.Cancelled = true;
@@ -198,11 +258,31 @@ namespace OpenNest.Engine.Fill
}
var positions = final.Positions;
+ // Floors belong to this public operation, not to any individual probe.
+ // A failed final check leaves every live part untouched.
+ if (!context.MeetsEntryFloors(positions, entry))
+ throw new InvalidOperationException("Expand proposal violates an entry clearance floor.");
+ double measured;
+ try
+ {
+ measured = MinimumPairClearance(context, positions, token);
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ result.Cancelled = true;
+ return result;
+ }
+ if (token.IsCancellationRequested)
+ {
+ result.Cancelled = true;
+ return result;
+ }
Apply(selected, entry, positions);
- var measured = MinimumPairClearance(context, positions);
- result.AchievedSpacing = System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured));
+ result.AchievedSpacing = final.Violations.Any(v => v.B == null)
+ ? 0
+ : System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured));
result.Violations = final.Violations;
return result;
}
@@ -220,9 +300,26 @@ namespace OpenNest.Engine.Fill
CancellationToken token = default
)
{
- var context = SeparationContext.Prepare(selected, plate);
+ SeparationContext context;
+ try
+ {
+ context = SeparationContext.Prepare(selected, plate, token);
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ return (false, selected.Select(p => p.Location).ToList(), new List());
+ }
var positions = context.Positions();
- return Separate(context, positions, spacing, maxIterations, token);
+ return Separate(context, positions, spacing, maxIterations, token, null);
+ }
+
+ internal static (bool Converged, List Positions, List Violations) SeparateObserved(
+ List selected, Plate plate, double spacing,
+ Action observer, int maxIterations = 100,
+ CancellationToken token = default)
+ {
+ var context = SeparationContext.Prepare(selected, plate, token);
+ return Separate(context, context.Positions(), spacing, maxIterations, token, observer);
}
private static void Apply(List selected, List from, List to)
@@ -236,12 +333,14 @@ namespace OpenNest.Engine.Fill
}
}
- private static double MinimumPairClearance(SeparationContext context, List positions)
+ private static double MinimumPairClearance(
+ SeparationContext context, List positions, CancellationToken token)
{
var min = double.MaxValue;
foreach (var pair in context.Pairs)
{
+ token.ThrowIfCancellationRequested();
var (distance, _) = context.PairClearance(pair.IndexA, pair.IndexB, positions);
if (distance < min)
min = distance;
@@ -255,11 +354,11 @@ namespace OpenNest.Engine.Fill
List start,
double spacing,
int maxIterations,
- CancellationToken token
+ CancellationToken token,
+ Action observer
)
{
var positions = new List(start);
- var stuck = new HashSet();
var violations = new List();
var epsMove = 1e-4;
@@ -274,14 +373,14 @@ namespace OpenNest.Engine.Fill
if (token.IsCancellationRequested)
return (false, start, violations);
+ // Retry every pair on the next pass if another blocker moved;
+ // a complete pass without progress stops genuinely pinned layouts.
var moved = 0.0;
- var stuckChanged = false;
foreach (var pair in context.Pairs)
{
- if (stuck.Contains(pair.Id))
- continue;
-
+ if (token.IsCancellationRequested)
+ return (false, start, violations);
var (distance, directionA) = context.PairClearance(
pair.IndexA,
pair.IndexB,
@@ -310,60 +409,25 @@ namespace OpenNest.Engine.Fill
// the B side travels the opposite way.
var direction = moverIndex == pair.IndexA ? directionA : -directionA;
- var room = context.ClipToWorkArea(
- moverIndex,
- positions[moverIndex],
- direction,
- need
- );
-
- // Take the largest valid step up to `room`: partial moves let
- // a blocked mover advance again once its own blockers move
- // away in later iterations (a wave separates a chain).
- var applied = 0.0;
- var blockedByEdge = false;
-
- if (room > 0)
+ // A zero-distance hole contact can have an ambiguous nearest-
+ // boundary fallback. Only if the normal candidate makes no
+ // safe progress, retry its opposite through the same path,
+ // floor and active-improvement checks. Never move the anchor.
+ var attempt = ProposeMove(context, positions, pair, distance, direction, need, epsMove);
+ if (attempt.Applied <= 0 && distance <= 1e-7)
{
- var trial = positions[moverIndex] + direction * room;
-
- if (context.MaintainsValidity(moverIndex, trial, positions))
- {
- positions[moverIndex] = trial;
- applied = room;
- }
- else
- {
- var lo = 0.0;
- var hi2 = room;
- for (var bisect = 0; bisect < 24 && hi2 - lo > 1e-6; bisect++)
- {
- var mid = (lo + hi2) / 2;
- if (
- context.MaintainsValidity(
- moverIndex,
- positions[moverIndex] + direction * mid,
- positions
- )
- )
- lo = mid;
- else
- hi2 = mid;
- }
-
- if (lo > epsMove)
- {
- positions[moverIndex] = positions[moverIndex] + direction * lo;
- applied = lo;
- }
- }
-
- blockedByEdge = applied < need - epsMove;
- moved += applied;
+ var opposite = ProposeMove(context, positions, pair, distance, -direction, need, epsMove);
+ if (opposite.Applied > attempt.Applied)
+ attempt = opposite;
}
- else
+ var applied = attempt.Applied;
+ if (applied > 0)
{
- blockedByEdge = true;
+ positions[moverIndex] = attempt.Proposal;
+ moved += applied;
+ observer?.Invoke(Checkpoint.AfterScratchMove);
+ if (token.IsCancellationRequested)
+ return (false, start, violations);
}
// A pair fully separated to the user target (the internal margin
@@ -382,16 +446,14 @@ namespace OpenNest.Engine.Fill
continue;
}
- // Zero progress twice in a row parks the pair; the final sweep
- // re-measures everything, so mid-loop bookkeeping never lies.
- if (stuck.Contains(pair.Id))
- continue;
-
- stuck.Add(pair.Id);
- stuckChanged = true;
+ // No progress now; a later part may unblock this pair on
+ // the next pass. The final sweep remeasures all pairs.
}
- if (moved < epsMove && !stuckChanged)
+ observer?.Invoke(Checkpoint.AfterPairPass);
+ if (token.IsCancellationRequested)
+ return (false, start, violations);
+ if (moved < epsMove)
break;
}
@@ -403,7 +465,9 @@ namespace OpenNest.Engine.Fill
violations.Clear();
foreach (var pair in context.Pairs)
{
- var (distance, _) = context.PairClearance(
+ if (token.IsCancellationRequested)
+ return (false, start, violations);
+ var (distance, directionA) = context.PairClearance(
pair.IndexA,
pair.IndexB,
positions
@@ -412,27 +476,74 @@ namespace OpenNest.Engine.Fill
if (distance >= spacing)
continue;
+ var direction = pair.Mover == pair.IndexA ? directionA : -directionA;
+ var need = target - distance;
+ var attempt = ProposeMove(context, positions, pair, distance, direction, need, epsMove);
+ if (attempt.Applied <= 0 && distance <= 1e-7)
+ {
+ var opposite = ProposeMove(context, positions, pair, distance, -direction, need, epsMove);
+ if (opposite.Applied > attempt.Applied)
+ attempt = opposite;
+ }
+ // Only an actual first work-area limit is an edge blocker.
+ // An available move at the iteration budget, or a part/floor
+ // limiting first, is not evidence of an edge obstruction.
+ var blockedByEdge = attempt.EdgeLimited && !attempt.PartLimited
+ && attempt.Applied < need - epsMove;
violations.Add(
new Violation
{
A = context.PartOf(pair.IndexA),
B = context.PartOf(pair.IndexB),
Achieved = distance,
- BlockedByEdge = stuck.Contains(pair.Id),
+ BlockedByEdge = blockedByEdge,
}
);
}
+ violations.AddRange(context.WorkAreaViolations(positions));
return (violations.Count == 0, positions, violations);
}
+ private static (double Applied, Vector Proposal, bool EdgeLimited, bool PartLimited) ProposeMove(
+ SeparationContext context, List positions, Pair pair,
+ double distance, Vector direction, double need, double epsMove)
+ {
+ var mover = pair.Mover;
+ var start = positions[mover];
+ var edgeRoom = context.ClipToWorkArea(mover, start, direction, need);
+ var room = context.ClipToMaterial(mover, positions, direction, edgeRoom);
+ var edgeLimited = edgeRoom < need - epsMove;
+ var partLimited = room < edgeRoom - epsMove;
+ if (room <= epsMove)
+ return (0, start, edgeLimited, partLimited);
+
+ var trial = start + direction * room;
+ if (context.MaintainsValidity(mover, trial, positions, pair, distance))
+ return (room, trial, edgeLimited, partLimited);
+
+ var lo = 0.0;
+ var hi = room;
+ for (var bisect = 0; bisect < 24 && hi - lo > 1e-6; bisect++)
+ {
+ var mid = (lo + hi) / 2;
+ if (context.MaintainsValidity(mover, start + direction * mid, positions, pair, distance))
+ lo = mid;
+ else
+ hi = mid;
+ }
+ return lo > epsMove
+ ? (lo, start + direction * lo, edgeLimited, true)
+ : (0, start, edgeLimited, true);
+ }
+
/// One constrained part↔part pair with its anchor mover.
private sealed class Pair
{
- public int Id;
public int IndexA;
public int IndexB;
public int Mover;
+ public double EntryFloor;
}
///
@@ -446,6 +557,8 @@ namespace OpenNest.Engine.Fill
private readonly List shapes; // per part
private readonly Box[] localBoxes; // per part, local frame
private readonly int moverCount;
+ private readonly int clampStart; // synthetic walls, after actual plate parts
+ private readonly IReadOnlyList clampBoxes; // exact placement-frame keep-outs
private readonly Box workArea;
public readonly List Pairs = new();
@@ -456,6 +569,11 @@ namespace OpenNest.Engine.Fill
public Polygon Outer;
public List Rings = new(); // outer + cutout rings, local frame
public List Holes = new();
+ public List Boundaries = new(); // native outer + hole loops, local frame
+ public SlideContactGeometry Contact;
+ public Box NativeBounds; // perimeter's native-entity envelope
+ public ShapeProfile MaterialProfile; // immutable local native loops
+ public bool HasCurves;
}
private SeparationContext(
@@ -463,6 +581,8 @@ namespace OpenNest.Engine.Fill
List shapes,
Box[] localBoxes,
int moverCount,
+ int clampStart,
+ IReadOnlyList clampBoxes,
Box workArea,
double spacingCap
)
@@ -471,14 +591,23 @@ namespace OpenNest.Engine.Fill
this.shapes = shapes;
this.localBoxes = localBoxes;
this.moverCount = moverCount;
+ this.clampStart = clampStart;
+ this.clampBoxes = clampBoxes;
this.workArea = workArea;
SpacingCap = spacingCap;
}
- public static SeparationContext Prepare(List selected, Plate plate)
+ public static SeparationContext Prepare(List selected, Plate plate, CancellationToken token)
{
+ token.ThrowIfCancellationRequested();
var movers = new List(selected);
var walls = plate.Parts.Where(p => !movers.Contains(p)).ToList();
+ var clampStart = movers.Count + walls.Count;
+ // Clamp walls include the bottom-edge clearance envelope.
+ var clampBoxes = Jobs.InteractiveClamps.ClearanceRectangles(plate);
+ walls.AddRange(Jobs.InteractiveClamps.WallParts(plate));
+ if (clampBoxes.Count != walls.Count + movers.Count - clampStart)
+ throw new InvalidOperationException("Clamp wall preparation changed its ordering.");
var parts = new List(movers.Count + walls.Count);
parts.AddRange(movers);
@@ -492,6 +621,7 @@ namespace OpenNest.Engine.Fill
for (var i = 0; i < parts.Count; i++)
{
+ token.ThrowIfCancellationRequested();
shapes.Add(PrepareProgram(programs, parts[i].Program));
localBoxes[i] = LocalBox(parts[i]);
}
@@ -506,10 +636,18 @@ namespace OpenNest.Engine.Fill
shapes,
localBoxes,
movers.Count,
+ clampStart,
+ clampBoxes,
workArea,
spacingCap
);
- context.BuildPairs();
+ context.BuildPairs(token);
+ var entry = context.Positions();
+ foreach (var pair in context.Pairs)
+ {
+ token.ThrowIfCancellationRequested();
+ pair.EntryFloor = context.PairClearance(pair.IndexA, pair.IndexB, entry).Distance;
+ }
return context;
}
@@ -530,18 +668,31 @@ namespace OpenNest.Engine.Fill
if (entities.Count > 0)
{
var profile = new ShapeProfile(entities);
+ prepared.MaterialProfile = profile;
if (profile.Perimeter != null)
{
+ prepared.NativeBounds = profile.Perimeter.BoundingBox;
prepared.Outer = profile.Perimeter.ToPolygonWithTolerance(0.001);
+ // Polygon.Cleanup compares vertices at geometry epsilon and
+ // can erase an accepted sub-grid clamp. Preserve exact line
+ // endpoints instead of treating its material as empty.
+ if ((profile.Perimeter.BoundingBox.Length < KeepOutOverlap.ToleranceFreeBelow
+ || profile.Perimeter.BoundingBox.Width < KeepOutOverlap.ToleranceFreeBelow)
+ && profile.Perimeter.Entities.All(e => e is Line))
+ prepared.Outer = ExactLineRing(profile.Perimeter.Entities);
prepared.Rings.Add(prepared.Outer);
+ prepared.Boundaries.AddRange(profile.Perimeter.Entities);
foreach (var cutout in profile.Cutouts)
{
var hole = cutout.ToPolygonWithTolerance(0.001);
prepared.Rings.Add(hole);
prepared.Holes.Add(hole);
+ prepared.Boundaries.AddRange(cutout.Entities);
}
+ prepared.Contact = SlideContactGeometry.Prepare(prepared.Boundaries);
+ prepared.HasCurves = prepared.Boundaries.Any(e => e is Arc || e is Circle);
}
}
@@ -549,6 +700,17 @@ namespace OpenNest.Engine.Fill
return prepared;
}
+ private static Polygon ExactLineRing(List entities)
+ {
+ var polygon = new Polygon();
+ foreach (var entity in entities)
+ polygon.Vertices.Add(((Line)entity).StartPoint);
+ if (polygon.Vertices.Count > 0)
+ polygon.Vertices.Add(polygon.Vertices[0]);
+ polygon.UpdateBounds();
+ return polygon;
+ }
+
private static Box LocalBox(Part part)
{
var box = part.BoundingBox;
@@ -560,12 +722,11 @@ namespace OpenNest.Engine.Fill
);
}
- private void BuildPairs()
+ private void BuildPairs(CancellationToken token)
{
- var id = 0;
-
for (var a = 0; a < parts.Count; a++)
{
+ token.ThrowIfCancellationRequested();
for (var b = a + 1; b < parts.Count; b++)
{
var aMover = a < moverCount;
@@ -577,7 +738,6 @@ namespace OpenNest.Engine.Fill
Pairs.Add(
new Pair
{
- Id = id++,
IndexA = a,
IndexB = b,
// Anchor policy: the later-index mover moves.
@@ -611,13 +771,87 @@ namespace OpenNest.Engine.Fill
{
var setA = shapes[indexA];
var setB = shapes[indexB];
+ var offsetA = positions[indexA];
+ var offsetB = positions[indexB];
+
+ if (parts[indexB].BaseDrawing.IsCutOff)
+ {
+ // A regenerated cutoff is a set of open native segments.
+ // Polygon.Close would invent an endpoint chord and miss the
+ // interior witness that sets the immutable entry floor.
+ if (setB.Boundaries.Count == 0)
+ return (double.MaxValue, new Vector(1, 0));
+ if (setA.Outer == null || setB.Boundaries.Any(e => e is not Line))
+ return (-1e-4, new Vector(1, 0));
+ var lines = CloneEntitiesAt(setB.Boundaries, offsetB).Cast().ToList();
+ var worldOuter = CloneAt(setA.Outer, offsetA);
+ var worldHoles = setA.Holes.Count == 0 ? null : CloneAll(setA.Holes, offsetA);
+ if (OpenPathMaterial.Enters(worldOuter, worldHoles, lines))
+ return (-1e-4, new Vector(1, 0));
+ var cutoffBest = double.MaxValue;
+ var cutoffDirection = new Vector(1, 0);
+ foreach (var ring in setA.Rings)
+ {
+ var clearance = Clearance.BoundaryDistance(CloneAt(ring, offsetA).ToLines(), lines);
+ if (clearance.Distance < cutoffBest)
+ {
+ cutoffBest = clearance.Distance;
+ cutoffDirection = clearance.Direction;
+ }
+ }
+ if (setA.HasCurves && cutoffBest >= 0 && cutoffBest < 0.002)
+ return (-1e-4, cutoffDirection);
+ return (cutoffBest, cutoffDirection);
+ }
+
+ if (indexB < clampStart && !parts[indexB].BaseDrawing.IsCutOff &&
+ (setA.Contact?.IsResolved != true || setB.Contact?.IsResolved != true))
+ {
+ // Unresolved or sub-scale material cannot be pronounced
+ // clear by a tolerant overlap kernel. Native envelopes
+ // intersecting at positive area yield an honest unresolved
+ // violation; disjoint envelopes may still use the usual
+ // boundary distance. Never infer a penetration depth here.
+ var boxA = setA.NativeBounds?.Translate(offsetA);
+ var boxB = setB.NativeBounds?.Translate(offsetB);
+ if (boxA == null || boxB == null ||
+ !double.IsFinite(boxA.Left) || !double.IsFinite(boxB.Left) ||
+ boxA.Right > boxB.Left && boxB.Right > boxA.Left &&
+ boxA.Top > boxB.Bottom && boxB.Top > boxA.Bottom)
+ return (-1e-4, new Vector(1, 0));
+ }
+
+ if (setA.Holes.Count == 0 && setB.Holes.Count == 0 &&
+ TryNativeDisk(setA, out var centerA, out var radiusA) &&
+ TryNativeDisk(setB, out var centerB, out var radiusB))
+ {
+ // Flattened polygons can miss a shallow arc overlap and
+ // report a positive boundary gap. Full disks admit an exact
+ // signed clearance and radial direction at every scratch pose.
+ var delta = centerA + offsetA - centerB - offsetB;
+ var length = System.Math.Sqrt(delta.DotProduct(delta));
+ if (!double.IsFinite(length))
+ return (0, new Vector(1, 0));
+ return (length - radiusA - radiusB,
+ length > 1e-12 ? delta / length : new Vector(1, 0));
+ }
+
+ if (indexB >= clampStart &&
+ KeepOutOverlap.NeedsToleranceFreeTest(clampBoxes[indexB - clampStart]))
+ {
+ // Tolerant polygon collision can miss a sub-grid zone even
+ // when it is wholly contained in part material. The native
+ // profile test is hole-aware and fail-closed. Its negative
+ // marker means unresolved overlap, not a measured depth.
+ var zone = clampBoxes[indexB - clampStart];
+ if (setA.MaterialProfile?.Perimeter == null ||
+ KeepOutOverlap.ProfileEnters(setA.MaterialProfile, offsetA, zone))
+ return (-1e-4, new Vector(1, 0));
+ }
if (setA.Outer == null || setB.Outer == null)
return (0, new Vector(1, 0));
- var offsetA = positions[indexA];
- var offsetB = positions[indexB];
-
var outerA = CloneAt(setA.Outer, offsetA);
var outerB = CloneAt(setB.Outer, offsetB);
@@ -653,6 +887,12 @@ namespace OpenNest.Engine.Fill
}
}
+ // Each prepared arc can sit up to 0.001 outside its inscribed
+ // chord. A positive polygon gap inside the combined chord band
+ // is not proof of native clearance. Keep the input but report
+ // uncertainty as a violation rather than a false clear.
+ if ((setA.HasCurves || setB.HasCurves) && best >= 0 && best < 0.002)
+ return (-1e-4, bestDir);
return (best, bestDir);
}
@@ -683,63 +923,384 @@ namespace OpenNest.Engine.Fill
}
///
- /// True when the part at stays inside the work
- /// area and keeps no material overlap with any other part (Collision
- /// oracle with hole subtraction, so part-in-cutout stays legal).
+ /// Stop at the first material-blocking contact along the whole straight
+ /// translation, including hole walls. Destination clearance alone can
+ /// accept a pose on the far side of a thin wall.
///
- public bool MaintainsValidity(int index, Vector trial, List positions)
+ public double ClipToMaterial(int index, List positions, Vector direction, double room)
{
- var box = localBoxes[index];
- var movedBox = box.Translate(trial);
-
- if (
- movedBox.Left < workArea.Left - 1e-9
- || movedBox.Right > workArea.Right + 1e-9
- || movedBox.Bottom < workArea.Bottom - 1e-9
- || movedBox.Top > workArea.Top + 1e-9
- )
- return false;
-
- var outer = shapes[index].Outer;
- if (outer == null)
- return true;
-
- var worldOuter = CloneAt(outer, trial);
- var worldHoles = shapes[index].Holes.Count == 0
- ? null
- : CloneAll(shapes[index].Holes, trial);
+ if (room <= 0 || !double.IsFinite(room)
+ || !double.IsFinite(direction.X) || !double.IsFinite(direction.Y))
+ return 0;
+ var moving = shapes[index];
+ if (moving.Contact?.IsResolved != true || moving.Outer == null)
+ return 0;
for (var i = 0; i < parts.Count; i++)
{
if (i == index)
continue;
-
- var otherOuter = shapes[i].Outer;
- if (otherOuter == null)
+ var stationary = shapes[i];
+ if (parts[i].BaseDrawing.IsCutOff)
+ {
+ // Regenerated cut-offs are intentional open cut paths,
+ // not malformed closed material. The unresolved contact
+ // classifier blocks their first native line contact;
+ // remote paths with no events do not pin every mover.
+ // Never treat an unknown/nonlinear cutoff as empty space.
+ if (stationary.Boundaries.Count == 0)
+ continue;
+ if (stationary.Boundaries.Any(e => e is not Line))
+ return 0;
+ foreach (var entity in stationary.Boundaries)
+ {
+ var line = (Line)entity;
+ if (!double.IsFinite(line.StartPoint.X) || !double.IsFinite(line.StartPoint.Y)
+ || !double.IsFinite(line.EndPoint.X) || !double.IsFinite(line.EndPoint.Y)
+ || line.StartPoint.X == line.EndPoint.X && line.StartPoint.Y == line.EndPoint.Y)
+ return 0;
+ }
+ // A segment box can overlap a concave/oblique part box
+ // while its actual open path remains far away. Use the
+ // same strict material-entry verdict as entry floors
+ // and final reporting, not the box as a blocker.
+ if (PairClearance(index, i, positions).Distance < 0)
+ return 0;
+ var hit = SpatialQuery.DirectionalDistance(
+ CloneEntitiesAt(moving.Boundaries, positions[index]),
+ CloneEntitiesAt(stationary.Boundaries, positions[i]), direction,
+ new SlideContactClassifier(moving.Contact, positions[index],
+ stationary.Contact, positions[i]));
+ if (double.IsNaN(hit) || hit < 0)
+ return 0;
+ if (hit < double.MaxValue)
+ room = System.Math.Min(room, hit);
continue;
-
- if (!BoxOverlap(movedBox, localBoxes[i].Translate(positions[i]), 0.002))
+ }
+ if (i >= clampStart && KeepOutOverlap.NeedsToleranceFreeTest(clampBoxes[i - clampStart]))
+ {
+ // Slide contact and polygon overlap have tolerances larger
+ // than a tiny keep-out. Clip at the earliest possible
+ // native-bound contact; if bounds overlap already, refuse
+ // uncertain motion instead of crossing the physical zone.
+ room = System.Math.Min(room, ClipToNativeBox(
+ moving, positions[index], direction, room, clampBoxes[i - clampStart]));
+ if (room <= 0)
+ return 0;
continue;
+ }
+ if (stationary.Contact?.IsResolved != true || stationary.Outer == null)
+ return 0; // Unknown boundary is not empty space.
- var worldOther = CloneAt(otherOuter, positions[i]);
- var worldOtherHoles = shapes[i].Holes.Count == 0
- ? null
- : CloneAll(shapes[i].Holes, positions[i]);
+ var origin = positions[index];
+ var alreadyOverlapping = Collision.HasOverlap(
+ CloneAt(moving.Outer, origin), CloneAt(stationary.Outer, positions[i]),
+ moving.Holes.Count == 0 ? null : CloneAll(moving.Holes, origin),
+ stationary.Holes.Count == 0 ? null : CloneAll(stationary.Holes, positions[i]));
+ if (!alreadyOverlapping && moving.Holes.Count == 0 && stationary.Holes.Count == 0 &&
+ TryNativeDisk(moving, out var mc, out var mr) &&
+ TryNativeDisk(stationary, out var sc, out var sr))
+ {
+ var delta = mc + origin - sc - positions[i];
+ var distanceSquared = delta.DotProduct(delta);
+ var contactSquared = (mr + sr) * (mr + sr);
+ if (!double.IsFinite(distanceSquared) || !double.IsFinite(contactSquared))
+ return 0;
+ alreadyOverlapping = distanceSquared < contactSquared;
+ }
+ if (alreadyOverlapping)
+ {
+ // An initially overlapping convex pair needs a direct
+ // separating-path certificate; the normal slide query
+ // is only for initially clear geometry. A certified
+ // prefix may still overlap when the edge clips its exit.
+ // Convex material cannot re-enter along the certified
+ // separating ray after its single exit.
+ if (!TryConvexDirectExit(moving, stationary, origin, positions[i], direction, out _))
+ return 0;
+ continue;
+ }
- if (
- Collision.HasOverlap(
- worldOuter,
- worldOther,
- worldHoles,
- worldOtherHoles
- )
- )
+ var query = SpatialQuery.DirectionalDistance(
+ CloneEntitiesAt(moving.Boundaries, origin),
+ CloneEntitiesAt(stationary.Boundaries, positions[i]),
+ direction,
+ new SlideContactClassifier(moving.Contact, origin, stationary.Contact, positions[i]));
+ if (double.IsNaN(query) || query < 0)
+ return 0;
+ if (query < double.MaxValue)
+ room = System.Math.Min(room, query);
+ }
+ return room;
+ }
+
+ private static double ClipToNativeBox(
+ RingSet mover, Vector start, Vector direction, double room, Box obstacle)
+ {
+ var bounds = mover.NativeBounds;
+ if (bounds == null || !double.IsFinite(bounds.Left) || !double.IsFinite(bounds.Right)
+ || !double.IsFinite(bounds.Bottom) || !double.IsFinite(bounds.Top)
+ || !double.IsFinite(obstacle.Left) || !double.IsFinite(obstacle.Right)
+ || !double.IsFinite(obstacle.Bottom) || !double.IsFinite(obstacle.Top))
+ return 0;
+ var from = bounds.Translate(start);
+ var enter = double.NegativeInfinity;
+ var leave = double.PositiveInfinity;
+ if (!Slab(from.Left, from.Right, obstacle.Left, obstacle.Right,
+ direction.X, ref enter, ref leave)
+ || !Slab(from.Bottom, from.Top, obstacle.Bottom, obstacle.Top,
+ direction.Y, ref enter, ref leave)
+ || leave <= 0 || enter >= room)
+ return room;
+ return System.Math.Max(0, enter);
+ }
+
+ private static bool Slab(double low, double high, double wallLow, double wallHigh,
+ double speed, ref double enter, ref double leave)
+ {
+ if (speed == 0)
+ return high > wallLow && low < wallHigh;
+ var first = (wallLow - high) / speed;
+ var last = (wallHigh - low) / speed;
+ enter = System.Math.Max(enter, System.Math.Min(first, last));
+ leave = System.Math.Min(leave, System.Math.Max(first, last));
+ return enter < leave;
+ }
+
+ private static List CloneEntitiesAt(List entities, Vector position)
+ {
+ var result = new List(entities.Count);
+ foreach (var entity in entities)
+ {
+ var copy = entity.Clone();
+ copy.Offset(position);
+ result.Add(copy);
+ }
+ return result;
+ }
+
+ private static bool TryConvexDirectExit(
+ RingSet moving, RingSet stationary, Vector movePos, Vector fixedPos,
+ Vector direction, out double exit)
+ {
+ exit = 0;
+ if (moving.Holes.Count != 0 || stationary.Holes.Count != 0)
+ return false;
+
+ if (TryNativeDisk(moving, out var movingCenter, out var movingRadius) &&
+ TryNativeDisk(stationary, out var fixedCenter, out var fixedRadius))
+ {
+ // Full circles are convex. The relative centre distance
+ // must increase from the start; its ray intersects the
+ // summed-radius disk at most once in forward travel.
+ var relative = movingCenter + movePos - fixedCenter - fixedPos;
+ var approach = relative.DotProduct(direction);
+ var radius = movingRadius + fixedRadius;
+ var discriminant = approach * approach + radius * radius
+ - relative.DotProduct(relative);
+ if (!double.IsFinite(approach) || approach < -1e-8
+ || !double.IsFinite(discriminant) || discriminant <= 0)
return false;
+ exit = -approach + System.Math.Sqrt(discriminant);
+ return double.IsFinite(exit) && exit > 0;
}
+ if (moving.Boundaries.Count == 0 || stationary.Boundaries.Count == 0
+ || moving.Boundaries.Any(e => e is not Line)
+ || stationary.Boundaries.Any(e => e is not Line)
+ || !IsConvex(moving.Outer) || !IsConvex(stationary.Outer))
+ return false;
+
+ // The ray must point along an actual separating support normal.
+ // It must be the nearer exit, not the direction that burrows
+ // through the other convex part before reaching its far side.
+ var normal = false;
+ foreach (var ring in new[] { moving.Outer, stationary.Outer })
+ foreach (var line in ring.ToLines())
+ {
+ var edge = line.pt2 - line.pt1;
+ var length = System.Math.Sqrt(edge.DotProduct(edge));
+ if (length > 1e-8 &&
+ System.Math.Abs((edge.X * direction.X + edge.Y * direction.Y) / length) < 1e-7)
+ normal = true;
+ }
+ if (!normal)
+ return false;
+
+ var (minA, maxA) = Project(moving.Outer, movePos, direction);
+ var (minB, maxB) = Project(stationary.Outer, fixedPos, direction);
+ if (!double.IsFinite(minA) || !double.IsFinite(maxB)
+ || maxB - minA > maxA - minB + 1e-7)
+ return false;
+ exit = System.Math.Max(0, maxB - minA);
+ return double.IsFinite(exit);
+ }
+
+ private static bool TryNativeDisk(RingSet shape, out Vector center, out double radius)
+ {
+ center = Vector.Zero;
+ radius = 0;
+ var boundaries = shape.Boundaries;
+ if (boundaries.Count == 1 && boundaries[0] is Circle circle)
+ {
+ center = circle.Center;
+ radius = circle.Radius;
+ }
+ else if (boundaries.Count > 0 && boundaries[0] is Arc first)
+ {
+ center = first.Center;
+ radius = first.Radius;
+ var reversed = first.IsReversed;
+ var sweep = 0.0;
+ foreach (var entity in boundaries)
+ {
+ if (entity is not Arc arc || arc.Center.X != center.X
+ || arc.Center.Y != center.Y || arc.Radius != radius
+ || arc.IsReversed != reversed)
+ return false;
+ sweep += arc.SweepAngle();
+ }
+ if (System.Math.Abs(sweep - 2 * System.Math.PI) > 1e-7)
+ return false;
+ }
+ else
+ return false;
+
+ return double.IsFinite(center.X) && double.IsFinite(center.Y)
+ && double.IsFinite(radius) && radius > 0;
+ }
+
+ private static (double Min, double Max) Project(Polygon polygon, Vector origin, Vector axis)
+ {
+ var min = double.PositiveInfinity;
+ var max = double.NegativeInfinity;
+ foreach (var point in polygon.Vertices)
+ {
+ var projection = (point + origin).DotProduct(axis);
+ min = System.Math.Min(min, projection);
+ max = System.Math.Max(max, projection);
+ }
+ return (min, max);
+ }
+
+ private static bool IsConvex(Polygon polygon)
+ {
+ var points = polygon.Vertices;
+ if (points.Count < 4)
+ return false;
+ var sign = 0;
+ for (var i = 0; i < points.Count - 1; i++)
+ {
+ var a = points[i];
+ var b = points[(i + 1) % (points.Count - 1)];
+ var c = points[(i + 2) % (points.Count - 1)];
+ var cross = (b.X - a.X) * (c.Y - b.Y) - (b.Y - a.Y) * (c.X - b.X);
+ if (!double.IsFinite(cross))
+ return false;
+ if (System.Math.Abs(cross) < 1e-8)
+ continue;
+ var next = System.Math.Sign(cross);
+ if (sign != 0 && sign != next)
+ return false;
+ sign = next;
+ }
+ return sign != 0;
+ }
+
+ public List WorkAreaViolations(List positions)
+ {
+ var violations = new List();
+ for (var i = 0; i < moverCount; i++)
+ {
+ if (InsideWorkArea(i, positions[i]))
+ continue;
+ var box = localBoxes[i].Translate(positions[i]);
+ var overshoot = System.Math.Max(
+ System.Math.Max(workArea.Left - box.Left, box.Right - workArea.Right),
+ System.Math.Max(workArea.Bottom - box.Bottom, box.Top - workArea.Top));
+ violations.Add(new Violation
+ {
+ A = parts[i],
+ B = null,
+ Achieved = double.IsFinite(overshoot) ? -System.Math.Max(0, overshoot) : -1e-4,
+ BlockedByEdge = true,
+ });
+ }
+ return violations;
+ }
+
+ // Smaller than the 0.002 tessellation margin; immutable entry floors
+ // prevent this comparison tolerance accumulating over iterations.
+ private const double FloorTolerance = 1e-6;
+
+ public bool MeetsEntryFloors(List positions, List entry)
+ {
+ for (var i = 0; i < moverCount; i++)
+ if (!InsideWorkArea(i, positions[i]) &&
+ (positions[i].X != entry[i].X || positions[i].Y != entry[i].Y))
+ return false;
+ foreach (var pair in Pairs)
+ {
+ var clearance = PairClearance(pair.IndexA, pair.IndexB, positions).Distance;
+ if (!double.IsFinite(clearance) || clearance < pair.EntryFloor - FloorTolerance)
+ return false;
+ }
return true;
}
+ private bool InsideWorkArea(int index, Vector position)
+ {
+ var box = localBoxes[index].Translate(position);
+ return double.IsFinite(box.Left) && double.IsFinite(box.Right)
+ && double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
+ && double.IsFinite(workArea.Left) && double.IsFinite(workArea.Right)
+ && double.IsFinite(workArea.Bottom) && double.IsFinite(workArea.Top)
+ && box.Left >= workArea.Left - 1e-9
+ && box.Right <= workArea.Right + 1e-9
+ && box.Bottom >= workArea.Bottom - 1e-9
+ && box.Top <= workArea.Top + 1e-9;
+ }
+
+ /// Check active improvement and every affected pair against
+ /// its immutable operation-entry floor on scratch positions.
+ public bool MaintainsValidity(
+ int index, Vector trial, List positions, Pair active, double activeDistance)
+ {
+ if (!InsideWorkArea(index, trial))
+ return false;
+
+ var current = positions[index];
+ positions[index] = trial;
+ try
+ {
+ var improved = PairClearance(active.IndexA, active.IndexB, positions).Distance;
+ if (!double.IsFinite(improved) || improved <= activeDistance + 1e-7)
+ return false;
+ foreach (var pair in Pairs)
+ {
+ if (pair.IndexA != index && pair.IndexB != index)
+ continue;
+ var clearance = PairClearance(pair.IndexA, pair.IndexB, positions).Distance;
+ if (!double.IsFinite(clearance) || clearance < pair.EntryFloor - FloorTolerance)
+ return false;
+ // Once an initial overlap is cleared, never reintroduce it.
+ if (clearance < 0)
+ {
+ positions[index] = current;
+ var before = PairClearance(pair.IndexA, pair.IndexB, positions).Distance;
+ positions[index] = trial;
+ if (before >= 0)
+ return false;
+ }
+ }
+ return true;
+ }
+ finally
+ {
+ positions[index] = current;
+ }
+ }
+
private static List CloneAll(List polygons, Vector offset)
{
var list = new List(polygons.Count);
diff --git a/OpenNest.Engine/Fill/RemnantFiller.cs b/OpenNest.Engine/Fill/RemnantFiller.cs
index 22b18aa..8b123ff 100644
--- a/OpenNest.Engine/Fill/RemnantFiller.cs
+++ b/OpenNest.Engine/Fill/RemnantFiller.cs
@@ -15,10 +15,18 @@ namespace OpenNest.Engine.Fill
private readonly RemnantFinder finder;
private readonly double spacing;
- public RemnantFiller(Box workArea, double spacing)
+ ///
+ /// are kept-out rectangles (e.g. inflated clamp
+ /// zones) treated like occupied space: they are excluded from the remnant areas
+ /// every fill attempt sees. Null keeps today's behaviour exactly.
+ ///
+ public RemnantFiller(Box workArea, double spacing, IEnumerable boxObstacles = null)
{
this.spacing = spacing;
finder = new RemnantFinder(workArea);
+ if (boxObstacles != null)
+ foreach (var box in boxObstacles)
+ finder.AddObstacle(box);
}
public void AddObstacles(IEnumerable parts)
diff --git a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
index 0cb8260..182e629 100644
--- a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
+++ b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using OpenNest.CNC;
namespace OpenNest.Engine.Jobs.Adapters;
@@ -54,8 +55,11 @@ public static class DrawingJobMapper
quantity,
plate.PartSpacing,
plate.EdgeSpacing,
- plate.Quadrant
- );
+ plate.Quadrant,
+ cost: null,
+ clampZones: new List(plate.ClampZones)
+ )
+ { ClampRail = plate.ClampRail };
}
public static Program ToProgram(PartGeometrySnapshot geometry)
@@ -104,12 +108,19 @@ public static class DrawingJobMapper
internal static double LegacyStep(RotationPolicy policy) =>
policy.Kind == RotationPolicyKind.Fixed ? OpenNest.Math.Angle.TwoPI : policy.Step;
- internal static Plate CreatePlate(NestPlateStock stock) =>
- new(stock.Size)
+ internal static Plate CreatePlate(NestPlateStock stock)
+ {
+ var plate = new Plate(stock.Size)
{
Quantity = 1,
PartSpacing = stock.PartSpacing,
EdgeSpacing = stock.EdgeSpacing,
Quadrant = stock.Quadrant,
};
+ // Every Plate stores zones in plate (sheet lower-left) coordinates; consumers map
+ // them into the placement frame through ClampZoneFrame, exactly as the stock did.
+ foreach (var zone in stock.SheetClampZones)
+ plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds));
+ return plate;
+ }
}
diff --git a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs
new file mode 100644
index 0000000..d5194ca
--- /dev/null
+++ b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs
@@ -0,0 +1,516 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+
+namespace OpenNest.Engine.Jobs;
+
+///
+/// D8/D9 last-resort clamp removal for the whole-job pipeline. After a normal clamped solve
+/// leaves demand unmet, finds parts that cannot fit anywhere with every clamp in place (C10),
+/// computes the fewest clamps whose removal lets each one fit (C11), and re-solves the
+/// residual demand on stock variants with those clamps taken off (C12). Never changes the
+/// result when nothing is blocked (C10's bit-identical guarantee) and never trades a removal
+/// for material savings - it only runs after the ordinary solve could not place a part.
+///
+internal static class ClampRemovalRecovery
+{
+ /// A result that replaces the caller's raw solve, and the expanded job it was
+ /// validated against (the caller's own stock plus the reduced-clamp variants used).
+ internal sealed record Outcome(NestJob Job, NestJobResult Result);
+
+ ///
+ /// Most engine solves one recovery may spend on feasibility probes, independent of job,
+ /// stock and rail size. Running out abandons the recovery and keeps the ordinary result
+ /// (no clamp is removed on an unfinished search).
+ ///
+ internal const int MaxProbeSolves = 1000;
+
+ ///
+ /// Null when there is nothing to recover: the job is already complete, no offered stock
+ /// has any clamp, no remaining part is genuinely clamp-blocked, or removing clamps did
+ /// not let anything new fit. Callers keep their original job/result unchanged in every
+ /// one of those cases (C10).
+ ///
+ internal static Outcome TryRecover(
+ NestJob job,
+ NestJobResult raw,
+ INestingEngine engine,
+ CancellationToken token
+ )
+ {
+ ArgumentNullException.ThrowIfNull(job);
+ ArgumentNullException.ThrowIfNull(raw);
+ ArgumentNullException.ThrowIfNull(engine);
+
+ // Recovery is optional: any failure inside it (a throwing probe or residual solve)
+ // keeps the caller's ordinary result instead of discarding it. Cancellation still
+ // propagates.
+ try
+ {
+ return Recover(job, raw, engine, token);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ token.ThrowIfCancellationRequested();
+ return null;
+ }
+ }
+
+ private static Outcome Recover(NestJob job, NestJobResult raw, INestingEngine engine, CancellationToken token)
+ {
+ if (raw.Status == NestJobStatus.Complete)
+ return null;
+
+ var availableStock = job.Plates.Where(s => s.Quantity != 0).ToList();
+ if (!availableStock.Any(CanRemoveClamps))
+ return null;
+
+ var partsById = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
+ var remaining = raw.Fulfillment
+ .Where(f => f.Unplaced > 0)
+ .Select(f => (Part: partsById[f.PartId], Unplaced: f.Unplaced))
+ .ToList();
+ if (remaining.Count == 0)
+ return null;
+
+ token.ThrowIfCancellationRequested();
+
+ // C10: only a part that fits nowhere with every clamp in place is "blocked". One
+ // lane's failure is not proof: a part any built-in lane can place clamped is not.
+ var lanes = FeasibilityLanes(engine);
+ var budget = new ProbeBudget(MaxProbeSolves);
+ var blocked = remaining
+ .Where(x => !lanes.Any(lane => FitsAnywhere(x.Part, availableStock, job.Options, lane, budget, token)))
+ .Select(x => x.Part)
+ .ToList();
+ if (blocked.Count == 0)
+ return null;
+
+ // Phase 2 state. Each kept sheet comes out of its base stock's remaining physical
+ // pool and the plate cap (C12: a finite pool is never exceeded).
+ var unmet = remaining.ToDictionary(x => x.Part.Id, x => x.Unplaced, StringComparer.Ordinal);
+ var blockedIds = new HashSet(blocked.Select(p => p.Id), StringComparer.Ordinal);
+ var pool = availableStock.ToDictionary(
+ s => s.Id,
+ s => s.Quantity is int q ? q - raw.StockUsage.Where(u => u.StockId == s.Id).Sum(u => u.Used) : (int?)null,
+ StringComparer.Ordinal);
+ int? platesLeft = job.Options.MaxPlates is int cap ? cap - raw.Plates.Count : null;
+ var tried = blocked.ToDictionary(p => p.Id, _ => new HashSet(StringComparer.Ordinal), StringComparer.Ordinal);
+ var recovered = new List<(NestPlateStock Variant, string BaseId, List Sheets)>();
+
+ // Rounds: each blocked part with demand left takes its best removal among the offered
+ // stocks it has not tried that still have sheets, so a part whose preferred stock runs
+ // out moves on to another stock (with that stock's own minimal removal).
+ for (var round = 0; round < availableStock.Count && platesLeft is not <= 0; round++)
+ {
+ // C11: per blocked part, the fewest clamps (any set, nearest the origin on ties)
+ // whose removal frees an empty sheet of one of those stocks.
+ // Parts needing the same removal on the same stock share one variant.
+ var groups = new List();
+ foreach (var part in blocked)
+ {
+ token.ThrowIfCancellationRequested();
+ if (unmet[part.Id] <= 0)
+ continue;
+ var usable = availableStock
+ .Where(s => pool[s.Id] is not <= 0 && !tried[part.Id].Contains(s.Id))
+ .ToList();
+ if (usable.Count == 0)
+ continue;
+ var best = BestRemoval(part, usable, job.Options, lanes, budget, token);
+ if (best == null)
+ {
+ // Unfit on all of them even with every clamp removed: stays unplaced.
+ tried[part.Id].UnionWith(usable.Select(s => s.Id));
+ continue;
+ }
+ tried[part.Id].Add(best.BaseStockId);
+ var group = groups.FirstOrDefault(g => g.Removal.Stock.Id == best.Stock.Id);
+ if (group == null)
+ groups.Add(group = new RemovalGroup(best));
+ group.BlockedIds.Add(part.Id);
+ }
+ if (groups.Count == 0)
+ break;
+
+ // One removal variant at a time, fewest removed clamps first: each variant takes
+ // its own blocked parts plus any other unmet demand that is not blocked (freed
+ // space may take other parts, C12). A sheet is kept only when it holds a part
+ // that needed exactly this removal (C10/C11 per sheet).
+ foreach (var group in groups.OrderBy(g => g.Removal.RemovedIds.Count))
+ {
+ token.ThrowIfCancellationRequested();
+ var baseId = group.Removal.BaseStockId;
+ var available = pool[baseId];
+ if (available <= 0 || platesLeft <= 0)
+ continue;
+ int? sheetCap = (available, platesLeft) switch
+ {
+ (int a, int p) => System.Math.Min(a, p),
+ (int a, null) => a,
+ (null, int p) => p,
+ _ => null,
+ };
+
+ var parts = remaining
+ .Where(x => unmet[x.Part.Id] > 0
+ && (group.BlockedIds.Contains(x.Part.Id) || !blockedIds.Contains(x.Part.Id)))
+ .Select(x => new NestJobPart(x.Part.Id, x.Part.Geometry, unmet[x.Part.Id], x.Part.Priority, x.Part.Rotation))
+ .ToList();
+ if (!parts.Any(p => group.BlockedIds.Contains(p.Id)))
+ continue;
+
+ var variant = Clone(group.Removal.Stock, group.Removal.Stock.Id, sheetCap, null);
+ var options = new NestJobOptions(job.Options.PlacementStrategy, platesLeft,
+ job.Options.SalvageRate, job.Options.MinimumSalvageDimension);
+ var groupJob = new NestJob(parts, new[] { variant }, options);
+
+ List kept = null;
+ foreach (var lane in lanes)
+ {
+ token.ThrowIfCancellationRequested();
+ var solved = lane.Solve(groupJob, null, token);
+ token.ThrowIfCancellationRequested();
+ kept = solved?.Plates
+ .Where(sheet => sheet.Placements.Any(p => group.BlockedIds.Contains(p.PartId)))
+ .Take(sheetCap ?? int.MaxValue)
+ .ToList();
+ if (kept is { Count: > 0 })
+ break;
+ }
+ if (kept is not { Count: > 0 })
+ continue;
+
+ foreach (var placement in kept.SelectMany(sheet => sheet.Placements))
+ unmet[placement.PartId]--;
+ if (pool[baseId] is int left)
+ pool[baseId] = left - kept.Count;
+ if (platesLeft is int plates)
+ platesLeft = plates - kept.Count;
+ recovered.Add((group.Removal.Stock, baseId, kept));
+ }
+ }
+ if (recovered.Count == 0)
+ return null;
+
+ return Merge(job, raw, recovered);
+ }
+
+ private sealed class RemovalGroup(Removal removal)
+ {
+ public Removal Removal { get; } = removal;
+ public HashSet BlockedIds { get; } = new(StringComparer.Ordinal);
+ }
+
+ ///
+ /// Engines whose placement of a lone part proves it fits (C10): the caller's own engine,
+ /// plus the built-in Irregular (NFP, true material) and Rectangles lanes. Default already
+ /// runs both, so it is enough on its own. Phase 2 tries them in this order too.
+ ///
+ private static IReadOnlyList FeasibilityLanes(INestingEngine engine)
+ {
+ if (engine is NestingEngines.Default.DefaultNestingEngine)
+ return new[] { engine };
+ var lanes = new List { engine };
+ if (engine is not NestingEngines.Irregular.IrregularNestingEngine)
+ lanes.Add(new NestingEngines.Irregular.IrregularNestingEngine());
+ if (engine is not NestingEngines.Rectangles.RectanglesNestingEngine)
+ lanes.Add(new NestingEngines.Rectangles.RectanglesNestingEngine());
+ return lanes;
+ }
+
+ private sealed record Removal(NestPlateStock Stock, string BaseStockId, IReadOnlyList RemovedIds);
+
+ ///
+ /// The (stock, removal) with the fewest removed clamps across every offered rail-backed
+ /// stock; ties prefer the stock listed earliest, then the clamps nearest the origin. One
+ /// all-clamps-removed probe per stock first dismisses stock the part cannot use at all
+ /// and picks the lanes worth asking; one probe per clamp then finds the clamps every
+ /// removal must include; the set search goes size by size across the remaining stock and
+ /// stops at the first size that fits anywhere.
+ ///
+ private static Removal BestRemoval(
+ NestJobPart part,
+ IReadOnlyList stocks,
+ NestJobOptions options,
+ IReadOnlyList lanes,
+ ProbeBudget budget,
+ CancellationToken token
+ )
+ {
+ var candidates = new List<(NestPlateStock Stock, List Zones, List Lanes)>();
+ foreach (var stock in stocks)
+ {
+ if (!CanRemoveClamps(stock))
+ continue;
+ token.ThrowIfCancellationRequested();
+ // Placement-frame X is signed from the origin, so |centre X| is the distance
+ // along the edge in every quadrant.
+ var zones = stock.ClampZones
+ .OrderBy(z => System.Math.Abs(z.Bounds.X + z.Bounds.Length / 2))
+ .ToList();
+ var allRemoved = Clone(stock, stock.Id, null, zones.Select(z => z.Id).ToList());
+ var fitting = lanes.Where(lane => FitsAlone(part, allRemoved, options, lane, budget, token)).ToList();
+ if (fitting.Count > 0)
+ candidates.Add((stock, zones, fitting));
+ }
+
+ // Removing more clamps never stops a part fitting, so a clamp the part cannot live
+ // with even when it is the only one left must be in every feasible removal.
+ var searches = new List<(NestPlateStock Stock, List Zones, List Lanes, List Mandatory)>();
+ foreach (var (stock, zones, fitting) in candidates)
+ {
+ var mandatory = new List();
+ for (var i = 0; i < zones.Count; i++)
+ {
+ token.ThrowIfCancellationRequested();
+ var onlyThis = Clone(stock, stock.Id, null, zones.Where((_, j) => j != i).Select(z => z.Id).ToList());
+ if (zones.Count == 1 || !fitting.Any(lane => FitsAlone(part, onlyThis, options, lane, budget, token)))
+ mandatory.Add(i);
+ }
+ searches.Add((stock, zones, fitting, mandatory));
+ }
+
+ // Then the fewest clamps overall (any set, not only neighbours: a notch can leave a
+ // middle clamp in place), size by size across stocks in offered order; within a stock
+ // the optional additions nearest the origin first.
+ var maxSize = searches.Count == 0 ? 0 : searches.Max(c => c.Zones.Count);
+ for (var size = 1; size <= maxSize; size++)
+ {
+ foreach (var (stock, zones, fitting, mandatory) in searches)
+ {
+ if (mandatory.Count > size || size > zones.Count)
+ continue;
+ var optional = Enumerable.Range(0, zones.Count).Where(i => !mandatory.Contains(i)).ToList();
+ foreach (var added in Combinations(optional, size - mandatory.Count))
+ {
+ token.ThrowIfCancellationRequested();
+ var removedIds = mandatory.Concat(added).OrderBy(i => i).Select(i => zones[i].Id).ToList();
+ var candidate = Clone(stock, Suffixed(stock.Id, removedIds), null, removedIds);
+ // Removing every clamp was already probed.
+ if (size == zones.Count || fitting.Any(lane => FitsAlone(part, candidate, options, lane, budget, token)))
+ return new Removal(candidate, stock.Id, removedIds);
+ }
+ }
+ }
+ return null;
+ }
+
+ /// Every -element subset of , lazily,
+ /// in lexicographic order of positions.
+ private static IEnumerable> Combinations(IReadOnlyList items, int count)
+ {
+ if (count < 0 || count > items.Count)
+ yield break;
+ var positions = Enumerable.Range(0, count).ToArray();
+ while (true)
+ {
+ yield return positions.Select(p => items[p]).ToList();
+ var k = count - 1;
+ while (k >= 0 && positions[k] == items.Count - count + k)
+ k--;
+ if (k < 0)
+ yield break;
+ positions[k]++;
+ for (var j = k + 1; j < count; j++)
+ positions[j] = positions[j - 1] + 1;
+ }
+ }
+
+ /// Only clamps resolved from a machine rail can be removed (see
+ /// ): explicit zones have no clamp to name.
+ private static bool CanRemoveClamps(NestPlateStock stock) =>
+ stock.ClampRail != null && stock.ClampZones.Count > 0;
+
+ private static bool FitsAlone(
+ NestJobPart part,
+ NestPlateStock stock,
+ NestJobOptions options,
+ INestingEngine engine,
+ ProbeBudget budget,
+ CancellationToken token
+ ) => Probe(part, new[] { stock }, options, engine, budget, token);
+
+ private static bool FitsAnywhere(
+ NestJobPart part,
+ IReadOnlyList stocks,
+ NestJobOptions options,
+ INestingEngine engine,
+ ProbeBudget budget,
+ CancellationToken token
+ ) => Probe(part, stocks, options, engine, budget, token);
+
+ /// Whether this part, alone and at quantity one, places on an empty sheet of any
+ /// of the given stock entries with the given engine. A question already answered for the
+ /// same part, engine and physically identical stock is reused; otherwise it spends one
+ /// probe from the budget.
+ private static bool Probe(
+ NestJobPart part,
+ IReadOnlyList stocks,
+ NestJobOptions options,
+ INestingEngine engine,
+ ProbeBudget budget,
+ CancellationToken token
+ )
+ {
+ var key = (engine, part.Id, string.Join(";", stocks.Select(Signature).Distinct(StringComparer.Ordinal)));
+ if (budget.Answers.TryGetValue(key, out var known))
+ return known;
+ budget.Spend();
+ var probePart = new NestJobPart(part.Id, part.Geometry, 1, part.Priority, part.Rotation);
+ var probeStock = stocks.Select(s => Unlimited(s)).ToList();
+ var probeOptions = new NestJobOptions(options.PlacementStrategy, 1, 0, 0);
+ var probeJob = new NestJob(new[] { probePart }, probeStock, probeOptions);
+ token.ThrowIfCancellationRequested();
+ var result = engine.Solve(probeJob, null, token);
+ var fits = result != null && result.Status == NestJobStatus.Complete;
+ budget.Answers[key] = fits;
+ return fits;
+ }
+
+ /// Everything about a stock entry that can change whether a lone part fits:
+ /// sheet size, quadrant, spacings and the exact clamp zones (not its id, cost or
+ /// quantity).
+ private static string Signature(NestPlateStock stock)
+ {
+ var e = stock.EdgeSpacing;
+ var zones = string.Join(",", stock.SheetClampZones.Select(z =>
+ FormattableString.Invariant($"{z.Id}:{z.Bounds.X:R}/{z.Bounds.Y:R}/{z.Bounds.Length:R}/{z.Bounds.Width:R}")));
+ return FormattableString.Invariant(
+ $"{stock.Size.Width:R}x{stock.Size.Length:R}|q{stock.Quadrant}|{stock.PartSpacing:R}|{e.Left:R},{e.Bottom:R},{e.Right:R},{e.Top:R}|{zones}");
+ }
+
+ private sealed class ProbeBudget(int solves)
+ {
+ private int left = solves;
+
+ /// Answers already paid for, by (engine, part, stock signatures).
+ public Dictionary<(INestingEngine, string, string), bool> Answers { get; } = new();
+
+ /// Throws once spent; recovery
+ /// then keeps the ordinary result.
+ public void Spend()
+ {
+ if (left-- <= 0)
+ throw new ProbeBudgetExhaustedException();
+ }
+ }
+
+ private sealed class ProbeBudgetExhaustedException()
+ : Exception("Clamp removal recovery ran out of feasibility probes.");
+
+ /// Same stock with unlimited quantity (feasibility probes never consume real
+ /// inventory); id/zones unchanged.
+ private static NestPlateStock Unlimited(NestPlateStock stock) =>
+ stock.Quantity == null ? stock : Clone(stock, stock.Id, null, null);
+
+ ///
+ /// A copy of with a new id/quantity and, when
+ /// is given, those clamp zones dropped. Built from the
+ /// stock's own sheet-frame zones, so the kept zones are bit-identical to the original's.
+ ///
+ private static NestPlateStock Clone(
+ NestPlateStock stock,
+ string id,
+ int? quantity,
+ IReadOnlyCollection removedIds
+ )
+ {
+ var sheetZones = stock.SheetClampZones
+ .Where(z => removedIds == null || !removedIds.Contains(z.Id))
+ .ToList();
+ return new NestPlateStock(
+ id,
+ stock.Size,
+ quantity,
+ stock.PartSpacing,
+ stock.EdgeSpacing,
+ stock.Quadrant,
+ stock.Cost,
+ sheetZones
+ )
+ { ClampRail = stock.ClampRail };
+ }
+
+ private static string Suffixed(string stockId, IReadOnlyList removedIds) =>
+ $"{stockId}::removed:{string.Join(",", removedIds)}";
+
+ ///
+ /// Combines the ordinary solve with the kept recovery sheets into one result, and the
+ /// expanded job it validates against: the caller's stock with each finite base reduced
+ /// by the sheets its variants took, plus each variant with exactly the sheets it used
+ /// (unlimited when its base is), so the expanded inventory equals the physical one.
+ /// Recovery placements get fresh instance indices continuing on from phase 1's.
+ ///
+ private static Outcome Merge(
+ NestJob job,
+ NestJobResult raw,
+ IReadOnlyList<(NestPlateStock Variant, string BaseId, List Sheets)> recovered
+ )
+ {
+ // A variant used in more than one round is one stock entry with all its sheets.
+ recovered = recovered
+ .GroupBy(r => r.Variant.Id, StringComparer.Ordinal)
+ .Select(g => (g.First().Variant, g.First().BaseId, g.SelectMany(r => r.Sheets).ToList()))
+ .ToList();
+ var placedSoFar = raw.Fulfillment.ToDictionary(f => f.PartId, f => f.Placed, StringComparer.Ordinal);
+ var nextPlateIndex = raw.Plates.Count;
+ var reindexed = new List();
+ var variantStock = new List();
+ var takenFromBase = new Dictionary(StringComparer.Ordinal);
+ foreach (var (variant, baseId, sheets) in recovered)
+ {
+ var baseStock = job.Plates.First(s => s.Id == baseId);
+ var used = Clone(variant, variant.Id, baseStock.Quantity == null ? null : sheets.Count, null);
+ variantStock.Add(used);
+ takenFromBase[baseStock.Id] = takenFromBase.GetValueOrDefault(baseStock.Id) + sheets.Count;
+ foreach (var sheet in sheets)
+ {
+ var placements = new List(sheet.Placements.Count);
+ foreach (var pose in sheet.Placements)
+ {
+ var index = placedSoFar.GetValueOrDefault(pose.PartId);
+ placedSoFar[pose.PartId] = index + 1;
+ placements.Add(pose with { InstanceIndex = index });
+ }
+ reindexed.Add(new NestJobPlateResult(nextPlateIndex++, used, placements));
+ }
+ }
+
+ var allPlates = raw.Plates.Concat(reindexed).ToList();
+ var fulfillment = job.Parts
+ .Select(p =>
+ {
+ var placed = placedSoFar.GetValueOrDefault(p.Id);
+ return new PartFulfillment(p.Id, p.Quantity, placed, p.Quantity - placed);
+ })
+ .ToList();
+
+ var used1 = new Dictionary(StringComparer.Ordinal);
+ foreach (var u in raw.StockUsage)
+ used1[u.StockId] = used1.GetValueOrDefault(u.StockId) + u.Used;
+
+ var expandedStock = job.Plates
+ .Select(s => s.Quantity is int q && takenFromBase.TryGetValue(s.Id, out var taken)
+ ? Clone(s, s.Id, q - taken, null)
+ : s)
+ .Concat(variantStock)
+ .ToList();
+ var stockUsage = expandedStock
+ .Select(s =>
+ {
+ var usedCount = used1.GetValueOrDefault(s.Id) + reindexed.Count(p => p.StockId == s.Id);
+ return new StockUsage(s.Id, usedCount, s.Quantity is int q ? q - usedCount : null);
+ })
+ .ToList();
+
+ var complete = fulfillment.All(f => f.Unplaced == 0);
+ var status = complete ? NestJobStatus.Complete : raw.Status;
+ var stopReason = complete ? NestJobStopReason.Completed : raw.StopReason;
+
+ var expandedJob = new NestJob(job.Parts, expandedStock, job.Options);
+ var result = new NestJobResult(status, stopReason, allPlates, fulfillment, stockUsage);
+ return new Outcome(expandedJob, result);
+ }
+}
diff --git a/OpenNest.Engine/Jobs/InteractiveClamps.cs b/OpenNest.Engine/Jobs/InteractiveClamps.cs
new file mode 100644
index 0000000..525de3c
--- /dev/null
+++ b/OpenNest.Engine/Jobs/InteractiveClamps.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using OpenNest.CNC;
+using OpenNest.Converters;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Jobs;
+
+///
+/// Interactive-path clamp keep-outs (Slice 4): the inflated rectangles and static obstacle
+/// parts that make the single-plate fill, push and pack surfaces respect
+/// (mapped into the placement frame) like existing parts. Zones are never material: the fake
+/// obstacle parts exist only inside a single call - they are never added to
+/// , plates, jobs or files.
+///
+internal static class InteractiveClamps
+{
+ public static bool HasZones(Plate plate) => plate.ClampZones.Count > 0;
+
+ ///
+ /// Raw zone rectangles in the parts' placement frame (plate zones are stored in sheet
+ /// coordinates, see ), detached copies, capture order.
+ ///
+ public static IReadOnlyList ZoneRectangles(Plate plate)
+ {
+ if (plate.ClampZones.Count == 0)
+ return Array.Empty();
+ var zones = ClampZoneFrame.ToPlacement(plate);
+ var boxes = new Box[zones.Count];
+ for (var i = 0; i < boxes.Length; i++)
+ boxes[i] = Copy(zones[i].Bounds);
+ return boxes;
+ }
+
+ ///
+ /// Placement keep-outs inflated by the full bottom-edge clearance (D6) - the same
+ /// convention the Rectangles lane blocks and engines honour, so fill and pack produce
+ /// placements with at least that clearance from the raw zone.
+ ///
+ public static IReadOnlyList ClearanceRectangles(Plate plate) =>
+ InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom));
+
+ ///
+ /// The validator's exact C1 rectangle (NestLayoutCheck: bottom spacing less the shared
+ /// spacing slack). Used by the service safety net so flush engine-clear placements are
+ /// never filtered by rounding.
+ ///
+ public static IReadOnlyList ValidationKeepOuts(Plate plate) =>
+ InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom - NestTolerances.SpacingSlack));
+
+ private static IReadOnlyList InflateZones(Plate plate, double clearance)
+ {
+ if (plate.ClampZones.Count == 0)
+ return Array.Empty();
+ var zones = ClampZoneFrame.ToPlacement(plate);
+ var boxes = new Box[zones.Count];
+ for (var i = 0; i < boxes.Length; i++)
+ boxes[i] = zones[i].Bounds.Offset(clearance);
+ return boxes;
+ }
+
+ ///
+ /// Static obstacle parts for the directional push kernels. The kernel enforces
+ /// part-spacing between every obstacle pair, so the rectangles carry only the
+ /// clearance the kernel would otherwise miss: max(0, bottom spacing - part spacing).
+ /// Final material clearance from a raw zone is then at least max(bottom, partSpacing),
+ /// never below the C1 keep-out.
+ ///
+ public static List ObstacleParts(Plate plate) =>
+ ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom - plate.PartSpacing));
+
+ /// Expander walls use the bottom-edge clearance as a fixed floor,
+ /// independent of the separation target or its final achieved spacing.
+ public static List WallParts(Plate plate) =>
+ ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom));
+
+ private static List ObstaclePartsFor(IReadOnlyList rectangles, double inflate)
+ {
+ var parts = new List(rectangles.Count);
+ foreach (var box in rectangles)
+ {
+ var inflated = box.Offset(inflate);
+ var drawing = RectangleDrawing(inflated.Length, inflated.Width);
+ parts.Add(new Part(drawing, new Vector(inflated.Left, inflated.Bottom)));
+ }
+ return parts;
+ }
+
+ ///
+ /// Exact material overlap oracle for the service safety net: part outline with its
+ /// holes subtracted against the keep-out rectangle, matching the C1 checker's verdict
+ /// (positive-area overlap only - flush contact is clear).
+ ///
+ public static bool MaterialInKeepOut(Part part, Box keepOut)
+ {
+ var partProfile = new ShapeProfile(PartGeometry.GetPartEntities(part));
+ if (partProfile.Perimeter == null)
+ return true;
+ // A keep-out below the polygon tolerance collapses in the tolerant test and would
+ // read as clear (PM b0062c73); judge it tolerance-free instead.
+ if (KeepOutOverlap.NeedsToleranceFreeTest(keepOut))
+ return KeepOutOverlap.ProfileEnters(partProfile, new Vector(0, 0), keepOut);
+ var zoneProfile = new ShapeProfile(RectangleEntities(keepOut));
+ if (zoneProfile.Perimeter == null)
+ return true;
+ return Collision.HasOverlap(
+ partProfile.Perimeter.ToPolygonWithTolerance(0.001),
+ zoneProfile.Perimeter.ToPolygonWithTolerance(0.001),
+ HolePolygons(partProfile),
+ HolePolygons(zoneProfile));
+ }
+
+ public static bool MaterialsTooClose(Part first, Part second, double spacing)
+ {
+ var a = new ShapeProfile(PartGeometry.GetPartEntities(first));
+ var b = new ShapeProfile(PartGeometry.GetPartEntities(second));
+ if (a.Perimeter == null || b.Perimeter == null)
+ return true;
+ var outerA = a.Perimeter.ToPolygonWithTolerance(0.001);
+ var outerB = b.Perimeter.ToPolygonWithTolerance(0.001);
+ var holesA = HolePolygons(a);
+ var holesB = HolePolygons(b);
+ if (Collision.HasOverlap(outerA, outerB, holesA, holesB))
+ return true;
+ if (spacing <= NestTolerances.SpacingSlack)
+ return false;
+ var ringsA = new List { outerA };
+ var ringsB = new List { outerB };
+ if (holesA != null) ringsA.AddRange(holesA);
+ if (holesB != null) ringsB.AddRange(holesB);
+ foreach (var ringA in ringsA)
+ foreach (var ringB in ringsB)
+ if (Clearance.BoundaryDistance(ringA, ringB).Distance
+ < spacing - NestTolerances.SpacingSlack)
+ return true;
+ return false;
+ }
+
+ private static List HolePolygons(ShapeProfile profile)
+ {
+ if (profile.Cutouts.Count == 0)
+ return null;
+ var holes = new List(profile.Cutouts.Count);
+ foreach (var cutout in profile.Cutouts)
+ holes.Add(cutout.ToPolygonWithTolerance(0.001));
+ return holes;
+ }
+
+ private static List RectangleEntities(Box box)
+ {
+ var program = new Program();
+ program.MoveTo(box.Left, box.Bottom);
+ program.LineTo(box.Right, box.Bottom);
+ program.LineTo(box.Right, box.Top);
+ program.LineTo(box.Left, box.Top);
+ program.LineTo(box.Left, box.Bottom);
+ return ConvertProgram.ToGeometry(program);
+ }
+
+ private static Box Copy(Box box) => new(box.X, box.Y, box.Length, box.Width);
+
+ // Rectangular obstacle drawings are immutable and shared across calls; rail clamps
+ // all share one size, so this stays tiny. Key equality implies identical geometry.
+ private static readonly ConcurrentDictionary<(double Length, double Width), Drawing> Drawings = new();
+
+ private static Drawing RectangleDrawing(double length, double width) =>
+ Drawings.GetOrAdd((length, width), size =>
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(size.Length, 0);
+ program.LineTo(size.Length, size.Width);
+ program.LineTo(0, size.Width);
+ program.LineTo(0, 0);
+ return new Drawing("clamp-zone-obstacle", program);
+ });
+}
diff --git a/OpenNest.Engine/Jobs/NestJobValidator.cs b/OpenNest.Engine/Jobs/NestJobValidator.cs
index 94baea1..1e25971 100644
--- a/OpenNest.Engine/Jobs/NestJobValidator.cs
+++ b/OpenNest.Engine/Jobs/NestJobValidator.cs
@@ -32,6 +32,7 @@ public static class NestJobValidator
$"Invalid stock dimensions/settings: {stock.Id}.",
nameof(job)
);
+ ValidateClampZones(stock, job);
}
foreach (var part in job.Parts)
{
@@ -71,6 +72,44 @@ public static class NestJobValidator
+ string.Join(", ", available.Where(s => !s.Cost.HasValue).Select(s => s.Id)));
}
+ ///
+ /// Every stock zone must be uniquely identified and reach onto its own sheet (a positive
+ /// overlap). A clamp is an avoidance area, so its whole footprint may hang past the sheet
+ /// edges; a zone wholly off the sheet is malformed. Geometry and finiteness are already
+ /// enforced by . Zones are compared in the placement frame, where
+ /// the stock carries them.
+ ///
+ private static void ValidateClampZones(NestPlateStock stock, NestJob job)
+ {
+ if (stock.ClampZones.Count == 0)
+ return;
+
+ var ids = new HashSet(StringComparer.Ordinal);
+ var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
+ var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
+
+ foreach (var zone in stock.ClampZones)
+ {
+ if (!ids.Add(zone.Id))
+ throw new ArgumentException(
+ $"Clamp zone id '{zone.Id}' is defined twice on stock {stock.Id}.",
+ nameof(job)
+ );
+
+ var b = zone.Bounds;
+ if (
+ !(b.Right > left)
+ || !(b.Left < left + stock.Size.Length)
+ || !(b.Top > bottom)
+ || !(b.Bottom < bottom + stock.Size.Width)
+ )
+ throw new ArgumentException(
+ $"Clamp zone '{zone.Id}' on stock {stock.Id} lies outside the sheet.",
+ nameof(job)
+ );
+ }
+ }
+
internal static void ValidateCandidate(
PlateCandidate candidate,
NestPlateStock stock,
diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
index c2597a3..aa08d62 100644
--- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs
+++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
@@ -153,11 +153,87 @@ public static class NestLayoutCheck
ValidateBounds(parts, plate, requirements, result);
ValidateAreaBudget(parts, plate, result);
ValidateSpacing(parts, plate.PartSpacing, requirements, result);
+ if (plate.ClampZones.Count > 0)
+ ValidateClamps(parts, plate, requirements, result);
}
return result;
}
+ ///
+ /// C1/C5: no part material may enter a clamp zone inflated by the plate's bottom-edge
+ /// spacing (D6) - the same clearance the part owes the sheet edge, enforced with the
+ /// shared spacing slack so exact engine placements are not reddened by rounding.
+ ///
+ private static void ValidateClamps(
+ List parts,
+ Plate plate,
+ IReadOnlyDictionary requirements,
+ List result
+ )
+ {
+ var inflateBy = InflationFor(plate.EdgeSpacing.Bottom);
+
+ // Plate zones are stored in sheet coordinates; parts are in the placement frame.
+ foreach (var zone in ClampZoneFrame.ToPlacement(plate))
+ {
+ // Box.Offset(d) grows the rectangle on all four sides.
+ var keepOut = zone.Bounds.Offset(inflateBy);
+ var keepOutOutline = RectangleOutline(keepOut);
+
+ foreach (var part in parts)
+ {
+ if (!BoxesTouch(keepOut, MaterialBounds(part)))
+ continue;
+
+ if (!OverlapsKeepOut(part, keepOutOutline, keepOut))
+ continue;
+
+ result.Add(
+ $"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) overlaps "
+ + $"clamp zone '{zone.Id}' including its edge clearance"
+ );
+ }
+ }
+ }
+
+ ///
+ /// The keep-out as an exact four-vertex ring. Routing a synthetic rectangle through the
+ /// tolerance-based validation offset collapses a zone smaller than that tolerance to
+ /// nothing (PM 6859db53), so the rectangle is never offset or flattened here.
+ ///
+ private static PartOutline RectangleOutline(Box box)
+ {
+ var perimeter = new Polygon();
+ perimeter.Vertices.Add(new Vector(box.Left, box.Bottom));
+ perimeter.Vertices.Add(new Vector(box.Right, box.Bottom));
+ perimeter.Vertices.Add(new Vector(box.Right, box.Top));
+ perimeter.Vertices.Add(new Vector(box.Left, box.Top));
+ perimeter.Vertices.Add(new Vector(box.Left, box.Bottom));
+ perimeter.UpdateBounds();
+ return new PartOutline { Perimeter = perimeter, Holes = new List() };
+ }
+
+ ///
+ /// Material against a keep-out rectangle. A rectangle below the outline tolerance, or one
+ /// the triangulated test cannot prepare, is judged by the tolerance-free rectangle test
+ /// on the part's native contours (not the grid-rounded validation outline): the
+ /// tolerant path would call real overlap clear. A part without material is skipped.
+ ///
+ private static bool OverlapsKeepOut(Part part, PartOutline keepOutOutline, Box keepOut)
+ {
+ if (KeepOutOverlap.NeedsToleranceFreeTest(keepOut) || keepOutOutline.Triangles == null)
+ {
+ var entities = ConvertProgram.ToGeometry(part.Program)
+ .Where(e => SpecialLayers.IsMaterial(e.Layer))
+ .ToList();
+ var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
+ return profile?.Perimeter != null && KeepOutOverlap.ProfileEnters(profile, part.Location, keepOut);
+ }
+ var material = Outline(part, 0);
+ return material != null && Overlaps(material, keepOutOutline);
+ }
+
///
/// Checks what the materialized layout cannot show: every sheet must be
/// one of the job's own stock entries (an engine may not invent a sheet
@@ -239,8 +315,31 @@ public static class NestLayoutCheck
&& expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top)
&& expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom)
&& expected.Quadrant == actual.Quadrant
+ && SameZones(expected.ClampZones, actual.ClampZones)
);
+ // Clamp sets are part of stock settings; a different set is not the offered stock.
+ private static bool SameZones(
+ IReadOnlyList x,
+ IReadOnlyList y
+ )
+ {
+ if (x.Count != y.Count)
+ return false;
+ for (var i = 0; i < x.Count; i++)
+ {
+ if (
+ !string.Equals(x[i].Id, y[i].Id, StringComparison.Ordinal)
+ || !x[i].Bounds.X.IsEqualTo(y[i].Bounds.X)
+ || !x[i].Bounds.Y.IsEqualTo(y[i].Bounds.Y)
+ || !x[i].Bounds.Length.IsEqualTo(y[i].Bounds.Length)
+ || !x[i].Bounds.Width.IsEqualTo(y[i].Bounds.Width)
+ )
+ return false;
+ }
+ return true;
+ }
+
private static string Describe(RotationPolicy policy) =>
policy.Kind switch
{
diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs
index b363327..0863533 100644
--- a/OpenNest.Engine/Jobs/NestPipeline.cs
+++ b/OpenNest.Engine/Jobs/NestPipeline.cs
@@ -176,6 +176,20 @@ public static class NestPipeline
var solveTime = clock.Elapsed;
token.ThrowIfCancellationRequested();
+ // D8/C10: only the main (non-cutout-preview) path offers last-resort clamp removal.
+ // No-op whenever nothing is blocked, so an ordinary solve is unaffected (C10).
+ if (prepass == null)
+ {
+ var recovered = ClampRemovalRecovery.TryRecover(job, raw, engine, token);
+ if (recovered != null)
+ {
+ job = recovered.Job;
+ engineJob = recovered.Job;
+ raw = recovered.Result;
+ }
+ }
+ token.ThrowIfCancellationRequested();
+
clock.Restart();
var names = drawingsByPartId.ToDictionary(
kv => kv.Key,
diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs
index a433345..86a282b 100644
--- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs
+++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
namespace OpenNest.Engine.Jobs;
@@ -42,6 +43,7 @@ public static class NestPipelineCommit
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
plate.Quantity = proposed.Quantity;
+ SyncClampZones(plate, proposed.Stock);
plate.Parts.AddRange(proposed.Parts);
applied.Add(plate);
}
@@ -52,4 +54,64 @@ public static class NestPipelineCommit
}
return applied.AsReadOnly();
}
+
+ ///
+ /// The live plate keeps exactly the clamp keep-outs its layout was solved and validated
+ /// against, including sheets created for this commit. Stock zones live in the placement
+ /// frame; plate zones in plate coordinates, so the stock's translation is inverted. Zones
+ /// already equal in the placement frame are left untouched (no change events). Replaced
+ /// zones stay tied to the plate's clamp rail only when that rail recreates them (directly,
+ /// or minus whichever clamps Auto Nest removed as a last resort, D8/C13); otherwise a
+ /// later size or quadrant assignment would silently swap in other clamps.
+ ///
+ private static void SyncClampZones(Plate plate, NestPlateStock stock)
+ {
+ var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
+ var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
+ // The rail the solved zones were resolved from wins over whatever the target follows;
+ // explicit stock keeps the target's own rail as before.
+ var rail = stock.ClampRail ?? plate.ClampRail;
+ if (!SameInPlacementFrame(plate.ClampZones, stock.ClampZones, left, bottom))
+ {
+ plate.SetClampRail(null); // explicit zones from here on
+ foreach (var zone in stock.ClampZones)
+ plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds.Translate(-left, -bottom)));
+ }
+ if (rail == null)
+ return;
+
+ // D8: the sheet's zones may be the rail's resolution for it minus clamps Auto Nest
+ // removed as a last resort; derive which (if any) so the committed plate keeps
+ // following the rail - and the removal - through later resizes (C13). This runs even
+ // when the target already held exactly these zones, so the removal is never lost.
+ var kept = new HashSet(plate.ClampZones.Select(z => z.Id), StringComparer.Ordinal);
+ var removedClamps = rail.ResolveFor(plate.Size, plate.Quadrant)
+ .Select(z => z.Id)
+ .Where(id => !kept.Contains(id))
+ .ToList();
+ var unchanged = ReferenceEquals(plate.ClampRail, rail) && plate.RemovedClamps.SetEquals(removedClamps);
+ if (!unchanged && rail.Matches(plate, removedClamps))
+ plate.SetClampRail(rail, removedClamps);
+ }
+
+ private static bool SameInPlacementFrame(
+ IList plateZones,
+ IReadOnlyList stockZones,
+ double left,
+ double bottom)
+ {
+ if (plateZones.Count != stockZones.Count)
+ return false;
+ for (var i = 0; i < plateZones.Count; i++)
+ {
+ // Same arithmetic NestPlateStock uses to carry plate zones into the placement frame.
+ var placed = plateZones[i].Bounds.Translate(left, bottom);
+ var solved = stockZones[i].Bounds;
+ if (!string.Equals(plateZones[i].Id, stockZones[i].Id, StringComparison.Ordinal)
+ || placed.X != solved.X || placed.Y != solved.Y
+ || placed.Length != solved.Length || placed.Width != solved.Width)
+ return false;
+ }
+ return true;
+ }
}
diff --git a/OpenNest.Engine/Jobs/NestPlateStock.cs b/OpenNest.Engine/Jobs/NestPlateStock.cs
index 15d8e0b..9cb8638 100644
--- a/OpenNest.Engine/Jobs/NestPlateStock.cs
+++ b/OpenNest.Engine/Jobs/NestPlateStock.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
@@ -14,19 +15,21 @@ public sealed class NestPlateStock
int? quantity = null,
double partSpacing = 0,
Spacing edgeSpacing = default,
- int quadrant = 1
+ int quadrant = 1,
+ IReadOnlyList clampZones = null
)
- : this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null) { }
+ : this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null, clampZones) { }
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
- Spacing edgeSpacing, int quadrant, double? cost)
+ Spacing edgeSpacing, int quadrant, double? cost,
+ IReadOnlyList clampZones = null)
{
if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0))
throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null.");
- Cost = cost;
ArgumentException.ThrowIfNullOrWhiteSpace(id);
if (quantity < 0)
throw new ArgumentOutOfRangeException(nameof(quantity));
+ Cost = cost;
Id = id;
Size = size;
Quantity = quantity;
@@ -41,8 +44,51 @@ public sealed class NestPlateStock
size.Length - edgeSpacing.Left - edgeSpacing.Right,
size.Width - edgeSpacing.Bottom - edgeSpacing.Top
);
+ // Zones arrive in plate coordinates and are carried in the placement frame,
+ // translated exactly like WorkArea. Detached copies: stock is immutable input.
+ if (clampZones == null || clampZones.Count == 0)
+ {
+ ClampZones = Array.Empty();
+ SheetClampZones = Array.Empty();
+ }
+ else
+ {
+ var zones = new ClampZone[clampZones.Count];
+ var sheetZones = new ClampZone[clampZones.Count];
+ for (var i = 0; i < zones.Length; i++)
+ {
+ zones[i] = new ClampZone(clampZones[i].Id, clampZones[i].Bounds.Translate(left, bottom));
+ sheetZones[i] = new ClampZone(clampZones[i].Id, clampZones[i].Bounds);
+ }
+ ClampZones = new DetachedZones(zones);
+ SheetClampZones = sheetZones;
+ }
}
+ ///
+ /// Keep-out rectangles in the placement frame (quadrant applied), translated exactly
+ /// like . Empty when the plate defines none; never null. Every
+ /// read returns detached copies: zone bounds are mutable, and the stock must stay
+ /// consistent with the zones its materialized plates use.
+ ///
+ public IReadOnlyList ClampZones { get; }
+
+ ///
+ /// The same zones exactly as given, in plate coordinates (the frame of
+ /// ), for plates built from this stock. Keeping the input
+ /// avoids a lossy translate-and-back; of such a
+ /// plate reproduces bit for bit.
+ ///
+ internal IReadOnlyList SheetClampZones { get; }
+
+ ///
+ /// The machine clamp rail were resolved from, when the stock was
+ /// built from a rail-following plate; null for explicit zones. Only rail-backed clamps may
+ /// be taken off as a last resort (D8): the rail is what lets a committed sheet name the
+ /// clamps to pull and carry their removal time, so explicit zones are never removed.
+ ///
+ internal ClampRail ClampRail { get; init; }
+
/// Generic per-physical-sheet cost; null selects area scoring.
public double? Cost { get; }
public string Id { get; }
@@ -66,4 +112,26 @@ public sealed class NestPlateStock
/// True when a width (X) by height (Y) envelope fits the work area within epsilon.
public bool Fits(double width, double height, double epsilon = 1e-9) =>
width <= workArea.Length + epsilon && height <= workArea.Width + epsilon;
+
+ private sealed class DetachedZones : IReadOnlyList
+ {
+ private readonly ClampZone[] zones;
+
+ public DetachedZones(ClampZone[] zones) => this.zones = zones;
+
+ public int Count => zones.Length;
+
+ public ClampZone this[int index] => Copy(zones[index]);
+
+ public IEnumerator GetEnumerator()
+ {
+ foreach (var zone in zones)
+ yield return Copy(zone);
+ }
+
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+
+ // The ClampZone constructor copies the rectangle, keeping its exact bits.
+ private static ClampZone Copy(ClampZone zone) => new(zone.Id, zone.Bounds);
+ }
}
diff --git a/OpenNest.Engine/Jobs/NestStockBuilder.cs b/OpenNest.Engine/Jobs/NestStockBuilder.cs
index 6f4db77..fc16992 100644
--- a/OpenNest.Engine/Jobs/NestStockBuilder.cs
+++ b/OpenNest.Engine/Jobs/NestStockBuilder.cs
@@ -23,20 +23,32 @@ public static class NestStockBuilder
{
var option = options[i];
ArgumentNullException.ThrowIfNull(option);
+ var size = new Size(option.Width, option.Length);
stock.Add(new NestPlateStock(
$"option-{i}",
- new Size(option.Width, option.Length),
+ size,
quantityPerOption,
template.PartSpacing,
template.EdgeSpacing,
template.Quadrant,
- option.Cost == 0 ? null : option.Cost
- ));
+ option.Cost == 0 ? null : option.Cost,
+ ClampZonesFor(template, size)
+ )
+ { ClampRail = template.ClampRail });
}
NestJobValidator.ValidateStockCosts(stock);
return stock.AsReadOnly();
}
+ ///
+ /// A template following the machine clamp rail offers each sheet size with the rail
+ /// resolved for that size (clamps are fixed to the machine, not the sheet); explicit
+ /// template zones are offered unchanged.
+ ///
+ private static IReadOnlyList ClampZonesFor(Plate template, Size size) =>
+ template.ClampRail?.ResolveFor(size, template.Quadrant)
+ ?? new List(template.ClampZones);
+
/// Single-sheet callers may not silently create additional sheets.
public static IReadOnlyList SinglePlate(Plate plate) =>
FromTemplate(plate, null, quantityPerOption: 1);
diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs
index f85033d..888bc0c 100644
--- a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs
+++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs
@@ -27,6 +27,9 @@ internal static class PlateFillOrchestrator
return new List();
var workArea = plate.WorkArea();
+ // Slice 4 (C1): inflated clamp keep-outs act like occupied space for every stage
+ // below - fill remnants, pack areas, pair placement - so a zone is never covered.
+ var clampKeepOuts = InteractiveClamps.ClearanceRectangles(plate);
var allParts = new List();
var plateArea = workArea.Width * workArea.Length;
@@ -41,7 +44,7 @@ internal static class PlateFillOrchestrator
if (fillItems.Count > 0)
{
- var remnantFiller = new RemnantFiller(workArea, plate.PartSpacing);
+ var remnantFiller = new RemnantFiller(workArea, plate.PartSpacing, clampKeepOuts);
var fillParts = remnantFiller.FillItems(
fillItems,
@@ -58,6 +61,7 @@ internal static class PlateFillOrchestrator
var placedObstacles = fillParts
.Select(part => part.BoundingBox.Offset(plate.PartSpacing))
.ToList();
+ placedObstacles.AddRange(clampKeepOuts);
var finder = new RemnantFinder(workArea, placedObstacles);
var remnants = finder.FindRemnants();
if (remnants.Count > 0)
@@ -90,11 +94,11 @@ internal static class PlateFillOrchestrator
if (pairItems.Count > 0 && !token.IsCancellationRequested)
{
- var placed = PlaceBestFitPairs(plate, comparer, pairItems, allParts, plate.WorkArea());
+ var placed = PlaceBestFitPairs(plate, comparer, pairItems, allParts, plate.WorkArea(), clampKeepOuts);
allParts.AddRange(placed);
}
- Compactor.Settle(allParts, plate.WorkArea(), plate.PartSpacing);
+ Compactor.Settle(allParts, plate);
return allParts;
}
@@ -113,13 +117,16 @@ internal static class PlateFillOrchestrator
IFillComparer comparer,
List pairItems,
List existingParts,
- Box fullWorkArea
+ Box fullWorkArea,
+ IReadOnlyList clampKeepOuts = null
)
{
var result = new List();
var obstacles = existingParts
.Select(part => part.BoundingBox.Offset(plate.PartSpacing))
.ToList();
+ if (clampKeepOuts != null)
+ obstacles.AddRange(clampKeepOuts);
var finder = new RemnantFinder(fullWorkArea, obstacles);
foreach (var item in pairItems)
diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs
index ca8f4de..b75e65e 100644
--- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs
+++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
+using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs.Placement.Fillers;
using OpenNest.Geometry;
@@ -39,7 +41,13 @@ public static class PlateFillService
CancellationToken token
)
{
- return RequireFiller(strategy, plate).Fill(item, workArea, progress, token);
+ var filler = RequireFiller(strategy, plate);
+ if (!InteractiveClamps.HasZones(plate))
+ return filler.Fill(item, workArea, progress, token);
+ var result = FillClearAreas(plate, workArea, item, token,
+ (area, demand) => filler.Fill(demand, area, null, token));
+ ReportSafe(progress, result, workArea);
+ return result;
}
public static List FillGroup(
@@ -51,7 +59,13 @@ public static class PlateFillService
CancellationToken token
)
{
- return RequireFiller(strategy, plate).Fill(groupParts, workArea, progress, token);
+ var filler = RequireFiller(strategy, plate);
+ if (!InteractiveClamps.HasZones(plate))
+ return filler.Fill(groupParts, workArea, progress, token);
+ var result = ClearAreas(plate, workArea, token, (area, _) =>
+ filler.Fill(groupParts, area, null, token), 0, groupSize: groupParts.Count);
+ ReportSafe(progress, result, workArea);
+ return result;
}
public static List PackArea(
@@ -63,7 +77,21 @@ public static class PlateFillService
CancellationToken token
)
{
- return RequireFiller(strategy, plate).PackArea(box, items, progress, token);
+ var filler = RequireFiller(strategy, plate);
+ if (!InteractiveClamps.HasZones(plate))
+ return filler.PackArea(box, items, progress, token);
+ var result = new List();
+ foreach (var area in ClearRegionBoxes(plate, box))
+ {
+ token.ThrowIfCancellationRequested();
+ var remaining = items.Select(item => CopyDemand(item,
+ item.Quantity > 0 ? System.Math.Max(0, item.Quantity - result.Count(part =>
+ ReferenceEquals(part.BaseDrawing, item.Drawing))) : item.Quantity)).ToList();
+ var proposed = filler.PackArea(area, remaining, null, token);
+ AddClear(result, proposed, plate, area, items);
+ }
+ ReportSafe(progress, result, box);
+ return result;
}
public static List Nest(
@@ -93,9 +121,151 @@ public static class PlateFillService
ResolveStrategy(strategy, allowEmpty: false);
var filler = CreateFiller(strategy, plate);
filler.PlateNumber = plateNumber;
- return filler.Nest(items, progress, token);
+ if (!InteractiveClamps.HasZones(plate))
+ return filler.Nest(items, progress, token);
+
+ // Interactive proposals are never committed here. Unlike the unconstrained
+ // strategies, partition the sheet at static keep-outs before searching, then
+ // certify each accepted material footprint against the full zone and occupants.
+ var result = new List();
+ foreach (var item in items.OrderBy(item => item.Priority))
+ {
+ token.ThrowIfCancellationRequested();
+ // Fill/Remnant whole-plate orchestration skips exhausted demand; the
+ // single-item Fill API uses zero to mean unlimited. Strip has its own
+ // historical zero-quantity behavior, preserved here.
+ if (item.Quantity <= 0 && ResolveStrategy(strategy) != "Strip")
+ continue;
+ var proposed = FillClearAreas(plate, plate.WorkArea(), item, token,
+ (area, demand) => filler.Fill(demand, area, null, token), result);
+ result.AddRange(proposed);
+ if (item.Quantity > 0)
+ item.Quantity = System.Math.Max(0, item.Quantity - proposed.Count);
+ }
+ ReportSafe(progress, result, plate.WorkArea(), plateNumber);
+ return result;
}
+ // Do not expose a strategy's unverified intermediate placements: the UI can
+ // accept a progress preview before this service returns its certified proposal.
+ private static void ReportSafe(IProgress progress, List parts,
+ Box area, int plateNumber = 0) => progress?.Report(new NestProgress
+ {
+ BestParts = parts,
+ ActiveWorkArea = area,
+ PlateNumber = plateNumber,
+ IsOverallBest = true,
+ Description = $"Clamp-clear proposal: {parts.Count} parts",
+ });
+
+ private static NestItem CopyDemand(NestItem item, int quantity) => new()
+ {
+ Drawing = item.Drawing,
+ Quantity = quantity,
+ Priority = item.Priority,
+ RotationStart = item.RotationStart,
+ RotationEnd = item.RotationEnd,
+ StepAngle = item.StepAngle,
+ };
+
+ private static List FillClearAreas(Plate plate, Box box, NestItem item,
+ CancellationToken token, Func> fill,
+ List previouslyPlaced = null) =>
+ ClearAreas(plate, box, token, fill, item.Quantity, item, previouslyPlaced);
+
+ private static List ClearAreas(Plate plate, Box box, CancellationToken token,
+ Func> fill, int quantity, NestItem item = null,
+ List previouslyPlaced = null, int groupSize = 1)
+ {
+ var result = new List();
+ foreach (var area in ClearRegionBoxes(plate, box))
+ {
+ token.ThrowIfCancellationRequested();
+ if (quantity > 0 && result.Count >= quantity)
+ break;
+ var demand = item == null ? null : CopyDemand(item,
+ quantity > 0 ? quantity - result.Count : quantity);
+ AddClear(result, fill(area, demand), plate, area,
+ item == null ? null : new List { item }, previouslyPlaced, groupSize);
+ }
+ return result;
+ }
+
+ private static List ClearRegionBoxes(Plate plate, Box area)
+ {
+ var obstacles = InteractiveClamps.ClearanceRectangles(plate).ToList();
+ obstacles.AddRange(plate.Parts.Where(part => !part.BaseDrawing.IsCutOff)
+ .Select(part => part.BoundingBox.Offset(plate.PartSpacing)));
+ return new RemnantFinder(area, obstacles).FindRemnants();
+ }
+
+ private static void AddClear(List accepted, List proposed, Plate plate,
+ Box area, List demand = null, List previouslyPlaced = null,
+ int groupSize = 1)
+ {
+ if (proposed == null || groupSize < 1)
+ return;
+ // FillLinear may append an incomplete trailing tile. Keep complete copies
+ // ahead of it, but never expose the incomplete tail as a group.
+ for (var start = 0; start + groupSize <= proposed.Count; start += groupSize)
+ {
+ // A group is one proposal: never return just its frame or just its
+ // insert when a member collides with a clamp or occupied material.
+ var batch = new List();
+ for (var index = start; index < start + groupSize; index++)
+ {
+ if (!TryAddClear(batch, proposed[index], accepted, plate, area,
+ demand, previouslyPlaced))
+ break;
+ }
+ if (batch.Count == groupSize)
+ accepted.AddRange(batch);
+ }
+ }
+
+ private static bool TryAddClear(List batch, Part part, List accepted,
+ Plate plate, Box area, List demand, List previouslyPlaced)
+ {
+ var bounds = part.BoundingBox;
+ var full = plate.WorkArea();
+ // The packer permits a tiny overhang only at a real sheet edge,
+ // never across a remnant boundary cut by a clamp or another part.
+ double Slack(double edge, double sheet) => System.Math.Abs(edge - sheet) < 1e-9
+ ? NestTolerances.WorkAreaSlack : 1e-6;
+ if (bounds.Left < area.Left - Slack(area.Left, full.Left)
+ || bounds.Right > area.Right + Slack(area.Right, full.Right)
+ || bounds.Bottom < area.Bottom - Slack(area.Bottom, full.Bottom)
+ || bounds.Top > area.Top + Slack(area.Top, full.Top))
+ return false;
+ if (demand != null && !demand.Any(item =>
+ ReferenceEquals(item.Drawing, part.BaseDrawing) &&
+ (item.Quantity <= 0 || accepted.Count(p =>
+ ReferenceEquals(p.BaseDrawing, item.Drawing)) + batch.Count(p =>
+ ReferenceEquals(p.BaseDrawing, item.Drawing)) < item.Quantity)))
+ return false;
+ if (InteractiveClamps.ValidationKeepOuts(plate).Any(zone =>
+ Overlaps(bounds, zone) && InteractiveClamps.MaterialInKeepOut(part, zone)))
+ return false;
+ if (plate.Parts.Any(existing => !existing.BaseDrawing.IsCutOff &&
+ Overlaps(bounds, existing.BoundingBox.Offset(plate.PartSpacing))
+ && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing))
+ || accepted.Any(existing => Overlaps(bounds,
+ existing.BoundingBox.Offset(plate.PartSpacing))
+ && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing))
+ || batch.Any(existing => Overlaps(bounds,
+ existing.BoundingBox.Offset(plate.PartSpacing))
+ && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing))
+ || (previouslyPlaced?.Any(existing => Overlaps(bounds,
+ existing.BoundingBox.Offset(plate.PartSpacing))
+ && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) ?? false))
+ return false;
+ batch.Add(part);
+ return true;
+ }
+
+ private static bool Overlaps(Box a, Box b) => a.Left < b.Right && a.Right > b.Left
+ && a.Bottom < b.Top && a.Top > b.Bottom;
+
///
/// Resolves a caller-supplied strategy name: null or empty means "Fill"; otherwise the name
/// must match a built-in strategy, matched case-insensitively like the legacy registry's
diff --git a/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs b/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs
index 9f4d5f1..6b42a08 100644
--- a/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs
+++ b/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs
@@ -41,7 +41,31 @@ internal static class RepeatedPlateGrouping
&& Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom)
&& a.Quantity == b.Quantity
&& a.Cost.HasValue == b.Cost.HasValue
- && (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value));
+ && (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value))
+ && SameZones(a.ClampZones, b.ClampZones);
+
+ // C8: a layout solved with clamps is never reused for a plate without them - and a
+ // different clamp set is different stock. Ordered; ids and exact bits participate.
+ private static bool SameZones(
+ System.Collections.Generic.IReadOnlyList x,
+ System.Collections.Generic.IReadOnlyList y
+ )
+ {
+ if (x.Count != y.Count)
+ return false;
+ for (var i = 0; i < x.Count; i++)
+ {
+ if (
+ !string.Equals(x[i].Id, y[i].Id, StringComparison.Ordinal)
+ || Bits(x[i].Bounds.X) != Bits(y[i].Bounds.X)
+ || Bits(x[i].Bounds.Y) != Bits(y[i].Bounds.Y)
+ || Bits(x[i].Bounds.Length) != Bits(y[i].Bounds.Length)
+ || Bits(x[i].Bounds.Width) != Bits(y[i].Bounds.Width)
+ )
+ return false;
+ }
+ return true;
+ }
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
diff --git a/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs b/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs
new file mode 100644
index 0000000..1ceb7b0
--- /dev/null
+++ b/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs
@@ -0,0 +1,61 @@
+#nullable enable
+using Clipper2Lib;
+using OpenNest.Engine.Jobs;
+
+namespace OpenNest.Engine.NestingEngines.Irregular;
+
+///
+/// Static, clearance-inflated rectangles prepared once per stock per solve. Kept out of
+/// the part catalog and its spacing cache: clamp clearance is bottom edge spacing, not
+/// part spacing, and two stocks at the same part spacing may have different clamps.
+///
+internal sealed class ClampObstacles
+{
+ private readonly Orientation[] rectangles;
+ private readonly NoFitCache nfps = new(0);
+
+ public ClampObstacles(NestPlateStock stock)
+ {
+ rectangles = new Orientation[stock.ClampZones.Count];
+ var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
+ for (var i = 0; i < rectangles.Length; i++)
+ {
+ var box = stock.ClampZones[i].Bounds;
+ var (left, right) = EncloseOnGrid(box.Left - clearance, box.Right + clearance);
+ var (bottom, top) = EncloseOnGrid(box.Bottom - clearance, box.Top + clearance);
+ rectangles[i] = new Orientation
+ {
+ // Real part types are nonnegative; the private cache cannot alias them.
+ TypeIndex = -1,
+ Index = i,
+ Rotation = 0,
+ Tolerance = 0,
+ MinX = left,
+ MinY = bottom,
+ MaxX = right,
+ MaxY = top,
+ Outline = new PathD
+ {
+ new(left, bottom), new(right, bottom), new(right, top), new(left, top),
+ },
+ };
+ }
+ }
+
+ // NoFitCache quantizes before adding its safety margin. Enclose the keep-out
+ // on that existing lattice so a sub-grid rectangle cannot disappear first.
+ private static (double Lower, double Upper) EncloseOnGrid(double lower, double upper)
+ {
+ var scale = System.Math.Pow(10, NoFitCache.Precision);
+ var first = System.Math.Floor(lower * scale);
+ // Positive dimensions can be smaller than a coordinate ulp (upper == lower).
+ var last = System.Math.Max(System.Math.Ceiling(upper * scale), first + 1);
+ return (first / scale, last / scale);
+ }
+
+ public int Count => rectangles.Length;
+
+ // No additional physical spacing. NoFitCache retains the established arc/grid safety
+ // margin and covered-hole certification for the moving orientation and rectangle.
+ public Nfp Get(int index, Orientation moving) => nfps.Get(rectangles[index], moving);
+}
diff --git a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs
index 00d9a6f..deb3b39 100644
--- a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs
+++ b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs
@@ -35,8 +35,8 @@ internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList Par
///
/// For every (part type, orientation) still in play the packer keeps the exact set of legal
/// reference points: the inner-fit rectangle of the work area minus the no-fit polygons of
-/// everything already placed. Each placement subtracts one translated NFP from each region,
-/// so regions only shrink, and a region that empties is retired for the rest of the sheet.
+/// static clamps and everything already placed. Each placement subtracts one translated NFP
+/// from each region, so regions only shrink; an empty region is retired for the sheet.
///
/// Choice rule, applied over all types and orientations at once (not in a fixed order):
/// 1. Gap fill - if any part fits without pushing the packing front forward, place the
@@ -62,6 +62,7 @@ internal sealed class FrontierPacker
private readonly Box work;
private readonly WorkCounter counter;
private readonly BlockCatalog? blocks;
+ private readonly ClampObstacles? clamps;
public FrontierPacker(
IReadOnlyList types,
@@ -71,10 +72,12 @@ internal sealed class FrontierPacker
PackAxis axis,
double beta,
WorkCounter counter,
- BlockCatalog? blocks = null
+ BlockCatalog? blocks = null,
+ ClampObstacles? clamps = null
)
{
this.blocks = blocks;
+ this.clamps = stock.ClampZones.Count == 0 ? null : clamps ?? new ClampObstacles(stock);
this.counter = counter;
this.types = types;
this.nfps = nfps;
@@ -97,6 +100,16 @@ internal sealed class FrontierPacker
if (!stock.Fits(o.Width, o.Height))
return false;
var region = new Region(o, work, single);
+ if (clamps != null)
+ for (var i = 0; i < clamps.Count; i++)
+ {
+ token.ThrowIfCancellationRequested();
+ var nfp = clamps.Get(i, o);
+ token.ThrowIfCancellationRequested();
+ region.Subtract(nfp, 0, 0);
+ counter.Add(1);
+ token.ThrowIfCancellationRequested();
+ }
states.Add(region);
byOrientation[o] = region;
return true;
diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs
index 55dcf08..688615d 100644
--- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs
+++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs
@@ -118,6 +118,7 @@ public sealed class IrregularNestingEngine : INestingEngine
private const int MaxTail = 3;
private readonly Dictionary caches = new();
+ private readonly Dictionary clamps = new();
private readonly Dictionary>> pairs = new();
private readonly Dictionary blocks = new();
private readonly IReadOnlyDictionary parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
@@ -198,6 +199,15 @@ public sealed class IrregularNestingEngine : INestingEngine
return cache;
}
+ private ClampObstacles? ClampsFor(NestPlateStock stock)
+ {
+ if (stock.ClampZones.Count == 0)
+ return null;
+ if (!clamps.TryGetValue(stock, out var found))
+ clamps[stock] = found = new ClampObstacles(stock);
+ return found;
+ }
+
public bool PairFits(PartType type)
{
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
@@ -265,7 +275,8 @@ public sealed class IrregularNestingEngine : INestingEngine
if (stock.Quantity is int available && used[stock.Id] >= available)
continue;
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
- var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
+ var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work,
+ BlocksFor(stock), ClampsFor(stock));
var fill = packer.Fill(remaining, token);
sheetPacked?.Invoke();
if (fill.Parts.Count > 0)
diff --git a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs
index 5f56cd3..51f3b82 100644
--- a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs
+++ b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs
@@ -56,6 +56,7 @@ internal static class SheetPacker
var work = stock.WorkArea;
var s = stock.PartSpacing;
var sheet = new MaxRectsSheet(work.Right - work.Left + s, work.Top - work.Bottom + s);
+ BlockClamps(sheet, stock, work, s);
var packTypes = types
.Select(t => new PackType(
t.Priority,
@@ -84,6 +85,59 @@ internal static class SheetPacker
return new SheetPlan(stock, world, area, envelope, rule, mode);
}
+ ///
+ /// Blocks every clamp keep-out into the sheet's free list before packing. In the
+ /// inflated-box model an item at position p covers material [p, p + size] plus a trailing
+ /// part-spacing tail; blocking bin-local [zone - clearance + s, zone + clearance] per axis
+ /// therefore means exactly: item material keeps at least the bottom-edge clearance from
+ /// every clamp side, without paying any extra part-spacing tax there. When s exceeds the
+ /// inflated span the interval inverts (decision D11): block a zero-extent line at the
+ /// clamp edge facing the sheet interior — at (zone max + clearance) when the zone center
+ /// lies in the lower half of the bin on that axis, else at (zone min - clearance + s).
+ /// Parts on the interior side then keep the exact clearance; the unavoidable extra
+ /// exclusion (s - extent - 2*clearance) lands on the side toward the nearer sheet edge.
+ /// The old collapse to the zone centerline was NOT conservative: an item could start
+ /// exactly on the centerline with material inside the clamp.
+ /// Empty zone lists change nothing: the free list evolves bit-identically (C2).
+ ///
+ private static void BlockClamps(MaxRectsSheet sheet, NestPlateStock stock, Box work, double s)
+ {
+ if (stock.ClampZones.Count == 0)
+ return;
+
+ var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
+ foreach (var zone in stock.ClampZones)
+ {
+ var b = zone.Bounds; // placement frame -> bin-local packing coordinates
+ double x0(double v) => v - work.Left;
+ double y0(double v) => v - work.Bottom;
+ var left = x0(b.Left) - clearance + s;
+ var right = x0(b.Right) + clearance;
+ if (right <= left)
+ {
+ var center = x0((b.Left + b.Right) * 0.5);
+ // D11: the interior-facing clamp edge (toward the farther sheet edge).
+ var line = center < sheet.Width * 0.5
+ ? x0(b.Right) + clearance
+ : x0(b.Left) - clearance + s;
+ left = line;
+ right = line;
+ }
+ var bottom = y0(b.Bottom) - clearance + s;
+ var top = y0(b.Top) + clearance;
+ if (top <= bottom)
+ {
+ var center = y0((b.Bottom + b.Top) * 0.5);
+ var line = center < sheet.Height * 0.5
+ ? y0(b.Top) + clearance
+ : y0(b.Bottom) - clearance + s;
+ bottom = line;
+ top = line;
+ }
+ sheet.Block(new Rect(left, bottom, right - left, top - bottom));
+ }
+ }
+
private static Box Union(Box a, Box b)
{
var l = System.Math.Min(a.Left, b.Left);
diff --git a/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs b/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs
index af788de..5b19bff 100644
--- a/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs
+++ b/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs
@@ -91,6 +91,20 @@ internal sealed class MaxRectsSheet
/// Commits an item and splits every free rectangle it intersects.
public void Place(Rect item)
+ {
+ Split(item);
+ used.Add(item);
+ }
+
+ ///
+ /// Carves a keep-out rectangle (a machine clamp's inflated exclusion, for example) out
+ /// of the free space before any packing: same split logic as placements, but not a used
+ /// item - it consumes no material and contributes nothing to contact scoring. The
+ /// rectangle may extend past the bin; only interior intersections count.
+ ///
+ public void Block(Rect keepOut) => Split(keepOut);
+
+ private void Split(Rect item)
{
var next = new List(free.Count + 8);
foreach (var f in free)
@@ -111,7 +125,6 @@ internal sealed class MaxRectsSheet
}
free.Clear();
free.AddRange(Prune(next));
- used.Add(item);
}
private static List Prune(List rects)
diff --git a/OpenNest.Engine/Sequencing/PlateSequencing.cs b/OpenNest.Engine/Sequencing/PlateSequencing.cs
index 2e9b99f..b934d64 100644
--- a/OpenNest.Engine/Sequencing/PlateSequencing.cs
+++ b/OpenNest.Engine/Sequencing/PlateSequencing.cs
@@ -34,16 +34,27 @@ namespace OpenNest.Engine.Sequencing
private static List OrderCutOffsFirst(List ordered, Plate plate)
{
- var cuts = ordered.Select((part, index) => (Part: part, Index: index))
- .Where(item => item.Part.BaseDrawing.IsCutOff).ToList();
- if (cuts.Count == 0)
- return ordered;
-
var definitions = new Dictionary(ReferenceEqualityComparer.Instance);
foreach (var cutOff in plate.CutOffs)
definitions[cutOff.Drawing] = cutOff;
-
var bounds = plate.BoundingBox(includeParts: false);
+
+ // A cutoff that splits the whole sheet goes after every part and other cutoff, so the
+ // head never travels back across it. It passes through no part.
+ var separating = ordered.Where(part => part.BaseDrawing.IsCutOff
+ && definitions.TryGetValue(part.BaseDrawing, out var definition)
+ && CutOff.SeparatesSheet(part, definition, bounds)).ToList();
+ if (separating.Count > 0)
+ {
+ var last = new HashSet(separating, ReferenceEqualityComparer.Instance);
+ ordered = ordered.Where(part => !last.Contains(part)).ToList();
+ }
+
+ var cuts = ordered.Select((part, index) => (Part: part, Index: index))
+ .Where(item => item.Part.BaseDrawing.IsCutOff).ToList();
+ if (cuts.Count == 0)
+ return ordered.Concat(separating).ToList();
+
var emitted = new bool[ordered.Count];
var result = new List(ordered.Count);
for (var i = 0; i < ordered.Count; i++)
@@ -75,6 +86,7 @@ namespace OpenNest.Engine.Sequencing
}
}
+ result.AddRange(separating);
return result;
}
}
diff --git a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs
new file mode 100644
index 0000000..3c2c88b
--- /dev/null
+++ b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs
@@ -0,0 +1,559 @@
+using System.IO.Compression;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using OpenNest.Geometry;
+
+namespace OpenNest.IO.Tests;
+
+///
+/// The nest's machine clamp rail persists on the plate defaults so plates created after a
+/// reload still get the clamps; plates whose saved zones are the rail's follow it again,
+/// anything else is preserved as saved, and an empty sheet the rail recreates is not
+/// written (the editor's trailing sentinel stays out of the file).
+///
+public class ClampRailPersistenceTests
+{
+ private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 };
+
+ private static ClampRail Machine() => new(1.5, 0.625, MachineCenters);
+
+ /// A rail nest whose first sheet is kept by a cut-off; the second is empty.
+ private static Nest RailNest(bool withEmptyTail = true)
+ {
+ var nest = new Nest("railed");
+ nest.PlateDefaults.Size = new Size(48, 96);
+ nest.SetClampRail(Machine());
+ var used = nest.CreatePlate();
+ used.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
+ if (withEmptyTail)
+ nest.CreatePlate();
+ return nest;
+ }
+
+ private static MemoryStream Write(Nest nest)
+ {
+ var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ stream.Position = 0;
+ return stream;
+ }
+
+ private static Nest RoundTrip(Nest nest) => new NestReader(Write(nest)).Read();
+
+ [Fact]
+ public void RailRoundTripsAndPlatesFollowItAfterReload()
+ {
+ var reopened = RoundTrip(RailNest());
+
+ Assert.Equal(Machine(), reopened.PlateDefaults.ClampRail);
+ var plate = Assert.Single(reopened.Plates); // the empty tail was not written
+ Assert.Same(reopened.PlateDefaults.ClampRail, plate.ClampRail);
+ Assert.Equal(7, plate.ClampZones.Count); // 96" sheet: the clamp at 111" is skipped
+
+ // Plates created or resized after the reload still get the machine clamps.
+ Assert.Equal(7, reopened.CreatePlate().ClampZones.Count);
+ plate.Size = new Size(48, 120);
+ Assert.Equal(8, plate.ClampZones.Count);
+ }
+
+ [Fact]
+ public void EmptyPlateWithZonesTheRailCannotRecreateIsKept()
+ {
+ var nest = RailNest(withEmptyTail: false);
+ var odd = nest.CreatePlate();
+ odd.SetClampRail(null!);
+ odd.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1)));
+
+ var reopened = RoundTrip(nest);
+
+ Assert.Equal(2, reopened.Plates.Count);
+ var kept = reopened.Plates[1];
+ Assert.Equal("manual", Assert.Single(kept.ClampZones).Id);
+ Assert.Null(kept.ClampRail); // preserved as saved, not re-resolved
+ }
+
+ [Fact]
+ public void EmptyRailSheetThatDiffersFromThePlateDefaultsIsKept()
+ {
+ // The rail recreates its zones, but nothing recreates this sheet's size and quadrant.
+ var nest = RailNest(withEmptyTail: false);
+ var odd = nest.CreatePlate();
+ odd.Size = new Size(48, 60);
+ odd.Quadrant = 3;
+
+ var reopened = RoundTrip(nest);
+
+ Assert.Equal(2, reopened.Plates.Count);
+ var kept = reopened.Plates[1];
+ Assert.Equal(new Size(48, 60), kept.Size);
+ Assert.Equal(3, kept.Quadrant);
+ Assert.Equal(4, kept.ClampZones.Count);
+ Assert.Same(reopened.PlateDefaults.ClampRail, kept.ClampRail);
+ }
+
+ [Fact]
+ public void ChangedDefaultsMoveUntouchedSentinelAndSaveNoEmptySheets()
+ {
+ var nest = new Nest("railed");
+ nest.PlateDefaults.Size = new Size(48, 96);
+ nest.PlateDefaults.Quadrant = 1;
+ nest.SetClampRail(Machine());
+ using var manager = new PlateManager(nest);
+ manager.EnsureSentinel();
+ var sentinel = Assert.Single(nest.Plates);
+
+ manager.ChangePlateDefaults(() =>
+ {
+ nest.PlateDefaults.Size = new Size(48, 60);
+ nest.PlateDefaults.Quadrant = 3;
+ nest.PlateDefaults.EdgeSpacing = new Spacing(1, 2, 3, 4);
+ nest.PlateDefaults.PartSpacing = 0.5;
+ });
+ manager.EnsureSentinel();
+
+ Assert.Same(sentinel, Assert.Single(nest.Plates));
+ using var stream = Write(nest);
+ using var json = JsonDocument.Parse(ReadNestJson(stream));
+ Assert.Equal(0, json.RootElement.GetProperty("plates").GetArrayLength());
+ stream.Position = 0;
+ var reopened = new NestReader(stream).Read();
+ Assert.Empty(reopened.Plates);
+ using var reopenedManager = new PlateManager(reopened);
+ reopenedManager.EnsureSentinel();
+ var newSentinel = Assert.Single(reopened.Plates);
+ Assert.Equal(new Size(48, 60), newSentinel.Size);
+ Assert.Equal(3, newSentinel.Quadrant);
+ Assert.Equal(new Spacing(1, 2, 3, 4), newSentinel.EdgeSpacing);
+ Assert.Equal(0.5, newSentinel.PartSpacing);
+ Assert.Same(reopened.PlateDefaults.ClampRail, newSentinel.ClampRail);
+ Assert.True(newSentinel.ClampRail.Matches(newSentinel));
+ Assert.Equal(4, newSentinel.ClampZones.Count);
+ Assert.Equal(53.25, newSentinel.ClampZones[0].Bounds.X);
+ Assert.Equal(47.375, newSentinel.ClampZones[0].Bounds.Y);
+ using var secondSave = Write(reopened);
+ using var secondJson = JsonDocument.Parse(ReadNestJson(secondSave));
+ Assert.Equal(0, secondJson.RootElement.GetProperty("plates").GetArrayLength());
+ }
+
+ [Theory]
+ [InlineData("edited")]
+ [InlineData("middle")]
+ [InlineData("removed")]
+ [InlineData("cutOff")]
+ [InlineData("explicit")]
+ public void ChangedDefaultsPreserveAndWriteConfiguredEmptySheets(string configuration)
+ {
+ var nest = RailNest(withEmptyTail: false);
+ var plate = nest.CreatePlate();
+ switch (configuration)
+ {
+ case "edited":
+ plate.Size = new Size(48, 84);
+ plate.Quadrant = 2;
+ break;
+ case "middle":
+ var separator = nest.CreatePlate();
+ separator.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
+ nest.CreatePlate();
+ break;
+ case "removed":
+ plate.SetClampRail(nest.PlateDefaults.ClampRail, new[] { "clamp-1" });
+ break;
+ case "cutOff":
+ plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
+ break;
+ case "explicit":
+ plate.SetClampRail(null!);
+ plate.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1)));
+ break;
+ }
+ var oldSize = plate.Size;
+ var oldQuadrant = plate.Quadrant;
+ var oldZones = plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)).ToArray();
+ using var manager = new PlateManager(nest);
+
+ manager.ChangePlateDefaults(() =>
+ {
+ nest.PlateDefaults.Size = new Size(48, 60);
+ nest.PlateDefaults.Quadrant = 3;
+ });
+
+ Assert.Equal(oldSize, plate.Size);
+ Assert.Equal(oldQuadrant, plate.Quadrant);
+ var reopened = RoundTrip(nest);
+ Assert.Equal(configuration == "middle" ? 3 : 2, reopened.Plates.Count);
+ var kept = reopened.Plates[1];
+ Assert.Equal(oldSize, kept.Size);
+ Assert.Equal(oldQuadrant, kept.Quadrant);
+ Assert.Equal(oldZones, kept.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)));
+ if (configuration == "removed")
+ Assert.Equal(new[] { "clamp-1" }, kept.RemovedClamps);
+ if (configuration == "cutOff")
+ Assert.Single(kept.CutOffs);
+ if (configuration == "explicit")
+ Assert.Null(kept.ClampRail);
+ }
+
+ [Fact]
+ public void SavedZonesThatDifferFromTheRailArePreservedExactly()
+ {
+ var nest = RailNest(withEmptyTail: false);
+ var plate = nest.Plates[0];
+ plate.SetClampRail(null!);
+ plate.ClampZones.Add(new ClampZone("clamp-1", new Box(5.5, 0, 1.25, 0.5)));
+
+ var reopenedPlate = Assert.Single(RoundTrip(nest).Plates);
+
+ var zone = Assert.Single(reopenedPlate.ClampZones);
+ Assert.Equal(5.5, zone.Bounds.X);
+ Assert.Equal(1.25, zone.Bounds.Length);
+ Assert.Null(reopenedPlate.ClampRail);
+ }
+
+ [Fact]
+ public void NestWithoutRailWritesNoRailKeyAndReadsNone()
+ {
+ var nest = new Nest("plain");
+ var plate = new Plate(48, 96);
+ plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
+ nest.Plates.Add(plate);
+ var stream = Write(nest);
+
+ Assert.DoesNotContain("clampRail", ReadNestJson(stream), StringComparison.Ordinal);
+ stream.Position = 0;
+ var reopened = new NestReader(stream).Read();
+ Assert.Null(reopened.PlateDefaults.ClampRail);
+ Assert.Empty(reopened.CreatePlate().ClampZones);
+ }
+
+ [Fact]
+ public void WriterEmitsCamelCaseRailOnPlateDefaults()
+ {
+ var json = ReadNestJson(Write(RailNest()));
+
+ Assert.Contains("\"clampRail\": {", json, StringComparison.Ordinal);
+ Assert.Contains("\"depth\": 0.625", json, StringComparison.Ordinal);
+ Assert.Contains("\"centers\": [", json, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("\"width\": 1.5", "\"width\": -1.5")]
+ [InlineData("\"centers\": [", "\"centers\": [-6, ")]
+ [InlineData("\"width\": 1.5", "\"width\": \"bad\"")]
+ [InlineData("\"width\": 1.5", "\"width\": null")]
+ [InlineData("\"centers\": [", "\"centers\": [\"bad\", ")]
+ [InlineData("\"centers\": [", "\"centers\": [null, ")]
+ [InlineData("\"centers\": [", "\"centers\": 6, \"unused\": [")]
+ [InlineData("\"clampRail\": {", "\"clampRail\": [6], \"unused\": {")]
+ public void MalformedRailIsIgnoredWhileTheNestLoads(string find, string replace)
+ {
+ var tampered = RewriteNestJson(Write(RailNest()), json =>
+ {
+ var start = json.IndexOf("\"clampRail\"", StringComparison.Ordinal);
+ Assert.True(start >= 0);
+ var at = json.IndexOf(find, start, StringComparison.Ordinal);
+ Assert.True(at >= 0);
+ return json[..at] + replace + json[(at + find.Length)..];
+ });
+
+ var reopened = new NestReader(tampered).Read();
+
+ Assert.Null(reopened.PlateDefaults.ClampRail);
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Equal(7, plate.ClampZones.Count); // saved zones still load, explicitly
+ Assert.Null(plate.ClampRail);
+ }
+
+ // D9: the rail's removal-minutes setting round-trips; a file without it defaults.
+
+ [Fact]
+ public void RemovalMinutesRoundTrips()
+ {
+ var nest = new Nest("railed");
+ nest.PlateDefaults.Size = new Size(48, 96);
+ nest.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters, removalMinutes: 8));
+
+ var reopened = RoundTrip(nest);
+
+ Assert.Equal(8, reopened.PlateDefaults.ClampRail.RemovalMinutes);
+ }
+
+ [Fact]
+ public void MissingRemovalMinutesDefaultsOnLoad()
+ {
+ // The writer always emits removalMinutes; simulate an older file that predates it by
+ // stripping the key directly rather than relying on exact JSON formatting elsewhere.
+ var json = ReadNestJson(Write(RailNest()));
+ Assert.Contains("\"removalMinutes\"", json, StringComparison.Ordinal);
+
+ var withoutKey = RewriteNestJson(Write(RailNest()),
+ text => RemoveJsonProperty(text, "removalMinutes"));
+
+ var reopened = new NestReader(withoutKey).Read();
+
+ Assert.Equal(ClampRail.DefaultRemovalMinutes, reopened.PlateDefaults.ClampRail.RemovalMinutes);
+ }
+
+ // D8/C13: last-resort per-sheet clamp removal persists and keeps following the rail.
+
+ [Fact]
+ public void RemovedClampsRoundTripAndTheReducedRailStillFollowsResizes()
+ {
+ var nest = new Nest("railed");
+ nest.PlateDefaults.Size = new Size(48, 120);
+ nest.SetClampRail(Machine());
+ var plate = nest.CreatePlate();
+ plate.SetClampRail(Machine(), new[] { "clamp-3" });
+
+ var reopened = RoundTrip(nest);
+ var reopenedPlate = Assert.Single(reopened.Plates);
+
+ Assert.Equal(7, reopenedPlate.ClampZones.Count);
+ Assert.DoesNotContain(reopenedPlate.ClampZones, z => z.Id == "clamp-3");
+ Assert.Equal(new[] { "clamp-3" }, reopenedPlate.RemovedClamps);
+ Assert.Same(reopened.PlateDefaults.ClampRail, reopenedPlate.ClampRail); // still rail-following
+
+ // The plate still follows the rail minus its removed clamp after a resize (C13).
+ reopenedPlate.Size = new Size(48, 60);
+ Assert.Equal(new[] { "clamp-1", "clamp-2", "clamp-4" }, reopenedPlate.ClampZones.Select(z => z.Id));
+ }
+
+ [Fact]
+ public void AnEmptyPlateWithOnlyRemovedClampsIsNotDropped()
+ {
+ // Every clamp removed: ClampZones is empty, but the removal state must still be saved.
+ var nest = new Nest("railed");
+ nest.PlateDefaults.Size = new Size(48, 96);
+ nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0 }));
+ var plate = nest.CreatePlate();
+ plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0 }), new[] { "clamp-1" });
+ Assert.Empty(plate.ClampZones);
+
+ var reopened = RoundTrip(nest);
+
+ var kept = Assert.Single(reopened.Plates);
+ Assert.Equal(new[] { "clamp-1" }, kept.RemovedClamps);
+ }
+
+ [Fact]
+ public void LegacyFileWithoutRemovedClampsKeyLoadsWithNoneRemoved()
+ {
+ // Sheets without a removal are written without the key, exactly like a file from
+ // before the feature.
+ var withoutKey = Write(RailNest());
+ Assert.DoesNotContain("\"removedClamps\"", ReadNestJson(withoutKey), StringComparison.Ordinal);
+
+ var reopened = new NestReader(withoutKey).Read();
+
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Empty(plate.RemovedClamps);
+ Assert.Equal(7, plate.ClampZones.Count);
+ }
+
+ // Slice 6 task 6.4 (Slice 3b quality review): optional removal fields read leniently
+ // and are only written when there is something to say.
+
+ private static MemoryStream RemovedClamp3Nest()
+ {
+ var nest = new Nest("railed");
+ nest.PlateDefaults.Size = new Size(48, 120);
+ nest.SetClampRail(Machine());
+ var plate = nest.CreatePlate();
+ plate.SetClampRail(Machine(), new[] { "clamp-3" });
+ return Write(nest);
+ }
+
+ private static MemoryStream WithPlateProperty(MemoryStream file, string name, string json) =>
+ RewriteNestJson(file, text =>
+ {
+ var root = JsonNode.Parse(text)!;
+ root["plates"]![0]![name] = JsonNode.Parse(json);
+ return root.ToJsonString();
+ });
+
+ // PM 9f3d676b: a wrongly shaped removedClamps value must not fail the whole nest.
+ [Theory]
+ [InlineData("\"clamp-3\"", false)]
+ [InlineData("[-1]", false)]
+ [InlineData("{\"id\": \"clamp-3\"}", false)]
+ [InlineData("[-1, \"clamp-3\"]", true)]
+ public void MalformedRemovedClampsLoadWithoutFailingTheNest(string value, bool keepsRemoval)
+ {
+ var tampered = WithPlateProperty(RemovedClamp3Nest(), "removedClamps", value);
+
+ var plate = Assert.Single(new NestReader(tampered).Read().Plates);
+
+ // The saved zones always load, so the removed clamp's area is never re-blocked or lost.
+ Assert.Equal(7, plate.ClampZones.Count);
+ Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-3");
+ Assert.Equal(keepsRemoval, plate.RemovedClamps.Contains("clamp-3"));
+ Assert.Equal(keepsRemoval, plate.ClampRail != null);
+ }
+
+ // PM 6ded8ac4 (b): an invalid removal time falls back to the default instead of
+ // dropping the rail and every sheet's removal state with it.
+ [Theory]
+ [InlineData("-1")]
+ [InlineData("\"x\"")]
+ public void InvalidRemovalMinutesFallBackToTheDefault(string minutes)
+ {
+ var tampered = RewriteNestJson(RemovedClamp3Nest(), text =>
+ {
+ var root = JsonNode.Parse(text)!;
+ root["plateDefaults"]!["clampRail"]!["removalMinutes"] = JsonNode.Parse(minutes);
+ return root.ToJsonString();
+ });
+
+ var reopened = new NestReader(tampered).Read();
+
+ Assert.NotNull(reopened.PlateDefaults.ClampRail);
+ Assert.Equal(ClampRail.DefaultRemovalMinutes, reopened.PlateDefaults.ClampRail.RemovalMinutes);
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Equal(new[] { "clamp-3" }, plate.RemovedClamps);
+ Assert.NotNull(plate.ClampRail);
+ }
+
+ // PM 6ded8ac4 (c): sheets without a removal write no removedClamps key at all.
+ [Fact]
+ public void SheetsWithoutRemovedClampsWriteNoRemovedClampsKey()
+ {
+ var json = ReadNestJson(Write(RailNest()));
+
+ Assert.DoesNotContain("\"removedClamps\"", json, StringComparison.Ordinal);
+ Assert.Contains("\"removedClamps\"", ReadNestJson(RemovedClamp3Nest()), StringComparison.Ordinal);
+ }
+
+ // PM 22daec3d: a sheet following a rail other than the nest default keeps its rail,
+ // removed clamps and removal time through save/reload.
+ [Fact]
+ public void SheetRailDifferentFromTheDefaultsSurvivesReload()
+ {
+ var nest = new Nest("no-default-rail");
+ nest.PlateDefaults.Size = new Size(48, 120);
+ var plate = nest.CreatePlate();
+ plate.SetClampRail(Machine(), new[] { "clamp-3" });
+
+ var reopened = Assert.Single(RoundTrip(nest).Plates);
+
+ Assert.Equal(Machine(), reopened.ClampRail);
+ Assert.Equal(new[] { "clamp-3" }, reopened.RemovedClamps);
+ Assert.Equal(ClampRail.DefaultRemovalMinutes, reopened.ClampRemovalMinutes);
+ Assert.Null(RoundTrip(nest).PlateDefaults.ClampRail);
+ }
+
+ [Fact]
+ public void SheetsFollowingTheDefaultRailWriteNoPerSheetRail()
+ {
+ var json = ReadNestJson(RemovedClamp3Nest());
+ var root = JsonNode.Parse(json)!;
+ Assert.Null(root["plates"]![0]!["clampRail"]);
+ }
+
+ // PM 455f6763 (C17): an empty rail sheet whose spacing differs from the defaults is
+ // configured stock, not the editor's sentinel, and is written.
+ [Theory]
+ [InlineData(0.5, 0.0)]
+ [InlineData(0.0, 0.125)]
+ public void EmptyRailSheetWithOtherSpacingIsKept(double partSpacing, double edge)
+ {
+ var nest = new Nest("railed");
+ nest.PlateDefaults.Size = new Size(48, 96);
+ nest.SetClampRail(Machine());
+ var plate = nest.CreatePlate();
+ plate.PartSpacing = partSpacing;
+ plate.EdgeSpacing = new Spacing(edge, edge, edge, edge);
+
+ var kept = Assert.Single(RoundTrip(nest).Plates);
+
+ Assert.Equal(partSpacing, kept.PartSpacing);
+ Assert.Equal(edge, kept.EdgeSpacing.Left);
+ }
+
+ // Delta review: a sheet whose rail differs from the defaults only in removal time is
+ // configured stock too (ChangePlateDefaults leaves it alone) and must be written.
+ [Fact]
+ public void EmptySheetOnARailWithOtherRemovalTimeIsKept()
+ {
+ var nest = new Nest("railed");
+ nest.PlateDefaults.Size = new Size(48, 96);
+ nest.SetClampRail(Machine());
+ var plate = nest.CreatePlate();
+ plate.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters, removalMinutes: 7));
+
+ var kept = Assert.Single(RoundTrip(nest).Plates);
+
+ Assert.Equal(7, kept.ClampRail.RemovalMinutes);
+ }
+
+ /// Removes a named property wherever it appears in the document, simulating a
+ /// key an older writer never emitted. Rebuilds via so the result
+ /// is always valid JSON regardless of whether the property was first, last or only one
+ /// in its object - unlike string surgery, which breaks on a dangling comma.
+ private static string RemoveJsonProperty(string json, string name)
+ {
+ var root = JsonNode.Parse(json)!;
+ var removed = RemovePropertyRecursive(root, name);
+ Assert.True(removed, $"Property '{name}' was not found to remove.");
+ return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
+ }
+
+ private static bool RemovePropertyRecursive(JsonNode node, string name)
+ {
+ switch (node)
+ {
+ case JsonObject obj:
+ var removed = obj.Remove(name);
+ foreach (var child in obj.ToList())
+ if (child.Value != null && RemovePropertyRecursive(child.Value, name))
+ removed = true;
+ return removed;
+ case JsonArray arr:
+ var any = false;
+ foreach (var item in arr)
+ if (item != null && RemovePropertyRecursive(item, name))
+ any = true;
+ return any;
+ default:
+ return false;
+ }
+ }
+
+ private static string ReadNestJson(MemoryStream zipBytes)
+ {
+ zipBytes.Position = 0;
+ using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true);
+ var entry = archive.GetEntry("nest.json");
+ Assert.NotNull(entry);
+ using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
+ private static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func rewrite)
+ {
+ zipBytes.Position = 0;
+ var output = new MemoryStream();
+ using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true))
+ using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ foreach (var entry in source.Entries)
+ {
+ var newEntry = target.CreateEntry(entry.FullName);
+ using var input = entry.Open();
+ using var outStream = newEntry.Open();
+ if (entry.FullName == "nest.json")
+ {
+ using var text = new StreamReader(input, Encoding.UTF8);
+ using var writer = new StreamWriter(outStream, new UTF8Encoding(false));
+ writer.Write(rewrite(text.ReadToEnd()));
+ }
+ else
+ {
+ input.CopyTo(outStream);
+ }
+ }
+ }
+ output.Position = 0;
+ return output;
+ }
+}
diff --git a/OpenNest.IO.Tests/ClampZonePersistenceTests.cs b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs
new file mode 100644
index 0000000..7e5fc61
--- /dev/null
+++ b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs
@@ -0,0 +1,176 @@
+using System.IO.Compression;
+using System.Text;
+using OpenNest.Geometry;
+
+namespace OpenNest.IO.Tests;
+
+///
+/// Clamp zones survive the .nest round trip; files without the "clamps" key load
+/// unchanged with zero zones, and a clamp-carrying sheet is never dropped by the
+/// empty-plate filter unless the nest's saved rail recreates exactly those zones
+/// (see ClampRailPersistenceTests). Malformed zones are skipped rather than poisoning the file.
+///
+public class ClampZonePersistenceTests
+{
+ private static Nest NestWithClamps(int zoneCount)
+ {
+ var nest = new Nest("clamped");
+ var plate = new Plate(48, 96);
+ for (var i = 0; i < zoneCount; i++)
+ plate.ClampZones.Add(new ClampZone($"clamp-{i}", new Box(2.25 + 10 * i, 0, 1.5, 0.625)));
+ nest.Plates.Add(plate);
+ return nest;
+ }
+
+ private static Nest RoundTrip(Nest nest)
+ {
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ stream.Position = 0;
+ return new NestReader(stream).Read();
+ }
+
+ [Fact]
+ public void ZonesRoundTripWithIdsAndPositions()
+ {
+ var reopened = RoundTrip(NestWithClamps(8));
+
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Equal(8, plate.ClampZones.Count);
+ for (var i = 0; i < 8; i++)
+ {
+ var zone = plate.ClampZones[i];
+ Assert.Equal($"clamp-{i}", zone.Id);
+ Assert.Equal(2.25 + 10 * i, zone.Bounds.X, 9);
+ Assert.Equal(0, zone.Bounds.Y, 9);
+ Assert.Equal(1.5, zone.Bounds.Length, 9);
+ Assert.Equal(0.625, zone.Bounds.Width, 9);
+ }
+ }
+
+ [Fact]
+ public void ClampOnlyPlateSurvivesTheEmptyPlateFilter()
+ {
+ // A sheet with no parts and no cut-offs but with clamp definitions is a real
+ // configuration (the machine rail exists even on an empty plate) and must persist.
+ var reopened = RoundTrip(NestWithClamps(3));
+
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Empty(plate.Parts);
+ Assert.Equal(3, plate.ClampZones.Count);
+ }
+
+ [Fact]
+ public void EmptyPlateWithoutClampsIsStillOmitted()
+ {
+ var nest = new Nest("empty");
+ nest.Plates.Add(new Plate(48, 96));
+ var reopened = RoundTrip(nest);
+ Assert.Empty(reopened.Plates);
+ }
+
+ [Fact]
+ public void ZonesCoexistWithPlacementsAndCutOffs()
+ {
+ var nest = new Nest("mixed");
+ var plate = new Plate(48, 96);
+ plate.ClampZones.Add(new ClampZone("clamp-0", new Box(5.25, 0, 1.5, 0.625)));
+ plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
+ nest.Plates.Add(plate);
+
+ var reopened = RoundTrip(nest);
+
+ var reopenedPlate = Assert.Single(reopened.Plates);
+ Assert.Single(reopenedPlate.ClampZones);
+ Assert.Equal("clamp-0", reopenedPlate.ClampZones[0].Id);
+ Assert.Single(reopenedPlate.CutOffs);
+ }
+
+ [Fact]
+ public void FileWithoutClampsKeyLoadsUnchanged()
+ {
+ // Files written before this feature have no "clamps" property at all.
+ var withZones = new MemoryStream();
+ Assert.True(new NestWriter(NestWithClamps(2)).Write(withZones));
+ var legacy = RewriteNestJson(withZones,
+ json => System.Text.RegularExpressions.Regex.Replace(json, @",\s*""clamps""\s*:\s*\[[^\]]*\]", ""));
+
+ legacy.Position = 0;
+ var reopened = new NestReader(legacy).Read();
+
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Empty(plate.Parts);
+ Assert.Empty(plate.ClampZones);
+ }
+
+ [Fact]
+ public void MalformedZonesAreSkippedWithoutThrowing()
+ {
+ // Hand-edited sizes must not poison the file: drop bad entries, keep good ones.
+ var withZones = new MemoryStream();
+ Assert.True(new NestWriter(NestWithClamps(1)).Write(withZones));
+ var tampered = RewriteNestJson(withZones, json => json.Replace(
+ "\"clamps\": [",
+ "\"clamps\": [{\"id\":\"bad\",\"x\":0,\"y\":0,\"length\":-5,\"width\":0.625},"
+ + "{\"id\":\"\",\"x\":0,\"y\":0,\"length\":1.5,\"width\":0.625},")
+ );
+
+ tampered.Position = 0;
+ var reopened = new NestReader(tampered).Read();
+
+ var plate = Assert.Single(reopened.Plates);
+ var zone = Assert.Single(plate.ClampZones);
+ Assert.Equal("clamp-0", zone.Id);
+ }
+
+ [Fact]
+ public void WriterEmitsCamelCaseClampsArray()
+ {
+ var withZones = new MemoryStream();
+ Assert.True(new NestWriter(NestWithClamps(1)).Write(withZones));
+ var json = ReadNestJson(withZones);
+
+ Assert.Contains("\"clamps\": [", json, StringComparison.Ordinal);
+ Assert.Contains("\"id\": \"clamp-0\"", json, StringComparison.Ordinal);
+ Assert.Contains("\"length\": 1.5", json, StringComparison.Ordinal);
+ Assert.Contains("\"width\": 0.625", json, StringComparison.Ordinal);
+ }
+
+ private static string ReadNestJson(MemoryStream zipBytes)
+ {
+ zipBytes.Position = 0;
+ using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true);
+ var entry = archive.GetEntry("nest.json");
+ Assert.NotNull(entry);
+ using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
+ private static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func rewrite)
+ {
+ zipBytes.Position = 0;
+ var output = new MemoryStream();
+ using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true))
+ using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ foreach (var entry in source.Entries)
+ {
+ var newEntry = target.CreateEntry(entry.FullName);
+ using var input = entry.Open();
+ using var outStream = newEntry.Open();
+ if (entry.FullName == "nest.json")
+ {
+ using var text = new StreamReader(input, Encoding.UTF8);
+ using var writer = new StreamWriter(outStream, new UTF8Encoding(false));
+ writer.Write(rewrite(text.ReadToEnd()));
+ }
+ else
+ {
+ input.CopyTo(outStream);
+ }
+ }
+ }
+ output.Position = 0;
+ return output;
+ }
+}
diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs
index 39fa7db..51262f5 100644
--- a/OpenNest.IO/NestFormat.cs
+++ b/OpenNest.IO/NestFormat.cs
@@ -1,5 +1,7 @@
+using System;
using System.Collections.Generic;
using System.Text.Json;
+using System.Text.Json.Serialization;
namespace OpenNest.IO
{
@@ -43,6 +45,131 @@ namespace OpenNest.IO
public double PartSpacing { get; init; }
public MaterialDto Material { get; init; } = new();
public SpacingDto EdgeSpacing { get; init; } = new();
+
+ ///
+ /// The nest's machine clamp rail, which new plates follow. Omitted when the nest
+ /// has none; older files and older readers simply have no rail.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonConverter(typeof(LenientClampRailConverter))]
+ public ClampRailDto ClampRail { get; init; }
+ }
+
+ ///
+ /// Reads a malformed or wrongly typed clamp rail as no rail instead of failing the
+ /// whole nest; the reader still validates the values (sign, size) it receives.
+ ///
+ public sealed class LenientClampRailConverter : JsonConverter
+ {
+ public override ClampRailDto Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options
+ )
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ var rail = document.RootElement;
+ if (
+ rail.ValueKind != JsonValueKind.Object
+ || !TryNumber(rail, "width", out var width)
+ || !TryNumber(rail, "depth", out var depth)
+ || !rail.TryGetProperty("centers", out var centers)
+ || centers.ValueKind != JsonValueKind.Array
+ )
+ return null;
+
+ var list = new List();
+ foreach (var center in centers.EnumerateArray())
+ {
+ if (center.ValueKind != JsonValueKind.Number || !center.TryGetDouble(out var x))
+ return null;
+ list.Add(x);
+ }
+ // Missing or unreadable -> null; the reader then falls back to the default.
+ double? removalMinutes = TryNumber(rail, "removalMinutes", out var minutes)
+ ? minutes
+ : null;
+ return new ClampRailDto { Width = width, Depth = depth, Centers = list, RemovalMinutes = removalMinutes };
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ ClampRailDto value,
+ JsonSerializerOptions options
+ )
+ {
+ writer.WriteStartObject();
+ writer.WriteNumber("width", value.Width);
+ writer.WriteNumber("depth", value.Depth);
+ writer.WriteStartArray("centers");
+ foreach (var center in value.Centers ?? new List())
+ writer.WriteNumberValue(center);
+ writer.WriteEndArray();
+ if (value.RemovalMinutes is double removalMinutes)
+ writer.WriteNumber("removalMinutes", removalMinutes);
+ writer.WriteEndObject();
+ }
+
+ private static bool TryNumber(JsonElement rail, string name, out double value)
+ {
+ value = 0;
+ return rail.TryGetProperty(name, out var element)
+ && element.ValueKind == JsonValueKind.Number
+ && element.TryGetDouble(out value);
+ }
+ }
+
+ ///
+ /// Reads an optional list of strings without failing the nest: a non-array value
+ /// reads as an empty list and non-string entries are skipped.
+ ///
+ public sealed class LenientStringListConverter : JsonConverter>
+ {
+ public override List Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options
+ )
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ var list = new List();
+ if (document.RootElement.ValueKind != JsonValueKind.Array)
+ return list;
+ foreach (var element in document.RootElement.EnumerateArray())
+ if (element.ValueKind == JsonValueKind.String)
+ list.Add(element.GetString());
+ return list;
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ List value,
+ JsonSerializerOptions options
+ )
+ {
+ writer.WriteStartArray();
+ foreach (var item in value)
+ writer.WriteStringValue(item);
+ writer.WriteEndArray();
+ }
+ }
+
+ public record ClampRailDto
+ {
+ /// Clamp extent along the origin-side edge (X).
+ public double Width { get; init; }
+
+ /// Clamp intrusion into the sheet from that edge (Y).
+ public double Depth { get; init; }
+
+ /// Clamp centers along X, measured from the plate origin.
+ public List Centers { get; init; } = new();
+
+ ///
+ /// Minutes to remove one clamp and put it back after cutting (D9). Null in older
+ /// files and readers fall back to .
+ ///
+ public double? RemovalMinutes { get; init; }
}
public record DrawingDto
@@ -71,6 +198,44 @@ namespace OpenNest.IO
public CuttingParametersDto CuttingParameters { get; init; }
public List Parts { get; init; } = new();
public List CutOffs { get; init; } = new();
+
+ ///
+ /// Machine clamp keep-out rectangles in plate coordinates. Missing in older
+ /// files, which load with no zones.
+ ///
+ public List Clamps { get; init; } = new();
+
+ ///
+ /// Rail clamp ids removed from this sheet as a last resort (D8). Omitted when none
+ /// were removed; missing or empty means none; already excludes them. Read
+ /// leniently: a wrongly shaped value reads as none removed, non-string entries
+ /// are skipped, and the saved zones load either way.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonConverter(typeof(LenientStringListConverter))]
+ public List RemovedClamps { get; init; } = new();
+
+ ///
+ /// The machine clamp rail this sheet follows, written only when it is not the
+ /// nest's default rail (otherwise the sheet re-attaches to that one on load).
+ /// Read leniently like .
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonConverter(typeof(LenientClampRailConverter))]
+ public ClampRailDto ClampRail { get; init; }
+ }
+
+ public record ClampZoneDto
+ {
+ public string Id { get; init; } = "";
+ public double X { get; init; }
+ public double Y { get; init; }
+
+ /// Extent along X.
+ public double Length { get; init; }
+
+ /// Extent along Y (depth from the y = 0 operator-side edge).
+ public double Width { get; init; }
}
public record PartDto
diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs
index 2c60753..e14c7f1 100644
--- a/OpenNest.IO/NestReader.cs
+++ b/OpenNest.IO/NestReader.cs
@@ -46,6 +46,9 @@ namespace OpenNest.IO
var drawingMap = BuildDrawings(dto, programs, entitySets);
ReadBestFits(drawingMap);
var nest = BuildNest(dto, drawingMap);
+ // Nests converted from PEP kept etch marks only in the placed programs; give them
+ // back to the drawing so removing lead-ins does not remove the marks too.
+ DrawingMarkRecovery.Apply(nest);
zipArchive.Dispose();
stream.Close();
@@ -327,6 +330,31 @@ namespace OpenNest.IO
}
}
+ ///
+ /// The saved machine clamp rail, or null when absent, empty or malformed (a bad rail
+ /// is ignored rather than failing the nest; each plate's saved zones still load). A
+ /// missing or invalid removal-minutes value falls back to .
+ ///
+ private static ClampRail ReadClampRail(ClampRailDto dto)
+ {
+ if (dto?.Centers == null || dto.Centers.Count == 0)
+ return null;
+ try
+ {
+ // An invalid removal time (negative or not finite) falls back to the default
+ // rather than dropping the rail, which would also discard every sheet's
+ // removed-clamp state.
+ var minutes = dto.RemovalMinutes is double m && double.IsFinite(m) && m >= 0
+ ? m
+ : ClampRail.DefaultRemovalMinutes;
+ return new ClampRail(dto.Width, dto.Depth, dto.Centers, minutes);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ }
+
private Nest BuildNest(NestDto dto, Dictionary drawingMap)
{
var nest = new Nest();
@@ -363,6 +391,8 @@ namespace OpenNest.IO
pd.EdgeSpacing.Right,
pd.EdgeSpacing.Top
);
+ var clampRail = ReadClampRail(pd.ClampRail);
+ nest.PlateDefaults.ClampRail = clampRail;
// Stock options: sheet sizes and salvage rate
nest.SalvageRate = dto.SalvageRate;
@@ -399,6 +429,37 @@ namespace OpenNest.IO
plate.GrainAngle = p.GrainAngle;
plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
+ // Clamp zones: older files have no key at all; an individually invalid
+ // entry is dropped rather than poisoning the whole nest.
+ if (p.Clamps != null)
+ {
+ foreach (var c in p.Clamps)
+ {
+ try
+ {
+ plate.ClampZones.Add(
+ new ClampZone(c.Id, new OpenNest.Geometry.Box(c.X, c.Y, c.Length, c.Width))
+ );
+ }
+ catch (ArgumentException)
+ {
+ // Invalid zone definition; ignore this one entry.
+ }
+ }
+ }
+
+ // A plate follows the nest rail only when its saved zones are exactly the
+ // rail's for this sheet minus any saved removed-clamp ids (C13); otherwise
+ // the saved zones are kept as static, explicit data.
+ var removedClamps = p.RemovedClamps?
+ .Where(id => !string.IsNullOrEmpty(id))
+ .ToHashSet(StringComparer.Ordinal)
+ ?? new HashSet();
+ // A sheet saved with its own rail (not the nest default) follows that one.
+ var plateRail = ReadClampRail(p.ClampRail) ?? clampRail;
+ if (plateRail != null && plateRail.Matches(plate, removedClamps))
+ plate.SetClampRail(plateRail, removedClamps);
+
for (var partIndex = 0; partIndex < p.Parts.Count; partIndex++)
{
var partDto = p.Parts[partIndex];
diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs
index e74c83b..fa96542 100644
--- a/OpenNest.IO/NestWriter.cs
+++ b/OpenNest.IO/NestWriter.cs
@@ -135,6 +135,7 @@ namespace OpenNest.IO
Right = pd.EdgeSpacing.Right,
Bottom = pd.EdgeSpacing.Bottom,
},
+ ClampRail = ToDto(pd.ClampRail),
};
}
@@ -197,6 +198,50 @@ namespace OpenNest.IO
return list;
}
+ ///
+ /// True when a plate created from the saved defaults would be this sheet's clamp
+ /// configuration: same size, quadrant, edge and part spacing, no last-resort
+ /// removal, not following another rail (removal time included), and zones the
+ /// saved rail recreates (C17: anything else is configured
+ /// stock and is written).
+ ///
+ private bool DefaultsRecreateClamps(Plate plate)
+ {
+ var defaults = nest.PlateDefaults;
+ return defaults.ClampRail is { } rail
+ && plate.RemovedClamps.Count == 0
+ && plate.Size.Width == defaults.Size.Width
+ && plate.Size.Length == defaults.Size.Length
+ && plate.Quadrant == defaults.Quadrant
+ && plate.PartSpacing == defaults.PartSpacing
+ && plate.EdgeSpacing.Left == defaults.EdgeSpacing.Left
+ && plate.EdgeSpacing.Bottom == defaults.EdgeSpacing.Bottom
+ && plate.EdgeSpacing.Right == defaults.EdgeSpacing.Right
+ && plate.EdgeSpacing.Top == defaults.EdgeSpacing.Top
+ && (plate.ClampRail == null || Equals(plate.ClampRail, rail))
+ && rail.Matches(plate);
+ }
+
+ private static ClampRailDto ToDto(ClampRail rail) =>
+ rail == null
+ ? null
+ : new ClampRailDto
+ {
+ Width = rail.Width,
+ Depth = rail.Depth,
+ Centers = rail.Centers.ToList(),
+ RemovalMinutes = rail.RemovalMinutes,
+ };
+
+ /// Numeric suffix of a rail clamp id ("clamp-3" -> 3), for a natural sort of
+ /// instead of lexical ("clamp-10" before "clamp-2").
+ /// A non-matching id sorts last by its own ordinal text.
+ private static int ClampIndex(string id) =>
+ id != null && id.StartsWith("clamp-", StringComparison.Ordinal)
+ && int.TryParse(id.AsSpan(6), out var index)
+ ? index
+ : int.MaxValue;
+
private List BuildPlateDtos()
{
var list = new List();
@@ -205,9 +250,17 @@ namespace OpenNest.IO
{
var plate = nest.Plates[i];
+ // An empty plate is omitted unless that would lose a clamp configuration the
+ // saved defaults cannot recreate. PlateManager moves an untouched trailing
+ // sentinel to new defaults during an edit, so it remains omitted; configured
+ // nondefault clamp sheets are still written.
if (
plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
&& plate.CutOffs.Count == 0
+ && (
+ (plate.ClampZones.Count == 0 && plate.RemovedClamps.Count == 0)
+ || DefaultsRecreateClamps(plate)
+ )
)
continue;
@@ -283,6 +336,28 @@ namespace OpenNest.IO
CutOffs = cutoffs,
GrainAngle = plate.GrainAngle,
CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters),
+ Clamps = plate.ClampZones
+ .Select(z => new ClampZoneDto
+ {
+ Id = z.Id,
+ X = z.Bounds.X,
+ Y = z.Bounds.Y,
+ Length = z.Bounds.Length,
+ Width = z.Bounds.Width,
+ })
+ .ToList(),
+ // Omitted unless a clamp was removed (files without the feature
+ // stay unchanged).
+ RemovedClamps = plate.RemovedClamps.Count == 0
+ ? null
+ : plate.RemovedClamps
+ .OrderBy(ClampIndex)
+ .ThenBy(id => id, StringComparer.Ordinal)
+ .ToList(),
+ // Only a rail other than the nest default needs saving per sheet.
+ ClampRail = Equals(plate.ClampRail, nest.PlateDefaults.ClampRail)
+ ? null
+ : ToDto(plate.ClampRail),
}
);
}
diff --git a/OpenNest.Tests/ClampRailTests.cs b/OpenNest.Tests/ClampRailTests.cs
new file mode 100644
index 0000000..c7f9873
--- /dev/null
+++ b/OpenNest.Tests/ClampRailTests.cs
@@ -0,0 +1,398 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+///
+/// The fixed machine clamp rail: X centers measured from the plate origin along the
+/// origin-side edge, resolved onto every sheet size without re-scaling (D2/D3).
+///
+public class ClampRailTests
+{
+ // The user's machine: ~8 clamps, 1.5" along the edge x 0.625" into the sheet.
+ private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 };
+
+ private static ClampRail Machine() => new(1.5, 0.625, MachineCenters);
+
+ // Plate(width, length): Width is the Y extent, Length the X extent along the clamp edge.
+ private static Plate Sheet(double width, double length, int quadrant = 1) =>
+ new(width, length) { Quadrant = quadrant };
+
+ [Theory]
+ [InlineData(0, 0.625)]
+ [InlineData(-1, 0.625)]
+ [InlineData(double.NaN, 0.625)]
+ [InlineData(double.PositiveInfinity, 0.625)]
+ [InlineData(1.5, 0)]
+ [InlineData(1.5, -0.5)]
+ [InlineData(1.5, double.NaN)]
+ public void NonPositiveOrNonFiniteSizesAreRejected(double width, double depth)
+ {
+ Assert.Throws(() => new ClampRail(width, depth, new[] { 6.0 }));
+ }
+
+ [Theory]
+ [InlineData(-0.5)]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ public void CentersMustBeFiniteAndFromTheOrigin(double center)
+ {
+ Assert.Throws(() => new ClampRail(1.5, 0.625, new[] { 6, center }));
+ Assert.Throws(() => new ClampRail(1.5, 0.625, null!));
+ }
+
+ [Fact]
+ public void CentersAreSortedAndDistinct()
+ {
+ var rail = new ClampRail(1.5, 0.625, new[] { 36.0, 6, 21, 6 });
+ Assert.Equal(new[] { 6.0, 21, 36 }, rail.Centers);
+ }
+
+ [Fact]
+ public void FullLengthSheetGetsEveryClampOnTheFrontEdge()
+ {
+ var zones = Machine().ResolveFor(new Size(60, 120), quadrant: 1);
+
+ Assert.Equal(8, zones.Count);
+ for (var i = 0; i < zones.Count; i++)
+ {
+ Assert.Equal($"clamp-{i + 1}", zones[i].Id);
+ Assert.Equal(MachineCenters[i] - 0.75, zones[i].Bounds.X);
+ Assert.Equal(0, zones[i].Bounds.Y);
+ Assert.Equal(1.5, zones[i].Bounds.Length);
+ Assert.Equal(0.625, zones[i].Bounds.Width);
+ }
+ }
+
+ [Fact]
+ public void ShorterSheetSkipsClampsBeyondItWithoutRescaling()
+ {
+ // D2: a 60" sheet keeps the clamps at their table positions and drops the rest.
+ var zones = Machine().ResolveFor(new Size(48, 60), quadrant: 1);
+
+ Assert.Equal(new[] { "clamp-1", "clamp-2", "clamp-3", "clamp-4" }, zones.Select(z => z.Id));
+ Assert.Equal(new[] { 5.25, 20.25, 35.25, 50.25 }, zones.Select(z => z.Bounds.X));
+ }
+
+ [Fact]
+ public void ClampReachingOntoTheSheetIsKeptWhole()
+ {
+ // A clamp is an avoidance area: if any of it reaches the sheet, all of it applies.
+ var rail = new ClampRail(1.5, 0.625, new[] { 59.5, 60.5, 60.75, 61 });
+
+ var zones = rail.ResolveFor(new Size(48, 60), quadrant: 1);
+
+ // 59.5 covers [58.75, 60.25] and 60.5 still reaches the last 0.25" [59.75, 61.25];
+ // 60.75 only touches the sheet end and 61 lies wholly beyond it.
+ Assert.Equal(new[] { "clamp-1", "clamp-2" }, zones.Select(z => z.Id));
+ Assert.Equal(new[] { 58.75, 59.75 }, zones.Select(z => z.Bounds.X));
+ Assert.All(zones, z => Assert.Equal(1.5, z.Bounds.Length));
+ }
+
+ [Fact]
+ public void ClampsNearTheOriginOrDeeperThanTheSheetStayWhole()
+ {
+ var rail = new ClampRail(1.5, 0.625, new[] { 0.25 });
+ var origin = Assert.Single(rail.ResolveFor(new Size(48, 96), quadrant: 1));
+ Assert.Equal(-0.5, origin.Bounds.X);
+ Assert.Equal(1.5, origin.Bounds.Length);
+
+ var deep = new ClampRail(1.5, 10, new[] { 6.0 });
+ var front = Assert.Single(deep.ResolveFor(new Size(4, 96), 1));
+ Assert.Equal(0, front.Bounds.Y);
+ Assert.Equal(10, front.Bounds.Width);
+ // Sheets extending to -Y have the origin edge on top; the clamp hangs below the sheet.
+ var top = Assert.Single(deep.ResolveFor(new Size(4, 96), 3));
+ Assert.Equal(4.0, top.Bounds.Top);
+ Assert.Equal(10, top.Bounds.Width);
+ }
+
+ [Theory]
+ [InlineData(0, 96)]
+ [InlineData(48, 0)]
+ [InlineData(double.NaN, 96)]
+ public void DegenerateSheetsGetNoZones(double width, double length)
+ {
+ Assert.Empty(Machine().ResolveFor(new Size(width, length), quadrant: 1));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ZonesSitOnTheOriginEdgeMeasuredFromTheOriginInEveryQuadrant(int quadrant)
+ {
+ var plate = Sheet(48, 96, quadrant);
+ plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0 }));
+ var zone = Assert.Single(plate.ClampZones);
+
+ // Plate coordinates start at the sheet's lower-left corner; translating by the
+ // sheet's placement corner gives machine coordinates around the origin.
+ var sheet = plate.BoundingBox(includeParts: false);
+ var machine = zone.Bounds.Translate(sheet.X, sheet.Y);
+ var awayFromOrigin = quadrant is 1 or 4 ? 1 : -1;
+ var intoSheet = quadrant is 1 or 2 ? 1 : -1;
+
+ Assert.Equal(6.0, System.Math.Abs(machine.Center.X), 12);
+ Assert.Equal(awayFromOrigin, System.Math.Sign(machine.Center.X));
+ Assert.Equal(0.0, intoSheet > 0 ? machine.Bottom : machine.Top, 12);
+ Assert.Equal(0.625, machine.Width);
+ Assert.Equal(1.5, machine.Length, 12);
+ Assert.True(zone.Bounds.Left >= 0 && zone.Bounds.Right <= 96 + 1e-9);
+ Assert.True(zone.Bounds.Bottom >= 0 && zone.Bounds.Top <= 48 + 1e-9);
+ }
+
+ [Fact]
+ public void PlateFollowsItsRailWhenResizedOrRotated()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine());
+ Assert.Equal(8, plate.ClampZones.Count);
+ Assert.True(plate.ClampRail.Matches(plate));
+
+ plate.Size = new Size(48, 60);
+ Assert.Equal(4, plate.ClampZones.Count);
+
+ // Rotating swaps the sheet's extents; the machine's clamps stay where they are.
+ plate.Rotate90(RotationType.CW);
+ Assert.Equal(new Size(60, 48), plate.Size);
+ Assert.Equal(new[] { 5.25, 20.25, 35.25 }, plate.ClampZones.Select(z => z.Bounds.X));
+
+ plate.Quadrant = 2;
+ Assert.True(plate.ClampRail.Matches(plate));
+ Assert.Equal(48 - 6.75, plate.ClampZones[0].Bounds.X);
+ }
+
+ [Fact]
+ public void UnchangedResolutionRaisesNoEvents()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine());
+ var original = plate.ClampZones.ToList();
+ var changes = 0;
+ plate.ClampZonesChanged += (_, _) => changes++;
+
+ plate.Size = new Size(48, 120);
+ plate.Quadrant = 1;
+ plate.SetClampRail(Machine());
+
+ Assert.Equal(0, changes);
+ Assert.Equal(original, plate.ClampZones); // same zone instances
+ }
+
+ [Fact]
+ public void DetachingTheRailRemovesItsZones()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine());
+
+ plate.SetClampRail(null!);
+
+ Assert.Null(plate.ClampRail);
+ Assert.Empty(plate.ClampZones);
+ plate.Size = new Size(48, 96);
+ Assert.Empty(plate.ClampZones);
+ }
+
+ [Fact]
+ public void PlatesWithoutARailKeepExplicitZonesThroughResizes()
+ {
+ var plate = Sheet(48, 96);
+ plate.ClampZones.Add(new ClampZone("manual", new Box(5.25, 0, 1.5, 0.625)));
+
+ plate.Size = new Size(48, 120);
+ plate.Quadrant = 3;
+
+ Assert.Equal("manual", Assert.Single(plate.ClampZones).Id);
+ Assert.False(Machine().Matches(plate));
+ }
+
+ [Fact]
+ public void NewPlatesFollowTheNestRailAtTheirOwnSize()
+ {
+ var nest = new Nest();
+ nest.PlateDefaults.Size = new Size(48, 60);
+ nest.SetClampRail(Machine());
+
+ var plate = nest.CreatePlate();
+
+ Assert.Same(nest.PlateDefaults.ClampRail, plate.ClampRail);
+ Assert.Equal(4, plate.ClampZones.Count);
+ plate.Size = new Size(60, 120);
+ Assert.Equal(8, plate.ClampZones.Count);
+ }
+
+ [Fact]
+ public void NestRailReplacesEveryPlatesZones()
+ {
+ var nest = new Nest();
+ nest.PlateDefaults.Size = new Size(48, 120);
+ var a = nest.CreatePlate();
+ var b = nest.CreatePlate();
+ b.Size = new Size(48, 60);
+ b.ClampZones.Add(new ClampZone("manual", new Box(1, 0, 1, 1)));
+
+ nest.SetClampRail(Machine());
+ Assert.Equal(8, a.ClampZones.Count);
+ Assert.Equal(4, b.ClampZones.Count);
+ Assert.DoesNotContain(b.ClampZones, z => z.Id == "manual");
+
+ // A rail without clamps is no rail: zones and the template are cleared everywhere.
+ nest.SetClampRail(new ClampRail(1.5, 0.625, Array.Empty()));
+ Assert.Null(nest.PlateDefaults.ClampRail);
+ Assert.All(nest.Plates, p => Assert.Empty(p.ClampZones));
+ Assert.Empty(nest.CreatePlate().ClampZones);
+ }
+
+ [Fact]
+ public void RailZonesStayOutOfQuantityAndUtilization()
+ {
+ var drawing = new Drawing("part", new OpenNest.CNC.Program());
+ var nest = new Nest();
+ nest.Drawings.Add(drawing);
+ nest.SetClampRail(Machine());
+ var plate = nest.CreatePlate();
+
+ Assert.Equal(8, plate.ClampZones.Count);
+ Assert.Empty(plate.Parts);
+ Assert.Equal(0, plate.Utilization());
+ nest.UpdateDrawingQuantities();
+ Assert.Equal(0, drawing.Quantity.Nested);
+ }
+
+ [Fact]
+ public void RailsCompareByValue()
+ {
+ Assert.Equal(Machine(), new ClampRail(1.5, 0.625, MachineCenters.Reverse()));
+ Assert.Equal(Machine().GetHashCode(), new ClampRail(1.5, 0.625, MachineCenters).GetHashCode());
+ Assert.NotEqual(Machine(), new ClampRail(1.5, 0.5, MachineCenters));
+ Assert.NotEqual(Machine(), new ClampRail(1.5, 0.625, MachineCenters.Take(7)));
+ }
+
+ // D9: minutes to remove one clamp and put it back, a variable setting on the rail.
+
+ [Fact]
+ public void RemovalMinutesDefaultsToThePlannerValue()
+ {
+ Assert.Equal(5.0, Machine().RemovalMinutes);
+ Assert.Equal(5.0, ClampRail.DefaultRemovalMinutes);
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ public void RemovalMinutesMustBeFiniteAndZeroOrMore(double minutes)
+ {
+ Assert.Throws(
+ () => new ClampRail(1.5, 0.625, new[] { 6.0 }, minutes));
+ }
+
+ [Fact]
+ public void ZeroRemovalMinutesIsAllowed()
+ {
+ var rail = new ClampRail(1.5, 0.625, new[] { 6.0 }, removalMinutes: 0);
+ Assert.Equal(0, rail.RemovalMinutes);
+ }
+
+ [Fact]
+ public void RailsWithDifferentRemovalMinutesAreNotEqual()
+ {
+ var fiveMinutes = new ClampRail(1.5, 0.625, MachineCenters, 5);
+ var tenMinutes = new ClampRail(1.5, 0.625, MachineCenters, 10);
+ Assert.NotEqual(fiveMinutes, tenMinutes);
+ Assert.NotEqual(fiveMinutes.GetHashCode(), tenMinutes.GetHashCode());
+ }
+
+ // D8/C13: last-resort per-sheet removal of individual rail clamps.
+
+ [Fact]
+ public void RemovedClampsAreSubtractedFromTheRailResolution()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine(), new[] { "clamp-3" });
+
+ Assert.Equal(7, plate.ClampZones.Count);
+ Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-3");
+ Assert.Equal(new[] { "clamp-3" }, plate.RemovedClamps);
+ }
+
+ [Fact]
+ public void RemovedClampStaysOffAfterResizeAndReappearsIfTheClampComesBackOntoTheSheet()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine(), new[] { "clamp-8" }); // centered at 111"
+
+ plate.Size = new Size(48, 60); // clamp-8 is beyond this sheet regardless
+ Assert.Equal(new[] { "clamp-1", "clamp-2", "clamp-3", "clamp-4" },
+ plate.ClampZones.Select(z => z.Id));
+ Assert.Equal(new[] { "clamp-8" }, plate.RemovedClamps);
+
+ plate.Size = new Size(48, 120); // clamp-8 is back on the sheet, but still removed
+ Assert.Equal(7, plate.ClampZones.Count);
+ Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-8");
+ }
+
+ [Fact]
+ public void ClampRemovalMinutesSumsTheRailsPerClampTime()
+ {
+ var plate = Sheet(48, 120);
+ Assert.Equal(0, plate.ClampRemovalMinutes); // no rail yet
+
+ plate.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters, removalMinutes: 7),
+ new[] { "clamp-3", "clamp-4" });
+ Assert.Equal(14, plate.ClampRemovalMinutes);
+
+ plate.SetClampRail(null!);
+ Assert.Equal(0, plate.ClampRemovalMinutes); // detaching forgets the removed set too
+ Assert.Empty(plate.RemovedClamps);
+ }
+
+ [Fact]
+ public void SetClampRailWithoutRemovedClampsBehavesAsBefore()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine());
+
+ Assert.Empty(plate.RemovedClamps);
+ Assert.Equal(8, plate.ClampZones.Count);
+ }
+
+ [Fact]
+ public void MatchesWithRemovedClampsComparesAgainstTheReducedResolution()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine(), new[] { "clamp-3" });
+
+ Assert.True(Machine().Matches(plate, new[] { "clamp-3" }));
+ Assert.False(Machine().Matches(plate)); // full resolution: zones don't match
+ Assert.False(Machine().Matches(plate, Array.Empty()));
+ Assert.False(Machine().Matches(plate, new[] { "clamp-4" }));
+ }
+ // PM 6ded8ac4 (a): only removed clamps that are actually on this sheet cost time.
+ [Fact]
+ public void RemovalTimeCountsOnlyRemovedClampsOnTheSheet()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine(), new[] { "clamp-3", "clamp-8", "clamp-99" });
+ Assert.Equal(2 * ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes); // 3 and 8
+
+ plate.Size = new Size(48, 60); // clamp-8 is now beyond the sheet
+ Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes);
+ Assert.Contains("clamp-8", plate.RemovedClamps); // still listed (C13)
+ }
+
+ // PM 6ded8ac4 (e): the removed set is read-only, even through a downcast, and an
+ // untouched plate does not share a mutable empty set with every other plate.
+ [Fact]
+ public void RemovedClampsCannotBeMutatedThroughTheCollection()
+ {
+ var removed = Sheet(48, 120);
+ removed.SetClampRail(Machine(), new[] { "clamp-3" });
+ var untouched = Sheet(48, 120);
+
+ Assert.True(((ICollection)removed.RemovedClamps).IsReadOnly);
+ Assert.True(((ICollection)untouched.RemovedClamps).IsReadOnly);
+ }
+}
diff --git a/OpenNest.Tests/ClampZoneTests.cs b/OpenNest.Tests/ClampZoneTests.cs
new file mode 100644
index 0000000..e720f92
--- /dev/null
+++ b/OpenNest.Tests/ClampZoneTests.cs
@@ -0,0 +1,133 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+///
+/// Rectangular machine-clamp keep-out zones: a first-class rectangle on the plate,
+/// never a part. Zones are exclusion and display only - quantity, utilization and
+/// cut accounting must never see them.
+///
+public class ClampZoneTests
+{
+ private static ClampZone Valid(string id = "clamp-1") =>
+ new(id, new Box(5.25, 0, 1.5, 0.625));
+
+ [Fact]
+ public void ZoneCarriesIdAndBounds()
+ {
+ var zone = Valid();
+ Assert.Equal("clamp-1", zone.Id);
+ Assert.Equal(5.25, zone.Bounds.X);
+ Assert.Equal(0, zone.Bounds.Y);
+ Assert.Equal(1.5, zone.Bounds.Length);
+ Assert.Equal(0.625, zone.Bounds.Width);
+ }
+
+ [Fact]
+ public void ZoneOwnsADetachedCopyOfItsBounds()
+ {
+ var bounds = new Box(5.25, 0, 1.5, 0.625);
+ var zone = new ClampZone("clamp", bounds);
+ bounds.Offset(100, 100);
+ Assert.Equal(5.25, zone.Bounds.X);
+ Assert.Equal(0, zone.Bounds.Y);
+ }
+
+ [Theory]
+ [InlineData(double.NaN, 0, 1.5, 0.625)]
+ [InlineData(0, double.PositiveInfinity, 1.5, 0.625)]
+ [InlineData(0, 0, double.NaN, 0.625)]
+ [InlineData(0, 0, 1.5, 0)] // zero-size zone keeps out nothing
+ [InlineData(0, 0, -1.5, 0.625)] // negative size is not a rectangle
+ [InlineData(0, 0, 1.5, -0.625)]
+ public void MalformedBoundsAreRejected(double x, double y, double length, double width)
+ {
+ Assert.Throws(() => new ClampZone("clamp", new Box(x, y, length, width)));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void MissingIdIsRejected(string? id)
+ {
+ Assert.ThrowsAny(
+ () => new ClampZone(id!, new Box(0, 0, 1.5, 0.625))
+ );
+ }
+
+ [Fact]
+ public void NullBoundsRejected()
+ {
+ Assert.Throws(() => new ClampZone("clamp", null!));
+ }
+
+ [Fact]
+ public void PlateStartsEmptyAndKeepsIndependentLists()
+ {
+ var a = new Plate(48, 96);
+ var b = new Plate(48, 96);
+ Assert.Empty(a.ClampZones);
+ a.ClampZones.Add(Valid());
+ Assert.Single(a.ClampZones);
+ Assert.Empty(b.ClampZones);
+ }
+
+ [Fact]
+ public void ZoneChangesRaiseClampZonesChanged()
+ {
+ var plate = new Plate(48, 96);
+ var raised = 0;
+ plate.ClampZonesChanged += (_, _) => raised++;
+
+ var zone = Valid();
+ plate.ClampZones.Add(zone);
+ Assert.Equal(1, raised);
+
+ plate.ClampZones.Remove(zone);
+ Assert.Equal(2, raised);
+ }
+
+ [Fact]
+ public void ZonesAreInvisibleToQuantityAndUtilization()
+ {
+ var plate = new Plate(48, 96) { Quantity = 1 };
+ for (var i = 0; i < 8; i++)
+ plate.ClampZones.Add(new ClampZone($"clamp-{i}", new Box(i * 2.0, 0, 1.5, 0.625)));
+
+ var drawing = MakeRectDrawing(10, 5);
+ drawing.Quantity.Required = 4;
+ var part = new Part(drawing);
+ plate.Parts.Add(part);
+
+ // The single placed part accounts exactly as it does on a plate without clamps.
+ Assert.Equal(1, drawing.Quantity.Nested);
+ Assert.Single(plate.Parts); // clamp zones never enter Parts
+ Assert.Equal(drawing.Area / plate.Area(), plate.Utilization(), 9);
+
+ plate.Parts.Remove(part);
+ Assert.Equal(0, drawing.Quantity.Nested);
+ }
+
+ [Fact]
+ public void ZonesNeverEnterCutSequenceOrPartList()
+ {
+ var plate = new Plate(48, 96);
+ plate.ClampZones.Add(Valid());
+ Assert.All(plate.Parts, p => Assert.False(p.BaseDrawing.IsCutOff));
+ Assert.Empty(plate.Parts);
+ Assert.DoesNotContain(plate.ClampZones, z => ReferenceEquals(z, null));
+ }
+
+ private static Drawing MakeRectDrawing(double length, double width)
+ {
+ var pgm = new Program();
+ pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(length, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(length, width)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, width)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
+ return new Drawing("rect", pgm);
+ }
+}
diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs
index 73838ad..51612f1 100644
--- a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs
+++ b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs
@@ -189,6 +189,171 @@ public class AutomaticCutOffPlannerTests
Assert.Equal(80.5 + Tolerance.Epsilon, plan.UsedSpan);
}
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ClampWall_SkipsNominalLineAndRefusesTailSeparator(int quadrant)
+ {
+ var plate = MakePlate(quadrant);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ plate.EdgeSpacing = new Spacing(0.25, 0.25);
+ // Real rail zones are stored in the sheet's lower-left frame in every quadrant.
+ plate.SetClampRail(new ClampRail(0.2, 0.625, new[] { 35.2, 80.7 }));
+ var before = plate.Parts.ToArray();
+ var plan = Plan(plate);
+ Assert.Equal(new[] { sign * 70.0 }, Positions(plan));
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Equal(0, plan.TailLength);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
+ Assert.Equal(before, plate.Parts.ToArray());
+ Assert.Empty(plate.CutOffs);
+ }
+
+ [Fact]
+ public void ClampOnlyBlocksItsInflatedSpan_AndRemovedClampStopsBlocking()
+ {
+ var plate = MakePlate();
+ plate.EdgeSpacing = new Spacing(0.25, 0.25);
+ plate.ClampZones.Add(new ClampZone("front", new Box(35.25, 0, 1.5, 0.625)));
+ var blocked = Plan(plate);
+ Assert.Equal(new[] { 70, 80.5 + Tolerance.Epsilon }, Positions(blocked));
+ Assert.True(blocked.HasSeparatedTail);
+ plate.ClampZones.Clear();
+ Assert.Equal(new[] { 35, 70, 80.5 + Tolerance.Epsilon }, Positions(Plan(plate)));
+ }
+
+ [Fact]
+ public void ExistingLineAcrossClampCannotCertifyTail_AndPartialFootprintBlocks()
+ {
+ var plate = MakePlate();
+ plate.EdgeSpacing = new Spacing(0.25, 0.25);
+ plate.ClampZones.Add(new ClampZone("hanging", new Box(80.6, 0, 0.2, 0.625)));
+ var existing = new CutOff(new Vector(80.5 + Tolerance.Epsilon, 0), CutOffAxis.Vertical);
+ plate.CutOffs.Add(existing);
+ existing.Regenerate(plate, new CutOffSettings());
+ var original = existing.Drawing.Program;
+ var plan = Plan(plate);
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Null(plan.TailSeparatorX);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
+ Assert.Same(original, existing.Drawing.Program);
+ Assert.Same(existing, Assert.Single(plate.CutOffs));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void NearEquivalentExistingSeparatorInsideClampCannotCertifyTail(int quadrant)
+ {
+ // The nominal separator (80.50001) lies just beyond the clamp edge (80.5000075),
+ // but the equivalent existing line that would be certified (80.500005) is inside it.
+ var plate = MakePlate(quadrant);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ // Footprint 79.500005..80.5000075 from the origin along the edge, in every quadrant.
+ plate.SetClampRail(new ClampRail(1.0000025, 0.625, new[] { 80.00000625 }));
+ var existing = new CutOff(new Vector(sign * 80.500005, 0), CutOffAxis.Vertical);
+ plate.CutOffs.Add(existing);
+ existing.Regenerate(plate, new CutOffSettings());
+ var original = existing.Drawing.Program;
+
+ var plan = Plan(plate);
+
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Null(plan.TailSeparatorX);
+ Assert.Equal(0, plan.TailLength);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
+ Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff);
+ Assert.Equal(new[] { sign * 35.0, sign * 70.0 }, Positions(plan));
+ Assert.Same(original, existing.Drawing.Program);
+ Assert.Same(existing, Assert.Single(plate.CutOffs));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void RailClampsBlockGridAndTailInEveryQuadrant(int quadrant)
+ {
+ // Rail clamps centered 35 and 80.5 from the origin, 1.5 wide, plus 0.25 clearance:
+ // the grid line at 35 and the separator at 80.50001 both cross a clamp.
+ var plate = MakePlate(quadrant);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ plate.EdgeSpacing = new Spacing(0.25, 0.25);
+ plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 35.0, 80.5 }));
+
+ var plan = Plan(plate);
+
+ Assert.Equal(new[] { sign * 70.0 }, Positions(plan));
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Equal(2, plan.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut));
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void RailClampClearanceOnlyContactBlocksAndClearLineSurvives(int quadrant)
+ {
+ // Physical footprint 36..37.5; the 0.25 clearance reaches a line at 35.75 but not 35.
+ var plate = MakePlate(quadrant);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ plate.EdgeSpacing = new Spacing(0.25, 0.25);
+ plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 36.75 }));
+ Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * (80.5 + Tolerance.Epsilon) },
+ Positions(Plan(plate)));
+ Assert.Equal(new[] { sign * 71.5, sign * (80.5 + Tolerance.Epsilon) },
+ Positions(Plan(plate, spacing: 35.75)));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void HangingRailClampKeepsWholeFootprintForTail(int quadrant)
+ {
+ // Clamp centered 120.5 on a 120-long sheet covers 119.75..121.25 from the origin;
+ // with 0.25 clearance it blocks the separator at 119.50001 behind a 119-long envelope.
+ var plate = MakePlate(quadrant, occupied: 119);
+ plate.EdgeSpacing = new Spacing(0.25, 0.25);
+ plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 120.5 }));
+ var plan = Plan(plate, spacing: 119.6);
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut);
+ Assert.Empty(plan.Definitions);
+ }
+
+ [Theory]
+ [InlineData(35)]
+ [InlineData(80.50001)]
+ public void SameLineLimitedManualCutOnClamp_StillBlocksWholePlan(double x)
+ {
+ var plate = MakePlate();
+ plate.ClampZones.Add(new ClampZone("under-manual", new Box(x - 0.75, 0, 1.5, 0.625)));
+ var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical) { StartLimit = 10, EndLimit = 60 };
+ plate.CutOffs.Add(manual);
+ var program = manual.Drawing.Program;
+
+ var plan = Plan(plate);
+
+ Assert.True(plan.HasBlockingDiagnostics);
+ Assert.Empty(plan.Definitions);
+ Assert.Empty(plan.PreviewParts);
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Contains(plan.Diagnostics, d => d.IsBlocking &&
+ d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict);
+ Assert.Same(program, manual.Drawing.Program);
+ Assert.Same(manual, Assert.Single(plate.CutOffs));
+ }
+
[Fact]
public void Planning_DoesNotMutateAnyLiveObjectsOrSequence()
{
@@ -548,7 +713,7 @@ public class AutomaticCutOffPlannerTests
}
[Fact]
- public void ToleranceCloseExistingSeparator_ThatStillTouchesClearance_DoesNotClaimTail()
+ public void NearEquivalentExistingSeparator_ThatStillTouchesClearance_DoesNotClaimTail()
{
var plate = MakePlate();
plate.PartSpacing = 0;
diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffShiftClampTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftClampTests.cs
new file mode 100644
index 0000000..60703e5
--- /dev/null
+++ b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftClampTests.cs
@@ -0,0 +1,175 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CutOffs;
+
+public class AutomaticCutOffShiftClampTests
+{
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ClampAroundNominalLine_MovesInsteadOfSkipping(int quadrant)
+ {
+ var plate = AutomaticCutOffShiftTests.UserCase(quadrant);
+ plate.EdgeSpacing = new Spacing(0, 0.25, 0, 0);
+ var rail = new ClampRail(2, 0.625, new[] { 35.0 });
+ plate.SetClampRail(rail);
+ var zones = plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)).ToArray();
+ var parts = plate.Parts.ToArray();
+ var plan = AutomaticCutOffPlanner.Create(plate, AutomaticCutOffShiftTests.Options(), new CutOffSettings());
+
+ Assert.False(plan.HasBlockingDiagnostics);
+ Assert.Equal(3, plan.Definitions.Count);
+ Assert.True(System.Math.Abs(plan.Definitions[0].Position.X) < 33.75);
+ Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ShiftedCut);
+ AutomaticCutOffShiftTests.AssertLimits(plan, 35, 17.5);
+ AssertClearsPhysicalFootprints(plate, rail, plan);
+ Assert.Equal(zones, plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)));
+ Assert.Equal(parts, plate.Parts.ToArray());
+ Assert.Empty(plate.CutOffs);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ClampClearanceOnly_ExcludesOtherwiseOptimalCandidate(int quadrant)
+ {
+ var plate = AutomaticCutOffShiftTests.UserCase(quadrant);
+ var rail = new ClampRail(0.2, 0.625, new[] { 35.2 });
+ plate.SetClampRail(rail);
+ var settings = new CutOffSettings();
+ var options = AutomaticCutOffShiftTests.Options();
+ var withoutClearance = AutomaticCutOffPlanner.Create(plate, options, settings);
+ var first = System.Math.Abs(withoutClearance.Definitions[0].Position.X);
+ Assert.InRange(first, 34.69, 34.71);
+ Assert.True(first < rail.Centers[0] - rail.Width / 2);
+
+ // Bottom clearance is the physical rail clearance in every quadrant, not Top.
+ plate.EdgeSpacing = new Spacing(0, 0.553, 0, 0);
+ var plan = AutomaticCutOffPlanner.Create(plate, options, settings);
+ Assert.Equal(3, plan.Definitions.Count);
+ Assert.True(System.Math.Abs(plan.Definitions[0].Position.X) < 34.547);
+ Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut);
+ Assert.Equal(withoutClearance.TailSeparatorX, plan.TailSeparatorX);
+ AutomaticCutOffShiftTests.AssertLimits(plan, 35, 17.5);
+ AssertClearsPhysicalFootprints(plate, rail, plan);
+ }
+
+ [Theory]
+ [InlineData(1, 18, 26.25)]
+ [InlineData(2, 18, 26.25)]
+ [InlineData(3, 18, 26.25)]
+ [InlineData(4, 18, 26.25)]
+ [InlineData(1, 40, 40)]
+ [InlineData(2, 40, 40)]
+ [InlineData(3, 40, 40)]
+ [InlineData(4, 40, 40)]
+ public void NoClampFreeArrangement_FallsBackToWholeGapNominalPositions(int quadrant, double width, double center)
+ {
+ var plate = AutomaticCutOffShiftTests.UserCase(quadrant);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ var rail = new ClampRail(width, 0.625, new[] { center });
+ plate.SetClampRail(rail);
+ // Width 18 covers the entire first-line window. Width 40 leaves candidates
+ // in both rows, but the allowed first/second windows are over 35 apart.
+ var plan = AutomaticCutOffPlanner.Create(plate, AutomaticCutOffShiftTests.Options(), new CutOffSettings());
+
+ Assert.False(plan.HasBlockingDiagnostics);
+ Assert.True(plan.HasSeparatedTail);
+ Assert.Equal(new[] { sign * 70.0, plan.TailSeparatorX!.Value }, plan.Definitions.Select(c => c.Position.X));
+ var blocked = Assert.Single(plan.Diagnostics.Where(d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut));
+ Assert.Equal(sign * 35, blocked.X);
+ Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ShiftedCut);
+ Assert.Equal(120, plan.FixedGridCutLength);
+ Assert.Equal(60, plan.StripCutLength);
+ AssertClearsPhysicalFootprints(plate, rail, plan);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ClampFallback_IsLocalToGap_OtherGapStillShifts(int quadrant)
+ {
+ var plate = new Plate(60, 200) { Quadrant = quadrant, PartSpacing = 0.3 };
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ foreach (var right in new[] { 34.75, 69.75, 114.75, 149.75, 180 })
+ plate.Parts.Add(AutomaticCutOffShiftTests.Rectangle(10, 20,
+ quadrant is 2 or 3 ? -right : right - 10, quadrant is 3 or 4 ? -30 : 10));
+ plate.CutOffs.Add(new CutOff(new Vector(sign * 80, 0), CutOffAxis.Vertical));
+ var rail = new ClampRail(18, 0.625, new[] { 26.25 });
+ plate.SetClampRail(rail);
+ var plan = AutomaticCutOffPlanner.Create(plate, AutomaticCutOffShiftTests.Options(), new CutOffSettings());
+
+ Assert.False(plan.HasBlockingDiagnostics);
+ Assert.Equal(4, plan.Definitions.Count);
+ Assert.Equal(sign * 70, plan.Definitions[0].Position.X);
+ var shifted = plan.Definitions.Skip(1).Take(2).Select(c => System.Math.Abs(c.Position.X)).ToArray();
+ Assert.InRange(shifted[0], 110.3, 114.75);
+ Assert.InRange(shifted[1] - shifted[0], 17.5 - AutomaticCutOffPlanner.GeometryTolerance,
+ 35 + AutomaticCutOffPlanner.GeometryTolerance);
+ Assert.True(shifted[1] < 150);
+ Assert.InRange(plan.UsedSpan - shifted[1], 0, 35);
+ Assert.Single(plan.Diagnostics.Where(d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut));
+ Assert.Equal(2, plan.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ShiftedCut));
+ AssertClearsPhysicalFootprints(plate, rail, plan);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void NearExistingSeparatorInsideClamp_RemainsUncertified(int quadrant)
+ {
+ var plate = AutomaticCutOffShiftTests.UserCase(quadrant);
+ var options = AutomaticCutOffShiftTests.Options();
+ var settings = new CutOffSettings();
+ var first = AutomaticCutOffPlanner.Create(plate, options, settings);
+ var separator = System.Math.Abs(first.TailSeparatorX!.Value);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ var existing = new CutOff(new Vector(sign * (separator - AutomaticCutOffPlanner.GeometryTolerance / 2), 0),
+ CutOffAxis.Vertical);
+ plate.CutOffs.Add(existing);
+ existing.Regenerate(plate, settings);
+ var original = existing.Drawing.Program;
+ // Proposed separator is clear, but its Near-equivalent manual coordinate is not.
+ var rail = new ClampRail(1, 0.625,
+ new[] { separator - AutomaticCutOffPlanner.GeometryTolerance / 4 - 0.5 });
+ plate.SetClampRail(rail);
+ var plan = AutomaticCutOffPlanner.Create(plate, options, settings);
+
+ Assert.False(plan.HasBlockingDiagnostics);
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Equal(plate.Size.Length, plan.UsedSpan);
+ Assert.Equal(2, plan.Definitions.Count);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
+ Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff);
+ Assert.Same(original, existing.Drawing.Program);
+ AssertClearsPhysicalFootprints(plate, rail, plan);
+ }
+
+ private static void AssertClearsPhysicalFootprints(Plate plate, ClampRail rail, AutomaticCutOffPlan plan)
+ {
+ // Independent physical oracle: distances from the origin, not mapped zone bounds.
+ foreach (var cut in plan.Definitions)
+ {
+ var distance = System.Math.Abs(cut.Position.X);
+ foreach (var center in rail.Centers)
+ {
+ var low = center - rail.Width / 2 - plate.EdgeSpacing.Bottom;
+ var high = center + rail.Width / 2 + plate.EdgeSpacing.Bottom;
+ Assert.True(distance < low || distance > high, $"Cut {distance:R} enters [{low:R}, {high:R}].");
+ }
+ foreach (var move in cut.Drawing.Program.Codes.OfType())
+ Assert.Equal(cut.Position.X, move.EndPoint.X);
+ }
+ }
+}
diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffShiftLifecycleTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftLifecycleTests.cs
new file mode 100644
index 0000000..89e9f4a
--- /dev/null
+++ b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftLifecycleTests.cs
@@ -0,0 +1,140 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using OpenNest.IO;
+
+namespace OpenNest.Tests.CutOffs;
+
+public class AutomaticCutOffShiftLifecycleTests
+{
+ private static readonly AutomaticCutOffOptions Shifted = new()
+ {
+ Spacing = 35,
+ MinimumStripLength = 17.5,
+ MinimumTailLength = 12,
+ };
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void BatchApply_SaveReload_RerunAndMove_PreserveShiftedDefinitionsAndClearance(int quadrant)
+ {
+ var nest = new Nest("shifted lifecycle");
+ var plate = AutomaticCutOffShiftTests.UserCase(quadrant);
+ for (var index = 0; index < plate.Parts.Count; index++)
+ {
+ // The nest drawing collection keys by name; distinct synthetic shapes
+ // must have distinct names for the writer to retain every part.
+ var drawing = plate.Parts[index].BaseDrawing;
+ drawing.Name = $"shift rectangle {index}";
+ nest.Drawings.Add(drawing);
+ }
+ nest.Plates.Add(plate);
+ var settings = new CutOffSettings();
+ var realParts = plate.Parts.ToArray();
+ var realPrograms = realParts.Select(p => p.Program).ToArray();
+ var proposal = Assert.Single(AutomaticCutOffBatch.Create(new[] { plate }, Shifted, settings));
+ Assert.Equal(3, proposal.Definitions.Count);
+ Assert.Empty(plate.CutOffs);
+ Assert.Equal(realParts, plate.Parts.ToArray());
+
+ var applied = Assert.Single(AutomaticCutOffBatch.Apply(new[] { plate }, Shifted, settings));
+ Assert.Equal(proposal.Definitions.Select(c => c.Position.X), applied.Definitions.Select(c => c.Position.X));
+ var locations = plate.CutOffs.Select(c => c.Position.X).ToArray();
+ AssertCutsClearRealRectangles(plate, settings.PartClearance);
+ Assert.Equal(realPrograms, realParts.Select(p => p.Program));
+ Assert.Empty(Assert.Single(AutomaticCutOffBatch.Apply(new[] { plate }, Shifted, settings)).Definitions);
+ Assert.Equal(locations, plate.CutOffs.Select(c => c.Position.X));
+
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ stream.Position = 0;
+ var loaded = new NestReader(stream).Read();
+ var reloaded = Assert.Single(loaded.Plates);
+ Assert.Equal(locations, reloaded.CutOffs.Select(c => c.Position.X));
+ Assert.Equal(realParts.Length, reloaded.Parts.Count(p => !p.BaseDrawing.IsCutOff));
+ var reloadedPlan = AutomaticCutOffPlanner.Create(reloaded, Shifted, settings);
+ Assert.Equal(proposal.OccupiedSpan, reloadedPlan.OccupiedSpan);
+ Assert.Empty(reloadedPlan.Definitions);
+ AssertCutsClearRealRectangles(reloaded, settings.PartClearance);
+
+ var moved = reloaded.Parts.First(p => !p.BaseDrawing.IsCutOff &&
+ System.Math.Abs(p.BoundingBox.Right - p.BoundingBox.Left - 10) < 1e-6);
+ moved.Offset(quadrant is 2 or 3 ? new Vector(-1, 0) : new Vector(1, 0));
+ reloaded.RegenerateCutOffs(settings);
+ Assert.Equal(locations, reloaded.CutOffs.Select(c => c.Position.X));
+ AssertCutsClearRealRectangles(reloaded, settings.PartClearance);
+ }
+
+ [Fact]
+ public void ShiftedBatch_LimitedSeparatorOnLaterPlateRefusesAllWithoutMutation()
+ {
+ var plates = new[] { AutomaticCutOffShiftTests.UserCase(), AutomaticCutOffShiftTests.UserCase() };
+ var settings = new CutOffSettings();
+ var separator = AutomaticCutOffPlanner.Create(plates[1], Shifted, settings).TailSeparatorX!.Value;
+ var manual = new CutOff(new Vector(separator, 0), CutOffAxis.Vertical) { EndLimit = 2 };
+ plates[1].CutOffs.Add(manual);
+ var before = plates.Select(p => p.Parts.ToArray()).ToArray();
+
+ var result = AutomaticCutOffBatch.Apply(plates, Shifted, settings);
+
+ Assert.True(result[1].HasBlockingDiagnostics);
+ Assert.Empty(plates[0].CutOffs);
+ Assert.Same(manual, Assert.Single(plates[1].CutOffs));
+ for (var i = 0; i < plates.Length; i++)
+ Assert.Equal(before[i], plates[i].Parts.ToArray());
+ }
+
+ [Fact]
+ public void ShiftedBatch_LateRegenerationFailureRollsBackEveryTouchedPlate()
+ {
+ var plates = new[] { AutomaticCutOffShiftTests.UserCase(), AutomaticCutOffShiftTests.UserCase() };
+ var before = plates.Select(p => p.Parts.ToArray()).ToArray();
+ var programs = plates.Select(p => p.Parts.Select(part => part.Program).ToArray()).ToArray();
+ var failOnce = true;
+ plates[1].PartAdded += (_, e) =>
+ {
+ if (failOnce && e.Item.BaseDrawing.IsCutOff)
+ {
+ failOnce = false;
+ throw new InvalidOperationException("Injected regeneration failure");
+ }
+ };
+
+ var error = Assert.Throws(() =>
+ AutomaticCutOffBatch.Apply(plates, Shifted, new CutOffSettings()));
+
+ Assert.False(failOnce);
+ Assert.Contains("original cut-offs were restored", error.Message);
+ for (var i = 0; i < plates.Length; i++)
+ {
+ Assert.Empty(plates[i].CutOffs);
+ Assert.Equal(before[i], plates[i].Parts.ToArray());
+ Assert.Equal(programs[i], plates[i].Parts.Select(p => p.Program).ToArray());
+ }
+ }
+
+ private static void AssertCutsClearRealRectangles(Plate plate, double clearance)
+ {
+ var material = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).Select(p => p.BoundingBox).ToArray();
+ foreach (var cut in plate.Parts.Where(p => p.BaseDrawing.IsCutOff))
+ {
+ var codes = cut.Program.Codes;
+ Assert.Equal(0, codes.Count % 2);
+ for (var index = 0; index < codes.Count; index += 2)
+ {
+ var from = Assert.IsType(codes[index]).EndPoint + cut.Location;
+ var to = Assert.IsType(codes[index + 1]).EndPoint + cut.Location;
+ var left = System.Math.Min(from.X, to.X);
+ var right = System.Math.Max(from.X, to.X);
+ var bottom = System.Math.Min(from.Y, to.Y);
+ var top = System.Math.Max(from.Y, to.Y);
+ Assert.All(material, box => Assert.True(
+ right <= box.Left - clearance || left >= box.Right + clearance ||
+ top <= box.Bottom - clearance || bottom >= box.Top + clearance,
+ $"Cut {from} -> {to} crosses a rectangle or its clearance."));
+ }
+ }
+ }
+}
diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs
new file mode 100644
index 0000000..3e239a0
--- /dev/null
+++ b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs
@@ -0,0 +1,369 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CutOffs;
+
+public class AutomaticCutOffShiftTests
+{
+ internal static AutomaticCutOffOptions Options(double scale = 1)
+ {
+ return new AutomaticCutOffOptions { Spacing = 35 * scale, MinimumStripLength = 17.5 * scale };
+ }
+
+ internal static Part Rectangle(double length, double width, double x, double y)
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(0, 0));
+ program.Codes.Add(new LinearMove(length, 0));
+ program.Codes.Add(new LinearMove(length, width));
+ program.Codes.Add(new LinearMove(0, width));
+ program.Codes.Add(new LinearMove(0, 0));
+ return new Part(new Drawing("shift rectangle", program), new Vector(x, y));
+ }
+
+ internal static Plate UserCase(int quadrant = 1, double scale = 1)
+ {
+ var plate = new Plate(60 * scale, 120 * scale) { Quadrant = quadrant, PartSpacing = 0.3 * scale };
+ foreach (var right in new[] { 34.75, 69.75, 80.0 })
+ plate.Parts.Add(Rectangle((right == 80 ? 5 : 10) * scale, (right == 80 ? 5 : 20) * scale,
+ (quadrant is 2 or 3 ? -right : right - (right == 80 ? 5 : 10)) * scale,
+ (quadrant is 3 or 4 ? -30 : 10) * scale));
+ return plate;
+ }
+
+ internal static double CutLength(Program program) => Enumerable.Range(0, program.Codes.Count / 2)
+ .Sum(i => System.Math.Abs(((LinearMove)program.Codes[2 * i + 1]).EndPoint.Y -
+ ((RapidMove)program.Codes[2 * i]).EndPoint.Y));
+
+ [Theory]
+ [InlineData(1, 1)]
+ [InlineData(2, 1)]
+ [InlineData(3, 1)]
+ [InlineData(4, 1)]
+ [InlineData(1, 25.4)]
+ [InlineData(2, 25.4)]
+ [InlineData(3, 25.4)]
+ [InlineData(4, 25.4)]
+ public void ParallelEdgeCase_SavesFortyInchesWithSameCountAndSeparator(int quadrant, double scale)
+ {
+ var plate = UserCase(quadrant, scale);
+ var settings = new CutOffSettings { PartClearance = 0.02 * scale };
+ var legacy = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 * scale }, settings);
+ var shifted = AutomaticCutOffPlanner.Create(plate, Options(scale), settings);
+ Assert.Equal(legacy.TailSeparatorX, shifted.TailSeparatorX);
+ Assert.Equal(3, shifted.Definitions.Count);
+ Assert.True(legacy.PreviewParts.Sum(p => CutLength(p.Program)) -
+ shifted.PreviewParts.Sum(p => CutLength(p.Program)) >= 39.9 * scale);
+ AssertLimits(shifted, 35 * scale, 17.5 * scale);
+ Assert.Empty(plate.CutOffs);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(0.00002)]
+ [InlineData(36)]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ public void MinimumStrip_RejectsInvalidValues(double minimum)
+ {
+ var options = Options();
+ options.MinimumStripLength = minimum;
+ Assert.Throws(() => AutomaticCutOffPlanner.Create(UserCase(), options, new CutOffSettings()));
+ }
+
+ [Fact]
+ public void EqualCosts_SelectNominalPositions_AndLastStripMayBeShort()
+ {
+ var plate = new Plate(60, 120) { PartSpacing = 0.3 };
+ plate.Parts.Add(Rectangle(40, 20, 0, 10));
+ var plan = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings());
+ Assert.Equal(new[] { 35.0, 40.3 + AutomaticCutOffPlanner.GeometryTolerance }, plan.Definitions.Select(c => c.Position.X));
+ Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ShiftedCut);
+ Assert.Equal(CutLength(plan.PreviewParts[0].Program), plan.StripCutLength, 8);
+ Assert.Equal(plan.StripCutLength, plan.FixedGridCutLength);
+ AssertLimits(plan, 35, 17.5);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void NullAndEqualSpacing_AreIdenticalIncludingDiagnosticsAndTotals(int quadrant)
+ {
+ var plate = UserCase(quadrant);
+ plate.CutOffs.Add(new CutOff(new Vector(quadrant is 2 or 3 ? -35 : 35, 0), CutOffAxis.Vertical));
+ var options = new AutomaticCutOffOptions { Spacing = 35 };
+ var legacy = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
+ options.MinimumStripLength = 35;
+ var fixedGrid = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
+ Assert.Equal(legacy.Definitions.Select(c => (c.Position, c.Axis, c.StartLimit, c.EndLimit)),
+ fixedGrid.Definitions.Select(c => (c.Position, c.Axis, c.StartLimit, c.EndLimit)));
+ Assert.Equal(legacy.PreviewParts.Select(p => p.Program.ToString()), fixedGrid.PreviewParts.Select(p => p.Program.ToString()));
+ Assert.Equal(legacy.Diagnostics, fixedGrid.Diagnostics);
+ Assert.Equal(legacy.TailSeparatorX, fixedGrid.TailSeparatorX);
+ Assert.Equal(legacy.UsedSpan, fixedGrid.UsedSpan);
+ Assert.Equal(legacy.StripCutLength, fixedGrid.StripCutLength);
+ Assert.Equal(legacy.FixedGridCutLength, fixedGrid.FixedGridCutLength);
+ Assert.Equal(legacy.PreviewParts.Take(legacy.Definitions.Count - (legacy.HasSeparatedTail ? 1 : 0))
+ .Sum(p => CutLength(p.Program)), legacy.StripCutLength, 8);
+ Assert.Equal(legacy.StripCutLength, legacy.FixedGridCutLength);
+ }
+
+ [Fact]
+ public void SeededTwoHundredPlates_NeverLonger_RespectLimitsAndAreDeterministic()
+ {
+ var random = new Random(42131);
+ for (var sample = 0; sample < 200; sample++)
+ {
+ var quadrant = sample % 4 + 1;
+ var plate = new Plate(60, 120) { Quadrant = quadrant, PartSpacing = 0.3 };
+ for (var index = 0; index < 4; index++)
+ {
+ var right = 20 + random.NextDouble() * 90;
+ var width = 2 + random.NextDouble() * 8;
+ var height = 2 + random.NextDouble() * 12;
+ var top = 20 + random.NextDouble() * 20;
+ plate.Parts.Add(Rectangle(width, height, quadrant is 2 or 3 ? -right : right - width,
+ quadrant is 3 or 4 ? -top : top - height));
+ }
+ var options = Options();
+ var settings = new CutOffSettings();
+ var first = AutomaticCutOffPlanner.Create(plate, options, settings);
+ var second = AutomaticCutOffPlanner.Create(plate, options, settings);
+ var legacy = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings);
+ Assert.Equal(legacy.Definitions.Count, first.Definitions.Count);
+ Assert.Equal(legacy.TailSeparatorX, first.TailSeparatorX);
+ Assert.True(first.StripCutLength <= first.FixedGridCutLength + 0.000001);
+ Assert.Equal(first.Definitions.Select(c => c.Position.X), second.Definitions.Select(c => c.Position.X));
+ Assert.Equal(first.Diagnostics, second.Diagnostics);
+ Assert.Equal(first.StripCutLength, second.StripCutLength);
+ Assert.Equal(first.PreviewParts.Take(first.PreviewParts.Count - 1).Sum(p => CutLength(p.Program)), first.StripCutLength, 8);
+ AssertLimits(first, 35, 17.5);
+ }
+ }
+
+ [Theory]
+ [InlineData(1, false)]
+ [InlineData(2, false)]
+ [InlineData(3, false)]
+ [InlineData(4, false)]
+ [InlineData(1, true)]
+ [InlineData(2, true)]
+ [InlineData(3, true)]
+ [InlineData(4, true)]
+ public void FullSpanManualLine_AtTwentyIsBoundary_NotAnExtraObstacle(int quadrant, bool explicitLimits)
+ {
+ var plate = UserCase(quadrant);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ var settings = new CutOffSettings { Overtravel = 0.2 };
+ var manual = new CutOff(new Vector(sign * 20, 0), CutOffAxis.Vertical);
+ if (explicitLimits)
+ {
+ var bounds = plate.BoundingBox(false);
+ manual.StartLimit = bounds.Bottom;
+ manual.EndLimit = bounds.Top + settings.Overtravel;
+ }
+ plate.CutOffs.Add(manual);
+ var plan = AutomaticCutOffPlanner.Create(plate, Options(), settings);
+ Assert.False(plan.HasBlockingDiagnostics);
+ Assert.Equal(2, plan.Definitions.Count);
+ // The feasible window 45.3..55 misses every rectangle, so all costs tie.
+ // One unbroken full-width segment at the gap-relative nominal X wins.
+ Assert.Equal(sign * 55, plan.Definitions[0].Position.X);
+ Assert.Single(plan.PreviewParts[0].Program.Codes.OfType());
+ Assert.Equal(60.2, CutLength(plan.PreviewParts[0].Program), 8);
+ Assert.Equal(plan.StripCutLength, plan.FixedGridCutLength);
+ Assert.InRange(System.Math.Abs(plan.Definitions[0].Position.X) - 20, 17.5, 35);
+ Assert.InRange(plan.UsedSpan - System.Math.Abs(plan.Definitions[0].Position.X), 0, 35);
+ Assert.Same(manual, Assert.Single(plate.CutOffs));
+ }
+
+ [Theory]
+ [InlineData(1, -1)]
+ [InlineData(2, -1)]
+ [InlineData(3, -1)]
+ [InlineData(4, -1)]
+ [InlineData(1, 0)]
+ [InlineData(2, 0)]
+ [InlineData(3, 0)]
+ [InlineData(4, 0)]
+ [InlineData(1, 1)]
+ [InlineData(2, 1)]
+ [InlineData(3, 1)]
+ [InlineData(4, 1)]
+ public void LimitedManualLine_AtBestPosition_IsAvoidedWithoutBlocking(int quadrant, int toleranceOffset)
+ {
+ var plate = UserCase(quadrant);
+ var best = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings());
+ var x = best.Definitions[0].Position.X;
+ var manualX = x + toleranceOffset * AutomaticCutOffPlanner.GeometryTolerance;
+ plate.CutOffs.Add(new CutOff(new Vector(manualX, 0), CutOffAxis.Vertical)
+ {
+ EndLimit = plate.BoundingBox(false).Bottom + 2,
+ });
+ var plan = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings());
+ Assert.False(plan.HasBlockingDiagnostics);
+ Assert.Equal(3, plan.Definitions.Count);
+ Assert.DoesNotContain(plan.Definitions, c => c.Position.X == x ||
+ System.Math.Abs(c.Position.X - manualX) <= AutomaticCutOffPlanner.GeometryTolerance);
+ Assert.True(plan.StripCutLength < plan.FixedGridCutLength);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void LimitedManualLine_AtSeparator_StillBlocksAndEmptiesProposedTotals(int quadrant)
+ {
+ var plate = UserCase(quadrant);
+ var first = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings());
+ plate.CutOffs.Add(new CutOff(new Vector(first.TailSeparatorX!.Value, 0), CutOffAxis.Vertical)
+ {
+ EndLimit = plate.BoundingBox(false).Bottom + 2,
+ });
+ var plan = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings());
+ Assert.True(plan.HasBlockingDiagnostics);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict && d.IsBlocking);
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Empty(plan.Definitions);
+ Assert.Empty(plan.PreviewParts);
+ Assert.Equal(0, plan.StripCutLength);
+ Assert.True(plan.FixedGridCutLength > 0);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ApplyAndRerun_FullSpanDefinitionsMakeEveryGapANoOp(int quadrant)
+ {
+ var plate = UserCase(quadrant);
+ var settings = new CutOffSettings();
+ var plan = AutomaticCutOffPlanner.Create(plate, Options(), settings);
+ plate.CutOffs.AddRange(plan.Definitions);
+ plate.RegenerateCutOffs(settings);
+ var parts = plate.Parts.ToArray();
+ var rerun = AutomaticCutOffPlanner.Create(plate, Options(), settings);
+ Assert.Empty(rerun.Definitions);
+ Assert.False(rerun.HasBlockingDiagnostics);
+ Assert.Equal(plan.TailSeparatorX, rerun.TailSeparatorX);
+ Assert.Equal(parts, plate.Parts.ToArray());
+ Assert.Equal(0, rerun.StripCutLength);
+ Assert.Equal(0, rerun.FixedGridCutLength);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void FullSpanLineInTail_IsIgnoredAsAStripBoundary(int quadrant)
+ {
+ var plate = UserCase(quadrant);
+ var before = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings());
+ plate.CutOffs.Add(new CutOff(new Vector(quadrant is 2 or 3 ? -110 : 110, 0), CutOffAxis.Vertical));
+ var after = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings());
+ Assert.Equal(before.Definitions.Select(c => c.Position.X), after.Definitions.Select(c => c.Position.X));
+ Assert.Equal(before.StripCutLength, after.StripCutLength);
+ Assert.Equal(before.FixedGridCutLength, after.FixedGridCutLength);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void UnsortedDuplicateBoundaries_SpacingSizedGapsNeedNoNewLines(int quadrant)
+ {
+ var plate = UserCase(quadrant);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ foreach (var distance in new[] { 55.0, 110, 20, 55, 0 })
+ plate.CutOffs.Add(new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical));
+ var plan = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings());
+ Assert.False(plan.HasBlockingDiagnostics);
+ Assert.Equal(plan.TailSeparatorX, Assert.Single(plan.Definitions).Position.X);
+ Assert.Equal(0, plan.StripCutLength);
+ Assert.Equal(0, plan.FixedGridCutLength);
+ Assert.Equal(5, plate.CutOffs.Count);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void LimitedManualLine_WhenNoAllowedCandidate_FallbackStillBlocks(int quadrant)
+ {
+ var plate = UserCase(quadrant);
+ var sign = quadrant is 2 or 3 ? -1 : 1;
+ plate.CutOffs.Add(new CutOff(new Vector(sign * 35, 0), CutOffAxis.Vertical)
+ {
+ EndLimit = plate.BoundingBox(false).Bottom + 2,
+ });
+ var options = Options();
+ // Only nominal 35 is on the lattice in this first-line window.
+ options.MinimumStripLength = 35 - AutomaticCutOffPlanner.GeometryTolerance / 2;
+ var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
+ Assert.True(plan.HasBlockingDiagnostics);
+ Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict && d.IsBlocking);
+ Assert.Empty(plan.Definitions);
+ Assert.False(plan.HasSeparatedTail);
+ Assert.Equal(0, plan.StripCutLength);
+ Assert.True(plan.FixedGridCutLength > 0);
+ }
+
+ [Fact]
+ public void LegacyGridReportsActualNewStripCutLengthWithoutChangingItsDefinitions()
+ {
+ var plate = new Plate(60, 120) { PartSpacing = 0.3 };
+ plate.Parts.Add(Rectangle(5, 5, 75, 10));
+ var settings = new CutOffSettings();
+ var options = new AutomaticCutOffOptions { Spacing = 35 };
+ var legacy = AutomaticCutOffPlanner.Create(plate, options, settings);
+ Assert.Equal(new[] { 35.0, 70.0 }, legacy.Definitions.Take(2).Select(c => c.Position.X));
+ Assert.Equal(120, legacy.PreviewParts.Take(2).Sum(p => CutLength(p.Program)));
+ Assert.Equal(120, legacy.StripCutLength);
+ Assert.Equal(legacy.StripCutLength, legacy.FixedGridCutLength);
+ options.MinimumStripLength = 35;
+ var equal = AutomaticCutOffPlanner.Create(plate, options, settings);
+ Assert.Equal(legacy.Definitions.Select(c => c.Position.X), equal.Definitions.Select(c => c.Position.X));
+ Assert.Equal(legacy.Diagnostics, equal.Diagnostics);
+ Assert.Equal(legacy.StripCutLength, equal.StripCutLength);
+ Assert.Equal(legacy.FixedGridCutLength, equal.FixedGridCutLength);
+ }
+
+ [Fact]
+ public void ShiftedSearchKeepsNominalLinesWithoutRejectingAValidDenseGrid()
+ {
+ var plate = new Plate(60, 120) { PartSpacing = 0 };
+ plate.Parts.Add(Rectangle(0.01, 0.01, 109.97009, 0.5));
+ var settings = new CutOffSettings();
+ var fixedGrid = AutomaticCutOffPlanner.Create(plate,
+ new AutomaticCutOffOptions { Spacing = 0.1 }, settings);
+ Assert.Equal(1101, fixedGrid.Definitions.Count);
+
+ var shifted = AutomaticCutOffPlanner.Create(plate,
+ new AutomaticCutOffOptions { Spacing = 0.1, MinimumStripLength = 0.05 }, settings);
+
+ Assert.Equal(fixedGrid.Definitions.Count, shifted.Definitions.Count);
+ Assert.True(shifted.StripCutLength <= shifted.FixedGridCutLength + 0.000001);
+ }
+
+ internal static void AssertLimits(AutomaticCutOffPlan plan, double spacing, double minimum)
+ {
+ var positions = plan.Definitions.Where(c => c.Position.X != plan.TailSeparatorX)
+ .Select(c => System.Math.Abs(c.Position.X)).ToArray();
+ var previous = 0.0;
+ foreach (var position in positions)
+ {
+ Assert.InRange(position - previous, minimum - AutomaticCutOffPlanner.GeometryTolerance,
+ spacing + AutomaticCutOffPlanner.GeometryTolerance);
+ previous = position;
+ }
+ Assert.InRange(plan.UsedSpan - previous, 0, spacing + AutomaticCutOffPlanner.GeometryTolerance);
+ }
+}
diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs
new file mode 100644
index 0000000..1a4a94e
--- /dev/null
+++ b/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs
@@ -0,0 +1,83 @@
+namespace OpenNest.Tests.CutOffs;
+
+public class AutomaticCutOffStripSearchTests
+{
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void EqualLength_PiercesThenDeviationBreakTies(bool pierceTieBreak)
+ {
+ var result = AutomaticCutOffStripSearch.Solve(0, new[] { new[] { 18.0, 35.0 } },
+ new[] { 35.0 }, 35, 17.5, x => (10, pierceTieBreak && x == 35 ? 2 : 1));
+ Assert.Equal(new[] { pierceTieBreak ? 18.0 : 35.0 }, result);
+ }
+
+ [Fact]
+ public void DecimalRoundedLengthTie_UsesPierceCountNotBinaryAdditionNoise()
+ {
+ var result = AutomaticCutOffStripSearch.Solve(0,
+ new[] { new[] { 20.0, 35.0 }, new[] { 40.0, 70.0 } },
+ new[] { 35.0, 70.0 }, 35, 17.5,
+ x => x switch { 20 => (0.1, 1), 40 => (0.2, 1), 35 => (0.3, 3), _ => (0.0, 0) });
+ Assert.Equal(new[] { 20.0, 40.0 }, result);
+ }
+
+ [Fact]
+ public void IntegralDoubleTie_UsesExactValueBeforePierces()
+ {
+ var first = 10576245042702564d;
+ var second = 16257049472735948d;
+ var combined = 26833294515438512d;
+ Assert.Equal((decimal)first + (decimal)second, (decimal)combined);
+ var result = AutomaticCutOffStripSearch.Solve(0,
+ new[] { new[] { 20.0, 35.0 }, new[] { 40.0, 70.0 } },
+ new[] { 35.0, 70.0 }, 35, 17.5,
+ x => x switch { 20 => (first, 1), 40 => (second, 1), 35 => (combined, 3), _ => (0.0, 0) });
+ Assert.Equal(new[] { 20.0, 40.0 }, result);
+ }
+
+ [Fact]
+ public void GlobalOptimum_IsBetterThanGreedyFirstLine()
+ {
+ var result = AutomaticCutOffStripSearch.Solve(0,
+ new[] { new[] { 20.0, 35.0 }, new[] { 40.0, 55.0, 70.0 } },
+ new[] { 35.0, 70.0 }, 35, 17.5, x => (x == 20 || x == 70 ? 0 : x == 35 ? 1 : 100, 1));
+ Assert.Equal(new[] { 35.0, 70.0 }, result);
+ }
+
+ [Fact]
+ public void LastTieBreak_IsLexicographicOverTheWholePrefix_NotTheLastPosition()
+ {
+ // Both paths are equally far from the nominal list, length and pierces are equal.
+ var result = AutomaticCutOffStripSearch.Solve(0,
+ new[] { new[] { 20.0, 25.0 }, new[] { 45.0, 50.0 } },
+ new[] { 22.5, 47.5 }, 35, 17.5, _ => (10, 1));
+ Assert.Equal(new[] { 20.0, 45.0 }, result);
+ }
+
+ [Fact]
+ public void SeededFiniteCandidateSearch_EqualsIndependentExhaustiveOracle()
+ {
+ var random = new Random(93512);
+ for (var sample = 0; sample < 200; sample++)
+ {
+ var rows = new[] { new[] { 18.0, 23, 29, 35 }, new[] { 38.0, 47, 56, 62, 70 }, new[] { 60.0, 74, 81, 91, 105 } };
+ var costs = rows.SelectMany(row => row).Distinct().ToDictionary(x => x,
+ _ => (Length: random.Next(0, 4) + random.Next(0, 1_000_000) / 1_000_000d,
+ SegmentCount: random.Next(0, 3)));
+ var nominal = new[] { 35.0, 70, 105 };
+ var paths = new List();
+ foreach (var first in rows[0])
+ foreach (var second in rows[1])
+ foreach (var third in rows[2])
+ if (second - first >= 17.5 && second - first <= 35 && third - second >= 17.5 && third - second <= 35)
+ paths.Add(new[] { first, second, third });
+ var expected = paths.OrderBy(path => path.Sum(x => (decimal)System.Math.Round(costs[x].Length, 6)))
+ .ThenBy(path => path.Sum(x => costs[x].SegmentCount))
+ .ThenBy(path => Enumerable.Range(0, 3).Sum(i => System.Math.Abs(nominal[i] - path[i])))
+ .ThenBy(path => path[0]).ThenBy(path => path[1]).ThenBy(path => path[2]).First();
+ var actual = AutomaticCutOffStripSearch.Solve(0, rows, nominal, 35, 17.5, x => costs[x]);
+ Assert.Equal(expected, actual);
+ }
+ }
+}
diff --git a/OpenNest.Tests/CutOffs/CutOffMeasurementTests.cs b/OpenNest.Tests/CutOffs/CutOffMeasurementTests.cs
new file mode 100644
index 0000000..3b9db02
--- /dev/null
+++ b/OpenNest.Tests/CutOffs/CutOffMeasurementTests.cs
@@ -0,0 +1,65 @@
+using System.Reflection;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CutOffs;
+
+public class CutOffMeasurementTests
+{
+ [Theory]
+ [InlineData(1, false, false)]
+ [InlineData(2, false, false)]
+ [InlineData(3, false, false)]
+ [InlineData(4, false, false)]
+ [InlineData(1, true, false)]
+ [InlineData(2, true, false)]
+ [InlineData(3, true, false)]
+ [InlineData(4, true, false)]
+ [InlineData(1, false, true)]
+ [InlineData(2, false, true)]
+ [InlineData(3, false, true)]
+ [InlineData(4, false, true)]
+ public void Measurement_MatchesRegeneratedSegmentsWithoutMutatingDrawing(int quadrant, bool limited, bool excluded)
+ {
+ var plate = new Plate(60, 120) { Quadrant = quadrant };
+ var negativeX = quadrant is 2 or 3;
+ var negativeY = quadrant is 3 or 4;
+ var program = new Program();
+ program.Codes.Add(new RapidMove(0, 0));
+ program.Codes.Add(new LinearMove(20, 0));
+ program.Codes.Add(new LinearMove(20, 20));
+ program.Codes.Add(new LinearMove(0, 20));
+ program.Codes.Add(new LinearMove(0, 0));
+ plate.Parts.Add(new Part(new Drawing("measurement rectangle", program),
+ new Vector(negativeX ? -40 : 20, negativeY ? -40 : 20)));
+ var bounds = plate.BoundingBox(false);
+ var cut = new CutOff(new Vector(negativeX ? -30 : 30, 0), CutOffAxis.Vertical);
+ var settings = new CutOffSettings
+ {
+ PartClearance = excluded ? 100 : 0.5,
+ Overtravel = 2,
+ MinSegmentLength = limited ? 21 : 0,
+ CutDirection = quadrant % 2 == 0 ? CutDirection.TowardOrigin : CutDirection.AwayFromOrigin,
+ };
+ if (limited)
+ {
+ cut.StartLimit = bounds.Bottom + 5;
+ cut.EndLimit = bounds.Top - 5;
+ }
+ var cache = Plate.BuildPerimeterCache(plate);
+ var original = cut.Drawing.Program;
+ // Reflection keeps the first run behavioral-red on the unchanged production tree.
+ var method = typeof(CutOff).GetMethod("MeasureSegments", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(method);
+ var measured = Assert.IsType>(method.Invoke(cut, new object[] { plate, settings, cache }));
+ Assert.Same(original, cut.Drawing.Program);
+ cut.Regenerate(plate, settings, cache);
+ var length = Enumerable.Range(0, cut.Drawing.Program.Codes.Count / 2).Sum(i =>
+ System.Math.Abs(Assert.IsType(cut.Drawing.Program.Codes[2 * i + 1]).EndPoint.Y -
+ Assert.IsType(cut.Drawing.Program.Codes[2 * i]).EndPoint.Y));
+ Assert.Equal(length, measured.Item1, 10);
+ Assert.Equal(cut.Drawing.Program.Codes.Count / 2, measured.Item2);
+ if (excluded)
+ Assert.Equal((0.0, 0), measured);
+ }
+}
diff --git a/OpenNest.Tests/CutOffs/CutOffSeparationTests.cs b/OpenNest.Tests/CutOffs/CutOffSeparationTests.cs
new file mode 100644
index 0000000..a98643c
--- /dev/null
+++ b/OpenNest.Tests/CutOffs/CutOffSeparationTests.cs
@@ -0,0 +1,65 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CutOffs;
+
+/// Which cut-offs split the whole sheet (and so must be cut after every part).
+public class CutOffSeparationTests
+{
+ [Theory]
+ [InlineData(1, CutDirection.AwayFromOrigin)]
+ [InlineData(2, CutDirection.TowardOrigin)]
+ [InlineData(3, CutDirection.AwayFromOrigin)]
+ [InlineData(4, CutDirection.TowardOrigin)]
+ public void ContinuousFullSpanLine_SeparatesTheSheet_InEveryQuadrantAndDirection(int quadrant, CutDirection direction)
+ {
+ var plate = new Plate(60, 120) { Quadrant = quadrant };
+ var bounds = plate.BoundingBox(includeParts: false);
+ plate.Parts.Add(TestHelpers.MakePartAt(bounds.Left + 10, bounds.Bottom + 10, 10));
+ var vertical = new CutOff(new Vector(bounds.Left + 40, 0), CutOffAxis.Vertical);
+ var horizontal = new CutOff(new Vector(0, bounds.Bottom + 40), CutOffAxis.Horizontal);
+ var crossing = new CutOff(new Vector(bounds.Left + 15, 0), CutOffAxis.Vertical);
+ plate.CutOffs.AddRange([vertical, horizontal, crossing]);
+ plate.RegenerateCutOffs(new CutOffSettings { CutDirection = direction, Overtravel = 0.5 });
+
+ Assert.True(Separates(plate, vertical));
+ Assert.True(Separates(plate, horizontal));
+ Assert.False(Separates(plate, crossing)); // Trimmed around the part: two pieces.
+ }
+
+ [Fact]
+ public void LimitedEdgeStaleOrBrokenLines_DoNotSeparateTheSheet()
+ {
+ var plate = new Plate(60, 120);
+ plate.Parts.Add(TestHelpers.MakePartAt(10, 10, 10));
+ var limited = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { EndLimit = 30 };
+ var onEdge = new CutOff(new Vector(0, 0), CutOffAxis.Vertical);
+ var stale = new CutOff(new Vector(50, 0), CutOffAxis.Vertical);
+ var split = new CutOff(new Vector(70, 0), CutOffAxis.Vertical);
+ plate.CutOffs.AddRange([limited, onEdge, stale, split]);
+ plate.RegenerateCutOffs(new CutOffSettings());
+ Assert.True(Separates(plate, stale));
+ Assert.True(Separates(plate, split));
+
+ stale.Position = new Vector(51, 0); // Moved without regeneration: its program is elsewhere.
+ var program = new Program();
+ program.Codes.Add(new RapidMove(70, 0));
+ program.Codes.Add(new LinearMove(70, 30));
+ program.Codes.Add(new RapidMove(70, 30.5));
+ program.Codes.Add(new LinearMove(70, 60));
+ split.Drawing.Program = program;
+ var broken = new Part(split.Drawing);
+
+ Assert.False(Separates(plate, limited));
+ Assert.False(Separates(plate, onEdge));
+ Assert.False(Separates(plate, stale));
+ Assert.False(CutOff.SeparatesSheet(broken, split, plate.BoundingBox(false))); // Two pierces leave a gap.
+ Assert.False(CutOff.SeparatesSheet(Part(plate, limited), null, plate.BoundingBox(false)));
+ }
+
+ private static bool Separates(Plate plate, CutOff cutOff) =>
+ CutOff.SeparatesSheet(Part(plate, cutOff), cutOff, plate.BoundingBox(includeParts: false));
+
+ private static Part Part(Plate plate, CutOff cutOff) =>
+ Assert.Single(plate.Parts, p => ReferenceEquals(p.BaseDrawing, cutOff.Drawing));
+}
diff --git a/OpenNest.Tests/CutOffs/PlannerCutOffStagingTests.cs b/OpenNest.Tests/CutOffs/PlannerCutOffStagingTests.cs
new file mode 100644
index 0000000..8ec5bd8
--- /dev/null
+++ b/OpenNest.Tests/CutOffs/PlannerCutOffStagingTests.cs
@@ -0,0 +1,190 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+using OpenNest.Tests.CuttingPlanning;
+
+namespace OpenNest.Tests.CutOffs;
+
+/// The cutting planner's existing-work banner, its Clear button and staged automatic cut-offs.
+public class PlannerCutOffStagingTests
+{
+ private static readonly AutomaticCutOffOptions Options = new() { Spacing = 35 };
+
+ [Fact]
+ public void Summarize_CountsAppliedLeadInsLockedPartsAndCutOffs_ButNotStagedOnes()
+ {
+ var nest = new Nest();
+ var first = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1), Clean("c", 23, 1));
+ var second = Plate(nest, Clean("d", 1, 1));
+ var third = Plate(nest, Clean("e", 1, 1));
+ Lead(first.Parts[0], locked: true);
+ Lead(first.Parts[2], locked: false);
+ var manual = new CutOff(new Vector(60, 0), CutOffAxis.Vertical);
+ var staged = new CutOff(new Vector(80, 0), CutOffAxis.Vertical);
+ third.CutOffs.Add(manual);
+ third.CutOffs.Add(staged);
+ third.RegenerateCutOffs(new CutOffSettings());
+
+ var summary = AppliedCuttingWork.Summarize([first, second, third], [4, 5, 6], [staged]);
+
+ Assert.Equal(2, summary.LeadInParts);
+ Assert.Equal(1, summary.LockedLeadInParts);
+ Assert.Equal(1, summary.CutOffs); // Cut-off parts are not lead-in parts; the staged line is ignored.
+ Assert.Equal([4, 6], summary.PlateNumbers);
+ Assert.True(summary.HasExistingWork);
+ Assert.Equal("ALREADY APPLIED: lead-ins on 2 parts (1 locked) and 1 cut-off on plates 4, 6."
+ + " Planning recalculates unlocked lead-ins. Planning keeps locked lead-ins and existing cut-offs unchanged."
+ + " Clear them to recalculate everything from scratch.", summary.Describe());
+
+ var clean = AppliedCuttingWork.Summarize([second], null, null);
+ Assert.False(clean.HasExistingWork);
+ Assert.Equal(string.Empty, clean.Describe());
+ Assert.False(AppliedCuttingWork.Summarize([third], null, third.CutOffs).HasExistingWork);
+ }
+
+ [Fact]
+ public void Clear_RemovesLeadInsAndCutOffs_KeepsPosesOrderAndQuantities()
+ {
+ var nest = new Nest();
+ var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1), Clean("c", 40, 1));
+ var parameters = ExplicitContourTests.Parameters();
+ Assert.Equal(CuttingCommitStatus.Applied,
+ CuttingPlanBatch.Capture([plate], parameters, preservePartOrder: true).Plan().Apply().Status);
+ plate.Parts[1].LeadInsLocked = true;
+ plate.CutOffs.Add(new CutOff(new Vector(30, 0), CutOffAxis.Vertical));
+ plate.RegenerateCutOffs(new CutOffSettings());
+ var real = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray();
+ var poses = real.Select(p => (p.Location, p.Rotation)).ToArray();
+ var quantities = real.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
+ Assert.All(real, p => Assert.True(p.HasManualLeadIns));
+ Assert.NotNull(plate.CuttingParameters);
+
+ var cleared = AppliedCuttingWork.Clear([plate], [3]);
+
+ Assert.Equal((3, 1, 1), (cleared.LeadInParts, cleared.LockedLeadInParts, cleared.CutOffs));
+ Assert.Equal([3], cleared.PlateNumbers);
+ Assert.Equal(real, plate.Parts.ToArray());
+ Assert.Empty(plate.CutOffs);
+ Assert.Null(plate.CuttingParameters);
+ Assert.All(real, p => Assert.False(p.HasManualLeadIns || p.LeadInsLocked));
+ Assert.All(real, p => Assert.True(ProgramContent.Equal(p.BaseDrawing.Program, p.Program)));
+ Assert.Equal(poses, real.Select(p => (p.Location, p.Rotation)));
+ Assert.Equal(quantities, real.Select(p => p.BaseDrawing.Quantity.Nested));
+ Assert.False(AppliedCuttingWork.Summarize([plate]).HasExistingWork);
+ }
+
+ [Fact]
+ public void Stage_Revert_RestoresTheEarlierCutOffsAndSequenceExactly()
+ {
+ var nest = new Nest();
+ var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 40, 1));
+ var settings = new CutOffSettings();
+ var manual = new CutOff(new Vector(75, 0), CutOffAxis.Vertical);
+ plate.CutOffs.Add(manual);
+ plate.RegenerateCutOffs(settings);
+ var manualPart = plate.Parts[^1];
+ plate.Parts.Remove(manualPart);
+ plate.Parts.Insert(0, manualPart);
+ var before = plate.Parts.ToArray();
+ var manualProgram = manual.Drawing.Program;
+
+ var staging = AutomaticCutOffBatch.Stage([plate], Options, settings);
+
+ Assert.False(staging.IsBlocked);
+ Assert.True(staging.CanRevert);
+ Assert.NotEmpty(staging.Definitions);
+ Assert.Equal(1 + staging.Definitions.Count, plate.CutOffs.Count);
+ Assert.Equal(plate.CutOffs.Count, plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
+
+ staging.Revert();
+
+ Assert.False(staging.CanRevert);
+ Assert.Same(manual, Assert.Single(plate.CutOffs));
+ Assert.Same(manualProgram, manual.Drawing.Program);
+ Assert.Equal(before, plate.Parts.ToArray());
+ staging.Revert(); // Runs once.
+ Assert.Equal(before, plate.Parts.ToArray());
+ }
+
+ [Fact]
+ public void Stage_Keep_LeavesTheCutOffsInPlace_AndABlockedBatchChangesNothing()
+ {
+ var nest = new Nest();
+ var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 40, 1));
+ var settings = new CutOffSettings();
+ var staging = AutomaticCutOffBatch.Stage([plate], Options, settings);
+ var staged = plate.CutOffs.ToArray();
+
+ staging.Keep();
+ staging.Revert();
+
+ Assert.False(staging.CanRevert);
+ Assert.Equal(staged, plate.CutOffs.ToArray());
+ Assert.NotEmpty(staged);
+
+ var other = Plate(nest, Clean("c", 1, 1), Clean("d", 40, 1));
+ var limited = new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 };
+ other.CutOffs.Add(limited);
+ var parts = other.Parts.ToArray();
+ var blocked = AutomaticCutOffBatch.Stage([other], Options, settings);
+
+ Assert.True(blocked.IsBlocked);
+ Assert.False(blocked.CanRevert);
+ Assert.Empty(blocked.Definitions);
+ Assert.Same(limited, Assert.Single(other.CutOffs));
+ Assert.Equal(parts, other.Parts.ToArray());
+ }
+
+ [Fact]
+ public void StagedCutOffs_AreSequencedByThePlannerBeforeThePartsTheyCross_AndSurviveApply()
+ {
+ var nest = new Nest();
+ var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 30, 1), Clean("c", 30, 40), Clean("d", 60, 20));
+ var settings = new CutOffSettings();
+ var staging = AutomaticCutOffBatch.Stage([plate], Options, settings);
+ Assert.NotEmpty(staging.Definitions);
+
+ // The planner judges crossings on the bounds it captured, before lead-ins widen them.
+ var captured = plate.Parts.ToDictionary(p => p, p => p.BoundingBox, ReferenceEqualityComparer.Instance);
+ var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: false).Plan();
+ Assert.True(proposal.CanApply);
+ Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
+ staging.Keep();
+
+ Assert.Equal(staging.Definitions, plate.CutOffs.ToArray());
+ var order = plate.Parts.ToList();
+ var bounds = plate.BoundingBox(includeParts: false);
+ var crossings = 0;
+ foreach (var definition in plate.CutOffs)
+ {
+ var cutIndex = order.FindIndex(p => ReferenceEquals(p.BaseDrawing, definition.Drawing));
+ Assert.True(cutIndex >= 0);
+ foreach (var part in order.Where(p => !p.BaseDrawing.IsCutOff))
+ {
+ Assert.True(part.HasManualLeadIns);
+ if (!CuttingDependencyGraph.CutOffCrosses(definition.Axis, definition.Position,
+ definition.StartLimit, definition.EndLimit, captured[part], bounds))
+ continue;
+ crossings++;
+ Assert.True(cutIndex < order.IndexOf(part)); // Planner rule: a crossing cut-off goes first.
+ }
+ }
+ Assert.True(crossings > 0); // The fixture must exercise cut-off precedence.
+ }
+
+ private static void Lead(Part part, bool locked) =>
+ Assert.True(part.RestoreLeadInProgram((Program)part.Program.Clone(), locked));
+
+ private static Plate Plate(Nest nest, params Part[] parts)
+ {
+ var plate = nest.CreatePlate();
+ plate.Size = new Size(100, 100);
+ foreach (var part in parts)
+ plate.Parts.Add(part);
+ return plate;
+ }
+
+ private static Part Clean(string name, double x, double y) =>
+ new(new Drawing(name, ExplicitContourTests.Square(false)), new Vector(x, y));
+}
diff --git a/OpenNest.Tests/CuttingPlanning/ClampZonePreviewTests.cs b/OpenNest.Tests/CuttingPlanning/ClampZonePreviewTests.cs
new file mode 100644
index 0000000..26c1de6
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ClampZonePreviewTests.cs
@@ -0,0 +1,22 @@
+using OpenNest.Engine.CuttingPlanning;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+/// Machine clamps stay visible on a cutting-plan preview of a clamped plate.
+public class ClampZonePreviewTests
+{
+ [Fact]
+ public void PreviewCarriesThePlatesClampZones()
+ {
+ var plate = BestEffortCuttingPlanTests.Plate(BestEffortCuttingPlanTests.TouchingContours());
+ plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 21 }));
+
+ var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
+ var preview = proposal.BuildPreview(0);
+
+ Assert.NotNull(preview);
+ Assert.Equal(plate.ClampZones, preview.ClampZones); // same immutable zones, same order
+ Assert.Equal(2, preview.ClampZones.Count);
+ Assert.Null(preview.ClampRail); // a detached display copy never re-resolves
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs
index 64270a7..7d4e845 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs
@@ -39,6 +39,67 @@ public class CuttingDependencyTests
Assert.Contains(q, plate.Parts);
}
+ [Fact]
+ public void Plan_SheetSeparatingCutOff_IsCutAfterEveryPartAndOtherCutOff()
+ {
+ var parameters = ExplicitContourTests.Parameters();
+ var (plate, separator, crossing, left, right) = SeparatedPlate();
+ var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters));
+ var separatorIndex = plate.Parts.IndexOf(separator);
+ Assert.Equal(Enumerable.Range(0, plate.Parts.Count).Where(i => i != separatorIndex),
+ snapshot.Dependencies.PrerequisitesOf(separatorIndex));
+
+ var result = CuttingPlanService.Plan(snapshot);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var order = result.ProposedOrder.Select(o => o.SourcePart).ToList();
+ Assert.Same(separator, order[^1]);
+ Assert.True(result.ProposedOrder[^1].SeparatesSheet);
+ Assert.False(result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, crossing)).SeparatesSheet);
+ Assert.True(order.IndexOf(crossing) < order.IndexOf(left));
+ Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
+ Assert.Same(separator, plate.Parts[^1]);
+ Assert.Contains(right, plate.Parts);
+ }
+
+ [Fact]
+ public void Plan_KeptOrderCuttingTheSeparatorFirst_IsAConstraintConflictThatSaysWhy()
+ {
+ var (plate, separator, _, _, _) = SeparatedPlate();
+ Assert.Same(separator, plate.Parts[0]);
+
+ var result = CuttingPlanService.Plan(CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
+ confirmedParameters: ExplicitContourTests.Parameters(), preservePartOrder: true)));
+
+ Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
+ Assert.Contains(result.Findings, f => f.Message.Contains("sheet-separating cutoff")
+ && f.Message.Contains("before the sheet is separated"));
+ }
+
+ // 100 x 100 sheet: squares at X 1-11 and 60-70; a cutoff at X 5 is trimmed around the left
+ // square, while a continuous line at X 40 splits the sheet. Both cutoffs start the sequence.
+ private static (Plate, Part Separator, Part Crossing, Part Left, Part Right) SeparatedPlate()
+ {
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ plate.Size = new Size(100, 100);
+ var left = new Part(new Drawing("left", ExplicitContourTests.Square(false)), new Vector(1, 1));
+ var right = new Part(new Drawing("right", ExplicitContourTests.Square(false)), new Vector(60, 1));
+ plate.Parts.Add(left);
+ plate.Parts.Add(right);
+ var separator = new CutOff(new Vector(40, 0), CutOffAxis.Vertical);
+ var crossing = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
+ plate.CutOffs.Add(separator);
+ plate.CutOffs.Add(crossing);
+ plate.RegenerateCutOffs(new CutOffSettings());
+ var cuts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToArray();
+ Assert.Equal(2, cuts.Length);
+ var separatorPart = cuts.Single(p => ReferenceEquals(p.BaseDrawing, separator.Drawing));
+ var crossingPart = cuts.Single(p => ReferenceEquals(p.BaseDrawing, crossing.Drawing));
+ plate.Parts.Reorder([separatorPart, crossingPart, left, right]);
+ return (plate, separatorPart, crossingPart, left, right);
+ }
+
[Fact]
public void Apply_CutOffReclassifiedAfterPlanning_IsStale()
{
diff --git a/OpenNest.Tests/Data/ClampRailEditorTests.cs b/OpenNest.Tests/Data/ClampRailEditorTests.cs
new file mode 100644
index 0000000..d10e3ff
--- /dev/null
+++ b/OpenNest.Tests/Data/ClampRailEditorTests.cs
@@ -0,0 +1,214 @@
+using OpenNest.Data;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Data;
+
+///
+/// Decision logic of the desktop Clamps dialog: edits one nest's rail in its units, keeps
+/// "apply to this nest" (OK) separate from "save as machine defaults" (immediate write).
+///
+public class ClampRailEditorTests
+{
+ private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 };
+
+ [Fact]
+ public void NestWithoutARailStartsEmptyAtTheDefaultClampSize()
+ {
+ var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: false);
+
+ Assert.Equal(1.5, editor.Width);
+ Assert.Equal(0.625, editor.Depth);
+ Assert.Empty(editor.Centers);
+ Assert.Null(editor.BuildRail());
+
+ var metric = new ClampRailEditor(null, Units.Millimeters, applyToNewNests: false);
+ Assert.Equal(1.5 * 25.4, metric.Width, 9);
+ }
+
+ [Fact]
+ public void NestRailIsEditedInPlace()
+ {
+ var rail = new ClampRail(1.5, 0.625, MachineCenters);
+
+ var editor = new ClampRailEditor(rail, Units.Inches, applyToNewNests: true);
+
+ Assert.Equal(MachineCenters, editor.Centers);
+ Assert.Equal(rail, editor.BuildRail());
+ Assert.True(editor.ApplyToNewNests);
+ }
+
+ [Fact]
+ public void CentersStaySortedAndRefuseInvalidOrDuplicateValues()
+ {
+ var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: false);
+
+ Assert.True(editor.TryAddCenter(21, out _));
+ Assert.True(editor.TryAddCenter(6, out var none));
+ Assert.Null(none);
+ Assert.False(editor.TryAddCenter(21, out var duplicate));
+ Assert.Contains("already", duplicate);
+ Assert.False(editor.TryAddCenter(-1, out var negative));
+ Assert.Contains("origin", negative);
+ Assert.False(editor.TryAddCenter(double.NaN, out _));
+ Assert.Equal(new[] { 6.0, 21 }, editor.Centers);
+
+ editor.RemoveCenterAt(0);
+ editor.RemoveCenterAt(5); // out of range: ignored
+ Assert.Equal(new[] { 21.0 }, editor.Centers);
+ }
+
+ [Fact]
+ public void ApplyingToTheNestReResolvesEveryPlate()
+ {
+ var nest = new Nest();
+ nest.PlateDefaults.Size = new Size(48, 120);
+ var full = nest.CreatePlate();
+ var shorter = nest.CreatePlate();
+ shorter.Size = new Size(48, 60);
+ var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: false);
+ foreach (var center in MachineCenters)
+ Assert.True(editor.TryAddCenter(center, out _));
+
+ editor.ApplyTo(nest);
+
+ Assert.Equal(8, full.ClampZones.Count);
+ Assert.Equal(4, shorter.ClampZones.Count);
+ Assert.Equal(8, nest.CreatePlate().ClampZones.Count);
+
+ // Removing every clamp and applying removes them from the nest.
+ while (editor.Centers.Count > 0)
+ editor.RemoveCenterAt(0);
+ editor.ApplyTo(nest);
+ Assert.Null(nest.PlateDefaults.ClampRail);
+ Assert.All(nest.Plates, p => Assert.Empty(p.ClampZones));
+ }
+
+ [Fact]
+ public void InvalidClampSizeIsRefusedBeforeApplyingOrSaving()
+ {
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: true);
+ editor.TryAddCenter(6, out _);
+ editor.Width = 0;
+
+ Assert.Throws(() => editor.ApplyTo(nest));
+ Assert.Throws(() => editor.ToMachineDefaults());
+ Assert.Empty(plate.ClampZones);
+ Assert.Null(nest.PlateDefaults.ClampRail);
+ }
+
+ [Fact]
+ public void MachineDefaultsCaptureTheEditedValuesInTheNestsUnits()
+ {
+ var editor = new ClampRailEditor(null, Units.Millimeters, applyToNewNests: true);
+ editor.Width = 40;
+ editor.Depth = 16;
+ editor.TryAddCenter(150, out _);
+
+ var settings = editor.ToMachineDefaults();
+
+ Assert.True(settings.Enabled);
+ Assert.Equal(Units.Millimeters, settings.Units);
+ Assert.Equal(40, settings.Width);
+ Assert.Equal(16, settings.Depth);
+ Assert.Equal(new[] { 150.0 }, settings.Centers);
+
+ // Without any clamp listed, saved defaults never enable clamps for new nests.
+ editor.RemoveCenterAt(0);
+ Assert.False(editor.ToMachineDefaults().Enabled);
+ editor.ApplyToNewNests = false;
+ editor.TryAddCenter(150, out _);
+ Assert.False(editor.ToMachineDefaults().Enabled);
+ }
+
+ [Fact]
+ public void LoadingMachineDefaultsOnlyFillsTheEditorInTheNestsUnits()
+ {
+ var nest = new Nest { Units = Units.Millimeters };
+ var plate = nest.CreatePlate();
+ var defaults = new ClampSettings
+ {
+ Enabled = true,
+ Units = Units.Inches,
+ Width = 1.5,
+ Depth = 0.625,
+ Centers = new[] { 21.0, 6 },
+ };
+ var editor = new ClampRailEditor(null, Units.Millimeters, applyToNewNests: false);
+
+ editor.LoadMachineDefaults(defaults);
+
+ Assert.Equal(1.5 * 25.4, editor.Width, 9);
+ Assert.Equal(0.625 * 25.4, editor.Depth, 9);
+ Assert.Equal(new[] { 6 * 25.4, 21 * 25.4 }, editor.Centers);
+ Assert.True(editor.ApplyToNewNests);
+ Assert.Empty(plate.ClampZones); // nothing applied until OK
+ Assert.Null(nest.PlateDefaults.ClampRail);
+
+ // Disabled defaults still load their values but do not ask to enable new nests.
+ defaults.Enabled = false;
+ editor.LoadMachineDefaults(defaults);
+ Assert.False(editor.ApplyToNewNests);
+ Assert.Equal(2, editor.Centers.Count);
+ }
+
+ [Fact]
+ public void ConvertedSettingsKeepEnabledAndScaleEveryLength()
+ {
+ var inches = new ClampSettings { Enabled = true, Width = 1.5, Depth = 0.625, Centers = new[] { 6.0 } };
+
+ var metric = inches.ConvertedTo(Units.Millimeters);
+
+ Assert.True(metric.Enabled);
+ Assert.Equal(Units.Millimeters, metric.Units);
+ Assert.Equal(38.1, metric.Width, 9);
+ Assert.Equal(15.875, metric.Depth, 9);
+ Assert.Equal(152.4, metric.Centers[0], 9);
+ Assert.Equal(1.5, inches.Width); // the source is not modified
+ Assert.Equal(1.5, inches.ConvertedTo(Units.Inches).Width);
+ }
+
+ // D9: the editor carries removal minutes through the same three boundaries as size.
+
+ [Fact]
+ public void RemovalMinutesStartsFromTheRailOrMachineDefault()
+ {
+ var rail = new ClampRail(1.5, 0.625, MachineCenters, removalMinutes: 7);
+ Assert.Equal(7, new ClampRailEditor(rail, Units.Inches, false).RemovalMinutes);
+ Assert.Equal(ClampRail.DefaultRemovalMinutes,
+ new ClampRailEditor(null, Units.Inches, false).RemovalMinutes);
+ }
+
+ [Fact]
+ public void RemovalMinutesIsNeverConvertedBetweenUnitsOnLoadOrBuild()
+ {
+ var editor = new ClampRailEditor(null, Units.Millimeters, applyToNewNests: true);
+ editor.RemovalMinutes = 8;
+ editor.TryAddCenter(150, out _);
+
+ Assert.Equal(8, editor.ToMachineDefaults().RemovalMinutes);
+ Assert.Equal(8, editor.BuildRail()!.RemovalMinutes);
+
+ var defaults = new ClampSettings
+ {
+ Enabled = true,
+ Units = Units.Inches,
+ RemovalMinutes = 9,
+ Centers = new[] { 6.0 },
+ };
+ editor.LoadMachineDefaults(defaults);
+ Assert.Equal(9, editor.RemovalMinutes); // a time, so unaffected by the unit change
+ }
+
+ [Fact]
+ public void InvalidRemovalMinutesIsRefusedBeforeSaving()
+ {
+ var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: true);
+ editor.TryAddCenter(6, out _);
+ editor.RemovalMinutes = -1;
+
+ Assert.Throws(() => editor.ToMachineDefaults());
+ Assert.Throws(() => editor.BuildRail());
+ }
+}
diff --git a/OpenNest.Tests/Data/ClampSettingsTests.cs b/OpenNest.Tests/Data/ClampSettingsTests.cs
new file mode 100644
index 0000000..5de2448
--- /dev/null
+++ b/OpenNest.Tests/Data/ClampSettingsTests.cs
@@ -0,0 +1,408 @@
+using System.Text.Json;
+using OpenNest.Data;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Data;
+
+///
+/// The machine clamp settings store (%APPDATA%\OpenNest\clamps.json): off by default, a
+/// never-throwing read that degrades value by value, and no effect until enabled.
+///
+public class ClampSettingsTests : IDisposable
+{
+ private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 };
+
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
+ private readonly string _path;
+
+ public ClampSettingsTests()
+ {
+ Directory.CreateDirectory(_directory);
+ _path = Path.Combine(_directory, "clamps.json");
+ }
+
+ public void Dispose()
+ {
+ if (!Directory.Exists(_directory))
+ return;
+ if (!OperatingSystem.IsWindows() && File.Exists(_path))
+ File.SetUnixFileMode(_path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ Directory.Delete(_directory, recursive: true);
+ }
+
+ private static ClampSettings Machine(bool enabled = true) => new()
+ {
+ Enabled = enabled,
+ Width = 1.5,
+ Depth = 0.625,
+ Centers = MachineCenters,
+ };
+
+ private static void AssertFallback(ClampSettings settings)
+ {
+ Assert.False(settings.Enabled);
+ Assert.Equal(Units.Inches, settings.Units);
+ Assert.Equal(1.5, settings.Width);
+ Assert.Equal(0.625, settings.Depth);
+ Assert.Empty(settings.Centers);
+ Assert.Null(settings.RailFor(Units.Inches));
+ }
+
+ [Fact]
+ public void DefaultPathUsesTheApplicationDataOpenNestDirectory()
+ {
+ Assert.Equal(
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenNest", "clamps.json"),
+ ClampSettings.DefaultPath);
+ }
+
+ [Fact]
+ public void MissingFileKeepsClampsOff()
+ {
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Missing, status);
+ AssertFallback(loaded);
+ Assert.Equal(ClampSettingsStatus.Missing, LoadStatus(""));
+ }
+
+ [Fact]
+ public void SaveThenLoadRoundTripsAsCamelCaseJson()
+ {
+ Machine().Save(_path);
+
+ var loaded = ClampSettings.Load(_path, out var status);
+ using var json = JsonDocument.Parse(File.ReadAllText(_path));
+
+ Assert.Equal(ClampSettingsStatus.Ok, status);
+ Assert.True(loaded.Enabled);
+ Assert.Equal(Units.Inches, loaded.Units);
+ Assert.Equal(1.5, loaded.Width);
+ Assert.Equal(0.625, loaded.Depth);
+ Assert.Equal(MachineCenters, loaded.Centers);
+ var root = json.RootElement;
+ Assert.Equal(1, root.GetProperty("version").GetInt32());
+ Assert.True(root.GetProperty("enabled").GetBoolean());
+ Assert.Equal("inches", root.GetProperty("units").GetString());
+ Assert.Equal(8, root.GetProperty("centers").GetArrayLength());
+ }
+
+ [Fact]
+ public void SaveCreatesTheSettingsDirectory()
+ {
+ var nested = Path.Combine(_directory, "AppData", "OpenNest", "clamps.json");
+
+ Machine().Save(nested);
+
+ Assert.True(ClampSettings.Load(nested).Enabled);
+ }
+
+ [Fact]
+ public void NothingChangesUntilEnabled()
+ {
+ var nest = new Nest();
+ Machine(enabled: false).ApplyToNewNest(nest);
+
+ Assert.Null(Machine(enabled: false).RailFor(Units.Inches));
+ Assert.Null(nest.PlateDefaults.ClampRail);
+ Assert.Empty(nest.CreatePlate().ClampZones);
+
+ // Enabled without any clamp center is still no clamps.
+ var empty = Machine();
+ empty.Centers = Array.Empty();
+ Assert.Null(empty.RailFor(Units.Inches));
+ }
+
+ [Fact]
+ public void EnabledSettingsGiveANewNestTheMachineRail()
+ {
+ var nest = new Nest { Units = Units.Inches };
+ nest.PlateDefaults.Size = new Size(48, 60);
+
+ Machine().ApplyToNewNest(nest);
+ var plate = nest.CreatePlate();
+
+ Assert.Equal(new ClampRail(1.5, 0.625, MachineCenters), nest.PlateDefaults.ClampRail);
+ Assert.Equal(new[] { 5.25, 20.25, 35.25, 50.25 }, plate.ClampZones.Select(z => z.Bounds.X));
+ }
+
+ [Fact]
+ public void RailIsConvertedToTheNestsUnits()
+ {
+ var metric = Machine().RailFor(Units.Millimeters)!;
+ Assert.Equal(1.5 * 25.4, metric.Width, 12);
+ Assert.Equal(0.625 * 25.4, metric.Depth, 12);
+ Assert.Equal(6 * 25.4, metric.Centers[0], 12);
+
+ var settings = new ClampSettings
+ {
+ Enabled = true,
+ Units = Units.Millimeters,
+ Width = 38.1,
+ Depth = 15.875,
+ Centers = new[] { 152.4 },
+ };
+ var inches = settings.RailFor(Units.Inches)!;
+ Assert.Equal(1.5, inches.Width, 12);
+ Assert.Equal(6, inches.Centers[0], 12);
+ Assert.Equal(38.1, settings.RailFor(Units.Millimeters)!.Width);
+ }
+
+ [Fact]
+ public void PartialFileMergesOverTheDefaults()
+ {
+ File.WriteAllText(_path, """{ "enabled": true, "centers": [21, 6] }""");
+
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Ok, status);
+ Assert.True(loaded.Enabled);
+ Assert.Equal(1.5, loaded.Width);
+ Assert.Equal(0.625, loaded.Depth);
+ Assert.Equal(new[] { 6.0, 21 }, loaded.Centers);
+ }
+
+ [Fact]
+ public void InvalidValuesFallBackIndividuallyAndAreReported()
+ {
+ File.WriteAllText(_path, """
+ {
+ "enabled": "yes",
+ "units": "7",
+ "width": -1,
+ "depth": "deep",
+ "centers": [6, "x", -3, 21, 1e400, 21],
+ "future": { "ignored": true }
+ }
+ """);
+
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Partial, status);
+ Assert.False(loaded.Enabled);
+ Assert.Equal(Units.Inches, loaded.Units);
+ Assert.Equal(1.5, loaded.Width);
+ Assert.Equal(0.625, loaded.Depth);
+ Assert.Equal(new[] { 6.0, 21 }, loaded.Centers);
+ }
+
+ [Theory]
+ [InlineData("""{ "centers": 6 }""")]
+ [InlineData("""{ "centers": [6, -1] }""")]
+ [InlineData("""{ "centers": [6, "21"] }""")]
+ [InlineData("""{ "units": ["inches"] }""")]
+ [InlineData("""{ "enabled": 1 }""")]
+ [InlineData("""{ "width": 0 }""")]
+ public void AnySingleInvalidValueIsReported(string json)
+ {
+ File.WriteAllText(_path, json);
+ Assert.Equal(ClampSettingsStatus.Partial, LoadStatus(_path));
+ }
+
+ [Theory]
+ [InlineData("enabled")]
+ [InlineData("units")]
+ [InlineData("width")]
+ [InlineData("depth")]
+ [InlineData("centers")]
+ public void ExplicitNullIsAnIgnoredValueAndIsReported(string field)
+ {
+ File.WriteAllText(_path, $$"""{ "enabled": true, "centers": [6], "{{field}}": null }""");
+
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Partial, status);
+ Assert.Equal(1.5, loaded.Width);
+ Assert.Equal(0.625, loaded.Depth);
+ }
+
+ [Fact]
+ public void MetricFileWithoutSizesGetsThePhysicalDefaultClamp()
+ {
+ File.WriteAllText(_path, """{ "enabled": true, "units": "millimeters", "centers": [152.4] }""");
+
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Ok, status);
+ Assert.Equal(1.5 * 25.4, loaded.Width, 9);
+ Assert.Equal(0.625 * 25.4, loaded.Depth, 9);
+ Assert.Equal(1.5, loaded.RailFor(Units.Inches)!.Width, 9);
+ }
+
+ [Fact]
+ public void MetricFileWithAnInvalidSizeFallsBackToThePhysicalDefault()
+ {
+ File.WriteAllText(_path, """{ "enabled": true, "units": "millimeters", "width": -1, "depth": 15, "centers": [152.4] }""");
+
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Partial, status);
+ Assert.Equal(38.1, loaded.Width, 9);
+ Assert.Equal(15, loaded.Depth);
+ }
+
+ [Theory]
+ [InlineData("""{ "enabled": true, "width": 1e308, "centers": [6] }""")]
+ [InlineData("""{ "enabled": true, "depth": 1e308, "centers": [6] }""")]
+ [InlineData("""{ "enabled": true, "centers": [6, 1e308] }""")]
+ public void ValuesThatCannotConvertBetweenUnitsAreIgnored(string json)
+ {
+ File.WriteAllText(_path, json);
+
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Partial, status);
+ var nest = new Nest { Units = Units.Millimeters };
+ loaded.ApplyToNewNest(nest); // must not throw while creating a nest
+ Assert.Equal(new[] { 6 * 25.4 }, nest.PlateDefaults.ClampRail!.Centers);
+ }
+
+ [Fact]
+ public void AnInvalidCenterIsDroppedButTheRestOfTheRailStillLoads()
+ {
+ File.WriteAllText(_path, """{ "enabled": true, "centers": [6, "21", 36] }""");
+
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ // The operator must be warned: the rail is now missing a physical clamp.
+ Assert.Equal(ClampSettingsStatus.Partial, status);
+ Assert.True(loaded.Enabled);
+ Assert.Equal(new[] { 6.0, 36 }, loaded.Centers);
+ }
+
+ [Theory]
+ [InlineData("{ this is not json")]
+ [InlineData("[6, 21, 36]")]
+ [InlineData("null")]
+ [InlineData("")]
+ public void UnparseableFileKeepsClampsOffAndIsReported(string json)
+ {
+ File.WriteAllText(_path, json);
+
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Invalid, status);
+ AssertFallback(loaded);
+ }
+
+ [SkippableFact]
+ public void UnreadableFileKeepsClampsOffAndIsReported()
+ {
+ if (OperatingSystem.IsWindows())
+ throw new SkipException("Unix file modes deny the read on Linux/macOS");
+
+ Machine().Save(_path);
+ File.SetUnixFileMode(_path, UnixFileMode.None);
+ Skip.If(CanRead(_path), "Process can read a mode-000 file (running as root)");
+
+ Assert.True(File.Exists(_path));
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Invalid, status);
+ AssertFallback(loaded);
+ }
+
+ [Fact]
+ public void FromRailCapturesANestRailForMachineDefaults()
+ {
+ var rail = new ClampRail(38.1, 15.875, new[] { 152.4, 533.4 });
+
+ var captured = ClampSettings.FromRail(rail, Units.Millimeters, enabled: true);
+
+ Assert.True(captured.Enabled);
+ Assert.Equal(Units.Millimeters, captured.Units);
+ Assert.Equal(38.1, captured.Width);
+ Assert.Equal(15.875, captured.Depth);
+ Assert.Equal(new[] { 152.4, 533.4 }, captured.Centers);
+ Assert.Equal(rail, captured.RailFor(Units.Millimeters));
+
+ var none = ClampSettings.FromRail(null, Units.Inches, enabled: false);
+ AssertFallback(none);
+ }
+
+ // D9: minutes to remove one clamp, stored alongside the rail's physical size.
+
+ [Fact]
+ public void RemovalMinutesDefaultsToThePlannerValue()
+ {
+ Assert.Equal(ClampRail.DefaultRemovalMinutes, new ClampSettings().RemovalMinutes);
+ Assert.Equal(ClampRail.DefaultRemovalMinutes, ClampSettings.Fallback.RemovalMinutes);
+ }
+
+ [Fact]
+ public void RemovalMinutesRoundTripsAndIsNeverScaledByUnits()
+ {
+ var settings = Machine();
+ settings.RemovalMinutes = 8;
+ settings.Save(_path);
+
+ var loaded = ClampSettings.Load(_path, out var status);
+ Assert.Equal(ClampSettingsStatus.Ok, status);
+ Assert.Equal(8, loaded.RemovalMinutes);
+ using var json = JsonDocument.Parse(File.ReadAllText(_path));
+ Assert.Equal(8, json.RootElement.GetProperty("removalMinutes").GetDouble());
+
+ // Width/depth/centers scale between units; removal time (not a length) never does.
+ var metric = loaded.ConvertedTo(Units.Millimeters);
+ Assert.Equal(8, metric.RemovalMinutes);
+ Assert.Equal(8, loaded.RailFor(Units.Millimeters)!.RemovalMinutes);
+ }
+
+ [Fact]
+ public void MissingRemovalMinutesDefaultsOnLoad()
+ {
+ File.WriteAllText(_path, """{ "enabled": true, "centers": [6] }""");
+
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Ok, status);
+ Assert.Equal(ClampRail.DefaultRemovalMinutes, loaded.RemovalMinutes);
+ }
+
+ [Theory]
+ [InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": -1 }""")]
+ [InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": "five" }""")]
+ [InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": null }""")]
+ public void InvalidRemovalMinutesFallsBackAndIsReported(string json)
+ {
+ File.WriteAllText(_path, json);
+
+ var loaded = ClampSettings.Load(_path, out var status);
+
+ Assert.Equal(ClampSettingsStatus.Partial, status);
+ Assert.Equal(ClampRail.DefaultRemovalMinutes, loaded.RemovalMinutes);
+ }
+
+ [Fact]
+ public void ZeroRemovalMinutesIsValid()
+ {
+ var settings = Machine();
+ settings.RemovalMinutes = 0;
+ settings.Save(_path);
+
+ var loaded = ClampSettings.Load(_path, out var status);
+ Assert.Equal(ClampSettingsStatus.Ok, status);
+ Assert.Equal(0, loaded.RemovalMinutes);
+ }
+
+ private static ClampSettingsStatus LoadStatus(string path)
+ {
+ ClampSettings.Load(path, out var status);
+ return status;
+ }
+
+ private static bool CanRead(string path)
+ {
+ try
+ {
+ using var _ = File.OpenRead(path);
+ return true;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/OpenNest.Tests/Diagnostics/ClampCutDiagnosticsTests.cs b/OpenNest.Tests/Diagnostics/ClampCutDiagnosticsTests.cs
new file mode 100644
index 0000000..59a59ee
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/ClampCutDiagnosticsTests.cs
@@ -0,0 +1,54 @@
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Diagnostics;
+
+public class ClampCutDiagnosticsTests
+{
+ [Theory]
+ [InlineData(CutOffAxis.Vertical, 10.5, 0, 1, true)]
+ [InlineData(CutOffAxis.Vertical, 10.5, 2, 4, false)]
+ [InlineData(CutOffAxis.Vertical, 14, 0, 2, false)]
+ [InlineData(CutOffAxis.Horizontal, 0.25, 9, 13, true)]
+ [InlineData(CutOffAxis.Horizontal, 2, 9, 13, false)]
+ [InlineData(CutOffAxis.Horizontal, 0.25, 12, 13, false)]
+ public void NominalLimitsAndBothAxesDetermineContact(CutOffAxis axis, double coordinate,
+ double start, double end, bool crosses)
+ {
+ var plate = new Plate(24, 48);
+ plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625)));
+ plate.CutOffs.Add(new CutOff(axis == CutOffAxis.Vertical
+ ? new Vector(coordinate, 0) : new Vector(0, coordinate), axis)
+ { StartLimit = start, EndLimit = end });
+ var findings = ClampCutDiagnostics.Find(plate);
+ Assert.Equal(crosses, findings.Count != 0);
+ if (crosses)
+ Assert.Equal("front", Assert.Single(findings).ClampId);
+ }
+
+ [Fact]
+ public void SegmentChecksPhysicalFootprintNotInfiniteLineOrPartSpacing()
+ {
+ var zone = new Box(10, 0, 1.5, 0.625);
+ Assert.True(ClampCutDiagnostics.Intersects(new Vector(9, 0.3), new Vector(12, 0.3),
+ zone, out var contact));
+ Assert.InRange(contact.X, 10, 11.5);
+ Assert.False(ClampCutDiagnostics.Intersects(new Vector(9, 3), new Vector(12, 3),
+ zone, out _));
+ Assert.False(ClampCutDiagnostics.Intersects(new Vector(9, 0.3), new Vector(9.9, 0.3),
+ zone, out _));
+ Assert.Throws(() => ClampCutDiagnostics.Intersects(
+ new Vector(double.NaN, 0), new Vector(12, 0), zone, out _));
+ }
+
+ [Fact]
+ public void OnlyActiveClampsAreReported()
+ {
+ var plate = new Plate(24, 48);
+ plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625)));
+ plate.CutOffs.Add(new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical));
+ Assert.Single(ClampCutDiagnostics.Find(plate));
+ plate.ClampZones.Clear(); // Removed on this sheet; no longer an obstacle.
+ Assert.Empty(ClampCutDiagnostics.Find(plate));
+ }
+}
diff --git a/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs b/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs
new file mode 100644
index 0000000..face1a7
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs
@@ -0,0 +1,319 @@
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+using Xunit;
+
+namespace OpenNest.Tests.Diagnostics;
+
+///
+/// Live plates store clamp zones in the sheet's lower-left frame; parts, cut-offs and motions
+/// use the signed quadrant frame. Every clamp check must compare in the placement frame.
+/// Fixtures use a real so an already-signed hand-made zone cannot hide
+/// a missing translation. Expected footprints come from the physical rule: a clamp centered c
+/// covers c +/- width/2 from the origin along the edge and depth into the sheet.
+///
+public class ClampZonePlacementFrameTests
+{
+ private const double SheetWidth = 81;
+ private const double SheetLength = 120;
+
+ private static int XSign(int quadrant) => quadrant is 2 or 3 ? -1 : 1;
+ private static int YSign(int quadrant) => quadrant is 3 or 4 ? -1 : 1;
+
+ private static Box WorldFootprint(int quadrant, double center, double width, double depth)
+ {
+ var near = center - width / 2;
+ var far = center + width / 2;
+ var x = XSign(quadrant) > 0 ? near : -far;
+ var y = YSign(quadrant) > 0 ? 0 : -depth;
+ return new Box(x, y, width, depth);
+ }
+
+ private static Plate RailPlate(int quadrant, params double[] centers)
+ {
+ var plate = new Plate(SheetWidth, SheetLength) { Quadrant = quadrant };
+ plate.SetClampRail(new ClampRail(1.5, 0.625, centers));
+ return plate;
+ }
+
+ private static Nest NestOf(Plate plate)
+ {
+ var nest = new Nest();
+ nest.Plates.Add(plate);
+ return nest;
+ }
+
+ private static Part Rectangle(double length, double width, Vector location, bool scribe = false)
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(length, 0);
+ program.LineTo(length, width);
+ program.LineTo(0, width);
+ program.LineTo(0, 0);
+ if (scribe)
+ foreach (var line in program.Codes.OfType())
+ line.Layer = LayerType.Scribe;
+ return new Part(new Drawing(scribe ? "scribe" : "rectangle", program), location);
+ }
+
+ private static void AssertSameBox(Box expected, Box actual)
+ {
+ Assert.Equal(expected.Left, actual.Left, 9);
+ Assert.Equal(expected.Bottom, actual.Bottom, 9);
+ Assert.Equal(expected.Length, actual.Length, 9);
+ Assert.Equal(expected.Width, actual.Width, 9);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ToPlacement_MapsWholeFootprintsWithoutChangingStoredZones(int quadrant)
+ {
+ var plate = RailPlate(quadrant, 35, 120.5);
+ var stored = plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y)).ToArray();
+
+ var mapped = ClampZoneFrame.ToPlacement(plate);
+
+ Assert.Equal(new[] { "clamp-1", "clamp-2" }, mapped.Select(z => z.Id));
+ AssertSameBox(WorldFootprint(quadrant, 35, 1.5, 0.625), mapped[0].Bounds);
+ AssertSameBox(WorldFootprint(quadrant, 120.5, 1.5, 0.625), mapped[1].Bounds);
+ Assert.Equal(stored, plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y)));
+ Assert.All(mapped, z => Assert.DoesNotContain(z, plate.ClampZones));
+ }
+
+ [Fact]
+ public void ToPlacement_RejectsMissingOrMalformedZones()
+ {
+ var plate = new Plate(24, 48);
+ var zone = new ClampZone("front", new Box(10, 0, 1.5, 0.625));
+ plate.ClampZones.Add(zone);
+ zone.Bounds.Width = -1;
+ Assert.Throws(() => ClampZoneFrame.ToPlacement(plate));
+ zone.Bounds.Width = 0.625;
+ zone.Bounds.X = double.NaN;
+ Assert.Throws(() => ClampZoneFrame.ToPlacement(plate));
+ plate.ClampZones.Clear();
+ plate.ClampZones.Add(null!);
+ Assert.Throws(() => ClampZoneFrame.ToPlacement(plate));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ManualCutoffsAcrossRailClampAreFoundInEveryQuadrant(int quadrant)
+ {
+ var plate = RailPlate(quadrant, 35);
+ var footprint = WorldFootprint(quadrant, 35, 1.5, 0.625);
+ plate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 35, 0), CutOffAxis.Vertical));
+ plate.CutOffs.Add(new CutOff(new Vector(0, YSign(quadrant) * 0.3), CutOffAxis.Horizontal));
+ plate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 50, 0), CutOffAxis.Vertical));
+
+ var findings = ClampCutDiagnostics.Find(plate);
+
+ Assert.Equal(new[] { 0, 1 }, findings.Select(f => f.CutOffIndex));
+ Assert.All(findings, f =>
+ {
+ Assert.Equal("clamp-1", f.ClampId);
+ Assert.InRange(f.Location.X, footprint.Left, footprint.Right);
+ Assert.InRange(f.Location.Y, footprint.Bottom, footprint.Top);
+ });
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate));
+ Assert.Equal(2, report.CutOffClampFindings.Count);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void PlateCaptureFindsPartMaterialOnRailClampInEveryQuadrant(int quadrant)
+ {
+ var plate = RailPlate(quadrant, 35);
+ // A 4x4 square from 33 to 37 along the edge, flush with the front edge.
+ plate.Parts.Add(Rectangle(4, 4, new Vector(XSign(quadrant) > 0 ? 33 : -37,
+ YSign(quadrant) > 0 ? 0 : -4)));
+ plate.Parts.Add(Rectangle(4, 4, new Vector(XSign(quadrant) > 0 ? 50 : -54,
+ YSign(quadrant) > 0 ? 0 : -4)));
+
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate));
+
+ var finding = Assert.Single(report.ClampFindings);
+ Assert.Equal(0, finding.PartAId);
+ Assert.Contains("clamp-1", finding.PartBName);
+ Assert.Equal(1.5 * 0.625, finding.Area, 3);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void PostingWarnsForCutoffRapidAndPartOnRailClampInEveryQuadrant(int quadrant)
+ {
+ var cutPlate = RailPlate(quadrant, 35);
+ cutPlate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 35, 0), CutOffAxis.Vertical));
+ cutPlate.RegenerateCutOffs(new CutOffSettings());
+ var cutReport = PostVerificationAnalyzer.Analyze(NestOf(cutPlate));
+ Assert.Contains(cutReport.Findings, f => f.Kind == PostVerificationKind.Overlap && f.ClampId == "clamp-1");
+ Assert.False(cutReport.CanPost(false));
+ Assert.True(cutReport.CanPost(true));
+
+ // The incoming rapid (0,0) -> (+/-40, +/-0.3) passes through the clamp at 35.
+ var rapidPlate = RailPlate(quadrant, 35);
+ rapidPlate.Parts.Add(Rectangle(0.1, 0.1,
+ new Vector(XSign(quadrant) * 40, YSign(quadrant) * 0.3), scribe: true));
+ var rapidReport = PostVerificationAnalyzer.Analyze(NestOf(rapidPlate));
+ var rapid = Assert.Single(rapidReport.Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
+ Assert.Equal("clamp-1", rapid.ClampId);
+ Assert.False(rapidReport.CanPost(false));
+
+ var partPlate = RailPlate(quadrant, 35);
+ partPlate.Parts.Add(Rectangle(4, 4, new Vector(XSign(quadrant) > 0 ? 33 : -37,
+ YSign(quadrant) > 0 ? 0 : -4)));
+ var partReport = PostVerificationAnalyzer.Analyze(NestOf(partPlate));
+ Assert.Contains(partReport.Findings, f => f.Kind == PostVerificationKind.Overlap
+ && f.PartNumber == 1 && f.OtherPartNumber == null && f.Overlap != null
+ && f.Message.Contains("clamp-1"));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void RailClampsAwayFromCutoffsAndRapidsProduceNoClampWarnings(int quadrant)
+ {
+ // The cut-off and every rapid stay between the origin and the clamp at 35.
+ var plate = RailPlate(quadrant, 35);
+ plate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 20, 0), CutOffAxis.Vertical));
+ plate.RegenerateCutOffs(new CutOffSettings());
+ plate.Parts.Add(Rectangle(0.1, 0.1, new Vector(XSign(quadrant) * 10, YSign(quadrant) * 5),
+ scribe: true));
+ var report = PostVerificationAnalyzer.Analyze(NestOf(plate));
+ Assert.DoesNotContain(report.Findings, f => f.ClampId != null || f.Message.Contains("clamp"));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void StalePlacedCutoffAcrossRailClampStillRequiresConsent(int quadrant)
+ {
+ // The placed vertical cut runs across the whole sheet width, through the clamp.
+ var plate = RailPlate(quadrant, 35);
+ var cut = new CutOff(new Vector(XSign(quadrant) * 35, 0), CutOffAxis.Vertical);
+ plate.CutOffs.Add(cut);
+ plate.RegenerateCutOffs(new CutOffSettings { CutDirection = CutDirection.TowardOrigin });
+
+ // In quadrants 3 and 4 the incoming rapid also reaches the clamp; only the cut is asserted.
+ var current = PostVerificationAnalyzer.Analyze(NestOf(plate));
+ var nominal = Assert.Single(current.Findings, f => f.Kind == PostVerificationKind.Overlap);
+ Assert.Equal("clamp-1", nominal.ClampId);
+ Assert.Equal(1, nominal.PartNumber);
+
+ // The definition moves clear of the clamp, but the placed program is not regenerated.
+ cut.Position = new Vector(XSign(quadrant) * 50, 0);
+ Assert.Empty(ClampCutDiagnostics.Find(plate));
+
+ var stale = PostVerificationAnalyzer.Analyze(NestOf(plate));
+ var placed = Assert.Single(stale.Findings, f => f.Kind == PostVerificationKind.Overlap);
+ Assert.Equal("clamp-1", placed.ClampId);
+ Assert.Equal(1, placed.PartNumber);
+ Assert.Contains("placed", placed.Message);
+ var footprint = WorldFootprint(quadrant, 35, 1.5, 0.625);
+ var location = Assert.NotNull(placed.Location);
+ Assert.InRange(location.X, footprint.Left, footprint.Right);
+ Assert.InRange(location.Y, footprint.Bottom, footprint.Top);
+ Assert.False(stale.CanPost(false));
+ Assert.True(stale.CanPost(true));
+ }
+
+ [Theory]
+ [InlineData("NaN")]
+ [InlineData("negative")]
+ [InlineData("overflow")]
+ [InlineData("null")]
+ public void MalformedActiveClampMakesPostingIncompleteEvenWithoutRapidsOrCutoffs(string invalid)
+ {
+ var plate = new Plate(24, 48);
+ if (invalid == "null")
+ plate.ClampZones.Add(null!);
+ else
+ {
+ var zone = new ClampZone("front", new Box(10, 0, 1.5, 0.625));
+ plate.ClampZones.Add(zone);
+ if (invalid == "NaN")
+ zone.Bounds.Y = double.NaN;
+ else if (invalid == "negative")
+ zone.Bounds.Width = -1;
+ else
+ {
+ zone.Bounds.X = 1e308;
+ zone.Bounds.Length = 1e308;
+ }
+ }
+ // Cut-first scribe: no nonzero rapid, no cut-off definitions.
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Scribe });
+ plate.Parts.Add(new Part(new Drawing("scribe", program)));
+
+ var report = PostVerificationAnalyzer.Analyze(NestOf(plate));
+
+ Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Incomplete
+ && f.Message.StartsWith("Clamp check:"));
+ Assert.False(report.CanPost(false));
+ Assert.Throws(() => PlateOverlapAnalyzer.CapturePlate(plate));
+ }
+
+ [Fact]
+ public void FreshnessCaptureToleratesMissingCutOffsAndZones()
+ {
+ var plate = new Plate(24, 48);
+ plate.CutOffs.Add(null!);
+ plate.ClampZones.Add(null!);
+
+ var stamp = OverlapGeometryStamp.Capture(plate);
+ var state = new OverlapReportState();
+ state.Begin(plate);
+
+ Assert.True(stamp.Matches(plate));
+ // The whole-plate capture refuses the malformed input inside the desktop's error boundary.
+ Assert.Throws(() => PlateOverlapAnalyzer.CapturePlate(plate));
+ plate.CutOffs.Clear();
+ plate.CutOffs.Add(new CutOff(new Vector(5, 0), CutOffAxis.Vertical));
+ Assert.False(stamp.Matches(plate));
+ plate.CutOffs.Clear();
+ plate.CutOffs.Add(null!);
+ Assert.True(stamp.Matches(plate));
+ plate.ClampZones.Clear();
+ plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625)));
+ Assert.False(stamp.Matches(plate));
+ }
+
+ [Fact]
+ public void QuadrantChangeMovesExplicitZonesAndStalesDisplayedClampFindings()
+ {
+ var plate = new Plate(24, 48);
+ plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625)));
+ plate.Parts.Add(Rectangle(4, 4, new Vector(9.25, 0)));
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate));
+ Assert.Single(report.ClampFindings);
+ var state = new OverlapReportState();
+ Assert.True(state.TryPublish(state.Begin(plate), plate, report));
+ Assert.Single(state.DisplayPairs);
+
+ // The stored zone bits are unchanged, but in quadrant 4 the zone is at Y -24..-23.375.
+ plate.Quadrant = 4;
+
+ Assert.False(state.EnsureFresh(plate));
+ Assert.Empty(state.DisplayPairs);
+ }
+}
diff --git a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs
new file mode 100644
index 0000000..673e3ef
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs
@@ -0,0 +1,315 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+using Xunit;
+
+namespace OpenNest.Tests.Diagnostics;
+
+///
+/// Slice 4 (C1): the material-overlap analyzer reports part-vs-clamp findings like
+/// other overlap findings - owned fragments, area and centroid - and they flow into
+/// hover text and paging. Files/plates without zones behave exactly as before.
+///
+public class PlateOverlapAnalyzerClampZoneTests
+{
+ private static Program Rect(double lengthX, double widthY)
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(lengthX, 0);
+ program.LineTo(lengthX, widthY);
+ program.LineTo(0, widthY);
+ program.LineTo(0, 0);
+ return program;
+ }
+
+ private static Drawing Square(string name, double size) => new(name, Rect(size, size));
+
+ private static readonly ClampZone Front1 = new("front-1", new Box(10, 0, 1.5, 0.625));
+ private static readonly ClampZone Front2 = new("front-2", new Box(20, 0, 1.5, 0.625));
+
+ [Fact]
+ public void ManualCutoffCrossingClampAppearsInOverlapCheckWithoutBeingMaterial()
+ {
+ var plate = new Plate(new Size(24, 48));
+ plate.ClampZones.Add(Front1);
+ plate.CutOffs.Add(new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical));
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate));
+ Assert.Empty(report.Pairs);
+ Assert.Empty(report.ClampFindings);
+ Assert.True(report.IsComplete);
+ var finding = Assert.Single(report.CutOffClampFindings);
+ Assert.Equal("front-1", finding.ClampId);
+ Assert.Equal(0, finding.CutOffIndex);
+ Assert.InRange(finding.Location.X, 10, 11.5);
+ var state = new OverlapReportState();
+ Assert.True(state.TryPublish(state.Begin(plate), plate, report));
+ Assert.Contains("cut-off", state.Message);
+ plate.CutOffs.Clear();
+ Assert.False(state.EnsureFresh(plate));
+ Assert.Equal(OverlapCheckStatus.Stale, state.Status);
+ }
+
+ [Fact]
+ public void PartOverlappingClampProducesHoleAwareFinding()
+ {
+ // 4x4 square at (9.25, 0): the shared fragment with front-1 is exactly its footprint.
+ var parts = new List { new(Square("plate-part", 4), new Vector(9.25, 0)) };
+
+ var report = PlateOverlapAnalyzer.Analyze(
+ PlateOverlapAnalyzer.Capture(parts, new[] { Front1, Front2 }));
+
+ Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
+ var finding = Assert.Single(report.ClampFindings);
+ Assert.Equal(0, finding.PartAId);
+ Assert.Equal("plate-part", finding.PartAName);
+ Assert.Contains("front-1", finding.PartBName);
+ Assert.Equal(1.5 * 0.625, finding.Area, 3);
+ Assert.InRange(finding.Centroid.X, 10 - 1e-3, 11.5 + 1e-3);
+ Assert.InRange(finding.Centroid.Y, 0 - 1e-3, 0.625 + 1e-3);
+ }
+
+ [Fact]
+ public void ClearPartsProduceNoClampFindings()
+ {
+ var parts = new List
+ {
+ new(Square("far", 4), new Vector(30, 10)),
+ new(Square("behind", 2), new Vector(10, 5)), // between the clamps, above them
+ };
+
+ var report = PlateOverlapAnalyzer.Analyze(
+ PlateOverlapAnalyzer.Capture(parts, new[] { Front1, Front2 }));
+
+ Assert.True(report.IsComplete);
+ Assert.Empty(report.Pairs);
+ Assert.Empty(report.ClampFindings);
+ }
+
+ [Fact]
+ public void LegacyCaptureFindsNoClampsAndKeepsPartPairs()
+ {
+ var parts = new List
+ {
+ new(Square("a", 4), new Vector(0, 0)),
+ new(Square("b", 4), new Vector(3, 0)), // overlaps a
+ };
+
+ var legacy = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts));
+ Assert.Single(legacy.Pairs);
+ Assert.Empty(legacy.ClampFindings);
+
+ var explicitEmpty = PlateOverlapAnalyzer.Analyze(
+ PlateOverlapAnalyzer.Capture(parts, System.Array.Empty()));
+ Assert.Equal(Describe(legacy), Describe(explicitEmpty));
+ }
+
+ [Fact]
+ public void IncrementalRecheckAfterMovingAClampMatchesAFullAnalysis()
+ {
+ var parts = new List
+ {
+ new(Square("a", 4), new Vector(9.25, 0)), // hits the zone at X 10
+ new(Square("b", 4), new Vector(30, 10)), // clear of the zone at X 10
+ };
+ var moved = new ClampZone("front-1", new Box(29, 10, 1.5, 0.625)); // now hits b
+
+ var previous = PlateOverlapAnalyzer.Analyze(
+ PlateOverlapAnalyzer.Capture(parts, new[] { Front1 }));
+ Assert.Equal(0, Assert.Single(previous.ClampFindings).PartAId);
+
+ var incremental = PlateOverlapAnalyzer.Analyze(
+ PlateOverlapAnalyzer.Capture(parts, new[] { moved }), previous);
+ var oracle = PlateOverlapAnalyzer.Analyze(
+ PlateOverlapAnalyzer.Capture(parts, new[] { moved }));
+
+ Assert.Equal(Describe(oracle), Describe(incremental));
+ Assert.Equal(1, Assert.Single(incremental.ClampFindings).PartAId);
+ }
+
+ [Fact]
+ public void FindingsRenderAsClampDetailsAndPageLikeOtherFindings()
+ {
+ var parts = new List
+ {
+ new(Square("hit-one", 4), new Vector(9.25, 0)),
+ new(Square("hit-two", 4), new Vector(19.25, 0)),
+ new(Square("also-hit-two", 4), new Vector(20.5, 0.3)),
+ };
+
+ var report = PlateOverlapAnalyzer.Analyze(
+ PlateOverlapAnalyzer.Capture(parts, new[] { Front1, Front2 }));
+
+ Assert.Equal(3, report.ClampFindings.Count); // hit-one/1, hit-two/2, hit-two/3 -> two zones each
+ var finding = report.ClampFindings.First(f => f.PartAName == "hit-one");
+ Assert.True(OverlapPairPresentation.IsClampFinding(finding));
+ var details = OverlapPairPresentation.Details(finding, Units.Inches, CultureInfo.InvariantCulture);
+ Assert.Contains("front-1", details);
+ Assert.Contains("hit-one", details);
+ Assert.DoesNotContain("Pair ", details);
+
+ // Hover text joins every finding; paging must reach all of them.
+ var text = string.Join("\n\n", report.ClampFindings.Select(f =>
+ OverlapPairPresentation.Details(f, Units.Inches, CultureInfo.InvariantCulture)));
+ var pages = OverlapHoverPages.Create(text, report.ClampFindings.Count, 6, 60, s => s.Length);
+ Assert.True(pages.PageCount > 1);
+ var all = string.Concat(Enumerable.Range(0, pages.PageCount)
+ .Select(page => { pages.MovePage(page == 0 ? 0 : 1); return string.Concat(pages.Lines); }));
+ Assert.Contains("front-2", all);
+ Assert.Contains("also-hit-two", all);
+ }
+
+ [Fact]
+ public void PublishedClampFindingIsVisibleAndZoneEditInvalidatesIt()
+ {
+ var plate = new Plate(new Size(24, 48));
+ plate.Parts.Add(new Part(Square("hit", 4), new Vector(9.25, 0)));
+ plate.ClampZones.Add(Front1);
+ var state = new OverlapReportState();
+ var generation = state.Begin(plate);
+ var report = PlateOverlapAnalyzer.Analyze(
+ PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), plate.ClampZones.ToArray()));
+ Assert.True(state.TryPublish(generation, plate, report));
+ Assert.Single(state.DisplayPairs);
+ Assert.Contains("clamp", state.Message, System.StringComparison.OrdinalIgnoreCase);
+ plate.ClampZones.Clear();
+ state.Invalidate(plate);
+ Assert.Empty(state.DisplayPairs);
+ Assert.Equal(OverlapCheckStatus.Stale, state.Status);
+ }
+
+ [Fact]
+ public void PublicationRefusesZonesChangedWhileWorkerWasRunning()
+ {
+ var plate = new Plate(new Size(24, 48));
+ plate.Parts.Add(new Part(Square("hit", 4), new Vector(9.25, 0)));
+ plate.ClampZones.Add(Front1);
+ var state = new OverlapReportState();
+ var generation = state.Begin(plate);
+ var report = PlateOverlapAnalyzer.Analyze(
+ PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), plate.ClampZones.ToArray()));
+ plate.ClampZones.Clear();
+ Assert.False(state.TryPublish(generation, plate, report));
+ Assert.NotEqual(OverlapCheckStatus.Current, state.Status);
+ }
+
+ // C16 (PM 6859db53): a 1e-5 square zone, far below the outline tolerance. A part covering
+ // it is still reported (never silently clear); flush or clear parts are not.
+ [Theory]
+ [InlineData(7.5, true)]
+ [InlineData(6.0, false)]
+ [InlineData(8.00001, false)]
+ [InlineData(3.0, false)]
+ public void TinyClampZoneIsJudgedWithoutOutlineCollapse(double x, bool overlaps)
+ {
+ var tiny = new ClampZone("tiny", new Box(8, 0, 1e-5, 1e-5));
+ var parts = new List { new(Square("p", 2), new Vector(x, 0)) };
+
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, new[] { tiny }));
+
+ Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
+ Assert.Equal(overlaps, report.ClampFindings.Any(f => f.PartBName.Contains("tiny")));
+ }
+
+ // PM 407f5eb7: real material, not the 0.001 arc flattening, decides a tiny clamp.
+ [Theory]
+ [InlineData("rect", 8.00002, 0.5, true)]
+ [InlineData("rect", 8.00005, 0.5, false)]
+ [InlineData("disk", 1.9998, 0, true)]
+ [InlineData("disk", 2.0002, 0, false)]
+ [InlineData("disk-box", 11.935615871667656, 10.503377293956166, true)] // arc cuts a corner
+ public void TinyClampIsJudgedAgainstRealMaterial(string material, double a, double b, bool overlaps)
+ {
+ Program program;
+ Box zone;
+ if (material == "rect")
+ {
+ program = Rect(8.00004, 2);
+ zone = new Box(a, b, 1e-5, 1e-5);
+ }
+ else
+ {
+ program = new Program();
+ program.MoveTo(12, 10);
+ program.ArcTo(8, 10, 10, 10, RotationType.CCW);
+ program.ArcTo(12, 10, 10, 10, RotationType.CCW);
+ if (material == "disk-box")
+ {
+ zone = new Box(a, b, 1e-5, 1e-5);
+ }
+ else
+ {
+ var cx = 10 + a * System.Math.Cos(0.02);
+ var cy = 10 + a * System.Math.Sin(0.02);
+ zone = new Box(cx - 0.000005, cy - 0.000005, 1e-5, 1e-5);
+ }
+ }
+ var parts = new List { new(new Drawing(material, program), new Vector(0, 0)) };
+
+ var report = PlateOverlapAnalyzer.Analyze(
+ PlateOverlapAnalyzer.Capture(parts, new[] { new ClampZone("tiny", zone) }));
+
+ Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
+ Assert.Equal(overlaps, report.ClampFindings.Count > 0);
+ }
+
+ // Delta review 2: material beside a concave (notch) arc is not lost for a tiny clamp.
+ [Fact]
+ public void TinyClampBesideAConcaveArcIsReported()
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(20, 0);
+ program.LineTo(20, 20);
+ program.LineTo(12, 20);
+ program.ArcTo(8, 20, 10, 20, RotationType.CW);
+ program.LineTo(0, 20);
+ program.LineTo(0, 0);
+ var arc = new ShapeProfile(OpenNest.Converters.ConvertProgram.ToGeometry(program))
+ .Perimeter.Entities.OfType().Single();
+ var native = arc.ToPoints(arc.SegmentsForTolerance(1e-5 / 4)).First(v => v.X > 11.9 && v.Y < 19.5);
+ var centre = new Vector(10, 20);
+ var outward = centre + (native - centre) / 2 * (2 + 6e-7);
+ var zone = new ClampZone("tiny", new Box(outward.X - 1e-5, outward.Y, 1e-5, 1e-5));
+
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(
+ new List { new(new Drawing("notch", program), new Vector(0, 0)) }, new[] { zone }));
+
+ Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
+ Assert.NotEmpty(report.ClampFindings);
+ }
+
+ // Delta review 3: a tiny clamp in the sliver beyond a convex arc's 0.001 chords must not
+ // be dismissed by the coarse material bounds before the fine check runs.
+ [Fact]
+ public void TinyClampBeyondCoarseChordsIsReported()
+ {
+ const double r = 0.01, half = 0.04;
+ var dx = r * System.Math.Sin(half);
+ var cy = 20 - r * System.Math.Cos(half);
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(20, 0);
+ program.LineTo(20, 20);
+ program.LineTo(10 + dx, 20);
+ program.ArcTo(10 - dx, 20, 10, cy, RotationType.CCW);
+ program.LineTo(0, 20);
+ program.LineTo(0, 0);
+ // Witness (10, 20.000002) is inside the arc (radius 0.009994 < 0.01) and the zone.
+ var zone = new ClampZone("tiny", new Box(9.999995, 20.000001199840007, 1e-5, 1e-5));
+
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(
+ new List { new(new Drawing("cap", program), new Vector(0, 0)) }, new[] { zone }));
+
+ Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
+ Assert.NotEmpty(report.ClampFindings);
+ }
+
+ private static string Describe(PlateOverlapReport report) =>
+ string.Join(";", report.Pairs.Select(p => $"{p.PartAId}/{p.PartBId}:{p.Area:R}"))
+ + "|"
+ + string.Join(";", report.ClampFindings.Select(f => $"{f.PartAId}/{f.PartBName}:{f.Area:R}"));
+}
diff --git a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs
index 855e09d..f95d15e 100644
--- a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs
+++ b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs
@@ -98,6 +98,53 @@ public class PostVerificationAnalyzerTests
Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
}
+ [Fact]
+ public void ManualCutoffAndRapidCrossingClampRequireFreshPostingConsent()
+ {
+ var nest = NestWith(Rectangle(50, 20, 4));
+ var plate = Assert.Single(nest.Plates);
+ plate.ClampZones.Add(new ClampZone("front-3", new Box(10, 0, 1.5, 0.625)));
+ var cutoff = new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical);
+ plate.CutOffs.Add(cutoff);
+ plate.RegenerateCutOffs(new CutOffSettings());
+ var report = PostVerificationAnalyzer.Analyze(nest);
+ Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Overlap
+ && f.Message.Contains("front-3") && f.Message.Contains("cut-off") && f.PlateNumber == 1);
+ Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.RapidCrossing
+ && f.Message.Contains("front-3") && f.RapidStart.HasValue && f.RapidEnd.HasValue);
+ Assert.False(report.CanPost(false));
+ Assert.True(report.CanPost(true));
+ Assert.False(PostVerificationAnalyzer.Analyze(nest).CanPost(false));
+ plate.ClampZones.Clear();
+ var withoutClamps = PostVerificationAnalyzer.Analyze(nest);
+ Assert.DoesNotContain(withoutClamps.Findings, f => f.Message.Contains("front-3"));
+ }
+
+ [Fact]
+ public void InvalidManualCutoffGeometryIsIncompleteNotClear()
+ {
+ var nest = NestWith(Rectangle(40, 20, 4));
+ var plate = nest.Plates[0];
+ plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625)));
+ plate.CutOffs.Add(new CutOff(new Vector(double.NaN, 0), CutOffAxis.Vertical));
+ var report = PostVerificationAnalyzer.Analyze(nest);
+ Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Incomplete
+ && f.Message.Contains("Clamp/cut-off check"));
+ Assert.False(report.CanPost(false));
+ }
+
+ [Fact]
+ public void PartMaterialOnClampAppearsInPrePostOverlapFindings()
+ {
+ var nest = NestWith(Rectangle(10, 0, 4));
+ nest.Plates[0].ClampZones.Add(new ClampZone("front-1", new Box(10, 0, 1.5, 0.625)));
+ var finding = Assert.Single(PostVerificationAnalyzer.Analyze(nest).Findings.Where(f =>
+ f.Kind == PostVerificationKind.Overlap && f.Message.Contains("front-1")));
+ Assert.Equal(1, finding.PartNumber);
+ Assert.Null(finding.OtherPartNumber);
+ Assert.NotNull(finding.Overlap);
+ }
+
[Fact]
public void ScribeAndCutoff_AreExemptFromOverlapAndLeadInChecks()
{
diff --git a/OpenNest.Tests/Diagnostics/StartPointClampRapidWarningTests.cs b/OpenNest.Tests/Diagnostics/StartPointClampRapidWarningTests.cs
new file mode 100644
index 0000000..25db649
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/StartPointClampRapidWarningTests.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+using Xunit;
+
+namespace OpenNest.Tests.Diagnostics;
+
+///
+/// D10 (user decision 2026-10-13, "keep the warning"): a clamp whose physical footprint
+/// covers the modeled program start (0,0) makes every direct XY rapid that leaves the start
+/// point cross it. That warning stays: no exemption for the first rapid, the start point or
+/// "head is already up", and no persisted bypass — every posting attempt re-reports it.
+/// Fixtures build the rail with and predict footprints from
+/// the physical rule (center +/- width/2 from the origin along the edge, depth into the
+/// sheet) plus an independent segment/box oracle, never from the analyzer's own math.
+///
+public class StartPointClampRapidWarningTests
+{
+ private const double OriginCenter = 0.25;
+ private const double ClampWidth = 1.5;
+ private const double ClampDepth = 0.625;
+
+ private static int XSign(int quadrant) => quadrant is 2 or 3 ? -1 : 1;
+ private static int YSign(int quadrant) => quadrant is 3 or 4 ? -1 : 1;
+
+ private static Box PhysicalFootprint(int quadrant, double center)
+ {
+ var near = center - ClampWidth / 2;
+ var far = center + ClampWidth / 2;
+ var x = XSign(quadrant) > 0 ? near : -far;
+ var y = YSign(quadrant) > 0 ? 0 : -ClampDepth;
+ return new Box(x, y, ClampWidth, ClampDepth);
+ }
+
+ /// Independent oracle: does the closed segment from->to touch the closed box?
+ private static bool SegmentTouchesBox(Vector from, Vector to, Box box)
+ {
+ var tEnter = 0.0;
+ var tExit = 1.0;
+ Clip(-dx(from, to), from.X - box.Left);
+ Clip(dx(from, to), box.Right - from.X);
+ Clip(-dy(from, to), from.Y - box.Bottom);
+ Clip(dy(from, to), box.Top - from.Y);
+ return tEnter <= tExit;
+
+ void Clip(double p, double q)
+ {
+ if (p == 0)
+ {
+ if (q < 0) tEnter = 2; // provably disjoint
+ return;
+ }
+ var ratio = q / p;
+ if (p < 0) tEnter = System.Math.Max(tEnter, ratio);
+ else tExit = System.Math.Min(tExit, ratio);
+ }
+ }
+
+ private static double dx(Vector from, Vector to) => to.X - from.X;
+ private static double dy(Vector from, Vector to) => to.Y - from.Y;
+
+ private static Plate RailPlate(int quadrant, params double[] centers)
+ {
+ var plate = new Plate(81, 120) { Quadrant = quadrant };
+ plate.SetClampRail(new ClampRail(ClampWidth, ClampDepth, centers));
+ return plate;
+ }
+
+ private static Part ScribeSquare(Vector location)
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(0.1, 0);
+ program.LineTo(0.1, 0.1);
+ program.LineTo(0, 0.1);
+ program.LineTo(0, 0);
+ foreach (var line in program.Codes.OfType())
+ line.Layer = LayerType.Scribe;
+ return new Part(new Drawing("scribe", program), location);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void OriginCoveringClampWarnsOnceOnTheFirstRapid(int quadrant)
+ {
+ // Oracle precondition: the rail rule puts a clamp at center 0.25 over the start point.
+ var footprint = PhysicalFootprint(quadrant, OriginCenter);
+ Assert.True(footprint.Left <= 0 && 0 <= footprint.Right);
+ Assert.True(footprint.Bottom <= 0 && 0 <= footprint.Top);
+
+ var plate = RailPlate(quadrant, OriginCenter);
+ var partLocation = new Vector(XSign(quadrant) * 40, YSign(quadrant) * 20);
+ plate.Parts.Add(ScribeSquare(partLocation));
+ // The oracle sees the incoming rapid (0,0) -> part start crossing the footprint.
+ Assert.True(SegmentTouchesBox(Vector.Zero, partLocation, footprint));
+
+ // Every posting attempt re-runs the analysis; nothing is remembered between attempts.
+ var report = PostVerificationAnalyzer.Analyze(NestOf(plate));
+ var again = PostVerificationAnalyzer.Analyze(NestOf(plate));
+
+ var rapid = Assert.Single(report.Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
+ Assert.Equal("clamp-1", rapid.ClampId);
+ Assert.Equal(1, rapid.PartNumber);
+ Assert.Equal(Vector.Zero, rapid.RapidStart);
+ var contact = Assert.Single(rapid.ContactPoints);
+ Assert.InRange(contact.X, footprint.Left, footprint.Right);
+ Assert.InRange(contact.Y, footprint.Bottom, footprint.Top);
+ Assert.Contains("Direct XY rapid crosses active clamp 'clamp-1'", rapid.Message);
+
+ var repeated = Assert.Single(again.Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
+ Assert.Equal(rapid.ClampId, repeated.ClampId);
+
+ // Consent is per attempt: never postable without it, always postable with it.
+ Assert.False(report.CanPost(false));
+ Assert.True(report.CanPost(true));
+ Assert.False(again.CanPost(false));
+ Assert.True(again.CanPost(true));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void RailWithoutOriginClampGivesNoClampRapidFinding(int quadrant)
+ {
+ var plate = RailPlate(quadrant, 6);
+ var footprint = PhysicalFootprint(quadrant, 6);
+ Assert.False(footprint.Left <= 0 && 0 <= footprint.Right &&
+ footprint.Bottom <= 0 && 0 <= footprint.Top);
+
+ var partLocation = new Vector(XSign(quadrant) * 10, YSign(quadrant) * 20);
+ plate.Parts.Add(ScribeSquare(partLocation));
+ // The oracle: this first rapid passes above the clamp, and the part clears it.
+ Assert.False(SegmentTouchesBox(Vector.Zero, partLocation, footprint));
+
+ var report = PostVerificationAnalyzer.Analyze(NestOf(plate));
+
+ Assert.DoesNotContain(report.Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
+ Assert.DoesNotContain(report.Findings, f => f.ClampId != null);
+ }
+
+ private static Nest NestOf(Plate plate)
+ {
+ var nest = new Nest();
+ nest.Plates.Add(plate);
+ return nest;
+ }
+}
diff --git a/OpenNest.Tests/Engine/ClampRailWorkflowTests.cs b/OpenNest.Tests/Engine/ClampRailWorkflowTests.cs
new file mode 100644
index 0000000..136a633
--- /dev/null
+++ b/OpenNest.Tests/Engine/ClampRailWorkflowTests.cs
@@ -0,0 +1,128 @@
+using OpenNest.CNC;
+using OpenNest.Data;
+using OpenNest.Engine;
+using OpenNest.Engine.Jobs;
+using OpenNest.Geometry;
+using OpenNest.IO;
+
+namespace OpenNest.Tests.Engine;
+
+///
+/// The desktop clamp workflow end to end without the form: enabled machine settings give a
+/// new nest its rail, whole-job Auto Nest (template stock, validated pipeline, commit) keeps
+/// every part clear of the clamps on every sheet while still using the edge between them,
+/// and save/reload preserves clamps and placements without writing the empty tail sheet.
+///
+public class ClampRailWorkflowTests
+{
+ private const double EdgeSpacing = 0.25;
+ private static readonly double[] Centers = { 3, 10.5, 18 };
+
+ private static Drawing Block()
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(4, 0);
+ program.LineTo(4, 2);
+ program.LineTo(0, 2);
+ program.LineTo(0, 0);
+ return new Drawing("neutral-block", program);
+ }
+
+ private static Nest NewNest(int quadrant)
+ {
+ var nest = new Nest { Units = Units.Inches };
+ nest.PlateDefaults.Size = new Size(12, 24); // 12 deep (Y), 24 along the clamp edge (X)
+ nest.PlateDefaults.Quadrant = quadrant;
+ nest.PlateDefaults.EdgeSpacing = new Spacing(EdgeSpacing, EdgeSpacing, EdgeSpacing, EdgeSpacing);
+ nest.PlateDefaults.PartSpacing = 0.25;
+ new ClampSettings { Enabled = true, Centers = Centers }.ApplyToNewNest(nest);
+ return nest;
+ }
+
+ /// Independent oracle: part boxes against clamps grown by the bottom edge spacing.
+ private static void AssertClearOfClamps(Plate plate)
+ {
+ var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
+ var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
+ Assert.NotEmpty(plate.ClampZones);
+ foreach (var zone in plate.ClampZones)
+ {
+ var keepOut = zone.Bounds.Translate(left, bottom);
+ foreach (var part in plate.Parts)
+ {
+ var box = part.BoundingBox;
+ var overlapX = System.Math.Min(box.Right, keepOut.Right + EdgeSpacing)
+ - System.Math.Max(box.Left, keepOut.Left - EdgeSpacing);
+ var overlapY = System.Math.Min(box.Top, keepOut.Top + EdgeSpacing)
+ - System.Math.Max(box.Bottom, keepOut.Bottom - EdgeSpacing);
+ Assert.False(overlapX > 1e-6 && overlapY > 1e-6,
+ $"{part.BaseDrawing.Name} at {box} enters {zone.Id} at {keepOut}");
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData("Rectangles", 1)]
+ [InlineData("Default", 1)]
+ [InlineData("Rectangles", 3)]
+ public void AutoNestAvoidsMachineClampsAndTheyRoundTrip(string engine, int quadrant)
+ {
+ const int quantity = 40;
+ var drawing = Block();
+ var nest = NewNest(quadrant);
+ nest.Drawings.Add(drawing);
+ var rail = nest.PlateDefaults.ClampRail;
+ Assert.NotNull(rail);
+ using var manager = new PlateManager(nest);
+ var template = manager.GetOrCreateEmpty();
+ Assert.Equal(3, template.ClampZones.Count);
+
+ var item = new NestItem
+ {
+ Drawing = drawing,
+ Quantity = quantity,
+ StepAngle = OpenNest.Math.Angle.HalfPI,
+ RotationStart = 0,
+ RotationEnd = OpenNest.Math.Angle.HalfPI,
+ };
+ var result = NestPipeline.Run(new NestPipelineRequest(engine, [item],
+ NestStockBuilder.FromTemplate(template, null)));
+ Assert.True(result.IsValid, string.Join("; ", result.Violations));
+ Assert.Equal(quantity, Assert.Single(result.Raw.Fulfillment).Placed);
+
+ var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
+
+ Assert.True(applied.Count >= 2, $"expected several sheets, got {applied.Count}");
+ foreach (var plate in applied)
+ {
+ Assert.Same(rail, plate.ClampRail);
+ Assert.True(rail.Matches(plate));
+ AssertClearOfClamps(plate);
+ }
+ if (quadrant == 1)
+ {
+ // The edge between clamps stays usable: some part sits in the clamp band.
+ Assert.Contains(applied.SelectMany(p => p.Parts),
+ p => p.BoundingBox.Bottom < 0.625 + EdgeSpacing);
+ }
+
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ var loaded = new NestReader(new MemoryStream(stream.ToArray())).Read();
+
+ Assert.Equal(rail, loaded.PlateDefaults.ClampRail);
+ Assert.Equal(applied.Count, loaded.Plates.Count); // trailing empty sheet not written
+ for (var i = 0; i < applied.Count; i++)
+ {
+ var reloaded = loaded.Plates[i];
+ Assert.Same(loaded.PlateDefaults.ClampRail, reloaded.ClampRail);
+ Assert.Equal(applied[i].ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)),
+ reloaded.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)));
+ Assert.Equal(applied[i].Parts.Select(p => (p.Location, p.Rotation)),
+ reloaded.Parts.Select(p => (p.Location, p.Rotation)));
+ AssertClearOfClamps(reloaded);
+ }
+ Assert.Equal(3, loaded.CreatePlate().ClampZones.Count);
+ }
+}
diff --git a/OpenNest.Tests/Geometry/ClearanceTests.cs b/OpenNest.Tests/Geometry/ClearanceTests.cs
index c7708d7..a070fdb 100644
--- a/OpenNest.Tests/Geometry/ClearanceTests.cs
+++ b/OpenNest.Tests/Geometry/ClearanceTests.cs
@@ -35,6 +35,28 @@ public class ClearanceTests
return p;
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void OpenPathBoundaryDistanceUsesSegmentInterior_NotFirstEndpoint(bool vertical)
+ {
+ var part = Square(0, 0, 2, 2);
+ var path = vertical
+ ? new Line(new Vector(10, -20), new Vector(10, 20))
+ : new Line(new Vector(-20, 10), new Vector(20, 10));
+ var lines = new List { path };
+
+ var initial = Clearance.BoundaryDistance(part.ToLines(), lines);
+ Assert.Equal(8, initial.Distance, 6); // Independent axis-aligned box gap.
+ Assert.Equal(vertical ? -1 : 0, initial.Direction.X, 6);
+ Assert.Equal(vertical ? 0 : -1, initial.Direction.Y, 6);
+
+ // A parallel translation leaves the true gap unchanged even though
+ // distance to the first endpoint changes substantially.
+ part.Offset(vertical ? new Vector(0, 3) : new Vector(3, 0));
+ Assert.Equal(8, Clearance.BoundaryDistance(part.ToLines(), lines).Distance, 6);
+ }
+
// ---- Separation ----
[Fact]
diff --git a/OpenNest.Tests/Geometry/OpenPathMaterialTests.cs b/OpenNest.Tests/Geometry/OpenPathMaterialTests.cs
new file mode 100644
index 0000000..9898562
--- /dev/null
+++ b/OpenNest.Tests/Geometry/OpenPathMaterialTests.cs
@@ -0,0 +1,61 @@
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Geometry;
+
+public class OpenPathMaterialTests
+{
+ private static Polygon Rectangle(double left, double bottom, double right, double top)
+ {
+ var polygon = new Polygon();
+ polygon.Vertices.AddRange(new[]
+ {
+ new Vector(left, bottom), new Vector(right, bottom),
+ new Vector(right, top), new Vector(left, top), new Vector(left, bottom),
+ });
+ polygon.UpdateBounds();
+ return polygon;
+ }
+
+ [Fact]
+ public void SlopedPathThroughSquareMaterialHasPositiveInteriorLength()
+ {
+ var square = Rectangle(40, 40, 42, 42);
+ var path = new List