diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index fbe2e0d13..5f01188b9 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -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; diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index ec5f2a3c2..5093eb706 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -17,6 +17,22 @@ public sealed class ShipAuthoring [Key(2)] public string ModelAsset; [Key(3)] public List Anchors = new List(); [Key(4)] public List SchematicLines = new List(); + // The moving parts of the rig, presentation only (ruling rig-is-presentation): no joint value feeds the simulation. + [Key(5)] public List Joints = new List(); +} + +// 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 Roles = new HashSet(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()) { @@ -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 anchors, Dictionary mounts, HashSet nodeIds) + { + var joints = ship.Joints ?? new List(); + var byId = new Dictionary(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 Driven(ShipAuthoring ship) => + new HashSet(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 { 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(); + } } diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 38216f481..b2823c4db 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -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 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 nodes) + { + var joints = new HashSet((ship.Joints ?? new List()).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()) + { + 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 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(StringComparer.Ordinal); - var nodes = json["nodes"]?.OfType() ?? Enumerable.Empty(); + var ids = new Dictionary(StringComparer.Ordinal); + var nodes = (json["nodes"]?.OfType() ?? Enumerable.Empty()).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()) + 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; + } } diff --git a/Assets/Scripts/ServerShared/ShipModPlan.cs b/Assets/Scripts/ServerShared/ShipModPlan.cs index bb80647a1..f242ab037 100644 --- a/Assets/Scripts/ServerShared/ShipModPlan.cs +++ b/Assets/Scripts/ServerShared/ShipModPlan.cs @@ -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() }; } } diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 98eeaf5f7..0ae6b54ae 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -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. diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index a2426906b..d23243c6f 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -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 { ["a"] = (1, false), ["b"] = (4, true) }, ShipModCatalog.ReadNodeIds(path)); + Assert.Equal(new Dictionary { ["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 tweak = null, string[] meshNodes = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak, meshNodes); - private static Dictionary Indices(Dictionary nodes) => + private static Dictionary Indices(Dictionary nodes) => nodes.ToDictionary(node => node.Key, node => node.Value.Index); private static int Occurrences(byte[] haystack, byte[] needle) diff --git a/tests/Aetheria.Shared.Tests/ShipRigTests.cs b/tests/Aetheria.Shared.Tests/ShipRigTests.cs new file mode 100644 index 000000000..a8219fbb2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipRigTests.cs @@ -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(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 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(() => 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); +} diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index 804ff723f..c2d177f21 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -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'");