mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-21 16:01:36 -04:00
fix(core): move an untouched trailing sheet to new nest defaults
Changing the nest defaults (Nest Info, Set as default, Edit Plate on an empty sheet) left the editor's trailing empty sheet on the old size and quadrant, so the writer's clamp-preserving empty-sheet rule saved it as a stale extra sheet. PlateManager.ChangePlateDefaults wraps a defaults edit: a trailing empty, cutoff-free sheet that exactly matched the old defaults (and followed their rail with no removed clamps or explicit zones) takes the new defaults; every other empty sheet keeps its configuration and is still written. PM cade6901-5f4a-4503-85bf-0ea681d9ca7f
This commit is contained in:
1 parent
6b995ac40a
commit
6fc1a0ebdc
6 files changed
+397
-8
No files matched your search
@@ -119,6 +119,50 @@ namespace OpenNest
|
||||
SubscribeToTailPlates();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a nest-defaults edit, moving only an untouched trailing empty sheet
|
||||
/// that matched the old defaults to the new defaults.
|
||||
/// </summary>
|
||||
public void ChangePlateDefaults(Action changeDefaults)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(changeDefaults);
|
||||
var defaults = nest.PlateDefaults;
|
||||
var trailing = Count > 0 ? nest.Plates[^1] : null;
|
||||
// Exact configuration equality, not geometric tolerance: even a small edit
|
||||
// makes this configured stock rather than the untouched workspace sentinel.
|
||||
var followsDefaults = trailing != null
|
||||
&& trailing.Parts.Count == 0
|
||||
&& trailing.CutOffs.Count == 0
|
||||
&& trailing.Size.Width == defaults.Size.Width
|
||||
&& trailing.Size.Length == defaults.Size.Length
|
||||
&& trailing.Quadrant == defaults.Quadrant
|
||||
&& trailing.EdgeSpacing.Left == defaults.EdgeSpacing.Left
|
||||
&& trailing.EdgeSpacing.Bottom == defaults.EdgeSpacing.Bottom
|
||||
&& trailing.EdgeSpacing.Right == defaults.EdgeSpacing.Right
|
||||
&& trailing.EdgeSpacing.Top == defaults.EdgeSpacing.Top
|
||||
&& trailing.PartSpacing == defaults.PartSpacing
|
||||
&& trailing.RemovedClamps.Count == 0
|
||||
&& Equals(trailing.ClampRail, defaults.ClampRail)
|
||||
&& (defaults.ClampRail == null
|
||||
? trailing.ClampZones.Count == 0
|
||||
: defaults.ClampRail.Matches(trailing));
|
||||
|
||||
changeDefaults();
|
||||
|
||||
if (!followsDefaults)
|
||||
return;
|
||||
|
||||
defaults = nest.PlateDefaults;
|
||||
trailing.Size = defaults.Size;
|
||||
trailing.Quadrant = defaults.Quadrant;
|
||||
trailing.EdgeSpacing = defaults.EdgeSpacing;
|
||||
trailing.PartSpacing = defaults.PartSpacing;
|
||||
trailing.SetClampRail(defaults.ClampRail);
|
||||
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||
if (ReferenceEquals(CurrentPlate, trailing))
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
public void BeginBatch()
|
||||
{
|
||||
batching = true;
|
||||
|
||||
@@ -92,6 +92,109 @@ public class ClampRailPersistenceTests
|
||||
Assert.Same(reopened.PlateDefaults.ClampRail, kept.ClampRail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedDefaultsMoveUntouchedSentinelAndSaveNoEmptySheets()
|
||||
{
|
||||
var nest = new Nest("railed");
|
||||
nest.PlateDefaults.Size = new Size(48, 96);
|
||||
nest.PlateDefaults.Quadrant = 1;
|
||||
nest.SetClampRail(Machine());
|
||||
using var manager = new PlateManager(nest);
|
||||
manager.EnsureSentinel();
|
||||
var sentinel = Assert.Single(nest.Plates);
|
||||
|
||||
manager.ChangePlateDefaults(() =>
|
||||
{
|
||||
nest.PlateDefaults.Size = new Size(48, 60);
|
||||
nest.PlateDefaults.Quadrant = 3;
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(1, 2, 3, 4);
|
||||
nest.PlateDefaults.PartSpacing = 0.5;
|
||||
});
|
||||
manager.EnsureSentinel();
|
||||
|
||||
Assert.Same(sentinel, Assert.Single(nest.Plates));
|
||||
using var stream = Write(nest);
|
||||
using var json = JsonDocument.Parse(ReadNestJson(stream));
|
||||
Assert.Equal(0, json.RootElement.GetProperty("plates").GetArrayLength());
|
||||
stream.Position = 0;
|
||||
var reopened = new NestReader(stream).Read();
|
||||
Assert.Empty(reopened.Plates);
|
||||
using var reopenedManager = new PlateManager(reopened);
|
||||
reopenedManager.EnsureSentinel();
|
||||
var newSentinel = Assert.Single(reopened.Plates);
|
||||
Assert.Equal(new Size(48, 60), newSentinel.Size);
|
||||
Assert.Equal(3, newSentinel.Quadrant);
|
||||
Assert.Equal(new Spacing(1, 2, 3, 4), newSentinel.EdgeSpacing);
|
||||
Assert.Equal(0.5, newSentinel.PartSpacing);
|
||||
Assert.Same(reopened.PlateDefaults.ClampRail, newSentinel.ClampRail);
|
||||
Assert.True(newSentinel.ClampRail.Matches(newSentinel));
|
||||
Assert.Equal(4, newSentinel.ClampZones.Count);
|
||||
Assert.Equal(53.25, newSentinel.ClampZones[0].Bounds.X);
|
||||
Assert.Equal(47.375, newSentinel.ClampZones[0].Bounds.Y);
|
||||
using var secondSave = Write(reopened);
|
||||
using var secondJson = JsonDocument.Parse(ReadNestJson(secondSave));
|
||||
Assert.Equal(0, secondJson.RootElement.GetProperty("plates").GetArrayLength());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("edited")]
|
||||
[InlineData("middle")]
|
||||
[InlineData("removed")]
|
||||
[InlineData("cutOff")]
|
||||
[InlineData("explicit")]
|
||||
public void ChangedDefaultsPreserveAndWriteConfiguredEmptySheets(string configuration)
|
||||
{
|
||||
var nest = RailNest(withEmptyTail: false);
|
||||
var plate = nest.CreatePlate();
|
||||
switch (configuration)
|
||||
{
|
||||
case "edited":
|
||||
plate.Size = new Size(48, 84);
|
||||
plate.Quadrant = 2;
|
||||
break;
|
||||
case "middle":
|
||||
var separator = nest.CreatePlate();
|
||||
separator.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
|
||||
nest.CreatePlate();
|
||||
break;
|
||||
case "removed":
|
||||
plate.SetClampRail(nest.PlateDefaults.ClampRail, new[] { "clamp-1" });
|
||||
break;
|
||||
case "cutOff":
|
||||
plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
|
||||
break;
|
||||
case "explicit":
|
||||
plate.SetClampRail(null!);
|
||||
plate.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1)));
|
||||
break;
|
||||
}
|
||||
var oldSize = plate.Size;
|
||||
var oldQuadrant = plate.Quadrant;
|
||||
var oldZones = plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)).ToArray();
|
||||
using var manager = new PlateManager(nest);
|
||||
|
||||
manager.ChangePlateDefaults(() =>
|
||||
{
|
||||
nest.PlateDefaults.Size = new Size(48, 60);
|
||||
nest.PlateDefaults.Quadrant = 3;
|
||||
});
|
||||
|
||||
Assert.Equal(oldSize, plate.Size);
|
||||
Assert.Equal(oldQuadrant, plate.Quadrant);
|
||||
var reopened = RoundTrip(nest);
|
||||
Assert.Equal(configuration == "middle" ? 3 : 2, reopened.Plates.Count);
|
||||
var kept = reopened.Plates[1];
|
||||
Assert.Equal(oldSize, kept.Size);
|
||||
Assert.Equal(oldQuadrant, kept.Quadrant);
|
||||
Assert.Equal(oldZones, kept.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)));
|
||||
if (configuration == "removed")
|
||||
Assert.Equal(new[] { "clamp-1" }, kept.RemovedClamps);
|
||||
if (configuration == "cutOff")
|
||||
Assert.Single(kept.CutOffs);
|
||||
if (configuration == "explicit")
|
||||
Assert.Null(kept.ClampRail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavedZonesThatDifferFromTheRailArePreservedExactly()
|
||||
{
|
||||
|
||||
@@ -240,8 +240,9 @@ namespace OpenNest.IO
|
||||
var plate = nest.Plates[i];
|
||||
|
||||
// An empty plate is omitted unless that would lose a clamp configuration the
|
||||
// saved defaults cannot recreate; a trailing empty sheet that still matches
|
||||
// the defaults is dropped.
|
||||
// saved defaults cannot recreate. PlateManager moves an untouched trailing
|
||||
// sentinel to new defaults during an edit, so it remains omitted; configured
|
||||
// nondefault clamp sheets are still written.
|
||||
if (
|
||||
plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
|
||||
&& plate.CutOffs.Count == 0
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class PlateManagerDefaultsTests
|
||||
{
|
||||
private static Nest CreateNest(bool withRail = true)
|
||||
{
|
||||
var nest = new Nest();
|
||||
nest.PlateDefaults.Size = new Size(48, 96);
|
||||
nest.PlateDefaults.Quadrant = 1;
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(1, 2, 3, 4);
|
||||
nest.PlateDefaults.PartSpacing = 0.25;
|
||||
if (withRail)
|
||||
nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 81.0 }));
|
||||
return nest;
|
||||
}
|
||||
|
||||
private static void ChangeDefaults(Nest nest)
|
||||
{
|
||||
nest.PlateDefaults.Size = new Size(48, 60);
|
||||
nest.PlateDefaults.Quadrant = 3;
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(5, 6, 7, 8);
|
||||
nest.PlateDefaults.PartSpacing = 0.5;
|
||||
}
|
||||
|
||||
private static void AssertMatchesDefaults(Nest nest, Plate plate)
|
||||
{
|
||||
Assert.Equal(nest.PlateDefaults.Size, plate.Size);
|
||||
Assert.Equal(nest.PlateDefaults.Quadrant, plate.Quadrant);
|
||||
Assert.Equal(nest.PlateDefaults.EdgeSpacing, plate.EdgeSpacing);
|
||||
Assert.Equal(nest.PlateDefaults.PartSpacing, plate.PartSpacing);
|
||||
Assert.Same(nest.PlateDefaults.ClampRail, plate.ClampRail);
|
||||
Assert.Empty(plate.RemovedClamps);
|
||||
if (plate.ClampRail == null)
|
||||
Assert.Empty(plate.ClampZones);
|
||||
else
|
||||
Assert.True(plate.ClampRail.Matches(plate));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ChangePlateDefaults_MovesUntouchedTrailingSheet(bool withRail)
|
||||
{
|
||||
var nest = CreateNest(withRail);
|
||||
using var manager = new PlateManager(nest);
|
||||
manager.EnsureSentinel();
|
||||
var sentinel = Assert.Single(nest.Plates);
|
||||
var listChanges = 0;
|
||||
var currentChanges = 0;
|
||||
manager.PlateListChanged += (_, _) => listChanges++;
|
||||
manager.CurrentPlateChanged += (_, e) =>
|
||||
{
|
||||
Assert.Same(sentinel, e.Plate);
|
||||
currentChanges++;
|
||||
};
|
||||
|
||||
manager.ChangePlateDefaults(() => ChangeDefaults(nest));
|
||||
|
||||
Assert.Same(sentinel, Assert.Single(nest.Plates));
|
||||
AssertMatchesDefaults(nest, sentinel);
|
||||
Assert.Equal(0, manager.CurrentIndex);
|
||||
Assert.Equal(1, listChanges);
|
||||
Assert.Equal(1, currentChanges);
|
||||
if (withRail)
|
||||
{
|
||||
// Independent physical oracle: Q3 origin clamp is at the sheet's upper right.
|
||||
var zone = Assert.Single(sentinel.ClampZones);
|
||||
Assert.Equal("clamp-1", zone.Id);
|
||||
Assert.Equal(53.25, zone.Bounds.X);
|
||||
Assert.Equal(47.375, zone.Bounds.Y);
|
||||
Assert.Equal(1.5, zone.Bounds.Length);
|
||||
Assert.Equal(0.625, zone.Bounds.Width);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ChangePlateDefaults_ReappliesTheNewRail(bool removeRail)
|
||||
{
|
||||
var nest = CreateNest();
|
||||
var sentinel = nest.CreatePlate();
|
||||
using var manager = new PlateManager(nest);
|
||||
var newRail = removeRail ? null : new ClampRail(2, 1, new[] { 10.0 });
|
||||
|
||||
manager.ChangePlateDefaults(() =>
|
||||
{
|
||||
ChangeDefaults(nest);
|
||||
nest.PlateDefaults.ClampRail = newRail;
|
||||
});
|
||||
|
||||
AssertMatchesDefaults(nest, sentinel);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("width")]
|
||||
[InlineData("length")]
|
||||
[InlineData("quadrant")]
|
||||
[InlineData("left")]
|
||||
[InlineData("bottom")]
|
||||
[InlineData("right")]
|
||||
[InlineData("top")]
|
||||
[InlineData("partSpacing")]
|
||||
[InlineData("removed")]
|
||||
[InlineData("explicit")]
|
||||
[InlineData("detachedMatchingZones")]
|
||||
[InlineData("differentRail")]
|
||||
[InlineData("editedRailZones")]
|
||||
[InlineData("cutOff")]
|
||||
[InlineData("part")]
|
||||
public void ChangePlateDefaults_PreservesEditedOrUsedTrailingSheet(string edit)
|
||||
{
|
||||
var nest = CreateNest();
|
||||
var plate = nest.CreatePlate();
|
||||
switch (edit)
|
||||
{
|
||||
// Even sub-tolerance changes are edits, not untouched defaults.
|
||||
case "width": plate.Size = new Size(48 + 1e-9, 96); break;
|
||||
case "length": plate.Size = new Size(48, 96 + 1e-9); break;
|
||||
case "quadrant": plate.Quadrant = 2; break;
|
||||
case "left": plate.EdgeSpacing.Left += 1e-9; break;
|
||||
case "bottom": plate.EdgeSpacing.Bottom += 1e-9; break;
|
||||
case "right": plate.EdgeSpacing.Right += 1e-9; break;
|
||||
case "top": plate.EdgeSpacing.Top += 1e-9; break;
|
||||
case "partSpacing": plate.PartSpacing += 1e-9; break;
|
||||
case "removed": plate.SetClampRail(nest.PlateDefaults.ClampRail, new[] { "clamp-1" }); break;
|
||||
case "explicit":
|
||||
plate.SetClampRail(null!);
|
||||
plate.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1)));
|
||||
break;
|
||||
case "detachedMatchingZones":
|
||||
var zones = plate.ClampZones.ToArray();
|
||||
plate.SetClampRail(null!);
|
||||
foreach (var zone in zones)
|
||||
plate.ClampZones.Add(zone);
|
||||
break;
|
||||
case "differentRail":
|
||||
plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 81.0 }, removalMinutes: 8));
|
||||
break;
|
||||
case "editedRailZones":
|
||||
plate.ClampZones.Clear();
|
||||
plate.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1)));
|
||||
break;
|
||||
case "cutOff": plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); break;
|
||||
case "part": plate.Parts.Add(new Part(new Drawing("block", new Program()))); break;
|
||||
}
|
||||
var size = plate.Size;
|
||||
var quadrant = plate.Quadrant;
|
||||
var spacing = plate.EdgeSpacing;
|
||||
var partSpacing = plate.PartSpacing;
|
||||
var rail = plate.ClampRail;
|
||||
var removed = plate.RemovedClamps.ToArray();
|
||||
var savedZones = plate.ClampZones.ToArray();
|
||||
var parts = plate.Parts.ToArray();
|
||||
var cutOffs = plate.CutOffs.ToArray();
|
||||
using var manager = new PlateManager(nest);
|
||||
var notifications = 0;
|
||||
manager.CurrentPlateChanged += (_, _) => notifications++;
|
||||
manager.PlateListChanged += (_, _) => notifications++;
|
||||
|
||||
manager.ChangePlateDefaults(() => ChangeDefaults(nest));
|
||||
|
||||
Assert.Equal(new Size(48, 60), nest.PlateDefaults.Size);
|
||||
Assert.Same(plate, Assert.Single(nest.Plates));
|
||||
Assert.Equal(size, plate.Size);
|
||||
Assert.Equal(quadrant, plate.Quadrant);
|
||||
Assert.Equal(spacing, plate.EdgeSpacing);
|
||||
Assert.Equal(partSpacing, plate.PartSpacing);
|
||||
Assert.Same(rail, plate.ClampRail);
|
||||
Assert.Equal(removed, plate.RemovedClamps);
|
||||
Assert.Equal(savedZones, plate.ClampZones);
|
||||
Assert.Equal(parts, plate.Parts);
|
||||
Assert.Equal(cutOffs, plate.CutOffs);
|
||||
Assert.Equal(0, notifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangePlateDefaults_PreservesMiddleEmptySheetAndCurrentSelection()
|
||||
{
|
||||
var nest = CreateNest();
|
||||
var middle = nest.CreatePlate();
|
||||
var sentinel = nest.CreatePlate();
|
||||
using var manager = new PlateManager(nest);
|
||||
manager.LoadFirst();
|
||||
var currentChanges = 0;
|
||||
manager.CurrentPlateChanged += (_, _) => currentChanges++;
|
||||
|
||||
manager.ChangePlateDefaults(() => ChangeDefaults(nest));
|
||||
|
||||
Assert.Equal(2, nest.Plates.Count);
|
||||
Assert.Equal(new Size(48, 96), middle.Size);
|
||||
Assert.Equal(1, middle.Quadrant);
|
||||
Assert.Equal(new Spacing(1, 2, 3, 4), middle.EdgeSpacing);
|
||||
Assert.Equal(0.25, middle.PartSpacing);
|
||||
Assert.Equal(2, middle.ClampZones.Count);
|
||||
AssertMatchesDefaults(nest, sentinel);
|
||||
Assert.Same(middle, manager.CurrentPlate);
|
||||
Assert.Equal(0, currentChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangePlateDefaults_SetFromExistingMovesOnlyTheSentinel()
|
||||
{
|
||||
var nest = CreateNest();
|
||||
var selected = nest.CreatePlate();
|
||||
selected.Size = new Size(48, 60);
|
||||
selected.Quadrant = 3;
|
||||
selected.EdgeSpacing = new Spacing(5, 6, 7, 8);
|
||||
selected.PartSpacing = 0.5;
|
||||
var sentinel = nest.CreatePlate();
|
||||
using var manager = new PlateManager(nest);
|
||||
|
||||
manager.ChangePlateDefaults(() => nest.PlateDefaults.SetFromExisting(selected));
|
||||
|
||||
AssertMatchesDefaults(nest, sentinel);
|
||||
AssertMatchesDefaults(nest, selected);
|
||||
Assert.Equal(2, nest.Plates.Count);
|
||||
Assert.Same(selected, manager.CurrentPlate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangePlateDefaults_WithoutPlatesStillAppliesDefaults()
|
||||
{
|
||||
var nest = CreateNest();
|
||||
using var manager = new PlateManager(nest);
|
||||
|
||||
manager.ChangePlateDefaults(() => ChangeDefaults(nest));
|
||||
|
||||
Assert.Empty(nest.Plates);
|
||||
manager.EnsureSentinel();
|
||||
AssertMatchesDefaults(nest, Assert.Single(nest.Plates));
|
||||
}
|
||||
}
|
||||
@@ -739,7 +739,7 @@ namespace OpenNest.Forms
|
||||
|
||||
if (form.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
{
|
||||
form.SaveNestInfo(Nest);
|
||||
PlateManager.ChangePlateDefaults(() => form.SaveNestInfo(Nest));
|
||||
drawingListBox1.Units = Nest.Units;
|
||||
drawingListBox1.Invalidate();
|
||||
}
|
||||
@@ -808,7 +808,7 @@ namespace OpenNest.Forms
|
||||
|
||||
public void SetCurrentPlateAsNestDefault()
|
||||
{
|
||||
Nest.PlateDefaults.SetFromExisting(PlateView.Plate);
|
||||
PlateManager.ChangePlateDefaults(() => Nest.PlateDefaults.SetFromExisting(PlateView.Plate));
|
||||
}
|
||||
|
||||
public void EditPlate()
|
||||
@@ -827,7 +827,7 @@ namespace OpenNest.Forms
|
||||
drawingListBox1.Refresh();
|
||||
|
||||
if (PlateView.Plate.Parts.Count == 0)
|
||||
Nest.PlateDefaults.SetFromExisting(PlateView.Plate);
|
||||
PlateManager.ChangePlateDefaults(() => Nest.PlateDefaults.SetFromExisting(PlateView.Plate));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -92,9 +92,14 @@ An empty plate (no parts, no cut-offs) is omitted from the file unless that
|
||||
would lose clamp zones: it is omitted like any other empty plate only when a
|
||||
new plate from the saved defaults would recreate it (same size and quadrant,
|
||||
zones the saved rail recreates), which keeps the editor's trailing empty sheet
|
||||
out of the file while it still matches the defaults. An empty plate with other
|
||||
zones, or a rail-following one whose size or quadrant differs from the defaults
|
||||
(including a trailing sheet created before the defaults changed), is written.
|
||||
out of the file while it matches the defaults. Nest-defaults edits move only an
|
||||
untouched trailing empty, cutoff-free sheet to the new size, quadrant, edge and
|
||||
part spacing, re-resolving its rail. It must have exactly matched the old defaults
|
||||
and followed their rail with no removed clamps or explicit zones (or have had no
|
||||
rail and no zones when the defaults had none). Edited sheets, middle empty sheets,
|
||||
and sheets with parts, cut-offs, removed clamps or explicit zones are not changed.
|
||||
An empty plate with other zones, or a rail-following one whose size or quadrant
|
||||
differs from the saved defaults, is still written.
|
||||
|
||||
## Drawing edits
|
||||
|
||||
|
||||
Reference in new issue
Block a user