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/__init__.py b/tools/blender/aetheria_ships/__init__.py index 90d1c9ad7..17ad00afc 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -24,7 +24,7 @@ from mathutils import Matrix, Vector from . import hull_grid, hull_source from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, - encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines, + encode_hardpoint, layout_revision, read, read_hull_type, read_layout, replace_layout, replace_lines, product_makers, replace_visual, valid_ship_id) MODEL_ASSET = "ship.glb" @@ -1557,8 +1557,10 @@ def _hull_mesh(name, mesh, material, offset=(0.0, 0.0, 0.0), yaw=0.0): def _build_hull_source(context, collection): """Replaces the objects Build made before with the hull source's zone meshes, anchors and collider under Ship Root. - Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language, or the - collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout row.""" + Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language (whose + proportions judge a hull whose ship.cc HullData.HullType is Ship, and no other), its zones overflow the layout grid, + or the collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout + row.""" source = _hull_source_file(collection) if source is None: raise ValueError("The bound package holds no hull.scad") @@ -1573,7 +1575,9 @@ def _build_hull_source(context, collection): "changed; Build From Hull Source replaces only what it made and nobody touched") addon = context.preferences.addons.get(__package__) executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD - hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable)) + hull_type = read_hull_type(bpy.path.abspath(collection["aetheria.ship_cc"]), _brokkr_cultlib(context)) + hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), + hull_type) broken = hull.failed() if broken: raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken)) 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/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 50a932e0d..fb2aa246a 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -14,11 +14,15 @@ with it (a hull that redefines or shadows an included language fails a check): the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor -Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() returns a HullBuild; the caller refuses on any failed -check. Messages and check names are fixed text: nothing a hull source says is echoed back. +Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", +"Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. +A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS +cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the +caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. """ import hashlib +import importlib.util import json import math import os @@ -29,6 +33,16 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Callable, Optional +if __package__: + from . import hull_grid, ship_cc +else: # loaded by path (tests, the bench): load the siblings the same way + def _sibling(name): + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + hull_grid, ship_cc = _sibling("hull_grid"), _sibling("ship_cc") + FACTIONS_DIR = Path(__file__).with_name("factions") HULL_FILE = "hull.scad" OPENSCAD_TIMEOUT = 180 # seconds, per part @@ -113,6 +127,7 @@ class HullBuild: glow: tuple radiator: tuple body_zone: str # the zone key the collider is made from + hull_type: str # HullData.HullType of the bound package ("Ship", "Station", "Turret"), as the caller read it checks: list = field(default_factory=list) def failed(self): @@ -274,10 +289,14 @@ def _language(library, faction): return language -def build(runner, library): +def build(runner, library, hull_type): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with - (the ECHO text of that faction's own language file). Raises HullSourceError when the source cannot be read at all; - otherwise returns a HullBuild whose failed() lists the checks it breaks.""" + (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller + reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when + hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a + HullBuild whose failed() lists the checks it breaks.""" + if not (type(hull_type) is str and hull_type in ship_cc.HULL_TYPE_NAMES): + raise HullSourceError("The hull type is not a HullData.HullType name (Ship, Station or Turret)") _, text = runner("brief") brief = read_brief(text) _, text = runner("lang") @@ -298,7 +317,7 @@ def build(runner, library): judge = donor or own body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, - own.glow, own.radiator, body_zone) + own.glow, own.radiator, body_zone, hull_type) by_faction = lambda language: language.faction result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] @@ -314,8 +333,9 @@ def _checks(hull, own, judge): checks.append(Check("every emitter and radiator anchor has a mesh", not missing, f"{len(missing)} without")) checks.append(Check("a donor is named exactly when the language has no proportions of its own", bool(brief.donor) == (own.proportion is None), "")) + # A language's proportions describe its ships; a station or turret is not held to them, and no check is listed. body = hull.zones[hull.body_zone] if hull.body_zone in hull.zones else Mesh((), ()) - if body.triangles and judge.proportion: + if hull.hull_type == "Ship" and body.triangles and judge.proportion: low, high = body.bounds() width, length, height = (high[0] - low[0]), (high[1] - low[1]), (high[2] - low[2]) ratios = (height / width if width else float("inf"), width / length if length else float("inf")) @@ -323,7 +343,7 @@ def _checks(hull, own, judge): checks.append(Check("the body's proportions lie inside the language's ranges", hw_lo <= ratios[0] <= hw_hi and wl_lo <= ratios[1] <= wl_hi, f"h/w {ratios[0]:.2f}, w/l {ratios[1]:.2f}")) - else: + elif hull.hull_type == "Ship": checks.append(Check("the body's proportions lie inside the language's ranges", False, "no body to judge")) every = [m for m in list(hull.zones.values()) + list(hull.anchor_meshes.values()) if m.triangles] if every: @@ -338,6 +358,14 @@ def _checks(hull, own, judge): else: checks.append(Check("the top view obeys the language's symmetry rule", False, "no geometry")) checks.append(Check("the length lies inside the brief's range", False, "no geometry")) + # Rasterise reads the zone meshes only, so the grid bound is judged over them, not over anchor meshes. + try: + width, height = hull_grid.grid_size([t for m in hull.zones.values() for t in _triangles_xy(m)]) + checks.append(Check("the top view fits the layout grid", + width <= hull_grid.MAX_CELLS and height <= hull_grid.MAX_CELLS, + f"{width} x {height} cells, at most {hull_grid.MAX_CELLS} a side")) + except ValueError: + checks.append(Check("the top view fits the layout grid", False, "no geometry")) triangles = sum(len(m.triangles) for m in every) checks.append(Check("the triangle count lies inside the brief's budget", brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}")) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 7cc093389..079296be4 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -16,6 +16,14 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, NamedTuple +if __package__: + from . import hull_grid +else: # loaded by path (tests, hull_source loaded by path): load the sibling the same way + import importlib.util + _grid_spec = importlib.util.spec_from_file_location("hull_grid", Path(__file__).with_name("hull_grid.py")) + hull_grid = importlib.util.module_from_spec(_grid_spec) + _grid_spec.loader.exec_module(hull_grid) + # The cultcache-py this module's raw envelope writes were tested against. cultcache_py has no __version__ and is loaded # from source, so the guard reads [project].version of the checkout the imported module itself came from. TESTED_CULTCACHE_PY = "0.3.0" @@ -34,15 +42,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 +218,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.""" @@ -224,8 +243,9 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_ if layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) != expected_revision: raise ValueError("The .cc layout changed since Load; reload before saving") width, height, cells = shape - if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height): - raise ValueError("Hull grid must be 1..32 cells wide and high with one value per cell") + limit = hull_grid.MAX_CELLS + if not (1 <= width <= limit and 1 <= height <= limit and len(cells) == width * height): + raise ValueError(f"Hull grid must be 1..{limit} cells wide and high with one value per cell") if not all(type(cell) is bool for cell in cells): raise ValueError("Hull grid cells must be booleans") if not any(cells): diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 8bb9b9c48..06f2db715 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -38,6 +38,8 @@ the hull.scad into the package and runs the panel's operators: changes nothing in the scene. Her art needs no product. Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing. Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. + The ship.cc HullData.HullType decides whether the language's proportions judge the hull: a body far wider than the + language's ships is refused as Ship and built as Station, and a Station wider than the layout grid is refused. """ import dataclasses @@ -698,6 +700,66 @@ def agent_guards(scene): return collection +def hull_type_gate(scene): + """The hull's HullData.HullType in ship.cc decides whether the language's proportions judge it; every hull type must + fit the layout grid. A body far wider than a Zhestokost ship is refused as Ship, built as Station, and a Station + wider than 32 cells is refused on the grid.""" + fixture, _, extent, length, rows, *_ = HULLS[0] + ship_id = "smoke.hull.role" + collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows) + ship_module = sys.modules["aetheria_ships.ship_cc"] + path = str(package / "ship.cc") + source = package / "hull.scad" + original = source.read_text() + slab = '\nif (part == "primary") translate([-%d, -2, 0]) cube([%d, 4, 1]);\n' + source.write_text(original + slab % (15, 30)) + judged = [] + real = hull_source.build + + def recorded(*args): + judged.append(real(*args)) + return judged[-1] + + sys.modules["aetheria_ships"].hull_source.build = recorded + try: + require(ship_module.read_hull_type(path, PACKAGES) == "Ship", "New Ship did not write a Ship hull") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "the body's proportions lie inside the language's ranges" in message, + f"A Ship far wider than its language allows was built: {message!r}") + print("PASS 5.1 a body wider than the language's ships is refused as Ship, on proportion") + + store = ship_module.read(path, PACKAGES).store + for envelope in store.pull_all(): + if envelope.type == ship_module.HULL_SCHEMA: + values = msgpack.unpackb(envelope.payload, raw=False) + values[ship_module.HULL_TYPE_SLOT] = 1 + store.push(dataclasses.replace(envelope, payload=msgpack.packb(values, use_bin_type=True))) + require(ship_module.read_hull_type(path, PACKAGES) == "Station", "The pushed HullType did not read back as Station") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + names = [c.name for c in judged[-1].checks] + require(result == {"FINISHED"} and judged[-1].hull_type == "Station" + and "the body's proportions lie inside the language's ranges" not in names + and "the top view fits the layout grid" in names, + f"The same body as Station was refused or judged on proportion: {message!r} {names}") + print("PASS 5.2 the same body as Station is built, with no proportion check and a grid check") + + built = set(generated(collection)) + source.write_text(original + slab % (35, 70)) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "the top view fits the layout grid" in message + and "the body's proportions lie inside the language's ranges" not in message + and set(generated(collection)) == built, + f"A Station 70 m wide was not refused on the layout grid, or the refusal changed the scene: {message!r}") + print("PASS 5.3 a Station 70 m wide is refused on the layout grid, not on proportion") + finally: + sys.modules["aetheria_ships"].hull_source.build = real + print("HULL_TYPE_GATE_OK", flush=True) + return collection + + def main(): require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}") bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True) @@ -733,6 +795,7 @@ def main(): previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette, maker, other) clear(previous) clear(agent_guards(scene)) + clear(hull_type_gate(scene)) never_built(scene) @@ -750,7 +813,7 @@ else: print("SMOKE_HULL_SOURCE_OK", flush=True) code = 0 finally: - for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards")): + for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards"), (None, "smoke.hull.role")): shutil.rmtree(MODS / ship_id, ignore_errors=True) if created: shutil.rmtree(MODS, ignore_errors=True) 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_hull_source.py b/tools/blender/tests/test_hull_source.py index 362664de7..bd534d83b 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -57,6 +57,7 @@ class Fixture: def __init__(self): self.library = {"zhestokost": language()} self.hull_lang = None + self.hull_type = "Ship" # HullData.HullType, which the caller reads from the package # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle. self.parts = { "brief": ("", brief()), @@ -77,7 +78,7 @@ class Fixture: return self.library[faction] def build(self): - return hs.build(self.runner(), self.read_library) + return hs.build(self.runner(), self.read_library, self.hull_type) def failed(self): return [c.name for c in self.build().failed()] @@ -128,6 +129,103 @@ class ReadingTests(unittest.TestCase): hs.read_languages(language(body="keel")) +PROPORTION = "the body's proportions lie inside the language's ranges" +GRID = "the top view fits the layout grid" + + +def names(built): + return [c.name for c in built.checks] + + +class HullTypeTests(unittest.TestCase): + """A language's proportions judge Ship hulls only; every other rule, and the layout grid, judges every hull type.""" + + @staticmethod + def fixture(hull_type, **parts): + fixture = Fixture() + fixture.hull_type = hull_type + fixture.parts.update({k: (v, "") for k, v in parts.items()}) + return fixture + + def test_a_station_or_turret_is_not_held_to_the_languages_proportions_and_lists_no_proportion_check(self): + wide = box(-12, 12, -5, 5, 0, 2) # h/w 0.08, w/l 2.4: outside both of Zhestokost's ranges + for hull_type in ("Station", "Turret"): + with self.subTest(hull_type): + built = self.fixture(hull_type, primary=wide).build() + self.assertEqual([c.name for c in built.failed()], []) + self.assertNotIn(PROPORTION, names(built)) + self.assertEqual(built.hull_type, hull_type) + self.assertEqual(self.fixture("Ship", primary=wide).failed(), [PROPORTION]) + + def test_a_station_with_no_body_lists_no_proportion_check_but_a_ship_fails_it(self): + self.assertNotIn(PROPORTION, names(self.fixture("Station", primary="").build())) + ship = self.fixture("Ship", primary="").build() + self.assertEqual([c.detail for c in ship.checks if c.name == PROPORTION], ["no body to judge"]) + + def test_a_ship_lists_the_proportion_check_when_it_passes(self): + built = self.fixture("Ship").build() + self.assertEqual([c.ok for c in built.checks if c.name == PROPORTION], [True]) + + def test_a_station_is_still_judged_on_symmetry_length_and_budget(self): + self.assertEqual(self.fixture("Station", secondary=box(2, 6, -1, 1, 2, 3)).failed(), + ["the top view obeys the language's bilateral rule"]) + self.assertEqual(self.fixture("Station", primary=box(-3, 3, -10, 10, 0, 2)).failed(), + ["the length lies inside the brief's range"]) + station = self.fixture("Station") + station.parts["brief"] = ("", brief(triangles=(10, 20))) + self.assertEqual(station.failed(), ["the triangle count lies inside the brief's budget"]) + + def test_every_hull_type_must_fit_the_layout_grid_at_thirty_two_cells_a_side(self): + for hull_type in ("Ship", "Station", "Turret"): + with self.subTest(hull_type): + fits = self.fixture(hull_type, secondary=box(-32, 32, -1, 1, 2, 3)).build() + self.assertEqual([(c.ok, c.detail) for c in fits.checks if c.name == GRID], + [(True, "32 x 5 cells, at most 32 a side")]) + wide = self.fixture(hull_type, secondary=box(-33, 33, -1, 1, 2, 3)) + self.assertEqual(wide.failed(), [GRID]) + self.assertEqual([c.detail for c in wide.build().checks if c.name == GRID], + ["33 x 5 cells, at most 32 a side"]) + long = self.fixture("Station", secondary=box(-1, 1, -32, 32, 2, 3)) + long.parts["brief"] = ("", brief(length=(8, 70))) + self.assertEqual(long.failed(), []) + self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 32 cells, at most 32 a side"]) + long = self.fixture("Station", secondary=box(-1, 1, -33, 33, 2, 3)) + long.parts["brief"] = ("", brief(length=(8, 70))) + self.assertEqual(long.failed(), [GRID]) + self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 33 cells, at most 32 a side"]) + + def test_the_grid_reads_zone_meshes_only_not_anchor_meshes(self): + built = self.fixture("Station", **{"emit:Th.A": box(-35, 35, 5, 5.1, 0.5, 1.5)}).build() + self.assertEqual([c.name for c in built.failed()], []) + self.assertEqual([c.detail for c in built.checks if c.name == GRID], ["3 x 5 cells, at most 32 a side"]) + + def test_a_hull_with_no_zone_geometry_fails_the_grid_check(self): + built = self.fixture("Station", primary="", secondary="").build() + self.assertEqual([(c.ok, c.detail) for c in built.checks if c.name == GRID], [(False, "no geometry")]) + + def test_build_has_no_default_hull_type(self): + fixture = Fixture() + with self.assertRaises(TypeError): + hs.build(fixture.runner(), fixture.read_library) + + def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self): + for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET): + with self.subTest(hull_type=hull_type): + fixture, read = Fixture(), [] + runner = fixture.runner() + with self.assertRaises(hs.HullSourceError) as caught: + hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type) + self.assertEqual(read, []) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_build_accepts_each_hulltype_name_and_only_a_ship_lists_the_proportion_check(self): + for hull_type in ("Ship", "Station", "Turret"): + with self.subTest(hull_type): + built = self.fixture(hull_type).build() + self.assertEqual([c.name for c in built.failed()], []) + self.assertEqual(PROPORTION in names(built), hull_type == "Ship") + + class MirrorOverlapTests(unittest.TestCase): @staticmethod def top(text): @@ -381,7 +479,7 @@ class LanguageOwnershipTests(unittest.TestCase): def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): asked = [] fixture = DonorTests.pirate() - hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction)) + hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship") self.assertEqual(sorted(asked), ["pirate", "rnd"]) diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 67facc162..828352438 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -15,6 +15,7 @@ import shutil import sys import tempfile import unittest +from unittest import mock from datetime import datetime from pathlib import Path from types import SimpleNamespace @@ -44,8 +45,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 +130,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) @@ -520,6 +544,16 @@ class LayoutTests(ShipFileCase): revision = self.layout()[2] self.assertNotEqual(before, self.bytes()) + def test_the_grid_limit_is_hull_grids_max_cells(self): + _, _, revision = self.layout() + before = self.bytes() + with self.assertRaisesRegex(ValueError, f"1..{ship_cc.hull_grid.MAX_CELLS} cells"): + self.edit(revision, width=33, height=1, cells=[True] * 33) + self.assertEqual(before, self.bytes()) + with mock.patch.object(ship_cc.hull_grid, "MAX_CELLS", 33): + self.edit(revision, width=33, height=1, cells=[True] * 33, hardpoints=[]) + self.assertEqual([33, 1, [True] * 33], self.layout()[0]) + def test_a_layout_save_refreshes_the_stored_at_of_the_hull_and_leaves_the_ships(self): stale = "2000-01-01T00:00:00+00:00" envelopes = self.envelopes()