// robot-arm.jsx — tower-builder + knockdown + occasional failures (v4)
//
// 3-axis arm (base yaw + shoulder + elbow + constrained wrist) stacks 4 cubes
// into a tower, knocks the tower down with a swinging arm, then resets.
// On every other cycle a different cube's grip "slips" mid-pickup — the arm
// shakes briefly (frustrated), then retries. Cubes have real physics during
// the knockdown (gravity + bounce damping).
function _clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function _easeInOut(t) { return t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2)/2; }
// ─── Scene + arm geometry ───────────────────────────────────────────────
const SVG_W = 540, SVG_H = 340;
const ORIGIN = { x: 250, y: 175 };
const DEPTH_A = Math.PI / 6;
const DEPTH_COS = Math.cos(DEPTH_A);
const DEPTH_SIN = Math.sin(DEPTH_A);
const DEPTH_K = 0.55;
const SHOULDER = { x: -40, y: 12, z: 28 };
const FLOOR_Y = -100;
const BENCH_TOP = 0;
const BENCH_BOT = -32;
const BENCH = { x0: -80, x1: 150, z0: -38, z1: -8 };
const STACK_PAD = { x0: -100, x1: -60, z0: -42, z1: -4 };
const L1 = 100, L2 = 100, L3 = 16, TOOL_OFFSET = 12;
const WRIST_TO_TIP = L3 + TOOL_OFFSET;
const CUBE_SIZE = 18, CUBE_HALF = 9;
const CUBE_TOP_Y = BENCH_TOP + CUBE_HALF;
const STACK_X = -80, STACK_Z = -23;
const SOURCES = [
{ x: 20, z: -28 },
{ x: 60, z: -18 },
{ x: 100, z: -28 },
{ x: 140, z: -18 },
];
const CUBES_INIT = SOURCES.map((s, i) => ({
srcX: s.x, srcY: CUBE_TOP_Y, srcZ: s.z,
dstX: STACK_X, dstY: CUBE_TOP_Y + i * CUBE_SIZE, dstZ: STACK_Z,
}));
const LIFT_Y = 70;
const PARK = { x: SHOULDER.x + 40, y: 50, z: SHOULDER.z - 30 };
// ─── Formations: where each cube ends up ────────────────────────────────
// dStack(i) = vertical tower; dGrid(i) = 2×2 grid; dLine(i) = horizontal row.
function dStack(i) {
return { x: STACK_X, y: CUBE_TOP_Y + i * CUBE_SIZE, z: STACK_Z };
}
function dGrid(i) {
const col = i % 2;
const row = (i / 2) | 0;
const s = CUBE_SIZE + 4;
return {
x: STACK_X + (col - 0.5) * s,
y: CUBE_TOP_Y,
z: STACK_Z + (row - 0.5) * s,
};
}
function dLine(i) {
// 4 cubes in a left-right row, slightly tilted in z for an iso-friendly read.
const span = 4;
return { x: STACK_X + (i - 1.5) * (CUBE_SIZE + 4), y: CUBE_TOP_Y, z: STACK_Z };
}
function dPyramid(i) {
const step = CUBE_SIZE + 2;
if (i === 3) return { x: STACK_X, y: CUBE_TOP_Y + CUBE_SIZE, z: STACK_Z };
return { x: STACK_X + (i - 1) * step, y: CUBE_TOP_Y, z: STACK_Z };
}
function dDomino(i) {
const step = CUBE_SIZE + 2;
return { x: STACK_X + (i - 1.5) * step, y: CUBE_TOP_Y, z: STACK_Z };
}
function dPisa(i) {
const lean = i * 4.5;
return { x: STACK_X + lean, y: CUBE_TOP_Y + i * CUBE_SIZE, z: STACK_Z };
}
function dSort(i) {
const ODD_X = STACK_X - 14;
const EVEN_X = STACK_X + 14;
if (i === 0) return { x: ODD_X, y: CUBE_TOP_Y, z: STACK_Z };
if (i === 1) return { x: EVEN_X, y: CUBE_TOP_Y, z: STACK_Z };
if (i === 2) return { x: ODD_X, y: CUBE_TOP_Y + CUBE_SIZE, z: STACK_Z };
return { x: EVEN_X, y: CUBE_TOP_Y + CUBE_SIZE, z: STACK_Z };
}
// ─── Phase builders ─────────────────────────────────────────────────────
function buildPickPlace(cubeIdx, dest) {
const c = CUBES_INIT[cubeIdx];
return [
{ type: "approach", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.45 },
{ type: "descend", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.30 },
{ type: "grip-close", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.20 },
{ type: "lift", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.40 },
{ type: "travel", cubeIdx, x: dest.x, y: LIFT_Y, z: dest.z, g: 1, d: 0.70 },
{ type: "place", cubeIdx, x: dest.x, y: dest.y, z: dest.z, g: 1, d: 0.28 },
{ type: "release", cubeIdx, x: dest.x, y: dest.y, z: dest.z, g: 0, d: 0.18 },
{ type: "lift-clear", cubeIdx, x: dest.x, y: LIFT_Y, z: dest.z, g: 0, d: 0.40 },
];
}
function buildPickPlaceWithFail(cubeIdx, dest) {
const c = CUBES_INIT[cubeIdx];
return [
{ type: "approach", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.45 },
{ type: "descend", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.30 },
// Grip closes — but the cube slips.
{ type: "grip-fail", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.18 },
// Arm rises, cube stayed on bench.
{ type: "lift-empty", cubeIdx, x: c.srcX, y: c.srcY + 22, z: c.srcZ, g: 1, d: 0.25 },
// Frustration: jitter side-to-side, grip open.
{ type: "frustrated-1", cubeIdx, x: c.srcX - 5, y: c.srcY + 24, z: c.srcZ + 3, g: 0, d: 0.10 },
{ type: "frustrated-2", cubeIdx, x: c.srcX + 5, y: c.srcY + 24, z: c.srcZ - 3, g: 0, d: 0.10 },
{ type: "frustrated-3", cubeIdx, x: c.srcX - 4, y: c.srcY + 22, z: c.srcZ + 2, g: 0, d: 0.10 },
{ type: "frustrated-settle", cubeIdx, x: c.srcX, y: c.srcY + 22, z: c.srcZ, g: 0, d: 0.14 },
// Retry.
{ type: "retry-descend", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.28 },
{ type: "grip-close", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.20 },
{ type: "lift", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.40 },
{ type: "travel", cubeIdx, x: dest.x, y: LIFT_Y, z: dest.z, g: 1, d: 0.70 },
{ type: "place", cubeIdx, x: dest.x, y: dest.y, z: dest.z, g: 1, d: 0.28 },
{ type: "release", cubeIdx, x: dest.x, y: dest.y, z: dest.z, g: 0, d: 0.18 },
{ type: "lift-clear", cubeIdx, x: dest.x, y: LIFT_Y, z: dest.z, g: 0, d: 0.40 },
];
}
// Picks a cube, then "inspects" it by yawing the arm side-to-side at viewing
// height before placing — like the arm is examining a part it just picked up.
function buildPickPlaceWithInspect(cubeIdx, dest) {
const c = CUBES_INIT[cubeIdx];
const ix = SHOULDER.x + 75, iy = 68, iz = SHOULDER.z - 12;
return [
{ type: "approach", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.45 },
{ type: "descend", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.30 },
{ type: "grip-close", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.20 },
{ type: "lift", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.40 },
// Inspect: yaw side to side while holding.
{ type: "inspect-bring", cubeIdx, x: ix, y: iy, z: iz, g: 1, d: 0.55 },
{ type: "inspect-left", cubeIdx, x: ix - 18, y: iy, z: iz - 18, g: 1, d: 0.45 },
{ type: "inspect-right", cubeIdx, x: ix + 18, y: iy, z: iz + 12, g: 1, d: 0.55 },
{ type: "inspect-center", cubeIdx, x: ix, y: iy, z: iz, g: 1, d: 0.40 },
{ type: "travel", cubeIdx, x: dest.x, y: LIFT_Y, z: dest.z, g: 1, d: 0.55 },
{ type: "place", cubeIdx, x: dest.x, y: dest.y, z: dest.z, g: 1, d: 0.28 },
{ type: "release", cubeIdx, x: dest.x, y: dest.y, z: dest.z, g: 0, d: 0.18 },
{ type: "lift-clear", cubeIdx, x: dest.x, y: LIFT_Y, z: dest.z, g: 0, d: 0.40 },
];
}
// Horizontal sweep through a tall stack (the original demolition).
function buildSweepDemo() {
return [
{ type: "kd-windup", cubeIdx: -1, x: STACK_X + 95, y: 38, z: STACK_Z + 6, g: 0, d: 0.55 },
{ type: "kd-cock", cubeIdx: -1, x: STACK_X + 100, y: 32, z: STACK_Z + 4, g: 0, d: 0.22 },
{ type: "kd-swing", cubeIdx: -1, x: STACK_X - 70, y: 30, z: STACK_Z - 6, g: 0, d: 0.28 },
{ type: "kd-follow", cubeIdx: -1, x: STACK_X - 100, y: 36, z: STACK_Z - 10, g: 0, d: 0.30 },
{ type: "kd-observe", cubeIdx: -1, x: STACK_X - 90, y: 55, z: STACK_Z - 8, g: 0, d: 1.20 },
];
}
// Slam straight down onto a low/spread formation (grid, line).
function buildCrushDemo() {
return [
{ type: "kd-rise", cubeIdx: -1, x: STACK_X + 20, y: 88, z: STACK_Z - 5, g: 0, d: 0.55 },
{ type: "kd-center", cubeIdx: -1, x: STACK_X, y: 88, z: STACK_Z, g: 0, d: 0.28 },
{ type: "kd-hold", cubeIdx: -1, x: STACK_X, y: 92, z: STACK_Z, g: 0, d: 0.20 },
{ type: "kd-strike", cubeIdx: -1, x: STACK_X, y: 24, z: STACK_Z, g: 0, d: 0.16 },
{ type: "kd-recoil", cubeIdx: -1, x: STACK_X + 6, y: 64, z: STACK_Z - 3, g: 0, d: 0.45 },
{ type: "kd-observe", cubeIdx: -1, x: STACK_X + 10, y: 64, z: STACK_Z - 6, g: 0, d: 1.05 },
];
}
// Push horizontally along a line, like sweeping pieces off a table.
function buildLineSweep() {
const x0 = STACK_X - (CUBE_SIZE + 4) * 1.5 - 30;
const x1 = STACK_X + (CUBE_SIZE + 4) * 2.5 + 10;
return [
{ type: "kd-windup", cubeIdx: -1, x: x0, y: 36, z: STACK_Z + 6, g: 0, d: 0.55 },
{ type: "kd-cock", cubeIdx: -1, x: x0 - 6, y: 30, z: STACK_Z + 4, g: 0, d: 0.20 },
{ type: "kd-swing", cubeIdx: -1, x: x1, y: 22, z: STACK_Z - 6, g: 0, d: 0.42 },
{ type: "kd-follow", cubeIdx: -1, x: x1 + 12, y: 36, z: STACK_Z - 8, g: 0, d: 0.30 },
{ type: "kd-observe", cubeIdx: -1, x: x1, y: 56, z: STACK_Z - 6, g: 0, d: 1.10 },
];
}
// Domino chain: tap the leftmost cube and let collisions cascade the rest.
function buildDominoTap() {
const firstX = STACK_X - (CUBE_SIZE + 2) * 1.5;
return [
{ type: "kd-windup", cubeIdx: -1, x: firstX - 42, y: 46, z: STACK_Z + 6, g: 0, d: 0.55 },
{ type: "kd-cock", cubeIdx: -1, x: firstX - 26, y: 30, z: STACK_Z + 4, g: 0, d: 0.22 },
{ type: "kd-tap", cubeIdx: -1, x: firstX + 12, y: 28, z: STACK_Z - 4, g: 0, d: 0.20 },
{ type: "kd-follow", cubeIdx: -1, x: firstX + 28, y: 38, z: STACK_Z - 6, g: 0, d: 0.30 },
{ type: "kd-observe",cubeIdx: -1, x: firstX + 40, y: 58, z: STACK_Z - 8, g: 0, d: 1.30 },
];
}
// Picks a cube, flourishes, vanishes it mid-air, and it reappears at the bin.
// Sleight-of-hand routine — cube teleports rather than gets carried.
function buildMagicTeleport(cubeIdx) {
const c = CUBES_INIT[cubeIdx];
const stage = { x: SHOULDER.x + 55, y: 90, z: SHOULDER.z - 4 };
const dest = dStack(cubeIdx);
return [
{ type: "approach", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.38 },
{ type: "descend", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.24 },
{ type: "grip-close", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.16 },
{ type: "magic-lift", cubeIdx, x: stage.x, y: stage.y, z: stage.z, g: 1, d: 0.42 },
{ type: "magic-flourish-l", cubeIdx, x: stage.x - 12, y: stage.y + 4, z: stage.z + 14, g: 1, d: 0.18 },
{ type: "magic-flourish-r", cubeIdx, x: stage.x + 12, y: stage.y + 4, z: stage.z - 14, g: 1, d: 0.18 },
{ type: "magic-flourish-c", cubeIdx, x: stage.x, y: stage.y, z: stage.z, g: 1, d: 0.14 },
// Cube fades to invisible while still in gripper.
{ type: "magic-vanish", cubeIdx, x: stage.x, y: stage.y + 4, z: stage.z, g: 1, d: 0.34 },
// Empty gripper makes its way to the bin (closed — preserves the misdirection).
{ type: "magic-traverse", cubeIdx, x: dest.x, y: LIFT_Y, z: dest.z, g: 1, d: 0.50 },
// Gripper opens — cube materialises on the stack.
{ type: "magic-reveal", cubeIdx, x: dest.x, y: LIFT_Y, z: dest.z, g: 0, d: 0.30 },
{ type: "magic-retract", cubeIdx, x: dest.x, y: LIFT_Y, z: dest.z, g: 0, d: 0.22 },
];
}
// Picks each cube and unceremoniously chucks it. No bin, no care.
function buildThrowAway(cubeIdx) {
const c = CUBES_INIT[cubeIdx];
// Per-cube windup + fling apex (varied so the four throws don't look identical)
const windups = [
{ x: SHOULDER.x - 22, y: 88, z: SHOULDER.z + 26 },
{ x: SHOULDER.x - 28, y: 92, z: SHOULDER.z + 18 },
{ x: SHOULDER.x - 18, y: 90, z: SHOULDER.z + 30 },
{ x: SHOULDER.x - 32, y: 96, z: SHOULDER.z + 14 },
];
const apexes = [
{ x: SHOULDER.x + 95, y: 56, z: SHOULDER.z - 24 },
{ x: SHOULDER.x + 100, y: 60, z: SHOULDER.z - 30 },
{ x: SHOULDER.x + 105, y: 52, z: SHOULDER.z - 20 },
{ x: SHOULDER.x + 95, y: 64, z: SHOULDER.z - 28 },
];
const w = windups[cubeIdx];
const a = apexes[cubeIdx];
return [
{ type: "approach", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.38 },
{ type: "descend", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.26 },
{ type: "grip-close", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.16 },
{ type: "lift", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.26 },
{ type: "throw-windup", cubeIdx, x: w.x, y: w.y, z: w.z, g: 1, d: 0.32 },
{ type: "throw-fling", cubeIdx, x: a.x, y: a.y, z: a.z, g: 1, d: 0.14 }, // fast
{ type: "throw-recoil", cubeIdx, x: a.x + 14, y: a.y + 10, z: a.z + 2, g: 0, d: 0.30 },
];
}
function buildHome() {
return [
{ type: "home", cubeIdx: -1, x: PARK.x, y: PARK.y, z: PARK.z, g: 0, d: 0.60 },
{ type: "reset", cubeIdx: -1, x: PARK.x, y: PARK.y, z: PARK.z, g: 0, d: 0.55 },
];
}
// Snap all 4 cubes into a stack at BIN-A, then pick top-down back to source.
function buildDisassemblySetup() {
return [
{ type: "disas-setup", cubeIdx: -1, x: PARK.x, y: PARK.y, z: PARK.z, g: 0, d: 0.05 },
{ type: "disas-survey", cubeIdx: -1, x: SHOULDER.x + 65, y: 80, z: SHOULDER.z - 5, g: 0, d: 0.50 },
];
}
// Step back and watch a precarious tower topple on its own.
function buildPisaCollapse() {
return [
{ type: "pisa-step-back", cubeIdx: -1, x: SHOULDER.x + 70, y: 55, z: SHOULDER.z + 22, g: 0, d: 0.55 },
{ type: "pisa-watch", cubeIdx: -1, x: SHOULDER.x + 70, y: 55, z: SHOULDER.z + 22, g: 0, d: 1.10 },
{ type: "pisa-collapse", cubeIdx: -1, x: SHOULDER.x + 70, y: 55, z: SHOULDER.z + 22, g: 0, d: 0.04 },
{ type: "kd-observe", cubeIdx: -1, x: SHOULDER.x + 70, y: 55, z: SHOULDER.z + 22, g: 0, d: 1.40 },
];
}
// Toss a cube up, gripper opens, catches it on the way down.
function buildJuggleOne(cubeIdx) {
const c = CUBES_INIT[cubeIdx];
const sx = SHOULDER.x + 60, sy = 72, sz = SHOULDER.z - 5;
return [
{ type: "approach", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.40 },
{ type: "descend", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.26 },
{ type: "grip-close", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.16 },
{ type: "lift", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.30 },
{ type: "juggle-bring", cubeIdx, x: sx, y: sy, z: sz, g: 1, d: 0.45 },
{ type: "juggle-toss", cubeIdx, x: sx, y: sy + 18, z: sz, g: 0, d: 0.20 },
{ type: "juggle-arc", cubeIdx, x: sx, y: sy + 14, z: sz, g: 0, d: 0.55 },
{ type: "juggle-catch", cubeIdx, x: sx, y: sy, z: sz, g: 1, d: 0.18 },
{ type: "travel", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.48 },
{ type: "place", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.24 },
{ type: "release", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.16 },
{ type: "lift-clear", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.30 },
];
}
function buildDisasPickPlace(stackLevel, cubeIdx) {
const c = CUBES_INIT[cubeIdx];
const stackY = CUBE_TOP_Y + stackLevel * CUBE_SIZE;
return [
{ type: "approach", cubeIdx, x: STACK_X, y: LIFT_Y + 10, z: STACK_Z, g: 0, d: 0.45 },
{ type: "descend", cubeIdx, x: STACK_X, y: stackY, z: STACK_Z, g: 0, d: 0.28 },
{ type: "grip-close", cubeIdx, x: STACK_X, y: stackY, z: STACK_Z, g: 1, d: 0.18 },
{ type: "lift", cubeIdx, x: STACK_X, y: LIFT_Y, z: STACK_Z, g: 1, d: 0.40 },
{ type: "travel", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.65 },
{ type: "place", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.28 },
{ type: "release", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.18 },
{ type: "lift-clear", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.38 },
];
}
// Wave hello — no cube interaction. Arm rises high and waves.
function buildWaveHello() {
const cx = SHOULDER.x + 60, cy = 100, cz = SHOULDER.z + 8;
return [
{ type: "hi-rise", cubeIdx: -1, x: cx, y: cy, z: cz, g: 0, d: 0.55 },
{ type: "hi-wave-l", cubeIdx: -1, x: cx, y: cy + 4, z: cz - 22, g: 1, d: 0.26 },
{ type: "hi-wave-r", cubeIdx: -1, x: cx, y: cy + 4, z: cz + 32, g: 0, d: 0.26 },
{ type: "hi-wave-l", cubeIdx: -1, x: cx, y: cy + 4, z: cz - 22, g: 1, d: 0.26 },
{ type: "hi-wave-r", cubeIdx: -1, x: cx, y: cy + 4, z: cz + 32, g: 0, d: 0.26 },
{ type: "hi-center", cubeIdx: -1, x: cx, y: cy, z: cz, g: 0, d: 0.50 },
];
}
// Hands a cube directly at the viewer, holds, retracts, places back.
function buildPresentToViewer(cubeIdx) {
const c = CUBES_INIT[cubeIdx];
const PX = SHOULDER.x + 38, PY = 70, PZ = SHOULDER.z + 52;
return [
{ type: "approach", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.42 },
{ type: "descend", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.28 },
{ type: "grip-close", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.18 },
{ type: "lift", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.36 },
{ type: "present-extend", cubeIdx, x: PX, y: PY, z: PZ, g: 1, d: 0.55 },
{ type: "present-hold", cubeIdx, x: PX, y: PY, z: PZ, g: 1, d: 0.85 },
{ type: "present-retract", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.55 },
{ type: "place", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.24 },
{ type: "release", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.16 },
{ type: "lift-clear", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.30 },
];
}
// Picks a cube and figure-8 sways with it.
function buildDance(cubeIdx) {
const c = CUBES_INIT[cubeIdx];
const cx = SHOULDER.x + 55, cy = 75, cz = SHOULDER.z - 6;
return [
{ type: "approach", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.40 },
{ type: "descend", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.26 },
{ type: "grip-close", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.16 },
{ type: "lift", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.30 },
{ type: "dance-bring", cubeIdx, x: cx, y: cy, z: cz, g: 1, d: 0.40 },
{ type: "dance-1", cubeIdx, x: cx - 22, y: cy + 6, z: cz - 14, g: 1, d: 0.22 },
{ type: "dance-2", cubeIdx, x: cx, y: cy + 12, z: cz - 6, g: 1, d: 0.20 },
{ type: "dance-3", cubeIdx, x: cx + 22, y: cy + 6, z: cz - 14, g: 1, d: 0.22 },
{ type: "dance-4", cubeIdx, x: cx + 16, y: cy - 6, z: cz + 12, g: 1, d: 0.20 },
{ type: "dance-5", cubeIdx, x: cx, y: cy - 10, z: cz + 14, g: 1, d: 0.20 },
{ type: "dance-6", cubeIdx, x: cx - 16, y: cy - 6, z: cz + 12, g: 1, d: 0.20 },
{ type: "dance-7", cubeIdx, x: cx, y: cy, z: cz, g: 1, d: 0.30 },
{ type: "travel", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 1, d: 0.45 },
{ type: "place", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 1, d: 0.24 },
{ type: "release", cubeIdx, x: c.srcX, y: c.srcY, z: c.srcZ, g: 0, d: 0.16 },
{ type: "lift-clear", cubeIdx, x: c.srcX, y: LIFT_Y, z: c.srcZ, g: 0, d: 0.30 },
];
}
// Cycle variants — each a meaningfully different routine.
const VARIANTS = [
// 0 · Build a vertical tower → swing-demolish.
[
...buildPickPlace(0, dStack(0)),
...buildPickPlace(1, dStack(1)),
...buildPickPlace(2, dStack(2)),
...buildPickPlace(3, dStack(3)),
...buildSweepDemo(),
...buildHome(),
],
// 1 · Tower, but cube 1 fails → retry → tower → swing-demolish.
[
...buildPickPlace(0, dStack(0)),
...buildPickPlaceWithFail(1, dStack(1)),
...buildPickPlace(2, dStack(2)),
...buildPickPlace(3, dStack(3)),
...buildSweepDemo(),
...buildHome(),
],
// 2 · 2×2 grid → top-down crush.
[
...buildPickPlace(0, dGrid(0)),
...buildPickPlace(1, dGrid(1)),
...buildPickPlace(2, dGrid(2)),
...buildPickPlace(3, dGrid(3)),
...buildCrushDemo(),
...buildHome(),
],
// 3 · Tower, but inspect cube 2 mid-pickup → swing-demolish.
[
...buildPickPlace(0, dStack(0)),
...buildPickPlace(1, dStack(1)),
...buildPickPlaceWithInspect(2, dStack(2)),
...buildPickPlace(3, dStack(3)),
...buildSweepDemo(),
...buildHome(),
],
// 4 · Line them up → side-sweep them off.
[
...buildPickPlace(0, dLine(0)),
...buildPickPlace(1, dLine(1)),
...buildPickPlace(2, dLine(2)),
...buildPickPlace(3, dLine(3)),
...buildLineSweep(),
...buildHome(),
],
// 5 · Doesn't care — picks each up and flings it.
[
...buildThrowAway(0),
...buildThrowAway(1),
...buildThrowAway(2),
...buildThrowAway(3),
...buildHome(),
],
// 6 · Stage magic — vanish each cube, reappear on the stack, knock down.
[
...buildMagicTeleport(0),
...buildMagicTeleport(1),
...buildMagicTeleport(2),
...buildMagicTeleport(3),
...buildSweepDemo(),
...buildHome(),
],
// 7 · Wave hello — no cubes.
[
...buildWaveHello(),
...buildHome(),
],
// 8 · Juggle.
[
...buildJuggleOne(1),
...buildJuggleOne(2),
...buildHome(),
],
// 9 · Pyramid → top-down crush.
[
...buildPickPlace(0, dPyramid(0)),
...buildPickPlace(1, dPyramid(1)),
...buildPickPlace(2, dPyramid(2)),
...buildPickPlace(3, dPyramid(3)),
...buildCrushDemo(),
...buildHome(),
],
// 10 · Sort odds/evens → sweep.
[
...buildPickPlace(0, dSort(0)),
...buildPickPlace(1, dSort(1)),
...buildPickPlace(2, dSort(2)),
...buildPickPlace(3, dSort(3)),
...buildSweepDemo(),
...buildHome(),
],
// 11 · Hands a cube to the viewer.
[
...buildPresentToViewer(1),
...buildHome(),
],
// 12 · Dance with a cube.
[
...buildDance(2),
...buildHome(),
],
// 13 · Disassembly.
[
...buildDisassemblySetup(),
...buildDisasPickPlace(3, 3),
...buildDisasPickPlace(2, 2),
...buildDisasPickPlace(1, 1),
...buildDisasPickPlace(0, 0),
...buildHome(),
],
// 14 · Tower of Pisa → self-collapse.
[
...buildPickPlace(0, dPisa(0)),
...buildPickPlace(1, dPisa(1)),
...buildPickPlace(2, dPisa(2)),
...buildPickPlace(3, dPisa(3)),
...buildPisaCollapse(),
...buildHome(),
],
// 15 · Domino chain.
[
...buildPickPlace(0, dDomino(0)),
...buildPickPlace(1, dDomino(1)),
...buildPickPlace(2, dDomino(2)),
...buildPickPlace(3, dDomino(3)),
...buildDominoTap(),
...buildHome(),
],
];
function routineDuration(routine) {
return routine.reduce((s, w) => s + w.d, 0);
}
function getArmPoseInRoutine(routine, t) {
const totalDur = routineDuration(routine);
const u = ((t % totalDur) + totalDur) % totalDur;
let acc = 0;
for (let i = 0; i < routine.length; i++) {
const w = routine[i];
if (u <= acc + w.d) {
const local = (u - acc) / w.d;
const next = routine[(i + 1) % routine.length];
const e = _easeInOut(local);
return {
x: w.x + (next.x - w.x) * e,
y: w.y + (next.y - w.y) * e,
z: w.z + (next.z - w.z) * e,
g: w.g + (next.g - w.g) * e,
phase: i,
phaseType: w.type,
local,
cubeIdx: w.cubeIdx,
};
}
acc += w.d;
}
const w = routine[0];
return { ...w, phase: 0, phaseType: w.type, local: 0 };
}
// ─── Projection + arm IK ────────────────────────────────────────────────
function project(x, y, z) {
return {
sx: ORIGIN.x + x - z * DEPTH_COS * DEPTH_K,
sy: ORIGIN.y - y - z * DEPTH_SIN * DEPTH_K,
};
}
function solveArm(targetX, targetY, targetZ) {
const dx = targetX - SHOULDER.x;
const dz = targetZ - SHOULDER.z;
const yaw = Math.atan2(dz, dx);
const wristTargetY = targetY + WRIST_TO_TIP;
const fwd = Math.hypot(dx, dz);
const up = wristTargetY - SHOULDER.y;
let wF = fwd, wU = up;
let r = Math.hypot(wF, wU);
const maxR = L1 + L2 - 0.6;
const minR = Math.abs(L1 - L2) + 0.6;
if (r > maxR) { const k = maxR / r; wF *= k; wU *= k; r = maxR; }
if (r < minR && r > 0.001) { const k = minR / r; wF *= k; wU *= k; r = minR; }
const cos2 = _clamp((r*r - L1*L1 - L2*L2) / (2*L1*L2), -1, 1);
const theta2 = Math.acos(cos2);
const gamma = Math.atan2(wF, wU);
const beta = Math.acos(_clamp((L1*L1 + r*r - L2*L2) / (2*L1*r), -1, 1));
const theta1 = gamma - beta;
const theta3 = Math.PI - (theta1 + theta2);
const eF = L1 * Math.sin(theta1);
const eU = L1 * Math.cos(theta1);
const wF2 = eF + L2 * Math.sin(theta1 + theta2);
const wU2 = eU + L2 * Math.cos(theta1 + theta2);
const fF = wF2;
const fU = wU2 - L3;
const cy = Math.cos(yaw), sy = Math.sin(yaw);
function awp(f, u) {
return { x: SHOULDER.x + f * cy, y: SHOULDER.y + u, z: SHOULDER.z + f * sy };
}
return {
yaw,
shoulder: SHOULDER,
elbow: awp(eF, eU),
wrist: awp(wF2, wU2),
flange: awp(fF, fU),
tip: awp(fF, fU - TOOL_OFFSET),
};
}
// ─── Render primitives ──────────────────────────────────────────────────
function IsoCube({ cx, cy, cz, size = CUBE_SIZE, color = "var(--accent)", index = -1, dim = false, tilt = 0 }) {
const h = size / 2;
const c = {};
for (const xs of [-h, h]) for (const ys of [-h, h]) for (const zs of [-h, h]) {
const k = (xs > 0 ? "R" : "L") + (ys > 0 ? "T" : "B") + (zs > 0 ? "F" : "K");
c[k] = project(cx + xs, cy + ys, cz + zs);
}
const front = `M${c.LBF.sx} ${c.LBF.sy} L${c.RBF.sx} ${c.RBF.sy} L${c.RTF.sx} ${c.RTF.sy} L${c.LTF.sx} ${c.LTF.sy} Z`;
const top = `M${c.LTF.sx} ${c.LTF.sy} L${c.RTF.sx} ${c.RTF.sy} L${c.RTK.sx} ${c.RTK.sy} L${c.LTK.sx} ${c.LTK.sy} Z`;
const right = `M${c.RTF.sx} ${c.RTF.sy} L${c.RBF.sx} ${c.RBF.sy} L${c.RBK.sx} ${c.RBK.sy} L${c.RTK.sx} ${c.RTK.sy} Z`;
const cxS = (c.LBF.sx + c.RTK.sx) / 2;
const cyS = (c.LBF.sy + c.RTK.sy) / 2;
const inner = (
{index >= 0 && (
{String(index + 1).padStart(2, "0")}
)}
);
if (tilt) {
return {inner};
}
return {inner};
}
function ArmCapsule({ p1, p2, width, fill = "url(#armSeg)", stroke = "rgba(255,255,255,0.18)" }) {
const a = project(p1.x, p1.y, p1.z);
const b = project(p2.x, p2.y, p2.z);
const dx = b.sx - a.sx, dy = b.sy - a.sy;
const len = Math.hypot(dx, dy);
if (len < 0.5) return null;
const ang = Math.atan2(dy, dx) * 180 / Math.PI;
return (
{Array.from({ length: Math.max(1, Math.floor(len / 22)) }).map((_, i) => (
))}
);
}
function Joint({ p, r = 7, accent = false }) {
const s = project(p.x, p.y, p.z);
return (
{accent && }
);
}
function Gripper({ flange, grip }) {
const s = project(flange.x, flange.y, flange.z);
const tipS = project(flange.x, flange.y - TOOL_OFFSET, flange.z);
const spread = 9 - grip * 5;
const jawLen = Math.abs(tipS.sy - s.sy) + 4;
return (
);
}
function Surface({ x0, x1, z0, z1, yTop, yBot, label }) {
const t = {
LBF: project(x0, yTop, z1), RBF: project(x1, yTop, z1),
LBK: project(x0, yTop, z0), RBK: project(x1, yTop, z0),
LFB: project(x0, yBot, z1), RFB: project(x1, yBot, z1),
LBB: project(x0, yBot, z0), RBB: project(x1, yBot, z0),
};
const top = `M${t.LBF.sx} ${t.LBF.sy} L${t.RBF.sx} ${t.RBF.sy} L${t.RBK.sx} ${t.RBK.sy} L${t.LBK.sx} ${t.LBK.sy} Z`;
const front = `M${t.LBF.sx} ${t.LBF.sy} L${t.RBF.sx} ${t.RBF.sy} L${t.RFB.sx} ${t.RFB.sy} L${t.LFB.sx} ${t.LFB.sy} Z`;
const right = `M${t.RBF.sx} ${t.RBF.sy} L${t.RBK.sx} ${t.RBK.sy} L${t.RBB.sx} ${t.RBB.sy} L${t.RFB.sx} ${t.RFB.sy} Z`;
return (
{label && (
{label}
)}
);
}
function SlotMarker({ cx, cz, w = CUBE_SIZE + 4, d = CUBE_SIZE + 4, label, active, warn }) {
const a = project(cx - w/2, BENCH_TOP + 0.5, cz + d/2);
const b = project(cx + w/2, BENCH_TOP + 0.5, cz + d/2);
const c = project(cx + w/2, BENCH_TOP + 0.5, cz - d/2);
const d2 = project(cx - w/2, BENCH_TOP + 0.5, cz - d/2);
const labPos = project(cx, BENCH_TOP + 0.5, cz - d/2 - 4);
const color = warn ? "#f5a623" : "var(--accent)";
const dim = warn ? "rgba(245,166,35,0.25)" : "rgba(124,252,77,0.22)";
return (
{label && (
{label}
)}
);
}
// ─── Phase-type groupings ───────────────────────────────────────────────
const FRUSTRATED_TYPES = new Set([
"grip-fail", "lift-empty",
"frustrated-1", "frustrated-2", "frustrated-3", "frustrated-settle",
]);
const PICKUP_TYPES = new Set([
"approach", "descend", "grip-close", "grip-fail", "lift-empty",
"frustrated-1", "frustrated-2", "frustrated-3", "frustrated-settle",
"retry-descend", "lift",
]);
const PLACE_TYPES = new Set(["travel", "place", "release", "lift-clear"]);
// ─── Main component ─────────────────────────────────────────────────────
function RobotArm() {
const [pose, setPose] = React.useState(() => getArmPoseInRoutine(VARIANTS[0], 0));
const stateRef = React.useRef({
acc: 0,
last: performance.now(),
cycleIdx: 0,
routine: VARIANTS[0],
cubes: CUBES_INIT.map((c) => ({
x: c.srcX, y: c.srcY, z: c.srcZ,
vx: 0, vy: 0, vz: 0,
tilt: 0, vRot: 0,
mode: "rest",
opacity: 1,
resetFrom: null,
})),
prevPhaseType: null,
trail: [],
particles: [],
frustration: 0,
});
// Spawn N sparkle particles centered at (x,y,z).
const spawnSparkles = React.useCallback((x, y, z, t, n = 24) => {
const s = stateRef.current;
for (let i = 0; i < n; i++) {
const ang = Math.random() * Math.PI * 2;
const speed = 22 + Math.random() * 48;
s.particles.push({
x: x + (Math.random() - 0.5) * 4,
y: y + (Math.random() - 0.5) * 6,
z: z + (Math.random() - 0.5) * 4,
vx: Math.cos(ang) * speed,
vy: 18 + Math.random() * 36,
vz: Math.sin(ang) * speed,
t,
life: 0.55 + Math.random() * 0.45,
r: 1 + Math.random() * 1.4,
});
}
}, []);
React.useEffect(() => {
let raf;
const tick = (now) => {
const s = stateRef.current;
const dt = Math.min((now - s.last) / 1000, 0.05);
s.last = now;
const mVar = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--motion"));
const m = isFinite(mVar) ? mVar : 1;
if (m > 0.01) {
s.acc += dt * m;
const totalDur = routineDuration(s.routine);
if (s.acc >= totalDur) {
s.acc -= totalDur;
s.cycleIdx = (s.cycleIdx + 1) % VARIANTS.length;
s.routine = VARIANTS[s.cycleIdx];
// Reset prevPhaseType so the first phase of new cycle triggers as "just entered"
s.prevPhaseType = null;
}
const newPose = getArmPoseInRoutine(s.routine, s.acc);
s.trail.push({ x: newPose.x, y: newPose.y, z: newPose.z, t: now });
while (s.trail.length && now - s.trail[0].t > 1300) s.trail.shift();
// Frustration meter
const isFrust = FRUSTRATED_TYPES.has(newPose.phaseType);
s.frustration = isFrust ? Math.min(1, s.frustration + dt * 6)
: Math.max(0, s.frustration - dt * 2.5);
const justEntered = s.prevPhaseType !== newPose.phaseType;
if (justEntered) {
if (newPose.phaseType === "grip-close" && newPose.cubeIdx >= 0) {
s.cubes[newPose.cubeIdx].mode = "held";
s.cubes[newPose.cubeIdx].tilt = 0;
s.cubes[newPose.cubeIdx].vRot = 0;
}
if (newPose.phaseType === "release" && newPose.cubeIdx >= 0) {
const c = s.cubes[newPose.cubeIdx];
c.mode = "rest";
c.x = newPose.x; c.y = newPose.y; c.z = newPose.z;
c.vx = c.vy = c.vz = 0; c.tilt = 0; c.vRot = 0;
}
if (newPose.phaseType === "disas-setup") {
s.cubes.forEach((c, i) => {
c.x = STACK_X;
c.y = CUBE_TOP_Y + i * CUBE_SIZE;
c.z = STACK_Z;
c.vx = c.vy = c.vz = 0;
c.vRot = 0;
c.tilt = 0;
c.mode = "rest";
c.opacity = 1;
});
}
if (newPose.phaseType === "pisa-collapse") {
s.cubes.forEach((c) => {
if (c.mode !== "rest") return;
if (Math.abs(c.x - STACK_X) > 36 || Math.abs(c.z - STACK_Z) > 20) return;
c.mode = "falling";
const h = Math.max(0, c.y - CUBE_TOP_Y);
c.vx = 32 + h * 0.9 + Math.random() * 18;
c.vy = 12 + h * 0.30 + Math.random() * 10;
c.vz = (Math.random() - 0.5) * 30;
c.vRot = 180 + Math.random() * 180;
});
}
if (newPose.phaseType === "juggle-toss" && newPose.cubeIdx >= 0) {
const c = s.cubes[newPose.cubeIdx];
const tossDur = (s.routine[newPose.phase].d
+ (s.routine[newPose.phase + 1]?.d ?? 0.55)) * 1000;
c.mode = "tossed";
c.tossT0 = now;
c.tossDur = tossDur;
c.tossStart = { x: c.x, y: c.y, z: c.z };
const catchPhase = s.routine[newPose.phase + 2];
c.tossEnd = catchPhase
? { x: catchPhase.x, y: catchPhase.y, z: catchPhase.z }
: { x: c.x, y: c.y, z: c.z };
c.tossApex = Math.max(c.y, c.tossEnd.y) + 55;
}
if (newPose.phaseType === "juggle-catch" && newPose.cubeIdx >= 0) {
const c = s.cubes[newPose.cubeIdx];
c.mode = "held";
c.tilt = 0;
c.vRot = 0;
}
if (newPose.phaseType === "kd-tap") {
// Push the leftmost resting cube near the stack +x; collisions
// cascade the rest of the row via the cube-cube pass below.
const candidates = s.cubes.filter(c =>
c.mode === "rest" && Math.abs(c.z - STACK_Z) < 14
&& c.x > BENCH.x0 - 4 && c.x < BENCH.x1 + 4);
if (candidates.length) {
const leftmost = candidates.reduce((a, b) => a.x < b.x ? a : b);
leftmost.mode = "falling";
leftmost.vx = 120;
leftmost.vy = 14;
leftmost.vz = (Math.random() - 0.5) * 6;
leftmost.vRot = 260;
}
}
if (newPose.phaseType === "kd-swing") {
// Horizontal sweep — push cubes along the swing direction.
const phase = s.routine[newPose.phase];
const prevPhase = s.routine[newPose.phase - 1] || phase;
const dirX = phase.x - prevPhase.x;
const dirZ = phase.z - prevPhase.z;
const mag = Math.hypot(dirX, dirZ) || 1;
const ux = dirX / mag, uz = dirZ / mag;
s.cubes.forEach((c) => {
if (c.mode === "rest"
&& Math.abs(c.x - STACK_X) < 60
&& Math.abs(c.z - STACK_Z) < 30) {
c.mode = "falling";
const h = Math.max(0, c.y - CUBE_TOP_Y);
const speed = 90 + Math.random() * 40 + h * 0.4;
c.vx = ux * speed + (Math.random() - 0.5) * 22;
c.vy = 28 + Math.random() * 30 + h * 0.55;
c.vz = uz * speed + (Math.random() - 0.5) * 40;
c.vRot = (Math.random() - 0.5) * 320;
}
});
}
if (newPose.phaseType === "magic-vanish" && newPose.cubeIdx >= 0) {
s.cubes[newPose.cubeIdx].mode = "vanishing";
spawnSparkles(newPose.x, newPose.y, newPose.z, now, 22);
}
if (newPose.phaseType === "magic-traverse" && newPose.cubeIdx >= 0) {
// Snap cube to destination, invisible.
const c = s.cubes[newPose.cubeIdx];
const dest = dStack(newPose.cubeIdx);
c.mode = "vanished";
c.opacity = 0;
c.x = dest.x; c.y = dest.y; c.z = dest.z;
c.vx = c.vy = c.vz = 0; c.tilt = 0; c.vRot = 0;
}
if (newPose.phaseType === "magic-reveal" && newPose.cubeIdx >= 0) {
const c = s.cubes[newPose.cubeIdx];
const dest = dStack(newPose.cubeIdx);
c.mode = "revealing";
c.x = dest.x; c.y = dest.y; c.z = dest.z;
spawnSparkles(dest.x, dest.y + 4, dest.z, now, 32);
}
if (newPose.phaseType === "magic-retract" && newPose.cubeIdx >= 0) {
const c = s.cubes[newPose.cubeIdx];
c.mode = "rest";
c.opacity = 1;
}
if (newPose.phaseType === "throw-recoil" && newPose.cubeIdx >= 0) {
// Fling: cube leaves the gripper with arc velocity toward a target.
const c = s.cubes[newPose.cubeIdx];
if (c.mode === "held") {
const throwTargets = [
{ x: SHOULDER.x + 200, z: SHOULDER.z + 28 },
{ x: SHOULDER.x + 155, z: SHOULDER.z + 58 },
{ x: SHOULDER.x + 195, z: SHOULDER.z - 26 },
{ x: SHOULDER.x + 130, z: SHOULDER.z + 8 },
];
const tg = throwTargets[newPose.cubeIdx];
const T = 1.0;
const gA = 260;
const restY = CUBE_TOP_Y;
c.mode = "falling";
c.vx = (tg.x - c.x) / T + (Math.random() - 0.5) * 14;
c.vy = (restY - c.y + 0.5 * gA * T * T) / T + Math.random() * 10;
c.vz = (tg.z - c.z) / T + (Math.random() - 0.5) * 14;
c.vRot = (Math.random() - 0.5) * 540;
}
}
if (newPose.phaseType === "kd-strike") {
// Crush from above — scatter cubes radially from strike center.
s.cubes.forEach((c) => {
if (c.mode === "rest"
&& Math.abs(c.x - STACK_X) < 60
&& Math.abs(c.z - STACK_Z) < 30) {
c.mode = "falling";
const dx = c.x - STACK_X;
const dz = c.z - STACK_Z;
const dist = Math.hypot(dx, dz);
let ux, uz;
if (dist < 0.5) {
const ang = Math.random() * Math.PI * 2;
ux = Math.cos(ang); uz = Math.sin(ang);
} else {
ux = dx / dist; uz = dz / dist;
}
const speed = 95 + Math.random() * 45;
c.vx = ux * speed + (Math.random() - 0.5) * 25;
c.vy = 32 + Math.random() * 22;
c.vz = uz * speed + (Math.random() - 0.5) * 25;
c.vRot = (Math.random() - 0.5) * 360;
}
});
}
if (newPose.phaseType === "reset") {
s.cubes.forEach((c) => {
c.resetFrom = { x: c.x, y: c.y, z: c.z, tilt: c.tilt };
c.mode = "resetting";
});
}
}
// Per-frame cube physics + held tracking
s.cubes.forEach((c, i) => {
if (c.mode === "held") {
c.x = newPose.x; c.y = newPose.y; c.z = newPose.z;
c.opacity = 1;
} else if (c.mode === "vanishing") {
// Still in the gripper, fading out
c.x = newPose.x; c.y = newPose.y; c.z = newPose.z;
c.opacity = Math.max(0, 1 - newPose.local);
} else if (c.mode === "vanished") {
c.opacity = 0;
} else if (c.mode === "revealing") {
c.opacity = Math.min(1, newPose.local);
} else if (c.mode === "tossed") {
const t = Math.min(1, (now - c.tossT0) / c.tossDur);
const st = c.tossStart, en = c.tossEnd;
c.x = st.x + (en.x - st.x) * t;
c.z = st.z + (en.z - st.z) * t;
const linY = st.y + (en.y - st.y) * t;
const peakAdd = c.tossApex - Math.max(st.y, en.y);
c.y = linY + 4 * peakAdd * t * (1 - t);
c.tilt += 540 * dt;
} else if (c.mode === "falling") {
const g = 260;
c.vy -= g * dt;
c.x += c.vx * dt;
c.y += c.vy * dt;
c.z += c.vz * dt;
c.tilt += c.vRot * dt;
const inBenchXZ = (c.x >= BENCH.x0 - 4 && c.x <= BENCH.x1 + 4 && c.z >= BENCH.z0 - 4 && c.z <= BENCH.z1 + 4);
const inStackXZ = (c.x >= STACK_PAD.x0 - 4 && c.x <= STACK_PAD.x1 + 4 && c.z >= STACK_PAD.z0 - 4 && c.z <= STACK_PAD.z1 + 4);
const restY = (inBenchXZ || inStackXZ) ? CUBE_TOP_Y : FLOOR_Y + CUBE_HALF;
if (c.y < restY) {
c.y = restY;
c.vy = -c.vy * 0.30;
c.vx *= 0.62;
c.vz *= 0.62;
c.vRot *= 0.45;
if (Math.abs(c.vy) < 4 && Math.abs(c.vx) < 3 && Math.abs(c.vz) < 3) {
c.vx = c.vy = c.vz = 0; c.vRot = 0; c.mode = "settled";
}
}
c.x = _clamp(c.x, -180, 220);
c.z = _clamp(c.z, -85, 80);
} else if (c.mode === "resetting") {
const e = _easeInOut(newPose.local);
const src = CUBES_INIT[i];
const from = c.resetFrom;
c.x = from.x + (src.srcX - from.x) * e;
c.y = from.y + (src.srcY - from.y) * e;
c.z = from.z + (src.srcZ - from.z) * e;
c.y += Math.sin(e * Math.PI) * 14;
c.tilt = from.tilt * (1 - e);
if (e >= 0.98) {
c.mode = "rest";
c.x = src.srcX; c.y = src.srcY; c.z = src.srcZ; c.tilt = 0;
}
}
});
// Pisa wobble while the arm is watching — animate a small tilt
// on each stack cube so it looks live.
if (newPose.phaseType === "pisa-watch") {
const t = newPose.local;
s.cubes.forEach((c) => {
if (c.mode !== "rest") return;
if (Math.abs(c.x - STACK_X) > 36 || Math.abs(c.z - STACK_Z) > 20) return;
const h = Math.max(0, c.y - CUBE_TOP_Y);
const amp = 0.6 + h * 0.04;
c.tilt = amp * Math.sin(t * Math.PI * 6) * (1 + t * 0.8);
});
}
// Cube-cube collisions — falling cubes wake resting cubes.
for (let i = 0; i < s.cubes.length; i++) {
const a = s.cubes[i];
if (a.mode !== "falling") continue;
for (let j = 0; j < s.cubes.length; j++) {
if (i === j) continue;
const b = s.cubes[j];
if (b.mode !== "rest" && b.mode !== "settled") continue;
const dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
const minDist = CUBE_SIZE * 0.92;
const d2 = dx*dx + dy*dy + dz*dz;
if (d2 < minDist * minDist && d2 > 0.05) {
const d = Math.sqrt(d2);
const overlap = minDist - d;
const nx = dx / d, ny = dy / d, nz = dz / d;
// Push apart
a.x += nx * overlap * 0.5;
a.y += ny * overlap * 0.5;
a.z += nz * overlap * 0.5;
b.x -= nx * overlap * 0.5;
b.y -= ny * overlap * 0.5;
b.z -= nz * overlap * 0.5;
// Wake b
b.mode = "falling";
b.vx = a.vx * 0.55 + nx * -22;
b.vy = Math.max(12, a.vy * 0.5);
b.vz = a.vz * 0.55 + nz * -22;
b.vRot = (Math.random() - 0.5) * 240;
// Damp a
a.vx *= 0.55;
a.vy *= 0.85;
a.vz *= 0.55;
}
}
}
// Update particles
const live = [];
for (const p of s.particles) {
const age = (now - p.t) / 1000;
if (age < p.life) {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
p.vy -= 70 * dt;
p.vx *= 1 - dt * 0.8;
p.vz *= 1 - dt * 0.8;
live.push(p);
}
}
s.particles = live;
s.prevPhaseType = newPose.phaseType;
setPose({ ...newPose, _now: now });
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
const s = stateRef.current;
const arm = solveArm(pose.x, pose.y, pose.z);
const now = pose._now || performance.now();
const cubes = s.cubes;
// "Held" for rendering purposes includes the fading-out cube and the
// mid-toss cube — they're rendered on top of the arm.
const heldIdx = cubes.findIndex((c) =>
c.mode === "held" || c.mode === "vanishing" || c.mode === "tossed");
const renderCubes = cubes.map((c, i) => ({ c, i }))
.filter(({ c }) =>
c.mode !== "held" && c.mode !== "vanishing" && c.mode !== "vanished"
&& c.mode !== "tossed")
.sort((a, b) => b.c.z - a.c.z);
const activeSourceIdx = (PICKUP_TYPES.has(pose.phaseType) && pose.cubeIdx >= 0) ? pose.cubeIdx : -1;
const stackActive = PLACE_TYPES.has(pose.phaseType);
const isKnockdown = pose.phaseType && pose.phaseType.startsWith("kd-");
const isInspect = pose.phaseType && pose.phaseType.startsWith("inspect-");
const isThrow = pose.phaseType && pose.phaseType.startsWith("throw-");
const isMagic = pose.phaseType && pose.phaseType.startsWith("magic-");
const isHi = pose.phaseType && pose.phaseType.startsWith("hi-");
const isPresent = pose.phaseType && pose.phaseType.startsWith("present-");
const isDance = pose.phaseType && pose.phaseType.startsWith("dance-");
const isPisa = pose.phaseType && pose.phaseType.startsWith("pisa-");
const isJuggle = pose.phaseType && pose.phaseType.startsWith("juggle-");
const yawDeg = arm.yaw * 180 / Math.PI;
const frustration = s.frustration;
const warn = frustration > 0.05;
// Status line text + color
let statusLine, statusColor;
if (frustration > 0.05) {
statusLine = "▲ GRIP-SLIP · RETRY";
statusColor = "#f5a623";
} else if (pose.phaseType === "kd-strike") {
statusLine = "× IMPACT";
statusColor = "#ef4444";
} else if (isKnockdown) {
statusLine = "× DEMOLITION";
statusColor = "#ef4444";
} else if (pose.phaseType === "throw-recoil" || (isThrow && pose.g < 0.5)) {
statusLine = "↗ FLING";
statusColor = "#a78bfa";
} else if (isHi) {
statusLine = "◐ HELLO";
statusColor = "#7dd3fc";
} else if (isPresent) {
statusLine = "◐ PRESENT";
statusColor = "#7dd3fc";
} else if (isDance) {
statusLine = "♪ DANCE";
statusColor = "#f0abfc";
} else if (pose.phaseType === "pisa-watch") {
statusLine = "⚠ UNSTABLE";
statusColor = "#f5a623";
} else if (pose.phaseType === "pisa-collapse") {
statusLine = "× COLLAPSE";
statusColor = "#ef4444";
} else if (pose.phaseType === "juggle-toss" || pose.phaseType === "juggle-arc") {
statusLine = "↑ JUGGLE";
statusColor = "#facc15";
} else if (pose.phaseType === "juggle-catch") {
statusLine = "● CATCH";
statusColor = "var(--accent)";
} else if (pose.phaseType === "kd-tap") {
statusLine = "⋯ DOMINO";
statusColor = "#fb923c";
} else if (pose.phaseType === "magic-vanish") {
statusLine = "✦ VANISH";
statusColor = "#c084fc";
} else if (pose.phaseType === "magic-reveal") {
statusLine = "✦ REVEAL";
statusColor = "#c084fc";
} else if (pose.phaseType === "magic-traverse") {
statusLine = "◌ EMPTY";
statusColor = "#c084fc";
} else if (isMagic) {
statusLine = "✦ FLOURISH";
statusColor = "#c084fc";
} else if (isInspect) {
statusLine = "◇ INSPECTING";
statusColor = "#7dd3fc";
} else if (pose.g > 0.5) {
statusLine = "● GRIP-CLOSED";
statusColor = "var(--accent)";
} else {
statusLine = "○ GRIP-OPEN";
statusColor = "var(--fg-4)";
}
// Mode line reflects the current routine variant.
const variantLabels = [
"tower → sweep",
"tower (retry) → sweep",
"2×2 grid → crush",
"tower + inspect → sweep",
"line → push-off",
"couldn't be bothered",
"now you see it…",
"hello",
"juggle act",
"pyramid → crush",
"sort odds & evens → sweep",
"for the viewer",
"dance routine",
"disassembly",
"tower of pisa → collapse",
"domino chain",
];
let modeLine = "arm-04 · " + (variantLabels[s.cycleIdx] || "stack + demo");
if (isKnockdown && pose.phaseType !== "kd-tap") modeLine = "arm-04 · DEMOLITION";
else if (pose.phaseType === "kd-tap") modeLine = "arm-04 · domino chain";
else if (isInspect) modeLine = "arm-04 · inspect routine";
else if (isThrow) modeLine = "arm-04 · fling mode";
else if (isMagic) modeLine = "arm-04 · stage magic";
else if (isHi) modeLine = "arm-04 · greeting";
else if (isPresent) modeLine = "arm-04 · for the viewer";
else if (isDance) modeLine = "arm-04 · dance routine";
else if (isPisa) modeLine = "arm-04 · tower of pisa";
else if (isJuggle) modeLine = "arm-04 · juggle act";
else if (frustration > 0.05) modeLine = "arm-04 · retry sequence";
return (
);
}
Object.assign(window, { RobotArm });