Aetheria/tools/blender/aetheria_ships/__init__.py
Meta Vens 91bae0dd83 Bake From Source: Cycles Selected-to-Active from Source onto her UV0, one albedo and normal pair per ship
Adds aetheria.bake_from_source: bakes the Source meshes onto the selected render meshes as
'<ship id>.albedo' (sRGB) and '<ship id>.normal' (Non-Color), packed, wired into every material
(a shared '<ship id>.hull' material for a mesh with none). One routine serves both maps and every
target. The Source's Metallic and Transmission Weight are neutralised for the bake (the glTF importer
sets Metallic 1.0, which bakes black); engine, samples, device, selection and Source are restored in
a finally, and a failure removes everything made and puts every changed link back. Three bake
preferences, a panel button, the tripo-hull.glb LFS fixture and smoke_bake.py (4 passes).
_refuse_loose_meshes now shares the select-offenders refusal with the bake.
All commits build; no C# touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:38:26 +02:00

1665 lines
76 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 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