Aetheria/tools/blender/tests/smoke_mounts.py
soul eaf7b799d4 Package moves no row: delete the schematic-from-model derivation
Remove _plan_mounts/_apply_mounts, _derived_position, CENTRELINE, hull_grid.cell_at,
mounted_position/mounted_cell and their tests (ruling remove-package-schematic-derivation).
Add Weapon makes a default-Position row; Mirror Mount mirrors each space within itself.
smoke_mounts rewritten (not run: bpy smokes are held off Starfire).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:16:35 +02:00

474 lines
24 KiB
Python

"""Headless Blender smoke for the add-on's weapon mounts, the gunboat path: Add Weapon, Add Muzzle, Mirror Mount, Remove
Mount, and that Package moves nothing of 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 schematic and the model are separate spaces (ruling schematic-and-model-are-separate-spaces): a row's Position is
authored in the layout, a mount's pose in the model, and the two are linked by name alone. 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 is made at HardpointData's default Position (the model point maps to no cell), the
smoke sets the Position as the panel would, and its muzzle sits 0.5 m toward -X of it. Mirror Mount makes a
CounterClockwise row at the footprint's reflection in the layout and the object's reflection in the model, with its
muzzle toward +X, and refuses a mount on the model's centreline and one whose footprint is its own reflection. 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 in the model, hand-sets its yaw and changes another's Rotation: Package leaves every row's Position, in the buffer
and in the .cc, and every mount's yaw as authored. Pass 4 removes a mount with two muzzles: its row, the mount and both
muzzles go, and the other rows stay.
"""
import json
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"
# (ship id, length in cells, the source sphere's Y scale, wide, grid width parity) of passes 6 to 8; the id's mod directory
# is removed at the end.
WIDTH_PASSES = ((ODD_ID, 7, 0.6, False, 1), (ODD_ID + ".wide-even", 14, 1.0, True, 0), (ODD_ID + ".wide-odd", 15, 1.0, True, 1))
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 set_position(scene, mount_id, position):
"""The panel's edit: a row's Position is authored in the layout."""
hp = next(h for h in scene.aetheria_layout.hardpoints if h.mount_id == mount_id)
hp.x, hp.y = position
def root_local(root, obj):
"""obj's origin in Ship Root's frame."""
return tuple(root.matrix_world.inverted() @ Vector(world(obj)))
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, sphere):
"""Mirror Mount, each space within itself, for every footprint width 1 to 4 at every authored Position of the grid:
a footprint on cells [a, a + w) of a W-wide grid has its twin row on [W - a - w, W - a), whatever the grid's parity,
and the twin object is the model reflection of the source (-x, y, z), wherever the author put the row. A footprint
that is its own reflection has no twin. The expected cells are computed here from the footprint, never read back
from the twin."""
state = scene.aetheria_layout
grid_w = state.width
y_cell = 1
model_point = (1.5, -1.0, 0.2) # off the centreline; it names no cell
mirrored = refused_count = 0
baseline = rows(scene)
for w in (1, 2, 3, 4):
for a in range(grid_w - w + 1):
mount_id = new_gun(scene, root, model_point)
set_footprint(scene, mount_id, "#" * w)
set_position(scene, mount_id, (a, y_cell))
select(bpy.data.objects[mount_id])
before = rows(scene)
expected = (grid_w - a - w, y_cell)
if expected == (a, y_cell):
refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a footprint that is its own reflection")
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 {a})")
(twin_id,) = {r[0] for r in rows(scene)} - {r[0] for r in before}
twin = row(scene, twin_id)
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")
reflected = (-model_point[0], model_point[1], model_point[2])
require(approx(root_local(root, bpy.data.objects[twin_id]), reflected),
f"The twin object is not the model reflection: {root_local(root, bpy.data.objects[twin_id])}")
mirrored += 1
remove_mount(twin_id)
remove_mount(mount_id)
require(mirrored >= 6 and refused_count >= 1, f"The matrix ran too little ({mirrored}, {refused_count})")
require(rows(scene) == baseline, "The matrix left rows behind")
# The twin carries the author's armour and arc, rotation mirrored, and the muzzle's reflection.
mount_id = new_gun(scene, root, model_point)
set_position(scene, mount_id, (0, y_cell))
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 muzzled 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, y_cell), f"The 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 model's centreline has no mirror image, whatever the row says; a millimetre is on it, two are not.
for x, ok in ((0.0, False), (5e-4, False), (2e-3, True)):
mount_id = new_gun(scene, root, (x, model_point[1], 0.0))
set_position(scene, mount_id, (0, y_cell))
select(bpy.data.objects[mount_id])
before = rows(scene)
if ok:
require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount just off the centreline was refused")
(twin_id,) = {r[0] for r in rows(scene)} - {r[0] for r in before}
remove_mount(twin_id)
else:
refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", f"Mirror Mount of a mount {x} m off the centreline")
require(rows(scene) == before, "A refused Mirror Mount changed a row")
remove_mount(mount_id)
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, (0, 0), CLOCKWISE, "#", 0.0, 0.0),
f"The gun's row is not the default Position (the model point maps to no cell): {gun_row}")
set_position(scene, "weapon.1", (4, 5)) # authored in the layout, as the panel would
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.0, 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 mount on the model's centreline")
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, 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, "##")
set_position(scene, pair, (3, 4))
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: Package moves nothing. A mount moved in the model, a hand-set yaw and a changed Rotation stay as authored.
gun, twin = bpy.data.objects["weapon.1"], bpy.data.objects["weapon.2"]
gun.location.y += 4.0 # two cells' worth aft in the model; the row is the author's
gun.rotation_euler.z = 0.3 # a hand-set facing
twin_yaw = twin.rotation_euler.z
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")
authored = rows(scene)
result, message = run(bpy.ops.aetheria.package_ship)
report = scene.aetheria_package_report
require(result == {"FINISHED"}, f"Package refused the moved ship: {report}")
require(rows(scene) == authored, "Package changed a row in the buffer")
require(row(scene, "weapon.1")[2] == (4, 5) and row(scene, "weapon.2")[2] == (1, 5), "Package moved a weapon's Position")
require(row(scene, "weapon.2")[3] == REVERSED and row(scene, "reactor.a") == reactor_before, "Package changed a Rotation or an internal row")
require(abs(gun.rotation_euler.z - 0.3) < 1e-6, f"Package turned a hand-set yaw: {gun.rotation_euler.z}")
require(abs(twin.rotation_euler.z - twin_yaw) < 1e-6, f"Package turned a mount to its row's Rotation: {twin.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, 5) and tuple(by_mount["weapon.2"]["Position"]) == (1, 5)
and by_mount["weapon.2"]["Rotation"] == REVERSED and tuple(by_mount["reactor.a"]["Position"]) == (2, 2),
"The .cc does not hold what the author left")
require([r for r in rows(scene) if r in pair_rows] == pair_rows, "The second Package moved a mirrored pair")
print("PASS 3 package moves nothing ok")
# Passes 6 to 8: the same mirror matrix on ships whose grids are odd, wide and even, and wide and odd (10 or more
# cells across, where real hulls live). Each pass clears the previous ship's objects, which hold the names the matrix
# looks mounts up by.
previous = collection
for number, (ship_id, length, y_scale, wide, parity) in enumerate(WIDTH_PASSES, start=6):
previous = matrix_pass(scene, previous, ship_id, length, y_scale, wide, parity)
print(f"PASS {number} mirror matrix on a {scene.aetheria_layout.width}-wide grid ok")
def matrix_pass(scene, previous, ship_id, length, y_scale, wide, parity):
for obj in list(previous.all_objects):
bpy.data.objects.remove(obj)
ship = bpy.data.collections.new(f"{COLLECTION} {ship_id}")
scene.collection.children.link(ship)
mesh = bpy.data.meshes.new(f"{ship_id} sphere")
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.5)
bm.to_mesh(mesh)
bm.free()
sphere = new_object(ship, f"{ship_id} Source Sphere", mesh)
sphere.scale = (1, y_scale, 0.4)
select(sphere)
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = ship_id, ship_id
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", length
result, message = run(bpy.ops.aetheria.new_ship)
require(result == {"FINISHED"}, f"New Ship ({ship_id}) was refused: {message}")
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"Save ({ship_id}) was refused: {message}")
width = scene.aetheria_layout.width
require(width % 2 == parity and (width >= 10 or not wide), f"The {ship_id} grid is {width} wide")
mirror_matrix(scene, sys.modules["aetheria_ships"]._ship_root(ship), sphere)
return ship
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:
for pass_id, *_ in WIDTH_PASSES:
shutil.rmtree(MODS / pass_id, ignore_errors=True)
if created_mods and not existed:
shutil.rmtree(MODS, ignore_errors=True)
sys.stdout.flush()
os._exit(code)