Rig in the visual record: ShipAuthoring.Joints, ShipAnchor.Joint, reach check, Bind and ShipModPlan.Rigs

Deletes the articulation anchor role. Adds ShipJoint (key 5 Joints), ShipAnchor.Joint (key 5),
the judge's joint-shape, mount-on-joint and yaw-reach checks over RigChains, Bind's GLB checks
(ReadNodeIds returns the nearest tagged ancestor), and ShipModPlan.Rigs. Presentation only:
arc stays HardpointData.FiringArc and traverse WeaponData.Tracking (ruling mount-arc-gun-traverse).
Not yet built or run when committed; the verification commit follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 05:23:02 +02:00
commit 9e516d06d9
8 changed files with 544 additions and 18 deletions

View file

@ -130,8 +130,8 @@ public static class FireControl
// arc, so the gun can fire along it. Direction: where a round from this gun travels and where its barrel points --
// the subject's intercept when it bears, else the arc-clamped aim. A chunk subject bears through its pose and
// flies the aim, as an untargeted round (Subject is null for it). Derived on every read and never stored: turrets
// traverse instantly in the simulation. Traverse speed belongs to the link (ruling arc-and-traverse-on-the-link),
// which will make this stateful behind the same signature. It never prices; HitProbability is the one price.
// traverse instantly in the simulation. Traverse is the weapon's Tracking (ruling mount-arc-gun-traverse), priced in
// TrackingRate; this holds no slew state, and a rig's slew is presentation. It never prices; HitProbability is the one price.
public static GunSolution Solution(Weapon weapon, Entity shooter, TargetRef subject)
{
var bears = false;

View file

@ -17,6 +17,22 @@ public sealed class ShipAuthoring
[Key(2)] public string ModelAsset;
[Key(3)] public List<ShipAnchor> Anchors = new List<ShipAnchor>();
[Key(4)] public List<ShipPolyline> SchematicLines = new List<ShipPolyline>();
// The moving parts of the rig, presentation only (ruling rig-is-presentation): no joint value feeds the simulation.
[Key(5)] public List<ShipJoint> Joints = new List<ShipJoint>();
}
// A rig joint: a bone of the model's armature. Axes are three floats per degree of freedom, a unit vector in Ship Root
// space at rest (glTF axes, +Y up); Min and Max are that degree of freedom's limits in degrees.
[MessagePackObject]
public sealed class ShipJoint
{
// The bone's aetheria.id, which is also its GLB node id.
[Key(0)] public string Id;
// The nearest joint above this one in the model, or null for a root joint.
[Key(1)] public string Parent;
[Key(2)] public float[] Axes;
[Key(3)] public float[] Min;
[Key(4)] public float[] Max;
}
[MessagePackObject]
@ -25,13 +41,15 @@ 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-mount,
// weapon-muzzle, radiator-mesh, or articulation (a pivot, never a mount).
// weapon-muzzle, or radiator-mesh.
[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;
// On a weapon-mount anchor: the id of the nearest joint above its node in the model; null or empty is a fixed mount.
[Key(5)] public string Joint;
}
// Evaluated or baked Grease Pencil stroke coordinates in Blender's right-handed Z-up
@ -53,8 +71,7 @@ public static class ShipAuthoringStore
// ShipAnchor.Role's vocabulary.
private static readonly HashSet<string> Roles = new HashSet<string>(StringComparer.Ordinal)
{
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh",
"articulation"
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh"
};
// The anchor role a visible hardpoint's mount plays, or null for an internal hardpoint, which has no anchor.
@ -272,6 +289,7 @@ public static class ShipAuthoringStore
foreach (var hardpoint in mounts.Values.Where(hardpoint => IsWeapon(hardpoint.Type)))
if (!anchors.Any(anchor => anchor.Role == "weapon-muzzle" && anchor.ParentId == hardpoint.Transform))
throw new InvalidOperationException($"{ship.Id}: weapon hardpoint {hardpoint.Transform} needs at least one muzzle anchor.");
ValidateRig(ship, anchors, mounts, nodeIds);
foreach (var line in ship.SchematicLines ?? new List<ShipPolyline>())
{
@ -291,4 +309,111 @@ public static class ShipAuthoringStore
throw new InvalidOperationException($"{ship.Id}: schematic line color must be finite RGBA.");
}
}
// A degree of freedom counts as yaw when its axis lies within 5 degrees of Ship Root up (glTF +Y).
private static readonly float YawAxisCos = MathF.Cos(5f * MathF.PI / 180f);
// The joints, which anchors ride them, and whether each driven joint's chain can sweep the arc its guns state. The
// joints are presentation, so this judges only that the rig could point the gun where the hardpoint says it may fire.
private static void ValidateRig(ShipAuthoring ship, List<ShipAnchor> anchors, Dictionary<string, HardpointData> mounts, HashSet<string> nodeIds)
{
var joints = ship.Joints ?? new List<ShipJoint>();
var byId = new Dictionary<string, ShipJoint>(StringComparer.Ordinal);
foreach (var joint in joints)
{
if (joint == null || string.IsNullOrWhiteSpace(joint.Id) || byId.ContainsKey(joint.Id))
throw new InvalidOperationException($"{ship.Id}: joint IDs must be present and unique.");
if (nodeIds.Contains(joint.Id))
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} is also an anchor's model node.");
byId.Add(joint.Id, joint);
}
foreach (var joint in joints)
{
var dofs = joint.Min?.Length ?? 0;
if (dofs < 1 || joint.Max?.Length != dofs || joint.Axes?.Length != 3 * dofs)
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} needs one or more degrees of freedom: Axes holds three values each, Min and Max one each.");
for (var dof = 0; dof < dofs; dof++)
{
var (x, y, z) = (joint.Axes[3 * dof], joint.Axes[3 * dof + 1], joint.Axes[3 * dof + 2]);
var (min, max) = (joint.Min[dof], joint.Max[dof]);
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z) || !float.IsFinite(min) || !float.IsFinite(max))
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} axes and limits must be finite.");
if (MathF.Abs(MathF.Sqrt(x * x + y * y + z * z) - 1f) > 1e-3f)
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} axis {dof} must be a unit vector.");
if (min < -180f || max > 180f || min > max)
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} limits {dof} must satisfy -180 <= Min <= Max <= 180.");
}
if (!string.IsNullOrEmpty(joint.Parent) && !byId.ContainsKey(joint.Parent))
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} has an unknown parent joint.");
}
foreach (var joint in joints)
{
var step = joint;
for (var depth = 0; !string.IsNullOrEmpty(step.Parent); depth++)
{
if (depth >= joints.Count)
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} is on a parent cycle.");
step = byId[step.Parent];
}
}
foreach (var anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.Joint)))
{
if (anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh")
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not ride a joint (follow-up gimballed-thrusters).");
if (anchor.Role != "weapon-mount")
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not name a joint; only a weapon mount rides one.");
if (!byId.ContainsKey(anchor.Joint))
throw new InvalidOperationException($"{ship.Id}: weapon mount {anchor.Id} names an unknown joint.");
}
var ridden = anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint))
.Select(anchor => (Hardpoint: mounts[anchor.Id], Joint: anchor.Joint)).ToArray();
foreach (var (hardpoint, _) in ridden)
if (!(hardpoint.FiringArc > 0f))
throw new InvalidOperationException($"{ship.Id}: weapon hardpoint {hardpoint.Transform} rides a joint, so it must state its firing arc above 0.");
foreach (var group in ridden.GroupBy(mount => mount.Joint, StringComparer.Ordinal))
{
var lead = group.First().Hardpoint;
foreach (var (hardpoint, _) in group)
if (hardpoint.Rotation != lead.Rotation || hardpoint.FiringArc != lead.FiringArc)
throw new InvalidOperationException($"{ship.Id}: weapon hardpoints {lead.Transform} and {hardpoint.Transform} ride joint {group.Key} and must share Rotation and FiringArc.");
var reach = YawReach(ship, group.Key);
if (reach < lead.FiringArc)
throw new InvalidOperationException($"{ship.Id}: joint {group.Key} chain reaches {reach} degrees of yaw, short of the {lead.FiringArc} degree firing arc of {lead.Transform}.");
}
}
// The yaw a driven joint's chain can sweep: the summed range of every degree of freedom whose axis is yaw, capped at a turn.
private static float YawReach(ShipAuthoring ship, string jointId)
{
var joints = ship.Joints.ToDictionary(joint => joint.Id, StringComparer.Ordinal);
var reach = 0f;
foreach (var joint in RigChains.Chain(ship, jointId).Select(id => joints[id]))
for (var dof = 0; dof < joint.Min.Length; dof++)
if (MathF.Abs(joint.Axes[3 * dof + 1]) >= YawAxisCos)
reach += joint.Max[dof] - joint.Min[dof];
return MathF.Min(reach, 360f);
}
}
// A rig's driven joints and the chains that move them. A driven joint is one a weapon-mount anchor names. Its chain runs
// from the joint up through its ancestors and stops before the nearest ancestor that is itself driven (that joint has
// its own gun, and its pose is that gun's business), listed root-first. The judge and the plan both read this one rule.
public static class RigChains
{
public static HashSet<string> Driven(ShipAuthoring ship) =>
new HashSet<string>(ship.Anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint)).Select(anchor => anchor.Joint),
StringComparer.Ordinal);
public static string[] Chain(ShipAuthoring ship, string jointId)
{
var joints = ship.Joints.ToDictionary(joint => joint.Id, StringComparer.Ordinal);
var driven = Driven(ship);
var chain = new List<string> { jointId };
for (var parent = joints[jointId].Parent; !string.IsNullOrEmpty(parent) && !driven.Contains(parent); parent = joints[parent].Parent)
chain.Add(parent);
chain.Reverse();
return chain.ToArray();
}
}

