Aetheria/tools/blender/aetheria_ships/__init__.py
Meta Vens 429c651a2a Package: sweep only what it generated (aetheria.generated), pin bounds centre, Source-only and collider-in-Generated
Stale sweep identified generated anchors by collection membership and role, so an author's hull-collider in
Generated was deleted. Generated anchors now carry aetheria.generated; the sweep removes only those. New smoke
pass package_asymmetric_pass: an L-shaped render hull pins tractor X and shield centre to the bounds centre,
a Source-only ship pins that generation never reads the Source fallback, and a collider placed in Generated
survives Package. All commits build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:55:18 +02:00

1075 lines
48 KiB
Python

"""Aetheria ship authoring controls in Brokkr's Blender sidebar."""
bl_info = {
"name": "Aetheria Ships for Brokkr",
"author": "GameCult",
"version": (0, 1, 0),
"blender": (4, 3, 0),
"location": "View3D > Sidebar > Brokkr > Aetheria Ship",
"description": "Author a typed Aetheria ship package: layout, lines, anchors and its GLB",
"category": "Object",
}
import math
import shutil
import subprocess
import sys
import traceback
from pathlib import Path
import bpy
import numpy
from mathutils import Matrix, Vector
from . import hull_grid
from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint,
encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines,
replace_visual, valid_ship_id)
MODEL_ASSET = "ship.glb"
SOURCE = "Source" # child collection: the Tripo mesh, input to Rasterise, never exported
GENERATED = "Generated" # child collection: derived objects, the Grid (never exported) and the anchors Package makes (exported)
SHIP_ROOT = "Ship Root"
AETHERDB_TIMEOUT = 120 # seconds
HARDPOINT_TYPES = tuple((str(i), name, name) for i, name in enumerate(HARDPOINT_TYPE_NAMES))
ROTATIONS = tuple((str(i), name, name) for i, name in enumerate(ROTATION_NAMES))
class AETHERIA_PG_cell(bpy.types.PropertyGroup):
occupied: bpy.props.BoolProperty(name="Occupied")
class AETHERIA_PG_hardpoint(bpy.types.PropertyGroup):
mount_id: bpy.props.StringProperty(name="Mount ID")
kind: bpy.props.EnumProperty(name="Type", items=HARDPOINT_TYPES)
x: bpy.props.IntProperty(name="X", min=0, max=31)
y: bpy.props.IntProperty(name="Y", min=0, max=31)
footprint: bpy.props.StringProperty(
name="Footprint", default="#", description="Top-to-bottom rows of # and ., separated by /"
)
rotation: bpy.props.EnumProperty(name="Rotation", items=ROTATIONS)
armor: bpy.props.FloatProperty(name="Armor", min=0)
firing_arc: bpy.props.FloatProperty(name="Firing Arc", min=0, max=360)
class AETHERIA_PG_layout(bpy.types.PropertyGroup):
ship_id: bpy.props.StringProperty()
ship_cc: bpy.props.StringProperty()
revision: bpy.props.StringProperty()
width: bpy.props.IntProperty(default=1)
height: bpy.props.IntProperty(default=1)
new_width: bpy.props.IntProperty(name="Width", default=1, min=1, max=32)
new_height: bpy.props.IntProperty(name="Height", default=1, min=1, max=32)
cells: bpy.props.CollectionProperty(type=AETHERIA_PG_cell)
hardpoints: bpy.props.CollectionProperty(type=AETHERIA_PG_hardpoint)
def _footprint(raw):
width, height, cells = raw[0]
return "/".join("".join("#" if cells[x * height + y] else "." for x in range(width))
for y in range(height - 1, -1, -1))
def _parse_footprint(text):
rows = text.split("/")
width = len(rows[0])
height = len(rows)
if not (1 <= width <= 32 and 1 <= height <= 32 and
all(len(row) == width and set(row) <= {"#", "."} for row in rows)):
raise ValueError("Footprint must use equal-width #/. rows separated by /")
return [[width, height, [rows[height - 1 - y][x] == "#"
for x in range(width) for y in range(height)]]]
def _layout_path(context):
collection = _ship_collection(context)
if not collection.get("aetheria.ship_cc"):
raise ValueError("The ship collection has no bound .cc path")
return collection, bpy.path.abspath(collection["aetheria.ship_cc"])
def _set_cells(state, width, height, cells):
state.width = state.new_width = width
state.height = state.new_height = height
state.cells.clear()
for cell in cells:
state.cells.add().occupied = bool(cell)
def _load_layout(context, collection, path):
"""Fills the scene's layout buffer from the bound .cc and redraws the Grid; returns (width, height, hardpoints)."""
shape, hardpoints, revision = read_layout(path, _brokkr_cultlib(context))
width, height, cells = shape
state = context.scene.aetheria_layout
state.ship_id = collection["aetheria.id"]
state.ship_cc = path
state.revision = revision
_set_cells(state, width, height, cells)
state.hardpoints.clear()
for raw in hardpoints:
fields = decode_hardpoint(raw)
hp = state.hardpoints.add()
hp.kind = str(fields["Type"])
hp.x, hp.y = fields["Position"]
hp.footprint = _footprint(fields["Shape"])
hp.mount_id = fields["Transform"] or ""
hp.rotation = str(fields["Rotation"])
hp.armor = fields["Armor"]
hp.firing_arc = fields["FiringArc"]
_redraw_grid(collection, state)
return width, height, len(hardpoints)
_EXPECTED = (OSError, ValueError, RuntimeError, ImportError, KeyError)
def _failure_text(exc):
"""What an operator reports for exc. This module's own ValueError and RuntimeError messages are fixed text that
names no input; any other error is reported by its type, and its text goes to the system console."""
if type(exc) in (ValueError, RuntimeError):
return str(exc)
traceback.print_exception(exc)
return f"Stopped by {type(exc).__name__}; see the system console"
def _refuse(operator, exc):
operator.report({"ERROR"}, _failure_text(exc))
return {"CANCELLED"}
class AETHERIA_OT_load_layout(bpy.types.Operator):
bl_idname = "aetheria.load_ship_layout"
bl_label = "Load Ship Layout"
bl_options = {"REGISTER"}
def execute(self, context):
try:
collection, path = _layout_path(context)
if collection.get("aetheria.pending"):
raise ValueError("Not saved yet; Save Layout creates it")
width, height, count = _load_layout(context, collection, path)
self.report({"INFO"}, f"Loaded {width}x{height} layout and {count} hardpoints")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
class AETHERIA_OT_resize_layout(bpy.types.Operator):
bl_idname = "aetheria.resize_ship_layout"
bl_label = "Resize Grid"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
state = context.scene.aetheria_layout
old = [cell.occupied for cell in state.cells]
width, height = state.width, state.height
expanded = [old[x * height + y] if x < width and y < height else False
for x in range(state.new_width) for y in range(state.new_height)]
state.cells.clear()
for cell in expanded:
state.cells.add().occupied = cell
state.width, state.height = state.new_width, state.new_height
return {"FINISHED"}
class AETHERIA_OT_add_hardpoint(bpy.types.Operator):
bl_idname = "aetheria.add_ship_hardpoint"
bl_label = "Add Hardpoint"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
context.scene.aetheria_layout.hardpoints.add()
return {"FINISHED"}
class AETHERIA_OT_remove_hardpoint(bpy.types.Operator):
bl_idname = "aetheria.remove_ship_hardpoint"
bl_label = "Remove Hardpoint"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty()
def execute(self, context):
state = context.scene.aetheria_layout
if 0 <= self.index < len(state.hardpoints):
state.hardpoints.remove(self.index)
return {"FINISHED"}
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]]
rows = [encode_hardpoint(Type=int(hp.kind), Position=[hp.x, hp.y], Shape=_parse_footprint(hp.footprint),
Transform=hp.mount_id, Rotation=int(hp.rotation), Armor=hp.armor, FiringArc=hp.firing_arc)
for hp in state.hardpoints]
return shape, rows
def _save_layout(context, collection, state):
"""The one writer of the .cc layout: writes the layout buffer, creating the file first when the ship is pending. A
failure after the file was created removes what this save made. Returns the hardpoint count."""
if not collection.get("aetheria.ship_cc"):
raise ValueError("The ship collection has no bound .cc path")
path = bpy.path.abspath(collection["aetheria.ship_cc"])
ship_id = collection["aetheria.id"]
if state.ship_id != ship_id or state.ship_cc != path:
raise ValueError("Load this collection's layout before saving")
cultlib = _brokkr_cultlib(context)
shape, rows = _encode_buffer(state)
pending = collection.get("aetheria.pending")
folder = Path(path).parent
if pending and Path(path).exists():
raise ValueError("A ship file already exists at the bound path (Ctrl+Z past a first Save leaves it there); "
"use Bind Ship Collection with that ship.cc, then Load Layout")
made_folder = bool(pending) and not folder.exists()
revision = state.revision
try:
if pending:
code, output = _aetherdb(context, "create", path, ship_id, pending[0], "--like", pending[1], ship_cc=path)
if output:
print(output)
if code != 0:
raise RuntimeError("AetherDb refused to create the ship; its message is in the system console")
revision = read_layout(path, cultlib)[2]
revision = replace_layout(path, cultlib, ship_id, revision, shape, rows)
except Exception:
if pending:
Path(path).unlink(missing_ok=True)
if made_folder:
shutil.rmtree(folder, ignore_errors=True)
raise
state.revision = revision
if pending:
del collection["aetheria.pending"]
_redraw_grid(collection, state)
return len(rows)
class AETHERIA_OT_save_layout(bpy.types.Operator):
bl_idname = "aetheria.save_ship_layout"
bl_label = "Save Layout to .cc"
bl_options = {"REGISTER"}
def execute(self, context):
try:
collection, _ = _layout_path(context)
count = _save_layout(context, collection, context.scene.aetheria_layout)
self.report({"INFO"}, f"Saved the layout and {count} hardpoints")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
def _brokkr_cultlib(context):
brokkr = context.preferences.addons.get("brokkr_bridge")
if brokkr is None:
raise RuntimeError("Enable Brokkr before using Aetheria Ships")
return brokkr.preferences.cultlib_py_src
def _is_ship(collection):
return collection.get("aetheria.asset_kind") == "ship"
def _ship_collection(context, unbound_ok=False):
"""The one chooser: the ship collection holding the active object. unbound_ok (Bind, New Ship) also accepts the
active object's only collection when no ship collection holds it."""
obj = context.active_object
if obj is None:
raise ValueError("Select an object in the ship collection")
ships = [collection for collection in bpy.data.collections if _is_ship(collection) and obj.name in collection.all_objects]
if len(ships) == 1:
return ships[0]
if unbound_ok and not ships and len(obj.users_collection) == 1:
return obj.users_collection[0]
raise ValueError("Select an object in exactly one ship collection" if unbound_ok
else "Selected object must belong to exactly one bound ship collection")
def _bind_collection(collection, path, ship_id, pending=None):
"""Binds the collection to the ship .cc at path (aetheria.asset_kind, .id and .ship_cc, and .pending = [name,
reference] for a ship whose file the first Save creates), or changes nothing."""
if collection.get("aetheria.asset_kind") not in (None, "ship"):
raise ValueError("The collection has another aetheria.asset_kind")
if collection.get("aetheria.id") not in (None, ship_id):
raise ValueError("The collection is bound to another ship ID")
stored = path
if bpy.data.filepath:
try:
stored = bpy.path.relpath(path)
except ValueError:
pass
keys = ("aetheria.asset_kind", "aetheria.id", "aetheria.ship_cc", "aetheria.pending")
before = {key: collection[key] for key in keys if key in collection}
values = {"aetheria.asset_kind": "ship", "aetheria.id": ship_id, "aetheria.ship_cc": stored}
if pending:
values["aetheria.pending"] = pending
try:
for key, value in values.items():
collection[key] = value
if not pending and "aetheria.pending" in collection:
del collection["aetheria.pending"]
except Exception:
for key in keys: # the ID-property store refused a value: put every key back as it was
if key in before:
collection[key] = before[key]
elif key in collection:
del collection[key]
raise
class AETHERIA_OT_bind_ship_collection(bpy.types.Operator):
bl_idname = "aetheria.bind_ship_collection"
bl_label = "Bind Ship Collection"
bl_description = "Bind the active collection to one typed ship .cc record"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
if not context.scene.aetheria_ship_cc_path:
raise ValueError("Choose an existing ship authoring .cc file")
path = bpy.path.abspath(context.scene.aetheria_ship_cc_path)
collection = _ship_collection(context, unbound_ok=True)
_bind_collection(collection, path, read(path, _brokkr_cultlib(context)).ship.body[0])
self.report({"INFO"}, "Bound the ship collection")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
class AETHERIA_OT_capture_ship_lines(bpy.types.Operator):
bl_idname = "aetheria.capture_ship_lines"
bl_label = "Capture Ship Lines"
bl_description = "Replace only the line slot in the selected typed ship .cc record"
bl_options = {"REGISTER"}
def execute(self, context):
try:
obj = context.active_object
collection = _ship_collection(context)
if not collection.get("aetheria.ship_cc"):
raise ValueError("The ship collection has no bound .cc path")
path = bpy.path.abspath(collection["aetheria.ship_cc"])
cultlib = _brokkr_cultlib(context)
if read(path, cultlib).ship.body[0] != collection["aetheria.id"]:
raise ValueError("The bound collection ID does not match its .cc ship ID")
lines = capture_grease_pencil(
obj, context.evaluated_depsgraph_get(), context.scene.frame_current,
evaluated=context.scene.aetheria_capture_evaluated_lines,
)
count = replace_lines(path, cultlib, lines)
self.report({"INFO"}, f"Captured {count} lines")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
class AETHERIA_AP_preferences(bpy.types.AddonPreferences):
bl_idname = __package__
game_folder: bpy.props.StringProperty(
name="Game folder", subtype="DIR_PATH",
description="The folder whose GameData holds Aetheria.cc and Mods (the install folder); its ModTools holds "
"AetherDb. Empty: the nearest folder above the bound .cc whose GameData holds Aetheria.cc")
def draw(self, context):
self.layout.prop(self, "game_folder")
GAME_FOLDER_REFUSAL = ("Set the add-on's Game folder preference to the folder whose GameData holds Aetheria.cc "
"and whose ModTools holds AetherDb")
def _game_folder(context, ship_cc=None):
"""The folder AetherDb runs against: the Game folder preference, else the nearest folder above the bound .cc whose
GameData holds Aetheria.cc. Refusals name the preference and never a path."""
addon = context.preferences.addons.get(__package__)
configured = addon.preferences.game_folder if addon and addon.preferences else ""
if configured:
folder = Path(bpy.path.abspath(configured))
if not (folder / "GameData" / "Aetheria.cc").is_file():
raise ValueError(GAME_FOLDER_REFUSAL)
return folder
if ship_cc:
for folder in Path(ship_cc).resolve().parents:
if (folder / "GameData" / "Aetheria.cc").is_file():
return folder
raise ValueError(GAME_FOLDER_REFUSAL)
def _aetherdb(context, *args, ship_cc=None):
"""Runs one AetherDb ship-authoring command from the game folder's ModTools; returns its exit code and its output,
stdout then stderr. ship_cc is the bound .cc the game folder is found from when the preference is empty."""
folder = _game_folder(context, ship_cc)
tool = folder / "ModTools" / ("AetherDb.exe" if sys.platform == "win32" else "AetherDb")
if not tool.is_file():
raise ValueError(GAME_FOLDER_REFUSAL)
try:
result = subprocess.run([str(tool), "ship-authoring", *args], cwd=folder, capture_output=True, text=True,
timeout=AETHERDB_TIMEOUT)
except subprocess.TimeoutExpired:
raise RuntimeError("AetherDb did not finish in time") from None
except OSError:
raise RuntimeError("AetherDb could not be started") from None
lines = (result.stdout + "\n" + result.stderr).splitlines()
return result.returncode, "\n".join(line for line in lines if line.strip())
def _layer_collection(layer, collection):
if layer.collection == collection:
return layer
for child in layer.children:
found = _layer_collection(child, collection)
if found:
return found
return None
def _anchors(collection):
"""One ShipAnchor row per object tagged aetheria.role. Its aetheria.id is both the anchor id and the GLB node id."""
rows = []
for obj in sorted(collection.all_objects, key=lambda candidate: candidate.name):
role = obj.get("aetheria.role")
if role is None:
continue
anchor_id = obj.get("aetheria.id")
if not isinstance(anchor_id, str) or not anchor_id.strip():
raise ValueError("An object with aetheria.role has no aetheria.id")
parent = None
if role == "weapon-muzzle":
parent = obj.parent.get("aetheria.id") if obj.parent else None
if not parent:
raise ValueError("A weapon-muzzle must be parented to its weapon mount object")
rows.append([anchor_id, str(role), anchor_id, parent, int(obj.get("aetheria.order", 0))])
return rows
def _export_glb(context, collection, filepath):
"""Exports the collection's objects with custom properties as node extras: Generated's anchors are exported; Grease
Pencil, Source and the Grid are not."""
view_layer = context.view_layer
layer = _layer_collection(view_layer.layer_collection, collection)
if layer is None:
raise ValueError("The ship collection is not in the current view layer")
previous_layer = view_layer.active_layer_collection
previous_selection = [obj for obj in view_layer.objects if obj.select_get()]
previous_active = view_layer.objects.active
try:
view_layer.active_layer_collection = layer
for obj in previous_selection:
obj.select_set(False)
derived = _derived_names(collection)
for obj in collection.all_objects:
if obj.type != "GREASEPENCIL" and obj.name not in derived and obj.name in view_layer.objects:
obj.select_set(True)
bpy.ops.export_scene.gltf(
filepath=filepath, export_format="GLB", use_active_collection=True, use_selection=True,
export_extras=True, export_yup=True, export_apply=True)
except RuntimeError: # the exporter's text holds paths and a traceback
traceback.print_exc()
raise RuntimeError("The GLB export failed; its message is in the system console") from None
finally:
for obj in view_layer.objects:
obj.select_set(obj in previous_selection)
view_layer.objects.active = previous_active
view_layer.active_layer_collection = previous_layer
def _child(collection, kind):
"""The child collection tagged aetheria.frame == kind. Blender keeps collection names unique per file, so the
name 'Source' or 'Generated' cannot identify it."""
return next((child for child in collection.children if child.get("aetheria.frame") == kind), None)
def _child_or_new(collection, kind, name):
child = _child(collection, kind)
if child is None:
child = bpy.data.collections.new(name)
child["aetheria.frame"] = kind
collection.children.link(child)
return child
def _derived_names(collection):
"""Names of the objects that are never exported: Source's, and Generated's that carry no aetheria.role (the Grid)."""
return {obj.name for kind in (SOURCE, GENERATED) for child in [_child(collection, kind)] if child
for obj in child.all_objects if kind == SOURCE or "aetheria.role" not in obj}
def _ship_root(collection):
return next((obj for obj in collection.objects if obj.get("aetheria.ship_root")), None)
def _meshes(objects):
return [obj for obj in objects if obj.type == "MESH"]
def _own_meshes(collection):
"""The ship's own render meshes: Ship Root's descendants outside Source and Generated that carry no aetheria.role."""
derived = _derived_names(collection)
return [obj for obj in _meshes(_ship_root(collection).children_recursive)
if obj.name not in derived and "aetheria.role" not in obj]
def _frame_meshes(collection):
"""The meshes Rasterise reads: the ship's own, the Source meshes while there are none."""
source = _child(collection, SOURCE)
return _own_meshes(collection) or (_meshes(source.all_objects) if source else [])
def _loose_meshes(collection):
"""Meshes of a collection that has a Ship Root which are not under the root, Generated's apart. Source meshes count:
Rasterise reads them and Flip Nose turns only what hangs from Ship Root."""
root = _ship_root(collection)
if root is None:
return []
generated = _child(collection, GENERATED)
derived = {obj.name for obj in generated.all_objects} if generated else set()
framed = {obj.name for obj in root.children_recursive}
return [obj for obj in _meshes(collection.all_objects) if obj.name not in derived and obj.name not in framed]
def _refuse_loose_meshes(context, collection):
"""Refuses while meshes sit outside Ship Root, selecting them so the author can parent them."""
loose = _loose_meshes(collection)
if not loose:
return
for obj in context.view_layer.objects:
obj.select_set(False)
for obj in loose:
if obj.name in context.view_layer.objects:
obj.select_set(True)
raise ValueError(f"{len(loose)} meshes in the ship collection are not under Ship Root; parent them to it")
def _frame_inputs(context, collection, state):
"""Ship Root and the meshes Rasterise reads, or the refusal that stops Rasterise and Flip Nose before they change
anything."""
root = _ship_root(collection)
if root is None:
raise ValueError("The ship collection has no Ship Root; run New Ship first")
if state.ship_id != collection["aetheria.id"]:
raise ValueError("Load this collection's layout first")
_refuse_loose_meshes(context, collection)
meshes = _frame_meshes(collection)
if not meshes:
raise ValueError("The ship collection has no render or Source mesh to rasterise")
return root, meshes
def _world_mesh(obj, depsgraph, matrix):
"""The evaluated mesh's points through matrix, as (points, triangles) numpy arrays."""
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh()
try:
points = numpy.empty(len(mesh.vertices) * 3, dtype=numpy.float32)
mesh.vertices.foreach_get("co", points)
mesh.calc_loop_triangles()
triangles = numpy.empty(len(mesh.loop_triangles) * 3, dtype=numpy.int32)
mesh.loop_triangles.foreach_get("vertices", triangles)
finally:
evaluated.to_mesh_clear()
linear = numpy.array(matrix, dtype=numpy.float64)
return points.reshape(-1, 3) @ linear[:3, :3].T + linear[:3, 3], triangles.reshape(-1, 3)
def _held(matrix):
"""The world matrix an object holds after matrix_world = matrix: Blender keeps location, rotation and scale, so a
shear (a non-uniform scaled parent under rotation) is dropped. The hull the author sees afterwards is this one."""
return Matrix.LocRotScale(*matrix.decompose())
def _triangles_xy(depsgraph, placements):
"""The top-down triangles of the evaluated meshes, each through its matrix: what the author sees from above.
placements is [(object, matrix)]."""
triangles = []
for obj, matrix in placements:
points, indices = _world_mesh(obj, depsgraph, matrix)
triangles += points[indices][:, :, :2].tolist()
return triangles
def _grid_current(collection, state):
"""Whether the Grid can be drawn: grid_origin was computed for the buffer's own size."""
origin = collection.get("aetheria.grid_origin")
return (origin is not None and len(origin) == 4 and (int(origin[2]), int(origin[3])) == (state.width, state.height)
and len(state.cells) == state.width * state.height)
def _discard_object(obj):
"""Removes obj, then its mesh when nothing else uses it."""
mesh = obj.data if obj.type == "MESH" else None
bpy.data.objects.remove(obj)
if mesh is not None and mesh.users == 0:
bpy.data.meshes.remove(mesh)
def _attach_to_root(obj, root):
"""Parents obj to root with an identity parent inverse and a zero local transform: its local frame is Ship Root's."""
obj.parent = root
obj.matrix_parent_inverse = Matrix.Identity(4)
obj.location = (0.0, 0.0, 0.0)
obj.rotation_mode = "XYZ"
obj.rotation_euler = (0.0, 0.0, 0.0)
obj.scale = (1.0, 1.0, 1.0)
def _redraw_grid(collection, state):
"""Redraws the Grid object from the layout buffer and the collection's aetheria.grid_origin while the origin was
computed for the buffer's size; otherwise removes the Grid. A collection without a Ship Root or a grid_origin has no
frame yet and gets no Grid."""
root = _ship_root(collection)
origin = collection.get("aetheria.grid_origin")
if root is None or origin is None:
return
generated = _child_or_new(collection, GENERATED, GENERATED)
grid = next((obj for obj in generated.objects if obj.get("aetheria.grid")), None)
if not _grid_current(collection, state):
if grid is not None:
_discard_object(grid)
return
origin = (float(origin[0]), float(origin[1]))
half = hull_grid.CELL_SIZE / 2
verts, edges, faces = [], [], []
for x in range(state.width):
for y in range(state.height):
cx, cy = hull_grid.cell_centre(origin, x, y)
i = len(verts)
verts += [(cx - half, cy - half, 0.0), (cx + half, cy - half, 0.0), (cx + half, cy + half, 0.0),
(cx - half, cy + half, 0.0)]
if state.cells[x * state.height + y].occupied:
faces.append((i, i + 1, i + 2, i + 3))
else:
edges += [(i, i + 1), (i + 1, i + 2), (i + 2, i + 3), (i + 3, i)]
mesh = bpy.data.meshes.new("Grid")
mesh.from_pydata(verts, edges, faces)
mesh.update(calc_edges=True)
if grid is None:
grid = bpy.data.objects.new("Grid", mesh)
grid["aetheria.grid"] = True
generated.objects.link(grid)
else:
previous, grid.data = grid.data, mesh
if previous.users == 0:
bpy.data.meshes.remove(previous)
_attach_to_root(grid, root)
grid.display_type = "WIRE"
def _rasterise(context, collection, state):
"""Proposes the hull's cells from the frame meshes, top-down in Ship Root's frame, into the layout buffer (its
hardpoints stay) and stores the grid's placement with the size it was computed for. Writes no file."""
root, meshes = _frame_inputs(context, collection, state)
to_root = root.matrix_world.inverted()
width, height, cells, origin = hull_grid.rasterise(_triangles_xy(
context.evaluated_depsgraph_get(), [(obj, to_root @ obj.matrix_world) for obj in meshes]))
_place_grid(collection, state, width, height, cells, origin)
return width, height
def _place_grid(collection, state, width, height, cells, origin):
"""Puts a rasterised hull into the layout buffer (its hardpoints stay), stores the grid's placement with the size it
was computed for and redraws the Grid. The only writer of aetheria.grid_origin."""
_set_cells(state, width, height, cells)
collection["aetheria.grid_origin"] = [float(origin[0]), float(origin[1]), width, height]
_redraw_grid(collection, state)
GENERATED_ROLES = (("map-icon", "map", "Map Icon"), ("shield", "shield", "Shield"), ("tractor", "tractor", "Tractor"))
def _recentre(root, point):
"""Moves Ship Root, and the ship with it, so that its local point (x, y) sits at world X = Y = 0."""
world = root.matrix_world @ Vector((point[0], point[1], 0.0))
moved = root.matrix_world.copy()
moved.translation.x -= world.x
moved.translation.y -= world.y
root.matrix_world = moved
def _regenerate_anchors(context, collection, root):
"""Rebuilds Generated's map-icon, shield and tractor from the render meshes (_own_meshes) in Ship Root's frame:
every anchor Package itself generated (marked aetheria.generated) and not marked aetheria.keep is discarded; anything
else in Generated, a collider included, is the operator's and stays. Then each of the three roles no other object in the
collection carries is made again. The operator's own object, or a kept one, owns its role. Everything that can
refuse comes before the first change."""
generated = _child_or_new(collection, GENERATED, GENERATED)
stale = [obj for obj in generated.all_objects if obj.get("aetheria.generated") and not obj.get("aetheria.keep")]
stale_names = {obj.name for obj in stale}
held = {obj["aetheria.role"] for obj in collection.all_objects
if "aetheria.role" in obj and obj.name not in stale_names}
meshes = _own_meshes(collection)
missing = [entry for entry in GENERATED_ROLES if meshes and entry[0] not in held]
made = []
if missing:
depsgraph = context.evaluated_depsgraph_get()
to_root = root.matrix_world.inverted()
points = numpy.vstack([_world_mesh(obj, depsgraph, to_root @ obj.matrix_world)[0] for obj in meshes])
if not len(points):
raise ValueError("The render meshes have no points to derive the map icon, shield and tractor from")
low, high = points.min(axis=0), points.max(axis=0)
centre, half = (low + high) / 2, (high - low) / 2
for role, anchor_id, name in missing:
if role == "map-icon":
outline = hull_grid.convex_outline(points[:, :2].tolist())
mesh = bpy.data.meshes.new(name)
mesh.from_pydata([(x, y, float(centre[2])) for x, y in outline], [], [tuple(range(len(outline)))])
mesh.update()
made.append((role, anchor_id, bpy.data.objects.new(name, mesh), None, None))
elif role == "shield":
empty = bpy.data.objects.new(name, None)
empty.empty_display_type = "SPHERE"
made.append((role, anchor_id, empty, tuple(float(v) for v in centre),
[max(1.15 * float(h), 0.1) for h in half]))
else:
empty = bpy.data.objects.new(name, None)
empty.empty_display_type = "ARROWS"
made.append((role, anchor_id, empty, (float(centre[0]), float(low[1]), float(centre[2])), None))
for obj in stale:
_discard_object(obj)
for role, anchor_id, obj, at, scale in made:
obj["aetheria.role"] = role
obj["aetheria.id"] = anchor_id
obj["aetheria.keep"] = False
obj["aetheria.generated"] = True
generated.objects.link(obj)
_attach_to_root(obj, root)
if at is not None:
obj.location = at
if scale is not None:
obj.scale = scale
class AETHERIA_OT_rasterise_hull(bpy.types.Operator):
bl_idname = "aetheria.rasterise_hull"
bl_label = "Rasterise Hull"
bl_description = "Propose the hull's cells from its top-down silhouette; you edit them afterwards, then Save Layout"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
width, height = _rasterise(context, _ship_collection(context), context.scene.aetheria_layout)
self.report({"INFO"}, f"Rasterised a {width}x{height} hull")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
class AETHERIA_OT_flip_nose(bpy.types.Operator):
bl_idname = "aetheria.flip_nose"
bl_label = "Flip Nose"
bl_description = "Turn the hull 180 degrees about Ship Root's Z axis, then rasterise it again"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
collection = _ship_collection(context)
state = context.scene.aetheria_layout
root, _ = _frame_inputs(context, collection, state)
turn = root.matrix_world @ Matrix.Rotation(math.pi, 4, "Z") @ root.matrix_world.inverted()
for child in root.children:
child.matrix_world = turn @ child.matrix_world
try:
_rasterise(context, collection, state)
except Exception:
for child in root.children: # a half turn is its own inverse
child.matrix_world = turn @ child.matrix_world
raise
self.report({"INFO"}, "Turned the nose about Ship Root")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
def _new_ship(context, ship_id, name, reference, length):
"""Sets the active collection up as a pending ship: binds it, moves its meshes into Source under a Ship Root, turns
them nose to -Y, sizes them to length cells, fills the layout buffer and draws the Grid. The meshes keep their data:
the placement is the objects' own matrix, so modifiers act as the author saw them and the cells are the ones checked
here. Every check and the rasterising come first; after the bind nothing can refuse. Nothing is written to disk: the
first Save creates the file."""
if not valid_ship_id(ship_id):
raise ValueError("The ship ID must use lower-case letters, digits, dots, underscores or hyphens, must not end "
"in a dot, and must not be a Windows device name")
if not name.strip() or not reference.strip():
raise ValueError("Give the ship a display name and a reference hull")
path = _game_folder(context, bpy.data.filepath or None) / "GameData" / "Mods" / ship_id / "ship.cc"
if path.exists():
raise ValueError("A ship with this ID already exists under GameData/Mods (Ctrl+Z past a first Save leaves it "
"there); use Bind Ship Collection with its ship.cc, then Load Layout, or give the new ship "
"another ID")
collection = _ship_collection(context, unbound_ok=True)
if collection.get("aetheria.id") is not None or _ship_root(collection) is not None:
raise ValueError("The collection is already a ship")
meshes = _meshes(collection.objects)
if not meshes:
raise ValueError("The ship collection holds no mesh objects to make a ship from")
if len(_meshes(collection.all_objects)) != len(meshes):
raise ValueError("The ship collection's meshes must sit directly in it, not in child collections")
worlds = {obj: obj.matrix_world.copy() for obj in meshes}
depsgraph = context.evaluated_depsgraph_get()
points = numpy.vstack([_world_mesh(obj, depsgraph, worlds[obj])[0] for obj in meshes])
extent_x, extent_y = (float(v) for v in numpy.ptp(points, axis=0)[:2])
longest = max(extent_x, extent_y)
if longest <= 0:
raise ValueError("The hull meshes have no horizontal extent")
scale = length * hull_grid.CELL_SIZE / longest
transform = Matrix.Diagonal((scale, scale, scale, 1.0))
if extent_x > extent_y:
transform = transform @ Matrix.Rotation(math.pi / 2, 4, "Z")
placed = {obj: _held(transform @ worlds[obj]) for obj in meshes}
width, height, cells, origin = hull_grid.rasterise(_triangles_xy(depsgraph, [(obj, placed[obj]) for obj in meshes]))
_bind_collection(collection, str(path), ship_id, [name, reference])
source = _child_or_new(collection, SOURCE, SOURCE)
root = bpy.data.objects.new(SHIP_ROOT, None)
root["aetheria.ship_root"] = True
collection.objects.link(root)
for obj in meshes:
collection.objects.unlink(obj)
source.objects.link(obj)
obj.parent = root
obj.matrix_parent_inverse = Matrix.Identity(4)
obj.matrix_world = placed[obj]
state = context.scene.aetheria_layout
state.ship_id = ship_id
state.ship_cc = bpy.path.abspath(collection["aetheria.ship_cc"])
state.revision = ""
state.hardpoints.clear()
_place_grid(collection, state, width, height, cells, origin)
class AETHERIA_OT_new_ship(bpy.types.Operator):
bl_idname = "aetheria.new_ship"
bl_label = "New Ship"
bl_description = ("Set the active collection up as a new ship: nose toward -Y, sized to the length, with a drafted "
"grid. Nothing is written until Save Layout")
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
scene = context.scene
try:
_new_ship(context, scene.aetheria_new_ship_id, scene.aetheria_new_ship_name,
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length)
self.report({"INFO"}, "New ship set up; Save Layout creates its file")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
def _ship_centre(context, path):
"""The saved Shape's centre of mass in cell coordinates, from AetherDb: C# owns it."""
code, output = _aetherdb(context, "centre", path, ship_cc=path)
try:
parts = output.split()
if code != 0 or len(parts) != 2:
raise ValueError
centre = (float(parts[0]), float(parts[1]))
if not all(map(math.isfinite, centre)):
raise ValueError
except ValueError:
print(output)
raise RuntimeError("AetherDb could not report the ship centre; its message is in the system console") from None
return centre
def _frame_ship(context, collection, root, path, saved_shape):
"""Package's frame step for a ship with a Ship Root: the grid must match the saved layout, then the anchors are
regenerated and Ship Root is moved last, so that the saved Shape's centre of mass sits at the world origin. The
anchors are read from the children's world matrices, so they are made before the move."""
origin = collection.get("aetheria.grid_origin")
if origin is None or len(origin) != 4 or (int(origin[2]), int(origin[3])) != (saved_shape[0], saved_shape[1]):
raise ValueError("Rasterise again: the grid no longer matches the saved layout")
centre = _ship_centre(context, path)
_regenerate_anchors(context, collection, root)
_recentre(root, hull_grid.cell_centre((float(origin[0]), float(origin[1])), centre[0], centre[1]))
class AETHERIA_OT_package_ship(bpy.types.Operator):
bl_idname = "aetheria.package_ship"
bl_label = "Package Ship"
bl_description = ("Put the grid's centre of mass at the world origin, derive the missing map icon, shield and "
"tractor, write the anchors and ship.glb from the bound collection, capture its LineArt, and "
"run AetherDb's validator")
bl_options = {"REGISTER"}
def execute(self, context):
scene = context.scene
try:
collection, path = _layout_path(context)
if collection.get("aetheria.pending"):
raise ValueError("Save Layout first")
_refuse_loose_meshes(context, collection)
cultlib = _brokkr_cultlib(context)
ship_id = collection["aetheria.id"]
state = scene.aetheria_layout
saved_shape, saved_rows, _ = read_layout(path, cultlib)
if state.ship_id == ship_id and state.ship_cc == path:
shape, rows = _encode_buffer(state)
if layout_revision(shape, rows) != layout_revision(
saved_shape, [row[:len(HARDPOINT_MEMBERS)] for row in saved_rows]):
raise ValueError("Save Layout first")
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)
if root is not None:
_frame_ship(context, collection, root, path, saved_shape)
anchors = _anchors(collection)
_export_glb(context, collection, str(Path(path).with_name(MODEL_ASSET)))
replace_visual(path, cultlib, ship_id, MODEL_ASSET, anchors)
pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"]
if len(pencils) == 1:
replace_lines(path, cultlib, capture_grease_pencil(
pencils[0], context.evaluated_depsgraph_get(), scene.frame_current,
evaluated=scene.aetheria_capture_evaluated_lines))
code, message = _aetherdb(context, "validate", path, ship_cc=path)
except _EXPECTED as exc:
scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}"
self.report({"ERROR"}, scene.aetheria_package_report)
return {"CANCELLED"}
if message:
print(message)
if code != 0:
scene.aetheria_package_report = (f"Packaged {len(anchors)} anchors; the validator refused the ship; "
"its message is in the system console")
self.report({"ERROR"}, scene.aetheria_package_report)
return {"CANCELLED"}
scene.aetheria_package_report = f"Packaged {len(anchors)} anchors; the validator accepted the ship"
self.report({"INFO"}, scene.aetheria_package_report)
return {"FINISHED"}
class AETHERIA_PT_ship(bpy.types.Panel):
bl_label = "Aetheria Ship"
bl_idname = "AETHERIA_PT_ship"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Brokkr"
def draw(self, context):
layout = self.layout
new = layout.box()
new.label(text="New Ship")
for prop in ("aetheria_new_ship_id", "aetheria_new_ship_name", "aetheria_new_ship_like",
"aetheria_new_ship_length"):
new.prop(context.scene, prop)
new.operator("aetheria.new_ship", icon="ADD")
layout.prop(context.scene, "aetheria_ship_cc_path")
layout.operator("aetheria.bind_ship_collection", icon="LINKED")
layout.prop(context.scene, "aetheria_capture_evaluated_lines")
obj = context.active_object
layout.label(text=f"Source: {obj.name if obj else '(none)'}")
state = context.scene.aetheria_layout
try:
collection = _ship_collection(context)
layout.label(text=f"Ship: {collection['aetheria.id']}")
if collection.get("aetheria.pending"):
layout.label(text="Not saved yet: Save Layout creates the file", icon="INFO")
elif (collection.get("aetheria.grid_origin") is not None and state.ship_id == collection["aetheria.id"]
and not _grid_current(collection, state)):
layout.label(text="Rasterise again: the grid no longer matches the layout", icon="INFO")
except (ValueError, KeyError):
layout.label(text="No bound ship collection", icon="ERROR")
layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL")
layout.separator()
row = layout.row(align=True)
row.operator("aetheria.load_ship_layout", icon="IMPORT")
row.operator("aetheria.flip_nose", icon="LOOP_BACK")
row.operator("aetheria.rasterise_hull", icon="MESH_GRID")
if not state.ship_id:
self._draw_package(context, layout)
return
layout.label(text=f"Layout: {state.ship_id}")
dims = layout.row(align=True)
dims.prop(state, "new_width")
dims.prop(state, "new_height")
dims.operator("aetheria.resize_ship_layout", text="Resize")
grid = layout.box()
grid.label(text="Hull cells (top row first)")
if len(state.cells) == state.width * state.height:
for y in range(state.height - 1, -1, -1):
row = grid.row(align=True)
row.label(text=str(y))
for x in range(state.width):
row.prop(state.cells[x * state.height + y], "occupied", text="")
layout.label(text="Hardpoints")
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
box.prop(hp, "kind")
position = box.row(align=True)
position.prop(hp, "x")
position.prop(hp, "y")
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")
layout.operator("aetheria.save_ship_layout", icon="FILE_TICK")
self._draw_package(context, layout)
@staticmethod
def _draw_package(context, layout):
layout.separator()
layout.operator("aetheria.package_ship", icon="PACKAGE")
obj = context.active_object
if obj is not None and "aetheria.role" in obj:
try:
generated = _child(_ship_collection(context), GENERATED)
except ValueError:
generated = None
if generated is not None and obj.name in generated.all_objects:
layout.prop(obj, '["aetheria.keep"]', text="Keep")
layout.label(text="Generated: Package rebuilds it unless Kept")
report = context.scene.aetheria_package_report
if report:
box = layout.box()
for line in report.splitlines():
box.label(text=line)
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_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)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.aetheria_layout = bpy.props.PointerProperty(type=AETHERIA_PG_layout)
bpy.types.Scene.aetheria_ship_cc_path = bpy.props.StringProperty(
name="Ship .cc", subtype="FILE_PATH", description="Existing typed ship authoring record"
)
bpy.types.Scene.aetheria_new_ship_id = bpy.props.StringProperty(
name="ID", description="The new ship's ID; also the folder under GameData/Mods")
bpy.types.Scene.aetheria_new_ship_name = bpy.props.StringProperty(name="Name", description="Display name")
bpy.types.Scene.aetheria_new_ship_like = bpy.props.StringProperty(
name="Like", default="Djinni", description="Shipped hull whose stats the new ship starts from")
bpy.types.Scene.aetheria_new_ship_length = bpy.props.IntProperty(
name="Length (cells)", default=10, min=1, max=hull_grid.MAX_CELLS,
description="Cells from stern to nose; the hull's long axis becomes this many 2 m cells")
bpy.types.Scene.aetheria_package_report = bpy.props.StringProperty(
name="Package report", description="AetherDb's verdict on the last Package Ship")
bpy.types.Scene.aetheria_capture_evaluated_lines = bpy.props.BoolProperty(
name="Evaluated LineArt", default=True,
description="Capture the visible modifier result at the current frame",
)
def unregister():
del bpy.types.Scene.aetheria_layout
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Scene.aetheria_capture_evaluated_lines
del bpy.types.Scene.aetheria_new_ship_length
del bpy.types.Scene.aetheria_new_ship_like
del bpy.types.Scene.aetheria_new_ship_name
del bpy.types.Scene.aetheria_new_ship_id
del bpy.types.Scene.aetheria_package_report
del bpy.types.Scene.aetheria_ship_cc_path