diff --git a/OpenNest.IO.Tests/SheetOutlinePersistenceTests.cs b/OpenNest.IO.Tests/SheetOutlinePersistenceTests.cs
new file mode 100644
index 00000000..48ffb5b6
--- /dev/null
+++ b/OpenNest.IO.Tests/SheetOutlinePersistenceTests.cs
@@ -0,0 +1,118 @@
+using System.Text.Json.Nodes;
+using OpenNest.Geometry;
+using static OpenNest.IO.Tests.NestArchiveJson;
+
+namespace OpenNest.IO.Tests;
+
+///
+/// A plate's survives the .nest round trip under an optional
+/// outline 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.
+///
+public class SheetOutlinePersistenceTests
+{
+ /// A 96 x 48 sheet missing its upper-right 48 x 24 corner, with a burned 4 x 4 patch.
+ 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);
+ }
+}
diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs
index d5e9775e..2d2b595e 100644
--- a/OpenNest.IO/NestFormat.cs
+++ b/OpenNest.IO/NestFormat.cs
@@ -696,6 +696,145 @@ namespace OpenNest.IO
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(WriteOnlyConverter))]
public ExpectedRemnantDto ExpectedRemnant { get; init; }
+
+ ///
+ /// The sheet's material when it is not a rectangle (), 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 ).
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonConverter(typeof(LenientSheetOutlineConverter))]
+ public SheetOutlineDto Outline { get; init; }
+ }
+
+ /// A non-rectangular sheet: its vertices and the vertices of each obstacle.
+ public record SheetOutlineDto
+ {
+ public List Points { get; init; } = new();
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public List> Obstacles { get; init; }
+
+ /// Why the value could not be read, when it was malformed; never written.
+ [JsonIgnore]
+ public string Problem { get; init; }
+ }
+
+ public record PointDto
+ {
+ public double X { get; init; }
+ public double Y { get; init; }
+ }
+
+ ///
+ /// Reads a sheet outline leniently: a value that is not an object, a missing or non-array
+ /// points, or a vertex that is not an object with numeric x and y
+ /// yields a DTO carrying the instead of failing the
+ /// file; a non-array obstacle list or entry is reported the same way. Geometry is not
+ /// checked here: does that when the plate is built.
+ ///
+ public sealed class LenientSheetOutlineConverter : JsonConverter
+ {
+ 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> 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>();
+ 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 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();
+ 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();
+ }
}
/// A plate's expected rectangular drop, with the shorter side.
diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs
index 3965773c..fa64dc37 100644
--- a/OpenNest.IO/NestReader.cs
+++ b/OpenNest.IO/NestReader.cs
@@ -530,6 +530,33 @@ namespace OpenNest.IO
/// missing or invalid removal-minutes value falls back to .
///
/// The saved support bed, or null when absent or invalid (a bad bed never fails the nest).
+ ///
+ /// 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.
+ ///
+ 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.
diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs
index ed27b15b..6664aecb 100644
--- a/OpenNest.IO/NestWriter.cs
+++ b/OpenNest.IO/NestWriter.cs
@@ -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
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index 0265a775..71f7dc17 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -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
diff --git a/docs/undo-redo.md b/docs/undo-redo.md
index e56b112f..61525a91 100644
--- a/docs/undo-redo.md
+++ b/docs/undo-redo.md
@@ -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;