From 0a70e0cf93a133753df9b2847c130860ba6003c8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:00:06 +0200 Subject: [PATCH 1/7] Blender ships: hull_grid.grid_size; ship_cc reads HullData.HullType, pinned to C# grid_size is rasterise's grid arithmetic without its MAX_CELLS refusal, so a check can judge the bound; rasterise calls the same private filter and sizing. ship_cc gains HULL_TYPE_SLOT (Key 25), HULL_TYPE_NAMES and read_hull_type; ShipSchemaPinTests ties both to HullData.HullType and the HullType enum. Co-Authored-By: Claude Opus 5.5 --- .../ShipSchemaPinTests.cs | 2 ++ tools/blender/aetheria_ships/hull_grid.py | 31 ++++++++++++++----- tools/blender/aetheria_ships/ship_cc.py | 13 +++++++- tools/blender/tests/test_hull_grid.py | 10 ++++++ tools/blender/tests/test_ship_cc.py | 25 ++++++++++++++- 5 files changed, 71 insertions(+), 10 deletions(-) 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) From 2267d8beec76a54f9871a7c035d0ad3d65ab9245 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:03:27 +0200 Subject: [PATCH 2/7] Hull source: language proportions judge Ship hulls only; zones must fit the layout grid build() takes the hull's HullData.HullType, with no default; the add-on reads it from the bound ship.cc. A Station or Turret lists no proportion check; every hull keeps symmetry, length, budget and anchor checks and gains the grid check, judged over zone meshes through hull_grid.grid_size. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 12 ++- tools/blender/aetheria_ships/hull_source.py | 36 +++++++-- tools/blender/tests/test_hull_source.py | 81 ++++++++++++++++++++- 3 files changed, 116 insertions(+), 13 deletions(-) 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_source.py b/tools/blender/aetheria_ships/hull_source.py index 50a932e0d..300c926f1 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,13 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Callable, Optional +if __package__: + from . import hull_grid +else: # loaded by path (tests, the bench): load the sibling the same way + _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) + FACTIONS_DIR = Path(__file__).with_name("factions") HULL_FILE = "hull.scad" OPENSCAD_TIMEOUT = 180 # seconds, per part @@ -113,6 +124,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,9 +286,10 @@ 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; + (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 when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" _, text = runner("brief") brief = read_brief(text) @@ -298,7 +311,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 +327,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 +337,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 +352,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/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 362664de7..653946eae 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,82 @@ 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, -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) + + class MirrorOverlapTests(unittest.TestCase): @staticmethod def top(text): @@ -381,7 +458,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"]) From 28496f27dfd05ff4e56c2e369ef15c68f162a1e8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:07:54 +0200 Subject: [PATCH 3/7] Hull source smoke: HullType gate (refused as Ship, built as Station, Station refused on the grid) Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_source.py | 4 +- tools/blender/tests/smoke_hull_source.py | 65 ++++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 300c926f1..ce5529897 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -289,8 +289,8 @@ def _language(library, faction): 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). 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 when the source cannot be read at all; - otherwise returns a HullBuild whose failed() lists the checks it breaks.""" + reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError when the source cannot be read at + all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" _, text = runner("brief") brief = read_brief(text) _, text = runner("lang") 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) From 8bfb06ed64835a156669700923b318076be5cd37 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:15:11 +0200 Subject: [PATCH 4/7] Hull source tests: a Station 32 cells long fits the grid (height bound, not only width) Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 653946eae..70d6e917a 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -185,6 +185,10 @@ class HullTypeTests(unittest.TestCase): 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]) From efeadec26502368f285c3b2141efacff10cfcbcb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:27:38 +0200 Subject: [PATCH 5/7] Blender ships tests: build refuses a hull type that is not a HullType name; the grid limit is hull_grid's (red) Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 17 +++++++++++++++++ tools/blender/tests/test_ship_cc.py | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 70d6e917a..bd534d83b 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -208,6 +208,23 @@ class HullTypeTests(unittest.TestCase): 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 diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 6e971320a..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 @@ -543,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() From da77380a0456110573f69c01ac64d49f3f7c83e3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:28:06 +0200 Subject: [PATCH 6/7] Hull source: build refuses a hull type that is not a HullType name, before reading the source Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_source.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index ce5529897..fb2aa246a 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -34,11 +34,14 @@ from pathlib import Path from typing import Callable, Optional if __package__: - from . import hull_grid -else: # loaded by path (tests, the bench): load the sibling the same way - _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) + 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" @@ -289,8 +292,11 @@ def _language(library, faction): 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). 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 when the source cannot be read at - all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" + 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") From c7e85676fe76c8d35d5bb9fd10b876ed09a6b567 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:28:07 +0200 Subject: [PATCH 7/7] ship_cc: replace_layout refuses on hull_grid.MAX_CELLS, the grid limit's one owner Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/ship_cc.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 82ef1d76f..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" @@ -235,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):