feat(core): remove individual rail clamps on one plate

Slice 3b task 1 (D8/D9): ClampRail gains RemovalMinutes (D9, default
5 minutes, validated finite >= 0) and Plate gains RemovedClamps (rail
clamp ids taken off this sheet as a last resort) plus the computed
ClampRemovalMinutes. Plate.SetClampRail gains an overload that
attaches a rail with a removed-clamp set; zones become the rail's
resolution for the sheet minus those ids, re-resolved on every
resize/rotate so a removed clamp stays off and reappears removed if a
later resize brings it back onto the sheet. ClampRail.Matches gains an
overload comparing against the reduced resolution.

IO: PlateDto.RemovedClamps and ClampRailDto.RemovalMinutes persist
(missing removalMinutes falls back to the default; missing
removedClamps means none). The writer's empty-plate drop logic no
longer drops a sheet whose only content is a removal state the
defaults can't recreate. The reader re-attaches the nest rail to a
plate only when its saved zones equal the rail's resolution minus its
saved removed ids, so a reduced-clamp sheet keeps following the rail
(and the removal) through later resizes instead of freezing as static
zones.

Engine/desktop wiring (minimal-removal computation, Auto Nest
last-resort removal, cut-time, UI) is Slice 3b tasks 2-3, not yet
done.

Tests: 16 new Core (ClampRailTests) + 5 new IO (ClampRailPersistenceTests)
covering validation, resize/resolve behavior, round-trip, legacy
missing-key defaults, and the empty-plate-with-removal-only retention
case. Mutation check (reverting the RemovedClamps subtraction) turns
3 Core + 2 IO assertions red, confirming real coverage.

Gates: Release OpenNest.Tests 3884 passed/31 skipped, OpenNest.IO.Tests
105 passed, OpenNest.Engine.Tests (Clamp filter) 98 passed; scoped
dotnet format --verify-no-changes clean.
This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent bafb311735
commit ec058d270c
7 files changed
+367 -15

No files matched your search

