// 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 ( OW · ROBOTICS {modeLine} YAW {(yawDeg >= 0 ? "+" : "")}{yawDeg.toFixed(0).padStart(3, "0")}° {" "} X{Math.round(pose.x).toString().padStart(3, "0")} {" "} Z{Math.round(pose.z).toString().padStart(3, "0")} {statusLine} {/* floor depth lines */} {[-40, -20, 0, 20, 40].map((zz) => { const a = project(-110, FLOOR_Y, zz); const b = project(170, FLOOR_Y, zz); return ; })} {(() => { const a = project(-150, FLOOR_Y, 60); const b = project(200, FLOOR_Y, 60); return ; })()} {SOURCES.map((src, i) => ( 0.05} /> ))} {/* trail */} {s.trail.map((p, i) => { const age = (now - p.t) / 1300; if (age >= 1) return null; const proj = project(p.x, p.y, p.z); return ; })} {/* static + falling cubes (back to front) */} {renderCubes.map(({ c, i }) => ( ))} {/* Robot base + column + yaw turret */} {(() => { const baseTop = FLOOR_Y + 26; const baseSurface = ( ); const col1 = project(SHOULDER.x - 9, baseTop, SHOULDER.z + 9); const col2 = project(SHOULDER.x + 9, baseTop, SHOULDER.z + 9); const col3 = project(SHOULDER.x + 9, SHOULDER.y - 8, SHOULDER.z + 9); const col4 = project(SHOULDER.x - 9, SHOULDER.y - 8, SHOULDER.z + 9); const colBack1 = project(SHOULDER.x + 9, baseTop, SHOULDER.z - 9); const colBack2 = project(SHOULDER.x + 9, SHOULDER.y - 8, SHOULDER.z - 9); const turretCenter = project(SHOULDER.x, SHOULDER.y - 6, SHOULDER.z); const nx = SHOULDER.x + Math.cos(arm.yaw) * 11; const nz = SHOULDER.z + Math.sin(arm.yaw) * 11; const np = project(nx, SHOULDER.y - 6, nz); const led = project(SHOULDER.x + 24, baseTop + 4, SHOULDER.z + 22); return ( {baseSurface} ); })()} {/* Held cube — on top of arm */} {heldIdx >= 0 && (() => { const c = cubes[heldIdx]; if ((c.opacity ?? 1) < 0.01) return null; return ( ); })()} {/* Sparkle particles — drawn on top of everything */} {s.particles.map((p, i) => { const age = (now - p.t) / 1000; const a = 1 - age / p.life; const proj = project(p.x, p.y, p.z); return ( ); })} {/* Frustration "!" — floats above the arm during retry */} {frustration > 0.15 && (() => { const eff = project(pose.x, pose.y + 35, pose.z); return ( ! ); })()} ); } Object.assign(window, { RobotArm });