View file

@ -182,6 +182,7 @@ public static class ShipModCatalog
foreach (var anchor in ship.Anchors)
if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh") && !modelNodes[anchor.ModelNodeId].HasMesh)
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none.");
BindRig(ship, modelNodes);
CultRecordRefs.Validate(hull);
return new Package
{
@ -190,9 +191,43 @@ public static class ShipModCatalog
};
}
// GLB is the xenos asset boundary. Only its node extras, and whether each node names a mesh, are read here; geometry
// belongs to the runtime importer.
public static Dictionary<string, (uint Index, bool HasMesh)> ReadNodeIds(string path)
// The rig the record states must be the rig the model has: every joint is a node, each joint's Parent and each mount's
// Joint is the nearest joint above its node, ancestors without an id being walked through by ReadNodeIds, and a thruster or radiator
// mesh sits under no joint (follow-up gimballed-thrusters).
private static void BindRig(ShipAuthoring ship, Dictionary<string, (uint Index, bool HasMesh, int Parent)> nodes)
{
var joints = new HashSet<string>((ship.Joints ?? new List<ShipJoint>()).Select(joint => joint.Id), StringComparer.Ordinal);
var idAt = nodes.ToDictionary(node => (int)node.Value.Index, node => node.Key);
// Parent is already the nearest ancestor that carries an id, so the walk up is the chain of those.
string JointAbove(string nodeId)
{
// glTF forbids cycles; the bound keeps a malformed model from hanging the binder.
var parent = nodes[nodeId].Parent;
for (var steps = 0; parent >= 0 && steps <= nodes.Count; steps++, parent = nodes[idAt[parent]].Parent)
if (joints.Contains(idAt[parent])) return idAt[parent];
return null;
}
foreach (var joint in ship.Joints ?? new List<ShipJoint>())
{
if (!nodes.ContainsKey(joint.Id))
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={joint.Id} for a joint.");
if (!string.Equals(JointAbove(joint.Id) ?? "", joint.Parent ?? "", StringComparison.Ordinal))
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} must name the nearest joint above its model node as its Parent.");
}
foreach (var anchor in ship.Anchors.Where(anchor => anchor.Role == "weapon-mount" || anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh"))
{
var above = JointAbove(anchor.ModelNodeId);
if (string.Equals(above ?? "", anchor.Joint ?? "", StringComparison.Ordinal)) continue;
throw new InvalidOperationException(anchor.Role == "weapon-mount"
? $"{ship.Id}: weapon mount {anchor.Id} must name the nearest joint above its model node as its Joint."
: $"{ship.Id}: {anchor.Role} anchor {anchor.Id} sits under a joint in the model (follow-up gimballed-thrusters).");
}
}
// GLB is the xenos asset boundary. Only its node extras, whether each node names a mesh, and its nearest identified ancestor's node index
// (-1 for none) are read here; geometry belongs to the runtime importer.
public static Dictionary<string, (uint Index, bool HasMesh, int Parent)> ReadNodeIds(string path)
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream);
@ -203,17 +238,29 @@ public static class ShipModCatalog
if (reader.ReadUInt32() != 0x4E4F534A || chunkLength > stream.Length - stream.Position)
throw new InvalidOperationException($"{path}: missing GLB JSON chunk.");
var json = JObject.Parse(Encoding.UTF8.GetString(reader.ReadBytes((int)chunkLength)));
var ids = new Dictionary<string, (uint Index, bool HasMesh)>(StringComparer.Ordinal);
var nodes = json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>();
var ids = new Dictionary<string, (uint Index, bool HasMesh, int Parent)>(StringComparer.Ordinal);
var nodes = (json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()).ToArray();
var parents = Enumerable.Repeat(-1, nodes.Length).ToArray();
for (var parent = 0; parent < nodes.Length; parent++)
foreach (var child in nodes[parent]["children"]?.Select(token => (int)token) ?? Enumerable.Empty<int>())
if (child >= 0 && child < nodes.Length) parents[child] = parent;
uint index = 0;
foreach (var node in nodes)
{
var id = (string)node["extras"]?["aetheria.id"];
if (string.IsNullOrEmpty(id)) { index++; continue; }
if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null)))
if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null, IdentifiedAncestor(parents, nodes, (int)index))))
throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}.");
index++;
}
return ids;
}
private static int IdentifiedAncestor(int[] parents, JObject[] nodes, int index)
{
var parent = parents[index];
for (var steps = 0; parent >= 0 && steps <= nodes.Length; steps++, parent = parents[parent])
if (!string.IsNullOrEmpty((string)nodes[parent]["extras"]?["aetheria.id"])) return parent;
return -1;
}
}

