feat(clamps): give clamps their own clearance from parts

A clamp rail can now carry its own Clearance, the gap parts keep from
every clamp footprint, independent of part spacing. Unset, it keeps the
sheet's bottom edge spacing as before. Clamp blocks drawn under the
sheet can use close to zero so the laser only stays off them.

Plate.ClampClearance and NestPlateStock.ClampClearance resolve it once;
the engines, interactive fill/pack/push, the layout check, Expander walls
and automatic cut-off placement all read it. The interactive push now
holds clamps at their square clearance keep-out (the layout check's
shape) instead of the part spacing, so parts no longer tuck into its
corners; the clamped fill characterization digests are re-pinned after
checking the new layouts validate.

Persisted as an optional `clearance` on the .nest clamp rail and the
machine clamp settings; edited in Nest > Clamps and the machine's
Clamps tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-29 17:25:27 -04:00
1 parent afdf196754
commit 0719476618
32 files changed
+546 -152

No files matched your search

+5 -5
View File
@@ -196,7 +196,7 @@ public static class AutomaticCutOffPlanner
var y = ySign * horizontalDistance.Value;
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
var clamp = clamps.FirstOrDefault(zone =>
y >= zone.Bounds.Bottom - plate.EdgeSpacing.Bottom && y <= zone.Bounds.Top + plate.EdgeSpacing.Bottom);
y >= zone.Bounds.Bottom - plate.ClampClearance && y <= zone.Bounds.Top + plate.ClampClearance);
if (limited || clamp != null)
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
@@ -460,7 +460,7 @@ public static class AutomaticCutOffPlanner
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.ClampBlockedCut,
$"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.",
$"The proposed cut crosses clamp '{blocked.Id}' (including its clearance); it was skipped.",
X: x));
if (isSeparator)
WarnNoSeparator(x);
@@ -522,7 +522,7 @@ public static class AutomaticCutOffPlanner
// 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;
x >= zone.Bounds.Left - plate.ClampClearance && x <= zone.Bounds.Right + plate.ClampClearance;
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
@@ -548,8 +548,8 @@ public static class AutomaticCutOffPlanner
Require(plate.Outline == null, "Automatic cut-offs need a rectangular sheet; this plate has an outline.", 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));
Nonnegative(plate.ClampClearance)),
"Clamp zones and clamp 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(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
+26 -7
View File
@@ -26,14 +26,16 @@ namespace OpenNest
double width,
double depth,
IEnumerable<double> centers,
double removalMinutes = DefaultRemovalMinutes
double removalMinutes = DefaultRemovalMinutes,
double? clearance = null
)
: this(
width,
depth,
(centers ?? throw new ArgumentNullException(nameof(centers)))
.Select(c => new RailClamp(ClampEdge.OriginSide, c)),
removalMinutes
removalMinutes,
clearance
)
{ }
@@ -41,7 +43,8 @@ namespace OpenNest
double width,
double depth,
IEnumerable<RailClamp> clamps,
double removalMinutes = DefaultRemovalMinutes
double removalMinutes = DefaultRemovalMinutes,
double? clearance = null
)
{
ArgumentNullException.ThrowIfNull(clamps);
@@ -60,6 +63,11 @@ namespace OpenNest
nameof(removalMinutes),
"Clamp removal time must be finite and zero or more."
);
if (clearance is double c && (!double.IsFinite(c) || c < 0))
throw new ArgumentOutOfRangeException(
nameof(clearance),
"Clamp clearance must be finite and zero or more."
);
var sorted = new SortedSet<RailClamp>();
foreach (var clamp in clamps)
@@ -77,6 +85,7 @@ namespace OpenNest
Width = width;
Depth = depth;
RemovalMinutes = removalMinutes;
Clearance = clearance;
Clamps = sorted.ToArray();
Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray();
}
@@ -93,6 +102,14 @@ namespace OpenNest
/// </summary>
public double RemovalMinutes { get; }
/// <summary>
/// The gap parts keep from every clamp footprint, independent of part spacing; null
/// keeps the sheet's bottom edge spacing (D6), the clearance before clamps had their
/// own. A clamp drawn as a support block under the sheet can use close to zero, so the
/// laser only stays off it. See <see cref="Plate.ClampClearance"/>.
/// </summary>
public double? Clearance { get; }
/// <summary>
/// Distinct clamps ordered by edge, then center. A clamp's position in this list
/// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids
@@ -103,9 +120,9 @@ namespace OpenNest
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
public IReadOnlyList<double> Centers { get; }
/// <summary>A copy with the same size and removal time and these clamps.</summary>
/// <summary>A copy with the same size, removal time and clearance and these clamps.</summary>
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
new(Width, Depth, clamps, RemovalMinutes);
new(Width, Depth, clamps, RemovalMinutes, Clearance);
/// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
public static string IdOf(int index) => $"clamp-{index + 1}";
@@ -225,20 +242,22 @@ namespace OpenNest
&& Width.Equals(other.Width)
&& Depth.Equals(other.Depth)
&& RemovalMinutes.Equals(other.RemovalMinutes)
&& Nullable.Equals(Clearance, other.Clearance)
&& Clamps.SequenceEqual(other.Clamps);
public override bool Equals(object obj) => Equals(obj as ClampRail);
public override int GetHashCode()
{
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clamps.Count);
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clearance, Clamps.Count);
foreach (var clamp in Clamps)
hash = HashCode.Combine(hash, clamp);
return hash;
}
public override string ToString() =>
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]";
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]"
+ (Clearance is double clearance ? $" clearance {clearance:R}" : "");
private static string Describe(RailClamp clamp) =>
clamp.Edge == ClampEdge.OriginSide
+8
View File
@@ -193,6 +193,14 @@ namespace OpenNest
/// </summary>
public ClampRail ClampRail { get; private set; }
/// <summary>
/// The gap part material keeps from every clamp zone: the rail's own
/// <see cref="OpenNest.ClampRail.Clearance"/>, else the bottom edge spacing (D6), never
/// negative. Part spacing never applies to clamps.
/// </summary>
public double ClampClearance =>
System.Math.Max(0, ClampRail?.Clearance ?? EdgeSpacing.Bottom);
/// <summary>
/// The machine support bed this sheet rests on (its nest's, see
/// <see cref="Nest.SetSupportBed"/>), or null for none. Display, analysis and support
+13 -2
View File
@@ -19,6 +19,7 @@ public sealed class ClampRailEditor
Width = initial.Width;
Depth = initial.Depth;
RemovalMinutes = initial.RemovalMinutes;
Clearance = initial.Clearance;
_clamps.AddRange(initial.Clamps.Order());
}
@@ -35,6 +36,9 @@ public sealed class ClampRailEditor
/// length, so it is never converted between units.</summary>
public double RemovalMinutes { get; set; }
/// <summary>The gap parts keep from every clamp; null uses each sheet's bottom edge spacing.</summary>
public double? Clearance { get; set; }
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
public bool ApplyToNewNests { get; set; }
@@ -91,6 +95,7 @@ public sealed class ClampRailEditor
Width = converted.Width;
Depth = converted.Depth;
RemovalMinutes = converted.RemovalMinutes;
Clearance = converted.Clearance;
ApplyToNewNests = settings.Enabled;
_clamps.Clear();
_clamps.AddRange(converted.Clamps.Distinct().Order());
@@ -99,10 +104,10 @@ public sealed class ClampRailEditor
/// <summary>
/// The edited rail, or null when no clamp is listed. Throws
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
/// negative/non-finite removal time.
/// negative/non-finite removal time or clearance.
/// </summary>
public ClampRail? BuildRail() =>
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes);
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes, Clearance);
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
public void ApplyTo(Nest nest)
@@ -128,6 +133,11 @@ public sealed class ClampRailEditor
nameof(RemovalMinutes),
"Clamp removal time must be finite and zero or more."
);
if (Clearance is double clearance && (!double.IsFinite(clearance) || clearance < 0))
throw new ArgumentOutOfRangeException(
nameof(Clearance),
"Clamp clearance must be finite and zero or more."
);
var settings = new ClampSettings
{
Enabled = ApplyToNewNests && _clamps.Count > 0,
@@ -135,6 +145,7 @@ public sealed class ClampRailEditor
Width = Width,
Depth = Depth,
RemovalMinutes = RemovalMinutes,
Clearance = Clearance,
};
settings.SetClamps(_clamps);
return settings;
+22 -2
View File
@@ -65,6 +65,12 @@ public sealed class ClampSettings
/// </summary>
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// <summary>
/// The gap parts keep from every clamp, in <see cref="Units"/>; null uses each sheet's
/// bottom edge spacing (<see cref="ClampRail.Clearance"/>).
/// </summary>
public double? Clearance { get; set; }
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
@@ -133,6 +139,7 @@ public sealed class ClampSettings
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
Clearance = rail?.Clearance,
};
if (rail != null)
settings.SetClamps(rail.Clamps);
@@ -150,11 +157,12 @@ public sealed class ClampSettings
return null;
var converted = ConvertedTo(units);
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes);
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes,
converted.Clearance);
}
/// <summary>
/// A copy with width, depth and centers expressed in <paramref name="units"/>
/// A copy with width, depth, clearance and centers expressed in <paramref name="units"/>
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
/// </summary>
@@ -168,6 +176,7 @@ public sealed class ClampSettings
Width = Width * scale,
Depth = Depth * scale,
RemovalMinutes = RemovalMinutes,
Clearance = Clearance * scale,
Centers = Centers.Select(c => c * scale).ToArray(),
FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(),
OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(),
@@ -290,6 +299,15 @@ public sealed class ClampSettings
ignored = true;
}
// Missing or null keeps the bottom edge spacing; anything else must be a length.
if (values.TryGetValue("clearance", out var clearance) && clearance.ValueKind != JsonValueKind.Null)
{
if (TryLength(clearance, out var value) && value >= 0)
settings.Clearance = value;
else
ignored = true;
}
if (values.TryGetValue("centers", out var centers))
settings.Centers = ReadCenters(centers, ref ignored);
if (values.TryGetValue("farSideCenters", out var farSide))
@@ -362,6 +380,8 @@ public sealed class ClampSettings
["removalMinutes"] = RemovalMinutes,
["centers"] = Centers,
};
if (Clearance is double clearance)
dto["clearance"] = clearance;
// Other edges are written only when used, so origin-side-only files stay unchanged.
if (FarSideCenters.Count > 0)
dto["farSideCenters"] = FarSideCenters;
+8 -1
View File
@@ -19,6 +19,9 @@ public sealed class MachineClamps
/// <summary>Minutes to remove one clamp and put it back after cutting; never scaled.</summary>
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// <summary>The gap parts keep from every clamp; null uses each sheet's bottom edge spacing.</summary>
public double? Clearance { get; set; }
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
public List<double> Centers { get; set; } = new();
@@ -49,6 +52,7 @@ public sealed class MachineClamps
Width = converted.Width,
Depth = converted.Depth,
RemovalMinutes = converted.RemovalMinutes,
Clearance = converted.Clearance,
Centers = converted.Centers.ToList(),
FarSideCenters = converted.FarSideCenters.ToList(),
OriginEndCenters = converted.OriginEndCenters.ToList(),
@@ -58,7 +62,8 @@ public sealed class MachineClamps
/// <summary>
/// The rail as clamp settings in the machine's <paramref name="units"/>. An invalid size or
/// removal time falls back to the default, and an invalid center is dropped, as when
/// removal time falls back to the default, an invalid clearance to the bottom edge spacing,
/// and an invalid center is dropped, as when
/// <see cref="ClampSettings.Parse"/> reads a file.
/// </summary>
public ClampSettings ToSettings(UnitSystem units)
@@ -70,6 +75,8 @@ public sealed class MachineClamps
defaults.Depth = Depth;
if (IsLength(RemovalMinutes) && RemovalMinutes >= 0)
defaults.RemovalMinutes = RemovalMinutes;
if (Clearance is double clearance && IsLength(clearance) && clearance >= 0)
defaults.Clearance = clearance;
defaults.Centers = Valid(Centers);
defaults.FarSideCenters = Valid(FarSideCenters);
defaults.OriginEndCenters = Valid(OriginEndCenters);
@@ -0,0 +1,101 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// A clamp rail's own clearance replaces the bottom edge spacing as the gap parts keep from
/// clamps, in the stock engines read, the layout check and the interactive push; part spacing
/// never applies to clamps.
/// </summary>
public class ClampClearanceTests
{
// 24 x 24 sheet, 0.5 edge spacing, 1.0 part spacing; one clamp at X 6 on the front edge.
private const double Edge = 0.5;
private const double ClampTop = 0.625;
private static Plate RailPlate(double? clearance)
{
var plate = new Plate(new Size(24, 24))
{
EdgeSpacing = new Spacing(Edge, Edge),
PartSpacing = 1.0,
};
plate.SetClampRail(new ClampRail(1.5, ClampTop, new[] { 6.0 }, clearance: clearance));
return plate;
}
private static Drawing Square(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, 0);
return new Drawing("square", program);
}
private static List<string> Violations(Plate plate, Part part) =>
NestLayoutCheck.Validate(new() { (plate, new List<Part> { part }) },
new Dictionary<Drawing, (string, int)> { [part.BaseDrawing] = ("square", 1) });
[Fact]
public void WithoutItsOwnClearanceTheRailKeepsTheBottomEdgeSpacing()
{
var plate = RailPlate(clearance: null);
Assert.Equal(Edge, plate.ClampClearance);
Assert.Equal(Edge, DrawingJobMapper.FromPlate("s", plate, 1).ClampClearance);
}
[Fact]
public void StockCarriesTheRailsOwnClearance()
{
var plate = RailPlate(clearance: 0.05);
Assert.Equal(0.05, plate.ClampClearance);
Assert.Equal(0.05, DrawingJobMapper.FromPlate("s", plate, 1).ClampClearance);
}
[Fact]
public void LayoutCheckEnforcesTheClampClearanceNotTheEdgeSpacing()
{
// A part 0.1 above the clamp, straddling it in X.
var part = new Part(Square(3), new Vector(5, ClampTop + 0.1));
Assert.Contains(Violations(RailPlate(clearance: null), part), v => v.Contains("clamp zone"));
Assert.DoesNotContain(Violations(RailPlate(clearance: 0.05), part), v => v.Contains("clamp zone"));
Assert.Contains(Violations(RailPlate(clearance: 0.2), part), v => v.Contains("clamp zone"));
}
[Fact]
public void PushStopsAtTheClampClearanceRatherThanThePartSpacing()
{
var plate = RailPlate(clearance: 0);
var mover = new Part(Square(3), new Vector(5, 8));
plate.Parts.Add(mover);
Compactor.Push(new List<Part> { mover }, plate, PushDirection.Down);
// Part spacing (1.0) and edge spacing (0.5) would both stop it higher.
Assert.InRange(mover.BoundingBox.Bottom, ClampTop - 1e-6, ClampTop + 0.01);
}
[Fact]
public void PushStillKeepsTheClampClearanceWhenItExceedsThePartSpacing()
{
var plate = RailPlate(clearance: 2);
var mover = new Part(Square(3), new Vector(5, 8));
plate.Parts.Add(mover);
Compactor.Push(new List<Part> { mover }, plate, PushDirection.Down);
Assert.InRange(mover.BoundingBox.Bottom, ClampTop + 2 - 0.01, ClampTop + 2 + 0.01);
}
}
@@ -338,9 +338,9 @@ public class FillStackCharacterizationTests
// --- interactive fill beside clamps ---
[Theory]
[InlineData("Fill", 40, "9B3EFF9B72243576")]
[InlineData("Strip", 40, "9B3EFF9B72243576")]
[InlineData("Vertical Remnant", 40, "039C0D607166D805")]
[InlineData("Fill", 40, "9BF5FC7FC47364A9")]
[InlineData("Strip", 40, "9BF5FC7FC47364A9")]
[InlineData("Vertical Remnant", 40, "9BF5FC7FC47364A9")]
[InlineData("Horizontal Remnant", 40, "C1E81564A620302A")]
public void FillItem_WithClampZones_PinnedLayout(
string strategy,
+138 -91
View File
@@ -13,39 +13,32 @@ namespace OpenNest.Engine.Fill
public static class Compactor
{
/// <summary>
/// The plate's clamp zones as static push obstacles (C1). Unlike ordinary
/// already-overlapping parts, a clamp stays a wall even when a selected member
/// straddles it: dropping it would let other clear members move into the zone.
/// Empty zone lists change every caller's arithmetic by nothing (C2).
/// The plate's clamp keep-outs (zones grown square by the clamp clearance) as static push
/// obstacles (C1), held at zero spacing rather than the part spacing. Unlike ordinary already-overlapping parts,
/// a clamp stays a wall even when a selected member straddles it: dropping it would
/// let other clear members move into the zone. Empty zone lists change every
/// caller's arithmetic by nothing (C2).
/// </summary>
private static List<Part> ZoneObstacles(Plate plate) =>
Jobs.InteractiveClamps.ObstacleParts(plate);
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
{
var obstacleParts = ZoneObstacles(plate);
obstacleParts.InsertRange(
0,
plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList()
);
private static ObstacleSet ZoneObstacles(Plate plate) =>
new(Jobs.InteractiveClamps.ObstacleParts(plate), 0);
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
}
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction) =>
Push(movingParts, plate, SpatialQuery.DirectionToOffset(direction, 1.0));
/// <summary>
/// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up).
/// </summary>
public static double Push(List<Part> movingParts, Plate plate, double angle)
{
var obstacleParts = ZoneObstacles(plate);
obstacleParts.InsertRange(
0,
plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList()
);
public static double Push(List<Part> movingParts, Plate plate, double angle) =>
Push(movingParts, plate, new Vector(System.Math.Cos(angle), System.Math.Sin(angle)));
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
private static double Push(List<Part> movingParts, Plate plate, Vector direction)
{
var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList();
return Push(movingParts, plate.WorkArea(), direction,
new ObstacleSet(obstacleParts, plate.PartSpacing), ZoneObstacles(plate));
}
/// <summary>
@@ -57,21 +50,20 @@ namespace OpenNest.Engine.Fill
Box workArea,
double partSpacing,
Vector direction
) => Push(movingParts, workArea, direction, new ObstacleSet(obstacleParts, partSpacing));
/// <summary>
/// Pushes movingParts to the first contact with the work-area edge or an obstacle,
/// keeping each obstacle set's own spacing.
/// </summary>
private static double Push(
List<Part> movingParts,
Box workArea,
Vector direction,
params ObstacleSet[] obstacleSets
)
{
var opposite = -direction;
var obstacleBoxes = new Box[obstacleParts.Count];
var obstacleSpacingBoxes = new Box[obstacleParts.Count];
var obstacleEntities = new List<Entity>[obstacleParts.Count];
var halfSpacing = System.Math.Max(0, partSpacing) / 2;
for (var i = 0; i < obstacleParts.Count; i++)
{
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
obstacleSpacingBoxes[i] = SpacingBounds(obstacleBoxes[i], halfSpacing);
}
var distance = double.MaxValue;
foreach (var moving in movingParts)
@@ -82,15 +74,64 @@ namespace OpenNest.Engine.Fill
else if (edgeDist < distance)
distance = edgeDist;
foreach (var set in obstacleSets)
distance = set.Travel(moving, direction, opposite, distance);
}
if (distance < double.MaxValue && distance > 0)
{
var offset = direction * distance;
foreach (var moving in movingParts)
moving.Offset(offset);
return distance;
}
return 0;
}
/// <summary>
/// Obstacles kept at one spacing, with their bounds and spacing-offset contours
/// cached for one push.
/// </summary>
private sealed class ObstacleSet
{
private readonly List<Part> parts;
private readonly Box[] spacingBoxes;
private readonly List<Entity>[] entities;
private readonly double halfSpacing;
public ObstacleSet(List<Part> parts, double spacing)
{
this.parts = parts;
Spacing = System.Math.Max(0, spacing);
halfSpacing = Spacing / 2;
Boxes = new Box[parts.Count];
spacingBoxes = new Box[parts.Count];
entities = new List<Entity>[parts.Count];
for (var i = 0; i < parts.Count; i++)
{
Boxes[i] = parts[i].BoundingBox;
spacingBoxes[i] = SpacingBounds(Boxes[i], halfSpacing);
}
}
public double Spacing { get; }
public Box[] Boxes { get; }
/// <summary>The shorter of <paramref name="distance"/> and how far
/// <paramref name="moving"/> travels before it comes within the spacing.</summary>
public double Travel(Part moving, Vector direction, Vector opposite, double distance)
{
var movingBox = moving.BoundingBox;
List<Entity> movingEntities = null;
// Check if any obstacle is inside the moving part — only then
// do we need cutout entities on the moving part.
var needCutouts = false;
for (var i = 0; i < obstacleBoxes.Length; i++)
for (var i = 0; i < Boxes.Length; i++)
{
if (movingBox.Contains(obstacleBoxes[i]))
if (movingBox.Contains(Boxes[i]))
{
needCutouts = true;
break;
@@ -100,9 +141,9 @@ namespace OpenNest.Engine.Fill
// Broad-phase bounds must enclose the spacing-offset contours.
// Raw bounds can miss near passes and overestimate the safe travel.
var movingSpacingBox = SpacingBounds(movingBox, halfSpacing);
for (var i = 0; i < obstacleBoxes.Length; i++)
for (var i = 0; i < Boxes.Length; i++)
{
var obstacleSpacingBox = obstacleSpacingBoxes[i];
var obstacleSpacingBox = spacingBoxes[i];
var reverseGap = SpatialQuery.DirectionalGap(
movingSpacingBox,
obstacleSpacingBox,
@@ -143,33 +184,25 @@ namespace OpenNest.Engine.Fill
// A moving part can be inside an obstacle's cutout. Omitting that
// loop would let it cross the inner wall before seeing the perimeter.
obstacleEntities[i] ??=
entities[i] ??=
halfSpacing > 0
? PartGeometry.GetOffsetPartEntities(obstacleParts[i], halfSpacing)
: PartGeometry.GetPartEntities(obstacleParts[i]);
? PartGeometry.GetOffsetPartEntities(parts[i], halfSpacing)
: PartGeometry.GetPartEntities(parts[i]);
// Contacts left by a previous push only block directions that would
// push material into material; the kernel classifies them.
var d = SpatialQuery.DirectionalDistance(
movingEntities,
obstacleEntities[i],
entities[i],
direction
);
if (d < distance)
distance = d;
}
}
if (distance < double.MaxValue && distance > 0)
{
var offset = direction * distance;
foreach (var moving in movingParts)
moving.Offset(offset);
return distance;
}
return 0;
}
private static Box SpacingBounds(Box box, double spacing)
@@ -228,19 +261,17 @@ namespace OpenNest.Engine.Fill
PushDirection direction
)
{
var obstacleParts = ZoneObstacles(plate);
obstacleParts.InsertRange(
0,
plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList()
);
var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList();
var zones = ZoneObstacles(plate);
return PushBoundingBox(
movingParts,
obstacleParts,
plate.WorkArea(),
plate.PartSpacing,
direction
direction,
(BoundingBoxes(obstacleParts), plate.PartSpacing),
(zones.Boxes, zones.Spacing)
);
}
@@ -250,12 +281,23 @@ namespace OpenNest.Engine.Fill
Box workArea,
double partSpacing,
PushDirection direction
) => PushBoundingBox(movingParts, workArea, direction, (BoundingBoxes(obstacleParts), partSpacing));
private static Box[] BoundingBoxes(List<Part> parts)
{
var boxes = new Box[parts.Count];
for (var i = 0; i < parts.Count; i++)
boxes[i] = parts[i].BoundingBox;
return boxes;
}
private static double PushBoundingBox(
List<Part> movingParts,
Box workArea,
PushDirection direction,
params (Box[] Boxes, double Spacing)[] obstacleSets
)
{
var obstacleBoxes = new Box[obstacleParts.Count];
for (var i = 0; i < obstacleParts.Count; i++)
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
var opposite = SpatialQuery.OppositeDirection(direction);
var isHorizontal = SpatialQuery.IsHorizontalDirection(direction);
var distance = double.MaxValue;
@@ -270,29 +312,32 @@ namespace OpenNest.Engine.Fill
var movingBox = moving.BoundingBox;
for (var i = 0; i < obstacleBoxes.Length; i++)
foreach (var (obstacleBoxes, spacing) in obstacleSets)
{
var reverseGap = SpatialQuery.DirectionalGap(
movingBox,
obstacleBoxes[i],
opposite
);
if (reverseGap > 0)
continue;
for (var i = 0; i < obstacleBoxes.Length; i++)
{
var reverseGap = SpatialQuery.DirectionalGap(
movingBox,
obstacleBoxes[i],
opposite
);
if (reverseGap > 0)
continue;
var perpOverlap = isHorizontal
? movingBox.IsHorizontalTo(obstacleBoxes[i], out _)
: movingBox.IsVerticalTo(obstacleBoxes[i], out _);
var perpOverlap = isHorizontal
? movingBox.IsHorizontalTo(obstacleBoxes[i], out _)
: movingBox.IsVerticalTo(obstacleBoxes[i], out _);
if (!perpOverlap)
continue;
if (!perpOverlap)
continue;
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
var d = gap - partSpacing - 0.002;
if (d < 0)
d = 0;
if (d < distance)
distance = d;
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
var d = gap - spacing - 0.002;
if (d < 0)
d = 0;
if (d < distance)
distance = d;
}
}
}
@@ -418,15 +463,17 @@ namespace OpenNest.Engine.Fill
if (parts.Count < 2)
return;
var obstacles = Jobs.InteractiveClamps.ObstacleParts(plate);
obstacles.AddRange(plate.Parts.Where(part => !parts.Contains(part)
&& !part.BaseDrawing.IsCutOff));
var obstacles = plate.Parts.Where(part => !parts.Contains(part)
&& !part.BaseDrawing.IsCutOff).ToList();
var zones = Jobs.InteractiveClamps.ObstacleParts(plate);
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);
foreach (var direction in new[] { PushDirection.Left, PushDirection.Down })
moved += Push(parts, workArea, SpatialQuery.DirectionToOffset(direction, 1.0),
new ObstacleSet(obstacles, plate.PartSpacing),
new ObstacleSet(zones, 0));
if (moved < threshold)
break;
+1 -1
View File
@@ -603,7 +603,7 @@ namespace OpenNest.Engine.Fill
var movers = new List<Part>(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.
// Clamp walls include the clamp clearance envelope.
var clampBoxes = Jobs.InteractiveClamps.ClearanceRectangles(plate);
walls.AddRange(Jobs.InteractiveClamps.WallParts(plate));
if (clampBoxes.Count != walls.Count + movers.Count - clampStart)
+11 -12
View File
@@ -34,20 +34,20 @@ internal static class InteractiveClamps
}
/// <summary>
/// Placement keep-outs inflated by the full bottom-edge clearance (D6) - the same
/// Placement keep-outs inflated by the full <see cref="Plate.ClampClearance"/> - the same
/// convention the Rectangles lane blocks and engines honour, so fill and pack produce
/// placements with at least that clearance from the raw zone.
/// </summary>
public static IReadOnlyList<Box> ClearanceRectangles(Plate plate) =>
InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom));
InflateZones(plate, plate.ClampClearance);
/// <summary>
/// The validator's exact C1 rectangle (NestLayoutCheck: bottom spacing less the shared
/// The validator's exact C1 rectangle (NestLayoutCheck: clamp clearance less the shared
/// spacing slack). Used by the service safety net so flush engine-clear placements are
/// never filtered by rounding.
/// </summary>
public static IReadOnlyList<Box> ValidationKeepOuts(Plate plate) =>
InflateZones(plate, System.Math.Max(0, plate.EdgeSpacing.Bottom - NestTolerances.SpacingSlack));
InflateZones(plate, System.Math.Max(0, plate.ClampClearance - NestTolerances.SpacingSlack));
private static IReadOnlyList<Box> InflateZones(Plate plate, double clearance)
{
@@ -61,19 +61,18 @@ internal static class InteractiveClamps
}
/// <summary>
/// Static obstacle parts for the directional push kernels. The kernel enforces
/// part-spacing between every obstacle pair, so the rectangles carry only the
/// clearance the kernel would otherwise miss: max(0, bottom spacing - part spacing).
/// Final material clearance from a raw zone is then at least max(bottom, partSpacing),
/// never below the C1 keep-out.
/// Static obstacle parts for the directional push kernels: each zone grown square by
/// <see cref="Plate.ClampClearance"/>, the layout check's keep-out, which the kernels then
/// hold at zero spacing (never the part spacing). A round spacing offset would let a part
/// tuck into the keep-out's corners.
/// </summary>
public static List<Part> ObstacleParts(Plate plate) =>
ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom - plate.PartSpacing));
ObstaclePartsFor(ZoneRectangles(plate), plate.ClampClearance);
/// <summary>Expander walls use the bottom-edge clearance as a fixed floor,
/// <summary>Expander walls use the clamp clearance as a fixed floor,
/// independent of the separation target or its final achieved spacing.</summary>
public static List<Part> WallParts(Plate plate) =>
ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom));
ObstaclePartsFor(ZoneRectangles(plate), plate.ClampClearance);
private static List<Part> ObstaclePartsFor(IReadOnlyList<Box> rectangles, double inflate)
{
+4 -3
View File
@@ -241,8 +241,8 @@ public static class NestLayoutCheck
}
/// <summary>
/// C1/C5: no part material may enter a clamp zone inflated by the plate's bottom-edge
/// spacing (D6) - the same clearance the part owes the sheet edge, enforced with the
/// C1/C5: no part material may enter a clamp zone inflated by the plate's clamp
/// clearance (the rail's own, else the bottom-edge spacing, D6), enforced with the
/// shared spacing slack so exact engine placements are not reddened by rounding.
/// </summary>
private static void ValidateClamps(
@@ -252,7 +252,7 @@ public static class NestLayoutCheck
List<string> result
)
{
var inflateBy = InflationFor(plate.EdgeSpacing.Bottom);
var inflateBy = InflationFor(plate.ClampClearance);
// Plate zones are stored in sheet coordinates; parts are in the placement frame.
foreach (var zone in ClampZoneFrame.ToPlacement(plate))
@@ -394,6 +394,7 @@ public static class NestLayoutCheck
&& expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right)
&& expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top)
&& expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom)
&& expected.ClampClearance.IsEqualTo(actual.ClampClearance)
&& expected.Quadrant == actual.Quadrant
&& Equals(expected.Outline, actual.Outline)
&& SameZones(expected.ClampZones, actual.ClampZones)
+7
View File
@@ -94,6 +94,13 @@ public sealed class NestPlateStock
/// </summary>
internal ClampRail ClampRail { get; init; }
/// <summary>
/// The gap part material keeps from every clamp zone, as <see cref="Plate.ClampClearance"/>:
/// the rail's own clearance, else the bottom edge spacing (D6). Never part spacing.
/// </summary>
[JsonIgnore]
public double ClampClearance => System.Math.Max(0, ClampRail?.Clearance ?? EdgeSpacing.Bottom);
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
public double? Cost { get; }
public string Id { get; }
@@ -39,6 +39,7 @@ internal static class RepeatedPlateGrouping
&& Bits(a.EdgeSpacing.Right) == Bits(b.EdgeSpacing.Right)
&& Bits(a.EdgeSpacing.Top) == Bits(b.EdgeSpacing.Top)
&& Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom)
&& Bits(a.ClampClearance) == Bits(b.ClampClearance)
&& a.Quantity == b.Quantity
&& Equals(a.Outline, b.Outline)
&& a.Cost.HasValue == b.Cost.HasValue
@@ -42,7 +42,7 @@ internal enum DiscSeed
/// seeds the sheet before the greedy pass fills the rest.
/// </summary>
/// <remarks>Every disc keeps the part spacing plus <see cref="Margin"/> from every other disc, and
/// its radius from the work-area edges and from every clamp zone grown by the bottom-edge
/// its radius from the work-area edges and from every clamp zone grown by the stock's clamp
/// clearance. Radii are <see cref="Item.Radius"/>, which covers the layout check's outward flattening,
/// so packing is never more lenient than the check. Deterministic.</remarks>
internal static class DiscPacker
@@ -145,9 +145,9 @@ internal static class DiscPacker
{
this.stock = stock;
work = stock.WorkArea;
// The layout check grows each zone by the bottom-edge clearance as a box, square
// The layout check grows each zone by the clamp clearance as a box, square
// corners included, so discs keep their radius from that grown box.
var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
var clearance = stock.ClampClearance;
clamps = stock.ClampZones.Select(z => z.Bounds.Offset(clearance)).ToArray();
Gap = stock.PartSpacing + Margin;
// Any disc within reach of a new one lies in the adjacent cells.
@@ -157,7 +157,7 @@ internal sealed class BarStock
/// Free runs of the bar's length. A clamp zone whose clearance-expanded cross extent
/// reaches the bar's row blocks its clearance-expanded axis interval for the whole row:
/// parts in a bar span its width, so none can pass beside a clamp. Clearance is the
/// stock's bottom edge spacing, as in the other engines; part spacing is not added.
/// stock's clamp clearance, as in the other engines; part spacing is not added.
/// </summary>
private static IReadOnlyList<BarSegment> FreeRuns(NestPlateStock stock, Box work, bool alongX)
{
@@ -165,7 +165,7 @@ internal sealed class BarStock
var end = alongX ? work.Right : work.Top;
var crossLow = alongX ? work.Bottom : work.Left;
var crossHigh = alongX ? work.Top : work.Right;
var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
var clearance = stock.ClampClearance;
var blocked = new List<(double Low, double High)>();
foreach (var zone in stock.ClampZones)
{
@@ -6,7 +6,7 @@ namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>
/// Static, clearance-inflated rectangles prepared once per stock per solve. Kept out of
/// the part catalog and its spacing cache: clamp clearance is bottom edge spacing, not
/// the part catalog and its spacing cache: clamp clearance is the stock's clamp clearance, not
/// part spacing, and two stocks at the same part spacing may have different clamps.
/// </summary>
internal sealed class ClampObstacles
@@ -17,7 +17,7 @@ internal sealed class ClampObstacles
public ClampObstacles(NestPlateStock stock)
{
rectangles = new Orientation[stock.ClampZones.Count];
var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
var clearance = stock.ClampClearance;
for (var i = 0; i < rectangles.Length; i++)
{
var box = stock.ClampZones[i].Bounds;
@@ -93,7 +93,7 @@ internal static class SheetPacker
/// 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
/// therefore means exactly: item material keeps at least the clamp 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
@@ -109,7 +109,7 @@ internal static class SheetPacker
if (stock.ClampZones.Count == 0)
return;
var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
var clearance = stock.ClampClearance;
foreach (var zone in stock.ClampZones)
{
var b = zone.Bounds; // placement frame -> bin-local packing coordinates
@@ -519,4 +519,17 @@ public class ClampRailPersistenceTests
return false;
}
}
[Fact]
public void RailClearanceRoundTripsAndIsOmittedWhenUnset()
{
var nest = RailNest(withEmptyTail: false);
nest.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters, clearance: 0.05));
var reopened = RoundTrip(nest);
Assert.Equal(0.05, reopened.PlateDefaults.ClampRail.Clearance);
Assert.Equal(0.05, Assert.Single(reopened.Plates).ClampClearance);
Assert.Null(RoundTrip(RailNest(withEmptyTail: false)).PlateDefaults.ClampRail.Clearance);
}
}
+10
View File
@@ -274,6 +274,7 @@ namespace OpenNest.IO
double? removalMinutes = TryNumber(rail, "removalMinutes", out var minutes)
? minutes
: null;
double? clearance = TryNumber(rail, "clearance", out var gap) ? gap : null;
return new ClampRailDto
{
Width = width,
@@ -283,6 +284,7 @@ namespace OpenNest.IO
OriginEndCenters = others[1],
FarEndCenters = others[2],
RemovalMinutes = removalMinutes,
Clearance = clearance,
};
}
@@ -329,6 +331,8 @@ namespace OpenNest.IO
WriteCenters(writer, OtherEdgeKeys[2], value.FarEndCenters);
if (value.RemovalMinutes is double removalMinutes)
writer.WriteNumber("removalMinutes", removalMinutes);
if (value.Clearance is double clearance)
writer.WriteNumber("clearance", clearance);
writer.WriteEndObject();
}
@@ -416,6 +420,12 @@ namespace OpenNest.IO
/// files and readers fall back to <see cref="OpenNest.ClampRail.DefaultRemovalMinutes"/>.
/// </summary>
public double? RemovalMinutes { get; init; }
/// <summary>
/// The gap parts keep from every clamp (<see cref="OpenNest.ClampRail.Clearance"/>).
/// Omitted when the rail uses the sheet's bottom edge spacing, as older files do.
/// </summary>
public double? Clearance { get; init; }
}
public record DrawingDto
+4 -2
View File
@@ -527,7 +527,8 @@ namespace OpenNest.IO
/// <summary>
/// The saved machine clamp rail, or null when absent, empty or malformed (a bad rail
/// is ignored rather than failing the nest; each plate's saved zones still load). A
/// missing or invalid removal-minutes value falls back to <see cref="ClampRail.DefaultRemovalMinutes"/>.
/// missing or invalid removal-minutes value falls back to <see cref="ClampRail.DefaultRemovalMinutes"/>,
/// and a missing or invalid clearance to the sheet's bottom edge spacing.
/// </summary>
/// <summary>The saved support bed, or null when absent or invalid (a bad bed never fails the nest).</summary>
/// <summary>
@@ -602,7 +603,8 @@ namespace OpenNest.IO
var minutes = dto.RemovalMinutes is double m && double.IsFinite(m) && m >= 0
? m
: ClampRail.DefaultRemovalMinutes;
return new ClampRail(dto.Width, dto.Depth, clamps, minutes);
double? clearance = dto.Clearance is double c && double.IsFinite(c) && c >= 0 ? c : null;
return new ClampRail(dto.Width, dto.Depth, clamps, minutes, clearance);
}
catch (ArgumentException)
{
+1
View File
@@ -323,6 +323,7 @@ namespace OpenNest.IO
OriginEndCenters = CentersOn(rail, ClampEdge.OriginEnd),
FarEndCenters = CentersOn(rail, ClampEdge.FarEnd),
RemovalMinutes = rail.RemovalMinutes,
Clearance = rail.Clearance,
};
private static List<double> CentersOn(ClampRail rail, ClampEdge edge) =>
+35
View File
@@ -495,4 +495,39 @@ public class ClampRailTests
Assert.Null(plate.RailClampAt(new Vector(36, 0.7)));
Assert.Equal("clamp-3", plate.RailClampAt(new Vector(36, 0.7), tolerance: 0.1)?.Id);
}
[Theory]
[InlineData(-0.1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void ClearanceMustBeFiniteAndZeroOrMore(double clearance)
{
Assert.Throws<ArgumentOutOfRangeException>(() => new ClampRail(1.5, 0.625, new[] { 6.0 }, clearance: clearance));
}
[Fact]
public void ClearanceIsPartOfTheRailAndSurvivesNewClamps()
{
var rail = new ClampRail(1.5, 0.625, new[] { 6.0 }, clearance: 0.05);
Assert.Equal(0.05, rail.Clearance);
Assert.Equal(0.05, rail.WithClamps(new[] { new RailClamp(ClampEdge.FarSide, 9) }).Clearance);
Assert.NotEqual(rail, new ClampRail(1.5, 0.625, new[] { 6.0 }));
Assert.Equal(rail, new ClampRail(1.5, 0.625, new[] { 6.0 }, clearance: 0.05));
}
[Fact]
public void PlateClampClearanceIsTheRailsOwnElseTheBottomEdgeSpacing()
{
var plate = Sheet(60, 120);
plate.EdgeSpacing = new Spacing(0.25, 0.5, 0.25, 0.25);
plate.PartSpacing = 1;
Assert.Equal(0.5, plate.ClampClearance);
plate.SetClampRail(Machine());
Assert.Equal(0.5, plate.ClampClearance);
plate.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters, clearance: 0));
Assert.Equal(0, plate.ClampClearance);
}
}
+26
View File
@@ -405,4 +405,30 @@ public class ClampSettingsTests : IDisposable
return false;
}
}
[Fact]
public void ClearanceRoundTripsConvertsAndReachesTheRail()
{
var settings = Machine();
settings.Clearance = 0.05;
var read = ClampSettings.Parse(settings.ToJson(), out var status);
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.Equal(0.05, read.Clearance);
Assert.Equal(0.05 * 25.4, read.ConvertedTo(Units.Millimeters).Clearance!.Value, 9);
Assert.Equal(0.05, read.RailFor(Units.Inches)!.Clearance);
Assert.Equal(0.05, ClampSettings.FromRail(read.RailFor(Units.Inches), Units.Inches, true).Clearance);
}
[Fact]
public void MissingClearanceKeepsTheBottomEdgeSpacingAndABadOneIsIgnored()
{
Assert.DoesNotContain("clearance", Machine().ToJson());
Assert.Null(ClampSettings.Parse(Machine().ToJson(), out _).RailFor(Units.Inches)!.Clearance);
var read = ClampSettings.Parse("{\"enabled\": true, \"centers\": [6], \"clearance\": -1}", out var status);
Assert.Equal(ClampSettingsStatus.Partial, status);
Assert.Null(read.Clearance);
}
}
+15
View File
@@ -227,4 +227,19 @@ public class MachineClampsTests : IDisposable
Assert.Equal(new[] { 12.0, 24.0 }, rail.Clamps.Select(c => System.Math.Round(c.Center, 9)));
Assert.Equal(new[] { 304.8, 609.6 }, machine.Clamps!.Centers.Select(c => System.Math.Round(c, 9)));
}
[Fact]
public void ClearanceConvertsWithTheMachineAndAnInvalidOneKeepsTheEdgeSpacing()
{
var legacy = Legacy();
legacy.Clearance = 0.1;
Assert.Equal(0.1 * 25.4, MachineClamps.FromSettings(legacy, UnitSystem.Millimeters).Clearance!.Value, 9);
var clamps = Rail();
clamps.Clearance = 0.05;
Assert.Equal(0.05, clamps.ToSettings(UnitSystem.Inches).Clearance);
clamps.Clearance = double.NaN;
Assert.Null(clamps.ToSettings(UnitSystem.Inches).Clearance);
}
}
+2 -1
View File
@@ -455,7 +455,8 @@ namespace OpenNest.Actions
{
rail = list.Count == 0
? null
: new ClampRail(ClampWidth, ClampDepth, list, Rail?.RemovalMinutes ?? Defaults.RemovalMinutes);
: new ClampRail(ClampWidth, ClampDepth, list, Rail?.RemovalMinutes ?? Defaults.RemovalMinutes,
Rail != null ? Rail.Clearance : Defaults.Clearance);
}
catch (ArgumentException ex)
{
+24 -5
View File
@@ -21,6 +21,8 @@ public sealed class ClampsForm : Form
private readonly NumericUpDown widthBox;
private readonly NumericUpDown depthBox;
private readonly NumericUpDown removalMinutesBox;
private readonly CheckBox ownClearanceBox;
private readonly NumericUpDown clearanceBox;
private readonly NumericUpDown centerBox;
private readonly ComboBox edgeBox;
private readonly ListBox centersList;
@@ -46,8 +48,8 @@ public sealed class ClampsForm : Form
Text = "Clamps";
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(480, 470);
MinimumSize = new Size(440, 460);
ClientSize = new Size(480, 500);
MinimumSize = new Size(440, 490);
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
@@ -73,9 +75,10 @@ public sealed class ClampsForm : Form
Padding = new Padding(0, 0, 0, 8),
Text =
"Fixed clamps on the plate edges, each centered a distance from the origin "
+ "along its edge. Auto Nest keeps parts clear of each clamp by the plate's "
+ "bottom edge spacing; clamps that do not reach a smaller plate are left off "
+ "it. Plate > Edit Clamps moves and adds clamps on the plate view.",
+ "along its edge. Parts keep the clamp clearance from each clamp (the plate's "
+ "bottom edge spacing unless set below; part spacing never applies); clamps "
+ "that do not reach a smaller plate are left off it. Plate > Edit Clamps moves "
+ "and adds clamps on the plate view.",
}
);
@@ -86,6 +89,19 @@ public sealed class ClampsForm : Form
AddRow(size, "Clamp width (along the edge):", widthBox, unit);
AddRow(size, "Clamp depth (into the plate):", depthBox, unit);
AddRow(size, "Minutes to remove and replace one clamp:", removalMinutesBox, "min");
ownClearanceBox = new CheckBox
{
Text = "Clearance from parts:",
AutoSize = true,
Checked = Editor.Clearance.HasValue,
Padding = new Padding(0, 3, 6, 0),
};
clearanceBox = NewLengthBox(Editor.Clearance ?? 0, minimum: 0m);
clearanceBox.Enabled = ownClearanceBox.Checked;
ownClearanceBox.CheckedChanged += (_, _) => clearanceBox.Enabled = ownClearanceBox.Checked;
size.Controls.Add(ownClearanceBox);
size.Controls.Add(clearanceBox);
size.Controls.Add(new Label { Text = unit, AutoSize = true, Padding = new Padding(0, 6, 0, 0) });
layout.Controls.Add(size);
var centersGroup = new GroupBox
@@ -226,6 +242,7 @@ public sealed class ClampsForm : Form
Editor.Width = (double)widthBox.Value;
Editor.Depth = (double)depthBox.Value;
Editor.RemovalMinutes = (double)removalMinutesBox.Value;
Editor.Clearance = ownClearanceBox.Checked ? (double)clearanceBox.Value : null;
Editor.ApplyToNewNests = applyToNewNestsBox.Checked;
}
@@ -268,6 +285,8 @@ public sealed class ClampsForm : Form
widthBox.Value = ToBoxValue(widthBox, Editor.Width);
depthBox.Value = ToBoxValue(depthBox, Editor.Depth);
removalMinutesBox.Value = ToBoxValue(removalMinutesBox, Editor.RemovalMinutes);
ownClearanceBox.Checked = Editor.Clearance.HasValue;
clearanceBox.Value = ToBoxValue(clearanceBox, Editor.Clearance ?? 0);
applyToNewNestsBox.Checked = Editor.ApplyToNewNests;
RefreshCenters();
}
+33
View File
@@ -297,6 +297,7 @@ namespace OpenNest.Forms
Width = shown.Width,
Depth = shown.Depth,
RemovalMinutes = shown.RemovalMinutes,
Clearance = shown.Clearance,
};
void Set(Action<MachineClamps> apply)
@@ -328,6 +329,7 @@ namespace OpenNest.Forms
CreateLabeledNumeric(shown.Depth, v => Set(c => c.Depth = v), 4, LengthUnitSuffix, 1000, 0.125m));
AddField(layout, ref row, "Removal time:",
CreateLabeledNumeric(shown.RemovalMinutes, v => Set(c => c.RemovalMinutes = v), 2, _ => "min per clamp", 1000, 0.5m));
AddField(layout, ref row, "Clearance:", CreateClampClearance(shown.Clearance, Set));
var centersGroup = new GroupBox
{
@@ -1017,6 +1019,37 @@ namespace OpenNest.Forms
return numeric;
}
// Unchecked keeps each sheet's bottom edge spacing (a null clearance), as before clamps
// had their own.
private Control CreateClampClearance(double? clearance, Action<Action<MachineClamps>> set)
{
var row = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Anchor = AnchorStyles.Left };
var ownBox = new CheckBox
{
Text = "Own clearance from parts",
AutoSize = true,
Checked = clearance.HasValue,
Margin = new Padding(0, 5, 6, 0),
};
var numeric = CreateNumericBox(clearance ?? 0, v => set(c => c.Clearance = ownBox.Checked ? v : null),
4, 1000, 0.0625m);
numeric.Enabled = ownBox.Checked;
ownBox.CheckedChanged += (s, e) =>
{
numeric.Enabled = ownBox.Checked;
set(c => c.Clearance = ownBox.Checked ? (double)numeric.Value : null);
};
row.Controls.Add(ownBox);
row.Controls.Add(numeric);
row.Controls.Add(new Label
{
AutoSize = true,
Margin = new Padding(6, 6, 0, 0),
Text = LengthUnitSuffix(null),
});
return row;
}
private Control CreateLabeledNumeric(
double value,
Action<double> setter,
+14 -3
View File
@@ -64,7 +64,16 @@ A nest can carry the machine's fixed clamp rail: identical rectangular clamps
center measured from the plate origin along that edge. The edges are named from
the origin: the origin side and far side run along X, the origin end and far
end along Y. **Nest > Clamps...** edits the current nest's rail as a list, and
**Plate > Edit Clamps** edits it on the plate view (below). **OK** applies it to every plate of the nest (**Cancel** discards
**Plate > Edit Clamps** edits it on the plate view (below).
Parts keep the rail's **clamp clearance** from every clamp, never the part spacing.
Leave **Clearance from parts** unchecked to use each sheet's bottom edge spacing
(the behavior before clamps had their own clearance); check it to set a gap of its
own, such as close to zero for a support block drawn under the sheet, where the only
need is that the laser does not cut or pierce over it. The same clearance governs
Auto Nest's engines, interactive fill, pack and push, the layout check and automatic
cut-off placement; Check Overlaps and pre-post verification still flag only parts
or cuts actually on a clamp. **OK** applies it to every plate of the nest (**Cancel** discards
the edits); removing every center removes the clamps. **Load Machine Defaults**
only fills the dialog with the clamps of the nest's machine (**Nest > Machine**).
**Save as Machine Defaults** writes the clamps, and whether new nests receive them,
@@ -75,14 +84,16 @@ refuses to open while nesting or a plate action is running.
Clamps are physical fixtures of a machine, so each machine keeps its own rail on the
**Clamps** tab of **Tools > Machine Configuration**: whether new nests get it, the
clamp width and depth, the minutes to remove one clamp, and the clamp centers, all in
clamp width and depth, the minutes to remove one clamp, the clamp clearance and the
clamp centers, all in
the machine's units. New and BOM import give a new nest the rail of the machine they
assign it (the **Tools > Nest Defaults** machine, or the first by name), converted to
the nest's units. A nest keeps its own copy: editing a machine's clamps, or assigning a
nest another machine, never moves the clamps of an existing nest; use **Load Machine
Defaults** for that. Clamps are off by default: a machine without clamps, or with them
switched off or without a center, gives new nests none. A stored size or removal time
that is not valid falls back to the default and an invalid center is dropped.
that is not valid falls back to the default, an invalid clearance to the bottom edge
spacing, and an invalid center is dropped.
Before machines held clamps, one `%APPDATA%\OpenNest\clamps.json` (or the server's
shared clamp settings) served every machine. At startup, while no machine has clamps of
+2 -2
View File
@@ -22,7 +22,7 @@ Only real parts contribute to the occupied envelope; existing cutoff parts do no
Cutoff trimming measures clearance from the part contour, excluding tagged lead-in and lead-out moves when building the closed outline. Leads must not turn a recessed outline into a convex hull and leave oversized gaps elsewhere. Genuinely open contours (a tab gap, for example) still use the conservative hull/bounds fallback, but the hull is built from the material contour only: a cut-off may cross a lead, and hulling a lead's pierce point stopped cut-offs far short of the part. With a nonzero clearance, a line passing exactly through a corner of the clearance outline is trimmed to the outline like any other line. Automatic strip lines sit on a regular lattice and often land on such corners; they once fell back to the part's bounding box there, and the strip search preferred those lines because the box fallback measured as less cutting. The conservative curve/offset approximation can leave a small extra gap (about 0.0003 in on a straight edge at the default 0.02-in clearance), not an extra quarter inch.
If no safe separator fits inside the sheet, there is no retained-tail claim and the nominal cuts may cover the full sheet length. A proposed tail is not reported as separated unless its generated program spans the full sheet width. An active clamp blocks any proposed full-width line whose X coordinate falls in the clamp's whole footprint plus bottom-edge clearance, including a nominal grid line or an equivalent existing line. A tolerance-close existing line is tested at its own X as well as the proposed X, because that existing line is what would be certified. A same-line manual cut with different limits still blocks the whole plan, even on a clamp. Blocked lines are skipped with a clamp diagnostic; a blocked tail also reports `NoSafeTailSeparator` rather than claiming separation. Removed clamps on a sheet are absent from its active zones and do not block cuts. Stored zones are relative to the sheet's lower-left corner; the planner maps them into the plate's quadrant frame (the same frame as part and cut-off coordinates) before comparing, so the rule holds in all four quadrants. Obstructed or minimum-length-filtered lines produce diagnostics; they are not proof of a completed partition.
If no safe separator fits inside the sheet, there is no retained-tail claim and the nominal cuts may cover the full sheet length. A proposed tail is not reported as separated unless its generated program spans the full sheet width. An active clamp blocks any proposed full-width line whose X coordinate falls in the clamp's whole footprint plus its clamp clearance, including a nominal grid line or an equivalent existing line. A tolerance-close existing line is tested at its own X as well as the proposed X, because that existing line is what would be certified. A same-line manual cut with different limits still blocks the whole plan, even on a clamp. Blocked lines are skipped with a clamp diagnostic; a blocked tail also reports `NoSafeTailSeparator` rather than claiming separation. Removed clamps on a sheet are absent from its active zones and do not block cuts. Stored zones are relative to the sheet's lower-left corner; the planner maps them into the plate's quadrant frame (the same frame as part and cut-off coordinates) before comparing, so the rule holds in all four quadrants. Obstructed or minimum-length-filtered lines produce diagnostics; they are not proof of a completed partition.
For a 120-by-81-inch sheet whose parts extend through 80 inches, the fixed grid would put skeleton lines at 35 and 70 inches, followed by a separator beyond 80 inches at the required margin. With a 17.5-inch minimum strip, the planner can move those two lines toward the origin to shorten their trimmed cut segments. The separator does not move, and there is no line at 105 inches through the retained tail.
@@ -40,7 +40,7 @@ For a 96-by-48-inch sheet with parts through Y 23 and a horizontal cut-off at Y
The preview searches line positions together to minimize the total emitted strip-line cutting length after part clearance and minimum-segment filtering. Equal lengths favor fewer pierces, then less total movement from the fixed grid. The number of new lines in each span stays at the fixed-grid count; it never adds cuts to chase a shorter path. For example, a part edge at 34.75 inches can make a 35-inch line run beside much of the part, while a small shift toward the origin lets that line cross the part and skip its material. The preview reports the measured strip-cut length and the corresponding fixed-grid length, including when Minimum strip equals spacing; a `ShiftedCut` notice identifies moved lines. Because the lines are optimized together, an individual line can cut slightly more while the total falls; its notice reports that local increase rather than a negative saving.
Existing full-width vertical cut-offs inside the used span become fixed strip boundaries; the planner fills only gaps longer than the spacing, never moving or duplicating the manual cut. A partial manual cut is not a boundary and its position is avoided. Active clamps exclude candidate positions including bottom-edge clearance. If a gap cannot place all its lines outside clamps, it falls back to nominal lines and reports any blocked cuts under the existing guard. An unchanged rerun adds nothing. The desktop defaults to shifting; API callers that omit `MinimumStripLength` retain the fixed-grid behavior.
Existing full-width vertical cut-offs inside the used span become fixed strip boundaries; the planner fills only gaps longer than the spacing, never moving or duplicating the manual cut. A partial manual cut is not a boundary and its position is avoided. Active clamps exclude candidate positions including their clamp clearance. If a gap cannot place all its lines outside clamps, it falls back to nominal lines and reports any blocked cuts under the existing guard. An unchanged rerun adds nothing. The desktop defaults to shifting; API callers that omit `MinimumStripLength` retain the fixed-grid behavior.
## Limits and operator review
+6
View File
@@ -150,6 +150,12 @@ the saved `clamps` load either way. A plate that follows a rail other than the n
saves it in its own optional `clampRail` (same shape), so its removal state and
time survive a reload; plates following the default rail omit it.
The rail's optional `clearance` is the gap parts keep from every clamp footprint,
separate from part spacing, so a clamp drawn as a support block under the sheet can
use close to zero. It is omitted when the rail uses the sheet's bottom edge
spacing, which is also what a missing, negative or non-finite value (and an older
reader) uses.
An empty plate (no parts, no cut-offs) is omitted from the file unless that
would lose clamp zones: it is omitted like any other empty plate only when a
new plate from the saved defaults would recreate it (same size, quadrant, edge
+4 -3
View File
@@ -185,8 +185,9 @@ obstacle, so a plug-in that ignores the outline is caught as any other invalid l
## Rectangular clamp zones
Whole-job Rectangles and Irregular exclude each stock's rectangular clamp zones,
expanded on all sides by its bottom edge spacing. Part spacing is not added to clamp
clearance. Default runs and validates both lanes as usual. Empty clamp collections
expanded on all sides by its clamp clearance (`NestPlateStock.ClampClearance`: the rail's
own `Clearance`, else the bottom edge spacing). Part spacing is not added to clamp
clearance; interactive fills, pushes and the layout check use the same clearance. Default runs and validates both lanes as usual. Empty clamp collections
leave the existing packing path unchanged; clamps never become requested or placed parts.
Every `Plate`, including plates the engine builds from stock, stores clamp zones relative
@@ -258,7 +259,7 @@ reporting progress, and Default discards it at almost no cost. Holes are not use
rings come from the cutout pre-pass, which hands the engine each nested chain as its outer frame.
Each disc goes, lower priority number first and then largest first, to the best feasible center
tangent to two of: the work-area walls, placed discs, and clamp zones grown by the bottom-edge
tangent to two of: the work-area walls, placed discs, and clamp zones grown by the clamp
clearance (square-cornered, as the layout check grows them). Discs keep the part spacing plus
twice `NestTolerances.ValidationOutline` between them. Bottom-left fills rows and left-bottom
fills columns; each also runs seeded with a hexagonal lattice of the type with the most remaining