From 42eb5214ce0448ea4aa6c8bac700515e4b10422d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 18:14:36 +0200 Subject: [PATCH] Add parallel typed ship authoring and Blender LineArt capture --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 150 ++++++++++++++++++ .../ServerShared/ShipAuthoring.cs.meta | 2 + docs/moddable-ship-authoring.md | 121 ++++++++++++++ .../ShipAuthoringTests.cs | 119 ++++++++++++++ tools/AetherDb/Program.cs | 3 +- tools/AetherDb/ShipAuthoringCommands.cs | 62 ++++++++ tools/blender/aetheria_ships/__init__.py | 87 ++++++++++ tools/blender/aetheria_ships/ship_cc.py | 93 +++++++++++ 8 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 Assets/Scripts/ServerShared/ShipAuthoring.cs create mode 100644 Assets/Scripts/ServerShared/ShipAuthoring.cs.meta create mode 100644 docs/moddable-ship-authoring.md create mode 100644 tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs create mode 100644 tools/AetherDb/ShipAuthoringCommands.cs create mode 100644 tools/blender/aetheria_ships/__init__.py create mode 100644 tools/blender/aetheria_ships/ship_cc.py diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs new file mode 100644 index 000000000..3eeb4eeed --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -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 Anchors = new List(); + [Key(4)] public List SchematicLines = new List(); +} + +[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().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().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(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(StringComparer.Ordinal); + var occupiedHardpointCells = new HashSet<(int x, int y)>(); + foreach (var hardpoint in hull.Hardpoints ?? new List()) + { + 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().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()) + { + 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."); + } + } +} diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs.meta b/Assets/Scripts/ServerShared/ShipAuthoring.cs.meta new file mode 100644 index 000000000..b9d7af3b4 --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0da6676b1995425fa66187e8e58956e7 diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md new file mode 100644 index 000000000..e5fe66669 --- /dev/null +++ b/docs/moddable-ship-authoring.md @@ -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 `, choose that `.cc` in the **Aetheria +Ship** panel, select a Grease Pencil object, and press **Capture Ship Lines**. +`ship-authoring inspect ` 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. diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs new file mode 100644 index 000000000..900d7f983 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -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(() => + ShipAuthoringStore.Validate(ship)).Message); + + ship = Fixture(); + ship.Anchors.RemoveAt(ship.Anchors.Count - 1); + Assert.Contains("has no model anchor", Assert.Throws(() => + ShipAuthoringStore.Validate(ship)).Message); + + ship = Fixture(); + ship.SchematicLines[0].Points[0] = float.NaN; + Assert.Contains("finite XYZ", Assert.Throws(() => + ShipAuthoringStore.Validate(ship)).Message); + + ship = Fixture(); + ShipAuthoringStore.Validate(ship); + ship.Hull.Shape.Cells[1, 0] = false; + Assert.Contains("outside the hull schematic", Assert.Throws(() => + 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(() => + 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 + { + new HardpointData + { + Type = HardpointType.Thruster, + Position = new int2(1, 0), + Shape = new Shape(), + Transform = "thruster.port" + } + } + }, + Anchors = new List + { + 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 + { + 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 } + } + } + }; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 46aae9575..c392c3bf3 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -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 ]... [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 ]... [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; } } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs new file mode 100644 index 000000000..ce21cc6ca --- /dev/null +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -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 | inspect | validate "); + return 1; + } + try + { + if (args[0] == "inspect" && args.Length == 2) + { + using var cache = ShipAuthoringStore.Open(args[1]); + var records = cache.GetAll().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() + }; + 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 | inspect | validate "); + return 1; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + } +} diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py new file mode 100644 index 000000000..c512e8405 --- /dev/null +++ b/tools/blender/aetheria_ships/__init__.py @@ -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 diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py new file mode 100644 index 000000000..4bedd2fb4 --- /dev/null +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -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