// Ragdoll — a Meep example for cone-twist, hinge, and spring joints.
//
// A jointed humanoid (16 rigid parts, 15 joints) starts lying horizontal, drops
// onto a sphere, and drapes over it — folding at the waist with the trunk and
// head off one side and the legs off the other. Click "Reset" to re-drop it (and
// stand the crates back up), or grab any part and fling it around. The structure /
// joint limits follow
// the classic cannon-es
// ragdoll demo, adapted to Meep's Y-up world and its joint API — with the trunk
// split into a pelvis / abdomen / chest so the spine can curl over the sphere,
// box feet on hinged ankles so the body's facing reads at a glance, and box
// hands that dangle off the wrists.
//
// Sections:
// §1 Body layout part shapes + positions, joint list
// §2 Helpers inertia tensors
// §3 Engine bootstrap
// §4 Camera + lights
// §5 Ground + sphere + crates static ground/sphere; four draggable play crates
// §6 Build the ragdoll parts + cone-twist / hinge / spring joints + self-collision
// (knees, elbows, ankles are hinges; the two spine joints are springs)
// §7 Reset control re-drop the figure + stand the crates back up
// §8 Raycast picking + spring drag (grab any part; orbit is frozen mid-drag)
// §9 Per-frame: drag target, cursor, HUD
//
// ─── Cone-twist, hinge, and spring joints in one paragraph ───────────────────
//
// Most joints are `asConeTwist(twistLo, twistHi, swing)`: a ball-socket point (3
// linear DOFs locked) whose angular motion is a limited TWIST about the bone and
// a limited SWING cone off it. The bone is the joint frame's X axis, so each
// joint's basis is built to aim frame-X along that limb's long axis (Y for the
// legs/spine/neck, X for the spread arms). The knees, elbows, and ankles are
// instead `asHinge` joints — they bend in a single plane (frame Y only), with the
// twist and out-of-plane swing locked, so they can't wobble sideways the way a
// symmetric cone lets them. The two spine joints are `setAngularSpring` joints —
// damped torsional springs on all three angular axes that bend smoothly toward
// rest instead of swinging free into a hard stop, so the back curls and settles
// without the hard-limit buzz a loaded cone shows. The limits / springs are what
// keep the ragdoll articulating like a body instead of folding into a heap.
import * as THREE from "three";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import { CapsuleGeometry } from "@woosh/meep-engine/src/engine/graphics/geometry/CapsuleGeometry.js";
import { PhysicsSystem } from "@woosh/meep-engine/src/engine/physics/ecs/PhysicsSystem.js";
import { ColliderObserverSystem } from "@woosh/meep-engine/src/engine/physics/ecs/ColliderObserverSystem.js";
import { RigidBody } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBody.js";
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { BodyKind } from "@woosh/meep-engine/src/engine/physics/ecs/BodyKind.js";
import { Joint, JOINT_WORLD } from "@woosh/meep-engine/src/engine/physics/ecs/Joint.js";
import { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.js";
import { CapsuleShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/CapsuleShape3D.js";
import { SphereShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/SphereShape3D.js";
import { Ray3 } from "@woosh/meep-engine/src/core/geom/3d/ray/Ray3.js";
import { PhysicsSurfacePoint } from "@woosh/meep-engine/src/engine/physics/queries/PhysicsSurfacePoint.js";
import TopDownCameraController from "@woosh/meep-engine/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js";
import TopDownCameraControllerSystem from "@woosh/meep-engine/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.js";
import Vector2 from "@woosh/meep-engine/src/core/geom/Vector2.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { randomFloatBetween } from "@woosh/meep-engine/src/core/math/random/randomFloatBetween.js";
const PI = Math.PI;
// ─── §1 Body layout ─────────────────────────────────────────────────────────
//
// Positions are pelvis-relative (pelvis at the origin), authored as an upright
// figure with the arms spread out to the sides; §6 lays the whole thing
// horizontal for the drop. The trunk segments (pelvis, abdomen, chest), the
// feet, and the hands are boxes; the head is a sphere; the four limbs are
// capsules. `restZ` turns a part's local frame at rest — legs stay vertical,
// arms (and the hands that continue them) turn 90° out.
//
// box: dims = [hx, hy, hz]
// sphere: dims = radius
// capsule: dims = [radius, cylinderHeight] (extent along the bone = height/2 + radius)
//
// Masses follow Winter's body-segment fractions for a ~70 kg human (head+neck
// ~7%, trunk ~50% split pelvis/abdomen/chest ≈ 14/14/23%, upper arm ~2.7%,
// forearm ~1.4%, hand ~0.7%, thigh ~10%, shank ~4.3%, foot ~1.4%). They sum to ~70. Magnitudes
// are realistic kg; what the solver actually feels is the *ratios* between
// jointed parts (the substep solver in §3 handles the heavy-trunk / light-arm
// ratio), and damping (§3) is mass-independent, so the scale just reads "human".
const PARTS = [
{ name: "pelvis", shape: "box", dims: [0.9, 0.5, 0.5], pos: [0, 0, 0], mass: 10 },
{ name: "abdomen", shape: "box", dims: [0.85, 0.6, 0.5], pos: [0, 1.1, 0], mass: 10 },
{ name: "chest", shape: "box", dims: [1.0, 0.6, 0.5], pos: [0, 2.3, 0], mass: 16 },
{ name: "head", shape: "sphere", dims: 0.8, pos: [0, 3.7, 0], mass: 5 },
{ name: "upperArmL", shape: "capsule", dims: [0.32, 0.9], pos: [1.77, 2.4, 0], mass: 2.0, restZ: -PI / 2 },
{ name: "lowerArmL", shape: "capsule", dims: [0.30, 0.9], pos: [3.29, 2.4, 0], mass: 1.0, restZ: -PI / 2 },
{ name: "handL", shape: "box", dims: [0.09, 0.40, 0.23], pos: [4.44, 2.4, 0], mass: 0.5, restZ: -PI / 2 },
{ name: "upperArmR", shape: "capsule", dims: [0.32, 0.9], pos: [-1.77, 2.4, 0], mass: 2.0, restZ: PI / 2 },
{ name: "lowerArmR", shape: "capsule", dims: [0.30, 0.9], pos: [-3.29, 2.4, 0], mass: 1.0, restZ: PI / 2 },
{ name: "handR", shape: "box", dims: [0.09, 0.40, 0.23], pos: [-4.44, 2.4, 0], mass: 0.5, restZ: PI / 2 },
{ name: "upperLegL", shape: "capsule", dims: [0.38, 1.2], pos: [0.55, -1.48, 0], mass: 7 },
{ name: "lowerLegL", shape: "capsule", dims: [0.34, 1.2], pos: [0.55, -3.40, 0], mass: 3 },
{ name: "footL", shape: "box", dims: [0.28, 0.16, 0.6], pos: [0.55, -4.50, 0.30], mass: 1 },
{ name: "upperLegR", shape: "capsule", dims: [0.38, 1.2], pos: [-0.55, -1.48, 0], mass: 7 },
{ name: "lowerLegR", shape: "capsule", dims: [0.34, 1.2], pos: [-0.55, -3.40, 0], mass: 3 },
{ name: "footR", shape: "box", dims: [0.28, 0.16, 0.6], pos: [-0.55, -4.50, 0.30], mass: 1 },
];
// Anatomical hinge ranges (radians). Knees, elbows, and ankles flex one way only
// — the limit is asymmetric and its SIGN is per-limb: anatomical flexion maps to
// a NEGATIVE frame-Y angle for the knees and the LEFT elbow, but a POSITIVE one
// for the RIGHT elbow (the left/right arms have mirrored rest spins, so their
// flex lands on opposite signs — verified numerically against the solver's
// angular-position convention). The ankles are frame-Y hinges too (dorsi/plantar-
// flexion about the mediolateral axis), with DORSIFLEXION = +frame-Y (toe up) for
// both feet — same convention as the knees, also verified numerically. HYPER is a
// small hyperextension allowance so a straight limb rests just off a stop.
const HYPER = 0.10; // ~6° hyperextension allowance
const KNEE_FLEX = 2.36; // ~135° of knee flexion
const ELBOW_FLEX = 2.50; // ~143° of elbow flexion
const ANKLE_DORSI = 0.44; // ~25° dorsiflexion (toe up, +frame-Y)
const ANKLE_PLANTAR = 0.79; // ~45° plantarflexion (toe down, −frame-Y)
// Spine spring gains (the two trunk joints). Damped torsional springs that pull
// each segment back toward straight: a compliant, self-damping alternative to a
// hard cone, so the back curls over the sphere and settles without hard-stop
// buzz. Twist is stiffer than swing — a waist bends far more readily than it
// rotates axially. These are the main tuning dials for the spine; the implicit
// solver is stable for any non-negative gains, so tune freely by eye.
const SPINE_K_SWING = 500; // N·m/rad — forward / lateral bend stiffness
const SPINE_K_TWIST = 700; // N·m/rad — axial-rotation stiffness (stiffer)
const SPINE_C = 60; // N·m·s/rad — damping on all three axes
// Cone-twist ranges for the ball-socket limbs. The shoulder is the body's most
// mobile joint, so its cone is much wider than the legs/spine. The hip opens a
// little past the spine/neck so the heavy legs can splay and don't sit hard
// against the stop while draping. Both stay symmetric cones with a modest twist.
const SHOULDER_SWING = 1.48; // ~85° swing cone (vs ~45° for the rest)
const SHOULDER_TWIST = PI / 5;// ~36° axial rotation
const HIP_SWING = 0.96; // ~55° swing cone
// Wrists are cone-twists too (not hinges like the ankles): the wrist is genuinely
// multi-axis — flexion/extension AND radial/ulnar deviation, with almost no axial
// twist — so a small cone lets a limp hand dangle naturally off the forearm in any
// direction rather than locking it to a single plane.
const WRIST_SWING = 0.87; // ~50° dangle cone (flex/extend + deviation)
const WRIST_TWIST = PI / 12; // ~15° axial twist (wrists barely rotate)
// [name, bodyA, bodyB, anchorA(localA), anchorB(localB), lim0, lim1, bone, kind?]
// Anchors are chosen so the two connection points coincide in the rest pose.
// `bone` is the limb's long axis in world space at rest: "Y" for the legs,
// spine, and neck; "X" for the spread arms (§6 builds each joint's basis from it).
// `kind` (optional): "hinge" for the knees/elbows (single-axis bend), "spring"
// for the two spine joints (soft trunk); absent ⇒ cone-twist. The two numeric
// columns are read per kind: cone-twist → (swing, twist); hinge → signed bend
// range (lower, upper) about frame Y; spring → unused (gains are the SPINE_*
// constants above, with frame X = the spine, so the stiffer twist acts axially).
const JOINTS = [
["spineLow", "pelvis", "abdomen", [0, 0.5, 0], [0, -0.6, 0], 0, 0, "Y", "spring"],
["spineUp", "abdomen", "chest", [0, 0.6, 0], [0, -0.6, 0], 0, 0, "Y", "spring"],
["neck", "chest", "head", [0, 0.6, 0], [0, -0.8, 0], PI / 4, PI / 8, "Y"],
["shoulderL", "chest", "upperArmL", [1.0, 0.1, 0], [0, -0.77, 0], SHOULDER_SWING, SHOULDER_TWIST, "X"],
["elbowL", "upperArmL", "lowerArmL", [0, 0.77, 0], [0, -0.75, 0], -ELBOW_FLEX, HYPER, "X", "hinge"],
["wristL", "lowerArmL", "handL", [0, 0.75, 0], [0, -0.40, 0], WRIST_SWING, WRIST_TWIST, "X"],
["shoulderR", "chest", "upperArmR", [-1.0, 0.1, 0], [0, -0.77, 0], SHOULDER_SWING, SHOULDER_TWIST, "X"],
["elbowR", "upperArmR", "lowerArmR", [0, 0.77, 0], [0, -0.75, 0], -HYPER, ELBOW_FLEX, "X", "hinge"],
["wristR", "lowerArmR", "handR", [0, 0.75, 0], [0, -0.40, 0], WRIST_SWING, WRIST_TWIST, "X"],
["hipL", "pelvis", "upperLegL", [0.55, -0.5, 0],[0, 0.98, 0], HIP_SWING, PI / 8, "Y"],
["kneeL", "upperLegL", "lowerLegL", [0, -0.98, 0], [0, 0.94, 0], -KNEE_FLEX, HYPER, "Y", "hinge"],
["ankleL", "lowerLegL", "footL", [0, -0.94, 0], [0, 0.16, -0.30], -ANKLE_PLANTAR, ANKLE_DORSI, "Y", "hinge"],
["hipR", "pelvis", "upperLegR", [-0.55, -0.5, 0],[0, 0.98, 0], HIP_SWING, PI / 8, "Y"],
["kneeR", "upperLegR", "lowerLegR", [0, -0.98, 0], [0, 0.94, 0], -KNEE_FLEX, HYPER, "Y", "hinge"],
["ankleR", "lowerLegR", "footR", [0, -0.94, 0], [0, 0.16, -0.30], -ANKLE_PLANTAR, ANKLE_DORSI, "Y", "hinge"],
];
// A joint's cone-twist axis is its frame X. We aim it along the bone: for a
// bone-along-Y limb, turn the frame 90° (Rz) so frame-X points along Y; for a
// bone-along-X limb (the spread arms) the frame is already aligned. §6 also
// composes in each body's own rest rotation so the cone stays centred at rest.
const Z_AXIS = new THREE.Vector3(0, 0, 1);
const BONE_TO_Y = new THREE.Quaternion().setFromAxisAngle(Z_AXIS, PI / 2);
const BONE_TO_X = new THREE.Quaternion(); // identity
// Lay the figure down for the drop: a rigid 90° turn about X (which preserves
// every joint anchor's coincidence). Applied to all spawn poses.
const SPAWN_Y = 11; // dropped from higher to clear the larger sphere (dome top y = 4)
const LAY_DOWN = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -PI / 2);
// Nudge the whole figure forward — the way the head points — by one head-size, so
// it lands off the sphere's pole and drapes to one side. In the authored figure
// the head is along +Y; LAY_DOWN turns that into the world forward axis. Applied
// uniformly to every part, so all joint anchors stay coincident.
const HEAD_SIZE = 2 * PARTS.find((p) => p.name === "head").dims; // sphere diameter
const FORWARD = new THREE.Vector3(0, 1, 0).applyQuaternion(LAY_DOWN).multiplyScalar(HEAD_SIZE);
const SKIN = 0x6fb1d6; // ragdoll colour
const ENV = 0x2a313b; // ground / sphere colour
const HILITE = 0x4ef0a8; // hover / held highlight (Meep brand green)
// Play crates — four boxes in the platform corners to shove around / drag. Heavy
// (well above any single body part, and over half the whole ~70 kg figure) so a
// collision is dominated by the crate: the ragdoll bends and piles around it
// rather than flinging it, and a crate dragged into the doll really knocks it about.
const CRATE_SIZE = 2.0; // full edge length
const CRATE_MASS = 40;
const CRATE_INSET = 15; // crate centres at (±INSET, _, ±INSET)
const CRATE_COLORS = [0x8a4b3a, 0x9c5a44, 0xa9654c, 0x7c4636]; // terracotta
// Drag spring (§8). Stiffness/damping scale with the grabbed body's mass so the
// pull feels consistent (k = m·ω², c = 2·ζ·m·ω). But a bare 0.5 kg hand makes too
// weak a spring to haul the ~70 kg figure through its joints — it just stretches
// the arm — so we FLOOR the effective mass at DRAG_GRIP_MASS. A light part then
// grips as firmly as a meaty one, and the whole doll follows when you pull a hand.
const DRAG_FREQ = 10; // natural frequency, rad/s
const DRAG_DAMPING_RATIO = 1.0; // 1 = critically damped (no overshoot)
const DRAG_GRIP_MASS = 10; // effective-mass floor so light parts still pull hard
const PICK_MAX_DIST = 300; // ray length for picking
// ─── §2 Helpers ─────────────────────────────────────────────────────────────
function boxInverseInertia(mass, hx, hy, hz) {
const k = mass / 3;
return new Vector3(
1 / (k * (hy * hy + hz * hz)),
1 / (k * (hx * hx + hz * hz)),
1 / (k * (hx * hx + hy * hy)),
);
}
function sphereInverseInertia(mass, radius) {
const i = 0.4 * mass * radius * radius;
return new Vector3(1 / i, 1 / i, 1 / i);
}
function capsuleInverseInertia(mass, radius, height) {
const length = height + 2 * radius;
const iy = 0.5 * mass * radius * radius;
const ixz = (mass / 12) * (3 * radius * radius + length * length);
return new Vector3(1 / ixz, 1 / iy, 1 / ixz);
}
// ─── §3 Engine bootstrap ────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine));
const physics = new PhysicsSystem();
config.addSystem(physics);
config.addSystem(new ColliderObserverSystem(physics));
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 2048,
focus: new Vector3(0, 2.5, 0),
distance: 26,
pitch: 0.42,
yaw: 0.7,
cameraFarDistance: 200,
showFps: false,
});
const ecd = engine.entityManager.dataset;
const physics = engine.entityManager.getSystem(PhysicsSystem);
// entity → its mesh material, for the hover / held highlight (§9). Every draggable
// body (ragdoll part or crate) gets its own material instance so it can glow alone.
const materialOf = new Map();
// Crate bodies + their spawn poses, so Reset (§7) can stand them back in the corners.
const crates = [];
// ─── §5 Ground + sphere + crates ────────────────────────────────────────────
{
// Ground — top face at y = 0. ~20% bigger than before for more room to play.
// Thick enough to fully bury the sphere's lower half (radius 4).
const t = new Transform();
t.position.set(0, -2.5, 0);
const b = new RigidBody();
b.kind = BodyKind.Static;
const c = new Collider();
c.shape = BoxShape3D.from(21.6, 2.5, 19.2);
c.friction = 0.6;
new Entity().add(t).add(b).add(c)
.add(ShadedGeometry.from(new THREE.BoxGeometry(43.2, 5, 38.4),
new THREE.MeshStandardMaterial({ color: 0x1a2028, roughness: 1 })))
.build(ecd);
}
{
// The landing sphere (radius 4), half-sunken into the ground so only the top
// hemisphere (a dome flush with the floor) protrudes under the waist.
const t = new Transform();
t.position.set(0, 0, 0); // centre at the ground surface (y = 0)
const b = new RigidBody();
b.kind = BodyKind.Static;
const c = new Collider();
c.shape = SphereShape3D.from(4); // r = 4
c.friction = 0.5;
new Entity().add(t).add(b).add(c)
.add(ShadedGeometry.from(new THREE.SphereGeometry(4, 40, 28),
new THREE.MeshStandardMaterial({ color: ENV, roughness: 0.85, metalness: 0.1 })))
.build(ecd);
}
// Four crates near the platform corners — heavy, dynamic boxes you can shove the
// ragdoll into or drag around themselves. Each gets its own material (highlight).
{
const half = CRATE_SIZE / 2;
const geometry = new THREE.BoxGeometry(CRATE_SIZE, CRATE_SIZE, CRATE_SIZE);
const invInertia = boxInverseInertia(CRATE_MASS, half, half, half);
let i = 0;
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
const t = new Transform();
t.position.set(sx * CRATE_INSET, half + 0.02, sz * CRATE_INSET); // resting on the floor
const b = new RigidBody();
b.kind = BodyKind.Dynamic;
b.mass = CRATE_MASS;
b.inverseInertiaLocal.copy(invInertia);
b.linearDamping = 0.05;
b.angularDamping = 0.1;
const c = new Collider();
c.shape = BoxShape3D.from(half, half, half);
c.friction = 0.6;
const material = new THREE.MeshStandardMaterial({
color: CRATE_COLORS[i % CRATE_COLORS.length], roughness: 0.85, metalness: 0.02,
});
const entity = new Entity().add(t).add(b).add(c)
.add(ShadedGeometry.from(geometry, material))
.build(ecd);
materialOf.set(entity, material);
crates.push({ transform: t, body: b, x: t.position.x, y: t.position.y, z: t.position.z });
i++;
}
}
}
// ─── §6 Build the ragdoll ───────────────────────────────────────────────────
const skinMaterial = new THREE.MeshStandardMaterial({ color: SKIN, roughness: 0.55, metalness: 0.1 });
// name → { entity, transform, body, home: {p:Vector3-like, q:quat} }
const parts = new Map();
const scratch = new THREE.Vector3();
function buildParts() {
for (const def of PARTS) {
// A part's own rest spin (the arms turn out to the sides; everything else
// is identity), then the whole figure is laid horizontal for the drop —
// one rigid rotation that preserves every joint anchor's coincidence.
const partRest = def.restZ ? new THREE.Quaternion().setFromAxisAngle(Z_AXIS, def.restZ) : new THREE.Quaternion();
const worldRot = LAY_DOWN.clone().multiply(partRest);
scratch.set(def.pos[0], def.pos[1], def.pos[2]).applyQuaternion(LAY_DOWN);
const px = scratch.x + FORWARD.x, py = scratch.y + SPAWN_Y + FORWARD.y, pz = scratch.z + FORWARD.z;
const transform = new Transform();
transform.position.set(px, py, pz);
transform.rotation.set(worldRot.x, worldRot.y, worldRot.z, worldRot.w);
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = def.mass;
body.angularDamping = 0.2; // was 0.12 — bleed residual spin so the drape settles instead of buzzing
body.linearDamping = 0.04; // was 0.02
const collider = new Collider();
collider.friction = 0.5;
let geometry;
if (def.shape === "sphere") {
collider.shape = SphereShape3D.from(def.dims);
body.inverseInertiaLocal.copy(sphereInverseInertia(def.mass, def.dims));
geometry = new THREE.SphereGeometry(def.dims, 20, 14);
} else if (def.shape === "capsule") {
const [r, h] = def.dims;
collider.shape = CapsuleShape3D.from(r, h);
body.inverseInertiaLocal.copy(capsuleInverseInertia(def.mass, r, h));
geometry = new CapsuleGeometry(r, h, 8, 14);
} else {
const [hx, hy, hz] = def.dims;
collider.shape = BoxShape3D.from(hx, hy, hz);
body.inverseInertiaLocal.copy(boxInverseInertia(def.mass, hx, hy, hz));
geometry = new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2);
}
// Own material instance per part, so a single grabbed part can glow (§9).
const material = skinMaterial.clone();
const entity = new Entity()
.add(transform)
.add(body)
.add(collider)
.add(ShadedGeometry.from(geometry, material))
.build(ecd);
materialOf.set(entity, material);
parts.set(def.name, {
entity, transform, body, rest: partRest,
home: { x: px, y: py, z: pz, qx: worldRot.x, qy: worldRot.y, qz: worldRot.z, qw: worldRot.w },
});
}
}
// Pairs of parts joined directly by a joint — excluded from self-collision so
// the joint and a contact don't fight at the shared anchor. Every OTHER part
// pair collides, which is what stops the limbs from passing through the body.
const jointedPairs = new Set();
const pairKey = (a, b) => (a < b ? a * 100000 + b : b * 100000 + a);
function buildJoints() {
const basisA = new THREE.Quaternion(), basisB = new THREE.Quaternion();
for (const [, aName, bName, aAnchor, bAnchor, lim0, lim1, bone, kind] of JOINTS) {
const a = parts.get(aName), b = parts.get(bName);
const joint = new Joint();
joint.entityA = a.entity;
joint.entityB = b.entity;
joint.localAnchorA.set(aAnchor[0], aAnchor[1], aAnchor[2]);
joint.localAnchorB.set(bAnchor[0], bAnchor[1], bAnchor[2]);
// Aim the joint's frame X along the bone, undoing each body's own rest
// spin: basis = rest⁻¹ · (frame-X → bone). With this, both bodies' joint
// frames coincide at rest, so the limit is centred there. Frame Y and Z
// are then the two axes perpendicular to the bone.
const boneTurn = bone === "X" ? BONE_TO_X : BONE_TO_Y;
basisA.copy(a.rest).invert().multiply(boneTurn);
basisB.copy(b.rest).invert().multiply(boneTurn);
joint.localBasisA.set(basisA.x, basisA.y, basisA.z, basisA.w);
joint.localBasisB.set(basisB.x, basisB.y, basisB.z, basisB.w);
if (kind === "hinge") {
// Knees and elbows are hinges, not cones: they bend in one plane.
// Free frame Y only — for both the legs (frame Y = the body's
// left-right axis) and the spread arms (frame Y = vertical), that is
// the limb's natural flex axis. asHinge(1) locks the other five DOFs
// (all linear, the twist on frame X, and the out-of-plane swing on
// frame Z), so the lateral wobble the symmetric cone allowed is gone.
// The bend range is asymmetric (lim0..lim1) so the limb flexes one
// way and barely hyperextends — see the per-limb signs above.
const lower = lim0, upper = lim1;
joint.asHinge(1);
joint.setAngularLimit(1, lower, upper);
} else if (kind === "spring") {
// Soft trunk: a ball-socket point (linear DOFs stay locked) with all
// three angular DOFs as damped torsional springs that pull the
// segment back toward straight. No hard stop to fight contacts, so
// the curl is smooth and self-damping; frame X is the spine, so the
// stiffer twist gain resists axial rotation more than the swings.
joint.setAngularSpring(0, SPINE_K_TWIST, SPINE_C); // twist (about spine)
joint.setAngularSpring(1, SPINE_K_SWING, SPINE_C); // swing
joint.setAngularSpring(2, SPINE_K_SWING, SPINE_C); // swing
} else {
const swing = lim0, twist = lim1;
joint.asConeTwist(-twist, twist, swing);
}
physics.link_joint(joint);
jointedPairs.add(pairKey(a.entity, b.entity));
}
}
buildParts();
buildJoints();
// Self-collision on, except for directly-jointed neighbours.
physics.setContactFilter((entityA, entityB) => !jointedPairs.has(pairKey(entityA, entityB)));
// ─── §7 Reset control ───────────────────────────────────────────────────────
// Reset the whole scene: drop the figure back to its laid-down pose (which
// satisfies the joints exactly, so there's no pop) with a little random spin, and
// stand the crates back up in their corners. Zero every velocity and wake them.
// No auto-loop — the figure drops once on load, and re-drops only on Reset.
function resetScene() {
endDrag(); // release anything held so it falls cleanly from the fresh pose
for (const { transform, body, home } of parts.values()) {
transform.position.set(home.x, home.y, home.z);
transform.rotation.set(home.qx, home.qy, home.qz, home.qw);
body.linearVelocity.set(0, 0, 0);
body.angularVelocity.set(
randomFloatBetween(Math.random, -1, 1),
randomFloatBetween(Math.random, -1, 1),
randomFloatBetween(Math.random, -1, 1),
);
physics.wake(body);
}
for (const { transform, body, x, y, z } of crates) {
transform.position.set(x, y, z);
transform.rotation.set(0, 0, 0, 1);
body.linearVelocity.set(0, 0, 0);
body.angularVelocity.set(0, 0, 0);
physics.wake(body);
}
}
document.getElementById("bodies").textContent = String(PARTS.length);
document.getElementById("joints").textContent = String(JOINTS.length);
const resetButton = document.getElementById("reset");
if (resetButton) resetButton.addEventListener("click", resetScene);
// ─── §8 Raycast picking + spring drag ───────────────────────────────────────
//
// Press on any dynamic part to grab it: turn the cursor into a world ray, raycast
// the physics world, and attach a mass-scaled spring `Joint` (entityB = world)
// between the hit point on the body and a world target we move to follow the
// cursor — so the part is hauled toward the pointer while still free to swing.
// Release unlinks the joint. The orbit camera would otherwise spin while we drag,
// so on grab we FREEZE it (snapshot its pose + disable its system) and restore it
// untouched on release — clicking empty space still orbits / scroll still zooms.
const cameraController = ecd.getAnyComponent(TopDownCameraController).component;
const cameraSystem = engine.entityManager.getSystem(TopDownCameraControllerSystem);
let cameraSnapshot = null;
function freezeCamera() {
if (cameraController === null || cameraSystem === null) return;
const c = cameraController;
cameraSnapshot = { yaw: c.yaw, pitch: c.pitch, roll: c.roll, distance: c.distance,
tx: c.target.x, ty: c.target.y, tz: c.target.z };
cameraSystem.enabled.set(false); // stop applying orbit input to the camera transform
}
function unfreezeCamera() {
if (cameraController === null || cameraSystem === null || cameraSnapshot === null) return;
const c = cameraController, s = cameraSnapshot;
// Restore the pre-grab pose, discarding any orbit input that accrued mid-drag.
c.yaw = s.yaw; c.pitch = s.pitch; c.roll = s.roll; c.distance = s.distance;
c.target.set(s.tx, s.ty, s.tz);
cameraSystem.enabled.set(true);
cameraSnapshot = null;
}
const ray = new Ray3();
const hit = new PhysicsSurfacePoint();
const ndc = new Vector2();
const raySource = new THREE.Vector3();
const rayDir = new THREE.Vector3();
const scratchQuat = new THREE.Quaternion();
const scratchVec = new THREE.Vector3();
// Cast the camera ray through a viewport-pixel position; returns the nearest hit
// (filling `hit`) or null.
function raycastAt(pixelPosition) {
engine.graphics.normalizeViewportPoint(pixelPosition, ndc);
engine.graphics.viewportProjectionRay(ndc.x, ndc.y, raySource, rayDir);
if (!Number.isFinite(rayDir.x)) return null; // camera not ready (zero-size canvas mid-load)
ray[0] = raySource.x; ray[1] = raySource.y; ray[2] = raySource.z;
ray[3] = rayDir.x; ray[4] = rayDir.y; ray[5] = rayDir.z;
ray[6] = PICK_MAX_DIST;
return physics.raycast(ray, hit) ? hit : null;
}
const isDynamic = (entity) => {
const body = ecd.getComponent(entity, RigidBody);
return body !== null && body.kind === BodyKind.Dynamic;
};
let dragJoint = null;
let draggedEntity = -1;
let draggedBody = null;
let dragDepth = 0; // camera→grab-point distance, held constant so the part tracks in the view plane
function beginDrag(entity, worldPoint, depth) {
const body = ecd.getComponent(entity, RigidBody);
const transform = ecd.getComponent(entity, Transform);
// World grab point → the body's local frame (anchorA). Bodies carry no scale.
const r = transform.rotation, p = transform.position;
scratchQuat.set(r.x, r.y, r.z, r.w).invert();
scratchVec.set(worldPoint.x - p.x, worldPoint.y - p.y, worldPoint.z - p.z).applyQuaternion(scratchQuat);
// Spring sized to the grabbed mass, floored at DRAG_GRIP_MASS so a light part
// (a hand) still pulls hard enough to haul the whole figure: k = m·ω², c = 2·ζ·m·ω.
const m = Math.max(body.mass, DRAG_GRIP_MASS);
const k = m * DRAG_FREQ * DRAG_FREQ;
const damp = 2 * DRAG_DAMPING_RATIO * m * DRAG_FREQ;
const joint = new Joint();
joint.entityA = entity;
joint.entityB = JOINT_WORLD;
joint.localAnchorA.set(scratchVec.x, scratchVec.y, scratchVec.z);
joint.localAnchorB.set(worldPoint.x, worldPoint.y, worldPoint.z);
joint.setLinearSpring(0, k, damp);
joint.setLinearSpring(1, k, damp);
joint.setLinearSpring(2, k, damp);
physics.link_joint(joint);
physics.wake(body);
dragJoint = joint;
draggedEntity = entity;
draggedBody = body;
dragDepth = depth;
freezeCamera();
}
function endDrag() {
if (dragJoint === null) return;
physics.unlink_joint(dragJoint);
dragJoint = null;
draggedEntity = -1;
draggedBody = null;
unfreezeCamera();
}
engine.devices.pointer.on.down.add((position) => {
const result = raycastAt(position);
if (result !== null && isDynamic(result.entity)) {
beginDrag(result.entity, result.position, result.t);
}
});
engine.devices.pointer.on.up.add(() => endDrag());
// ─── §9 Per-frame: drag target, cursor, HUD ─────────────────────────────────
const fpsEl = document.getElementById("fps");
const canvas = engine.graphics.domElement;
let fpsWindow = 0, fpsFrames = 0;
let lastFrameMs = performance.now();
// Hover / held highlight — light the active body's material emissive, clear the
// rest. `intensity` undefined ⇒ a hover (skip re-set if unchanged); a number ⇒ a
// held glow (re-applied each frame). Bodies without a registered material (the
// static ground / sphere) just no-op.
let highlighted = -1;
function highlight(entity, intensity) {
if (highlighted === entity && intensity === undefined) return;
if (highlighted !== -1 && highlighted !== entity) {
const prev = materialOf.get(highlighted);
if (prev) prev.emissive.setHex(0x000000);
}
highlighted = entity;
if (entity !== -1) {
const mat = materialOf.get(entity);
if (mat) { mat.emissive.setHex(HILITE); mat.emissiveIntensity = intensity ?? 0.4; }
}
}
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = (nowMs - lastFrameMs) / 1000;
lastFrameMs = nowMs;
const pointer = engine.devices.pointer.position;
if (dragJoint !== null) {
// Move the spring's world target to the cursor at the fixed grab depth so
// the part tracks the pointer in the view plane; keep the body awake.
engine.graphics.normalizeViewportPoint(pointer, ndc);
engine.graphics.viewportProjectionRay(ndc.x, ndc.y, raySource, rayDir);
if (Number.isFinite(rayDir.x)) {
dragJoint.localAnchorB.set(
raySource.x + rayDir.x * dragDepth,
raySource.y + rayDir.y * dragDepth,
raySource.z + rayDir.z * dragDepth,
);
}
physics.wake(draggedBody);
highlight(draggedEntity, 0.7); // brighter while held
canvas.style.cursor = "grabbing";
} else {
// Hover affordance: highlight + grab cursor over any draggable part.
const result = raycastAt(pointer);
const over = (result !== null && isDynamic(result.entity)) ? result.entity : -1;
highlight(over);
canvas.style.cursor = over !== -1 ? "grab" : "default";
}
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
if (fpsEl) fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});