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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 17:40:44 -04:00
1 parent 15750949dd
commit 23f093fcc1
16 files changed
+671 -25

No files matched your search

+10
View File
@@ -112,6 +112,16 @@ namespace OpenNest
public double SalvageRate { get; set; } = 0.5;
/// <summary>
/// The smallest side, in model units, of an offcut credited as salvage or reported as the
/// plate's expected remnant (<see cref="PlateRemnant.Expected"/>). Auto Nest records the
/// minimum it last used.
/// </summary>
public double MinimumRemnantDimension { get; set; } = DefaultMinimumRemnantDimension;
/// <summary>Auto Nest's default minimum remnant side.</summary>
public const double DefaultMinimumRemnantDimension = 12.0;
public Plate CreatePlate()
{
var plate = PlateDefaults.CreateNew();
+169
View File
@@ -0,0 +1,169 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest;
/// <summary>Where an expected remnant comes from.</summary>
public enum PlateRemnantSource
{
/// <summary>Material a full-span cut-off cuts free of the parts.</summary>
CutOff,
/// <summary>The largest full-width or full-length edge strip outside the parts, as salvage measures it.</summary>
Offcut,
}
/// <summary>
/// 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.
/// </summary>
public sealed class PlateRemnant
{
private PlateRemnant(Box box, PlateRemnantSource source)
{
Box = box;
Source = source;
}
public Box Box { get; }
public PlateRemnantSource Source { get; }
/// <summary>
/// The drop <paramref name="plate"/> is expected to leave, or null when it has no parts or no
/// rectangle has both sides at least <paramref name="minimumDimension"/>. 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
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks.
/// </summary>
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);
}
/// <summary>
/// The largest full-width/full-length edge strip of <paramref name="workArea"/> clear of the
/// material envelope (<paramref name="left"/> to <paramref name="top"/>) by
/// <paramref name="partSpacing"/>, with both sides at least <paramref name="minimum"/>; null
/// when none qualifies. Ties keep the first of bottom, top, left, right. This is the salvage
/// offcut whole-job costing credits.
/// </summary>
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;
}
/// <summary>Analytic world-space material bounds; surface marks never bound material.</summary>
public static Box MaterialBounds(Part part) =>
ConvertProgram.ToGeometry(part.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.GetBoundingBox()
.Translate(part.Location);
/// <summary>
/// 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 <paramref name="minimum"/>.
/// </summary>
private static Box CutOffDrop(Plate plate, IReadOnlyList<Box> 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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
+17
View File
@@ -40,6 +40,23 @@ public sealed class NestPlateSheet
/// bundle used for several sheets repeats its code. Empty when no sheet is coded.
/// </summary>
public IReadOnlyList<string> StockItemCodes { get; init; } = Array.Empty<string>();
/// <summary>
/// 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).
/// </summary>
public NestExpectedRemnant? ExpectedRemnant { get; init; }
}
/// <summary>A plate's expected drop as stored, with <see cref="Width"/> the shorter side.</summary>
public sealed class NestExpectedRemnant
{
public double Width { get; init; }
public double Length { get; init; }
/// <summary><c>cutoff</c> when a full-span cut-off frees it, <c>offcut</c> for the largest edge strip.</summary>
public string Source { get; init; } = "";
}
/// <summary>A sheet size as stored, with <see cref="Width"/> the shorter side.</summary>
+3 -17
View File
@@ -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);
+1 -5
View File
@@ -553,11 +553,7 @@ public static class NestLayoutCheck
}
/// <summary>Analytic world-space material bounds; surface marks never bound material.</summary>
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);
/// <summary>Inflation that enforces <paramref name="spacing"/> less the shared slack.</summary>
private static double InflationFor(double spacing) => SpacingOutline.InflationFor(spacing);
@@ -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;
/// <summary>
/// Each saved plate carries a derived <c>expectedRemnant</c> computed from its layout at save;
/// readers never load it, and the nest's minimum remnant side round-trips.
/// </summary>
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<double>(), remnant["length"]!.GetValue<double>(), remnant["source"]!.GetValue<string>());
[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))));
}
}
+40
View File
@@ -44,6 +44,12 @@ namespace OpenNest.IO
public List<PlateDto> Plates { get; init; } = new();
public List<PlateOptionDto> PlateOptions { get; init; } = new();
public double SalvageRate { get; init; } = 0.5;
/// <summary>
/// Smallest remnant side in model units. Missing in older files, which load the Auto
/// Nest default.
/// </summary>
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.
/// </summary>
/// <summary>Writes derived data and skips it on read, whatever its shape.</summary>
public sealed class WriteOnlyConverter<T> : JsonConverter<T>
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<List<string>>
{
public override List<string> Read(
@@ -666,6 +687,25 @@ namespace OpenNest.IO
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(LenientClampRailConverter))]
public ClampRailDto ClampRail { get; init; }
/// <summary>
/// Derived at save from the layout (<see cref="PlateRemnant.Expected"/>) 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.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(WriteOnlyConverter<ExpectedRemnantDto>))]
public ExpectedRemnantDto ExpectedRemnant { get; init; }
}
/// <summary>A plate's expected rectangular drop, with <see cref="Width"/> the shorter side.</summary>
public record ExpectedRemnantDto
{
public double Width { get; init; }
public double Length { get; init; }
/// <summary><c>cutoff</c> or <c>offcut</c>.</summary>
public string Source { get; init; } = "";
}
public record ClampZoneDto
+4
View File
@@ -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
+12
View File
@@ -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)
+54 -1
View File
@@ -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<NestSheets>($"/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]
+52
View File
@@ -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<string>? 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<RemnantJson?>
{
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<List<string>>
{
+167
View File
@@ -0,0 +1,167 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests;
/// <summary>
/// <see cref="PlateRemnant.Expected"/>: a full-span cut-off clear of the parts defines the drop,
/// otherwise the largest salvage edge offcut, otherwise none.
/// </summary>
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)));
}
}
+2 -1
View File
@@ -70,7 +70,7 @@ namespace OpenNest.Forms
}
/// <summary>The smallest offcut credited as salvage unless the operator types another.</summary>
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()
@@ -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)
+25
View File
@@ -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
+9 -1
View File
@@ -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<yy>-<seq>` (`OpenNest.Data.NestNames`): the