From d76b27673ad445a0d79d6569e51111ff28123dce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 13:49:45 +0200 Subject: [PATCH] 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)); + } +}