View file

@ -15,6 +15,10 @@ public sealed class ShipModPlan
public string[] Radiators;
// Each weapon mount (role weapon-mount) and its muzzle anchors, by Order then id.
public (string Mount, string[] Muzzles)[] Weapons;
// One entry per driven joint (one a weapon mount names), by its first weapon in hull order: the joint, its chain
// root-first (RigChains), and the lead mount (the joint's first weapon in hull order) with that mount's first muzzle.
// Presentation only: the assembler rigs these and nothing here reaches the simulation.
public (string Joint, string[] Chain, string LeadMount, string LeadMuzzle)[] Rigs;
// Maps a pair Validate accepts; every rule about which anchor may play which part is Validate's, so this refuses nothing.
public static ShipModPlan Build(HullData hull, ShipAuthoring visual)
@ -26,6 +30,11 @@ public sealed class ShipModPlan
var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle")
.OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray();
var jointOf = visual.Anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint))
.ToDictionary(anchor => anchor.Id, anchor => anchor.Joint, StringComparer.Ordinal);
var leads = hardpoints.Where(hardpoint => jointOf.ContainsKey(hardpoint.Transform)).GroupBy(hardpoint => jointOf[hardpoint.Transform], StringComparer.Ordinal)
.Select(group => (Joint: group.Key, Lead: group.First().Transform));
return new ShipModPlan
{
MapIcon = Only("map-icon"),
@ -36,7 +45,9 @@ public sealed class ShipModPlan
Radiators = Mounts(HardpointType.Radiator),
Weapons = hardpoints.Where(hardpoint => ShipAuthoringStore.IsWeapon(hardpoint.Type)).Select(hardpoint => (
hardpoint.Transform,
muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray()
muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray(),
Rigs = leads.Select(rig => (rig.Joint, RigChains.Chain(visual, rig.Joint), rig.Lead,
muzzles.First(muzzle => muzzle.ParentId == rig.Lead).Id)).ToArray()
};
}
}

