Off-centre joint limits (20..50, -10..50, -50..0) so Max-Min is distinguishable from |Min|+|Max| and 2*max/2*Max; Bind walking past tagged nodes that are not joints; Rigs and the lead mount follow hull order where it differs from name order. Tests only; builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
384 lines
19 KiB
C#
384 lines
19 KiB
C#
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))));
|
|
}
|
|
|
|
// Reach is Max - Min per degree of freedom, however the limits sit about zero: a sweep from 20 to 50 reaches 30, and one from
|
|
// -10 to 50 reaches 60. Symmetric limits cannot tell that from |Min| + |Max| (70, 60) or twice the larger side (100, 100).
|
|
[Fact]
|
|
public void ReachIsMaxMinusMinForLimitsThatSitOffCentre()
|
|
{
|
|
OneGun(30, (Up, 20f, 50f)).Validate();
|
|
Assert.Contains("reaches 30 degrees", Refusal(OneGun(31, (Up, 20f, 50f))));
|
|
OneGun(60, (Up, -10f, 50f)).Validate();
|
|
Assert.Contains("reaches 60 degrees", Refusal(OneGun(61, (Up, -10f, 50f))));
|
|
OneGun(50, (Up, -50f, 0f)).Validate();
|
|
Assert.Contains("reaches 50 degrees", Refusal(OneGun(51, (Up, -50f, 0f))));
|
|
// Off-centre limits on two serial degrees of freedom add their own ranges: 30 + 60.
|
|
OneGun(90, (Up, 20f, 50f), (Up, -10f, 50f)).Validate();
|
|
Assert.Contains("reaches 90 degrees", Refusal(OneGun(91, (Up, 20f, 50f), (Up, -10f, 50f))));
|
|
}
|
|
|
|
[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, 179f, 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));
|
|
}
|
|
|
|
// The exporter tags nodes that are not joints (a bracket, a forearm mesh). They sit between a mount and its joint and
|
|
// between a joint and its parent joint, and the nearest JOINT above is still what Bind compares.
|
|
[Fact]
|
|
public void BindWalksPastTaggedNodesThatAreNotJoints()
|
|
{
|
|
var nodes = RigNodes.Select(node => node.Name switch
|
|
{
|
|
"bracket" => node with { Id = "bracket" },
|
|
"wrist" => node with { Parent = "forearm" },
|
|
_ => node
|
|
}).Append(new Node("forearm", "forearm", "elbow")).ToArray();
|
|
|
|
var package = ShipModCatalog.ReadPackage(Package(null, nodes));
|
|
|
|
Assert.Equal(new[] { "arm", "elbow", "wrist" }, package.Visual.Joints.Select(joint => joint.Id));
|
|
Assert.Equal("wrist", package.Visual.Anchors.Single(anchor => anchor.Id == "gun").Joint);
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
// Hull order, not name order: the first gun rides "turret" and the second "barrel".
|
|
[Fact]
|
|
public void RigsFollowHullOrderAndNotTheOrderOfTheJointNames()
|
|
{
|
|
var ship = Armed("turret", "barrel");
|
|
ship.Visual.Joints.Add(Joint("turret", null, (Up, -180f, 180f)));
|
|
ship.Visual.Joints.Add(Joint("barrel", null, (Up, -180f, 180f)));
|
|
|
|
var rigs = ShipModPlan.Build(ship.Hull, ship.Visual).Rigs;
|
|
|
|
Assert.Equal(new[] { "turret", "barrel" }, rigs.Select(rig => rig.Joint));
|
|
Assert.Equal(new[] { "g0", "g1" }, rigs.Select(rig => rig.LeadMount));
|
|
}
|
|
|
|
// g1 is listed before g0 in the hull though its name sorts after it.
|
|
[Fact]
|
|
public void TheLeadMountOfAJointIsItsFirstWeaponInHullOrderWhateverTheNames()
|
|
{
|
|
var ship = Armed("j", "j");
|
|
ship.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
|
|
var g1 = ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1");
|
|
ship.Hull.Hardpoints.Remove(g1);
|
|
ship.Hull.Hardpoints.Insert(ship.Hull.Hardpoints.FindIndex(hardpoint => hardpoint.Transform == "g0"), g1);
|
|
|
|
var rig = Assert.Single(ShipModPlan.Build(ship.Hull, ship.Visual).Rigs);
|
|
|
|
Assert.Equal(("g1", "g1.muzzle"), (rig.LeadMount, rig.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);
|
|
}
|