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:
parent
8755da5ecb
commit
9e516d06d9
8 changed files with 544 additions and 18 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
321
tests/Aetheria.Shared.Tests/ShipRigTests.cs
Normal file
321
tests/Aetheria.Shared.Tests/ShipRigTests.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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'");
|
||||
|
|
|
|||
Loading…
Reference in a new issue