View file

@ -232,7 +232,7 @@ public sealed class ShipAuthoringTests
private static readonly string[] AllRoles =
{
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh", "articulation"
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh"
};
// The fixture with its thruster swapped for one hardpoint of the given type at mount id "mount", and no anchor for it.

View file

@ -434,7 +434,7 @@ public sealed class ShipModCatalogTests : IDisposable
{
var path = Write("nodes.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[
{""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}}]}"));
Assert.Equal(new Dictionary<string, (uint, bool)> { ["a"] = (1, false), ["b"] = (4, true) }, ShipModCatalog.ReadNodeIds(path));
Assert.Equal(new Dictionary<string, (uint, bool, int)> { ["a"] = (1, false, -1), ["b"] = (4, true, -1) }, ShipModCatalog.ReadNodeIds(path));
}
[Fact]
@ -598,7 +598,7 @@ public sealed class ShipModCatalogTests : IDisposable
private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb",
Action<ShipParts> tweak = null, string[] meshNodes = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak, meshNodes);
private static Dictionary<string, uint> Indices(Dictionary<string, (uint Index, bool HasMesh)> nodes) =>
private static Dictionary<string, uint> Indices(Dictionary<string, (uint Index, bool HasMesh, int Parent)> nodes) =>
nodes.ToDictionary(node => node.Key, node => node.Value.Index);
private static int Occurrences(byte[] haystack, byte[] needle)

View file

