Add parallel typed ship authoring and Blender LineArt capture
This commit is contained in:
parent
4b594e116c
commit
42eb5214ce
8 changed files with 636 additions and 1 deletions
150
Assets/Scripts/ServerShared/ShipAuthoring.cs
Normal file
150
Assets/Scripts/ServerShared/ShipAuthoring.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/ServerShared/ShipAuthoring.cs.meta
Normal file
2
Assets/Scripts/ServerShared/ShipAuthoring.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0da6676b1995425fa66187e8e58956e7
|
||||
121
docs/moddable-ship-authoring.md
Normal file
121
docs/moddable-ship-authoring.md
Normal 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.
|
||||
119
tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs
Normal file
119
tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs
Normal 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 }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
tools/AetherDb/ShipAuthoringCommands.cs
Normal file
62
tools/AetherDb/ShipAuthoringCommands.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
87
tools/blender/aetheria_ships/__init__.py
Normal file
87
tools/blender/aetheria_ships/__init__.py
Normal 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
|
||||
93
tools/blender/aetheria_ships/ship_cc.py
Normal file
93
tools/blender/aetheria_ships/ship_cc.py
Normal 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
|
||||
Loading…
Reference in a new issue