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;