// A capsule, built as a Shade `Geometry`.
//
// meep 3 ships seven primitive generators (box, plane, polyhedron, octahedron,
// torus, cylinder, torus knot) and a capsule is not one of them, so the demos
// that draw capsule-shaped bodies carry this local builder. Same convention as
// the physics `CapsuleShape3D` it usually accompanies: `radius` is the cap
// radius, `height` is the length of the cylindrical middle (so the total tip to
// tip length is `height + 2 * radius`), and the axis is Y.
import { Attribute } from "@woosh/meep-engine/src/shade/renderer/geometry/Attribute.js";
import { Geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/Geometry.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
/**
* @param {number} [radius] cap radius
* @param {number} [height] length of the cylindrical middle section
* @param {number} [capSegments] rings per hemisphere
* @param {number} [radialSegments] segments around the axis
* @returns {Geometry}
*/
export function make_capsule_geometry(radius = 0.5, height = 1, capSegments = 8, radialSegments = 16) {
const positions = [];
const normals = [];
const uvs = [];
const indices = [];
const halfHeight = height / 2;
// Rings from the top pole to the bottom pole. The two hemispheres are swept
// by polar angle; the middle is a single pair of rings offset along Y, so
// the seam between cap and cylinder shares its vertices' normals.
const rings = [];
for (let i = 0; i <= capSegments; i++) {
const v = (i / capSegments) * (Math.PI / 2); // 0 at the pole, PI/2 at the equator
rings.push({ y: halfHeight + Math.cos(v) * radius, r: Math.sin(v) * radius, ny: Math.cos(v), offset: halfHeight });
}
for (let i = 0; i <= capSegments; i++) {
const v = (Math.PI / 2) + (i / capSegments) * (Math.PI / 2);
rings.push({ y: -halfHeight + Math.cos(v) * radius, r: Math.sin(v) * radius, ny: Math.cos(v), offset: -halfHeight });
}
const totalLength = height + 2 * radius;
for (let ri = 0; ri < rings.length; ri++) {
const ring = rings[ri];
for (let s = 0; s <= radialSegments; s++) {
const u = s / radialSegments;
const theta = u * Math.PI * 2;
const cos = Math.cos(theta);
const sin = Math.sin(theta);
positions.push(ring.r * cos, ring.y, ring.r * sin);
// The normal of a capsule is the normal of the sphere its point
// belongs to, which is the point taken relative to that cap's centre.
const nx = ring.r * cos;
const nz = ring.r * sin;
const ny = ring.ny * radius;
const len = Math.hypot(nx, ny, nz) || 1;
normals.push(nx / len, ny / len, nz / len);
uvs.push(u, (ring.y + totalLength / 2) / totalLength);
}
}
const perRing = radialSegments + 1;
for (let ri = 0; ri < rings.length - 1; ri++) {
for (let s = 0; s < radialSegments; s++) {
const a = ri * perRing + s;
const b = a + 1;
const c = a + perRing;
const d = c + 1;
indices.push(a, c, b);
indices.push(b, c, d);
}
}
const geometry = new Geometry();
geometry.name = "Capsule";
geometry.index = Attribute.from(new Uint32Array(indices), 1, StandardAttributes.Index);
geometry.addAttribute(Attribute.from(new Float32Array(positions), 3, StandardAttributes.Position));
geometry.addAttribute(Attribute.from(new Float32Array(normals), 3, StandardAttributes.Normal));
geometry.addAttribute(Attribute.from(new Float32Array(uvs), 2, StandardAttributes.TextureCoordinates0));
return geometry;
}
// 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, materials, 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.
//
// This is the *physics* raycast, not a renderer one: it walks the physics
// broadphase BVHs and refines each crossing leaf against the collider's true
// shape, so it answers with a body and an exact surface point. (Shade has its
// own `PickingSystem` for picking what is *drawn*; here we want the body to
// attach a constraint to, so the physics query is the direct answer.)
import { v3_set } from "@woosh/meep-engine/src/core/geom/vec3/v3_set.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import {
shaded_geometry_announce_change
} from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/shaded_geometry_announce_change.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
import { make_octahedron_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_octahedron_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
// Shade ships seven primitive generators and a capsule is not one of them, so
// the chain links carry a local builder (src/lib/make_capsule_geometry.js).
import { make_capsule_geometry } from "./lib/make_capsule_geometry.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";
import Quaternion from "@woosh/meep-engine/src/core/geom/Quaternion.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 = "#4ef0a8"; // Meep brand green — hover / held glow
const PLATFORM_COLOR = "#161c24";
const STRUCT_COLOR = "#2a313b";
const LINK_COLOR = "#9aa6b2"; // brushed steel
const BALL_COLOR = "#586573"; // dark iron
const BOX_COLORS = ["#8a4b3a", "#9c5a44", "#a9654c", "#7c4636"]; // terracotta, like cube-wall
// ─── §2 Helpers — inertia, materials, 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);
// Shade's PBR material. The hex colours above are sRGB (what a designer types)
// and the renderer works in linear light, so decode once here.
function standardMaterial(hexColor, roughness, metalness) {
const material = new StandardShadeMaterial();
material.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(hexColor)));
material.roughness_factor = roughness;
material.metallic_factor = metalness;
return material;
}
// ─── §3 Engine bootstrap ────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine.graphics, EngineHarness.shadeScene(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 Transform64 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,
});
// Exposure and key light, the same in every demo on this site. Shade's automatic
// exposure is off: these scenes are lit to be looked at, not adapted to, and an
// adapting frame makes two screenshots of the same demo disagree. That leaves
// the light doing the work - and a directional light's intensity is read as lux,
// where the harness's default of 0.9 is a value from the three.js era that only
// ever looked lit because auto-exposure opened up to meet it.
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
engine.entityManager.dataset.traverseEntities([Light], (light) => {
if (light.type.getValue() === LightType.DIRECTION) {
light.intensity.set(6);
}
});
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 (Transform64 + 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 Transform64();
t.setTranslation(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(
meshlet_geometry_build_from_geometry(make_box_geometry(hx * 2, hy * 2, hz * 2)),
standardMaterial(PLATFORM_COLOR, 1, 0),
))
.build(ecd);
}
// The anchor block — purely decorative (Transform64 + mesh, no body), like the beam
// in the `chains` example. The chain hangs from a fixed world point just under it.
{
const t = new Transform64();
t.setTranslation(ANCHOR[0], ANCHOR[1], ANCHOR[2]);
new Entity()
.add(t)
.add(ShadedGeometry.from(
meshlet_geometry_build_from_geometry(make_box_geometry(2.4, 1.0, 2.4)),
standardMaterial(STRUCT_COLOR, 0.8, 0.2),
))
.build(ecd);
}
// ─── §6 The wrecking ball ───────────────────────────────────────────────────
//
// Geometry that a `ShadedGeometry` draws has to be a `MeshletGeometry` — the
// clustered GPU form — so every authoring-form primitive goes through
// `meshlet_geometry_build_from_geometry` once, here, and is then shared by every
// entity that draws that shape.
const linkGeometry = meshlet_geometry_build_from_geometry(make_capsule_geometry(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 = standardMaterial(LINK_COLOR, 0.4, 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 Transform64();
transform.setTranslation(0, HANG_Y - SEG / 2 - i * SEG, 0);
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = LINK_MASS;
body.inverseInertiaLocal.set(linkInvInertia);
body.linearDamping = 0.05;
body.angularDamping = 0.1;
// Geometry and material are shared across the links — the highlight in §9
// swaps which material a body is drawn with rather than editing one, so
// sharing costs nothing here.
const entity = dynamicBody(transform, body, linkShape, 0.4, 0, linkGeometry, linkMaterial);
links.push(entity);
linkIndex.set(entity, i);
}
// The ball at the bottom of the chain.
const ballTransform = new Transform64();
ballTransform.setTranslation(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);
v3_set(ballBody.inverseInertiaLocal, 0, ballInvI, ballInvI, ballInvI);
ballBody.linearDamping = 0.04;
ballBody.angularDamping = 0.1;
// Shade has no UV sphere: a subdivided octahedron is the sphere, and detail 4 is
// smooth at this radius.
const ball = dynamicBody(
ballTransform, ballBody, SphereShape3D.from(BALL_RADIUS), 0.6, 0.1,
meshlet_geometry_build_from_geometry(make_octahedron_geometry(BALL_RADIUS, 12)),
standardMaterial(BALL_COLOR, 0.45, 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;
v3_set(anchor.localAnchorA, 0, 0, SEG / 2, 0);
v3_set(anchor.localAnchorB, 0, 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];
v3_set(joint.localAnchorA, 0, 0, -SEG / 2, 0);
v3_set(joint.localAnchorB, 0, 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;
v3_set(ballJoint.localAnchorA, 0, 0, -SEG / 2, 0);
v3_set(ballJoint.localAnchorB, 0, 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 = meshlet_geometry_build_from_geometry(make_box_geometry(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 Transform64();
t.setTranslation(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.set(boxInvInertia);
body.linearDamping = 0.05;
body.angularDamping = 0.1;
boxes.push(dynamicBody(
t, body, boxShape, 0.6, 0, boxGeometry,
standardMaterial(BOX_COLORS[c % BOX_COLORS.length], 0.85, 0.02),
));
c++;
}
}
// ─── §8 Raycast picking + spring drag ───────────────────────────────────────
const ray = new Ray3();
const hit = new PhysicsSurfacePoint();
const ndc = new Vector2();
const raySource = new Vector3();
const rayDir = new Vector3();
const scratchQuat = new Quaternion();
const scratchVec = new 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
// `worldPoint` is the hit's `position`: a three-element Float64Array indexed
// [0][1][2], which is how every point the physics queries fill in is shaped.
function beginDrag(entity, worldPoint, depth) {
const body = ecd.getComponent(entity, RigidBody);
const transform = ecd.getComponent(entity, Transform64);
// World grab point → the body's local frame (anchorA). No scale on bodies.
scratchQuat.set(
transform.rotation_x, transform.rotation_y, transform.rotation_z, transform.rotation_w
).invert();
scratchVec.set(
worldPoint[0] - transform.translation_x,
worldPoint[1] - transform.translation_y,
worldPoint[2] - transform.translation_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;
v3_set(joint.localAnchorA, 0, scratchVec.x, scratchVec.y, scratchVec.z);
v3_set(joint.localAnchorB, 0, worldPoint[0], worldPoint[1], worldPoint[2]);
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);
}
});
// `globalUp` rather than `up`: `up` only fires for a release over the canvas, so
// a fling that ends with the cursor off the edge would otherwise never let go.
engine.devices.pointer.on.globalUp.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 — the body under the cursor is drawn in Meep green, brighter while
// it is actually held.
//
// The renderer reads a material's parameters the first time it meets that
// material in the scene and keeps the copy it uploaded; editing a material that
// is already being drawn does not change the picture. So a highlight is a *swap
// to another material*, and the two below are shared by every body. A component
// says nothing when it is written, so the swap is two steps: put the material on
// the `ShadedGeometry` and announce the change, which is what makes the system
// rewrite the row the body already holds. `materialOf` (§4) remembers what each
// body should go back to.
function highlightMaterial(intensity) {
const material = standardMaterial(HILITE, 0.5, 0.1);
const green = Color.from_sRGB_to_linear(Color.parse(HILITE));
// `emissive_factor` multiplies the emissive texture, which defaults to a
// white pixel — so on a texture-less material it *is* the emitted colour.
material.emissive_factor.set(green.r * intensity, green.g * intensity, green.b * intensity, 1);
return material;
}
// Hover is a green body that is still lit and shaded; held glows. The scene is
// dark, so auto-exposure is generous and a little emissive goes a long way.
const hoverMaterial = highlightMaterial(0.04);
const heldMaterial = highlightMaterial(0.35);
let highlighted = -1;
let highlightedWith = null;
function paint(entity, material) {
if (material === undefined || material === null) return;
const sg = ecd.getComponent(entity, ShadedGeometry);
if (sg === null || sg === undefined || sg.material === material) return;
sg.material = material;
shaded_geometry_announce_change(ecd, entity);
}
function highlight(entity, material = hoverMaterial) {
if (highlighted === entity && highlightedWith === material) return;
if (highlighted !== -1) paint(highlighted, materialOf.get(highlighted));
highlighted = entity;
highlightedWith = material;
if (entity !== -1) paint(entity, material);
}
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)) {
v3_set(dragJoint.localAnchorB, 0,
raySource.x + rayDir.x * dragDepth,
raySource.y + rayDir.y * dragDepth,
raySource.z + rayDir.z * dragDepth,
);
}
physics.wake(draggedBody);
highlight(draggedEntity, heldMaterial);
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;
}
});
# 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 and switches the canvas cursor to `grab` /
`grabbing`. The renderer reads a material's parameters the first time it meets
that material in the scene and keeps the copy it uploaded, so the highlight is a
*swap* to a shared green material (re-adding the `ShadedGeometry` component,
which relinks the primitive) rather than an edit of the material already in use.
- **Getting mouse input at all** — the engine mounts its canvas inside a view
stack that is `pointer-events: none`, and re-opts the render viewport back in.
The renderer builds its canvas when it *starts*, after that opt-in was applied,
so the live canvas inherits `none` and `engine.devices.pointer` never sees a
move or a press. `engine.graphics.domElement.style.pointerEvents = "auto"` after
bootstrap puts it back — anything mouse-driven needs that line today.
- **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({
// Vite's dependency pre-bundling rewrites the engine's own asset imports and
// loses their URLs, so a dev server ends up serving ".../deps/undefined" for
// the renderer's data files. The engine is source-only ESM and needs no
// pre-bundling - but its CommonJS dependencies do, or the import of them from inside
// the excluded engine finds no default export.
optimizeDeps: {
exclude: ["@woosh/meep-engine"],
include: ["dat.gui", "opentype.js"],
},
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",
},
});