Merge pull request 'Moddable ships: starter command, top-speed fix, Wren stand-in package, thrust-scale test pins' (#1) from nibu/moddable-ships-wiring into master

This commit is contained in:
Nibu 2026-11-10 18:02:25 +00:00
commit c33feebde2
13 changed files with 371 additions and 20 deletions

View file

@ -590,12 +590,24 @@ public class ActionGameManager : MonoBehaviour
}
var generator = new LoadoutGenerator(ref ItemManager.Random, ItemManager, CurrentGalaxy, Zone.GalaxyZone, null, 2);
var pack = generator.GenerateShipLoadout(data => string.Equals(data.Name, hullName, StringComparison.InvariantCultureIgnoreCase));
if (pack == null)
Ship ship;
if (pack != null) ship = (Ship) EntitySerializer.Unpack(ItemManager, Zone, pack);
else
{
ConsoleController.Instance.AppendLogLine($"No ship hull named \"{hullName}\"");
return;
// An unoffered hull is drawn by nobody; it reaches a ship only through a Loadout (ruling off-market-is-general).
var failures = new List<string>();
var loadout = ItemManager.ItemData.GetAll<Loadout>().FirstOrDefault(l =>
ItemManager.ItemData.Get(l.Hull) is { HullType: HullType.Ship } hull &&
string.Equals(hull.Name, hullName, StringComparison.InvariantCultureIgnoreCase));
ship = loadout == null ? null : Loadouts.Materialize(ItemManager, Zone, loadout, generator.ChooseProduct, failures) as Ship;
if (ship == null)
{
ConsoleController.Instance.AppendLogLine(failures.Count > 0
? $"Cannot build \"{hullName}\": {string.Join("; ", failures)}"
: $"No ship hull named \"{hullName}\" that a maker offers or a Loadout fits");
return;
}
}
var ship = (Ship) EntitySerializer.Unpack(ItemManager, Zone, pack);
ship.IsPlayerShip = true;
ship.Direction = _currentEntity.Direction;
ship.Position.xz = _currentEntity.Position.xz + _currentEntity.Direction.Rotate(ItemRotation.Clockwise) * 30;

BIN
GameData/Mods/aetheria.wren/ship.cc (Stored with Git LFS) Normal file

Binary file not shown.

BIN
GameData/Mods/aetheria.wren/ship.glb (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -63,6 +63,11 @@ be empties. Package exports the collection, Grease Pencil excluded, to
collection holds exactly one Grease Pencil object, and shows AetherDb's
verdict in the panel. It runs the AetherDb in the game folder's `ModTools`
(see Tools below).
A package made with `create` and no `--like` has no top speed, which `validate` refuses; `ship-authoring stats <ship.cc> --top-speed <v>`
adds the one `VelocityLimit` it needs (and edits it on a later run).
Without Blender, `ship-authoring starter <mods-dir> <id> <name> --like <shipped hull>` writes a complete flyable package: the
shipped hull's stats, a 4×6 grid carrying two thrusters, a radiator, two energy guns and the internal modules, and a boxed
`ship.glb` with every anchor. The model is a stand-in to replace through Blender and **Package**.
`ship-authoring inspect <ship.cc>` reads drafts; `validate` requires complete
hull, model, and anchor authoring, and judges `ship.glb` once it exists.
@ -240,8 +245,9 @@ catalog. Continue refuses a run naming a design the catalog no longer holds
price and `ship-authoring product <ship.cc> <maker> --name <name>
[--description <text>]` writes the package's product by that maker
(`--remove` deletes it). A package with a product needs a price above 0,
because generation skips price 0; a package with none stays valid and is
console-only. Compose follows.
because generation skips price 0; a package with none stays valid but no
maker offers it, so generation never draws it. Console `fly <hull>` reaches such a hull only
through a catalog Loadout of it. Compose follows.
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

View file

@ -53,7 +53,8 @@ public sealed class BrownoutTests : IDisposable
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp()
QualityPriceModifier = new ExponentialLerp(),
ThrustScale = 1f // these pin a thruster's authored push, not the global flight tuning
};
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };

