diff --git a/OpenNest.Core/Nest.cs b/OpenNest.Core/Nest.cs
index 47b2c906..f0a85bcc 100644
--- a/OpenNest.Core/Nest.cs
+++ b/OpenNest.Core/Nest.cs
@@ -112,6 +112,16 @@ namespace OpenNest
public double SalvageRate { get; set; } = 0.5;
+ ///
+ /// The smallest side, in model units, of an offcut credited as salvage or reported as the
+ /// plate's expected remnant (). Auto Nest records the
+ /// minimum it last used.
+ ///
+ public double MinimumRemnantDimension { get; set; } = DefaultMinimumRemnantDimension;
+
+ /// Auto Nest's default minimum remnant side.
+ public const double DefaultMinimumRemnantDimension = 12.0;
+
public Plate CreatePlate()
{
var plate = PlateDefaults.CreateNew();
diff --git a/OpenNest.Core/PlateRemnant.cs b/OpenNest.Core/PlateRemnant.cs
new file mode 100644
index 00000000..cb2b3153
--- /dev/null
+++ b/OpenNest.Core/PlateRemnant.cs
@@ -0,0 +1,169 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Converters;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest;
+
+/// Where an expected remnant comes from.
+public enum PlateRemnantSource
+{
+ /// Material a full-span cut-off cuts free of the parts.
+ CutOff,
+
+ /// The largest full-width or full-length edge strip outside the parts, as salvage measures it.
+ Offcut,
+}
+
+///
+/// The rectangular drop a plate is expected to leave, in plate coordinates (the quadrant frame
+/// of its parts and cut-offs). Derived from the current layout; never stored on the plate.
+///
+public sealed class PlateRemnant
+{
+ private PlateRemnant(Box box, PlateRemnantSource source)
+ {
+ Box = box;
+ Source = source;
+ }
+
+ public Box Box { get; }
+
+ public PlateRemnantSource Source { get; }
+
+ ///
+ /// The drop is expected to leave, or null when it has no parts or no
+ /// rectangle has both sides at least . A full-span cut-off
+ /// (no limit short of either sheet edge) with no part material beyond it defines the drop: the
+ /// sheet past its nominal position, less the plate's kerf for each bounding cut-off line. It
+ /// wins over a larger edge offcut. Otherwise the drop is the largest edge offcut that
+ /// finds. Material bounds ignore cut-off parts and marks.
+ ///
+ public static PlateRemnant Expected(Plate plate, double minimumDimension)
+ {
+ // A part with no material (marks only) bounds nothing.
+ var bounds = plate.Parts
+ .Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
+ .Select(p => ConvertProgram.ToGeometry(p.Program).Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
+ is { Count: > 0 } material
+ ? material.GetBoundingBox().Translate(p.Location)
+ : null)
+ .Where(b => b != null)
+ .ToList();
+ if (bounds.Count == 0)
+ return null;
+
+ var minimum = System.Math.Max(0, minimumDimension);
+ var cutOff = CutOffDrop(plate, bounds, minimum);
+ if (cutOff != null)
+ return new PlateRemnant(cutOff, PlateRemnantSource.CutOff);
+
+ var offcut = LargestOffcut(plate.WorkArea(), plate.PartSpacing,
+ bounds.Min(b => b.Left), bounds.Min(b => b.Bottom), bounds.Max(b => b.Right), bounds.Max(b => b.Top),
+ minimum);
+ return offcut == null ? null : new PlateRemnant(offcut, PlateRemnantSource.Offcut);
+ }
+
+ ///
+ /// The largest full-width/full-length edge strip of clear of the
+ /// material envelope ( to ) by
+ /// , with both sides at least ; null
+ /// when none qualifies. Ties keep the first of bottom, top, left, right. This is the salvage
+ /// offcut whole-job costing credits.
+ ///
+ public static Box LargestOffcut(Box workArea, double partSpacing,
+ double left, double bottom, double right, double top, double minimum)
+ {
+ var work = workArea;
+ var gap = partSpacing;
+ var candidates = new[]
+ {
+ new Box(work.Left, work.Bottom, work.Length, bottom - work.Bottom - gap),
+ new Box(work.Left, top + gap, work.Length, work.Top - top - gap),
+ new Box(work.Left, work.Bottom, left - work.Left - gap, work.Width),
+ new Box(right + gap, work.Bottom, work.Right - right - gap, work.Width),
+ };
+ Box best = null;
+ foreach (var candidate in candidates)
+ {
+ if (candidate.Length >= minimum && candidate.Width >= minimum
+ && (best == null || candidate.Length * candidate.Width > best.Length * best.Width))
+ best = candidate;
+ }
+ return best;
+ }
+
+ /// Analytic world-space material bounds; surface marks never bound material.
+ public static Box MaterialBounds(Part part) =>
+ ConvertProgram.ToGeometry(part.Program)
+ .Where(e => SpecialLayers.IsMaterial(e.Layer))
+ .GetBoundingBox()
+ .Translate(part.Location);
+
+ ///
+ /// The largest piece between full-span cut-off lines (or a line and the sheet edge) that no
+ /// part material reaches into, with both sides at least .
+ ///
+ private static Box CutOffDrop(Plate plate, IReadOnlyList material, double minimum)
+ {
+ var sheet = plate.BoundingBox(includeParts: false);
+ var kerf = plate.CuttingParameters?.Kerf is double k && double.IsFinite(k) && k > 0 ? k : 0;
+ Box best = null;
+ foreach (var vertical in new[] { true, false })
+ {
+ var low = vertical ? sheet.Left : sheet.Bottom;
+ var high = vertical ? sheet.Right : sheet.Top;
+ var lines = plate.CutOffs
+ .Where(c => (c.Axis == CutOffAxis.Vertical) == vertical && SpansSheet(c, sheet))
+ .Select(c => vertical ? c.Position.X : c.Position.Y)
+ .Where(at => at > low + Tolerance.Epsilon && at < high - Tolerance.Epsilon)
+ .Distinct()
+ .OrderBy(at => at)
+ .ToList();
+ if (lines.Count == 0)
+ continue;
+
+ // Each piece runs from one boundary to the next; at least one is a cut-off line.
+ var boundaries = new List<(double At, bool Cut)> { (low, false) };
+ boundaries.AddRange(lines.Select(at => (at, true)));
+ boundaries.Add((high, false));
+ for (var i = 0; i + 1 < boundaries.Count; i++)
+ {
+ var (from, fromCut) = boundaries[i];
+ var (to, toCut) = boundaries[i + 1];
+ if (material.Any(b => Reaches(b, vertical, from, to)))
+ continue;
+ var start = fromCut ? from + kerf : from;
+ var end = toCut ? to - kerf : to;
+ var piece = vertical
+ ? new Box(start, sheet.Bottom, end - start, sheet.Width)
+ : new Box(sheet.Left, start, sheet.Length, end - start);
+ if (piece.Length >= minimum && piece.Width >= minimum
+ && (best == null || piece.Length * piece.Width > best.Length * best.Width))
+ best = piece;
+ }
+ }
+ return best;
+ }
+
+ ///
+ /// Whether the nominal line runs edge to edge. A head-locked overcut only extends the
+ /// program past the far edge, so the nominal limits decide.
+ ///
+ private static bool SpansSheet(CutOff cutOff, Box sheet)
+ {
+ var vertical = cutOff.Axis == CutOffAxis.Vertical;
+ var alongMin = vertical ? sheet.Bottom : sheet.Left;
+ var alongMax = vertical ? sheet.Top : sheet.Right;
+ return (cutOff.StartLimit is not double start || start <= alongMin + Tolerance.Epsilon)
+ && (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon);
+ }
+
+ private static bool Reaches(Box material, bool vertical, double from, double to)
+ {
+ var low = vertical ? material.Left : material.Bottom;
+ var high = vertical ? material.Right : material.Top;
+ return high > from + Tolerance.Epsilon && low < to - Tolerance.Epsilon;
+ }
+}
diff --git a/OpenNest.Data/NestSheets.cs b/OpenNest.Data/NestSheets.cs
index 62cab5ab..e0b6a5ad 100644
--- a/OpenNest.Data/NestSheets.cs
+++ b/OpenNest.Data/NestSheets.cs
@@ -40,6 +40,23 @@ public sealed class NestPlateSheet
/// bundle used for several sheets repeats its code. Empty when no sheet is coded.
///
public IReadOnlyList StockItemCodes { get; init; } = Array.Empty();
+
+ ///
+ /// The rectangular drop each of this plate's sheets is expected to leave, as the desktop
+ /// computed it at save; null when none (or the file predates it).
+ ///
+ public NestExpectedRemnant? ExpectedRemnant { get; init; }
+}
+
+/// A plate's expected drop as stored, with the shorter side.
+public sealed class NestExpectedRemnant
+{
+ public double Width { get; init; }
+
+ public double Length { get; init; }
+
+ /// cutoff when a full-span cut-off frees it, offcut for the largest edge strip.
+ public string Source { get; init; } = "";
}
/// A sheet size as stored, with the shorter side.
diff --git a/OpenNest.Engine/Jobs/NestJobCost.cs b/OpenNest.Engine/Jobs/NestJobCost.cs
index 4abd34ed..4638dd9e 100644
--- a/OpenNest.Engine/Jobs/NestJobCost.cs
+++ b/OpenNest.Engine/Jobs/NestJobCost.cs
@@ -92,23 +92,9 @@ public static class NestJobCost
}
private static double LargestOffcut(
- NestPlateStock stock, double left, double bottom, double right, double top, double minimum)
- {
- var work = stock.WorkArea;
- var gap = stock.PartSpacing;
- var candidates = new[]
- {
- (work.Length, bottom - work.Bottom - gap),
- (work.Length, work.Top - top - gap),
- (left - work.Left - gap, work.Width),
- (work.Right - right - gap, work.Width),
- };
- return candidates
- .Where(c => c.Item1 >= minimum && c.Item2 >= minimum)
- .Select(c => c.Item1 * c.Item2)
- .DefaultIfEmpty(0)
- .Max();
- }
+ NestPlateStock stock, double left, double bottom, double right, double top, double minimum) =>
+ PlateRemnant.LargestOffcut(stock.WorkArea, stock.PartSpacing, left, bottom, right, top, minimum)
+ is { } offcut ? offcut.Length * offcut.Width : 0;
public static bool UsesExplicitCosts(NestJob job) =>
job.Plates.Any(s => s.Quantity != 0 && s.Cost.HasValue);
diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
index c6c28578..5c51c0aa 100644
--- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs
+++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
@@ -553,11 +553,7 @@ public static class NestLayoutCheck
}
/// Analytic world-space material bounds; surface marks never bound material.
- internal static Box MaterialBounds(Part part) =>
- ConvertProgram.ToGeometry(part.Program)
- .Where(e => SpecialLayers.IsMaterial(e.Layer))
- .GetBoundingBox()
- .Translate(part.Location);
+ internal static Box MaterialBounds(Part part) => PlateRemnant.MaterialBounds(part);
/// Inflation that enforces less the shared slack.
private static double InflationFor(double spacing) => SpacingOutline.InflationFor(spacing);
diff --git a/OpenNest.IO.Tests/ExpectedRemnantPersistenceTests.cs b/OpenNest.IO.Tests/ExpectedRemnantPersistenceTests.cs
new file mode 100644
index 00000000..15a8ea05
--- /dev/null
+++ b/OpenNest.IO.Tests/ExpectedRemnantPersistenceTests.cs
@@ -0,0 +1,103 @@
+using System.Text.Json.Nodes;
+using OpenNest.Geometry;
+using OpenNest.Shapes;
+using static OpenNest.IO.Tests.NestArchiveJson;
+
+namespace OpenNest.IO.Tests;
+
+///
+/// Each saved plate carries a derived expectedRemnant computed from its layout at save;
+/// readers never load it, and the nest's minimum remnant side round-trips.
+///
+public class ExpectedRemnantPersistenceTests
+{
+ // A 48 x 96 sheet with one 10 x 10 part at the origin: an 86 x 48 offcut to its right.
+ private static Nest NestWithPart()
+ {
+ var nest = new Nest("remnant") { Material = new Material("Mild Steel", "A36"), Thickness = 0.25 };
+ var drawing = new RectangleShape { Name = "SQ", Length = 10, Width = 10 }.GetDrawing();
+ nest.Drawings.Add(drawing);
+ var plate = new Plate(48, 96);
+ plate.Parts.Add(new Part(drawing, new Vector(0, 0)));
+ nest.Plates.Add(plate);
+ return nest;
+ }
+
+ private static byte[] Write(Nest nest)
+ {
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ return stream.ToArray();
+ }
+
+ private static JsonNode Remnant(byte[] archive) => NestJson(archive)["plates"]![0]!["expectedRemnant"];
+
+ private static (double, double, string) Shape(JsonNode remnant) =>
+ (remnant!["width"]!.GetValue(), remnant["length"]!.GetValue(), remnant["source"]!.GetValue());
+
+ [Fact]
+ public void Offcut_IsWrittenWithWidthTheShortSide()
+ {
+ Assert.Equal((48.0, 86.0, "offcut"), Shape(Remnant(Write(NestWithPart()))));
+ }
+
+ [Fact]
+ public void CutOffDrop_IsWrittenAsCutoff()
+ {
+ var nest = NestWithPart();
+ nest.Plates[0].CutOffs.Add(new CutOff(new Vector(60, 0), CutOffAxis.Vertical));
+ nest.Plates[0].RegenerateCutOffs(new CutOffSettings());
+
+ Assert.Equal((36.0, 48.0, "cutoff"), Shape(Remnant(Write(nest))));
+ }
+
+ [Fact]
+ public void NoRemnant_WritesNoKey()
+ {
+ var nest = NestWithPart();
+ nest.MinimumRemnantDimension = 50;
+
+ Assert.False(NestJson(Write(nest))["plates"]![0]!.AsObject().ContainsKey("expectedRemnant"));
+ }
+
+ [Fact]
+ public void MinimumRemnantDimension_RoundTrips_AndOldFilesLoadTheDefault()
+ {
+ var nest = NestWithPart();
+ nest.MinimumRemnantDimension = 6;
+ var archive = Write(nest);
+
+ Assert.Equal(6, RoundTrip(archive).Nest.MinimumRemnantDimension);
+
+ var json = NestJson(archive);
+ Assert.True(json.Remove("minimumRemnantDimension"));
+ Assert.Equal(Nest.DefaultMinimumRemnantDimension,
+ RoundTrip(ReplaceNestJson(archive, json)).Nest.MinimumRemnantDimension);
+ }
+
+ [Theory]
+ [InlineData("{\"width\": 1, \"length\": 2, \"source\": \"cutoff\"}")]
+ [InlineData("\"junk\"")]
+ [InlineData("[1, 2]")]
+ public void StoredValue_IsIgnoredOnRead_AndRecomputedOnSave(string stored)
+ {
+ var archive = Write(NestWithPart());
+ var json = NestJson(archive);
+ json["plates"]![0]!["expectedRemnant"] = JsonNode.Parse(stored);
+
+ var (reopened, _) = RoundTrip(ReplaceNestJson(archive, json));
+
+ Assert.Single(reopened.Plates);
+ Assert.Equal((48.0, 86.0, "offcut"), Shape(Remnant(Write(reopened))));
+ }
+
+ [Fact]
+ public void PartMove_IsReflectedOnTheNextSave()
+ {
+ var (reopened, _) = RoundTrip(Write(NestWithPart()));
+ reopened.Plates[0].Parts[0].Location = new Vector(40, 0);
+
+ // Strips: 40 x 48 left of the part, 46 x 48 right of it, 96 x 38 above it.
+ Assert.Equal((38.0, 96.0, "offcut"), Shape(Remnant(Write(reopened))));
+ }
+}
diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs
index f8088d6f..036d9f0e 100644
--- a/OpenNest.IO/NestFormat.cs
+++ b/OpenNest.IO/NestFormat.cs
@@ -44,6 +44,12 @@ namespace OpenNest.IO
public List Plates { get; init; } = new();
public List PlateOptions { get; init; } = new();
public double SalvageRate { get; init; } = 0.5;
+
+ ///
+ /// Smallest remnant side in model units. Missing in older files, which load the Auto
+ /// Nest default.
+ ///
+ public double MinimumRemnantDimension { get; init; } = Nest.DefaultMinimumRemnantDimension;
}
public record PlateDefaultsDto
@@ -339,6 +345,21 @@ namespace OpenNest.IO
/// 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.
///
+ /// Writes derived data and skips it on read, whatever its shape.
+ public sealed class WriteOnlyConverter : JsonConverter
+ where T : class
+ {
+ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ reader.Skip();
+ return null;
+ }
+
+ // The converter sits on the property, not the type, so this does not recurse.
+ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) =>
+ JsonSerializer.Serialize(writer, value, options);
+ }
+
public sealed class LenientStringListConverter : JsonConverter>
{
public override List Read(
@@ -666,6 +687,25 @@ namespace OpenNest.IO
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(LenientClampRailConverter))]
public ClampRailDto ClampRail { get; init; }
+
+ ///
+ /// Derived at save from the layout () for readers
+ /// without geometry, such as the server. Omitted when the plate leaves none; never read
+ /// back, so a stale value cannot reach the model.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonConverter(typeof(WriteOnlyConverter))]
+ public ExpectedRemnantDto ExpectedRemnant { get; init; }
+ }
+
+ /// A plate's expected rectangular drop, with the shorter side.
+ public record ExpectedRemnantDto
+ {
+ public double Width { get; init; }
+ public double Length { get; init; }
+
+ /// cutoff or offcut.
+ public string Source { get; init; } = "";
}
public record ClampZoneDto
diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs
index 8d5c66e0..b3a32671 100644
--- a/OpenNest.IO/NestReader.cs
+++ b/OpenNest.IO/NestReader.cs
@@ -575,6 +575,10 @@ namespace OpenNest.IO
// Stock options: sheet sizes and salvage rate
nest.SalvageRate = dto.SalvageRate;
+ nest.MinimumRemnantDimension = double.IsFinite(dto.MinimumRemnantDimension)
+ && dto.MinimumRemnantDimension > 0
+ ? dto.MinimumRemnantDimension
+ : Nest.DefaultMinimumRemnantDimension;
if (dto.PlateOptions != null)
{
nest.PlateOptions = dto
diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs
index af97b9d7..8747963e 100644
--- a/OpenNest.IO/NestWriter.cs
+++ b/OpenNest.IO/NestWriter.cs
@@ -128,6 +128,7 @@ namespace OpenNest.IO
.ToList()
?? new(),
SalvageRate = nest.SalvageRate,
+ MinimumRemnantDimension = nest.MinimumRemnantDimension,
};
}
@@ -449,12 +450,23 @@ namespace OpenNest.IO
? null
: ToDto(plate.ClampRail),
StockItemCodes = plate.StockItemCodes.Count == 0 ? null : plate.StockItemCodes.ToList(),
+ ExpectedRemnant = ToDto(PlateRemnant.Expected(plate, nest.MinimumRemnantDimension)),
}
);
}
return list;
}
+ private static ExpectedRemnantDto ToDto(PlateRemnant remnant) =>
+ remnant == null
+ ? null
+ : new ExpectedRemnantDto
+ {
+ Width = System.Math.Min(remnant.Box.Width, remnant.Box.Length),
+ Length = System.Math.Max(remnant.Box.Width, remnant.Box.Length),
+ Source = remnant.Source == PlateRemnantSource.CutOff ? "cutoff" : "offcut",
+ };
+
private void WriteBestFits(ZipArchive zipArchive)
{
if (BestFitStore == null)
diff --git a/OpenNest.Server.Tests/NestSheetsTests.cs b/OpenNest.Server.Tests/NestSheetsTests.cs
index 4321edac..3a53c497 100644
--- a/OpenNest.Server.Tests/NestSheetsTests.cs
+++ b/OpenNest.Server.Tests/NestSheetsTests.cs
@@ -178,9 +178,62 @@ public sealed class NestSheetsTests
var plate = Assert.Single(document.RootElement.GetProperty("plates").EnumerateArray());
Assert.Equal(
- ["index", "width", "length", "quantity", "stockItemCodes"],
+ ["index", "width", "length", "quantity", "stockItemCodes", "expectedRemnant"],
plate.EnumerateObject().Select(p => p.Name).ToArray());
Assert.Equal("DROP-1182", Assert.Single(plate.GetProperty("stockItemCodes").EnumerateArray()).GetString());
+ Assert.Equal(
+ ["width", "length", "source"],
+ plate.GetProperty("expectedRemnant").EnumerateObject().Select(p => p.Name).ToArray());
+ }
+
+ [Fact]
+ public async Task Plates_ReportTheExpectedRemnantNestWriterSaved()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ // The synthetic 20 x 30 plate's 6 x 3 part leaves a 20 x 22 strip beside it.
+ var nest = NestApiTests.SyntheticNest();
+ var drawing = nest.Drawings.Single();
+ var cut = new Plate(new Size(48, 96)) { Quantity = 2 };
+ cut.Parts.Add(new Part(drawing, new Vector(0, 0)));
+ cut.CutOffs.Add(new CutOff(new Vector(60, 0), CutOffAxis.Vertical));
+ cut.RegenerateCutOffs(new CutOffSettings());
+ nest.Plates.Add(cut);
+ // Nothing on a 14 x 14 sheet reaches the 12-inch minimum.
+ var small = new Plate(new Size(14, 14)) { Quantity = 1 };
+ small.Parts.Add(new Part(drawing, new Vector(2, 3)));
+ nest.Plates.Add(small);
+
+ var sheets = await Sheets(client, nest);
+
+ Assert.Equal(
+ [(20.0, 22.0, "offcut"), (36.0, 48.0, "cutoff"), (0.0, 0.0, "none")],
+ sheets.Plates.Select(p => p.ExpectedRemnant is { } r ? (r.Width, r.Length, r.Source) : (0.0, 0.0, "none")).ToArray());
+ }
+
+ [Fact]
+ public async Task Plates_ReadExpectedRemnantsLeniently()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var archive = TestNests.Zip("nest.json", """
+ {"drawings":[{"id":1,"name":"Kept","quantity":{"required":2}}],
+ "plates":[{"id":1,"size":{"width":48,"length":96},"parts":[{"drawingId":1}],
+ "expectedRemnant":{"width":60,"length":34.25,"source":"cutoff"}},
+ {"id":2,"size":{"width":48,"length":96},"parts":[{"drawingId":1}],
+ "expectedRemnant":"junk"},
+ {"id":3,"size":{"width":48,"length":96},"parts":[{"drawingId":1}],
+ "expectedRemnant":{"width":10,"length":20,"source":"guess"}},
+ {"id":4,"size":{"width":48,"length":96},"parts":[{"drawingId":1}]}]}
+ """);
+ var id = await Create(client, archive);
+
+ var sheets = (await client.GetFromJsonAsync($"/api/nests/{id}/sheets", Options))!;
+
+ // Width is the short side even when the file says otherwise.
+ var remnant = sheets.Plates[0].ExpectedRemnant!;
+ Assert.Equal((34.25, 60.0, "cutoff"), (remnant.Width, remnant.Length, remnant.Source));
+ Assert.All(sheets.Plates.Skip(1), p => Assert.Null(p.ExpectedRemnant));
}
[Fact]
diff --git a/OpenNest.Server/NestArchive.cs b/OpenNest.Server/NestArchive.cs
index a7892897..3ef27e21 100644
--- a/OpenNest.Server/NestArchive.cs
+++ b/OpenNest.Server/NestArchive.cs
@@ -101,6 +101,7 @@ internal static class NestArchive
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
.Take(System.Math.Max(p.Quantity, 0))
.ToList(),
+ ExpectedRemnant = ReadRemnant(p.ExpectedRemnant),
};
})
.ToList();
@@ -217,8 +218,59 @@ internal static class NestArchive
[JsonConverter(typeof(LenientStringListJson))]
public List? StockItemCodes { get; set; }
+
+ [JsonConverter(typeof(LenientRemnantJson))]
+ public RemnantJson? ExpectedRemnant { get; set; }
}
+ private sealed class RemnantJson
+ {
+ public double Width { get; set; }
+ public double Length { get; set; }
+ public string? Source { get; set; }
+ }
+
+ // Derived data: a wrongly shaped value reads as none rather than failing the whole nest.
+ private sealed class LenientRemnantJson : JsonConverter
+ {
+ public override RemnantJson? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ var root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object
+ || !TryNumber(root, "width", out var width)
+ || !TryNumber(root, "length", out var length))
+ return null;
+ var source = root.TryGetProperty("source", out var s) && s.ValueKind == JsonValueKind.String
+ ? s.GetString()
+ : null;
+ return new RemnantJson { Width = width, Length = length, Source = source };
+ }
+
+ public override void Write(Utf8JsonWriter writer, RemnantJson? value, JsonSerializerOptions options) =>
+ JsonSerializer.Serialize(writer, value);
+
+ private static bool TryNumber(JsonElement element, string name, out double value)
+ {
+ value = 0;
+ return element.TryGetProperty(name, out var property)
+ && property.ValueKind == JsonValueKind.Number
+ && property.TryGetDouble(out value);
+ }
+ }
+
+ private static NestExpectedRemnant? ReadRemnant(RemnantJson? remnant) =>
+ remnant is { Source: "cutoff" or "offcut" }
+ && double.IsFinite(remnant.Width) && double.IsFinite(remnant.Length)
+ && remnant.Width > 0 && remnant.Length > 0
+ ? new NestExpectedRemnant
+ {
+ Width = System.Math.Min(remnant.Width, remnant.Length),
+ Length = System.Math.Max(remnant.Width, remnant.Length),
+ Source = remnant.Source,
+ }
+ : null;
+
// NestReader's lenient list: a wrongly shaped value reads as empty, non-strings are skipped.
private sealed class LenientStringListJson : JsonConverter>
{
diff --git a/OpenNest.Tests/PlateRemnantTests.cs b/OpenNest.Tests/PlateRemnantTests.cs
new file mode 100644
index 00000000..241d62dc
--- /dev/null
+++ b/OpenNest.Tests/PlateRemnantTests.cs
@@ -0,0 +1,167 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Geometry;
+using OpenNest.Shapes;
+
+namespace OpenNest.Tests;
+
+///
+/// : a full-span cut-off clear of the parts defines the drop,
+/// otherwise the largest salvage edge offcut, otherwise none.
+///
+public class PlateRemnantTests
+{
+ // A 48 x 96 sheet (Y x X) with one 10 x 10 part in the origin corner. The largest edge
+ // offcut is the 86 x 48 strip to the right of the part.
+ private static Plate PlateWithPart(Drawing drawing = null)
+ {
+ var plate = new Plate(48, 96);
+ plate.Parts.Add(new Part(drawing ?? new RectangleShape { Name = "SQ", Length = 10, Width = 10 }.GetDrawing(),
+ new Vector(0, 0)));
+ return plate;
+ }
+
+ private static (double X, double Y, double Length, double Width, PlateRemnantSource Source) Of(PlateRemnant remnant) =>
+ (remnant.Box.X, remnant.Box.Y, remnant.Box.Length, remnant.Box.Width, remnant.Source);
+
+ [Fact]
+ public void NoCutOff_IsTheLargestEdgeOffcut()
+ {
+ var remnant = PlateRemnant.Expected(PlateWithPart(), 12);
+
+ Assert.Equal((10.0, 0.0, 86.0, 48.0, PlateRemnantSource.Offcut), Of(remnant));
+ }
+
+ [Fact]
+ public void Offcut_KeepsThePartSpacingAndEdgeSpacing()
+ {
+ var plate = PlateWithPart();
+ plate.PartSpacing = 0.5;
+ plate.EdgeSpacing = new Spacing(1, 1, 1, 1);
+
+ var remnant = PlateRemnant.Expected(plate, 12);
+
+ Assert.Equal((10.5, 1.0, 84.5, 46.0, PlateRemnantSource.Offcut), Of(remnant));
+ }
+
+ [Fact]
+ public void FullSpanCutOff_WinsOverALargerOffcut()
+ {
+ var plate = PlateWithPart();
+ plate.CutOffs.Add(new CutOff(new Vector(60, 0), CutOffAxis.Vertical));
+
+ var remnant = PlateRemnant.Expected(plate, 12);
+
+ Assert.Equal((60.0, 0.0, 36.0, 48.0, PlateRemnantSource.CutOff), Of(remnant));
+ }
+
+ [Fact]
+ public void CutOff_SubtractsTheKerf()
+ {
+ var plate = PlateWithPart();
+ plate.CuttingParameters = new CuttingParameters { Kerf = 0.25 };
+ plate.CutOffs.Add(new CutOff(new Vector(0, 30), CutOffAxis.Horizontal));
+
+ var remnant = PlateRemnant.Expected(plate, 12);
+
+ Assert.Equal((0.0, 30.25, 96.0, 17.75, PlateRemnantSource.CutOff), Of(remnant));
+ }
+
+ [Fact]
+ public void CutOffThroughParts_IsNotADropEdge()
+ {
+ // The strip line at X = 5 crosses the part; only the separator at 60 frees material.
+ var plate = PlateWithPart();
+ plate.CutOffs.Add(new CutOff(new Vector(5, 0), CutOffAxis.Vertical));
+ plate.CutOffs.Add(new CutOff(new Vector(60, 0), CutOffAxis.Vertical));
+
+ var remnant = PlateRemnant.Expected(plate, 12);
+
+ Assert.Equal((60.0, 0.0, 36.0, 48.0, PlateRemnantSource.CutOff), Of(remnant));
+ }
+
+ [Fact]
+ public void PiecesBetweenCutOffs_ReportTheLargest()
+ {
+ var plate = PlateWithPart();
+ plate.CuttingParameters = new CuttingParameters { Kerf = 0.25 };
+ plate.CutOffs.Add(new CutOff(new Vector(20, 0), CutOffAxis.Vertical));
+ plate.CutOffs.Add(new CutOff(new Vector(70, 0), CutOffAxis.Vertical));
+
+ var remnant = PlateRemnant.Expected(plate, 12);
+
+ // 20..70 less kerf at both lines beats 70..96 less one kerf.
+ Assert.Equal((20.25, 0.0, 49.5, 48.0, PlateRemnantSource.CutOff), Of(remnant));
+ }
+
+ [Fact]
+ public void PartialSpanCutOff_FallsThroughToTheOffcut()
+ {
+ var plate = PlateWithPart();
+ plate.CutOffs.Add(new CutOff(new Vector(60, 0), CutOffAxis.Vertical) { EndLimit = 24 });
+
+ var remnant = PlateRemnant.Expected(plate, 12);
+
+ Assert.Equal((10.0, 0.0, 86.0, 48.0, PlateRemnantSource.Offcut), Of(remnant));
+ }
+
+ [Fact]
+ public void HeadLockedOvercut_UsesTheNominalPosition()
+ {
+ var plate = PlateWithPart();
+ plate.CutOffs.Add(new CutOff(new Vector(0, 30), CutOffAxis.Horizontal) { HeadLockOvercut = 1.5 });
+ plate.RegenerateCutOffs(new CutOffSettings());
+
+ var remnant = PlateRemnant.Expected(plate, 12);
+
+ Assert.Equal((0.0, 30.0, 96.0, 18.0, PlateRemnantSource.CutOff), Of(remnant));
+ }
+
+ [Fact]
+ public void CutOffDropBelowTheMinimum_FallsThroughToTheOffcut()
+ {
+ var plate = PlateWithPart();
+ plate.CutOffs.Add(new CutOff(new Vector(90, 0), CutOffAxis.Vertical));
+
+ var remnant = PlateRemnant.Expected(plate, 12);
+
+ Assert.Equal((10.0, 0.0, 86.0, 48.0, PlateRemnantSource.Offcut), Of(remnant));
+ }
+
+ [Fact]
+ public void NothingMeetsTheMinimum_IsNone()
+ {
+ Assert.Null(PlateRemnant.Expected(PlateWithPart(), 50));
+ }
+
+ [Fact]
+ public void EmptyPlate_IsNone()
+ {
+ var plate = new Plate(48, 96);
+ plate.CutOffs.Add(new CutOff(new Vector(60, 0), CutOffAxis.Vertical));
+
+ Assert.Null(PlateRemnant.Expected(plate, 0));
+ }
+
+ [Fact]
+ public void Marks_DoNotBoundMaterial()
+ {
+ // A scribe line reaching X = 50 is not material: the offcut and the cut-off drop both
+ // start at the 10-inch part edge or the cut-off line.
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(10, 0);
+ program.LineTo(10, 10);
+ program.LineTo(0, 10);
+ program.LineTo(0, 0);
+ program.MoveTo(1, 5);
+ program.Codes.Add(new LinearMove(50, 5) { Layer = LayerType.Scribe });
+ var marked = new Drawing("MARKED", program);
+
+ Assert.Equal((10.0, 0.0, 86.0, 48.0, PlateRemnantSource.Offcut), Of(PlateRemnant.Expected(PlateWithPart(marked), 12)));
+
+ var cut = PlateWithPart(marked);
+ cut.CutOffs.Add(new CutOff(new Vector(30, 0), CutOffAxis.Vertical));
+ Assert.Equal((30.0, 0.0, 66.0, 48.0, PlateRemnantSource.CutOff), Of(PlateRemnant.Expected(cut, 12)));
+ }
+}
diff --git a/OpenNest/Forms/AutoNestForm.cs b/OpenNest/Forms/AutoNestForm.cs
index 5a6dab4a..41e6ac65 100644
--- a/OpenNest/Forms/AutoNestForm.cs
+++ b/OpenNest/Forms/AutoNestForm.cs
@@ -70,7 +70,7 @@ namespace OpenNest.Forms
}
/// The smallest offcut credited as salvage unless the operator types another.
- public const double DefaultMinRemnantSize = 12.0;
+ public const double DefaultMinRemnantSize = Nest.DefaultMinimumRemnantDimension;
public double MinRemnantSize
{
@@ -80,6 +80,7 @@ namespace OpenNest.Forms
return val;
return DefaultMinRemnantSize;
}
+ set { minRemnantBox.Text = value.ToString("G"); }
}
private void LoadEngines()
diff --git a/OpenNest/Forms/Commands/AutoNestCommand.cs b/OpenNest/Forms/Commands/AutoNestCommand.cs
index 7d772acf..03bf8bce 100644
--- a/OpenNest/Forms/Commands/AutoNestCommand.cs
+++ b/OpenNest/Forms/Commands/AutoNestCommand.cs
@@ -56,6 +56,8 @@ namespace OpenNest.Forms.Commands
using var form = new AutoNestForm(target.Nest);
if (target.Nest.PlateOptions.Count > 0)
form.LoadPlateOptions(target.Nest.PlateOptions, target.Nest.SalvageRate);
+ if (target.Nest.MinimumRemnantDimension > 0)
+ form.MinRemnantSize = target.Nest.MinimumRemnantDimension;
if (form.ShowDialog(owner) != DialogResult.OK)
return null;
@@ -126,6 +128,7 @@ namespace OpenNest.Forms.Commands
if (savedOptions?.Count > 0)
target.Nest.PlateOptions = savedOptions;
target.Nest.SalvageRate = salvageRate;
+ target.Nest.MinimumRemnantDimension = minRemnantSize;
}
target.PlateView.Invalidate();
if (allowInvalid)
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index a6fd6209..cd8e5fa2 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -172,6 +172,31 @@ The model drops them when they stop describing the sheet: resizing a plate
and changing the nest's material, grade or thickness clears every plate's.
Auto Nest assigns them only to sheets it nests on ledger stock.
+## Expected remnants
+
+Each written plate may carry a derived `expectedRemnant`: `{ "width", "length",
+"source" }`, the rectangular drop it is expected to leave, in model units with
+`width` the shorter side. `NestWriter` computes it at every save from the current
+layout with Core's `PlateRemnant.Expected`, for readers that have no geometry
+(OpenNest.Server's `/sheets`). It is omitted when the plate leaves none. Readers
+skip it whatever its shape and never load it into the model, so a value edited by
+hand or left by an older layout cannot go stale.
+
+- `"cutoff"`: a full-span cut-off (no limit short of either sheet edge) frees
+ material no part reaches into. The drop runs from the line's nominal position
+ to the sheet edge or the next such line, across the whole sheet, less the
+ plate's kerf (`cuttingParameters.kerf`, zero without parameters) at each
+ bounding line. A head-locked overcut uses the nominal position. It wins over a
+ larger edge offcut; the largest such piece is reported.
+- `"offcut"`: otherwise, the largest full-width or full-length strip of the
+ work area clear of the parts' material bounds by the part spacing, exactly the
+ offcut whole-job salvage credits. Cut-off parts and marks never bound material.
+
+Both sides must reach the nest's `minimumRemnantDimension` (model units; Auto Nest
+records the minimum remnant size it last used, and older files load 12). A cut-off
+drop below it falls through to the offcut. Interior holes and irregular leftovers
+are not reported.
+
## Support grid
`plateDefaults.supportBed` optionally stores the machine support bed every sheet of
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index 34d4fe7b..cd9c2f5a 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -102,7 +102,7 @@ entry, never programs or geometry, and takes the archive-derived fields below fr
| GET | `/api/nests/query?search=&status=&material=&materialGrade=&thickness=&sort=&order=&offset=&limit=` | — | `{ "items": NestRecord[], "total", "offset", "limit" }`: one bounded page filtered in SQL (see below), or 400 for an invalid parameter |
| GET | `/api/nests/{id}` | — | `NestRecord` or 404 |
| GET | `/api/nests/{id}/file` | — | `.nest` archive bytes (`application/zip`) or 404 |
-| GET | `/api/nests/{id}/sheets` | — | `{ "nestId", "revision", "sheets": [{ "width", "length", "quantity" }], "parts": [{ "name", "required", "nested" }], "plates": [{ "index", "width", "length", "quantity", "stockItemCodes" }] }` read from the current archive (see below), 404, or 500 problem when the stored archive is unreadable |
+| GET | `/api/nests/{id}/sheets` | — | `{ "nestId", "revision", "sheets": [{ "width", "length", "quantity" }], "parts": [{ "name", "required", "nested" }], "plates": [{ "index", "width", "length", "quantity", "stockItemCodes", "expectedRemnant" }] }` read from the current archive (see below), 404, or 500 problem when the stored archive is unreadable |
| POST | `/api/nests` | multipart: `metadata` (JSON `NestRecord`) + `file` (`.nest` bytes) | `NestRecord` with server-assigned `id` when the client sends an empty guid; 409 with the current record (active or deleted) when the `id` already exists, storing nothing |
| PUT | `/api/nests/{id}/file` | multipart: `metadata` + `file` | Updated `NestRecord`, or 404 if `id` is unknown |
| PUT | `/api/nests/{id}/metadata` | JSON `NestRecord` | Updated `NestRecord` (archive untouched), or 404 |
@@ -141,6 +141,14 @@ its code; empty when none is coded). Codes come from the plate's optional
read as `NestReader` reads them. MaterialLedger can hold exactly those items and
match uncoded sheets by size.
+Each plate also reports `expectedRemnant`, `{ "width", "length", "source" }`
+(`width` the shorter side, in the nest's units, `source` `cutoff` or `offcut`), the
+rectangular drop each of its sheets is expected to leave, or `null` when none. The
+server has no geometry: it reports the plate's derived `expectedRemnant` that the
+desktop wrote at save (see [expected remnants](nest-file-format.md#expected-remnants)).
+A file without it, or with a malformed or non-positive value or an unknown source,
+reports `null`.
+
### Generated nest names
**File > New** names a nest `N-` (`OpenNest.Data.NestNames`): the