Edit ship schematic and hardpoints in Blender .cc authoring
This commit is contained in:
parent
24c2964046
commit
375d6bd40e
3 changed files with 282 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue