feat: build articulated crow flight v2
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
disposeCrow,
|
||||
disposeDog,
|
||||
disposeHumanoid,
|
||||
poseCrow,
|
||||
poseCrowFlight,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
@@ -100,6 +101,52 @@ describe("procedural actor assets", () => {
|
||||
disposeCrow(rig);
|
||||
});
|
||||
|
||||
it("builds a layered v2 feather rig with shoulder, elbow, wrist, tail and claw anatomy", () => {
|
||||
const rig = buildCrow();
|
||||
assert.equal(rig.root.userData.rigVersion, 2);
|
||||
assert.equal(rig.joints.elbowLeft.parent, rig.joints.shoulderLeft);
|
||||
assert.equal(rig.joints.wristLeft.parent, rig.joints.elbowLeft);
|
||||
assert.equal(rig.joints.elbowRight.parent, rig.joints.shoulderRight);
|
||||
assert.equal(rig.joints.wristRight.parent, rig.joints.elbowRight);
|
||||
|
||||
const allMeshes = meshes(rig.root);
|
||||
const featherCount = (prefix: string) => allMeshes
|
||||
.filter((mesh) => mesh.name.startsWith(prefix))
|
||||
.reduce((sum, mesh) => sum + Number(mesh.userData.featherCount ?? 0), 0);
|
||||
assert.equal(featherCount("crow.primaries."), 18);
|
||||
assert.equal(featherCount("crow.secondaries-"), 14);
|
||||
assert.equal(featherCount("crow.tail-feathers."), 9);
|
||||
assert.equal(allMeshes.filter((mesh) => mesh.name.startsWith("crow.toe.")).length, 8);
|
||||
assert.ok(allMeshes.length <= 36, `${allMeshes.length} meshes keeps one crow bounded for mobile/peers`);
|
||||
disposeCrow(rig);
|
||||
});
|
||||
|
||||
it("poses a materially different glide, bank, tuck and perch without rebuilding geometry", () => {
|
||||
const rig = buildCrow();
|
||||
const width = () => new THREE.Box3().setFromObject(rig.root).getSize(new THREE.Vector3()).x;
|
||||
|
||||
poseCrow(rig, { state: "glide", amount: 0.8, flight: 1 });
|
||||
const glideWidth = width();
|
||||
assert.equal(rig.root.userData.pose, "glide");
|
||||
assert.ok(Math.abs(glideWidth - CROW_METRICS.wingspan) < 0.12, `glide width ${glideWidth}`);
|
||||
|
||||
poseCrow(rig, { state: "bank", amount: 0.8, bank: 0.75, flight: 1 });
|
||||
assert.equal(rig.root.userData.pose, "bank");
|
||||
assert.notEqual(rig.joints.shoulderLeft.rotation.z, -rig.joints.shoulderRight.rotation.z,
|
||||
"bank makes the wing dihedral asymmetric");
|
||||
assert.ok(rig.joints.tail.rotation.z < 0, "tail counters a right bank");
|
||||
|
||||
poseCrow(rig, { state: "tuck", amount: 1, flight: 1 });
|
||||
const tuckWidth = width();
|
||||
poseCrow(rig, { state: "perch", amount: 1, flight: 0 });
|
||||
const perchWidth = width();
|
||||
assert.ok(tuckWidth < glideWidth, `${tuckWidth} tuck < ${glideWidth} glide`);
|
||||
assert.ok(perchWidth < tuckWidth, `${perchWidth} perch < ${tuckWidth} tuck`);
|
||||
assert.equal(rig.joints.legLeft.rotation.x, 0);
|
||||
assert.equal(rig.joints.legRight.rotation.x, 0);
|
||||
disposeCrow(rig);
|
||||
});
|
||||
|
||||
it("never disposes caller-owned materials unless explicitly requested", () => {
|
||||
const source = buildCrow();
|
||||
const shared = meshes(source.root)[0]!.material as THREE.Material;
|
||||
|
||||
@@ -129,6 +129,76 @@ describe("crow flight", () => {
|
||||
controller.stepFixed({ modeRequest: "ground" });
|
||||
assert.equal(controller.state().mode, "ground");
|
||||
assert.equal(controller.state().y, 12);
|
||||
assert.equal(controller.state().crowPose, "perch");
|
||||
assert.equal(controller.state().perched, true);
|
||||
});
|
||||
|
||||
it("models deterministic wind, radial thermal lift, banking and finite energy", () => {
|
||||
const options = groundOptions({
|
||||
kind: "crow",
|
||||
mode: "flight",
|
||||
position: { x: 0, y: 20, z: 0 },
|
||||
minFlightAltitude: 2,
|
||||
maxFlightAltitude: 80,
|
||||
crowWind: { xMps: 2, zMps: -0.5 },
|
||||
crowThermals: [{ x: 0, z: 0, radiusM: 100, liftMps: 2.5 }],
|
||||
crowInitialEnergy: 0.6,
|
||||
crowEnergyDrainPerSecond: 0.1,
|
||||
crowEnergyRecoveryPerSecond: 0.2,
|
||||
});
|
||||
const first = new ActorController(options);
|
||||
const replay = new ActorController(options);
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const actions = { forward: 1, turn: 0.7, climb: 0.1 };
|
||||
first.stepFixed(actions);
|
||||
replay.stepFixed(actions);
|
||||
}
|
||||
assert.deepEqual(first.snapshot(), replay.snapshot());
|
||||
assert.ok(first.state().x > 0, "the configured eastward air mass advects the bird");
|
||||
assert.ok(first.state().thermalLiftMps > 2, "the bird remains near the thermal core");
|
||||
assert.ok(first.state().liftMps > first.state().thermalLiftMps);
|
||||
assert.ok(first.state().roll > 0);
|
||||
assert.equal(first.state().crowPose, "flap");
|
||||
assert.ok(first.state().flightEnergy < 0.6 && first.state().flightEnergy >= 0);
|
||||
|
||||
const spent = first.state().flightEnergy;
|
||||
for (let index = 0; index < 10; index += 1) first.stepFixed({ glide: true, turn: 0.7 });
|
||||
assert.ok(first.state().flightEnergy > spent);
|
||||
assert.equal(first.state().crowPose, "bank");
|
||||
assert.equal(first.state().gliding, true);
|
||||
|
||||
first.stepFixed({ forward: -1, glide: true, pitch: -1 });
|
||||
assert.equal(first.state().crowPose, "tuck");
|
||||
assert.ok(first.state().flightEnergy >= 0 && first.state().flightEnergy <= 1);
|
||||
});
|
||||
|
||||
it("replays wind and thermal flight bit-for-bit across powered, glide and tuck states", () => {
|
||||
const options = groundOptions({
|
||||
kind: "crow",
|
||||
mode: "flight",
|
||||
position: { x: -10, y: 15, z: 5 },
|
||||
minFlightAltitude: 2,
|
||||
maxFlightAltitude: 100,
|
||||
crowWind: { xMps: -1.25, zMps: 0.8 },
|
||||
crowThermals: [
|
||||
{ x: -10, z: 5, radiusM: 45, liftMps: 3.2 },
|
||||
{ x: 80, z: -20, radiusM: 25, liftMps: 1.4 },
|
||||
],
|
||||
crowInitialEnergy: 0.82,
|
||||
});
|
||||
const frames = [
|
||||
{ steps: 90, actions: { forward: 0.8, turn: 0.3, climb: 0.2 } },
|
||||
{ steps: 45, actions: { glide: true, turn: -0.7 } },
|
||||
{ steps: 20, actions: { forward: -1, glide: true, pitch: -0.7 } },
|
||||
{ steps: 30, actions: { forward: 0.4, pitch: 0.2 } },
|
||||
];
|
||||
const first = replayActorInputs(options, frames);
|
||||
const second = replayActorInputs(options, frames);
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(first.final.elapsedSteps, 185);
|
||||
assert.ok(first.trajectory.every((state) =>
|
||||
state.flightEnergy >= 0 && state.flightEnergy <= 1 &&
|
||||
Number.isFinite(state.liftMps) && Number.isFinite(state.roll)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,6 +249,10 @@ describe("actor identity, reset and replay", () => {
|
||||
it("rejects invalid configuration and identity before creating state", () => {
|
||||
assert.throws(() => new ActorController(groundOptions({ minFlightAltitude: 5, maxFlightAltitude: 2 })), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({ walkSpeedMps: 0 })), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({ crowEnergyDrainPerSecond: 0 })), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({
|
||||
crowThermals: [{ x: 0, z: 0, radiusM: 0, liftMps: 2 }],
|
||||
})), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({
|
||||
horizontalBounds: { minX: 2, maxX: 1, minZ: 0, maxZ: 1 },
|
||||
})), RangeError);
|
||||
|
||||
@@ -66,6 +66,13 @@ describe("playable city scene actor", () => {
|
||||
assert.ok(actor.state().y > 4 && actor.state().y <= 6);
|
||||
assert.equal(actor.root.rotation.order, "YXZ");
|
||||
assert.equal(actor.root.rotation.x, actor.state().pitch);
|
||||
const crow = actor.root.getObjectByName("crow");
|
||||
assert.equal(crow?.userData.pose, "flap");
|
||||
actor.setActions({ glide: true, turn: 1 });
|
||||
actor.tick(0.3);
|
||||
assert.equal(actor.state().crowPose, "bank");
|
||||
assert.equal(crow?.userData.pose, "bank");
|
||||
assert.notEqual(actor.root.rotation.z, 0, "the scene root presents controller-owned bank");
|
||||
const pose = actor.followPose();
|
||||
assert.ok(pose.position.toArray().every(Number.isFinite));
|
||||
assert.ok(pose.target.toArray().every(Number.isFinite));
|
||||
|
||||
Reference in New Issue
Block a user