From e4f19df15e526400dfd1a96faf93f1f049f6c029 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 13:48:17 +0200 Subject: [PATCH 1/5] Shape caches: behavioural tests (CenterOfMass and AllCoordinates stale after an edit; Resize fill kept) Builds; ShapeCacheTests.CenterOfMassFollowsACellCleared and ResizeRecountsEveryCoordinateRead fail at this commit by design (fixed in the next). Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ShapeCacheTests.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/ShapeCacheTests.cs diff --git a/tests/Aetheria.Shared.Tests/ShapeCacheTests.cs b/tests/Aetheria.Shared.Tests/ShapeCacheTests.cs new file mode 100644 index 000000000..f1228d425 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShapeCacheTests.cs @@ -0,0 +1,53 @@ +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; + +// Shape's derived reads (Coordinates, AllCoordinates, CenterOfMass) are caches of Cells and follow every edit. +public sealed class ShapeCacheTests +{ + [Fact] + public void CenterOfMassFollowsACellCleared() + { + var shape = new Shape(2, 1); + shape[int2(0, 0)] = true; + shape[int2(1, 0)] = true; + Assert.Equal(0.5f, shape.CenterOfMass.x); + + shape[int2(0, 0)] = false; + + Assert.Equal(1f, shape.CenterOfMass.x); + Assert.Equal(0f, shape.CenterOfMass.y); + } + + [Fact] + public void ResizeRecountsEveryCoordinateRead() + { + var shape = new Shape(2, 1); + shape[int2(0, 0)] = true; + shape[int2(1, 0)] = true; + Assert.Equal(2, shape.AllCoordinates.Length); + Assert.Equal(2, shape.Coordinates.Length); + var before = shape.CenterOfMass; + + shape.Resize(3, 2); + + Assert.Equal(6, shape.AllCoordinates.Length); + Assert.Equal(shape.Cells.Cast().Count(c => c), shape.Coordinates.Length); + Assert.NotEqual(before, shape.CenterOfMass); + } + + [Fact] + public void ResizeKeepsExistingCellsAndFillsNewOnes() + { + // The hull drawer's width grow relies on the fill (ships-studio-footprint r1 keeps it). + var shape = new Shape(2, 1); + shape[int2(0, 0)] = true; + + shape.Resize(3, 1); + + Assert.True(shape[int2(0, 0)]); + Assert.False(shape[int2(1, 0)]); + Assert.True(shape[int2(2, 0)]); + } +} From 7a44e0c00c8e0eced8d95d79671af90b8977bebc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 13:48:35 +0200 Subject: [PATCH 2/5] Shape: one dirty flag invalidates Coordinates, AllCoordinates and CenterOfMass; Resize marks dirty Resize keeps its fill (new cells on): the hull drawer's width grow relies on it. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ItemData.cs | 38 +++++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 9fad4cfd0..1b4ea1c9f 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -60,6 +60,7 @@ public class Shape } Cells = newCells; + _dirty = true; } private static int mod(int x, int m) { @@ -85,11 +86,7 @@ public class Shape { get { - if (_dirty) - { - _cachedShapeCoordinates = EnumerateShapeCoordinates().ToArray(); - _dirty = false; - } + Refresh(); return _cachedShapeCoordinates; } } @@ -108,7 +105,14 @@ public class Shape private int2[] _cachedAllShapeCoordinates; [IgnoreMember] - public int2[] AllCoordinates => _cachedAllShapeCoordinates ?? (_cachedAllShapeCoordinates = EnumerateAllShapeCoordinates().ToArray()); + public int2[] AllCoordinates + { + get + { + Refresh(); + return _cachedAllShapeCoordinates; + } + } private IEnumerable EnumerateAllShapeCoordinates() { @@ -121,11 +125,27 @@ public class Shape } } - private float2? _centerOfMass; + private float2 _centerOfMass; [IgnoreMember] - public float2 CenterOfMass => _centerOfMass ?? (_centerOfMass = Coordinates - .Aggregate(float2.zero, (total, coord) => total + coord) / Coordinates.Length).Value; + public float2 CenterOfMass + { + get + { + Refresh(); + return _centerOfMass; + } + } + + // One dirty flag: Coordinates, AllCoordinates and CenterOfMass are caches of Cells and recompute together. + private void Refresh() + { + if (!_dirty) return; + _cachedShapeCoordinates = EnumerateShapeCoordinates().ToArray(); + _cachedAllShapeCoordinates = EnumerateAllShapeCoordinates().ToArray(); + _centerOfMass = _cachedShapeCoordinates.Aggregate(float2.zero, (total, coord) => total + coord) / _cachedShapeCoordinates.Length; + _dirty = false; + } public bool this[int2 pos] { get { return pos.x >= 0 && pos.y >= 0 && pos.x < Width && pos.y < Height && Cells[pos.x, pos.y]; } From d76b27673ad445a0d79d6569e51111ff28123dce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 13:49:45 +0200 Subject: [PATCH 3/5] HullFootprint: the one footprint cell reading and edit rule (grow, trim, refuse last cell), with tests Not yet built or run (verify job at the end). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/HullFootprint.cs | 43 ++++++++ .../ServerShared/HullFootprint.cs.meta | 11 +++ .../HullFootprintTests.cs | 99 +++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 Assets/Scripts/ServerShared/HullFootprint.cs create mode 100644 Assets/Scripts/ServerShared/HullFootprint.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/HullFootprintTests.cs diff --git a/Assets/Scripts/ServerShared/HullFootprint.cs b/Assets/Scripts/ServerShared/HullFootprint.cs new file mode 100644 index 000000000..9c3c6c3be --- /dev/null +++ b/Assets/Scripts/ServerShared/HullFootprint.cs @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using System.Linq; +using CultMath; + +// A hardpoint's schematic footprint: its Shape laid over the hull's cells at its Position, in hull orientation. +// The footprint is authored for gameplay and never derived from the model or the Blender add-on +// (schematic-and-model-are-separate-spaces). This is the one reading of footprint cells in hull space and the one +// edit rule, so any host (the Studio drawer now, a Thing host later) edits footprints the same way. +public static class HullFootprint +{ + public static IEnumerable Cells(HardpointData hardpoint) => + hardpoint.Shape.Coordinates.Select(c => c + hardpoint.Position); + + // Toggles one hull cell of the footprint at `position`. A cell outside the footprint grows it toward any edge; + // a cell inside is removed and empty border rows and columns are trimmed; every other cell keeps its hull + // coordinate. Removing the last cell is refused (false, outputs are the inputs' values). Builds a new Shape and + // never edits the one it is given. + public static bool Toggle(Shape footprint, int2 position, int2 hullCell, out Shape shape, out int2 newPosition) + { + var cells = Cells(footprint, position); + if (!cells.Remove(hullCell)) cells.Add(hullCell); + else if (cells.Count == 0) + { + shape = footprint; + newPosition = position; + return false; + } + + var min = cells.Aggregate((a, b) => new int2(math.min(a.x, b.x), math.min(a.y, b.y))); + var max = cells.Aggregate((a, b) => new int2(math.max(a.x, b.x), math.max(a.y, b.y))); + shape = new Shape(max.x - min.x + 1, max.y - min.y + 1); + foreach (var cell in cells) shape[cell - min] = true; + newPosition = min; + return true; + } + + private static HashSet Cells(Shape footprint, int2 position) => + new HashSet(footprint.Coordinates.Select(c => c + position)); +} diff --git a/Assets/Scripts/ServerShared/HullFootprint.cs.meta b/Assets/Scripts/ServerShared/HullFootprint.cs.meta new file mode 100644 index 000000000..60a662fa0 --- /dev/null +++ b/Assets/Scripts/ServerShared/HullFootprint.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e2b8c4a7d1f4a39b6c0e83f91d7a254 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/Aetheria.Shared.Tests/HullFootprintTests.cs b/tests/Aetheria.Shared.Tests/HullFootprintTests.cs new file mode 100644 index 000000000..6d36d50aa --- /dev/null +++ b/tests/Aetheria.Shared.Tests/HullFootprintTests.cs @@ -0,0 +1,99 @@ +using System.Linq; +using CultMath; +using Xunit; + +// HullFootprint: the one rule for editing a hardpoint's schematic footprint (Position + Shape together) and the +// one reading of its cells in hull space. +public sealed class HullFootprintTests +{ + private static HardpointData Hardpoint(int2 position, params int2[] cellsInShapeSpace) + { + var max = cellsInShapeSpace.Aggregate(new int2(0, 0), (a, c) => new int2(CultMath.math.max(a.x, c.x), CultMath.math.max(a.y, c.y))); + var shape = new Shape(max.x + 1, max.y + 1); + foreach (var c in cellsInShapeSpace) shape[c] = true; + return new HardpointData { Position = position, Shape = shape }; + } + + private static int2[] Hull(HardpointData hp) => HullFootprint.Cells(hp).OrderBy(c => c.y).ThenBy(c => c.x).ToArray(); + + private static HardpointData Apply(HardpointData hp, int2 hullCell) + { + Assert.True(HullFootprint.Toggle(hp.Shape, hp.Position, hullCell, out var shape, out var position)); + return new HardpointData { Position = position, Shape = shape }; + } + + [Fact] + public void CellsAreTheShapeLaidAtPosition() + { + var hp = Hardpoint(new int2(3, 3), new int2(0, 0), new int2(1, 0)); + Assert.Equal(new[] { new int2(3, 3), new int2(4, 3) }, Hull(hp)); + } + + [Fact] + public void FootprintGrowsTowardAnyEdgeAndKeepsItsCells() + { + var hp = Hardpoint(new int2(3, 3), new int2(0, 0)); + + var grown = Apply(Apply(hp, new int2(2, 3)), new int2(3, 2)); + + Assert.Equal(new int2(2, 2), grown.Position); + Assert.Equal(2, grown.Shape.Width); + Assert.Equal(2, grown.Shape.Height); + Assert.Equal(new[] { new int2(3, 2), new int2(2, 3), new int2(3, 3) }, Hull(grown)); + + var right = Apply(Apply(hp, new int2(4, 3)), new int2(3, 4)); + Assert.Equal(new int2(3, 3), right.Position); + Assert.Equal(new[] { new int2(3, 3), new int2(4, 3), new int2(3, 4) }, Hull(right)); + } + + [Fact] + public void FootprintTrimsBackToItsOriginal() + { + var hp = Hardpoint(new int2(3, 3), new int2(0, 0)); + var grown = Apply(Apply(hp, new int2(2, 3)), new int2(3, 2)); + + var trimmed = Apply(Apply(grown, new int2(2, 3)), new int2(3, 2)); + + Assert.Equal(hp.Position, trimmed.Position); + Assert.Equal(1, trimmed.Shape.Width); + Assert.Equal(1, trimmed.Shape.Height); + Assert.Equal(Hull(hp), Hull(trimmed)); + } + + [Fact] + public void FootprintRefusesItsLastCell() + { + var hp = Hardpoint(new int2(3, 3), new int2(0, 0)); + + Assert.False(HullFootprint.Toggle(hp.Shape, hp.Position, new int2(3, 3), out var shape, out var position)); + + Assert.Equal(hp.Position, position); + Assert.Same(hp.Shape, shape); + Assert.Equal(new[] { new int2(3, 3) }, Hull(hp)); + } + + [Fact] + public void FootprintToggleLeavesItsInputUnchanged() + { + var hp = Hardpoint(new int2(3, 3), new int2(0, 0), new int2(1, 0)); + + HullFootprint.Toggle(hp.Shape, hp.Position, new int2(2, 3), out _, out _); + HullFootprint.Toggle(hp.Shape, hp.Position, new int2(4, 3), out _, out _); + + Assert.Equal(new int2(3, 3), hp.Position); + Assert.Equal(2, hp.Shape.Width); + Assert.Equal(1, hp.Shape.Height); + Assert.Equal(new[] { new int2(3, 3), new int2(4, 3) }, Hull(hp)); + } + + [Fact] + public void TrimmingAnInteriorBorderMovesPositionWithoutMovingCells() + { + var hp = Hardpoint(new int2(5, 5), new int2(0, 0), new int2(1, 0), new int2(2, 0)); + + var trimmed = Apply(hp, new int2(5, 5)); + + Assert.Equal(new int2(6, 5), trimmed.Position); + Assert.Equal(new[] { new int2(6, 5), new int2(7, 5) }, Hull(trimmed)); + } +} From f37949f1394466427818e2cc10adf511a498e257 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 13:50:50 +0200 Subject: [PATCH 4/5] Studio: HardpointFootprintDrawer paints a hardpoint's footprint over its hull's cells; the hull drawer tint reads HullFootprint.Cells Editor code; not yet compiled (Unity batch compile at the end). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/CultCacheDrawers.cs | 47 ++++++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Editor/CultCacheDrawers.cs b/Assets/Scripts/Editor/CultCacheDrawers.cs index c446212db..818746e0f 100644 --- a/Assets/Scripts/Editor/CultCacheDrawers.cs +++ b/Assets/Scripts/Editor/CultCacheDrawers.cs @@ -132,8 +132,7 @@ public sealed class InspectableSchematicShapeDrawer : ICultInspectorDrawer using (new EditorGUILayout.HorizontalScope()) for (var x = 0; x < shape.Width; x++) { - var hardpoint = hull?.Hardpoints.FirstOrDefault(hp => hp.Shape.Coordinates - .Any(v => v.x + hp.Position.x == x && v.y + hp.Position.y == y)); + var hardpoint = hull?.Hardpoints.FirstOrDefault(hp => HullFootprint.Cells(hp).Contains(int2(x, y))); var previous = GUI.backgroundColor; if (hardpoint != null) GUI.backgroundColor = hardpoint.TintColor.ToColor(); shape[int2(x, y)] = GUILayout.Toggle(shape[int2(x, y)], GUIContent.none, GUILayout.Width(16)); @@ -145,3 +144,47 @@ public sealed class InspectableSchematicShapeDrawer : ICultInspectorDrawer return shape; } } + +// A hardpoint's schematic footprint over its hull's cells: the hull's occupied cells shaded, this hardpoint's cells lit +// in its tint, other hardpoints' cells tinted and disabled. A click edits Position and Shape together through +// HullFootprint.Toggle, which owns the rule so a Thing host reads the same one. The footprint is authored for gameplay, +// never derived from the model. Read-only on inspector.Record; only the HardpointData it is given changes. +[CultInspectorDrawer(typeof(HardpointData))] +public sealed class HardpointFootprintDrawer : ICultInspectorDrawer +{ + public object Draw(CultInspector inspector, string label, Type type, object value, MemberInfo member) + { + var hardpoint = value as HardpointData; + if (type != typeof(HardpointData) || hardpoint == null) return inspector.DrawDefault(label, type, value, member); + var hull = inspector.Record as HullData; + var own = hull?.Hardpoints.FirstOrDefault(hp => hp.Type == hardpoint.Type && hp.Position.Equals(hardpoint.Position) + && HullFootprint.Cells(hp).SequenceEqual(HullFootprint.Cells(hardpoint))); + var drawn = (HardpointData) inspector.DrawDefault(label, type, value, member); + if (hull?.Shape == null) return drawn; + + var others = hull.Hardpoints.Where(hp => hp != own).ToArray(); + EditorGUILayout.LabelField("Footprint"); + for (var y = hull.Shape.Height - 1; y >= 0; y--) + using (new EditorGUILayout.HorizontalScope()) + for (var x = 0; x < hull.Shape.Width; x++) + { + var cell = int2(x, y); + var other = others.FirstOrDefault(hp => HullFootprint.Cells(hp).Contains(cell)); + var on = HullFootprint.Cells(drawn).Contains(cell); + var previous = GUI.backgroundColor; + GUI.backgroundColor = on ? drawn.TintColor.ToColor() : other != null ? other.TintColor.ToColor() + : hull.Shape[cell] ? Color.gray : previous; + EditorGUI.BeginChangeCheck(); + using (new EditorGUI.DisabledScope(other != null && !on)) + GUILayout.Toggle(on || other != null, GUIContent.none, GUILayout.Width(16)); + if (EditorGUI.EndChangeCheck() && HullFootprint.Toggle(drawn.Shape, drawn.Position, cell, out var shape, out var position)) + { + drawn.Shape = shape; + drawn.Position = position; + GUI.changed = true; + } + GUI.backgroundColor = previous; + } + return drawn; + } +} From 1349953500e56c9e8a527fd5dd6585d9fcb3a825 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 14:33:41 +0200 Subject: [PATCH 5/5] Shape.SetLine writes through the indexer so it invalidates the caches; footprint tests pin refusal Position and a wide shape Builds: yes (Shared-only; verified on Yggdrasil in the report). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ItemData.cs | 2 +- .../HullFootprintTests.cs | 27 +++++++++++++++++++ .../Aetheria.Shared.Tests/ShapeCacheTests.cs | 15 +++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 1b4ea1c9f..d921c1227 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -276,7 +276,7 @@ public class Shape { if (x >= 0 && x < Width && y >= 0 && y < Height) { - Cells[steep ? y : x, steep ? x : y] = true; + this[int2(steep ? y : x, steep ? x : y)] = true; } error += derr; if (error >= 0.5f) diff --git a/tests/Aetheria.Shared.Tests/HullFootprintTests.cs b/tests/Aetheria.Shared.Tests/HullFootprintTests.cs index 6d36d50aa..2baa5d422 100644 --- a/tests/Aetheria.Shared.Tests/HullFootprintTests.cs +++ b/tests/Aetheria.Shared.Tests/HullFootprintTests.cs @@ -72,6 +72,33 @@ public sealed class HullFootprintTests Assert.Equal(new[] { new int2(3, 3) }, Hull(hp)); } + [Fact] + public void RefusedClickLeavesPositionWhereItWasWhenTheLastCellIsNotAtPosition() + { + var hp = Hardpoint(new int2(3, 3), new int2(1, 0)); + Assert.Equal(new[] { new int2(4, 3) }, Hull(hp)); + + Assert.False(HullFootprint.Toggle(hp.Shape, hp.Position, new int2(4, 3), out var shape, out var position)); + + Assert.Equal(new int2(3, 3), position); + Assert.Same(hp.Shape, shape); + } + + [Fact] + public void WideFootprintLaysEveryCellAtPositionAndEditsAtItsFarEnd() + { + var hp = Hardpoint(new int2(2, 2), new int2(0, 0), new int2(1, 0), new int2(2, 0), new int2(3, 0), new int2(4, 0)); + Assert.Equal(new[] { new int2(2, 2), new int2(3, 2), new int2(4, 2), new int2(5, 2), new int2(6, 2) }, Hull(hp)); + + var grown = Apply(hp, new int2(7, 2)); + Assert.Equal(new int2(2, 2), grown.Position); + Assert.Equal(new[] { new int2(2, 2), new int2(3, 2), new int2(4, 2), new int2(5, 2), new int2(6, 2), new int2(7, 2) }, Hull(grown)); + + var trimmed = Apply(hp, new int2(6, 2)); + Assert.Equal(new int2(2, 2), trimmed.Position); + Assert.Equal(new[] { new int2(2, 2), new int2(3, 2), new int2(4, 2), new int2(5, 2) }, Hull(trimmed)); + } + [Fact] public void FootprintToggleLeavesItsInputUnchanged() { diff --git a/tests/Aetheria.Shared.Tests/ShapeCacheTests.cs b/tests/Aetheria.Shared.Tests/ShapeCacheTests.cs index f1228d425..21fd6e914 100644 --- a/tests/Aetheria.Shared.Tests/ShapeCacheTests.cs +++ b/tests/Aetheria.Shared.Tests/ShapeCacheTests.cs @@ -50,4 +50,19 @@ public sealed class ShapeCacheTests Assert.False(shape[int2(1, 0)]); Assert.True(shape[int2(2, 0)]); } + + [Fact] + public void SetLineRecountsEveryCoordinateRead() + { + var shape = new Shape(4, 1); + shape[int2(0, 0)] = true; + Assert.Single(shape.Coordinates); + Assert.Equal(0f, shape.CenterOfMass.x); + + shape.SetLine(float2(0f, 0f), float2(3f, 0f)); + + Assert.Equal(4, shape.Coordinates.Length); + Assert.Equal(4, shape.AllCoordinates.Length); + Assert.Equal(1.5f, shape.CenterOfMass.x); + } }