Merge remote-tracking branch 'github/master' into feat/inspection-view-navigation

This commit is contained in:
aj committed 2026-10-14 10:21:03 -04:00
commit 29f48789dd
126 files changed
+15253 -398

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest;
/// <summary>Existing cutting work on a set of plates: applied lead-ins and cut-offs.</summary>
/// <param name="LeadInParts">Real parts carrying applied lead-ins (locked ones included).</param>
/// <param name="LockedLeadInParts">Of those, parts whose lead-ins are locked.</param>
/// <param name="CutOffs">Cut-off definitions.</param>
/// <param name="PlateNumbers">Display numbers of the plates holding any of it, ascending.</param>
public sealed record AppliedCuttingWorkSummary(int LeadInParts, int LockedLeadInParts, int CutOffs,
IReadOnlyList<int> PlateNumbers)
{
public bool HasExistingWork => LeadInParts > 0 || CutOffs > 0;
/// <summary>Operator text for the cutting planner's warning banner; empty when nothing exists.</summary>
public string Describe()
{
if (!HasExistingWork)
return string.Empty;
var found = new List<string>();
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<string>();
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")}";
}
/// <summary>Finds and clears applied lead-ins and cut-offs so a plan can recalculate them.</summary>
public static class AppliedCuttingWork
{
/// <summary>
/// Counts applied lead-ins on real parts and cut-off definitions. Definitions in
/// <paramref name="ignoredCutOffs"/> (for example ones staged by the caller) are not counted.
/// </summary>
public static AppliedCuttingWorkSummary Summarize(IReadOnlyList<Plate> plates,
IReadOnlyList<int> plateNumbers = null, IEnumerable<CutOff> 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<CutOff>(ignoredCutOffs ?? [], ReferenceEqualityComparer.Instance);
int leads = 0, locked = 0, cutOffs = 0;
var numbers = new SortedSet<int>();
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());
}
/// <summary>
/// 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.
/// </summary>
public static AppliedCuttingWorkSummary Clear(IReadOnlyList<Plate> plates, IReadOnlyList<int> 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;
}
}
+80 -17
View File
@@ -42,11 +42,19 @@ public static class AutomaticCutOffBatch
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
/// </summary>
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
AutomaticCutOffOptions options, CutOffSettings settings) => Stage(plates, options, settings).Plans;
/// <summary>
/// Applies like <see cref="Apply"/>, but keeps the recovery state so the caller can take the
/// new cut-offs back off again (<see cref="AutomaticCutOffStaging.Revert"/>) when the work they
/// were added for is abandoned. A blocked batch changes nothing and has nothing to revert.
/// </summary>
public static AutomaticCutOffStaging Stage(IReadOnlyList<Plate> 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<Exception>();
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<Exception> RestoreAll(IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
{
var errors = new List<Exception>();
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
};
}
}
/// <summary>
/// Cut-offs added by <see cref="AutomaticCutOffBatch.Stage"/> that are still provisional: either
/// <see cref="Keep"/> them once the work they belong to is committed, or <see cref="Revert"/> them.
/// Owner-thread only, like the plates it changed.
/// </summary>
public sealed class AutomaticCutOffStaging
{
private IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions;
internal AutomaticCutOffStaging(IReadOnlyList<AutomaticCutOffPlan> plans,
IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
{
Plans = plans;
this.restoreActions = restoreActions;
Definitions = plans.SelectMany(plan => plan.HasBlockingDiagnostics ? [] : plan.Definitions).ToArray();
}
/// <summary>The fresh plans, one per plate in the order given.</summary>
public IReadOnlyList<AutomaticCutOffPlan> Plans { get; }
/// <summary>True when any plate blocked the batch; then nothing was added.</summary>
public bool IsBlocked => Plans.Any(plan => plan.HasBlockingDiagnostics);
/// <summary>The definitions this staging added (empty when blocked or nothing was needed).</summary>
public IReadOnlyList<CutOff> Definitions { get; }
/// <summary>True while the added cut-offs can still be reverted.</summary>
public bool CanRevert => restoreActions.Count > 0;
/// <summary>Makes the added cut-offs permanent; a later <see cref="Revert"/> does nothing.</summary>
public void Keep() => restoreActions = [];
/// <summary>
/// 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.
/// </summary>
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));
}
}
+175 -6
View File
@@ -21,6 +21,12 @@ public sealed class AutomaticCutOffOptions
/// The desktop default is 12 inches (304.8 mm).
/// </summary>
public double MinimumTailLength { get; set; }
/// <summary>
/// Minimum strip length in model units. Null or equal to Spacing preserves the fixed grid.
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
/// </summary>
public double? MinimumStripLength { get; set; }
}
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
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
public bool HasSeparatedTail => TailSeparatorX.HasValue;
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
public double StripCutLength { get; internal set; }
/// <summary>Length of the nominal new strip lines in the same gaps. Equal to StripCutLength on the fixed-grid path.</summary>
public double FixedGridCutLength { get; internal set; }
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
}
@@ -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;
/// <summary>
/// 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<AutomaticCutOffDiagnostic>();
var separated = false;
var separatorX = (double?)null;
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
var stripCutLength = 0.0;
var fixedGridCutLength = 0.0;
var measurements = new Dictionary<double, (double Length, int SegmentCount)>();
var candidates = new HashSet<double>();
// 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<double>(count);
for (var index = 1; index <= count; index++)
{
var distance = start + index * options.Spacing;
nominal.Add(distance);
fixedGridCutLength += Measure(distance).Length;
}
if (count == 0)
return;
var positions = AutomaticCutOffStripSearch.FindPositions(start, end, count,
options.Spacing, options.MinimumStripLength.Value, step, Allowed, Measure) ?? nominal.ToArray();
for (var index = 0; index < positions.Length; index++)
{
var distance = positions[index];
AddCandidate(sign * distance, false);
if (!Near(distance, nominal[index]))
{
var difference = Measure(nominal[index]).Length - Measure(distance).Length;
var comparison = difference > 0 ? $"{difference:G8} model units less" :
difference < 0 ? $"{-difference:G8} model units more" : "the same length";
diagnostics.Add(new AutomaticCutOffDiagnostic(AutomaticCutOffDiagnosticCode.ShiftedCut,
$"Moved from {nominal[index]:G8} to {distance:G8} model units; "
+ $"this line cuts {comparison}.", X: sign * distance));
}
}
}
bool Allowed(double distance)
{
RegisterCandidate(distance);
// Use the certification path's exact Near predicate, including its ULP
// allowance, so a selected line cannot rediscover a limited-cut conflict.
var x = sign * distance;
return !plate.CutOffs.Any(c => c.Axis == CutOffAxis.Vertical && Near(c.Position.X, x)) &&
!clamps.Any(zone => CrossesClamp(zone, x));
}
void RegisterCandidate(double distance)
{
if (candidates.Add(distance))
Require(candidates.Count <= MaximumShiftCandidateCount,
$"Shift search would generate more than {MaximumShiftCandidateCount} distinct positions. "
+ "Increase spacing or the minimum strip length, or use the fixed grid.", nameof(options));
}
(double Length, int SegmentCount) Measure(double distance)
{
RegisterCandidate(distance);
if (!measurements.TryGetValue(distance, out var measured))
{
measured = 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));
+157
View File
@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
namespace OpenNest;
/// <summary>Exact layered search over sorted positions; each transition uses a sliding minimum.</summary>
internal static class AutomaticCutOffStripSearch
{
internal static double[] FindPositions(double start, double end, int count, double spacing,
double minimum, double step, Func<double, bool> allowed,
Func<double, (double Length, int SegmentCount)> measure)
{
var rows = new List<double[]>(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<double>();
// 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<double[]> rows,
IReadOnlyList<double> nominal, double spacing, double minimum,
Func<double, (double Length, int SegmentCount)> measure)
{
var previous = new List<State> { new() { Position = start } };
for (var line = 0; line < rows.Count; line++)
{
var current = new List<State>(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<State> 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;
}
}
+183
View File
@@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// 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.
/// </summary>
public sealed class ClampRail : IEquatable<ClampRail>
{
/// <summary>
/// Planner default minutes to remove one clamp and put it back after cutting (D9),
/// used when a caller does not supply its own value.
/// </summary>
public const double DefaultRemovalMinutes = 5.0;
public ClampRail(
double width,
double depth,
IEnumerable<double> 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<double>();
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();
}
/// <summary>Clamp extent along the edge (X).</summary>
public double Width { get; }
/// <summary>Clamp intrusion into the sheet from the origin-side edge (Y).</summary>
public double Depth { get; }
/// <summary>
/// Minutes to remove one clamp and put it back after cutting (D9). Added to a sheet's
/// cut-time estimate once per clamp <see cref="Plate.RemovedClamps"/> lists there.
/// </summary>
public double RemovalMinutes { get; }
/// <summary>Distinct clamp centers along X from the origin, ascending.</summary>
public IReadOnlyList<double> Centers { get; }
/// <summary>
/// The zones this rail places on a sheet, in plate coordinates (the frame of
/// <see cref="Plate.ClampZones"/>: 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 <c>clamp-N</c> by
/// rail position, so one physical clamp keeps its id on every sheet even when others
/// are skipped.
/// </summary>
public IReadOnlyList<ClampZone> 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<ClampZone>();
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<ClampZone>(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;
}
/// <summary>True when the plate's zones are exactly (ids and bits) this rail's for its sheet.</summary>
public bool Matches(Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
return SameZones(plate.ClampZones, ResolveFor(plate.Size, plate.Quadrant));
}
/// <summary>
/// True when the plate's zones are exactly this rail's resolution for its sheet minus
/// the clamps named in <paramref name="removedClamps"/> (D8 last-resort removal, C13).
/// A null or empty set is the same check as <see cref="Matches(Plate)"/>.
/// </summary>
public bool Matches(Plate plate, IReadOnlyCollection<string> 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<ClampZone> current, IReadOnlyList<ClampZone> 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")))}]";
}
}
+52
View File
@@ -0,0 +1,52 @@
using System;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Stable identifier used by persistence and diagnostics.</summary>
public string Id { get; }
/// <summary>
/// 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).
/// </summary>
public Box Bounds { get; }
public override string ToString() =>
$"ClampZone '{Id}' [{Bounds.Left:R}, {Bounds.Bottom:R}] x {Bounds.Length:R} x {Bounds.Width:R}";
}
}
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>
/// Maps a plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
/// (<see cref="ClampRail.ResolveFor"/>), 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 <c>NestPlateStock</c>, so this is the only translation any consumer applies.
/// </summary>
public static class ClampZoneFrame
{
/// <summary>
/// Detached placement-frame copies in the plate's zone order; empty when there are none.
/// Throws <see cref="ArgumentException"/> for a missing or malformed zone: zone bounds stay
/// publicly mutable, so construction-time validation is not proof of current geometry.
/// </summary>
public static IReadOnlyList<ClampZone> ToPlacement(Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
var zones = plate.ClampZones;
if (zones == null || zones.Count == 0)
return Array.Empty<ClampZone>();
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;
}
}
@@ -0,0 +1,170 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Converters;
/// <summary>
/// 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.
/// </summary>
public static class DrawingMarkRecovery
{
private const double MatchTolerance = 0.001;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Recovers marks for one drawing from its placed parts; true when marks were added.</summary>
public static bool Apply(Drawing drawing, IReadOnlyList<Part> 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));
/// <summary>
/// The part's marks as they would sit in its drawing: <see cref="Part.RemoveLeadIns"/>
/// rebuilds the part as the drawing turned by <see cref="Part.Rotation"/>, so undo that turn.
/// </summary>
private static List<Entity> 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<Entity> expected, List<Entity> 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<Vector> points, Entity entity) =>
points.Count > 0 && points.All(point => entity.ClosestPointTo(point).DistanceTo(point) <= MatchTolerance);
private static List<Vector> Samples(Entity entity) => entity switch
{
Line line => new List<Vector>
{
line.StartPoint,
line.EndPoint,
new((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2),
},
Arc arc => new List<Vector> { arc.StartPoint(), arc.EndPoint(), arc.MidPoint() },
Circle circle => new List<Vector>
{
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<Vector>(),
};
/// <summary>
/// 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.
/// </summary>
private static void AppendMarks(Program program, List<Entity> 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<Motion>().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<Motion>())
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,
};
}
+66
View File
@@ -39,6 +39,72 @@ namespace OpenNest
Drawing.Program = program;
}
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
internal (double Length, int SegmentCount) MeasureSegments(
Plate plate,
CutOffSettings settings,
Dictionary<Part, Entity> cache = null
)
{
var segments = ComputeSegments(plate, settings, cache);
return (segments.Sum(s => s.End - s.Start), segments.Count);
}
/// <summary>
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, 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.
/// </summary>
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";
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>A nominal manual cut-off line meeting an active, physical clamp footprint.</summary>
public sealed record CutOffClampFinding(int CutOffIndex, string ClampId, Vector Location);
/// <summary>
/// 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 (<see cref="ClampZoneFrame"/>).
/// </summary>
public static class ClampCutDiagnostics
{
public static IReadOnlyList<CutOffClampFinding> Find(Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
var findings = new List<CutOffClampFinding>();
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();
}
/// <summary>True for contact with the whole rectangle, including a boundary touch.</summary>
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);
}
@@ -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]))
@@ -9,12 +9,19 @@ namespace OpenNest.Diagnostics;
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
public static class OverlapPairPresentation
{
public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
/// <summary>True for part-vs-clamp findings carried on the report (negative clamp slot).</summary>
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}";
}
@@ -21,14 +21,22 @@ public sealed class OverlapReportState
public PlateOverlapReport Report { get; private set; }
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
Array.Empty<CutOffClampFinding>();
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<CutOffClampFinding>();
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<PlateOverlapPair>();
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
displayStamp = null;
observedDisplayStamp = null;
}
@@ -17,13 +17,41 @@ public static class PlateOverlapAnalyzer
{
public const double ChordTolerance = 0.001;
/// <summary>
/// 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 <see cref="ArgumentException"/>.
/// </summary>
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());
}
/// <summary>
/// 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.
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
CancellationToken cancellationToken = default) =>
Capture(parts, new OverlapMaterialCache(), cancellationToken);
Capture(parts, null, new OverlapMaterialCache(), cancellationToken);
/// <summary>
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, additionally capturing
/// the plate's clamp keep-out rectangles: Analyze then reports part-vs-clamp findings
/// (C1) alongside part pairs. Null means no zones.
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
IReadOnlyList<ClampZone> clampZones, CancellationToken cancellationToken = default) =>
Capture(parts, clampZones, new OverlapMaterialCache(), cancellationToken);
/// <summary>
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
@@ -31,6 +59,12 @@ public static class PlateOverlapAnalyzer
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
CancellationToken cancellationToken = default) =>
Capture(parts, null, cache, cancellationToken);
/// <inheritdoc cref="Capture(IReadOnlyList{Part}, IReadOnlyList{ClampZone}, CancellationToken)"/>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
IReadOnlyList<ClampZone> 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<CapturedClamp>();
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());
}
/// <summary>
/// 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.
/// </summary>
private static List<PlateOverlapPair> FindClampOverlaps(List<PreparedPart> prepared,
PlateOverlapSnapshot snapshot, List<PlateOverlapIssue> issues,
CancellationToken cancellationToken)
{
var findings = new List<PlateOverlapPair>();
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<PolygonAreaMoments>();
var regions = new List<PlateOverlapRegion>();
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<PlateOverlapRegion> { 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;
}
/// <summary>
@@ -8,11 +8,14 @@ namespace OpenNest.Diagnostics;
public sealed class PlateOverlapReport
{
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
PlateOverlapSnapshot snapshot)
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
List<CutOffClampFinding> cutOffClampFindings = null)
{
Pairs = pairs.AsReadOnly();
Issues = issues.AsReadOnly();
Snapshot = snapshot;
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
}
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
@@ -20,6 +23,16 @@ public sealed class PlateOverlapReport
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
/// <summary>
/// Part-vs-clamp-zone findings (C1): the same owned-fragment shape as part pairs, with a
/// negative <see cref="PlateOverlapPair.PartBId"/> slot encoding the zone ordinal. Findings
/// are reported like other overlap findings; they do not change <see cref="IsComplete"/>.
/// </summary>
public IReadOnlyList<PlateOverlapPair> ClampFindings { get; }
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
public IReadOnlyList<CutOffClampFinding> 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
/// </summary>
public sealed class PlateOverlapSnapshot
{
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null)
{
Parts = parts.AsReadOnly();
Issues = issues.AsReadOnly();
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
}
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
internal IReadOnlyList<CapturedClamp> Clamps { get; }
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
}
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
double Rotation, Vector Location);
/// <summary>A clamp keep-out rectangle owned by the snapshot; Ordinal is its capture index.</summary>
internal sealed record CapturedClamp(int Ordinal, string Id, Box Bounds);
/// <summary>Deterministic negative PartBId for a part-vs-clamp finding's clamp slot.</summary>
internal static class ClampFindingSlot
{
public static int IdFor(int ordinal) => -2 - ordinal;
public static int OrdinalOf(int pairPartBId) => -2 - pairPartBId;
}
@@ -56,6 +56,51 @@ public static class PostVerificationAnalyzer
}
var materialParts = new List<Part>();
var indices = new List<int>();
// 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<ClampZone> 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<ClampZone>()).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;
@@ -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
@@ -24,6 +24,8 @@ public sealed record PostVerificationFinding(PostVerificationKind Kind, int Plat
/// <summary>Rapid boundary contacts in travel order; collinear contact spans use their endpoints.</summary>
public IReadOnlyList<Vector> ContactPoints { get; init; } = Array.Empty<Vector>();
public PlateOverlapPair Overlap { get; init; }
/// <summary>Active clamp contacted by a cut-off or direct XY rapid, if applicable.</summary>
public string ClampId { get; init; }
}
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
+10
View File
@@ -80,6 +80,16 @@ namespace OpenNest.Geometry
return Separation(linesA, linesB);
}
/// <summary>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.</summary>
internal static ClearanceResult BoundaryDistance(List<Line> closed, List<Line> open)
{
if (closed.Count == 0 || open.Count == 0)
return new ClearanceResult(0, new Vector(1, 0));
return Separation(closed, open);
}
/// <summary>
/// Minimum boundary distance between two non-overlapping rings and the
/// direction that translates <paramref name="linesA"/> away from
+161
View File
@@ -0,0 +1,161 @@
using System.Collections.Generic;
namespace OpenNest.Geometry;
/// <summary>
/// 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.
/// </summary>
public static class KeepOutOverlap
{
/// <summary>The scale below which tolerance-based overlap tests are not trusted for a
/// keep-out rectangle (the validation outline chord tolerance).</summary>
public const double ToleranceFreeBelow = 0.001;
/// <summary>A keep-out narrower than <see cref="ToleranceFreeBelow"/> on either axis.</summary>
public static bool NeedsToleranceFreeTest(Box keepOut) =>
keepOut.Length < ToleranceFreeBelow || keepOut.Width < ToleranceFreeBelow;
/// <summary>
/// Chord tolerance for flattening real part material against <paramref name="keepOut"/>:
/// a quarter of its smaller side, at most <see cref="ToleranceFreeBelow"/>.
/// </summary>
public static double FlatteningTolerance(Box keepOut) =>
System.Math.Min(ToleranceFreeBelow, System.Math.Min(keepOut.Length, keepOut.Width) / 4);
/// <summary>
/// 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 <see cref="FlatteningTolerance"/>. 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.
/// </summary>
public static IEnumerable<(Polygon Perimeter, List<Polygon> 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<Polygon>(profile.Cutouts.Count);
foreach (var cutout in profile.Cutouts)
holes.Add(cutout.ToPolygonWithTolerance(tolerance, circumscribe));
yield return (perimeter, holes);
}
}
/// <summary>
/// True when either of the <see cref="ConservativePolygons"/>, translated by
/// <paramref name="offset"/>, enters <paramref name="keepOut"/>.
/// </summary>
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;
}
/// <summary>
/// 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).
/// </summary>
public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> 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;
}
/// <summary>Liang-Barsky clip against the open rectangle; a positive-length part of the
/// segment must lie strictly inside it.</summary>
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;
}
/// <summary>Restricts [t0, t1] to the parameters where <c>denominator * t &lt; numerator</c>.</summary>
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;
}
/// <summary>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).</summary>
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;
}
}
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
namespace OpenNest.Geometry;
/// <summary>
/// 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.
/// </summary>
internal static class OpenPathMaterial
{
public static bool Enters(Polygon outer, IReadOnlyList<Polygon> holes, IReadOnlyList<Line> 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<double> { 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<double> 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<double> 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;
}
+25 -1
View File
@@ -65,6 +65,21 @@ namespace OpenNest
return plate;
}
/// <summary>
/// Makes <paramref name="rail"/> this nest's machine clamp rail: new plates follow it
/// (<see cref="PlateSettings.ClampRail"/>) 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.
/// </summary>
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; }
}
/// <summary>
/// The machine clamp rail new plates follow, or null for none. Set through
/// <see cref="Nest.SetClampRail"/> to also update existing plates.
/// </summary>
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;
}
}
}
+123 -3
View File
@@ -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<CutOff>();
ClampZones = new ObservableList<ClampZone>();
ClampZones.ItemAdded += (_, _) => OnClampZonesChanged();
ClampZones.ItemRemoved += (_, _) => OnClampZonesChanged();
ClampZones.ItemChanged += (_, _) => OnClampZonesChanged();
Quadrant = 1;
}
@@ -81,9 +86,20 @@ namespace OpenNest
public Spacing EdgeSpacing;
/// <summary>
/// The size of the plate.
/// The size of the plate. A plate following a <see cref="ClampRail"/> re-resolves
/// its clamp zones for the new size.
/// </summary>
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
/// </summary>
public ObservableList<CutOff> CutOffs { get; set; }
/// <summary>
/// Fixed rectangular machine-clamp keep-outs in plate coordinates
/// (<see cref="ClampZone"/>). Never parts: excluded from quantity, utilization,
/// cut sequence and CNC output; placement rules and display consume them. Mutate
/// through this list; <see cref="ClampZonesChanged"/> fires for every change.
/// </summary>
public ObservableList<ClampZone> ClampZones { get; }
/// <summary>
/// Raised after <see cref="ClampZones"/> content changes so views can refresh
/// the zone overlay. Carries no geometry arguments.
/// </summary>
public event EventHandler? ClampZonesChanged;
private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
/// <summary>
/// The machine clamp rail this plate follows, or null when its
/// <see cref="ClampZones"/> are explicit (engine sheets, files without a rail). While
/// set, the zones are derived: re-resolved whenever <see cref="Size"/> or
/// <see cref="Quadrant"/> changes, because the clamps are fixed to the machine.
/// </summary>
public ClampRail ClampRail { get; private set; }
private static readonly IReadOnlySet<string> NoRemovedClamps = ImmutableHashSet<string>.Empty;
/// <summary>
/// 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
/// <see cref="ClampRail"/> is set; <see cref="ClampZones"/> 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).
/// </summary>
public IReadOnlySet<string> RemovedClamps { get; private set; } = NoRemovedClamps;
/// <summary>
/// Minutes this sheet's cut-time estimate gains from <see cref="RemovedClamps"/> (D9):
/// each listed clamp the rail places on this sheet adds
/// <see cref="OpenNest.ClampRail.RemovalMinutes"/> 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).
/// </summary>
public double ClampRemovalMinutes =>
ClampRail == null || RemovedClamps.Count == 0
? 0
: ClampRail.ResolveFor(Size, Quadrant).Count(zone => RemovedClamps.Contains(zone.Id))
* ClampRail.RemovalMinutes;
/// <summary>
/// Follows <paramref name="rail"/> and replaces the zones with its resolution for
/// this sheet; null detaches and removes every zone. Unchanged zones raise no events.
/// </summary>
public void SetClampRail(ClampRail rail) => SetClampRail(rail, null);
/// <summary>
/// Follows <paramref name="rail"/> with the clamps named in <paramref name="removedClamps"/>
/// taken off this sheet (D8): zones become the rail's resolution for this sheet minus
/// those ids. Null <paramref name="rail"/> detaches, clears every zone and forgets the
/// removed set (removal only means something while following a rail).
/// </summary>
public void SetClampRail(ClampRail rail, IEnumerable<string> 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<ClampZone> ResolveRailZones()
{
if (ClampRail == null)
return Array.Empty<ClampZone>();
var resolved = ClampRail.ResolveFor(Size, Quadrant);
if (RemovedClamps.Count == 0)
return resolved;
var kept = new List<ClampZone>(resolved.Count);
foreach (var zone in resolved)
if (!RemovedClamps.Contains(zone.Id))
kept.Add(zone);
return kept;
}
private void ReplaceClampZones(IReadOnlyList<ClampZone> zones)
{
if (ClampRail.SameZones(ClampZones, zones))
return;
ClampZones.Clear();
foreach (var zone in zones)
ClampZones.Add(zone);
}
/// <summary>
/// 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();
}
}
/// <summary>
+44
View File
@@ -119,6 +119,50 @@ namespace OpenNest
SubscribeToTailPlates();
}
/// <summary>
/// Applies a nest-defaults edit, moving only an untouched trailing empty sheet
/// that matched the old defaults to the new defaults.
/// </summary>
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;
+8 -1
View File
@@ -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;
}
}
+11
View File
@@ -10,6 +10,13 @@ namespace OpenNest
public double CutDistance;
/// <summary>
/// Minutes added by last-resort clamp removal (D9): once per physical sheet, for
/// every clamp <see cref="Plate.RemovedClamps"/> lists. Zero for a plate with no
/// removal, so existing timing is unaffected.
/// </summary>
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,
};
}
}
+132
View File
@@ -0,0 +1,132 @@
namespace OpenNest.Data;
/// <summary>
/// Decision logic behind the desktop Clamps dialog: edits one nest's clamp rail in that
/// nest's units. Three explicit boundaries: <see cref="LoadMachineDefaults"/> only fills
/// the editor, <see cref="ApplyTo"/> changes the nest (the dialog's OK), and
/// <see cref="ToMachineDefaults"/> is what "Save as Machine Defaults" writes immediately.
/// </summary>
public sealed class ClampRailEditor
{
private readonly List<double> _centers = new();
/// <summary>Starts from the nest's rail, or the default clamp size with no clamps.</summary>
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);
}
/// <summary>The nest's units; every value is in these units.</summary>
public Units Units { get; }
/// <summary>Clamp extent along the edge (X).</summary>
public double Width { get; set; }
/// <summary>Clamp intrusion into the sheet (Y).</summary>
public double Depth { get; set; }
/// <summary>Minutes to remove one clamp and put it back after cutting (D9); not a
/// length, so it is never converted between units.</summary>
public double RemovalMinutes { get; set; }
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
public bool ApplyToNewNests { get; set; }
/// <summary>Clamp centers along X from the plate origin, ascending and distinct.</summary>
public IReadOnlyList<double> Centers => _centers;
/// <summary>
/// Adds a clamp center, keeping the list sorted. Refuses (with an operator-facing
/// reason) values that are not finite, negative, or already listed.
/// </summary>
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);
}
/// <summary>
/// Replaces the editor's values with the machine defaults converted to this nest's
/// units. Nothing is applied or saved.
/// </summary>
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));
}
/// <summary>
/// The edited rail, or null when no clamp is listed. Throws
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
/// negative/non-finite removal time.
/// </summary>
public ClampRail? BuildRail() =>
_centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers, RemovalMinutes);
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
public void ApplyTo(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
nest.SetClampRail(BuildRail());
}
/// <summary>
/// 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.
/// </summary>
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(),
};
}
}
+334
View File
@@ -0,0 +1,334 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>Outcome of <see cref="ClampSettings.Load(string, out ClampSettingsStatus)"/>.</summary>
public enum ClampSettingsStatus
{
/// <summary>Every value present in the file was valid.</summary>
Ok,
/// <summary>No file exists at the path; clamps stay off.</summary>
Missing,
/// <summary>
/// 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.
/// </summary>
Partial,
/// <summary>The file exists but could not be read or parsed; clamps stay off.</summary>
Invalid,
}
/// <summary>
/// The machine's fixed edge-clamp rail, persisted to a single JSON file (by default
/// %APPDATA%\OpenNest\clamps.json). Off by default: until <see cref="Enabled"/> 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.
/// </summary>
public sealed class ClampSettings
{
public const int CurrentVersion = 1;
/// <summary>Default clamp extent along the edge, in <see cref="Units"/> (inches).</summary>
public const double DefaultWidth = 1.5;
/// <summary>Default clamp intrusion into the sheet, in <see cref="Units"/> (inches).</summary>
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,
};
/// <summary>Whether new nests receive this rail. Default off.</summary>
public bool Enabled { get; set; }
/// <summary>Units of <see cref="Width"/>, <see cref="Depth"/> and <see cref="Centers"/>.</summary>
public Units Units { get; set; } = Units.Inches;
public double Width { get; set; } = DefaultWidth;
public double Depth { get; set; } = DefaultDepth;
/// <summary>
/// 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.
/// </summary>
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// <summary>Clamp centers along X, measured from the plate origin.</summary>
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
/// <summary>The built-in settings used for a missing or unreadable file: clamps off.</summary>
public static ClampSettings Fallback => new();
/// <summary>%APPDATA%\OpenNest\clamps.json.</summary>
public static string DefaultPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest",
"clamps.json"
);
/// <summary>
/// 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".
/// </summary>
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<double>(),
};
/// <summary>
/// The rail a new nest in <paramref name="units"/> receives, or null when clamps are
/// disabled or no center is configured. Values are converted between inches and
/// millimeters as needed; <see cref="RemovalMinutes"/> is a time, so it is never scaled.
/// </summary>
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);
}
/// <summary>
/// A copy with width, depth and centers expressed in <paramref name="units"/>
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
/// </summary>
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(),
};
}
/// <summary>
/// Gives a newly created nest this machine rail when enabled (after the nest's units are
/// set); otherwise leaves it untouched.
/// </summary>
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 _);
/// <summary>
/// Loads the settings, never throwing. A missing or unreadable file yields
/// <see cref="Fallback"/> (clamps off); a readable file merges valid values over it and
/// reports <see cref="ClampSettingsStatus.Partial"/> if anything was ignored.
/// </summary>
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<string, JsonElement> values;
try
{
using var document = JsonDocument.Parse(File.ReadAllText(path));
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
status = ClampSettingsStatus.Invalid;
return settings;
}
values = new Dictionary<string, JsonElement>(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<double>();
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;
}
/// <summary>
/// Writes the file (creating the parent directory), retrying briefly on IO collisions
/// the same way <see cref="LocalJsonProvider"/> does.
/// </summary>
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);
/// <summary>
/// A finite JSON number that stays finite in either unit, so applying it to an inch or a
/// millimeter nest can never overflow.
/// </summary>
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);
}
/// <summary>
/// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept
/// numeric strings and comma-joined names.
/// </summary>
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<Units>())
{
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;
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<Part> { 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<Part> { 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<Part> { 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<Part> { 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<Part> { 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<Part> { 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<Part> { 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<Part> { 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<Part> { 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);
}
}
@@ -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<Entity> { 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<Part> { 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<Part> { 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<Entity> { 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<Part> { 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<Part> { 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);
}
}
@@ -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<object[]> Cases()
{
foreach (var quadrant in new[] { 1, 2, 3, 4 })
foreach (var verticalMotion in new[] { false, true })
foreach (var cutoff in new[] { false, true })
foreach (var expand in new[] { false, true })
yield return new object[] { quadrant, verticalMotion, cutoff, expand };
}
[Theory]
[MemberData(nameof(Cases))]
public void RemoteCutoffMustNotPinNegativeParallelMotion(
int quadrant, bool verticalMotion, bool cutoff, bool expand)
{
var plate = new Plate(new Size(80, 80))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0, 0),
};
var origin = new Vector(
quadrant == 2 || quadrant == 3 ? -80 : 0,
quadrant == 3 || quadrant == 4 ? -80 : 0);
var anchorPose = origin + new Vector(30, 30);
var moverPose = anchorPose + (verticalMotion ? new Vector(0, -3) : new Vector(-3, 0));
var anchor = new Part(new Drawing("anchor", Square()), anchorPose);
var mover = new Part(new Drawing("mover", Square()), moverPose);
plate.Parts.Add(anchor);
plate.Parts.Add(mover);
if (cutoff)
{
plate.CutOffs.Add(new CutOff(
origin + (verticalMotion ? new Vector(40, 0) : new Vector(0, 40)),
verticalMotion ? CutOffAxis.Vertical : CutOffAxis.Horizontal));
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
}
Vector end;
int violations;
if (expand)
{
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 3 });
end = mover.Location;
violations = result.Violations.Count;
Assert.True(result.AchievedSpacing >= 2.99,
$"spacing={result.AchievedSpacing:R}; end=({end.X:R},{end.Y:R})");
}
else
{
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 3);
end = result.Positions[1];
violations = result.Violations.Count;
Assert.True(result.Converged,
$"unchanged=({end.X:R},{end.Y:R}), violations={violations}");
Assert.Equal(moverPose.X, mover.Location.X);
Assert.Equal(moverPose.Y, mover.Location.Y);
}
// Two units of negative-axis motion opens the selected gap 1 -> 3.
// The parallel open cutoff remains eight units away throughout.
Assert.True(verticalMotion ? end.Y <= moverPose.Y - 1.999 : end.X <= moverPose.X - 1.999);
Assert.Equal(0, violations);
Assert.Equal(anchorPose.X, anchor.Location.X);
Assert.Equal(anchorPose.Y, anchor.Location.Y);
if (cutoff)
{
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Single(plate.CutOffs);
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
}
}
}
@@ -0,0 +1,149 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderFinalQualityTests
{
private static Plate Sheet() => new(new Size(12, 30)) { EdgeSpacing = new Spacing(0, 0) };
private static Program Disk(double radius) =>
ConvertGeometry.ToProgram(new List<Entity> { new Circle(0, 0, radius) });
private static Program Rectangle(double width, double height)
{
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;
}
[Theory]
[InlineData(0.73, 0.001)]
[InlineData(0.017, 0.001)]
[InlineData(0.73, 0.0001)]
public void Expand_NativeDisks_MustReportOrRemoveRemainingOverlap(double angle, double depth)
{
var plate = Sheet();
var anchor = Add(plate, Disk(2), 7, 6);
var mover = Add(plate, Disk(2),
7 + (4 - depth) * System.Math.Cos(angle),
6 + (4 - depth) * System.Math.Sin(angle));
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 3 });
var nativeGap = anchor.Location.DistanceTo(mover.Location) - 4;
Assert.True(nativeGap >= -1e-8 || result.Violations.Any(v => v.Achieved < 0),
$"Native disks still overlap by {-nativeGap:R} but spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}; mover=({mover.Location.X:R},{mover.Location.Y:R})");
}
[Fact]
public void Separate_CurvedExactContact_CanStillGrowARealGap()
{
var plate = Sheet();
Add(plate, Disk(2), 10, 6);
var mover = Add(plate, Rectangle(1, 1), 12, 5.5);
var result = Expander.Separate(new List<Part> { mover }, plate, spacing: 1);
Assert.True(result.Converged, $"curved contact pinned: {result.Violations.Count} violations");
Assert.True(result.Positions[0].X >= 13 - 1e-3);
Assert.Equal(12, mover.Location.X);
}
[Fact]
public void Separate_SmallConcaveOverlap_MustNotClaimConvergedAtZero()
{
var plate = Sheet();
var program = new Program();
const double k = 0.0001;
program.MoveTo(0, 0);
program.LineTo(6 * k, 0);
program.LineTo(6 * k, 6 * k);
program.LineTo(4 * k, 6 * k);
program.LineTo(4 * k, 2 * k);
program.LineTo(2 * k, 2 * k);
program.LineTo(2 * k, 6 * k);
program.LineTo(0, 6 * k);
program.LineTo(0, 0);
Add(plate, program, 10, 5);
var mover = Add(plate, Rectangle(2 * k, 2 * k), 10 + k, 5 + 3 * k);
// Left finger [10,10.0002] × [5.0002,5.0006] intersects the
// mover [10.0001,10.0003] × [5.0003,5.0005] in positive area.
var result = Expander.Separate(new List<Part> { mover }, plate, 0);
Assert.False(result.Converged,
$"Known material overlap; pose={result.Positions[0]}, violations={result.Violations.Count}");
Assert.Contains(result.Violations, v => v.Achieved < 0);
}
[Fact]
public void Expand_RemoteRegeneratedCutoff_MustNotPinUnrelatedGrowth()
{
var plate = Sheet();
var anchor = Add(plate, Rectangle(2, 2), 1, 4);
var mover = Add(plate, Rectangle(2, 2), 4, 4);
plate.CutOffs.Add(new CutOff(new Vector(0, 10), CutOffAxis.Horizontal));
var settings = new CutOffSettings();
plate.RegenerateCutOffs(settings);
var cutPart = Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 3 });
Assert.True(mover.Location.X >= 6 - 1e-4 && result.AchievedSpacing >= 2.99,
$"Remote cutoff pinned horizontal move: x={mover.Location.X:R}, achieved={result.AchievedSpacing:R}, violations={result.Violations.Count}");
Assert.Equal(0, cutPart.Location.X);
Assert.Equal(0, cutPart.Location.Y);
plate.RegenerateCutOffs(settings);
Assert.Single(plate.CutOffs);
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
}
[Fact]
public void Expand_RegeneratedCutoffBlocksCrossingEvenWithClearFarEndpoint()
{
var plate = new Plate(new Size(20, 30)) { EdgeSpacing = new Spacing(0, 0) };
var anchor = Add(plate, Rectangle(2, 2), 1, 1);
var mover = Add(plate, Rectangle(2, 2), 1, 6);
plate.CutOffs.Add(new CutOff(new Vector(0, 10), CutOffAxis.Horizontal));
var settings = new CutOffSettings();
plate.RegenerateCutOffs(settings);
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, spacing: 12);
Assert.False(result.Converged);
Assert.True(result.Positions[1].Y + 2 <= 10 + 1e-6,
$"Mover crossed regenerated cut path: y={result.Positions[1].Y:R}");
Assert.Equal(6, mover.Location.Y);
plate.RegenerateCutOffs(settings);
Assert.Single(plate.CutOffs);
}
[Fact]
public void Expand_OutsideUnchangedMover_MustNotReturnUnqualifiedSuccess()
{
var plate = Sheet();
var anchor = Add(plate, Rectangle(2, 2), 1, 4);
var mover = Add(plate, Rectangle(2, 2), 35, 4);
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 3 });
Assert.True(mover.BoundingBox.Right > 30); // The invalid entry stays unchanged.
Assert.Equal(35, mover.Location.X);
Assert.Equal(0, result.AchievedSpacing);
var edge = Assert.Single(result.Violations.Where(v => ReferenceEquals(v.A, mover)));
Assert.Null(edge.B);
Assert.True(edge.BlockedByEdge);
}
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderIntersectingOpenPathTests
{
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<object[]> Cases()
{
foreach (var quadrant in new[] { 1, 2, 3, 4 })
foreach (var transpose in new[] { false, true })
foreach (var expand in new[] { false, true })
yield return new object[] { quadrant, transpose, expand };
}
[Theory]
[MemberData(nameof(Cases))]
public void IntersectingOpenPathMustRemainAViolation(int quadrant, bool transpose, bool expand)
{
var origin = new Vector(quadrant is 2 or 3 ? -100 : 0,
quadrant is 3 or 4 ? -100 : 0);
Vector Swap(double x, double y) => transpose ? new Vector(y, x) : new Vector(x, y);
var plate = new Plate(new Size(100, 100))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0, 0),
};
var expectedA = origin + Swap(40, 40);
var expectedB = origin + Swap(43, 40);
var anchor = new Part(new Drawing("anchor", Square()), expectedA);
var mover = new Part(new Drawing("mover", Square()), expectedB);
plate.Parts.Add(anchor);
plate.Parts.Add(mover);
var path = new Program();
var start = Swap(20, 40);
var across = Swap(70, 42);
var high = Swap(70, 60);
var tail = Swap(75, 60);
path.MoveTo(start.X, start.Y);
path.LineTo(across.X, across.Y);
path.LineTo(high.X, high.Y);
path.LineTo(tail.X, tail.Y);
var cutoff = new Part(new Drawing("neutral-open-cut", path) { IsCutOff = true }, origin);
plate.Parts.Add(cutoff);
// First segment is y=40+0.04*(x-20). At x=41 and x=44,
// y=40.84 and 40.96 are strictly INSIDE both squares. Signed origins
// and transposition preserve these independent interior witnesses.
List<Expander.Violation> violations;
if (expand)
{
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 0.1 });
violations = result.Violations;
Assert.Equal(0, result.AchievedSpacing);
}
else
{
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 0);
violations = result.Violations;
Assert.False(result.Converged,
$"Native path intersects both squares but reports converged with {violations.Count} violations");
}
Assert.Contains(violations, v => ReferenceEquals(v.B, cutoff) && v.Achieved < 0);
Assert.Equal(expectedA.X, anchor.Location.X);
Assert.Equal(expectedA.Y, anchor.Location.Y);
Assert.Equal(expectedB.X, mover.Location.X);
Assert.Equal(expectedB.Y, mover.Location.Y);
}
}
@@ -0,0 +1,101 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderQualityReviewTests
{
private static Program Rectangle(double width, double height)
{
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 AddSquare(Plate plate, double x, double y, double size)
{
var part = new Part(new Drawing("neutral", Rectangle(size, size)), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Plate MakePlate(double length) =>
new(new Size(12, length)) { EdgeSpacing = new Spacing(0, 0) };
[Fact]
public void TinyZoneContainedInMover_MustNotReturnClearPositiveSpacing()
{
var plate = MakePlate(30);
plate.ClampZones.Add(new ClampZone("tiny", new Box(5, 5, 1e-5, 1e-5)));
var anchor = AddSquare(plate, 1, 4, 2);
var mover = AddSquare(plate, 4, 4, 2);
// Independent oracle: [5,5.00001]^2 lies strictly inside [4,6]^2.
var result = Expander.Expand(
new List<Part> { anchor, mover },
plate,
new Expander.Options { InitialStep = 3, MaxSpacing = 3 }
);
Assert.True(
result.Violations.Count > 0,
$"Physical clamp is still inside mover but report is clear: spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}"
);
Assert.True(result.Violations[0].Achieved < 0);
}
[Fact]
public void TinyZoneInsideCutoutIsNotReportedAsMaterialOverlap()
{
var plate = MakePlate(30);
plate.ClampZones.Add(new ClampZone("tiny", new Box(5, 5, 1e-5, 1e-5)));
var frameProgram = Rectangle(10, 10);
frameProgram.MoveTo(4, 4);
frameProgram.LineTo(6, 4);
frameProgram.LineTo(6, 6);
frameProgram.LineTo(4, 6);
frameProgram.LineTo(4, 4);
var frame = new Part(new Drawing("neutral-frame", frameProgram), Vector.Zero);
plate.Parts.Add(frame);
var result = Expander.Separate(new List<Part> { frame }, plate, spacing: 0.5);
Assert.True(result.Converged, $"zone inside cutout reported as material: {result.Violations.Count}");
Assert.Empty(result.Violations);
Assert.Equal(0, frame.Location.X);
}
[Fact]
public void EdgeLimitedConvexOverlap_ShouldRetainCertifiedPartialImprovement()
{
var plate = MakePlate(11.5);
var anchor = AddSquare(plate, 5, 4, 4);
var mover = AddSquare(plate, 7, 4, 4);
var result = Expander.Expand(
new List<Part> { anchor, mover }, plate, new Expander.Options { MaxSpacing = 1 }
);
// Along x in [7,7.5], penetration is 9-x and strictly decreases.
Assert.True(
mover.Location.X >= 7.49,
$"Lost safe partial-overlap improvement: x={mover.Location.X:R}; expected edge at 7.5"
);
Assert.NotEmpty(result.Violations);
Assert.True(mover.Location.X + 4 <= 11.5 + 1e-9);
}
[Fact]
public void NegativeTarget_ConvexOverlapCanReachLessNegativeClearance()
{
var plate = MakePlate(30);
var anchor = AddSquare(plate, 5, 4, 4);
var mover = AddSquare(plate, 7, 4, 4);
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, -1);
Assert.True(result.Converged, $"Signed target -1 was not reached; x={result.Positions[1].X:R}");
Assert.True(result.Positions[1].X >= 8);
Assert.Equal(7, mover.Location.X);
}
}
@@ -0,0 +1,86 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderRemoteObliquePathTests
{
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<object[]> Cases()
{
foreach (var quadrant in new[] { 1, 2, 3, 4 })
foreach (var transpose in new[] { false, true })
foreach (var cutoff in new[] { false, true })
foreach (var expand in new[] { false, true })
yield return new object[] { quadrant, transpose, cutoff, expand };
}
[Theory]
[MemberData(nameof(Cases))]
public void RemoteObliqueOpenPathMustNotPinSafeParallelGrowth(
int quadrant, bool transpose, bool cutoff, bool expand)
{
var origin = new Vector(quadrant is 2 or 3 ? -100 : 0,
quadrant is 3 or 4 ? -100 : 0);
Vector Swap(double x, double y) => transpose ? new Vector(y, x) : new Vector(x, y);
var plate = new Plate(new Size(100, 100))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0, 0),
};
var anchorPose = origin + Swap(40, 40);
var moverPose = origin + Swap(43, 40);
var anchor = new Part(new Drawing("anchor", Square()), anchorPose);
var mover = new Part(new Drawing("mover", Square()), moverPose);
plate.Parts.Add(anchor);
plate.Parts.Add(mover);
if (cutoff)
{
var path = new Program();
var start = Swap(18, 32);
var middle = Swap(68, 64);
var high = Swap(68, 80);
var tail = Swap(75, 80);
path.MoveTo(start.X, start.Y);
path.LineTo(middle.X, middle.Y);
path.LineTo(high.X, high.Y);
path.LineTo(tail.X, tail.Y);
plate.Parts.Add(new Part(new Drawing("neutral-path", path) { IsCutOff = true }, origin));
}
// First line is y=.64*x+20.48. The anchor's nearest corner (40,42)
// has exact gap 4.08/sqrt(1+.64^2)>3; mover (43,42) >5.
Vector end;
if (expand)
{
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 2 });
end = mover.Location;
Assert.True(result.AchievedSpacing >= 1.99,
$"Remote cutoff pinned mover: spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}");
}
else
{
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 2);
end = result.Positions[1];
Assert.True(result.Converged,
$"Remote cutoff pinned mover: end=({end.X:R},{end.Y:R}), violations={result.Violations.Count}");
Assert.Equal(moverPose.X, mover.Location.X);
Assert.Equal(moverPose.Y, mover.Location.Y);
}
Assert.True(transpose ? end.Y >= moverPose.Y + 0.999 : end.X >= moverPose.X + 0.999);
Assert.Equal(anchorPose.X, anchor.Location.X);
Assert.Equal(anchorPose.Y, anchor.Location.Y);
}
}
@@ -0,0 +1,77 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderSpecRegressionTests
{
private static Program Disk(double radius)
{
var p = new Program();
p.MoveTo(radius, 0);
p.ArcTo(-radius, 0, 0, 0, RotationType.CCW);
p.ArcTo(radius, 0, 0, 0, RotationType.CCW);
return p;
}
private static (Plate Plate, Part A, Part B) Fixture()
{
var plate = new Plate(new Size(12, 30)) { EdgeSpacing = new Spacing(0, 0) };
var a = new Part(new Drawing("disk-a", Disk(2)), new Vector(7, 6));
var b = new Part(new Drawing("disk-b", Disk(2)), new Vector(9, 6));
plate.Parts.Add(a);
plate.Parts.Add(b);
return (plate, a, b);
}
[Fact]
public void Separate_OverlappingNativeDisks_RetainsCertifiedConvexExit()
{
var (plate, a, b) = Fixture();
var result = Expander.Separate(new List<Part> { a, b }, plate, spacing: 3);
Assert.True(result.Converged,
$"remaining violations={result.Violations.Count}; mover=({result.Positions[1].X:R},{result.Positions[1].Y:R})");
// Independent analytic disk material clearance, not production polygons.
Assert.True(result.Positions[1].DistanceTo(result.Positions[0]) - 4 >= 2.99);
Assert.Equal(7, a.Location.X);
Assert.Equal(9, b.Location.X); // scratch-only
}
[Fact]
public void Separate_OverlappingNativeDisks_CannotPassThroughThirdWall()
{
var (plate, a, b) = Fixture();
var wallProgram = new Program();
wallProgram.MoveTo(0, 0);
wallProgram.LineTo(0.2, 0);
wallProgram.LineTo(0.2, 12);
wallProgram.LineTo(0, 12);
wallProgram.LineTo(0, 0);
var wall = new Part(new Drawing("fixed-wall", wallProgram), new Vector(12, 0));
plate.Parts.Add(wall);
var result = Expander.Separate(new List<Part> { a, b }, plate, spacing: 3);
Assert.False(result.Converged);
Assert.True(result.Positions[1].X + 2 <= 12 + 1e-6,
$"native disk crossed third wall: centre={result.Positions[1].X:R}");
Assert.Equal(9, b.Location.X);
Assert.Equal(12, wall.Location.X);
}
[Fact]
public void Expand_OverlappingNativeDisks_RetainsExistingSeparation()
{
var (plate, a, b) = Fixture();
var result = Expander.Expand(new List<Part> { a, b }, plate,
new Expander.Options { InitialStep = 0.5, MaxSpacing = 3 });
var nativeGap = a.Location.DistanceTo(b.Location) - 4;
Assert.True(nativeGap >= 2.99,
$"native gap={nativeGap:R}; achieved={result.AchievedSpacing:R}; violations={result.Violations.Count}");
Assert.True(result.AchievedSpacing >= 2.9);
Assert.Empty(result.Violations);
Assert.Equal(7, a.Location.X);
}
}
+324
View File
@@ -33,6 +33,13 @@ public class ExpanderTests
return part;
}
private static Part AddRectangle(Plate plate, double x, double y, double length, double width)
{
var part = new Part(new Drawing($"rect{plate.Parts.Count}", Rectangle(length, width)), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5)
{
var plate = new Plate(new Size(widthY, lengthX));
@@ -327,6 +334,323 @@ public class ExpanderTests
Assert.Throws<ArgumentNullException>(() => Expander.Expand(new List<Part> { a, a }, null));
}
[Fact]
public void Separate_PreservesEachEntryGap_EvenWhenTargetIsSmaller()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 1, 4, 2);
var mover = AddSquare(plate, 4, 4, 2);
var wall = AddSquare(plate, 12, 4, 2);
var initialWallGap = wall.Location.X - (mover.Location.X + 2);
var (converged, positions, _) = Expander.Separate(
new List<Part> { anchor, mover }, plate, spacing: 3
);
// The wall is six units away at entry. Merely clearing the requested
// three-unit spacing is not permission to consume that original gap.
Assert.True(positions[1].X + 2 <= wall.Location.X - initialWallGap + 1e-6,
$"entry gap {initialWallGap}, proposed gap {wall.Location.X - positions[1].X - 2}");
Assert.Equal(new Vector(4, 4), mover.Location);
Assert.Equal(new Vector(12, 4), wall.Location);
Assert.False(converged); // This direct translation cannot reach 3 safely.
}
[Fact]
public void Separate_RetriesMiddleAfterLaterBlockerMoves()
{
var plate = MakePlate(60, 14, edge: 0);
var first = AddSquare(plate, 5, 5);
var middle = AddSquare(plate, 10, 5);
var last = AddSquare(plate, 14, 5);
var selected = new List<Part> { first, middle, last };
var (converged, positions, violations) = Expander.Separate(selected, plate, spacing: 2);
Assert.True(converged, $"remaining: {violations.Count}");
Assert.Empty(violations);
Assert.Equal(5, positions[0].X);
Assert.True(positions[1].X >= 11 - 1e-4);
Assert.True(positions[2].X >= positions[1].X + 6 - 1e-4);
Assert.Equal(10, middle.Location.X); // Separate is scratch-only.
Assert.Equal(14, last.Location.X);
var result = Expander.Expand(selected, plate, new Expander.Options { MaxSpacing = 2 });
Assert.True(result.AchievedSpacing >= 1.99, $"achieved {result.AchievedSpacing}");
Assert.True(middle.Location.X >= 11 - 1e-4);
Assert.True(last.Location.X >= middle.Location.X + 6 - 1e-4);
Assert.Equal(5, first.Location.X);
}
[Fact]
public void Separate_CannotPassThroughFullHeightWall_ToClearEndpoint()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 1, 4, 2);
var moving = AddSquare(plate, 4, 4, 2);
var wall = AddRectangle(plate, 6.5, 0, 0.2, 12);
// Target 5 puts the otherwise clear endpoint beyond the wall AND
// preserves the entry wall gap of 0.5. An endpoint-only check passes it.
var (converged, poses, violations) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 5
);
Assert.False(converged);
Assert.NotEmpty(violations);
Assert.True(poses[1].X + 2 <= wall.Location.X + 1e-6,
$"move crossed wall: right={poses[1].X + 2}, wall={wall.Location.X}");
Assert.Equal(4, moving.Location.X); // public Separate never commits.
Assert.Equal(6.5, wall.Location.X);
var result = Expander.Expand(new List<Part> { anchor, moving }, plate,
new Expander.Options { InitialStep = 5, MaxSpacing = 5 });
Assert.True(moving.Location.X + 2 <= wall.Location.X + 1e-6);
Assert.True(result.AchievedSpacing < 5);
}
[Fact]
public void Separate_OverlappingPair_CannotExitAcrossThirdWall()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 5, 4);
var moving = AddSquare(plate, 7, 4); // Overlaps anchor by two.
var wall = AddRectangle(plate, 12, 0, 0.2, 12);
var (converged, poses, violations) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 5
);
Assert.False(converged);
Assert.NotEmpty(violations);
Assert.True(poses[1].X + 4 <= wall.Location.X + 1e-6,
$"overlap exit crossed third wall: {poses[1].X}");
Assert.Equal(7, moving.Location.X);
Assert.Equal(12, wall.Location.X);
}
[Fact]
public void Separate_CannotCrossOppositeCutoutWall()
{
var plate = MakePlate(40, 20, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(10, 10, 0.2, 0.2, 9.6, 9.6)), new Vector(5, 1));
plate.Parts.Add(host);
var anchor = AddSquare(plate, 1, 5, 1);
var moving = AddSquare(plate, 5.2, 5, 1); // Touches inner left wall.
var (converged, poses, _) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 14
);
Assert.False(converged);
Assert.True(poses[1].X + 1 <= 14.8 + 1e-6,
$"insert crossed opposite hole wall: {poses[1].X}");
Assert.Equal(5.2, moving.Location.X);
}
[Fact]
public void Separate_TinyClampZone_IsNotAnInvisibleWall()
{
var plate = MakePlate(30, 12, edge: 0);
plate.ClampZones.Add(new ClampZone("tiny", new Box(6.5, 0, 1e-5, 1e-5)));
var anchor = AddSquare(plate, 1, 0, 2);
var moving = AddSquare(plate, 4, 0, 2);
var (converged, poses, _) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 6
);
Assert.False(converged);
Assert.True(poses[1].X + 2 <= 6.5 + 1e-6,
$"crossed tiny clamp at x=6.5: right={poses[1].X + 2}");
Assert.Equal(4, moving.Location.X);
}
[Fact]
public void Separate_RemoteTinyClampDoesNotPinUnrelatedGrowth()
{
var plate = MakePlate(30, 12, edge: 0);
plate.ClampZones.Add(new ClampZone("tiny", new Box(0, 0, 1e-5, 1e-5)));
var anchor = AddSquare(plate, 1, 4, 2);
var moving = AddSquare(plate, 4, 4, 2);
var (converged, poses, violations) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 3
);
Assert.True(converged, $"remote zone pinned growth: {violations.Count} violations, " +
$"poses {poses[0].X:R},{poses[1].X:R}; " +
string.Join("; ", violations.Select(v => $"{v.A.BaseDrawing.Name}/{v.B.BaseDrawing.Name}={v.Achieved:R}")));
Assert.True(poses[1].X >= 6 - 1e-4);
Assert.Equal(4, moving.Location.X);
}
[Fact]
public void Separate_TouchingHoleWall_UsesOppositeDirectionToOpenGap()
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), new Vector(0, 0));
plate.Parts.Add(host);
var insert = AddSquare(plate, 13, 9, 2);
var (converged, poses, violations) = Expander.Separate(
new List<Part> { insert }, plate, spacing: 1
);
Assert.True(converged, $"contact still blocked: {violations.Count}");
Assert.True(poses[0].X <= 12 + 1e-4, $"insert at x={poses[0].X}");
Assert.True(poses[0].X >= 5 + 1 - 1e-4);
Assert.Equal(13, insert.Location.X);
Assert.Equal(0, host.Location.X);
}
[Theory]
[InlineData(13, 9)]
[InlineData(5, 9)]
[InlineData(9, 13)]
[InlineData(9, 5)]
[InlineData(12.9, 9)] // Positive gap uses its ordinary nearest-boundary direction.
public void Separate_FourHoleSides_ReachARealUnitGap(double x, double y)
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), Vector.Zero);
plate.Parts.Add(host);
var insert = AddSquare(plate, x, y, 2);
var (converged, poses, violations) = Expander.Separate(new List<Part> { insert }, plate, 1);
Assert.True(converged, $"{x},{y}: {violations.Count} violations");
Assert.InRange(poses[0].X, 6 - 1e-4, 12 + 1e-4);
Assert.InRange(poses[0].Y, 6 - 1e-4, 12 + 1e-4);
Assert.Equal(x, insert.Location.X);
Assert.Equal(y, insert.Location.Y);
}
[Fact]
public void Expand_TouchingHoleInsertWithSecondSelectedPart_StaysInsideCutout()
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), Vector.Zero);
plate.Parts.Add(host);
var insert = AddSquare(plate, 13, 9, 2);
var other = AddSquare(plate, 30, 9, 2);
var result = Expander.Expand(new List<Part> { insert, other }, plate,
new Expander.Options { MaxSpacing = 1 });
Assert.False(result.Cancelled);
Assert.True(result.AchievedSpacing >= 0.99);
Assert.InRange(insert.Location.X, 6 - 1e-4, 12 + 1e-4);
Assert.False(MateriallyOverlaps(insert, host));
Assert.Equal(0, host.Location.X);
}
[Fact]
public void Separate_ReportsPartBlockerRatherThanPlateEdge()
{
var plate = MakePlate(20, 20, edge: 0);
var pinned = AddSquare(plate, 8, 8);
AddSquare(plate, 3.8, 8);
AddSquare(plate, 12.2, 8);
AddSquare(plate, 8, 3.8);
AddSquare(plate, 8, 12.2);
var (_, poses, violations) = Expander.Separate(new List<Part> { pinned }, plate, 1);
Assert.NotEmpty(violations);
Assert.All(violations, v => Assert.False(v.BlockedByEdge,
$"{v.B.BaseDrawing.Name}: blocker before the work-area edge, pose={poses[0]}"));
}
[Fact]
public void Separate_ReportsActualPlateEdgeWhenItLimitsMovement()
{
var plate = MakePlate(12, 12, edge: 0);
var anchor = AddSquare(plate, 1, 4, 2);
var moving = AddSquare(plate, 7, 4, 2);
var (_, poses, violations) = Expander.Separate(new List<Part> { anchor, moving }, plate, 9);
var pair = Assert.Single(violations.Where(v => ReferenceEquals(v.A, anchor) && ReferenceEquals(v.B, moving)));
Assert.True(pair.BlockedByEdge);
Assert.True(poses[1].X + 2 <= 12 + 1e-6);
}
[Fact]
public void Separate_ReportsPartBeforeFartherEdge_OnNarrowPlate()
{
var plate = MakePlate(9, 2, edge: 0);
var anchor = AddSquare(plate, 0, 0, 2);
var moving = AddSquare(plate, 3, 0, 2);
var wall = AddRectangle(plate, 5.5, 0, 0.2, 2);
var (_, poses, violations) = Expander.Separate(new List<Part> { anchor, moving }, plate, 4);
var pair = Assert.Single(violations.Where(v => ReferenceEquals(v.A, anchor) && ReferenceEquals(v.B, moving)));
Assert.False(pair.BlockedByEdge);
Assert.True(poses[1].X + 2 <= wall.Location.X + 1e-6);
}
[Theory]
[InlineData((int)Expander.Checkpoint.AfterScratchMove)]
[InlineData((int)Expander.Checkpoint.AfterSuccessfulProbe)]
[InlineData((int)Expander.Checkpoint.BeforeApply)]
public void Expand_CancellationAtDeterministicCheckpoint_LeavesLiveStateBitIdentical(int stage)
{
var cancelAt = (Expander.Checkpoint)stage;
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 5, 4);
var moving = AddSquare(plate, 10, 4);
var wall = AddSquare(plate, 0, 4);
var before = plate.Parts.Select(p => (
Part: p, Program: p.Program,
X: BitConverter.DoubleToInt64Bits(p.Location.X),
Y: BitConverter.DoubleToInt64Bits(p.Location.Y),
Left: BitConverter.DoubleToInt64Bits(p.BoundingBox.Left))).ToArray();
var partSpacing = plate.PartSpacing;
var edgeSpacing = plate.EdgeSpacing;
using var cts = new CancellationTokenSource();
var seen = false;
var result = Expander.ExpandObserved(new List<Part> { anchor, moving }, plate,
new Expander.Options { MaxSpacing = 3 }, checkpoint =>
{
if (checkpoint != cancelAt)
return;
seen = true;
cts.Cancel();
}, cts.Token);
Assert.True(seen, $"checkpoint {cancelAt} was not reached");
Assert.True(result.Cancelled);
foreach (var state in before)
{
Assert.Same(state.Program, state.Part.Program);
Assert.Equal(state.X, BitConverter.DoubleToInt64Bits(state.Part.Location.X));
Assert.Equal(state.Y, BitConverter.DoubleToInt64Bits(state.Part.Location.Y));
Assert.Equal(state.Left, BitConverter.DoubleToInt64Bits(state.Part.BoundingBox.Left));
}
Assert.Equal(partSpacing, plate.PartSpacing);
Assert.Equal(edgeSpacing, plate.EdgeSpacing);
Assert.Equal(0, wall.Location.X);
}
[Fact]
public void Separate_PinnedBetweenFixedParts_StopsAfterOneNoProgressPass()
{
var plate = MakePlate(20, 20, edge: 0);
var pinned = AddSquare(plate, 8, 8);
AddSquare(plate, 3.8, 8);
AddSquare(plate, 12.2, 8);
AddSquare(plate, 8, 3.8);
AddSquare(plate, 8, 12.2);
var passes = 0;
var initial = pinned.Location;
var (converged, poses, violations) = Expander.SeparateObserved(
new List<Part> { pinned }, plate, spacing: 1,
checkpoint =>
{
if (checkpoint == Expander.Checkpoint.AfterPairPass)
passes++;
}, maxIterations: 10000
);
Assert.False(converged);
Assert.NotEmpty(violations);
Assert.Equal(1, passes); // Not a MaxIterations spin or wall-clock assertion.
Assert.Equal(initial, poses[0]);
Assert.Equal(initial, pinned.Location);
}
[Fact]
public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance()
{
@@ -0,0 +1,103 @@
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// A template plate following the machine clamp rail offers every stock size with the rail
/// resolved for that size (D2): no clamp beyond a shorter sheet, every clamp on a longer
/// one, measured from the origin in every quadrant.
/// </summary>
public class ClampRailStockTests
{
private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 };
private static Plate RailTemplate(int quadrant = 1)
{
var template = new Plate(48, 120) { Quadrant = quadrant, EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25) };
template.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters));
return template;
}
private static PlateOption[] Options() =>
[
new PlateOption { Width = 48, Length = 96 },
new PlateOption { Width = 60, Length = 144 },
new PlateOption { Width = 48, Length = 60 },
];
[Fact]
public void EachOptionGetsTheRailResolvedForItsOwnSize()
{
var stock = NestStockBuilder.FromTemplate(RailTemplate(), Options());
Assert.Equal(new[] { 7, 8, 4 }, stock.Select(s => s.ClampZones.Count));
Assert.Equal(new[] { 5.25, 20.25, 35.25, 50.25 }, stock[2].ClampZones.Select(z => z.Bounds.X));
// Every offered sheet passes job validation: no clamp lies outside its sheet.
NestJobValidator.Validate(new NestJob([], stock));
}
[Theory]
[InlineData(2)]
[InlineData(3)]
public void OptionsResolveFromTheOriginWhenSheetsExtendToNegativeX(int quadrant)
{
var stock = NestStockBuilder.FromTemplate(RailTemplate(quadrant), Options());
foreach (var sheet in stock)
{
// Placement frame: the first clamp is 6" from the origin, toward -X.
var first = sheet.ClampZones.Single(z => z.Id == "clamp-1").Bounds;
Assert.Equal(-6.0, first.Center.X, 9);
}
NestJobValidator.Validate(new NestJob([], stock));
}
[Theory]
[InlineData("Rectangles")]
[InlineData("Irregular")]
[InlineData("Default")]
public void LanesAvoidTheWholeFootprintOfAClampHangingPastTheSheetEnd(string engine)
{
// 12" sheet, clamp centered 12.25" from the origin: 1" of its 1.5" lies past the end.
var template = new Plate(10, 12) { EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25), PartSpacing = 0.25 };
template.SetClampRail(new ClampRail(1.5, 0.625, new[] { 12.25 }));
var zone = Assert.Single(template.ClampZones);
Assert.Equal(11.5, zone.Bounds.X);
Assert.Equal(1.5, zone.Bounds.Length);
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(1, 1));
var result = NestPipeline.Run(new NestPipelineRequest(engine,
[new NestItem { Drawing = drawing, Quantity = 80 }], NestStockBuilder.SinglePlate(template)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var parts = result.Plates.SelectMany(p => p.Parts).ToList();
Assert.NotEmpty(parts);
// Independent oracle: the whole clamp grown by the bottom edge spacing.
var keepOut = zone.Bounds.Offset(0.25);
Assert.All(parts, part =>
{
var box = part.BoundingBox;
var overlapX = System.Math.Min(box.Right, keepOut.Right) - System.Math.Max(box.Left, keepOut.Left);
var overlapY = System.Math.Min(box.Top, keepOut.Top) - System.Math.Max(box.Bottom, keepOut.Bottom);
Assert.False(overlapX > 1e-6 && overlapY > 1e-6, $"{box} enters {keepOut}");
});
// Rectangles still uses the front edge beside the clamp. (Irregular may lift its whole
// block above the clamp band at the same count; that is a layout choice, not a leak.)
if (engine == "Rectangles")
Assert.Contains(parts, p => p.BoundingBox.Bottom < keepOut.Top && p.BoundingBox.Right <= keepOut.Left + 1e-6);
}
[Fact]
public void SingleSheetStockCarriesTheTemplatesRailZones()
{
var template = RailTemplate();
var single = Assert.Single(NestStockBuilder.SinglePlate(template));
Assert.Equal(8, single.ClampZones.Count);
Assert.Equal(template.ClampZones.Select(z => z.Bounds.X), single.ClampZones.Select(z => z.Bounds.X));
}
}
@@ -0,0 +1,48 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// D8/C13: a plate Auto Nest commits with a last-resort clamp removal keeps following its
/// rail - minus the removed clamp - through later resizes, exactly like an ordinary
/// rail-following sheet (Slice 3), and the removal itself is real, inspectable plate state.
/// </summary>
public class ClampRemovalCommitTests
{
[Fact]
public void CommittedPlateKeepsFollowingTheRailMinusTheRemovedClamp()
{
// Same geometry as ClampRemovalRecoveryTests.BlockedPartRemovesOnlyTheNearestOriginCoveredClamp:
// clamps 15" apart on a 23"-long, 3"-wide sheet; a 14"-wide part can only clear both by
// removing one, and "clamp-1" (nearest the origin) wins the tie.
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(3, 23);
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 21.0 }));
nest.CreatePlate(); // empty template the commit fills
using var manager = new PlateManager(nest);
var request = new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
NestStockBuilder.SinglePlate(nest.Plates[0]));
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
var plate = Assert.Single(applied);
Assert.Equal(new[] { "clamp-1" }, plate.RemovedClamps);
Assert.NotNull(plate.ClampRail);
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-1");
Assert.Contains(plate.ClampZones, z => z.Id == "clamp-2");
Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes); // 1 clamp removed
// C13: still follows the rail (minus the removal) through a resize/rotate.
plate.Size = new Size(3, 60);
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-1");
Assert.Contains(plate.ClampZones, z => z.Id == "clamp-2");
}
}
@@ -0,0 +1,132 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Engine.Tests.NestingEngines;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// D8/D9 last-resort clamp removal in the whole-job pipeline: a part blocked by clamps on
/// every offered stock gets the fewest clamps removed that let it fit (C10/C11), and the
/// residual solve is merged back into one result; everything else is untouched (C10's
/// bit-identical guarantee for an unblocked job).
/// </summary>
public class ClampRemovalRecoveryTests
{
// Two clamps 15" apart on a 23"-long, 3"-wide sheet (no edge/part spacing, so the math
// below is exact): clamp-1 spans X [5.25, 6.75], clamp-2 spans X [20.25, 21.75]. A 3"-tall
// part exactly fills the sheet's Y extent, so it always sits at Y = 0 - through the clamp
// band - for whatever X it lands at.
private static ClampZone Clamp1() => new("clamp-1", new Box(5.25, 0, 1.5, 0.625));
private static ClampZone Clamp2() => new("clamp-2", new Box(20.25, 0, 1.5, 0.625));
// The machine rail those zones resolve from: only rail-backed clamps can be removed.
private static readonly ClampRail Rail = new(1.5, 0.625, new[] { 6.0, 21.0 });
private static NestPlateStock ClampedSheet(params ClampZone[] zones) =>
RailSheet(null, zones);
private static NestPlateStock RailSheet(int? quantity, params ClampZone[] zones) =>
new("sheet", new Size(3, 23), quantity, 0, default, 1, null, zones) { ClampRail = Rail };
private static readonly RectanglesNestingEngine Engine = new();
[Fact]
public void BlockedPartRemovesOnlyTheNearestOriginCoveredClamp()
{
// A 14"-wide part: with both clamps up, no X position in [0, 9] clears both (the gap
// between them is 13.5" < 14"), so it is genuinely blocked (C10), not merely unlucky.
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Incomplete, raw.Status); // sanity: today's solve fails
var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default);
Assert.NotNull(outcome);
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
var sheet = Assert.Single(outcome.Result.Plates);
Assert.Single(sheet.Placements);
// Removing either clamp alone would free enough room; "clamp-1" is nearest the origin.
Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1");
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
var fulfillment = Assert.Single(outcome.Result.Fulfillment);
Assert.Equal(0, fulfillment.Unplaced);
// The expanded job the result validates against offers the original stock plus
// exactly the reduced-clamp variant actually used.
Assert.Equal(2, outcome.Job.Plates.Count);
}
[Fact]
public void RemovalFreesSpaceForOtherResidualDemandToo()
{
// C12: a sheet nested with a clamp removed may also take other residual parts.
var blocked = JobBuilder.Rectangle("wide", w: 14, h: 3, count: 1);
var filler = JobBuilder.Rectangle("filler", w: 2, h: 1, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { blocked, filler }, new[] { stock });
var raw = Engine.Solve(job);
var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default);
Assert.NotNull(outcome);
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
Assert.All(outcome.Result.Fulfillment, f => Assert.Equal(0, f.Unplaced));
}
[Fact]
public void AlreadyCompleteResultIsUntouched()
{
// C10: no blocked part exists, so recovery is a strict no-op (bit-identical to Slice 3).
var part = JobBuilder.Rectangle("p", w: 1, h: 1, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Complete, raw.Status);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
[Fact]
public void NoClampsOfferedNeverRecovers()
{
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
var stock = JobBuilder.Stock("sheet", width: 3, length: 10); // too short, no clamps
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.NotEqual(NestJobStatus.Complete, raw.Status);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
[Fact]
public void PartBlockedOnlyByStockExhaustionIsNeverTreatedAsClampBlocked()
{
// The part fits a fresh clamped sheet on its own; only a single-sheet cap stops the
// second copy. That is a capacity limit, not a clamp block, so nothing is removed.
var part = JobBuilder.Rectangle("p", w: 12, h: 3, count: 2);
var stock = RailSheet(1, Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Incomplete, raw.Status);
Assert.Equal(NestJobStopReason.StockExhausted, raw.StopReason);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
[Fact]
public void UnfitEvenWithoutAnyClampStaysUnplaced()
{
// Wider than the whole sheet: removing every clamp still cannot make it fit.
var part = JobBuilder.Rectangle("p", w: 30, h: 3, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Incomplete, raw.Status);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
}
@@ -0,0 +1,391 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Engine.Tests.NestingEngines;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Slice 6 task 6.4: regressions for the independent Slice 3b reviews of last-resort clamp
/// removal (D8/D9, C10-C13). Geometry: a 23"-long, 3"-wide sheet with 1.5 x 0.625 clamps
/// centred at X = 6 and X = 21 (clamp-1 spans X [5.25, 6.75], clamp-2 [20.25, 21.75]); a
/// 3"-tall part always crosses the clamp band, a 14"-wide part fits only with one clamp
/// removed, a 22"-wide part only with both removed.
/// </summary>
public class ClampRemovalRepairTests
{
private static ClampZone[] Zones() => new[]
{
new ClampZone("clamp-1", new Box(5.25, 0, 1.5, 0.625)),
new ClampZone("clamp-2", new Box(20.25, 0, 1.5, 0.625)),
};
private static readonly ClampRail Rail = new(1.5, 0.625, new[] { 6.0, 21.0 });
/// <summary>Stock resolved from the machine rail (as NestStockBuilder builds it).</summary>
private static NestPlateStock Stock(int? quantity = null) =>
new("sheet", new Size(3, 23), quantity, 0, default, 1, null, Zones()) { ClampRail = Rail };
/// <summary>The same zones given explicitly, with no rail behind them.</summary>
private static NestPlateStock ExplicitStock() =>
JobBuilder.Stock("sheet", width: 3, length: 23, clampZones: Zones());
private static NestItem Item(string name, double w, double h, int quantity) =>
new() { Drawing = new Drawing(name, TestDrawingFactory.Rectangle(w, h)), Quantity = quantity };
private static NestPipelineRequest Request(NestPlateStock stock, params NestItem[] items) =>
new("Rectangles", items, new[] { stock });
/// <summary>Delegates the ordinary solve to Rectangles, then fails every later call.</summary>
private sealed class ProbeThrowEngine : INestingEngine
{
public int Calls;
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
if (++Calls > 1)
throw new InvalidOperationException("feasibility probe failed");
return new RectanglesNestingEngine().Solve(job, progress, token);
}
}
/// <summary>Solves with Rectangles and cancels the run on its second call.</summary>
private sealed class CancelOnSecondCallEngine(CancellationTokenSource source) : INestingEngine
{
private int calls;
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
var result = new RectanglesNestingEngine().Solve(job, progress, token);
if (++calls == 2)
source.Cancel();
return result;
}
}
// PM 1db4333f: an optional recovery probe failure must not discard the ordinary result.
[Fact]
public void FailingRecoveryProbeKeepsTheOrdinaryResult()
{
var engine = new ProbeThrowEngine();
var request = Request(Stock(), Item("wide", 14, 3, 1), Item("small", 2, 1, 1));
var result = NestPipeline.Run(engine, "probe-throw", request);
Assert.True(engine.Calls > 1, "recovery never probed");
Assert.True(result.IsValid, string.Join("; ", result.Violations));
// Items map to part ids in request order: part-0 = wide (blocked), part-1 = small.
Assert.Equal(1, Assert.Single(result.Raw.Fulfillment, f => f.PartId == "part-0").Unplaced);
Assert.Equal(0, Assert.Single(result.Raw.Fulfillment, f => f.PartId == "part-1").Unplaced);
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
}
[Fact]
public void CancellationDuringRecoveryStillCancelsTheRun()
{
using var source = new CancellationTokenSource();
var request = Request(Stock(), Item("wide", 14, 3, 1), Item("small", 2, 1, 1));
Assert.ThrowsAny<OperationCanceledException>(() =>
NestPipeline.Run(new CancelOnSecondCallEngine(source), "cancel", request, token: source.Token));
}
// PM 346d46ed: explicit zones have no rail to name a removed clamp or its time.
[Fact]
public void ExplicitZonesWithoutARailAreNeverRemoved()
{
var result = NestPipeline.Run(Request(ExplicitStock(), Item("wide", 14, 3, 1)));
Assert.NotEqual(NestJobStatus.Complete, result.Status);
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
}
[Fact]
public void RailStockRemovalIsNamedOnACommittedPlateWithoutARail()
{
// The stock comes from a rail-following template; the commit target is an empty
// sheet holding the same zones explicitly. The removal must still be recorded.
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
var template = new Plate(new Size(3, 23)) { EdgeSpacing = new Spacing(0, 0, 0, 0) };
template.SetClampRail(Rail);
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(3, 23);
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
var target = nest.CreatePlate();
foreach (var zone in Zones())
target.ClampZones.Add(zone);
Assert.Null(target.ClampRail);
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, NestStockBuilder.SinglePlate(template)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var plate = Assert.Single(NestPipelineCommit.ApplyToEmptyPlates(result, manager));
Assert.Equal(new[] { "clamp-1" }, plate.RemovedClamps);
Assert.NotNull(plate.ClampRail);
Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes);
}
// PM 6ded8ac4 (d): the nearest-origin tie-break follows distance along the edge, not
// the order the zones happen to be listed in.
[Fact]
public void TieBreakPrefersTheClampNearestTheOriginWhateverTheZoneOrder()
{
var reversed = new NestPlateStock("sheet", new Size(3, 23), null, 0, default, 1, null,
Zones().Reverse().ToArray())
{ ClampRail = Rail };
var result = NestPipeline.Run(Request(reversed, Item("wide", 14, 3, 1)));
Assert.Equal(NestJobStatus.Complete, result.Status);
var sheet = Assert.Single(result.Raw.Plates);
Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1");
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
}
// PM c5984c6e (C10): one lane's failure to place is not proof the part cannot fit. The
// fixed-rotation L below fits at the origin with its notch over the clamp (an explicit
// witness passes the layout check), but Rectangles packs its 10 x 10 bounding box and
// cannot place it; no clamp may be removed for it.
[Fact]
public void PartThatFitsClampedInSomeLaneNeverRemovesAClamp()
{
var program = new OpenNest.CNC.Program();
program.MoveTo(0, 0);
program.LineTo(2, 0);
program.LineTo(2, 8);
program.LineTo(10, 8);
program.LineTo(10, 10);
program.LineTo(0, 10);
program.LineTo(0, 0);
var rail = new ClampRail(1.5, 0.625, new[] { 5.75 }); // clamp-1 spans X [5, 6.5]
var size = new Size(10, 10);
var stock = new NestPlateStock("sheet", size, null, 0, default, 1, null, rail.ResolveFor(size, 1))
{ ClampRail = rail };
var drawing = new Drawing("notched", program);
var witnessJob = new NestJob(new[] { DrawingJobMapper.FromItem("p", new NestItem { Drawing = drawing, Quantity = 1 }) },
new[] { stock });
var witness = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.Empty(NestLayoutCheck.Violations(witnessJob, witness)); // fits with the clamp in place
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { stock }));
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
Assert.All(result.Raw.Plates, sheet => Assert.Single(sheet.Stock.ClampZones));
}
private static int RemovedCount(NestJobPlateResult sheet) => 2 - sheet.Stock.ClampZones.Count;
// PM f91e054a (C11): minimality holds per sheet. part-0 (22 wide) needs both clamps off,
// part-1 (14 wide) only clamp-1; the sheet holding only part-1 must keep clamp-2.
[Fact]
public void EachSheetRemovesOnlyTheClampsItsOwnPartsNeed()
{
var result = NestPipeline.Run(Request(Stock(), Item("widest", 22, 3, 1), Item("wide", 14, 3, 1)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Raw.Plates.Count);
foreach (var sheet in result.Raw.Plates)
{
var needsBoth = sheet.Placements.Any(p => p.PartId == "part-0");
Assert.Equal(needsBoth ? 2 : 1, RemovedCount(sheet));
if (!needsBoth)
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
}
}
// PM 245a555f (C12): reduced-clamp sheets come out of the same finite pool.
[Theory]
[InlineData(3, false)] // three blocked parts, one physical sheet
[InlineData(1, true)] // the ordinary part already used the only sheet
public void RemovalNeverInventsSheetsBeyondTheFiniteStock(int wide, bool small)
{
var items = new List<NestItem> { Item("wide", 14, 3, wide) };
if (small)
items.Add(Item("small", 2, 1, 1));
var result = NestPipeline.Run(Request(Stock(quantity: 1), items.ToArray()));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Single(result.Raw.Plates);
Assert.Equal(1, result.Raw.StockUsage.Sum(u => u.Used));
Assert.Equal(1, result.Job.Plates.Sum(s => s.Quantity ?? int.MaxValue));
Assert.All(result.Raw.StockUsage, u => Assert.True(u.Remaining is null or >= 0));
}
/// <summary>Never places anything; counts how often recovery asks it to.</summary>
private sealed class CountingNoFitEngine : INestingEngine
{
public int Calls;
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
Calls++;
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.NoPlacementFound,
Array.Empty<NestJobPlateResult>(),
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)).ToList(),
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)).ToList());
}
}
private static NestPlateStock WideRailStock(string id, ClampRail rail)
{
var size = new Size(3, 23);
return new NestPlateStock(id, size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) { ClampRail = rail };
}
// PM 0e655fdb: parts that cannot fit even with every clamp removed are dismissed by one
// all-clamps-removed probe per stock instead of a search over every clamp run.
[Fact]
public void PartsThatNeverFitAreDismissedWithoutSearchingClampRuns()
{
var rail = new ClampRail(0.5, 0.625, Enumerable.Range(0, 20).Select(i => 0.5 + i * 1.1));
var stocks = Enumerable.Range(0, 4).Select(i => WideRailStock($"option-{i}", rail)).ToArray();
var parts = Enumerable.Range(0, 25).Select(i => JobBuilder.Rectangle($"p{i}", w: 30, h: 3, count: 1)).ToArray();
var job = new NestJob(parts, stocks);
var engine = new CountingNoFitEngine();
var raw = engine.Solve(job);
engine.Calls = 0;
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
Assert.Null(outcome);
// One feasibility probe per part plus one all-clamps-removed probe per part and stock.
Assert.True(engine.Calls <= 25 + 25 * 4, $"recovery made {engine.Calls} solves");
}
// PM 0e655fdb: the search has a fixed budget; running out keeps the ordinary result.
[Fact]
public void RecoveryStopsAtItsProbeBudget()
{
// 23-wide parts fit only with all 20 clamps off: every smaller run is probed first.
var rail = new ClampRail(0.5, 0.625, Enumerable.Range(0, 20).Select(i => 0.5 + i * 1.1));
var stocks = Enumerable.Range(0, 4).Select(i => WideRailStock($"option-{i}", rail)).ToArray();
var parts = Enumerable.Range(0, 25).Select(i => JobBuilder.Rectangle($"p{i}", w: 23, h: 3, count: 1)).ToArray();
var job = new NestJob(parts, stocks);
var engine = new CountingNoFitEngine();
var raw = engine.Solve(job);
engine.Calls = 0;
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
Assert.Null(outcome);
Assert.True(engine.Calls <= ClampRemovalRecovery.MaxProbeSolves, $"recovery made {engine.Calls} solves");
}
// PM 60f7a673: the removal is recorded whatever the empty target sheet already holds.
[Theory]
[InlineData("reduced-explicit")] // no rail, already exactly the solved reduced zones
[InlineData("other-rail")] // follows a different rail that cannot recreate them
public void CommittedRemovalIsRecordedOnAnyEmptyTarget(string target)
{
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(3, 23);
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
var plate = nest.CreatePlate();
if (target == "reduced-explicit")
plate.ClampZones.Add(Zones()[1]);
else
plate.SetClampRail(new ClampRail(2, 0.625, new[] { 6.0, 21.0 }));
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(Request(Stock(), Item("wide", 14, 3, 1)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var applied = Assert.Single(NestPipelineCommit.ApplyToEmptyPlates(result, manager));
Assert.Same(plate, applied);
Assert.Same(Rail, applied.ClampRail);
Assert.Equal(new[] { "clamp-1" }, applied.RemovedClamps);
Assert.Equal(new[] { "clamp-2" }, applied.ClampZones.Select(z => z.Id));
Assert.Equal(ClampRail.DefaultRemovalMinutes, applied.ClampRemovalMinutes);
}
// PM bf4438ac: when one offered stock runs out, a blocked part may still use another
// offered stock that has sheets left (each with its own minimal removal).
[Theory]
[InlineData(2, false)] // two blocked copies: one on A, one on B
[InlineData(1, true)] // the ordinary part took A; the blocked part goes to B
public void RecoveryMovesOnToAnotherStockWithSheetsLeft(int wide, bool small)
{
NestPlateStock Sheet(string id, double length)
{
var size = new Size(3, length);
return new NestPlateStock(id, size, 1, 0, default, 1, null, Rail.ResolveFor(size, 1)) { ClampRail = Rail };
}
var items = new List<NestItem> { Item("wide", 14, 3, wide) };
if (small)
items.Add(Item("small", 2, 1, 1));
var request = new NestPipelineRequest("Rectangles", items, new[] { Sheet("A", 23), Sheet("B", 24) },
new NestJobOptions(maxPlates: 2));
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Raw.Plates.Count);
Assert.All(result.Raw.Plates.Where(p => p.StockId.Contains("removed")), p => Assert.Single(p.Stock.ClampZones));
Assert.Equal(2, result.Raw.StockUsage.Sum(u => u.Used));
}
// Delta review policy gate 3: identical offered stock (four 48x120 sheets with the same
// eight-clamp rail) must not multiply the probe count, or a moderate job of full-sheet
// parts runs out of budget although every part fits with its clamps removed.
[Fact]
public void IdenticalStockOptionsShareTheirProbes()
{
var rail = new ClampRail(1.5, 0.625, new[] { 6.0, 21.0, 36.0, 51.0, 66.0, 81.0, 96.0, 111.0 });
var size = new Size(48, 120);
var stocks = Enumerable.Range(0, 4)
.Select(i => new NestPlateStock($"s{i}", size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) { ClampRail = rail })
.ToArray();
var parts = Enumerable.Range(0, 8)
.Select(i => JobBuilder.Rectangle($"full-{i}", w: 120, h: 48, count: 1, x: i * 1e-3))
.ToArray();
var job = new NestJob(parts, stocks);
var engine = new OpenNest.Engine.NestingEngines.Default.DefaultNestingEngine();
var raw = engine.Solve(job);
Assert.Empty(raw.Plates);
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
Assert.NotNull(outcome);
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
Assert.Equal(8, outcome.Result.Plates.Count);
Assert.All(outcome.Result.Plates, sheet => Assert.Empty(sheet.Stock.ClampZones));
}
// PM 3df03af2 (C11/D8 "the fewest clamps"): the minimal set need not be neighbours. The
// notch over the middle clamp lets it stay while the two outer clamps come off.
[Fact]
public void FewestClampsNeedNotBeNeighbours()
{
var program = new OpenNest.CNC.Program();
program.MoveTo(0, 0);
program.LineTo(8, 0);
program.LineTo(8, 1);
program.LineTo(15, 1);
program.LineTo(15, 0);
program.LineTo(23, 0);
program.LineTo(23, 3);
program.LineTo(0, 3);
program.LineTo(0, 0);
var rail = new ClampRail(1.5, 0.625, new[] { 3.0, 11.5, 20.0 });
var size = new Size(3, 23);
var stock = new NestPlateStock("sheet", size, null, 0, default, 1, null, rail.ResolveFor(size, 1))
{ ClampRail = rail };
var result = NestPipeline.Run(new NestPipelineRequest("Default",
new[] { new NestItem { Drawing = new Drawing("middle-notch", program), Quantity = 1 } },
new[] { stock }));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
var sheet = Assert.Single(result.Raw.Plates);
Assert.Equal(new[] { "clamp-2" }, sheet.Stock.ClampZones.Select(z => z.Id));
}
}
@@ -0,0 +1,170 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// A committed live plate must carry exactly the clamp keep-outs its layout was solved and
/// validated against, in plate coordinates, including sheets the commit had to create.
/// </summary>
public class ClampZoneCommitTests
{
// A 1.5 x 0.625 clamp on the front edge of a 48 x 96 sheet (Length 96 runs along X).
private const double ZoneX = 5.25;
private static ClampZone Zone() => new("clamp-1", new Box(ZoneX, 0, 1.5, 0.625));
/// <summary>Two different one-part sheets so repeat grouping cannot merge them.</summary>
private sealed class TwoSheetStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var stock = job.Plates[0];
var work = stock.WorkArea;
var builder = new NestJobResultBuilder(job);
builder.AddSheet(stock, new[] { (job.Parts[0].Id, work.Left + 20, work.Bottom + 10, 0.0) });
builder.AddSheet(stock, new[] { (job.Parts[0].Id, work.Left + 40, work.Bottom + 20, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}
}
private static NestPipelineResult Result(Drawing drawing, int quadrant, params ClampZone[] zones)
{
var result = NestPipeline.Run(new TwoSheetStub(), "stub", new NestPipelineRequest("stub",
new[] { new NestItem { Drawing = drawing, Quantity = 2 } },
new[]
{
new NestPlateStock("sheet", new Size(48, 96), null, 0.25, new Spacing(1, 1, 1, 1),
quadrant, cost: null, clampZones: zones),
}));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Count);
return result;
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void EveryCommittedPlateCarriesTheSolvedZonesInPlateCoordinates(int quadrant)
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.CreatePlate(); // empty template without zones; the second sheet is created by commit
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, quadrant, Zone()), manager);
Assert.Equal(2, applied.Count);
Assert.All(applied, plate =>
{
var zone = Assert.Single(plate.ClampZones);
Assert.Equal("clamp-1", zone.Id);
// Exact bits: the inverse of the stock's placement-frame translation.
Assert.Equal(ZoneX, 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 MatchingPlateZonesAreKeptWithoutChangeEvents()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var template = nest.CreatePlate();
template.Quadrant = 3;
template.Size = new Size(48, 96);
var original = Zone();
template.ClampZones.Add(original);
var changes = 0;
template.ClampZonesChanged += (_, _) => changes++;
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, 3, Zone()), manager);
Assert.Same(template, applied[0]);
Assert.Same(original, Assert.Single(template.ClampZones));
Assert.Equal(0, changes);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void SolvedZonesARailCannotRecreateStopFollowingThatRail(int quadrant)
{
// The nest rail puts a clamp at 6"; the template carries a different explicit zone,
// so the extra sheet the commit creates must not keep following the nest rail.
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(48, 96);
nest.PlateDefaults.Quadrant = quadrant;
nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0 }));
var template = nest.CreatePlate();
template.SetClampRail(null!);
var manual = new ClampZone("manual", new Box(10, 0, 2, 0.5));
template.ClampZones.Add(manual);
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(
Result(drawing, quadrant, template.ClampZones.ToArray()), manager);
Assert.Equal(2, applied.Count);
Assert.All(applied, plate =>
{
Assert.Null(plate.ClampRail);
plate.Size = plate.Size; // the Edit Plate dialog assigns Size even when unchanged
plate.Quadrant = plate.Quadrant;
Assert.Equal("manual", Assert.Single(plate.ClampZones).Id);
Assert.Equal("manual", Assert.Single(Assert.Single(
NestStockBuilder.FromTemplate(plate, [new PlateOption { Width = 48, Length = 96 }])).ClampZones).Id);
});
}
[Fact]
public void SolvedZonesTheRailRecreatesKeepFollowingIt()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(48, 96);
var rail = new ClampRail(1.5, 0.625, new[] { 6.0 });
nest.SetClampRail(rail);
var template = nest.CreatePlate();
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(
Result(drawing, 1, template.ClampZones.ToArray()), manager);
Assert.Equal(2, applied.Count);
Assert.All(applied, plate =>
{
Assert.Same(rail, plate.ClampRail);
plate.Size = new Size(48, 4);
Assert.Empty(plate.ClampZones); // still re-resolves: no clamp reaches a 4" sheet
});
}
[Fact]
public void ZonesTheLayoutWasNotSolvedWithAreRemoved()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var template = nest.CreatePlate();
template.ClampZones.Add(Zone());
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, 1), manager);
Assert.All(applied, plate => Assert.Empty(plate.ClampZones));
}
}
@@ -0,0 +1,223 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Clamp zones as engine input: immutable stock carry with the WorkArea quadrant
/// transform, validation of zone geometry, repeated-layout identity (C8) and the
/// layout-check violation against the D6-inflated rectangle (C1/C5).
/// </summary>
public class ClampZoneEngineTests
{
private const double ZoneX = 5.25;
private const double ZoneLength = 1.5;
private const double ZoneWidth = 0.625;
private static ClampZone Zone(string id = "clamp-0", double x = ZoneX) =>
new(id, new Box(x, 0, ZoneLength, ZoneWidth));
private static NestPlateStock Stock(
int quadrant = 1,
double bottomSpacing = 0.25,
params ClampZone[] zones
) =>
new("sheet", new Size(48, 96), null, 0.25,
new Spacing(0.25, bottomSpacing, 0.25, 0.25), quadrant, null, zones);
// ---- stock carry and quadrant transform ----------------------------------------
[Fact]
public void StockWithoutZonesExposesAnEmptyList()
{
var stock = new NestPlateStock("s", new Size(48, 96));
Assert.Empty(stock.ClampZones);
}
[Theory]
[InlineData(1, 0.0, 0.0)]
[InlineData(2, -96.0, 0.0)]
[InlineData(3, -96.0, -48.0)]
[InlineData(4, 0.0, -48.0)]
public void ZonesTransformWithTheWorkAreaIntoEveryQuadrant(int quadrant, double dx, double dy)
{
var stock = Stock(quadrant: quadrant, zones: Zone());
var zone = Assert.Single(stock.ClampZones);
Assert.Equal("clamp-0", zone.Id);
Assert.Equal(ZoneX + dx, zone.Bounds.X); // exact bits: same translation WorkArea uses
Assert.Equal(dy, zone.Bounds.Y);
Assert.Equal(ZoneLength, zone.Bounds.Length);
Assert.Equal(ZoneWidth, zone.Bounds.Width);
// The keep-out stays inside the sheet box of this quadrant.
var work = stock.WorkArea;
Assert.True(zone.Bounds.Right <= work.Right + 0.25);
Assert.True(zone.Bounds.Bottom >= work.Bottom - 0.25 - 0.25);
}
[Fact]
public void StockDetachesZonesFromCallerBoxes()
{
var bounds = new Box(ZoneX, 0, ZoneLength, ZoneWidth);
var zone = new ClampZone("clamp", bounds);
var stock = Stock(zones: zone);
bounds.Offset(500, 500);
Assert.Equal(ZoneX, Assert.Single(stock.ClampZones).Bounds.X);
}
[Fact]
public void MapperCarriesPlateZonesInBothDirections()
{
var plate = new Plate(48, 96) { Quadrant = 2 };
plate.ClampZones.Add(Zone());
var stock = DrawingJobMapper.FromPlate("s", plate, quantity: null);
var zone = Assert.Single(stock.ClampZones);
Assert.Equal(ZoneX - 96, zone.Bounds.X);
Assert.Equal(0, zone.Bounds.Y);
// A plate materialized from the stock stores plate coordinates like every Plate;
// mapping it into the placement frame reproduces the stock zone exactly.
var materialized = DrawingJobMapper.CreatePlate(stock);
var copy = Assert.Single(materialized.ClampZones);
Assert.Equal(ZoneX, copy.Bounds.X);
Assert.Equal(0, copy.Bounds.Y); // quadrant 2 keeps Y at zero
Assert.Equal("clamp-0", copy.Id);
var placed = Assert.Single(ClampZoneFrame.ToPlacement(materialized));
Assert.Equal(zone.Bounds.X, placed.Bounds.X);
Assert.Equal(zone.Bounds.Y, placed.Bounds.Y);
}
[Fact]
public void StockBuilderCarriesTemplateZonesForEveryOption()
{
var template = new Plate(48, 96);
template.ClampZones.Add(Zone("a", 3));
template.ClampZones.Add(Zone("b", 20));
var single = Assert.Single(NestStockBuilder.SinglePlate(template));
Assert.Equal(2, single.ClampZones.Count);
var options = NestStockBuilder.FromTemplate(template,
[new PlateOption { Width = 48, Length = 96 }, new PlateOption { Width = 40, Length = 80 }]);
Assert.All(options, stock => Assert.Equal(2, stock.ClampZones.Count));
}
// ---- validator ------------------------------------------------------------------
[Fact]
public void ValidatorAcceptsEdgeFlushZones()
{
var job = new NestJob([], [Stock(zones: new[] { Zone(), Zone("flush", 94.5) })]);
NestJobValidator.Validate(job); // y = 0 front edge and right-edge flush both legal
}
[Fact]
public void ValidatorAcceptsWholeClampsHangingPastTheSheet()
{
// A clamp is an avoidance area: a footprint reaching onto the sheet is kept whole.
NestJobValidator.Validate(new NestJob([], [Stock(zones: Zone(x: 95.5))]));
NestJobValidator.Validate(new NestJob([], [new NestPlateStock("s", new Size(48, 96),
clampZones: [new ClampZone("under", new Box(5, -0.5, 1.5, 0.625))])]));
NestJobValidator.Validate(new NestJob([], [Stock(quadrant: 3, zones: Zone(x: -0.5))]));
}
[Theory]
[InlineData(96, 0)] // touches the far end only
[InlineData(100, 0)]
[InlineData(-1.5, 0)] // touches the origin end only
[InlineData(5, -0.625)] // below the front edge, touching it
[InlineData(5, 48)] // beyond the back edge
public void ValidatorRejectsZonesWhollyOffTheSheet(double x, double y)
{
var job = new NestJob([], [new NestPlateStock("s", new Size(48, 96),
clampZones: [new ClampZone("off", new Box(x, y, 1.5, 0.625))])]);
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(job));
}
[Fact]
public void ValidatorRejectsDuplicateZoneIds()
{
var job = new NestJob([], [Stock(zones: new[] { Zone("dup", 3), Zone("dup", 20) })]);
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(job));
}
// ---- repeated-layout identity (C8) ----------------------------------------------
[Fact]
public void DifferentClampSetsAreDifferentStock()
{
var plain = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25);
var clamped = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25,
clampZones: [Zone()]);
var sameAsPlain = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25,
clampZones: [Zone()]);
Assert.False(RepeatedPlateGrouping.SameStock(plain, clamped));
Assert.True(RepeatedPlateGrouping.SameStock(clamped, sameAsPlain));
var pose = new NestJobPlacement("p", 0, 1, 1, 0);
var groups = RepeatedPlateGrouping.Create(
[
new NestJobPlateResult(0, clamped, [pose]),
new NestJobPlateResult(1, plain, [pose]),
new NestJobPlateResult(2, sameAsPlain, [pose]),
], consolidate: true);
// sheet2 repeats sheet0's exact layout and an identical clamp set, so only the
// plain-clamp sheet stays a separate group; the clamped layout is never reused
// for the plain sheet (C8).
Assert.Equal(new[] { 2, 1 }, groups.Select(g => g.Quantity).ToArray());
}
// ---- layout check (C1/C5) ---------------------------------------------------------
private static NestJobPart RectPart(string id = "p", double w = 2, double h = 2) =>
new(id, PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(w, h)), 1);
private static IReadOnlyList<string> Check(NestPlateStock stock, double x, double y)
{
var job = new NestJob([RectPart()], [stock]);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, stock, [new NestJobPlacement("p", 0, x, y, 0)])],
[], []);
return NestLayoutCheck.Violations(job, result);
}
[Fact]
public void CheckerFlagsPartsInsideTheInflatedClampRectangle()
{
// keep-out = zone inflated by bottom EdgeSpacing (0.25): x [~5.0, ~7.0], y up to ~0.875
var violations = Check(Stock(zones: Zone()), x: 5.3, y: 0.4);
Assert.Contains(violations, v => v.Contains("clamp-0") && v.Contains("clamp zone"));
}
[Fact]
public void CheckerAcceptsPartsClearOfTheInflatedClampRectangle()
{
// Above the inflated depth (0.625 + 0.25) and clear in X beyond the inflated width.
Assert.DoesNotContain(Check(Stock(zones: Zone()), x: 5.3, y: 1.0),
v => v.Contains("clamp zone"));
Assert.DoesNotContain(Check(Stock(zones: Zone()), x: 7.2, y: 0.4),
v => v.Contains("clamp zone"));
}
[Fact]
public void CheckerEnforcesTransformedZonesInOtherQuadrants()
{
// Quadrant 3 moves the front-edge zone to (5.25-96, -48); a part inside it must red.
var stock = Stock(quadrant: 3, zones: Zone());
var violations = Check(stock, x: 5.25 - 96 + 0.1, y: -48 + 0.4);
Assert.Contains(violations, v => v.Contains("clamp zone"));
// The same sheet a clear distance away from the transformed zone passes.
Assert.DoesNotContain(Check(stock, x: -50, y: -40), v => v.Contains("clamp zone"));
}
[Fact]
public void EmptyClampListAddsNoViolations()
{
Assert.DoesNotContain(Check(Stock(), x: 10, y: 10), v => v.Contains("clamp"));
}
}
@@ -0,0 +1,266 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Slice 4 (C1): the interactive single-plate surface - PlateFillService strategies
/// (Fill/Strip/Vertical+Horizontal Remnant), PackArea and whole-plate Nest - never
/// proposes a part inside a clamp keep-out inflated by the plate's bottom-edge
/// spacing, while material outside the zones stays usable. Oracles are analytic:
/// strict positive-area overlap against independently inflated zone rectangles.
/// </summary>
public class ClampZoneInteractiveFillTests
{
// Plate: 48 (X) by 24 (Y), uniform 0.25 edge spacing -> bottom clearance 0.25.
// Two 1.5 x 0.625 clamps on the front edge at X 10 and X 20.
private const double Edge = 0.25;
private const double PartSpacing = 0.5;
private static readonly Box ZoneA = new(10, 0, 1.5, 0.625);
private static readonly Box ZoneB = new(20, 0, 1.5, 0.625);
private static Plate MakePlate(params ClampZone[] zones)
{
var plate = new Plate(new Size(24, 48));
plate.EdgeSpacing = new Spacing(Edge, Edge);
plate.PartSpacing = PartSpacing;
foreach (var zone in zones)
plate.ClampZones.Add(zone);
return plate;
}
private static Program RectProgram(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 RectDrawing(double lengthX, double widthY) =>
new($"rect-{lengthX}-{widthY}", RectProgram(lengthX, widthY));
private static Box Inflated(Box zone, double clearance) =>
new(zone.X - clearance, zone.Y - clearance, zone.Length + 2 * clearance, zone.Width + 2 * clearance);
/// <summary>Strict positive-area overlap: touching edges (the admitted closure) are clear.</summary>
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 AssertClearOfClamps(IEnumerable<Part> parts, params Box[] zones)
{
foreach (var part in parts)
foreach (var zone in zones)
{
var keepOut = Inflated(zone, Edge);
Assert.False(
Overlaps(part.BoundingBox, keepOut),
$"'{part.BaseDrawing.Name}' at ({part.Location.X:R},{part.Location.Y:R}) "
+ $"box [{part.BoundingBox.Left:R}..{part.BoundingBox.Right:R} x "
+ $"{part.BoundingBox.Bottom:R}..{part.BoundingBox.Top:R}] enters the clamp keep-out"
);
}
}
public static TheoryData<string> Strategies => new() { "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" };
[Theory]
[MemberData(nameof(Strategies))]
public void FillItemNeverProposesPartInsideClampKeepOut(string strategy)
{
var occupant = new Part(RectDrawing(6, 6), new Vector(38, 14)); // occupied-plate fixture
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
plate.Parts.Add(occupant);
var item = new NestItem { Drawing = RectDrawing(4, 4), Quantity = 3 };
var parts = PlateFillService.FillItem(
strategy, plate, item, plate.WorkArea(), null, CancellationToken.None);
Assert.Equal(3, parts.Count);
AssertClearOfClamps(parts, ZoneA, ZoneB);
}
[Theory]
[MemberData(nameof(Strategies))]
public void FillUsesMaterialBetweenAndBehindTheClamps(string strategy)
{
// The band covers both clamps only where y < 3: parts must land in the strip
// above the clamps and in the slot between them - not merely fewer parts.
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
var band = new Box(9, 0.25, 13, 2.75);
var item = new NestItem { Drawing = RectDrawing(3, 1.8), Quantity = 2 };
var parts = PlateFillService.FillItem(strategy, plate, item, band, null, CancellationToken.None);
Assert.Equal(2, parts.Count);
AssertClearOfClamps(parts, ZoneA, ZoneB);
}
[Fact]
public void DesktopGroupFillThenSettleRemainsClearOfPlateOccupant()
{
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 = new Part(RectDrawing(4, 19.5), new Vector(0.25, 0.25));
plate.Parts.Add(occupant);
var group = new List<Part> { new(RectDrawing(3, 3)) };
var result = PlateFillService.FillGroup("Fill", plate, group,
plate.WorkArea(), null, CancellationToken.None);
Assert.True(result.Count > 1);
Assert.DoesNotContain(result, part => Overlaps(part.BoundingBox, occupant.BoundingBox));
Compactor.Settle(result, plate, plate.WorkArea());
Assert.DoesNotContain(result, part => Overlaps(part.BoundingBox, occupant.BoundingBox));
AssertClearOfClamps(result, new Box(15, 0, 1.5, 0.625));
}
[Fact]
public void FillGroupRetainsCompleteCopiesAheadOfAPartialTrailingCopy()
{
var drawing = RectDrawing(2, 2);
var group = new List<Part>
{
new(drawing, new Vector(0, 0)), new(drawing, new Vector(3, 0)),
};
var plate = new Plate(new Size(20, 20)) { PartSpacing = 0 };
var area = new Box(0, 0, 8, 2);
var original = PlateFillService.FillGroup("Fill", plate, group, area,
null, CancellationToken.None);
Assert.True(original.Count > group.Count && original.Count % group.Count != 0,
$"expected a full copy and partial tail, got {original.Count}");
plate.ClampZones.Add(new ClampZone("remote", new Box(18, 0, 1, 0.5)));
var clamped = PlateFillService.FillGroup("Fill", plate, group, area,
null, CancellationToken.None);
Assert.Equal(group.Count, clamped.Count);
}
[Fact]
public void FillGroupRejectsTheWholeCopyWhenOnlyOneMemberTouchesAClamp()
{
var plate = new Plate(new Size(20, 20)) { PartSpacing = 0 };
plate.ClampZones.Add(new ClampZone("front", new Box(5, 0, 1, 0.5)));
var drawing = RectDrawing(2, 2);
var group = new List<Part>
{
new(drawing, new Vector(2, 0)), new(drawing, new Vector(5, 0)),
};
var parts = PlateFillService.FillGroup("Fill", plate, group, new Box(2, 0, 5, 2),
null, CancellationToken.None);
Assert.Equal(0, parts.Count % group.Count);
Assert.Empty(parts);
}
[Fact]
public void FillGroupKeepsAnInsertInItsFrameHoleWhenAClampIsElsewhere()
{
var program = RectProgram(4, 4);
program.MoveTo(1, 1);
program.LineTo(1, 3);
program.LineTo(3, 3);
program.LineTo(3, 1);
program.LineTo(1, 1);
var frame = new Drawing("frame", program);
var insert = RectDrawing(1, 1);
var plate = new Plate(new Size(20, 20)) { PartSpacing = 0.25 };
plate.ClampZones.Add(new ClampZone("remote", new Box(18, 0, 1, 0.5)));
var group = new List<Part>
{
new(frame, new Vector(1, 3)), new(insert, new Vector(2.5, 4.5)),
};
var parts = PlateFillService.FillGroup("Fill", plate, group, new Box(1, 3, 4, 4),
null, CancellationToken.None);
Assert.Contains(parts, part => ReferenceEquals(part.BaseDrawing, frame));
Assert.Contains(parts, part => ReferenceEquals(part.BaseDrawing, insert));
Assert.Equal(0, parts.Count % 2);
}
[Fact]
public void PackAreaKeepsAllowedOverhangOnlyAtThePlateBoundary()
{
var plate = new Plate(new Size(12, 12))
{
EdgeSpacing = new Spacing(1, 1),
PartSpacing = 0,
};
plate.ClampZones.Add(new ClampZone("front", new Box(5, 0, 1, 0.5)));
var drawing = RectDrawing(10 + OpenNest.Engine.NestingEngines.Rectangles.SheetPacker.OverhangAllowance * 0.8, 4);
var items = new List<NestItem> { new() { Drawing = drawing, Quantity = 2 } };
var parts = PlateFillService.PackArea("Fill", plate, plate.WorkArea(), items,
null, CancellationToken.None);
Assert.Equal(2, parts.Count);
Assert.All(parts, part => Assert.True(part.BoundingBox.Right > plate.WorkArea().Right));
AssertClearOfClamps(parts, new Box(5, 0, 1, 0.5));
}
[Fact]
public void PackAreaNeverPacksInsideClampKeepOut()
{
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
var items = new List<NestItem> { new() { Drawing = RectDrawing(4, 4), Quantity = 8 } };
var parts = PlateFillService.PackArea(
"Fill", plate, plate.WorkArea(), items, null, CancellationToken.None);
Assert.True(parts.Count >= 5, $"expected the unclamped material to pack parts, got {parts.Count}");
AssertClearOfClamps(parts, ZoneA, ZoneB);
}
[Fact]
public void ProgressNeverPublishesUnverifiedClampPlacements()
{
var plate = MakePlate(new ClampZone("front-1", ZoneA));
var updates = new List<NestProgress>();
var progress = new InlineProgress(update => updates.Add(update));
var item = new NestItem { Drawing = RectDrawing(4, 4), Quantity = 4 };
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(),
progress, CancellationToken.None);
Assert.NotEmpty(parts);
Assert.NotEmpty(updates);
Assert.All(updates, update => AssertClearOfClamps(update.BestParts, ZoneA));
}
private sealed class InlineProgress(System.Action<NestProgress> report) : System.IProgress<NestProgress>
{
public void Report(NestProgress value) => report(value);
}
[Theory]
[MemberData(nameof(Strategies))]
public void WholePlateNestWithNoRequestedPartsDoesNotFillBecauseOfClamps(string strategy)
{
var plate = MakePlate(new ClampZone("front-1", ZoneA));
var item = new NestItem { Drawing = RectDrawing(3, 3), Quantity = 0 };
var parts = PlateFillService.Nest(strategy, plate, new List<NestItem> { item },
null, CancellationToken.None);
if (strategy != "Strip")
Assert.Empty(parts);
}
[Theory]
[MemberData(nameof(Strategies))]
public void WholePlateNestAvoidsClampsOnAnOccupiedPlate(string strategy)
{
var occupant = new Part(RectDrawing(6, 6), new Vector(38, 14));
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
plate.Parts.Add(occupant);
var items = new List<NestItem> { new() { Drawing = RectDrawing(4, 4), Quantity = 10 } };
var parts = PlateFillService.Nest(strategy, plate, items, null, CancellationToken.None);
Assert.Equal(10, parts.Count);
AssertClearOfClamps(parts, ZoneA, ZoneB);
}
}
@@ -0,0 +1,132 @@
using System;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Every <see cref="Plate"/> stores clamp zones in the sheet's lower-left frame, including
/// plates the engine builds from stock; consumers map them into the signed placement frame
/// through <see cref="ClampZoneFrame"/>. Fixtures use a real rail in all quadrants, with
/// expected footprints from the physical rule (center +/- width/2 from the origin along the
/// edge, depth into the sheet).
/// </summary>
public class ClampZoneSheetFrameTests
{
private static (string, long, long, long, long) Bits(ClampZone zone) =>
(zone.Id, BitConverter.DoubleToInt64Bits(zone.Bounds.X), BitConverter.DoubleToInt64Bits(zone.Bounds.Y),
BitConverter.DoubleToInt64Bits(zone.Bounds.Length), BitConverter.DoubleToInt64Bits(zone.Bounds.Width));
private static Box World(int quadrant, double center, double width = 1.5, double depth = 0.625)
{
var x = quadrant is 2 or 3 ? -(center + width / 2) : center - width / 2;
var y = quadrant is 3 or 4 ? -depth : 0;
return new Box(x, y, width, depth);
}
private static Program Rectangle(double length, double width)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(length, 0);
program.LineTo(length, width);
program.LineTo(0, width);
program.LineTo(0, 0);
return program;
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MaterializedPlatesStoreSheetFrameZonesAndWarnForCutsThroughThem(int quadrant)
{
var live = new Plate(81, 120) { Quadrant = quadrant };
live.SetClampRail(new ClampRail(1.5, 0.625, new[] { 35.0, 120.5 }));
var stock = DrawingJobMapper.FromPlate("s", live, 1);
var job = new NestJob(Array.Empty<NestJobPart>(), new[] { stock });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, Array.Empty<NestJobPlacement>()) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
var nest = NestResultMaterializer.Materialize(job, result).Nest;
var plate = Assert.Single(nest.Plates);
Assert.Equal(live.ClampZones.Select(Bits), plate.ClampZones.Select(Bits));
Assert.Equal(stock.ClampZones.Select(Bits), ClampZoneFrame.ToPlacement(plate).Select(Bits));
var footprint = World(quadrant, 35);
Assert.Equal(footprint.Left, stock.ClampZones[0].Bounds.Left, 9);
Assert.Equal(footprint.Bottom, stock.ClampZones[0].Bounds.Bottom, 9);
// A short cut-off through the physical clamp must be found and require consent.
plate.CutOffs.Add(new CutOff(new Vector(quadrant is 2 or 3 ? -35 : 35, 0), CutOffAxis.Vertical)
{
StartLimit = quadrant is 3 or 4 ? -2 : 0,
EndLimit = quadrant is 3 or 4 ? 0 : 2,
});
Assert.Equal("clamp-1", Assert.Single(ClampCutDiagnostics.Find(plate)).ClampId);
plate.RegenerateCutOffs(new CutOffSettings());
var report = PostVerificationAnalyzer.Analyze(nest);
Assert.Contains(report.Findings, f => f.ClampId == "clamp-1");
Assert.False(report.CanPost(false));
}
[Fact]
public void StockClampZonesCannotBeEditedThroughTheReturnedZones()
{
// Stock is immutable input: editing a returned zone must not move the stock's clamp
// away from the zones its materialized plates (validation, fill, posting) use.
var live = new Plate(24, 48);
live.ClampZones.Add(new ClampZone("z", new Box(10, 0, 1.5, 0.625)));
var stock = DrawingJobMapper.FromPlate("s", live, 1);
stock.ClampZones[0].Bounds.X += 10;
foreach (var zone in stock.ClampZones)
zone.Bounds.Y = 5;
var current = Assert.Single(stock.ClampZones);
Assert.Equal(10, current.Bounds.X);
Assert.Equal(0, current.Bounds.Y);
var plate = DrawingJobMapper.CreatePlate(stock);
Assert.Equal(stock.ClampZones.Select(Bits), ClampZoneFrame.ToPlacement(plate).Select(Bits));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void InteractiveFillOnLiveRailPlateAvoidsClampsInEveryQuadrant(int quadrant)
{
// Five rows of 4.29 squares at 0.5 spacing span 23.45 of the 23.5 work-area height, so an
// unobstructed fill reaches the front edge in every quadrant.
var plate = new Plate(new Size(24, 48))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0.25, 0.25),
PartSpacing = 0.5,
};
var centers = new[] { 10.75, 20.75, 30.75, 40.75 };
plate.SetClampRail(new ClampRail(1.5, 0.625, centers));
var keepOuts = centers.Select(c => World(quadrant, c).Offset(0.25)).ToArray();
var item = new NestItem { Drawing = new Drawing("square", Rectangle(4.29, 4.29)), Quantity = 500 };
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), null, CancellationToken.None);
Assert.True(parts.Count >= 20, $"only {parts.Count} parts placed");
foreach (var part in parts)
{
var box = part.BoundingBox;
Assert.DoesNotContain(keepOuts, k => box.Left < k.Right - 1e-6 && k.Left < box.Right - 1e-6
&& box.Bottom < k.Top - 1e-6 && k.Bottom < box.Top - 1e-6);
}
}
}
@@ -0,0 +1,297 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.NestingEngines.Default;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// C16 (PM 6859db53): a clamp zone the job validator accepts may be far smaller than the
/// validation outline tolerance (here 1e-5 square). Every keep-out consumer must check it
/// without throwing, flag a placement whose material overlaps it (plus the D6 clearance),
/// and keep a placement that only touches or clears it clear. Tolerances are unchanged (C5):
/// the expected verdicts below are exact rectangle arithmetic, not the checker's output.
/// </summary>
public class TinyClampZoneTests
{
private const double Tiny = 1e-5;
private static readonly ClampZone TinyZone = new("tiny", new Box(8, 0, Tiny, Tiny));
private static Program SquareProgram()
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(2, 0));
program.Codes.Add(new LinearMove(2, 2));
program.Codes.Add(new LinearMove(0, 2));
program.Codes.Add(new LinearMove(0, 0));
return program;
}
private static NestJobPart Square() =>
new("square", PartGeometrySnapshot.FromProgram(SquareProgram()), 1, 0, RotationPolicy.Fixed(0));
private static NestPlateStock Stock(double bottom) =>
new("sheet", new Size(10, 12), null, 0, new Spacing(0, bottom, 0, 0), 1,
new[] { TinyZone });
private static NestJob Job(double bottom) => new(new[] { Square() }, new[] { Stock(bottom) });
private static IReadOnlyList<string> ClampViolations(NestJob job, NestJobResult result) =>
NestLayoutCheck.Violations(job, result).Where(v => v.Contains("clamp zone")).ToList();
[Fact]
public void RectanglesResultOnATinyClampIsCheckedWithoutThrowing()
{
// The PM probe: Rectangles places the square flush against the tiny clamp.
var job = Job(0);
NestJobValidator.Validate(job);
var result = new RectanglesNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
var violations = ClampViolations(job, result);
// Independent oracle: the square's footprint shares no positive area with the zone.
var pose = Assert.Single(result.Plates.SelectMany(p => p.Placements));
var overlapX = System.Math.Min(pose.X + 2, 8 + Tiny) - System.Math.Max(pose.X, 8);
var overlapY = System.Math.Min(pose.Y + 2, Tiny) - System.Math.Max(pose.Y, 0);
var expectOverlap = overlapX > 0 && overlapY > 0;
Assert.Equal(expectOverlap, violations.Count > 0);
}
[Fact]
public void DefaultSolvesATinyClampJobToAValidResult()
{
var job = Job(0);
var result = new DefaultNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Empty(NestLayoutCheck.Violations(job, result));
}
// (bottom edge spacing, x, y, material overlaps the zone inflated by the clearance).
// The keep-out is [8 - c, 8 + 1e-5 + c] x [-c, 1e-5 + c] with c = bottom - 0.0005
// (the shared spacing slack); the 2x2 square spans [x, x+2] x [y, y+2].
[Theory]
[InlineData(0.0, 7.5, 0.0, true)] // covers the zone
[InlineData(0.0, 6.0, 0.0, false)] // right edge flush on the zone's left side
[InlineData(0.0, 8.00001, 0.0, false)] // left edge flush on the zone's right side
[InlineData(0.0, 8.0, Tiny, false)] // bottom edge flush on the zone's top
[InlineData(0.0, 3.0, 0.0, false)] // well clear
[InlineData(0.25, 7.5, 0.1, true)] // over the zone, inside the clearance band
[InlineData(0.25, 8.0, 0.24, true)] // above the zone but inside the clearance
[InlineData(0.25, 8.25001, 0.1, false)] // exactly at the clearance to the right
[InlineData(0.25, 7.0, 0.25001, false)] // exactly at the clearance above
[InlineData(0.25, 3.0, 0.25, false)] // well clear
public void LayoutCheckJudgesPlacementsAgainstATinyClamp(double bottom, double x, double y,
bool overlaps)
{
var job = Job(bottom);
NestJobValidator.Validate(job);
var stock = job.Plates[0];
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("square", 0, x, y, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
var violations = ClampViolations(job, result);
Assert.Equal(overlaps, violations.Count > 0);
}
private static Program FrameProgram()
{
// 4x4 square with a 2x2 hole in its middle.
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(4, 0));
program.Codes.Add(new LinearMove(4, 4));
program.Codes.Add(new LinearMove(0, 4));
program.Codes.Add(new LinearMove(0, 0));
program.Codes.Add(new RapidMove(1, 1));
program.Codes.Add(new LinearMove(1, 3));
program.Codes.Add(new LinearMove(3, 3));
program.Codes.Add(new LinearMove(3, 1));
program.Codes.Add(new LinearMove(1, 1));
return program;
}
// The tiny zone inside the frame's hole is clear; inside its material it is not.
[Theory]
[InlineData(6.0, -2.0, false)] // hole spans [7, 9] x [-1, 1] around the zone
[InlineData(6.0, 0.0, true)] // the frame's bottom bar covers the zone
public void LayoutCheckSeesHolesAroundATinyClamp(double x, double y, bool overlaps)
{
var frame = new NestJobPart("frame", PartGeometrySnapshot.FromProgram(FrameProgram()), 1, 0,
RotationPolicy.Fixed(0));
var job = new NestJob(new[] { frame }, new[] { Stock(0) });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, job.Plates[0], new[] { new NestJobPlacement("frame", 0, x, y, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.Equal(overlaps, ClampViolations(job, result).Count > 0);
}
[Theory]
[InlineData(7.5, 0.0, true)]
[InlineData(6.0, 0.0, false)]
[InlineData(8.00001, 0.0, false)]
[InlineData(3.0, 0.0, false)]
public void InteractiveKeepOutTestJudgesATinyClamp(double x, double y, bool overlaps)
{
var drawing = new Drawing("square", SquareProgram());
var part = new Part(drawing, new Vector(x, y));
Assert.Equal(overlaps, InteractiveClamps.MaterialInKeepOut(part, new Box(8, 0, Tiny, Tiny)));
}
private static Plate TinyZonePlate()
{
var plate = new Plate(new Size(10, 12)); // zero edge and part spacing
plate.ClampZones.Add(TinyZone);
return plate;
}
// Positive-area overlap with the zone, admitting 1e-9 of floating closure (a ten
// thousandth of the zone): a push that stops flush on the zone is clear.
private static bool EntersTinyZone(Part part) =>
System.Math.Min(part.BoundingBox.Right, 8 + Tiny) - System.Math.Max(part.BoundingBox.Left, 8) > 1e-9
&& System.Math.Min(part.BoundingBox.Top, Tiny) - System.Math.Max(part.BoundingBox.Bottom, 0) > 1e-9;
[Theory]
[InlineData("Fill")]
[InlineData("Strip")]
[InlineData("Vertical Remnant")]
[InlineData("Horizontal Remnant")]
public void InteractiveFillNeverCoversATinyClamp(string strategy)
{
var plate = TinyZonePlate();
var item = new NestItem { Drawing = new Drawing("square", SquareProgram()), Quantity = 30 };
var parts = PlateFillService.FillItem(strategy, plate, item, plate.WorkArea(), null,
System.Threading.CancellationToken.None);
Assert.NotEmpty(parts);
Assert.DoesNotContain(parts, EntersTinyZone);
}
[Fact]
public void PushDownStopsBeforeCoveringATinyClamp()
{
var plate = TinyZonePlate();
var mover = new Part(new Drawing("square", SquareProgram()), new Vector(7.5, 5));
plate.Parts.Add(mover);
Compactor.Push(new List<Part> { mover }, plate, PushDirection.Down);
Assert.False(EntersTinyZone(mover), $"pushed to ({mover.Location.X:R},{mover.Location.Y:R})");
}
// PM 407f5eb7: the tolerance-free test must read the part's real material, not the
// 1e-4-grid validation outline or a 0.001 arc flattening.
private static Program RectProgram(double w, double h)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(w, 0);
program.LineTo(w, h);
program.LineTo(0, h);
program.LineTo(0, 0);
return program;
}
private static Program DiskProgram()
{
// Radius 2 centred at (10, 10), two absolute CCW half circles.
var program = new Program();
program.MoveTo(12, 10);
program.ArcTo(8, 10, 10, 10, RotationType.CCW);
program.ArcTo(12, 10, 10, 10, RotationType.CCW);
return program;
}
private static Box DiskBox(double radius)
{
var cx = 10 + radius * System.Math.Cos(0.02);
var cy = 10 + radius * System.Math.Sin(0.02);
return new Box(cx - 0.000005, cy - 0.000005, Tiny, Tiny);
}
public static TheoryData<string, Box, bool> RealMaterialRows => new()
{
{ "rect-8.00004", new Box(8.00002, 0.5, Tiny, Tiny), true }, // wholly inside the part
{ "rect-8.00004", new Box(8.00005, 0.5, Tiny, Tiny), false }, // just beyond its edge
{ "rect-2.00004", new Box(2.000032, 1, 5e-6, Tiny), true },
{ "disk", DiskBox(1.9998), true }, // inside the true arc, outside its 0.001 chords
{ "disk", DiskBox(2.0002), false }, // just outside the true arc
// The arc cuts a corner off the zone, 6.2e-7 deep: less than a fine chord's sag.
{ "disk", new Box(11.935615871667656, 10.503377293956166, Tiny, Tiny), true },
};
private static Program MaterialProgram(string name) => name switch
{
"rect-8.00004" => RectProgram(8.00004, 2),
"rect-2.00004" => RectProgram(2.00004, 2),
_ => DiskProgram(),
};
[Theory]
[MemberData(nameof(RealMaterialRows))]
public void LayoutCheckReadsRealMaterialAroundATinyClamp(string material, Box zone, bool overlaps)
{
var program = MaterialProgram(material);
var stock = new NestPlateStock("sheet", new Size(20, 20), null, 0, default, 1,
new[] { new ClampZone("tiny", zone) });
var job = new NestJob(new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1, 0,
RotationPolicy.Fixed(0)) }, new[] { stock });
NestJobValidator.Validate(job);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.Equal(overlaps, ClampViolations(job, result).Count > 0);
}
[Theory]
[MemberData(nameof(RealMaterialRows))]
public void InteractiveKeepOutTestReadsRealMaterial(string material, Box zone, bool overlaps)
{
var part = new Part(new Drawing(material, MaterialProgram(material)));
Assert.Equal(overlaps, InteractiveClamps.MaterialInKeepOut(part, zone));
}
// Delta review 2: a concave (notch) arc must not lose material either. The zone's corner
// sits 6e-7 into the material beside a semicircular notch, at a flattening vertex.
internal static (Program Program, Box Zone) NotchCornerFixture()
{
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<Arc>().Single();
var native = arc.ToPoints(arc.SegmentsForTolerance(Tiny / 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);
return (program, new Box(outward.X - Tiny, outward.Y, Tiny, Tiny));
}
[Fact]
public void ConcaveArcMaterialIsNotLostBesideATinyClamp()
{
var (program, zone) = NotchCornerFixture();
var stock = new NestPlateStock("sheet", new Size(25, 25), null, 0, default, 1,
new[] { new ClampZone("tiny", zone) });
var job = new NestJob(new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1, 0,
RotationPolicy.Fixed(0)) }, new[] { stock });
NestJobValidator.Validate(job);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.NotEmpty(ClampViolations(job, result));
Assert.True(InteractiveClamps.MaterialInKeepOut(new Part(new Drawing("notch", program)), zone));
}
}
@@ -0,0 +1,339 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Default;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularClampZoneTests
{
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
new Dictionary<int, IReadOnlyList<PairPose>>();
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void CurvedPartsClearEveryInflatedClampInEveryQuadrant(int quadrant)
{
var stock = Stock("sheet", 10, 20, spacing: 0.1, quadrant: quadrant,
edge: new Spacing(0.1, 0.25, 0.1, 0.1), clampZones:
[new("left", new Box(0, 0, 3, 0.625)), new("middle", new Box(7, 0, 1.5, 0.625))]);
var job = Job([Part("disc", Disc(1), 9, RotationPolicy.Fixed(0))], [stock]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(9, Assert.Single(result.Plates).Placements.Count);
AssertDiscsClearClamps(result, 1);
LayoutAssert.Valid(job, result);
Assert.Contains(result.Plates[0].Placements, p => p.Y - 1 < stock.WorkArea.Bottom + 0.05);
}
[Theory]
[InlineData("Irregular")]
[InlineData("Rectangles")]
[InlineData("Default")]
public void BothLanesAndDefaultAvoidClamps(string engine)
{
var job = Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))],
[Stock("sheet", 5, 12, edge: new Spacing(0.25, 0.25, 0.25, 0.25),
clampZones: [new("front", new Box(0, 0, 3, 0.625))])]);
INestingEngine solver = engine switch
{
"Irregular" => new IrregularNestingEngine(),
"Rectangles" => new RectanglesNestingEngine(),
_ => new DefaultNestingEngine(),
};
var result = solver.Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
AssertDiscsClearClamps(result, 1);
LayoutAssert.Valid(job, result);
}
[Theory]
[InlineData(0)]
[InlineData(4)]
public void ClampClearanceUsesBottomEdgeSpacingNotPartSpacing(double partSpacing)
{
var stock = Stock("sheet", 4, 5, spacing: partSpacing,
edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new("rail", new Box(0, 0, 5, 0.625))]);
var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
var pose = Assert.Single(Assert.Single(result.Plates).Placements);
Assert.InRange(pose.Y - 1, 0.875, 0.90);
AssertDiscsClearClamps(result, 1);
LayoutAssert.Valid(job, result);
}
[Fact]
public void FullRailBlocksTheOnlyRowInsteadOfReturningAnUnsafePlacement()
{
var stock = Stock("sheet", 2.5, 6, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new("rail", new Box(0, 0, 6, 0.625))]);
var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Empty(result.Plates);
Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced);
LayoutAssert.Valid(job, result);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
public void PairOnlyOrientationsAlsoLoseClampBlockedSpace(int axis)
{
var stock = Stock("sheet", 10, 10, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new("rail", new Box(0, 0, 10, 1))]);
var job = Job([Rectangle("bar", 2, 3, 2, RotationPolicy.Automatic)], [stock]);
var types = PartCatalog.Build(job);
var extra = PartCatalog.CreateOrientation(types[0], 100, System.Math.PI / 2)!;
var pair = new PairPose { A = extra, B = extra, Dx = 0, Dy = 3 };
var pairs = new Dictionary<int, IReadOnlyList<PairPose>> { [0] = new[] { pair } };
var fill = new FrontierPacker(types, new NoFitCache(0), pairs, stock,
(PackAxis)axis, 1, new WorkCounter()).Fill([2], default);
Assert.Equal(2, fill.Parts.Count);
Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25));
if (axis == 0)
Assert.All(fill.Parts, p => Assert.Same(extra, p.Orientation));
AssertValidFill(job, fill);
}
[Fact]
public void BlockOnlyOrientationsAlsoLoseClampBlockedSpace()
{
var stock = Stock("sheet", 20, 12, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new("rail", new Box(0, 0, 12, 1))]);
var job = Job([Rectangle("bar", 2, 3, 6, RotationPolicy.Automatic)], [stock]);
var types = PartCatalog.Build(job);
using var blocks = new BlockCatalog(0.1, types, NoPairs, new WorkCounter(), long.MaxValue);
var fill = new FrontierPacker(types, new NoFitCache(0.1), NoPairs, stock,
PackAxis.Y, 1, new WorkCounter(), blocks).Fill([6], default);
Assert.Equal(6, fill.Parts.Count);
Assert.True(blocks.PreparationCount > 0);
Assert.Contains(fill.Parts, p => !types[0].Orientations.Contains(p.Orientation));
Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25));
AssertValidFill(job, fill);
}
[Fact]
public void SameSpacingStocksDoNotShareClampGeometry()
{
var wall = new ClampZone("rail", new Box(0, 0, 6, 1));
var job = Job([Rectangle("part", 2, 2, 1)],
[Stock("blocked", 2.5, 6, clampZones: [wall]), Stock("open", 2.5, 6)]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("open", Assert.Single(result.Plates).StockId);
LayoutAssert.Valid(job, result);
}
[Fact]
public void ConcavePartCanUseItsOpenNotchAroundAClamp()
{
// The only fitting pose surrounds the rail in an open notch. Blocking the
// part's bounding box instead of its NFP would incorrectly reject this job.
var program = Polyline((0, 0), (1, 0), (1, 3), (3, 3),
(3, 0), (4, 0), (4, 4), (0, 4));
var stock = Stock("sheet", 4.2, 4, edge: new Spacing(0, 0.1, 0, 0),
clampZones: [new("notch", new Box(1.5, 0, 1, 2))]);
var job = Job([Part("notched", program, 1, RotationPolicy.Fixed(0))], [stock]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
var pose = Assert.Single(Assert.Single(result.Plates).Placements);
Assert.Equal(0, pose.X, 8);
Assert.Equal(0.1, pose.Y, 8);
// Analytic notch walls versus the rectangle inflated by 0.1.
Assert.True(pose.X + 1 <= 1.4 && pose.X + 3 >= 2.6 && pose.Y + 3 >= 2.1);
LayoutAssert.Valid(job, result);
}
[Fact]
public void DifferentClampedStocksAtTheSameSpacingKeepTheirOwnObstacles()
{
var job = Job([Rectangle("part", 2, 2, 2)],
[Stock("left", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(0, 0, 3, 1))]),
Stock("right", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(3, 0, 3, 1))])]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Plates.Count);
foreach (var sheet in result.Plates)
{
var pose = Assert.Single(sheet.Placements);
Assert.True(sheet.StockId == "left" ? pose.X >= 3 : pose.X + 2 <= 3);
}
LayoutAssert.Valid(job, result);
}
[Fact]
public void CancelledClampPackingDoesNotReturnAnEmptySuccess()
{
var stock = Stock("sheet", 4, 5, clampZones: [new("rail", new Box(0, 0, 5, 1))]);
var job = Job([Rectangle("part", 2, 2, 1)], [stock]);
var packer = new FrontierPacker(PartCatalog.Build(job), new NoFitCache(0), NoPairs,
stock, PackAxis.X, 1, new WorkCounter());
Assert.Throws<OperationCanceledException>(() => packer.Fill([1], new CancellationToken(true)));
}
[Fact]
public void RemoteSubGridClampDoesNotCrashWholeSolve()
{
// Exact reported reproducer: Stock takes Y width, then X length.
var stock = Stock("sheet", 10, 12,
clampZones: [new("remote-small-clamp", new Box(8, 0, 1e-5, 1e-5))]);
var job = Job([Rectangle("part", 2, 2, 1)], [stock]);
NestJobValidator.Validate(job);
var result = new IrregularNestingEngine().Solve(job);
AssertCompleteRectangleClearsClamps(result, 0, 0);
}
public static IEnumerable<object[]> SubGridClampCases()
{
foreach (var quadrant in Enumerable.Range(1, 4))
foreach (var fixture in Enumerable.Range(0, 6))
yield return [quadrant, fixture];
}
[Theory]
[MemberData(nameof(SubGridClampCases))]
public void SubGridClampsBlockTheOriginalRectanglePlacement(int quadrant, int fixture)
{
var (bounds, originX, originY, clearance) = fixture switch
{
0 => (new Box(0, 0, 1e-5, 1e-5), 0.0, 0.0, 0.0),
1 => (new Box(0.75, 0.5, 1e-5, 1e-5), 7.25, -3.125, 0.0),
2 => (new Box(0.75, 0.5, 1e-5, 0.75), 7.25, -3.125, 0.0),
3 => (new Box(0.5, 0.75, 0.75, 1e-5), 7.25, -3.125, 0.0),
// Positive dimensions can disappear even before grid conversion.
4 => (new Box(1, 1, 1e-20, 1e-20), 7.25, -3.125, 0.0),
_ => (new Box(0, 0, 1e-5, 1e-5), 0.0, 0.0, 1e-6),
};
var edge = new Spacing(0, clearance, 0, 0);
var stock = Stock("sheet", 10, 12, quadrant: quadrant, edge: edge,
clampZones: [new("tiny", bounds)]);
var part = Rectangle("part", 2, 2, 1, x: originX, y: originY);
var job = Job([part], [stock]);
NestJobValidator.Validate(job);
var zone = Assert.Single(stock.ClampZones);
if (fixture == 4)
{
Assert.Equal(zone.Bounds.Left, zone.Bounds.Right);
Assert.Equal(zone.Bounds.Bottom, zone.Bounds.Top);
}
// A drop-clamp mutation must fail: the same job without a clamp enters it.
var unclamped = Job([part], [Stock("sheet", 10, 12, quadrant: quadrant, edge: edge)]);
var original = new IrregularNestingEngine().Solve(unclamped);
AssertCompleteRectangleClearsClamps(original, originX, originY);
var originalPose = Assert.Single(Assert.Single(original.Plates).Placements);
Assert.True(RectangleEntersInflatedClamp(originalPose, originX, originY, zone.Bounds, clearance),
"The fixture must block the unclamped placement.");
var result = new IrregularNestingEngine().Solve(job);
AssertCompleteRectangleClearsClamps(result, originX, originY);
}
[Theory]
[InlineData(0, "0F90BA3EF6C6392CF1C1E01A6209D55CA88B4F968D6DD0D01850AE15A349FE88")]
[InlineData(1, "A970D56C5768763CE339999F91ADD7FFB9EC2AF6C1585CC51C4B0869D45D0C53")]
[InlineData(2, "65ECA7F7163DB0AB7BCA331FBF2A240604B25C44D62CE97CB2015FC78A9631AA")]
// Full result hashes captured on d71f086 before static-obstacle preparation.
public void EmptyClampLayoutsKeepBaselineBits(int fixture, string expected)
{
var job = fixture switch
{
0 => Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))], [Stock("sheet", 5, 12, spacing: 0.25)]),
1 => Job([Part("ell", LShape(4, 3, 1), 1), Part("tri", Triangle(3, 2), 1)],
[Stock("sheet", 10, 15, spacing: 0.25)]),
_ => Job([Rectangle("square", 4, 4, 3)], [Stock("sheet", 5, 5, spacing: 0.25)]),
};
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(result))));
Assert.Equal(expected, hash);
}
private static void AssertCompleteRectangleClearsClamps(NestJobResult result, double originX, double originY)
{
// Do not call NestLayoutCheck here: its separate S1 tiny-clamp null-outline
// defect can throw when broad-phase bounds touch. This is an analytic oracle.
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(NestJobStopReason.Completed, result.StopReason);
Assert.Equal(new PartFulfillment("part", 1, 1, 0), Assert.Single(result.Fulfillment));
Assert.Equal(1, Assert.Single(result.StockUsage).Used);
var sheet = Assert.Single(result.Plates);
var pose = Assert.Single(sheet.Placements);
Assert.Equal("part", pose.PartId);
Assert.Equal(0, pose.InstanceIndex);
Assert.Equal(0, pose.Rotation);
var work = sheet.Stock.WorkArea;
var slack = NestTolerances.WorkAreaSlack;
var left = pose.X + originX;
var bottom = pose.Y + originY;
Assert.True(left >= work.Left - slack && bottom >= work.Bottom - slack
&& left + 2 <= work.Right + slack && bottom + 2 <= work.Top + slack);
foreach (var zone in sheet.Stock.ClampZones)
Assert.False(RectangleEntersInflatedClamp(pose, originX, originY, zone.Bounds,
sheet.Stock.EdgeSpacing.Bottom),
$"Rectangle at ({pose.X:R}, {pose.Y:R}) enters inflated clamp {zone.Id}");
}
private static bool RectangleEntersInflatedClamp(NestJobPlacement pose, double originX, double originY,
Box bounds, double clearance)
{
// Decimal arithmetic retains the positive 1e-20 fixture dimensions even
// when Box.Right == Box.Left in double arithmetic. No Clipper/checker reuse.
var left = (decimal)pose.X + (decimal)originX;
var bottom = (decimal)pose.Y + (decimal)originY;
var c = (decimal)clearance;
return left < (decimal)bounds.Left + (decimal)bounds.Length + c
&& left + 2 > (decimal)bounds.Left - c
&& bottom < (decimal)bounds.Bottom + (decimal)bounds.Width + c
&& bottom + 2 > (decimal)bounds.Bottom - c;
}
private static void AssertDiscsClearClamps(NestJobResult result, double radius)
{
foreach (var sheet in result.Plates)
foreach (var pose in sheet.Placements)
foreach (var zone in sheet.Stock.ClampZones)
{
var b = zone.Bounds;
var c = sheet.Stock.EdgeSpacing.Bottom;
var dx = pose.X - System.Math.Clamp(pose.X, b.Left - c, b.Right + c);
var dy = pose.Y - System.Math.Clamp(pose.Y, b.Bottom - c, b.Top + c);
Assert.True(dx * dx + dy * dy >= radius * radius,
$"Disc at ({pose.X:R}, {pose.Y:R}) enters inflated clamp {zone.Id}");
}
}
private static void AssertValidFill(NestJob job, SheetFill fill)
{
var builder = new NestJobResultBuilder(job);
builder.AddSheet(fill.Stock, fill.Parts.Select(p =>
(job.Parts[p.Orientation.TypeIndex].Id, p.X, p.Y, p.Orientation.Rotation)));
LayoutAssert.Valid(job, builder.Build(NestJobStopReason.Completed));
}
}
@@ -16,8 +16,9 @@ public static class JobBuilder
/// <param name="width">Y extent.</param>
/// <param name="length">X extent.</param>
public static NestPlateStock Stock(string id, double width, double length, double spacing = 0,
Spacing edge = default, int quadrant = 1, int? quantity = null) =>
new(id, new Size(width, length), quantity, spacing, edge, quadrant);
Spacing edge = default, int quadrant = 1, int? quantity = null,
ClampZone[]? clampZones = null) =>
new(id, new Size(width, length), quantity, spacing, edge, quadrant, null, clampZones);
public static NestJobPart Rectangle(string id, double w, double h, int count,
RotationPolicy? rotation = null, double x = 0, double y = 0) =>
@@ -0,0 +1,305 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>
/// The Rectangles lane treats clamp zones as hard obstacles: placements never enter
/// the keep-out, demand uses the material between clamps, and layouts with zero
/// clamps stay bit-identical to the pre-clamp baseline (C2). Irregular and Default
/// parity is covered separately by IrregularClampZoneTests.
/// </summary>
public class RectanglesClampZoneTests
{
private static OpenNest.Engine.NestingEngines.Rectangles.RectanglesNestingEngine Engine() => new();
// ---- C2 characterization: zero-clamp layouts must not shift by a single bit -------
private static string LayoutHash(NestJobResult result)
{
var sb = new StringBuilder();
foreach (var sheet in result.Plates)
foreach (var p in sheet.Placements)
sb.Append(sheet.PlateIndex).Append('|').Append(sheet.StockId).Append('|')
.Append(p.PartId).Append('|')
.Append(p.X.ToString("R", CultureInfo.InvariantCulture)).Append('|')
.Append(p.Y.ToString("R", CultureInfo.InvariantCulture)).Append('|')
.Append(p.Rotation.ToString("R", CultureInfo.InvariantCulture))
.Append('\n');
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
}
[Theory]
// Pre-clamp baseline hashes, captured from the unchanged packer at commit a3f1124.
[InlineData("1FA61E933D2D0D6F1E3336FCEBBB21CEB7E7D1533FDB253F9D48E947217575CE")]
public void ZeroClampLayoutIsBitIdenticalToBaseline(string hash) =>
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
new[] { Part("rect", Rectangle(10, 5), 12) },
new[] { Stock("sheet", 48, 96, spacing: 0.25) }))));
[Theory]
[InlineData("9B343B6C5CF2CE8753F2E04ACB0A9C4D5921F4ADC15AA2586AF466769C0F019B")]
public void ZeroClampMixedShapeLayoutIsBitIdenticalToBaseline(string hash) =>
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
new[]
{
Part("disc", Disc(3), 10),
Part("ell", LShape(12, 8, 4), 10),
Part("tri", Triangle(9, 6), 10),
},
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5)) }))));
[Theory]
[InlineData("DBF93C1684D85B5EE7EB24C96A84904D2BA0F9A8183631EFC61638CB8B9B935A")]
public void ZeroClampMultiSheetLayoutIsBitIdenticalToBaseline(string hash) =>
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
new[] { Part("square", Rectangle(10, 10), 30) },
new[] { Stock("sheet", 25, 45, spacing: 0.25) }))));
// ---- clamp avoidance ---------------------------------------------------------------
[Fact]
public void PartsNeverTouchAClampZone()
{
var clamp = new ClampZone("front-center", new Box(19.25, 0, 1.5, 0.625));
var job = Job(
new[] { Rectangle("blk", 6, 6, 6) },
new[] { Stock("sheet", 20, 40, spacing: 0.5, clampZones: [clamp]) });
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
{
// Fixed 0-degree rectangles: analytic independent oracle against the raw zone.
Assert.False(
placement.X < 20.75 && placement.X + 6 > 19.25 &&
placement.Y < 0.625 && placement.Y + 6 > 0,
$"placement at ({placement.X},{placement.Y}) intrudes into the clamp zone");
}
}
[Fact]
public void DemandLandsBetweenClampsWhenItFits()
{
// One row of three 2x2 parts; only [0,3], [5,7] and [9,12] are open along X.
var clamps = new[]
{
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
};
var job = Job(
new[] { Rectangle("row", 2, 2, 3) },
new[] { Stock("strip", 2, 12, clampZones: clamps) });
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
var placements = result.Plates.SelectMany(p => p.Placements).ToList();
Assert.Equal(3, placements.Count);
Assert.Contains(placements, p => System.Math.Abs(p.X - 5) < 1e-9); // the middle slot
}
[Fact]
public void ClampWallAcrossTheFrontEdgeBlocksTheOnlyViableRow()
{
// Without clamps the single viable row sits at y = bottom spacing (1.0). The full-width
// front clamp with its edge clearance raises that floor above the work area.
var wall = new ClampZone("rail-wall", new Box(0, 0, 10, 1));
var job = Job(
new[] { Rectangle("tall", 4, 4, 2) },
new[]
{
Stock("sheet", 6, 10, spacing: 0,
edge: new Spacing(left: 0.25, bottom: 1, right: 0.25, top: 0.25),
clampZones: [wall]),
});
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Empty(result.Plates.SelectMany(p => p.Placements));
}
[Fact]
public void ZonesClearOfTheWorkAreaNeedNotShiftAnything()
{
// Top-edge zone the demand never reaches: layout stays identical to no clamps.
var far = new ClampZone("back", new Box(0, 47.5, 1.5, 0.5));
var jobClamped = Job(
new[] { Part("rect", Rectangle(10, 5), 12) },
new[] { Stock("sheet", 48, 96, spacing: 0.25, clampZones: [far]) });
var jobPlain = Job(
new[] { Part("rect", Rectangle(10, 5), 12) },
new[] { Stock("sheet", 48, 96, spacing: 0.25) });
var clamped = Engine().Solve(jobClamped);
var plain = Engine().Solve(jobPlain);
LayoutAssert.Valid(jobClamped, clamped);
Assert.Equal(LayoutHash(plain), LayoutHash(clamped));
}
// ---- large part spacing vs shallow clamp spans (PM d86d22c9, decision D11) --------
//
// The packer reserves a trailing part-spacing tail behind every item, so the block it
// carves per axis is [zone - clearance + s, zone + clearance]. When s exceeds the
// inflated span, that interval inverts. 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). The
// old collapse to the zone centerline let a part start inside the clamp.
//
// The oracle below is independent of the packer and of NestLayoutCheck: material
// [x, x+w] x [y, y+h] must not share interior with the clamp footprint predicted
// directly from the rail rule (center +/- width/2 from the origin edge, depth into the
// sheet, translated exactly like the work area), inflated by the bottom-edge spacing.
// Boundary contact is clear (C16).
private static Box InflatedRail(int quadrant, double length, double width,
double center, double clampWidth, double depth, double clearance)
{
var left = quadrant is 1 or 4 ? 0.0 : -length;
var bottom = quadrant is 1 or 2 ? 0.0 : -width;
var near = center - clampWidth / 2 + left;
var far = center + clampWidth / 2 + left;
return new Box(
System.Math.Min(near, far) - clearance,
bottom - clearance,
clampWidth + 2 * clearance,
depth + 2 * clearance);
}
private static void AssertMaterialClearOfRail(NestJobResult result, Box inflated,
double partW, double partH)
{
Assert.NotEmpty(result.Plates.SelectMany(p => p.Placements));
foreach (var p in result.Plates.SelectMany(pl => pl.Placements))
{
var overlaps = p.X < inflated.Right - 1e-9 && p.X + partW > inflated.Left + 1e-9
&& p.Y < inflated.Top - 1e-9 && p.Y + partH > inflated.Bottom + 1e-9;
Assert.False(overlaps,
$"material [{p.X},{p.X + partW}] x [{p.Y},{p.Y + partH}] intrudes into the " +
$"inflated clamp footprint [{inflated.Left},{inflated.Right}] x " +
$"[{inflated.Bottom},{inflated.Top}]");
}
}
[Theory]
// PM d86d22c9 probe fixture: Y degenerate (s=4 > 0.625 + 2x0.25), full-width front
// rail. Red on ab50900: Rectangles completed at y=0.3125, material on the clamp.
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void DegenerateYSpanKeepsExactClearanceInEveryQuadrant(int quadrant)
{
var stock = Stock("sheet", 4, 5, spacing: 4, edge: new Spacing(0, 0.25, 0, 0),
quadrant: quadrant, clampZones: [new ClampZone("rail", new Box(0, 0, 5, 0.625))]);
var job = Job(new[] { Rectangle("square", 2, 2, 1) }, new[] { stock });
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
AssertMaterialClearOfRail(result,
InflatedRail(quadrant, 5, 4, 2.5, 5, 0.625, 0.25), 2, 2);
}
[Fact]
public void DegenerateXSpanMidRailKeepsExactClearance()
{
// X degenerate (s=3 > 1.5 + 2x0.25); the 10-wide part is forced across the clamp's
// X span because neither side can hold it with the tail. Red on ab50900: completed
// at x=11.5 (the centerline), material inside the clamp.
var stock = Stock("sheet", 4, 23, spacing: 3, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new ClampZone("mid-rail", new Box(10.75, 0, 1.5, 0.625))]);
var job = Job(new[] { Rectangle("wide", 10, 2, 1) }, new[] { stock });
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
AssertMaterialClearOfRail(result, InflatedRail(1, 23, 4, 11.5, 1.5, 0.625, 0.25), 10, 2);
}
[Fact]
public void DegenerateXSpanFarRailClearsTheInteriorFacingEdge()
{
// The clamp center sits in the upper half of the bin, so D11 blocks the
// origin-facing edge (zone min - clearance + s = 20). Red on ab50900: the
// centerline split let the 2x2 land at x=21, material inside the clamp;
// after D11 it lands at x=20 — boundary contact, which is clear.
var stock = Stock("sheet", 4, 23, spacing: 3, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new ClampZone("far-rail", new Box(20.25, 0, 1.5, 0.625))]);
var job = Job(new[] { Rectangle("small", 2, 2, 1) }, new[] { stock });
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
AssertMaterialClearOfRail(result, InflatedRail(1, 23, 4, 21, 1.5, 0.625, 0.25), 2, 2);
}
// ---- C15 keep rows: non-inverted spans and between-clamp packing stay bit-exact ----
private static NestJobResult KeepFixtureLargeSpacingNonInverted() => Engine().Solve(Job(
new[] { Rectangle("blk", 6, 6, 6) },
new[] { Stock("sheet", 20, 40, spacing: 0.5, clampZones: [new ClampZone("front-center", new Box(19.25, 0, 1.5, 0.625))]) }));
private static NestJobResult KeepFixtureBetweenClamps() => Engine().Solve(Job(
new[] { Rectangle("row", 2, 2, 3) },
new[] { Stock("strip", 2, 12, clampZones: new[]
{
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
}) }));
private static NestJobResult KeepFixtureBetweenClampsWithSpacing() => Engine().Solve(Job(
new[] { Rectangle("row", 2, 2, 3) },
new[] { Stock("strip", 2, 12, spacing: 0.5, clampZones: new[]
{
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
}) }));
private static NestJobResult KeepFixtureMidRailSmallSpacing() => Engine().Solve(Job(
new[] { Rectangle("wide", 10, 2, 2) },
new[] { Stock("sheet", 4, 23, spacing: 0.5, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new ClampZone("mid-rail", new Box(10.75, 0, 1.5, 0.625))]) }));
[Theory]
// Captured from the unchanged packer at ab50900 (see progress task-6.2 evidence):
// non-inverted clamp spans must keep today's blocks and layouts bit-for-bit.
[InlineData("KEEP_LARGE_SPACING")]
[InlineData("KEEP_BETWEEN_CLAMPS")]
[InlineData("KEEP_BETWEEN_SPACED")]
[InlineData("KEEP_MID_RAIL_SMALL_SPACING")]
public void NonInvertedClampLayoutsMatchAb50900Digests(string fixture)
{
var hash = fixture switch
{
"KEEP_LARGE_SPACING" => "F7E4691C085FFB2164CEFFFB9B082B5F271E56E8B8B3D2AE23ECF5646FD40450",
"KEEP_BETWEEN_CLAMPS" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61",
"KEEP_BETWEEN_SPACED" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61",
"KEEP_MID_RAIL_SMALL_SPACING" => "97868486E834CA0B1DB110FF717635E38537DB96DF5CDFBB2AFE37E4B1366B71",
_ => throw new ArgumentOutOfRangeException(nameof(fixture)),
};
var actual = fixture switch
{
"KEEP_LARGE_SPACING" => LayoutHash(KeepFixtureLargeSpacingNonInverted()),
"KEEP_BETWEEN_CLAMPS" => LayoutHash(KeepFixtureBetweenClamps()),
"KEEP_BETWEEN_SPACED" => LayoutHash(KeepFixtureBetweenClampsWithSpacing()),
"KEEP_MID_RAIL_SMALL_SPACING" => LayoutHash(KeepFixtureMidRailSmallSpacing()),
_ => throw new ArgumentOutOfRangeException(nameof(fixture)),
};
Assert.Equal(hash, actual);
}
}
@@ -83,6 +83,16 @@ internal sealed class CuttingDependencyGraph
{
if (!nodes[cut].IsCutOff)
continue;
if (nodes[cut].SeparatesSheet)
{
// Cutting a line that splits the sheet comes after every part and every other
// cutoff, so the head never travels back across the separated edge. Such a line
// is continuous, so it passes through no part and needs no crossing edge.
for (var other = 0; other < nodes.Count; other++)
if (!nodes[other].SeparatesSheet)
edges[cut].Add(other);
continue;
}
var definition = nodes[cut].CutOff;
for (var part = 0; part < nodes.Count; part++)
{
@@ -190,9 +200,10 @@ internal sealed class DependencyNode
private LeadMaterialSnapshot material;
internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds,
Func<LeadMaterialSnapshot> materialFactory)
Func<LeadMaterialSnapshot> materialFactory, bool separatesSheet = false)
{
IsCutOff = isCutOff;
SeparatesSheet = isCutOff && separatesSheet;
CutOff = cutOff;
PlacedBounds = Copy(placedBounds);
CleanBounds = cleanBounds == null ? null : Copy(cleanBounds);
@@ -201,6 +212,8 @@ internal sealed class DependencyNode
}
internal bool IsCutOff { get; }
/// <summary>A cutoff whose placed program is one continuous cut across the whole sheet.</summary>
internal bool SeparatesSheet { get; }
internal CutOffDefinition CutOff { get; }
internal Box PlacedBounds { get; }
internal Box CleanBounds { get; }
@@ -336,6 +336,9 @@ public sealed class CuttingPlanProposal
PartSpacing = source.PartSpacing,
EdgeSpacing = source.EdgeSpacing,
};
// Machine clamps stay visible while inspecting the proposal; zones are immutable.
foreach (var zone in source.ClampZones)
preview.ClampZones.Add(zone);
foreach (var proposal in planned.Result.ProposedOrder)
{
var part = (Part)proposal.SourcePart.Clone();
@@ -147,12 +147,15 @@ public sealed class FixedProgramPlacement
}
return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
{ SelectedProgram = selected, IsCutOff = IsCutOff };
{ SelectedProgram = selected, IsCutOff = IsCutOff, SeparatesSheet = SeparatesSheet };
}
/// <summary>A cutoff: always a fixed open-cut program, never material or regenerated.</summary>
public bool IsCutOff { get; internal init; }
/// <summary>A cutoff that splits the whole sheet; it is cut after every other placement.</summary>
public bool SeparatesSheet { get; internal init; }
public Part SourcePart { get; }
public int SourceOrdinal { get; }
public Vector Location { get; }
@@ -78,12 +78,15 @@ public static class CuttingPlanService
var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
if (!cut.HasCuttingContour)
throw new ArgumentException("Placed cutoff program has no nonzero cutting motions.");
nodes.Add(new(true, definitions.TryGetValue(source.BaseDrawing, out var definition)
var defined = definitions.TryGetValue(source.BaseDrawing, out var definition);
var separates = defined && CutOff.SeparatesSheet(source, definition,
request.Plate.BoundingBox(includeParts: false));
nodes.Add(new(true, defined
? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null,
source.BoundingBox, null, null));
source.BoundingBox, null, null, separates));
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
cut, OwnedProgramCopy.Copy(source.Program, token))
{ IsCutOff = true });
{ IsCutOff = true, SeparatesSheet = separates });
continue;
}
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
@@ -382,6 +385,10 @@ public static class CuttingPlanService
{
var part = snapshot.Placements[violation.Part];
var prerequisite = snapshot.Placements[violation.Prerequisite];
if (part.SeparatesSheet)
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
$"{subject} cuts the sheet-separating cutoff (part {part.SourceOrdinal + 1}) before part "
+ $"{prerequisite.SourceOrdinal + 1}; every part and other cutoff must be cut before the sheet is separated.");
var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout";
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
$"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1}).");
+67 -9
View File
@@ -12,11 +12,22 @@ namespace OpenNest.Engine.Fill
/// </summary>
public static class Compactor
{
/// <summary>
/// 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).
/// </summary>
private static List<Part> ZoneObstacles(Plate plate) =>
Jobs.InteractiveClamps.ObstacleParts(plate);
public static double Push(List<Part> 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
/// </summary>
public static double Push(List<Part> 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;
}
}
/// <summary>
/// As <see cref="Settle(List{Part}, Box, double, double, int)"/>, 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).
/// </summary>
public static void Settle(
List<Part> parts,
Plate plate,
double threshold = 0.01,
int maxIterations = 20
) => Settle(parts, plate, plate.WorkArea(), threshold, maxIterations);
/// <summary>Keep a fill group's own work-area bounds while excluding plate clamps.</summary>
public static void Settle(
List<Part> 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;
}
}
}
}
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -15,10 +15,18 @@ namespace OpenNest.Engine.Fill
private readonly RemnantFinder finder;
private readonly double spacing;
public RemnantFiller(Box workArea, double spacing)
/// <summary>
/// <paramref name="boxObstacles"/> 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.
/// </summary>
public RemnantFiller(Box workArea, double spacing, IEnumerable<Box> boxObstacles = null)
{
this.spacing = spacing;
finder = new RemnantFinder(workArea);
if (boxObstacles != null)
foreach (var box in boxObstacles)
finder.AddObstacle(box);
}
public void AddObstacles(IEnumerable<Part> parts)
@@ -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<ClampZone>(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;
}
}
@@ -0,0 +1,516 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest.Engine.Jobs;
/// <summary>
/// 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.
/// </summary>
internal static class ClampRemovalRecovery
{
/// <summary>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).</summary>
internal sealed record Outcome(NestJob Job, NestJobResult Result);
/// <summary>
/// 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).
/// </summary>
internal const int MaxProbeSolves = 1000;
/// <summary>
/// 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).
/// </summary>
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<string>(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<string>(StringComparer.Ordinal), StringComparer.Ordinal);
var recovered = new List<(NestPlateStock Variant, string BaseId, List<NestJobPlateResult> 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<RemovalGroup>();
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<NestJobPlateResult> 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<string> BlockedIds { get; } = new(StringComparer.Ordinal);
}
/// <summary>
/// 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.
/// </summary>
private static IReadOnlyList<INestingEngine> FeasibilityLanes(INestingEngine engine)
{
if (engine is NestingEngines.Default.DefaultNestingEngine)
return new[] { engine };
var lanes = new List<INestingEngine> { 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<string> RemovedIds);
/// <summary>
/// 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.
/// </summary>
private static Removal BestRemoval(
NestJobPart part,
IReadOnlyList<NestPlateStock> stocks,
NestJobOptions options,
IReadOnlyList<INestingEngine> lanes,
ProbeBudget budget,
CancellationToken token
)
{
var candidates = new List<(NestPlateStock Stock, List<ClampZone> Zones, List<INestingEngine> 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<ClampZone> Zones, List<INestingEngine> Lanes, List<int> Mandatory)>();
foreach (var (stock, zones, fitting) in candidates)
{
var mandatory = new List<int>();
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;
}
/// <summary>Every <paramref name="count"/>-element subset of <paramref name="items"/>, lazily,
/// in lexicographic order of positions.</summary>
private static IEnumerable<List<int>> Combinations(IReadOnlyList<int> 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;
}
}
/// <summary>Only clamps resolved from a machine rail can be removed (see
/// <see cref="NestPlateStock.ClampRail"/>): explicit zones have no clamp to name.</summary>
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<NestPlateStock> stocks,
NestJobOptions options,
INestingEngine engine,
ProbeBudget budget,
CancellationToken token
) => Probe(part, stocks, options, engine, budget, token);
/// <summary>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.</summary>
private static bool Probe(
NestJobPart part,
IReadOnlyList<NestPlateStock> 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;
}
/// <summary>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).</summary>
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;
/// <summary>Answers already paid for, by (engine, part, stock signatures).</summary>
public Dictionary<(INestingEngine, string, string), bool> Answers { get; } = new();
/// <summary>Throws <see cref="ProbeBudgetExhaustedException"/> once spent; recovery
/// then keeps the ordinary result.</summary>
public void Spend()
{
if (left-- <= 0)
throw new ProbeBudgetExhaustedException();
}
}
private sealed class ProbeBudgetExhaustedException()
: Exception("Clamp removal recovery ran out of feasibility probes.");
/// <summary>Same stock with unlimited quantity (feasibility probes never consume real
/// inventory); id/zones unchanged.</summary>
private static NestPlateStock Unlimited(NestPlateStock stock) =>
stock.Quantity == null ? stock : Clone(stock, stock.Id, null, null);
/// <summary>
/// A copy of <paramref name="stock"/> with a new id/quantity and, when
/// <paramref name="removedIds"/> 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.
/// </summary>
private static NestPlateStock Clone(
NestPlateStock stock,
string id,
int? quantity,
IReadOnlyCollection<string> 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<string> removedIds) =>
$"{stockId}::removed:{string.Join(",", removedIds)}";
/// <summary>
/// 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.
/// </summary>
private static Outcome Merge(
NestJob job,
NestJobResult raw,
IReadOnlyList<(NestPlateStock Variant, string BaseId, List<NestJobPlateResult> 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<NestJobPlateResult>();
var variantStock = new List<NestPlateStock>();
var takenFromBase = new Dictionary<string, int>(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<NestJobPlacement>(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<string, int>(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);
}
}
+178
View File
@@ -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;
/// <summary>
/// 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
/// <see cref="Plate.ClampZones"/> (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
/// <see cref="Plate.Parts"/>, plates, jobs or files.
/// </summary>
internal static class InteractiveClamps
{
public static bool HasZones(Plate plate) => plate.ClampZones.Count > 0;
/// <summary>
/// Raw zone rectangles in the parts' placement frame (plate zones are stored in sheet
/// coordinates, see <see cref="ClampZoneFrame"/>), detached copies, capture order.
/// </summary>
public static IReadOnlyList<Box> ZoneRectangles(Plate plate)
{
if (plate.ClampZones.Count == 0)
return Array.Empty<Box>();
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;
}
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<Box> ClearanceRectangles(Plate plate) =>
InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom));
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<Box> ValidationKeepOuts(Plate plate) =>
InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom - NestTolerances.SpacingSlack));
private static IReadOnlyList<Box> InflateZones(Plate plate, double clearance)
{
if (plate.ClampZones.Count == 0)
return Array.Empty<Box>();
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;
}
/// <summary>
/// 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.
/// </summary>
public static List<Part> ObstacleParts(Plate plate) =>
ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom - plate.PartSpacing));
/// <summary>Expander walls use the bottom-edge clearance as a fixed floor,
/// independent of the separation target or its final achieved spacing.</summary>
public static List<Part> WallParts(Plate plate) =>
ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom));
private static List<Part> ObstaclePartsFor(IReadOnlyList<Box> rectangles, double inflate)
{
var parts = new List<Part>(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;
}
/// <summary>
/// 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).
/// </summary>
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<Polygon> { outerA };
var ringsB = new List<Polygon> { 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<Polygon> HolePolygons(ShapeProfile profile)
{
if (profile.Cutouts.Count == 0)
return null;
var holes = new List<Polygon>(profile.Cutouts.Count);
foreach (var cutout in profile.Cutouts)
holes.Add(cutout.ToPolygonWithTolerance(0.001));
return holes;
}
private static List<Entity> 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);
});
}
+39
View File
@@ -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)));
}
/// <summary>
/// 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 <see cref="ClampZone"/>. Zones are compared in the placement frame, where
/// the stock carries them.
/// </summary>
private static void ValidateClampZones(NestPlateStock stock, NestJob job)
{
if (stock.ClampZones.Count == 0)
return;
var ids = new HashSet<string>(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,
+99
View File
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
private static void ValidateClamps(
List<Part> parts,
Plate plate,
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
List<string> 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"
);
}
}
}
/// <summary>
/// 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.
/// </summary>
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<Polygon>() };
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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<ClampZone> x,
IReadOnlyList<ClampZone> 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
{
+14
View File
@@ -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,
@@ -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();
}
/// <summary>
/// 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.
/// </summary>
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<string>(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<ClampZone> plateZones,
IReadOnlyList<ClampZone> 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;
}
}
+72 -4
View File
@@ -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<ClampZone> 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<ClampZone> 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<ClampZone>();
SheetClampZones = Array.Empty<ClampZone>();
}
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;
}
}
/// <summary>
/// Keep-out rectangles in the placement frame (quadrant applied), translated exactly
/// like <see cref="WorkArea"/>. 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.
/// </summary>
public IReadOnlyList<ClampZone> ClampZones { get; }
/// <summary>
/// The same zones exactly as given, in plate coordinates (the frame of
/// <see cref="Plate.ClampZones"/>), for plates built from this stock. Keeping the input
/// avoids a lossy translate-and-back; <see cref="ClampZoneFrame.ToPlacement"/> of such a
/// plate reproduces <see cref="ClampZones"/> bit for bit.
/// </summary>
internal IReadOnlyList<ClampZone> SheetClampZones { get; }
/// <summary>
/// The machine clamp rail <see cref="ClampZones"/> 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.
/// </summary>
internal ClampRail ClampRail { get; init; }
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
public double? Cost { get; }
public string Id { get; }
@@ -66,4 +112,26 @@ public sealed class NestPlateStock
/// <summary>True when a width (X) by height (Y) envelope fits the work area within epsilon.</summary>
public bool Fits(double width, double height, double epsilon = 1e-9) =>
width <= workArea.Length + epsilon && height <= workArea.Width + epsilon;
private sealed class DetachedZones : IReadOnlyList<ClampZone>
{
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<ClampZone> 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);
}
}
+15 -3
View File
@@ -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();
}
/// <summary>
/// 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.
/// </summary>
private static IReadOnlyList<ClampZone> ClampZonesFor(Plate template, Size size) =>
template.ClampRail?.ResolveFor(size, template.Quadrant)
?? new List<ClampZone>(template.ClampZones);
/// <summary>Single-sheet callers may not silently create additional sheets.</summary>
public static IReadOnlyList<NestPlateStock> SinglePlate(Plate plate) =>
FromTemplate(plate, null, quantityPerOption: 1);
@@ -27,6 +27,9 @@ internal static class PlateFillOrchestrator
return new List<Part>();
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<Part>();
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<NestItem> pairItems,
List<Part> existingParts,
Box fullWorkArea
Box fullWorkArea,
IReadOnlyList<Box> clampKeepOuts = null
)
{
var result = new List<Part>();
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)
@@ -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<Part> 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<Part> 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<Part>();
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<Part> 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<Part>();
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<NestProgress> progress, List<Part> 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<Part> FillClearAreas(Plate plate, Box box, NestItem item,
CancellationToken token, Func<Box, NestItem, List<Part>> fill,
List<Part> previouslyPlaced = null) =>
ClearAreas(plate, box, token, fill, item.Quantity, item, previouslyPlaced);
private static List<Part> ClearAreas(Plate plate, Box box, CancellationToken token,
Func<Box, NestItem, List<Part>> fill, int quantity, NestItem item = null,
List<Part> previouslyPlaced = null, int groupSize = 1)
{
var result = new List<Part>();
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<NestItem> { item }, previouslyPlaced, groupSize);
}
return result;
}
private static List<Box> 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<Part> accepted, List<Part> proposed, Plate plate,
Box area, List<NestItem> demand = null, List<Part> 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<Part>();
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<Part> batch, Part part, List<Part> accepted,
Plate plate, Box area, List<NestItem> demand, List<Part> 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;
/// <summary>
/// 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
+25 -1
View File
@@ -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<ClampZone> x,
System.Collections.Generic.IReadOnlyList<ClampZone> 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);
@@ -0,0 +1,61 @@
#nullable enable
using Clipper2Lib;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>
/// 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.
/// </summary>
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);
}
@@ -35,8 +35,8 @@ internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList<Placed> 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<PartType> 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;
@@ -118,6 +118,7 @@ public sealed class IrregularNestingEngine : INestingEngine
private const int MaxTail = 3;
private readonly Dictionary<double, NoFitCache> caches = new();
private readonly Dictionary<NestPlateStock, ClampObstacles> clamps = new();
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
private readonly Dictionary<double, BlockCatalog> blocks = new();
private readonly IReadOnlyDictionary<string, NestJobPart> 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)
@@ -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);
}
/// <summary>
/// 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).
/// </summary>
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);
@@ -91,6 +91,20 @@ internal sealed class MaxRectsSheet
/// <summary>Commits an item and splits every free rectangle it intersects.</summary>
public void Place(Rect item)
{
Split(item);
used.Add(item);
}
/// <summary>
/// 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.
/// </summary>
public void Block(Rect keepOut) => Split(keepOut);
private void Split(Rect item)
{
var next = new List<Rect>(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<Rect> Prune(List<Rect> rects)
+18 -6
View File
@@ -34,16 +34,27 @@ namespace OpenNest.Engine.Sequencing
private static List<Part> OrderCutOffsFirst(List<Part> 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<Drawing, CutOff>(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<Part>(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<Part>(ordered.Count);
for (var i = 0; i < ordered.Count; i++)
@@ -75,6 +86,7 @@ namespace OpenNest.Engine.Sequencing
}
}
result.AddRange(separating);
return result;
}
}
@@ -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;
/// <summary>
/// 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).
/// </summary>
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);
/// <summary>A rail nest whose first sheet is kept by a cut-off; the second is empty.</summary>
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);
}
/// <summary>Removes a named property wherever it appears in the document, simulating a
/// key an older writer never emitted. Rebuilds via <see cref="JsonNode"/> 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.</summary>
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<string, string> 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;
}
}
@@ -0,0 +1,176 @@
using System.IO.Compression;
using System.Text;
using OpenNest.Geometry;
namespace OpenNest.IO.Tests;
/// <summary>
/// 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.
/// </summary>
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<string, string> 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;
}
}
+165
View File
@@ -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();
/// <summary>
/// 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.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(LenientClampRailConverter))]
public ClampRailDto ClampRail { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public sealed class LenientClampRailConverter : JsonConverter<ClampRailDto>
{
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<double>();
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<double>())
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);
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class LenientStringListConverter : JsonConverter<List<string>>
{
public override List<string> Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
using var document = JsonDocument.ParseValue(ref reader);
var list = new List<string>();
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<string> value,
JsonSerializerOptions options
)
{
writer.WriteStartArray();
foreach (var item in value)
writer.WriteStringValue(item);
writer.WriteEndArray();
}
}
public record ClampRailDto
{
/// <summary>Clamp extent along the origin-side edge (X).</summary>
public double Width { get; init; }
/// <summary>Clamp intrusion into the sheet from that edge (Y).</summary>
public double Depth { get; init; }
/// <summary>Clamp centers along X, measured from the plate origin.</summary>
public List<double> Centers { get; init; } = new();
/// <summary>
/// Minutes to remove one clamp and put it back after cutting (D9). Null in older
/// files and readers fall back to <see cref="OpenNest.ClampRail.DefaultRemovalMinutes"/>.
/// </summary>
public double? RemovalMinutes { get; init; }
}
public record DrawingDto
@@ -71,6 +198,44 @@ namespace OpenNest.IO
public CuttingParametersDto CuttingParameters { get; init; }
public List<PartDto> Parts { get; init; } = new();
public List<CutOffDto> CutOffs { get; init; } = new();
/// <summary>
/// Machine clamp keep-out rectangles in plate coordinates. Missing in older
/// files, which load with no zones.
/// </summary>
public List<ClampZoneDto> Clamps { get; init; } = new();
/// <summary>
/// Rail clamp ids removed from this sheet as a last resort (D8). Omitted when none
/// were removed; missing or empty means none; <see cref="Clamps"/> 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.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(LenientStringListConverter))]
public List<string> RemovedClamps { get; init; } = new();
/// <summary>
/// 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 <see cref="PlateDefaultsDto.ClampRail"/>.
/// </summary>
[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; }
/// <summary>Extent along X.</summary>
public double Length { get; init; }
/// <summary>Extent along Y (depth from the y = 0 operator-side edge).</summary>
public double Width { get; init; }
}
public record PartDto
+61
View File
@@ -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
}
}
/// <summary>
/// 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 <see cref="ClampRail.DefaultRemovalMinutes"/>.
/// </summary>
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<int, Drawing> 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<string>();
// 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];
+75
View File
@@ -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;
}
/// <summary>
/// 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).
/// </summary>
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,
};
/// <summary>Numeric suffix of a rail clamp id ("clamp-3" -&gt; 3), for a natural sort of
/// <see cref="PlateDto.RemovedClamps"/> instead of lexical ("clamp-10" before "clamp-2").
/// A non-matching id sorts last by its own ordinal text.</summary>
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<PlateDto> BuildPlateDtos()
{
var list = new List<PlateDto>();
@@ -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),
}
);
}
+398
View File
@@ -0,0 +1,398 @@
using OpenNest.Geometry;
namespace OpenNest.Tests;
/// <summary>
/// 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).
/// </summary>
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<ArgumentOutOfRangeException>(() => new ClampRail(width, depth, new[] { 6.0 }));
}
[Theory]
[InlineData(-0.5)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void CentersMustBeFiniteAndFromTheOrigin(double center)
{
Assert.Throws<ArgumentOutOfRangeException>(() => new ClampRail(1.5, 0.625, new[] { 6, center }));
Assert.Throws<ArgumentNullException>(() => 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<double>()));
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<ArgumentOutOfRangeException>(
() => 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<string>()));
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<string>)removed.RemovedClamps).IsReadOnly);
Assert.True(((ICollection<string>)untouched.RemovedClamps).IsReadOnly);
}
}
+133
View File
@@ -0,0 +1,133 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests;
/// <summary>
/// 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.
/// </summary>
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<ArgumentException>(() => new ClampZone("clamp", new Box(x, y, length, width)));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void MissingIdIsRejected(string? id)
{
Assert.ThrowsAny<ArgumentException>(
() => new ClampZone(id!, new Box(0, 0, 1.5, 0.625))
);
}
[Fact]
public void NullBoundsRejected()
{
Assert.Throws<ArgumentNullException>(() => 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);
}
}
@@ -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;
@@ -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<Motion>())
Assert.Equal(cut.Position.X, move.EndPoint.X);
}
}
}
@@ -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<InvalidOperationException>(() =>
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<RapidMove>(codes[index]).EndPoint + cut.Location;
var to = Assert.IsType<LinearMove>(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."));
}
}
}
}
@@ -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<ArgumentException>(() => 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<LinearMove>());
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);
}
}
@@ -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<double[]>();
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);
}
}
}
@@ -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<ValueTuple<double, int>>(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<LinearMove>(cut.Drawing.Program.Codes[2 * i + 1]).EndPoint.Y -
Assert.IsType<RapidMove>(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);
}
}
@@ -0,0 +1,65 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests.CutOffs;
/// <summary>Which cut-offs split the whole sheet (and so must be cut after every part).</summary>
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));
}
@@ -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;
/// <summary>The cutting planner's existing-work banner, its Clear button and staged automatic cut-offs.</summary>
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));
}
@@ -0,0 +1,22 @@
using OpenNest.Engine.CuttingPlanning;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>Machine clamps stay visible on a cutting-plan preview of a clamped plate.</summary>
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
}
}
@@ -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()
{
+214
View File
@@ -0,0 +1,214 @@
using OpenNest.Data;
using OpenNest.Geometry;
namespace OpenNest.Tests.Data;
/// <summary>
/// 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).
/// </summary>
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<ArgumentOutOfRangeException>(() => editor.ApplyTo(nest));
Assert.Throws<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => editor.ToMachineDefaults());
Assert.Throws<ArgumentOutOfRangeException>(() => editor.BuildRail());
}
}
+408
View File
@@ -0,0 +1,408 @@
using System.Text.Json;
using OpenNest.Data;
using OpenNest.Geometry;
namespace OpenNest.Tests.Data;
/// <summary>
/// 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.
/// </summary>
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<double>();
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;
}
}
}
@@ -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<ArgumentException>(() => 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));
}
}
@@ -0,0 +1,319 @@
using System.Linq;
using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Diagnostics;
/// <summary>
/// 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 <see cref="ClampRail"/> 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.
/// </summary>
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<LinearMove>())
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<ArgumentException>(() => ClampZoneFrame.ToPlacement(plate));
zone.Bounds.Width = 0.625;
zone.Bounds.X = double.NaN;
Assert.Throws<ArgumentException>(() => ClampZoneFrame.ToPlacement(plate));
plate.ClampZones.Clear();
plate.ClampZones.Add(null!);
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<Part> { 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<Part>
{
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<Part>
{
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<ClampZone>()));
Assert.Equal(Describe(legacy), Describe(explicitEmpty));
}
[Fact]
public void IncrementalRecheckAfterMovingAClampMatchesAFullAnalysis()
{
var parts = new List<Part>
{
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<Part>
{
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<Part> { 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<Part> { 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<Arc>().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<Part> { 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<Part> { 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}"));
}
@@ -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()
{
@@ -0,0 +1,154 @@
using System;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Diagnostics;
/// <summary>
/// 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 <see cref="Plate.SetClampRail"/> 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.
/// </summary>
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);
}
/// <summary>Independent oracle: does the closed segment from->to touch the closed box?</summary>
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<LinearMove>())
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;
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Independent oracle: part boxes against clamps grown by the bottom edge spacing.</summary>
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);
}
}
+22
View File
@@ -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<Line> { 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]
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more