View file

@ -28,7 +28,8 @@ public sealed class ConditionRatioTests : IDisposable
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp()
QualityPriceModifier = new ExponentialLerp(),
ThrustScale = 1f // these pin a thruster's authored push, not the global flight tuning
};
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };

View file

@ -31,7 +31,15 @@ public sealed class RestoredHullsTests
// (tools/AetherDb/AuthoredSettings.cs) is the same headless YAML reader tools/AetherDb already uses to
// inspect Settings.asset outside Unity; this loads the real GameplaySettings the same way instead of
// hand-copying its fields a second time.
internal static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings");
internal static GameplaySettings Settings() => PinnedFlight(AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings"));
// The authored settings with the global thrust tuning at 1 (GameplaySettings.ThrustScale, 0.1 in play): tests that pin
// what a thruster, a hull or a brain does read the gear's own thrust, so a retune of the knob does not move them.
internal static GameplaySettings PinnedFlight(GameplaySettings settings)
{
settings.ThrustScale = 1f;
return settings;
}
// Same composition FireControlCut7Tests.OpenReadOnlyRealCatalog uses: a registry scoped to the shipped
// assembly's own [CultDocument] types, so this test's own process-wide type registration never pollutes the

View file

@ -53,7 +53,7 @@ public sealed partial class RunStartTests : IDisposable
_galaxy = new Galaxy(tutorial, authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
_cache, new PlayerSettings(),
Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed);
_items = new ItemManager(1u, _cache, new ProvenanceLedger(), authored.Read<GameplaySettings>("GameplaySettings"), _ => { });
_items = new ItemManager(1u, _cache, new ProvenanceLedger(), RestoredHullsTests.PinnedFlight(authored.Read<GameplaySettings>("GameplaySettings")), _ => { });
_protagonist = _galaxy.ResolveFaction(tutorial.ProtagonistFaction);
// GameSettings.StartingHullName is a plain field of the Unity settings object, not a ServerShared subtree

View file

@ -501,6 +501,28 @@ public sealed class ShipModCatalogTests : IDisposable
Assert.True(File.Exists(target), error);
}
// starter: a complete package from nothing but a hull to take stats from. The package must pass the reader the game boots
// with (GLB node ids and mesh roles included) and plan a rig, and a second run must not touch the first package.
[Fact]
public void StarterWritesAPackageTheGameLoadsAndPlansARigFrom()
{
var like = new HullData { Name = "Like", HullType = HullType.Ship, Mass = 1234, Behaviors = { new VelocityLimitData() } };
var mods = Path.Combine(_directory.Path, "GameData", "Mods");
var path = ShipStarterCommand.Write(mods, "probe.starter", "Probe Starter", like);
var package = ShipModCatalog.ReadPackage(path);
Assert.Equal(("Probe Starter", 1234f), (package.Hull.Name, package.Hull.Mass));
var plan = ShipModPlan.Build(package.Hull, package.Visual);
Assert.Equal(2, plan.Thrusters.Length);
Assert.Single(plan.Radiators);
Assert.Equal(new[] { 1, 1 }, plan.Weapons.Select(weapon => weapon.Muzzles.Length));
Assert.Equal(package.Hull.Shape.Width * package.Hull.Shape.Height, package.Hull.Shape.Cells.Cast<bool>().Count(cell => cell));
var before = File.ReadAllBytes(path);
Assert.Throws<InvalidOperationException>(() => ShipStarterCommand.Write(mods, "probe.starter", "Other", like));
Assert.Equal(before, File.ReadAllBytes(path));
}
[Fact]
public void CentreCommandPrintsShapeCenterOfMass()
{

View file

@ -383,10 +383,15 @@ public sealed class ShipProductTests : IDisposable
Assert.Equal((1f, 0f, 8000f), (ShipAuthoringStore.Load(path).Hull.Mass, ShipAuthoringStore.Load(path).Hull.Drag, ShipAuthoringStore.Load(path).Hull.Durability));
Assert.Equal(0, Run("stats", bare, "--armor", "12").Code);
Assert.Equal(12f, ShipAuthoringStore.Load(bare).Hull.Armor);
// A hull with no top speed (create without --like) gets its one VelocityLimit from the first --top-speed.
Assert.Equal(0, Run("stats", bare, "--top-speed", "50").Code);
var added = ShipAuthoringStore.Load(bare).Hull.Behaviors.OfType<VelocityLimitData>().Single().TopSpeed;
Assert.Equal((50f, 50f), (added.Min, added.Max));
// A hull with no VelocityLimit gets exactly one from --top-speed, the only way a hull made without --like can
// satisfy validate; a second run edits that one rather than adding another.
Assert.Empty(ShipAuthoringStore.Load(bare).Hull.Behaviors.OfType<VelocityLimitData>());
Assert.Equal(0, Run("stats", bare, "--mass", "5", "--top-speed", "50").Code);
Assert.Equal(0, Run("stats", bare, "--top-speed", "60").Code);
var added = Assert.Single(ShipAuthoringStore.Load(bare).Hull.Behaviors.OfType<VelocityLimitData>()).TopSpeed;
Assert.Equal((60f, 60f), (added.Min, added.Max));
Assert.Equal(5f, ShipAuthoringStore.Load(bare).Hull.Mass);
}
[Fact]

