// Wrecking ball — a Meep example for raycast picking + interactive dragging.
//
// A heavy ball hangs from a jointed chain off a block in the sky, over a platform
// with four crates near the corners. The whole point is the mouse: click and drag
// any *dynamic* body. On press we turn the cursor into a world ray with the
// graphics engine's own projection, raycast it against the physics world, and if
// it hits a non-static body we attach a spring `Joint` between the hit point and a
// target that follows the cursor — so the body is hauled toward the pointer and
// you can fling the wrecking ball through the crates.
//
// Sections:
// §1 Tuning constants
// §2 Helpers — inertia, body builder
// §3 Engine bootstrap
// §4 Camera + lights fixed framing (no orbit — the mouse drags bodies)
// §5 Platform + anchor block
// §6 The wrecking ball chain links + ball-socket joints + the ball
// §7 Four crates
// §8 Raycast picking + spring drag
// §9 Per-frame: drag, hover highlight, HUD
//
// ─── Raycast + drag in one paragraph ─────────────────────────────────────────
//
// `engine.graphics.viewportProjectionRay(ndcX, ndcY, src, dir)` builds a world
// ray through a clip-space point; `physics.raycast(ray, result, filter)` returns
// the nearest collider hit (entity, world point, distance). To drag, we make a
// `Joint` with `entityB = JOINT_WORLD`: `localAnchorA` is the hit point in the
// body's frame, `localAnchorB` is a world target we move to follow the cursor,
// and the three linear DOFs are `setLinearSpring`s so the body is pulled there
// compliantly while still free to swing. Release unlinks the joint.
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 Vector2 from "@woosh/meep-engine/src/core/geom/Vector2.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
// ─── §1 Tuning constants ────────────────────────────────────────────────────
const PLATFORM_HALF = [9, 0.5, 9]; // half-extents; top sits at y = 0
const ANCHOR = [0, 11, 0]; // the block in the sky the chain hangs off
const HANG_Y = ANCHOR[1] - 0.5; // chain's fixed world hang point (block underside)
// Chain — capsule links wired with ball-socket joints, like the `chains` example.
const LINKS = 12;
const SEG = 0.6; // centre-to-centre link spacing = joint anchor offset
const LINK_RADIUS = 0.16;
const LINK_HEIGHT = SEG - 2 * LINK_RADIUS;
const LINK_MASS = 1.0; // not too light vs the ball, or the chain stretches
const LINK_BEND = 0.5; // max swing per joint, rad — keeps the chain from folding
// The ball. Unit sphere (the narrowphase sphere path is closed-form at r = 1).
const BALL_RADIUS = 1;
const BALL_MASS = 16;
// Four crates, inset from the platform corners.
const BOX_SIZE = 1.2;
const BOX_MASS = 4;
const BOX_INSET = 7; // crate centres at (±INSET, _, ±INSET)
// Drag spring. We size stiffness/damping to the picked body's mass so the response
// (a ~DRAG_FREQ rad/s, near-critically-damped pull) feels the same for a light
// link and the heavy ball.
const DRAG_FREQ = 9; // natural frequency, rad/s
const DRAG_DAMPING_RATIO = 1.0; // 1 = critically damped (no overshoot)
const PICK_MAX_DIST = 300; // ray length for picking
const HILITE = 0x4ef0a8; // Meep brand green — hover / held glow
const PLATFORM_COLOR = 0x161c24;
const STRUCT_COLOR = 0x2a313b;
const LINK_COLOR = 0x9aa6b2; // brushed steel
const BALL_COLOR = 0x586573; // dark iron
const BOX_COLORS = [0x8a4b3a, 0x9c5a44, 0xa9654c, 0x7c4636]; // terracotta, like cube-wall
// ─── §2 Helpers — inertia + a dynamic-body builder ──────────────────────────
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 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);
}
const sphereInverseInertia = (mass, radius) => 1 / (0.4 * mass * radius * radius);
// ─── §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));
},
});
// ─── §4 Camera + lights ─────────────────────────────────────────────────────
//
// A fixed 3/4 framing. We drop the orbit controller (`cameraController: false`)
// precisely so the mouse-drag is free to grab bodies instead of spinning the
// camera. With nothing driving the camera Transform it just stays where buildBasics
// put it — and `viewportProjectionRay` reads that same camera for picking.
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 2048,
focus: new Vector3(0, 4, 0),
distance: 27,
pitch: 0.5,
yaw: 0.6,
cameraFarDistance: 200,
cameraController: false,
showFps: false,
});
const ecd = engine.entityManager.dataset;
const physics = engine.entityManager.getSystem(PhysicsSystem);
// entity → its mesh material, for the hover / held highlight (§9).
const materialOf = new Map();
// Build a dynamic body (Transform + RigidBody + Collider + mesh) and register its
// material so it can be highlighted on hover.
function dynamicBody(transform, body, shape, friction, restitution, geometry, material) {
const collider = new Collider();
collider.shape = shape;
collider.friction = friction;
collider.restitution = restitution;
const entity = new Entity()
.add(transform)
.add(body)
.add(collider)
.add(ShadedGeometry.from(geometry, material))
.build(ecd);
materialOf.set(entity, material);
return entity;
}
// ─── §5 Platform + anchor block ─────────────────────────────────────────────
// Platform — a static box; everything rests on / collides with it.
{
const [hx, hy, hz] = PLATFORM_HALF;
const t = new Transform();
t.position.set(0, -hy, 0); // top face at y = 0
const body = new RigidBody();
body.kind = BodyKind.Static;
const collider = new Collider();
collider.shape = BoxShape3D.from(hx, hy, hz);
collider.friction = 0.8;
new Entity()
.add(t)
.add(body)
.add(collider)
.add(ShadedGeometry.from(
new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2),
new THREE.MeshStandardMaterial({ color: PLATFORM_COLOR, roughness: 1, metalness: 0 }),
))
.build(ecd);
}
// The anchor block — purely decorative (Transform + mesh, no body), like the beam
// in the `chains` example. The chain hangs from a fixed world point just under it.
{
const t = new Transform();
t.position.set(ANCHOR[0], ANCHOR[1], ANCHOR[2]);
new Entity()
.add(t)
.add(ShadedGeometry.from(
new THREE.BoxGeometry(2.4, 1.0, 2.4),
new THREE.MeshStandardMaterial({ color: STRUCT_COLOR, roughness: 0.8, metalness: 0.2 }),
))
.build(ecd);
}
// ─── §6 The wrecking ball ───────────────────────────────────────────────────
const linkGeometry = new CapsuleGeometry(LINK_RADIUS, LINK_HEIGHT, 6, 12);
const linkShape = CapsuleShape3D.from(LINK_RADIUS, LINK_HEIGHT);
const linkInvInertia = capsuleInverseInertia(LINK_MASS, LINK_RADIUS, LINK_HEIGHT);
const linkMaterial = new THREE.MeshStandardMaterial({ color: LINK_COLOR, roughness: 0.4, metalness: 0.7 });
// entity → link index, so the contact filter can spot jointed neighbours.
const linkIndex = new Map();
const links = [];
for (let i = 0; i < LINKS; i++) {
const transform = new Transform();
transform.position.set(0, HANG_Y - SEG / 2 - i * SEG, 0);
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = LINK_MASS;
body.inverseInertiaLocal.copy(linkInvInertia);
body.linearDamping = 0.05;
body.angularDamping = 0.1;
const entity = dynamicBody(transform, body, linkShape, 0.4, 0, linkGeometry, linkMaterial.clone());
links.push(entity);
linkIndex.set(entity, i);
}
// The ball at the bottom of the chain.
const ballTransform = new Transform();
ballTransform.position.set(0, HANG_Y - LINKS * SEG - BALL_RADIUS, 0);
const ballBody = new RigidBody();
ballBody.kind = BodyKind.Dynamic;
ballBody.mass = BALL_MASS;
const ballInvI = sphereInverseInertia(BALL_MASS, BALL_RADIUS);
ballBody.inverseInertiaLocal.set(ballInvI, ballInvI, ballInvI);
ballBody.linearDamping = 0.04;
ballBody.angularDamping = 0.1;
const ball = dynamicBody(
ballTransform, ballBody, SphereShape3D.from(BALL_RADIUS), 0.6, 0.1,
new THREE.SphereGeometry(BALL_RADIUS, 28, 20),
new THREE.MeshStandardMaterial({ color: BALL_COLOR, roughness: 0.45, metalness: 0.6 }),
);
// — Joints — top link to the world hang point, each link to the next, ball to the
// last link. Default Joint = ball-socket (linear locked, angular free).
{
const anchor = new Joint();
anchor.entityA = links[0];
anchor.entityB = JOINT_WORLD;
anchor.localAnchorA.set(0, SEG / 2, 0);
anchor.localAnchorB.set(0, HANG_Y, 0);
physics.link_joint(anchor);
for (let i = 0; i < LINKS - 1; i++) {
const joint = new Joint();
joint.entityA = links[i];
joint.entityB = links[i + 1];
joint.localAnchorA.set(0, -SEG / 2, 0);
joint.localAnchorB.set(0, SEG / 2, 0);
joint.setAngularLimit(0, -LINK_BEND, LINK_BEND); // bending stiffness (X, Z); Y free twist
joint.setAngularLimit(2, -LINK_BEND, LINK_BEND);
physics.link_joint(joint);
}
const ballJoint = new Joint();
ballJoint.entityA = links[LINKS - 1];
ballJoint.entityB = ball;
ballJoint.localAnchorA.set(0, -SEG / 2, 0);
ballJoint.localAnchorB.set(0, BALL_RADIUS, 0); // top of the ball
physics.link_joint(ballJoint);
}
// Don't collide jointed neighbours (adjacent links, and the ball with the last
// link): they touch at their shared anchor by construction, so colliding them
// just fights the joint and jitters. Everything else collides normally.
physics.setContactFilter((entityA, entityB) => {
const ia = linkIndex.get(entityA);
const ib = linkIndex.get(entityB);
if (ia !== undefined && ib !== undefined && Math.abs(ia - ib) === 1) return false; // adjacent links
const lastLink = links[LINKS - 1];
if ((entityA === ball && entityB === lastLink) || (entityB === ball && entityA === lastLink)) return false;
return true;
});
// ─── §7 Four crates ─────────────────────────────────────────────────────────
const boxGeometry = new THREE.BoxGeometry(BOX_SIZE, BOX_SIZE, BOX_SIZE);
const boxShape = BoxShape3D.from(BOX_SIZE / 2, BOX_SIZE / 2, BOX_SIZE / 2);
const boxInvInertia = boxInverseInertia(BOX_MASS, BOX_SIZE / 2, BOX_SIZE / 2, BOX_SIZE / 2);
const boxes = [];
let c = 0;
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
const t = new Transform();
t.position.set(sx * BOX_INSET, BOX_SIZE / 2 + 0.02, sz * BOX_INSET);
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = BOX_MASS;
body.inverseInertiaLocal.copy(boxInvInertia);
body.linearDamping = 0.05;
body.angularDamping = 0.1;
boxes.push(dynamicBody(
t, body, boxShape, 0.6, 0, boxGeometry,
new THREE.MeshStandardMaterial({ color: BOX_COLORS[c % BOX_COLORS.length], roughness: 0.85, metalness: 0.02 }),
));
c++;
}
}
// ─── §8 Raycast picking + spring drag ───────────────────────────────────────
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
// or null. Fills `hit` on success.
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 yet (e.g. zero-size canvas mid-load)
// Ray3 is array-backed: [ox,oy,oz, dx,dy,dz, tMax] (see Ray3.from).
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;
};
// Active drag state.
let dragJoint = null;
let draggedEntity = -1;
let draggedBody = null;
let dragDepth = 0; // distance from the camera to the grab point — held constant
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). No scale on bodies.
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 body's mass: k = m·ω², c = 2·ζ·m·ω.
const k = body.mass * DRAG_FREQ * DRAG_FREQ;
const damp = 2 * DRAG_DAMPING_RATIO * body.mass * 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;
}
function endDrag() {
if (dragJoint !== null) physics.unlink_joint(dragJoint);
dragJoint = null;
draggedEntity = -1;
draggedBody = null;
}
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, hover highlight, HUD ────────────────────────
const fpsEl = document.getElementById("fps");
const bodiesEl = document.getElementById("bodies");
const holdingEl = document.getElementById("holding");
const canvas = engine.graphics.domElement;
// Highlight bookkeeping — set the hovered/held body's material emissive, restore
// the rest. Each draggable body has its own material instance (cloned in §6/§7).
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; }
}
}
function labelFor(entity) {
if (entity === ball) return "ball";
if (linkIndex.has(entity)) return "chain link";
return "crate";
}
let fpsWindow = 0, fpsFrames = 0;
let lastFrameMs = performance.now();
if (bodiesEl) bodiesEl.textContent = String(materialOf.size);
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, kept at the grab depth so
// the body tracks the pointer in the view plane. Keep it awake so the
// spring keeps acting even if it would otherwise settle.
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);
canvas.style.cursor = "grabbing";
if (holdingEl) holdingEl.textContent = labelFor(draggedEntity);
} else {
// Not dragging: hover-test under the cursor for affordance.
const result = raycastAt(pointer);
const over = result !== null && isDynamic(result.entity) ? result.entity : -1;
highlight(over);
canvas.style.cursor = over !== -1 ? "grab" : "default";
if (holdingEl) holdingEl.textContent = "—";
}
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
if (fpsEl) fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wrecking ball · Meep</title>
<meta name="robots" content="noindex">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #07090c;
color: #e6edf3;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
/* the canvas shows a grab cursor over draggable bodies (set from JS) */
canvas { cursor: default; }
.panel {
position: fixed;
z-index: 100;
background: rgba(7, 9, 12, 0.72);
border: 1px solid #1f2731;
border-radius: 10px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
}
.hud {
top: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.82rem;
line-height: 1.7;
color: #9aa5b1;
min-width: 190px;
}
.hud .label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-right: 0.5rem;
}
.hud .value { color: #4ef0a8; }
.legend {
bottom: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-size: 0.82rem;
line-height: 1.55;
max-width: 440px;
color: #9aa5b1;
}
.legend strong { color: #e6edf3; }
.legend code {
color: #4ef0a8;
background: rgba(78,240,168,0.08);
padding: 0.05rem 0.3rem;
border-radius: 4px;
font-size: 0.78rem;
}
</style>
</head>
<body>
<div class="panel hud">
<div><span class="label">fps</span><span class="value" id="fps">--</span></div>
<div><span class="label">bodies</span><span class="value" id="bodies">--</span></div>
<div><span class="label">holding</span><span class="value" id="holding">—</span></div>
</div>
<div class="panel legend">
<strong>Click and drag</strong> any object — the ball, a chain link, or a crate.
A camera-ray <code>raycast</code> picks whatever is under the cursor, and a
spring <code>Joint</code> drags it toward the pointer. Fling the wrecking ball
into the crates. (Static bodies — the platform — can't be grabbed.)
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
# wrecking-ball
A heavy ball hangs from a jointed chain off a block in the sky, over a platform
with four crates near the corners. The whole point is the **mouse**: click and
drag any *dynamic* body. On press the cursor is turned into a world ray and cast
against the physics world; if it hits a non-static body, a spring `Joint` is
attached between the grab point and a target that follows the pointer — so the
body is hauled toward the cursor and you can fling the wrecking ball through the
crates. The static platform can't be grabbed.
Controls: **click and drag** any object — the ball, a chain link, or a crate.
Release to let go (the wrecking ball keeps swinging). Hover to see what's
grabbable.
## Run locally
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```
Output goes to `../../public/examples/wrecking-ball/demo.html`.
## What this demonstrates
- **Raycast picking** — `engine.graphics.viewportProjectionRay(ndcX, ndcY, src,
dir)` turns a clip-space cursor position into a world ray (camera origin + unit
direction), and `physics.raycast(ray, result, filter)` walks the broadphase
BVHs and refines against each candidate's true shape, filling `result` with the
nearest hit's `entity`, world `position`, and distance `t`. We pick whatever is
directly under the cursor and only grab it if its `RigidBody` is `Dynamic` — so
the static platform is never picked up.
- **A spring drag constraint** — dragging is a `Joint` with `entityB =
JOINT_WORLD`. `localAnchorA` is the grab point expressed in the body's local
frame; `localAnchorB` is a world target we move each frame to the cursor ray at
the original grab depth. The three linear DOFs are `setLinearSpring`s (the
angular DOFs stay free), so the body is pulled toward the pointer *compliantly* —
it can still swing and tumble under the pull rather than snapping rigidly. The
spring's stiffness and damping are scaled to the picked body's mass
(`k = m·ω²`, `c = 2ζmω`), so a light chain link and the heavy ball both respond
the same. Releasing calls `physics.unlink_joint`.
- **A jointed chain (the wrecking ball)** — a column of capsule links wired with
default ball-socket `Joint`s (linear DOFs locked, angular free): the top link
hangs from a fixed world point, each link joins the next, and the heavy ball
hangs off the last. A small per-joint angular limit (`setAngularLimit`) gives the
chain bending stiffness so it doesn't fold through itself, and a
`setContactFilter` stops jointed neighbours (and the ball + last link) from
colliding at their shared anchors.
- **Hover affordance** — every frame, when not dragging, a raycast under the
cursor highlights the body you'd grab (its material's emissive) and switches the
canvas cursor to `grab` / `grabbing`. Each draggable body gets its own material
instance so it can be lit individually.
- **A fixed camera, on purpose** — `buildBasics` is called with
`cameraController: false`. The orbit controller also drives a mouse-drag, which
would fight the body-dragging, so we drop it and frame the scene with a fixed
3/4 view. `viewportProjectionRay` reads that same camera for picking.
- **Static vs dynamic bodies** — the platform and the sky block are static (the
block is pure decoration, no body at all); the chain links, the ball, and the
crates are dynamic. Only dynamic bodies are grabbable, settle under gravity, and
respond to the wrecking ball.
import { defineConfig } from "vite";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
{
// Copy the committed source thumbnail into the generated gallery folder.
// public/examples/<id>/ is build output (gitignored); thumbnail.png is
// kept in source here and copied through on every build so the gallery
// (src/data/examples.ts) can resolve it.
name: "copy-thumbnail",
apply: "build",
closeBundle() {
const thumb = resolve(__dirname, "thumbnail.png");
const dst = resolve(__dirname, "../../public/examples/wrecking-ball");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/wrecking-ball"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});