Add parallel typed ship authoring and Blender LineArt capture

This commit is contained in:
Meta Vens 2026-09-26 18:14:36 +02:00
commit 42eb5214ce
8 changed files with 636 additions and 1 deletions

View file

@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
// A mod ship's authoring record. Hull is the existing simulation design, embedded here so a
// proof ship has one writable source; the shipped catalog contains no copy of that ship.
[CultDocument("aetheria.ship_authoring", "1"), MessagePackObject]
public sealed class ShipAuthoring
{
[CultName, Key(0)] public string Id;
[Key(1)] public HullData Hull;
[Key(2)] public string ModelAsset;
[Key(3)] public List<ShipAnchor> Anchors = new List<ShipAnchor>();
[Key(4)] public List<ShipPolyline> SchematicLines = new List<ShipPolyline>();
}
[MessagePackObject]
public sealed class ShipAnchor
{
// Stable semantic identity. HullData.Hardpoints[].Transform names this ID for mounts.
[Key(0)] public string Id;
// One of: map-icon, hull-collider, shield, tractor, thruster-emitter,
// weapon-muzzle, radiator-mesh, or articulation.
[Key(1)] public string Role;
// Stable node ID in the compiled model, never a Blender display name.
[Key(2)] public string ModelNodeId;
// Only child roles use ParentId; e.g. a muzzle names its weapon hardpoint.
[Key(3)] public string ParentId;
[Key(4)] public int Order;
}
// Evaluated or baked Grease Pencil stroke coordinates in Blender's right-handed Z-up
// space. The runtime renderer owns its coordinate conversion and camera projection.
[MessagePackObject]
public sealed class ShipPolyline
{
[Key(0)] public string Layer;
[Key(1)] public string Material;
[Key(2)] public float[] Points;
[Key(3)] public float[] Radii;
[Key(4)] public float[] Opacities;
[Key(5)] public bool Cyclic;
[Key(6)] public float[] Color;
}
public static class ShipAuthoringStore
{
public static CultCache Open(string path, bool writable = false)
{
var cache = new CultCache();
try
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(ShipAuthoring) });
return cache;
}
catch
{
cache.Dispose();
throw;
}
}
public static ShipAuthoring Read(string path)
{
using var cache = Open(path);
var records = cache.GetAll<ShipAuthoring>().ToArray();
if (records.Length != 1)
throw new InvalidOperationException($"{path} must hold exactly one ship authoring record, found {records.Length}.");
Validate(records[0]);
return records[0];
}
public static void Validate(ShipAuthoring ship)
{
if (ship == null) throw new InvalidOperationException("Ship authoring record is null.");
if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required.");
var hull = ship.Hull ?? throw new InvalidOperationException($"{ship.Id}: hull data is required.");
if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required.");
if (hull.Shape?.Cells == null || hull.Shape.Width < 1 || hull.Shape.Height < 1 ||
!hull.Shape.Cells.Cast<bool>().Any(occupied => occupied))
throw new InvalidOperationException($"{ship.Id}: schematic must contain at least one cell.");
if (!string.IsNullOrEmpty(hull.Prefab))
throw new InvalidOperationException($"{ship.Id}: a mod ship cannot name a Unity prefab.");
if (string.IsNullOrWhiteSpace(ship.ModelAsset) || Path.IsPathRooted(ship.ModelAsset) ||
ship.ModelAsset.Replace('\\', '/').Split('/').Any(part => part == ".."))
throw new InvalidOperationException($"{ship.Id}: model asset must be a relative package path.");
var anchors = ship.Anchors ?? throw new InvalidOperationException($"{ship.Id}: anchors are required.");
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var anchor in anchors)
{
if (anchor == null || string.IsNullOrWhiteSpace(anchor.Id) || !ids.Add(anchor.Id))
throw new InvalidOperationException($"{ship.Id}: anchor IDs must be present and unique.");
if (string.IsNullOrWhiteSpace(anchor.ModelNodeId))
throw new InvalidOperationException($"{ship.Id}: anchor {anchor.Id} needs a model node ID.");
}
foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" })
if (anchors.Count(anchor => anchor.Role == role) != 1)
throw new InvalidOperationException($"{ship.Id}: exactly one {role} anchor is required.");
var mounts = new HashSet<string>(StringComparer.Ordinal);
var occupiedHardpointCells = new HashSet<(int x, int y)>();
foreach (var hardpoint in hull.Hardpoints ?? new List<HardpointData>())
{
if (hardpoint == null || string.IsNullOrWhiteSpace(hardpoint.Transform) ||
!mounts.Add(hardpoint.Transform))
throw new InvalidOperationException($"{ship.Id}: hardpoint IDs must be present and unique.");
if (hardpoint.Shape?.Cells == null || !hardpoint.Shape.Cells.Cast<bool>().Any(occupied => occupied))
throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no cells.");
for (var cellX = 0; cellX < hardpoint.Shape.Width; cellX++)
for (var cellY = 0; cellY < hardpoint.Shape.Height; cellY++)
{
if (!hardpoint.Shape.Cells[cellX, cellY]) continue;
var x = hardpoint.Position.x + cellX;
var y = hardpoint.Position.y + cellY;
if (x < 0 || y < 0 || x >= hull.Shape.Width || y >= hull.Shape.Height || !hull.Shape.Cells[x, y])
throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} lies outside the hull schematic.");
if (!occupiedHardpointCells.Add((x, y)))
throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} overlaps another hardpoint.");
}
if (!anchors.Any(anchor => anchor.Id == hardpoint.Transform))
throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no model anchor.");
}
foreach (var anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.ParentId)))
if (!mounts.Contains(anchor.ParentId))
throw new InvalidOperationException($"{ship.Id}: anchor {anchor.Id} has unknown hardpoint parent {anchor.ParentId}.");
foreach (var line in ship.SchematicLines ?? new List<ShipPolyline>())
{
var points = line?.Points;
if (points == null || points.Length < 6 || points.Length % 3 != 0 ||
points.Any(value => float.IsNaN(value) || float.IsInfinity(value)))
throw new InvalidOperationException($"{ship.Id}: schematic line needs finite XYZ points.");
var count = points.Length / 3;
if (line.Radii != null && line.Radii.Length != count ||
line.Opacities != null && line.Opacities.Length != count)
throw new InvalidOperationException($"{ship.Id}: schematic line point attributes have the wrong length.");
if (line.Radii != null && line.Radii.Any(value => float.IsNaN(value) || float.IsInfinity(value) || value < 0) ||
line.Opacities != null && line.Opacities.Any(value => float.IsNaN(value) || float.IsInfinity(value) || value < 0 || value > 1))
throw new InvalidOperationException($"{ship.Id}: schematic line radius and opacity must be finite and in range.");
if (line.Color != null && (line.Color.Length != 4 ||
line.Color.Any(value => float.IsNaN(value) || float.IsInfinity(value))))
throw new InvalidOperationException($"{ship.Id}: schematic line color must be finite RGBA.");
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0da6676b1995425fa66187e8e58956e7

View file

@ -0,0 +1,121 @@
# Moddable ship authoring: parallel proof
Status: design and proof lane. The existing `HullData` catalog records, Longinus and
Djinni prefabs, and their spawn path remain live until a complete mod ship passes
authoring, validation, loading, and play checks.
## LineArt proof from Quiet.blend
`C:\Users\Meta\Desktop\Quiet.blend` is a read-only source for this probe. At frame
1, `Quiet / Dexter Quiet Lineart` evaluates to 1,040 polylines with 3,125
points. Blender 5.2.2 wrote those coordinates, point radii and opacities,
material/layer names, cyclic flags, and RGBA into `Build/quiet.cc` without
changing the `.blend`. C# reopened the same typed record and counted the same
lines and points. A second line replacement preserved its record key, schema
ID, hull, model asset, and anchors byte for byte. `Build/quiet.cc` is an
ignored draft probe, not an installed ship.
The line positions remain 3D Blender world coordinates. The runtime can
lower them to a hologram or schematic view without regenerating LineArt.
LineArt evaluation depends on the source scene and camera, so a captured set
may be incomplete from another view. Gameplay cell occupancy remains the
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
`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.
Create a draft with `dotnet run --project tools/AetherDb -- ship-authoring
create <ship.cc> <stable-id> <name>`, choose that `.cc` in the **Aetheria
Ship** panel, select a Grease Pencil object, and press **Capture Ship Lines**.
`ship-authoring inspect <ship.cc>` reads drafts; `validate` requires complete
hull, model, and anchor authoring.
## Objective
A mod author edits one typed ship record in a `.cc` file, including the hull
schematic and every hardpoint. Blender, through Aetheria tooling built on
Brokkr, edits that same record while displaying its visual source. The game
assembles an instance from the record and its referenced asset package at
runtime. A mod package can be installed without opening the Unity Editor.
## Current mechanism
`GameData/Aetheria.cc` holds `HullData`: the cell shape, hardpoint footprints,
stats, and a Unity Addressables prefab GUID. `ZoneRenderer.LoadEntity` loads
that prefab and expects `ShipInstance`; `EntityInstance` and `ShipInstance`
match catalog hardpoint transform strings against manually wired prefab
arrays. The new FBX naming builder automates prefab wiring but still emits a
Unity prefab and is part of this existing path. Brokkr can capture Blender
collections and custom properties; the AetheriaEve Blender authoring v1
contract describes a future collection-to-CultMesh deploy lane, but that lane
is not yet an end-to-end ship loader in the current game.
## Target authority map for the parallel lane
- **Owner:** a versioned `ShipAuthoring` document in a mod-owned `.cc` file
owns the ship's authored hull, schematic, hardpoints, structural and
presentation associations, and asset references. A ship ID is stable across
display-name changes.
- **Inputs:** the authoring record; a Blender visual source identified by the
record; compiled mesh, texture, and material artifacts; references to gear
designs where the game requires them.
- **Outputs:** a validated runtime hull design and an assembled visual/physics
`ShipInstance`. The assembly and any catalog row are derived products.
- **Derived state:** Unity objects, component arrays, collision components,
particle emitters, and schematic UI. Installed equipment, damage, heat,
cargo, ownership, and position are live game state.
- **Forbidden writers:** Blender object names, Unity prefabs, generated catalog
rows, and runtime components may not independently decide the schematic,
hardpoint type or footprint, armor, drag, or mount identity. Geometry nodes
carry stable IDs; the `.cc` document gives them meaning.
- **Shared path:** Blender validation, command-line validation, editor preview,
and runtime loading read the same typed record and invoke the same semantic
validator. A bad mod is rejected before partial scene construction.
- **Cut line:** no existing ship path is removed during proof. Migration later
deletes or demotes the old ship-specific catalog/prefab authority for each
migrated hull; it does not keep two writable definitions of that hull.
At this proof stage, `ship-authoring validate` is the sole caller of the
semantic validator. The Blender capture command writes only line data and the
`inspect` command reads incomplete drafts. Editor preview and runtime loading
still need to be built. The current gameplay catalog and prefab loader do not
read these mod records.
## Proof gates
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.
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.
4. A packaged mod ship loads in a built Unity player without Unity Editor or
Blender installed. Runtime assembly creates the same components the
current `ShipInstance` expects and uses the existing simulation rules.
5. Spawn, equip, thrust, fire, shield, damage, save/reload, and schematic UI
work for the mod ship. Only then is a shipped hull eligible for migration.
## Structural and build budget
The proof reuses `HullData` simulation semantics, CultCache `.cc` persistence,
Brokkr's Blender host, and the current `ShipInstance` behavior. This pass adds
one authoring document type, one validator, and one Blender-facing editor
surface. A later pass needs one runtime construction boundary. It does not add
a daemon, a second gameplay
simulation, or another Unity prefab generator. The existing name-based FBX
builder remains available to existing content but is not an input to this
lane. Its retirement is a later cut, after migration.
Focused checks use `Aetheria.Shared` and `tests/Aetheria.Shared.Tests` under
`netstandard2.1` / `net10.0`, Python CultCache interoperability checks, and
the Unity 6000.3.24f1 Editor compile and play probe. No workspace-wide build
or new executable target is justified for schema work. The current catalog is
12.7 MB; it is not rewritten during the proof. The worktree starts clean from
`4b594e11`; unrelated Unity scene and material edits remain in the original
checkout.

View file

@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using CultMath;
using Xunit;
public sealed class ShipAuthoringTests
{
[Fact]
public async Task StandaloneShipRoundTripsWithoutTouchingTheCatalog()
{
var root = Path.Combine(Path.GetTempPath(), "aetheria-ship-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var path = Path.Combine(root, "skiff.cc");
try
{
var ship = Fixture();
ShipAuthoringStore.Validate(ship);
using (var cache = ShipAuthoringStore.Open(path, writable: true))
{
cache.Upsert(ship);
await cache.FlushAsync();
}
var reopened = ShipAuthoringStore.Read(path);
Assert.Equal("mod.skiff", reopened.Id);
Assert.Equal("Skiff", reopened.Hull.Name);
Assert.True(reopened.Hull.Shape.Cells[1, 0]);
Assert.Equal("thruster.port", reopened.Hull.Hardpoints[0].Transform);
Assert.Equal("skiff.glb", reopened.ModelAsset);
Assert.Equal(2, reopened.SchematicLines[0].Points.Length / 3);
}
finally { Directory.Delete(root, true); }
}
[Fact]
public void InvalidSchematicOrDanglingMountCannotBePublished()
{
var ship = Fixture();
ship.Hull.Shape.Cells[1, 0] = false;
Assert.Contains("outside the hull schematic", Assert.Throws<InvalidOperationException>(() =>
ShipAuthoringStore.Validate(ship)).Message);
ship = Fixture();
ship.Anchors.RemoveAt(ship.Anchors.Count - 1);
Assert.Contains("has no model anchor", Assert.Throws<InvalidOperationException>(() =>
ShipAuthoringStore.Validate(ship)).Message);
ship = Fixture();
ship.SchematicLines[0].Points[0] = float.NaN;
Assert.Contains("finite XYZ", Assert.Throws<InvalidOperationException>(() =>
ShipAuthoringStore.Validate(ship)).Message);
ship = Fixture();
ShipAuthoringStore.Validate(ship);
ship.Hull.Shape.Cells[1, 0] = false;
Assert.Contains("outside the hull schematic", Assert.Throws<InvalidOperationException>(() =>
ShipAuthoringStore.Validate(ship)).Message);
ship = Fixture();
ship.Hull.Hardpoints.Add(new HardpointData
{
Type = HardpointType.Thruster,
Position = new int2(1, 0),
Shape = new Shape(),
Transform = "thruster.starboard"
});
ship.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" });
Assert.Contains("overlaps another hardpoint", Assert.Throws<InvalidOperationException>(() =>
ShipAuthoringStore.Validate(ship)).Message);
}
private static ShipAuthoring Fixture()
{
var shape = new Shape(2, 2);
shape.Cells[0, 0] = true;
shape.Cells[1, 0] = true;
return new ShipAuthoring
{
Id = "mod.skiff",
ModelAsset = "skiff.glb",
Hull = new HullData
{
Name = "Skiff",
Shape = shape,
Hardpoints = new List<HardpointData>
{
new HardpointData
{
Type = HardpointType.Thruster,
Position = new int2(1, 0),
Shape = new Shape(),
Transform = "thruster.port"
}
}
},
Anchors = new List<ShipAnchor>
{
new ShipAnchor { Id = "map", Role = "map-icon", ModelNodeId = "map" },
new ShipAnchor { Id = "collider", Role = "hull-collider", ModelNodeId = "collider" },
new ShipAnchor { Id = "shield", Role = "shield", ModelNodeId = "shield" },
new ShipAnchor { Id = "tractor", Role = "tractor", ModelNodeId = "tractor" },
new ShipAnchor { Id = "thruster.port", Role = "thruster-emitter", ModelNodeId = "thruster-port" }
},
SchematicLines = new List<ShipPolyline>
{
new ShipPolyline
{
Layer = "Hull",
Material = "White",
Points = new[] { 0f, 0f, 0f, 1f, 0f, 0f },
Radii = new[] { .01f, .01f },
Opacities = new[] { 1f, 1f },
Color = new[] { 1f, 1f, 1f, 1f }
}
}
};
}
}

View file

@ -36,8 +36,9 @@ public static class Program
case "targeting-catalog": return TargetingCatalog(args.Contains("apply"));
case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply"));
case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply"));
case "ship-authoring": return ShipAuthoringCommands.Run(args.Skip(1).ToArray());
default:
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]");
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate");
return 1;
}
}

View file

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
// Uses the existing database executable; mod authoring does not earn another binary target.
public static class ShipAuthoringCommands
{
public static int Run(string[] args)
{
if (args.Length == 0)
{
Console.Error.WriteLine("ship-authoring create <ship.cc> <stable-id> <name> | inspect <ship.cc> | validate <ship.cc>");
return 1;
}
try
{
if (args[0] == "inspect" && args.Length == 2)
{
using var cache = ShipAuthoringStore.Open(args[1]);
var records = cache.GetAll<ShipAuthoring>().ToArray();
if (records.Length != 1) throw new InvalidOperationException($"Expected one ship, found {records.Length}.");
var ship = records[0];
Console.WriteLine($"{ship.Id}: {ship.Hull?.Name}, {ship.Hull?.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points");
return 0;
}
if (args[0] == "validate" && args.Length == 2)
{
var ship = ShipAuthoringStore.Read(args[1]);
Console.WriteLine($"{ship.Id}: {ship.Hull.Name}, {ship.Hull.Hardpoints.Count} hardpoints, {ship.SchematicLines.Count} lines");
return 0;
}
if (args[0] == "create" && args.Length == 4)
{
var path = Path.GetFullPath(args[1]);
if (File.Exists(path)) throw new InvalidOperationException($"Refusing to replace {path}");
Directory.CreateDirectory(Path.GetDirectoryName(path));
var ship = new ShipAuthoring
{
Id = args[2],
Hull = new HullData { Name = args[3], Shape = new Shape() },
ModelAsset = "ship.glb",
Anchors = new List<ShipAnchor>()
};
using (var cache = ShipAuthoringStore.Open(path, writable: true))
{
cache.Upsert(ship);
cache.FlushAsync().Wait();
}
Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender.");
return 0;
}
Console.Error.WriteLine("ship-authoring create <ship.cc> <stable-id> <name> | inspect <ship.cc> | validate <ship.cc>");
return 1;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return 1;
}
}
}

View file

@ -0,0 +1,87 @@
"""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": "Write Grease Pencil ship lines into a typed Aetheria .cc ship record",
"category": "Object",
}
import bpy
from .ship_cc import capture_grease_pencil, replace_lines
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
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
if obj is None:
raise ValueError("Select the ship's Grease Pencil LineArt object")
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)
cultlib = _brokkr_cultlib(context)
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 into {path}")
return {"FINISHED"}
except (OSError, ValueError, RuntimeError, ImportError) as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
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
layout.prop(context.scene, "aetheria_ship_cc_path")
layout.prop(context.scene, "aetheria_capture_evaluated_lines")
obj = context.active_object
layout.label(text=f"Source: {obj.name if obj else '(none)'}")
layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL")
classes = (AETHERIA_OT_capture_ship_lines, AETHERIA_PT_ship)
def register():
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_capture_evaluated_lines = bpy.props.BoolProperty(
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():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Scene.aetheria_capture_evaluated_lines
del bpy.types.Scene.aetheria_ship_cc_path

View file

@ -0,0 +1,93 @@
"""Aetheria's Blender-side view of one typed ShipAuthoring CultCache record.
The schema and authority live in Aetheria's C# ShipAuthoring type. This module
preserves the C# schema catalog and all slots it does not edit. Brokkr supplies
the Blender host and CultLib Python dependency path.
"""
from __future__ import annotations
import sys
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SCHEMA = "aetheria.ship_authoring"
SCHEMATIC_LINES_SLOT = 4
def _libraries(cultlib_packages: str):
source = Path(cultlib_packages) / "cultcache-py" / "src"
if source.exists() and str(source) not in sys.path:
sys.path.insert(0, str(source))
import cultcache_py # type: ignore
import msgpack # type: ignore
return cultcache_py, msgpack
def read(path: str, cultlib_packages: str):
cultcache, msgpack = _libraries(cultlib_packages)
store = cultcache.SingleFileMessagePackBackingStore(path)
envelopes = store.pull_all()
ships = [envelope for envelope in envelopes if envelope.type == SCHEMA]
if len(envelopes) != 1 or len(ships) != 1:
raise ValueError(f"{path} must hold exactly one {SCHEMA} record")
envelope = ships[0]
body = msgpack.unpackb(envelope.payload, raw=False)
if not isinstance(body, list) or len(body) < SCHEMATIC_LINES_SLOT + 1:
raise ValueError(f"{path} has an incompatible {SCHEMA} payload")
return store, envelope, body, msgpack
def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> int:
store, envelope, body, msgpack = read(path, cultlib_packages)
if not lines:
raise ValueError("No Grease Pencil strokes were captured; the ship record was not changed")
body[SCHEMATIC_LINES_SLOT] = lines
updated = replace(
envelope,
payload=msgpack.packb(body, use_bin_type=True),
stored_at=datetime.now(timezone.utc).isoformat(),
)
store.push(updated)
return len(lines)
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")
source = obj.evaluated_get(depsgraph) if evaluated else obj
lines: list[list[Any]] = []
for layer in source.data.layers:
frames = [frame for frame in layer.frames if frame.frame_number <= frame_number]
if not frames:
continue
frame = max(frames, key=lambda candidate: candidate.frame_number)
for stroke in frame.drawing.strokes:
if len(stroke.points) < 2:
continue
material = source.data.materials[stroke.material_index]
rgba = (
tuple(material.grease_pencil.color)
if material and material.grease_pencil
else tuple(material.diffuse_color) if material else (1.0, 1.0, 1.0, 1.0)
)
points: list[float] = []
radii: list[float] = []
opacities: list[float] = []
for point in stroke.points:
position = obj.matrix_world @ point.position
points.extend(float(value) for value in position)
radii.append(float(point.radius))
opacities.append(float(point.opacity))
lines.append([
layer.name,
material.name if material else "",
points,
radii,
opacities,
bool(stroke.cyclic),
[float(value) for value in rgba],
])
return lines