Merge origin/master into merge/contact-reveals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 05:40:05 +02:00
commit efee2a2adc
4 changed files with 826 additions and 2 deletions

View file

@ -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,245 @@ 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")
# 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)), (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
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 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")
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 origin is None:
raise ValueError("Rasterise first: a mount needs the ship's 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 +1150,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 +1234,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 +1288,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)

View file

@ -103,3 +103,56 @@ 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 _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] - _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):
"""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("/"))

View file

@ -0,0 +1,463 @@
"""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 <game folder>/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
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"
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 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)
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("<II", data, 12)
require(kind == 0x4E4F534A, "The GLB's first chunk is not JSON")
nodes = json.loads(data[20:20 + length])["nodes"]
parents = {child: index for index, node in enumerate(nodes) for child in node.get("children", [])}
index = next((i for i, node in enumerate(nodes) if node.get("name") == name), None)
require(index is not None, "The GLB has no node for the muzzle")
def multiply(a, b):
ax, ay, az, aw = a
bx, by, bz, bw = b
return (aw * bx + ax * bw + ay * bz - az * by, aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw, aw * bw - ax * bx - ay * by - az * bz)
chain = []
while index is not None:
chain.append(nodes[index].get("rotation", (0.0, 0.0, 0.0, 1.0)))
index = parents.get(index)
q = (0.0, 0.0, 0.0, 1.0)
for rotation in reversed(chain):
q = multiply(q, rotation)
x, y, z, w = q
# q v q^-1 for v = (0, 0, 1)
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)
sys.path.insert(0, str(REPO / "tools" / "blender"))
addon_utils.enable("aetheria_ships", default_set=True, handle_error=None)
import aetheria_ships
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
require(not (MODS / SHIP_ID).exists(), f"{MODS / SHIP_ID} exists; remove it first")
path = str(MODS / SHIP_ID / "ship.cc")
scene = bpy.context.scene
collection = bpy.data.collections.new(COLLECTION)
scene.collection.children.link(collection)
sphere_mesh = bpy.data.meshes.new("Sphere")
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.5)
bm.to_mesh(sphere_mesh)
bm.free()
sphere = new_object(collection, "Source Sphere", sphere_mesh)
sphere.scale = (1, 0.6, 0.4)
select(sphere)
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = SHIP_ID, "Smoke Mounts"
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", 10
result, message = run(bpy.ops.aetheria.new_ship)
require(result == {"FINISHED"}, f"New Ship was refused: {message}")
root = bpy.data.objects["Ship Root"]
state = scene.aetheria_layout
require((state.width, state.height) == (6, 10), f"The grid is {state.width}x{state.height}")
origin = collection["aetheria.grid_origin"]
require(approx((origin[0], origin[1]), (5.0, 9.0), 0.2), f"The hull is not centred on X = 0: {tuple(origin)}")
# The packaged ship needs the four anchors Package does not make, and a drive.
tag(new_object(collection, "Map Icon", quad("Map Icon", 1.0), (0, 0, -1), root), "map-icon", "map")
tag(new_object(collection, "Hull Collider", tetrahedron("Hull Collider"), parent=root), "hull-collider", "collider")
tag(new_object(collection, "Shield", None, parent=root), "shield", "shield")
tag(new_object(collection, "Tractor", None, (0, 2, 0), root), "tractor", "tractor")
disc = new_object(collection, "Thruster", quad("Thruster", 0.4), (0, 8, 0), root)
tag(disc, "thruster-emitter", "thruster")
for mount, kind, x, y in (("thruster", "3", 2, 0), ("reactor.a", "5", 2, 2)):
bpy.ops.aetheria.add_ship_hardpoint()
hp = state.hardpoints[len(state.hardpoints) - 1]
hp.mount_id, hp.kind, hp.x, hp.y, hp.footprint = mount, kind, x, y, "#"
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"Save was refused: {message}")
reactor_before = row(scene, "reactor.a")
select(sphere)
# Pass 1: Add Weapon on the starboard flank, Clockwise; Mirror Mount.
scene.cursor.location = (-3.0, -1.0, 0.0)
result, message = run(lambda: bpy.ops.aetheria.add_ship_weapon(kind=BALLISTIC, rotation=str(CLOCKWISE)))
require(result == {"FINISHED"}, f"Add Weapon was refused: {message}")
gun = bpy.data.objects["weapon.1"]
require(gun.get("aetheria.role") == "weapon-mount" and gun.parent == root, "The mount is not a tagged child of Ship Root")
gun_row = row(scene, "weapon.1")
require(gun_row == ("weapon.1", 10, (4, 5), CLOCKWISE, "#", 0.0, 0.0), f"The gun's row is wrong: {gun_row}")
muzzles = [o for o in bpy.data.objects if o.get("aetheria.role") == "weapon-muzzle"]
require([m.get("aetheria.id") for m in muzzles] == ["weapon.1.muzzle.1"] and muzzles[0].parent == gun
and muzzles[0].get("aetheria.order") == 1, "Add Weapon did not make its one ordered muzzle")
gun_at, muzzle_at = world(gun), world(muzzles[0])
require(approx(gun_at, (-3.0, -1.0, 0.0)), f"The mount is not at the cursor: {gun_at}")
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")
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}")
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}")
require(approx(tuple(m - t for m, t in zip(world(twin_muzzle), twin_at)), (0.5, 0.0, 0.0)),
"The mirrored muzzle does not point toward +X")
scene.cursor.location = (0.2, 0.0, 0.0)
select(sphere)
require(run(lambda: bpy.ops.aetheria.add_ship_weapon(kind=BALLISTIC, rotation="0"))[0] == {"FINISHED"},
"Add Weapon on the centreline was refused")
centre = bpy.data.objects["weapon.3"]
select(centre)
before = rows(scene)
refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a centreline mount")
require(rows(scene) == before and bpy.data.objects.get("weapon.4") is None, "A refused Mirror Mount changed something")
print("PASS 1 add weapon and mirror ok")
# Pass 4 first, on the centreline mount (it must not reach Package): Add Muzzle, then Remove Mount.
select(centre)
require(run(bpy.ops.aetheria.add_ship_muzzle)[0] == {"FINISHED"}, "Add Muzzle was refused")
second = bpy.data.objects.get("weapon.3.muzzle.2")
require(second is not None and second.parent == centre and second.get("aetheria.order") == 2,
"Add Muzzle did not make the next ordered muzzle under the mount")
others = [r for r in rows(scene) if r[0] != "weapon.3"]
select(centre)
require(run(bpy.ops.aetheria.remove_ship_mount)[0] == {"FINISHED"}, "Remove Mount was refused")
require(bpy.data.objects.get("weapon.3") is None and bpy.data.objects.get("weapon.3.muzzle.1") is None
and bpy.data.objects.get("weapon.3.muzzle.2") is None, "Remove Mount left the mount or a muzzle")
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)
require(result == {"FINISHED"}, f"Save before Package was refused: {message}")
result, message = run(bpy.ops.aetheria.package_ship)
report = scene.aetheria_package_report
require(result == {"FINISHED"}, f"Package refused the gunboat ship: {report}")
glb = Path(path).with_name("ship.glb")
starboard = glb_world_rotation_of(glb, "weapon.1.muzzle.1")
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.
gun, twin = bpy.data.objects["weapon.1"], bpy.data.objects["weapon.2"]
gun.location.y += 4.0 # two cells aft
gun.rotation_euler.z = 0.3 # a hand-set facing: Package overwrites it from the row
for hp in scene.aetheria_layout.hardpoints:
if hp.mount_id == "weapon.2":
hp.rotation = str(REVERSED)
select(sphere)
require(run(bpy.ops.aetheria.save_ship_layout)[0] == {"FINISHED"}, "Save before the second Package was refused")
require(row(scene, "weapon.1")[2] == (4, 5), "The buffer's Position moved before Package")
result, message = run(bpy.ops.aetheria.package_ship)
report = scene.aetheria_package_report
require(result == {"FINISHED"}, f"Package refused the moved ship: {report}")
require(row(scene, "weapon.1")[2] == (4, 3), f"Package did not move the row two cells: {row(scene, 'weapon.1')[2]}")
require(row(scene, "weapon.2")[2] == (1, 5), "Package moved a mount that did not move")
require(row(scene, "reactor.a") == reactor_before, "Package changed an internal row")
require(abs(twin.rotation_euler.z - math.pi) < 1e-6 or abs(twin.rotation_euler.z + math.pi) < 1e-6,
f"The Reversed mount's yaw is not 180 degrees: {twin.rotation_euler.z}")
require(abs(gun.rotation_euler.z + math.pi / 2) < 1e-6, f"Package kept a hand-set yaw: {gun.rotation_euler.z}")
saved = aetheria_ships.ship_cc.read_layout(path, PACKAGES)[1]
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
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()
try:
main()
except SystemExit as stop:
print(f"SMOKE_MOUNTS_FAILED: {stop}", flush=True)
code = 1
except Exception:
traceback.print_exc()
print("SMOKE_MOUNTS_FAILED", flush=True)
code = 1
else:
print("SMOKE_MOUNTS_OK", flush=True)
code = 0
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()
os._exit(code)

