Aetheria/tools/blender/aetheria_ships/__init__.py
Meta Vens 4895c75236 Bind Ship Collection: compute the stored .cc path before binding, absolute when relative cannot hold
The bind called bpy.path.relpath after setting aetheria.asset_kind and aetheria.id, so a .cc on another drive than the
.blend raised and left the collection half-bound, and an unsaved .blend stored a path relative to the working
directory. The stored path is now computed first: absolute for an unsaved .blend, '//'-relative when relpath succeeds,
absolute when it raises ValueError. Every reader already resolves both through bpy.path.abspath.

smoke_package.py gains the bind cases: unsaved stores absolute and survives two Save As moves, a failing bind leaves
no aetheria.* property, same-drive stores '//'-relative, and with SMOKE_OTHER_DRIVE the final bind crosses drives
before the package passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:37:21 +02:00

502 lines
22 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 subprocess
from pathlib import Path
import bpy
from .ship_cc import (HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint,
encode_hardpoint, read, read_layout, replace_layout, replace_lines, replace_visual)
MODEL_ASSET = "ship.glb"
AETHERDB_TIMEOUT = 600 # seconds; the first run builds AetherDb
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("Ship collection has no bound .cc path")
return collection, bpy.path.abspath(collection["aetheria.ship_cc"])
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)
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
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)
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"]
self.report({"INFO"}, f"Loaded {width}x{height} layout and {len(hardpoints)} hardpoints")
return {"FINISHED"}
except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
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"}
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, 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 before saving")
cells = [cell.occupied for cell in state.cells]
hardpoints = [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]
state.revision = replace_layout(
path, _brokkr_cultlib(context), state.ship_id, state.revision,
[state.width, state.height, cells], hardpoints
)
self.report({"INFO"}, f"Saved {len(hardpoints)} hardpoints to {path}")
return {"FINISHED"}
except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
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 _ship_collection(context):
obj = context.active_object
if obj is None:
raise ValueError("Select an object in the ship collection")
matches = [collection for collection in bpy.data.collections
if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects]
if len(matches) != 1:
raise ValueError("Selected object must belong to exactly one bound ship collection")
return matches[0]
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")
obj = context.active_object
if obj is None:
raise ValueError("Select an object in the ship collection")
direct = list(obj.users_collection)
marked = [collection for collection in bpy.data.collections
if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects]
if len(marked) == 1:
collection = marked[0]
elif len(marked) == 0 and len(direct) == 1:
collection = direct[0]
else:
raise ValueError("Select an object in exactly one ship collection")
path = bpy.path.abspath(context.scene.aetheria_ship_cc_path)
ship_id = read(path, _brokkr_cultlib(context)).ship.body[0]
if collection.get("aetheria.asset_kind") not in (None, "ship"):
raise ValueError(f"{collection.name} has another aetheria.asset_kind")
if collection.get("aetheria.id") not in (None, ship_id):
raise ValueError(f"{collection.name} is bound to another ship ID")
stored = path
if bpy.data.filepath:
try:
stored = bpy.path.relpath(path)
except ValueError:
pass
collection["aetheria.asset_kind"] = "ship"
collection["aetheria.id"] = ship_id
collection["aetheria.ship_cc"] = stored
self.report({"INFO"}, f"Bound {collection.name} to {ship_id}")
return {"FINISHED"}
except (OSError, ValueError, RuntimeError, ImportError) as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
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(f"{collection.name} has no bound ship .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 into {path}")
return {"FINISHED"}
except (OSError, ValueError, RuntimeError, ImportError) as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
class AETHERIA_AP_preferences(bpy.types.AddonPreferences):
bl_idname = __package__
aetheria_repo: bpy.props.StringProperty(
name="Aetheria repo", subtype="DIR_PATH",
description="Aetheria checkout whose tools/AetherDb validates packages. "
"Empty: the nearest folder above the bound .cc that holds tools/AetherDb")
def draw(self, context):
self.layout.prop(self, "aetheria_repo")
def _aetheria_repo(context, ship_cc):
addon = context.preferences.addons.get(__package__)
configured = addon.preferences.aetheria_repo if addon and addon.preferences else ""
if configured:
repo = Path(bpy.path.abspath(configured))
if not (repo / "tools" / "AetherDb").is_dir():
raise ValueError(f"The Aetheria repo preference {repo} has no tools/AetherDb")
return repo
for folder in Path(ship_cc).resolve().parents:
if (folder / "tools" / "AetherDb").is_dir():
return folder
raise ValueError("No Aetheria repo above the ship .cc; set it in the add-on's preferences")
def _aetherdb(context, ship_cc, *args):
"""Runs one AetherDb ship-authoring command; returns its exit code and its output, stdout then stderr. The compiler
warnings 'dotnet run' prints when it rebuilds AetherDb are left out; errors are kept."""
repo = _aetheria_repo(context, ship_cc)
result = subprocess.run(
["dotnet", "run", "--project", str(repo / "tools" / "AetherDb"), "--", "ship-authoring", *args],
cwd=repo, capture_output=True, text=True, timeout=AETHERDB_TIMEOUT)
lines = (result.stdout + "\n" + result.stderr).splitlines()
return result.returncode, "\n".join(line for line in lines if line.strip() and ": warning " not in line)
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(f"{obj.name} has aetheria.role {role} but 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(f"Muzzle {obj.name} 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, Grease Pencil excluded, with custom properties as node extras."""
view_layer = context.view_layer
layer = _layer_collection(view_layer.layer_collection, collection)
if layer is None:
raise ValueError(f"{collection.name} 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)
for obj in collection.all_objects:
if obj.type != "GREASEPENCIL" 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)
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
class AETHERIA_OT_package_ship(bpy.types.Operator):
bl_idname = "aetheria.package_ship"
bl_label = "Package Ship"
bl_description = ("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)
cultlib = _brokkr_cultlib(context)
ship_id = collection["aetheria.id"]
if read(path, cultlib).ship.body[0] != ship_id:
raise ValueError("The bound collection ID does not match its .cc ship ID")
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, path, "validate", path)
except (OSError, ValueError, RuntimeError, ImportError, KeyError, subprocess.SubprocessError) as exc:
scene.aetheria_package_report = f"Package failed: {exc}"
self.report({"ERROR"}, scene.aetheria_package_report)
return {"CANCELLED"}
scene.aetheria_package_report = message or f"validate exited {code}"
if code != 0:
self.report({"ERROR"}, f"Packaged {len(anchors)} anchors; the validator refused it: {scene.aetheria_package_report}")
return {"CANCELLED"}
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
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)'}")
try:
collection = _ship_collection(context)
layout.label(text=f"Ship: {collection['aetheria.id']}")
except (ValueError, KeyError):
layout.label(text="No bound ship collection", icon="ERROR")
layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL")
layout.separator()
layout.operator("aetheria.load_ship_layout", icon="IMPORT")
state = context.scene.aetheria_layout
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")
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_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_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_package_report
del bpy.types.Scene.aetheria_ship_cc_path