mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 22:01:06 -04:00
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:
1 parent
15750949dd
commit
23f093fcc1
16 files changed
+671
-25
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>>
|
||||
{
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user