View file

@ -0,0 +1,68 @@
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
using Random = CultMath.Random;
// GameData/Mods/aetheria.wren: a first-party package written by `ship-authoring starter --like Djinni`, then priced and given
// an unoffered Aeronautics Unlimited product by `ship-authoring price` and `product`; its model is the starter's boxed
// stand-in. It is the pipeline's proof from a hull package on disk to a ship that flies in the headless simulation: the
// package composes into the catalog, its mounts take catalog gear, and the fitted ship moves under thrust. `fly` cannot
// reach it (an unoffered product is never drawn), so these tests are the flight check until a pinned Loadout exists.
public sealed class WrenPackageTests
{
private const string Id = "aetheria.wren";
private static HullData Wren(CultCache cache) => cache.Get(new CultRecordRef<HullData>(ShipModCatalog.HullKey(Id)));
[Fact]
public void TheStarterPackageIsInTheComposedCatalogWithTheStatsOfItsLike()
{
using var cache = RestoredHullsTests.OpenCatalog();
var hull = Wren(cache);
var like = cache.GetByName<HullData>("Djinni");
Assert.NotNull(hull);
Assert.Equal(("Wren", HullType.Ship, like.Mass, like.Durability), (hull.Name, hull.HullType, hull.Mass, hull.Durability));
Assert.Equal(ShipModCatalog.AuthoringKey(Id), hull.Visual.Key);
Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster));
Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Energy));
}
// The starter's product is its maker's but off the market (rulings package-carries-product, off-market-is-unoffered-product):
// the census wants every design sold or placed, and no faction fields a stand-in box.
[Fact]
public void TheStarterIsMadeButNeverOffered()
{
using var cache = RestoredHullsTests.OpenCatalog();
var hull = Wren(cache);
var product = Assert.Single(cache.GetAll<FactionProductData>(), p => p.Design.Key.Equals(ShipModCatalog.HullKey(Id)));
Assert.Equal("Aeronautics Unlimited", cache.Get(product.Manufacturer).Name);
Assert.True(product.NotOffered);
Assert.True(hull.Price > 0);
Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(cache));
}
// Every mount takes a catalog design: the starter's footprints are sized to gear that exists, so it can be fitted out.
[Fact]
public void EveryMountTakesACatalogDesign()
{
using var cache = RestoredHullsTests.OpenCatalog();
var designs = cache.GetAll<EquippableItemData>().ToArray();
var bare = Wren(cache).Hardpoints.Where(hardpoint => !designs.Any(hardpoint.Takes)).Select(hardpoint => $"{hardpoint.Transform} ({hardpoint.Type})").ToArray();
// No shield design fits a 1x1 yet (hardpoint-fit lists Djinni's 4-cell shield with none either); every other mount does.
Assert.Equal(new[] { "internal.shield (Shield)" }, bare);
}
[Fact]
public void TheStarterFliesUnderThrust()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, "Wren");
var start = ship.Velocity;
ship.MovementDirection = float2(0, 1);
for (var i = 0; i < 10; i++) ship.Zone.Update(1f / 60f);
Assert.NotEqual(start, ship.Velocity);
Assert.True(ship.GetBehavior<VelocityLimit>().Limit > 0f);
}
}

