// Chains — a Meep example for the rigid-body joint system.
//
// Four chains hang from a fixed beam, each a column of capsule links wired
// together with ball-socket joints. A capsule "log" sweeps left↔right about
// two-thirds of the way down, barging through the chains and setting them
// swinging. Let go and they settle back under gravity.
//
// Sections:
// §1 Tuning constants
// §2 Helpers
// §3 Engine bootstrap
// §4 Camera + lights
// §5 Decorative beam + floor (no physics)
// §6 The chains links + ball-socket joints
// §7 The sweeping log one kinematic capsule
// §8 Per-frame: reverse the log at the ends + HUD
//
// ─── Joints in one paragraph ─────────────────────────────────────────────────
//
// A `Joint` is a configurable 6-DOF constraint between two bodies (or a body and
// the world). Its default is a BALL-SOCKET: the three linear DOFs are locked so
// the two anchor points are held coincident, the three angular DOFs are free —
// exactly a chain link. You set `entityA`/`entityB` and the local anchor on each
// body, then register it with `physics.link_joint(joint)`. `entityB =
// JOINT_WORLD` pins body A to a fixed world point (`localAnchorB`), which is how
// the top of each chain hangs from the beam.
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 { RigidBodyFlags } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBodyFlags.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 { CapsuleShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/CapsuleShape3D.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
// ─── §1 Tuning constants ────────────────────────────────────────────────────
const CHAIN_COUNT = 4;
const CHAIN_SPACING = 3.5; // metres between chains, along X
const ANCHOR_Y = 10; // height of the beam the chains hang from
// Each chain is LINKS capsule segments. SEG is the centre-to-centre spacing of
// adjacent links, which is also where the joint anchors sit (±SEG/2 from a
// link's centre). The capsule is sized so its rounded ends meet that anchor.
const LINKS = 12;
const SEG = 0.6;
const LINK_RADIUS = 0.13;
const LINK_HEIGHT = SEG - 2 * LINK_RADIUS; // cylinder section; total ≈ SEG
const LINK_MASS = 0.3;
const LINK_BEND = 0.6; // max swing per joint, rad — bending stiffness
const CHAIN_LENGTH = LINKS * SEG; // 7.2 m
// The log sweeps at two-thirds of the way down the chain.
const LOG_Y = ANCHOR_Y - (2 / 3) * CHAIN_LENGTH;
const LOG_RADIUS = 0.6;
const LOG_LENGTH = 6; // cylinder section, laid along Z
const LOG_X_LIMIT = 8.5; // sweep amplitude — comfortably past the chains
const LOG_PERIOD = 11; // seconds for one full there-and-back sweep
const LINK_COLOR = 0x9aa6b2; // brushed steel
const LOG_COLOR = 0x4ef0a8; // Meep brand green — the moving agent
// ─── §2 Helpers ─────────────────────────────────────────────────────────────
// Inverse inertia of a capsule (approximated as a cylinder of length
// height + 2·radius), so links swing and tumble rather than staying axis-locked.
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, 5.5, 0),
distance: 22,
pitch: 0.4, // mostly head-on so the chains read as vertical columns
yaw: 0.45, // a little off-axis
cameraFarDistance: 200,
showFps: false,
});
const ecd = engine.entityManager.dataset;
const physics = engine.entityManager.getSystem(PhysicsSystem);
// ─── §5 Decorative beam + floor (no physics) ────────────────────────────────
//
// Pure ShadedGeometry — a Transform + mesh, no body or collider. They give the
// scene a frame to read against; nothing collides with them.
function decor(geometry, material, x, y, z) {
const t = new Transform();
t.position.set(x, y, z);
new Entity().add(t).add(ShadedGeometry.from(geometry, material)).build(ecd);
}
const darkMat = new THREE.MeshStandardMaterial({ color: 0x2a313b, roughness: 0.9, metalness: 0.1 });
const beamSpan = (CHAIN_COUNT - 1) * CHAIN_SPACING + 2;
decor(new THREE.BoxGeometry(beamSpan, 0.5, 0.8), darkMat, 0, ANCHOR_Y + 0.25, 0); // the beam
decor(new THREE.BoxGeometry(40, 0.5, 24), new THREE.MeshStandardMaterial({ color: 0x161c24, roughness: 1 }), 0, -0.25, 0); // floor
// ─── §6 The chains ──────────────────────────────────────────────────────────
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 → { chain, idx } so the contact filter can spot jointed neighbours.
const linkInfo = new Map();
let totalLinks = 0;
for (let c = 0; c < CHAIN_COUNT; c++) {
const chainX = (c - (CHAIN_COUNT - 1) / 2) * CHAIN_SPACING;
// — Build the link bodies, top to bottom —
const links = [];
for (let i = 0; i < LINKS; i++) {
const transform = new Transform();
transform.position.set(chainX, ANCHOR_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 collider = new Collider();
collider.shape = linkShape;
collider.friction = 0.4;
const entity = new Entity()
.add(transform)
.add(body)
.add(collider)
.add(ShadedGeometry.from(linkGeometry, linkMaterial))
.build(ecd);
links.push(entity);
linkInfo.set(entity, { chain: c, idx: i });
totalLinks++;
}
// — Wire them with ball-socket joints (the default Joint) —
// The bodies above are already linked into the solver by their .build(),
// so link_joint can resolve them now.
// Top link → fixed world point under the beam.
const anchor = new Joint();
anchor.entityA = links[0];
anchor.entityB = JOINT_WORLD;
anchor.localAnchorA.set(0, SEG / 2, 0); // top of link 0
anchor.localAnchorB.set(chainX, ANCHOR_Y, 0); // world hang point
physics.link_joint(anchor);
// Link i bottom ↔ link i+1 top: the two anchors are held coincident, the
// links are free to pivot — a flexible chain.
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); // bottom of link i
joint.localAnchorB.set(0, SEG / 2, 0); // top of link i+1
// Bending stiffness: cap how far one link can swing relative to the next
// (frame X and Z; Y stays the free twist axis). A bare ball-socket folds
// flat under a hard shove and the fat capsules overlap — this limit, plus
// the contact filter above, keeps the chain from passing through itself.
joint.setAngularLimit(0, -LINK_BEND, LINK_BEND);
joint.setAngularLimit(2, -LINK_BEND, LINK_BEND);
physics.link_joint(joint);
}
}
// Stop a folded chain from passing through itself: every body pair collides
// EXCEPT two links that are joint-neighbours in the same chain. Those touch at
// their shared anchor by construction, so colliding them would just fight the
// joint and jitter — every other pair (non-adjacent links, cross-chain links,
// the log) collides normally.
physics.setContactFilter((entityA, entityB) => {
const a = linkInfo.get(entityA);
const b = linkInfo.get(entityB);
if (a !== undefined && b !== undefined && a.chain === b.chain && Math.abs(a.idx - b.idx) === 1) {
return false;
}
return true;
});
// ─── §7 The sweeping log ────────────────────────────────────────────────────
//
// A KinematicVelocity body: it's driven by its velocity and shoves dynamic
// bodies aside, but nothing pushes it back. Laid horizontal along Z (a 90°
// tilt of the Y-axis capsule) so it sweeps through the whole row of chains as
// it travels in X. §8 drives its velocity on a sine so it eases in and out at
// the ends of the sweep rather than snapping direction.
const logTransform = new Transform();
logTransform.position.set(-LOG_X_LIMIT, LOG_Y, 0); // start at the left extreme
logTransform.rotation.set(Math.SQRT1_2, 0, 0, Math.SQRT1_2); // Rx(90°): Y axis → Z
const logBody = new RigidBody();
logBody.kind = BodyKind.KinematicVelocity;
logBody.setFlag(RigidBodyFlags.DisableSleep); // keep it sweeping forever
logBody.linearVelocity.set(0, 0, 0); // ramped up from rest in §8
const logCollider = new Collider();
logCollider.shape = CapsuleShape3D.from(LOG_RADIUS, LOG_LENGTH);
logCollider.friction = 0.5;
new Entity()
.add(logTransform)
.add(logBody)
.add(logCollider)
.add(ShadedGeometry.from(
new CapsuleGeometry(LOG_RADIUS, LOG_LENGTH, 10, 20),
new THREE.MeshStandardMaterial({ color: LOG_COLOR, roughness: 0.45, metalness: 0.2 }),
))
.build(ecd);
// ─── §8 Per-frame: reverse the log at the ends + HUD ────────────────────────
document.getElementById("links").textContent = String(totalLinks);
document.getElementById("chains").textContent = String(CHAIN_COUNT);
const fpsEl = document.getElementById("fps");
const LOG_OMEGA = (2 * Math.PI) / LOG_PERIOD;
let logTime = 0;
let fpsWindow = 0, fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = (nowMs - lastFrameMs) / 1000;
lastFrameMs = nowMs;
// Drive the log along x(t) = -A·cos(ωt), i.e. velocity = A·ω·sin(ωt). The
// velocity eases to zero at each extreme — so it decelerates into the
// turnaround and accelerates back out, no jerk — and peaks as it crosses
// the middle of the chains. (Kinematic + sleep-disabled: setting the
// velocity is all it takes; the solver integrates the pose.)
logTime += dt;
logBody.linearVelocity.set(LOG_X_LIMIT * LOG_OMEGA * Math.sin(LOG_OMEGA * logTime), 0, 0);
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>Chains · 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;
}
.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: 180px;
}
.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: 420px;
color: #9aa5b1;
}
.legend strong { color: #e6edf3; }
</style>
</head>
<body>
<div class="panel hud">
<div><span class="label">fps</span><span class="value" id="fps">--</span></div>
<div><span class="label">chains</span><span class="value" id="chains">4</span></div>
<div><span class="label">links</span><span class="value" id="links">48</span></div>
</div>
<div class="panel legend">
Four <strong>chains</strong> — columns of capsule links wired with ball-socket
<code>Joint</code>s — hang from a fixed beam. A capsule <strong>log</strong>
sweeps left↔right two-thirds of the way down, barging through them. Drag to
orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Jointed chains",
"description": "Four chains of ball-socket-jointed capsule links hang from a beam; a kinematic capsule log sweeps through them two-thirds of the way down, setting them swinging.",
"category": "Physics",
"status": "live",
"order": 4,
"tags": ["physics", "joints", "rope", "chain", "kinematic", "ecs"],
"sourceHint": "examples-src/chains/",
"demoUrl": "/examples/chains/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/chains",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Four jointed chains dangling from fixed anchors, swept by a moving capsule log.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "2.146.0",
"three": "0.136.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# chains
Four chains hang from a fixed beam, each a column of capsule links wired
together with ball-socket joints. A capsule "log" sweeps left↔right about
two-thirds of the way down, barging through the chains and setting them
swinging; let go and they settle back under gravity.
## Run locally
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```
Output goes to `../../public/examples/chains/demo.html`.
## What this demonstrates
- **Joints** — the new `Joint` 6-DOF constraint. Its default is a **ball-socket**
(three linear DOFs locked so the anchor points stay coincident, three angular
DOFs free), which is exactly a chain link. Set `entityA`/`entityB` and a local
anchor on each, then register with `physics.link_joint(joint)`.
- **Anchoring to the world** — `entityB = JOINT_WORLD` pins a body to a fixed
world point (`localAnchorB` is then a world coordinate); that's how the top of
each chain hangs from the beam.
- **Joint chains** — a column of links connected anchor-to-anchor forms a
flexible rope; the solver runs extra iterations on joints so impulses
propagate all the way down.
- **Kinematic bodies** — the log is a `KinematicVelocity` body: driven by its
velocity, it shoves the dynamic links aside but is never pushed back. Its
velocity follows a sine (`A·ω·sin(ωt)`), so it eases to a stop at each extreme
and accelerates back through the middle rather than snapping direction.
- **Bending stiffness via joint limits** — each inter-link joint caps its swing
with `setAngularLimit` (an angular constraint, ±0.6 rad), so a link can't fold
flat against its neighbour. A bare ball-socket chain folds through itself under
a hard shove; the limit makes it read as an articulated chain.
- **Contact filtering** — `physics.setContactFilter` rejects collisions between
joint-neighbour links (which touch at their shared anchor and would only fight
the joint), while letting every other pair collide — so non-adjacent and
cross-chain links can't interpenetrate, but the resting chain doesn't jitter.
- **Decorative geometry** — the beam and floor are plain `ShadedGeometry`
entities (a `Transform` + mesh, no body), there only to frame the scene.
import { defineConfig } from "vite";
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({
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/chains"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});