@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using CultMath;
using Xunit;
// The rig in the visual record: what the judge (Validate) refuses about joints and the guns that ride them, what Bind
// refuses when the GLB disagrees with the record, and what ShipModPlan.Rigs hands the assembler. The rig is
// presentation (ruling rig-is-presentation): arc stays the hardpoint's and traverse the weapon's, so the judge only
// asks that the chain could sweep the arc the hardpoint states.
public sealed class ShipRigTests : IDisposable
{
private readonly TempDirectory _directory = new TempDirectory();
public void Dispose() => _directory.Dispose();
private static readonly float[] Up = { 0f, 1f, 0f };
private static readonly float[] Right = { 1f, 0f, 0f };
private static ShipJoint Joint(string id, string parent, params (float[] Axis, float Min, float Max)[] dofs) => new ShipJoint
{
Id = id, Parent = parent,
Axes = dofs.SelectMany(dof => dof.Axis).ToArray(), Min = dofs.Select(dof => dof.Min).ToArray(), Max = dofs.Select(dof => dof.Max).ToArray()
};
private static float[] Tilted(float degrees) => new[] { MathF.Sin(degrees * MathF.PI / 180f), MathF.Cos(degrees * MathF.PI / 180f), 0f };
// The fixture's skiff with energy guns "g0".."gN" side by side (one cell each) and the thruster after them. Each gun
// is a weapon mount (node "gN") with one muzzle ("gN.muzzle", node "gN-muzzle"), fires 180 degrees, and rides the joint
// its tuple names (null is a fixed mount).
private static ShipParts Armed(params string[] jointOfGun)
{
var ship = ShipAuthoringTests.Fixture();
var shape = new Shape(jointOfGun.Length + 1, 1);
for (var x = 0; x <= jointOfGun.Length; x++) shape.Cells[x, 0] = true;
ship.Hull.Shape = shape;
ship.Hull.Hardpoints[0].Position = new int2(jointOfGun.Length, 0);
for (var gun = 0; gun < jointOfGun.Length; gun++)
{
ship.Hull.Hardpoints.Add(new HardpointData
{ Type = HardpointType.Energy, Position = new int2(gun, 0), Shape = new Shape(), Transform = $"g{gun}", FiringArc = 180 });
ship.Visual.Anchors.Add(new ShipAnchor { Id = $"g{gun}", Role = "weapon-mount", ModelNodeId = $"g{gun}", Joint = jointOfGun[gun] });
ship.Visual.Anchors.Add(new ShipAnchor { Id = $"g{gun}.muzzle", Role = "weapon-muzzle", ModelNodeId = $"g{gun}-muzzle", ParentId = $"g{gun}" });
}
return ship;
}
private static string Refusal(ShipParts ship) => Assert.Throws<InvalidOperationException>(ship.Validate).Message;
// One gun, on joint "j" with the given degrees of freedom, firing the given arc.
private static ShipParts OneGun(float arc, params (float[] Axis, float Min, float Max)[] dofs)
{
var ship = Armed("j");
ship.Visual.Joints.Add(Joint("j", null, dofs));
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g0").FiringArc = arc;
return ship;
}
[Fact]
public void AnArmedShipWithAJointIsValid() => OneGun(90, (Up, -45f, 45f)).Validate();
[Fact]
public void ReachIsTheYawRangeOfTheChainAgainstTheArcTheHardpointStates()
{
OneGun(90, (Up, -45f, 45f)).Validate();
var short1 = Refusal(OneGun(100, (Up, -45f, 45f)));
Assert.Contains("joint j chain reaches 90 degrees of yaw", short1);
Assert.Contains("100 degree firing arc", short1);
// Serial degrees of freedom add, whether they share a joint or sit on two.
OneGun(120, (Up, -30f, 30f), (Up, -30f, 30f)).Validate();
Assert.Contains("reaches 120", Refusal(OneGun(121, (Up, -30f, 30f), (Up, -30f, 30f))));
var serial = Armed("child");
serial.Visual.Joints.Add(Joint("base", null, (Up, -30f, 30f)));
serial.Visual.Joints.Add(Joint("child", "base", (Up, -30f, 30f)));
serial.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g0").FiringArc = 120;
serial.Validate();
serial.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g0").FiringArc = 121;
Assert.Contains("reaches 120", Refusal(serial));
// A degree of freedom about +X is pitch: it adds nothing, however wide.
Assert.Contains("reaches 0 degrees", Refusal(OneGun(10, (Right, -180f, 180f))));
Assert.Contains("reaches 90", Refusal(OneGun(91, (Up, -45f, 45f), (Right, -180f, 180f))));
// A full turn covers every arc up to 360, and no reach is reported above it.
OneGun(360, (Up, -180f, 180f)).Validate();
Assert.Contains("reaches 360 degrees", Refusal(OneGun(400, (Up, -180f, 180f), (Up, -180f, 180f))));
}
[Fact]
public void AnAxisWithinFiveDegreesOfUpIsYawAndOneBeyondItIsNot()
{
OneGun(90, (Tilted(4f), -45f, 45f)).Validate();
Assert.Contains("reaches 0 degrees", Refusal(OneGun(90, (Tilted(6f), -45f, 45f))));
// An axis pointing down is as much yaw as one pointing up.
OneGun(90, (new[] { 0f, -1f, 0f }, -45f, 45f)).Validate();
}
[Fact]
public void AChainStopsAtADrivenAncestorSoItsYawDoesNotCountForTheChild()
{
var ship = Armed("outer", "inner");
ship.Visual.Joints.Add(Joint("outer", null, (Up, -180f, 180f)));
ship.Visual.Joints.Add(Joint("inner", "outer", (Up, -10f, 10f)));
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 20;
ship.Validate();
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 100;
Assert.Contains("joint inner chain reaches 20 degrees", Refusal(ship));
// With nothing riding the outer joint it is only a link in the inner chain, and counts.
ship.Visual.Anchors.Single(anchor => anchor.Id == "g0").Joint = null;
ship.Validate();
}
[Fact]
public void AGunOnAJointStatesItsArc()
{
var ship = OneGun(0, (Up, -180f, 180f));
Assert.Contains("must state its firing arc above 0", Refusal(ship));
// The same hardpoint on no joint is a fixed mount, and a fixed mount at the default arc is fine.
ship.Visual.Anchors.Single(anchor => anchor.Id == "g0").Joint = null;
ship.Validate();
}
[Fact]
public void MountsOnOneJointShareTheirArcAndRotation()
{
var ship = Armed("j", "j");
ship.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
ship.Validate();
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 90;
Assert.Contains("must share Rotation and FiringArc", Refusal(ship));
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 180;
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").Rotation = ItemRotation.Reversed;
Assert.Contains("must share Rotation and FiringArc", Refusal(ship));
// Guns on different joints, or one on none, keep their own.
ship.Visual.Joints.Add(Joint("k", null, (Up, -180f, 180f)));
ship.Visual.Anchors.Single(anchor => anchor.Id == "g1").Joint = "k";
ship.Validate();
}
[Fact]
public void AJointRidesOnlyAWeaponMountThatNamesARealJoint()
{
var ship = Armed("ghost");
Assert.Contains("names an unknown joint", Refusal(ship));
ship = Armed("j");
ship.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
ship.Visual.Anchors.Single(anchor => anchor.Id == "g0.muzzle").Joint = "j";
Assert.Contains("only a weapon mount rides one", Refusal(ship));
}
[Fact]
public void JointShapeAcceptsItsBoundariesAndTheUnitTolerance()
{
OneGun(1, (Up, -180f, 180f)).Validate();
OneGun(1, (Up, 180f, 180f)).Validate();
OneGun(1, (new[] { 0f, 1.0009f, 0f }, 0f, 5f)).Validate();
OneGun(1, (new[] { 0f, .9991f, 0f }, 0f, 5f)).Validate();
Assert.Contains("must be a unit vector", Refusal(OneGun(1, (new[] { 0f, 1.0015f, 0f }, 0f, 5f))));
Assert.Contains("must be a unit vector", Refusal(OneGun(1, (new[] { 0f, .9985f, 0f }, 0f, 5f))));
}
// ---- Bind: the GLB must have the rig the record states ----
// A model node: its glTF name (unique, what Parent names), its aetheria.id (null for a node the exporter left untagged),
// its parent's name, and whether it carries a mesh.
private record Node(string Name, string Id, string Parent, bool Mesh = false);
// arm > elbow > wrist > (an untagged bracket) > gun > gun-muzzle, with the fixture's other nodes at the root.
private static readonly Node[] RigNodes =
{
new Node("map", "map", null), new Node("collider", "collider", null), new Node("shield", "shield", null),
new Node("tractor", "tractor", null), new Node("thruster-port", "thruster-port", null, true),
new Node("arm", "arm", null), new Node("elbow", "elbow", "arm"), new Node("wrist", "wrist", "elbow"),
new Node("bracket", null, "wrist"), new Node("gun", "gun", "bracket"), new Node("gun-muzzle", "gun-muzzle", "gun")
};
private static string GlbJson(Node[] nodes)
{
var text = new StringBuilder(@"{""asset"":{""version"":""2.0""},""meshes"":[{""primitives"":[]}],""nodes"":[");
for (var index = 0; index < nodes.Length; index++)
{
var node = nodes[index];
var kids = Enumerable.Range(0, nodes.Length).Where(child => nodes[child].Parent == node.Name).ToArray();
text.Append(index > 0 ? "," : "").Append($@"{{""name"":""{node.Name}""");
if (node.Mesh) text.Append(@",""mesh"":0");
if (node.Id != null) text.Append($@",""extras"":{{""aetheria.id"":""{node.Id}""}}");
if (kids.Length > 0) text.Append(@",""children"":[" + string.Join(",", kids) + "]");
text.Append('}');
}
return text.Append("]}").ToString();
}
// The fixture's package with the arm rig (arm, elbow, wrist; the gun on the wrist), then the tweak, written beside a
// GLB of the given nodes.
private string Package(Action<ShipParts> tweak, Node[] nodes = null)
{
var mods = Path.Combine(_directory.Path, "Mods");
Directory.CreateDirectory(mods);
var directory = ShipFixture.WritePackage(mods, "mod.skiff", tweak: ship =>
{
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "gun").FiringArc = 90;
ship.Visual.Joints.Add(Joint("arm", null, (Up, -45f, 45f)));
ship.Visual.Joints.Add(Joint("elbow", "arm", (Right, -45f, 45f)));
ship.Visual.Joints.Add(Joint("wrist", "elbow", (Up, -45f, 45f)));
ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Joint = "wrist";
tweak?.Invoke(ship);
});
File.WriteAllBytes(Path.Combine(directory, "skiff.glb"), ShipFixture.Glb(GlbJson(nodes ?? RigNodes)));
return Path.Combine(directory, "ship.cc");
}
private static string BindRefusal(string path) => Assert.Throws<InvalidOperationException>(() => ShipModCatalog.ReadPackage(path)).Message;
private static Node[] Reparent(string name, string parent) =>
RigNodes.Select(node => node.Name == name ? node with { Parent = parent } : node).ToArray();
[Fact]
public void BindAcceptsARigTheModelHasEvenWithAnUntaggedNodeBetween()
{
var package = ShipModCatalog.ReadPackage(Package(null));
Assert.Equal(new[] { "arm", "elbow", "wrist" }, package.Visual.Joints.Select(joint => joint.Id));
}
[Fact]
public void BindRefusesAMountWhoseJointIsNotTheNearestOneAboveItsNode()
{
Assert.Contains("weapon mount gun must name the nearest joint above its model node",
BindRefusal(Package(ship => ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Joint = "elbow")));
}
[Fact]
public void BindRefusesAMountThatTheRecordAndTheModelPlaceDifferently()
{
// The record says the gun is fixed; the model hangs it under the wrist.
Assert.Contains("weapon mount gun must name the nearest joint above its model node",
BindRefusal(Package(ship => ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Joint = null)));
// The record rides the wrist; the model hangs the gun at the root.
Assert.Contains("weapon mount gun must name the nearest joint above its model node", BindRefusal(Package(null, Reparent("gun", null))));
}
[Fact]
public void BindRefusesAJointThatIsNoModelNode()
{
var path = Package(null, RigNodes.Where(node => node.Name != "elbow").Select(node => node.Name == "wrist" ? node with { Parent = null } : node).ToArray());
Assert.Contains("model has no node with aetheria.id=elbow for a joint", BindRefusal(path));
}
[Fact]
public void BindRefusesAJointWhoseParentSkipsAJointNode()
{
Assert.Contains("joint wrist must name the nearest joint above its model node as its Parent",
BindRefusal(Package(ship => ship.Visual.Joints.Single(joint => joint.Id == "wrist").Parent = "arm")));
Assert.Contains("joint elbow must name the nearest joint above its model node as its Parent",
BindRefusal(Package(ship => ship.Visual.Joints.Single(joint => joint.Id == "elbow").Parent = null)));
}
[Fact]
public void BindRefusesAThrusterMeshUnderAJointNamingTheFollowUp() =>
Assert.Contains("gimballed-thrusters", BindRefusal(Package(null, Reparent("thruster-port", "wrist"))));
[Fact]
public void NodeParentsAreTheNearestTaggedAncestorAndRootsHaveNone()
{
var path = Path.Combine(_directory.Path, "rig.glb");
File.WriteAllBytes(path, ShipFixture.Glb(GlbJson(RigNodes)));
var nodes = ShipModCatalog.ReadNodeIds(path);
Assert.Equal(-1, nodes["arm"].Parent);
Assert.Equal((int)nodes["arm"].Index, nodes["elbow"].Parent);
Assert.Equal((int)nodes["wrist"].Index, nodes["gun"].Parent);
Assert.Equal((int)nodes["gun"].Index, nodes["gun-muzzle"].Parent);
}
// ---- ShipModPlan.Rigs ----
[Fact]
public void RigsAreOnePerDrivenJointInHullOrderOfTheirFirstWeaponWithChainsThatStopAtDrivenAncestors()
{
// g0 on the inner joint, g1 and g2 on the outer one (g2 authored first among the anchors).
var ship = Armed("barrel", "turret", "turret");
ship.Visual.Joints.Add(Joint("turret", null, (Up, -180f, 180f)));
ship.Visual.Joints.Add(Joint("barrel", "turret", (Up, -180f, 180f)));
ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "g1.muzzle");
ship.Visual.Anchors.Add(new ShipAnchor { Id = "g1.b", Role = "weapon-muzzle", ModelNodeId = "g1-b", ParentId = "g1", Order = 1 });
ship.Visual.Anchors.Add(new ShipAnchor { Id = "g1.z", Role = "weapon-muzzle", ModelNodeId = "g1-z", ParentId = "g1", Order = 0 });
ship.Visual.Anchors.Reverse();
var rigs = ShipModPlan.Build(ship.Hull, ship.Visual).Rigs;
Assert.Equal(2, rigs.Length);
Assert.Equal("barrel", rigs[0].Joint);
Assert.Equal(new[] { "barrel" }, rigs[0].Chain);
Assert.Equal(("g0", "g0.muzzle"), (rigs[0].LeadMount, rigs[0].LeadMuzzle));
Assert.Equal("turret", rigs[1].Joint);
Assert.Equal(new[] { "turret" }, rigs[1].Chain);
Assert.Equal(("g1", "g1.z"), (rigs[1].LeadMount, rigs[1].LeadMuzzle));
}
[Fact]
public void AnUndrivenParentJointIsPartOfTheChainRootFirst()
{
var ship = Armed("wrist");
ship.Visual.Joints.Add(Joint("arm", null, (Up, -90f, 90f)));
ship.Visual.Joints.Add(Joint("elbow", "arm", (Right, -45f, 45f)));
ship.Visual.Joints.Add(Joint("wrist", "elbow", (Up, -90f, 90f)));
var rig = Assert.Single(ShipModPlan.Build(ship.Hull, ship.Visual).Rigs);
Assert.Equal(new[] { "arm", "elbow", "wrist" }, rig.Chain);
}
[Fact]
public void AShipWithNoGunOnAJointHasNoRigs() =>
Assert.Empty(ShipModPlan.Build(Armed((string)null).Hull, Armed((string)null).Visual).Rigs);
}

