From 375d6bd40e693019b26a1a2086bacc7bccd5b369 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 19:23:01 +0200 Subject: [PATCH] Edit ship schematic and hardpoints in Blender .cc authoring --- docs/moddable-ship-authoring.md | 29 ++- tools/blender/aetheria_ships/__init__.py | 213 ++++++++++++++++++++++- tools/blender/aetheria_ships/ship_cc.py | 52 ++++++ 3 files changed, 282 insertions(+), 12 deletions(-) diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 3ded258c7..85f9c79f8 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -23,8 +23,9 @@ authored `HullData.Shape` in the `.cc`; strokes do not decide armor or mount footprints. `tools/blender/aetheria_ships` is a separate Blender add-on in Brokkr's -sidebar. It uses Brokkr's configured CultLib Python source and edits only the -line slot of an existing `aetheria.ship_authoring` record. To try it, make +sidebar. It uses Brokkr's configured CultLib Python source and edits the +line, hull shape, and hardpoint slots of an existing typed +`aetheria.ship_authoring` record. To try it, make `F:\Projects\Brokkr\surfaces\blender` and this repo's `tools\blender` visible to Blender's add-on search path, enable `brokkr_bridge` then `aetheria_ships`, and install `msgpack` into Blender's Python environment. @@ -36,6 +37,16 @@ Collection**. The collection stores `aetheria.asset_kind=ship`, the stable that bound collection and press **Capture Ship Lines**. Capture checks the collection ID against the record before writing; object and collection display names are not IDs. +**Load Ship Layout** opens the same `.cc` record's hull grid and hardpoints in +the panel. Toggle occupied grid cells, resize up to 32×32, and edit each +hardpoint's type, mount ID, position, footprint, rotation, armor, and firing +arc. A footprint uses top-to-bottom rows of `#` (occupied) and `.` (empty), +separated by `/`; `##/##` is a 2×2 mount. **Save Layout to .cc** replaces only +the hull shape and hardpoint slots. It preserves hull stats, anchors, visual +assets, lines, the record key, and schema. Save rejects a changed ship ID or +layout revision; reload first if another editor changed those fields. A line +capture leaves the loaded layout revision intact. The saved layout is a draft until +its mount IDs resolve to visual anchors and C# validation passes. `ship-authoring inspect ` reads drafts; `validate` requires complete hull, model, and anchor authoring. @@ -85,8 +96,9 @@ is not yet an end-to-end ship loader in the current game. migrated hull; it does not keep two writable definitions of that hull. `ship-authoring validate`, the catalog composer, and the visual importer call -the same semantic validator. Blender capture writes only line data and -`inspect` reads incomplete drafts. Unity's **Aetheria / Preview Mod Ship +the same semantic validator. Blender edits the hull grid, hardpoints, and +captured lines in the typed source record; `inspect` reads incomplete drafts. +Unity's **Aetheria / Preview Mod Ship Package** imports a validated package from disk, resolves stable GLB node IDs, and draws the captured polylines directly as 3D line segments. Point opacity and color are retained; radius and material-specific stroke styling are not @@ -136,9 +148,10 @@ derived catalog only for gameplay reads. 1. A typed `.cc` record round-trips between C# and Blender's Python runtime, preserving unknown future fields and record identity. -2. Blender edits schematic cells and hardpoints in that record, and validates - their association with stable visual node IDs. The model supplies geometry, - not a second copy of hull semantics. +2. Blender edits schematic cells and hardpoints in that record. Stable visual + node IDs still need a collection-to-package authoring action; the C# + validator checks their association with mount IDs and GLB nodes. The model + supplies geometry, not a second copy of hull semantics. 3. A command-line validator rejects malformed cells, overlapping or dangling hardpoints, missing model nodes, and unresolved asset references with precise errors. It changes no live catalog or scene on failure. The @@ -154,7 +167,7 @@ derived catalog only for gameplay reads. The proof reuses `HullData` simulation semantics, CultCache `.cc` persistence, Brokkr's Blender host, and the current `ShipInstance` behavior. It adds one -authoring document type and validator, a Blender-facing line capture surface, +authoring document type and validator, Blender-facing layout and line editing, a disposable catalog composer, and one runtime GLB/line visual importer with an Editor preview. The remaining construction work is `ShipInstance` wiring and boot-time preloading. No daemon, second gameplay simulation, or Unity diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index da2ae8043..b12a7b38e 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -12,7 +12,171 @@ bl_info = { import bpy -from .ship_cc import capture_grease_pencil, read, replace_lines +from .ship_cc import capture_grease_pencil, read, read_layout, replace_layout, replace_lines + + +HARDPOINT_TYPES = tuple((str(i), name, name) for i, name in enumerate(( + "Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator", + "Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule", "AetherDrive", +))) +ROTATIONS = (("0", "None", "None"), ("1", "Counterclockwise", "Counterclockwise"), + ("2", "Reversed", "Reversed"), ("3", "Clockwise", "Clockwise")) + + +class AETHERIA_PG_cell(bpy.types.PropertyGroup): + occupied: bpy.props.BoolProperty(name="Occupied") + + +class AETHERIA_PG_hardpoint(bpy.types.PropertyGroup): + mount_id: bpy.props.StringProperty(name="Mount ID") + kind: bpy.props.EnumProperty(name="Type", items=HARDPOINT_TYPES) + x: bpy.props.IntProperty(name="X", min=0, max=31) + y: bpy.props.IntProperty(name="Y", min=0, max=31) + footprint: bpy.props.StringProperty( + name="Footprint", default="#", description="Top-to-bottom rows of # and ., separated by /" + ) + rotation: bpy.props.EnumProperty(name="Rotation", items=ROTATIONS) + armor: bpy.props.FloatProperty(name="Armor", min=0) + firing_arc: bpy.props.FloatProperty(name="Firing Arc", min=0, max=360) + + +class AETHERIA_PG_layout(bpy.types.PropertyGroup): + ship_id: bpy.props.StringProperty() + ship_cc: bpy.props.StringProperty() + revision: bpy.props.StringProperty() + width: bpy.props.IntProperty(default=1) + height: bpy.props.IntProperty(default=1) + new_width: bpy.props.IntProperty(name="Width", default=1, min=1, max=32) + new_height: bpy.props.IntProperty(name="Height", default=1, min=1, max=32) + cells: bpy.props.CollectionProperty(type=AETHERIA_PG_cell) + hardpoints: bpy.props.CollectionProperty(type=AETHERIA_PG_hardpoint) + + +def _footprint(raw): + width, height, cells = raw[0] + return "/".join("".join("#" if cells[x * height + y] else "." for x in range(width)) + for y in range(height - 1, -1, -1)) + + +def _parse_footprint(text): + rows = text.split("/") + width = len(rows[0]) + height = len(rows) + if not (1 <= width <= 32 and 1 <= height <= 32 and + all(len(row) == width and set(row) <= {"#", "."} for row in rows)): + raise ValueError("Footprint must use equal-width #/. rows separated by /") + return [[width, height, [rows[height - 1 - y][x] == "#" + for x in range(width) for y in range(height)]]] + + +def _layout_path(context): + collection = _ship_collection(context) + if not collection.get("aetheria.ship_cc"): + raise ValueError("Ship collection has no bound .cc path") + return collection, bpy.path.abspath(collection["aetheria.ship_cc"]) + + +class AETHERIA_OT_load_layout(bpy.types.Operator): + bl_idname = "aetheria.load_ship_layout" + bl_label = "Load Ship Layout" + bl_options = {"REGISTER"} + + def execute(self, context): + try: + collection, path = _layout_path(context) + shape, hardpoints, revision = read_layout(path, _brokkr_cultlib(context)) + width, height, cells = shape + state = context.scene.aetheria_layout + state.ship_id = collection["aetheria.id"] + state.ship_cc = path + state.revision = revision + state.width = state.new_width = width + state.height = state.new_height = height + state.cells.clear() + for cell in cells: + state.cells.add().occupied = bool(cell) + state.hardpoints.clear() + for raw in hardpoints: + hp = state.hardpoints.add() + hp.kind = str(raw[0]) + hp.x, hp.y = raw[1] + hp.footprint = _footprint(raw[2]) + hp.mount_id = raw[3] or "" + hp.rotation = str(raw[4]) + hp.armor = raw[5] + hp.firing_arc = raw[6] + self.report({"INFO"}, f"Loaded {width}x{height} layout and {len(hardpoints)} hardpoints") + return {"FINISHED"} + except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} + + +class AETHERIA_OT_resize_layout(bpy.types.Operator): + bl_idname = "aetheria.resize_ship_layout" + bl_label = "Resize Grid" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + state = context.scene.aetheria_layout + old = [cell.occupied for cell in state.cells] + width, height = state.width, state.height + expanded = [old[x * height + y] if x < width and y < height else False + for x in range(state.new_width) for y in range(state.new_height)] + state.cells.clear() + for cell in expanded: + state.cells.add().occupied = cell + state.width, state.height = state.new_width, state.new_height + return {"FINISHED"} + + +class AETHERIA_OT_add_hardpoint(bpy.types.Operator): + bl_idname = "aetheria.add_ship_hardpoint" + bl_label = "Add Hardpoint" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + context.scene.aetheria_layout.hardpoints.add() + return {"FINISHED"} + + +class AETHERIA_OT_remove_hardpoint(bpy.types.Operator): + bl_idname = "aetheria.remove_ship_hardpoint" + bl_label = "Remove Hardpoint" + bl_options = {"REGISTER", "UNDO"} + index: bpy.props.IntProperty() + + def execute(self, context): + state = context.scene.aetheria_layout + if 0 <= self.index < len(state.hardpoints): + state.hardpoints.remove(self.index) + return {"FINISHED"} + + +class AETHERIA_OT_save_layout(bpy.types.Operator): + bl_idname = "aetheria.save_ship_layout" + bl_label = "Save Layout to .cc" + bl_options = {"REGISTER"} + + def execute(self, context): + try: + collection, path = _layout_path(context) + state = context.scene.aetheria_layout + if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: + raise ValueError("Load this collection's layout before saving") + cells = [cell.occupied for cell in state.cells] + hardpoints = [[int(hp.kind), [hp.x, hp.y], _parse_footprint(hp.footprint), + hp.mount_id, int(hp.rotation), hp.armor, hp.firing_arc] + for hp in state.hardpoints] + state.revision = replace_layout( + path, _brokkr_cultlib(context), state.ship_id, state.revision, + [state.width, state.height, cells], hardpoints + ) + self.report({"INFO"}, f"Saved {len(hardpoints)} hardpoints to {path}") + return {"FINISHED"} + except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} def _brokkr_cultlib(context): @@ -121,11 +285,53 @@ class AETHERIA_PT_ship(bpy.types.Panel): layout.label(text="No bound ship collection", icon="ERROR") layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL") + layout.separator() + layout.operator("aetheria.load_ship_layout", icon="IMPORT") + state = context.scene.aetheria_layout + if not state.ship_id: + return + layout.label(text=f"Layout: {state.ship_id}") + dims = layout.row(align=True) + dims.prop(state, "new_width") + dims.prop(state, "new_height") + dims.operator("aetheria.resize_ship_layout", text="Resize") + grid = layout.box() + grid.label(text="Hull cells (top row first)") + if len(state.cells) == state.width * state.height: + for y in range(state.height - 1, -1, -1): + row = grid.row(align=True) + row.label(text=str(y)) + for x in range(state.width): + row.prop(state.cells[x * state.height + y], "occupied", text="") + layout.label(text="Hardpoints") + for index, hp in enumerate(state.hardpoints): + box = layout.box() + header = box.row(align=True) + header.prop(hp, "mount_id") + header.operator("aetheria.remove_ship_hardpoint", text="", icon="X").index = index + box.prop(hp, "kind") + position = box.row(align=True) + position.prop(hp, "x") + position.prop(hp, "y") + box.prop(hp, "footprint") + box.prop(hp, "rotation") + box.prop(hp, "armor") + box.prop(hp, "firing_arc") + layout.operator("aetheria.add_ship_hardpoint", icon="ADD") + layout.operator("aetheria.save_ship_layout", icon="FILE_TICK") -classes = (AETHERIA_OT_bind_ship_collection, AETHERIA_OT_capture_ship_lines, AETHERIA_PT_ship) + +classes = (AETHERIA_PG_cell, AETHERIA_PG_hardpoint, AETHERIA_PG_layout, + AETHERIA_OT_load_layout, AETHERIA_OT_resize_layout, + AETHERIA_OT_add_hardpoint, AETHERIA_OT_remove_hardpoint, + AETHERIA_OT_save_layout, AETHERIA_OT_bind_ship_collection, + AETHERIA_OT_capture_ship_lines, AETHERIA_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" ) @@ -133,11 +339,10 @@ def register(): name="Evaluated LineArt", default=True, description="Capture the visible modifier result at the current frame", ) - for cls in classes: - bpy.utils.register_class(cls) 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 diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 4bedd2fb4..727db49e0 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -8,6 +8,7 @@ the Blender host and CultLib Python dependency path. from __future__ import annotations import sys +import hashlib from dataclasses import replace from datetime import datetime, timezone from pathlib import Path @@ -15,6 +16,9 @@ from typing import Any SCHEMA = "aetheria.ship_authoring" SCHEMATIC_LINES_SLOT = 4 +HULL_SLOT = 1 +HULL_SHAPE_SLOT = 5 +HULL_HARDPOINTS_SLOT = 23 def _libraries(cultlib_packages: str): @@ -54,6 +58,54 @@ def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> i return len(lines) +def read_layout(path: str, cultlib_packages: str): + _, _, body, msgpack = read(path, cultlib_packages) + hull = body[HULL_SLOT] + if not isinstance(hull, list) or len(hull) <= HULL_HARDPOINTS_SLOT: + raise ValueError("Ship hull has an incompatible typed payload") + return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], _layout_revision(hull, msgpack) + + +def _layout_revision(hull, msgpack): + return hashlib.sha256(msgpack.packb( + [hull[HULL_SHAPE_SLOT], hull[HULL_HARDPOINTS_SLOT]], use_bin_type=True + )).hexdigest() + + +def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_revision: str, + shape: list[Any], hardpoints: list[list[Any]]) -> str: + store, envelope, body, msgpack = read(path, cultlib_packages) + if body[0] != expected_id: + raise ValueError("The bound ship ID changed; reload its layout") + hull = body[HULL_SLOT] + if _layout_revision(hull, msgpack) != expected_revision: + raise ValueError("The .cc layout changed since Load; reload before saving") + width, height, cells = shape + if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height): + raise ValueError("Hull grid must be 1..32 cells wide and high with one value per cell") + if not any(cells): + raise ValueError("Hull grid needs an occupied cell") + previous = {hp[3]: hp[7:] for hp in body[HULL_SLOT][HULL_HARDPOINTS_SLOT] + if isinstance(hp, list) and len(hp) > 7 and isinstance(hp[3], str)} + updated_hardpoints = [] + for hardpoint in hardpoints: + if len(hardpoint) != 7 or not isinstance(hardpoint[3], str) or not hardpoint[3]: + raise ValueError("Each hardpoint needs seven typed fields and a stable mount ID") + hp_width, hp_height, hp_cells = hardpoint[2][0] + if not (1 <= hp_width <= 32 and 1 <= hp_height <= 32 and + len(hp_cells) == hp_width * hp_height and any(hp_cells)): + raise ValueError(f"Hardpoint {hardpoint[3]} has an invalid footprint") + updated_hardpoints.append(hardpoint + previous.get(hardpoint[3], [])) + hull[HULL_SHAPE_SLOT] = [shape] + hull[HULL_HARDPOINTS_SLOT] = updated_hardpoints + store.push(replace( + envelope, + payload=msgpack.packb(body, use_bin_type=True), + stored_at=datetime.now(timezone.utc).isoformat(), + )) + return _layout_revision(hull, msgpack) + + def capture_grease_pencil(obj: Any, depsgraph: Any, frame_number: int, *, evaluated: bool = True) -> list[list[Any]]: if obj.type != "GREASEPENCIL": raise ValueError(f"{obj.name} is not a Grease Pencil object")