+42 -2
View File
@@ -15,7 +15,18 @@ namespace OpenNest
/// </summary>
public sealed class ClampRail : IEquatable<ClampRail>
{
public ClampRail(double width, double depth, IEnumerable<double> centers)
/// <summary>
/// Planner default minutes to remove one clamp and put it back after cutting (D9),
/// used when a caller does not supply its own value.
/// </summary>
public const double DefaultRemovalMinutes = 5.0;
public ClampRail(
double width,
double depth,
IEnumerable<double> centers,
double removalMinutes = DefaultRemovalMinutes
)
{
ArgumentNullException.ThrowIfNull(centers);
if (!double.IsFinite(width) || width <= 0)
@@ -28,6 +39,11 @@ namespace OpenNest
nameof(depth),
"Clamp depth must be finite and positive."
);
if (!double.IsFinite(removalMinutes) || removalMinutes < 0)
throw new ArgumentOutOfRangeException(
nameof(removalMinutes),
"Clamp removal time must be finite and zero or more."
);
var sorted = new SortedSet<double>();
foreach (var center in centers)
@@ -42,6 +58,7 @@ namespace OpenNest
Width = width;
Depth = depth;
RemovalMinutes = removalMinutes;
Centers = sorted.ToArray();
}
@@ -51,6 +68,12 @@ namespace OpenNest
/// <summary>Clamp intrusion into the sheet from the origin-side edge (Y).</summary>
public double Depth { get; }
/// <summary>
/// Minutes to remove one clamp and put it back after cutting (D9). Added to a sheet's
/// cut-time estimate once per clamp <see cref="Plate.RemovedClamps"/> lists there.
/// </summary>
public double RemovalMinutes { get; }
/// <summary>Distinct clamp centers along X from the origin, ascending.</summary>
public IReadOnlyList<double> Centers { get; }
@@ -101,6 +124,22 @@ namespace OpenNest
return SameZones(plate.ClampZones, ResolveFor(plate.Size, plate.Quadrant));
}
/// <summary>
/// True when the plate's zones are exactly this rail's resolution for its sheet minus
/// the clamps named in <paramref name="removedClamps"/> (D8 last-resort removal, C13).
/// A null or empty set is the same check as <see cref="Matches(Plate)"/>.
/// </summary>
public bool Matches(Plate plate, IReadOnlyCollection<string> removedClamps)
{
ArgumentNullException.ThrowIfNull(plate);
if (removedClamps == null || removedClamps.Count == 0)
return Matches(plate);
var expected = ResolveFor(plate.Size, plate.Quadrant)
.Where(z => !removedClamps.Contains(z.Id))
.ToList();
return SameZones(plate.ClampZones, expected);
}
internal static bool SameZones(IList<ClampZone> current, IReadOnlyList<ClampZone> expected)
{
if (current.Count != expected.Count)
@@ -125,13 +164,14 @@ namespace OpenNest
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, Centers.Count);
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Centers.Count);
foreach (var center in Centers)
hash = HashCode.Combine(hash, center);
return hash;
+48 -5
View File
@@ -141,23 +141,66 @@ namespace OpenNest
/// </summary>
public ClampRail ClampRail { get; private set; }
private static readonly IReadOnlySet<string> NoRemovedClamps = new HashSet<string>();
/// <summary>
/// Rail clamp ids taken off this sheet as a last resort (D8) so a part that could not
/// be nested anywhere with every clamp in place still fits. Empty unless
/// <see cref="ClampRail"/> is set; <see cref="ClampZones"/> already excludes them, so
/// nothing else needs to consult this list to avoid them. Ids stay listed across a
/// resize/rotate even when the matching clamp is temporarily beyond the sheet, so it
/// re-appears removed if a later resize brings it back onto the sheet (C13).
/// </summary>
public IReadOnlySet<string> RemovedClamps { get; private set; } = NoRemovedClamps;
/// <summary>
/// Minutes this sheet's cut-time estimate gains from <see cref="RemovedClamps"/> (D9):
/// each listed clamp adds <see cref="OpenNest.ClampRail.RemovalMinutes"/> once. Zero
/// when there is no rail (removal is meaningless without one to remove clamps from).
/// </summary>
public double ClampRemovalMinutes =>
ClampRail == null ? 0 : RemovedClamps.Count * ClampRail.RemovalMinutes;
/// <summary>
/// Follows <paramref name="rail"/> and replaces the zones with its resolution for
/// this sheet; null detaches and removes every zone. Unchanged zones raise no events.
/// </summary>
public void SetClampRail(ClampRail rail)
public void SetClampRail(ClampRail rail) => SetClampRail(rail, null);
/// <summary>
/// Follows <paramref name="rail"/> with the clamps named in <paramref name="removedClamps"/>
/// taken off this sheet (D8): zones become the rail's resolution for this sheet minus
/// those ids. Null <paramref name="rail"/> detaches, clears every zone and forgets the
/// removed set (removal only means something while following a rail).
/// </summary>
public void SetClampRail(ClampRail rail, IEnumerable<string> removedClamps)
{
ClampRail = rail;
ReplaceClampZones(
rail == null ? Array.Empty<ClampZone>() : rail.ResolveFor(Size, Quadrant)
);
RemovedClamps = rail == null || removedClamps == null
? NoRemovedClamps
: new HashSet<string>(removedClamps, StringComparer.Ordinal);
ReplaceClampZones(ResolveRailZones());
}
private void ResolveClampRail()
{
// Called from the constructor's Size assignment before the zone list exists.
if (ClampRail != null)
ReplaceClampZones(ClampRail.ResolveFor(Size, Quadrant));
ReplaceClampZones(ResolveRailZones());
}
private IReadOnlyList<ClampZone> ResolveRailZones()
{
if (ClampRail == null)
return Array.Empty<ClampZone>();
var resolved = ClampRail.ResolveFor(Size, Quadrant);
if (RemovedClamps.Count == 0)
return resolved;
var kept = new List<ClampZone>(resolved.Count);
foreach (var zone in resolved)
if (!RemovedClamps.Contains(zone.Id))
kept.Add(zone);
return kept;
}
private void ReplaceClampZones(IReadOnlyList<ClampZone> zones)
@@ -1,5 +1,7 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using OpenNest.Geometry;
namespace OpenNest.IO.Tests;
@@ -160,6 +162,123 @@ public class ClampRailPersistenceTests
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()
{
var withoutKey = RewriteNestJson(Write(RailNest()),
text => RemoveJsonProperty(text, "removedClamps"));
var reopened = new NestReader(withoutKey).Read();
var plate = Assert.Single(reopened.Plates);
Assert.Empty(plate.RemovedClamps);
Assert.Equal(7, plate.ClampZones.Count);
}
/// <summary>Removes a named property wherever it appears in the document, simulating a
/// key an older writer never emitted. Rebuilds via <see cref="JsonNode"/> so the result
/// is always valid JSON regardless of whether the property was first, last or only one
/// in its object - unlike string surgery, which breaks on a dangling comma.</summary>
private static string RemoveJsonProperty(string json, string name)
{
var root = JsonNode.Parse(json)!;
var removed = RemovePropertyRecursive(root, name);
Assert.True(removed, $"Property '{name}' was not found to remove.");
return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
}
private static bool RemovePropertyRecursive(JsonNode node, string name)
{
switch (node)
{
case JsonObject obj:
var removed = obj.Remove(name);
foreach (var child in obj.ToList())
if (child.Value != null && RemovePropertyRecursive(child.Value, name))
removed = true;
return removed;
case JsonArray arr:
var any = false;
foreach (var item in arr)
if (item != null && RemovePropertyRecursive(item, name))
any = true;
return any;
default:
return false;
}
}
private static string ReadNestJson(MemoryStream zipBytes)
{
zipBytes.Position = 0;
+19 -1
View File
@@ -85,7 +85,11 @@ namespace OpenNest.IO
return null;
list.Add(x);
}
return new ClampRailDto { Width = width, Depth = depth, Centers = list };
// 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(
@@ -101,6 +105,8 @@ namespace OpenNest.IO
foreach (var center in value.Centers ?? new List<double>())
writer.WriteNumberValue(center);
writer.WriteEndArray();
if (value.RemovalMinutes is double removalMinutes)
writer.WriteNumber("removalMinutes", removalMinutes);
writer.WriteEndObject();
}
@@ -123,6 +129,12 @@ namespace OpenNest.IO
/// <summary>Clamp centers along X, measured from the plate origin.</summary>
public List<double> Centers { get; init; } = new();
/// <summary>
/// Minutes to remove one clamp and put it back after cutting (D9). Null in older
/// files and readers fall back to <see cref="OpenNest.ClampRail.DefaultRemovalMinutes"/>.
/// </summary>
public double? RemovalMinutes { get; init; }
}
public record DrawingDto
@@ -157,6 +169,12 @@ namespace OpenNest.IO
/// files, which load with no zones.
/// </summary>
public List<ClampZoneDto> Clamps { get; init; } = new();
/// <summary>
/// Rail clamp ids removed from this sheet as a last resort (D8). Missing or empty
/// means none were removed; <see cref="Clamps"/> already excludes them.
/// </summary>
public List<string> RemovedClamps { get; init; } = new();
}
public record ClampZoneDto
+16 -5
View File
@@ -329,7 +329,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).
/// is ignored rather than failing the nest; each plate's saved zones still load). A
/// missing removal-minutes value falls back to <see cref="ClampRail.DefaultRemovalMinutes"/>.
/// </summary>
private static ClampRail ReadClampRail(ClampRailDto dto)
{
@@ -337,7 +338,12 @@ namespace OpenNest.IO
return null;
try
{
return new ClampRail(dto.Width, dto.Depth, dto.Centers);
return new ClampRail(
dto.Width,
dto.Depth,
dto.Centers,
dto.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes
);
}
catch (ArgumentException)
{
@@ -439,9 +445,14 @@ namespace OpenNest.IO
}
// A plate follows the nest rail only when its saved zones are exactly the
// rail's for this sheet; anything else is preserved as saved.
if (clampRail != null && clampRail.Matches(plate))
plate.SetClampRail(clampRail);
// rail's for this sheet minus any saved removed-clamp ids (C13); otherwise
// the saved zones are kept as static, explicit data.
var removedClamps = p.RemovedClamps?
.Where(id => !string.IsNullOrEmpty(id))
.ToHashSet(StringComparer.Ordinal)
?? new HashSet<string>();
if (clampRail != null && clampRail.Matches(plate, removedClamps))
plate.SetClampRail(clampRail, removedClamps);
for (var partIndex = 0; partIndex < p.Parts.Count; partIndex++)
{
+21 -2
View File
@@ -142,6 +142,7 @@ namespace OpenNest.IO
Width = pd.ClampRail.Width,
Depth = pd.ClampRail.Depth,
Centers = pd.ClampRail.Centers.ToList(),
RemovalMinutes = pd.ClampRail.RemovalMinutes,
},
};
}
@@ -207,18 +208,29 @@ namespace OpenNest.IO
/// <summary>
/// True when a plate created from the saved defaults would be this sheet's clamp
/// configuration: same size and quadrant, and zones the saved rail recreates.
/// configuration: same size and quadrant, no last-resort removal, and zones the
/// saved rail recreates.
/// </summary>
private bool DefaultsRecreateClamps(Plate plate)
{
var defaults = nest.PlateDefaults;
return defaults.ClampRail is { } rail
&& plate.RemovedClamps.Count == 0
&& plate.Size.Width == defaults.Size.Width
&& plate.Size.Length == defaults.Size.Length
&& plate.Quadrant == defaults.Quadrant
&& rail.Matches(plate);
}
/// <summary>Numeric suffix of a rail clamp id ("clamp-3" -&gt; 3), for a natural sort of
/// <see cref="PlateDto.RemovedClamps"/> instead of lexical ("clamp-10" before "clamp-2").
/// A non-matching id sorts last by its own ordinal text.</summary>
private static int ClampIndex(string id) =>
id != null && id.StartsWith("clamp-", StringComparison.Ordinal)
&& int.TryParse(id.AsSpan(6), out var index)
? index
: int.MaxValue;
private List<PlateDto> BuildPlateDtos()
{
var list = new List<PlateDto>();
@@ -233,7 +245,10 @@ namespace OpenNest.IO
if (
plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
&& plate.CutOffs.Count == 0
&& (plate.ClampZones.Count == 0 || DefaultsRecreateClamps(plate))
&& (
(plate.ClampZones.Count == 0 && plate.RemovedClamps.Count == 0)
|| DefaultsRecreateClamps(plate)
)
)
continue;
@@ -319,6 +334,10 @@ namespace OpenNest.IO
Width = z.Bounds.Width,
})
.ToList(),
RemovedClamps = plate.RemovedClamps
.OrderBy(ClampIndex)
.ThenBy(id => id, StringComparer.Ordinal)
.ToList(),
}
);
}
+102
View File
@@ -268,4 +268,106 @@ public class ClampRailTests
Assert.NotEqual(Machine(), new ClampRail(1.5, 0.5, MachineCenters));
Assert.NotEqual(Machine(), new ClampRail(1.5, 0.625, MachineCenters.Take(7)));
}
// D9: minutes to remove one clamp and put it back, a variable setting on the rail.
[Fact]
public void RemovalMinutesDefaultsToThePlannerValue()
{
Assert.Equal(5.0, Machine().RemovalMinutes);
Assert.Equal(5.0, ClampRail.DefaultRemovalMinutes);
}
[Theory]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void RemovalMinutesMustBeFiniteAndZeroOrMore(double minutes)
{
Assert.Throws<ArgumentOutOfRangeException>(
() => new ClampRail(1.5, 0.625, new[] { 6.0 }, minutes));
}
[Fact]
public void ZeroRemovalMinutesIsAllowed()
{
var rail = new ClampRail(1.5, 0.625, new[] { 6.0 }, removalMinutes: 0);
Assert.Equal(0, rail.RemovalMinutes);
}
[Fact]
public void RailsWithDifferentRemovalMinutesAreNotEqual()
{
var fiveMinutes = new ClampRail(1.5, 0.625, MachineCenters, 5);
var tenMinutes = new ClampRail(1.5, 0.625, MachineCenters, 10);
Assert.NotEqual(fiveMinutes, tenMinutes);
Assert.NotEqual(fiveMinutes.GetHashCode(), tenMinutes.GetHashCode());
}
// D8/C13: last-resort per-sheet removal of individual rail clamps.
[Fact]
public void RemovedClampsAreSubtractedFromTheRailResolution()
{
var plate = Sheet(48, 120);
plate.SetClampRail(Machine(), new[] { "clamp-3" });
Assert.Equal(7, plate.ClampZones.Count);
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-3");
Assert.Equal(new[] { "clamp-3" }, plate.RemovedClamps);
}
[Fact]
public void RemovedClampStaysOffAfterResizeAndReappearsIfTheClampComesBackOntoTheSheet()
{
var plate = Sheet(48, 120);
plate.SetClampRail(Machine(), new[] { "clamp-8" }); // centered at 111"
plate.Size = new Size(48, 60); // clamp-8 is beyond this sheet regardless
Assert.Equal(new[] { "clamp-1", "clamp-2", "clamp-3", "clamp-4" },
plate.ClampZones.Select(z => z.Id));
Assert.Equal(new[] { "clamp-8" }, plate.RemovedClamps);
plate.Size = new Size(48, 120); // clamp-8 is back on the sheet, but still removed
Assert.Equal(7, plate.ClampZones.Count);
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-8");
}
[Fact]
public void ClampRemovalMinutesSumsTheRailsPerClampTime()
{
var plate = Sheet(48, 120);
Assert.Equal(0, plate.ClampRemovalMinutes); // no rail yet
plate.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters, removalMinutes: 7),
new[] { "clamp-3", "clamp-4" });
Assert.Equal(14, plate.ClampRemovalMinutes);
plate.SetClampRail(null!);
Assert.Equal(0, plate.ClampRemovalMinutes); // detaching forgets the removed set too
Assert.Empty(plate.RemovedClamps);
}
[Fact]
public void SetClampRailWithoutRemovedClampsBehavesAsBefore()
{
var plate = Sheet(48, 120);
plate.SetClampRail(Machine());
Assert.Empty(plate.RemovedClamps);
Assert.Equal(8, plate.ClampZones.Count);
}
[Fact]
public void MatchesWithRemovedClampsComparesAgainstTheReducedResolution()
{
var plate = Sheet(48, 120);
plate.SetClampRail(Machine(), new[] { "clamp-3" });
Assert.True(Machine().Matches(plate, new[] { "clamp-3" }));
Assert.False(Machine().Matches(plate)); // full resolution: zones don't match
Assert.False(Machine().Matches(plate, Array.Empty<string>()));
Assert.False(Machine().Matches(plate, new[] { "clamp-4" }));
}
}