mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-01 15:51:37 -05:00
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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user