mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-16 04:21:36 -04:00
Merge remote-tracking branch 'github/master' into feat/inspection-view-navigation
This commit is contained in:
commit
29f48789dd
126 files changed
+15253
-398
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")))}]";
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 < 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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}).");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+703
-142
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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" -> 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),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user