View file

@ -8,7 +8,7 @@ using System.Linq;
// Uses the existing database executable; mod authoring does not earn another binary target.
public static class ShipAuthoringCommands
{
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | stats <ship.cc> [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose <shipped.cc> <derived.cc> <mods-dir>";
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | starter <mods-dir> <stable-id> <name> --like <shipped hull name> | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | stats <ship.cc> [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose <shipped.cc> <derived.cc> <mods-dir>";
private static readonly string[] StatFlags = { "--mass", "--durability", "--armor", "--drag", "--top-speed" };
@ -78,6 +78,12 @@ public static class ShipAuthoringCommands
Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender.");
return 0;
}
if (args[0] == "starter" && args.Length == 6 && args[4] == "--like")
{
var path = ShipStarterCommand.Write(args[1], args[2], args[3], ShippedShipHull(args[5]));
Console.WriteLine($"Wrote a flyable starter package {path}; replace its stand-in model through Blender and Package, then set price and product.");
return 0;
}
if (args[0] == "price" && args.Length == 3)
{
// Judged before the file is touched: a refusal leaves it byte-identical.
@ -110,11 +116,14 @@ public static class ShipAuthoringCommands
var (hull, ship, _) = ShipAuthoringStore.Load(args[1]);
if (stats.TryGetValue("--top-speed", out var topSpeed))
{
hull.Behaviors ??= new List<BehaviorData>();
var limits = hull.Behaviors.OfType<VelocityLimitData>().ToList();
// A hull made without --like has none, and Validate refuses a ship hull without one: the first --top-speed adds it.
if (limits.Count == 0) { limits.Add(new VelocityLimitData()); hull.Behaviors.Add(limits[0]); }
if (limits.Count != 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs at most one VelocityLimit on the hull; it has {limits.Count}.");
var limits = (hull.Behaviors ?? new List<BehaviorData>()).OfType<VelocityLimitData>().ToArray();
if (limits.Length > 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs at most one VelocityLimit on the hull; it has {limits.Length}.");
// A hull made without --like has none, and this is its only way to get one: validate refuses a ship without a top speed.
if (limits.Length == 0)
{
limits = new[] { new VelocityLimitData() };
(hull.Behaviors ??= new List<BehaviorData>()).Add(limits[0]);
}
// A fixed top speed, as every shipped hull authors one (Min equal to Max); its terms are kept.
limits[0].TopSpeed.Min = limits[0].TopSpeed.Max = topSpeed;
}

View file

@ -0,0 +1,213 @@
using GameCult.Caching;
using CultMath;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
// `ship-authoring starter`: a complete, flyable mod package for authors with no Blender at hand. It writes the package
// (<mods>/<id>/ship.cc and ship.glb) with the stats of a shipped hull, a small hull grid, a working rig (two thrusters,
// a radiator, two energy guns, the internal modules) and a boxed model that carries every anchor the rig needs. The
// model is a stand-in an author replaces through Blender and Package; the records are judged by the same validator and
// reader the game boots with, so a starter that is written is a starter the game loads.
public static class ShipStarterCommand
{
// Hull grid, x across, y from the stern (0) to the bow. The mount rows below say what sits in each cell.
private const int Width = 4, Height = 6;
private sealed class Part
{
public string Id, Role, Parent;
public HardpointType? Mount;
public int2 Cell, Size = new int2(1, 1);
public float3 Position;
// Box half-extents; null for an empty node. Scale is the node scale, used by the shield envelope.
public float3? Half;
public float3? Scale;
}
public static string Write(string modsRoot, string id, string name, HullData like)
{
ShipAuthoringStore.RequireShipId(id);
var directory = Path.Combine(Path.GetFullPath(modsRoot), id);
var path = Path.Combine(directory, "ship.cc");
if (File.Exists(path) || File.Exists(Path.Combine(directory, "ship.glb")))
throw new InvalidOperationException($"Refusing to replace the package in {directory}");
// Metres, nose toward -Y, up +Z, as the Blender add-on's kit frame has it; the GLB writer converts to glTF's Y-up.
var parts = new[]
{
new Part { Id = "map", Role = "map-icon", Half = new float3(3f, 5f, .05f) },
new Part { Id = "collider", Role = "hull-collider", Half = new float3(3f, 5f, 1f) },
new Part { Id = "shield", Role = "shield", Scale = new float3(4f, 6f, 2.5f) },
new Part { Id = "tractor", Role = "tractor", Position = new float3(0f, -5f, 0f) },
new Part { Id = "thruster.port", Role = "thruster-emitter", Mount = HardpointType.Thruster, Cell = new int2(0, 0), Size = new int2(2, 1), Position = new float3(2.2f, 5.4f, 0f), Half = new float3(.4f, .6f, .4f) },
new Part { Id = "thruster.starboard", Role = "thruster-emitter", Mount = HardpointType.Thruster, Cell = new int2(2, 0), Size = new int2(2, 1), Position = new float3(-2.2f, 5.4f, 0f), Half = new float3(.4f, .6f, .4f) },
new Part { Id = "radiator", Role = "radiator-mesh", Mount = HardpointType.Radiator, Cell = new int2(1, 1), Size = new int2(2, 1), Position = new float3(0f, 2f, 1.1f), Half = new float3(1.5f, .8f, .08f) },
new Part { Id = "gun.port", Role = "weapon-mount", Mount = HardpointType.Energy, Cell = new int2(0, 5), Position = new float3(1.8f, -3f, 1.1f) },
new Part { Id = "gun.port.muzzle", Role = "weapon-muzzle", Parent = "gun.port", Position = new float3(1.8f, -4f, 1.1f) },
new Part { Id = "gun.starboard", Role = "weapon-mount", Mount = HardpointType.Energy, Cell = new int2(3, 5), Position = new float3(-1.8f, -3f, 1.1f) },
new Part { Id = "gun.starboard.muzzle", Role = "weapon-muzzle", Parent = "gun.starboard", Position = new float3(-1.8f, -4f, 1.1f) },
};
// Internal modules show on no model, so they have a cell and no anchor.
var internals = new[]
{
("reactor", HardpointType.Reactor, new int2(1, 2), new int2(2, 2)), ("shield", HardpointType.Shield, new int2(3, 4), new int2(1, 1)),
("control", HardpointType.ControlModule, new int2(1, 4), new int2(2, 2)), ("sensors", HardpointType.Sensors, new int2(0, 4), new int2(1, 1)),
};
var ship = new ShipAuthoring
{
Id = id,
ModelAsset = "ship.glb",
Anchors = parts.Select(part => new ShipAnchor { Id = part.Id, Role = part.Role, ModelNodeId = part.Id, ParentId = part.Parent }).ToList()
};
var hull = ShipAuthoringStore.HullLike(like, name, id);
hull.Shape = new Shape(Width, Height);
for (var x = 0; x < Width; x++)
for (var y = 0; y < Height; y++)
hull.Shape.Cells[x, y] = true;
hull.Hardpoints = parts.Where(part => part.Mount != null)
.Select(part => (part.Id, part.Mount.Value, part.Cell, part.Size))
.Concat(internals.Select(row => (Id: "internal." + row.Item1, Mount: row.Item2, Cell: row.Item3, Size: row.Item4)))
.Select(row => new HardpointData
{
Type = row.Item2, Position = row.Item3, Shape = Block(row.Item4), Transform = row.Item1,
// The grid's stern is y = 0 and its bow the far row: a drive there pushes the ship toward the bow, which the
// authoring grid spells Reversed (as Djinni's stern drives are).
Rotation = row.Item2 == HardpointType.Thruster ? ItemRotation.Reversed : ItemRotation.None
}).ToList();
ShipAuthoringStore.Validate(hull, ship);
Directory.CreateDirectory(directory);
File.WriteAllBytes(Path.Combine(directory, ship.ModelAsset), Glb(parts));
using (var cache = ShipAuthoringStore.Open(path, writable: true))
{
ShipAuthoringStore.Write(cache, hull, ship);
cache.FlushAsync().Wait();
}
// The reader the game boots with, over the written package: the GLB's node ids and mesh roles are judged too.
ShipModCatalog.ReadPackage(path);
return path;
}
private static Shape Block(int2 size)
{
var shape = new Shape(size.x, size.y);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
return shape;
}
// One GLB holding every part as a root-level node, each box with its own vertices (a convex collider cooks from it
// as it is), and each muzzle a child of its mount. Node ids travel in extras.aetheria.id, as Package writes them.
private static byte[] Glb(Part[] parts)
{
var bin = new List<byte>();
var views = new List<string>();
var accessors = new List<string>();
var meshes = new List<string>();
var meshOf = new Dictionary<Part, int>();
int View(byte[] bytes, int target)
{
while (bin.Count % 4 != 0) bin.Add(0);
views.Add($@"{{""buffer"":0,""byteOffset"":{bin.Count},""byteLength"":{bytes.Length},""target"":{target}}}");
bin.AddRange(bytes);
return views.Count - 1;
}
string F(float value) => value.ToString("R", CultureInfo.InvariantCulture);
foreach (var part in parts.Where(part => part.Half != null))
{
var (hx, hy, hz) = (part.Half.Value.x, part.Half.Value.y, part.Half.Value.z);
var positions = new List<float>();
var normals = new List<float>();
var indices = new List<ushort>();
// Six faces, outward normals, in the Blender frame; Gltf() turns each into glTF's.
foreach (var (axis, sign) in new[] { (0, 1), (0, -1), (1, 1), (1, -1), (2, 1), (2, -1) })
{
var u = (axis + 1) % 3;
var v = (axis + 2) % 3;
var half = new[] { hx, hy, hz };
var first = (ushort)(positions.Count / 3);
foreach (var (a, b) in new[] { (-1, -1), (1, -1), (1, 1), (-1, 1) })
{
var corner = new float[3];
corner[axis] = sign * half[axis];
corner[u] = a * half[u];
corner[v] = b * half[v];
positions.AddRange(Gltf(corner[0], corner[1], corner[2]));
var normal = new float[3];
normal[axis] = sign;
normals.AddRange(Gltf(normal[0], normal[1], normal[2]));
}
// The (axis, u, v) frame is right-handed, so the corner order above winds counter-clockwise seen from +axis.
var order = sign > 0 ? new[] { 0, 1, 2, 0, 2, 3 } : new[] { 0, 2, 1, 0, 3, 2 };
indices.AddRange(order.Select(i => (ushort)(first + i)));
}
var positionView = View(positions.SelectMany(BitConverter.GetBytes).ToArray(), 34962);
var normalView = View(normals.SelectMany(BitConverter.GetBytes).ToArray(), 34962);
var indexView = View(indices.SelectMany(BitConverter.GetBytes).ToArray(), 34963);
var low = Gltf(-hx, -hy, -hz);
var high = Gltf(hx, hy, hz);
var box = new[] { Math.Min(low[0], high[0]), Math.Min(low[1], high[1]), Math.Min(low[2], high[2]), Math.Max(low[0], high[0]), Math.Max(low[1], high[1]), Math.Max(low[2], high[2]) };
accessors.Add($@"{{""bufferView"":{positionView},""componentType"":5126,""count"":{positions.Count / 3},""type"":""VEC3"",""min"":[{F(box[0])},{F(box[1])},{F(box[2])}],""max"":[{F(box[3])},{F(box[4])},{F(box[5])}]}}");
accessors.Add($@"{{""bufferView"":{normalView},""componentType"":5126,""count"":{normals.Count / 3},""type"":""VEC3""}}");
accessors.Add($@"{{""bufferView"":{indexView},""componentType"":5123,""count"":{indices.Count},""type"":""SCALAR""}}");
meshOf[part] = meshes.Count;
meshes.Add($@"{{""primitives"":[{{""attributes"":{{""POSITION"":{accessors.Count - 3},""NORMAL"":{accessors.Count - 2}}},""indices"":{accessors.Count - 1}}}]}}");
}
// A muzzle is a child of its mount, so its translation is relative to the mount's.
var byId = parts.ToDictionary(part => part.Id, StringComparer.Ordinal);
var nodeIndex = parts.Select((part, index) => (part.Id, index)).ToDictionary(pair => pair.Id, pair => pair.index, StringComparer.Ordinal);
string Node(Part part)
{
var translation = part.Parent == null ? part.Position : part.Position - byId[part.Parent].Position;
var g = Gltf(translation.x, translation.y, translation.z);
var json = new StringBuilder($@"{{""name"":""{part.Id}""");
if (meshOf.TryGetValue(part, out var mesh)) json.Append($@",""mesh"":{mesh}");
if (part.Scale != null) { var s = Gltf(part.Scale.Value.x, part.Scale.Value.y, part.Scale.Value.z); json.Append($@",""scale"":[{F(Math.Abs(s[0]))},{F(Math.Abs(s[1]))},{F(Math.Abs(s[2]))}]"); }
json.Append($@",""translation"":[{F(g[0])},{F(g[1])},{F(g[2])}]");
var children = parts.Where(child => child.Parent == part.Id).Select(child => nodeIndex[child.Id]).ToArray();
if (children.Length > 0) json.Append($@",""children"":[{string.Join(",", children)}]");
json.Append($@",""extras"":{{""aetheria.id"":""{part.Id}""}}}}");
return json.ToString();
}
var roots = parts.Where(part => part.Parent == null).Select(part => nodeIndex[part.Id]);
var document = $@"{{""asset"":{{""version"":""2.0"",""generator"":""AetherDb ship-authoring starter""}},""scene"":0,""scenes"":[{{""nodes"":[{string.Join(",", roots)}]}}]," +
$@"""nodes"":[{string.Join(",", parts.Select(Node))}],""meshes"":[{string.Join(",", meshes)}],""accessors"":[{string.Join(",", accessors)}]," +
$@"""bufferViews"":[{string.Join(",", views)}],""buffers"":[{{""byteLength"":{bin.Count + (4 - bin.Count % 4) % 4}}}]}}";
return Container(document, bin.ToArray());
}
// Blender (x right, y back, z up) to glTF (x right, y up, z toward the viewer): the exporter's +Y-up conversion.
private static float[] Gltf(float x, float y, float z) => new[] { x, z, -y };
private static byte[] Container(string json, byte[] bin)
{
byte[] Pad(byte[] bytes, byte fill)
{
var padded = new byte[(bytes.Length + 3) / 4 * 4];
Array.Fill(padded, fill);
bytes.CopyTo(padded, 0);
return padded;
}
var body = Pad(Encoding.UTF8.GetBytes(json), (byte)' ');
var data = Pad(bin, 0);
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write(0x46546C67u);
writer.Write(2u);
writer.Write((uint)(28 + body.Length + data.Length));
writer.Write((uint)body.Length);
writer.Write(0x4E4F534Au);
writer.Write(body);
writer.Write((uint)data.Length);
writer.Write(0x004E4942u);
writer.Write(data);
return stream.ToArray();
}
}