From 23f093fcc120b8cf1a32695b3be031cf83b443f1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 25 Oct 2026 15:22:03 -0400 Subject: [PATCH] feat(nest): plates report the remnant they are expected to leave Core PlateRemnant.Expected picks a full-span cut-off drop clear of the parts (less kerf, nominal position for head-locked overcuts), else the largest salvage edge offcut, else none below the nest's new persisted MinimumRemnantDimension. NestJobCost's salvage offcut now calls the Core rule unchanged. NestWriter writes a derived plate expectedRemnant that readers skip, and /sheets reports it per plate. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Nest.cs | 10 ++ OpenNest.Core/PlateRemnant.cs | 169 ++++++++++++++++++ OpenNest.Data/NestSheets.cs | 17 ++ OpenNest.Engine/Jobs/NestJobCost.cs | 20 +-- OpenNest.Engine/Jobs/NestLayoutCheck.cs | 6 +- .../ExpectedRemnantPersistenceTests.cs | 103 +++++++++++ OpenNest.IO/NestFormat.cs | 40 +++++ OpenNest.IO/NestReader.cs | 4 + OpenNest.IO/NestWriter.cs | 12 ++ OpenNest.Server.Tests/NestSheetsTests.cs | 55 +++++- OpenNest.Server/NestArchive.cs | 52 ++++++ OpenNest.Tests/PlateRemnantTests.cs | 167 +++++++++++++++++ OpenNest/Forms/AutoNestForm.cs | 3 +- OpenNest/Forms/Commands/AutoNestCommand.cs | 3 + docs/nest-file-format.md | 25 +++ docs/nest-storage.md | 10 +- 16 files changed, 671 insertions(+), 25 deletions(-) create mode 100644 OpenNest.Core/PlateRemnant.cs create mode 100644 OpenNest.IO.Tests/ExpectedRemnantPersistenceTests.cs create mode 100644 OpenNest.Tests/PlateRemnantTests.cs 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