"""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 functools import partial 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): _add_row(context.scene.aetheria_layout) 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"} 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]] 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") max_texture: bpy.props.IntProperty( name="Max texture", default=2048, min=64, soft_max=8192, description="The longest side, in pixels, a material image keeps in ship.glb; larger images are exported " "scaled to it and the .blend's own images are left as they are") bake_size: bpy.props.IntProperty( name="Bake size", default=2048, min=64, soft_max=8192, description="The side, in pixels, of the square albedo and normal images Bake From Source makes") bake_cage_extrusion: bpy.props.FloatProperty( name="Bake cage extrusion", default=0.05, min=0.0, unit="LENGTH", description="How far, in metres, Bake From Source pushes each render mesh surface out along its normals before " "casting rays at Source. Too small misses Source where it rises above the render mesh; too large " "catches parts across a gap") bake_max_ray_distance: bpy.props.FloatProperty( name="Bake max ray distance", default=0.0, min=0.0, unit="LENGTH", description="The longest ray, in metres, Bake From Source casts from the extruded surface. 0 means no limit") def draw(self, context): self.layout.prop(self, "game_folder") self.layout.prop(self, "max_texture") self.layout.prop(self, "bake_size") self.layout.prop(self, "bake_cage_extrusion") self.layout.prop(self, "bake_max_ray_distance") 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 DEFAULT_MAX_TEXTURE = 2048 def _max_texture(context): addon = context.preferences.addons.get(__package__) return addon.preferences.max_texture if addon and addon.preferences else DEFAULT_MAX_TEXTURE def _image_nodes(tree, seen=None): """Every Image Texture node of a node tree, node groups included.""" seen = set() if seen is None else seen if tree is None or tree.name_full in seen: return seen.add(tree.name_full) for node in tree.nodes: if node.type == "TEX_IMAGE" and node.image is not None: yield node elif node.type == "GROUP": yield from _image_nodes(node.node_tree, seen) def _swap_in_scaled_images(objects, limit, swaps): """Points each Image Texture node of the objects' materials that holds an image longer than limit at a scaled copy, and appends each (node, original, copy) to swaps for _restore_images, so a failure part way still restores. The originals are never modified.""" copies = {} for obj in objects: for slot in obj.material_slots: for node in _image_nodes(slot.material.node_tree if slot.material else None): original = node.image longest = max(original.size) if longest <= limit: continue if original not in copies: copy = original.copy() try: copy.scale(max(1, round(original.size[0] * limit / longest)), max(1, round(original.size[1] * limit / longest))) except BaseException: bpy.data.images.remove(copy) # not yet in swaps, so _restore_images cannot find it raise copies[original] = copy node.image = copies[original] swaps.append((node, original, copies[original])) def _restore_images(swaps): for node, original, _ in swaps: node.image = original for copy in {id(copy): copy for _, _, copy in swaps}.values(): bpy.data.images.remove(copy) 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 swaps = [] try: view_layer.active_layer_collection = layer for obj in previous_selection: obj.select_set(False) derived = _derived_names(collection) exported = [obj for obj in collection.all_objects if obj.type != "GREASEPENCIL" and obj.name not in derived and obj.name in view_layer.objects] for obj in exported: obj.select_set(True) _swap_in_scaled_images(exported, _max_texture(context), swaps) 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: _restore_images(swaps) 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 loose: _refuse_selecting(context, loose, 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) BAKE_SAMPLES = 1 # one sample per texel: the maps are a starting point and the smoke runs on the CPU BAKE_MAPS = (("albedo", "sRGB", {"type": "DIFFUSE", "pass_filter": {"COLOR"}}), ("normal", "Non-Color", {"type": "NORMAL", "normal_space": "TANGENT"})) def _principled(material): tree = material.node_tree if material else None return next((node for node in tree.nodes if node.type == "BSDF_PRINCIPLED"), None) if tree else None def _wired_image(material, kind): """The Image Texture node already feeding the material's Principled Base Color (albedo) or, through a Normal Map node, its Normal.""" socket = _principled(material).inputs["Base Color" if kind == "albedo" else "Normal"] node = socket.links[0].from_node if socket.is_linked else None if kind == "normal" and node is not None and node.type == "NORMAL_MAP": colour = node.inputs["Color"] node = colour.links[0].from_node if colour.is_linked else None return node if node is not None and node.type == "TEX_IMAGE" else None def _select_only(context, objects): keep = {obj.name for obj in objects} for obj in context.view_layer.objects: obj.select_set(obj.name in keep) def _refuse_selecting(context, offenders, message): """Selects the offenders and refuses with message, which names a count and never an object.""" _select_only(context, offenders) raise ValueError(message) def _bake_inputs(context, collection): """The ship ID, the Source meshes and the selected render meshes Bake From Source works on, or the refusal that stops it before anything changes.""" if context.mode != "OBJECT": raise ValueError("Switch to Object mode before baking") source = _child(collection, SOURCE) sources = _meshes(source.all_objects) if source else [] if _ship_root(collection) is None or not sources: raise ValueError("Bake From Source needs a Ship Root and a mesh in Source; run New Ship first") layer = context.view_layer hidden = [obj for obj in sources if obj.name not in layer.objects or obj.hide_get()] if hidden: _refuse_selecting(context, [obj for obj in hidden if obj.name in layer.objects], f"{len(hidden)} Source meshes are hidden or outside the view layer; show them to bake") targets = [obj for obj in _own_meshes(collection) if obj.name in layer.objects and obj.select_get()] if not targets: raise ValueError("Select the render meshes to bake onto") flat = [obj for obj in targets if not obj.data.uv_layers] if flat: _refuse_selecting(context, flat, f"{len(flat)} selected meshes have no UV layer; unwrap them first") bare = [obj for obj in targets if any(_principled(slot.material) is None for slot in obj.material_slots)] if bare: _refuse_selecting(context, bare, f"{len(bare)} selected meshes have a material without a Principled BSDF") return collection["aetheria.id"], sources, targets def _target_materials(targets): return list({slot.material.name: slot.material for obj in targets for slot in obj.material_slots}.values()) def _hold(owner, name, value, restore): """Sets owner.name to value for the bake; restore gets the step that puts the old value back.""" restore.append(partial(setattr, owner, name, getattr(owner, name))) setattr(owner, name, value) def _isolate_sources(sources, restore): """The Source materials as the bake should see them: Metallic and Transmission Weight at 0, links into them muted.""" seen = set() for obj in sources: for slot in obj.material_slots: for node in (slot.material.node_tree.nodes if slot.material and slot.material.node_tree else ()): if node.type != "BSDF_PRINCIPLED" or (slot.material.name, node.name) in seen: continue seen.add((slot.material.name, node.name)) for name in ("Metallic", "Transmission Weight"): socket = node.inputs[name] for link in socket.links: _hold(link, "is_muted", True, restore) _hold(socket, "default_value", 0.0, restore) def _remove_image(name): image = bpy.data.images.get(name) if image: bpy.data.images.remove(image) def _remove_node(material_name, node_name): material = bpy.data.materials.get(material_name) node = material.node_tree.nodes.get(node_name) if material and material.node_tree else None if node: material.node_tree.nodes.remove(node) def _remove_material(name): material = bpy.data.materials.get(name) if material: bpy.data.materials.remove(material) def _clear_materials(mesh_name): bpy.data.meshes[mesh_name].materials.clear() def _restore_link(material_name, node_name, input_name, previous): tree = bpy.data.materials[material_name].node_tree socket = tree.nodes[node_name].inputs[input_name] for link in list(socket.links): tree.links.remove(link) if previous: tree.links.new(next(out for out in tree.nodes[previous[0]].outputs if out.identifier == previous[1]), socket) def _set_node_image(material_name, node_name, image_name): bpy.data.materials[material_name].node_tree.nodes[node_name].image = bpy.data.images.get(image_name) def _link_into(material, node, input_name, source, undo): socket = node.inputs[input_name] previous = (socket.links[0].from_node.name, socket.links[0].from_socket.identifier) if socket.is_linked else None material.node_tree.links.new(source, socket) undo.append(partial(_restore_link, material.name, node.name, input_name, previous)) def _bake_map(context, sources, targets, settings, extrusion, max_distance, nodes): """Bakes one map from the Source meshes onto every target, the first target clearing the image and the later ones adding to it. nodes maps a material's name to the Image Texture node that holds this map's image. This is the add-on's only bake.""" layer = context.view_layer for index, target in enumerate(targets): _select_only(context, sources + [target]) layer.objects.active = target for slot in target.material_slots: slot.material.node_tree.nodes.active = nodes[slot.material.name] try: bpy.ops.object.bake( use_selected_to_active=True, cage_extrusion=extrusion, max_ray_distance=max_distance, use_clear=index == 0, **settings) except RuntimeError: # Blender's text names objects and images traceback.print_exc() raise RuntimeError("The bake failed; its message is in the system console") from None def _bake_from_source(context, collection, replace): """Bakes the Source meshes onto the selected render meshes' UV0 as one albedo and one normal image per ship, and wires them into every material of those meshes. The images are new and are wired only after both bakes finish; any failure puts every changed value back and removes what was made. The Source materials, scene settings and selection are as they were afterwards.""" ship_id, sources, targets = _bake_inputs(context, collection) if not replace and any(_wired_image(material, kind) for material in _target_materials(targets) for kind, _, _ in BAKE_MAPS): raise ValueError("Selected meshes already have baked images; confirm the replace") addon = context.preferences.addons[__package__].preferences scene, layer = context.scene, context.view_layer previous_selection = [obj for obj in layer.objects if obj.select_get()] previous_active = layer.objects.active restore, undo, replaced = [], [], {} restore.append(lambda: ([obj.select_set(obj in previous_selection) for obj in layer.objects], setattr(layer.objects, "active", previous_active))) try: _hold(scene.render, "engine", "CYCLES", restore) _hold(scene.cycles, "samples", BAKE_SAMPLES, restore) _hold(scene.cycles, "device", "CPU", restore) _isolate_sources(sources, restore) images = {} for kind, space, _ in BAKE_MAPS: image = bpy.data.images.new(f"{ship_id}.{kind}", addon.bake_size, addon.bake_size, alpha=False) undo.append(partial(_remove_image, image.name)) image.colorspace_settings.name = space images[kind] = image hull = None for obj in targets: if not obj.data.materials: if hull is None: hull = bpy.data.materials.new(f"{ship_id}.hull") hull.use_nodes = True undo.append(partial(_remove_material, hull.name)) obj.data.materials.append(hull) undo.append(partial(_clear_materials, obj.data.name)) materials = _target_materials(targets) held = {kind: {} for kind, _, _ in BAKE_MAPS} for material in materials: for kind, _, _ in BAKE_MAPS: node = material.node_tree.nodes.new("ShaderNodeTexImage") node.image, node.label = images[kind], f"Baked {kind}" undo.append(partial(_remove_node, material.name, node.name)) held[kind][material.name] = node for kind, _, settings in BAKE_MAPS: _bake_map(context, sources, targets, settings, addon.bake_cage_extrusion, addon.bake_max_ray_distance, held[kind]) for image in images.values(): image.pack() for material in materials: # the commit: every step is in undo bsdf = _principled(material) for kind, _, _ in BAKE_MAPS: node = held[kind][material.name] old = _wired_image(material, kind) if old is not None: if old.image: replaced[old.image.name] = old.image undo.append(partial(_set_node_image, material.name, old.name, old.image.name if old.image else None)) old.image = images[kind] _remove_node(material.name, node.name) elif kind == "albedo": _link_into(material, bsdf, "Base Color", node.outputs["Color"], undo) else: normal = material.node_tree.nodes.new("ShaderNodeNormalMap") undo.append(partial(_remove_node, material.name, normal.name)) material.node_tree.links.new(node.outputs["Color"], normal.inputs["Color"]) _link_into(material, bsdf, "Normal", normal.outputs["Normal"], undo) except BaseException: for step in reversed(undo): step() raise finally: for step in reversed(restore): step() for image in replaced.values(): if image.users == 0: bpy.data.images.remove(image) for kind, image in images.items(): image.name = f"{ship_id}.{kind}" class AETHERIA_OT_bake_from_source(bpy.types.Operator): bl_idname = "aetheria.bake_from_source" bl_label = "Bake From Source" bl_description = ("Bake the Source meshes' colour and normal onto the selected render meshes' UV0 as the ship's albedo " "and normal maps, and wire them into their materials. Nothing rebakes unless you press this") bl_options = {"REGISTER", "UNDO"} replace: bpy.props.BoolProperty( name="Replace", default=False, options={"SKIP_SAVE"}, description="Replace the images the selected meshes' materials already wire into Base Color and Normal") def invoke(self, context, event): try: _, _, targets = _bake_inputs(context, _ship_collection(context)) except _EXPECTED: return self.execute(context) # the same refusal, reported if any(_wired_image(material, kind) for material in _target_materials(targets) for kind, _, _ in BAKE_MAPS): self.replace = True return context.window_manager.invoke_confirm( self, event, message="The selected meshes already have baked images. Replace them?") return self.execute(context) def execute(self, context): try: _bake_from_source(context, _ship_collection(context), self.replace) self.report({"INFO"}, "Baked the albedo and normal maps from Source") 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) 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) 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") layout.operator("aetheria.bake_from_source", icon="RENDER_RESULT") 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") 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") 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) @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_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_bake_from_source, 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