1
0

feat: build articulated crow flight v2

This commit is contained in:
2026-08-19 01:12:08 -07:00
parent 747744fa6c
commit 94de2d8bee
10 changed files with 789 additions and 106 deletions
+47
View File
@@ -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;
+74
View File
@@ -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);
+7
View File
@@ -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));