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