Edit ship schematic and hardpoints in Blender .cc authoring

This commit is contained in:
Meta Vens 2026-09-26 19:23:01 +02:00
commit 375d6bd40e
3 changed files with 282 additions and 12 deletions

View file

@ -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 <ship.cc>` 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

View file

@ -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

View file

@ -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")