diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index 6316241ad..760595dcc 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -22,6 +22,7 @@ public sealed class ShipSchemaPinTests [InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))] [InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))] [InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))] + [InlineData("HULL_TYPE_SLOT", typeof(HullData), nameof(HullData.HullType))] [InlineData("PRODUCT_MANUFACTURER_SLOT", typeof(FactionProductData), nameof(FactionProductData.Manufacturer))] public void PythonSlotConstantsAreTheKeysOfTheMembersTheyNameInTheirComments(string constant, Type owner, string member) { @@ -136,6 +137,7 @@ public sealed class ShipSchemaPinTests { Assert.Equal(EnumNames(), Strings("HARDPOINT_TYPE_NAMES")); Assert.Equal(EnumNames(), Strings("ROTATION_NAMES")); + Assert.Equal(EnumNames(), Strings("HULL_TYPE_NAMES")); } [Fact] diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py index e408105be..3752fb5e7 100644 --- a/tools/blender/aetheria_ships/hull_grid.py +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -32,6 +32,27 @@ def _inside(triangle, px, py): return not ((d1 < 0 or d2 < 0 or d3 < 0) and (d1 > 0 or d2 > 0 or d3 > 0)) +def _with_area(triangles_xy): + triangles = [tri for tri in triangles_xy if + (tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])] + if not triangles: + raise ValueError("The hull has no triangle with area to rasterise") + return triangles + + +def _size(triangles, cell_size): + xs = [p[0] for tri in triangles for p in tri] + ys = [p[1] for tri in triangles for p in tri] + return _cells_across(max(xs) - min(xs), cell_size), _cells_across(max(ys) - min(ys), cell_size), xs, ys + + +def grid_size(triangles_xy, cell_size=CELL_SIZE): + """(width, height) in cells of the grid rasterise would lay over these triangles, without refusing a grid over + MAX_CELLS: the caller judges the bound. Raises ValueError when no triangle has area.""" + width, height, _, _ = _size(_with_area(triangles_xy), cell_size) + return width, height + + def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES): """Occupied cells of a top-down silhouette given as triangles of (x, y) points in the Ship Root frame. @@ -40,14 +61,8 @@ def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES): inside several triangles counts once, and each triangle is tested only at the samples inside its bounding box. Returns (width, height, cells, grid_origin): cells are column-major, cells[x * height + y], and grid_origin is cell (0, 0)'s centre.""" - triangles = [tri for tri in triangles_xy if - (tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])] - if not triangles: - raise ValueError("The hull has no triangle with area to rasterise") - xs = [p[0] for tri in triangles for p in tri] - ys = [p[1] for tri in triangles for p in tri] - width = _cells_across(max(xs) - min(xs), cell_size) - height = _cells_across(max(ys) - min(ys), cell_size) + triangles = _with_area(triangles_xy) + width, height, xs, ys = _size(triangles, cell_size) for axis, count in (("wide", width), ("long", height)): if count > MAX_CELLS: raise ValueError(f"The hull needs {count} cells {axis}; a ship grid holds at most {MAX_CELLS}") diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 7cc093389..82ef1d76f 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -34,15 +34,17 @@ ANCHORS_SLOT = 3 # ShipAuthoring.Anchors SCHEMATIC_LINES_SLOT = 4 # ShipAuthoring.SchematicLines HULL_SHAPE_SLOT = 5 # ItemData.Shape HULL_HARDPOINTS_SLOT = 23 # HullData.Hardpoints +HULL_TYPE_SLOT = 25 # HullData.HullType # HardpointData member names in slot order; a hardpoint row is one value per name, and any slot past the last # name belongs to a later schema and is carried through untouched. HARDPOINT_MEMBERS = ("Type", "Position", "Shape", "Transform", "Rotation", "Armor", "FiringArc") # ShipAnchor member names in slot order; as for hardpoints, slots past the last name are carried through by anchor Id. ANCHOR_MEMBERS = ("Id", "Role", "ModelNodeId", "ParentId", "Order") -# Enum member names in value order (HardpointType, ItemRotation). +# Enum member names in value order (HardpointType, ItemRotation, HullType). HARDPOINT_TYPE_NAMES = ("Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator", "Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule") ROTATION_NAMES = ("None", "CounterClockwise", "Reversed", "Clockwise") +HULL_TYPE_NAMES = ("Ship", "Station", "Turret") # ShipAuthoringStore.RequireShipId's rule, mirrored so New Ship refuses before it touches anything; ShipSchemaPinTests @@ -208,6 +210,15 @@ def read_layout(path: str, cultlib_packages: str): return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) +def read_hull_type(path: str, cultlib_packages: str) -> str: + """The hull's HullData.HullType as its enum member name ("Ship", "Station" or "Turret").""" + hull = _hull_body(read(path, cultlib_packages)) + value = hull[HULL_TYPE_SLOT] if len(hull) > HULL_TYPE_SLOT else None + if type(value) is not int or not 0 <= value < len(HULL_TYPE_NAMES): + raise ValueError("The ship file's hull has no hull type this add-on knows") + return HULL_TYPE_NAMES[value] + + def layout_revision(shape: list[Any], hardpoints: list[list[Any]]) -> str: """The revision of a layout: a hash of its shape ([width, height, cells]) and hardpoint rows as the file stores them. replace_layout returns it, Load records it, and Package compares the layout buffer's against the file's.""" diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py index 06667c0f2..ecc79b9fc 100644 --- a/tools/blender/tests/test_hull_grid.py +++ b/tools/blender/tests/test_hull_grid.py @@ -136,6 +136,16 @@ class RasteriseTests(unittest.TestCase): hull_grid.rasterise(rect(0, 0, 66, 2)) self.assertEqual(str(caught.exception), "The hull needs 33 cells wide; a ship grid holds at most 32") + def test_grid_size_is_rasterises_size_and_does_not_refuse_an_oversized_grid(self): + self.assertEqual(hull_grid.grid_size(rect(0, 0, 2, 64)), hull_grid.rasterise(rect(0, 0, 2, 64))[:2]) + self.assertEqual(hull_grid.grid_size(rect(-3, -1, 3, 4) + CORNER), hull_grid.rasterise(rect(-3, -1, 3, 4) + CORNER)[:2]) + self.assertEqual(hull_grid.grid_size(rect(0, 0, 2, 66)), (1, 33)) + self.assertEqual(hull_grid.grid_size(rect(0, 0, 66, 2)), (33, 1)) + self.assertEqual(hull_grid.grid_size(rect(0, 0, 4, 4), cell_size=1.0), (4, 4)) + with self.assertRaises(ValueError) as caught: + hull_grid.grid_size([((0, 0), (1, 1), (2, 2))]) + self.assertEqual(str(caught.exception), "The hull has no triangle with area to rasterise") + def test_a_silhouette_with_no_area_is_refused(self): for triangles in ([], [((0, 0), (1, 1), (2, 2))]): with self.assertRaises(ValueError) as caught: diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 67facc162..6e971320a 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -44,8 +44,9 @@ def hardpoint_row(mount="thruster.port", tail=("future-hardpoint-slot",)): def hull_body(): - hull = [None] * (ship_cc.HULL_HARDPOINTS_SLOT + 1) + hull = [None] * (ship_cc.HULL_TYPE_SLOT + 1) hull[1] = "Skiff" + hull[ship_cc.HULL_TYPE_SLOT] = 0 hull[ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, False, True, False])] hull[ship_cc.HULL_HARDPOINTS_SLOT] = [hardpoint_row()] hull.append("future-hull-slot") @@ -128,6 +129,28 @@ class ReadTests(ShipFileCase): self.assertEqual("Skiff", file.hull.body[1]) self.assertEqual(ship_cc.HULL_SCHEMA, file.hull.envelope.type) + def test_reads_the_hulls_type_by_its_enum_name(self): + for value, name in ((0, "Ship"), (1, "Station"), (2, "Turret")): + with self.subTest(name): + hull = hull_body() + hull[ship_cc.HULL_TYPE_SLOT] = value + self.write(ship_body(), hull) + self.assertEqual(name, ship_cc.read_hull_type(self.path, PACKAGES)) + + def test_refuses_a_hull_type_it_does_not_know_without_echoing_the_path(self): + short = hull_body()[:ship_cc.HULL_TYPE_SLOT] + for label, value in (("past the enum", 3), ("negative", -1), ("bool", True), ("missing", None), + ("text", "Ship"), ("short body", short)): + with self.subTest(label): + hull = value if label == "short body" else hull_body() + if label != "short body": + hull[ship_cc.HULL_TYPE_SLOT] = value + self.write(ship_body(), hull) + with self.assertRaises(ValueError) as caught: + ship_cc.read_hull_type(self.path, PACKAGES) + self.assertEqual("The ship file's hull has no hull type this add-on knows", str(caught.exception)) + self.assertNotIn(self.path, str(caught.exception)) + def test_reads_a_file_whose_hull_also_carries_products(self): self.add_products() file = ship_cc.read(self.path, PACKAGES)