feat(io): persist a plate's sheet outline

An optional per-plate `outline` key holds the vertices and obstacles;
rectangular plates write nothing new, an empty outlined plate is kept,
and a malformed value loads the saved size as a rectangle with a reader
warning. Undo snapshots restore it through the same path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Fable 5.1 committed 2026-10-28 22:16:36 -04:00
1 parent 672ed09074
commit fa7a1f0fce
6 files changed
+319 -2

No files matched your search

@@ -0,0 +1,118 @@
using System.Text.Json.Nodes;
using OpenNest.Geometry;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
/// <summary>
/// A plate's <see cref="SheetOutline"/> survives the .nest round trip under an optional
/// <c>outline</c> key; files without it load rectangles unchanged, an empty outlined plate is
/// kept, and a malformed value loads as a rectangle with a reader warning.
/// </summary>
public class SheetOutlinePersistenceTests
{
/// <summary>A 96 x 48 sheet missing its upper-right 48 x 24 corner, with a burned 4 x 4 patch.</summary>
private static SheetOutline Ell() => new(
new[] { new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(48, 24), new Vector(48, 48), new Vector(0, 48) },
new[] { new[] { new Vector(10, 10), new Vector(14, 10), new Vector(14, 14), new Vector(10, 14) } });
private static byte[] Write(Nest nest)
{
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
return stream.ToArray();
}
private static Nest OutlinedNest()
{
var nest = new Nest("outlined");
nest.Plates.Add(new Plate { Outline = Ell(), Quadrant = 3, EdgeSpacing = new Spacing(1, 2, 3, 4) });
return nest;
}
[Fact]
public void OutlineRoundTripsWithObstaclesAndKeepsAnEmptyPlate()
{
var (reopened, warnings) = RoundTrip(Write(OutlinedNest()));
Assert.Empty(warnings);
var plate = Assert.Single(reopened.Plates);
Assert.Equal(Ell(), plate.Outline);
Assert.Equal(48, plate.Size.Width);
Assert.Equal(96, plate.Size.Length);
Assert.Equal(3, plate.Quadrant);
Assert.Equal(96 * 48 - 48 * 24 - 16, plate.Area(), 6);
}
[Fact]
public void FileShapeIsPointsAndObstaclesInPlateCoordinates()
{
var plates = NestJson(Write(OutlinedNest()))["plates"]!.AsArray();
var outline = Assert.Single(plates)!["outline"]!.AsObject();
var points = outline["points"]!.AsArray();
Assert.Equal(6, points.Count);
Assert.Equal(0, (double)points[0]!["x"]!);
Assert.Equal(0, (double)points[0]!["y"]!);
Assert.Equal(96, (double)points[1]!["x"]!);
var obstacle = Assert.Single(outline["obstacles"]!.AsArray())!.AsArray();
Assert.Equal(4, obstacle.Count);
Assert.Equal(10, (double)obstacle[0]!["x"]!);
}
[Fact]
public void RectangularPlatesWriteNoOutlineKeyAndLoadWithout()
{
var nest = new Nest("plain");
nest.Plates.Add(new Plate(48, 96));
nest.Plates[0].CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
var archive = Write(nest);
var json = NestJson(archive);
Assert.Null(Assert.Single(json["plates"]!.AsArray())!["outline"]);
var (reopened, warnings) = RoundTrip(archive);
Assert.Empty(warnings);
Assert.Null(Assert.Single(reopened.Plates).Outline);
}
[Theory]
[InlineData("\"not an object\"", "not an object")]
[InlineData("{}", "'points' is missing")]
[InlineData("{\"points\": 5}", "not an array")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": \"ten\", \"y\": 0}, {\"x\": 0, \"y\": 10}]}", "vertex 2")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 0}]}", "three distinct vertices")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 10}, {\"x\": 10, \"y\": 0}, {\"x\": 0, \"y\": 10}]}", "no area")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 0}, {\"x\": 10, \"y\": 10}, {\"x\": 2, \"y\": -2}]}", "simple polygon")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 0}, {\"x\": 10, \"y\": 10}], \"obstacles\": 3}", "'obstacles' is not an array")]
public void MalformedOutlineLoadsARectangleWithAWarning(string value, string problem)
{
var archive = Write(OutlinedNest());
var json = NestJson(archive);
var plate = Assert.Single(json["plates"]!.AsArray())!.AsObject();
plate["outline"] = JsonNode.Parse(value);
var (reopened, warnings) = RoundTrip(ReplaceNestJson(archive, json));
var loaded = Assert.Single(reopened.Plates);
Assert.Null(loaded.Outline);
Assert.Equal(48, loaded.Size.Width);
Assert.Equal(96, loaded.Size.Length);
var warning = Assert.Single(warnings);
Assert.Contains("Plate 1", warning);
Assert.Contains(problem, warning);
}
[Fact]
public void SnapshotRestoresTheOutline()
{
var nest = OutlinedNest();
var snapshot = NestSnapshot.Capture(nest);
nest.Plates[0].Outline = null;
nest.Plates[0].Size = new Size(60, 120);
var restored = snapshot.Restore(nest);
Assert.Equal(Ell(), Assert.Single(restored.Plates).Outline);
Assert.Equal(96, restored.Plates[0].Size.Length);
}
}
+139
View File
@@ -696,6 +696,145 @@ namespace OpenNest.IO
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(WriteOnlyConverter<ExpectedRemnantDto>))]
public ExpectedRemnantDto ExpectedRemnant { get; init; }
/// <summary>
/// The sheet's material when it is not a rectangle (<see cref="SheetOutline"/>), in
/// plate coordinates; omitted for a rectangular sheet, which is every sheet in older
/// files. Read leniently: a wrongly shaped value loads as a rectangle with a reader
/// warning (see <see cref="NestReader.Warnings"/>).
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(LenientSheetOutlineConverter))]
public SheetOutlineDto Outline { get; init; }
}
/// <summary>A non-rectangular sheet: its vertices and the vertices of each obstacle.</summary>
public record SheetOutlineDto
{
public List<PointDto> Points { get; init; } = new();
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<List<PointDto>> Obstacles { get; init; }
/// <summary>Why the value could not be read, when it was malformed; never written.</summary>
[JsonIgnore]
public string Problem { get; init; }
}
public record PointDto
{
public double X { get; init; }
public double Y { get; init; }
}
/// <summary>
/// Reads a sheet outline leniently: a value that is not an object, a missing or non-array
/// <c>points</c>, or a vertex that is not an object with numeric <c>x</c> and <c>y</c>
/// yields a DTO carrying the <see cref="SheetOutlineDto.Problem"/> instead of failing the
/// file; a non-array obstacle list or entry is reported the same way. Geometry is not
/// checked here: <see cref="SheetOutline"/> does that when the plate is built.
/// </summary>
public sealed class LenientSheetOutlineConverter : JsonConverter<SheetOutlineDto>
{
public override SheetOutlineDto Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Null)
return null;
if (root.ValueKind != JsonValueKind.Object)
return new SheetOutlineDto { Problem = "it is not an object" };
if (!root.TryGetProperty("points", out var list))
return new SheetOutlineDto { Problem = "'points' is missing" };
if (!TryPointList(list, out var points, out var problem))
return new SheetOutlineDto { Problem = problem };
List<List<PointDto>> obstacles = null;
if (root.TryGetProperty("obstacles", out var entries) && entries.ValueKind != JsonValueKind.Null)
{
if (entries.ValueKind != JsonValueKind.Array)
return new SheetOutlineDto { Problem = "'obstacles' is not an array" };
obstacles = new List<List<PointDto>>();
var index = 0;
foreach (var entry in entries.EnumerateArray())
{
index++;
if (!TryPointList(entry, out var obstacle, out problem))
return new SheetOutlineDto { Problem = $"obstacle {index}: {problem}" };
obstacles.Add(obstacle);
}
}
return new SheetOutlineDto { Points = points, Obstacles = obstacles };
}
private static bool TryPointList(JsonElement list, out List<PointDto> points, out string problem)
{
points = null;
problem = null;
if (list.ValueKind != JsonValueKind.Array)
{
problem = "the vertex list is not an array";
return false;
}
points = new List<PointDto>();
foreach (var element in list.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.Object
|| !TryNumber(element, "x", out var x)
|| !TryNumber(element, "y", out var y))
{
problem = $"vertex {points.Count + 1} is not an object with numeric x and y";
return false;
}
points.Add(new PointDto { X = x, Y = y });
}
return true;
}
public override void Write(
Utf8JsonWriter writer,
SheetOutlineDto value,
JsonSerializerOptions options
)
{
writer.WriteStartObject();
writer.WriteStartArray("points");
foreach (var point in value.Points)
WritePoint(writer, point);
writer.WriteEndArray();
if (value.Obstacles is { Count: > 0 })
{
writer.WriteStartArray("obstacles");
foreach (var obstacle in value.Obstacles)
{
writer.WriteStartArray();
foreach (var point in obstacle)
WritePoint(writer, point);
writer.WriteEndArray();
}
writer.WriteEndArray();
}
writer.WriteEndObject();
}
private static bool TryNumber(JsonElement point, string name, out double value)
{
value = 0;
return point.TryGetProperty(name, out var element)
&& element.ValueKind == JsonValueKind.Number
&& element.TryGetDouble(out value);
}
private static void WritePoint(Utf8JsonWriter writer, PointDto point)
{
writer.WriteStartObject();
writer.WriteNumber("x", point.X);
writer.WriteNumber("y", point.Y);
writer.WriteEndObject();
}
}
/// <summary>A plate's expected rectangular drop, with <see cref="Width"/> the shorter side.</summary>
+28
View File
@@ -530,6 +530,33 @@ namespace OpenNest.IO
/// missing or invalid removal-minutes value falls back to <see cref="ClampRail.DefaultRemovalMinutes"/>.
/// </summary>
/// <summary>The saved support bed, or null when absent or invalid (a bad bed never fails the nest).</summary>
/// <summary>
/// The plate's sheet outline, or null (a rectangle) when the file has none or the value
/// is malformed or not a simple polygon; the latter adds a warning naming the plate.
/// </summary>
private SheetOutline ReadOutline(SheetOutlineDto dto, int plateId)
{
if (dto == null)
return null;
string problem = dto.Problem;
if (problem == null)
{
try
{
return new SheetOutline(
dto.Points.Select(p => new Vector(p.X, p.Y)),
dto.Obstacles?.Select(o => o.Select(p => new Vector(p.X, p.Y))));
}
catch (ArgumentException exception)
{
problem = exception.Message;
}
}
warnings.Add($"Plate {plateId}: its sheet outline could not be read ({problem}); "
+ "the plate loads as a rectangle of its saved size.");
return null;
}
private static SupportBed ReadSupportBed(SupportBedDto dto)
{
if (dto == null)
@@ -666,6 +693,7 @@ namespace OpenNest.IO
);
plate.GrainAngle = p.GrainAngle;
plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
plate.Outline = ReadOutline(p.Outline, p.Id);
// Clamp zones: older files have no key at all; an individually invalid
// entry is dropped rather than poisoning the whole nest.
+15
View File
@@ -353,6 +353,7 @@ namespace OpenNest.IO
!KeepEmptyPlates
&& plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
&& plate.CutOffs.Count == 0
&& plate.Outline == null
&& (
(plate.ClampZones.Count == 0 && plate.RemovedClamps.Count == 0)
|| DefaultsRecreateClamps(plate)
@@ -461,12 +462,26 @@ namespace OpenNest.IO
: ToDto(plate.ClampRail),
StockItemCodes = plate.StockItemCodes.Count == 0 ? null : plate.StockItemCodes.ToList(),
ExpectedRemnant = ToDto(PlateRemnant.Expected(plate, nest.MinimumRemnantDimension)),
Outline = ToDto(plate.Outline),
}
);
}
return list;
}
private static SheetOutlineDto ToDto(SheetOutline outline) =>
outline == null
? null
: new SheetOutlineDto
{
Points = outline.Points.Select(p => new PointDto { X = p.X, Y = p.Y }).ToList(),
Obstacles = outline.Obstacles.Count == 0
? null
: outline.Obstacles
.Select(o => o.Select(p => new PointDto { X = p.X, Y = p.Y }).ToList())
.ToList(),
};
private static ExpectedRemnantDto ToDto(PlateRemnant remnant) =>
remnant == null
? null
+17
View File
@@ -203,6 +203,23 @@ records the minimum remnant size it last used, and older files load 12). A cut-o
drop below it falls through to the offcut. Interior holes and irregular leftovers
are not reported.
## Sheet outline
A plate that is not a rectangle (see [irregular stock](irregular-stock.md)) carries an
optional `outline`: `points`, the vertices of its material as `{ "x", "y" }` objects in plate
coordinates (the sheet's lower-left frame, as `clamps`), counterclockwise from the lowest-left
vertex, and optional `obstacles`, a list of such vertex lists for areas inside it that must stay
empty (omitted when there are none). The plate's `size` is still written and is the outline's
bounding box. The key is omitted for a rectangular sheet, so files without outlines are
unchanged, and older readers ignore it and load the rectangle of the saved size.
Read leniently: a value that is not an object, a missing or non-array `points`, a vertex that is
not an object with numeric `x` and `y`, a non-array `obstacles`, or a polygon `SheetOutline`
refuses (fewer than three distinct vertices, no area, self-crossing) loads the plate as a
rectangle of its saved size and adds a `NestReader.Warnings` entry naming the plate; the rest
of the nest loads. An empty plate with an outline is always written: new-plate defaults cannot
recreate it. `expectedRemnant` is never written for an outlined plate.
## Support grid
`plateDefaults.supportBed` optionally stores the machine support bed every sheet of
+2 -2
View File
@@ -21,8 +21,8 @@ Everything stored in the nest:
- parts: add, delete, cut, move, drag, nudge, rotate, align, push, center, expand spacing, clone;
- placement: fill, fill area, pattern tile, best-fit placement, Auto Nest, Clear Nest;
- plates: add, remove, remove empty plates, Edit Plate, rotate, resize, cutting parameters,
clamps and clamp rail, the support grid, the nest's default plate;
- plates: add, remove, remove empty plates, Edit Plate (its sheet outline included), rotate,
resize, cutting parameters, clamps and clamp rail, the support grid, the nest's default plate;
- cutting: manual and automatic cut-offs and their drags, Auto Sequence, Set Sequence,
Sequence by Path, placing and removing lead-ins, Plan Cutting's Apply, clearing lead-ins and
cut-offs;