From 42227f343581e6d77cff5b98c336973eb2803bf4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:45:12 +0200 Subject: [PATCH 1/4] Blender add-on weapon mounts: Add Weapon, Add Muzzle, Mirror Mount, Remove Mount; Package derives mounted Position and yaw hull_grid gains mounted_position, facing_yaw, mirror_rotation and mirror_footprint. The mount helpers edit the layout buffer and create a tagged weapon-mount object with its row as a pair; Package derives each mounted row's Position from its object and each mount's yaw from its row's Rotation, then saves the buffer through _save_layout. The panel gets a Mounts box and greys a mounted row's x and y. smoke_mounts.py pins the gunboat flank, derive and remove paths. All commits build. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 254 +++++++++++++++++- tools/blender/aetheria_ships/hull_grid.py | 36 +++ tools/blender/tests/smoke_mounts.py | 305 ++++++++++++++++++++++ tools/blender/tests/test_hull_grid.py | 24 ++ 4 files changed, 617 insertions(+), 2 deletions(-) create mode 100644 tools/blender/tests/smoke_mounts.py diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 6d633d427..154912ec6 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -180,7 +180,7 @@ class AETHERIA_OT_add_hardpoint(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.aetheria_layout.hardpoints.add() + _add_row(context.scene.aetheria_layout) return {"FINISHED"} @@ -197,6 +197,239 @@ class AETHERIA_OT_remove_hardpoint(bpy.types.Operator): return {"FINISHED"} + +MOUNT_ROLE = "weapon-mount" +MUZZLE_ROLE = "weapon-muzzle" +MUZZLE_OFFSET = (0.0, -0.5, 0.0) # in the mount's frame: half a metre along its facing +WEAPON_TYPES = tuple(item for item in HARDPOINT_TYPES if item[1] in ("Energy", "Ballistic", "Launcher")) +CENTRELINE = hull_grid.CELL_SIZE / 2 # a mount closer than this to Ship Root's X = 0 has no mirror image + + +def _add_row(state, **fields): + """The one row creation: a new layout row, with HardpointData's defaults for anything not named.""" + hp = state.hardpoints.add() + for name, value in fields.items(): + setattr(hp, name, value) + return hp + + +def _mount_objects(collection): + """The weapon-mount objects by their aetheria.id.""" + return {obj["aetheria.id"]: obj for obj in collection.all_objects + if obj.get("aetheria.role") == MOUNT_ROLE and isinstance(obj.get("aetheria.id"), str)} + + +def _muzzles(mount): + return sorted((child for child in mount.children if child.get("aetheria.role") == MUZZLE_ROLE), + key=lambda child: (int(child.get("aetheria.order", 0)), child.name)) + + +def _mount_frame(context): + """(collection, layout buffer, Ship Root, grid_origin) for a mount helper: the buffer must be this ship's and the grid + must match it.""" + collection, path = _layout_path(context) + state = context.scene.aetheria_layout + if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: + raise ValueError("Load this collection's layout first") + root = _ship_root(collection) + origin = collection.get("aetheria.grid_origin") + if root is None or origin is None: + raise ValueError("Rasterise first: a mount needs the ship's Ship Root and grid") + if not _grid_current(collection, state): + raise ValueError("Rasterise again: the grid no longer matches the layout") + return collection, state, root, (float(origin[0]), float(origin[1])) + + +def _root_local(root, obj): + """obj's origin in Ship Root's frame.""" + return root.matrix_world.inverted() @ obj.matrix_world.translation + + +def _derived_position(state, origin, local, footprint): + """The one derivation of a mounted row's Position: the cell at the object's origin (Ship Root frame), offset by the + footprint's centre. Refuses a Position outside the grid.""" + width, height = _parse_footprint(footprint)[0][:2] + position = hull_grid.mounted_position(hull_grid.cell_at(origin, (local[0], local[1])), width, height) + if not (0 <= position[0] < state.width and 0 <= position[1] < state.height): + raise ValueError("The mount lies outside the grid") + return position + + +def _selected_mount(context, collection): + obj = context.active_object + if obj is None or obj.get("aetheria.role") != MOUNT_ROLE or obj.name not in collection.all_objects: + raise ValueError("Select a weapon mount") + return obj + + +def _mount_row(state, mount_id): + row = next((hp for hp in state.hardpoints if hp.mount_id == mount_id), None) + if row is None: + raise ValueError("The weapon mount has no layout row") + return row + + +def _new_mount_id(collection, state): + taken = {hp.mount_id for hp in state.hardpoints} | set(_mount_objects(collection)) + n = 1 + while f"weapon.{n}" in taken: + n += 1 + return f"weapon.{n}" + + +def _make_empty(collection, anchor_id, role, parent, local, yaw=0.0, order=None): + empty = bpy.data.objects.new(anchor_id, None) + empty["aetheria.role"] = role + empty["aetheria.id"] = anchor_id + if order is not None: + empty["aetheria.order"] = order + collection.objects.link(empty) + _attach_to_root(empty, parent) + empty.location = tuple(local) + empty.rotation_euler = (0.0, 0.0, yaw) + return empty + + +def _make_muzzle(collection, mount, k, local, order): + return _make_empty(collection, f"{mount['aetheria.id']}.muzzle.{k}", MUZZLE_ROLE, mount, local, order=order) + + +def _add_mount(collection, state, root, origin, kind, rotation, local, footprint="#"): + """A weapon-mount object under Ship Root and its row, as a matching pair; returns the mount.""" + position = _derived_position(state, origin, local, footprint) + mount_id = _new_mount_id(collection, state) + mount = _make_empty(collection, mount_id, MOUNT_ROLE, root, local, hull_grid.facing_yaw(rotation)) + _add_row(state, kind=kind, mount_id=mount_id, rotation=str(rotation), footprint=footprint, + x=position[0], y=position[1]) + return mount + + +class AETHERIA_OT_add_weapon(bpy.types.Operator): + bl_idname = "aetheria.add_ship_weapon" + bl_label = "Add Weapon" + bl_description = "Add a weapon mount and its first muzzle at the 3D cursor, with a layout row" + bl_options = {"REGISTER", "UNDO"} + kind: bpy.props.EnumProperty(name="Type", items=WEAPON_TYPES) + rotation: bpy.props.EnumProperty(name="Rotation", items=ROTATIONS) + + def execute(self, context): + try: + collection, state, root, origin = _mount_frame(context) + local = root.matrix_world.inverted() @ context.scene.cursor.location + mount = _add_mount(collection, state, root, origin, self.kind, int(self.rotation), local) + _make_muzzle(collection, mount, 1, MUZZLE_OFFSET, 1) + except _EXPECTED as exc: + return _refuse(self, exc) + return {"FINISHED"} + + +class AETHERIA_OT_add_muzzle(bpy.types.Operator): + bl_idname = "aetheria.add_ship_muzzle" + bl_label = "Add Muzzle" + bl_description = "Add a muzzle under the selected weapon mount" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + try: + collection, _, _, _ = _mount_frame(context) + mount = _selected_mount(context, collection) + existing = _muzzles(mount) + names = {child.name for child in existing} + k = len(existing) + 1 + while f"{mount['aetheria.id']}.muzzle.{k}" in names: + k += 1 + order = max((int(child.get("aetheria.order", 0)) for child in existing), default=0) + 1 + _make_muzzle(collection, mount, k, MUZZLE_OFFSET, order) + except _EXPECTED as exc: + return _refuse(self, exc) + return {"FINISHED"} + + +class AETHERIA_OT_mirror_mount(bpy.types.Operator): + bl_idname = "aetheria.mirror_ship_mount" + bl_label = "Mirror Mount" + bl_description = "Duplicate the selected weapon mount and its muzzles across the centreline, as a new row" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + try: + collection, state, root, origin = _mount_frame(context) + mount = _selected_mount(context, collection) + row = _mount_row(state, mount["aetheria.id"]) + local = _root_local(root, mount) + if abs(local.x) < CENTRELINE: + raise ValueError("The weapon mount is on the centreline; it has no mirror image") + mirrored = _add_mount(collection, state, root, origin, row.kind, + hull_grid.mirror_rotation(int(row.rotation)), (-local.x, local.y, local.z), + hull_grid.mirror_footprint(row.footprint)) + new_row = _mount_row(state, mirrored["aetheria.id"]) + for name in ("armor", "firing_arc"): # the author's own numbers, copied; the helper invents none + setattr(new_row, name, getattr(row, name)) + for k, muzzle in enumerate(_muzzles(mount), start=1): + _make_muzzle(collection, mirrored, k, (-muzzle.location.x, muzzle.location.y, muzzle.location.z), + int(muzzle.get("aetheria.order", 0))) + except _EXPECTED as exc: + return _refuse(self, exc) + return {"FINISHED"} + + +class AETHERIA_OT_remove_mount(bpy.types.Operator): + bl_idname = "aetheria.remove_ship_mount" + bl_label = "Remove Mount" + bl_description = "Delete the selected weapon mount, its muzzles and its layout row" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + try: + collection, state, _, _ = _mount_frame(context) + mount = _selected_mount(context, collection) + mount_id = mount["aetheria.id"] + for index in reversed(range(len(state.hardpoints))): + if state.hardpoints[index].mount_id == mount_id: + state.hardpoints.remove(index) + for muzzle in _muzzles(mount): + _discard_object(muzzle) + _discard_object(mount) + except _EXPECTED as exc: + return _refuse(self, exc) + return {"FINISHED"} + + +def _plan_mounts(collection, state, root, path): + """(row, weapon-mount object, Position, yaw) for every buffer row whose Transform names a weapon-mount object: the + Position from the object's placement, the yaw from the row's Rotation. Refuses before anything changes.""" + mounts = _mount_objects(collection) + if not mounts: + return [] + if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: + raise ValueError("Load Layout first: the weapon mounts need this ship's layout") + paired = [(hp, mounts[hp.mount_id]) for hp in state.hardpoints if hp.mount_id in mounts] + if not paired: + return [] + origin = collection.get("aetheria.grid_origin") + if root is None or origin is None: + raise ValueError("Rasterise first: a mount needs the ship's Ship Root and grid") + plan = [] + for hp, obj in paired: + if obj.parent != root: + raise ValueError("A weapon mount must be parented to Ship Root") + position = _derived_position(state, (float(origin[0]), float(origin[1])), _root_local(root, obj), hp.footprint) + plan.append((hp, obj, position, hull_grid.facing_yaw(int(hp.rotation)))) + return plan + + +def _apply_mounts(context, collection, state, plan): + """Writes the planned Positions into the buffer and yaws into the objects, then saves the buffer through + _save_layout, so the buffer and the .cc agree.""" + if not plan: + return + for hp, obj, position, yaw in plan: + hp.x, hp.y = position + obj.rotation_mode = "XYZ" + obj.rotation_euler = (0.0, 0.0, yaw) + _save_layout(context, collection, state) + + def _encode_buffer(state): """The layout buffer as ship_cc stores it: (shape, hardpoint rows).""" shape = [state.width, state.height, [cell.occupied for cell in state.cells]] @@ -911,8 +1144,10 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): if read(path, cultlib).ship.body[0] != ship_id: raise ValueError("The bound collection ID does not match its .cc ship ID") root = _ship_root(collection) + mounts = _plan_mounts(collection, state, root, path) if root is not None: _frame_ship(context, collection, root, path, saved_shape) + _apply_mounts(context, collection, state, mounts) anchors = _anchors(collection) _export_glb(context, collection, str(Path(path).with_name(MODEL_ASSET))) replace_visual(path, cultlib, ship_id, MODEL_ASSET, anchors) @@ -993,20 +1228,34 @@ class AETHERIA_PT_ship(bpy.types.Panel): for x in range(state.width): row.prop(state.cells[x * state.height + y], "occupied", text="") layout.label(text="Hardpoints") + try: + mounts = _mount_objects(_ship_collection(context)) + except ValueError: + mounts = {} for index, hp in enumerate(state.hardpoints): box = layout.box() header = box.row(align=True) header.prop(hp, "mount_id") - header.operator("aetheria.remove_ship_hardpoint", text="", icon="X").index = index + if hp.mount_id not in mounts: # a mounted row goes with its object: Remove Mount + header.operator("aetheria.remove_ship_hardpoint", text="", icon="X").index = index box.prop(hp, "kind") position = box.row(align=True) + position.enabled = hp.mount_id not in mounts position.prop(hp, "x") position.prop(hp, "y") + if hp.mount_id in mounts: + box.label(text="Mount object: Position follows it", icon="EMPTY_ARROWS") box.prop(hp, "footprint") box.prop(hp, "rotation") box.prop(hp, "armor") box.prop(hp, "firing_arc") layout.operator("aetheria.add_ship_hardpoint", icon="ADD") + mount_box = layout.box() + mount_box.label(text="Mounts") + mount_box.operator("aetheria.add_ship_weapon", icon="ADD") + mount_box.operator("aetheria.add_ship_muzzle", icon="ADD") + mount_box.operator("aetheria.mirror_ship_mount", icon="MOD_MIRROR") + mount_box.operator("aetheria.remove_ship_mount", icon="X") layout.operator("aetheria.save_ship_layout", icon="FILE_TICK") self._draw_package(context, layout) @@ -1033,6 +1282,7 @@ class AETHERIA_PT_ship(bpy.types.Panel): classes = (AETHERIA_PG_cell, AETHERIA_PG_hardpoint, AETHERIA_PG_layout, AETHERIA_OT_load_layout, AETHERIA_OT_resize_layout, AETHERIA_OT_add_hardpoint, AETHERIA_OT_remove_hardpoint, + AETHERIA_OT_add_weapon, AETHERIA_OT_add_muzzle, AETHERIA_OT_mirror_mount, AETHERIA_OT_remove_mount, AETHERIA_OT_save_layout, AETHERIA_OT_bind_ship_collection, AETHERIA_OT_capture_ship_lines, AETHERIA_OT_new_ship, AETHERIA_OT_flip_nose, AETHERIA_OT_rasterise_hull, AETHERIA_OT_package_ship, AETHERIA_AP_preferences, AETHERIA_PT_ship) diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py index fce193892..d6e4a1ae2 100644 --- a/tools/blender/aetheria_ships/hull_grid.py +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -103,3 +103,39 @@ def convex_outline(points_xy): if len(outline) < 3: raise ValueError("The points have no outline: they are fewer than three or collinear") return outline + + +# ItemRotation values, in ship_cc.ROTATION_NAMES order. +ROTATION_NONE, ROTATION_COUNTER_CLOCKWISE, ROTATION_REVERSED, ROTATION_CLOCKWISE = 0, 1, 2, 3 +_FACING_YAW = { + ROTATION_NONE: 0.0, + ROTATION_COUNTER_CLOCKWISE: math.pi / 2, + ROTATION_REVERSED: math.pi, + ROTATION_CLOCKWISE: -math.pi / 2, +} + + +def mounted_position(cell, footprint_w, footprint_h): + """The hardpoint Position (its footprint's origin cell) whose w x h footprint is centred on cell: ShipAuthoringStore + places footprint cell (cx, cy) at Position + (cx, cy), and the footprint's centre is ((w - 1) // 2, (h - 1) // 2).""" + return (cell[0] - (footprint_w - 1) // 2, cell[1] - (footprint_h - 1) // 2) + + +def facing_yaw(rotation): + """Radians about Z that turn a mount empty so its local -Y points along the hardpoint Rotation's facing: None the + nose (-Y), CounterClockwise port (+X), Reversed aft (+Y), Clockwise starboard (-X).""" + return _FACING_YAW[rotation] + + +def mirror_rotation(rotation): + """The Rotation of a hardpoint mirrored across the centreline: Clockwise and CounterClockwise swap.""" + if rotation == ROTATION_CLOCKWISE: + return ROTATION_COUNTER_CLOCKWISE + if rotation == ROTATION_COUNTER_CLOCKWISE: + return ROTATION_CLOCKWISE + return rotation + + +def mirror_footprint(footprint): + """A footprint text ('#' and '.' rows separated by '/') mirrored across the centreline: each row reversed.""" + return "/".join(row[::-1] for row in footprint.split("/")) diff --git a/tools/blender/tests/smoke_mounts.py b/tools/blender/tests/smoke_mounts.py new file mode 100644 index 000000000..afc31012d --- /dev/null +++ b/tools/blender/tests/smoke_mounts.py @@ -0,0 +1,305 @@ +"""Headless Blender smoke for the add-on's weapon mounts, the gunboat path: Add Weapon, Add Muzzle, Mirror Mount, Remove +Mount, and what Package derives from them. + + blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_mounts.py + +Environment as smoke_package.py's header says: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER (the published AetherDb under its +ModTools, no dotnet), and msgpack importable. The ship is created under /GameData/Mods/smoke.mounts, which +this smoke removes when it ends. + +The ship is smoke_frame's sphere, centred on the origin, through New Ship (6 x 10 cells, cell x = 0 at +X). Pass 1 adds a +Ballistic gun on the starboard flank as Clockwise: its row's Rotation is 3, its Position is the cell under the mount, and +its muzzle sits 0.5 m toward -X of it. Mirror Mount makes a CounterClockwise row at the mirrored cell with its muzzle +toward +X, and refuses a mount on the centreline. Pass 2 packages: the Clockwise muzzle node in the GLB turns glTF +Z to +glTF -X, and the mirrored one to +X. Pass 3 moves a weapon two cells by script and sets another's Rotation to Reversed in +the panel state: Package moves the first row's Position by two cells, leaves the internal Reactor row alone, yaws the +second mount to 180 degrees and the first back to its Rotation's yaw, and the validator accepts the ship. Pass 4 removes a +mount with two muzzles: its row, the mount and both muzzles go, and the other rows stay. +""" + +import json +import math +import os +import shutil +import struct +import sys +import traceback +from pathlib import Path + +import addon_utils +import bmesh +import bpy + +REPO = Path(__file__).resolve().parents[3] +PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory") +SHIP_ID = "smoke.mounts" +GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb")) +MODS = GAME / "GameData" / "Mods" +COLLECTION = "Smoke Mounts" +NAMES_NOT_ECHOED = (SHIP_ID, COLLECTION, "weapon.", str(GAME), GAME.name) +CLOCKWISE, COUNTER_CLOCKWISE, REVERSED = 3, 1, 2 +BALLISTIC = "10" + + +def new_object(collection, name, mesh, location=(0, 0, 0), parent=None): + obj = bpy.data.objects.new(name, mesh) + obj.location = location + obj.parent = parent + collection.objects.link(obj) + return obj + + +def tag(obj, role, anchor_id): + obj["aetheria.role"] = role + obj["aetheria.id"] = anchor_id + return obj + + +def quad(name, size): + mesh = bpy.data.meshes.new(name) + half = size / 2 + mesh.from_pydata([(-half, -half, 0), (half, -half, 0), (half, half, 0), (-half, half, 0)], [], [(0, 1, 2, 3)]) + return mesh + + +def tetrahedron(name): + mesh = bpy.data.meshes.new(name) + mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)], [], [(0, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3)]) + return mesh + + +def run(operator): + try: + return operator(), "" + except RuntimeError as exc: + print(f"OPERATOR error: {exc}") + return {"CANCELLED"}, str(exc) + + +def require(condition, message): + if not condition: + raise SystemExit(message) + + +def refused(operator, expected, what): + result, message = run(operator) + require(result == {"CANCELLED"}, f"{what} was not refused") + require(expected in message, f"{what}: the report is not the fixed text ({message!r})") + for name in NAMES_NOT_ECHOED: + require(name not in message, f"{what}: the report echoes an input ({name})") + + +def select(obj): + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + +def approx(a, b, tolerance=1e-3): + return all(abs(x - y) <= tolerance for x, y in zip(a, b)) + + +def rows(scene): + return [(hp.mount_id, int(hp.kind), (hp.x, hp.y), int(hp.rotation), hp.footprint, hp.armor, hp.firing_arc) + for hp in scene.aetheria_layout.hardpoints] + + +def row(scene, mount_id): + found = [r for r in rows(scene) if r[0] == mount_id] + require(len(found) == 1, f"Expected one row for a mount, found {len(found)}") + return found[0] + + +def world(obj): + bpy.context.view_layer.update() + return tuple(obj.matrix_world.translation) + + +def glb_world_rotation_of(path, name): + """The GLB node's world rotation applied to glTF +Z.""" + data = Path(path).read_bytes() + length, kind = struct.unpack_from(" Date: Mon, 12 Oct 2026 03:47:44 +0200 Subject: [PATCH 2/4] smoke_mounts: an asymmetric footprint shows Mirror Mount turning it All commits build. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_mounts.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/blender/tests/smoke_mounts.py b/tools/blender/tests/smoke_mounts.py index afc31012d..495a0ecc9 100644 --- a/tools/blender/tests/smoke_mounts.py +++ b/tools/blender/tests/smoke_mounts.py @@ -109,6 +109,10 @@ def row(scene, mount_id): return found[0] +def set_footprint(scene, mount_id, footprint): + next(hp for hp in scene.aetheria_layout.hardpoints if hp.mount_id == mount_id).footprint = footprint + + def world(obj): bpy.context.view_layer.update() return tuple(obj.matrix_world.translation) @@ -206,12 +210,15 @@ def main(): require(approx(tuple(m - g for m, g in zip(muzzle_at, gun_at)), (-0.5, 0.0, 0.0)), "The Clockwise muzzle is not 0.5 m toward -X of its mount") + set_footprint(scene, "weapon.1", "#.") # an asymmetric footprint shows the mirror turning it select(gun) result, message = run(bpy.ops.aetheria.mirror_ship_mount) require(result == {"FINISHED"}, f"Mirror Mount was refused: {message}") twin = bpy.data.objects["weapon.2"] twin_row = row(scene, "weapon.2") - require(twin_row == ("weapon.2", 10, (1, 5), COUNTER_CLOCKWISE, "#", 0.0, 0.0), f"The mirrored row is wrong: {twin_row}") + require(twin_row == ("weapon.2", 10, (1, 5), COUNTER_CLOCKWISE, ".#", 0.0, 0.0), f"The mirrored row is wrong: {twin_row}") + set_footprint(scene, "weapon.1", "#") + set_footprint(scene, "weapon.2", "#") twin_muzzle = bpy.data.objects["weapon.2.muzzle.1"] twin_at = world(twin) require(approx(twin_at, (3.0, -1.0, 0.0)), f"The mirrored mount is not across the centreline: {twin_at}") From 56129b43d5074ab820e8d1f338ba92a910f5c174 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 04:03:12 +0200 Subject: [PATCH 3/4] Package derives no mounts for a ship with no Ship Root, and refuses a framed one whose layout is not loaded smoke_package's hand-tagged ship has a weapon-mount object, no Ship Root and no loaded buffer; the first mounts commit refused it. A ship with no frame has no grid to derive from, so Package leaves its rows alone as before. A framed ship with a mount and an unloaded buffer is refused with 'Load Layout first'. All commits build. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 6 +++--- tools/blender/tests/smoke_mounts.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 154912ec6..913b1da57 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -399,7 +399,7 @@ def _plan_mounts(collection, state, root, path): """(row, weapon-mount object, Position, yaw) for every buffer row whose Transform names a weapon-mount object: the Position from the object's placement, the yaw from the row's Rotation. Refuses before anything changes.""" mounts = _mount_objects(collection) - if not mounts: + if not mounts or root is None: # a ship with no Ship Root has no grid to derive from: its rows are the author's return [] if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: raise ValueError("Load Layout first: the weapon mounts need this ship's layout") @@ -407,8 +407,8 @@ def _plan_mounts(collection, state, root, path): if not paired: return [] origin = collection.get("aetheria.grid_origin") - if root is None or origin is None: - raise ValueError("Rasterise first: a mount needs the ship's Ship Root and grid") + if origin is None: + raise ValueError("Rasterise first: a mount needs the ship's grid") plan = [] for hp, obj in paired: if obj.parent != root: diff --git a/tools/blender/tests/smoke_mounts.py b/tools/blender/tests/smoke_mounts.py index 495a0ecc9..879781f3f 100644 --- a/tools/blender/tests/smoke_mounts.py +++ b/tools/blender/tests/smoke_mounts.py @@ -286,6 +286,16 @@ def main(): by_mount = {fields["Transform"]: fields for fields in map(aetheria_ships.ship_cc.decode_hardpoint, saved)} require(tuple(by_mount["weapon.1"]["Position"]) == (4, 3) and by_mount["weapon.2"]["Rotation"] == REVERSED and tuple(by_mount["reactor.a"]["Position"]) == (2, 2), "The .cc does not hold what Package derived") + # A framed ship whose layout buffer is not loaded cannot be derived from: Package refuses and changes nothing. + state = scene.aetheria_layout + ship_id, glb_before = state.ship_id, glb.read_bytes() + state.ship_id = "" + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"CANCELLED"} and "Load Layout first" in scene.aetheria_package_report + and all(name not in scene.aetheria_package_report for name in NAMES_NOT_ECHOED), + f"Package took an unloaded layout: {scene.aetheria_package_report}") + require(glb.read_bytes() == glb_before, "A refused Package wrote the GLB") + state.ship_id = ship_id print("PASS 3 package derives ok") From 96b255541c1ca89aff1b13f50db0c7f0172a7d76 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 04:33:26 +0200 Subject: [PATCH 4/4] Mirror Mount reflects the footprint's cells about the centre line, derived once from the footprint hull_grid.mirror_position gives the twin Position as grid_w - a - w, so an even-width footprint mirrors to its true reflection and the cell lookup's tie rule no longer decides a mirror; the twin's object stands in the cell that Position puts it in, so Package derives the same Position back. test_hull_grid pins the reflection on odd and even grids and rows of 4 or more cells; smoke_mounts runs footprint widths 1 to 4 at every cell of an even and an odd grid, the boundary tie, the armour and arc copy, the muzzle's local X, the centre-line threshold, the grid edge and the parent check, and keeps a mirrored pair of two-cell footprints through Package and the validator. Every commit builds. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 8 +- tools/blender/aetheria_ships/hull_grid.py | 19 ++- tools/blender/tests/smoke_mounts.py | 149 +++++++++++++++++++++- tools/blender/tests/test_hull_grid.py | 28 ++++ 4 files changed, 198 insertions(+), 6 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 913b1da57..75e70b1f6 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -359,8 +359,14 @@ class AETHERIA_OT_mirror_mount(bpy.types.Operator): local = _root_local(root, mount) if abs(local.x) < CENTRELINE: raise ValueError("The weapon mount is on the centreline; it has no mirror image") + # The twin's footprint is the reflection of this one's cells; its object stands in the cell that Position + # puts it in (never at -x, whose cell lookup breaks ties one way), so Package derives the Position back. + width, height = _parse_footprint(row.footprint)[0][:2] + position = hull_grid.mirror_position(_derived_position(state, origin, local, row.footprint), width, + state.width) + twin_x = hull_grid.cell_centre(origin, *hull_grid.mounted_cell(position, width, height))[0] mirrored = _add_mount(collection, state, root, origin, row.kind, - hull_grid.mirror_rotation(int(row.rotation)), (-local.x, local.y, local.z), + hull_grid.mirror_rotation(int(row.rotation)), (twin_x, local.y, local.z), hull_grid.mirror_footprint(row.footprint)) new_row = _mount_row(state, mirrored["aetheria.id"]) for name in ("armor", "firing_arc"): # the author's own numbers, copied; the helper invents none diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py index d6e4a1ae2..b38770339 100644 --- a/tools/blender/aetheria_ships/hull_grid.py +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -115,10 +115,27 @@ _FACING_YAW = { } +def _centre_offset(n): + """The footprint cell, counted from its origin, that stands for an n-cell extent's centre: the lower of the two + middle cells when n is even.""" + return (n - 1) // 2 + + def mounted_position(cell, footprint_w, footprint_h): """The hardpoint Position (its footprint's origin cell) whose w x h footprint is centred on cell: ShipAuthoringStore places footprint cell (cx, cy) at Position + (cx, cy), and the footprint's centre is ((w - 1) // 2, (h - 1) // 2).""" - return (cell[0] - (footprint_w - 1) // 2, cell[1] - (footprint_h - 1) // 2) + return (cell[0] - _centre_offset(footprint_w), cell[1] - _centre_offset(footprint_h)) + + +def mounted_cell(position, footprint_w, footprint_h): + """The cell a mount's object stands in for a hardpoint at position: the inverse of mounted_position.""" + return (position[0] + _centre_offset(footprint_w), position[1] + _centre_offset(footprint_h)) + + +def mirror_position(position, footprint_w, grid_w): + """The Position of the mirror image of a footprint across the grid's centre line: a footprint on cells [a, a + w) of + a grid_w-wide grid mirrors to cells [grid_w - a - w, grid_w - a). It depends on the footprint's extent alone.""" + return (grid_w - position[0] - footprint_w, position[1]) def facing_yaw(rotation): diff --git a/tools/blender/tests/smoke_mounts.py b/tools/blender/tests/smoke_mounts.py index 879781f3f..1c0944f6e 100644 --- a/tools/blender/tests/smoke_mounts.py +++ b/tools/blender/tests/smoke_mounts.py @@ -29,10 +29,13 @@ from pathlib import Path import addon_utils import bmesh import bpy +from mathutils import Vector REPO = Path(__file__).resolve().parents[3] PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory") SHIP_ID = "smoke.mounts" +ODD_ID = "smoke.mounts.odd" +ODD_LENGTH = 7 GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb")) MODS = GAME / "GameData" / "Mods" COLLECTION = "Smoke Mounts" @@ -146,6 +149,101 @@ def glb_world_rotation_of(path, name): return (2 * (x * z + w * y), 2 * (y * z - w * x), 1 - 2 * (x * x + y * y)) +def new_gun(scene, root, location, rotation=CLOCKWISE): + """Add Weapon with the 3D cursor at location in Ship Root's frame; returns the new row's mount id.""" + before = {r[0] for r in rows(scene)} + select(root) # the active object names the ship collection + scene.cursor.location = root.matrix_world @ Vector(location) + result, message = run(lambda: bpy.ops.aetheria.add_ship_weapon(kind=BALLISTIC, rotation=str(rotation))) + require(result == {"FINISHED"}, f"Add Weapon was refused: {message}") + (mount_id,) = {r[0] for r in rows(scene)} - before + return mount_id + + +def remove_mount(mount_id): + select(bpy.data.objects[mount_id]) + require(run(bpy.ops.aetheria.remove_ship_mount)[0] == {"FINISHED"}, "Remove Mount was refused") + + +def mirror_matrix(scene, root, origin, sphere): + """Mirror Mount against the reflection of the footprint's cells, for every footprint width 1 to 4 at every cell of + the grid: a footprint on cells [a, a + w) of a W-wide grid has its twin on [W - a - w, W - a), whatever the grid's + parity, and a mount within a cell of the centre line has no twin. The expected cells are computed here from the + footprint, never read back from the twin.""" + state = scene.aetheria_layout + grid_w, grid_h = state.width, state.height + y_cell = 1 + y = origin[1] - 2.0 * y_cell + mirrored = refused_count = 0 + baseline = rows(scene) + for w in (1, 2, 3, 4): + for c in range(grid_w): # the cell the mount's object stands in; a footprint is centred on it + a = c - (w - 1) // 2 + if a < 0 or a + w > grid_w: + continue + x = origin[0] - 2.0 * c + mount_id = new_gun(scene, root, (x, y, 0.0)) + set_footprint(scene, mount_id, "#" * w) + select(bpy.data.objects[mount_id]) + before = rows(scene) + if abs(x) < 1.0: + refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount near the centre line") + require(rows(scene) == before, "A refused Mirror Mount changed a row") + refused_count += 1 + else: + require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, f"Mirror Mount was refused (width {w}, cell {c})") + (twin_id,) = {r[0] for r in rows(scene)} - {r[0] for r in before} + twin = row(scene, twin_id) + expected = (grid_w - a - w, y_cell) + require(twin[2] == expected, f"A {w}-wide footprint at cell {a} of {grid_w} mirrored to {twin[2]}, not {expected}") + require(twin[4] == "#" * w, "The mirrored footprint is not the reflection of the footprint") + mirrored += 1 + remove_mount(twin_id) + remove_mount(mount_id) + require(mirrored >= 6 and (grid_w % 2 == 0 or refused_count >= 1), f"The matrix ran too little ({mirrored}, {refused_count})") + require(rows(scene) == baseline, "The matrix left rows behind") + + # A mount exactly on a cell boundary: its twin is the reflection of the cell it was placed in, not a second rounding. + k = grid_w // 2 + mount_id = new_gun(scene, root, (origin[0] - 2.0 * k - 1.0, y, 0.0)) + source = row(scene, mount_id) + hp = next(h for h in state.hardpoints if h.mount_id == mount_id) + hp.armor, hp.firing_arc = 3.0, 45.0 # the author's numbers + muzzle = bpy.data.objects[f"{mount_id}.muzzle.1"] + muzzle.location.x = 0.3 + select(bpy.data.objects[mount_id]) + before_ids = {r[0] for r in rows(scene)} + require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount of a boundary mount was refused") + (twin_id,) = {r[0] for r in rows(scene)} - before_ids + twin = row(scene, twin_id) + require(twin[2] == (grid_w - 1 - source[2][0], y_cell), f"The boundary mount's twin is at {twin[2]}") + require(twin[3] == COUNTER_CLOCKWISE and twin[5:] == (3.0, 45.0), f"The twin's rotation or armour and arc are wrong: {twin}") + gun_at, twin_at = world(bpy.data.objects[mount_id]), world(bpy.data.objects[twin_id]) + source_offset = tuple(m - g for m, g in zip(world(muzzle), gun_at)) + twin_offset = tuple(m - g for m, g in zip(world(bpy.data.objects[f"{twin_id}.muzzle.1"]), twin_at)) + require(approx(twin_offset, (-source_offset[0], source_offset[1], source_offset[2])), + f"The mirrored muzzle is not the reflection of the source muzzle: {source_offset} vs {twin_offset}") + remove_mount(twin_id) + remove_mount(mount_id) + + # The centre line is a cell wide: half a metre off it has no twin. + mount_id = new_gun(scene, root, (0.5, y, 0.0)) + select(bpy.data.objects[mount_id]) + before = rows(scene) + refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a mount half a metre off the centre line") + require(rows(scene) == before, "A refused Mirror Mount changed a row") + remove_mount(mount_id) + + # A mount whose cell is one past the grid's last is refused, on either axis, and changes nothing. + for edge in ((origin[0] - 2.0 * grid_w, y), (origin[0], origin[1] - 2.0 * grid_h)): + before, objects = rows(scene), len(bpy.data.objects) + scene.cursor.location = root.matrix_world @ Vector((edge[0], edge[1], 0.0)) + select(sphere) + refused(lambda: bpy.ops.aetheria.add_ship_weapon(kind=BALLISTIC, rotation=str(CLOCKWISE)), + "outside the grid", "Add Weapon past the grid's edge") + require(rows(scene) == before and len(bpy.data.objects) == objects, "A refused Add Weapon changed something") + + 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) @@ -210,15 +308,12 @@ def main(): require(approx(tuple(m - g for m, g in zip(muzzle_at, gun_at)), (-0.5, 0.0, 0.0)), "The Clockwise muzzle is not 0.5 m toward -X of its mount") - set_footprint(scene, "weapon.1", "#.") # an asymmetric footprint shows the mirror turning it select(gun) result, message = run(bpy.ops.aetheria.mirror_ship_mount) require(result == {"FINISHED"}, f"Mirror Mount was refused: {message}") twin = bpy.data.objects["weapon.2"] twin_row = row(scene, "weapon.2") - require(twin_row == ("weapon.2", 10, (1, 5), COUNTER_CLOCKWISE, ".#", 0.0, 0.0), f"The mirrored row is wrong: {twin_row}") - set_footprint(scene, "weapon.1", "#") - set_footprint(scene, "weapon.2", "#") + require(twin_row == ("weapon.2", 10, (1, 5), COUNTER_CLOCKWISE, "#", 0.0, 0.0), f"The mirrored row is wrong: {twin_row}") twin_muzzle = bpy.data.objects["weapon.2.muzzle.1"] twin_at = world(twin) require(approx(twin_at, (3.0, -1.0, 0.0)), f"The mirrored mount is not across the centreline: {twin_at}") @@ -249,6 +344,17 @@ def main(): require(rows(scene) == others, "Remove Mount changed a row besides its own") print("PASS 4 remove mount ok") + mirror_matrix(scene, root, (origin[0], origin[1]), sphere) + print("PASS 5 mirror matrix ok") + + # A mirrored pair of two-cell footprints stays where Mirror Mount put it through Package and the validator. + pair = new_gun(scene, root, (-1.0, 1.0, 0.0)) + set_footprint(scene, pair, "##") + select(bpy.data.objects[pair]) + require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount of the pair was refused") + pair_rows = [r for r in rows(scene) if r[0] not in ("weapon.1", "weapon.2") and r[0].startswith("weapon.")] + require(sorted(r[2] for r in pair_rows) == [(1, 4), (3, 4)], f"The pair is not a reflection: {pair_rows}") + # Pass 2: Package; the GLB's muzzle nodes face outboard. select(sphere) result, message = run(bpy.ops.aetheria.save_ship_layout) @@ -261,6 +367,7 @@ def main(): port = glb_world_rotation_of(glb, "weapon.2.muzzle.1") require(approx(starboard, (-1.0, 0.0, 0.0)), f"The Clockwise muzzle node does not turn glTF +Z to -X: {starboard}") require(approx(port, (1.0, 0.0, 0.0)), f"The mirrored muzzle node does not turn glTF +Z to +X: {port}") + require([r for r in rows(scene) if r in pair_rows] == pair_rows, "Package moved a mirrored pair") print("PASS 2 package and glb ok") # Pass 3: a moved mount and a changed Rotation; Package derives Position and yaw from them. @@ -296,8 +403,40 @@ def main(): f"Package took an unloaded layout: {scene.aetheria_package_report}") require(glb.read_bytes() == glb_before, "A refused Package wrote the GLB") state.ship_id = ship_id + require([r for r in rows(scene) if r in pair_rows] == pair_rows, "The second Package moved a mirrored pair") + twin.parent = None # a mount that is not under Ship Root cannot be derived from + row_before = rows(scene) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"CANCELLED"} and "must be parented" in scene.aetheria_package_report and rows(scene) == row_before, + f"Package took a mount outside Ship Root: {scene.aetheria_package_report}") + twin.parent = root print("PASS 3 package derives ok") + # Pass 6: the same mirror matrix on a ship whose grid is an odd number of cells wide. + for obj in list(collection.all_objects): # the first ship's objects hold the names the matrix looks mounts up by + bpy.data.objects.remove(obj) + odd = bpy.data.collections.new(COLLECTION + " Odd") + scene.collection.children.link(odd) + odd_mesh = bpy.data.meshes.new("Odd Sphere") + bm = bmesh.new() + bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.5) + bm.to_mesh(odd_mesh) + bm.free() + odd_sphere = new_object(odd, "Odd Source Sphere", odd_mesh) + odd_sphere.scale = (1, 0.6, 0.4) + select(odd_sphere) + scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = ODD_ID, "Smoke Mounts Odd" + scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", ODD_LENGTH + result, message = run(bpy.ops.aetheria.new_ship) + require(result == {"FINISHED"}, f"New Ship (odd) was refused: {message}") + result, message = run(bpy.ops.aetheria.save_ship_layout) + require(result == {"FINISHED"}, f"Save (odd) was refused: {message}") + odd_root = aetheria_ships._ship_root(odd) + odd_origin = odd["aetheria.grid_origin"] + require(scene.aetheria_layout.width % 2 == 1, f"The odd ship's grid is {scene.aetheria_layout.width} wide") + mirror_matrix(scene, odd_root, (odd_origin[0], odd_origin[1]), odd_sphere) + print("PASS 6 mirror matrix on an odd grid ok") + created_mods = not MODS.exists() existed = (MODS / SHIP_ID).exists() @@ -316,6 +455,8 @@ else: finally: if not existed: shutil.rmtree(MODS / SHIP_ID, ignore_errors=True) + if not existed: + shutil.rmtree(MODS / ODD_ID, ignore_errors=True) if created_mods and not existed: shutil.rmtree(MODS, ignore_errors=True) sys.stdout.flush() diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py index 87474af8a..ae310a949 100644 --- a/tools/blender/tests/test_hull_grid.py +++ b/tools/blender/tests/test_hull_grid.py @@ -198,12 +198,40 @@ class MountArithmeticTests(unittest.TestCase): def test_mirror_footprint_reverses_each_row(self): self.assertEqual(hull_grid.mirror_footprint("##./.#."), ".##/.#.") self.assertEqual(hull_grid.mirror_footprint("#"), "#") + for footprint, expected in {"#.": ".#", "#..#.": ".#..#", "##..": "..##", "#..../.##..": "....#/..##.", + "#.....": ".....#", "##.#.../......": "...#.##/......"}.items(): + self.assertEqual(hull_grid.mirror_footprint(footprint), expected, footprint) def test_mounted_position_offsets_the_cell_by_the_footprint_centre(self): for (w, h), expected in {(1, 1): (5, 4), (3, 1): (4, 4), (3, 3): (4, 3), (2, 1): (5, 4), (2, 2): (5, 4), (4, 2): (4, 4)}.items(): self.assertEqual(hull_grid.mounted_position((5, 4), w, h), expected, (w, h)) + def test_mirror_position_reflects_the_footprints_cells_on_odd_and_even_grids(self): + for grid_w in range(1, 10): + for w in range(1, grid_w + 1): + for a in range(0, grid_w - w + 1): + twin = hull_grid.mirror_position((a, 7), w, grid_w) + reflected = sorted(grid_w - 1 - (a + i) for i in range(w)) # each cell across the centre line + self.assertEqual([twin[0] + i for i in range(w)], reflected, (grid_w, w, a)) + self.assertEqual(twin[1], 7) + + def test_a_mirrored_footprint_keeps_its_picture_in_the_reflected_cells(self): + grid_w, a, footprint = 7, 1, "#.##" # cells 1, 3, 4 filled + twin = hull_grid.mirror_position((a, 0), 4, grid_w) + filled = {twin[0] + i for i, c in enumerate(hull_grid.mirror_footprint(footprint)) if c == "#"} + self.assertEqual(filled, {grid_w - 1 - c for c in (1, 3, 4)}) + + def test_an_object_in_the_mounted_cell_derives_the_position_back_for_every_width(self): + origin = (5.0, 9.0) + for w in range(1, 7): + for h in range(1, 4): + for position in ((0, 0), (3, 5), (6, 2)): + cell = hull_grid.mounted_cell(position, w, h) + centre = hull_grid.cell_centre(origin, *cell) + self.assertEqual(hull_grid.mounted_position(hull_grid.cell_at(origin, centre), w, h), position, + (w, h, position)) + if __name__ == "__main__": unittest.main()