View file

@ -7,6 +7,7 @@ hull_grid is loaded by path because the package __init__ imports bpy. Frame: nos
"""
import importlib.util
import math
import unittest
from pathlib import Path
@ -181,5 +182,56 @@ class ConvexOutlineTests(unittest.TestCase):
hull_grid.convex_outline(points)
class MountArithmeticTests(unittest.TestCase):
def test_facing_yaw_turns_local_minus_y_to_the_facing(self):
expected = {hull_grid.ROTATION_NONE: (0, -1), hull_grid.ROTATION_COUNTER_CLOCKWISE: (1, 0),
hull_grid.ROTATION_REVERSED: (0, 1), hull_grid.ROTATION_CLOCKWISE: (-1, 0)}
for rotation, (fx, fy) in expected.items():
yaw = hull_grid.facing_yaw(rotation)
x, y = math.sin(yaw), -math.cos(yaw) # R(yaw) applied to (0, -1)
self.assertAlmostEqual(x, fx, places=9, msg=rotation)
self.assertAlmostEqual(y, fy, places=9, msg=rotation)
def test_mirror_rotation_swaps_only_clockwise_and_counter_clockwise(self):
self.assertEqual([hull_grid.mirror_rotation(r) for r in range(4)], [0, 3, 2, 1])
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()