diff --git a/OpenNest.Core/AppliedCuttingWork.cs b/OpenNest.Core/AppliedCuttingWork.cs new file mode 100644 index 0000000..0a1acc9 --- /dev/null +++ b/OpenNest.Core/AppliedCuttingWork.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest; + +/// Existing cutting work on a set of plates: applied lead-ins and cut-offs. +/// Real parts carrying applied lead-ins (locked ones included). +/// Of those, parts whose lead-ins are locked. +/// Cut-off definitions. +/// Display numbers of the plates holding any of it, ascending. +public sealed record AppliedCuttingWorkSummary(int LeadInParts, int LockedLeadInParts, int CutOffs, + IReadOnlyList PlateNumbers) +{ + public bool HasExistingWork => LeadInParts > 0 || CutOffs > 0; + + /// Operator text for the cutting planner's warning banner; empty when nothing exists. + public string Describe() + { + if (!HasExistingWork) + return string.Empty; + var found = new List(); + if (LeadInParts > 0) + found.Add($"lead-ins on {Count(LeadInParts, "part")}" + + (LockedLeadInParts > 0 ? $" ({LockedLeadInParts} locked)" : string.Empty)); + if (CutOffs > 0) + found.Add(Count(CutOffs, "cut-off")); + var plates = PlateNumbers.Count == 1 ? "plate " : "plates "; + var kept = new List(); + if (LockedLeadInParts > 0) + kept.Add("locked lead-ins"); + if (CutOffs > 0) + kept.Add("existing cut-offs"); + return $"ALREADY APPLIED: {string.Join(" and ", found)} on {plates}{string.Join(", ", PlateNumbers)}." + + (LeadInParts > LockedLeadInParts ? " Planning recalculates unlocked lead-ins." : string.Empty) + + (kept.Count > 0 ? $" Planning keeps {string.Join(" and ", kept)} unchanged." : string.Empty) + + " Clear them to recalculate everything from scratch."; + } + + private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}"; +} + +/// Finds and clears applied lead-ins and cut-offs so a plan can recalculate them. +public static class AppliedCuttingWork +{ + /// + /// Counts applied lead-ins on real parts and cut-off definitions. Definitions in + /// (for example ones staged by the caller) are not counted. + /// + public static AppliedCuttingWorkSummary Summarize(IReadOnlyList plates, + IReadOnlyList plateNumbers = null, IEnumerable ignoredCutOffs = null) + { + ArgumentNullException.ThrowIfNull(plates); + if (plateNumbers != null && plateNumbers.Count != plates.Count) + throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers)); + var ignored = new HashSet(ignoredCutOffs ?? [], ReferenceEqualityComparer.Instance); + int leads = 0, locked = 0, cutOffs = 0; + var numbers = new SortedSet(); + for (var index = 0; index < plates.Count; index++) + { + var plate = plates[index] ?? throw new ArgumentException("Plates must not be null.", nameof(plates)); + var plateLeads = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray(); + var plateCutOffs = plate.CutOffs.Count(c => !ignored.Contains(c)); + leads += plateLeads.Length; + locked += plateLeads.Count(p => p.LeadInsLocked); + cutOffs += plateCutOffs; + if (plateLeads.Length > 0 || plateCutOffs > 0) + numbers.Add(plateNumbers?[index] ?? index + 1); + } + return new(leads, locked, cutOffs, numbers.ToArray()); + } + + /// + /// Removes every applied lead-in (locked ones too) from real parts and every cut-off with + /// its cut-off parts, and forgets each plate's recorded cutting parameters. Part poses, + /// order and quantities are unchanged. Returns what was cleared. + /// + public static AppliedCuttingWorkSummary Clear(IReadOnlyList plates, IReadOnlyList plateNumbers = null) + { + var cleared = Summarize(plates, plateNumbers); + foreach (var plate in plates) + { + foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray()) + part.RemoveLeadIns(); + plate.CutOffs.Clear(); + for (var index = plate.Parts.Count - 1; index >= 0; index--) + if (plate.Parts[index].BaseDrawing.IsCutOff) + plate.Parts.RemoveAt(index); + plate.CuttingParameters = null; + } + return cleared; + } +} diff --git a/OpenNest.Core/AutomaticCutOffBatch.cs b/OpenNest.Core/AutomaticCutOffBatch.cs index 1c04ae3..6e42950 100644 --- a/OpenNest.Core/AutomaticCutOffBatch.cs +++ b/OpenNest.Core/AutomaticCutOffBatch.cs @@ -42,11 +42,19 @@ public static class AutomaticCutOffBatch /// Returned plans describe the fresh proposal, not preview parts to accept into a plate. /// public static IReadOnlyList Apply(IReadOnlyList plates, + AutomaticCutOffOptions options, CutOffSettings settings) => Stage(plates, options, settings).Plans; + + /// + /// Applies like , but keeps the recovery state so the caller can take the + /// new cut-offs back off again () when the work they + /// were added for is abandoned. A blocked batch changes nothing and has nothing to revert. + /// + public static AutomaticCutOffStaging Stage(IReadOnlyList plates, AutomaticCutOffOptions options, CutOffSettings settings) { var plans = Create(plates, options, settings); if (plans.Any(plan => plan.HasBlockingDiagnostics)) - return plans; + return new AutomaticCutOffStaging(plans, []); var restoreActions = new List<(int PlateNumber, Action Restore)>(); try @@ -66,21 +74,7 @@ public static class AutomaticCutOffBatch } catch (Exception applyError) { - var rollbackErrors = new List(); - for (var index = restoreActions.Count - 1; index >= 0; index--) - { - var saved = restoreActions[index]; - try - { - saved.Restore(); - } - catch (Exception rollbackError) - { - // A broken observer on one plate must not prevent recovery of the others. - rollbackErrors.Add(new InvalidOperationException( - $"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError)); - } - } + var rollbackErrors = RestoreAll(restoreActions); if (rollbackErrors.Count > 0) throw new InvalidOperationException( $"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: " @@ -91,7 +85,27 @@ public static class AutomaticCutOffBatch throw new InvalidOperationException( $"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError); } - return plans; + return new AutomaticCutOffStaging(plans, restoreActions); + } + + internal static List RestoreAll(IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions) + { + var errors = new List(); + for (var index = restoreActions.Count - 1; index >= 0; index--) + { + var saved = restoreActions[index]; + try + { + saved.Restore(); + } + catch (Exception rollbackError) + { + // A broken observer on one plate must not prevent recovery of the others. + errors.Add(new InvalidOperationException( + $"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError)); + } + } + return errors; } private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan) @@ -117,3 +131,52 @@ public static class AutomaticCutOffBatch }; } } + +/// +/// Cut-offs added by that are still provisional: either +/// them once the work they belong to is committed, or them. +/// Owner-thread only, like the plates it changed. +/// +public sealed class AutomaticCutOffStaging +{ + private IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions; + + internal AutomaticCutOffStaging(IReadOnlyList plans, + IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions) + { + Plans = plans; + this.restoreActions = restoreActions; + Definitions = plans.SelectMany(plan => plan.HasBlockingDiagnostics ? [] : plan.Definitions).ToArray(); + } + + /// The fresh plans, one per plate in the order given. + public IReadOnlyList Plans { get; } + + /// True when any plate blocked the batch; then nothing was added. + public bool IsBlocked => Plans.Any(plan => plan.HasBlockingDiagnostics); + + /// The definitions this staging added (empty when blocked or nothing was needed). + public IReadOnlyList Definitions { get; } + + /// True while the added cut-offs can still be reverted. + public bool CanRevert => restoreActions.Count > 0; + + /// Makes the added cut-offs permanent; a later does nothing. + public void Keep() => restoreActions = []; + + /// + /// Removes the added cut-offs and restores each touched plate's previous cut-off programs and + /// sequence. Real parts are not touched. Runs once; throws if any plate could not be restored. + /// + public void Revert() + { + var actions = restoreActions; + restoreActions = []; + var errors = AutomaticCutOffBatch.RestoreAll(actions); + if (errors.Count > 0) + throw new InvalidOperationException( + "Removing the added cut-offs failed: " + string.Join("; ", errors.Select(e => e.Message)) + + "\nThe nest may be incomplete; review it before saving or cutting.", + new AggregateException(errors)); + } +} diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs index be1f594..ce5e0f7 100644 --- a/OpenNest.Core/AutomaticCutOffPlanner.cs +++ b/OpenNest.Core/AutomaticCutOffPlanner.cs @@ -21,6 +21,12 @@ public sealed class AutomaticCutOffOptions /// The desktop default is 12 inches (304.8 mm). /// public double MinimumTailLength { get; set; } + + /// + /// Minimum strip length in model units. Null or equal to Spacing preserves the fixed grid. + /// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing. + /// + public double? MinimumStripLength { get; set; } } public enum AutomaticCutOffDiagnosticCode @@ -29,8 +35,10 @@ public enum AutomaticCutOffDiagnosticCode LimitedCutOffConflict, EmptyCut, SegmentedCut, + ClampBlockedCut, NoSafeTailSeparator, TailBelowMinimum, + ShiftedCut, } public sealed record AutomaticCutOffDiagnostic( @@ -69,6 +77,12 @@ public sealed class AutomaticCutOffPlan /// True only for a verified full-width separator, never just a nominal boundary. public bool HasSeparatedTail => TailSeparatorX.HasValue; + /// Total cut length of proposed new strip lines (excludes the tail separator). + public double StripCutLength { get; internal set; } + + /// Length of the nominal new strip lines in the same gaps. Equal to StripCutLength on the fixed-grid path. + public double FixedGridCutLength { get; internal set; } + public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking); } @@ -78,6 +92,7 @@ public static class AutomaticCutOffPlanner public const double GeometryTolerance = Tolerance.Epsilon; public const double MinimumSpacing = 2 * GeometryTolerance; public const int MaximumCandidateCount = 10000; + public const int MaximumShiftCandidateCount = 20000; /// /// Plans without changing the plate, its parts, or existing cut-off definitions/programs. @@ -93,6 +108,8 @@ public static class AutomaticCutOffPlanner ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(settings); ValidateInputs(plate, options, settings); + // Stored zones use the sheet's lower-left frame; cut positions are signed. + var clamps = ClampZoneFrame.ToPlacement(plate); var bounds = plate.BoundingBox(false); Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel), "Physical sheet bounds and overtravel must be finite.", nameof(plate)); @@ -130,14 +147,57 @@ public static class AutomaticCutOffPlanner var diagnostics = new List(); var separated = false; var separatorX = (double?)null; + var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing; + var stripCutLength = 0.0; + var fixedGridCutLength = 0.0; + var measurements = new Dictionary(); + var candidates = new HashSet(); - // Multiplication by an integer avoids drift from repeated floating-point addition. - for (var index = 1; index <= (int)candidateCount; index++) + if (shifted) { - var distance = index * options.Spacing; - if (distance >= usedSpan - GeometryTolerance) - break; - AddCandidate(sign * distance, false); + // Full-span definitions fix the strip boundaries, even when their emitted + // programs are segmented. Partial cuts remain obstacles, not boundaries. + var boundaries = plate.CutOffs + .Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings)) + .Select(c => sign * c.Position.X) + .Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance) + .Distinct().OrderBy(distance => distance).Append(usedSpan); + var gaps = new List<(double Start, double End, int Count)>(); + var start = 0.0; + var nominalCount = 0; + foreach (var end in boundaries) + { + var count = 0; + for (var index = 1; index <= (int)candidateCount; index++) + { + if (start + index * options.Spacing >= end - GeometryTolerance) + break; + count++; + } + gaps.Add((start, end, count)); + nominalCount += count; + start = end; + } + // The origin-based lattice alone fits within 20,000 positions, but its + // off-lattice nominal lines need reserved slots too. Budget both before + // constructing any row so a valid, dense grid is never refused halfway. + Require(nominalCount < MaximumShiftCandidateCount, + "Too many nominal strip lines for the shift search.", nameof(options)); + var step = System.Math.Max(options.Spacing / 700, + usedSpan / (MaximumShiftCandidateCount - nominalCount)); + foreach (var gap in gaps) + AddShiftedGap(gap.Start, gap.End, gap.Count, step); + } + else + { + // Keep the legacy loop and certification unchanged when shifting is disabled. + for (var index = 1; index <= (int)candidateCount; index++) + { + var distance = index * options.Spacing; + if (distance >= usedSpan - GeometryTolerance) + break; + AddCandidate(sign * distance, false); + } } if (hasTailCandidate) { @@ -155,9 +215,14 @@ public static class AutomaticCutOffPlanner { // A partial proposal must not accidentally be accepted around a manual conflict. definitions.Clear(); + stripCutLength = 0; separated = false; separatorX = null; } + // The legacy placement loop stays untouched. Its detached programs are already + // regenerated, so reporting their actual new strip cutting needs no new search. + if (!shifted) + fixedGridCutLength = stripCutLength; return new AutomaticCutOffPlan { @@ -168,12 +233,74 @@ public static class AutomaticCutOffPlanner UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length, TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0, TailSeparatorX = separatorX, + StripCutLength = stripCutLength, + FixedGridCutLength = fixedGridCutLength, }; + void AddShiftedGap(double start, double end, int count, double step) + { + var nominal = new List(count); + for (var index = 1; index <= count; index++) + { + var distance = start + index * options.Spacing; + nominal.Add(distance); + fixedGridCutLength += Measure(distance).Length; + } + if (count == 0) + return; + var positions = AutomaticCutOffStripSearch.FindPositions(start, end, count, + options.Spacing, options.MinimumStripLength.Value, step, Allowed, Measure) ?? nominal.ToArray(); + for (var index = 0; index < positions.Length; index++) + { + var distance = positions[index]; + AddCandidate(sign * distance, false); + if (!Near(distance, nominal[index])) + { + var difference = Measure(nominal[index]).Length - Measure(distance).Length; + var comparison = difference > 0 ? $"{difference:G8} model units less" : + difference < 0 ? $"{-difference:G8} model units more" : "the same length"; + diagnostics.Add(new AutomaticCutOffDiagnostic(AutomaticCutOffDiagnosticCode.ShiftedCut, + $"Moved from {nominal[index]:G8} to {distance:G8} model units; " + + $"this line cuts {comparison}.", X: sign * distance)); + } + } + } + + bool Allowed(double distance) + { + RegisterCandidate(distance); + // Use the certification path's exact Near predicate, including its ULP + // allowance, so a selected line cannot rediscover a limited-cut conflict. + var x = sign * distance; + return !plate.CutOffs.Any(c => c.Axis == CutOffAxis.Vertical && Near(c.Position.X, x)) && + !clamps.Any(zone => CrossesClamp(zone, x)); + } + + void RegisterCandidate(double distance) + { + if (candidates.Add(distance)) + Require(candidates.Count <= MaximumShiftCandidateCount, + $"Shift search would generate more than {MaximumShiftCandidateCount} distinct positions. " + + "Increase spacing or the minimum strip length, or use the fixed grid.", nameof(options)); + } + + (double Length, int SegmentCount) Measure(double distance) + { + RegisterCandidate(distance); + if (!measurements.TryGetValue(distance, out var measured)) + { + measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical) + .MeasureSegments(plate, settings, cache); + measurements.Add(distance, measured); + } + return measured; + } + void AddCandidate(double x, bool isSeparator) { var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical && Near(c.Position.X, x)).ToList(); + if (matches.Any(c => !FullSpanLimits(c, bounds, settings))) { diagnostics.Add(new AutomaticCutOffDiagnostic( @@ -185,6 +312,24 @@ public static class AutomaticCutOffPlanner return; } + // A full-width cut crosses every active front-edge clamp at its X. Test the + // proposed X and every equivalent existing definition, because a tolerance-close + // existing line is regenerated and certified at its own X. Keep the whole + // footprint when it hangs off the sheet. A same-line manual limit conflict above + // still blocks the whole plan, clamp or not. + var blocked = clamps.FirstOrDefault(zone => + matches.Select(c => c.Position.X).Prepend(x).Any(position => CrossesClamp(zone, position))); + if (blocked != null) + { + diagnostics.Add(new AutomaticCutOffDiagnostic( + AutomaticCutOffDiagnosticCode.ClampBlockedCut, + $"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.", + X: x)); + if (isSeparator) + WarnNoSeparator(x); + return; + } + // Even a duplicate must be regenerated detached with CURRENT settings. Its live // drawing can be stale, and a tolerance-close line can intersect a part at the tail. var existing = matches.FirstOrDefault(); @@ -222,9 +367,26 @@ public static class AutomaticCutOffPlanner X: x)); if (usable && existing == null) + { definitions.Add(candidate); + if (!isSeparator) + { + if (shifted) + stripCutLength += Measure(System.Math.Abs(x)).Length; + else + for (var index = 0; index < program.Codes.Count; index += 2) + stripCutLength += System.Math.Abs( + ((LinearMove)program.Codes[index + 1]).EndPoint.Y - + ((RapidMove)program.Codes[index]).EndPoint.Y); + } + } } + // Share the exact placement-frame predicate with candidate exclusion and final + // certification. Infeasible gaps still use nominal lines through AddCandidate. + bool CrossesClamp(ClampZone zone, double x) => + x >= zone.Bounds.Left - plate.EdgeSpacing.Bottom && x <= zone.Bounds.Right + plate.EdgeSpacing.Bottom; + void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic( AutomaticCutOffDiagnosticCode.NoSafeTailSeparator, "No safe full-width tail separator survives the current settings. Review the tail manually.", @@ -235,6 +397,10 @@ public static class AutomaticCutOffPlanner { Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing, $"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options)); + Require(!options.MinimumStripLength.HasValue || + (double.IsFinite(options.MinimumStripLength.Value) && options.MinimumStripLength.Value > MinimumSpacing && + options.MinimumStripLength.Value <= options.Spacing), + $"Minimum strip length must be finite, greater than {MinimumSpacing} and no greater than spacing.", nameof(options)); Require(Nonnegative(options.MinimumTailLength), "Minimum tail length must be finite and nonnegative.", nameof(options)); Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 && @@ -242,6 +408,9 @@ public static class AutomaticCutOffPlanner "Sheet length and width must be positive and finite.", nameof(plate)); Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate)); Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate)); + Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 || + Nonnegative(plate.EdgeSpacing.Bottom)), + "Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate)); Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) && Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings)); Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings)); diff --git a/OpenNest.Core/AutomaticCutOffStripSearch.cs b/OpenNest.Core/AutomaticCutOffStripSearch.cs new file mode 100644 index 0000000..3607560 --- /dev/null +++ b/OpenNest.Core/AutomaticCutOffStripSearch.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; + +namespace OpenNest; + +/// Exact layered search over sorted positions; each transition uses a sliding minimum. +internal static class AutomaticCutOffStripSearch +{ + internal static double[] FindPositions(double start, double end, int count, double spacing, + double minimum, double step, Func allowed, + Func measure) + { + var rows = new List(count); + var nominal = new double[count]; + for (var line = 1; line <= count; line++) + { + var today = start + line * spacing; + nominal[line - 1] = today; + var low = System.Math.Max(start + line * minimum, end - (count - line + 1) * spacing); + var high = System.Math.Min(today, end - AutomaticCutOffPlanner.GeometryTolerance); + var positions = new List(); + // Integer indices keep candidates on the origin-based lattice without cumulative drift. + var first = (long)System.Math.Ceiling(low / step); + var last = (long)System.Math.Floor(high / step); + for (var index = first; index <= last; index++) + { + var position = index * step; + if (position >= low && position <= high && allowed(position)) + positions.Add(position); + } + if (allowed(today)) + positions.Add(today); + positions.Sort(); + for (var index = positions.Count - 1; index > 0; index--) + if (positions[index] == positions[index - 1]) + positions.RemoveAt(index); + if (positions.Count == 0) + return null; + rows.Add(positions.ToArray()); + } + return Solve(start, rows, nominal, spacing, minimum, measure); + } + + // Kept independent of geometry so tests can compare against exhaustive, non-greedy oracles. + internal static double[] Solve(double start, IReadOnlyList rows, + IReadOnlyList nominal, double spacing, double minimum, + Func measure) + { + var previous = new List { new() { Position = start } }; + for (var line = 0; line < rows.Count; line++) + { + var current = new List(rows[line].Length); + var deque = new int[previous.Count]; + var head = 0; + var tail = 0; + var added = 0; + foreach (var position in rows[line]) + { + var upper = position - minimum + AutomaticCutOffPlanner.GeometryTolerance; + var lower = position - spacing - AutomaticCutOffPlanner.GeometryTolerance; + while (added < previous.Count && previous[added].Position <= upper) + { + while (tail > head && Compare(previous[added], previous[deque[tail - 1]]) < 0) + tail--; + deque[tail++] = added++; + } + while (head < tail && previous[deque[head]].Position < lower) + head++; + if (head == tail) + continue; + var parent = previous[deque[head]]; + var cost = measure(position); + current.Add(new State + { + Position = position, + Length = parent.Length + RoundedMicroUnits(cost.Length), + Pierces = parent.Pierces + cost.SegmentCount, + Deviation = parent.Deviation + System.Math.Abs(nominal[line] - position), + Parent = parent, + }); + } + if (current.Count == 0) + return null; + AssignLexicographicRanks(current, previous.Count); + previous = current; + } + State best = null; + foreach (var state in previous) + if (best == null || Compare(state, best) < 0) + best = state; + var result = new double[rows.Count]; + for (var line = result.Length - 1; line >= 0; line--) + { + result[line] = best.Position; + best = best.Parent; + } + return result; + } + + private static BigInteger RoundedMicroUnits(double length) + { + if (!double.IsFinite(length) || length < 0) + throw new ArgumentException("Cut-off segment length must be finite and nonnegative.", nameof(length)); + var rounded = System.Math.Round(length, 6); + // A shortest round-trip decimal may spell a different integer near 1e16 + // (for example 26833294515438512d prints as 26833294515438510). + // An already-integral double is exact; never round it through text. + if (rounded == System.Math.Truncate(rounded)) + return new BigInteger(rounded) * 1_000_000; + // Compare sums of the specified decimal-rounded model units, not binary + // approximations such as 0.1 + 0.2 versus 0.3. The round-trip string + // retains micro-unit digits for fractional scores. + var value = decimal.Parse(rounded.ToString("R", CultureInfo.InvariantCulture), + NumberStyles.Float, CultureInfo.InvariantCulture); + return new BigInteger(decimal.Round(value * 1_000_000m, 0)); + } + + private static int Compare(State left, State right) + { + var comparison = left.Length.CompareTo(right.Length); + if (comparison == 0) + comparison = left.Pierces.CompareTo(right.Pierces); + if (comparison == 0) + comparison = left.Deviation.CompareTo(right.Deviation); + return comparison != 0 ? comparison : left.LexicographicRank.CompareTo(right.LexicographicRank); + } + + private static void AssignLexicographicRanks(List states, int parentCount) + { + // Stable counting sort by prefix rank. Positions are already sorted, so within a + // prefix their order is lexicographic. Ranking and transitions are linear per row. + var offsets = new int[parentCount]; + foreach (var state in states) + offsets[state.Parent.LexicographicRank]++; + var total = 0; + for (var index = 0; index < offsets.Length; index++) + { + var count = offsets[index]; + offsets[index] = total; + total += count; + } + foreach (var state in states) + state.LexicographicRank = offsets[state.Parent.LexicographicRank]++; + } + + private sealed class State + { + public double Position; + public BigInteger Length; + public int Pierces; + public double Deviation; + public State Parent; + public int LexicographicRank; + } +} diff --git a/OpenNest.Core/ClampRail.cs b/OpenNest.Core/ClampRail.cs new file mode 100644 index 0000000..65ac565 --- /dev/null +++ b/OpenNest.Core/ClampRail.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// The machine's fixed clamp rail: identical rectangular clamps whose X centers are + /// measured from the plate origin along the origin-side (operator) edge. Plates always + /// start at the origin, so the same rail resolves onto every sheet size without + /// re-scaling. A clamp whose footprint lies wholly beyond a sheet is skipped; one that + /// still reaches onto it is kept whole: the zone is an avoidance area, so its full + /// footprint applies even where it hangs past the sheet. + /// + public sealed class ClampRail : IEquatable + { + /// + /// Planner default minutes to remove one clamp and put it back after cutting (D9), + /// used when a caller does not supply its own value. + /// + public const double DefaultRemovalMinutes = 5.0; + + public ClampRail( + double width, + double depth, + IEnumerable centers, + double removalMinutes = DefaultRemovalMinutes + ) + { + ArgumentNullException.ThrowIfNull(centers); + if (!double.IsFinite(width) || width <= 0) + throw new ArgumentOutOfRangeException( + nameof(width), + "Clamp width must be finite and positive." + ); + if (!double.IsFinite(depth) || depth <= 0) + throw new ArgumentOutOfRangeException( + nameof(depth), + "Clamp depth must be finite and positive." + ); + if (!double.IsFinite(removalMinutes) || removalMinutes < 0) + throw new ArgumentOutOfRangeException( + nameof(removalMinutes), + "Clamp removal time must be finite and zero or more." + ); + + var sorted = new SortedSet(); + foreach (var center in centers) + { + if (!double.IsFinite(center) || center < 0) + throw new ArgumentOutOfRangeException( + nameof(centers), + "Clamp centers must be finite and measured from the origin (zero or more)." + ); + sorted.Add(center); + } + + Width = width; + Depth = depth; + RemovalMinutes = removalMinutes; + Centers = sorted.ToArray(); + } + + /// Clamp extent along the edge (X). + public double Width { get; } + + /// Clamp intrusion into the sheet from the origin-side edge (Y). + public double Depth { get; } + + /// + /// Minutes to remove one clamp and put it back after cutting (D9). Added to a sheet's + /// cut-time estimate once per clamp lists there. + /// + public double RemovalMinutes { get; } + + /// Distinct clamp centers along X from the origin, ascending. + public IReadOnlyList Centers { get; } + + /// + /// The zones this rail places on a sheet, in plate coordinates (the frame of + /// : origin at the sheet's lower-left corner). The + /// quadrant locates the plate origin, and with it the origin-side edge, on the sheet. + /// Zones are whole clamp footprints, never clipped, so one reaching past a sheet end + /// (or deeper than a narrow sheet) extends beyond the sheet. Ids are clamp-N by + /// rail position, so one physical clamp keeps its id on every sheet even when others + /// are skipped. + /// + public IReadOnlyList ResolveFor(Size size, int quadrant) + { + var length = size.Length; + var sheetWidth = size.Width; + if (!(length > 0) || !(sheetWidth > 0) || !double.IsFinite(length) || !double.IsFinite(sheetWidth)) + return Array.Empty(); + + var extendsLeft = quadrant is 2 or 3; + var extendsDown = quadrant is 3 or 4; + // The origin-side edge is y = 0 of the sheet, or its top when it extends to -Y. + var y = extendsDown ? sheetWidth - Depth : 0; + var half = Width / 2; + + var zones = new List(Centers.Count); + for (var i = 0; i < Centers.Count; i++) + { + // Distances from the origin along the edge. + var near = Centers[i] - half; + var far = Centers[i] + half; + if (near >= length) + continue; // wholly beyond this sheet + + // Plate coordinates are measured from the lower-left corner; a sheet extending + // to -X from the origin has its origin edge at x = length. + var x = extendsLeft ? length - far : near; + zones.Add(new ClampZone($"clamp-{i + 1}", new Box(x, y, Width, Depth))); + } + + return zones; + } + + /// True when the plate's zones are exactly (ids and bits) this rail's for its sheet. + public bool Matches(Plate plate) + { + ArgumentNullException.ThrowIfNull(plate); + return SameZones(plate.ClampZones, ResolveFor(plate.Size, plate.Quadrant)); + } + + /// + /// True when the plate's zones are exactly this rail's resolution for its sheet minus + /// the clamps named in (D8 last-resort removal, C13). + /// A null or empty set is the same check as . + /// + public bool Matches(Plate plate, IReadOnlyCollection removedClamps) + { + ArgumentNullException.ThrowIfNull(plate); + if (removedClamps == null || removedClamps.Count == 0) + return Matches(plate); + var expected = ResolveFor(plate.Size, plate.Quadrant) + .Where(z => !removedClamps.Contains(z.Id)) + .ToList(); + return SameZones(plate.ClampZones, expected); + } + + internal static bool SameZones(IList current, IReadOnlyList expected) + { + if (current.Count != expected.Count) + return false; + for (var i = 0; i < current.Count; i++) + { + var a = current[i]; + var b = expected[i]; + if ( + !string.Equals(a.Id, b.Id, StringComparison.Ordinal) + || a.Bounds.X != b.Bounds.X + || a.Bounds.Y != b.Bounds.Y + || a.Bounds.Length != b.Bounds.Length + || a.Bounds.Width != b.Bounds.Width + ) + return false; + } + return true; + } + + public bool Equals(ClampRail other) => + other is not null + && Width.Equals(other.Width) + && Depth.Equals(other.Depth) + && RemovalMinutes.Equals(other.RemovalMinutes) + && Centers.SequenceEqual(other.Centers); + + public override bool Equals(object obj) => Equals(obj as ClampRail); + + public override int GetHashCode() + { + var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Centers.Count); + foreach (var center in Centers) + hash = HashCode.Combine(hash, center); + return hash; + } + + public override string ToString() => + $"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Centers.Select(c => c.ToString("R")))}]"; + } +} diff --git a/OpenNest.Core/ClampZone.cs b/OpenNest.Core/ClampZone.cs new file mode 100644 index 0000000..43f15fb --- /dev/null +++ b/OpenNest.Core/ClampZone.cs @@ -0,0 +1,52 @@ +using System; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame: + /// origin at the plate corner, X along the edge, Y depth from it). Machine edge clamps + /// are the first users; a future "don't cut here" tag is another entry of this shape. + /// Zones are exclusion and display only - never material, quantity, utilization, + /// cut sequence or CNC output. Rectangles only: axis-aligned by construction. + /// + public sealed class ClampZone + { + public ClampZone(string id, Box bounds) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentNullException.ThrowIfNull(bounds); + if ( + !double.IsFinite(bounds.X) + || !double.IsFinite(bounds.Y) + || !double.IsFinite(bounds.Length) + || !double.IsFinite(bounds.Width) + ) + throw new ArgumentException( + $"Clamp zone '{id}' bounds must have finite coordinates and size.", + nameof(bounds) + ); + if (bounds.Length <= 0 || bounds.Width <= 0) + throw new ArgumentException( + $"Clamp zone '{id}' must have a positive length (X) and width (Y).", + nameof(bounds) + ); + + Id = id; + // Detached copy: the zone owns its rectangle, callers may keep mutating theirs. + Bounds = new Box(bounds.X, bounds.Y, bounds.Length, bounds.Width); + } + + /// Stable identifier used by persistence and diagnostics. + public string Id { get; } + + /// + /// The rectangle in plate coordinates: Length is the extent along X, Width the + /// extent along Y (depth from the y = 0 operator-side edge for machine clamps). + /// + public Box Bounds { get; } + + public override string ToString() => + $"ClampZone '{Id}' [{Bounds.Left:R}, {Bounds.Bottom:R}] x {Bounds.Length:R} x {Bounds.Width:R}"; + } +} diff --git a/OpenNest.Core/ClampZoneFrame.cs b/OpenNest.Core/ClampZoneFrame.cs new file mode 100644 index 0000000..70f90d4 --- /dev/null +++ b/OpenNest.Core/ClampZoneFrame.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest; + +/// +/// Maps a plate's , stored in the sheet's lower-left frame +/// (), into the signed placement frame of its parts, +/// cut-offs and motions. The translation matches the sheet itself and the engine stock: +/// -Length on X in quadrants 2 and 3, -Width on Y in quadrants 3 and 4. Whole footprints are +/// kept, never clipped. Every plate uses the sheet frame, including plates the engine builds +/// from a NestPlateStock, so this is the only translation any consumer applies. +/// +public static class ClampZoneFrame +{ + /// + /// Detached placement-frame copies in the plate's zone order; empty when there are none. + /// Throws for a missing or malformed zone: zone bounds stay + /// publicly mutable, so construction-time validation is not proof of current geometry. + /// + public static IReadOnlyList ToPlacement(Plate plate) + { + ArgumentNullException.ThrowIfNull(plate); + var zones = plate.ClampZones; + if (zones == null || zones.Count == 0) + return Array.Empty(); + if (plate.Quadrant is < 1 or > 4) + throw new ArgumentException("Quadrant must be 1 through 4.", nameof(plate)); + var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0; + var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0; + if (!double.IsFinite(left) || !double.IsFinite(bottom)) + throw new ArgumentException("Sheet size must be finite.", nameof(plate)); + + var mapped = new ClampZone[zones.Count]; + for (var i = 0; i < mapped.Length; i++) + { + var zone = zones[i]; + if (zone?.Bounds == null) + throw new ArgumentException($"Clamp zone {i + 1} is missing.", nameof(plate)); + var box = zone.Bounds; + var x = box.X + left; + var y = box.Y + bottom; + if (!(box.Length > 0) || !(box.Width > 0) || !double.IsFinite(box.Length) + || !double.IsFinite(box.Width) || !double.IsFinite(box.X) || !double.IsFinite(box.Y) + || !double.IsFinite(x) || !double.IsFinite(y) + || !double.IsFinite(x + box.Length) || !double.IsFinite(y + box.Width)) + throw new ArgumentException( + $"Clamp zone '{zone.Id}' must have finite bounds and a positive size.", nameof(plate)); + mapped[i] = new ClampZone(zone.Id, new Box(x, y, box.Length, box.Width)); + } + return mapped; + } +} diff --git a/OpenNest.Core/Converters/DrawingMarkRecovery.cs b/OpenNest.Core/Converters/DrawingMarkRecovery.cs new file mode 100644 index 0000000..df92fc1 --- /dev/null +++ b/OpenNest.Core/Converters/DrawingMarkRecovery.cs @@ -0,0 +1,170 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Converters; + +/// +/// Puts etch marks back on drawings that lost them. Nests converted from PEP kept etch marks +/// only in each placed part's saved cutting program; the drawing itself had none, so removing +/// lead-ins (which rebuilds the part from its drawing) silently removed the marks too. +/// +public static class DrawingMarkRecovery +{ + private const double MatchTolerance = 0.001; + + /// + /// For each drawing without etch marks whose saved placed programs all carry the same marks, + /// adds those marks to the drawing in its own frame and refreshes the drawing's clean placed + /// parts. Saved programs are never changed. Drawings whose saved programs disagree, or that + /// already have marks, are left alone. Returns the number of drawings that were given marks. + /// + public static int Apply(Nest nest) + { + if (nest == null) + return 0; + + var parts = nest.Plates.SelectMany(plate => plate.Parts).ToList(); + var recovered = 0; + foreach (var group in parts.GroupBy(part => part.BaseDrawing, ReferenceEqualityComparer.Instance)) + { + var drawing = (Drawing)group.Key; + if (Apply(drawing, group.ToList())) + recovered++; + } + return recovered; + } + + /// Recovers marks for one drawing from its placed parts; true when marks were added. + public static bool Apply(Drawing drawing, IReadOnlyList placedParts) + { + if (drawing?.Program == null || drawing.IsCutOff || placedParts == null) + return false; + // An incremental call's end position is not tracked by Program.EndPoint, so only + // flat drawings (which is what conversion produces) can be extended safely. + if (HasMarks(drawing.Program) || drawing.Program.Codes.Any(code => code is SubProgramCall)) + return false; + + var saved = placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing) && part.HasManualLeadIns) + .ToList(); + if (saved.Count == 0) + return false; + + var marks = MarksInDrawingFrame(saved[0]); + if (marks.Count == 0 || saved.Skip(1).Any(part => !SameMarks(marks, MarksInDrawingFrame(part)))) + return false; + + AppendMarks(drawing.Program, marks); + drawing.UpdateArea(); + drawing.RecomputeCanonicalAngle(); + + foreach (var part in placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing) + && !part.HasManualLeadIns)) + part.Update(); + return true; + } + + private static bool HasMarks(Program program) => + program.Codes.Any(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe } + || code is SubProgramCall { Program: not null } call && HasMarks(call.Program)); + + /// + /// The part's marks as they would sit in its drawing: + /// rebuilds the part as the drawing turned by , so undo that turn. + /// + private static List MarksInDrawingFrame(Part part) + { + var marks = ConvertProgram.ToGeometry(part.Program) + .Where(entity => entity.Layer == SpecialLayers.Scribe) + .ToList(); + if (!Tolerance.IsEqualTo(part.Rotation, 0)) + foreach (var mark in marks) + mark.Rotate(-part.Rotation); + return marks; + } + + private static bool SameMarks(List expected, List actual) + { + if (expected.Count != actual.Count) + return false; + var unmatched = actual.ToList(); + foreach (var mark in expected) + { + var index = unmatched.FindIndex(other => SameMark(mark, other)); + if (index < 0) + return false; + unmatched.RemoveAt(index); + } + return true; + } + + // Direction does not matter for an etch: both marks must lie on each other. + private static bool SameMark(Entity a, Entity b) => + a.Type == b.Type && LiesOn(Samples(a), b) && LiesOn(Samples(b), a); + + private static bool LiesOn(List points, Entity entity) => + points.Count > 0 && points.All(point => entity.ClosestPointTo(point).DistanceTo(point) <= MatchTolerance); + + private static List Samples(Entity entity) => entity switch + { + Line line => new List + { + line.StartPoint, + line.EndPoint, + new((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2), + }, + Arc arc => new List { arc.StartPoint(), arc.EndPoint(), arc.MidPoint() }, + Circle circle => new List + { + new(circle.Center.X + circle.Radius, circle.Center.Y), + new(circle.Center.X - circle.Radius, circle.Center.Y), + new(circle.Center.X, circle.Center.Y + circle.Radius), + new(circle.Center.X, circle.Center.Y - circle.Radius), + }, + _ => new List(), + }; + + /// + /// Appends the marks after the drawing's existing motion, so the existing codes and the + /// drawing's first-rapid origin stay exactly as they were. + /// + private static void AppendMarks(Program program, List marks) + { + var shape = new Shape(); + shape.Entities.AddRange(marks); + var markProgram = ConvertGeometry.ToProgram(shape); + + // Start from the drawing's end point, with an explicit rapid onto the first mark. + var start = EndOf(program); + var first = markProgram.Codes.OfType().First(); + var positioned = new Program(Mode.Absolute); + positioned.Codes.Add(new RapidMove(start)); + if (first is not RapidMove) + positioned.Codes.Add(new RapidMove(StartOf(marks[0]))); + positioned.Codes.AddRange(markProgram.Codes); + + if (program.Mode == Mode.Incremental) + positioned.Mode = Mode.Incremental; + // Drop the move onto the drawing's end point; it is where the drawing already stops. + program.Codes.AddRange(positioned.Codes.Skip(1)); + } + + // Where a flat program leaves the head (callers exclude sub-program calls). + private static Vector EndOf(Program program) + { + var position = Vector.Zero; + foreach (var motion in program.Codes.OfType()) + position = program.Mode == Mode.Incremental ? position + motion.EndPoint : motion.EndPoint; + return position; + } + + private static Vector StartOf(Entity entity) => entity switch + { + Line line => line.StartPoint, + Arc arc => arc.StartPoint(), + Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y), + _ => Vector.Zero, + }; +} diff --git a/OpenNest.Core/CutOff.cs b/OpenNest.Core/CutOff.cs index b7d375a..5cc8861 100644 --- a/OpenNest.Core/CutOff.cs +++ b/OpenNest.Core/CutOff.cs @@ -39,6 +39,72 @@ namespace OpenNest Drawing.Program = program; } + /// Measures the same trimmed, filtered segments as regeneration without building a program. + internal (double Length, int SegmentCount) MeasureSegments( + Plate plate, + CutOffSettings settings, + Dictionary cache = null + ) + { + var segments = ComputeSegments(plate, settings, cache); + return (segments.Sum(s => s.End - s.Start), segments.Count); + } + + /// + /// True when , the placed part of , cuts + /// one continuous straight line on the definition's axis across the whole sheet, so it + /// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a + /// line on the sheet edge, a stale or unrecognised program) does not separate the sheet. + /// + internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet) + { + if (part?.Program == null || definition == null || sheet == null + || part.Program.Mode != Mode.Absolute) + return false; + var vertical = definition.Axis == CutOffAxis.Vertical; + var line = vertical ? definition.Position.X : definition.Position.Y; + var acrossMin = vertical ? sheet.Left : sheet.Bottom; + var acrossMax = vertical ? sheet.Right : sheet.Top; + var alongMin = vertical ? sheet.Bottom : sheet.Left; + var alongMax = vertical ? sheet.Top : sheet.Right; + if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon)) + return false; + + Vector? position = null; + double? low = null, high = null; + var cutting = false; + foreach (var code in part.Program.Codes) + { + switch (code) + { + case RapidMove rapid: + if (cutting) + return false; // A second pierce leaves an uncut gap. + position = rapid.EndPoint + part.Location; + break; + case LinearMove move when position != null: + var end = move.EndPoint + part.Location; + if (!OnLine(position.Value) || !OnLine(end)) + return false; + foreach (var value in new[] { Along(position.Value), Along(end) }) + { + low = low == null ? value : System.Math.Min(low.Value, value); + high = high == null ? value : System.Math.Max(high.Value, value); + } + position = end; + cutting = true; + break; + case Motion: + return false; + } + } + return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon; + + bool OnLine(Vector point) => + System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon; + double Along(Vector point) => vertical ? point.Y : point.X; + } + private string GetName() { var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H"; diff --git a/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs b/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs new file mode 100644 index 0000000..559483d --- /dev/null +++ b/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Diagnostics; + +/// A nominal manual cut-off line meeting an active, physical clamp footprint. +public sealed record CutOffClampFinding(int CutOffIndex, string ClampId, Vector Location); + +/// +/// Read-only axis-aligned clamp contact checks. Nominal cut-off definitions are checked +/// even if their current regenerated program is stale or trimmed by nearby parts. +/// A removed clamp is absent from Plate.ClampZones and therefore cannot be reported. +/// Zones are compared in the plate's signed placement frame (). +/// +public static class ClampCutDiagnostics +{ + public static IReadOnlyList Find(Plate plate) + { + ArgumentNullException.ThrowIfNull(plate); + var findings = new List(); + if (plate.ClampZones.Count == 0 || plate.CutOffs.Count == 0) + return findings.AsReadOnly(); + var zones = ClampZoneFrame.ToPlacement(plate); + var sheet = plate.BoundingBox(false); + for (var index = 0; index < plate.CutOffs.Count; index++) + { + var cut = plate.CutOffs[index]; + if (cut == null || !Enum.IsDefined(cut.Axis)) + throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate)); + var start = cut.Axis == CutOffAxis.Vertical + ? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom) + : new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y); + var end = cut.Axis == CutOffAxis.Vertical + ? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top) + : new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y); + foreach (var zone in zones) + if (Intersects(start, end, zone.Bounds, out var location)) + findings.Add(new CutOffClampFinding(index, zone.Id, location)); + } + return findings.AsReadOnly(); + } + + /// True for contact with the whole rectangle, including a boundary touch. + public static bool Intersects(Vector from, Vector to, Box box, out Vector location) + { + location = default; + if (box == null || !Finite(from) || !Finite(to) || !Finite(box.Location) + || !double.IsFinite(box.Right) || !double.IsFinite(box.Top) + || box.Length <= 0 || box.Width <= 0) + throw new ArgumentException("Clamp contact geometry must be finite and nonempty."); + var dx = to.X - from.X; + var dy = to.Y - from.Y; + if (!double.IsFinite(dx) || !double.IsFinite(dy) + || !double.IsFinite(from.X - box.Left) || !double.IsFinite(box.Right - from.X) + || !double.IsFinite(from.Y - box.Bottom) || !double.IsFinite(box.Top - from.Y)) + throw new ArgumentException("Clamp contact motion is too large."); + var enter = 0.0; + var exit = 1.0; + if (!Clip(-dx, from.X - box.Left, ref enter, ref exit) + || !Clip(dx, box.Right - from.X, ref enter, ref exit) + || !Clip(-dy, from.Y - box.Bottom, ref enter, ref exit) + || !Clip(dy, box.Top - from.Y, ref enter, ref exit)) + return false; + var midpoint = (enter + exit) * 0.5; + location = new Vector(from.X + dx * midpoint, from.Y + dy * midpoint); + return true; + } + + private static bool Clip(double p, double q, ref double enter, ref double exit) + { + if (p == 0) + return q >= 0; + var ratio = q / p; + if (p < 0) + enter = System.Math.Max(enter, ratio); + else + exit = System.Math.Min(exit, ratio); + return enter <= exit; + } + + private static bool Finite(Vector value) => double.IsFinite(value.X) && double.IsFinite(value.Y); +} diff --git a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs index fb7f33f..ceae55b 100644 --- a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs +++ b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs @@ -11,20 +11,71 @@ public sealed class OverlapGeometryStamp { private readonly Plate plate; private readonly Entry[] entries; + private readonly (string Id, long X, long Y, long Length, long Width)[] zones; + private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs; + private readonly (long Width, long Length, int Quadrant) sheet; private OverlapGeometryStamp(Plate plate) { this.plate = plate; + sheet = SheetState(plate); entries = new Entry[plate.Parts.Count]; for (var i = 0; i < entries.Length; i++) entries[i] = new Entry(plate.Parts[i]); + zones = new (string, long, long, long, long)[plate.ClampZones.Count]; + for (var i = 0; i < zones.Length; i++) + zones[i] = ZoneState(plate.ClampZones[i]); + cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count]; + for (var i = 0; i < cutoffs.Length; i++) + cutoffs[i] = CutOffState(plate.CutOffs[i]); } + // Missing entries are malformed input the analysis itself reports; freshness must not + // throw first (the desktop captures it before its error boundary). + private static (string, long, long, long, long) ZoneState(ClampZone zone) => + zone?.Bounds is not { } box ? default + : (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width)); + + private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) => + cut == null ? default + : (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y), + cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null, + cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null); + + private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); + + private static (long, long, int) SheetState(Plate plate) => + (Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant); + public static OverlapGeometryStamp Capture(Plate plate) => new(plate); + internal bool SameZones(Plate current) + { + // Stored zones are sheet-relative: a size or quadrant change moves them in the + // parts' frame without changing their bits. + if (!ReferenceEquals(plate, current) || current.ClampZones.Count != zones.Length + || sheet != SheetState(current)) + return false; + for (var i = 0; i < zones.Length; i++) + if (zones[i] != ZoneState(current.ClampZones[i])) + return false; + return true; + } + + internal bool SameCutOffs(Plate current) + { + if (!ReferenceEquals(plate, current) || current.CutOffs.Count != cutoffs.Length + || sheet != SheetState(current)) + return false; + for (var i = 0; i < cutoffs.Length; i++) + if (cutoffs[i] != CutOffState(current.CutOffs[i])) + return false; + return true; + } + public bool Matches(Plate current) { - if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length) + if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current)) return false; for (var i = 0; i < entries.Length; i++) if (!entries[i].Matches(current.Parts[i])) diff --git a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs index 36669a8..ee4fc47 100644 --- a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs +++ b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs @@ -9,12 +9,19 @@ namespace OpenNest.Diagnostics; /// Read-only pair presentation; no geometry preparation or collision queries. public static class OverlapPairPresentation { - public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}"; + /// True for part-vs-clamp findings carried on the report (negative clamp slot). + public static bool IsClampFinding(PlateOverlapPair pair) => pair.PartBId < 0; + + public static string Label(PlateOverlapPair pair) => + IsClampFinding(pair) ? $"{pair.PartAId + 1}/clamp" : $"{pair.PartAId + 1}/{pair.PartBId + 1}"; public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null) { var units = UnitsHelper.GetShortString(capturedUnits); - return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n" + var head = IsClampFinding(pair) + ? $"Clamp overlap: {pair.PartAName} / {pair.PartBName}" + : $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}"; + return head + "\n" + $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n" + $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}"; } diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs index 3da9309..c5f99a1 100644 --- a/OpenNest.Core/Diagnostics/OverlapReportState.cs +++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs @@ -21,14 +21,22 @@ public sealed class OverlapReportState public PlateOverlapReport Report { get; private set; } /// Known overlap pairs safe to draw, even while the full layout needs a recheck. public IReadOnlyList DisplayPairs { get; private set; } = Array.Empty(); + public IReadOnlyList DisplayCutOffClampFindings { get; private set; } = + Array.Empty(); public bool IsRunning => Status == OverlapCheckStatus.Checking; public string Message => Status switch { OverlapCheckStatus.Checking => "Checking overlaps…", - OverlapCheckStatus.Current => Report.Pairs.Count == 0 - ? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs", + OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0 + ? (Report.Pairs.Count == 0 ? "No material overlaps detected" + : $"Overlaps: {Report.Pairs.Count} pairs") + : $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, " + + $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts", OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; " + + (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ") + + (Report.CutOffClampFindings.Count == 0 ? "" + : $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ") + $"{uncheckedPartCount} parts could not be checked", OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again", OverlapCheckStatus.Canceled => "Overlap check canceled", @@ -55,7 +63,9 @@ public sealed class OverlapReportState if (!CanComplete(generation, plate)) return false; Report = report; - DisplayPairs = report.Pairs; + DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs + : report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly(); + DisplayCutOffClampFindings = report.CutOffClampFindings; displayStamp = stamp; observedDisplayStamp = stamp; uncheckedPartCount = CountUncheckedParts(report.Issues); @@ -106,10 +116,14 @@ public sealed class OverlapReportState if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true) return; var unchanged = displayStamp.UnchangedSlots(plate); - var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList(); + var sameZones = displayStamp.SameZones(plate); + if (!sameZones || !displayStamp.SameCutOffs(plate)) + DisplayCutOffClampFindings = Array.Empty(); + var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] + && (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList(); if (retained.Count != DisplayPairs.Count) DisplayPairs = retained.AsReadOnly(); - if (DisplayPairs.Count == 0) + if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0) ClearDisplayPairs(); else observedDisplayStamp = OverlapGeometryStamp.Capture(plate); @@ -118,6 +132,7 @@ public sealed class OverlapReportState private void ClearDisplayPairs() { DisplayPairs = Array.Empty(); + DisplayCutOffClampFindings = Array.Empty(); displayStamp = null; observedDisplayStamp = null; } diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs index 607b30e..a602a97 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs @@ -17,13 +17,41 @@ public static class PlateOverlapAnalyzer { public const double ChordTolerance = 0.001; + /// + /// Capture a whole plate including active clamps and nominal cut-off definitions. + /// Use this entry for the desktop overlap check so manual cut-off contacts + /// cannot be mistaken for a clear plate merely because cut-offs are not material. + /// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed + /// placement frame; a malformed zone throws . + /// + public static PlateOverlapSnapshot CapturePlate(Plate plate, OverlapMaterialCache cache = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plate); + var material = Capture(plate.Parts.ToArray(), ClampZoneFrame.ToPlacement(plate).ToArray(), + cache ?? new OverlapMaterialCache(), cancellationToken); + var cutoffs = ClampCutDiagnostics.Find(plate); + cancellationToken.ThrowIfCancellationRequested(); + return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(), + material.Clamps.ToList(), cutoffs.ToList()); + } + /// /// Captures poses and converts each distinct clean source program to owned entities once. /// Inputs must not change during capture. Later analysis never reads live domain objects. /// public static PlateOverlapSnapshot Capture(IReadOnlyList parts, CancellationToken cancellationToken = default) => - Capture(parts, new OverlapMaterialCache(), cancellationToken); + Capture(parts, null, new OverlapMaterialCache(), cancellationToken); + + /// + /// As , additionally capturing + /// the plate's clamp keep-out rectangles: Analyze then reports part-vs-clamp findings + /// (C1) alongside part pairs. Null means no zones. + /// + public static PlateOverlapSnapshot Capture(IReadOnlyList parts, + IReadOnlyList clampZones, CancellationToken cancellationToken = default) => + Capture(parts, clampZones, new OverlapMaterialCache(), cancellationToken); /// /// As , but reuses converted @@ -31,6 +59,12 @@ public static class PlateOverlapAnalyzer /// cache before any in-place clean-program edit (see ). /// public static PlateOverlapSnapshot Capture(IReadOnlyList parts, OverlapMaterialCache cache, + CancellationToken cancellationToken = default) => + Capture(parts, null, cache, cancellationToken); + + /// + public static PlateOverlapSnapshot Capture(IReadOnlyList parts, + IReadOnlyList clampZones, OverlapMaterialCache cache, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(parts); @@ -64,8 +98,21 @@ public static class PlateOverlapAnalyzer issues.Add(new PlateOverlapIssue(id, null, exception.Message)); } } + var clamps = new List(); + if (clampZones != null) + { + for (var ordinal = 0; ordinal < clampZones.Count; ordinal++) + { + cancellationToken.ThrowIfCancellationRequested(); + var zone = clampZones[ordinal]; + // ClampZone validates finiteness and positive size at construction; the + // snapshot still owns its own copy so later plate edits never move it. + clamps.Add(new CapturedClamp(ordinal, zone.Id, + new Box(zone.Bounds.X, zone.Bounds.Y, zone.Bounds.Length, zone.Bounds.Width))); + } + } cancellationToken.ThrowIfCancellationRequested(); - return new PlateOverlapSnapshot(captured, issues); + return new PlateOverlapSnapshot(captured, issues, clamps); } private static OverlapSource CaptureSource(Program program) @@ -194,9 +241,120 @@ public static class PlateOverlapAnalyzer } } cancellationToken.ThrowIfCancellationRequested(); + var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken); return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId) .ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId) - .ThenBy(issue => issue.PartBId).ToList(), snapshot); + .ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings + .OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(), + snapshot.CutOffClampFindings.ToList()); + } + + /// + /// C1 part-vs-clamp findings: the prepared hole-aware material against each captured + /// zone rectangle with the same exact Collision oracle as part pairs. Deterministic in + /// (part input id, clamp ordinal) order; failures become part issues like any other + /// pair geometry failure. Empty zone lists return without touching the material. + /// + private static List FindClampOverlaps(List prepared, + PlateOverlapSnapshot snapshot, List issues, + CancellationToken cancellationToken) + { + var findings = new List(); + if (snapshot.Clamps.Count == 0) + return findings; + + foreach (var part in prepared) + { + var partBounds = part.Material.Outer.BoundingBox; + foreach (var clamp in snapshot.Clamps) + { + cancellationToken.ThrowIfCancellationRequested(); + var zone = clamp.Bounds; + // The prepared material is inscribed at ChordTolerance, so real material can + // reach that far past its bounds; a tiny clamp there must still be checked. + var reach = KeepOutOverlap.NeedsToleranceFreeTest(zone) ? ChordTolerance : 0; + if (partBounds.Right + reach <= zone.Left || zone.Right <= partBounds.Left - reach + || partBounds.Top + reach <= zone.Bottom || zone.Top <= partBounds.Bottom - reach) + continue; + try + { + // A clamp below the polygon tolerance collapses in Collision.Check and would + // read as clear (PM aa995123): judge it tolerance-free and report the whole + // (negligibly small) clamp rectangle as the overlap fragment. + if (KeepOutOverlap.NeedsToleranceFreeTest(zone)) + { + // Re-read the native contours conservatively and finely enough for + // this clamp: the shared 0.001 chords can hide material between an + // arc and its chord. + var profile = new ShapeProfile(part.Input.Source.Entities.Select(e => e.Clone()).ToList()); + if (profile.Perimeter == null) + throw new ArgumentException("Drawing has no closed material contour."); + if (KeepOutOverlap.ConservativePolygons(profile, zone) + .Select(polygons => new OverlapMaterial(polygons.Perimeter, polygons.Holes) + .Transform(part.Input.Rotation, part.Input.Location)) + .Any(fine => KeepOutOverlap.MaterialEnters(fine.Outer, fine.Holes, zone))) + findings.Add(TinyClampFinding(part, clamp)); + continue; + } + var origin = part.Material.Outer.Vertices[0]; + var local = part.Material.Transform(0, origin * -1); + var rect = RectanglePolygon(zone, origin * -1); + var result = Collision.Check(local.Outer, rect, local.Holes, null); + if (!result.Overlaps) + continue; + var moments = new List(); + var regions = new List(); + foreach (var region in result.OverlapRegions) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment)) + throw new ArithmeticException("Clamp overlap fragment area moments are invalid."); + moments.Add(fragment); + regions.Add(new PlateOverlapRegion( + region.Vertices.Select(point => point + origin), fragment.Area)); + } + if (!PolygonAreaMoments.TryCombine(moments, out var combined)) + throw new ArithmeticException("Combined clamp overlap area moments are invalid."); + var centroid = combined.Centroid + origin; + if (!OverlapMaterial.IsFinite(centroid)) + throw new ArithmeticException("Clamp overlap centroid is not finite in world coordinates."); + findings.Add(new PlateOverlapPair(part.Input.Id, + ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name, + $"Clamp '{clamp.Id}'", regions, centroid)); + } + catch (Exception exception) when (IsGeometryFailure(exception)) + { + issues.Add(new PlateOverlapIssue(part.Input.Id, null, exception.Message)); + } + } + } + + return findings; + } + + private static PlateOverlapPair TinyClampFinding(PreparedPart part, CapturedClamp clamp) + { + var zone = clamp.Bounds; + var corners = new[] + { + new Vector(zone.Left, zone.Bottom), new Vector(zone.Right, zone.Bottom), + new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top), + }; + var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2); + return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name, + $"Clamp '{clamp.Id}'", new List { new(corners, zone.Length * zone.Width) }, centre); + } + + private static Polygon RectanglePolygon(Box box, Vector offset) + { + var polygon = new Polygon(); + polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Bottom + offset.Y)); + polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Bottom + offset.Y)); + polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Top + offset.Y)); + polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Top + offset.Y)); + polygon.Close(); + polygon.UpdateBounds(); + return polygon; } /// diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs index 900483d..a5fd5b4 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs @@ -8,11 +8,14 @@ namespace OpenNest.Diagnostics; public sealed class PlateOverlapReport { internal PlateOverlapReport(List pairs, List issues, - PlateOverlapSnapshot snapshot) + PlateOverlapSnapshot snapshot, List clampFindings = null, + List cutOffClampFindings = null) { Pairs = pairs.AsReadOnly(); Issues = issues.AsReadOnly(); Snapshot = snapshot; + ClampFindings = (clampFindings ?? new List()).AsReadOnly(); + CutOffClampFindings = (cutOffClampFindings ?? new List()).AsReadOnly(); } /// The owned input this report was computed from; the baseline for incremental rechecks. @@ -20,6 +23,16 @@ public sealed class PlateOverlapReport public IReadOnlyList Pairs { get; } public IReadOnlyList Issues { get; } + + /// + /// Part-vs-clamp-zone findings (C1): the same owned-fragment shape as part pairs, with a + /// negative slot encoding the zone ordinal. Findings + /// are reported like other overlap findings; they do not change . + /// + public IReadOnlyList ClampFindings { get; } + /// Nominal cut-off/clamp contacts. Lines have no material area or part pair. + public IReadOnlyList CutOffClampFindings { get; } + public bool IsComplete => Issues.Count == 0; public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance; } @@ -102,15 +115,32 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message /// public sealed class PlateOverlapSnapshot { - internal PlateOverlapSnapshot(List parts, List issues) + internal PlateOverlapSnapshot(List parts, List issues, + List clamps = null, List cutOffClampFindings = null) { Parts = parts.AsReadOnly(); Issues = issues.AsReadOnly(); + Clamps = (clamps ?? new List()).AsReadOnly(); + CutOffClampFindings = (cutOffClampFindings ?? new List()).AsReadOnly(); } internal IReadOnlyList Parts { get; } internal IReadOnlyList Issues { get; } + + /// Owned clamp keep-out rectangles in plate coordinates, capture order. + internal IReadOnlyList Clamps { get; } + internal IReadOnlyList CutOffClampFindings { get; } } internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source, double Rotation, Vector Location); + +/// A clamp keep-out rectangle owned by the snapshot; Ordinal is its capture index. +internal sealed record CapturedClamp(int Ordinal, string Id, Box Bounds); + +/// Deterministic negative PartBId for a part-vs-clamp finding's clamp slot. +internal static class ClampFindingSlot +{ + public static int IdFor(int ordinal) => -2 - ordinal; + public static int OrdinalOf(int pairPartBId) => -2 - pairPartBId; +} diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs index 4110b56..8cb64cf 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs @@ -56,6 +56,51 @@ public static class PostVerificationAnalyzer } var materialParts = new List(); var indices = new List(); + // Stored zones use the sheet's lower-left frame; motions and parts are signed. + // Validate them before any early exit: zone bounds are publicly mutable. + IReadOnlyList clamps; + try + { + clamps = ClampZoneFrame.ToPlacement(plate); + } + catch (Exception exception) when (IsInvalid(exception)) + { + clamps = null; + findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null, + "Clamp check: " + exception.Message)); + } + // One clamp warning per cut-off part and clamp: the placed-program check below + // reports only what the nominal definition check has not already reported. + var cutOffClampContacts = new HashSet<(int PartNumber, string ClampId)>(); + if (clamps != null) + { + try + { + foreach (var contact in ClampCutDiagnostics.Find(plate)) + { + var cut = plate.CutOffs[contact.CutOffIndex]; + var partIndex = -1; + for (var partSlot = 0; partSlot < plate.Parts.Count; partSlot++) + if (ReferenceEquals(plate.Parts[partSlot]?.BaseDrawing, cut.Drawing)) + { + partIndex = partSlot; + break; + } + if (partIndex >= 0) + cutOffClampContacts.Add((partIndex + 1, contact.ClampId)); + findings.Add(new(PostVerificationKind.Overlap, plateNumber, + partIndex < 0 ? null : partIndex + 1, null, + $"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. " + + "Review the clamp and cut before posting; this warning is not a repair.") + { Location = contact.Location, ClampId = contact.ClampId }); + } + } + catch (Exception exception) when (IsInvalid(exception)) + { + findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null, + "Clamp/cut-off check: " + exception.Message)); + } + } var obstacles = new ReleasedContourState(); Vector? position = startPoint; for (var index = 0; index < plate.Parts.Count; index++) @@ -95,6 +140,46 @@ public static class PostVerificationAnalyzer && move.Curve.Length > PostVerificationGeometry.Epsilon)) Incomplete("Placed program has no cutting contour motions for this drawing."); obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken); + if (clamps is { Count: > 0 }) + foreach (var move in moves) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!move.Rapid || move.Start is not { } from + || from.X == move.End.X && from.Y == move.End.Y) + continue; + foreach (var zone in clamps) + if (ClampCutDiagnostics.Intersects(from, move.End, zone.Bounds, out var contact)) + findings.Add(new(PostVerificationKind.RapidCrossing, plateNumber, + partNumber, null, + $"Direct XY rapid crosses active clamp '{zone.Id}'. " + + "Head height is not modeled; review the posted route and clamp setup.") + { + Location = (from + move.End) * 0.5, + RapidStart = from, + RapidEnd = move.End, + ContactPoints = new[] { contact }, + ClampId = zone.Id + }); + } + // The placed program is what posts: a definition edited without + // regenerating it can still cut through a clamp its nominal line misses. + if (cutoff && clamps is { Count: > 0 }) + foreach (var move in moves) + { + cancellationToken.ThrowIfCancellationRequested(); + if (move.Rapid || move.Start is not { } from) + continue; + foreach (var zone in clamps) + if (!cutOffClampContacts.Contains((partNumber, zone.Id)) + && CutTouches(move, from, zone.Bounds, out var contact)) + { + cutOffClampContacts.Add((partNumber, zone.Id)); + findings.Add(new(PostVerificationKind.Overlap, plateNumber, partNumber, null, + $"The placed cut-off program crosses active clamp '{zone.Id}'. " + + "Regenerate or move the cut-off before posting; this warning is not a repair.") + { Location = contact, ClampId = zone.Id }); + } + } position = moves[^1].End; } catch (Exception exception) when (IsInvalid(exception)) @@ -110,11 +195,17 @@ public static class PostVerificationAnalyzer } cancellationToken.ThrowIfCancellationRequested(); var overlap = PlateOverlapAnalyzer.Analyze( - PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken); + PlateOverlapAnalyzer.Capture(materialParts, (clamps ?? Array.Empty()).ToArray(), + cancellationToken), + cancellationToken); foreach (var pair in overlap.Pairs) findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId], indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).") { Location = pair.Centroid, Overlap = pair }); + foreach (var pair in overlap.ClampFindings) + findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId], + null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).") + { Location = pair.Centroid, Overlap = pair }); foreach (var issue in overlap.Issues) findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId], issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message)); @@ -123,6 +214,19 @@ public static class PostVerificationAnalyzer return new PostVerificationReport(findings); } + // Lines are tested exactly; an arc conservatively by its whole supporting extent. + private static bool CutTouches(ExecutionMotion move, Vector from, Box zone, out Vector contact) + { + if (move.Curve == null || move.Curve.IsLine) + return ClampCutDiagnostics.Intersects(from, move.End, zone, out contact); + var extent = move.Curve.Extent; + contact = new Vector( + System.Math.Clamp(move.Curve.Midpoint.X, zone.Left, zone.Right), + System.Math.Clamp(move.Curve.Midpoint.Y, zone.Bottom, zone.Top)); + return !(extent.MinX > zone.Right || extent.MaxX < zone.Left + || extent.MinY > zone.Top || extent.MaxY < zone.Bottom); + } + private static bool IsInvalid(Exception exception) => exception is ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException; diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 5e78685..5689a51 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -147,6 +147,7 @@ internal static class PostVerificationGeometry private double Radius { get; } private double Sweep { get; } internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End); + internal bool IsLine => !Center.HasValue; // Native entities are freshly allocated; the immutable curve never exposes state. internal Entity ToEntity() => Center is { } center diff --git a/OpenNest.Core/Diagnostics/PostVerificationReport.cs b/OpenNest.Core/Diagnostics/PostVerificationReport.cs index b399f83..8b1e6b3 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationReport.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationReport.cs @@ -24,6 +24,8 @@ public sealed record PostVerificationFinding(PostVerificationKind Kind, int Plat /// Rapid boundary contacts in travel order; collinear contact spans use their endpoints. public IReadOnlyList ContactPoints { get; init; } = Array.Empty(); public PlateOverlapPair Overlap { get; init; } + /// Active clamp contacted by a cut-off or direct XY rapid, if applicable. + public string ClampId { get; init; } } /// Owned, immutable findings. Consent is evaluated afresh, never stored. diff --git a/OpenNest.Core/Geometry/Clearance.cs b/OpenNest.Core/Geometry/Clearance.cs index ca90a4f..d37eab9 100644 --- a/OpenNest.Core/Geometry/Clearance.cs +++ b/OpenNest.Core/Geometry/Clearance.cs @@ -80,6 +80,16 @@ namespace OpenNest.Geometry return Separation(linesA, linesB); } + /// Distance from a closed boundary's segments to every segment + /// of an intentional open cutting path. Unlike polygonizing that path, + /// this retains segment interiors and disjoint subpaths. + internal static ClearanceResult BoundaryDistance(List closed, List open) + { + if (closed.Count == 0 || open.Count == 0) + return new ClearanceResult(0, new Vector(1, 0)); + return Separation(closed, open); + } + /// /// Minimum boundary distance between two non-overlapping rings and the /// direction that translates away from diff --git a/OpenNest.Core/Geometry/KeepOutOverlap.cs b/OpenNest.Core/Geometry/KeepOutOverlap.cs new file mode 100644 index 0000000..3264436 --- /dev/null +++ b/OpenNest.Core/Geometry/KeepOutOverlap.cs @@ -0,0 +1,161 @@ +using System.Collections.Generic; + +namespace OpenNest.Geometry; + +/// +/// Tolerance-free test of material against an axis-aligned keep-out rectangle, for +/// rectangles too small for the tolerance-based overlap machinery (validation offsets, +/// Clipper grids, triangulation and vector equality work at 1e-3 to 1e-5 scale and collapse +/// such a rectangle, PM 6859db53). Positive-area overlap only: flush contact is clear, as +/// everywhere else. +/// +public static class KeepOutOverlap +{ + /// The scale below which tolerance-based overlap tests are not trusted for a + /// keep-out rectangle (the validation outline chord tolerance). + public const double ToleranceFreeBelow = 0.001; + + /// A keep-out narrower than on either axis. + public static bool NeedsToleranceFreeTest(Box keepOut) => + keepOut.Length < ToleranceFreeBelow || keepOut.Width < ToleranceFreeBelow; + + /// + /// Chord tolerance for flattening real part material against : + /// a quarter of its smaller side, at most . + /// + public static double FlatteningTolerance(Box keepOut) => + System.Math.Min(ToleranceFreeBelow, System.Math.Min(keepOut.Length, keepOut.Width) / 4); + + /// + /// A part's native contours flattened two ways for a tiny keep-out, never through a + /// grid-rounding offset: lines exact, every arc (perimeter and cutouts) inscribed in the + /// first and circumscribed in the second, at . Along a + /// convex arc the circumscribed version holds all real material, along a concave arc + /// (a notch, a round hole) the inscribed one does, so together they hold all of it and + /// testing both finds any real overlap. The price is a possible report within that + /// tolerance of an arc, never a false clear. + /// + public static IEnumerable<(Polygon Perimeter, List Holes)> ConservativePolygons( + ShapeProfile profile, Box keepOut) + { + var tolerance = FlatteningTolerance(keepOut); + foreach (var circumscribe in new[] { false, true }) + { + var perimeter = profile.Perimeter.ToPolygonWithTolerance(tolerance, circumscribe); + var holes = new List(profile.Cutouts.Count); + foreach (var cutout in profile.Cutouts) + holes.Add(cutout.ToPolygonWithTolerance(tolerance, circumscribe)); + yield return (perimeter, holes); + } + } + + /// + /// True when either of the , translated by + /// , enters . + /// + public static bool ProfileEnters(ShapeProfile profile, Vector offset, Box keepOut) + { + foreach (var (perimeter, holes) in ConservativePolygons(profile, keepOut)) + { + perimeter.Offset(offset); + foreach (var hole in holes) + hole.Offset(offset); + if (MaterialEnters(perimeter, holes, keepOut)) + return true; + } + return false; + } + + /// + /// True when the material (perimeter minus holes) shares positive area with the open + /// rectangle: some boundary edge runs through its interior, or its interior lies wholly + /// inside the material (tested at its centre). + /// + public static bool MaterialEnters(Polygon perimeter, IReadOnlyList holes, Box box) + { + if (RingEnters(perimeter, box)) + return true; + if (holes != null) + foreach (var hole in holes) + if (RingEnters(hole, box)) + return true; + + var centre = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2); + if (!Contains(perimeter, centre)) + return false; + if (holes != null) + foreach (var hole in holes) + if (Contains(hole, centre)) + return false; + return true; + } + + private static bool RingEnters(Polygon ring, Box box) + { + var vertices = ring.Vertices; + var count = vertices.Count; + for (var i = 0; i < count; i++) + { + var next = vertices[(i + 1) % count]; + if (SegmentEntersOpenBox(vertices[i], next, box)) + return true; + } + return false; + } + + /// Liang-Barsky clip against the open rectangle; a positive-length part of the + /// segment must lie strictly inside it. + private static bool SegmentEntersOpenBox(Vector p, Vector q, Box box) + { + var dx = q.X - p.X; + var dy = q.Y - p.Y; + var t0 = 0.0; + var t1 = 1.0; + return Clip(-dx, p.X - box.Left, ref t0, ref t1) + && Clip(dx, box.Right - p.X, ref t0, ref t1) + && Clip(-dy, p.Y - box.Bottom, ref t0, ref t1) + && Clip(dy, box.Top - p.Y, ref t0, ref t1) + && t0 < t1; + } + + /// Restricts [t0, t1] to the parameters where denominator * t < numerator. + private static bool Clip(double denominator, double numerator, ref double t0, ref double t1) + { + if (denominator == 0) + return numerator > 0; + var r = numerator / denominator; + if (denominator < 0) + { + if (r >= t1) + return false; + if (r > t0) + t0 = r; + } + else + { + if (r <= t0) + return false; + if (r < t1) + t1 = r; + } + return true; + } + + /// Even-odd ray cast; exact for a point that is not on the ring (callers only + /// ask after ruling out any edge through the rectangle containing the point). + private static bool Contains(Polygon ring, Vector point) + { + var inside = false; + var vertices = ring.Vertices; + var count = vertices.Count; + for (int i = 0, j = count - 1; i < count; j = i++) + { + var a = vertices[i]; + var b = vertices[j]; + if ((a.Y > point.Y) != (b.Y > point.Y) + && point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X) + inside = !inside; + } + return inside; + } +} diff --git a/OpenNest.Core/Geometry/OpenPathMaterial.cs b/OpenNest.Core/Geometry/OpenPathMaterial.cs new file mode 100644 index 0000000..2f2ad8c --- /dev/null +++ b/OpenNest.Core/Geometry/OpenPathMaterial.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; + +namespace OpenNest.Geometry; + +/// +/// Strict positive-length entry of an open native line path into polygon material. +/// Boundary-only contact is not entry; holes subtract material. This deliberately +/// does not infer material from a path's bounding box or close the open path. +/// Curved contours are flattened by the caller, which must handle chord uncertainty. +/// +internal static class OpenPathMaterial +{ + public static bool Enters(Polygon outer, IReadOnlyList holes, IReadOnlyList path) + { + if (outer?.Vertices.Count < 4 || path == null) + return true; // Unresolved geometry is not a certificate of clear material. + + foreach (var line in path) + { + var start = line.StartPoint; + var end = line.EndPoint; + var travel = end - start; + var lengthSquared = travel.DotProduct(travel); + if (!double.IsFinite(lengthSquared) || lengthSquared <= 0) + return true; + + var cuts = new List { 0, 1 }; + CollectCuts(outer, start, travel, lengthSquared, cuts); + if (holes != null) + foreach (var hole in holes) + CollectCuts(hole, start, travel, lengthSquared, cuts); + cuts.Sort(); + for (var i = 1; i < cuts.Count; i++) + { + if (cuts[i] <= cuts[i - 1]) + continue; + var midpoint = start + travel * ((cuts[i - 1] + cuts[i]) / 2); + if (!StrictlyInside(outer, midpoint)) + continue; + var inHole = false; + if (holes != null) + foreach (var hole in holes) + if (InsideOrOnBoundary(hole, midpoint)) + { + inHole = true; + break; + } + if (!inHole) + return true; + } + } + return false; + } + + private static void CollectCuts(Polygon ring, Vector start, Vector travel, + double lengthSquared, List cuts) + { + if (ring?.Vertices.Count < 4) + { + // A degenerate hole cannot be subtracted reliably. The caller's + // unresolved-topology guard handles this case before this helper. + return; + } + var vertices = ring.Vertices; + for (var i = 1; i < vertices.Count; i++) + { + var a = vertices[i - 1]; + var edge = vertices[i] - a; + var difference = a - start; + var denominator = Cross(travel, edge); + if (denominator == 0) + { + if (Cross(difference, travel) != 0) + continue; + AddParameter(difference.DotProduct(travel) / lengthSquared, cuts); + AddParameter((vertices[i] - start).DotProduct(travel) / lengthSquared, cuts); + continue; + } + var t = Cross(difference, edge) / denominator; + var u = Cross(difference, travel) / denominator; + if (u >= -1e-12 && u <= 1 + 1e-12) + AddParameter(t, cuts); + } + } + + private static void AddParameter(double t, List cuts) + { + if (double.IsFinite(t) && t >= -1e-12 && t <= 1 + 1e-12) + cuts.Add(System.Math.Clamp(t, 0, 1)); + } + + private static bool StrictlyInside(Polygon ring, Vector point) => + Contains(ring, point, boundaryIsInside: false); + + private static bool InsideOrOnBoundary(Polygon ring, Vector point) => + Contains(ring, point, boundaryIsInside: true); + + private static bool Contains(Polygon ring, Vector point, bool boundaryIsInside) + { + var vertices = ring.Vertices; + var inside = false; + for (var i = 1; i < vertices.Count; i++) + { + var a = vertices[i - 1]; + var b = vertices[i]; + var edge = b - a; + var relative = point - a; + if (Cross(edge, relative) == 0 && + point.X >= System.Math.Min(a.X, b.X) && point.X <= System.Math.Max(a.X, b.X) && + point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y)) + return boundaryIsInside; + if ((a.Y > point.Y) != (b.Y > point.Y) && + point.X < a.X + (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y)) + inside = !inside; + } + return inside; + } + + private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X; +} diff --git a/OpenNest.Core/Nest.cs b/OpenNest.Core/Nest.cs index 2f9d1ca..6d288c8 100644 --- a/OpenNest.Core/Nest.cs +++ b/OpenNest.Core/Nest.cs @@ -65,6 +65,21 @@ namespace OpenNest return plate; } + /// + /// Makes this nest's machine clamp rail: new plates follow it + /// () and every existing plate re-resolves its + /// zones from it. Null, or a rail without clamps, removes the rail and every plate's + /// zones; the rail is nest-wide, so per-plate zones are replaced either way. + /// + public void SetClampRail(ClampRail rail) + { + if (rail != null && rail.Centers.Count == 0) + rail = null; + PlateDefaults.ClampRail = rail; + foreach (var plate in Plates) + plate.SetClampRail(rail); + } + public void UpdateDrawingQuantities() { foreach (var drawing in Drawings) @@ -114,6 +129,12 @@ namespace OpenNest set { plate.PartSpacing = value; } } + /// + /// The machine clamp rail new plates follow, or null for none. Set through + /// to also update existing plates. + /// + public ClampRail ClampRail { get; set; } + public void SetFromExisting(Plate plate) { Quadrant = plate.Quadrant; @@ -124,7 +145,7 @@ namespace OpenNest public Plate CreateNew() { - return new Plate() + var created = new Plate() { Size = Size, EdgeSpacing = EdgeSpacing, @@ -132,6 +153,9 @@ namespace OpenNest Quadrant = Quadrant, Quantity = 1, }; + if (ClampRail != null) + created.SetClampRail(ClampRail); + return created; } } } diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 0f3cf96..31f9228 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using OpenNest.Collections; using OpenNest.Geometry; @@ -55,6 +56,10 @@ namespace OpenNest Parts.ItemAdded += Parts_PartAdded; Parts.ItemRemoved += Parts_PartRemoved; CutOffs = new ObservableList(); + ClampZones = new ObservableList(); + ClampZones.ItemAdded += (_, _) => OnClampZonesChanged(); + ClampZones.ItemRemoved += (_, _) => OnClampZonesChanged(); + ClampZones.ItemChanged += (_, _) => OnClampZonesChanged(); Quadrant = 1; } @@ -81,9 +86,20 @@ namespace OpenNest public Spacing EdgeSpacing; /// - /// The size of the plate. + /// The size of the plate. A plate following a re-resolves + /// its clamp zones for the new size. /// - public Size Size { get; set; } + public Size Size + { + get { return size; } + set + { + size = value; + ResolveClampRail(); + } + } + + private Size size; public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; } @@ -102,6 +118,106 @@ namespace OpenNest /// public ObservableList CutOffs { get; set; } + /// + /// Fixed rectangular machine-clamp keep-outs in plate coordinates + /// (). Never parts: excluded from quantity, utilization, + /// cut sequence and CNC output; placement rules and display consume them. Mutate + /// through this list; fires for every change. + /// + public ObservableList ClampZones { get; } + + /// + /// Raised after content changes so views can refresh + /// the zone overlay. Carries no geometry arguments. + /// + public event EventHandler? ClampZonesChanged; + + private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty); + + /// + /// The machine clamp rail this plate follows, or null when its + /// are explicit (engine sheets, files without a rail). While + /// set, the zones are derived: re-resolved whenever or + /// changes, because the clamps are fixed to the machine. + /// + public ClampRail ClampRail { get; private set; } + + private static readonly IReadOnlySet NoRemovedClamps = ImmutableHashSet.Empty; + + /// + /// Rail clamp ids taken off this sheet as a last resort (D8) so a part that could not + /// be nested anywhere with every clamp in place still fits. Empty unless + /// is set; already excludes them, so + /// nothing else needs to consult this list to avoid them. Ids stay listed across a + /// resize/rotate even when the matching clamp is temporarily beyond the sheet, so it + /// re-appears removed if a later resize brings it back onto the sheet (C13). + /// + public IReadOnlySet RemovedClamps { get; private set; } = NoRemovedClamps; + + /// + /// Minutes this sheet's cut-time estimate gains from (D9): + /// each listed clamp the rail places on this sheet adds + /// once; a listed clamp that is beyond + /// the sheet (or unknown to the rail) is not pulled and costs nothing. Zero when there + /// is no rail (removal is meaningless without one to remove clamps from). + /// + public double ClampRemovalMinutes => + ClampRail == null || RemovedClamps.Count == 0 + ? 0 + : ClampRail.ResolveFor(Size, Quadrant).Count(zone => RemovedClamps.Contains(zone.Id)) + * ClampRail.RemovalMinutes; + + /// + /// Follows and replaces the zones with its resolution for + /// this sheet; null detaches and removes every zone. Unchanged zones raise no events. + /// + public void SetClampRail(ClampRail rail) => SetClampRail(rail, null); + + /// + /// Follows with the clamps named in + /// taken off this sheet (D8): zones become the rail's resolution for this sheet minus + /// those ids. Null detaches, clears every zone and forgets the + /// removed set (removal only means something while following a rail). + /// + public void SetClampRail(ClampRail rail, IEnumerable removedClamps) + { + ClampRail = rail; + RemovedClamps = rail == null || removedClamps == null + ? NoRemovedClamps + : removedClamps.ToImmutableHashSet(StringComparer.Ordinal); + ReplaceClampZones(ResolveRailZones()); + } + + private void ResolveClampRail() + { + // Called from the constructor's Size assignment before the zone list exists. + if (ClampRail != null) + ReplaceClampZones(ResolveRailZones()); + } + + private IReadOnlyList ResolveRailZones() + { + if (ClampRail == null) + return Array.Empty(); + var resolved = ClampRail.ResolveFor(Size, Quadrant); + if (RemovedClamps.Count == 0) + return resolved; + var kept = new List(resolved.Count); + foreach (var zone in resolved) + if (!RemovedClamps.Contains(zone.Id)) + kept.Add(zone); + return kept; + } + + private void ReplaceClampZones(IReadOnlyList zones) + { + if (ClampRail.SameZones(ClampZones, zones)) + return; + ClampZones.Clear(); + foreach (var zone in zones) + ClampZones.Add(zone); + } + /// /// Regenerates all cut-off drawings and materializes them as parts. /// Existing cut-off parts are removed first, then each cut-off is @@ -236,7 +352,11 @@ namespace OpenNest public int Quadrant { get { return quadrant; } - set { quadrant = value <= 4 && value > 0 ? value : 1; } + set + { + quadrant = value <= 4 && value > 0 ? value : 1; + ResolveClampRail(); + } } /// diff --git a/OpenNest.Core/PlateManager.cs b/OpenNest.Core/PlateManager.cs index 01d4cd8..a70ca2d 100644 --- a/OpenNest.Core/PlateManager.cs +++ b/OpenNest.Core/PlateManager.cs @@ -119,6 +119,50 @@ namespace OpenNest SubscribeToTailPlates(); } + /// + /// Applies a nest-defaults edit, moving only an untouched trailing empty sheet + /// that matched the old defaults to the new defaults. + /// + public void ChangePlateDefaults(Action changeDefaults) + { + ArgumentNullException.ThrowIfNull(changeDefaults); + var defaults = nest.PlateDefaults; + var trailing = Count > 0 ? nest.Plates[^1] : null; + // Exact configuration equality, not geometric tolerance: even a small edit + // makes this configured stock rather than the untouched workspace sentinel. + var followsDefaults = trailing != null + && trailing.Parts.Count == 0 + && trailing.CutOffs.Count == 0 + && trailing.Size.Width == defaults.Size.Width + && trailing.Size.Length == defaults.Size.Length + && trailing.Quadrant == defaults.Quadrant + && trailing.EdgeSpacing.Left == defaults.EdgeSpacing.Left + && trailing.EdgeSpacing.Bottom == defaults.EdgeSpacing.Bottom + && trailing.EdgeSpacing.Right == defaults.EdgeSpacing.Right + && trailing.EdgeSpacing.Top == defaults.EdgeSpacing.Top + && trailing.PartSpacing == defaults.PartSpacing + && trailing.RemovedClamps.Count == 0 + && Equals(trailing.ClampRail, defaults.ClampRail) + && (defaults.ClampRail == null + ? trailing.ClampZones.Count == 0 + : defaults.ClampRail.Matches(trailing)); + + changeDefaults(); + + if (!followsDefaults) + return; + + defaults = nest.PlateDefaults; + trailing.Size = defaults.Size; + trailing.Quadrant = defaults.Quadrant; + trailing.EdgeSpacing = defaults.EdgeSpacing; + trailing.PartSpacing = defaults.PartSpacing; + trailing.SetClampRail(defaults.ClampRail); + PlateListChanged?.Invoke(this, EventArgs.Empty); + if (ReferenceEquals(CurrentPlate, trailing)) + FireCurrentPlateChanged(); + } + public void BeginBatch() { batching = true; diff --git a/OpenNest.Core/Timing.cs b/OpenNest.Core/Timing.cs index 0d6e628..338bde9 100644 --- a/OpenNest.Core/Timing.cs +++ b/OpenNest.Core/Timing.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using OpenNest.Api; using OpenNest.CNC; @@ -43,6 +43,10 @@ namespace OpenNest pos = endpt; } + // D9: once per physical sheet, before the quantity multiply below scales it with + // everything else cut on that sheet. + info.ClampRemovalMinutes = plate.ClampRemovalMinutes; + info *= plate.Quantity; return info; @@ -91,6 +95,9 @@ namespace OpenNest time += TimeSpan.FromTicks(info.PierceCount * cutParams.PierceTime.Ticks); + // D9: a flat per-removed-clamp time, already in minutes regardless of cutParams.Units. + time += TimeSpan.FromMinutes(info.ClampRemovalMinutes); + return time; } } diff --git a/OpenNest.Core/TimingInfo.cs b/OpenNest.Core/TimingInfo.cs index b2bdf81..8a3e97c 100644 --- a/OpenNest.Core/TimingInfo.cs +++ b/OpenNest.Core/TimingInfo.cs @@ -10,6 +10,13 @@ namespace OpenNest public double CutDistance; + /// + /// Minutes added by last-resort clamp removal (D9): once per physical sheet, for + /// every clamp lists. Zero for a plate with no + /// removal, so existing timing is unaffected. + /// + public double ClampRemovalMinutes; + public static TimingInfo operator +(TimingInfo info1, TimingInfo info2) { return new TimingInfo @@ -18,6 +25,7 @@ namespace OpenNest IntersectionCount = info1.IntersectionCount + info2.IntersectionCount, TravelDistance = info1.TravelDistance + info2.TravelDistance, PierceCount = info1.PierceCount + info2.PierceCount, + ClampRemovalMinutes = info1.ClampRemovalMinutes + info2.ClampRemovalMinutes, }; } @@ -29,6 +37,7 @@ namespace OpenNest IntersectionCount = info1.IntersectionCount - info2.IntersectionCount, TravelDistance = info1.TravelDistance - info2.TravelDistance, PierceCount = info1.PierceCount - info2.PierceCount, + ClampRemovalMinutes = info1.ClampRemovalMinutes - info2.ClampRemovalMinutes, }; } @@ -40,6 +49,7 @@ namespace OpenNest IntersectionCount = info1.IntersectionCount * info2.IntersectionCount, TravelDistance = info1.TravelDistance * info2.TravelDistance, PierceCount = info1.PierceCount * info2.PierceCount, + ClampRemovalMinutes = info1.ClampRemovalMinutes * info2.ClampRemovalMinutes, }; } @@ -51,6 +61,7 @@ namespace OpenNest IntersectionCount = info1.IntersectionCount * factor, TravelDistance = info1.TravelDistance * factor, PierceCount = info1.PierceCount * factor, + ClampRemovalMinutes = info1.ClampRemovalMinutes * factor, }; } } diff --git a/OpenNest.Data/ClampRailEditor.cs b/OpenNest.Data/ClampRailEditor.cs new file mode 100644 index 0000000..5f210f8 --- /dev/null +++ b/OpenNest.Data/ClampRailEditor.cs @@ -0,0 +1,132 @@ +namespace OpenNest.Data; + +/// +/// Decision logic behind the desktop Clamps dialog: edits one nest's clamp rail in that +/// nest's units. Three explicit boundaries: only fills +/// the editor, changes the nest (the dialog's OK), and +/// is what "Save as Machine Defaults" writes immediately. +/// +public sealed class ClampRailEditor +{ + private readonly List _centers = new(); + + /// Starts from the nest's rail, or the default clamp size with no clamps. + public ClampRailEditor(ClampRail? rail, Units units, bool applyToNewNests) + { + Units = units; + ApplyToNewNests = applyToNewNests; + var initial = ClampSettings.FromRail(rail, units, applyToNewNests); + Width = initial.Width; + Depth = initial.Depth; + RemovalMinutes = initial.RemovalMinutes; + _centers.AddRange(initial.Centers); + } + + /// The nest's units; every value is in these units. + public Units Units { get; } + + /// Clamp extent along the edge (X). + public double Width { get; set; } + + /// Clamp intrusion into the sheet (Y). + public double Depth { get; set; } + + /// Minutes to remove one clamp and put it back after cutting (D9); not a + /// length, so it is never converted between units. + public double RemovalMinutes { get; set; } + + /// Whether saved machine defaults give new nests these clamps. + public bool ApplyToNewNests { get; set; } + + /// Clamp centers along X from the plate origin, ascending and distinct. + public IReadOnlyList Centers => _centers; + + /// + /// Adds a clamp center, keeping the list sorted. Refuses (with an operator-facing + /// reason) values that are not finite, negative, or already listed. + /// + public bool TryAddCenter(double center, out string? error) + { + if (!double.IsFinite(center) || center < 0) + { + error = "Enter a clamp center measured from the plate origin (zero or more)."; + return false; + } + + var index = _centers.BinarySearch(center); + if (index >= 0) + { + error = $"A clamp is already centered at {center:0.####}."; + return false; + } + + _centers.Insert(~index, center); + error = null; + return true; + } + + public void RemoveCenterAt(int index) + { + if (index >= 0 && index < _centers.Count) + _centers.RemoveAt(index); + } + + /// + /// Replaces the editor's values with the machine defaults converted to this nest's + /// units. Nothing is applied or saved. + /// + public void LoadMachineDefaults(ClampSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + var converted = settings.ConvertedTo(Units); + Width = converted.Width; + Depth = converted.Depth; + RemovalMinutes = converted.RemovalMinutes; + ApplyToNewNests = settings.Enabled; + _centers.Clear(); + _centers.AddRange(converted.Centers.Distinct().OrderBy(c => c)); + } + + /// + /// The edited rail, or null when no clamp is listed. Throws + /// for a non-positive width or depth, or a + /// negative/non-finite removal time. + /// + public ClampRail? BuildRail() => + _centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers, RemovalMinutes); + + /// The dialog's OK: makes the edited rail the nest's, re-resolving every plate. + public void ApplyTo(Nest nest) + { + ArgumentNullException.ThrowIfNull(nest); + nest.SetClampRail(BuildRail()); + } + + /// + /// The machine settings "Save as Machine Defaults" writes: these values in this nest's + /// units, enabled for new nests only when requested and at least one clamp is listed. + /// + public ClampSettings ToMachineDefaults() + { + // Validate before anything is persisted. + if (!double.IsFinite(Width) || Width <= 0 || !double.IsFinite(Depth) || Depth <= 0) + throw new ArgumentOutOfRangeException( + nameof(Width), + "Clamp width and depth must be positive." + ); + if (!double.IsFinite(RemovalMinutes) || RemovalMinutes < 0) + throw new ArgumentOutOfRangeException( + nameof(RemovalMinutes), + "Clamp removal time must be finite and zero or more." + ); + return new ClampSettings + { + Enabled = ApplyToNewNests && _centers.Count > 0, + Units = Units, + Width = Width, + Depth = Depth, + RemovalMinutes = RemovalMinutes, + Centers = _centers.ToArray(), + }; + } +} diff --git a/OpenNest.Data/ClampSettings.cs b/OpenNest.Data/ClampSettings.cs new file mode 100644 index 0000000..eb71bd0 --- /dev/null +++ b/OpenNest.Data/ClampSettings.cs @@ -0,0 +1,334 @@ +using System.Text.Json; + +namespace OpenNest.Data; + +/// Outcome of . +public enum ClampSettingsStatus +{ + /// Every value present in the file was valid. + Ok, + + /// No file exists at the path; clamps stay off. + Missing, + + /// + /// The file loaded, but some values were invalid and were ignored (an invalid clamp + /// center is dropped, other fields fall back individually). Callers should warn: the + /// resulting rail may lack a physical clamp. + /// + Partial, + + /// The file exists but could not be read or parsed; clamps stay off. + Invalid, +} + +/// +/// The machine's fixed edge-clamp rail, persisted to a single JSON file (by default +/// %APPDATA%\OpenNest\clamps.json). Off by default: until is set +/// with at least one center, new nests get no clamps and behave exactly as before. +/// Loading never throws; values are validated field by field. +/// +public sealed class ClampSettings +{ + public const int CurrentVersion = 1; + + /// Default clamp extent along the edge, in (inches). + public const double DefaultWidth = 1.5; + + /// Default clamp intrusion into the sheet, in (inches). + public const double DefaultDepth = 0.625; + + private const double MillimetersPerInch = 25.4; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// Whether new nests receive this rail. Default off. + public bool Enabled { get; set; } + + /// Units of , and . + public Units Units { get; set; } = Units.Inches; + + public double Width { get; set; } = DefaultWidth; + + public double Depth { get; set; } = DefaultDepth; + + /// + /// Minutes to remove one clamp and put it back after cutting (D9), stored and edited in + /// the machine clamp settings alongside the rail's physical size. + /// + public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes; + + /// Clamp centers along X, measured from the plate origin. + public IReadOnlyList Centers { get; set; } = Array.Empty(); + + /// The built-in settings used for a missing or unreadable file: clamps off. + public static ClampSettings Fallback => new(); + + /// %APPDATA%\OpenNest\clamps.json. + public static string DefaultPath => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenNest", + "clamps.json" + ); + + /// + /// Captures a nest's rail (or the default clamp size and no centers when it has none) + /// in that nest's units, e.g. for "save as machine defaults". + /// + public static ClampSettings FromRail(ClampRail? rail, Units units, bool enabled) => + new() + { + Enabled = enabled, + Units = units, + Width = rail?.Width ?? ConvertDefault(DefaultWidth, units), + Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units), + RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes, + Centers = rail?.Centers.ToArray() ?? Array.Empty(), + }; + + /// + /// The rail a new nest in receives, or null when clamps are + /// disabled or no center is configured. Values are converted between inches and + /// millimeters as needed; is a time, so it is never scaled. + /// + public ClampRail? RailFor(Units units) + { + if (!Enabled || Centers.Count == 0) + return null; + + var converted = ConvertedTo(units); + return new ClampRail(converted.Width, converted.Depth, converted.Centers, converted.RemovalMinutes); + } + + /// + /// A copy with width, depth and centers expressed in + /// (inches and millimeters convert at 25.4); and + /// (a time, not a length) are kept unscaled. + /// + public ClampSettings ConvertedTo(Units units) + { + var scale = Scale(Units, units); + return new ClampSettings + { + Enabled = Enabled, + Units = units, + Width = Width * scale, + Depth = Depth * scale, + RemovalMinutes = RemovalMinutes, + Centers = Centers.Select(c => c * scale).ToArray(), + }; + } + + /// + /// Gives a newly created nest this machine rail when enabled (after the nest's units are + /// set); otherwise leaves it untouched. + /// + public void ApplyToNewNest(Nest nest) + { + ArgumentNullException.ThrowIfNull(nest); + var rail = RailFor(nest.Units); + if (rail != null) + nest.SetClampRail(rail); + } + + public static ClampSettings Load(string path) => Load(path, out _); + + /// + /// Loads the settings, never throwing. A missing or unreadable file yields + /// (clamps off); a readable file merges valid values over it and + /// reports if anything was ignored. + /// + public static ClampSettings Load(string path, out ClampSettingsStatus status) + { + var settings = Fallback; + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + status = ClampSettingsStatus.Missing; + return settings; + } + + // Read as a raw document so a missing field (fallback, Ok) is distinguishable from a + // present but unusable one, including an explicit null (fallback, Partial). + Dictionary values; + try + { + using var document = JsonDocument.Parse(File.ReadAllText(path)); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + status = ClampSettingsStatus.Invalid; + return settings; + } + values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in document.RootElement.EnumerateObject()) + values[property.Name] = property.Value.Clone(); + } + catch (Exception ex) when (IsUnreadableFile(ex)) + { + status = ClampSettingsStatus.Invalid; + return settings; + } + + var ignored = false; + + if (values.TryGetValue("enabled", out var enabled)) + { + if (enabled.ValueKind is JsonValueKind.True or JsonValueKind.False) + settings.Enabled = enabled.GetBoolean(); + else + ignored = true; + } + + if (values.TryGetValue("units", out var units)) + { + if (TryParseUnits(units, out var parsed)) + settings.Units = parsed; + else + ignored = true; + } + + // Fallback sizes are the physical default clamp, expressed in the file's units. + settings.Width = ConvertDefault(DefaultWidth, settings.Units); + settings.Depth = ConvertDefault(DefaultDepth, settings.Units); + + if (values.TryGetValue("width", out var width)) + { + if (TryLength(width, out var value) && value > 0) + settings.Width = value; + else + ignored = true; + } + + if (values.TryGetValue("depth", out var depth)) + { + if (TryLength(depth, out var value) && value > 0) + settings.Depth = value; + else + ignored = true; + } + + if (values.TryGetValue("removalMinutes", out var removalMinutes)) + { + if (TryLength(removalMinutes, out var value) && value >= 0) + settings.RemovalMinutes = value; + else + ignored = true; + } + + if (values.TryGetValue("centers", out var centers)) + { + if (centers.ValueKind == JsonValueKind.Array) + { + var valid = new List(); + foreach (var center in centers.EnumerateArray()) + { + if (TryLength(center, out var x) && x >= 0) + valid.Add(x); + else + ignored = true; + } + settings.Centers = valid.Distinct().OrderBy(x => x).ToArray(); + } + else + { + ignored = true; + } + } + + status = ignored ? ClampSettingsStatus.Partial : ClampSettingsStatus.Ok; + return settings; + } + + /// + /// Writes the file (creating the parent directory), retrying briefly on IO collisions + /// the same way does. + /// + public void Save(string path, int maxRetries = 3) + { + var dto = new + { + version = CurrentVersion, + enabled = Enabled, + units = Units.ToString().ToLowerInvariant(), + width = Width, + depth = Depth, + removalMinutes = RemovalMinutes, + centers = Centers, + }; + var json = JsonSerializer.Serialize(dto, JsonOptions); + + var directory = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + for (var attempt = 0; attempt < maxRetries; attempt++) + { + try + { + File.WriteAllText(path, json); + return; + } + catch (IOException) when (attempt < maxRetries - 1) + { + Thread.Sleep(100); + } + } + } + + private static double Scale(Units from, Units to) => + from == to ? 1 + : from == Units.Inches ? MillimetersPerInch + : 1 / MillimetersPerInch; + + private static double ConvertDefault(double inches, Units units) => + inches * Scale(Units.Inches, units); + + /// + /// A finite JSON number that stays finite in either unit, so applying it to an inch or a + /// millimeter nest can never overflow. + /// + private static bool TryLength(JsonElement element, out double value) + { + value = 0; + return element.ValueKind == JsonValueKind.Number + && element.TryGetDouble(out value) + && double.IsFinite(value) + && double.IsFinite(value * MillimetersPerInch); + } + + /// + /// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept + /// numeric strings and comma-joined names. + /// + private static bool TryParseUnits(JsonElement element, out Units units) + { + units = default; + if (element.ValueKind != JsonValueKind.String) + return false; + + var text = element.GetString(); + foreach (var candidate in Enum.GetValues()) + { + if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase)) + { + units = candidate; + return true; + } + } + + return false; + } + + private static bool IsUnreadableFile(Exception ex) => + ex + is JsonException + or IOException + or UnauthorizedAccessException + or NotSupportedException + or System.Security.SecurityException; +} diff --git a/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs b/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs new file mode 100644 index 0000000..b84acfd --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Fill; + +/// +/// Slice 4: interactive pushes and spacing expansion never target poses inside a +/// clamp keep-out. The plate's zones act as static obstacles for Compactor.Push, +/// and as static walls for Expander — including the Expander edge inputs (clusters +/// that already overlap or are pinned to an edge), which must degrade to a +/// violation report instead of a crash. +/// +public class ClampZonePushTests +{ + // Plate: 24 x 24, 0.5 uniform edge spacing, part spacing 1.0. Zones on the + // front edge; validation clearance (bottom edge spacing) is 0.5. + private const double Edge = 0.5; + + private static readonly Box ZoneA = new(3, 0, 1.5, 0.625); + private static readonly Box ZoneB = new(8, 0, 1.5, 0.625); + + private static Plate MakePlate(params ClampZone[] zones) + { + var plate = new Plate(new Size(24, 24)); + plate.EdgeSpacing = new Spacing(Edge, Edge); + plate.PartSpacing = 1.0; + foreach (var zone in zones) + plate.ClampZones.Add(zone); + return plate; + } + + private static Program SquareProgram(double size) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(size, 0); + program.LineTo(size, size); + program.LineTo(0, size); + program.LineTo(0, size); + program.LineTo(0, 0); + return program; + } + + private static Part AddSquare(Plate plate, double x, double y, double size = 3) + { + var part = new Part(new Drawing($"sq{plate.Parts.Count}", SquareProgram(size)), new Vector(x, y)); + plate.Parts.Add(part); + return part; + } + + private static Box Inflated(Box zone, double clearance) => + new(zone.X - clearance, zone.Y - clearance, zone.Length + 2 * clearance, zone.Width + 2 * clearance); + + private static bool Overlaps(Box a, Box b) => + a.Left < b.Right - 1e-6 && b.Left < a.Right - 1e-6 && a.Bottom < b.Top - 1e-6 && b.Bottom < a.Top - 1e-6; + + private static void AssertClear(Plate plate, params Part[] parts) + { + foreach (var part in parts) + foreach (var zone in plate.ClampZones) + Assert.False( + Overlaps(part.BoundingBox, Inflated(zone.Bounds, Edge)), + $"'{part.BaseDrawing.Name}' at ({part.Location.X:R},{part.Location.Y:R}) enters clamp '{zone.Id}'" + ); + } + + [Fact] + public void PushDownTowardTheClampEdgeStopsClearOfTheKeepOut() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + var mover = AddSquare(plate, 3, 8); // column crosses zone front-1 + + var moved = Compactor.Push(new List { mover }, plate, PushDirection.Down); + + Assert.True(moved > 0.1, $"expected a downward move, got {moved}"); + AssertClear(plate, mover); + } + + [Fact] + public void PushDownBetweenTheClampsStillReachesTheEdge() + { + // Material between clamps stays usable (D6): the free column pushes to the edge. + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + var mover = AddSquare(plate, 14, 8); + + var moved = Compactor.Push(new List { mover }, plate, PushDirection.Down); + + Assert.True(moved > 7, $"free column should reach near the edge, moved {moved}"); + AssertClear(plate, mover); + } + + [Fact] + public void SettleDoesNotMoveGroupThroughAnOccupiedPart() + { + var plate = new Plate(new Size(20, 20)) + { + EdgeSpacing = new Spacing(0.25, 0.25), + PartSpacing = 0.1, + }; + plate.ClampZones.Add(new ClampZone("remote", new Box(15, 0, 1.5, 0.625))); + var occupant = AddSquare(plate, 0.25, 0.25, 4); + var first = AddSquare(plate, 6, 7); + var second = AddSquare(plate, 11, 7); + Compactor.Settle(new List { first, second }, plate, plate.WorkArea()); + Assert.False(Overlaps(first.BoundingBox, occupant.BoundingBox)); + Assert.False(Overlaps(second.BoundingBox, occupant.BoundingBox)); + AssertClear(plate, first, second); + } + + [Fact] + public void SettleKeepsGroupOutsideClampsInsideItsFillArea() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA)); + var first = AddSquare(plate, 3, 8); + var second = AddSquare(plate, 12, 8); + Compactor.Settle(new List { first, second }, plate, plate.WorkArea()); + AssertClear(plate, first, second); + } + + [Fact] + public void PushWithAlreadyOverlappingMemberDoesNotMoveClearMemberIntoClamp() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA)); + var straddler = AddSquare(plate, 3, 0.5); + var clear = AddSquare(plate, 3, 8); + Compactor.Push(new List { straddler, clear }, plate, PushDirection.Down); + AssertClear(plate, clear); + } + + [Fact] + public void ExpanderPreservesBottomEdgeClearanceWhenItsAchievedSpacingIsSmaller() + { + var plate = new Plate(new Size(12, 12)) + { + EdgeSpacing = new Spacing(2, 2), + PartSpacing = 0.1, + }; + plate.ClampZones.Add(new ClampZone("front", new Box(4, 0, 2, 1))); + var upper = AddSquare(plate, 4, 5, 2); + var lower = AddSquare(plate, 4, 3.1, 2); + var result = Expander.Expand(new List { upper, lower }, plate); + Assert.False(result.Cancelled); + Assert.True(lower.BoundingBox.Bottom >= 3 - 1e-6, + $"Achieved={result.AchievedSpacing}, lower at {lower.Location.Y}"); + } + + [Fact] + public void ExpanderNeverTargetsAPoseInsideAClampWhenGrowthWouldEnterOne() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + // Overlapping pair directly above zone front-2 (validation keep-out top y = 1.125): + // separating them drives the lower square down through the keep-out boundary. + var upper = AddSquare(plate, 8, 4.0, 3); + var lower = AddSquare(plate, 8, 1.2, 3); + + var result = Expander.Expand(new List { upper, lower }, plate); + + Assert.False(result.Cancelled); + AssertClear(plate, upper, lower); + } + + [Fact] + public void ExpanderDegradesToAReportWhenAMemberAlreadyOverlapsAClamp() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + var straddler = AddSquare(plate, 3, 0.3, 3); // starts inside the clamp keep-out + var clear = AddSquare(plate, 15, 0.3, 3); + + var result = Expander.Expand(new List { straddler, clear }, plate); + + Assert.False(result.Cancelled); + // The clear member must not be driven into any clamp while its partner is stuck. + AssertClear(plate, clear); + } + + [Fact] + public void ExpanderOnEdgePinnedClusterWithClampsReportsInsteadOfCrashing() + { + var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB)); + // Three tight squares away from the clamp edge (the Expander edge-input class: + // an overlapping, mutually blocked cluster). All start clear of the zones. + var a = AddSquare(plate, 15, 0.5, 3); + var b = AddSquare(plate, 17.75, 0.5, 3); + var c = AddSquare(plate, 15, 3.25, 3); + + var result = Expander.Expand(new List { a, b, c }, plate); + + Assert.False(result.Cancelled); + // Whatever the achieved spacing or reported violations, no clear member may + // end inside a clamp keep-out. + AssertClear(plate, a, b, c); + } +} diff --git a/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs new file mode 100644 index 0000000..2d66932 --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs @@ -0,0 +1,121 @@ +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Fill; + +public class ExpanderCutoffDeltaTests +{ + private static Plate Sheet() => new(new Size(30, 30)) { EdgeSpacing = new Spacing(0, 0) }; + + private static Program Rectangle(double width = 2, double height = 2) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(width, 0); + program.LineTo(width, height); + program.LineTo(0, height); + program.LineTo(0, 0); + return program; + } + + private static Part Add(Plate plate, Program program, double x, double y) + { + var part = new Part(new Drawing("neutral", program), new Vector(x, y)); + plate.Parts.Add(part); + return part; + } + + private static Program Disk(double radius) => + ConvertGeometry.ToProgram(new List { new Circle(0, 0, radius) }); + + [Theory] + [InlineData(CutOffAxis.Horizontal)] + [InlineData(CutOffAxis.Vertical)] + public void Expand_RegeneratedCutoff_MustPreserveNativeEntryGap(CutOffAxis axis) + { + var plate = Sheet(); + var anchor = Add(plate, Rectangle(), 1, 1); + var mover = Add(plate, Rectangle(), 4, 4); + plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis)); + var settings = new CutOffSettings(); + plate.RegenerateCutOffs(settings); + var before = axis == CutOffAxis.Horizontal + ? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right; + + var result = Expander.Expand(new List { anchor, mover }, plate, + new Expander.Options { MaxSpacing = 3 }); + var after = axis == CutOffAxis.Horizontal + ? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right; + Assert.True(after >= before - 1.1e-6, + $"Cutoff entry gap shrank {before:R}->{after:R}, mover=({mover.Location.X:R},{mover.Location.Y:R}), achieved={result.AchievedSpacing:R}, violations={result.Violations.Count}"); + plate.RegenerateCutOffs(settings); + Assert.Single(plate.CutOffs); + } + + [Fact] + public void Expand_RemoteOpenU_MustNotMoveTowardNativeCutoff() + { + var plate = Sheet(); + var anchor = Add(plate, Rectangle(), 1, 4); + var mover = Add(plate, Rectangle(), 4, 4); + var program = new Program(); + program.MoveTo(0, 10); + program.LineTo(30, 10); + program.LineTo(30, 0); + program.LineTo(0, 0); + plate.Parts.Add(new Part(new Drawing("neutral-cutoff", program) { IsCutOff = true }, Vector.Zero)); + + var result = Expander.Expand(new List { anchor, mover }, plate, + new Expander.Options { MaxSpacing = 3 }); + // The three real segments are at least four units from the selected + // square at entry; an invented closing chord is not a real obstacle. + var gap = System.Math.Min(mover.BoundingBox.Bottom, + System.Math.Min(10 - mover.BoundingBox.Top, 30 - mover.BoundingBox.Right)); + Assert.True(gap >= 4 - 1.1e-6, + $"Open path gap shrank to {gap:R}; mover=({mover.Location.X:R},{mover.Location.Y:R}); spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Separate_ShallowDiskOrRingVsRectangle_MustNotClaimClear(bool annulus) + { + var plate = Sheet(); + var entities = new List { new Circle(0, 0, 2) }; + if (annulus) + entities.Add(new Circle(0, 0, 1)); + Add(plate, ConvertGeometry.ToProgram(entities), 10, 10); + const double angle = 0.017; + const double depth = 0.0005; + var mover = Add(plate, Rectangle(0.01, 0.01), + 10 + (2 - depth) * System.Math.Cos(angle), + 10 + (2 - depth) * System.Math.Sin(angle)); + + var result = Expander.Separate(new List { mover }, plate, 0); + var nearest = result.Positions[0].DistanceTo(new Vector(10, 10)); + Assert.True(nearest >= 2 - 1e-8 || result.Violations.Any(v => v.Achieved < 0), + $"Native material overlap remains: nearest={nearest:R}, converged={result.Converged}, violations={result.Violations.Count}"); + } + + [Theory] + [InlineData(CutOffAxis.Horizontal)] + [InlineData(CutOffAxis.Vertical)] + public void Separate_RegeneratedCutoff_MustNotCrossEvenAtFarClearEndpoint(CutOffAxis axis) + { + var plate = Sheet(); + var horizontal = axis == CutOffAxis.Horizontal; + var anchor = Add(plate, Rectangle(), 1, 1); + var mover = Add(plate, Rectangle(), horizontal ? 1 : 6, horizontal ? 6 : 1); + plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis)); + plate.RegenerateCutOffs(new CutOffSettings()); + + var result = Expander.Separate(new List { anchor, mover }, plate, 12); + var trailingEdge = (horizontal ? result.Positions[1].Y : result.Positions[1].X) + 2; + Assert.True(trailingEdge <= 10 + 1e-6, + $"Crossed native cutoff, trailing edge={trailingEdge:R}"); + Assert.False(result.Converged); + } +} diff --git a/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs new file mode 100644 index 0000000..51cefcb --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs @@ -0,0 +1,94 @@ +using OpenNest.CNC; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Fill; + +public class ExpanderCutoffQuadrantTests +{ + private static Program Square() + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(2, 0); + program.LineTo(2, 2); + program.LineTo(0, 2); + program.LineTo(0, 0); + return program; + } + + public static IEnumerable 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 { 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 { 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)); + } + } +} diff --git a/OpenNest.Engine.Tests/Fill/ExpanderFinalQualityTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderFinalQualityTests.cs new file mode 100644 index 0000000..021dd79 --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderFinalQualityTests.cs @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); + } +} diff --git a/OpenNest.Engine.Tests/Fill/ExpanderIntersectingOpenPathTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderIntersectingOpenPathTests.cs new file mode 100644 index 0000000..f1936da --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderIntersectingOpenPathTests.cs @@ -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 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 violations; + if (expand) + { + var result = Expander.Expand(new List { anchor, mover }, plate, + new Expander.Options { MaxSpacing = 0.1 }); + violations = result.Violations; + Assert.Equal(0, result.AchievedSpacing); + } + else + { + var result = Expander.Separate(new List { 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); + } +} diff --git a/OpenNest.Engine.Tests/Fill/ExpanderQualityReviewTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderQualityReviewTests.cs new file mode 100644 index 0000000..1acd79c --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderQualityReviewTests.cs @@ -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 { 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 { 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 { 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 { 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); + } +} diff --git a/OpenNest.Engine.Tests/Fill/ExpanderRemoteObliquePathTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderRemoteObliquePathTests.cs new file mode 100644 index 0000000..b1b04e9 --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderRemoteObliquePathTests.cs @@ -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 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 { 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 { 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); + } +} diff --git a/OpenNest.Engine.Tests/Fill/ExpanderSpecRegressionTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderSpecRegressionTests.cs new file mode 100644 index 0000000..ffd572d --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderSpecRegressionTests.cs @@ -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 { 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 { 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 { 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); + } +} diff --git a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs index b396d2e..77a29f0 100644 --- a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs +++ b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs @@ -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(() => Expander.Expand(new List { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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() { diff --git a/OpenNest.Engine.Tests/Jobs/ClampRailStockTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRailStockTests.cs new file mode 100644 index 0000000..24fb20f --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampRailStockTests.cs @@ -0,0 +1,103 @@ +using OpenNest.Engine; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// 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. +/// +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)); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalCommitTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalCommitTests.cs new file mode 100644 index 0000000..3fc8fdb --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalCommitTests.cs @@ -0,0 +1,48 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// 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. +/// +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"); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs new file mode 100644 index 0000000..bd70818 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs @@ -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; + +/// +/// 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). +/// +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)); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs new file mode 100644 index 0000000..a021897 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs @@ -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; + +/// +/// 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. +/// +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 }); + + /// Stock resolved from the machine rail (as NestStockBuilder builds it). + private static NestPlateStock Stock(int? quantity = null) => + new("sheet", new Size(3, 23), quantity, 0, default, 1, null, Zones()) { ClampRail = Rail }; + + /// The same zones given explicitly, with no rail behind them. + 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 }); + + /// Delegates the ordinary solve to Rectangles, then fails every later call. + private sealed class ProbeThrowEngine : INestingEngine + { + public int Calls; + + public NestJobResult Solve(NestJob job, IProgress progress = null, + CancellationToken token = default) + { + if (++Calls > 1) + throw new InvalidOperationException("feasibility probe failed"); + return new RectanglesNestingEngine().Solve(job, progress, token); + } + } + + /// Solves with Rectangles and cancels the run on its second call. + private sealed class CancelOnSecondCallEngine(CancellationTokenSource source) : INestingEngine + { + private int calls; + + public NestJobResult Solve(NestJob job, IProgress 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(() => + 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(), Array.Empty()); + 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 { 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)); + } + /// Never places anything; counts how often recovery asks it to. + private sealed class CountingNoFitEngine : INestingEngine + { + public int Calls; + + public NestJobResult Solve(NestJob job, IProgress progress = null, + CancellationToken token = default) + { + Calls++; + return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.NoPlacementFound, + Array.Empty(), + 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 { 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)); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneCommitTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneCommitTests.cs new file mode 100644 index 0000000..867eb5c --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampZoneCommitTests.cs @@ -0,0 +1,170 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// 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. +/// +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)); + + /// Two different one-part sheets so repeat grouping cannot merge them. + private sealed class TwoSheetStub : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? 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)); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs new file mode 100644 index 0000000..21da7b6 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs @@ -0,0 +1,223 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// 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). +/// +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(() => NestJobValidator.Validate(job)); + } + + [Fact] + public void ValidatorRejectsDuplicateZoneIds() + { + var job = new NestJob([], [Stock(zones: new[] { Zone("dup", 3), Zone("dup", 20) })]); + Assert.Throws(() => 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 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")); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneInteractiveFillTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneInteractiveFillTests.cs new file mode 100644 index 0000000..e42b36a --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampZoneInteractiveFillTests.cs @@ -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; + +/// +/// 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. +/// +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); + + /// Strict positive-area overlap: touching edges (the admitted closure) are clear. + 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 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 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 { 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 + { + 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 + { + 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 + { + 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 { 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 { 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(); + 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 report) : System.IProgress + { + 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 { 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 { 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); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs new file mode 100644 index 0000000..5f2c005 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs @@ -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; + +/// +/// Every 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 . 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). +/// +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(), new[] { stock }); + var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, stock, Array.Empty()) }, + Array.Empty(), Array.Empty()); + + 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); + } + } +} diff --git a/OpenNest.Engine.Tests/Jobs/TinyClampZoneTests.cs b/OpenNest.Engine.Tests/Jobs/TinyClampZoneTests.cs new file mode 100644 index 0000000..6fdb390 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/TinyClampZoneTests.cs @@ -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; + +/// +/// 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. +/// +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 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(), Array.Empty()); + + 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(), Array.Empty()); + + 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 { 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 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(), Array.Empty()); + + 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().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(), Array.Empty()); + + Assert.NotEmpty(ClampViolations(job, result)); + Assert.True(InteractiveClamps.MaterialInKeepOut(new Part(new Drawing("notch", program)), zone)); + } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs new file mode 100644 index 0000000..ad2e2e8 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs @@ -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> NoPairs = + new Dictionary>(); + + [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> { [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(() => 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 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)); + } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/JobBuilder.cs b/OpenNest.Engine.Tests/NestingEngines/JobBuilder.cs index 32d4e92..3689b70 100644 --- a/OpenNest.Engine.Tests/NestingEngines/JobBuilder.cs +++ b/OpenNest.Engine.Tests/NestingEngines/JobBuilder.cs @@ -16,8 +16,9 @@ public static class JobBuilder /// Y extent. /// X extent. 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) => diff --git a/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs b/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs new file mode 100644 index 0000000..16b1ddd --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs @@ -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; + +/// +/// 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. +/// +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); + } +} diff --git a/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs b/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs index 6d03bd6..e172886 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs @@ -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 materialFactory) + Func 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; } + /// A cutoff whose placed program is one continuous cut across the whole sheet. + internal bool SeparatesSheet { get; } internal CutOffDefinition CutOff { get; } internal Box PlacedBounds { get; } internal Box CleanBounds { get; } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs index d26a529..06474fa 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs @@ -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(); diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index 388266c..3936056 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -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 }; } /// A cutoff: always a fixed open-cut program, never material or regenerated. public bool IsCutOff { get; internal init; } + /// A cutoff that splits the whole sheet; it is cut after every other placement. + public bool SeparatesSheet { get; internal init; } + public Part SourcePart { get; } public int SourceOrdinal { get; } public Vector Location { get; } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index 5681a72..23b7eae 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -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})."); diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs index dc3b019..e32a6a0 100644 --- a/OpenNest.Engine/Fill/Compactor.cs +++ b/OpenNest.Engine/Fill/Compactor.cs @@ -12,11 +12,22 @@ namespace OpenNest.Engine.Fill /// public static class Compactor { + /// + /// The plate's clamp zones as static push obstacles (C1). Unlike ordinary + /// already-overlapping parts, a clamp stays a wall even when a selected member + /// straddles it: dropping it would let other clear members move into the zone. + /// Empty zone lists change every caller's arithmetic by nothing (C2). + /// + private static List ZoneObstacles(Plate plate) => + Jobs.InteractiveClamps.ObstacleParts(plate); public static double Push(List movingParts, Plate plate, PushDirection direction) { - var obstacleParts = plate - .Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) - .ToList(); + var obstacleParts = ZoneObstacles(plate); + obstacleParts.InsertRange( + 0, + plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) + .ToList() + ); return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); } @@ -26,9 +37,12 @@ namespace OpenNest.Engine.Fill /// public static double Push(List movingParts, Plate plate, double angle) { - var obstacleParts = plate - .Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) - .ToList(); + var obstacleParts = ZoneObstacles(plate); + obstacleParts.InsertRange( + 0, + plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) + .ToList() + ); var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); @@ -214,9 +228,12 @@ namespace OpenNest.Engine.Fill PushDirection direction ) { - var obstacleParts = plate - .Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) - .ToList(); + var obstacleParts = ZoneObstacles(plate); + obstacleParts.InsertRange( + 0, + plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) + .ToList() + ); return PushBoundingBox( movingParts, @@ -374,5 +391,46 @@ namespace OpenNest.Engine.Fill break; } } + + /// + /// As , settling inside the + /// plate's work area with its clamp zones as static obstacles: settling never parks + /// a part inside a keep-out (C1), and a clear cluster still compacts against zones + /// like any other obstacle. With no zones this behaves exactly like settling in the + /// plate's work area (C2). + /// + public static void Settle( + List parts, + Plate plate, + double threshold = 0.01, + int maxIterations = 20 + ) => Settle(parts, plate, plate.WorkArea(), threshold, maxIterations); + + /// Keep a fill group's own work-area bounds while excluding plate clamps. + public static void Settle( + List parts, + Plate plate, + Box workArea, + double threshold = 0.01, + int maxIterations = 20 + ) + { + if (parts.Count < 2) + return; + + var obstacles = Jobs.InteractiveClamps.ObstacleParts(plate); + obstacles.AddRange(plate.Parts.Where(part => !parts.Contains(part) + && !part.BaseDrawing.IsCutOff)); + + for (var i = 0; i < maxIterations; i++) + { + var moved = 0.0; + moved += Push(parts, obstacles, workArea, plate.PartSpacing, PushDirection.Left); + moved += Push(parts, obstacles, workArea, plate.PartSpacing, PushDirection.Down); + + if (moved < threshold) + break; + } + } } } diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs index d378658..b426b51 100644 --- a/OpenNest.Engine/Fill/Expander.cs +++ b/OpenNest.Engine/Fill/Expander.cs @@ -14,16 +14,15 @@ namespace OpenNest.Engine.Fill /// boundaries. The inverse of : it grows gaps instead /// of closing them. /// - /// Input with overlaps is accepted: an overlapping pair is a pair with - /// negative clearance and is separated along the minimum-translation - /// direction. Moves are straight-line only — a pair that could separate only - /// by routing around a blocker is reported as a violation instead. Final - /// positions are always overlap-free; nothing moves off the work area. + /// Input with overlaps is accepted for safe direct separation; unresolved + /// overlaps remain in the violation report. Moves are straight-line only — + /// a pair that requires routing around a blocker remains unresolved. + /// Plate edges retain their EdgeSpacing floor. /// /// - /// Anchor policy: within a violated pair the later-indexed selected part - /// moves; the counterpart moves only as a fallback when the anchor mover is - /// fully blocked by a work-area edge. Walls (non-selected parts) never move. + /// Anchor policy: within a violated selected pair only its later-indexed + /// selected part moves. Nonselected parts never move; there is no counterpart + /// fallback when the prescribed mover is blocked. /// /// public static class Expander @@ -46,9 +45,11 @@ namespace OpenNest.Engine.Fill public sealed class Violation { public Part A; - public Part B; + public Part B; // null for an invalid work-area pose - /// Clearance actually reached (may be negative for unresolvable overlaps). + /// Signed clearance for a pair, or negative overshoot + /// for an invalid work-area pose. Sub-grid uncertain material uses a + /// negative marker, not a measured penetration depth. public double Achieved; /// True when a work-area edge, not a part, blocked the last needed move. @@ -60,30 +61,57 @@ namespace OpenNest.Engine.Fill /// /// Minimum part-to-part clearance the returned layout satisfies, /// never below zero. 0 with violations means the layout was only - /// cleaned up as far as possible, not opened up. + /// cleaned up as far as possible or still has invalid input (such as + /// a selected part outside the work area), not opened up. /// public double AchievedSpacing; /// True when the run was cancelled; no positions were changed. public bool Cancelled; - /// Pairs that could not reach . + /// Pairs still below the attempted spacing and invalid + /// work-area poses, including unresolved negative clearances when no + /// positive target was feasible. public List Violations = new(); } + /// Per-call test checkpoints; no process-global mutable hook. + internal enum Checkpoint + { + AfterScratchMove, + AfterSuccessfulProbe, + AfterPairPass, + BeforeApply, + } + + internal static Result ExpandObserved( + List selected, Plate plate, Options options, + Action observer, CancellationToken token = default + ) => ExpandCore(selected, plate, options, token, observer); + /// /// Raises the part-to-part spacing of the selection as far as the plate /// and its other parts allow, applying the best spacing found. Plate /// PartSpacing/EdgeSpacing are not modified; edges keep their own /// EdgeSpacing floor while only part-to-part clearance chases the target. - /// Mutates locations; probing never touches - /// them, so a failed or cancelled run leaves the layout unchanged. + /// Mutates locations only after the final + /// proposal passes validation; failed individual probes and cancellation + /// leave all live locations unchanged. A completed run can apply safe + /// partial separation and report any remaining violations. /// public static Result Expand( List selected, Plate plate, Options options = null, CancellationToken token = default + ) => ExpandCore(selected, plate, options, token, null); + + private static Result ExpandCore( + List selected, + Plate plate, + Options options, + CancellationToken token, + Action observer ) { if (plate == null) @@ -100,11 +128,29 @@ namespace OpenNest.Engine.Fill ); var opt = options ?? new Options(); - var context = SeparationContext.Prepare(selected, plate); - var entry = context.Positions(); // all parts; movers are [0, count) var result = new Result(); + SeparationContext context; + try + { + context = SeparationContext.Prepare(selected, plate, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } + var entry = context.Positions(); // all parts; movers are [0, count) - var sMin = MinimumPairClearance(context, entry); + double sMin; + try + { + sMin = MinimumPairClearance(context, entry, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } result.AchievedSpacing = System.Math.Max(0, sMin); if (token.IsCancellationRequested) @@ -119,7 +165,9 @@ namespace OpenNest.Engine.Fill (bool Converged, List Positions) Probe(double spacing) { - var attempt = Separate(context, entry, spacing, opt.MaxIterations, token); + var attempt = Separate(context, entry, spacing, opt.MaxIterations, token, observer); + if (attempt.Converged) + observer?.Invoke(Checkpoint.AfterSuccessfulProbe); return (attempt.Converged, attempt.Positions); } @@ -149,6 +197,11 @@ namespace OpenNest.Engine.Fill } var probe = Probe(s); + if (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } if (probe.Converged) { @@ -177,7 +230,13 @@ namespace OpenNest.Engine.Fill } var mid = (lo + hi) / 2; - if (Probe(mid).Converged) + var midProbe = Probe(mid); + if (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } + if (midProbe.Converged) lo = mid; else hi = mid; @@ -189,8 +248,9 @@ namespace OpenNest.Engine.Fill // Deterministic, so this reproduces any committed probe exactly; when // sGood was never probed (overlapping entry) it still performs the // best-effort cleanup and yields the violation report. - var final = Separate(context, entry, System.Math.Max(0, sGood), opt.MaxIterations, token); + var final = Separate(context, entry, System.Math.Max(0, sGood), opt.MaxIterations, token, observer); + observer?.Invoke(Checkpoint.BeforeApply); if (token.IsCancellationRequested) { result.Cancelled = true; @@ -198,11 +258,31 @@ namespace OpenNest.Engine.Fill } var positions = final.Positions; + // Floors belong to this public operation, not to any individual probe. + // A failed final check leaves every live part untouched. + if (!context.MeetsEntryFloors(positions, entry)) + throw new InvalidOperationException("Expand proposal violates an entry clearance floor."); + double measured; + try + { + measured = MinimumPairClearance(context, positions, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } + if (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } Apply(selected, entry, positions); - var measured = MinimumPairClearance(context, positions); - result.AchievedSpacing = System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured)); + result.AchievedSpacing = final.Violations.Any(v => v.B == null) + ? 0 + : System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured)); result.Violations = final.Violations; return result; } @@ -220,9 +300,26 @@ namespace OpenNest.Engine.Fill CancellationToken token = default ) { - var context = SeparationContext.Prepare(selected, plate); + SeparationContext context; + try + { + context = SeparationContext.Prepare(selected, plate, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return (false, selected.Select(p => p.Location).ToList(), new List()); + } var positions = context.Positions(); - return Separate(context, positions, spacing, maxIterations, token); + return Separate(context, positions, spacing, maxIterations, token, null); + } + + internal static (bool Converged, List Positions, List Violations) SeparateObserved( + List selected, Plate plate, double spacing, + Action observer, int maxIterations = 100, + CancellationToken token = default) + { + var context = SeparationContext.Prepare(selected, plate, token); + return Separate(context, context.Positions(), spacing, maxIterations, token, observer); } private static void Apply(List selected, List from, List to) @@ -236,12 +333,14 @@ namespace OpenNest.Engine.Fill } } - private static double MinimumPairClearance(SeparationContext context, List positions) + private static double MinimumPairClearance( + SeparationContext context, List positions, CancellationToken token) { var min = double.MaxValue; foreach (var pair in context.Pairs) { + token.ThrowIfCancellationRequested(); var (distance, _) = context.PairClearance(pair.IndexA, pair.IndexB, positions); if (distance < min) min = distance; @@ -255,11 +354,11 @@ namespace OpenNest.Engine.Fill List start, double spacing, int maxIterations, - CancellationToken token + CancellationToken token, + Action observer ) { var positions = new List(start); - var stuck = new HashSet(); var violations = new List(); var epsMove = 1e-4; @@ -274,14 +373,14 @@ namespace OpenNest.Engine.Fill if (token.IsCancellationRequested) return (false, start, violations); + // Retry every pair on the next pass if another blocker moved; + // a complete pass without progress stops genuinely pinned layouts. var moved = 0.0; - var stuckChanged = false; foreach (var pair in context.Pairs) { - if (stuck.Contains(pair.Id)) - continue; - + if (token.IsCancellationRequested) + return (false, start, violations); var (distance, directionA) = context.PairClearance( pair.IndexA, pair.IndexB, @@ -310,60 +409,25 @@ namespace OpenNest.Engine.Fill // the B side travels the opposite way. var direction = moverIndex == pair.IndexA ? directionA : -directionA; - var room = context.ClipToWorkArea( - moverIndex, - positions[moverIndex], - direction, - need - ); - - // Take the largest valid step up to `room`: partial moves let - // a blocked mover advance again once its own blockers move - // away in later iterations (a wave separates a chain). - var applied = 0.0; - var blockedByEdge = false; - - if (room > 0) + // A zero-distance hole contact can have an ambiguous nearest- + // boundary fallback. Only if the normal candidate makes no + // safe progress, retry its opposite through the same path, + // floor and active-improvement checks. Never move the anchor. + var attempt = ProposeMove(context, positions, pair, distance, direction, need, epsMove); + if (attempt.Applied <= 0 && distance <= 1e-7) { - var trial = positions[moverIndex] + direction * room; - - if (context.MaintainsValidity(moverIndex, trial, positions)) - { - positions[moverIndex] = trial; - applied = room; - } - else - { - var lo = 0.0; - var hi2 = room; - for (var bisect = 0; bisect < 24 && hi2 - lo > 1e-6; bisect++) - { - var mid = (lo + hi2) / 2; - if ( - context.MaintainsValidity( - moverIndex, - positions[moverIndex] + direction * mid, - positions - ) - ) - lo = mid; - else - hi2 = mid; - } - - if (lo > epsMove) - { - positions[moverIndex] = positions[moverIndex] + direction * lo; - applied = lo; - } - } - - blockedByEdge = applied < need - epsMove; - moved += applied; + var opposite = ProposeMove(context, positions, pair, distance, -direction, need, epsMove); + if (opposite.Applied > attempt.Applied) + attempt = opposite; } - else + var applied = attempt.Applied; + if (applied > 0) { - blockedByEdge = true; + positions[moverIndex] = attempt.Proposal; + moved += applied; + observer?.Invoke(Checkpoint.AfterScratchMove); + if (token.IsCancellationRequested) + return (false, start, violations); } // A pair fully separated to the user target (the internal margin @@ -382,16 +446,14 @@ namespace OpenNest.Engine.Fill continue; } - // Zero progress twice in a row parks the pair; the final sweep - // re-measures everything, so mid-loop bookkeeping never lies. - if (stuck.Contains(pair.Id)) - continue; - - stuck.Add(pair.Id); - stuckChanged = true; + // No progress now; a later part may unblock this pair on + // the next pass. The final sweep remeasures all pairs. } - if (moved < epsMove && !stuckChanged) + observer?.Invoke(Checkpoint.AfterPairPass); + if (token.IsCancellationRequested) + return (false, start, violations); + if (moved < epsMove) break; } @@ -403,7 +465,9 @@ namespace OpenNest.Engine.Fill violations.Clear(); foreach (var pair in context.Pairs) { - var (distance, _) = context.PairClearance( + if (token.IsCancellationRequested) + return (false, start, violations); + var (distance, directionA) = context.PairClearance( pair.IndexA, pair.IndexB, positions @@ -412,27 +476,74 @@ namespace OpenNest.Engine.Fill if (distance >= spacing) continue; + var direction = pair.Mover == pair.IndexA ? directionA : -directionA; + var need = target - distance; + var attempt = ProposeMove(context, positions, pair, distance, direction, need, epsMove); + if (attempt.Applied <= 0 && distance <= 1e-7) + { + var opposite = ProposeMove(context, positions, pair, distance, -direction, need, epsMove); + if (opposite.Applied > attempt.Applied) + attempt = opposite; + } + // Only an actual first work-area limit is an edge blocker. + // An available move at the iteration budget, or a part/floor + // limiting first, is not evidence of an edge obstruction. + var blockedByEdge = attempt.EdgeLimited && !attempt.PartLimited + && attempt.Applied < need - epsMove; violations.Add( new Violation { A = context.PartOf(pair.IndexA), B = context.PartOf(pair.IndexB), Achieved = distance, - BlockedByEdge = stuck.Contains(pair.Id), + BlockedByEdge = blockedByEdge, } ); } + violations.AddRange(context.WorkAreaViolations(positions)); return (violations.Count == 0, positions, violations); } + private static (double Applied, Vector Proposal, bool EdgeLimited, bool PartLimited) ProposeMove( + SeparationContext context, List positions, Pair pair, + double distance, Vector direction, double need, double epsMove) + { + var mover = pair.Mover; + var start = positions[mover]; + var edgeRoom = context.ClipToWorkArea(mover, start, direction, need); + var room = context.ClipToMaterial(mover, positions, direction, edgeRoom); + var edgeLimited = edgeRoom < need - epsMove; + var partLimited = room < edgeRoom - epsMove; + if (room <= epsMove) + return (0, start, edgeLimited, partLimited); + + var trial = start + direction * room; + if (context.MaintainsValidity(mover, trial, positions, pair, distance)) + return (room, trial, edgeLimited, partLimited); + + var lo = 0.0; + var hi = room; + for (var bisect = 0; bisect < 24 && hi - lo > 1e-6; bisect++) + { + var mid = (lo + hi) / 2; + if (context.MaintainsValidity(mover, start + direction * mid, positions, pair, distance)) + lo = mid; + else + hi = mid; + } + return lo > epsMove + ? (lo, start + direction * lo, edgeLimited, true) + : (0, start, edgeLimited, true); + } + /// One constrained part↔part pair with its anchor mover. private sealed class Pair { - public int Id; public int IndexA; public int IndexB; public int Mover; + public double EntryFloor; } /// @@ -446,6 +557,8 @@ namespace OpenNest.Engine.Fill private readonly List shapes; // per part private readonly Box[] localBoxes; // per part, local frame private readonly int moverCount; + private readonly int clampStart; // synthetic walls, after actual plate parts + private readonly IReadOnlyList clampBoxes; // exact placement-frame keep-outs private readonly Box workArea; public readonly List Pairs = new(); @@ -456,6 +569,11 @@ namespace OpenNest.Engine.Fill public Polygon Outer; public List Rings = new(); // outer + cutout rings, local frame public List Holes = new(); + public List Boundaries = new(); // native outer + hole loops, local frame + public SlideContactGeometry Contact; + public Box NativeBounds; // perimeter's native-entity envelope + public ShapeProfile MaterialProfile; // immutable local native loops + public bool HasCurves; } private SeparationContext( @@ -463,6 +581,8 @@ namespace OpenNest.Engine.Fill List shapes, Box[] localBoxes, int moverCount, + int clampStart, + IReadOnlyList clampBoxes, Box workArea, double spacingCap ) @@ -471,14 +591,23 @@ namespace OpenNest.Engine.Fill this.shapes = shapes; this.localBoxes = localBoxes; this.moverCount = moverCount; + this.clampStart = clampStart; + this.clampBoxes = clampBoxes; this.workArea = workArea; SpacingCap = spacingCap; } - public static SeparationContext Prepare(List selected, Plate plate) + public static SeparationContext Prepare(List selected, Plate plate, CancellationToken token) { + token.ThrowIfCancellationRequested(); var movers = new List(selected); var walls = plate.Parts.Where(p => !movers.Contains(p)).ToList(); + var clampStart = movers.Count + walls.Count; + // Clamp walls include the bottom-edge clearance envelope. + var clampBoxes = Jobs.InteractiveClamps.ClearanceRectangles(plate); + walls.AddRange(Jobs.InteractiveClamps.WallParts(plate)); + if (clampBoxes.Count != walls.Count + movers.Count - clampStart) + throw new InvalidOperationException("Clamp wall preparation changed its ordering."); var parts = new List(movers.Count + walls.Count); parts.AddRange(movers); @@ -492,6 +621,7 @@ namespace OpenNest.Engine.Fill for (var i = 0; i < parts.Count; i++) { + token.ThrowIfCancellationRequested(); shapes.Add(PrepareProgram(programs, parts[i].Program)); localBoxes[i] = LocalBox(parts[i]); } @@ -506,10 +636,18 @@ namespace OpenNest.Engine.Fill shapes, localBoxes, movers.Count, + clampStart, + clampBoxes, workArea, spacingCap ); - context.BuildPairs(); + context.BuildPairs(token); + var entry = context.Positions(); + foreach (var pair in context.Pairs) + { + token.ThrowIfCancellationRequested(); + pair.EntryFloor = context.PairClearance(pair.IndexA, pair.IndexB, entry).Distance; + } return context; } @@ -530,18 +668,31 @@ namespace OpenNest.Engine.Fill if (entities.Count > 0) { var profile = new ShapeProfile(entities); + prepared.MaterialProfile = profile; if (profile.Perimeter != null) { + prepared.NativeBounds = profile.Perimeter.BoundingBox; prepared.Outer = profile.Perimeter.ToPolygonWithTolerance(0.001); + // Polygon.Cleanup compares vertices at geometry epsilon and + // can erase an accepted sub-grid clamp. Preserve exact line + // endpoints instead of treating its material as empty. + if ((profile.Perimeter.BoundingBox.Length < KeepOutOverlap.ToleranceFreeBelow + || profile.Perimeter.BoundingBox.Width < KeepOutOverlap.ToleranceFreeBelow) + && profile.Perimeter.Entities.All(e => e is Line)) + prepared.Outer = ExactLineRing(profile.Perimeter.Entities); prepared.Rings.Add(prepared.Outer); + prepared.Boundaries.AddRange(profile.Perimeter.Entities); foreach (var cutout in profile.Cutouts) { var hole = cutout.ToPolygonWithTolerance(0.001); prepared.Rings.Add(hole); prepared.Holes.Add(hole); + prepared.Boundaries.AddRange(cutout.Entities); } + prepared.Contact = SlideContactGeometry.Prepare(prepared.Boundaries); + prepared.HasCurves = prepared.Boundaries.Any(e => e is Arc || e is Circle); } } @@ -549,6 +700,17 @@ namespace OpenNest.Engine.Fill return prepared; } + private static Polygon ExactLineRing(List entities) + { + var polygon = new Polygon(); + foreach (var entity in entities) + polygon.Vertices.Add(((Line)entity).StartPoint); + if (polygon.Vertices.Count > 0) + polygon.Vertices.Add(polygon.Vertices[0]); + polygon.UpdateBounds(); + return polygon; + } + private static Box LocalBox(Part part) { var box = part.BoundingBox; @@ -560,12 +722,11 @@ namespace OpenNest.Engine.Fill ); } - private void BuildPairs() + private void BuildPairs(CancellationToken token) { - var id = 0; - for (var a = 0; a < parts.Count; a++) { + token.ThrowIfCancellationRequested(); for (var b = a + 1; b < parts.Count; b++) { var aMover = a < moverCount; @@ -577,7 +738,6 @@ namespace OpenNest.Engine.Fill Pairs.Add( new Pair { - Id = id++, IndexA = a, IndexB = b, // Anchor policy: the later-index mover moves. @@ -611,13 +771,87 @@ namespace OpenNest.Engine.Fill { var setA = shapes[indexA]; var setB = shapes[indexB]; + var offsetA = positions[indexA]; + var offsetB = positions[indexB]; + + if (parts[indexB].BaseDrawing.IsCutOff) + { + // A regenerated cutoff is a set of open native segments. + // Polygon.Close would invent an endpoint chord and miss the + // interior witness that sets the immutable entry floor. + if (setB.Boundaries.Count == 0) + return (double.MaxValue, new Vector(1, 0)); + if (setA.Outer == null || setB.Boundaries.Any(e => e is not Line)) + return (-1e-4, new Vector(1, 0)); + var lines = CloneEntitiesAt(setB.Boundaries, offsetB).Cast().ToList(); + var worldOuter = CloneAt(setA.Outer, offsetA); + var worldHoles = setA.Holes.Count == 0 ? null : CloneAll(setA.Holes, offsetA); + if (OpenPathMaterial.Enters(worldOuter, worldHoles, lines)) + return (-1e-4, new Vector(1, 0)); + var cutoffBest = double.MaxValue; + var cutoffDirection = new Vector(1, 0); + foreach (var ring in setA.Rings) + { + var clearance = Clearance.BoundaryDistance(CloneAt(ring, offsetA).ToLines(), lines); + if (clearance.Distance < cutoffBest) + { + cutoffBest = clearance.Distance; + cutoffDirection = clearance.Direction; + } + } + if (setA.HasCurves && cutoffBest >= 0 && cutoffBest < 0.002) + return (-1e-4, cutoffDirection); + return (cutoffBest, cutoffDirection); + } + + if (indexB < clampStart && !parts[indexB].BaseDrawing.IsCutOff && + (setA.Contact?.IsResolved != true || setB.Contact?.IsResolved != true)) + { + // Unresolved or sub-scale material cannot be pronounced + // clear by a tolerant overlap kernel. Native envelopes + // intersecting at positive area yield an honest unresolved + // violation; disjoint envelopes may still use the usual + // boundary distance. Never infer a penetration depth here. + var boxA = setA.NativeBounds?.Translate(offsetA); + var boxB = setB.NativeBounds?.Translate(offsetB); + if (boxA == null || boxB == null || + !double.IsFinite(boxA.Left) || !double.IsFinite(boxB.Left) || + boxA.Right > boxB.Left && boxB.Right > boxA.Left && + boxA.Top > boxB.Bottom && boxB.Top > boxA.Bottom) + return (-1e-4, new Vector(1, 0)); + } + + if (setA.Holes.Count == 0 && setB.Holes.Count == 0 && + TryNativeDisk(setA, out var centerA, out var radiusA) && + TryNativeDisk(setB, out var centerB, out var radiusB)) + { + // Flattened polygons can miss a shallow arc overlap and + // report a positive boundary gap. Full disks admit an exact + // signed clearance and radial direction at every scratch pose. + var delta = centerA + offsetA - centerB - offsetB; + var length = System.Math.Sqrt(delta.DotProduct(delta)); + if (!double.IsFinite(length)) + return (0, new Vector(1, 0)); + return (length - radiusA - radiusB, + length > 1e-12 ? delta / length : new Vector(1, 0)); + } + + if (indexB >= clampStart && + KeepOutOverlap.NeedsToleranceFreeTest(clampBoxes[indexB - clampStart])) + { + // Tolerant polygon collision can miss a sub-grid zone even + // when it is wholly contained in part material. The native + // profile test is hole-aware and fail-closed. Its negative + // marker means unresolved overlap, not a measured depth. + var zone = clampBoxes[indexB - clampStart]; + if (setA.MaterialProfile?.Perimeter == null || + KeepOutOverlap.ProfileEnters(setA.MaterialProfile, offsetA, zone)) + return (-1e-4, new Vector(1, 0)); + } if (setA.Outer == null || setB.Outer == null) return (0, new Vector(1, 0)); - var offsetA = positions[indexA]; - var offsetB = positions[indexB]; - var outerA = CloneAt(setA.Outer, offsetA); var outerB = CloneAt(setB.Outer, offsetB); @@ -653,6 +887,12 @@ namespace OpenNest.Engine.Fill } } + // Each prepared arc can sit up to 0.001 outside its inscribed + // chord. A positive polygon gap inside the combined chord band + // is not proof of native clearance. Keep the input but report + // uncertainty as a violation rather than a false clear. + if ((setA.HasCurves || setB.HasCurves) && best >= 0 && best < 0.002) + return (-1e-4, bestDir); return (best, bestDir); } @@ -683,63 +923,384 @@ namespace OpenNest.Engine.Fill } /// - /// True when the part at stays inside the work - /// area and keeps no material overlap with any other part (Collision - /// oracle with hole subtraction, so part-in-cutout stays legal). + /// Stop at the first material-blocking contact along the whole straight + /// translation, including hole walls. Destination clearance alone can + /// accept a pose on the far side of a thin wall. /// - public bool MaintainsValidity(int index, Vector trial, List positions) + public double ClipToMaterial(int index, List positions, Vector direction, double room) { - var box = localBoxes[index]; - var movedBox = box.Translate(trial); - - if ( - movedBox.Left < workArea.Left - 1e-9 - || movedBox.Right > workArea.Right + 1e-9 - || movedBox.Bottom < workArea.Bottom - 1e-9 - || movedBox.Top > workArea.Top + 1e-9 - ) - return false; - - var outer = shapes[index].Outer; - if (outer == null) - return true; - - var worldOuter = CloneAt(outer, trial); - var worldHoles = shapes[index].Holes.Count == 0 - ? null - : CloneAll(shapes[index].Holes, trial); + if (room <= 0 || !double.IsFinite(room) + || !double.IsFinite(direction.X) || !double.IsFinite(direction.Y)) + return 0; + var moving = shapes[index]; + if (moving.Contact?.IsResolved != true || moving.Outer == null) + return 0; for (var i = 0; i < parts.Count; i++) { if (i == index) continue; - - var otherOuter = shapes[i].Outer; - if (otherOuter == null) + var stationary = shapes[i]; + if (parts[i].BaseDrawing.IsCutOff) + { + // Regenerated cut-offs are intentional open cut paths, + // not malformed closed material. The unresolved contact + // classifier blocks their first native line contact; + // remote paths with no events do not pin every mover. + // Never treat an unknown/nonlinear cutoff as empty space. + if (stationary.Boundaries.Count == 0) + continue; + if (stationary.Boundaries.Any(e => e is not Line)) + return 0; + foreach (var entity in stationary.Boundaries) + { + var line = (Line)entity; + if (!double.IsFinite(line.StartPoint.X) || !double.IsFinite(line.StartPoint.Y) + || !double.IsFinite(line.EndPoint.X) || !double.IsFinite(line.EndPoint.Y) + || line.StartPoint.X == line.EndPoint.X && line.StartPoint.Y == line.EndPoint.Y) + return 0; + } + // A segment box can overlap a concave/oblique part box + // while its actual open path remains far away. Use the + // same strict material-entry verdict as entry floors + // and final reporting, not the box as a blocker. + if (PairClearance(index, i, positions).Distance < 0) + return 0; + var hit = SpatialQuery.DirectionalDistance( + CloneEntitiesAt(moving.Boundaries, positions[index]), + CloneEntitiesAt(stationary.Boundaries, positions[i]), direction, + new SlideContactClassifier(moving.Contact, positions[index], + stationary.Contact, positions[i])); + if (double.IsNaN(hit) || hit < 0) + return 0; + if (hit < double.MaxValue) + room = System.Math.Min(room, hit); continue; - - if (!BoxOverlap(movedBox, localBoxes[i].Translate(positions[i]), 0.002)) + } + if (i >= clampStart && KeepOutOverlap.NeedsToleranceFreeTest(clampBoxes[i - clampStart])) + { + // Slide contact and polygon overlap have tolerances larger + // than a tiny keep-out. Clip at the earliest possible + // native-bound contact; if bounds overlap already, refuse + // uncertain motion instead of crossing the physical zone. + room = System.Math.Min(room, ClipToNativeBox( + moving, positions[index], direction, room, clampBoxes[i - clampStart])); + if (room <= 0) + return 0; continue; + } + if (stationary.Contact?.IsResolved != true || stationary.Outer == null) + return 0; // Unknown boundary is not empty space. - var worldOther = CloneAt(otherOuter, positions[i]); - var worldOtherHoles = shapes[i].Holes.Count == 0 - ? null - : CloneAll(shapes[i].Holes, positions[i]); + var origin = positions[index]; + var alreadyOverlapping = Collision.HasOverlap( + CloneAt(moving.Outer, origin), CloneAt(stationary.Outer, positions[i]), + moving.Holes.Count == 0 ? null : CloneAll(moving.Holes, origin), + stationary.Holes.Count == 0 ? null : CloneAll(stationary.Holes, positions[i])); + if (!alreadyOverlapping && moving.Holes.Count == 0 && stationary.Holes.Count == 0 && + TryNativeDisk(moving, out var mc, out var mr) && + TryNativeDisk(stationary, out var sc, out var sr)) + { + var delta = mc + origin - sc - positions[i]; + var distanceSquared = delta.DotProduct(delta); + var contactSquared = (mr + sr) * (mr + sr); + if (!double.IsFinite(distanceSquared) || !double.IsFinite(contactSquared)) + return 0; + alreadyOverlapping = distanceSquared < contactSquared; + } + if (alreadyOverlapping) + { + // An initially overlapping convex pair needs a direct + // separating-path certificate; the normal slide query + // is only for initially clear geometry. A certified + // prefix may still overlap when the edge clips its exit. + // Convex material cannot re-enter along the certified + // separating ray after its single exit. + if (!TryConvexDirectExit(moving, stationary, origin, positions[i], direction, out _)) + return 0; + continue; + } - if ( - Collision.HasOverlap( - worldOuter, - worldOther, - worldHoles, - worldOtherHoles - ) - ) + var query = SpatialQuery.DirectionalDistance( + CloneEntitiesAt(moving.Boundaries, origin), + CloneEntitiesAt(stationary.Boundaries, positions[i]), + direction, + new SlideContactClassifier(moving.Contact, origin, stationary.Contact, positions[i])); + if (double.IsNaN(query) || query < 0) + return 0; + if (query < double.MaxValue) + room = System.Math.Min(room, query); + } + return room; + } + + private static double ClipToNativeBox( + RingSet mover, Vector start, Vector direction, double room, Box obstacle) + { + var bounds = mover.NativeBounds; + if (bounds == null || !double.IsFinite(bounds.Left) || !double.IsFinite(bounds.Right) + || !double.IsFinite(bounds.Bottom) || !double.IsFinite(bounds.Top) + || !double.IsFinite(obstacle.Left) || !double.IsFinite(obstacle.Right) + || !double.IsFinite(obstacle.Bottom) || !double.IsFinite(obstacle.Top)) + return 0; + var from = bounds.Translate(start); + var enter = double.NegativeInfinity; + var leave = double.PositiveInfinity; + if (!Slab(from.Left, from.Right, obstacle.Left, obstacle.Right, + direction.X, ref enter, ref leave) + || !Slab(from.Bottom, from.Top, obstacle.Bottom, obstacle.Top, + direction.Y, ref enter, ref leave) + || leave <= 0 || enter >= room) + return room; + return System.Math.Max(0, enter); + } + + private static bool Slab(double low, double high, double wallLow, double wallHigh, + double speed, ref double enter, ref double leave) + { + if (speed == 0) + return high > wallLow && low < wallHigh; + var first = (wallLow - high) / speed; + var last = (wallHigh - low) / speed; + enter = System.Math.Max(enter, System.Math.Min(first, last)); + leave = System.Math.Min(leave, System.Math.Max(first, last)); + return enter < leave; + } + + private static List CloneEntitiesAt(List entities, Vector position) + { + var result = new List(entities.Count); + foreach (var entity in entities) + { + var copy = entity.Clone(); + copy.Offset(position); + result.Add(copy); + } + return result; + } + + private static bool TryConvexDirectExit( + RingSet moving, RingSet stationary, Vector movePos, Vector fixedPos, + Vector direction, out double exit) + { + exit = 0; + if (moving.Holes.Count != 0 || stationary.Holes.Count != 0) + return false; + + if (TryNativeDisk(moving, out var movingCenter, out var movingRadius) && + TryNativeDisk(stationary, out var fixedCenter, out var fixedRadius)) + { + // Full circles are convex. The relative centre distance + // must increase from the start; its ray intersects the + // summed-radius disk at most once in forward travel. + var relative = movingCenter + movePos - fixedCenter - fixedPos; + var approach = relative.DotProduct(direction); + var radius = movingRadius + fixedRadius; + var discriminant = approach * approach + radius * radius + - relative.DotProduct(relative); + if (!double.IsFinite(approach) || approach < -1e-8 + || !double.IsFinite(discriminant) || discriminant <= 0) return false; + exit = -approach + System.Math.Sqrt(discriminant); + return double.IsFinite(exit) && exit > 0; } + if (moving.Boundaries.Count == 0 || stationary.Boundaries.Count == 0 + || moving.Boundaries.Any(e => e is not Line) + || stationary.Boundaries.Any(e => e is not Line) + || !IsConvex(moving.Outer) || !IsConvex(stationary.Outer)) + return false; + + // The ray must point along an actual separating support normal. + // It must be the nearer exit, not the direction that burrows + // through the other convex part before reaching its far side. + var normal = false; + foreach (var ring in new[] { moving.Outer, stationary.Outer }) + foreach (var line in ring.ToLines()) + { + var edge = line.pt2 - line.pt1; + var length = System.Math.Sqrt(edge.DotProduct(edge)); + if (length > 1e-8 && + System.Math.Abs((edge.X * direction.X + edge.Y * direction.Y) / length) < 1e-7) + normal = true; + } + if (!normal) + return false; + + var (minA, maxA) = Project(moving.Outer, movePos, direction); + var (minB, maxB) = Project(stationary.Outer, fixedPos, direction); + if (!double.IsFinite(minA) || !double.IsFinite(maxB) + || maxB - minA > maxA - minB + 1e-7) + return false; + exit = System.Math.Max(0, maxB - minA); + return double.IsFinite(exit); + } + + private static bool TryNativeDisk(RingSet shape, out Vector center, out double radius) + { + center = Vector.Zero; + radius = 0; + var boundaries = shape.Boundaries; + if (boundaries.Count == 1 && boundaries[0] is Circle circle) + { + center = circle.Center; + radius = circle.Radius; + } + else if (boundaries.Count > 0 && boundaries[0] is Arc first) + { + center = first.Center; + radius = first.Radius; + var reversed = first.IsReversed; + var sweep = 0.0; + foreach (var entity in boundaries) + { + if (entity is not Arc arc || arc.Center.X != center.X + || arc.Center.Y != center.Y || arc.Radius != radius + || arc.IsReversed != reversed) + return false; + sweep += arc.SweepAngle(); + } + if (System.Math.Abs(sweep - 2 * System.Math.PI) > 1e-7) + return false; + } + else + return false; + + return double.IsFinite(center.X) && double.IsFinite(center.Y) + && double.IsFinite(radius) && radius > 0; + } + + private static (double Min, double Max) Project(Polygon polygon, Vector origin, Vector axis) + { + var min = double.PositiveInfinity; + var max = double.NegativeInfinity; + foreach (var point in polygon.Vertices) + { + var projection = (point + origin).DotProduct(axis); + min = System.Math.Min(min, projection); + max = System.Math.Max(max, projection); + } + return (min, max); + } + + private static bool IsConvex(Polygon polygon) + { + var points = polygon.Vertices; + if (points.Count < 4) + return false; + var sign = 0; + for (var i = 0; i < points.Count - 1; i++) + { + var a = points[i]; + var b = points[(i + 1) % (points.Count - 1)]; + var c = points[(i + 2) % (points.Count - 1)]; + var cross = (b.X - a.X) * (c.Y - b.Y) - (b.Y - a.Y) * (c.X - b.X); + if (!double.IsFinite(cross)) + return false; + if (System.Math.Abs(cross) < 1e-8) + continue; + var next = System.Math.Sign(cross); + if (sign != 0 && sign != next) + return false; + sign = next; + } + return sign != 0; + } + + public List WorkAreaViolations(List positions) + { + var violations = new List(); + for (var i = 0; i < moverCount; i++) + { + if (InsideWorkArea(i, positions[i])) + continue; + var box = localBoxes[i].Translate(positions[i]); + var overshoot = System.Math.Max( + System.Math.Max(workArea.Left - box.Left, box.Right - workArea.Right), + System.Math.Max(workArea.Bottom - box.Bottom, box.Top - workArea.Top)); + violations.Add(new Violation + { + A = parts[i], + B = null, + Achieved = double.IsFinite(overshoot) ? -System.Math.Max(0, overshoot) : -1e-4, + BlockedByEdge = true, + }); + } + return violations; + } + + // Smaller than the 0.002 tessellation margin; immutable entry floors + // prevent this comparison tolerance accumulating over iterations. + private const double FloorTolerance = 1e-6; + + public bool MeetsEntryFloors(List positions, List entry) + { + for (var i = 0; i < moverCount; i++) + if (!InsideWorkArea(i, positions[i]) && + (positions[i].X != entry[i].X || positions[i].Y != entry[i].Y)) + return false; + foreach (var pair in Pairs) + { + var clearance = PairClearance(pair.IndexA, pair.IndexB, positions).Distance; + if (!double.IsFinite(clearance) || clearance < pair.EntryFloor - FloorTolerance) + return false; + } return true; } + private bool InsideWorkArea(int index, Vector position) + { + var box = localBoxes[index].Translate(position); + return double.IsFinite(box.Left) && double.IsFinite(box.Right) + && double.IsFinite(box.Bottom) && double.IsFinite(box.Top) + && double.IsFinite(workArea.Left) && double.IsFinite(workArea.Right) + && double.IsFinite(workArea.Bottom) && double.IsFinite(workArea.Top) + && box.Left >= workArea.Left - 1e-9 + && box.Right <= workArea.Right + 1e-9 + && box.Bottom >= workArea.Bottom - 1e-9 + && box.Top <= workArea.Top + 1e-9; + } + + /// Check active improvement and every affected pair against + /// its immutable operation-entry floor on scratch positions. + public bool MaintainsValidity( + int index, Vector trial, List positions, Pair active, double activeDistance) + { + if (!InsideWorkArea(index, trial)) + return false; + + var current = positions[index]; + positions[index] = trial; + try + { + var improved = PairClearance(active.IndexA, active.IndexB, positions).Distance; + if (!double.IsFinite(improved) || improved <= activeDistance + 1e-7) + return false; + foreach (var pair in Pairs) + { + if (pair.IndexA != index && pair.IndexB != index) + continue; + var clearance = PairClearance(pair.IndexA, pair.IndexB, positions).Distance; + if (!double.IsFinite(clearance) || clearance < pair.EntryFloor - FloorTolerance) + return false; + // Once an initial overlap is cleared, never reintroduce it. + if (clearance < 0) + { + positions[index] = current; + var before = PairClearance(pair.IndexA, pair.IndexB, positions).Distance; + positions[index] = trial; + if (before >= 0) + return false; + } + } + return true; + } + finally + { + positions[index] = current; + } + } + private static List CloneAll(List polygons, Vector offset) { var list = new List(polygons.Count); diff --git a/OpenNest.Engine/Fill/RemnantFiller.cs b/OpenNest.Engine/Fill/RemnantFiller.cs index 22b18aa..8b123ff 100644 --- a/OpenNest.Engine/Fill/RemnantFiller.cs +++ b/OpenNest.Engine/Fill/RemnantFiller.cs @@ -15,10 +15,18 @@ namespace OpenNest.Engine.Fill private readonly RemnantFinder finder; private readonly double spacing; - public RemnantFiller(Box workArea, double spacing) + /// + /// are kept-out rectangles (e.g. inflated clamp + /// zones) treated like occupied space: they are excluded from the remnant areas + /// every fill attempt sees. Null keeps today's behaviour exactly. + /// + public RemnantFiller(Box workArea, double spacing, IEnumerable boxObstacles = null) { this.spacing = spacing; finder = new RemnantFinder(workArea); + if (boxObstacles != null) + foreach (var box in boxObstacles) + finder.AddObstacle(box); } public void AddObstacles(IEnumerable parts) diff --git a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs index 0cb8260..182e629 100644 --- a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs +++ b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using OpenNest.CNC; namespace OpenNest.Engine.Jobs.Adapters; @@ -54,8 +55,11 @@ public static class DrawingJobMapper quantity, plate.PartSpacing, plate.EdgeSpacing, - plate.Quadrant - ); + plate.Quadrant, + cost: null, + clampZones: new List(plate.ClampZones) + ) + { ClampRail = plate.ClampRail }; } public static Program ToProgram(PartGeometrySnapshot geometry) @@ -104,12 +108,19 @@ public static class DrawingJobMapper internal static double LegacyStep(RotationPolicy policy) => policy.Kind == RotationPolicyKind.Fixed ? OpenNest.Math.Angle.TwoPI : policy.Step; - internal static Plate CreatePlate(NestPlateStock stock) => - new(stock.Size) + internal static Plate CreatePlate(NestPlateStock stock) + { + var plate = new Plate(stock.Size) { Quantity = 1, PartSpacing = stock.PartSpacing, EdgeSpacing = stock.EdgeSpacing, Quadrant = stock.Quadrant, }; + // Every Plate stores zones in plate (sheet lower-left) coordinates; consumers map + // them into the placement frame through ClampZoneFrame, exactly as the stock did. + foreach (var zone in stock.SheetClampZones) + plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds)); + return plate; + } } diff --git a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs new file mode 100644 index 0000000..d5194ca --- /dev/null +++ b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace OpenNest.Engine.Jobs; + +/// +/// D8/D9 last-resort clamp removal for the whole-job pipeline. After a normal clamped solve +/// leaves demand unmet, finds parts that cannot fit anywhere with every clamp in place (C10), +/// computes the fewest clamps whose removal lets each one fit (C11), and re-solves the +/// residual demand on stock variants with those clamps taken off (C12). Never changes the +/// result when nothing is blocked (C10's bit-identical guarantee) and never trades a removal +/// for material savings - it only runs after the ordinary solve could not place a part. +/// +internal static class ClampRemovalRecovery +{ + /// A result that replaces the caller's raw solve, and the expanded job it was + /// validated against (the caller's own stock plus the reduced-clamp variants used). + internal sealed record Outcome(NestJob Job, NestJobResult Result); + + /// + /// Most engine solves one recovery may spend on feasibility probes, independent of job, + /// stock and rail size. Running out abandons the recovery and keeps the ordinary result + /// (no clamp is removed on an unfinished search). + /// + internal const int MaxProbeSolves = 1000; + + /// + /// Null when there is nothing to recover: the job is already complete, no offered stock + /// has any clamp, no remaining part is genuinely clamp-blocked, or removing clamps did + /// not let anything new fit. Callers keep their original job/result unchanged in every + /// one of those cases (C10). + /// + internal static Outcome TryRecover( + NestJob job, + NestJobResult raw, + INestingEngine engine, + CancellationToken token + ) + { + ArgumentNullException.ThrowIfNull(job); + ArgumentNullException.ThrowIfNull(raw); + ArgumentNullException.ThrowIfNull(engine); + + // Recovery is optional: any failure inside it (a throwing probe or residual solve) + // keeps the caller's ordinary result instead of discarding it. Cancellation still + // propagates. + try + { + return Recover(job, raw, engine, token); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + token.ThrowIfCancellationRequested(); + return null; + } + } + + private static Outcome Recover(NestJob job, NestJobResult raw, INestingEngine engine, CancellationToken token) + { + if (raw.Status == NestJobStatus.Complete) + return null; + + var availableStock = job.Plates.Where(s => s.Quantity != 0).ToList(); + if (!availableStock.Any(CanRemoveClamps)) + return null; + + var partsById = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal); + var remaining = raw.Fulfillment + .Where(f => f.Unplaced > 0) + .Select(f => (Part: partsById[f.PartId], Unplaced: f.Unplaced)) + .ToList(); + if (remaining.Count == 0) + return null; + + token.ThrowIfCancellationRequested(); + + // C10: only a part that fits nowhere with every clamp in place is "blocked". One + // lane's failure is not proof: a part any built-in lane can place clamped is not. + var lanes = FeasibilityLanes(engine); + var budget = new ProbeBudget(MaxProbeSolves); + var blocked = remaining + .Where(x => !lanes.Any(lane => FitsAnywhere(x.Part, availableStock, job.Options, lane, budget, token))) + .Select(x => x.Part) + .ToList(); + if (blocked.Count == 0) + return null; + + // Phase 2 state. Each kept sheet comes out of its base stock's remaining physical + // pool and the plate cap (C12: a finite pool is never exceeded). + var unmet = remaining.ToDictionary(x => x.Part.Id, x => x.Unplaced, StringComparer.Ordinal); + var blockedIds = new HashSet(blocked.Select(p => p.Id), StringComparer.Ordinal); + var pool = availableStock.ToDictionary( + s => s.Id, + s => s.Quantity is int q ? q - raw.StockUsage.Where(u => u.StockId == s.Id).Sum(u => u.Used) : (int?)null, + StringComparer.Ordinal); + int? platesLeft = job.Options.MaxPlates is int cap ? cap - raw.Plates.Count : null; + var tried = blocked.ToDictionary(p => p.Id, _ => new HashSet(StringComparer.Ordinal), StringComparer.Ordinal); + var recovered = new List<(NestPlateStock Variant, string BaseId, List Sheets)>(); + + // Rounds: each blocked part with demand left takes its best removal among the offered + // stocks it has not tried that still have sheets, so a part whose preferred stock runs + // out moves on to another stock (with that stock's own minimal removal). + for (var round = 0; round < availableStock.Count && platesLeft is not <= 0; round++) + { + // C11: per blocked part, the fewest clamps (any set, nearest the origin on ties) + // whose removal frees an empty sheet of one of those stocks. + // Parts needing the same removal on the same stock share one variant. + var groups = new List(); + foreach (var part in blocked) + { + token.ThrowIfCancellationRequested(); + if (unmet[part.Id] <= 0) + continue; + var usable = availableStock + .Where(s => pool[s.Id] is not <= 0 && !tried[part.Id].Contains(s.Id)) + .ToList(); + if (usable.Count == 0) + continue; + var best = BestRemoval(part, usable, job.Options, lanes, budget, token); + if (best == null) + { + // Unfit on all of them even with every clamp removed: stays unplaced. + tried[part.Id].UnionWith(usable.Select(s => s.Id)); + continue; + } + tried[part.Id].Add(best.BaseStockId); + var group = groups.FirstOrDefault(g => g.Removal.Stock.Id == best.Stock.Id); + if (group == null) + groups.Add(group = new RemovalGroup(best)); + group.BlockedIds.Add(part.Id); + } + if (groups.Count == 0) + break; + + // One removal variant at a time, fewest removed clamps first: each variant takes + // its own blocked parts plus any other unmet demand that is not blocked (freed + // space may take other parts, C12). A sheet is kept only when it holds a part + // that needed exactly this removal (C10/C11 per sheet). + foreach (var group in groups.OrderBy(g => g.Removal.RemovedIds.Count)) + { + token.ThrowIfCancellationRequested(); + var baseId = group.Removal.BaseStockId; + var available = pool[baseId]; + if (available <= 0 || platesLeft <= 0) + continue; + int? sheetCap = (available, platesLeft) switch + { + (int a, int p) => System.Math.Min(a, p), + (int a, null) => a, + (null, int p) => p, + _ => null, + }; + + var parts = remaining + .Where(x => unmet[x.Part.Id] > 0 + && (group.BlockedIds.Contains(x.Part.Id) || !blockedIds.Contains(x.Part.Id))) + .Select(x => new NestJobPart(x.Part.Id, x.Part.Geometry, unmet[x.Part.Id], x.Part.Priority, x.Part.Rotation)) + .ToList(); + if (!parts.Any(p => group.BlockedIds.Contains(p.Id))) + continue; + + var variant = Clone(group.Removal.Stock, group.Removal.Stock.Id, sheetCap, null); + var options = new NestJobOptions(job.Options.PlacementStrategy, platesLeft, + job.Options.SalvageRate, job.Options.MinimumSalvageDimension); + var groupJob = new NestJob(parts, new[] { variant }, options); + + List kept = null; + foreach (var lane in lanes) + { + token.ThrowIfCancellationRequested(); + var solved = lane.Solve(groupJob, null, token); + token.ThrowIfCancellationRequested(); + kept = solved?.Plates + .Where(sheet => sheet.Placements.Any(p => group.BlockedIds.Contains(p.PartId))) + .Take(sheetCap ?? int.MaxValue) + .ToList(); + if (kept is { Count: > 0 }) + break; + } + if (kept is not { Count: > 0 }) + continue; + + foreach (var placement in kept.SelectMany(sheet => sheet.Placements)) + unmet[placement.PartId]--; + if (pool[baseId] is int left) + pool[baseId] = left - kept.Count; + if (platesLeft is int plates) + platesLeft = plates - kept.Count; + recovered.Add((group.Removal.Stock, baseId, kept)); + } + } + if (recovered.Count == 0) + return null; + + return Merge(job, raw, recovered); + } + + private sealed class RemovalGroup(Removal removal) + { + public Removal Removal { get; } = removal; + public HashSet BlockedIds { get; } = new(StringComparer.Ordinal); + } + + /// + /// Engines whose placement of a lone part proves it fits (C10): the caller's own engine, + /// plus the built-in Irregular (NFP, true material) and Rectangles lanes. Default already + /// runs both, so it is enough on its own. Phase 2 tries them in this order too. + /// + private static IReadOnlyList FeasibilityLanes(INestingEngine engine) + { + if (engine is NestingEngines.Default.DefaultNestingEngine) + return new[] { engine }; + var lanes = new List { engine }; + if (engine is not NestingEngines.Irregular.IrregularNestingEngine) + lanes.Add(new NestingEngines.Irregular.IrregularNestingEngine()); + if (engine is not NestingEngines.Rectangles.RectanglesNestingEngine) + lanes.Add(new NestingEngines.Rectangles.RectanglesNestingEngine()); + return lanes; + } + + private sealed record Removal(NestPlateStock Stock, string BaseStockId, IReadOnlyList RemovedIds); + + /// + /// The (stock, removal) with the fewest removed clamps across every offered rail-backed + /// stock; ties prefer the stock listed earliest, then the clamps nearest the origin. One + /// all-clamps-removed probe per stock first dismisses stock the part cannot use at all + /// and picks the lanes worth asking; one probe per clamp then finds the clamps every + /// removal must include; the set search goes size by size across the remaining stock and + /// stops at the first size that fits anywhere. + /// + private static Removal BestRemoval( + NestJobPart part, + IReadOnlyList stocks, + NestJobOptions options, + IReadOnlyList lanes, + ProbeBudget budget, + CancellationToken token + ) + { + var candidates = new List<(NestPlateStock Stock, List Zones, List Lanes)>(); + foreach (var stock in stocks) + { + if (!CanRemoveClamps(stock)) + continue; + token.ThrowIfCancellationRequested(); + // Placement-frame X is signed from the origin, so |centre X| is the distance + // along the edge in every quadrant. + var zones = stock.ClampZones + .OrderBy(z => System.Math.Abs(z.Bounds.X + z.Bounds.Length / 2)) + .ToList(); + var allRemoved = Clone(stock, stock.Id, null, zones.Select(z => z.Id).ToList()); + var fitting = lanes.Where(lane => FitsAlone(part, allRemoved, options, lane, budget, token)).ToList(); + if (fitting.Count > 0) + candidates.Add((stock, zones, fitting)); + } + + // Removing more clamps never stops a part fitting, so a clamp the part cannot live + // with even when it is the only one left must be in every feasible removal. + var searches = new List<(NestPlateStock Stock, List Zones, List Lanes, List Mandatory)>(); + foreach (var (stock, zones, fitting) in candidates) + { + var mandatory = new List(); + for (var i = 0; i < zones.Count; i++) + { + token.ThrowIfCancellationRequested(); + var onlyThis = Clone(stock, stock.Id, null, zones.Where((_, j) => j != i).Select(z => z.Id).ToList()); + if (zones.Count == 1 || !fitting.Any(lane => FitsAlone(part, onlyThis, options, lane, budget, token))) + mandatory.Add(i); + } + searches.Add((stock, zones, fitting, mandatory)); + } + + // Then the fewest clamps overall (any set, not only neighbours: a notch can leave a + // middle clamp in place), size by size across stocks in offered order; within a stock + // the optional additions nearest the origin first. + var maxSize = searches.Count == 0 ? 0 : searches.Max(c => c.Zones.Count); + for (var size = 1; size <= maxSize; size++) + { + foreach (var (stock, zones, fitting, mandatory) in searches) + { + if (mandatory.Count > size || size > zones.Count) + continue; + var optional = Enumerable.Range(0, zones.Count).Where(i => !mandatory.Contains(i)).ToList(); + foreach (var added in Combinations(optional, size - mandatory.Count)) + { + token.ThrowIfCancellationRequested(); + var removedIds = mandatory.Concat(added).OrderBy(i => i).Select(i => zones[i].Id).ToList(); + var candidate = Clone(stock, Suffixed(stock.Id, removedIds), null, removedIds); + // Removing every clamp was already probed. + if (size == zones.Count || fitting.Any(lane => FitsAlone(part, candidate, options, lane, budget, token))) + return new Removal(candidate, stock.Id, removedIds); + } + } + } + return null; + } + + /// Every -element subset of , lazily, + /// in lexicographic order of positions. + private static IEnumerable> Combinations(IReadOnlyList items, int count) + { + if (count < 0 || count > items.Count) + yield break; + var positions = Enumerable.Range(0, count).ToArray(); + while (true) + { + yield return positions.Select(p => items[p]).ToList(); + var k = count - 1; + while (k >= 0 && positions[k] == items.Count - count + k) + k--; + if (k < 0) + yield break; + positions[k]++; + for (var j = k + 1; j < count; j++) + positions[j] = positions[j - 1] + 1; + } + } + + /// Only clamps resolved from a machine rail can be removed (see + /// ): explicit zones have no clamp to name. + private static bool CanRemoveClamps(NestPlateStock stock) => + stock.ClampRail != null && stock.ClampZones.Count > 0; + + private static bool FitsAlone( + NestJobPart part, + NestPlateStock stock, + NestJobOptions options, + INestingEngine engine, + ProbeBudget budget, + CancellationToken token + ) => Probe(part, new[] { stock }, options, engine, budget, token); + + private static bool FitsAnywhere( + NestJobPart part, + IReadOnlyList stocks, + NestJobOptions options, + INestingEngine engine, + ProbeBudget budget, + CancellationToken token + ) => Probe(part, stocks, options, engine, budget, token); + + /// Whether this part, alone and at quantity one, places on an empty sheet of any + /// of the given stock entries with the given engine. A question already answered for the + /// same part, engine and physically identical stock is reused; otherwise it spends one + /// probe from the budget. + private static bool Probe( + NestJobPart part, + IReadOnlyList stocks, + NestJobOptions options, + INestingEngine engine, + ProbeBudget budget, + CancellationToken token + ) + { + var key = (engine, part.Id, string.Join(";", stocks.Select(Signature).Distinct(StringComparer.Ordinal))); + if (budget.Answers.TryGetValue(key, out var known)) + return known; + budget.Spend(); + var probePart = new NestJobPart(part.Id, part.Geometry, 1, part.Priority, part.Rotation); + var probeStock = stocks.Select(s => Unlimited(s)).ToList(); + var probeOptions = new NestJobOptions(options.PlacementStrategy, 1, 0, 0); + var probeJob = new NestJob(new[] { probePart }, probeStock, probeOptions); + token.ThrowIfCancellationRequested(); + var result = engine.Solve(probeJob, null, token); + var fits = result != null && result.Status == NestJobStatus.Complete; + budget.Answers[key] = fits; + return fits; + } + + /// Everything about a stock entry that can change whether a lone part fits: + /// sheet size, quadrant, spacings and the exact clamp zones (not its id, cost or + /// quantity). + private static string Signature(NestPlateStock stock) + { + var e = stock.EdgeSpacing; + var zones = string.Join(",", stock.SheetClampZones.Select(z => + FormattableString.Invariant($"{z.Id}:{z.Bounds.X:R}/{z.Bounds.Y:R}/{z.Bounds.Length:R}/{z.Bounds.Width:R}"))); + return FormattableString.Invariant( + $"{stock.Size.Width:R}x{stock.Size.Length:R}|q{stock.Quadrant}|{stock.PartSpacing:R}|{e.Left:R},{e.Bottom:R},{e.Right:R},{e.Top:R}|{zones}"); + } + + private sealed class ProbeBudget(int solves) + { + private int left = solves; + + /// Answers already paid for, by (engine, part, stock signatures). + public Dictionary<(INestingEngine, string, string), bool> Answers { get; } = new(); + + /// Throws once spent; recovery + /// then keeps the ordinary result. + public void Spend() + { + if (left-- <= 0) + throw new ProbeBudgetExhaustedException(); + } + } + + private sealed class ProbeBudgetExhaustedException() + : Exception("Clamp removal recovery ran out of feasibility probes."); + + /// Same stock with unlimited quantity (feasibility probes never consume real + /// inventory); id/zones unchanged. + private static NestPlateStock Unlimited(NestPlateStock stock) => + stock.Quantity == null ? stock : Clone(stock, stock.Id, null, null); + + /// + /// A copy of with a new id/quantity and, when + /// is given, those clamp zones dropped. Built from the + /// stock's own sheet-frame zones, so the kept zones are bit-identical to the original's. + /// + private static NestPlateStock Clone( + NestPlateStock stock, + string id, + int? quantity, + IReadOnlyCollection removedIds + ) + { + var sheetZones = stock.SheetClampZones + .Where(z => removedIds == null || !removedIds.Contains(z.Id)) + .ToList(); + return new NestPlateStock( + id, + stock.Size, + quantity, + stock.PartSpacing, + stock.EdgeSpacing, + stock.Quadrant, + stock.Cost, + sheetZones + ) + { ClampRail = stock.ClampRail }; + } + + private static string Suffixed(string stockId, IReadOnlyList removedIds) => + $"{stockId}::removed:{string.Join(",", removedIds)}"; + + /// + /// Combines the ordinary solve with the kept recovery sheets into one result, and the + /// expanded job it validates against: the caller's stock with each finite base reduced + /// by the sheets its variants took, plus each variant with exactly the sheets it used + /// (unlimited when its base is), so the expanded inventory equals the physical one. + /// Recovery placements get fresh instance indices continuing on from phase 1's. + /// + private static Outcome Merge( + NestJob job, + NestJobResult raw, + IReadOnlyList<(NestPlateStock Variant, string BaseId, List Sheets)> recovered + ) + { + // A variant used in more than one round is one stock entry with all its sheets. + recovered = recovered + .GroupBy(r => r.Variant.Id, StringComparer.Ordinal) + .Select(g => (g.First().Variant, g.First().BaseId, g.SelectMany(r => r.Sheets).ToList())) + .ToList(); + var placedSoFar = raw.Fulfillment.ToDictionary(f => f.PartId, f => f.Placed, StringComparer.Ordinal); + var nextPlateIndex = raw.Plates.Count; + var reindexed = new List(); + var variantStock = new List(); + var takenFromBase = new Dictionary(StringComparer.Ordinal); + foreach (var (variant, baseId, sheets) in recovered) + { + var baseStock = job.Plates.First(s => s.Id == baseId); + var used = Clone(variant, variant.Id, baseStock.Quantity == null ? null : sheets.Count, null); + variantStock.Add(used); + takenFromBase[baseStock.Id] = takenFromBase.GetValueOrDefault(baseStock.Id) + sheets.Count; + foreach (var sheet in sheets) + { + var placements = new List(sheet.Placements.Count); + foreach (var pose in sheet.Placements) + { + var index = placedSoFar.GetValueOrDefault(pose.PartId); + placedSoFar[pose.PartId] = index + 1; + placements.Add(pose with { InstanceIndex = index }); + } + reindexed.Add(new NestJobPlateResult(nextPlateIndex++, used, placements)); + } + } + + var allPlates = raw.Plates.Concat(reindexed).ToList(); + var fulfillment = job.Parts + .Select(p => + { + var placed = placedSoFar.GetValueOrDefault(p.Id); + return new PartFulfillment(p.Id, p.Quantity, placed, p.Quantity - placed); + }) + .ToList(); + + var used1 = new Dictionary(StringComparer.Ordinal); + foreach (var u in raw.StockUsage) + used1[u.StockId] = used1.GetValueOrDefault(u.StockId) + u.Used; + + var expandedStock = job.Plates + .Select(s => s.Quantity is int q && takenFromBase.TryGetValue(s.Id, out var taken) + ? Clone(s, s.Id, q - taken, null) + : s) + .Concat(variantStock) + .ToList(); + var stockUsage = expandedStock + .Select(s => + { + var usedCount = used1.GetValueOrDefault(s.Id) + reindexed.Count(p => p.StockId == s.Id); + return new StockUsage(s.Id, usedCount, s.Quantity is int q ? q - usedCount : null); + }) + .ToList(); + + var complete = fulfillment.All(f => f.Unplaced == 0); + var status = complete ? NestJobStatus.Complete : raw.Status; + var stopReason = complete ? NestJobStopReason.Completed : raw.StopReason; + + var expandedJob = new NestJob(job.Parts, expandedStock, job.Options); + var result = new NestJobResult(status, stopReason, allPlates, fulfillment, stockUsage); + return new Outcome(expandedJob, result); + } +} diff --git a/OpenNest.Engine/Jobs/InteractiveClamps.cs b/OpenNest.Engine/Jobs/InteractiveClamps.cs new file mode 100644 index 0000000..525de3c --- /dev/null +++ b/OpenNest.Engine/Jobs/InteractiveClamps.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs; + +/// +/// Interactive-path clamp keep-outs (Slice 4): the inflated rectangles and static obstacle +/// parts that make the single-plate fill, push and pack surfaces respect +/// (mapped into the placement frame) like existing parts. Zones are never material: the fake +/// obstacle parts exist only inside a single call - they are never added to +/// , plates, jobs or files. +/// +internal static class InteractiveClamps +{ + public static bool HasZones(Plate plate) => plate.ClampZones.Count > 0; + + /// + /// Raw zone rectangles in the parts' placement frame (plate zones are stored in sheet + /// coordinates, see ), detached copies, capture order. + /// + public static IReadOnlyList ZoneRectangles(Plate plate) + { + if (plate.ClampZones.Count == 0) + return Array.Empty(); + var zones = ClampZoneFrame.ToPlacement(plate); + var boxes = new Box[zones.Count]; + for (var i = 0; i < boxes.Length; i++) + boxes[i] = Copy(zones[i].Bounds); + return boxes; + } + + /// + /// Placement keep-outs inflated by the full bottom-edge clearance (D6) - the same + /// convention the Rectangles lane blocks and engines honour, so fill and pack produce + /// placements with at least that clearance from the raw zone. + /// + public static IReadOnlyList ClearanceRectangles(Plate plate) => + InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom)); + + /// + /// The validator's exact C1 rectangle (NestLayoutCheck: bottom spacing less the shared + /// spacing slack). Used by the service safety net so flush engine-clear placements are + /// never filtered by rounding. + /// + public static IReadOnlyList ValidationKeepOuts(Plate plate) => + InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom - NestTolerances.SpacingSlack)); + + private static IReadOnlyList InflateZones(Plate plate, double clearance) + { + if (plate.ClampZones.Count == 0) + return Array.Empty(); + var zones = ClampZoneFrame.ToPlacement(plate); + var boxes = new Box[zones.Count]; + for (var i = 0; i < boxes.Length; i++) + boxes[i] = zones[i].Bounds.Offset(clearance); + return boxes; + } + + /// + /// Static obstacle parts for the directional push kernels. The kernel enforces + /// part-spacing between every obstacle pair, so the rectangles carry only the + /// clearance the kernel would otherwise miss: max(0, bottom spacing - part spacing). + /// Final material clearance from a raw zone is then at least max(bottom, partSpacing), + /// never below the C1 keep-out. + /// + public static List ObstacleParts(Plate plate) => + ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom - plate.PartSpacing)); + + /// Expander walls use the bottom-edge clearance as a fixed floor, + /// independent of the separation target or its final achieved spacing. + public static List WallParts(Plate plate) => + ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom)); + + private static List ObstaclePartsFor(IReadOnlyList rectangles, double inflate) + { + var parts = new List(rectangles.Count); + foreach (var box in rectangles) + { + var inflated = box.Offset(inflate); + var drawing = RectangleDrawing(inflated.Length, inflated.Width); + parts.Add(new Part(drawing, new Vector(inflated.Left, inflated.Bottom))); + } + return parts; + } + + /// + /// Exact material overlap oracle for the service safety net: part outline with its + /// holes subtracted against the keep-out rectangle, matching the C1 checker's verdict + /// (positive-area overlap only - flush contact is clear). + /// + public static bool MaterialInKeepOut(Part part, Box keepOut) + { + var partProfile = new ShapeProfile(PartGeometry.GetPartEntities(part)); + if (partProfile.Perimeter == null) + return true; + // A keep-out below the polygon tolerance collapses in the tolerant test and would + // read as clear (PM b0062c73); judge it tolerance-free instead. + if (KeepOutOverlap.NeedsToleranceFreeTest(keepOut)) + return KeepOutOverlap.ProfileEnters(partProfile, new Vector(0, 0), keepOut); + var zoneProfile = new ShapeProfile(RectangleEntities(keepOut)); + if (zoneProfile.Perimeter == null) + return true; + return Collision.HasOverlap( + partProfile.Perimeter.ToPolygonWithTolerance(0.001), + zoneProfile.Perimeter.ToPolygonWithTolerance(0.001), + HolePolygons(partProfile), + HolePolygons(zoneProfile)); + } + + public static bool MaterialsTooClose(Part first, Part second, double spacing) + { + var a = new ShapeProfile(PartGeometry.GetPartEntities(first)); + var b = new ShapeProfile(PartGeometry.GetPartEntities(second)); + if (a.Perimeter == null || b.Perimeter == null) + return true; + var outerA = a.Perimeter.ToPolygonWithTolerance(0.001); + var outerB = b.Perimeter.ToPolygonWithTolerance(0.001); + var holesA = HolePolygons(a); + var holesB = HolePolygons(b); + if (Collision.HasOverlap(outerA, outerB, holesA, holesB)) + return true; + if (spacing <= NestTolerances.SpacingSlack) + return false; + var ringsA = new List { outerA }; + var ringsB = new List { outerB }; + if (holesA != null) ringsA.AddRange(holesA); + if (holesB != null) ringsB.AddRange(holesB); + foreach (var ringA in ringsA) + foreach (var ringB in ringsB) + if (Clearance.BoundaryDistance(ringA, ringB).Distance + < spacing - NestTolerances.SpacingSlack) + return true; + return false; + } + + private static List HolePolygons(ShapeProfile profile) + { + if (profile.Cutouts.Count == 0) + return null; + var holes = new List(profile.Cutouts.Count); + foreach (var cutout in profile.Cutouts) + holes.Add(cutout.ToPolygonWithTolerance(0.001)); + return holes; + } + + private static List RectangleEntities(Box box) + { + var program = new Program(); + program.MoveTo(box.Left, box.Bottom); + program.LineTo(box.Right, box.Bottom); + program.LineTo(box.Right, box.Top); + program.LineTo(box.Left, box.Top); + program.LineTo(box.Left, box.Bottom); + return ConvertProgram.ToGeometry(program); + } + + private static Box Copy(Box box) => new(box.X, box.Y, box.Length, box.Width); + + // Rectangular obstacle drawings are immutable and shared across calls; rail clamps + // all share one size, so this stays tiny. Key equality implies identical geometry. + private static readonly ConcurrentDictionary<(double Length, double Width), Drawing> Drawings = new(); + + private static Drawing RectangleDrawing(double length, double width) => + Drawings.GetOrAdd((length, width), size => + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(size.Length, 0); + program.LineTo(size.Length, size.Width); + program.LineTo(0, size.Width); + program.LineTo(0, 0); + return new Drawing("clamp-zone-obstacle", program); + }); +} diff --git a/OpenNest.Engine/Jobs/NestJobValidator.cs b/OpenNest.Engine/Jobs/NestJobValidator.cs index 94baea1..1e25971 100644 --- a/OpenNest.Engine/Jobs/NestJobValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobValidator.cs @@ -32,6 +32,7 @@ public static class NestJobValidator $"Invalid stock dimensions/settings: {stock.Id}.", nameof(job) ); + ValidateClampZones(stock, job); } foreach (var part in job.Parts) { @@ -71,6 +72,44 @@ public static class NestJobValidator + string.Join(", ", available.Where(s => !s.Cost.HasValue).Select(s => s.Id))); } + /// + /// Every stock zone must be uniquely identified and reach onto its own sheet (a positive + /// overlap). A clamp is an avoidance area, so its whole footprint may hang past the sheet + /// edges; a zone wholly off the sheet is malformed. Geometry and finiteness are already + /// enforced by . Zones are compared in the placement frame, where + /// the stock carries them. + /// + private static void ValidateClampZones(NestPlateStock stock, NestJob job) + { + if (stock.ClampZones.Count == 0) + return; + + var ids = new HashSet(StringComparer.Ordinal); + var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length; + var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width; + + foreach (var zone in stock.ClampZones) + { + if (!ids.Add(zone.Id)) + throw new ArgumentException( + $"Clamp zone id '{zone.Id}' is defined twice on stock {stock.Id}.", + nameof(job) + ); + + var b = zone.Bounds; + if ( + !(b.Right > left) + || !(b.Left < left + stock.Size.Length) + || !(b.Top > bottom) + || !(b.Bottom < bottom + stock.Size.Width) + ) + throw new ArgumentException( + $"Clamp zone '{zone.Id}' on stock {stock.Id} lies outside the sheet.", + nameof(job) + ); + } + } + internal static void ValidateCandidate( PlateCandidate candidate, NestPlateStock stock, diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index c2597a3..aa08d62 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -153,11 +153,87 @@ public static class NestLayoutCheck ValidateBounds(parts, plate, requirements, result); ValidateAreaBudget(parts, plate, result); ValidateSpacing(parts, plate.PartSpacing, requirements, result); + if (plate.ClampZones.Count > 0) + ValidateClamps(parts, plate, requirements, result); } return result; } + /// + /// C1/C5: no part material may enter a clamp zone inflated by the plate's bottom-edge + /// spacing (D6) - the same clearance the part owes the sheet edge, enforced with the + /// shared spacing slack so exact engine placements are not reddened by rounding. + /// + private static void ValidateClamps( + List parts, + Plate plate, + IReadOnlyDictionary requirements, + List result + ) + { + var inflateBy = InflationFor(plate.EdgeSpacing.Bottom); + + // Plate zones are stored in sheet coordinates; parts are in the placement frame. + foreach (var zone in ClampZoneFrame.ToPlacement(plate)) + { + // Box.Offset(d) grows the rectangle on all four sides. + var keepOut = zone.Bounds.Offset(inflateBy); + var keepOutOutline = RectangleOutline(keepOut); + + foreach (var part in parts) + { + if (!BoxesTouch(keepOut, MaterialBounds(part))) + continue; + + if (!OverlapsKeepOut(part, keepOutOutline, keepOut)) + continue; + + result.Add( + $"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) overlaps " + + $"clamp zone '{zone.Id}' including its edge clearance" + ); + } + } + } + + /// + /// The keep-out as an exact four-vertex ring. Routing a synthetic rectangle through the + /// tolerance-based validation offset collapses a zone smaller than that tolerance to + /// nothing (PM 6859db53), so the rectangle is never offset or flattened here. + /// + private static PartOutline RectangleOutline(Box box) + { + var perimeter = new Polygon(); + perimeter.Vertices.Add(new Vector(box.Left, box.Bottom)); + perimeter.Vertices.Add(new Vector(box.Right, box.Bottom)); + perimeter.Vertices.Add(new Vector(box.Right, box.Top)); + perimeter.Vertices.Add(new Vector(box.Left, box.Top)); + perimeter.Vertices.Add(new Vector(box.Left, box.Bottom)); + perimeter.UpdateBounds(); + return new PartOutline { Perimeter = perimeter, Holes = new List() }; + } + + /// + /// Material against a keep-out rectangle. A rectangle below the outline tolerance, or one + /// the triangulated test cannot prepare, is judged by the tolerance-free rectangle test + /// on the part's native contours (not the grid-rounded validation outline): the + /// tolerant path would call real overlap clear. A part without material is skipped. + /// + private static bool OverlapsKeepOut(Part part, PartOutline keepOutOutline, Box keepOut) + { + if (KeepOutOverlap.NeedsToleranceFreeTest(keepOut) || keepOutOutline.Triangles == null) + { + var entities = ConvertProgram.ToGeometry(part.Program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + var profile = entities.Count == 0 ? null : new ShapeProfile(entities); + return profile?.Perimeter != null && KeepOutOverlap.ProfileEnters(profile, part.Location, keepOut); + } + var material = Outline(part, 0); + return material != null && Overlaps(material, keepOutOutline); + } + /// /// Checks what the materialized layout cannot show: every sheet must be /// one of the job's own stock entries (an engine may not invent a sheet @@ -239,8 +315,31 @@ public static class NestLayoutCheck && expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top) && expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom) && expected.Quadrant == actual.Quadrant + && SameZones(expected.ClampZones, actual.ClampZones) ); + // Clamp sets are part of stock settings; a different set is not the offered stock. + private static bool SameZones( + IReadOnlyList x, + IReadOnlyList y + ) + { + if (x.Count != y.Count) + return false; + for (var i = 0; i < x.Count; i++) + { + if ( + !string.Equals(x[i].Id, y[i].Id, StringComparison.Ordinal) + || !x[i].Bounds.X.IsEqualTo(y[i].Bounds.X) + || !x[i].Bounds.Y.IsEqualTo(y[i].Bounds.Y) + || !x[i].Bounds.Length.IsEqualTo(y[i].Bounds.Length) + || !x[i].Bounds.Width.IsEqualTo(y[i].Bounds.Width) + ) + return false; + } + return true; + } + private static string Describe(RotationPolicy policy) => policy.Kind switch { diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index b363327..0863533 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -176,6 +176,20 @@ public static class NestPipeline var solveTime = clock.Elapsed; token.ThrowIfCancellationRequested(); + // D8/C10: only the main (non-cutout-preview) path offers last-resort clamp removal. + // No-op whenever nothing is blocked, so an ordinary solve is unaffected (C10). + if (prepass == null) + { + var recovered = ClampRemovalRecovery.TryRecover(job, raw, engine, token); + if (recovered != null) + { + job = recovered.Job; + engineJob = recovered.Job; + raw = recovered.Result; + } + } + token.ThrowIfCancellationRequested(); + clock.Restart(); var names = drawingsByPartId.ToDictionary( kv => kv.Key, diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs index a433345..86a282b 100644 --- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs +++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; namespace OpenNest.Engine.Jobs; @@ -42,6 +43,7 @@ public static class NestPipelineCommit plate.EdgeSpacing = proposed.Stock.EdgeSpacing; plate.Quadrant = proposed.Stock.Quadrant; plate.Quantity = proposed.Quantity; + SyncClampZones(plate, proposed.Stock); plate.Parts.AddRange(proposed.Parts); applied.Add(plate); } @@ -52,4 +54,64 @@ public static class NestPipelineCommit } return applied.AsReadOnly(); } + + /// + /// The live plate keeps exactly the clamp keep-outs its layout was solved and validated + /// against, including sheets created for this commit. Stock zones live in the placement + /// frame; plate zones in plate coordinates, so the stock's translation is inverted. Zones + /// already equal in the placement frame are left untouched (no change events). Replaced + /// zones stay tied to the plate's clamp rail only when that rail recreates them (directly, + /// or minus whichever clamps Auto Nest removed as a last resort, D8/C13); otherwise a + /// later size or quadrant assignment would silently swap in other clamps. + /// + private static void SyncClampZones(Plate plate, NestPlateStock stock) + { + var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length; + var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width; + // The rail the solved zones were resolved from wins over whatever the target follows; + // explicit stock keeps the target's own rail as before. + var rail = stock.ClampRail ?? plate.ClampRail; + if (!SameInPlacementFrame(plate.ClampZones, stock.ClampZones, left, bottom)) + { + plate.SetClampRail(null); // explicit zones from here on + foreach (var zone in stock.ClampZones) + plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds.Translate(-left, -bottom))); + } + if (rail == null) + return; + + // D8: the sheet's zones may be the rail's resolution for it minus clamps Auto Nest + // removed as a last resort; derive which (if any) so the committed plate keeps + // following the rail - and the removal - through later resizes (C13). This runs even + // when the target already held exactly these zones, so the removal is never lost. + var kept = new HashSet(plate.ClampZones.Select(z => z.Id), StringComparer.Ordinal); + var removedClamps = rail.ResolveFor(plate.Size, plate.Quadrant) + .Select(z => z.Id) + .Where(id => !kept.Contains(id)) + .ToList(); + var unchanged = ReferenceEquals(plate.ClampRail, rail) && plate.RemovedClamps.SetEquals(removedClamps); + if (!unchanged && rail.Matches(plate, removedClamps)) + plate.SetClampRail(rail, removedClamps); + } + + private static bool SameInPlacementFrame( + IList plateZones, + IReadOnlyList stockZones, + double left, + double bottom) + { + if (plateZones.Count != stockZones.Count) + return false; + for (var i = 0; i < plateZones.Count; i++) + { + // Same arithmetic NestPlateStock uses to carry plate zones into the placement frame. + var placed = plateZones[i].Bounds.Translate(left, bottom); + var solved = stockZones[i].Bounds; + if (!string.Equals(plateZones[i].Id, stockZones[i].Id, StringComparison.Ordinal) + || placed.X != solved.X || placed.Y != solved.Y + || placed.Length != solved.Length || placed.Width != solved.Width) + return false; + } + return true; + } } diff --git a/OpenNest.Engine/Jobs/NestPlateStock.cs b/OpenNest.Engine/Jobs/NestPlateStock.cs index 15d8e0b..9cb8638 100644 --- a/OpenNest.Engine/Jobs/NestPlateStock.cs +++ b/OpenNest.Engine/Jobs/NestPlateStock.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using OpenNest.Geometry; namespace OpenNest.Engine.Jobs; @@ -14,19 +15,21 @@ public sealed class NestPlateStock int? quantity = null, double partSpacing = 0, Spacing edgeSpacing = default, - int quadrant = 1 + int quadrant = 1, + IReadOnlyList clampZones = null ) - : this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null) { } + : this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null, clampZones) { } public NestPlateStock(string id, Size size, int? quantity, double partSpacing, - Spacing edgeSpacing, int quadrant, double? cost) + Spacing edgeSpacing, int quadrant, double? cost, + IReadOnlyList clampZones = null) { if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0)) throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null."); - Cost = cost; ArgumentException.ThrowIfNullOrWhiteSpace(id); if (quantity < 0) throw new ArgumentOutOfRangeException(nameof(quantity)); + Cost = cost; Id = id; Size = size; Quantity = quantity; @@ -41,8 +44,51 @@ public sealed class NestPlateStock size.Length - edgeSpacing.Left - edgeSpacing.Right, size.Width - edgeSpacing.Bottom - edgeSpacing.Top ); + // Zones arrive in plate coordinates and are carried in the placement frame, + // translated exactly like WorkArea. Detached copies: stock is immutable input. + if (clampZones == null || clampZones.Count == 0) + { + ClampZones = Array.Empty(); + SheetClampZones = Array.Empty(); + } + else + { + var zones = new ClampZone[clampZones.Count]; + var sheetZones = new ClampZone[clampZones.Count]; + for (var i = 0; i < zones.Length; i++) + { + zones[i] = new ClampZone(clampZones[i].Id, clampZones[i].Bounds.Translate(left, bottom)); + sheetZones[i] = new ClampZone(clampZones[i].Id, clampZones[i].Bounds); + } + ClampZones = new DetachedZones(zones); + SheetClampZones = sheetZones; + } } + /// + /// Keep-out rectangles in the placement frame (quadrant applied), translated exactly + /// like . Empty when the plate defines none; never null. Every + /// read returns detached copies: zone bounds are mutable, and the stock must stay + /// consistent with the zones its materialized plates use. + /// + public IReadOnlyList ClampZones { get; } + + /// + /// The same zones exactly as given, in plate coordinates (the frame of + /// ), for plates built from this stock. Keeping the input + /// avoids a lossy translate-and-back; of such a + /// plate reproduces bit for bit. + /// + internal IReadOnlyList SheetClampZones { get; } + + /// + /// The machine clamp rail were resolved from, when the stock was + /// built from a rail-following plate; null for explicit zones. Only rail-backed clamps may + /// be taken off as a last resort (D8): the rail is what lets a committed sheet name the + /// clamps to pull and carry their removal time, so explicit zones are never removed. + /// + internal ClampRail ClampRail { get; init; } + /// Generic per-physical-sheet cost; null selects area scoring. public double? Cost { get; } public string Id { get; } @@ -66,4 +112,26 @@ public sealed class NestPlateStock /// True when a width (X) by height (Y) envelope fits the work area within epsilon. public bool Fits(double width, double height, double epsilon = 1e-9) => width <= workArea.Length + epsilon && height <= workArea.Width + epsilon; + + private sealed class DetachedZones : IReadOnlyList + { + private readonly ClampZone[] zones; + + public DetachedZones(ClampZone[] zones) => this.zones = zones; + + public int Count => zones.Length; + + public ClampZone this[int index] => Copy(zones[index]); + + public IEnumerator GetEnumerator() + { + foreach (var zone in zones) + yield return Copy(zone); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + // The ClampZone constructor copies the rectangle, keeping its exact bits. + private static ClampZone Copy(ClampZone zone) => new(zone.Id, zone.Bounds); + } } diff --git a/OpenNest.Engine/Jobs/NestStockBuilder.cs b/OpenNest.Engine/Jobs/NestStockBuilder.cs index 6f4db77..fc16992 100644 --- a/OpenNest.Engine/Jobs/NestStockBuilder.cs +++ b/OpenNest.Engine/Jobs/NestStockBuilder.cs @@ -23,20 +23,32 @@ public static class NestStockBuilder { var option = options[i]; ArgumentNullException.ThrowIfNull(option); + var size = new Size(option.Width, option.Length); stock.Add(new NestPlateStock( $"option-{i}", - new Size(option.Width, option.Length), + size, quantityPerOption, template.PartSpacing, template.EdgeSpacing, template.Quadrant, - option.Cost == 0 ? null : option.Cost - )); + option.Cost == 0 ? null : option.Cost, + ClampZonesFor(template, size) + ) + { ClampRail = template.ClampRail }); } NestJobValidator.ValidateStockCosts(stock); return stock.AsReadOnly(); } + /// + /// A template following the machine clamp rail offers each sheet size with the rail + /// resolved for that size (clamps are fixed to the machine, not the sheet); explicit + /// template zones are offered unchanged. + /// + private static IReadOnlyList ClampZonesFor(Plate template, Size size) => + template.ClampRail?.ResolveFor(size, template.Quadrant) + ?? new List(template.ClampZones); + /// Single-sheet callers may not silently create additional sheets. public static IReadOnlyList SinglePlate(Plate plate) => FromTemplate(plate, null, quantityPerOption: 1); diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs index f85033d..888bc0c 100644 --- a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs +++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs @@ -27,6 +27,9 @@ internal static class PlateFillOrchestrator return new List(); var workArea = plate.WorkArea(); + // Slice 4 (C1): inflated clamp keep-outs act like occupied space for every stage + // below - fill remnants, pack areas, pair placement - so a zone is never covered. + var clampKeepOuts = InteractiveClamps.ClearanceRectangles(plate); var allParts = new List(); var plateArea = workArea.Width * workArea.Length; @@ -41,7 +44,7 @@ internal static class PlateFillOrchestrator if (fillItems.Count > 0) { - var remnantFiller = new RemnantFiller(workArea, plate.PartSpacing); + var remnantFiller = new RemnantFiller(workArea, plate.PartSpacing, clampKeepOuts); var fillParts = remnantFiller.FillItems( fillItems, @@ -58,6 +61,7 @@ internal static class PlateFillOrchestrator var placedObstacles = fillParts .Select(part => part.BoundingBox.Offset(plate.PartSpacing)) .ToList(); + placedObstacles.AddRange(clampKeepOuts); var finder = new RemnantFinder(workArea, placedObstacles); var remnants = finder.FindRemnants(); if (remnants.Count > 0) @@ -90,11 +94,11 @@ internal static class PlateFillOrchestrator if (pairItems.Count > 0 && !token.IsCancellationRequested) { - var placed = PlaceBestFitPairs(plate, comparer, pairItems, allParts, plate.WorkArea()); + var placed = PlaceBestFitPairs(plate, comparer, pairItems, allParts, plate.WorkArea(), clampKeepOuts); allParts.AddRange(placed); } - Compactor.Settle(allParts, plate.WorkArea(), plate.PartSpacing); + Compactor.Settle(allParts, plate); return allParts; } @@ -113,13 +117,16 @@ internal static class PlateFillOrchestrator IFillComparer comparer, List pairItems, List existingParts, - Box fullWorkArea + Box fullWorkArea, + IReadOnlyList clampKeepOuts = null ) { var result = new List(); var obstacles = existingParts .Select(part => part.BoundingBox.Offset(plate.PartSpacing)) .ToList(); + if (clampKeepOuts != null) + obstacles.AddRange(clampKeepOuts); var finder = new RemnantFinder(fullWorkArea, obstacles); foreach (var item in pairItems) diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs index ca8f4de..b75e65e 100644 --- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs +++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; +using OpenNest.Engine.Fill; using OpenNest.Engine.Jobs.Placement.Fillers; using OpenNest.Geometry; @@ -39,7 +41,13 @@ public static class PlateFillService CancellationToken token ) { - return RequireFiller(strategy, plate).Fill(item, workArea, progress, token); + var filler = RequireFiller(strategy, plate); + if (!InteractiveClamps.HasZones(plate)) + return filler.Fill(item, workArea, progress, token); + var result = FillClearAreas(plate, workArea, item, token, + (area, demand) => filler.Fill(demand, area, null, token)); + ReportSafe(progress, result, workArea); + return result; } public static List FillGroup( @@ -51,7 +59,13 @@ public static class PlateFillService CancellationToken token ) { - return RequireFiller(strategy, plate).Fill(groupParts, workArea, progress, token); + var filler = RequireFiller(strategy, plate); + if (!InteractiveClamps.HasZones(plate)) + return filler.Fill(groupParts, workArea, progress, token); + var result = ClearAreas(plate, workArea, token, (area, _) => + filler.Fill(groupParts, area, null, token), 0, groupSize: groupParts.Count); + ReportSafe(progress, result, workArea); + return result; } public static List PackArea( @@ -63,7 +77,21 @@ public static class PlateFillService CancellationToken token ) { - return RequireFiller(strategy, plate).PackArea(box, items, progress, token); + var filler = RequireFiller(strategy, plate); + if (!InteractiveClamps.HasZones(plate)) + return filler.PackArea(box, items, progress, token); + var result = new List(); + foreach (var area in ClearRegionBoxes(plate, box)) + { + token.ThrowIfCancellationRequested(); + var remaining = items.Select(item => CopyDemand(item, + item.Quantity > 0 ? System.Math.Max(0, item.Quantity - result.Count(part => + ReferenceEquals(part.BaseDrawing, item.Drawing))) : item.Quantity)).ToList(); + var proposed = filler.PackArea(area, remaining, null, token); + AddClear(result, proposed, plate, area, items); + } + ReportSafe(progress, result, box); + return result; } public static List Nest( @@ -93,9 +121,151 @@ public static class PlateFillService ResolveStrategy(strategy, allowEmpty: false); var filler = CreateFiller(strategy, plate); filler.PlateNumber = plateNumber; - return filler.Nest(items, progress, token); + if (!InteractiveClamps.HasZones(plate)) + return filler.Nest(items, progress, token); + + // Interactive proposals are never committed here. Unlike the unconstrained + // strategies, partition the sheet at static keep-outs before searching, then + // certify each accepted material footprint against the full zone and occupants. + var result = new List(); + foreach (var item in items.OrderBy(item => item.Priority)) + { + token.ThrowIfCancellationRequested(); + // Fill/Remnant whole-plate orchestration skips exhausted demand; the + // single-item Fill API uses zero to mean unlimited. Strip has its own + // historical zero-quantity behavior, preserved here. + if (item.Quantity <= 0 && ResolveStrategy(strategy) != "Strip") + continue; + var proposed = FillClearAreas(plate, plate.WorkArea(), item, token, + (area, demand) => filler.Fill(demand, area, null, token), result); + result.AddRange(proposed); + if (item.Quantity > 0) + item.Quantity = System.Math.Max(0, item.Quantity - proposed.Count); + } + ReportSafe(progress, result, plate.WorkArea(), plateNumber); + return result; } + // Do not expose a strategy's unverified intermediate placements: the UI can + // accept a progress preview before this service returns its certified proposal. + private static void ReportSafe(IProgress progress, List parts, + Box area, int plateNumber = 0) => progress?.Report(new NestProgress + { + BestParts = parts, + ActiveWorkArea = area, + PlateNumber = plateNumber, + IsOverallBest = true, + Description = $"Clamp-clear proposal: {parts.Count} parts", + }); + + private static NestItem CopyDemand(NestItem item, int quantity) => new() + { + Drawing = item.Drawing, + Quantity = quantity, + Priority = item.Priority, + RotationStart = item.RotationStart, + RotationEnd = item.RotationEnd, + StepAngle = item.StepAngle, + }; + + private static List FillClearAreas(Plate plate, Box box, NestItem item, + CancellationToken token, Func> fill, + List previouslyPlaced = null) => + ClearAreas(plate, box, token, fill, item.Quantity, item, previouslyPlaced); + + private static List ClearAreas(Plate plate, Box box, CancellationToken token, + Func> fill, int quantity, NestItem item = null, + List previouslyPlaced = null, int groupSize = 1) + { + var result = new List(); + foreach (var area in ClearRegionBoxes(plate, box)) + { + token.ThrowIfCancellationRequested(); + if (quantity > 0 && result.Count >= quantity) + break; + var demand = item == null ? null : CopyDemand(item, + quantity > 0 ? quantity - result.Count : quantity); + AddClear(result, fill(area, demand), plate, area, + item == null ? null : new List { item }, previouslyPlaced, groupSize); + } + return result; + } + + private static List ClearRegionBoxes(Plate plate, Box area) + { + var obstacles = InteractiveClamps.ClearanceRectangles(plate).ToList(); + obstacles.AddRange(plate.Parts.Where(part => !part.BaseDrawing.IsCutOff) + .Select(part => part.BoundingBox.Offset(plate.PartSpacing))); + return new RemnantFinder(area, obstacles).FindRemnants(); + } + + private static void AddClear(List accepted, List proposed, Plate plate, + Box area, List demand = null, List previouslyPlaced = null, + int groupSize = 1) + { + if (proposed == null || groupSize < 1) + return; + // FillLinear may append an incomplete trailing tile. Keep complete copies + // ahead of it, but never expose the incomplete tail as a group. + for (var start = 0; start + groupSize <= proposed.Count; start += groupSize) + { + // A group is one proposal: never return just its frame or just its + // insert when a member collides with a clamp or occupied material. + var batch = new List(); + for (var index = start; index < start + groupSize; index++) + { + if (!TryAddClear(batch, proposed[index], accepted, plate, area, + demand, previouslyPlaced)) + break; + } + if (batch.Count == groupSize) + accepted.AddRange(batch); + } + } + + private static bool TryAddClear(List batch, Part part, List accepted, + Plate plate, Box area, List demand, List previouslyPlaced) + { + var bounds = part.BoundingBox; + var full = plate.WorkArea(); + // The packer permits a tiny overhang only at a real sheet edge, + // never across a remnant boundary cut by a clamp or another part. + double Slack(double edge, double sheet) => System.Math.Abs(edge - sheet) < 1e-9 + ? NestTolerances.WorkAreaSlack : 1e-6; + if (bounds.Left < area.Left - Slack(area.Left, full.Left) + || bounds.Right > area.Right + Slack(area.Right, full.Right) + || bounds.Bottom < area.Bottom - Slack(area.Bottom, full.Bottom) + || bounds.Top > area.Top + Slack(area.Top, full.Top)) + return false; + if (demand != null && !demand.Any(item => + ReferenceEquals(item.Drawing, part.BaseDrawing) && + (item.Quantity <= 0 || accepted.Count(p => + ReferenceEquals(p.BaseDrawing, item.Drawing)) + batch.Count(p => + ReferenceEquals(p.BaseDrawing, item.Drawing)) < item.Quantity))) + return false; + if (InteractiveClamps.ValidationKeepOuts(plate).Any(zone => + Overlaps(bounds, zone) && InteractiveClamps.MaterialInKeepOut(part, zone))) + return false; + if (plate.Parts.Any(existing => !existing.BaseDrawing.IsCutOff && + Overlaps(bounds, existing.BoundingBox.Offset(plate.PartSpacing)) + && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) + || accepted.Any(existing => Overlaps(bounds, + existing.BoundingBox.Offset(plate.PartSpacing)) + && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) + || batch.Any(existing => Overlaps(bounds, + existing.BoundingBox.Offset(plate.PartSpacing)) + && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) + || (previouslyPlaced?.Any(existing => Overlaps(bounds, + existing.BoundingBox.Offset(plate.PartSpacing)) + && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) ?? false)) + return false; + batch.Add(part); + return true; + } + + private static bool Overlaps(Box a, Box b) => a.Left < b.Right && a.Right > b.Left + && a.Bottom < b.Top && a.Top > b.Bottom; + /// /// Resolves a caller-supplied strategy name: null or empty means "Fill"; otherwise the name /// must match a built-in strategy, matched case-insensitively like the legacy registry's diff --git a/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs b/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs index 9f4d5f1..6b42a08 100644 --- a/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs +++ b/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs @@ -41,7 +41,31 @@ internal static class RepeatedPlateGrouping && Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom) && a.Quantity == b.Quantity && a.Cost.HasValue == b.Cost.HasValue - && (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value)); + && (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value)) + && SameZones(a.ClampZones, b.ClampZones); + + // C8: a layout solved with clamps is never reused for a plate without them - and a + // different clamp set is different stock. Ordered; ids and exact bits participate. + private static bool SameZones( + System.Collections.Generic.IReadOnlyList x, + System.Collections.Generic.IReadOnlyList y + ) + { + if (x.Count != y.Count) + return false; + for (var i = 0; i < x.Count; i++) + { + if ( + !string.Equals(x[i].Id, y[i].Id, StringComparison.Ordinal) + || Bits(x[i].Bounds.X) != Bits(y[i].Bounds.X) + || Bits(x[i].Bounds.Y) != Bits(y[i].Bounds.Y) + || Bits(x[i].Bounds.Length) != Bits(y[i].Bounds.Length) + || Bits(x[i].Bounds.Width) != Bits(y[i].Bounds.Width) + ) + return false; + } + return true; + } private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); diff --git a/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs b/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs new file mode 100644 index 0000000..1ceb7b0 --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs @@ -0,0 +1,61 @@ +#nullable enable +using Clipper2Lib; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.NestingEngines.Irregular; + +/// +/// Static, clearance-inflated rectangles prepared once per stock per solve. Kept out of +/// the part catalog and its spacing cache: clamp clearance is bottom edge spacing, not +/// part spacing, and two stocks at the same part spacing may have different clamps. +/// +internal sealed class ClampObstacles +{ + private readonly Orientation[] rectangles; + private readonly NoFitCache nfps = new(0); + + public ClampObstacles(NestPlateStock stock) + { + rectangles = new Orientation[stock.ClampZones.Count]; + var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom); + for (var i = 0; i < rectangles.Length; i++) + { + var box = stock.ClampZones[i].Bounds; + var (left, right) = EncloseOnGrid(box.Left - clearance, box.Right + clearance); + var (bottom, top) = EncloseOnGrid(box.Bottom - clearance, box.Top + clearance); + rectangles[i] = new Orientation + { + // Real part types are nonnegative; the private cache cannot alias them. + TypeIndex = -1, + Index = i, + Rotation = 0, + Tolerance = 0, + MinX = left, + MinY = bottom, + MaxX = right, + MaxY = top, + Outline = new PathD + { + new(left, bottom), new(right, bottom), new(right, top), new(left, top), + }, + }; + } + } + + // NoFitCache quantizes before adding its safety margin. Enclose the keep-out + // on that existing lattice so a sub-grid rectangle cannot disappear first. + private static (double Lower, double Upper) EncloseOnGrid(double lower, double upper) + { + var scale = System.Math.Pow(10, NoFitCache.Precision); + var first = System.Math.Floor(lower * scale); + // Positive dimensions can be smaller than a coordinate ulp (upper == lower). + var last = System.Math.Max(System.Math.Ceiling(upper * scale), first + 1); + return (first / scale, last / scale); + } + + public int Count => rectangles.Length; + + // No additional physical spacing. NoFitCache retains the established arc/grid safety + // margin and covered-hole certification for the moving orientation and rectangle. + public Nfp Get(int index, Orientation moving) => nfps.Get(rectangles[index], moving); +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs index 00d9a6f..deb3b39 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs @@ -35,8 +35,8 @@ internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList Par /// /// For every (part type, orientation) still in play the packer keeps the exact set of legal /// reference points: the inner-fit rectangle of the work area minus the no-fit polygons of -/// everything already placed. Each placement subtracts one translated NFP from each region, -/// so regions only shrink, and a region that empties is retired for the rest of the sheet. +/// static clamps and everything already placed. Each placement subtracts one translated NFP +/// from each region, so regions only shrink; an empty region is retired for the sheet. /// /// Choice rule, applied over all types and orientations at once (not in a fixed order): /// 1. Gap fill - if any part fits without pushing the packing front forward, place the @@ -62,6 +62,7 @@ internal sealed class FrontierPacker private readonly Box work; private readonly WorkCounter counter; private readonly BlockCatalog? blocks; + private readonly ClampObstacles? clamps; public FrontierPacker( IReadOnlyList types, @@ -71,10 +72,12 @@ internal sealed class FrontierPacker PackAxis axis, double beta, WorkCounter counter, - BlockCatalog? blocks = null + BlockCatalog? blocks = null, + ClampObstacles? clamps = null ) { this.blocks = blocks; + this.clamps = stock.ClampZones.Count == 0 ? null : clamps ?? new ClampObstacles(stock); this.counter = counter; this.types = types; this.nfps = nfps; @@ -97,6 +100,16 @@ internal sealed class FrontierPacker if (!stock.Fits(o.Width, o.Height)) return false; var region = new Region(o, work, single); + if (clamps != null) + for (var i = 0; i < clamps.Count; i++) + { + token.ThrowIfCancellationRequested(); + var nfp = clamps.Get(i, o); + token.ThrowIfCancellationRequested(); + region.Subtract(nfp, 0, 0); + counter.Add(1); + token.ThrowIfCancellationRequested(); + } states.Add(region); byOrientation[o] = region; return true; diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index 55dcf08..688615d 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -118,6 +118,7 @@ public sealed class IrregularNestingEngine : INestingEngine private const int MaxTail = 3; private readonly Dictionary caches = new(); + private readonly Dictionary clamps = new(); private readonly Dictionary>> pairs = new(); private readonly Dictionary blocks = new(); private readonly IReadOnlyDictionary parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal); @@ -198,6 +199,15 @@ public sealed class IrregularNestingEngine : INestingEngine return cache; } + private ClampObstacles? ClampsFor(NestPlateStock stock) + { + if (stock.ClampZones.Count == 0) + return null; + if (!clamps.TryGetValue(stock, out var found)) + clamps[stock] = found = new ClampObstacles(stock); + return found; + } + public bool PairFits(PartType type) { if (type.Part.Quantity < 2 || type.Orientations.Count == 0) @@ -265,7 +275,8 @@ public sealed class IrregularNestingEngine : INestingEngine if (stock.Quantity is int available && used[stock.Id] >= available) continue; progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0)); - var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock)); + var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, + BlocksFor(stock), ClampsFor(stock)); var fill = packer.Fill(remaining, token); sheetPacked?.Invoke(); if (fill.Parts.Count > 0) diff --git a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs index 5f56cd3..51f3b82 100644 --- a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs +++ b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs @@ -56,6 +56,7 @@ internal static class SheetPacker var work = stock.WorkArea; var s = stock.PartSpacing; var sheet = new MaxRectsSheet(work.Right - work.Left + s, work.Top - work.Bottom + s); + BlockClamps(sheet, stock, work, s); var packTypes = types .Select(t => new PackType( t.Priority, @@ -84,6 +85,59 @@ internal static class SheetPacker return new SheetPlan(stock, world, area, envelope, rule, mode); } + /// + /// Blocks every clamp keep-out into the sheet's free list before packing. In the + /// inflated-box model an item at position p covers material [p, p + size] plus a trailing + /// part-spacing tail; blocking bin-local [zone - clearance + s, zone + clearance] per axis + /// therefore means exactly: item material keeps at least the bottom-edge clearance from + /// every clamp side, without paying any extra part-spacing tax there. When s exceeds the + /// inflated span the interval inverts (decision D11): block a zero-extent line at the + /// clamp edge facing the sheet interior — at (zone max + clearance) when the zone center + /// lies in the lower half of the bin on that axis, else at (zone min - clearance + s). + /// Parts on the interior side then keep the exact clearance; the unavoidable extra + /// exclusion (s - extent - 2*clearance) lands on the side toward the nearer sheet edge. + /// The old collapse to the zone centerline was NOT conservative: an item could start + /// exactly on the centerline with material inside the clamp. + /// Empty zone lists change nothing: the free list evolves bit-identically (C2). + /// + private static void BlockClamps(MaxRectsSheet sheet, NestPlateStock stock, Box work, double s) + { + if (stock.ClampZones.Count == 0) + return; + + var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom); + foreach (var zone in stock.ClampZones) + { + var b = zone.Bounds; // placement frame -> bin-local packing coordinates + double x0(double v) => v - work.Left; + double y0(double v) => v - work.Bottom; + var left = x0(b.Left) - clearance + s; + var right = x0(b.Right) + clearance; + if (right <= left) + { + var center = x0((b.Left + b.Right) * 0.5); + // D11: the interior-facing clamp edge (toward the farther sheet edge). + var line = center < sheet.Width * 0.5 + ? x0(b.Right) + clearance + : x0(b.Left) - clearance + s; + left = line; + right = line; + } + var bottom = y0(b.Bottom) - clearance + s; + var top = y0(b.Top) + clearance; + if (top <= bottom) + { + var center = y0((b.Bottom + b.Top) * 0.5); + var line = center < sheet.Height * 0.5 + ? y0(b.Top) + clearance + : y0(b.Bottom) - clearance + s; + bottom = line; + top = line; + } + sheet.Block(new Rect(left, bottom, right - left, top - bottom)); + } + } + private static Box Union(Box a, Box b) { var l = System.Math.Min(a.Left, b.Left); diff --git a/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs b/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs index af788de..5b19bff 100644 --- a/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs +++ b/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs @@ -91,6 +91,20 @@ internal sealed class MaxRectsSheet /// Commits an item and splits every free rectangle it intersects. public void Place(Rect item) + { + Split(item); + used.Add(item); + } + + /// + /// Carves a keep-out rectangle (a machine clamp's inflated exclusion, for example) out + /// of the free space before any packing: same split logic as placements, but not a used + /// item - it consumes no material and contributes nothing to contact scoring. The + /// rectangle may extend past the bin; only interior intersections count. + /// + public void Block(Rect keepOut) => Split(keepOut); + + private void Split(Rect item) { var next = new List(free.Count + 8); foreach (var f in free) @@ -111,7 +125,6 @@ internal sealed class MaxRectsSheet } free.Clear(); free.AddRange(Prune(next)); - used.Add(item); } private static List Prune(List rects) diff --git a/OpenNest.Engine/Sequencing/PlateSequencing.cs b/OpenNest.Engine/Sequencing/PlateSequencing.cs index 2e9b99f..b934d64 100644 --- a/OpenNest.Engine/Sequencing/PlateSequencing.cs +++ b/OpenNest.Engine/Sequencing/PlateSequencing.cs @@ -34,16 +34,27 @@ namespace OpenNest.Engine.Sequencing private static List OrderCutOffsFirst(List ordered, Plate plate) { - var cuts = ordered.Select((part, index) => (Part: part, Index: index)) - .Where(item => item.Part.BaseDrawing.IsCutOff).ToList(); - if (cuts.Count == 0) - return ordered; - var definitions = new Dictionary(ReferenceEqualityComparer.Instance); foreach (var cutOff in plate.CutOffs) definitions[cutOff.Drawing] = cutOff; - var bounds = plate.BoundingBox(includeParts: false); + + // A cutoff that splits the whole sheet goes after every part and other cutoff, so the + // head never travels back across it. It passes through no part. + var separating = ordered.Where(part => part.BaseDrawing.IsCutOff + && definitions.TryGetValue(part.BaseDrawing, out var definition) + && CutOff.SeparatesSheet(part, definition, bounds)).ToList(); + if (separating.Count > 0) + { + var last = new HashSet(separating, ReferenceEqualityComparer.Instance); + ordered = ordered.Where(part => !last.Contains(part)).ToList(); + } + + var cuts = ordered.Select((part, index) => (Part: part, Index: index)) + .Where(item => item.Part.BaseDrawing.IsCutOff).ToList(); + if (cuts.Count == 0) + return ordered.Concat(separating).ToList(); + var emitted = new bool[ordered.Count]; var result = new List(ordered.Count); for (var i = 0; i < ordered.Count; i++) @@ -75,6 +86,7 @@ namespace OpenNest.Engine.Sequencing } } + result.AddRange(separating); return result; } } diff --git a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs new file mode 100644 index 0000000..3c2c88b --- /dev/null +++ b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs @@ -0,0 +1,559 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using OpenNest.Geometry; + +namespace OpenNest.IO.Tests; + +/// +/// The nest's machine clamp rail persists on the plate defaults so plates created after a +/// reload still get the clamps; plates whose saved zones are the rail's follow it again, +/// anything else is preserved as saved, and an empty sheet the rail recreates is not +/// written (the editor's trailing sentinel stays out of the file). +/// +public class ClampRailPersistenceTests +{ + private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 }; + + private static ClampRail Machine() => new(1.5, 0.625, MachineCenters); + + /// A rail nest whose first sheet is kept by a cut-off; the second is empty. + private static Nest RailNest(bool withEmptyTail = true) + { + var nest = new Nest("railed"); + nest.PlateDefaults.Size = new Size(48, 96); + nest.SetClampRail(Machine()); + var used = nest.CreatePlate(); + used.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); + if (withEmptyTail) + nest.CreatePlate(); + return nest; + } + + private static MemoryStream Write(Nest nest) + { + var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + return stream; + } + + private static Nest RoundTrip(Nest nest) => new NestReader(Write(nest)).Read(); + + [Fact] + public void RailRoundTripsAndPlatesFollowItAfterReload() + { + var reopened = RoundTrip(RailNest()); + + Assert.Equal(Machine(), reopened.PlateDefaults.ClampRail); + var plate = Assert.Single(reopened.Plates); // the empty tail was not written + Assert.Same(reopened.PlateDefaults.ClampRail, plate.ClampRail); + Assert.Equal(7, plate.ClampZones.Count); // 96" sheet: the clamp at 111" is skipped + + // Plates created or resized after the reload still get the machine clamps. + Assert.Equal(7, reopened.CreatePlate().ClampZones.Count); + plate.Size = new Size(48, 120); + Assert.Equal(8, plate.ClampZones.Count); + } + + [Fact] + public void EmptyPlateWithZonesTheRailCannotRecreateIsKept() + { + var nest = RailNest(withEmptyTail: false); + var odd = nest.CreatePlate(); + odd.SetClampRail(null!); + odd.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1))); + + var reopened = RoundTrip(nest); + + Assert.Equal(2, reopened.Plates.Count); + var kept = reopened.Plates[1]; + Assert.Equal("manual", Assert.Single(kept.ClampZones).Id); + Assert.Null(kept.ClampRail); // preserved as saved, not re-resolved + } + + [Fact] + public void EmptyRailSheetThatDiffersFromThePlateDefaultsIsKept() + { + // The rail recreates its zones, but nothing recreates this sheet's size and quadrant. + var nest = RailNest(withEmptyTail: false); + var odd = nest.CreatePlate(); + odd.Size = new Size(48, 60); + odd.Quadrant = 3; + + var reopened = RoundTrip(nest); + + Assert.Equal(2, reopened.Plates.Count); + var kept = reopened.Plates[1]; + Assert.Equal(new Size(48, 60), kept.Size); + Assert.Equal(3, kept.Quadrant); + Assert.Equal(4, kept.ClampZones.Count); + Assert.Same(reopened.PlateDefaults.ClampRail, kept.ClampRail); + } + + [Fact] + public void ChangedDefaultsMoveUntouchedSentinelAndSaveNoEmptySheets() + { + var nest = new Nest("railed"); + nest.PlateDefaults.Size = new Size(48, 96); + nest.PlateDefaults.Quadrant = 1; + nest.SetClampRail(Machine()); + using var manager = new PlateManager(nest); + manager.EnsureSentinel(); + var sentinel = Assert.Single(nest.Plates); + + manager.ChangePlateDefaults(() => + { + nest.PlateDefaults.Size = new Size(48, 60); + nest.PlateDefaults.Quadrant = 3; + nest.PlateDefaults.EdgeSpacing = new Spacing(1, 2, 3, 4); + nest.PlateDefaults.PartSpacing = 0.5; + }); + manager.EnsureSentinel(); + + Assert.Same(sentinel, Assert.Single(nest.Plates)); + using var stream = Write(nest); + using var json = JsonDocument.Parse(ReadNestJson(stream)); + Assert.Equal(0, json.RootElement.GetProperty("plates").GetArrayLength()); + stream.Position = 0; + var reopened = new NestReader(stream).Read(); + Assert.Empty(reopened.Plates); + using var reopenedManager = new PlateManager(reopened); + reopenedManager.EnsureSentinel(); + var newSentinel = Assert.Single(reopened.Plates); + Assert.Equal(new Size(48, 60), newSentinel.Size); + Assert.Equal(3, newSentinel.Quadrant); + Assert.Equal(new Spacing(1, 2, 3, 4), newSentinel.EdgeSpacing); + Assert.Equal(0.5, newSentinel.PartSpacing); + Assert.Same(reopened.PlateDefaults.ClampRail, newSentinel.ClampRail); + Assert.True(newSentinel.ClampRail.Matches(newSentinel)); + Assert.Equal(4, newSentinel.ClampZones.Count); + Assert.Equal(53.25, newSentinel.ClampZones[0].Bounds.X); + Assert.Equal(47.375, newSentinel.ClampZones[0].Bounds.Y); + using var secondSave = Write(reopened); + using var secondJson = JsonDocument.Parse(ReadNestJson(secondSave)); + Assert.Equal(0, secondJson.RootElement.GetProperty("plates").GetArrayLength()); + } + + [Theory] + [InlineData("edited")] + [InlineData("middle")] + [InlineData("removed")] + [InlineData("cutOff")] + [InlineData("explicit")] + public void ChangedDefaultsPreserveAndWriteConfiguredEmptySheets(string configuration) + { + var nest = RailNest(withEmptyTail: false); + var plate = nest.CreatePlate(); + switch (configuration) + { + case "edited": + plate.Size = new Size(48, 84); + plate.Quadrant = 2; + break; + case "middle": + var separator = nest.CreatePlate(); + separator.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); + nest.CreatePlate(); + break; + case "removed": + plate.SetClampRail(nest.PlateDefaults.ClampRail, new[] { "clamp-1" }); + break; + case "cutOff": + plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); + break; + case "explicit": + plate.SetClampRail(null!); + plate.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1))); + break; + } + var oldSize = plate.Size; + var oldQuadrant = plate.Quadrant; + var oldZones = plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)).ToArray(); + using var manager = new PlateManager(nest); + + manager.ChangePlateDefaults(() => + { + nest.PlateDefaults.Size = new Size(48, 60); + nest.PlateDefaults.Quadrant = 3; + }); + + Assert.Equal(oldSize, plate.Size); + Assert.Equal(oldQuadrant, plate.Quadrant); + var reopened = RoundTrip(nest); + Assert.Equal(configuration == "middle" ? 3 : 2, reopened.Plates.Count); + var kept = reopened.Plates[1]; + Assert.Equal(oldSize, kept.Size); + Assert.Equal(oldQuadrant, kept.Quadrant); + Assert.Equal(oldZones, kept.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width))); + if (configuration == "removed") + Assert.Equal(new[] { "clamp-1" }, kept.RemovedClamps); + if (configuration == "cutOff") + Assert.Single(kept.CutOffs); + if (configuration == "explicit") + Assert.Null(kept.ClampRail); + } + + [Fact] + public void SavedZonesThatDifferFromTheRailArePreservedExactly() + { + var nest = RailNest(withEmptyTail: false); + var plate = nest.Plates[0]; + plate.SetClampRail(null!); + plate.ClampZones.Add(new ClampZone("clamp-1", new Box(5.5, 0, 1.25, 0.5))); + + var reopenedPlate = Assert.Single(RoundTrip(nest).Plates); + + var zone = Assert.Single(reopenedPlate.ClampZones); + Assert.Equal(5.5, zone.Bounds.X); + Assert.Equal(1.25, zone.Bounds.Length); + Assert.Null(reopenedPlate.ClampRail); + } + + [Fact] + public void NestWithoutRailWritesNoRailKeyAndReadsNone() + { + var nest = new Nest("plain"); + var plate = new Plate(48, 96); + plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); + nest.Plates.Add(plate); + var stream = Write(nest); + + Assert.DoesNotContain("clampRail", ReadNestJson(stream), StringComparison.Ordinal); + stream.Position = 0; + var reopened = new NestReader(stream).Read(); + Assert.Null(reopened.PlateDefaults.ClampRail); + Assert.Empty(reopened.CreatePlate().ClampZones); + } + + [Fact] + public void WriterEmitsCamelCaseRailOnPlateDefaults() + { + var json = ReadNestJson(Write(RailNest())); + + Assert.Contains("\"clampRail\": {", json, StringComparison.Ordinal); + Assert.Contains("\"depth\": 0.625", json, StringComparison.Ordinal); + Assert.Contains("\"centers\": [", json, StringComparison.Ordinal); + } + + [Theory] + [InlineData("\"width\": 1.5", "\"width\": -1.5")] + [InlineData("\"centers\": [", "\"centers\": [-6, ")] + [InlineData("\"width\": 1.5", "\"width\": \"bad\"")] + [InlineData("\"width\": 1.5", "\"width\": null")] + [InlineData("\"centers\": [", "\"centers\": [\"bad\", ")] + [InlineData("\"centers\": [", "\"centers\": [null, ")] + [InlineData("\"centers\": [", "\"centers\": 6, \"unused\": [")] + [InlineData("\"clampRail\": {", "\"clampRail\": [6], \"unused\": {")] + public void MalformedRailIsIgnoredWhileTheNestLoads(string find, string replace) + { + var tampered = RewriteNestJson(Write(RailNest()), json => + { + var start = json.IndexOf("\"clampRail\"", StringComparison.Ordinal); + Assert.True(start >= 0); + var at = json.IndexOf(find, start, StringComparison.Ordinal); + Assert.True(at >= 0); + return json[..at] + replace + json[(at + find.Length)..]; + }); + + var reopened = new NestReader(tampered).Read(); + + Assert.Null(reopened.PlateDefaults.ClampRail); + var plate = Assert.Single(reopened.Plates); + Assert.Equal(7, plate.ClampZones.Count); // saved zones still load, explicitly + Assert.Null(plate.ClampRail); + } + + // D9: the rail's removal-minutes setting round-trips; a file without it defaults. + + [Fact] + public void RemovalMinutesRoundTrips() + { + var nest = new Nest("railed"); + nest.PlateDefaults.Size = new Size(48, 96); + nest.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters, removalMinutes: 8)); + + var reopened = RoundTrip(nest); + + Assert.Equal(8, reopened.PlateDefaults.ClampRail.RemovalMinutes); + } + + [Fact] + public void MissingRemovalMinutesDefaultsOnLoad() + { + // The writer always emits removalMinutes; simulate an older file that predates it by + // stripping the key directly rather than relying on exact JSON formatting elsewhere. + var json = ReadNestJson(Write(RailNest())); + Assert.Contains("\"removalMinutes\"", json, StringComparison.Ordinal); + + var withoutKey = RewriteNestJson(Write(RailNest()), + text => RemoveJsonProperty(text, "removalMinutes")); + + var reopened = new NestReader(withoutKey).Read(); + + Assert.Equal(ClampRail.DefaultRemovalMinutes, reopened.PlateDefaults.ClampRail.RemovalMinutes); + } + + // D8/C13: last-resort per-sheet clamp removal persists and keeps following the rail. + + [Fact] + public void RemovedClampsRoundTripAndTheReducedRailStillFollowsResizes() + { + var nest = new Nest("railed"); + nest.PlateDefaults.Size = new Size(48, 120); + nest.SetClampRail(Machine()); + var plate = nest.CreatePlate(); + plate.SetClampRail(Machine(), new[] { "clamp-3" }); + + var reopened = RoundTrip(nest); + var reopenedPlate = Assert.Single(reopened.Plates); + + Assert.Equal(7, reopenedPlate.ClampZones.Count); + Assert.DoesNotContain(reopenedPlate.ClampZones, z => z.Id == "clamp-3"); + Assert.Equal(new[] { "clamp-3" }, reopenedPlate.RemovedClamps); + Assert.Same(reopened.PlateDefaults.ClampRail, reopenedPlate.ClampRail); // still rail-following + + // The plate still follows the rail minus its removed clamp after a resize (C13). + reopenedPlate.Size = new Size(48, 60); + Assert.Equal(new[] { "clamp-1", "clamp-2", "clamp-4" }, reopenedPlate.ClampZones.Select(z => z.Id)); + } + + [Fact] + public void AnEmptyPlateWithOnlyRemovedClampsIsNotDropped() + { + // Every clamp removed: ClampZones is empty, but the removal state must still be saved. + var nest = new Nest("railed"); + nest.PlateDefaults.Size = new Size(48, 96); + nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0 })); + var plate = nest.CreatePlate(); + plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0 }), new[] { "clamp-1" }); + Assert.Empty(plate.ClampZones); + + var reopened = RoundTrip(nest); + + var kept = Assert.Single(reopened.Plates); + Assert.Equal(new[] { "clamp-1" }, kept.RemovedClamps); + } + + [Fact] + public void LegacyFileWithoutRemovedClampsKeyLoadsWithNoneRemoved() + { + // Sheets without a removal are written without the key, exactly like a file from + // before the feature. + var withoutKey = Write(RailNest()); + Assert.DoesNotContain("\"removedClamps\"", ReadNestJson(withoutKey), StringComparison.Ordinal); + + var reopened = new NestReader(withoutKey).Read(); + + var plate = Assert.Single(reopened.Plates); + Assert.Empty(plate.RemovedClamps); + Assert.Equal(7, plate.ClampZones.Count); + } + + // Slice 6 task 6.4 (Slice 3b quality review): optional removal fields read leniently + // and are only written when there is something to say. + + private static MemoryStream RemovedClamp3Nest() + { + var nest = new Nest("railed"); + nest.PlateDefaults.Size = new Size(48, 120); + nest.SetClampRail(Machine()); + var plate = nest.CreatePlate(); + plate.SetClampRail(Machine(), new[] { "clamp-3" }); + return Write(nest); + } + + private static MemoryStream WithPlateProperty(MemoryStream file, string name, string json) => + RewriteNestJson(file, text => + { + var root = JsonNode.Parse(text)!; + root["plates"]![0]![name] = JsonNode.Parse(json); + return root.ToJsonString(); + }); + + // PM 9f3d676b: a wrongly shaped removedClamps value must not fail the whole nest. + [Theory] + [InlineData("\"clamp-3\"", false)] + [InlineData("[-1]", false)] + [InlineData("{\"id\": \"clamp-3\"}", false)] + [InlineData("[-1, \"clamp-3\"]", true)] + public void MalformedRemovedClampsLoadWithoutFailingTheNest(string value, bool keepsRemoval) + { + var tampered = WithPlateProperty(RemovedClamp3Nest(), "removedClamps", value); + + var plate = Assert.Single(new NestReader(tampered).Read().Plates); + + // The saved zones always load, so the removed clamp's area is never re-blocked or lost. + Assert.Equal(7, plate.ClampZones.Count); + Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-3"); + Assert.Equal(keepsRemoval, plate.RemovedClamps.Contains("clamp-3")); + Assert.Equal(keepsRemoval, plate.ClampRail != null); + } + + // PM 6ded8ac4 (b): an invalid removal time falls back to the default instead of + // dropping the rail and every sheet's removal state with it. + [Theory] + [InlineData("-1")] + [InlineData("\"x\"")] + public void InvalidRemovalMinutesFallBackToTheDefault(string minutes) + { + var tampered = RewriteNestJson(RemovedClamp3Nest(), text => + { + var root = JsonNode.Parse(text)!; + root["plateDefaults"]!["clampRail"]!["removalMinutes"] = JsonNode.Parse(minutes); + return root.ToJsonString(); + }); + + var reopened = new NestReader(tampered).Read(); + + Assert.NotNull(reopened.PlateDefaults.ClampRail); + Assert.Equal(ClampRail.DefaultRemovalMinutes, reopened.PlateDefaults.ClampRail.RemovalMinutes); + var plate = Assert.Single(reopened.Plates); + Assert.Equal(new[] { "clamp-3" }, plate.RemovedClamps); + Assert.NotNull(plate.ClampRail); + } + + // PM 6ded8ac4 (c): sheets without a removal write no removedClamps key at all. + [Fact] + public void SheetsWithoutRemovedClampsWriteNoRemovedClampsKey() + { + var json = ReadNestJson(Write(RailNest())); + + Assert.DoesNotContain("\"removedClamps\"", json, StringComparison.Ordinal); + Assert.Contains("\"removedClamps\"", ReadNestJson(RemovedClamp3Nest()), StringComparison.Ordinal); + } + + // PM 22daec3d: a sheet following a rail other than the nest default keeps its rail, + // removed clamps and removal time through save/reload. + [Fact] + public void SheetRailDifferentFromTheDefaultsSurvivesReload() + { + var nest = new Nest("no-default-rail"); + nest.PlateDefaults.Size = new Size(48, 120); + var plate = nest.CreatePlate(); + plate.SetClampRail(Machine(), new[] { "clamp-3" }); + + var reopened = Assert.Single(RoundTrip(nest).Plates); + + Assert.Equal(Machine(), reopened.ClampRail); + Assert.Equal(new[] { "clamp-3" }, reopened.RemovedClamps); + Assert.Equal(ClampRail.DefaultRemovalMinutes, reopened.ClampRemovalMinutes); + Assert.Null(RoundTrip(nest).PlateDefaults.ClampRail); + } + + [Fact] + public void SheetsFollowingTheDefaultRailWriteNoPerSheetRail() + { + var json = ReadNestJson(RemovedClamp3Nest()); + var root = JsonNode.Parse(json)!; + Assert.Null(root["plates"]![0]!["clampRail"]); + } + + // PM 455f6763 (C17): an empty rail sheet whose spacing differs from the defaults is + // configured stock, not the editor's sentinel, and is written. + [Theory] + [InlineData(0.5, 0.0)] + [InlineData(0.0, 0.125)] + public void EmptyRailSheetWithOtherSpacingIsKept(double partSpacing, double edge) + { + var nest = new Nest("railed"); + nest.PlateDefaults.Size = new Size(48, 96); + nest.SetClampRail(Machine()); + var plate = nest.CreatePlate(); + plate.PartSpacing = partSpacing; + plate.EdgeSpacing = new Spacing(edge, edge, edge, edge); + + var kept = Assert.Single(RoundTrip(nest).Plates); + + Assert.Equal(partSpacing, kept.PartSpacing); + Assert.Equal(edge, kept.EdgeSpacing.Left); + } + + // Delta review: a sheet whose rail differs from the defaults only in removal time is + // configured stock too (ChangePlateDefaults leaves it alone) and must be written. + [Fact] + public void EmptySheetOnARailWithOtherRemovalTimeIsKept() + { + var nest = new Nest("railed"); + nest.PlateDefaults.Size = new Size(48, 96); + nest.SetClampRail(Machine()); + var plate = nest.CreatePlate(); + plate.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters, removalMinutes: 7)); + + var kept = Assert.Single(RoundTrip(nest).Plates); + + Assert.Equal(7, kept.ClampRail.RemovalMinutes); + } + + /// Removes a named property wherever it appears in the document, simulating a + /// key an older writer never emitted. Rebuilds via so the result + /// is always valid JSON regardless of whether the property was first, last or only one + /// in its object - unlike string surgery, which breaks on a dangling comma. + private static string RemoveJsonProperty(string json, string name) + { + var root = JsonNode.Parse(json)!; + var removed = RemovePropertyRecursive(root, name); + Assert.True(removed, $"Property '{name}' was not found to remove."); + return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + private static bool RemovePropertyRecursive(JsonNode node, string name) + { + switch (node) + { + case JsonObject obj: + var removed = obj.Remove(name); + foreach (var child in obj.ToList()) + if (child.Value != null && RemovePropertyRecursive(child.Value, name)) + removed = true; + return removed; + case JsonArray arr: + var any = false; + foreach (var item in arr) + if (item != null && RemovePropertyRecursive(item, name)) + any = true; + return any; + default: + return false; + } + } + + private static string ReadNestJson(MemoryStream zipBytes) + { + zipBytes.Position = 0; + using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true); + var entry = archive.GetEntry("nest.json"); + Assert.NotNull(entry); + using var reader = new StreamReader(entry.Open(), Encoding.UTF8); + return reader.ReadToEnd(); + } + + private static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func rewrite) + { + zipBytes.Position = 0; + var output = new MemoryStream(); + using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true)) + using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var entry in source.Entries) + { + var newEntry = target.CreateEntry(entry.FullName); + using var input = entry.Open(); + using var outStream = newEntry.Open(); + if (entry.FullName == "nest.json") + { + using var text = new StreamReader(input, Encoding.UTF8); + using var writer = new StreamWriter(outStream, new UTF8Encoding(false)); + writer.Write(rewrite(text.ReadToEnd())); + } + else + { + input.CopyTo(outStream); + } + } + } + output.Position = 0; + return output; + } +} diff --git a/OpenNest.IO.Tests/ClampZonePersistenceTests.cs b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs new file mode 100644 index 0000000..7e5fc61 --- /dev/null +++ b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs @@ -0,0 +1,176 @@ +using System.IO.Compression; +using System.Text; +using OpenNest.Geometry; + +namespace OpenNest.IO.Tests; + +/// +/// Clamp zones survive the .nest round trip; files without the "clamps" key load +/// unchanged with zero zones, and a clamp-carrying sheet is never dropped by the +/// empty-plate filter unless the nest's saved rail recreates exactly those zones +/// (see ClampRailPersistenceTests). Malformed zones are skipped rather than poisoning the file. +/// +public class ClampZonePersistenceTests +{ + private static Nest NestWithClamps(int zoneCount) + { + var nest = new Nest("clamped"); + var plate = new Plate(48, 96); + for (var i = 0; i < zoneCount; i++) + plate.ClampZones.Add(new ClampZone($"clamp-{i}", new Box(2.25 + 10 * i, 0, 1.5, 0.625))); + nest.Plates.Add(plate); + return nest; + } + + private static Nest RoundTrip(Nest nest) + { + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + return new NestReader(stream).Read(); + } + + [Fact] + public void ZonesRoundTripWithIdsAndPositions() + { + var reopened = RoundTrip(NestWithClamps(8)); + + var plate = Assert.Single(reopened.Plates); + Assert.Equal(8, plate.ClampZones.Count); + for (var i = 0; i < 8; i++) + { + var zone = plate.ClampZones[i]; + Assert.Equal($"clamp-{i}", zone.Id); + Assert.Equal(2.25 + 10 * i, zone.Bounds.X, 9); + Assert.Equal(0, zone.Bounds.Y, 9); + Assert.Equal(1.5, zone.Bounds.Length, 9); + Assert.Equal(0.625, zone.Bounds.Width, 9); + } + } + + [Fact] + public void ClampOnlyPlateSurvivesTheEmptyPlateFilter() + { + // A sheet with no parts and no cut-offs but with clamp definitions is a real + // configuration (the machine rail exists even on an empty plate) and must persist. + var reopened = RoundTrip(NestWithClamps(3)); + + var plate = Assert.Single(reopened.Plates); + Assert.Empty(plate.Parts); + Assert.Equal(3, plate.ClampZones.Count); + } + + [Fact] + public void EmptyPlateWithoutClampsIsStillOmitted() + { + var nest = new Nest("empty"); + nest.Plates.Add(new Plate(48, 96)); + var reopened = RoundTrip(nest); + Assert.Empty(reopened.Plates); + } + + [Fact] + public void ZonesCoexistWithPlacementsAndCutOffs() + { + var nest = new Nest("mixed"); + var plate = new Plate(48, 96); + plate.ClampZones.Add(new ClampZone("clamp-0", new Box(5.25, 0, 1.5, 0.625))); + plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); + nest.Plates.Add(plate); + + var reopened = RoundTrip(nest); + + var reopenedPlate = Assert.Single(reopened.Plates); + Assert.Single(reopenedPlate.ClampZones); + Assert.Equal("clamp-0", reopenedPlate.ClampZones[0].Id); + Assert.Single(reopenedPlate.CutOffs); + } + + [Fact] + public void FileWithoutClampsKeyLoadsUnchanged() + { + // Files written before this feature have no "clamps" property at all. + var withZones = new MemoryStream(); + Assert.True(new NestWriter(NestWithClamps(2)).Write(withZones)); + var legacy = RewriteNestJson(withZones, + json => System.Text.RegularExpressions.Regex.Replace(json, @",\s*""clamps""\s*:\s*\[[^\]]*\]", "")); + + legacy.Position = 0; + var reopened = new NestReader(legacy).Read(); + + var plate = Assert.Single(reopened.Plates); + Assert.Empty(plate.Parts); + Assert.Empty(plate.ClampZones); + } + + [Fact] + public void MalformedZonesAreSkippedWithoutThrowing() + { + // Hand-edited sizes must not poison the file: drop bad entries, keep good ones. + var withZones = new MemoryStream(); + Assert.True(new NestWriter(NestWithClamps(1)).Write(withZones)); + var tampered = RewriteNestJson(withZones, json => json.Replace( + "\"clamps\": [", + "\"clamps\": [{\"id\":\"bad\",\"x\":0,\"y\":0,\"length\":-5,\"width\":0.625}," + + "{\"id\":\"\",\"x\":0,\"y\":0,\"length\":1.5,\"width\":0.625},") + ); + + tampered.Position = 0; + var reopened = new NestReader(tampered).Read(); + + var plate = Assert.Single(reopened.Plates); + var zone = Assert.Single(plate.ClampZones); + Assert.Equal("clamp-0", zone.Id); + } + + [Fact] + public void WriterEmitsCamelCaseClampsArray() + { + var withZones = new MemoryStream(); + Assert.True(new NestWriter(NestWithClamps(1)).Write(withZones)); + var json = ReadNestJson(withZones); + + Assert.Contains("\"clamps\": [", json, StringComparison.Ordinal); + Assert.Contains("\"id\": \"clamp-0\"", json, StringComparison.Ordinal); + Assert.Contains("\"length\": 1.5", json, StringComparison.Ordinal); + Assert.Contains("\"width\": 0.625", json, StringComparison.Ordinal); + } + + private static string ReadNestJson(MemoryStream zipBytes) + { + zipBytes.Position = 0; + using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true); + var entry = archive.GetEntry("nest.json"); + Assert.NotNull(entry); + using var reader = new StreamReader(entry.Open(), Encoding.UTF8); + return reader.ReadToEnd(); + } + + private static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func rewrite) + { + zipBytes.Position = 0; + var output = new MemoryStream(); + using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true)) + using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var entry in source.Entries) + { + var newEntry = target.CreateEntry(entry.FullName); + using var input = entry.Open(); + using var outStream = newEntry.Open(); + if (entry.FullName == "nest.json") + { + using var text = new StreamReader(input, Encoding.UTF8); + using var writer = new StreamWriter(outStream, new UTF8Encoding(false)); + writer.Write(rewrite(text.ReadToEnd())); + } + else + { + input.CopyTo(outStream); + } + } + } + output.Position = 0; + return output; + } +} diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index 39fa7db..51262f5 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Text.Json; +using System.Text.Json.Serialization; namespace OpenNest.IO { @@ -43,6 +45,131 @@ namespace OpenNest.IO public double PartSpacing { get; init; } public MaterialDto Material { get; init; } = new(); public SpacingDto EdgeSpacing { get; init; } = new(); + + /// + /// The nest's machine clamp rail, which new plates follow. Omitted when the nest + /// has none; older files and older readers simply have no rail. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(LenientClampRailConverter))] + public ClampRailDto ClampRail { get; init; } + } + + /// + /// Reads a malformed or wrongly typed clamp rail as no rail instead of failing the + /// whole nest; the reader still validates the values (sign, size) it receives. + /// + public sealed class LenientClampRailConverter : JsonConverter + { + public override ClampRailDto Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + using var document = JsonDocument.ParseValue(ref reader); + var rail = document.RootElement; + if ( + rail.ValueKind != JsonValueKind.Object + || !TryNumber(rail, "width", out var width) + || !TryNumber(rail, "depth", out var depth) + || !rail.TryGetProperty("centers", out var centers) + || centers.ValueKind != JsonValueKind.Array + ) + return null; + + var list = new List(); + foreach (var center in centers.EnumerateArray()) + { + if (center.ValueKind != JsonValueKind.Number || !center.TryGetDouble(out var x)) + return null; + list.Add(x); + } + // Missing or unreadable -> null; the reader then falls back to the default. + double? removalMinutes = TryNumber(rail, "removalMinutes", out var minutes) + ? minutes + : null; + return new ClampRailDto { Width = width, Depth = depth, Centers = list, RemovalMinutes = removalMinutes }; + } + + public override void Write( + Utf8JsonWriter writer, + ClampRailDto value, + JsonSerializerOptions options + ) + { + writer.WriteStartObject(); + writer.WriteNumber("width", value.Width); + writer.WriteNumber("depth", value.Depth); + writer.WriteStartArray("centers"); + foreach (var center in value.Centers ?? new List()) + writer.WriteNumberValue(center); + writer.WriteEndArray(); + if (value.RemovalMinutes is double removalMinutes) + writer.WriteNumber("removalMinutes", removalMinutes); + writer.WriteEndObject(); + } + + private static bool TryNumber(JsonElement rail, string name, out double value) + { + value = 0; + return rail.TryGetProperty(name, out var element) + && element.ValueKind == JsonValueKind.Number + && element.TryGetDouble(out value); + } + } + + /// + /// Reads an optional list of strings without failing the nest: a non-array value + /// reads as an empty list and non-string entries are skipped. + /// + public sealed class LenientStringListConverter : JsonConverter> + { + public override List Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + using var document = JsonDocument.ParseValue(ref reader); + var list = new List(); + if (document.RootElement.ValueKind != JsonValueKind.Array) + return list; + foreach (var element in document.RootElement.EnumerateArray()) + if (element.ValueKind == JsonValueKind.String) + list.Add(element.GetString()); + return list; + } + + public override void Write( + Utf8JsonWriter writer, + List value, + JsonSerializerOptions options + ) + { + writer.WriteStartArray(); + foreach (var item in value) + writer.WriteStringValue(item); + writer.WriteEndArray(); + } + } + + public record ClampRailDto + { + /// Clamp extent along the origin-side edge (X). + public double Width { get; init; } + + /// Clamp intrusion into the sheet from that edge (Y). + public double Depth { get; init; } + + /// Clamp centers along X, measured from the plate origin. + public List Centers { get; init; } = new(); + + /// + /// Minutes to remove one clamp and put it back after cutting (D9). Null in older + /// files and readers fall back to . + /// + public double? RemovalMinutes { get; init; } } public record DrawingDto @@ -71,6 +198,44 @@ namespace OpenNest.IO public CuttingParametersDto CuttingParameters { get; init; } public List Parts { get; init; } = new(); public List CutOffs { get; init; } = new(); + + /// + /// Machine clamp keep-out rectangles in plate coordinates. Missing in older + /// files, which load with no zones. + /// + public List Clamps { get; init; } = new(); + + /// + /// Rail clamp ids removed from this sheet as a last resort (D8). Omitted when none + /// were removed; missing or empty means none; already excludes them. Read + /// leniently: a wrongly shaped value reads as none removed, non-string entries + /// are skipped, and the saved zones load either way. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(LenientStringListConverter))] + public List RemovedClamps { get; init; } = new(); + + /// + /// The machine clamp rail this sheet follows, written only when it is not the + /// nest's default rail (otherwise the sheet re-attaches to that one on load). + /// Read leniently like . + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(LenientClampRailConverter))] + public ClampRailDto ClampRail { get; init; } + } + + public record ClampZoneDto + { + public string Id { get; init; } = ""; + public double X { get; init; } + public double Y { get; init; } + + /// Extent along X. + public double Length { get; init; } + + /// Extent along Y (depth from the y = 0 operator-side edge). + public double Width { get; init; } } public record PartDto diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 2c60753..e14c7f1 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -46,6 +46,9 @@ namespace OpenNest.IO var drawingMap = BuildDrawings(dto, programs, entitySets); ReadBestFits(drawingMap); var nest = BuildNest(dto, drawingMap); + // Nests converted from PEP kept etch marks only in the placed programs; give them + // back to the drawing so removing lead-ins does not remove the marks too. + DrawingMarkRecovery.Apply(nest); zipArchive.Dispose(); stream.Close(); @@ -327,6 +330,31 @@ namespace OpenNest.IO } } + /// + /// The saved machine clamp rail, or null when absent, empty or malformed (a bad rail + /// is ignored rather than failing the nest; each plate's saved zones still load). A + /// missing or invalid removal-minutes value falls back to . + /// + private static ClampRail ReadClampRail(ClampRailDto dto) + { + if (dto?.Centers == null || dto.Centers.Count == 0) + return null; + try + { + // An invalid removal time (negative or not finite) falls back to the default + // rather than dropping the rail, which would also discard every sheet's + // removed-clamp state. + var minutes = dto.RemovalMinutes is double m && double.IsFinite(m) && m >= 0 + ? m + : ClampRail.DefaultRemovalMinutes; + return new ClampRail(dto.Width, dto.Depth, dto.Centers, minutes); + } + catch (ArgumentException) + { + return null; + } + } + private Nest BuildNest(NestDto dto, Dictionary drawingMap) { var nest = new Nest(); @@ -363,6 +391,8 @@ namespace OpenNest.IO pd.EdgeSpacing.Right, pd.EdgeSpacing.Top ); + var clampRail = ReadClampRail(pd.ClampRail); + nest.PlateDefaults.ClampRail = clampRail; // Stock options: sheet sizes and salvage rate nest.SalvageRate = dto.SalvageRate; @@ -399,6 +429,37 @@ namespace OpenNest.IO plate.GrainAngle = p.GrainAngle; plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters); + // Clamp zones: older files have no key at all; an individually invalid + // entry is dropped rather than poisoning the whole nest. + if (p.Clamps != null) + { + foreach (var c in p.Clamps) + { + try + { + plate.ClampZones.Add( + new ClampZone(c.Id, new OpenNest.Geometry.Box(c.X, c.Y, c.Length, c.Width)) + ); + } + catch (ArgumentException) + { + // Invalid zone definition; ignore this one entry. + } + } + } + + // A plate follows the nest rail only when its saved zones are exactly the + // rail's for this sheet minus any saved removed-clamp ids (C13); otherwise + // the saved zones are kept as static, explicit data. + var removedClamps = p.RemovedClamps? + .Where(id => !string.IsNullOrEmpty(id)) + .ToHashSet(StringComparer.Ordinal) + ?? new HashSet(); + // A sheet saved with its own rail (not the nest default) follows that one. + var plateRail = ReadClampRail(p.ClampRail) ?? clampRail; + if (plateRail != null && plateRail.Matches(plate, removedClamps)) + plate.SetClampRail(plateRail, removedClamps); + for (var partIndex = 0; partIndex < p.Parts.Count; partIndex++) { var partDto = p.Parts[partIndex]; diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index e74c83b..fa96542 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -135,6 +135,7 @@ namespace OpenNest.IO Right = pd.EdgeSpacing.Right, Bottom = pd.EdgeSpacing.Bottom, }, + ClampRail = ToDto(pd.ClampRail), }; } @@ -197,6 +198,50 @@ namespace OpenNest.IO return list; } + /// + /// True when a plate created from the saved defaults would be this sheet's clamp + /// configuration: same size, quadrant, edge and part spacing, no last-resort + /// removal, not following another rail (removal time included), and zones the + /// saved rail recreates (C17: anything else is configured + /// stock and is written). + /// + private bool DefaultsRecreateClamps(Plate plate) + { + var defaults = nest.PlateDefaults; + return defaults.ClampRail is { } rail + && plate.RemovedClamps.Count == 0 + && plate.Size.Width == defaults.Size.Width + && plate.Size.Length == defaults.Size.Length + && plate.Quadrant == defaults.Quadrant + && plate.PartSpacing == defaults.PartSpacing + && plate.EdgeSpacing.Left == defaults.EdgeSpacing.Left + && plate.EdgeSpacing.Bottom == defaults.EdgeSpacing.Bottom + && plate.EdgeSpacing.Right == defaults.EdgeSpacing.Right + && plate.EdgeSpacing.Top == defaults.EdgeSpacing.Top + && (plate.ClampRail == null || Equals(plate.ClampRail, rail)) + && rail.Matches(plate); + } + + private static ClampRailDto ToDto(ClampRail rail) => + rail == null + ? null + : new ClampRailDto + { + Width = rail.Width, + Depth = rail.Depth, + Centers = rail.Centers.ToList(), + RemovalMinutes = rail.RemovalMinutes, + }; + + /// Numeric suffix of a rail clamp id ("clamp-3" -> 3), for a natural sort of + /// instead of lexical ("clamp-10" before "clamp-2"). + /// A non-matching id sorts last by its own ordinal text. + private static int ClampIndex(string id) => + id != null && id.StartsWith("clamp-", StringComparison.Ordinal) + && int.TryParse(id.AsSpan(6), out var index) + ? index + : int.MaxValue; + private List BuildPlateDtos() { var list = new List(); @@ -205,9 +250,17 @@ namespace OpenNest.IO { var plate = nest.Plates[i]; + // An empty plate is omitted unless that would lose a clamp configuration the + // saved defaults cannot recreate. PlateManager moves an untouched trailing + // sentinel to new defaults during an edit, so it remains omitted; configured + // nondefault clamp sheets are still written. if ( plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0 && plate.CutOffs.Count == 0 + && ( + (plate.ClampZones.Count == 0 && plate.RemovedClamps.Count == 0) + || DefaultsRecreateClamps(plate) + ) ) continue; @@ -283,6 +336,28 @@ namespace OpenNest.IO CutOffs = cutoffs, GrainAngle = plate.GrainAngle, CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters), + Clamps = plate.ClampZones + .Select(z => new ClampZoneDto + { + Id = z.Id, + X = z.Bounds.X, + Y = z.Bounds.Y, + Length = z.Bounds.Length, + Width = z.Bounds.Width, + }) + .ToList(), + // Omitted unless a clamp was removed (files without the feature + // stay unchanged). + RemovedClamps = plate.RemovedClamps.Count == 0 + ? null + : plate.RemovedClamps + .OrderBy(ClampIndex) + .ThenBy(id => id, StringComparer.Ordinal) + .ToList(), + // Only a rail other than the nest default needs saving per sheet. + ClampRail = Equals(plate.ClampRail, nest.PlateDefaults.ClampRail) + ? null + : ToDto(plate.ClampRail), } ); } diff --git a/OpenNest.Tests/ClampRailTests.cs b/OpenNest.Tests/ClampRailTests.cs new file mode 100644 index 0000000..c7f9873 --- /dev/null +++ b/OpenNest.Tests/ClampRailTests.cs @@ -0,0 +1,398 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +/// +/// The fixed machine clamp rail: X centers measured from the plate origin along the +/// origin-side edge, resolved onto every sheet size without re-scaling (D2/D3). +/// +public class ClampRailTests +{ + // The user's machine: ~8 clamps, 1.5" along the edge x 0.625" into the sheet. + private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 }; + + private static ClampRail Machine() => new(1.5, 0.625, MachineCenters); + + // Plate(width, length): Width is the Y extent, Length the X extent along the clamp edge. + private static Plate Sheet(double width, double length, int quadrant = 1) => + new(width, length) { Quadrant = quadrant }; + + [Theory] + [InlineData(0, 0.625)] + [InlineData(-1, 0.625)] + [InlineData(double.NaN, 0.625)] + [InlineData(double.PositiveInfinity, 0.625)] + [InlineData(1.5, 0)] + [InlineData(1.5, -0.5)] + [InlineData(1.5, double.NaN)] + public void NonPositiveOrNonFiniteSizesAreRejected(double width, double depth) + { + Assert.Throws(() => new ClampRail(width, depth, new[] { 6.0 })); + } + + [Theory] + [InlineData(-0.5)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void CentersMustBeFiniteAndFromTheOrigin(double center) + { + Assert.Throws(() => new ClampRail(1.5, 0.625, new[] { 6, center })); + Assert.Throws(() => new ClampRail(1.5, 0.625, null!)); + } + + [Fact] + public void CentersAreSortedAndDistinct() + { + var rail = new ClampRail(1.5, 0.625, new[] { 36.0, 6, 21, 6 }); + Assert.Equal(new[] { 6.0, 21, 36 }, rail.Centers); + } + + [Fact] + public void FullLengthSheetGetsEveryClampOnTheFrontEdge() + { + var zones = Machine().ResolveFor(new Size(60, 120), quadrant: 1); + + Assert.Equal(8, zones.Count); + for (var i = 0; i < zones.Count; i++) + { + Assert.Equal($"clamp-{i + 1}", zones[i].Id); + Assert.Equal(MachineCenters[i] - 0.75, zones[i].Bounds.X); + Assert.Equal(0, zones[i].Bounds.Y); + Assert.Equal(1.5, zones[i].Bounds.Length); + Assert.Equal(0.625, zones[i].Bounds.Width); + } + } + + [Fact] + public void ShorterSheetSkipsClampsBeyondItWithoutRescaling() + { + // D2: a 60" sheet keeps the clamps at their table positions and drops the rest. + var zones = Machine().ResolveFor(new Size(48, 60), quadrant: 1); + + Assert.Equal(new[] { "clamp-1", "clamp-2", "clamp-3", "clamp-4" }, zones.Select(z => z.Id)); + Assert.Equal(new[] { 5.25, 20.25, 35.25, 50.25 }, zones.Select(z => z.Bounds.X)); + } + + [Fact] + public void ClampReachingOntoTheSheetIsKeptWhole() + { + // A clamp is an avoidance area: if any of it reaches the sheet, all of it applies. + var rail = new ClampRail(1.5, 0.625, new[] { 59.5, 60.5, 60.75, 61 }); + + var zones = rail.ResolveFor(new Size(48, 60), quadrant: 1); + + // 59.5 covers [58.75, 60.25] and 60.5 still reaches the last 0.25" [59.75, 61.25]; + // 60.75 only touches the sheet end and 61 lies wholly beyond it. + Assert.Equal(new[] { "clamp-1", "clamp-2" }, zones.Select(z => z.Id)); + Assert.Equal(new[] { 58.75, 59.75 }, zones.Select(z => z.Bounds.X)); + Assert.All(zones, z => Assert.Equal(1.5, z.Bounds.Length)); + } + + [Fact] + public void ClampsNearTheOriginOrDeeperThanTheSheetStayWhole() + { + var rail = new ClampRail(1.5, 0.625, new[] { 0.25 }); + var origin = Assert.Single(rail.ResolveFor(new Size(48, 96), quadrant: 1)); + Assert.Equal(-0.5, origin.Bounds.X); + Assert.Equal(1.5, origin.Bounds.Length); + + var deep = new ClampRail(1.5, 10, new[] { 6.0 }); + var front = Assert.Single(deep.ResolveFor(new Size(4, 96), 1)); + Assert.Equal(0, front.Bounds.Y); + Assert.Equal(10, front.Bounds.Width); + // Sheets extending to -Y have the origin edge on top; the clamp hangs below the sheet. + var top = Assert.Single(deep.ResolveFor(new Size(4, 96), 3)); + Assert.Equal(4.0, top.Bounds.Top); + Assert.Equal(10, top.Bounds.Width); + } + + [Theory] + [InlineData(0, 96)] + [InlineData(48, 0)] + [InlineData(double.NaN, 96)] + public void DegenerateSheetsGetNoZones(double width, double length) + { + Assert.Empty(Machine().ResolveFor(new Size(width, length), quadrant: 1)); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ZonesSitOnTheOriginEdgeMeasuredFromTheOriginInEveryQuadrant(int quadrant) + { + var plate = Sheet(48, 96, quadrant); + plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0 })); + var zone = Assert.Single(plate.ClampZones); + + // Plate coordinates start at the sheet's lower-left corner; translating by the + // sheet's placement corner gives machine coordinates around the origin. + var sheet = plate.BoundingBox(includeParts: false); + var machine = zone.Bounds.Translate(sheet.X, sheet.Y); + var awayFromOrigin = quadrant is 1 or 4 ? 1 : -1; + var intoSheet = quadrant is 1 or 2 ? 1 : -1; + + Assert.Equal(6.0, System.Math.Abs(machine.Center.X), 12); + Assert.Equal(awayFromOrigin, System.Math.Sign(machine.Center.X)); + Assert.Equal(0.0, intoSheet > 0 ? machine.Bottom : machine.Top, 12); + Assert.Equal(0.625, machine.Width); + Assert.Equal(1.5, machine.Length, 12); + Assert.True(zone.Bounds.Left >= 0 && zone.Bounds.Right <= 96 + 1e-9); + Assert.True(zone.Bounds.Bottom >= 0 && zone.Bounds.Top <= 48 + 1e-9); + } + + [Fact] + public void PlateFollowsItsRailWhenResizedOrRotated() + { + var plate = Sheet(48, 120); + plate.SetClampRail(Machine()); + Assert.Equal(8, plate.ClampZones.Count); + Assert.True(plate.ClampRail.Matches(plate)); + + plate.Size = new Size(48, 60); + Assert.Equal(4, plate.ClampZones.Count); + + // Rotating swaps the sheet's extents; the machine's clamps stay where they are. + plate.Rotate90(RotationType.CW); + Assert.Equal(new Size(60, 48), plate.Size); + Assert.Equal(new[] { 5.25, 20.25, 35.25 }, plate.ClampZones.Select(z => z.Bounds.X)); + + plate.Quadrant = 2; + Assert.True(plate.ClampRail.Matches(plate)); + Assert.Equal(48 - 6.75, plate.ClampZones[0].Bounds.X); + } + + [Fact] + public void UnchangedResolutionRaisesNoEvents() + { + var plate = Sheet(48, 120); + plate.SetClampRail(Machine()); + var original = plate.ClampZones.ToList(); + var changes = 0; + plate.ClampZonesChanged += (_, _) => changes++; + + plate.Size = new Size(48, 120); + plate.Quadrant = 1; + plate.SetClampRail(Machine()); + + Assert.Equal(0, changes); + Assert.Equal(original, plate.ClampZones); // same zone instances + } + + [Fact] + public void DetachingTheRailRemovesItsZones() + { + var plate = Sheet(48, 120); + plate.SetClampRail(Machine()); + + plate.SetClampRail(null!); + + Assert.Null(plate.ClampRail); + Assert.Empty(plate.ClampZones); + plate.Size = new Size(48, 96); + Assert.Empty(plate.ClampZones); + } + + [Fact] + public void PlatesWithoutARailKeepExplicitZonesThroughResizes() + { + var plate = Sheet(48, 96); + plate.ClampZones.Add(new ClampZone("manual", new Box(5.25, 0, 1.5, 0.625))); + + plate.Size = new Size(48, 120); + plate.Quadrant = 3; + + Assert.Equal("manual", Assert.Single(plate.ClampZones).Id); + Assert.False(Machine().Matches(plate)); + } + + [Fact] + public void NewPlatesFollowTheNestRailAtTheirOwnSize() + { + var nest = new Nest(); + nest.PlateDefaults.Size = new Size(48, 60); + nest.SetClampRail(Machine()); + + var plate = nest.CreatePlate(); + + Assert.Same(nest.PlateDefaults.ClampRail, plate.ClampRail); + Assert.Equal(4, plate.ClampZones.Count); + plate.Size = new Size(60, 120); + Assert.Equal(8, plate.ClampZones.Count); + } + + [Fact] + public void NestRailReplacesEveryPlatesZones() + { + var nest = new Nest(); + nest.PlateDefaults.Size = new Size(48, 120); + var a = nest.CreatePlate(); + var b = nest.CreatePlate(); + b.Size = new Size(48, 60); + b.ClampZones.Add(new ClampZone("manual", new Box(1, 0, 1, 1))); + + nest.SetClampRail(Machine()); + Assert.Equal(8, a.ClampZones.Count); + Assert.Equal(4, b.ClampZones.Count); + Assert.DoesNotContain(b.ClampZones, z => z.Id == "manual"); + + // A rail without clamps is no rail: zones and the template are cleared everywhere. + nest.SetClampRail(new ClampRail(1.5, 0.625, Array.Empty())); + Assert.Null(nest.PlateDefaults.ClampRail); + Assert.All(nest.Plates, p => Assert.Empty(p.ClampZones)); + Assert.Empty(nest.CreatePlate().ClampZones); + } + + [Fact] + public void RailZonesStayOutOfQuantityAndUtilization() + { + var drawing = new Drawing("part", new OpenNest.CNC.Program()); + var nest = new Nest(); + nest.Drawings.Add(drawing); + nest.SetClampRail(Machine()); + var plate = nest.CreatePlate(); + + Assert.Equal(8, plate.ClampZones.Count); + Assert.Empty(plate.Parts); + Assert.Equal(0, plate.Utilization()); + nest.UpdateDrawingQuantities(); + Assert.Equal(0, drawing.Quantity.Nested); + } + + [Fact] + public void RailsCompareByValue() + { + Assert.Equal(Machine(), new ClampRail(1.5, 0.625, MachineCenters.Reverse())); + Assert.Equal(Machine().GetHashCode(), new ClampRail(1.5, 0.625, MachineCenters).GetHashCode()); + Assert.NotEqual(Machine(), new ClampRail(1.5, 0.5, MachineCenters)); + Assert.NotEqual(Machine(), new ClampRail(1.5, 0.625, MachineCenters.Take(7))); + } + + // D9: minutes to remove one clamp and put it back, a variable setting on the rail. + + [Fact] + public void RemovalMinutesDefaultsToThePlannerValue() + { + Assert.Equal(5.0, Machine().RemovalMinutes); + Assert.Equal(5.0, ClampRail.DefaultRemovalMinutes); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void RemovalMinutesMustBeFiniteAndZeroOrMore(double minutes) + { + Assert.Throws( + () => new ClampRail(1.5, 0.625, new[] { 6.0 }, minutes)); + } + + [Fact] + public void ZeroRemovalMinutesIsAllowed() + { + var rail = new ClampRail(1.5, 0.625, new[] { 6.0 }, removalMinutes: 0); + Assert.Equal(0, rail.RemovalMinutes); + } + + [Fact] + public void RailsWithDifferentRemovalMinutesAreNotEqual() + { + var fiveMinutes = new ClampRail(1.5, 0.625, MachineCenters, 5); + var tenMinutes = new ClampRail(1.5, 0.625, MachineCenters, 10); + Assert.NotEqual(fiveMinutes, tenMinutes); + Assert.NotEqual(fiveMinutes.GetHashCode(), tenMinutes.GetHashCode()); + } + + // D8/C13: last-resort per-sheet removal of individual rail clamps. + + [Fact] + public void RemovedClampsAreSubtractedFromTheRailResolution() + { + var plate = Sheet(48, 120); + plate.SetClampRail(Machine(), new[] { "clamp-3" }); + + Assert.Equal(7, plate.ClampZones.Count); + Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-3"); + Assert.Equal(new[] { "clamp-3" }, plate.RemovedClamps); + } + + [Fact] + public void RemovedClampStaysOffAfterResizeAndReappearsIfTheClampComesBackOntoTheSheet() + { + var plate = Sheet(48, 120); + plate.SetClampRail(Machine(), new[] { "clamp-8" }); // centered at 111" + + plate.Size = new Size(48, 60); // clamp-8 is beyond this sheet regardless + Assert.Equal(new[] { "clamp-1", "clamp-2", "clamp-3", "clamp-4" }, + plate.ClampZones.Select(z => z.Id)); + Assert.Equal(new[] { "clamp-8" }, plate.RemovedClamps); + + plate.Size = new Size(48, 120); // clamp-8 is back on the sheet, but still removed + Assert.Equal(7, plate.ClampZones.Count); + Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-8"); + } + + [Fact] + public void ClampRemovalMinutesSumsTheRailsPerClampTime() + { + var plate = Sheet(48, 120); + Assert.Equal(0, plate.ClampRemovalMinutes); // no rail yet + + plate.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters, removalMinutes: 7), + new[] { "clamp-3", "clamp-4" }); + Assert.Equal(14, plate.ClampRemovalMinutes); + + plate.SetClampRail(null!); + Assert.Equal(0, plate.ClampRemovalMinutes); // detaching forgets the removed set too + Assert.Empty(plate.RemovedClamps); + } + + [Fact] + public void SetClampRailWithoutRemovedClampsBehavesAsBefore() + { + var plate = Sheet(48, 120); + plate.SetClampRail(Machine()); + + Assert.Empty(plate.RemovedClamps); + Assert.Equal(8, plate.ClampZones.Count); + } + + [Fact] + public void MatchesWithRemovedClampsComparesAgainstTheReducedResolution() + { + var plate = Sheet(48, 120); + plate.SetClampRail(Machine(), new[] { "clamp-3" }); + + Assert.True(Machine().Matches(plate, new[] { "clamp-3" })); + Assert.False(Machine().Matches(plate)); // full resolution: zones don't match + Assert.False(Machine().Matches(plate, Array.Empty())); + Assert.False(Machine().Matches(plate, new[] { "clamp-4" })); + } + // PM 6ded8ac4 (a): only removed clamps that are actually on this sheet cost time. + [Fact] + public void RemovalTimeCountsOnlyRemovedClampsOnTheSheet() + { + var plate = Sheet(48, 120); + plate.SetClampRail(Machine(), new[] { "clamp-3", "clamp-8", "clamp-99" }); + Assert.Equal(2 * ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes); // 3 and 8 + + plate.Size = new Size(48, 60); // clamp-8 is now beyond the sheet + Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes); + Assert.Contains("clamp-8", plate.RemovedClamps); // still listed (C13) + } + + // PM 6ded8ac4 (e): the removed set is read-only, even through a downcast, and an + // untouched plate does not share a mutable empty set with every other plate. + [Fact] + public void RemovedClampsCannotBeMutatedThroughTheCollection() + { + var removed = Sheet(48, 120); + removed.SetClampRail(Machine(), new[] { "clamp-3" }); + var untouched = Sheet(48, 120); + + Assert.True(((ICollection)removed.RemovedClamps).IsReadOnly); + Assert.True(((ICollection)untouched.RemovedClamps).IsReadOnly); + } +} diff --git a/OpenNest.Tests/ClampZoneTests.cs b/OpenNest.Tests/ClampZoneTests.cs new file mode 100644 index 0000000..e720f92 --- /dev/null +++ b/OpenNest.Tests/ClampZoneTests.cs @@ -0,0 +1,133 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +/// +/// Rectangular machine-clamp keep-out zones: a first-class rectangle on the plate, +/// never a part. Zones are exclusion and display only - quantity, utilization and +/// cut accounting must never see them. +/// +public class ClampZoneTests +{ + private static ClampZone Valid(string id = "clamp-1") => + new(id, new Box(5.25, 0, 1.5, 0.625)); + + [Fact] + public void ZoneCarriesIdAndBounds() + { + var zone = Valid(); + Assert.Equal("clamp-1", zone.Id); + Assert.Equal(5.25, zone.Bounds.X); + Assert.Equal(0, zone.Bounds.Y); + Assert.Equal(1.5, zone.Bounds.Length); + Assert.Equal(0.625, zone.Bounds.Width); + } + + [Fact] + public void ZoneOwnsADetachedCopyOfItsBounds() + { + var bounds = new Box(5.25, 0, 1.5, 0.625); + var zone = new ClampZone("clamp", bounds); + bounds.Offset(100, 100); + Assert.Equal(5.25, zone.Bounds.X); + Assert.Equal(0, zone.Bounds.Y); + } + + [Theory] + [InlineData(double.NaN, 0, 1.5, 0.625)] + [InlineData(0, double.PositiveInfinity, 1.5, 0.625)] + [InlineData(0, 0, double.NaN, 0.625)] + [InlineData(0, 0, 1.5, 0)] // zero-size zone keeps out nothing + [InlineData(0, 0, -1.5, 0.625)] // negative size is not a rectangle + [InlineData(0, 0, 1.5, -0.625)] + public void MalformedBoundsAreRejected(double x, double y, double length, double width) + { + Assert.Throws(() => new ClampZone("clamp", new Box(x, y, length, width))); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void MissingIdIsRejected(string? id) + { + Assert.ThrowsAny( + () => new ClampZone(id!, new Box(0, 0, 1.5, 0.625)) + ); + } + + [Fact] + public void NullBoundsRejected() + { + Assert.Throws(() => new ClampZone("clamp", null!)); + } + + [Fact] + public void PlateStartsEmptyAndKeepsIndependentLists() + { + var a = new Plate(48, 96); + var b = new Plate(48, 96); + Assert.Empty(a.ClampZones); + a.ClampZones.Add(Valid()); + Assert.Single(a.ClampZones); + Assert.Empty(b.ClampZones); + } + + [Fact] + public void ZoneChangesRaiseClampZonesChanged() + { + var plate = new Plate(48, 96); + var raised = 0; + plate.ClampZonesChanged += (_, _) => raised++; + + var zone = Valid(); + plate.ClampZones.Add(zone); + Assert.Equal(1, raised); + + plate.ClampZones.Remove(zone); + Assert.Equal(2, raised); + } + + [Fact] + public void ZonesAreInvisibleToQuantityAndUtilization() + { + var plate = new Plate(48, 96) { Quantity = 1 }; + for (var i = 0; i < 8; i++) + plate.ClampZones.Add(new ClampZone($"clamp-{i}", new Box(i * 2.0, 0, 1.5, 0.625))); + + var drawing = MakeRectDrawing(10, 5); + drawing.Quantity.Required = 4; + var part = new Part(drawing); + plate.Parts.Add(part); + + // The single placed part accounts exactly as it does on a plate without clamps. + Assert.Equal(1, drawing.Quantity.Nested); + Assert.Single(plate.Parts); // clamp zones never enter Parts + Assert.Equal(drawing.Area / plate.Area(), plate.Utilization(), 9); + + plate.Parts.Remove(part); + Assert.Equal(0, drawing.Quantity.Nested); + } + + [Fact] + public void ZonesNeverEnterCutSequenceOrPartList() + { + var plate = new Plate(48, 96); + plate.ClampZones.Add(Valid()); + Assert.All(plate.Parts, p => Assert.False(p.BaseDrawing.IsCutOff)); + Assert.Empty(plate.Parts); + Assert.DoesNotContain(plate.ClampZones, z => ReferenceEquals(z, null)); + } + + private static Drawing MakeRectDrawing(double length, double width) + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(length, 0))); + pgm.Codes.Add(new LinearMove(new Vector(length, width))); + pgm.Codes.Add(new LinearMove(new Vector(0, width))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + return new Drawing("rect", pgm); + } +} diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs index 73838ad..51612f1 100644 --- a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs @@ -189,6 +189,171 @@ public class AutomaticCutOffPlannerTests Assert.Equal(80.5 + Tolerance.Epsilon, plan.UsedSpan); } + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ClampWall_SkipsNominalLineAndRefusesTailSeparator(int quadrant) + { + var plate = MakePlate(quadrant); + var sign = quadrant is 2 or 3 ? -1 : 1; + plate.EdgeSpacing = new Spacing(0.25, 0.25); + // Real rail zones are stored in the sheet's lower-left frame in every quadrant. + plate.SetClampRail(new ClampRail(0.2, 0.625, new[] { 35.2, 80.7 })); + var before = plate.Parts.ToArray(); + var plan = Plan(plate); + Assert.Equal(new[] { sign * 70.0 }, Positions(plan)); + Assert.False(plan.HasSeparatedTail); + Assert.Equal(0, plan.TailLength); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + Assert.Equal(before, plate.Parts.ToArray()); + Assert.Empty(plate.CutOffs); + } + + [Fact] + public void ClampOnlyBlocksItsInflatedSpan_AndRemovedClampStopsBlocking() + { + var plate = MakePlate(); + plate.EdgeSpacing = new Spacing(0.25, 0.25); + plate.ClampZones.Add(new ClampZone("front", new Box(35.25, 0, 1.5, 0.625))); + var blocked = Plan(plate); + Assert.Equal(new[] { 70, 80.5 + Tolerance.Epsilon }, Positions(blocked)); + Assert.True(blocked.HasSeparatedTail); + plate.ClampZones.Clear(); + Assert.Equal(new[] { 35, 70, 80.5 + Tolerance.Epsilon }, Positions(Plan(plate))); + } + + [Fact] + public void ExistingLineAcrossClampCannotCertifyTail_AndPartialFootprintBlocks() + { + var plate = MakePlate(); + plate.EdgeSpacing = new Spacing(0.25, 0.25); + plate.ClampZones.Add(new ClampZone("hanging", new Box(80.6, 0, 0.2, 0.625))); + var existing = new CutOff(new Vector(80.5 + Tolerance.Epsilon, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(existing); + existing.Regenerate(plate, new CutOffSettings()); + var original = existing.Drawing.Program; + var plan = Plan(plate); + Assert.False(plan.HasSeparatedTail); + Assert.Null(plan.TailSeparatorX); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + Assert.Same(original, existing.Drawing.Program); + Assert.Same(existing, Assert.Single(plate.CutOffs)); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void NearEquivalentExistingSeparatorInsideClampCannotCertifyTail(int quadrant) + { + // The nominal separator (80.50001) lies just beyond the clamp edge (80.5000075), + // but the equivalent existing line that would be certified (80.500005) is inside it. + var plate = MakePlate(quadrant); + var sign = quadrant is 2 or 3 ? -1 : 1; + // Footprint 79.500005..80.5000075 from the origin along the edge, in every quadrant. + plate.SetClampRail(new ClampRail(1.0000025, 0.625, new[] { 80.00000625 })); + var existing = new CutOff(new Vector(sign * 80.500005, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(existing); + existing.Regenerate(plate, new CutOffSettings()); + var original = existing.Drawing.Program; + + var plan = Plan(plate); + + Assert.False(plan.HasSeparatedTail); + Assert.Null(plan.TailSeparatorX); + Assert.Equal(0, plan.TailLength); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff); + Assert.Equal(new[] { sign * 35.0, sign * 70.0 }, Positions(plan)); + Assert.Same(original, existing.Drawing.Program); + Assert.Same(existing, Assert.Single(plate.CutOffs)); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void RailClampsBlockGridAndTailInEveryQuadrant(int quadrant) + { + // Rail clamps centered 35 and 80.5 from the origin, 1.5 wide, plus 0.25 clearance: + // the grid line at 35 and the separator at 80.50001 both cross a clamp. + var plate = MakePlate(quadrant); + var sign = quadrant is 2 or 3 ? -1 : 1; + plate.EdgeSpacing = new Spacing(0.25, 0.25); + plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 35.0, 80.5 })); + + var plan = Plan(plate); + + Assert.Equal(new[] { sign * 70.0 }, Positions(plan)); + Assert.False(plan.HasSeparatedTail); + Assert.Equal(2, plan.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut)); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void RailClampClearanceOnlyContactBlocksAndClearLineSurvives(int quadrant) + { + // Physical footprint 36..37.5; the 0.25 clearance reaches a line at 35.75 but not 35. + var plate = MakePlate(quadrant); + var sign = quadrant is 2 or 3 ? -1 : 1; + plate.EdgeSpacing = new Spacing(0.25, 0.25); + plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 36.75 })); + Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * (80.5 + Tolerance.Epsilon) }, + Positions(Plan(plate))); + Assert.Equal(new[] { sign * 71.5, sign * (80.5 + Tolerance.Epsilon) }, + Positions(Plan(plate, spacing: 35.75))); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void HangingRailClampKeepsWholeFootprintForTail(int quadrant) + { + // Clamp centered 120.5 on a 120-long sheet covers 119.75..121.25 from the origin; + // with 0.25 clearance it blocks the separator at 119.50001 behind a 119-long envelope. + var plate = MakePlate(quadrant, occupied: 119); + plate.EdgeSpacing = new Spacing(0.25, 0.25); + plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 120.5 })); + var plan = Plan(plate, spacing: 119.6); + Assert.False(plan.HasSeparatedTail); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut); + Assert.Empty(plan.Definitions); + } + + [Theory] + [InlineData(35)] + [InlineData(80.50001)] + public void SameLineLimitedManualCutOnClamp_StillBlocksWholePlan(double x) + { + var plate = MakePlate(); + plate.ClampZones.Add(new ClampZone("under-manual", new Box(x - 0.75, 0, 1.5, 0.625))); + var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical) { StartLimit = 10, EndLimit = 60 }; + plate.CutOffs.Add(manual); + var program = manual.Drawing.Program; + + var plan = Plan(plate); + + Assert.True(plan.HasBlockingDiagnostics); + Assert.Empty(plan.Definitions); + Assert.Empty(plan.PreviewParts); + Assert.False(plan.HasSeparatedTail); + Assert.Contains(plan.Diagnostics, d => d.IsBlocking && + d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict); + Assert.Same(program, manual.Drawing.Program); + Assert.Same(manual, Assert.Single(plate.CutOffs)); + } + [Fact] public void Planning_DoesNotMutateAnyLiveObjectsOrSequence() { @@ -548,7 +713,7 @@ public class AutomaticCutOffPlannerTests } [Fact] - public void ToleranceCloseExistingSeparator_ThatStillTouchesClearance_DoesNotClaimTail() + public void NearEquivalentExistingSeparator_ThatStillTouchesClearance_DoesNotClaimTail() { var plate = MakePlate(); plate.PartSpacing = 0; diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffShiftClampTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftClampTests.cs new file mode 100644 index 0000000..60703e5 --- /dev/null +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftClampTests.cs @@ -0,0 +1,175 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CutOffs; + +public class AutomaticCutOffShiftClampTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ClampAroundNominalLine_MovesInsteadOfSkipping(int quadrant) + { + var plate = AutomaticCutOffShiftTests.UserCase(quadrant); + plate.EdgeSpacing = new Spacing(0, 0.25, 0, 0); + var rail = new ClampRail(2, 0.625, new[] { 35.0 }); + plate.SetClampRail(rail); + var zones = plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)).ToArray(); + var parts = plate.Parts.ToArray(); + var plan = AutomaticCutOffPlanner.Create(plate, AutomaticCutOffShiftTests.Options(), new CutOffSettings()); + + Assert.False(plan.HasBlockingDiagnostics); + Assert.Equal(3, plan.Definitions.Count); + Assert.True(System.Math.Abs(plan.Definitions[0].Position.X) < 33.75); + Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ShiftedCut); + AutomaticCutOffShiftTests.AssertLimits(plan, 35, 17.5); + AssertClearsPhysicalFootprints(plate, rail, plan); + Assert.Equal(zones, plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width))); + Assert.Equal(parts, plate.Parts.ToArray()); + Assert.Empty(plate.CutOffs); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ClampClearanceOnly_ExcludesOtherwiseOptimalCandidate(int quadrant) + { + var plate = AutomaticCutOffShiftTests.UserCase(quadrant); + var rail = new ClampRail(0.2, 0.625, new[] { 35.2 }); + plate.SetClampRail(rail); + var settings = new CutOffSettings(); + var options = AutomaticCutOffShiftTests.Options(); + var withoutClearance = AutomaticCutOffPlanner.Create(plate, options, settings); + var first = System.Math.Abs(withoutClearance.Definitions[0].Position.X); + Assert.InRange(first, 34.69, 34.71); + Assert.True(first < rail.Centers[0] - rail.Width / 2); + + // Bottom clearance is the physical rail clearance in every quadrant, not Top. + plate.EdgeSpacing = new Spacing(0, 0.553, 0, 0); + var plan = AutomaticCutOffPlanner.Create(plate, options, settings); + Assert.Equal(3, plan.Definitions.Count); + Assert.True(System.Math.Abs(plan.Definitions[0].Position.X) < 34.547); + Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut); + Assert.Equal(withoutClearance.TailSeparatorX, plan.TailSeparatorX); + AutomaticCutOffShiftTests.AssertLimits(plan, 35, 17.5); + AssertClearsPhysicalFootprints(plate, rail, plan); + } + + [Theory] + [InlineData(1, 18, 26.25)] + [InlineData(2, 18, 26.25)] + [InlineData(3, 18, 26.25)] + [InlineData(4, 18, 26.25)] + [InlineData(1, 40, 40)] + [InlineData(2, 40, 40)] + [InlineData(3, 40, 40)] + [InlineData(4, 40, 40)] + public void NoClampFreeArrangement_FallsBackToWholeGapNominalPositions(int quadrant, double width, double center) + { + var plate = AutomaticCutOffShiftTests.UserCase(quadrant); + var sign = quadrant is 2 or 3 ? -1 : 1; + var rail = new ClampRail(width, 0.625, new[] { center }); + plate.SetClampRail(rail); + // Width 18 covers the entire first-line window. Width 40 leaves candidates + // in both rows, but the allowed first/second windows are over 35 apart. + var plan = AutomaticCutOffPlanner.Create(plate, AutomaticCutOffShiftTests.Options(), new CutOffSettings()); + + Assert.False(plan.HasBlockingDiagnostics); + Assert.True(plan.HasSeparatedTail); + Assert.Equal(new[] { sign * 70.0, plan.TailSeparatorX!.Value }, plan.Definitions.Select(c => c.Position.X)); + var blocked = Assert.Single(plan.Diagnostics.Where(d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut)); + Assert.Equal(sign * 35, blocked.X); + Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ShiftedCut); + Assert.Equal(120, plan.FixedGridCutLength); + Assert.Equal(60, plan.StripCutLength); + AssertClearsPhysicalFootprints(plate, rail, plan); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ClampFallback_IsLocalToGap_OtherGapStillShifts(int quadrant) + { + var plate = new Plate(60, 200) { Quadrant = quadrant, PartSpacing = 0.3 }; + var sign = quadrant is 2 or 3 ? -1 : 1; + foreach (var right in new[] { 34.75, 69.75, 114.75, 149.75, 180 }) + plate.Parts.Add(AutomaticCutOffShiftTests.Rectangle(10, 20, + quadrant is 2 or 3 ? -right : right - 10, quadrant is 3 or 4 ? -30 : 10)); + plate.CutOffs.Add(new CutOff(new Vector(sign * 80, 0), CutOffAxis.Vertical)); + var rail = new ClampRail(18, 0.625, new[] { 26.25 }); + plate.SetClampRail(rail); + var plan = AutomaticCutOffPlanner.Create(plate, AutomaticCutOffShiftTests.Options(), new CutOffSettings()); + + Assert.False(plan.HasBlockingDiagnostics); + Assert.Equal(4, plan.Definitions.Count); + Assert.Equal(sign * 70, plan.Definitions[0].Position.X); + var shifted = plan.Definitions.Skip(1).Take(2).Select(c => System.Math.Abs(c.Position.X)).ToArray(); + Assert.InRange(shifted[0], 110.3, 114.75); + Assert.InRange(shifted[1] - shifted[0], 17.5 - AutomaticCutOffPlanner.GeometryTolerance, + 35 + AutomaticCutOffPlanner.GeometryTolerance); + Assert.True(shifted[1] < 150); + Assert.InRange(plan.UsedSpan - shifted[1], 0, 35); + Assert.Single(plan.Diagnostics.Where(d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut)); + Assert.Equal(2, plan.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ShiftedCut)); + AssertClearsPhysicalFootprints(plate, rail, plan); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void NearExistingSeparatorInsideClamp_RemainsUncertified(int quadrant) + { + var plate = AutomaticCutOffShiftTests.UserCase(quadrant); + var options = AutomaticCutOffShiftTests.Options(); + var settings = new CutOffSettings(); + var first = AutomaticCutOffPlanner.Create(plate, options, settings); + var separator = System.Math.Abs(first.TailSeparatorX!.Value); + var sign = quadrant is 2 or 3 ? -1 : 1; + var existing = new CutOff(new Vector(sign * (separator - AutomaticCutOffPlanner.GeometryTolerance / 2), 0), + CutOffAxis.Vertical); + plate.CutOffs.Add(existing); + existing.Regenerate(plate, settings); + var original = existing.Drawing.Program; + // Proposed separator is clear, but its Near-equivalent manual coordinate is not. + var rail = new ClampRail(1, 0.625, + new[] { separator - AutomaticCutOffPlanner.GeometryTolerance / 4 - 0.5 }); + plate.SetClampRail(rail); + var plan = AutomaticCutOffPlanner.Create(plate, options, settings); + + Assert.False(plan.HasBlockingDiagnostics); + Assert.False(plan.HasSeparatedTail); + Assert.Equal(plate.Size.Length, plan.UsedSpan); + Assert.Equal(2, plan.Definitions.Count); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff); + Assert.Same(original, existing.Drawing.Program); + AssertClearsPhysicalFootprints(plate, rail, plan); + } + + private static void AssertClearsPhysicalFootprints(Plate plate, ClampRail rail, AutomaticCutOffPlan plan) + { + // Independent physical oracle: distances from the origin, not mapped zone bounds. + foreach (var cut in plan.Definitions) + { + var distance = System.Math.Abs(cut.Position.X); + foreach (var center in rail.Centers) + { + var low = center - rail.Width / 2 - plate.EdgeSpacing.Bottom; + var high = center + rail.Width / 2 + plate.EdgeSpacing.Bottom; + Assert.True(distance < low || distance > high, $"Cut {distance:R} enters [{low:R}, {high:R}]."); + } + foreach (var move in cut.Drawing.Program.Codes.OfType()) + Assert.Equal(cut.Position.X, move.EndPoint.X); + } + } +} diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffShiftLifecycleTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftLifecycleTests.cs new file mode 100644 index 0000000..89e9f4a --- /dev/null +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftLifecycleTests.cs @@ -0,0 +1,140 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.CutOffs; + +public class AutomaticCutOffShiftLifecycleTests +{ + private static readonly AutomaticCutOffOptions Shifted = new() + { + Spacing = 35, + MinimumStripLength = 17.5, + MinimumTailLength = 12, + }; + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void BatchApply_SaveReload_RerunAndMove_PreserveShiftedDefinitionsAndClearance(int quadrant) + { + var nest = new Nest("shifted lifecycle"); + var plate = AutomaticCutOffShiftTests.UserCase(quadrant); + for (var index = 0; index < plate.Parts.Count; index++) + { + // The nest drawing collection keys by name; distinct synthetic shapes + // must have distinct names for the writer to retain every part. + var drawing = plate.Parts[index].BaseDrawing; + drawing.Name = $"shift rectangle {index}"; + nest.Drawings.Add(drawing); + } + nest.Plates.Add(plate); + var settings = new CutOffSettings(); + var realParts = plate.Parts.ToArray(); + var realPrograms = realParts.Select(p => p.Program).ToArray(); + var proposal = Assert.Single(AutomaticCutOffBatch.Create(new[] { plate }, Shifted, settings)); + Assert.Equal(3, proposal.Definitions.Count); + Assert.Empty(plate.CutOffs); + Assert.Equal(realParts, plate.Parts.ToArray()); + + var applied = Assert.Single(AutomaticCutOffBatch.Apply(new[] { plate }, Shifted, settings)); + Assert.Equal(proposal.Definitions.Select(c => c.Position.X), applied.Definitions.Select(c => c.Position.X)); + var locations = plate.CutOffs.Select(c => c.Position.X).ToArray(); + AssertCutsClearRealRectangles(plate, settings.PartClearance); + Assert.Equal(realPrograms, realParts.Select(p => p.Program)); + Assert.Empty(Assert.Single(AutomaticCutOffBatch.Apply(new[] { plate }, Shifted, settings)).Definitions); + Assert.Equal(locations, plate.CutOffs.Select(c => c.Position.X)); + + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + var loaded = new NestReader(stream).Read(); + var reloaded = Assert.Single(loaded.Plates); + Assert.Equal(locations, reloaded.CutOffs.Select(c => c.Position.X)); + Assert.Equal(realParts.Length, reloaded.Parts.Count(p => !p.BaseDrawing.IsCutOff)); + var reloadedPlan = AutomaticCutOffPlanner.Create(reloaded, Shifted, settings); + Assert.Equal(proposal.OccupiedSpan, reloadedPlan.OccupiedSpan); + Assert.Empty(reloadedPlan.Definitions); + AssertCutsClearRealRectangles(reloaded, settings.PartClearance); + + var moved = reloaded.Parts.First(p => !p.BaseDrawing.IsCutOff && + System.Math.Abs(p.BoundingBox.Right - p.BoundingBox.Left - 10) < 1e-6); + moved.Offset(quadrant is 2 or 3 ? new Vector(-1, 0) : new Vector(1, 0)); + reloaded.RegenerateCutOffs(settings); + Assert.Equal(locations, reloaded.CutOffs.Select(c => c.Position.X)); + AssertCutsClearRealRectangles(reloaded, settings.PartClearance); + } + + [Fact] + public void ShiftedBatch_LimitedSeparatorOnLaterPlateRefusesAllWithoutMutation() + { + var plates = new[] { AutomaticCutOffShiftTests.UserCase(), AutomaticCutOffShiftTests.UserCase() }; + var settings = new CutOffSettings(); + var separator = AutomaticCutOffPlanner.Create(plates[1], Shifted, settings).TailSeparatorX!.Value; + var manual = new CutOff(new Vector(separator, 0), CutOffAxis.Vertical) { EndLimit = 2 }; + plates[1].CutOffs.Add(manual); + var before = plates.Select(p => p.Parts.ToArray()).ToArray(); + + var result = AutomaticCutOffBatch.Apply(plates, Shifted, settings); + + Assert.True(result[1].HasBlockingDiagnostics); + Assert.Empty(plates[0].CutOffs); + Assert.Same(manual, Assert.Single(plates[1].CutOffs)); + for (var i = 0; i < plates.Length; i++) + Assert.Equal(before[i], plates[i].Parts.ToArray()); + } + + [Fact] + public void ShiftedBatch_LateRegenerationFailureRollsBackEveryTouchedPlate() + { + var plates = new[] { AutomaticCutOffShiftTests.UserCase(), AutomaticCutOffShiftTests.UserCase() }; + var before = plates.Select(p => p.Parts.ToArray()).ToArray(); + var programs = plates.Select(p => p.Parts.Select(part => part.Program).ToArray()).ToArray(); + var failOnce = true; + plates[1].PartAdded += (_, e) => + { + if (failOnce && e.Item.BaseDrawing.IsCutOff) + { + failOnce = false; + throw new InvalidOperationException("Injected regeneration failure"); + } + }; + + var error = Assert.Throws(() => + AutomaticCutOffBatch.Apply(plates, Shifted, new CutOffSettings())); + + Assert.False(failOnce); + Assert.Contains("original cut-offs were restored", error.Message); + for (var i = 0; i < plates.Length; i++) + { + Assert.Empty(plates[i].CutOffs); + Assert.Equal(before[i], plates[i].Parts.ToArray()); + Assert.Equal(programs[i], plates[i].Parts.Select(p => p.Program).ToArray()); + } + } + + private static void AssertCutsClearRealRectangles(Plate plate, double clearance) + { + var material = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).Select(p => p.BoundingBox).ToArray(); + foreach (var cut in plate.Parts.Where(p => p.BaseDrawing.IsCutOff)) + { + var codes = cut.Program.Codes; + Assert.Equal(0, codes.Count % 2); + for (var index = 0; index < codes.Count; index += 2) + { + var from = Assert.IsType(codes[index]).EndPoint + cut.Location; + var to = Assert.IsType(codes[index + 1]).EndPoint + cut.Location; + var left = System.Math.Min(from.X, to.X); + var right = System.Math.Max(from.X, to.X); + var bottom = System.Math.Min(from.Y, to.Y); + var top = System.Math.Max(from.Y, to.Y); + Assert.All(material, box => Assert.True( + right <= box.Left - clearance || left >= box.Right + clearance || + top <= box.Bottom - clearance || bottom >= box.Top + clearance, + $"Cut {from} -> {to} crosses a rectangle or its clearance.")); + } + } + } +} diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs new file mode 100644 index 0000000..3e239a0 --- /dev/null +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs @@ -0,0 +1,369 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CutOffs; + +public class AutomaticCutOffShiftTests +{ + internal static AutomaticCutOffOptions Options(double scale = 1) + { + return new AutomaticCutOffOptions { Spacing = 35 * scale, MinimumStripLength = 17.5 * scale }; + } + + internal static Part Rectangle(double length, double width, double x, double y) + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(length, 0)); + program.Codes.Add(new LinearMove(length, width)); + program.Codes.Add(new LinearMove(0, width)); + program.Codes.Add(new LinearMove(0, 0)); + return new Part(new Drawing("shift rectangle", program), new Vector(x, y)); + } + + internal static Plate UserCase(int quadrant = 1, double scale = 1) + { + var plate = new Plate(60 * scale, 120 * scale) { Quadrant = quadrant, PartSpacing = 0.3 * scale }; + foreach (var right in new[] { 34.75, 69.75, 80.0 }) + plate.Parts.Add(Rectangle((right == 80 ? 5 : 10) * scale, (right == 80 ? 5 : 20) * scale, + (quadrant is 2 or 3 ? -right : right - (right == 80 ? 5 : 10)) * scale, + (quadrant is 3 or 4 ? -30 : 10) * scale)); + return plate; + } + + internal static double CutLength(Program program) => Enumerable.Range(0, program.Codes.Count / 2) + .Sum(i => System.Math.Abs(((LinearMove)program.Codes[2 * i + 1]).EndPoint.Y - + ((RapidMove)program.Codes[2 * i]).EndPoint.Y)); + + [Theory] + [InlineData(1, 1)] + [InlineData(2, 1)] + [InlineData(3, 1)] + [InlineData(4, 1)] + [InlineData(1, 25.4)] + [InlineData(2, 25.4)] + [InlineData(3, 25.4)] + [InlineData(4, 25.4)] + public void ParallelEdgeCase_SavesFortyInchesWithSameCountAndSeparator(int quadrant, double scale) + { + var plate = UserCase(quadrant, scale); + var settings = new CutOffSettings { PartClearance = 0.02 * scale }; + var legacy = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 * scale }, settings); + var shifted = AutomaticCutOffPlanner.Create(plate, Options(scale), settings); + Assert.Equal(legacy.TailSeparatorX, shifted.TailSeparatorX); + Assert.Equal(3, shifted.Definitions.Count); + Assert.True(legacy.PreviewParts.Sum(p => CutLength(p.Program)) - + shifted.PreviewParts.Sum(p => CutLength(p.Program)) >= 39.9 * scale); + AssertLimits(shifted, 35 * scale, 17.5 * scale); + Assert.Empty(plate.CutOffs); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(0.00002)] + [InlineData(36)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void MinimumStrip_RejectsInvalidValues(double minimum) + { + var options = Options(); + options.MinimumStripLength = minimum; + Assert.Throws(() => AutomaticCutOffPlanner.Create(UserCase(), options, new CutOffSettings())); + } + + [Fact] + public void EqualCosts_SelectNominalPositions_AndLastStripMayBeShort() + { + var plate = new Plate(60, 120) { PartSpacing = 0.3 }; + plate.Parts.Add(Rectangle(40, 20, 0, 10)); + var plan = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings()); + Assert.Equal(new[] { 35.0, 40.3 + AutomaticCutOffPlanner.GeometryTolerance }, plan.Definitions.Select(c => c.Position.X)); + Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ShiftedCut); + Assert.Equal(CutLength(plan.PreviewParts[0].Program), plan.StripCutLength, 8); + Assert.Equal(plan.StripCutLength, plan.FixedGridCutLength); + AssertLimits(plan, 35, 17.5); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void NullAndEqualSpacing_AreIdenticalIncludingDiagnosticsAndTotals(int quadrant) + { + var plate = UserCase(quadrant); + plate.CutOffs.Add(new CutOff(new Vector(quadrant is 2 or 3 ? -35 : 35, 0), CutOffAxis.Vertical)); + var options = new AutomaticCutOffOptions { Spacing = 35 }; + var legacy = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings()); + options.MinimumStripLength = 35; + var fixedGrid = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings()); + Assert.Equal(legacy.Definitions.Select(c => (c.Position, c.Axis, c.StartLimit, c.EndLimit)), + fixedGrid.Definitions.Select(c => (c.Position, c.Axis, c.StartLimit, c.EndLimit))); + Assert.Equal(legacy.PreviewParts.Select(p => p.Program.ToString()), fixedGrid.PreviewParts.Select(p => p.Program.ToString())); + Assert.Equal(legacy.Diagnostics, fixedGrid.Diagnostics); + Assert.Equal(legacy.TailSeparatorX, fixedGrid.TailSeparatorX); + Assert.Equal(legacy.UsedSpan, fixedGrid.UsedSpan); + Assert.Equal(legacy.StripCutLength, fixedGrid.StripCutLength); + Assert.Equal(legacy.FixedGridCutLength, fixedGrid.FixedGridCutLength); + Assert.Equal(legacy.PreviewParts.Take(legacy.Definitions.Count - (legacy.HasSeparatedTail ? 1 : 0)) + .Sum(p => CutLength(p.Program)), legacy.StripCutLength, 8); + Assert.Equal(legacy.StripCutLength, legacy.FixedGridCutLength); + } + + [Fact] + public void SeededTwoHundredPlates_NeverLonger_RespectLimitsAndAreDeterministic() + { + var random = new Random(42131); + for (var sample = 0; sample < 200; sample++) + { + var quadrant = sample % 4 + 1; + var plate = new Plate(60, 120) { Quadrant = quadrant, PartSpacing = 0.3 }; + for (var index = 0; index < 4; index++) + { + var right = 20 + random.NextDouble() * 90; + var width = 2 + random.NextDouble() * 8; + var height = 2 + random.NextDouble() * 12; + var top = 20 + random.NextDouble() * 20; + plate.Parts.Add(Rectangle(width, height, quadrant is 2 or 3 ? -right : right - width, + quadrant is 3 or 4 ? -top : top - height)); + } + var options = Options(); + var settings = new CutOffSettings(); + var first = AutomaticCutOffPlanner.Create(plate, options, settings); + var second = AutomaticCutOffPlanner.Create(plate, options, settings); + var legacy = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings); + Assert.Equal(legacy.Definitions.Count, first.Definitions.Count); + Assert.Equal(legacy.TailSeparatorX, first.TailSeparatorX); + Assert.True(first.StripCutLength <= first.FixedGridCutLength + 0.000001); + Assert.Equal(first.Definitions.Select(c => c.Position.X), second.Definitions.Select(c => c.Position.X)); + Assert.Equal(first.Diagnostics, second.Diagnostics); + Assert.Equal(first.StripCutLength, second.StripCutLength); + Assert.Equal(first.PreviewParts.Take(first.PreviewParts.Count - 1).Sum(p => CutLength(p.Program)), first.StripCutLength, 8); + AssertLimits(first, 35, 17.5); + } + } + + [Theory] + [InlineData(1, false)] + [InlineData(2, false)] + [InlineData(3, false)] + [InlineData(4, false)] + [InlineData(1, true)] + [InlineData(2, true)] + [InlineData(3, true)] + [InlineData(4, true)] + public void FullSpanManualLine_AtTwentyIsBoundary_NotAnExtraObstacle(int quadrant, bool explicitLimits) + { + var plate = UserCase(quadrant); + var sign = quadrant is 2 or 3 ? -1 : 1; + var settings = new CutOffSettings { Overtravel = 0.2 }; + var manual = new CutOff(new Vector(sign * 20, 0), CutOffAxis.Vertical); + if (explicitLimits) + { + var bounds = plate.BoundingBox(false); + manual.StartLimit = bounds.Bottom; + manual.EndLimit = bounds.Top + settings.Overtravel; + } + plate.CutOffs.Add(manual); + var plan = AutomaticCutOffPlanner.Create(plate, Options(), settings); + Assert.False(plan.HasBlockingDiagnostics); + Assert.Equal(2, plan.Definitions.Count); + // The feasible window 45.3..55 misses every rectangle, so all costs tie. + // One unbroken full-width segment at the gap-relative nominal X wins. + Assert.Equal(sign * 55, plan.Definitions[0].Position.X); + Assert.Single(plan.PreviewParts[0].Program.Codes.OfType()); + Assert.Equal(60.2, CutLength(plan.PreviewParts[0].Program), 8); + Assert.Equal(plan.StripCutLength, plan.FixedGridCutLength); + Assert.InRange(System.Math.Abs(plan.Definitions[0].Position.X) - 20, 17.5, 35); + Assert.InRange(plan.UsedSpan - System.Math.Abs(plan.Definitions[0].Position.X), 0, 35); + Assert.Same(manual, Assert.Single(plate.CutOffs)); + } + + [Theory] + [InlineData(1, -1)] + [InlineData(2, -1)] + [InlineData(3, -1)] + [InlineData(4, -1)] + [InlineData(1, 0)] + [InlineData(2, 0)] + [InlineData(3, 0)] + [InlineData(4, 0)] + [InlineData(1, 1)] + [InlineData(2, 1)] + [InlineData(3, 1)] + [InlineData(4, 1)] + public void LimitedManualLine_AtBestPosition_IsAvoidedWithoutBlocking(int quadrant, int toleranceOffset) + { + var plate = UserCase(quadrant); + var best = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings()); + var x = best.Definitions[0].Position.X; + var manualX = x + toleranceOffset * AutomaticCutOffPlanner.GeometryTolerance; + plate.CutOffs.Add(new CutOff(new Vector(manualX, 0), CutOffAxis.Vertical) + { + EndLimit = plate.BoundingBox(false).Bottom + 2, + }); + var plan = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings()); + Assert.False(plan.HasBlockingDiagnostics); + Assert.Equal(3, plan.Definitions.Count); + Assert.DoesNotContain(plan.Definitions, c => c.Position.X == x || + System.Math.Abs(c.Position.X - manualX) <= AutomaticCutOffPlanner.GeometryTolerance); + Assert.True(plan.StripCutLength < plan.FixedGridCutLength); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void LimitedManualLine_AtSeparator_StillBlocksAndEmptiesProposedTotals(int quadrant) + { + var plate = UserCase(quadrant); + var first = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings()); + plate.CutOffs.Add(new CutOff(new Vector(first.TailSeparatorX!.Value, 0), CutOffAxis.Vertical) + { + EndLimit = plate.BoundingBox(false).Bottom + 2, + }); + var plan = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings()); + Assert.True(plan.HasBlockingDiagnostics); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict && d.IsBlocking); + Assert.False(plan.HasSeparatedTail); + Assert.Empty(plan.Definitions); + Assert.Empty(plan.PreviewParts); + Assert.Equal(0, plan.StripCutLength); + Assert.True(plan.FixedGridCutLength > 0); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ApplyAndRerun_FullSpanDefinitionsMakeEveryGapANoOp(int quadrant) + { + var plate = UserCase(quadrant); + var settings = new CutOffSettings(); + var plan = AutomaticCutOffPlanner.Create(plate, Options(), settings); + plate.CutOffs.AddRange(plan.Definitions); + plate.RegenerateCutOffs(settings); + var parts = plate.Parts.ToArray(); + var rerun = AutomaticCutOffPlanner.Create(plate, Options(), settings); + Assert.Empty(rerun.Definitions); + Assert.False(rerun.HasBlockingDiagnostics); + Assert.Equal(plan.TailSeparatorX, rerun.TailSeparatorX); + Assert.Equal(parts, plate.Parts.ToArray()); + Assert.Equal(0, rerun.StripCutLength); + Assert.Equal(0, rerun.FixedGridCutLength); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void FullSpanLineInTail_IsIgnoredAsAStripBoundary(int quadrant) + { + var plate = UserCase(quadrant); + var before = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings()); + plate.CutOffs.Add(new CutOff(new Vector(quadrant is 2 or 3 ? -110 : 110, 0), CutOffAxis.Vertical)); + var after = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings()); + Assert.Equal(before.Definitions.Select(c => c.Position.X), after.Definitions.Select(c => c.Position.X)); + Assert.Equal(before.StripCutLength, after.StripCutLength); + Assert.Equal(before.FixedGridCutLength, after.FixedGridCutLength); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void UnsortedDuplicateBoundaries_SpacingSizedGapsNeedNoNewLines(int quadrant) + { + var plate = UserCase(quadrant); + var sign = quadrant is 2 or 3 ? -1 : 1; + foreach (var distance in new[] { 55.0, 110, 20, 55, 0 }) + plate.CutOffs.Add(new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)); + var plan = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings()); + Assert.False(plan.HasBlockingDiagnostics); + Assert.Equal(plan.TailSeparatorX, Assert.Single(plan.Definitions).Position.X); + Assert.Equal(0, plan.StripCutLength); + Assert.Equal(0, plan.FixedGridCutLength); + Assert.Equal(5, plate.CutOffs.Count); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void LimitedManualLine_WhenNoAllowedCandidate_FallbackStillBlocks(int quadrant) + { + var plate = UserCase(quadrant); + var sign = quadrant is 2 or 3 ? -1 : 1; + plate.CutOffs.Add(new CutOff(new Vector(sign * 35, 0), CutOffAxis.Vertical) + { + EndLimit = plate.BoundingBox(false).Bottom + 2, + }); + var options = Options(); + // Only nominal 35 is on the lattice in this first-line window. + options.MinimumStripLength = 35 - AutomaticCutOffPlanner.GeometryTolerance / 2; + var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings()); + Assert.True(plan.HasBlockingDiagnostics); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict && d.IsBlocking); + Assert.Empty(plan.Definitions); + Assert.False(plan.HasSeparatedTail); + Assert.Equal(0, plan.StripCutLength); + Assert.True(plan.FixedGridCutLength > 0); + } + + [Fact] + public void LegacyGridReportsActualNewStripCutLengthWithoutChangingItsDefinitions() + { + var plate = new Plate(60, 120) { PartSpacing = 0.3 }; + plate.Parts.Add(Rectangle(5, 5, 75, 10)); + var settings = new CutOffSettings(); + var options = new AutomaticCutOffOptions { Spacing = 35 }; + var legacy = AutomaticCutOffPlanner.Create(plate, options, settings); + Assert.Equal(new[] { 35.0, 70.0 }, legacy.Definitions.Take(2).Select(c => c.Position.X)); + Assert.Equal(120, legacy.PreviewParts.Take(2).Sum(p => CutLength(p.Program))); + Assert.Equal(120, legacy.StripCutLength); + Assert.Equal(legacy.StripCutLength, legacy.FixedGridCutLength); + options.MinimumStripLength = 35; + var equal = AutomaticCutOffPlanner.Create(plate, options, settings); + Assert.Equal(legacy.Definitions.Select(c => c.Position.X), equal.Definitions.Select(c => c.Position.X)); + Assert.Equal(legacy.Diagnostics, equal.Diagnostics); + Assert.Equal(legacy.StripCutLength, equal.StripCutLength); + Assert.Equal(legacy.FixedGridCutLength, equal.FixedGridCutLength); + } + + [Fact] + public void ShiftedSearchKeepsNominalLinesWithoutRejectingAValidDenseGrid() + { + var plate = new Plate(60, 120) { PartSpacing = 0 }; + plate.Parts.Add(Rectangle(0.01, 0.01, 109.97009, 0.5)); + var settings = new CutOffSettings(); + var fixedGrid = AutomaticCutOffPlanner.Create(plate, + new AutomaticCutOffOptions { Spacing = 0.1 }, settings); + Assert.Equal(1101, fixedGrid.Definitions.Count); + + var shifted = AutomaticCutOffPlanner.Create(plate, + new AutomaticCutOffOptions { Spacing = 0.1, MinimumStripLength = 0.05 }, settings); + + Assert.Equal(fixedGrid.Definitions.Count, shifted.Definitions.Count); + Assert.True(shifted.StripCutLength <= shifted.FixedGridCutLength + 0.000001); + } + + internal static void AssertLimits(AutomaticCutOffPlan plan, double spacing, double minimum) + { + var positions = plan.Definitions.Where(c => c.Position.X != plan.TailSeparatorX) + .Select(c => System.Math.Abs(c.Position.X)).ToArray(); + var previous = 0.0; + foreach (var position in positions) + { + Assert.InRange(position - previous, minimum - AutomaticCutOffPlanner.GeometryTolerance, + spacing + AutomaticCutOffPlanner.GeometryTolerance); + previous = position; + } + Assert.InRange(plan.UsedSpan - previous, 0, spacing + AutomaticCutOffPlanner.GeometryTolerance); + } +} diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs new file mode 100644 index 0000000..1a4a94e --- /dev/null +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs @@ -0,0 +1,83 @@ +namespace OpenNest.Tests.CutOffs; + +public class AutomaticCutOffStripSearchTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EqualLength_PiercesThenDeviationBreakTies(bool pierceTieBreak) + { + var result = AutomaticCutOffStripSearch.Solve(0, new[] { new[] { 18.0, 35.0 } }, + new[] { 35.0 }, 35, 17.5, x => (10, pierceTieBreak && x == 35 ? 2 : 1)); + Assert.Equal(new[] { pierceTieBreak ? 18.0 : 35.0 }, result); + } + + [Fact] + public void DecimalRoundedLengthTie_UsesPierceCountNotBinaryAdditionNoise() + { + var result = AutomaticCutOffStripSearch.Solve(0, + new[] { new[] { 20.0, 35.0 }, new[] { 40.0, 70.0 } }, + new[] { 35.0, 70.0 }, 35, 17.5, + x => x switch { 20 => (0.1, 1), 40 => (0.2, 1), 35 => (0.3, 3), _ => (0.0, 0) }); + Assert.Equal(new[] { 20.0, 40.0 }, result); + } + + [Fact] + public void IntegralDoubleTie_UsesExactValueBeforePierces() + { + var first = 10576245042702564d; + var second = 16257049472735948d; + var combined = 26833294515438512d; + Assert.Equal((decimal)first + (decimal)second, (decimal)combined); + var result = AutomaticCutOffStripSearch.Solve(0, + new[] { new[] { 20.0, 35.0 }, new[] { 40.0, 70.0 } }, + new[] { 35.0, 70.0 }, 35, 17.5, + x => x switch { 20 => (first, 1), 40 => (second, 1), 35 => (combined, 3), _ => (0.0, 0) }); + Assert.Equal(new[] { 20.0, 40.0 }, result); + } + + [Fact] + public void GlobalOptimum_IsBetterThanGreedyFirstLine() + { + var result = AutomaticCutOffStripSearch.Solve(0, + new[] { new[] { 20.0, 35.0 }, new[] { 40.0, 55.0, 70.0 } }, + new[] { 35.0, 70.0 }, 35, 17.5, x => (x == 20 || x == 70 ? 0 : x == 35 ? 1 : 100, 1)); + Assert.Equal(new[] { 35.0, 70.0 }, result); + } + + [Fact] + public void LastTieBreak_IsLexicographicOverTheWholePrefix_NotTheLastPosition() + { + // Both paths are equally far from the nominal list, length and pierces are equal. + var result = AutomaticCutOffStripSearch.Solve(0, + new[] { new[] { 20.0, 25.0 }, new[] { 45.0, 50.0 } }, + new[] { 22.5, 47.5 }, 35, 17.5, _ => (10, 1)); + Assert.Equal(new[] { 20.0, 45.0 }, result); + } + + [Fact] + public void SeededFiniteCandidateSearch_EqualsIndependentExhaustiveOracle() + { + var random = new Random(93512); + for (var sample = 0; sample < 200; sample++) + { + var rows = new[] { new[] { 18.0, 23, 29, 35 }, new[] { 38.0, 47, 56, 62, 70 }, new[] { 60.0, 74, 81, 91, 105 } }; + var costs = rows.SelectMany(row => row).Distinct().ToDictionary(x => x, + _ => (Length: random.Next(0, 4) + random.Next(0, 1_000_000) / 1_000_000d, + SegmentCount: random.Next(0, 3))); + var nominal = new[] { 35.0, 70, 105 }; + var paths = new List(); + foreach (var first in rows[0]) + foreach (var second in rows[1]) + foreach (var third in rows[2]) + if (second - first >= 17.5 && second - first <= 35 && third - second >= 17.5 && third - second <= 35) + paths.Add(new[] { first, second, third }); + var expected = paths.OrderBy(path => path.Sum(x => (decimal)System.Math.Round(costs[x].Length, 6))) + .ThenBy(path => path.Sum(x => costs[x].SegmentCount)) + .ThenBy(path => Enumerable.Range(0, 3).Sum(i => System.Math.Abs(nominal[i] - path[i]))) + .ThenBy(path => path[0]).ThenBy(path => path[1]).ThenBy(path => path[2]).First(); + var actual = AutomaticCutOffStripSearch.Solve(0, rows, nominal, 35, 17.5, x => costs[x]); + Assert.Equal(expected, actual); + } + } +} diff --git a/OpenNest.Tests/CutOffs/CutOffMeasurementTests.cs b/OpenNest.Tests/CutOffs/CutOffMeasurementTests.cs new file mode 100644 index 0000000..3b9db02 --- /dev/null +++ b/OpenNest.Tests/CutOffs/CutOffMeasurementTests.cs @@ -0,0 +1,65 @@ +using System.Reflection; +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CutOffs; + +public class CutOffMeasurementTests +{ + [Theory] + [InlineData(1, false, false)] + [InlineData(2, false, false)] + [InlineData(3, false, false)] + [InlineData(4, false, false)] + [InlineData(1, true, false)] + [InlineData(2, true, false)] + [InlineData(3, true, false)] + [InlineData(4, true, false)] + [InlineData(1, false, true)] + [InlineData(2, false, true)] + [InlineData(3, false, true)] + [InlineData(4, false, true)] + public void Measurement_MatchesRegeneratedSegmentsWithoutMutatingDrawing(int quadrant, bool limited, bool excluded) + { + var plate = new Plate(60, 120) { Quadrant = quadrant }; + var negativeX = quadrant is 2 or 3; + var negativeY = quadrant is 3 or 4; + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(20, 0)); + program.Codes.Add(new LinearMove(20, 20)); + program.Codes.Add(new LinearMove(0, 20)); + program.Codes.Add(new LinearMove(0, 0)); + plate.Parts.Add(new Part(new Drawing("measurement rectangle", program), + new Vector(negativeX ? -40 : 20, negativeY ? -40 : 20))); + var bounds = plate.BoundingBox(false); + var cut = new CutOff(new Vector(negativeX ? -30 : 30, 0), CutOffAxis.Vertical); + var settings = new CutOffSettings + { + PartClearance = excluded ? 100 : 0.5, + Overtravel = 2, + MinSegmentLength = limited ? 21 : 0, + CutDirection = quadrant % 2 == 0 ? CutDirection.TowardOrigin : CutDirection.AwayFromOrigin, + }; + if (limited) + { + cut.StartLimit = bounds.Bottom + 5; + cut.EndLimit = bounds.Top - 5; + } + var cache = Plate.BuildPerimeterCache(plate); + var original = cut.Drawing.Program; + // Reflection keeps the first run behavioral-red on the unchanged production tree. + var method = typeof(CutOff).GetMethod("MeasureSegments", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + var measured = Assert.IsType>(method.Invoke(cut, new object[] { plate, settings, cache })); + Assert.Same(original, cut.Drawing.Program); + cut.Regenerate(plate, settings, cache); + var length = Enumerable.Range(0, cut.Drawing.Program.Codes.Count / 2).Sum(i => + System.Math.Abs(Assert.IsType(cut.Drawing.Program.Codes[2 * i + 1]).EndPoint.Y - + Assert.IsType(cut.Drawing.Program.Codes[2 * i]).EndPoint.Y)); + Assert.Equal(length, measured.Item1, 10); + Assert.Equal(cut.Drawing.Program.Codes.Count / 2, measured.Item2); + if (excluded) + Assert.Equal((0.0, 0), measured); + } +} diff --git a/OpenNest.Tests/CutOffs/CutOffSeparationTests.cs b/OpenNest.Tests/CutOffs/CutOffSeparationTests.cs new file mode 100644 index 0000000..a98643c --- /dev/null +++ b/OpenNest.Tests/CutOffs/CutOffSeparationTests.cs @@ -0,0 +1,65 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CutOffs; + +/// Which cut-offs split the whole sheet (and so must be cut after every part). +public class CutOffSeparationTests +{ + [Theory] + [InlineData(1, CutDirection.AwayFromOrigin)] + [InlineData(2, CutDirection.TowardOrigin)] + [InlineData(3, CutDirection.AwayFromOrigin)] + [InlineData(4, CutDirection.TowardOrigin)] + public void ContinuousFullSpanLine_SeparatesTheSheet_InEveryQuadrantAndDirection(int quadrant, CutDirection direction) + { + var plate = new Plate(60, 120) { Quadrant = quadrant }; + var bounds = plate.BoundingBox(includeParts: false); + plate.Parts.Add(TestHelpers.MakePartAt(bounds.Left + 10, bounds.Bottom + 10, 10)); + var vertical = new CutOff(new Vector(bounds.Left + 40, 0), CutOffAxis.Vertical); + var horizontal = new CutOff(new Vector(0, bounds.Bottom + 40), CutOffAxis.Horizontal); + var crossing = new CutOff(new Vector(bounds.Left + 15, 0), CutOffAxis.Vertical); + plate.CutOffs.AddRange([vertical, horizontal, crossing]); + plate.RegenerateCutOffs(new CutOffSettings { CutDirection = direction, Overtravel = 0.5 }); + + Assert.True(Separates(plate, vertical)); + Assert.True(Separates(plate, horizontal)); + Assert.False(Separates(plate, crossing)); // Trimmed around the part: two pieces. + } + + [Fact] + public void LimitedEdgeStaleOrBrokenLines_DoNotSeparateTheSheet() + { + var plate = new Plate(60, 120); + plate.Parts.Add(TestHelpers.MakePartAt(10, 10, 10)); + var limited = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { EndLimit = 30 }; + var onEdge = new CutOff(new Vector(0, 0), CutOffAxis.Vertical); + var stale = new CutOff(new Vector(50, 0), CutOffAxis.Vertical); + var split = new CutOff(new Vector(70, 0), CutOffAxis.Vertical); + plate.CutOffs.AddRange([limited, onEdge, stale, split]); + plate.RegenerateCutOffs(new CutOffSettings()); + Assert.True(Separates(plate, stale)); + Assert.True(Separates(plate, split)); + + stale.Position = new Vector(51, 0); // Moved without regeneration: its program is elsewhere. + var program = new Program(); + program.Codes.Add(new RapidMove(70, 0)); + program.Codes.Add(new LinearMove(70, 30)); + program.Codes.Add(new RapidMove(70, 30.5)); + program.Codes.Add(new LinearMove(70, 60)); + split.Drawing.Program = program; + var broken = new Part(split.Drawing); + + Assert.False(Separates(plate, limited)); + Assert.False(Separates(plate, onEdge)); + Assert.False(Separates(plate, stale)); + Assert.False(CutOff.SeparatesSheet(broken, split, plate.BoundingBox(false))); // Two pierces leave a gap. + Assert.False(CutOff.SeparatesSheet(Part(plate, limited), null, plate.BoundingBox(false))); + } + + private static bool Separates(Plate plate, CutOff cutOff) => + CutOff.SeparatesSheet(Part(plate, cutOff), cutOff, plate.BoundingBox(includeParts: false)); + + private static Part Part(Plate plate, CutOff cutOff) => + Assert.Single(plate.Parts, p => ReferenceEquals(p.BaseDrawing, cutOff.Drawing)); +} diff --git a/OpenNest.Tests/CutOffs/PlannerCutOffStagingTests.cs b/OpenNest.Tests/CutOffs/PlannerCutOffStagingTests.cs new file mode 100644 index 0000000..8ec5bd8 --- /dev/null +++ b/OpenNest.Tests/CutOffs/PlannerCutOffStagingTests.cs @@ -0,0 +1,190 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; +using OpenNest.Tests.CuttingPlanning; + +namespace OpenNest.Tests.CutOffs; + +/// The cutting planner's existing-work banner, its Clear button and staged automatic cut-offs. +public class PlannerCutOffStagingTests +{ + private static readonly AutomaticCutOffOptions Options = new() { Spacing = 35 }; + + [Fact] + public void Summarize_CountsAppliedLeadInsLockedPartsAndCutOffs_ButNotStagedOnes() + { + var nest = new Nest(); + var first = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1), Clean("c", 23, 1)); + var second = Plate(nest, Clean("d", 1, 1)); + var third = Plate(nest, Clean("e", 1, 1)); + Lead(first.Parts[0], locked: true); + Lead(first.Parts[2], locked: false); + var manual = new CutOff(new Vector(60, 0), CutOffAxis.Vertical); + var staged = new CutOff(new Vector(80, 0), CutOffAxis.Vertical); + third.CutOffs.Add(manual); + third.CutOffs.Add(staged); + third.RegenerateCutOffs(new CutOffSettings()); + + var summary = AppliedCuttingWork.Summarize([first, second, third], [4, 5, 6], [staged]); + + Assert.Equal(2, summary.LeadInParts); + Assert.Equal(1, summary.LockedLeadInParts); + Assert.Equal(1, summary.CutOffs); // Cut-off parts are not lead-in parts; the staged line is ignored. + Assert.Equal([4, 6], summary.PlateNumbers); + Assert.True(summary.HasExistingWork); + Assert.Equal("ALREADY APPLIED: lead-ins on 2 parts (1 locked) and 1 cut-off on plates 4, 6." + + " Planning recalculates unlocked lead-ins. Planning keeps locked lead-ins and existing cut-offs unchanged." + + " Clear them to recalculate everything from scratch.", summary.Describe()); + + var clean = AppliedCuttingWork.Summarize([second], null, null); + Assert.False(clean.HasExistingWork); + Assert.Equal(string.Empty, clean.Describe()); + Assert.False(AppliedCuttingWork.Summarize([third], null, third.CutOffs).HasExistingWork); + } + + [Fact] + public void Clear_RemovesLeadInsAndCutOffs_KeepsPosesOrderAndQuantities() + { + var nest = new Nest(); + var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1), Clean("c", 40, 1)); + var parameters = ExplicitContourTests.Parameters(); + Assert.Equal(CuttingCommitStatus.Applied, + CuttingPlanBatch.Capture([plate], parameters, preservePartOrder: true).Plan().Apply().Status); + plate.Parts[1].LeadInsLocked = true; + plate.CutOffs.Add(new CutOff(new Vector(30, 0), CutOffAxis.Vertical)); + plate.RegenerateCutOffs(new CutOffSettings()); + var real = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray(); + var poses = real.Select(p => (p.Location, p.Rotation)).ToArray(); + var quantities = real.Select(p => p.BaseDrawing.Quantity.Nested).ToArray(); + Assert.All(real, p => Assert.True(p.HasManualLeadIns)); + Assert.NotNull(plate.CuttingParameters); + + var cleared = AppliedCuttingWork.Clear([plate], [3]); + + Assert.Equal((3, 1, 1), (cleared.LeadInParts, cleared.LockedLeadInParts, cleared.CutOffs)); + Assert.Equal([3], cleared.PlateNumbers); + Assert.Equal(real, plate.Parts.ToArray()); + Assert.Empty(plate.CutOffs); + Assert.Null(plate.CuttingParameters); + Assert.All(real, p => Assert.False(p.HasManualLeadIns || p.LeadInsLocked)); + Assert.All(real, p => Assert.True(ProgramContent.Equal(p.BaseDrawing.Program, p.Program))); + Assert.Equal(poses, real.Select(p => (p.Location, p.Rotation))); + Assert.Equal(quantities, real.Select(p => p.BaseDrawing.Quantity.Nested)); + Assert.False(AppliedCuttingWork.Summarize([plate]).HasExistingWork); + } + + [Fact] + public void Stage_Revert_RestoresTheEarlierCutOffsAndSequenceExactly() + { + var nest = new Nest(); + var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 40, 1)); + var settings = new CutOffSettings(); + var manual = new CutOff(new Vector(75, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(manual); + plate.RegenerateCutOffs(settings); + var manualPart = plate.Parts[^1]; + plate.Parts.Remove(manualPart); + plate.Parts.Insert(0, manualPart); + var before = plate.Parts.ToArray(); + var manualProgram = manual.Drawing.Program; + + var staging = AutomaticCutOffBatch.Stage([plate], Options, settings); + + Assert.False(staging.IsBlocked); + Assert.True(staging.CanRevert); + Assert.NotEmpty(staging.Definitions); + Assert.Equal(1 + staging.Definitions.Count, plate.CutOffs.Count); + Assert.Equal(plate.CutOffs.Count, plate.Parts.Count(p => p.BaseDrawing.IsCutOff)); + + staging.Revert(); + + Assert.False(staging.CanRevert); + Assert.Same(manual, Assert.Single(plate.CutOffs)); + Assert.Same(manualProgram, manual.Drawing.Program); + Assert.Equal(before, plate.Parts.ToArray()); + staging.Revert(); // Runs once. + Assert.Equal(before, plate.Parts.ToArray()); + } + + [Fact] + public void Stage_Keep_LeavesTheCutOffsInPlace_AndABlockedBatchChangesNothing() + { + var nest = new Nest(); + var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 40, 1)); + var settings = new CutOffSettings(); + var staging = AutomaticCutOffBatch.Stage([plate], Options, settings); + var staged = plate.CutOffs.ToArray(); + + staging.Keep(); + staging.Revert(); + + Assert.False(staging.CanRevert); + Assert.Equal(staged, plate.CutOffs.ToArray()); + Assert.NotEmpty(staged); + + var other = Plate(nest, Clean("c", 1, 1), Clean("d", 40, 1)); + var limited = new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 }; + other.CutOffs.Add(limited); + var parts = other.Parts.ToArray(); + var blocked = AutomaticCutOffBatch.Stage([other], Options, settings); + + Assert.True(blocked.IsBlocked); + Assert.False(blocked.CanRevert); + Assert.Empty(blocked.Definitions); + Assert.Same(limited, Assert.Single(other.CutOffs)); + Assert.Equal(parts, other.Parts.ToArray()); + } + + [Fact] + public void StagedCutOffs_AreSequencedByThePlannerBeforeThePartsTheyCross_AndSurviveApply() + { + var nest = new Nest(); + var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 30, 1), Clean("c", 30, 40), Clean("d", 60, 20)); + var settings = new CutOffSettings(); + var staging = AutomaticCutOffBatch.Stage([plate], Options, settings); + Assert.NotEmpty(staging.Definitions); + + // The planner judges crossings on the bounds it captured, before lead-ins widen them. + var captured = plate.Parts.ToDictionary(p => p, p => p.BoundingBox, ReferenceEqualityComparer.Instance); + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: false).Plan(); + Assert.True(proposal.CanApply); + Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status); + staging.Keep(); + + Assert.Equal(staging.Definitions, plate.CutOffs.ToArray()); + var order = plate.Parts.ToList(); + var bounds = plate.BoundingBox(includeParts: false); + var crossings = 0; + foreach (var definition in plate.CutOffs) + { + var cutIndex = order.FindIndex(p => ReferenceEquals(p.BaseDrawing, definition.Drawing)); + Assert.True(cutIndex >= 0); + foreach (var part in order.Where(p => !p.BaseDrawing.IsCutOff)) + { + Assert.True(part.HasManualLeadIns); + if (!CuttingDependencyGraph.CutOffCrosses(definition.Axis, definition.Position, + definition.StartLimit, definition.EndLimit, captured[part], bounds)) + continue; + crossings++; + Assert.True(cutIndex < order.IndexOf(part)); // Planner rule: a crossing cut-off goes first. + } + } + Assert.True(crossings > 0); // The fixture must exercise cut-off precedence. + } + + private static void Lead(Part part, bool locked) => + Assert.True(part.RestoreLeadInProgram((Program)part.Program.Clone(), locked)); + + private static Plate Plate(Nest nest, params Part[] parts) + { + var plate = nest.CreatePlate(); + plate.Size = new Size(100, 100); + foreach (var part in parts) + plate.Parts.Add(part); + return plate; + } + + private static Part Clean(string name, double x, double y) => + new(new Drawing(name, ExplicitContourTests.Square(false)), new Vector(x, y)); +} diff --git a/OpenNest.Tests/CuttingPlanning/ClampZonePreviewTests.cs b/OpenNest.Tests/CuttingPlanning/ClampZonePreviewTests.cs new file mode 100644 index 0000000..26c1de6 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ClampZonePreviewTests.cs @@ -0,0 +1,22 @@ +using OpenNest.Engine.CuttingPlanning; + +namespace OpenNest.Tests.CuttingPlanning; + +/// Machine clamps stay visible on a cutting-plan preview of a clamped plate. +public class ClampZonePreviewTests +{ + [Fact] + public void PreviewCarriesThePlatesClampZones() + { + var plate = BestEffortCuttingPlanTests.Plate(BestEffortCuttingPlanTests.TouchingContours()); + plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 21 })); + + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan(); + var preview = proposal.BuildPreview(0); + + Assert.NotNull(preview); + Assert.Equal(plate.ClampZones, preview.ClampZones); // same immutable zones, same order + Assert.Equal(2, preview.ClampZones.Count); + Assert.Null(preview.ClampRail); // a detached display copy never re-resolves + } +} diff --git a/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs index 64270a7..7d4e845 100644 --- a/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs @@ -39,6 +39,67 @@ public class CuttingDependencyTests Assert.Contains(q, plate.Parts); } + [Fact] + public void Plan_SheetSeparatingCutOff_IsCutAfterEveryPartAndOtherCutOff() + { + var parameters = ExplicitContourTests.Parameters(); + var (plate, separator, crossing, left, right) = SeparatedPlate(); + var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters)); + var separatorIndex = plate.Parts.IndexOf(separator); + Assert.Equal(Enumerable.Range(0, plate.Parts.Count).Where(i => i != separatorIndex), + snapshot.Dependencies.PrerequisitesOf(separatorIndex)); + + var result = CuttingPlanService.Plan(snapshot); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + var order = result.ProposedOrder.Select(o => o.SourcePart).ToList(); + Assert.Same(separator, order[^1]); + Assert.True(result.ProposedOrder[^1].SeparatesSheet); + Assert.False(result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, crossing)).SeparatesSheet); + Assert.True(order.IndexOf(crossing) < order.IndexOf(left)); + Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status); + Assert.Same(separator, plate.Parts[^1]); + Assert.Contains(right, plate.Parts); + } + + [Fact] + public void Plan_KeptOrderCuttingTheSeparatorFirst_IsAConstraintConflictThatSaysWhy() + { + var (plate, separator, _, _, _) = SeparatedPlate(); + Assert.Same(separator, plate.Parts[0]); + + var result = CuttingPlanService.Plan(CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: ExplicitContourTests.Parameters(), preservePartOrder: true))); + + Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status); + Assert.Contains(result.Findings, f => f.Message.Contains("sheet-separating cutoff") + && f.Message.Contains("before the sheet is separated")); + } + + // 100 x 100 sheet: squares at X 1-11 and 60-70; a cutoff at X 5 is trimmed around the left + // square, while a continuous line at X 40 splits the sheet. Both cutoffs start the sequence. + private static (Plate, Part Separator, Part Crossing, Part Left, Part Right) SeparatedPlate() + { + var nest = new Nest(); + var plate = nest.CreatePlate(); + plate.Size = new Size(100, 100); + var left = new Part(new Drawing("left", ExplicitContourTests.Square(false)), new Vector(1, 1)); + var right = new Part(new Drawing("right", ExplicitContourTests.Square(false)), new Vector(60, 1)); + plate.Parts.Add(left); + plate.Parts.Add(right); + var separator = new CutOff(new Vector(40, 0), CutOffAxis.Vertical); + var crossing = new CutOff(new Vector(5, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(separator); + plate.CutOffs.Add(crossing); + plate.RegenerateCutOffs(new CutOffSettings()); + var cuts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToArray(); + Assert.Equal(2, cuts.Length); + var separatorPart = cuts.Single(p => ReferenceEquals(p.BaseDrawing, separator.Drawing)); + var crossingPart = cuts.Single(p => ReferenceEquals(p.BaseDrawing, crossing.Drawing)); + plate.Parts.Reorder([separatorPart, crossingPart, left, right]); + return (plate, separatorPart, crossingPart, left, right); + } + [Fact] public void Apply_CutOffReclassifiedAfterPlanning_IsStale() { diff --git a/OpenNest.Tests/Data/ClampRailEditorTests.cs b/OpenNest.Tests/Data/ClampRailEditorTests.cs new file mode 100644 index 0000000..d10e3ff --- /dev/null +++ b/OpenNest.Tests/Data/ClampRailEditorTests.cs @@ -0,0 +1,214 @@ +using OpenNest.Data; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Data; + +/// +/// Decision logic of the desktop Clamps dialog: edits one nest's rail in its units, keeps +/// "apply to this nest" (OK) separate from "save as machine defaults" (immediate write). +/// +public class ClampRailEditorTests +{ + private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 }; + + [Fact] + public void NestWithoutARailStartsEmptyAtTheDefaultClampSize() + { + var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: false); + + Assert.Equal(1.5, editor.Width); + Assert.Equal(0.625, editor.Depth); + Assert.Empty(editor.Centers); + Assert.Null(editor.BuildRail()); + + var metric = new ClampRailEditor(null, Units.Millimeters, applyToNewNests: false); + Assert.Equal(1.5 * 25.4, metric.Width, 9); + } + + [Fact] + public void NestRailIsEditedInPlace() + { + var rail = new ClampRail(1.5, 0.625, MachineCenters); + + var editor = new ClampRailEditor(rail, Units.Inches, applyToNewNests: true); + + Assert.Equal(MachineCenters, editor.Centers); + Assert.Equal(rail, editor.BuildRail()); + Assert.True(editor.ApplyToNewNests); + } + + [Fact] + public void CentersStaySortedAndRefuseInvalidOrDuplicateValues() + { + var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: false); + + Assert.True(editor.TryAddCenter(21, out _)); + Assert.True(editor.TryAddCenter(6, out var none)); + Assert.Null(none); + Assert.False(editor.TryAddCenter(21, out var duplicate)); + Assert.Contains("already", duplicate); + Assert.False(editor.TryAddCenter(-1, out var negative)); + Assert.Contains("origin", negative); + Assert.False(editor.TryAddCenter(double.NaN, out _)); + Assert.Equal(new[] { 6.0, 21 }, editor.Centers); + + editor.RemoveCenterAt(0); + editor.RemoveCenterAt(5); // out of range: ignored + Assert.Equal(new[] { 21.0 }, editor.Centers); + } + + [Fact] + public void ApplyingToTheNestReResolvesEveryPlate() + { + var nest = new Nest(); + nest.PlateDefaults.Size = new Size(48, 120); + var full = nest.CreatePlate(); + var shorter = nest.CreatePlate(); + shorter.Size = new Size(48, 60); + var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: false); + foreach (var center in MachineCenters) + Assert.True(editor.TryAddCenter(center, out _)); + + editor.ApplyTo(nest); + + Assert.Equal(8, full.ClampZones.Count); + Assert.Equal(4, shorter.ClampZones.Count); + Assert.Equal(8, nest.CreatePlate().ClampZones.Count); + + // Removing every clamp and applying removes them from the nest. + while (editor.Centers.Count > 0) + editor.RemoveCenterAt(0); + editor.ApplyTo(nest); + Assert.Null(nest.PlateDefaults.ClampRail); + Assert.All(nest.Plates, p => Assert.Empty(p.ClampZones)); + } + + [Fact] + public void InvalidClampSizeIsRefusedBeforeApplyingOrSaving() + { + var nest = new Nest(); + var plate = nest.CreatePlate(); + var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: true); + editor.TryAddCenter(6, out _); + editor.Width = 0; + + Assert.Throws(() => editor.ApplyTo(nest)); + Assert.Throws(() => editor.ToMachineDefaults()); + Assert.Empty(plate.ClampZones); + Assert.Null(nest.PlateDefaults.ClampRail); + } + + [Fact] + public void MachineDefaultsCaptureTheEditedValuesInTheNestsUnits() + { + var editor = new ClampRailEditor(null, Units.Millimeters, applyToNewNests: true); + editor.Width = 40; + editor.Depth = 16; + editor.TryAddCenter(150, out _); + + var settings = editor.ToMachineDefaults(); + + Assert.True(settings.Enabled); + Assert.Equal(Units.Millimeters, settings.Units); + Assert.Equal(40, settings.Width); + Assert.Equal(16, settings.Depth); + Assert.Equal(new[] { 150.0 }, settings.Centers); + + // Without any clamp listed, saved defaults never enable clamps for new nests. + editor.RemoveCenterAt(0); + Assert.False(editor.ToMachineDefaults().Enabled); + editor.ApplyToNewNests = false; + editor.TryAddCenter(150, out _); + Assert.False(editor.ToMachineDefaults().Enabled); + } + + [Fact] + public void LoadingMachineDefaultsOnlyFillsTheEditorInTheNestsUnits() + { + var nest = new Nest { Units = Units.Millimeters }; + var plate = nest.CreatePlate(); + var defaults = new ClampSettings + { + Enabled = true, + Units = Units.Inches, + Width = 1.5, + Depth = 0.625, + Centers = new[] { 21.0, 6 }, + }; + var editor = new ClampRailEditor(null, Units.Millimeters, applyToNewNests: false); + + editor.LoadMachineDefaults(defaults); + + Assert.Equal(1.5 * 25.4, editor.Width, 9); + Assert.Equal(0.625 * 25.4, editor.Depth, 9); + Assert.Equal(new[] { 6 * 25.4, 21 * 25.4 }, editor.Centers); + Assert.True(editor.ApplyToNewNests); + Assert.Empty(plate.ClampZones); // nothing applied until OK + Assert.Null(nest.PlateDefaults.ClampRail); + + // Disabled defaults still load their values but do not ask to enable new nests. + defaults.Enabled = false; + editor.LoadMachineDefaults(defaults); + Assert.False(editor.ApplyToNewNests); + Assert.Equal(2, editor.Centers.Count); + } + + [Fact] + public void ConvertedSettingsKeepEnabledAndScaleEveryLength() + { + var inches = new ClampSettings { Enabled = true, Width = 1.5, Depth = 0.625, Centers = new[] { 6.0 } }; + + var metric = inches.ConvertedTo(Units.Millimeters); + + Assert.True(metric.Enabled); + Assert.Equal(Units.Millimeters, metric.Units); + Assert.Equal(38.1, metric.Width, 9); + Assert.Equal(15.875, metric.Depth, 9); + Assert.Equal(152.4, metric.Centers[0], 9); + Assert.Equal(1.5, inches.Width); // the source is not modified + Assert.Equal(1.5, inches.ConvertedTo(Units.Inches).Width); + } + + // D9: the editor carries removal minutes through the same three boundaries as size. + + [Fact] + public void RemovalMinutesStartsFromTheRailOrMachineDefault() + { + var rail = new ClampRail(1.5, 0.625, MachineCenters, removalMinutes: 7); + Assert.Equal(7, new ClampRailEditor(rail, Units.Inches, false).RemovalMinutes); + Assert.Equal(ClampRail.DefaultRemovalMinutes, + new ClampRailEditor(null, Units.Inches, false).RemovalMinutes); + } + + [Fact] + public void RemovalMinutesIsNeverConvertedBetweenUnitsOnLoadOrBuild() + { + var editor = new ClampRailEditor(null, Units.Millimeters, applyToNewNests: true); + editor.RemovalMinutes = 8; + editor.TryAddCenter(150, out _); + + Assert.Equal(8, editor.ToMachineDefaults().RemovalMinutes); + Assert.Equal(8, editor.BuildRail()!.RemovalMinutes); + + var defaults = new ClampSettings + { + Enabled = true, + Units = Units.Inches, + RemovalMinutes = 9, + Centers = new[] { 6.0 }, + }; + editor.LoadMachineDefaults(defaults); + Assert.Equal(9, editor.RemovalMinutes); // a time, so unaffected by the unit change + } + + [Fact] + public void InvalidRemovalMinutesIsRefusedBeforeSaving() + { + var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: true); + editor.TryAddCenter(6, out _); + editor.RemovalMinutes = -1; + + Assert.Throws(() => editor.ToMachineDefaults()); + Assert.Throws(() => editor.BuildRail()); + } +} diff --git a/OpenNest.Tests/Data/ClampSettingsTests.cs b/OpenNest.Tests/Data/ClampSettingsTests.cs new file mode 100644 index 0000000..5de2448 --- /dev/null +++ b/OpenNest.Tests/Data/ClampSettingsTests.cs @@ -0,0 +1,408 @@ +using System.Text.Json; +using OpenNest.Data; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Data; + +/// +/// The machine clamp settings store (%APPDATA%\OpenNest\clamps.json): off by default, a +/// never-throwing read that degrades value by value, and no effect until enabled. +/// +public class ClampSettingsTests : IDisposable +{ + private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 }; + + private readonly string _directory = Path.Combine( + Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString()); + private readonly string _path; + + public ClampSettingsTests() + { + Directory.CreateDirectory(_directory); + _path = Path.Combine(_directory, "clamps.json"); + } + + public void Dispose() + { + if (!Directory.Exists(_directory)) + return; + if (!OperatingSystem.IsWindows() && File.Exists(_path)) + File.SetUnixFileMode(_path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + Directory.Delete(_directory, recursive: true); + } + + private static ClampSettings Machine(bool enabled = true) => new() + { + Enabled = enabled, + Width = 1.5, + Depth = 0.625, + Centers = MachineCenters, + }; + + private static void AssertFallback(ClampSettings settings) + { + Assert.False(settings.Enabled); + Assert.Equal(Units.Inches, settings.Units); + Assert.Equal(1.5, settings.Width); + Assert.Equal(0.625, settings.Depth); + Assert.Empty(settings.Centers); + Assert.Null(settings.RailFor(Units.Inches)); + } + + [Fact] + public void DefaultPathUsesTheApplicationDataOpenNestDirectory() + { + Assert.Equal( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenNest", "clamps.json"), + ClampSettings.DefaultPath); + } + + [Fact] + public void MissingFileKeepsClampsOff() + { + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Missing, status); + AssertFallback(loaded); + Assert.Equal(ClampSettingsStatus.Missing, LoadStatus("")); + } + + [Fact] + public void SaveThenLoadRoundTripsAsCamelCaseJson() + { + Machine().Save(_path); + + var loaded = ClampSettings.Load(_path, out var status); + using var json = JsonDocument.Parse(File.ReadAllText(_path)); + + Assert.Equal(ClampSettingsStatus.Ok, status); + Assert.True(loaded.Enabled); + Assert.Equal(Units.Inches, loaded.Units); + Assert.Equal(1.5, loaded.Width); + Assert.Equal(0.625, loaded.Depth); + Assert.Equal(MachineCenters, loaded.Centers); + var root = json.RootElement; + Assert.Equal(1, root.GetProperty("version").GetInt32()); + Assert.True(root.GetProperty("enabled").GetBoolean()); + Assert.Equal("inches", root.GetProperty("units").GetString()); + Assert.Equal(8, root.GetProperty("centers").GetArrayLength()); + } + + [Fact] + public void SaveCreatesTheSettingsDirectory() + { + var nested = Path.Combine(_directory, "AppData", "OpenNest", "clamps.json"); + + Machine().Save(nested); + + Assert.True(ClampSettings.Load(nested).Enabled); + } + + [Fact] + public void NothingChangesUntilEnabled() + { + var nest = new Nest(); + Machine(enabled: false).ApplyToNewNest(nest); + + Assert.Null(Machine(enabled: false).RailFor(Units.Inches)); + Assert.Null(nest.PlateDefaults.ClampRail); + Assert.Empty(nest.CreatePlate().ClampZones); + + // Enabled without any clamp center is still no clamps. + var empty = Machine(); + empty.Centers = Array.Empty(); + Assert.Null(empty.RailFor(Units.Inches)); + } + + [Fact] + public void EnabledSettingsGiveANewNestTheMachineRail() + { + var nest = new Nest { Units = Units.Inches }; + nest.PlateDefaults.Size = new Size(48, 60); + + Machine().ApplyToNewNest(nest); + var plate = nest.CreatePlate(); + + Assert.Equal(new ClampRail(1.5, 0.625, MachineCenters), nest.PlateDefaults.ClampRail); + Assert.Equal(new[] { 5.25, 20.25, 35.25, 50.25 }, plate.ClampZones.Select(z => z.Bounds.X)); + } + + [Fact] + public void RailIsConvertedToTheNestsUnits() + { + var metric = Machine().RailFor(Units.Millimeters)!; + Assert.Equal(1.5 * 25.4, metric.Width, 12); + Assert.Equal(0.625 * 25.4, metric.Depth, 12); + Assert.Equal(6 * 25.4, metric.Centers[0], 12); + + var settings = new ClampSettings + { + Enabled = true, + Units = Units.Millimeters, + Width = 38.1, + Depth = 15.875, + Centers = new[] { 152.4 }, + }; + var inches = settings.RailFor(Units.Inches)!; + Assert.Equal(1.5, inches.Width, 12); + Assert.Equal(6, inches.Centers[0], 12); + Assert.Equal(38.1, settings.RailFor(Units.Millimeters)!.Width); + } + + [Fact] + public void PartialFileMergesOverTheDefaults() + { + File.WriteAllText(_path, """{ "enabled": true, "centers": [21, 6] }"""); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Ok, status); + Assert.True(loaded.Enabled); + Assert.Equal(1.5, loaded.Width); + Assert.Equal(0.625, loaded.Depth); + Assert.Equal(new[] { 6.0, 21 }, loaded.Centers); + } + + [Fact] + public void InvalidValuesFallBackIndividuallyAndAreReported() + { + File.WriteAllText(_path, """ + { + "enabled": "yes", + "units": "7", + "width": -1, + "depth": "deep", + "centers": [6, "x", -3, 21, 1e400, 21], + "future": { "ignored": true } + } + """); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Partial, status); + Assert.False(loaded.Enabled); + Assert.Equal(Units.Inches, loaded.Units); + Assert.Equal(1.5, loaded.Width); + Assert.Equal(0.625, loaded.Depth); + Assert.Equal(new[] { 6.0, 21 }, loaded.Centers); + } + + [Theory] + [InlineData("""{ "centers": 6 }""")] + [InlineData("""{ "centers": [6, -1] }""")] + [InlineData("""{ "centers": [6, "21"] }""")] + [InlineData("""{ "units": ["inches"] }""")] + [InlineData("""{ "enabled": 1 }""")] + [InlineData("""{ "width": 0 }""")] + public void AnySingleInvalidValueIsReported(string json) + { + File.WriteAllText(_path, json); + Assert.Equal(ClampSettingsStatus.Partial, LoadStatus(_path)); + } + + [Theory] + [InlineData("enabled")] + [InlineData("units")] + [InlineData("width")] + [InlineData("depth")] + [InlineData("centers")] + public void ExplicitNullIsAnIgnoredValueAndIsReported(string field) + { + File.WriteAllText(_path, $$"""{ "enabled": true, "centers": [6], "{{field}}": null }"""); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Partial, status); + Assert.Equal(1.5, loaded.Width); + Assert.Equal(0.625, loaded.Depth); + } + + [Fact] + public void MetricFileWithoutSizesGetsThePhysicalDefaultClamp() + { + File.WriteAllText(_path, """{ "enabled": true, "units": "millimeters", "centers": [152.4] }"""); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Ok, status); + Assert.Equal(1.5 * 25.4, loaded.Width, 9); + Assert.Equal(0.625 * 25.4, loaded.Depth, 9); + Assert.Equal(1.5, loaded.RailFor(Units.Inches)!.Width, 9); + } + + [Fact] + public void MetricFileWithAnInvalidSizeFallsBackToThePhysicalDefault() + { + File.WriteAllText(_path, """{ "enabled": true, "units": "millimeters", "width": -1, "depth": 15, "centers": [152.4] }"""); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Partial, status); + Assert.Equal(38.1, loaded.Width, 9); + Assert.Equal(15, loaded.Depth); + } + + [Theory] + [InlineData("""{ "enabled": true, "width": 1e308, "centers": [6] }""")] + [InlineData("""{ "enabled": true, "depth": 1e308, "centers": [6] }""")] + [InlineData("""{ "enabled": true, "centers": [6, 1e308] }""")] + public void ValuesThatCannotConvertBetweenUnitsAreIgnored(string json) + { + File.WriteAllText(_path, json); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Partial, status); + var nest = new Nest { Units = Units.Millimeters }; + loaded.ApplyToNewNest(nest); // must not throw while creating a nest + Assert.Equal(new[] { 6 * 25.4 }, nest.PlateDefaults.ClampRail!.Centers); + } + + [Fact] + public void AnInvalidCenterIsDroppedButTheRestOfTheRailStillLoads() + { + File.WriteAllText(_path, """{ "enabled": true, "centers": [6, "21", 36] }"""); + + var loaded = ClampSettings.Load(_path, out var status); + + // The operator must be warned: the rail is now missing a physical clamp. + Assert.Equal(ClampSettingsStatus.Partial, status); + Assert.True(loaded.Enabled); + Assert.Equal(new[] { 6.0, 36 }, loaded.Centers); + } + + [Theory] + [InlineData("{ this is not json")] + [InlineData("[6, 21, 36]")] + [InlineData("null")] + [InlineData("")] + public void UnparseableFileKeepsClampsOffAndIsReported(string json) + { + File.WriteAllText(_path, json); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Invalid, status); + AssertFallback(loaded); + } + + [SkippableFact] + public void UnreadableFileKeepsClampsOffAndIsReported() + { + if (OperatingSystem.IsWindows()) + throw new SkipException("Unix file modes deny the read on Linux/macOS"); + + Machine().Save(_path); + File.SetUnixFileMode(_path, UnixFileMode.None); + Skip.If(CanRead(_path), "Process can read a mode-000 file (running as root)"); + + Assert.True(File.Exists(_path)); + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Invalid, status); + AssertFallback(loaded); + } + + [Fact] + public void FromRailCapturesANestRailForMachineDefaults() + { + var rail = new ClampRail(38.1, 15.875, new[] { 152.4, 533.4 }); + + var captured = ClampSettings.FromRail(rail, Units.Millimeters, enabled: true); + + Assert.True(captured.Enabled); + Assert.Equal(Units.Millimeters, captured.Units); + Assert.Equal(38.1, captured.Width); + Assert.Equal(15.875, captured.Depth); + Assert.Equal(new[] { 152.4, 533.4 }, captured.Centers); + Assert.Equal(rail, captured.RailFor(Units.Millimeters)); + + var none = ClampSettings.FromRail(null, Units.Inches, enabled: false); + AssertFallback(none); + } + + // D9: minutes to remove one clamp, stored alongside the rail's physical size. + + [Fact] + public void RemovalMinutesDefaultsToThePlannerValue() + { + Assert.Equal(ClampRail.DefaultRemovalMinutes, new ClampSettings().RemovalMinutes); + Assert.Equal(ClampRail.DefaultRemovalMinutes, ClampSettings.Fallback.RemovalMinutes); + } + + [Fact] + public void RemovalMinutesRoundTripsAndIsNeverScaledByUnits() + { + var settings = Machine(); + settings.RemovalMinutes = 8; + settings.Save(_path); + + var loaded = ClampSettings.Load(_path, out var status); + Assert.Equal(ClampSettingsStatus.Ok, status); + Assert.Equal(8, loaded.RemovalMinutes); + using var json = JsonDocument.Parse(File.ReadAllText(_path)); + Assert.Equal(8, json.RootElement.GetProperty("removalMinutes").GetDouble()); + + // Width/depth/centers scale between units; removal time (not a length) never does. + var metric = loaded.ConvertedTo(Units.Millimeters); + Assert.Equal(8, metric.RemovalMinutes); + Assert.Equal(8, loaded.RailFor(Units.Millimeters)!.RemovalMinutes); + } + + [Fact] + public void MissingRemovalMinutesDefaultsOnLoad() + { + File.WriteAllText(_path, """{ "enabled": true, "centers": [6] }"""); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Ok, status); + Assert.Equal(ClampRail.DefaultRemovalMinutes, loaded.RemovalMinutes); + } + + [Theory] + [InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": -1 }""")] + [InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": "five" }""")] + [InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": null }""")] + public void InvalidRemovalMinutesFallsBackAndIsReported(string json) + { + File.WriteAllText(_path, json); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Partial, status); + Assert.Equal(ClampRail.DefaultRemovalMinutes, loaded.RemovalMinutes); + } + + [Fact] + public void ZeroRemovalMinutesIsValid() + { + var settings = Machine(); + settings.RemovalMinutes = 0; + settings.Save(_path); + + var loaded = ClampSettings.Load(_path, out var status); + Assert.Equal(ClampSettingsStatus.Ok, status); + Assert.Equal(0, loaded.RemovalMinutes); + } + + private static ClampSettingsStatus LoadStatus(string path) + { + ClampSettings.Load(path, out var status); + return status; + } + + private static bool CanRead(string path) + { + try + { + using var _ = File.OpenRead(path); + return true; + } + catch (UnauthorizedAccessException) + { + return false; + } + } +} diff --git a/OpenNest.Tests/Diagnostics/ClampCutDiagnosticsTests.cs b/OpenNest.Tests/Diagnostics/ClampCutDiagnosticsTests.cs new file mode 100644 index 0000000..59a59ee --- /dev/null +++ b/OpenNest.Tests/Diagnostics/ClampCutDiagnosticsTests.cs @@ -0,0 +1,54 @@ +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Diagnostics; + +public class ClampCutDiagnosticsTests +{ + [Theory] + [InlineData(CutOffAxis.Vertical, 10.5, 0, 1, true)] + [InlineData(CutOffAxis.Vertical, 10.5, 2, 4, false)] + [InlineData(CutOffAxis.Vertical, 14, 0, 2, false)] + [InlineData(CutOffAxis.Horizontal, 0.25, 9, 13, true)] + [InlineData(CutOffAxis.Horizontal, 2, 9, 13, false)] + [InlineData(CutOffAxis.Horizontal, 0.25, 12, 13, false)] + public void NominalLimitsAndBothAxesDetermineContact(CutOffAxis axis, double coordinate, + double start, double end, bool crosses) + { + var plate = new Plate(24, 48); + plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625))); + plate.CutOffs.Add(new CutOff(axis == CutOffAxis.Vertical + ? new Vector(coordinate, 0) : new Vector(0, coordinate), axis) + { StartLimit = start, EndLimit = end }); + var findings = ClampCutDiagnostics.Find(plate); + Assert.Equal(crosses, findings.Count != 0); + if (crosses) + Assert.Equal("front", Assert.Single(findings).ClampId); + } + + [Fact] + public void SegmentChecksPhysicalFootprintNotInfiniteLineOrPartSpacing() + { + var zone = new Box(10, 0, 1.5, 0.625); + Assert.True(ClampCutDiagnostics.Intersects(new Vector(9, 0.3), new Vector(12, 0.3), + zone, out var contact)); + Assert.InRange(contact.X, 10, 11.5); + Assert.False(ClampCutDiagnostics.Intersects(new Vector(9, 3), new Vector(12, 3), + zone, out _)); + Assert.False(ClampCutDiagnostics.Intersects(new Vector(9, 0.3), new Vector(9.9, 0.3), + zone, out _)); + Assert.Throws(() => ClampCutDiagnostics.Intersects( + new Vector(double.NaN, 0), new Vector(12, 0), zone, out _)); + } + + [Fact] + public void OnlyActiveClampsAreReported() + { + var plate = new Plate(24, 48); + plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625))); + plate.CutOffs.Add(new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical)); + Assert.Single(ClampCutDiagnostics.Find(plate)); + plate.ClampZones.Clear(); // Removed on this sheet; no longer an obstacle. + Assert.Empty(ClampCutDiagnostics.Find(plate)); + } +} diff --git a/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs b/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs new file mode 100644 index 0000000..face1a7 --- /dev/null +++ b/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs @@ -0,0 +1,319 @@ +using System.Linq; +using OpenNest.CNC; +using OpenNest.Diagnostics; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Diagnostics; + +/// +/// Live plates store clamp zones in the sheet's lower-left frame; parts, cut-offs and motions +/// use the signed quadrant frame. Every clamp check must compare in the placement frame. +/// Fixtures use a real so an already-signed hand-made zone cannot hide +/// a missing translation. Expected footprints come from the physical rule: a clamp centered c +/// covers c +/- width/2 from the origin along the edge and depth into the sheet. +/// +public class ClampZonePlacementFrameTests +{ + private const double SheetWidth = 81; + private const double SheetLength = 120; + + private static int XSign(int quadrant) => quadrant is 2 or 3 ? -1 : 1; + private static int YSign(int quadrant) => quadrant is 3 or 4 ? -1 : 1; + + private static Box WorldFootprint(int quadrant, double center, double width, double depth) + { + var near = center - width / 2; + var far = center + width / 2; + var x = XSign(quadrant) > 0 ? near : -far; + var y = YSign(quadrant) > 0 ? 0 : -depth; + return new Box(x, y, width, depth); + } + + private static Plate RailPlate(int quadrant, params double[] centers) + { + var plate = new Plate(SheetWidth, SheetLength) { Quadrant = quadrant }; + plate.SetClampRail(new ClampRail(1.5, 0.625, centers)); + return plate; + } + + private static Nest NestOf(Plate plate) + { + var nest = new Nest(); + nest.Plates.Add(plate); + return nest; + } + + private static Part Rectangle(double length, double width, Vector location, bool scribe = false) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(length, 0); + program.LineTo(length, width); + program.LineTo(0, width); + program.LineTo(0, 0); + if (scribe) + foreach (var line in program.Codes.OfType()) + line.Layer = LayerType.Scribe; + return new Part(new Drawing(scribe ? "scribe" : "rectangle", program), location); + } + + private static void AssertSameBox(Box expected, Box actual) + { + Assert.Equal(expected.Left, actual.Left, 9); + Assert.Equal(expected.Bottom, actual.Bottom, 9); + Assert.Equal(expected.Length, actual.Length, 9); + Assert.Equal(expected.Width, actual.Width, 9); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ToPlacement_MapsWholeFootprintsWithoutChangingStoredZones(int quadrant) + { + var plate = RailPlate(quadrant, 35, 120.5); + var stored = plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y)).ToArray(); + + var mapped = ClampZoneFrame.ToPlacement(plate); + + Assert.Equal(new[] { "clamp-1", "clamp-2" }, mapped.Select(z => z.Id)); + AssertSameBox(WorldFootprint(quadrant, 35, 1.5, 0.625), mapped[0].Bounds); + AssertSameBox(WorldFootprint(quadrant, 120.5, 1.5, 0.625), mapped[1].Bounds); + Assert.Equal(stored, plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y))); + Assert.All(mapped, z => Assert.DoesNotContain(z, plate.ClampZones)); + } + + [Fact] + public void ToPlacement_RejectsMissingOrMalformedZones() + { + var plate = new Plate(24, 48); + var zone = new ClampZone("front", new Box(10, 0, 1.5, 0.625)); + plate.ClampZones.Add(zone); + zone.Bounds.Width = -1; + Assert.Throws(() => ClampZoneFrame.ToPlacement(plate)); + zone.Bounds.Width = 0.625; + zone.Bounds.X = double.NaN; + Assert.Throws(() => ClampZoneFrame.ToPlacement(plate)); + plate.ClampZones.Clear(); + plate.ClampZones.Add(null!); + Assert.Throws(() => ClampZoneFrame.ToPlacement(plate)); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ManualCutoffsAcrossRailClampAreFoundInEveryQuadrant(int quadrant) + { + var plate = RailPlate(quadrant, 35); + var footprint = WorldFootprint(quadrant, 35, 1.5, 0.625); + plate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 35, 0), CutOffAxis.Vertical)); + plate.CutOffs.Add(new CutOff(new Vector(0, YSign(quadrant) * 0.3), CutOffAxis.Horizontal)); + plate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 50, 0), CutOffAxis.Vertical)); + + var findings = ClampCutDiagnostics.Find(plate); + + Assert.Equal(new[] { 0, 1 }, findings.Select(f => f.CutOffIndex)); + Assert.All(findings, f => + { + Assert.Equal("clamp-1", f.ClampId); + Assert.InRange(f.Location.X, footprint.Left, footprint.Right); + Assert.InRange(f.Location.Y, footprint.Bottom, footprint.Top); + }); + var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate)); + Assert.Equal(2, report.CutOffClampFindings.Count); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void PlateCaptureFindsPartMaterialOnRailClampInEveryQuadrant(int quadrant) + { + var plate = RailPlate(quadrant, 35); + // A 4x4 square from 33 to 37 along the edge, flush with the front edge. + plate.Parts.Add(Rectangle(4, 4, new Vector(XSign(quadrant) > 0 ? 33 : -37, + YSign(quadrant) > 0 ? 0 : -4))); + plate.Parts.Add(Rectangle(4, 4, new Vector(XSign(quadrant) > 0 ? 50 : -54, + YSign(quadrant) > 0 ? 0 : -4))); + + var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate)); + + var finding = Assert.Single(report.ClampFindings); + Assert.Equal(0, finding.PartAId); + Assert.Contains("clamp-1", finding.PartBName); + Assert.Equal(1.5 * 0.625, finding.Area, 3); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void PostingWarnsForCutoffRapidAndPartOnRailClampInEveryQuadrant(int quadrant) + { + var cutPlate = RailPlate(quadrant, 35); + cutPlate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 35, 0), CutOffAxis.Vertical)); + cutPlate.RegenerateCutOffs(new CutOffSettings()); + var cutReport = PostVerificationAnalyzer.Analyze(NestOf(cutPlate)); + Assert.Contains(cutReport.Findings, f => f.Kind == PostVerificationKind.Overlap && f.ClampId == "clamp-1"); + Assert.False(cutReport.CanPost(false)); + Assert.True(cutReport.CanPost(true)); + + // The incoming rapid (0,0) -> (+/-40, +/-0.3) passes through the clamp at 35. + var rapidPlate = RailPlate(quadrant, 35); + rapidPlate.Parts.Add(Rectangle(0.1, 0.1, + new Vector(XSign(quadrant) * 40, YSign(quadrant) * 0.3), scribe: true)); + var rapidReport = PostVerificationAnalyzer.Analyze(NestOf(rapidPlate)); + var rapid = Assert.Single(rapidReport.Findings, f => f.Kind == PostVerificationKind.RapidCrossing); + Assert.Equal("clamp-1", rapid.ClampId); + Assert.False(rapidReport.CanPost(false)); + + var partPlate = RailPlate(quadrant, 35); + partPlate.Parts.Add(Rectangle(4, 4, new Vector(XSign(quadrant) > 0 ? 33 : -37, + YSign(quadrant) > 0 ? 0 : -4))); + var partReport = PostVerificationAnalyzer.Analyze(NestOf(partPlate)); + Assert.Contains(partReport.Findings, f => f.Kind == PostVerificationKind.Overlap + && f.PartNumber == 1 && f.OtherPartNumber == null && f.Overlap != null + && f.Message.Contains("clamp-1")); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void RailClampsAwayFromCutoffsAndRapidsProduceNoClampWarnings(int quadrant) + { + // The cut-off and every rapid stay between the origin and the clamp at 35. + var plate = RailPlate(quadrant, 35); + plate.CutOffs.Add(new CutOff(new Vector(XSign(quadrant) * 20, 0), CutOffAxis.Vertical)); + plate.RegenerateCutOffs(new CutOffSettings()); + plate.Parts.Add(Rectangle(0.1, 0.1, new Vector(XSign(quadrant) * 10, YSign(quadrant) * 5), + scribe: true)); + var report = PostVerificationAnalyzer.Analyze(NestOf(plate)); + Assert.DoesNotContain(report.Findings, f => f.ClampId != null || f.Message.Contains("clamp")); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void StalePlacedCutoffAcrossRailClampStillRequiresConsent(int quadrant) + { + // The placed vertical cut runs across the whole sheet width, through the clamp. + var plate = RailPlate(quadrant, 35); + var cut = new CutOff(new Vector(XSign(quadrant) * 35, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(cut); + plate.RegenerateCutOffs(new CutOffSettings { CutDirection = CutDirection.TowardOrigin }); + + // In quadrants 3 and 4 the incoming rapid also reaches the clamp; only the cut is asserted. + var current = PostVerificationAnalyzer.Analyze(NestOf(plate)); + var nominal = Assert.Single(current.Findings, f => f.Kind == PostVerificationKind.Overlap); + Assert.Equal("clamp-1", nominal.ClampId); + Assert.Equal(1, nominal.PartNumber); + + // The definition moves clear of the clamp, but the placed program is not regenerated. + cut.Position = new Vector(XSign(quadrant) * 50, 0); + Assert.Empty(ClampCutDiagnostics.Find(plate)); + + var stale = PostVerificationAnalyzer.Analyze(NestOf(plate)); + var placed = Assert.Single(stale.Findings, f => f.Kind == PostVerificationKind.Overlap); + Assert.Equal("clamp-1", placed.ClampId); + Assert.Equal(1, placed.PartNumber); + Assert.Contains("placed", placed.Message); + var footprint = WorldFootprint(quadrant, 35, 1.5, 0.625); + var location = Assert.NotNull(placed.Location); + Assert.InRange(location.X, footprint.Left, footprint.Right); + Assert.InRange(location.Y, footprint.Bottom, footprint.Top); + Assert.False(stale.CanPost(false)); + Assert.True(stale.CanPost(true)); + } + + [Theory] + [InlineData("NaN")] + [InlineData("negative")] + [InlineData("overflow")] + [InlineData("null")] + public void MalformedActiveClampMakesPostingIncompleteEvenWithoutRapidsOrCutoffs(string invalid) + { + var plate = new Plate(24, 48); + if (invalid == "null") + plate.ClampZones.Add(null!); + else + { + var zone = new ClampZone("front", new Box(10, 0, 1.5, 0.625)); + plate.ClampZones.Add(zone); + if (invalid == "NaN") + zone.Bounds.Y = double.NaN; + else if (invalid == "negative") + zone.Bounds.Width = -1; + else + { + zone.Bounds.X = 1e308; + zone.Bounds.Length = 1e308; + } + } + // Cut-first scribe: no nonzero rapid, no cut-off definitions. + var program = new Program(); + program.MoveTo(0, 0); + program.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Scribe }); + plate.Parts.Add(new Part(new Drawing("scribe", program))); + + var report = PostVerificationAnalyzer.Analyze(NestOf(plate)); + + Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Incomplete + && f.Message.StartsWith("Clamp check:")); + Assert.False(report.CanPost(false)); + Assert.Throws(() => PlateOverlapAnalyzer.CapturePlate(plate)); + } + + [Fact] + public void FreshnessCaptureToleratesMissingCutOffsAndZones() + { + var plate = new Plate(24, 48); + plate.CutOffs.Add(null!); + plate.ClampZones.Add(null!); + + var stamp = OverlapGeometryStamp.Capture(plate); + var state = new OverlapReportState(); + state.Begin(plate); + + Assert.True(stamp.Matches(plate)); + // The whole-plate capture refuses the malformed input inside the desktop's error boundary. + Assert.Throws(() => PlateOverlapAnalyzer.CapturePlate(plate)); + plate.CutOffs.Clear(); + plate.CutOffs.Add(new CutOff(new Vector(5, 0), CutOffAxis.Vertical)); + Assert.False(stamp.Matches(plate)); + plate.CutOffs.Clear(); + plate.CutOffs.Add(null!); + Assert.True(stamp.Matches(plate)); + plate.ClampZones.Clear(); + plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625))); + Assert.False(stamp.Matches(plate)); + } + + [Fact] + public void QuadrantChangeMovesExplicitZonesAndStalesDisplayedClampFindings() + { + var plate = new Plate(24, 48); + plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625))); + plate.Parts.Add(Rectangle(4, 4, new Vector(9.25, 0))); + var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate)); + Assert.Single(report.ClampFindings); + var state = new OverlapReportState(); + Assert.True(state.TryPublish(state.Begin(plate), plate, report)); + Assert.Single(state.DisplayPairs); + + // The stored zone bits are unchanged, but in quadrant 4 the zone is at Y -24..-23.375. + plate.Quadrant = 4; + + Assert.False(state.EnsureFresh(plate)); + Assert.Empty(state.DisplayPairs); + } +} diff --git a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs new file mode 100644 index 0000000..673e3ef --- /dev/null +++ b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs @@ -0,0 +1,315 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Diagnostics; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Diagnostics; + +/// +/// Slice 4 (C1): the material-overlap analyzer reports part-vs-clamp findings like +/// other overlap findings - owned fragments, area and centroid - and they flow into +/// hover text and paging. Files/plates without zones behave exactly as before. +/// +public class PlateOverlapAnalyzerClampZoneTests +{ + private static Program Rect(double lengthX, double widthY) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(lengthX, 0); + program.LineTo(lengthX, widthY); + program.LineTo(0, widthY); + program.LineTo(0, 0); + return program; + } + + private static Drawing Square(string name, double size) => new(name, Rect(size, size)); + + private static readonly ClampZone Front1 = new("front-1", new Box(10, 0, 1.5, 0.625)); + private static readonly ClampZone Front2 = new("front-2", new Box(20, 0, 1.5, 0.625)); + + [Fact] + public void ManualCutoffCrossingClampAppearsInOverlapCheckWithoutBeingMaterial() + { + var plate = new Plate(new Size(24, 48)); + plate.ClampZones.Add(Front1); + plate.CutOffs.Add(new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical)); + var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate)); + Assert.Empty(report.Pairs); + Assert.Empty(report.ClampFindings); + Assert.True(report.IsComplete); + var finding = Assert.Single(report.CutOffClampFindings); + Assert.Equal("front-1", finding.ClampId); + Assert.Equal(0, finding.CutOffIndex); + Assert.InRange(finding.Location.X, 10, 11.5); + var state = new OverlapReportState(); + Assert.True(state.TryPublish(state.Begin(plate), plate, report)); + Assert.Contains("cut-off", state.Message); + plate.CutOffs.Clear(); + Assert.False(state.EnsureFresh(plate)); + Assert.Equal(OverlapCheckStatus.Stale, state.Status); + } + + [Fact] + public void PartOverlappingClampProducesHoleAwareFinding() + { + // 4x4 square at (9.25, 0): the shared fragment with front-1 is exactly its footprint. + var parts = new List { new(Square("plate-part", 4), new Vector(9.25, 0)) }; + + var report = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(parts, new[] { Front1, Front2 })); + + Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message))); + var finding = Assert.Single(report.ClampFindings); + Assert.Equal(0, finding.PartAId); + Assert.Equal("plate-part", finding.PartAName); + Assert.Contains("front-1", finding.PartBName); + Assert.Equal(1.5 * 0.625, finding.Area, 3); + Assert.InRange(finding.Centroid.X, 10 - 1e-3, 11.5 + 1e-3); + Assert.InRange(finding.Centroid.Y, 0 - 1e-3, 0.625 + 1e-3); + } + + [Fact] + public void ClearPartsProduceNoClampFindings() + { + var parts = new List + { + new(Square("far", 4), new Vector(30, 10)), + new(Square("behind", 2), new Vector(10, 5)), // between the clamps, above them + }; + + var report = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(parts, new[] { Front1, Front2 })); + + Assert.True(report.IsComplete); + Assert.Empty(report.Pairs); + Assert.Empty(report.ClampFindings); + } + + [Fact] + public void LegacyCaptureFindsNoClampsAndKeepsPartPairs() + { + var parts = new List + { + new(Square("a", 4), new Vector(0, 0)), + new(Square("b", 4), new Vector(3, 0)), // overlaps a + }; + + var legacy = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts)); + Assert.Single(legacy.Pairs); + Assert.Empty(legacy.ClampFindings); + + var explicitEmpty = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(parts, System.Array.Empty())); + Assert.Equal(Describe(legacy), Describe(explicitEmpty)); + } + + [Fact] + public void IncrementalRecheckAfterMovingAClampMatchesAFullAnalysis() + { + var parts = new List + { + new(Square("a", 4), new Vector(9.25, 0)), // hits the zone at X 10 + new(Square("b", 4), new Vector(30, 10)), // clear of the zone at X 10 + }; + var moved = new ClampZone("front-1", new Box(29, 10, 1.5, 0.625)); // now hits b + + var previous = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(parts, new[] { Front1 })); + Assert.Equal(0, Assert.Single(previous.ClampFindings).PartAId); + + var incremental = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(parts, new[] { moved }), previous); + var oracle = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(parts, new[] { moved })); + + Assert.Equal(Describe(oracle), Describe(incremental)); + Assert.Equal(1, Assert.Single(incremental.ClampFindings).PartAId); + } + + [Fact] + public void FindingsRenderAsClampDetailsAndPageLikeOtherFindings() + { + var parts = new List + { + new(Square("hit-one", 4), new Vector(9.25, 0)), + new(Square("hit-two", 4), new Vector(19.25, 0)), + new(Square("also-hit-two", 4), new Vector(20.5, 0.3)), + }; + + var report = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(parts, new[] { Front1, Front2 })); + + Assert.Equal(3, report.ClampFindings.Count); // hit-one/1, hit-two/2, hit-two/3 -> two zones each + var finding = report.ClampFindings.First(f => f.PartAName == "hit-one"); + Assert.True(OverlapPairPresentation.IsClampFinding(finding)); + var details = OverlapPairPresentation.Details(finding, Units.Inches, CultureInfo.InvariantCulture); + Assert.Contains("front-1", details); + Assert.Contains("hit-one", details); + Assert.DoesNotContain("Pair ", details); + + // Hover text joins every finding; paging must reach all of them. + var text = string.Join("\n\n", report.ClampFindings.Select(f => + OverlapPairPresentation.Details(f, Units.Inches, CultureInfo.InvariantCulture))); + var pages = OverlapHoverPages.Create(text, report.ClampFindings.Count, 6, 60, s => s.Length); + Assert.True(pages.PageCount > 1); + var all = string.Concat(Enumerable.Range(0, pages.PageCount) + .Select(page => { pages.MovePage(page == 0 ? 0 : 1); return string.Concat(pages.Lines); })); + Assert.Contains("front-2", all); + Assert.Contains("also-hit-two", all); + } + + [Fact] + public void PublishedClampFindingIsVisibleAndZoneEditInvalidatesIt() + { + var plate = new Plate(new Size(24, 48)); + plate.Parts.Add(new Part(Square("hit", 4), new Vector(9.25, 0))); + plate.ClampZones.Add(Front1); + var state = new OverlapReportState(); + var generation = state.Begin(plate); + var report = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), plate.ClampZones.ToArray())); + Assert.True(state.TryPublish(generation, plate, report)); + Assert.Single(state.DisplayPairs); + Assert.Contains("clamp", state.Message, System.StringComparison.OrdinalIgnoreCase); + plate.ClampZones.Clear(); + state.Invalidate(plate); + Assert.Empty(state.DisplayPairs); + Assert.Equal(OverlapCheckStatus.Stale, state.Status); + } + + [Fact] + public void PublicationRefusesZonesChangedWhileWorkerWasRunning() + { + var plate = new Plate(new Size(24, 48)); + plate.Parts.Add(new Part(Square("hit", 4), new Vector(9.25, 0))); + plate.ClampZones.Add(Front1); + var state = new OverlapReportState(); + var generation = state.Begin(plate); + var report = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), plate.ClampZones.ToArray())); + plate.ClampZones.Clear(); + Assert.False(state.TryPublish(generation, plate, report)); + Assert.NotEqual(OverlapCheckStatus.Current, state.Status); + } + + // C16 (PM 6859db53): a 1e-5 square zone, far below the outline tolerance. A part covering + // it is still reported (never silently clear); flush or clear parts are not. + [Theory] + [InlineData(7.5, true)] + [InlineData(6.0, false)] + [InlineData(8.00001, false)] + [InlineData(3.0, false)] + public void TinyClampZoneIsJudgedWithoutOutlineCollapse(double x, bool overlaps) + { + var tiny = new ClampZone("tiny", new Box(8, 0, 1e-5, 1e-5)); + var parts = new List { new(Square("p", 2), new Vector(x, 0)) }; + + var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, new[] { tiny })); + + Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message))); + Assert.Equal(overlaps, report.ClampFindings.Any(f => f.PartBName.Contains("tiny"))); + } + + // PM 407f5eb7: real material, not the 0.001 arc flattening, decides a tiny clamp. + [Theory] + [InlineData("rect", 8.00002, 0.5, true)] + [InlineData("rect", 8.00005, 0.5, false)] + [InlineData("disk", 1.9998, 0, true)] + [InlineData("disk", 2.0002, 0, false)] + [InlineData("disk-box", 11.935615871667656, 10.503377293956166, true)] // arc cuts a corner + public void TinyClampIsJudgedAgainstRealMaterial(string material, double a, double b, bool overlaps) + { + Program program; + Box zone; + if (material == "rect") + { + program = Rect(8.00004, 2); + zone = new Box(a, b, 1e-5, 1e-5); + } + else + { + program = new Program(); + program.MoveTo(12, 10); + program.ArcTo(8, 10, 10, 10, RotationType.CCW); + program.ArcTo(12, 10, 10, 10, RotationType.CCW); + if (material == "disk-box") + { + zone = new Box(a, b, 1e-5, 1e-5); + } + else + { + var cx = 10 + a * System.Math.Cos(0.02); + var cy = 10 + a * System.Math.Sin(0.02); + zone = new Box(cx - 0.000005, cy - 0.000005, 1e-5, 1e-5); + } + } + var parts = new List { new(new Drawing(material, program), new Vector(0, 0)) }; + + var report = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(parts, new[] { new ClampZone("tiny", zone) })); + + Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message))); + Assert.Equal(overlaps, report.ClampFindings.Count > 0); + } + + // Delta review 2: material beside a concave (notch) arc is not lost for a tiny clamp. + [Fact] + public void TinyClampBesideAConcaveArcIsReported() + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(20, 0); + program.LineTo(20, 20); + program.LineTo(12, 20); + program.ArcTo(8, 20, 10, 20, RotationType.CW); + program.LineTo(0, 20); + program.LineTo(0, 0); + var arc = new ShapeProfile(OpenNest.Converters.ConvertProgram.ToGeometry(program)) + .Perimeter.Entities.OfType().Single(); + var native = arc.ToPoints(arc.SegmentsForTolerance(1e-5 / 4)).First(v => v.X > 11.9 && v.Y < 19.5); + var centre = new Vector(10, 20); + var outward = centre + (native - centre) / 2 * (2 + 6e-7); + var zone = new ClampZone("tiny", new Box(outward.X - 1e-5, outward.Y, 1e-5, 1e-5)); + + var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture( + new List { new(new Drawing("notch", program), new Vector(0, 0)) }, new[] { zone })); + + Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message))); + Assert.NotEmpty(report.ClampFindings); + } + + // Delta review 3: a tiny clamp in the sliver beyond a convex arc's 0.001 chords must not + // be dismissed by the coarse material bounds before the fine check runs. + [Fact] + public void TinyClampBeyondCoarseChordsIsReported() + { + const double r = 0.01, half = 0.04; + var dx = r * System.Math.Sin(half); + var cy = 20 - r * System.Math.Cos(half); + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(20, 0); + program.LineTo(20, 20); + program.LineTo(10 + dx, 20); + program.ArcTo(10 - dx, 20, 10, cy, RotationType.CCW); + program.LineTo(0, 20); + program.LineTo(0, 0); + // Witness (10, 20.000002) is inside the arc (radius 0.009994 < 0.01) and the zone. + var zone = new ClampZone("tiny", new Box(9.999995, 20.000001199840007, 1e-5, 1e-5)); + + var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture( + new List { new(new Drawing("cap", program), new Vector(0, 0)) }, new[] { zone })); + + Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message))); + Assert.NotEmpty(report.ClampFindings); + } + + private static string Describe(PlateOverlapReport report) => + string.Join(";", report.Pairs.Select(p => $"{p.PartAId}/{p.PartBId}:{p.Area:R}")) + + "|" + + string.Join(";", report.ClampFindings.Select(f => $"{f.PartAId}/{f.PartBName}:{f.Area:R}")); +} diff --git a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs index 855e09d..f95d15e 100644 --- a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs +++ b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs @@ -98,6 +98,53 @@ public class PostVerificationAnalyzerTests Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn)); } + [Fact] + public void ManualCutoffAndRapidCrossingClampRequireFreshPostingConsent() + { + var nest = NestWith(Rectangle(50, 20, 4)); + var plate = Assert.Single(nest.Plates); + plate.ClampZones.Add(new ClampZone("front-3", new Box(10, 0, 1.5, 0.625))); + var cutoff = new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(cutoff); + plate.RegenerateCutOffs(new CutOffSettings()); + var report = PostVerificationAnalyzer.Analyze(nest); + Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Overlap + && f.Message.Contains("front-3") && f.Message.Contains("cut-off") && f.PlateNumber == 1); + Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.RapidCrossing + && f.Message.Contains("front-3") && f.RapidStart.HasValue && f.RapidEnd.HasValue); + Assert.False(report.CanPost(false)); + Assert.True(report.CanPost(true)); + Assert.False(PostVerificationAnalyzer.Analyze(nest).CanPost(false)); + plate.ClampZones.Clear(); + var withoutClamps = PostVerificationAnalyzer.Analyze(nest); + Assert.DoesNotContain(withoutClamps.Findings, f => f.Message.Contains("front-3")); + } + + [Fact] + public void InvalidManualCutoffGeometryIsIncompleteNotClear() + { + var nest = NestWith(Rectangle(40, 20, 4)); + var plate = nest.Plates[0]; + plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625))); + plate.CutOffs.Add(new CutOff(new Vector(double.NaN, 0), CutOffAxis.Vertical)); + var report = PostVerificationAnalyzer.Analyze(nest); + Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Incomplete + && f.Message.Contains("Clamp/cut-off check")); + Assert.False(report.CanPost(false)); + } + + [Fact] + public void PartMaterialOnClampAppearsInPrePostOverlapFindings() + { + var nest = NestWith(Rectangle(10, 0, 4)); + nest.Plates[0].ClampZones.Add(new ClampZone("front-1", new Box(10, 0, 1.5, 0.625))); + var finding = Assert.Single(PostVerificationAnalyzer.Analyze(nest).Findings.Where(f => + f.Kind == PostVerificationKind.Overlap && f.Message.Contains("front-1"))); + Assert.Equal(1, finding.PartNumber); + Assert.Null(finding.OtherPartNumber); + Assert.NotNull(finding.Overlap); + } + [Fact] public void ScribeAndCutoff_AreExemptFromOverlapAndLeadInChecks() { diff --git a/OpenNest.Tests/Diagnostics/StartPointClampRapidWarningTests.cs b/OpenNest.Tests/Diagnostics/StartPointClampRapidWarningTests.cs new file mode 100644 index 0000000..25db649 --- /dev/null +++ b/OpenNest.Tests/Diagnostics/StartPointClampRapidWarningTests.cs @@ -0,0 +1,154 @@ +using System; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Diagnostics; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Diagnostics; + +/// +/// D10 (user decision 2026-10-13, "keep the warning"): a clamp whose physical footprint +/// covers the modeled program start (0,0) makes every direct XY rapid that leaves the start +/// point cross it. That warning stays: no exemption for the first rapid, the start point or +/// "head is already up", and no persisted bypass — every posting attempt re-reports it. +/// Fixtures build the rail with and predict footprints from +/// the physical rule (center +/- width/2 from the origin along the edge, depth into the +/// sheet) plus an independent segment/box oracle, never from the analyzer's own math. +/// +public class StartPointClampRapidWarningTests +{ + private const double OriginCenter = 0.25; + private const double ClampWidth = 1.5; + private const double ClampDepth = 0.625; + + private static int XSign(int quadrant) => quadrant is 2 or 3 ? -1 : 1; + private static int YSign(int quadrant) => quadrant is 3 or 4 ? -1 : 1; + + private static Box PhysicalFootprint(int quadrant, double center) + { + var near = center - ClampWidth / 2; + var far = center + ClampWidth / 2; + var x = XSign(quadrant) > 0 ? near : -far; + var y = YSign(quadrant) > 0 ? 0 : -ClampDepth; + return new Box(x, y, ClampWidth, ClampDepth); + } + + /// Independent oracle: does the closed segment from->to touch the closed box? + private static bool SegmentTouchesBox(Vector from, Vector to, Box box) + { + var tEnter = 0.0; + var tExit = 1.0; + Clip(-dx(from, to), from.X - box.Left); + Clip(dx(from, to), box.Right - from.X); + Clip(-dy(from, to), from.Y - box.Bottom); + Clip(dy(from, to), box.Top - from.Y); + return tEnter <= tExit; + + void Clip(double p, double q) + { + if (p == 0) + { + if (q < 0) tEnter = 2; // provably disjoint + return; + } + var ratio = q / p; + if (p < 0) tEnter = System.Math.Max(tEnter, ratio); + else tExit = System.Math.Min(tExit, ratio); + } + } + + private static double dx(Vector from, Vector to) => to.X - from.X; + private static double dy(Vector from, Vector to) => to.Y - from.Y; + + private static Plate RailPlate(int quadrant, params double[] centers) + { + var plate = new Plate(81, 120) { Quadrant = quadrant }; + plate.SetClampRail(new ClampRail(ClampWidth, ClampDepth, centers)); + return plate; + } + + private static Part ScribeSquare(Vector location) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(0.1, 0); + program.LineTo(0.1, 0.1); + program.LineTo(0, 0.1); + program.LineTo(0, 0); + foreach (var line in program.Codes.OfType()) + line.Layer = LayerType.Scribe; + return new Part(new Drawing("scribe", program), location); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void OriginCoveringClampWarnsOnceOnTheFirstRapid(int quadrant) + { + // Oracle precondition: the rail rule puts a clamp at center 0.25 over the start point. + var footprint = PhysicalFootprint(quadrant, OriginCenter); + Assert.True(footprint.Left <= 0 && 0 <= footprint.Right); + Assert.True(footprint.Bottom <= 0 && 0 <= footprint.Top); + + var plate = RailPlate(quadrant, OriginCenter); + var partLocation = new Vector(XSign(quadrant) * 40, YSign(quadrant) * 20); + plate.Parts.Add(ScribeSquare(partLocation)); + // The oracle sees the incoming rapid (0,0) -> part start crossing the footprint. + Assert.True(SegmentTouchesBox(Vector.Zero, partLocation, footprint)); + + // Every posting attempt re-runs the analysis; nothing is remembered between attempts. + var report = PostVerificationAnalyzer.Analyze(NestOf(plate)); + var again = PostVerificationAnalyzer.Analyze(NestOf(plate)); + + var rapid = Assert.Single(report.Findings, f => f.Kind == PostVerificationKind.RapidCrossing); + Assert.Equal("clamp-1", rapid.ClampId); + Assert.Equal(1, rapid.PartNumber); + Assert.Equal(Vector.Zero, rapid.RapidStart); + var contact = Assert.Single(rapid.ContactPoints); + Assert.InRange(contact.X, footprint.Left, footprint.Right); + Assert.InRange(contact.Y, footprint.Bottom, footprint.Top); + Assert.Contains("Direct XY rapid crosses active clamp 'clamp-1'", rapid.Message); + + var repeated = Assert.Single(again.Findings, f => f.Kind == PostVerificationKind.RapidCrossing); + Assert.Equal(rapid.ClampId, repeated.ClampId); + + // Consent is per attempt: never postable without it, always postable with it. + Assert.False(report.CanPost(false)); + Assert.True(report.CanPost(true)); + Assert.False(again.CanPost(false)); + Assert.True(again.CanPost(true)); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void RailWithoutOriginClampGivesNoClampRapidFinding(int quadrant) + { + var plate = RailPlate(quadrant, 6); + var footprint = PhysicalFootprint(quadrant, 6); + Assert.False(footprint.Left <= 0 && 0 <= footprint.Right && + footprint.Bottom <= 0 && 0 <= footprint.Top); + + var partLocation = new Vector(XSign(quadrant) * 10, YSign(quadrant) * 20); + plate.Parts.Add(ScribeSquare(partLocation)); + // The oracle: this first rapid passes above the clamp, and the part clears it. + Assert.False(SegmentTouchesBox(Vector.Zero, partLocation, footprint)); + + var report = PostVerificationAnalyzer.Analyze(NestOf(plate)); + + Assert.DoesNotContain(report.Findings, f => f.Kind == PostVerificationKind.RapidCrossing); + Assert.DoesNotContain(report.Findings, f => f.ClampId != null); + } + + private static Nest NestOf(Plate plate) + { + var nest = new Nest(); + nest.Plates.Add(plate); + return nest; + } +} diff --git a/OpenNest.Tests/Engine/ClampRailWorkflowTests.cs b/OpenNest.Tests/Engine/ClampRailWorkflowTests.cs new file mode 100644 index 0000000..136a633 --- /dev/null +++ b/OpenNest.Tests/Engine/ClampRailWorkflowTests.cs @@ -0,0 +1,128 @@ +using OpenNest.CNC; +using OpenNest.Data; +using OpenNest.Engine; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.Engine; + +/// +/// The desktop clamp workflow end to end without the form: enabled machine settings give a +/// new nest its rail, whole-job Auto Nest (template stock, validated pipeline, commit) keeps +/// every part clear of the clamps on every sheet while still using the edge between them, +/// and save/reload preserves clamps and placements without writing the empty tail sheet. +/// +public class ClampRailWorkflowTests +{ + private const double EdgeSpacing = 0.25; + private static readonly double[] Centers = { 3, 10.5, 18 }; + + private static Drawing Block() + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(4, 0); + program.LineTo(4, 2); + program.LineTo(0, 2); + program.LineTo(0, 0); + return new Drawing("neutral-block", program); + } + + private static Nest NewNest(int quadrant) + { + var nest = new Nest { Units = Units.Inches }; + nest.PlateDefaults.Size = new Size(12, 24); // 12 deep (Y), 24 along the clamp edge (X) + nest.PlateDefaults.Quadrant = quadrant; + nest.PlateDefaults.EdgeSpacing = new Spacing(EdgeSpacing, EdgeSpacing, EdgeSpacing, EdgeSpacing); + nest.PlateDefaults.PartSpacing = 0.25; + new ClampSettings { Enabled = true, Centers = Centers }.ApplyToNewNest(nest); + return nest; + } + + /// Independent oracle: part boxes against clamps grown by the bottom edge spacing. + private static void AssertClearOfClamps(Plate plate) + { + var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0; + var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0; + Assert.NotEmpty(plate.ClampZones); + foreach (var zone in plate.ClampZones) + { + var keepOut = zone.Bounds.Translate(left, bottom); + foreach (var part in plate.Parts) + { + var box = part.BoundingBox; + var overlapX = System.Math.Min(box.Right, keepOut.Right + EdgeSpacing) + - System.Math.Max(box.Left, keepOut.Left - EdgeSpacing); + var overlapY = System.Math.Min(box.Top, keepOut.Top + EdgeSpacing) + - System.Math.Max(box.Bottom, keepOut.Bottom - EdgeSpacing); + Assert.False(overlapX > 1e-6 && overlapY > 1e-6, + $"{part.BaseDrawing.Name} at {box} enters {zone.Id} at {keepOut}"); + } + } + } + + [Theory] + [InlineData("Rectangles", 1)] + [InlineData("Default", 1)] + [InlineData("Rectangles", 3)] + public void AutoNestAvoidsMachineClampsAndTheyRoundTrip(string engine, int quadrant) + { + const int quantity = 40; + var drawing = Block(); + var nest = NewNest(quadrant); + nest.Drawings.Add(drawing); + var rail = nest.PlateDefaults.ClampRail; + Assert.NotNull(rail); + using var manager = new PlateManager(nest); + var template = manager.GetOrCreateEmpty(); + Assert.Equal(3, template.ClampZones.Count); + + var item = new NestItem + { + Drawing = drawing, + Quantity = quantity, + StepAngle = OpenNest.Math.Angle.HalfPI, + RotationStart = 0, + RotationEnd = OpenNest.Math.Angle.HalfPI, + }; + var result = NestPipeline.Run(new NestPipelineRequest(engine, [item], + NestStockBuilder.FromTemplate(template, null))); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(quantity, Assert.Single(result.Raw.Fulfillment).Placed); + + var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager); + + Assert.True(applied.Count >= 2, $"expected several sheets, got {applied.Count}"); + foreach (var plate in applied) + { + Assert.Same(rail, plate.ClampRail); + Assert.True(rail.Matches(plate)); + AssertClearOfClamps(plate); + } + if (quadrant == 1) + { + // The edge between clamps stays usable: some part sits in the clamp band. + Assert.Contains(applied.SelectMany(p => p.Parts), + p => p.BoundingBox.Bottom < 0.625 + EdgeSpacing); + } + + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + var loaded = new NestReader(new MemoryStream(stream.ToArray())).Read(); + + Assert.Equal(rail, loaded.PlateDefaults.ClampRail); + Assert.Equal(applied.Count, loaded.Plates.Count); // trailing empty sheet not written + for (var i = 0; i < applied.Count; i++) + { + var reloaded = loaded.Plates[i]; + Assert.Same(loaded.PlateDefaults.ClampRail, reloaded.ClampRail); + Assert.Equal(applied[i].ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)), + reloaded.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width))); + Assert.Equal(applied[i].Parts.Select(p => (p.Location, p.Rotation)), + reloaded.Parts.Select(p => (p.Location, p.Rotation))); + AssertClearOfClamps(reloaded); + } + Assert.Equal(3, loaded.CreatePlate().ClampZones.Count); + } +} diff --git a/OpenNest.Tests/Geometry/ClearanceTests.cs b/OpenNest.Tests/Geometry/ClearanceTests.cs index c7708d7..a070fdb 100644 --- a/OpenNest.Tests/Geometry/ClearanceTests.cs +++ b/OpenNest.Tests/Geometry/ClearanceTests.cs @@ -35,6 +35,28 @@ public class ClearanceTests return p; } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OpenPathBoundaryDistanceUsesSegmentInterior_NotFirstEndpoint(bool vertical) + { + var part = Square(0, 0, 2, 2); + var path = vertical + ? new Line(new Vector(10, -20), new Vector(10, 20)) + : new Line(new Vector(-20, 10), new Vector(20, 10)); + var lines = new List { path }; + + var initial = Clearance.BoundaryDistance(part.ToLines(), lines); + Assert.Equal(8, initial.Distance, 6); // Independent axis-aligned box gap. + Assert.Equal(vertical ? -1 : 0, initial.Direction.X, 6); + Assert.Equal(vertical ? 0 : -1, initial.Direction.Y, 6); + + // A parallel translation leaves the true gap unchanged even though + // distance to the first endpoint changes substantially. + part.Offset(vertical ? new Vector(0, 3) : new Vector(3, 0)); + Assert.Equal(8, Clearance.BoundaryDistance(part.ToLines(), lines).Distance, 6); + } + // ---- Separation ---- [Fact] diff --git a/OpenNest.Tests/Geometry/OpenPathMaterialTests.cs b/OpenNest.Tests/Geometry/OpenPathMaterialTests.cs new file mode 100644 index 0000000..9898562 --- /dev/null +++ b/OpenNest.Tests/Geometry/OpenPathMaterialTests.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Geometry; + +public class OpenPathMaterialTests +{ + private static Polygon Rectangle(double left, double bottom, double right, double top) + { + var polygon = new Polygon(); + polygon.Vertices.AddRange(new[] + { + new Vector(left, bottom), new Vector(right, bottom), + new Vector(right, top), new Vector(left, top), new Vector(left, bottom), + }); + polygon.UpdateBounds(); + return polygon; + } + + [Fact] + public void SlopedPathThroughSquareMaterialHasPositiveInteriorLength() + { + var square = Rectangle(40, 40, 42, 42); + var path = new List { new(new Vector(20, 40), new Vector(70, 42)) }; + // At x=41, y=40.84 is strictly inside [40,42]^2. + Assert.True(OpenPathMaterial.Enters(square, null, path)); + } + + [Fact] + public void WhollyContainedSegmentIsNotMisreadAsClearBoundaryGap() + { + var square = Rectangle(0, 0, 10, 10); + var path = new List { new(new Vector(3, 5), new Vector(7, 5)) }; + Assert.True(OpenPathMaterial.Enters(square, null, path)); + } + + [Fact] + public void BoundaryContactAndHoleInteriorRemainClear() + { + var square = Rectangle(0, 0, 10, 10); + var alongBoundary = new List { new(new Vector(-1, 10), new Vector(11, 10)) }; + Assert.False(OpenPathMaterial.Enters(square, null, alongBoundary)); + + var hole = Rectangle(4, 4, 6, 6); + var insideHole = new List { new(new Vector(4.5, 5), new Vector(5.5, 5)) }; + Assert.False(OpenPathMaterial.Enters(square, new List { hole }, insideHole)); + var crossingHoleWall = new List { new(new Vector(5, 5), new Vector(7, 5)) }; + Assert.True(OpenPathMaterial.Enters(square, new List { hole }, crossingHoleWall)); + } + + [Fact] + public void ObliquePathCanHaveOverlappingBoxesWithoutEnteringMaterial() + { + var square = Rectangle(40, 40, 42, 42); + var oblique = new List { new(new Vector(18, 32), new Vector(68, 64)) }; + // At x=40 the line is y=46.08, not inside this square. + Assert.True(oblique[0].BoundingBox.Left < square.BoundingBox.Right); + Assert.True(oblique[0].BoundingBox.Bottom < square.BoundingBox.Top); + Assert.False(OpenPathMaterial.Enters(square, null, oblique)); + } +} diff --git a/OpenNest.Tests/IO/ConvertedEtchMarkTests.cs b/OpenNest.Tests/IO/ConvertedEtchMarkTests.cs new file mode 100644 index 0000000..0db5675 --- /dev/null +++ b/OpenNest.Tests/IO/ConvertedEtchMarkTests.cs @@ -0,0 +1,232 @@ +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.IO; + +/// +/// Nests converted from PEP carry etch marks only in each placed part's locked cutting program; +/// the drawing has none. Removing lead-ins rebuilds a part from its drawing, so loading such a +/// nest must give the marks back to the drawing or clearing lead-ins erases them. +/// +public class ConvertedEtchMarkTests +{ + private const double HalfTurn = System.Math.PI; + private const double QuarterTurn = System.Math.PI / 2; + + [Theory] + [InlineData(Mode.Incremental)] + [InlineData(Mode.Absolute)] + public void ClearingLeadInsKeepsEtchMarksOfConvertedDrawing(Mode mode) + { + var loaded = Read(Save(ConvertedNest(mode))); + var parts = loaded.Plates[0].Parts; + + AppliedCuttingWork.Clear(loaded.Plates.ToList()); + + Assert.All(parts, part => Assert.False(part.HasManualLeadIns)); + AssertMarks(Expected(0, new Vector(0, 0)), parts[0]); + AssertMarks(Expected(QuarterTurn, new Vector(30, 2)), parts[1]); + AssertMarks(Expected(HalfTurn, new Vector(50, 10)), parts[2]); + } + + /// + /// Turning a mark is not symmetric, so a slanted placement proves the marks are put back in + /// the drawing's own frame rather than the frame of the placement they were read from. + /// + [Theory] + [InlineData(Mode.Incremental)] + [InlineData(Mode.Absolute)] + public void MarksOfASlantedPlacementAreTurnedBackIntoTheDrawingFrame(Mode mode) + { + const double slant = System.Math.PI / 6; + var drawing = new Drawing("converted", Build(marks: false, mode)); + var clean = new Part(drawing); + clean.Rotate(slant); + clean.Offset(12, 7); + var nest = NestOf(drawing, SavedPart(drawing, Build(marks: true, mode), slant, new Vector(12, 7)), clean); + + var loaded = Read(Save(nest)); + var parts = loaded.Plates[0].Parts; + + Assert.Equal(2, MarkMoves(loaded.Drawings.Single().Program)); + var inDrawingFrame = MarkSamples(loaded.Drawings.Single().Program, Vector.Zero); + Assert.Equal(MarkSamples(Build(marks: true, Mode.Incremental), Vector.Zero), + inDrawingFrame, VectorSame); + AssertMarks(Expected(slant, new Vector(12, 7)), parts[1]); + AppliedCuttingWork.Clear(loaded.Plates.ToList()); + AssertMarks(Expected(slant, new Vector(12, 7)), parts[0]); + AssertMarks(Expected(slant, new Vector(12, 7)), parts[1]); + } + + [Fact] + public void LoadingGivesTheMarksToCleanPartsAndLeavesSavedProgramsAlone() + { + var nest = ConvertedNest(Mode.Incremental); + var saved = nest.Plates[0].Parts.Take(2).Select(p => NestWriter.GetProgramText(p.Program)).ToArray(); + + var loaded = Read(Save(nest)); + var parts = loaded.Plates[0].Parts; + + Assert.Equal(2, MarkMoves(loaded.Drawings.Single().Program)); + Assert.True(parts[0].LeadInsLocked); + Assert.Equal(saved[0], NestWriter.GetProgramText(parts[0].Program)); + Assert.Equal(saved[1], NestWriter.GetProgramText(parts[1].Program)); + Assert.False(parts[2].HasManualLeadIns); + AssertMarks(Expected(HalfTurn, new Vector(50, 10)), parts[2]); + } + + [Fact] + public void RecoveredMarksAreSavedOnceAndKeepTheSavedPrograms() + { + var once = Read(Save(ConvertedNest(Mode.Incremental))); + var text = NestWriter.GetProgramText(once.Drawings.Single().Program); + + var twice = Read(Save(once)); + + Assert.Equal(text, NestWriter.GetProgramText(twice.Drawings.Single().Program)); + Assert.Equal(2, MarkMoves(twice.Drawings.Single().Program)); + Assert.True(twice.Plates[0].Parts[0].HasManualLeadIns); + Assert.True(twice.Plates[0].Parts[1].HasManualLeadIns); + } + + [Fact] + public void DrawingThatAlreadyHasMarksIsUnchanged() + { + var drawing = new Drawing("imported", Build(marks: true, Mode.Incremental)); + var text = NestWriter.GetProgramText(drawing.Program); + var part = new Part(drawing); + part.Rotate(QuarterTurn); + part.Offset(30, 2); + part.ApplyLeadIns(new OpenNest.CNC.CuttingStrategy.CuttingParameters(), new Vector(-5, -5)); + var nest = NestOf(drawing, part); + + var loaded = Read(Save(nest)); + AppliedCuttingWork.Clear(loaded.Plates.ToList()); + + Assert.Equal(text, NestWriter.GetProgramText(loaded.Drawings.Single().Program)); + AssertMarks(Expected(QuarterTurn, new Vector(30, 2)), loaded.Plates[0].Parts[0]); + } + + [Fact] + public void PartsWithDifferentMarksLeaveTheDrawingWithout() + { + var drawing = new Drawing("converted", Build(marks: false, Mode.Incremental)); + var withMarks = SavedPart(drawing, Build(marks: true, Mode.Incremental), 0, new Vector(0, 0)); + var other = Build(marks: true, Mode.Incremental); + other.Codes.RemoveAt(other.Codes.FindLastIndex(code => code is ArcMove)); + var withLineOnly = SavedPart(drawing, other, 0, new Vector(20, 0)); + + var reader = new NestReader(new MemoryStream(Save(NestOf(drawing, withMarks, withLineOnly)))); + var loaded = reader.Read(); + + Assert.Empty(reader.Warnings); + Assert.Equal(0, MarkMoves(loaded.Drawings.Single().Program)); + Assert.True(loaded.Plates[0].Parts.All(p => p.HasManualLeadIns)); + } + + /// + /// A converted nest: the drawing is material only, two placements keep the full PEP cutting + /// program (etch line and etch circle) at different turns, and one placement is clean. + /// + private static Nest ConvertedNest(Mode mode) + { + var drawing = new Drawing("converted", Build(marks: false, mode)); + var clean = new Part(drawing); + clean.Rotate(HalfTurn); + clean.Offset(50, 10); + return NestOf(drawing, + SavedPart(drawing, Build(marks: true, mode), 0, new Vector(0, 0)), + SavedPart(drawing, Build(marks: true, mode), QuarterTurn, new Vector(30, 2)), + clean); + } + + private static Part SavedPart(Drawing drawing, Program cutting, double rotation, Vector location) + { + var part = new Part(drawing); + part.Rotate(rotation); + part.Offset(location); + var program = (Program)cutting.Clone(); + program.Rotate(rotation); + Assert.True(part.RestoreLeadInProgram(program, locked: true)); + return part; + } + + /// A 10 x 6 plate with an etch line and an etch circle; without marks they are rapids. + private static Program Build(bool marks, Mode mode) + { + var program = new Program(Mode.Absolute); + program.Codes.Add(new RapidMove(2, 3)); + program.Codes.Add(marks ? new LinearMove(4, 3) { Layer = LayerType.Scribe } : new RapidMove(4, 3)); + program.Codes.Add(new RapidMove(7.5, 3)); + program.Codes.Add(marks + ? new ArcMove(new Vector(7.5, 3), new Vector(7, 3), RotationType.CCW) { Layer = LayerType.Scribe } + : new RapidMove(7.5, 3)); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(10, 0)); + program.Codes.Add(new LinearMove(10, 6)); + program.Codes.Add(new LinearMove(0, 6)); + program.Codes.Add(new LinearMove(0, 0)); + program.Mode = mode; + return program; + } + + private static Nest NestOf(Drawing drawing, params Part[] parts) + { + var nest = new Nest { Name = "converted-etch" }; + nest.Drawings.Add(drawing); + var plate = new Plate { Size = new Size(48, 96) }; + foreach (var part in parts) + plate.Parts.Add(part); + nest.Plates.Add(plate); + return nest; + } + + /// World-space mark samples of the full program turned and placed like a part. + private static List Expected(double rotation, Vector location) + { + var program = Build(marks: true, Mode.Incremental); + program.Rotate(rotation); + return MarkSamples(program, location); + } + + private static void AssertMarks(List expected, Part part) + { + var actual = MarkSamples(part.Program, part.Location); + Assert.Equal(expected.Count, actual.Count); + foreach (var point in expected) + { + var index = actual.FindIndex(p => p.DistanceTo(point) <= 1e-4); + Assert.True(index >= 0, $"No etch mark at {point} on the part at {part.Location}."); + actual.RemoveAt(index); + } + } + + // Turn-independent samples: line ends, and each circle's centre and radius. + private static List MarkSamples(Program program, Vector location) => + ConvertProgram.ToGeometry(program) + .Where(entity => entity.Layer == SpecialLayers.Scribe) + .SelectMany(entity => entity switch + { + Line line => new[] { line.StartPoint + location, line.EndPoint + location }, + Circle circle => new[] { circle.Center + location, new Vector(circle.Radius, 0) }, + Arc arc => new[] { arc.StartPoint() + location, arc.EndPoint() + location }, + _ => Array.Empty(), + }) + .ToList(); + + private static bool VectorSame(Vector left, Vector right) => left.DistanceTo(right) <= 1e-6; + + private static int MarkMoves(Program program) => + program.Codes.Count(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe }); + + private static byte[] Save(Nest nest) + { + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + return stream.ToArray(); + } + + private static Nest Read(byte[] bytes) => new NestReader(new MemoryStream(bytes)).Read(); +} diff --git a/OpenNest.Tests/PlateManagerDefaultsTests.cs b/OpenNest.Tests/PlateManagerDefaultsTests.cs new file mode 100644 index 0000000..7426e25 --- /dev/null +++ b/OpenNest.Tests/PlateManagerDefaultsTests.cs @@ -0,0 +1,236 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class PlateManagerDefaultsTests +{ + private static Nest CreateNest(bool withRail = true) + { + var nest = new Nest(); + nest.PlateDefaults.Size = new Size(48, 96); + nest.PlateDefaults.Quadrant = 1; + nest.PlateDefaults.EdgeSpacing = new Spacing(1, 2, 3, 4); + nest.PlateDefaults.PartSpacing = 0.25; + if (withRail) + nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 81.0 })); + return nest; + } + + private static void ChangeDefaults(Nest nest) + { + nest.PlateDefaults.Size = new Size(48, 60); + nest.PlateDefaults.Quadrant = 3; + nest.PlateDefaults.EdgeSpacing = new Spacing(5, 6, 7, 8); + nest.PlateDefaults.PartSpacing = 0.5; + } + + private static void AssertMatchesDefaults(Nest nest, Plate plate) + { + Assert.Equal(nest.PlateDefaults.Size, plate.Size); + Assert.Equal(nest.PlateDefaults.Quadrant, plate.Quadrant); + Assert.Equal(nest.PlateDefaults.EdgeSpacing, plate.EdgeSpacing); + Assert.Equal(nest.PlateDefaults.PartSpacing, plate.PartSpacing); + Assert.Same(nest.PlateDefaults.ClampRail, plate.ClampRail); + Assert.Empty(plate.RemovedClamps); + if (plate.ClampRail == null) + Assert.Empty(plate.ClampZones); + else + Assert.True(plate.ClampRail.Matches(plate)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ChangePlateDefaults_MovesUntouchedTrailingSheet(bool withRail) + { + var nest = CreateNest(withRail); + using var manager = new PlateManager(nest); + manager.EnsureSentinel(); + var sentinel = Assert.Single(nest.Plates); + var listChanges = 0; + var currentChanges = 0; + manager.PlateListChanged += (_, _) => listChanges++; + manager.CurrentPlateChanged += (_, e) => + { + Assert.Same(sentinel, e.Plate); + currentChanges++; + }; + + manager.ChangePlateDefaults(() => ChangeDefaults(nest)); + + Assert.Same(sentinel, Assert.Single(nest.Plates)); + AssertMatchesDefaults(nest, sentinel); + Assert.Equal(0, manager.CurrentIndex); + Assert.Equal(1, listChanges); + Assert.Equal(1, currentChanges); + if (withRail) + { + // Independent physical oracle: Q3 origin clamp is at the sheet's upper right. + var zone = Assert.Single(sentinel.ClampZones); + Assert.Equal("clamp-1", zone.Id); + Assert.Equal(53.25, zone.Bounds.X); + Assert.Equal(47.375, zone.Bounds.Y); + Assert.Equal(1.5, zone.Bounds.Length); + Assert.Equal(0.625, zone.Bounds.Width); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ChangePlateDefaults_ReappliesTheNewRail(bool removeRail) + { + var nest = CreateNest(); + var sentinel = nest.CreatePlate(); + using var manager = new PlateManager(nest); + var newRail = removeRail ? null : new ClampRail(2, 1, new[] { 10.0 }); + + manager.ChangePlateDefaults(() => + { + ChangeDefaults(nest); + nest.PlateDefaults.ClampRail = newRail; + }); + + AssertMatchesDefaults(nest, sentinel); + } + + [Theory] + [InlineData("width")] + [InlineData("length")] + [InlineData("quadrant")] + [InlineData("left")] + [InlineData("bottom")] + [InlineData("right")] + [InlineData("top")] + [InlineData("partSpacing")] + [InlineData("removed")] + [InlineData("explicit")] + [InlineData("detachedMatchingZones")] + [InlineData("differentRail")] + [InlineData("editedRailZones")] + [InlineData("cutOff")] + [InlineData("part")] + public void ChangePlateDefaults_PreservesEditedOrUsedTrailingSheet(string edit) + { + var nest = CreateNest(); + var plate = nest.CreatePlate(); + switch (edit) + { + // Even sub-tolerance changes are edits, not untouched defaults. + case "width": plate.Size = new Size(48 + 1e-9, 96); break; + case "length": plate.Size = new Size(48, 96 + 1e-9); break; + case "quadrant": plate.Quadrant = 2; break; + case "left": plate.EdgeSpacing.Left += 1e-9; break; + case "bottom": plate.EdgeSpacing.Bottom += 1e-9; break; + case "right": plate.EdgeSpacing.Right += 1e-9; break; + case "top": plate.EdgeSpacing.Top += 1e-9; break; + case "partSpacing": plate.PartSpacing += 1e-9; break; + case "removed": plate.SetClampRail(nest.PlateDefaults.ClampRail, new[] { "clamp-1" }); break; + case "explicit": + plate.SetClampRail(null!); + plate.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1))); + break; + case "detachedMatchingZones": + var zones = plate.ClampZones.ToArray(); + plate.SetClampRail(null!); + foreach (var zone in zones) + plate.ClampZones.Add(zone); + break; + case "differentRail": + plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 81.0 }, removalMinutes: 8)); + break; + case "editedRailZones": + plate.ClampZones.Clear(); + plate.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1))); + break; + case "cutOff": plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); break; + case "part": plate.Parts.Add(new Part(new Drawing("block", new Program()))); break; + } + var size = plate.Size; + var quadrant = plate.Quadrant; + var spacing = plate.EdgeSpacing; + var partSpacing = plate.PartSpacing; + var rail = plate.ClampRail; + var removed = plate.RemovedClamps.ToArray(); + var savedZones = plate.ClampZones.ToArray(); + var parts = plate.Parts.ToArray(); + var cutOffs = plate.CutOffs.ToArray(); + using var manager = new PlateManager(nest); + var notifications = 0; + manager.CurrentPlateChanged += (_, _) => notifications++; + manager.PlateListChanged += (_, _) => notifications++; + + manager.ChangePlateDefaults(() => ChangeDefaults(nest)); + + Assert.Equal(new Size(48, 60), nest.PlateDefaults.Size); + Assert.Same(plate, Assert.Single(nest.Plates)); + Assert.Equal(size, plate.Size); + Assert.Equal(quadrant, plate.Quadrant); + Assert.Equal(spacing, plate.EdgeSpacing); + Assert.Equal(partSpacing, plate.PartSpacing); + Assert.Same(rail, plate.ClampRail); + Assert.Equal(removed, plate.RemovedClamps); + Assert.Equal(savedZones, plate.ClampZones); + Assert.Equal(parts, plate.Parts); + Assert.Equal(cutOffs, plate.CutOffs); + Assert.Equal(0, notifications); + } + + [Fact] + public void ChangePlateDefaults_PreservesMiddleEmptySheetAndCurrentSelection() + { + var nest = CreateNest(); + var middle = nest.CreatePlate(); + var sentinel = nest.CreatePlate(); + using var manager = new PlateManager(nest); + manager.LoadFirst(); + var currentChanges = 0; + manager.CurrentPlateChanged += (_, _) => currentChanges++; + + manager.ChangePlateDefaults(() => ChangeDefaults(nest)); + + Assert.Equal(2, nest.Plates.Count); + Assert.Equal(new Size(48, 96), middle.Size); + Assert.Equal(1, middle.Quadrant); + Assert.Equal(new Spacing(1, 2, 3, 4), middle.EdgeSpacing); + Assert.Equal(0.25, middle.PartSpacing); + Assert.Equal(2, middle.ClampZones.Count); + AssertMatchesDefaults(nest, sentinel); + Assert.Same(middle, manager.CurrentPlate); + Assert.Equal(0, currentChanges); + } + + [Fact] + public void ChangePlateDefaults_SetFromExistingMovesOnlyTheSentinel() + { + var nest = CreateNest(); + var selected = nest.CreatePlate(); + selected.Size = new Size(48, 60); + selected.Quadrant = 3; + selected.EdgeSpacing = new Spacing(5, 6, 7, 8); + selected.PartSpacing = 0.5; + var sentinel = nest.CreatePlate(); + using var manager = new PlateManager(nest); + + manager.ChangePlateDefaults(() => nest.PlateDefaults.SetFromExisting(selected)); + + AssertMatchesDefaults(nest, sentinel); + AssertMatchesDefaults(nest, selected); + Assert.Equal(2, nest.Plates.Count); + Assert.Same(selected, manager.CurrentPlate); + } + + [Fact] + public void ChangePlateDefaults_WithoutPlatesStillAppliesDefaults() + { + var nest = CreateNest(); + using var manager = new PlateManager(nest); + + manager.ChangePlateDefaults(() => ChangeDefaults(nest)); + + Assert.Empty(nest.Plates); + manager.EnsureSentinel(); + AssertMatchesDefaults(nest, Assert.Single(nest.Plates)); + } +} diff --git a/OpenNest.Tests/Sequencing/PlateSequencingTests.cs b/OpenNest.Tests/Sequencing/PlateSequencingTests.cs index 1652143..6853348 100644 --- a/OpenNest.Tests/Sequencing/PlateSequencingTests.cs +++ b/OpenNest.Tests/Sequencing/PlateSequencingTests.cs @@ -245,20 +245,42 @@ public class PlateSequencingTests } [Fact] - public void Apply_NonCrossingTailCutOff_KeepsItsNormalSequencePlace() + public void Apply_SheetSeparatingCutOff_GoesAfterEveryPartAndCrossingCutOff() { - var part = TestHelpers.MakePartAt(10, 10, 10); - var plate = TestHelpers.MakePlate(60, 120, part); - var tail = new CutOff(new Vector(40, 0), CutOffAxis.Vertical); - plate.CutOffs.Add(tail); - plate.RegenerateCutOffs(new CutOffSettings()); - var parameters = new SequenceParameters { Method = SequenceMethod.LeftSide }; - var expected = Baseline(plate, parameters); - Assert.Same(part, expected[0]); + var hazards = 0; + foreach (var method in Enum.GetValues()) + { + var left = TestHelpers.MakePartAt(10, 10, 10); + var right = TestHelpers.MakePartAt(80, 10, 10); + var plate = TestHelpers.MakePlate(60, 120, left, right); + var separator = new CutOff(new Vector(40, 0), CutOffAxis.Vertical); + var crossing = new CutOff(new Vector(85, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(separator); + plate.CutOffs.Add(crossing); + plate.RegenerateCutOffs(new CutOffSettings()); + var parameters = new SequenceParameters { Method = method }; + var separatorPart = plate.Parts.Single(p => ReferenceEquals(p.BaseDrawing, separator.Drawing)); + List baseline; + try + { + baseline = Baseline(plate, parameters).ToList(); + } + catch (NotSupportedException) + { + continue; + } + if (baseline.IndexOf(separatorPart) < baseline.IndexOf(right) + || baseline.IndexOf(separatorPart) < baseline.IndexOf(left)) + hazards++; - PlateSequencing.Apply(plate, parameters); + PlateSequencing.Apply(plate, parameters); - Assert.Equal(expected, plate.Parts); + Assert.Same(separatorPart, plate.Parts[^1]); + AssertPrecedes(plate, crossing, right); + Assert.Equal(4, plate.Parts.Count); + } + // Without the rule, at least one route cuts the separator before a part. + Assert.True(hazards > 0); } [Fact] diff --git a/OpenNest.Tests/TimingTests.cs b/OpenNest.Tests/TimingTests.cs new file mode 100644 index 0000000..f9f6528 --- /dev/null +++ b/OpenNest.Tests/TimingTests.cs @@ -0,0 +1,62 @@ +using OpenNest.Api; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +/// +/// D9: a sheet's cut-time estimate gains the rail's removal minutes once per removed clamp, +/// scaled by the plate's cut quantity like every other timing field; a plate with nothing +/// removed is unaffected. +/// +public class TimingTests +{ + private static Plate PlateWithOneRemovedClamp(double removalMinutes, int quantity = 1) + { + var plate = new Plate(3, 23) { Quantity = quantity, EdgeSpacing = new Spacing(0, 0, 0, 0) }; + var rail = new ClampRail(1.5, 0.625, new[] { 6.0, 21.0 }, removalMinutes); + plate.SetClampRail(rail, new[] { "clamp-1" }); + return plate; + } + + [Fact] + public void PlateWithNoRemovedClampsAddsNoTime() + { + var plate = new Plate(48, 96) { Quantity = 3 }; + + var info = Timing.GetTimingInfo(plate); + + Assert.Equal(0, info.ClampRemovalMinutes); + } + + [Fact] + public void RemovedClampMinutesAreAddedOncePerPhysicalSheet() + { + var plate = PlateWithOneRemovedClamp(removalMinutes: 5, quantity: 3); + + var info = Timing.GetTimingInfo(plate); + + Assert.Equal(15, info.ClampRemovalMinutes); // 5 minutes x 3 physical sheets + } + + [Fact] + public void CalculateTimeAddsTheRemovalMinutesToTheEstimate() + { + var info = new TimingInfo { ClampRemovalMinutes = 7 }; + + var time = Timing.CalculateTime(info, CutParameters.Default); + + Assert.Equal(TimeSpan.FromMinutes(7), time); + } + + [Fact] + public void NestTotalsSumRemovalMinutesAcrossPlates() + { + var nest = new Nest(); + nest.Plates.Add(PlateWithOneRemovedClamp(removalMinutes: 5)); + nest.Plates.Add(new Plate(48, 96)); // ordinary plate, no clamps + + var info = Timing.GetTimingInfo(nest); + + Assert.Equal(5, info.ClampRemovalMinutes); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs b/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs index b350eff..f9f1376 100644 --- a/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs @@ -20,12 +20,124 @@ public class AutomaticCutOffFormTests using var form = new AutomaticCutOffForm(view, units); Assert.Equal(spacing, Control(form, "spacingBox").Text); Assert.Contains(suffix, Control