View file

@ -172,17 +172,39 @@ public sealed class ShipValidationTests
Add("anchors-null", s => s.Visual.Anchors = null, "anchors are required");
Add("anchor-null", s => s.Visual.Anchors.Add(null), "anchor IDs must be present and unique");
Add("anchor-id-null", s => s.Visual.Anchors[4].Id = null, "anchor IDs must be present and unique");
Add("anchor-id-blank", s => s.Visual.Anchors.Add(new ShipAnchor { Id = " ", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique");
Add("anchor-id-duplicate", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "map", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique");
Add("anchor-id-blank", s => s.Visual.Anchors.Add(new ShipAnchor { Id = " ", Role = "weapon-muzzle", ModelNodeId = "x" }), "anchor IDs must be present and unique");
Add("anchor-id-duplicate", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "map", Role = "weapon-muzzle", ModelNodeId = "x" }), "anchor IDs must be present and unique");
Add("anchor-node-null", s => s.Visual.Anchors[4].ModelNodeId = null, "needs a model node ID");
Add("anchor-node-blank", s => s.Visual.Anchors[4].ModelNodeId = " ", "needs a model node ID");
Add("anchor-node-shared", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "other", Role = "articulation", ModelNodeId = "map" }), "claimed by more than one anchor");
Add("anchor-node-shared", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "other", Role = "weapon-muzzle", ModelNodeId = "map" }), "claimed by more than one anchor");
foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" })
{
Add("role-missing-" + role, s => s.Visual.Anchors.RemoveAll(a => a.Role == role), $"exactly one {role} anchor is required");
Add("role-doubled-" + role, s => s.Visual.Anchors.Add(new ShipAnchor { Id = "extra", Role = role, ModelNodeId = "extra" }), $"exactly one {role} anchor is required");
}
void Joint(ShipParts s, string id, string parent, float[] axes, float[] min, float[] max) =>
s.Visual.Joints.Add(new ShipJoint { Id = id, Parent = parent, Axes = axes, Min = min, Max = max });
var yaw = new[] { 0f, 1f, 0f };
Add("joint-id-blank", s => Joint(s, " ", null, yaw, new[] { -1f }, new[] { 1f }), "joint IDs must be present and unique");
Add("joint-null", s => s.Visual.Joints.Add(null), "joint IDs must be present and unique");
Add("joint-id-duplicate", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); }, "joint IDs must be present and unique");
Add("joint-id-is-an-anchor-node", s => Joint(s, "map", null, yaw, new[] { -1f }, new[] { 1f }), "is also an anchor's model node");
Add("joint-axes-length-four", s => Joint(s, "j", null, new[] { 0f, 1f, 0f, 0f }, new[] { -1f }, new[] { 1f }), "needs one or more degrees of freedom");
Add("joint-no-degrees-of-freedom", s => Joint(s, "j", null, new float[0], new float[0], new float[0]), "needs one or more degrees of freedom");
Add("joint-null-limits", s => Joint(s, "j", null, yaw, null, null), "needs one or more degrees of freedom");
Add("joint-max-count-differs", s => Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f, 2f }), "needs one or more degrees of freedom");
Add("joint-axis-not-unit", s => Joint(s, "j", null, new[] { 0f, 2f, 0f }, new[] { -1f }, new[] { 1f }), "must be a unit vector");
Add("joint-axis-not-finite", s => Joint(s, "j", null, new[] { 0f, float.NaN, 0f }, new[] { -1f }, new[] { 1f }), "axes and limits must be finite");
Add("joint-limit-not-finite", s => Joint(s, "j", null, yaw, new[] { -1f }, new[] { float.PositiveInfinity }), "axes and limits must be finite");
Add("joint-min-above-max", s => Joint(s, "j", null, yaw, new[] { 10f }, new[] { 5f }), "must satisfy -180 <= Min <= Max <= 180");
Add("joint-max-181", s => Joint(s, "j", null, yaw, new[] { 0f }, new[] { 181f }), "must satisfy -180 <= Min <= Max <= 180");
Add("joint-min-minus-181", s => Joint(s, "j", null, yaw, new[] { -181f }, new[] { 0f }), "must satisfy -180 <= Min <= Max <= 180");
Add("joint-parent-dangling", s => Joint(s, "j", "ghost", yaw, new[] { -1f }, new[] { 1f }), "has an unknown parent joint");
Add("joint-cycle", s => { Joint(s, "a", "b", yaw, new[] { -1f }, new[] { 1f }); Joint(s, "b", "a", yaw, new[] { -1f }, new[] { 1f }); }, "is on a parent cycle");
Add("joint-on-a-thruster-mount", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[4].Joint = "j"; }, "gimballed-thrusters");
Add("joint-on-a-radiator-mount", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[4].Role = "radiator-mesh"; s.Visual.Anchors[4].Joint = "j"; s.Hull.Hardpoints[0].Type = HardpointType.Radiator; }, "gimballed-thrusters");
Add("joint-on-a-map-icon", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[0].Joint = "j"; }, "may not name a joint");
Add("anchor-role-unknown", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "x", Role = "engine", ModelNodeId = "x" }), "has unknown role 'engine'");
Add("anchor-role-null", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "x", Role = null, ModelNodeId = "x" }), "has unknown role ''");
Add("anchor-role-cased", s => s.Visual.Anchors[0].Role = "Map-Icon", "has unknown role 'Map-Icon'");