ROBOTIC ARM CORAL 3x3

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/670a061d709e44c3/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>NONET · Face-Driven Arm Wall</title>
<style>
  :root{ --ink:#111; --mute:#8d8d8d; --line:#ececec; --soft:#f5f5f5; }
  html,body{margin:0;height:100%;background:#040405;overflow:hidden;
    font-family:ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;}
  canvas{display:block}
  #panel{position:fixed;top:16px;left:16px;width:262px;max-height:calc(100vh - 32px);overflow:auto;
    background:#fff;color:var(--ink);border-radius:10px;padding:14px 14px 12px;font-size:12px;
    box-shadow:0 10px 40px rgba(0,0,0,.45);box-sizing:border-box}
  h1{font-size:13px;letter-spacing:.14em;margin:0;font-weight:600}
  .sub{color:var(--mute);margin:2px 0 10px}
  .status{display:flex;align-items:center;gap:7px;padding:7px 9px;background:var(--soft);border-radius:7px}
  .dot{width:7px;height:7px;border-radius:50%;background:#ccc;flex:none}
  .dot.on{background:#1a1a1a}
  #ev{height:14px;color:var(--mute);font-size:11px;margin:6px 2px 0;white-space:nowrap;overflow:hidden}
  .label{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);margin:12px 0 6px}
  .seg{display:flex;gap:4px}
  .seg button,.beh button{flex:1;border:1px solid var(--line);background:#fff;color:var(--ink);
    border-radius:6px;padding:6px 8px;font:inherit;cursor:pointer;text-align:left}
  .seg button{text-align:center}
  .seg button.on,.beh button.on{background:#111;color:#fff;border-color:#111}
  .beh{display:grid;grid-template-columns:1fr 1fr;gap:4px}
  .chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
  .chips:empty{display:none}
  .chips button{border:1px solid var(--line);background:#fff;color:var(--ink);border-radius:20px;padding:3px 9px;font:inherit;font-size:11px;cursor:pointer}
  .chips button.on{background:#111;color:#fff;border-color:#111}
  .chips button i{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;vertical-align:1px}
  .beh button span{color:var(--mute);margin-right:6px}
  .beh button.on span{color:#aaa}
  #info{color:#444;line-height:1.45;margin-top:8px;min-height:64px}
  .bars{display:grid;grid-template-columns:62px 1fr;gap:4px 8px;align-items:center}
  .bar{height:4px;background:var(--line);border-radius:2px;position:relative;overflow:hidden}
  .bar i{position:absolute;top:0;bottom:0;left:0;background:#111;border-radius:2px}
  .bars span{color:#555}
  .toggles{display:flex;flex-wrap:wrap;gap:4px}
  .toggles button{border:1px solid var(--line);background:#fff;border-radius:20px;padding:4px 10px;font:inherit;cursor:pointer}
  .toggles button.on{background:#111;color:#fff;border-color:#111}
  #help{color:var(--mute);line-height:1.55;margin-top:10px;font-size:11px}
  #video{position:fixed;right:16px;bottom:16px;width:200px;border-radius:8px;transform:scaleX(-1);
    border:3px solid #fff;box-shadow:0 10px 40px rgba(0,0,0,.45);background:#fff}
  #video.hidden{display:none}
  kbd{font:inherit;background:var(--soft);border:1px solid var(--line);border-radius:3px;padding:0 4px}
</style>
</head>
<body>
<div id="panel">
  <h1>NONET</h1>
  <div class="sub">9 Arme · Wandmontage · 7 Achsen · 12 LEDs</div>
  <div class="status"><div class="dot" id="dot"></div><div id="status">Initialisiere …</div></div>
  <div id="ev"></div>

  <div class="label">Quelle</div>
  <div class="seg" id="srcSeg">
    <button data-src="camera">Kamera</button>
    <button data-src="mouse">Maus</button>
  </div>

  <div class="label">Verhalten</div>
  <div class="beh" id="beh"></div>
  <div class="chips" id="var"></div>
  <div id="info"></div>

  <div class="label">Charakter der Arme</div>
  <div class="chips" id="chr"></div>

  <div class="label">Signale</div>
  <div class="bars" id="bars"></div>

  <div class="label">Ansicht</div>
  <div class="toggles">
    <button id="tParallax" class="on">Parallax</button>
    <button id="tGlare" class="on">Blendung</button>
    <button id="tCables" class="on">Kabel</button>
    <button id="tLights" class="on">Raumlicht</button>
    <button id="tVideo" class="on">Video</button>
    <button id="tDebug">Debug</button>
  </div>
  <div id="help"></div>
</div>
<video id="video" playsinline muted></video>

<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>

<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

const V = (x = 0, y = 0, z = 0) => new THREE.Vector3(x, y, z);

/* =====================================================================
   CONFIG
   Gelenke (Index):  0 Basis-Drehung · 1 Schulter · 2 Oberarm-Torsion
                     3 Ellbogen · 4 Unterarm-Torsion · 5 Handgelenk · 6 Ring-Rotation
   ===================================================================== */
const CONFIG = {
  grid: { cols: 3, rows: 3, dx: 1.45, dy: 1.45, cy: 0.2 },
  arm: {
    h0: 0.22, L1: 0.85, L2: 0.78, L3: 0.26, ringR: 0.15, leds: 12,
    rest:  [0, 0.75, 0, -1.35, 0, 0.6, 0],
    lim:   [[-2.4, 2.4], [-0.7, 1.7], [-2.2, 2.2], [-2.7, 0.35], [-2.2, 2.2], [-2.2, 2.2], [-3.1, 3.1]],
    prefW: [0.06, 0.04, 0.18, 0.04, 0.18, 0.05, 1.0],   // Haltungs-Gewichte (Redundanz)
    servoK: [1, 1, 0.8, 1.1, 0.8, 1.4, 0.7],             // Steifigkeit je Gelenk
    servoV: [1, 1, 1.4, 1.2, 1.4, 1.6, 1.8],             // Max-Geschwindigkeit je Gelenk
  },
  ik: { iters: 3, lambda: 0.08, eps: 1e-3, maxStep: 0.22, maxPosErr: 0.6 },
  view: { camPos: V(0, 0.35, 7.2), lookAt: V(0, 0.2, 0.7), fov: 46, parallax: V(1.2, 0.7, -0.9) },
  face: {
    spanX: 5.2, spanY: 3.4, centerY: 0.25, zNear: 3.6, zFar: 7.4,
    depthNearCm: 35, depthFarCm: 85, gazePlaneZ: 1.3,
    mirrorX: true, invertYaw: false, invertPitch: false, invertRoll: false, swapEyes: false,
    lostGrace: 0.6,
  },
  cable: { segments: 16, iters: 12, damping: 0.985, gravity: 6.0 },
  led: { attack: 30, release: 6, off: new THREE.Color(0.025, 0.024, 0.022), boost: 2.8, light: 4, lightDist: 4.5,
         eyeClosedBoost: 1.1, squintBoost: 0.45, wideDim: 0.6 },   // invertierte Lider: Augen zu = heller, weit auf = dunkler
  fx: { bloomBase: 0.75, bloomGlare: 0.55, bloomRadius: 0.5, bloomThreshold: 0.25, exposureGlare: 0.08, sprite: 0.45 },
  flight: { nearStart: 0.5, nearFull: 0.92, lateral: 2.6 },          // Nähe → Ringköpfe flüchten
};
const FLOOR_Y = -2.6;

const MP_VER   = '0.10.14';
const MP_URL   = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MP_VER}/vision_bundle.mjs`;
const MP_WASM  = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MP_VER}/wasm`;
const MP_MODEL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task';

/* =====================================================================
   Utils
   ===================================================================== */
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (rate, dt) => 1 - Math.exp(-rate * dt);
const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
function mulberry32(s) { return () => { s |= 0; s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s);
  t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
function hash1(n) { const s = Math.sin(n * 127.1) * 43758.5453; return s - Math.floor(s); }
function noise1(x, seed = 0) { const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f);
  return lerp(hash1(i + seed * 57.3), hash1(i + 1 + seed * 57.3), u) * 2 - 1; }
function heart(x) { let d = x - Math.floor(x); if (d > 0.5) d -= 1;
  return Math.min(1, Math.exp(-((d * 9) ** 2)) + 0.55 * Math.exp(-(((d - 0.2) * 9) ** 2))); }
const AX = V(1, 0, 0), AY = V(0, 1, 0), WORLD_X = V(1, 0, 0), WORLD_UP = V(0, 1, 0);
// Wandmontage: lokales +Y = Wandnormale (+Z Welt), lokales +Z = Welt-oben
const WALL_Q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(V(-1, 0, 0), V(0, 0, 1), V(0, 1, 0)));

class EventBus {
  constructor() { this.m = new Map(); }
  on(e, f) { if (!this.m.has(e)) this.m.set(e, []); this.m.get(e).push(f); return () => this.off(e, f); }
  off(e, f) { const a = this.m.get(e); if (a) this.m.set(e, a.filter(x => x !== f)); }
  emit(e, d) { (this.m.get(e) || []).forEach(f => f(d)); this.m.get('*')?.forEach(f => f(e)); }
}

/* =====================================================================
   FACE SOURCES → einheitliches raw-Objekt oder null
   { nx, ny, depth01, yaw, pitch, roll, jaw, smile, blinkL, blinkR, squint, brow, pucker }
   ===================================================================== */
class MediaPipeSource {
  constructor(video) { this.kind = 'camera'; this.video = video; this.ready = false; this.lastTime = -1; this.last = null;
    this._m = new THREE.Matrix4(); this._r = new THREE.Matrix4(); this._q = new THREE.Quaternion(); this._f = V(); this._u = V(); }
  async init(onStatus) {
    onStatus('Lade MediaPipe …');
    const vision = await import(MP_URL);
    const fileset = await vision.FilesetResolver.forVisionTasks(MP_WASM);
    const opts = delegate => ({ baseOptions: { modelAssetPath: MP_MODEL, delegate }, runningMode: 'VIDEO',
      numFaces: 1, outputFaceBlendshapes: true, outputFacialTransformationMatrixes: true });
    try { this.lm = await vision.FaceLandmarker.createFromOptions(fileset, opts('GPU')); }
    catch (e) { this.lm = await vision.FaceLandmarker.createFromOptions(fileset, opts('CPU')); }
    onStatus('Kamera anfragen …');
    this.stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: 'user' }, audio: false });
    this.video.srcObject = this.stream; await this.video.play();
    this.ready = true;
  }
  read(nowMs) {
    if (!this.ready || this.video.readyState < 2) return null;
    if (this.video.currentTime === this.lastTime) return this.last;
    this.lastTime = this.video.currentTime;
    const res = this.lm.detectForVideo(this.video, nowMs);
    this.last = (res.faceLandmarks && res.faceLandmarks.length) ? this.convert(res) : null;
    return this.last;
  }
  convert(res) {
    const F = CONFIG.face, L = res.faceLandmarks[0], c = L[168];
    const nx = (F.mirrorX ? (0.5 - c.x) : (c.x - 0.5)) * 2, ny = (0.5 - c.y) * 2;
    const bs = {}; (res.faceBlendshapes?.[0]?.categories || []).forEach(k => bs[k.categoryName] = k.score);
    let blinkL = bs.eyeBlinkLeft || 0, blinkR = bs.eyeBlinkRight || 0;
    if (F.swapEyes) [blinkL, blinkR] = [blinkR, blinkL];
    let yaw = 0, pitch = 0, roll = 0, depth01 = null;
    const M = res.facialTransformationMatrixes?.[0];
    if (M) {
      this._m.fromArray(M.data); this._r.extractRotation(this._m); this._q.setFromRotationMatrix(this._r);
      const f = this._f.set(0, 0, 1).applyQuaternion(this._q), u = this._u.set(0, 1, 0).applyQuaternion(this._q);
      const hx = -f.x, hy = f.y, hz = -f.z;     // Kameraraum → Welt (Blick in Kamera = Blick zur Wand)
      yaw = Math.atan2(hx, -hz); pitch = Math.asin(clamp(hy, -1, 1)); roll = Math.atan2(-u.x, u.y);
      const zcm = -M.data[14];
      if (zcm > 5) depth01 = 1 - clamp((zcm - F.depthNearCm) / (F.depthFarCm - F.depthNearCm), 0, 1);
    }
    if (depth01 === null) { const w = Math.hypot(L[454].x - L[234].x, L[454].y - L[234].y); depth01 = clamp((w - 0.12) / 0.23, 0, 1); }
    return { nx, ny, depth01, yaw, pitch, roll, jaw: bs.jawOpen || 0,
      smile: ((bs.mouthSmileLeft || 0) + (bs.mouthSmileRight || 0)) / 2,
      squint: ((bs.eyeSquintLeft || 0) + (bs.eyeSquintRight || 0)) / 2,
      wide: ((bs.eyeWideLeft || 0) + (bs.eyeWideRight || 0)) / 2,
      blinkL, blinkR, brow: bs.browInnerUp || 0, pucker: bs.mouthPucker || 0 };
  }
}

class MouseSource {
  constructor() {
    this.kind = 'mouse'; this.ready = true; this.nx = 0; this.ny = 0; this.depth = 0.45; this.inside = true; this.hidden = false;
    this.keys = new Set();
    addEventListener('pointermove', e => { this.nx = (e.clientX / innerWidth) * 2 - 1; this.ny = -((e.clientY / innerHeight) * 2 - 1); this.inside = true; });
    addEventListener('wheel', e => { this.depth = clamp(this.depth - e.deltaY * 0.0012, 0, 1); }, { passive: true });
    addEventListener('keydown', e => { this.keys.add(e.code); if (e.code === 'KeyH') this.hidden = !this.hidden; if (e.code === 'Space' || e.code.startsWith('Arrow')) e.preventDefault(); });
    addEventListener('keyup', e => this.keys.delete(e.code));
    document.addEventListener('pointerleave', () => this.inside = false);
  }
  read() {
    if (!this.inside || this.hidden) return null;
    const on = c => this.keys.has(c) ? 1 : 0;
    return { nx: this.nx, ny: this.ny, depth01: this.depth,
      yaw: (on('ArrowRight') - on('ArrowLeft')) * 0.6,
      pitch: (on('ArrowUp') - on('ArrowDown')) * 0.4 - on('KeyN') * 0.5,
      roll: (on('KeyE') - on('KeyQ')) * 0.45,
      jaw: on('Space') * 0.85, smile: on('KeyS'), squint: on('KeyX') * 0.8, wide: on('KeyO') * 0.5,
      blinkL: Math.max(on('KeyB'), on('KeyA')), blinkR: on('KeyB'),
      brow: on('KeyW'), pucker: on('KeyP') };
  }
}

/* =====================================================================
   FACE STATE — Glättung, Weltabbildung, Gesten-Events, Verlauf
   Events: faceFound faceLost blink eyesClose eyesOpen wink mouthOpen mouthClose smile squint nod shake
   ===================================================================== */
class FaceState {
  constructor(bus) {
    this.bus = bus; this.time = 0; this.present = false; this.presence = 0; this.lost = 0; this.lostTime = 999;
    Object.assign(this, { nx: 0, ny: 0, depth: 0.4, yaw: 0, pitch: 0, roll: 0, jaw: 0, smile: 0, squint: 0, wide: 0,
      blinkL: 0, blinkR: 0, blink: 0, brow: 0, pucker: 0, expr: 0, speed: 0, yawVel: 0, pitchVel: 0 });
    this.pos = V(0, CONFIG.face.centerY, CONFIG.face.zFar);
    this.headDir = V(0, 0, -1); this.gaze = V(); this.vel = V(); this._p = V();
    this.flags = { eyesClosed: false, wink: false, mouthOpen: false, smiling: false, squinting: false };
    this.closedAt = 0; this.nodArmed = true; this.shakeSign = 0; this.shakeCount = 0; this.shakeT = 0;
    this.hist = [];
  }
  update(raw, dt) {
    const F = CONFIG.face; this.time += dt;
    if (raw) { this.lost = 0; if (!this.present) { this.present = true; this.bus.emit('faceFound', this); } }
    else if (this.present) { this.lost += dt; if (this.lost > F.lostGrace) { this.present = false; this.bus.emit('faceLost', this); } }
    this.lostTime = this.present ? 0 : this.lostTime + dt;
    this.presence = lerp(this.presence, this.present ? 1 : 0, smooth(this.present ? 2.8 : 1.1, dt));
    const py = this.yaw, pp = this.pitch;

    if (raw) {
      const a = smooth(12, dt), fast = smooth(30, dt), rot = smooth(12, dt);
      this.nx = lerp(this.nx, raw.nx, a); this.ny = lerp(this.ny, raw.ny, a);
      this.depth = lerp(this.depth, raw.depth01, smooth(6, dt));
      this.yaw   = lerp(this.yaw,   raw.yaw   * (F.invertYaw ? -1 : 1), rot);
      this.pitch = lerp(this.pitch, raw.pitch * (F.invertPitch ? -1 : 1), rot);
      this.roll  = lerp(this.roll,  raw.roll  * (F.invertRoll ? -1 : 1), rot);
      this.jaw    = lerp(this.jaw,    smoothstep(0.04, 0.55, raw.jaw), fast);
      this.smile  = lerp(this.smile,  smoothstep(0.15, 0.7, raw.smile), a);
      this.blinkL = lerp(this.blinkL, smoothstep(0.25, 0.6, raw.blinkL), fast);
      this.blinkR = lerp(this.blinkR, smoothstep(0.25, 0.6, raw.blinkR), fast);
      const bl = Math.min(this.blinkL, this.blinkR);
      this.squint = lerp(this.squint, smoothstep(0.15, 0.55, raw.squint) * (1 - bl), smooth(14, dt));
      this.wide   = lerp(this.wide,   smoothstep(0.04, 0.4, raw.wide || 0) * (1 - bl), smooth(14, dt));
      this.brow   = lerp(this.brow,   smoothstep(0.1, 0.7, raw.brow), a);
      this.pucker = lerp(this.pucker, smoothstep(0.2, 0.8, raw.pucker), a);
    } else if (!this.present) {
      const d = 1 - smooth(3, dt);
      ['jaw', 'smile', 'squint', 'wide', 'blinkL', 'blinkR', 'brow', 'pucker'].forEach(k => this[k] *= d);
    }
    this.blink = Math.min(this.blinkL, this.blinkR);
    this.expr = this.smile - this.jaw;                       // −1 Mund offen … +1 Lächeln
    this.yawVel = lerp(this.yawVel, (this.yaw - py) / Math.max(dt, 1e-3), smooth(15, dt));
    this.pitchVel = lerp(this.pitchVel, (this.pitch - pp) / Math.max(dt, 1e-3), smooth(15, dt));

    this._p.copy(this.pos);
    this.pos.set(this.nx * F.spanX / 2, F.centerY + this.ny * F.spanY / 2, lerp(F.zFar, F.zNear, this.depth));
    this.vel.subVectors(this.pos, this._p).divideScalar(Math.max(dt, 1e-3));
    this.speed = lerp(this.speed, this.vel.length(), smooth(6, dt));
    const cp = Math.cos(this.pitch);
    this.headDir.set(Math.sin(this.yaw) * cp, Math.sin(this.pitch), -Math.cos(this.yaw) * cp).normalize();
    const s = (F.gazePlaneZ - this.pos.z) / Math.min(this.headDir.z, -0.15);
    this.gaze.copy(this.pos).addScaledVector(this.headDir, s);

    if (this.present) this.detectEvents();
    this.hist.push({ t: this.time, nx: this.nx, ny: this.ny, yaw: this.yaw, pitch: this.pitch, roll: this.roll, jaw: this.jaw, smile: this.smile,
      squint: this.squint, brow: this.brow, depth: this.depth, blink: this.blink });
    while (this.hist.length && this.hist[0].t < this.time - 2.5) this.hist.shift();
  }
  sample(delay) {                                     // Gesichtszustand vor `delay` Sekunden
    const tt = this.time - delay, h = this.hist;
    for (let i = h.length - 1; i >= 0; i--) if (h[i].t <= tt) return h[i];
    return h[0] || this;
  }
  detectEvents() {
    const f = this.flags, B = this.bus;
    if (!f.eyesClosed && this.blink > 0.6) { f.eyesClosed = true; this.closedAt = this.time; B.emit('eyesClose', this); }
    else if (f.eyesClosed && this.blink < 0.35) { f.eyesClosed = false; const d = this.time - this.closedAt; B.emit(d < 0.4 ? 'blink' : 'eyesOpen', { duration: d }); }
    const w = this.blinkL - this.blinkR;
    if (!f.wink && Math.abs(w) > 0.55) { f.wink = true; B.emit('wink', { side: w > 0 ? 'left' : 'right' }); }
    else if (f.wink && Math.abs(w) < 0.25) f.wink = false;
    if (!f.mouthOpen && this.jaw > 0.4) { f.mouthOpen = true; B.emit('mouthOpen', this); }
    else if (f.mouthOpen && this.jaw < 0.2) { f.mouthOpen = false; B.emit('mouthClose', this); }
    if (!f.smiling && this.smile > 0.6) { f.smiling = true; B.emit('smile', this); }
    else if (f.smiling && this.smile < 0.3) f.smiling = false;
    if (!f.squinting && this.squint > 0.55) { f.squinting = true; B.emit('squint', this); }
    else if (f.squinting && this.squint < 0.3) f.squinting = false;
    if (this.nodArmed && this.pitchVel < -1.3) { this.nodArmed = false; B.emit('nod', this); }
    else if (!this.nodArmed && this.pitchVel > -0.3) this.nodArmed = true;
    if (Math.abs(this.yawVel) > 1.6) {
      const sg = Math.sign(this.yawVel);
      if (sg !== this.shakeSign) { this.shakeSign = sg; this.shakeCount++; this.shakeT = this.time; }
    }
    if (this.time - this.shakeT > 0.6) this.shakeCount = 0;
    if (this.shakeCount >= 3) { this.shakeCount = 0; B.emit('shake', this); }
  }
}

/* =====================================================================
   KINEMATIK — 7 Achsen, Damped-Least-Squares-IK mit Haltungs-Präferenz
   (Position + Ringrichtung als Aufgabe, Torsionen/Ringrotation über Redundanz)
   ===================================================================== */
const _fq = new THREE.Quaternion(), _ft = new THREE.Quaternion(), _fv = V();
function fk(q, P, N, Qout) {
  const A = CONFIG.arm; _fq.identity(); P.set(0, 0, 0);
  _fq.multiply(_ft.setFromAxisAngle(AY, q[0])); P.add(_fv.set(0, A.h0, 0).applyQuaternion(_fq));
  _fq.multiply(_ft.setFromAxisAngle(AX, q[1])); _fq.multiply(_ft.setFromAxisAngle(AY, q[2])); P.add(_fv.set(0, A.L1, 0).applyQuaternion(_fq));
  _fq.multiply(_ft.setFromAxisAngle(AX, q[3])); _fq.multiply(_ft.setFromAxisAngle(AY, q[4])); P.add(_fv.set(0, A.L2, 0).applyQuaternion(_fq));
  _fq.multiply(_ft.setFromAxisAngle(AX, q[5])); _fq.multiply(_ft.setFromAxisAngle(AY, q[6])); P.add(_fv.set(0, A.L3, 0).applyQuaternion(_fq));
  N.set(0, 1, 0).applyQuaternion(_fq); if (Qout) Qout.copy(_fq);
}
function solveLinear(A, b, n) {
  for (let c = 0; c < n; c++) {
    let p = c, m = Math.abs(A[c][c]);
    for (let r = c + 1; r < n; r++) { const v = Math.abs(A[r][c]); if (v > m) { m = v; p = r; } }
    if (p !== c) { const tr = A[p]; A[p] = A[c]; A[c] = tr; const tb = b[p]; b[p] = b[c]; b[c] = tb; }
    const d = A[c][c] || 1e-9;
    for (let r = c + 1; r < n; r++) { const f = A[r][c] / d; if (!f) continue; for (let k = c; k < n; k++) A[r][k] -= f * A[c][k]; b[r] -= f * b[c]; }
  }
  const x = new Float64Array(n);
  for (let r = n - 1; r >= 0; r--) { let s = b[r]; for (let k = r + 1; k < n; k++) s -= A[r][k] * x[k]; x[r] = s / (A[r][r] || 1e-9); }
  return x;
}
class IKSolver {
  constructor() {
    this.J = Array.from({ length: 6 }, () => new Float64Array(7));
    this.A = Array.from({ length: 7 }, () => new Float64Array(7));
    this.b = new Float64Array(7); this.e = new Float64Array(6);
    this.P = V(); this.N = V(); this.P2 = V(); this.N2 = V(); this.ep = V();
  }
  solve(q, tipL, aimL, g) {
    const C = CONFIG.ik, lim = CONFIG.arm.lim, J = this.J, A = this.A, b = this.b, e = this.e;
    const wp = g.wPos, wa = g.wAim;
    for (let it = 0; it < C.iters; it++) {
      fk(q, this.P, this.N);
      const ep = this.ep.subVectors(tipL, this.P), el = ep.length();
      if (el > C.maxPosErr) ep.multiplyScalar(C.maxPosErr / el);
      e[0] = ep.x * wp; e[1] = ep.y * wp; e[2] = ep.z * wp;
      e[3] = (aimL.x - this.N.x) * wa; e[4] = (aimL.y - this.N.y) * wa; e[5] = (aimL.z - this.N.z) * wa;
      for (let j = 0; j < 7; j++) {
        const s = q[j]; q[j] = s + C.eps; fk(q, this.P2, this.N2); q[j] = s;
        J[0][j] = (this.P2.x - this.P.x) * wp / C.eps; J[1][j] = (this.P2.y - this.P.y) * wp / C.eps; J[2][j] = (this.P2.z - this.P.z) * wp / C.eps;
        J[3][j] = (this.N2.x - this.N.x) * wa / C.eps; J[4][j] = (this.N2.y - this.N.y) * wa / C.eps; J[5][j] = (this.N2.z - this.N.z) * wa / C.eps;
      }
      for (let r = 0; r < 7; r++) {
        let br = 0; for (let k = 0; k < 6; k++) br += J[k][r] * e[k];
        const w2 = g.prefW[r] * g.prefW[r];
        b[r] = br + w2 * (g.pref[r] - q[r]);
        for (let c = 0; c < 7; c++) { let s = 0; for (let k = 0; k < 6; k++) s += J[k][r] * J[k][c]; A[r][c] = s; }
        A[r][r] += w2 + C.lambda * C.lambda;
      }
      const dq = solveLinear(A, b, 7);
      for (let j = 0; j < 7; j++) q[j] = clamp(q[j] + clamp(dq[j], -C.maxStep, C.maxStep), lim[j][0], lim[j][1]);
    }
  }
}

class Servo {
  constructor(v, min, max) { Object.assign(this, { value: v, vel: 0, target: v, min, max }); }
  setTarget(t) { this.target = clamp(t, this.min, this.max); }
  step(dt, k, maxVel) {
    const c = 2 * Math.sqrt(k);
    this.vel = clamp(this.vel + (k * (this.target - this.value) - c * this.vel) * dt, -maxVel, maxVel);
    this.value = clamp(this.value + this.vel * dt, this.min, this.max);
  }
}

/* Goal: was ein Verhalten pro Arm und Frame vorgibt */
const WHITE = new THREE.Color(1, 1, 1);
class Goal {
  constructor() { this.tip = V(0, 0, 1.5); this.aim = V(0, 0, 1);
    this.pref = new Float32Array(7); this.prefW = new Float32Array(7); this.offset = new Float32Array(7); this.reset(); }
  reset() {
    this.wPos = 1; this.wAim = 0.6;                    // Gewicht Spitzenposition / Ringrichtung
    this.pref.set(CONFIG.arm.rest); this.prefW.set(CONFIG.arm.prefW);
    this.offset.fill(0);                              // Eigenbewegung direkt auf die Servos
    this.k = 14; this.maxVel = 4;
    this.led = { program: 'uniform', params: { level: 0 }, color: WHITE, gain: 0 };
  }
}

/* =====================================================================
   ROBOTERARM (Geometrie + Servos)
   ===================================================================== */
const SHARED = (() => {
  const A = CONFIG.arm, R = A.ringR;
  const jointG = (r, l) => new THREE.CylinderGeometry(r, r, l, 28).rotateZ(Math.PI / 2);
  return {
    geo: {
      plate: new THREE.BoxGeometry(0.58, 0.05, 0.58),
      bolt: new THREE.CylinderGeometry(0.022, 0.022, 0.025, 12),
      collar: new THREE.CylinderGeometry(0.17, 0.19, 0.06, 36),
      turret: new THREE.CylinderGeometry(0.12, 0.15, A.h0, 32),
      jointL: jointG(0.105, 0.3), jointM: jointG(0.085, 0.25), jointS: jointG(0.06, 0.19),
      bone1: new THREE.CylinderGeometry(0.058, 0.07, A.L1 - 0.12, 6),
      bone2: new THREE.CylinderGeometry(0.045, 0.056, A.L2 - 0.1, 6),
      strip1: new THREE.BoxGeometry(0.018, (A.L1 - 0.12) * 0.7, 0.04),
      strip2: new THREE.BoxGeometry(0.016, (A.L2 - 0.1) * 0.7, 0.034),
      bone3: new THREE.CylinderGeometry(0.03, 0.04, A.L3, 16),
      ringFace: new THREE.RingGeometry(R - 0.032, R + 0.045, 56, 1).rotateX(-Math.PI / 2),
      ringOuter: new THREE.CylinderGeometry(R + 0.045, R + 0.045, 0.026, 56, 1, true),
      ringInner: new THREE.CylinderGeometry(R - 0.032, R - 0.032, 0.026, 56, 1, true),
      hub: new THREE.CylinderGeometry(0.03, 0.034, 0.03, 16),
      spoke: new THREE.BoxGeometry(R - 0.05, 0.012, 0.014),
      diffuser: new THREE.TorusGeometry(R, 0.024, 10, 60),
      led: new THREE.SphereGeometry(0.019, 10, 8),
      dot: new THREE.SphereGeometry(0.014, 8, 6),
      clamp: new THREE.CylinderGeometry(0.03, 0.03, 0.04, 12),
      port: new THREE.CylinderGeometry(0.04, 0.05, 0.06, 18).rotateX(Math.PI / 2),
    },
    mat: {
      body:   new THREE.MeshStandardMaterial({ color: 0x1c1e22, metalness: 0.6, roughness: 0.4 }),
      joint:  new THREE.MeshStandardMaterial({ color: 0x2d3036, metalness: 0.8, roughness: 0.3 }),
      plate:  new THREE.MeshStandardMaterial({ color: 0x15161a, metalness: 0.5, roughness: 0.5 }),
      accent: new THREE.MeshStandardMaterial({ color: 0x4a4f58, metalness: 0.85, roughness: 0.25 }),
      pcb:    new THREE.MeshStandardMaterial({ color: 0x0a0a0b, metalness: 0.2, roughness: 0.7, side: THREE.DoubleSide }),
      unlit:  new THREE.MeshBasicMaterial({ color: 0x060607 }),          // Nabe/Speichen: bleibt dunkel
      diffuser: new THREE.MeshStandardMaterial({ color: 0x141518, roughness: 0.5, metalness: 0.1, emissive: 0x000000 }),
      led:    new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false }),
      cable:  new THREE.MeshStandardMaterial({ color: 0x0b0b0c, roughness: 0.5, metalness: 0.2, side: THREE.DoubleSide }),
    },
  };
})();

const GLARE_TEX = (() => {
  const c = document.createElement('canvas'); c.width = c.height = 128; const g = c.getContext('2d');
  const r = g.createRadialGradient(64, 64, 0, 64, 64, 64);    // ringförmiger Schein, Mitte bleibt frei
  r.addColorStop(0, 'rgba(255,255,255,0)'); r.addColorStop(0.3, 'rgba(255,255,255,0)');
  r.addColorStop(0.42, 'rgba(255,255,255,0.7)'); r.addColorStop(0.52, 'rgba(255,255,255,0.25)');
  r.addColorStop(0.75, 'rgba(255,255,255,0.05)'); r.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = r; g.fillRect(0, 0, 128, 128); return new THREE.CanvasTexture(c);
})();

const _a1 = V(), _a2 = V();
class RobotArm {
  constructor(index, col, row, basePos) {
    const A = CONFIG.arm;
    Object.assign(this, { index, col, row });
    this.rng = mulberry32(1000 + index * 77); this.rand = this.rng(); this.seed = index * 13.37;
    this.reach = A.L1 + A.L2 + A.L3;
    this.root = new THREE.Group(); this.root.position.copy(basePos); this.root.quaternion.copy(WALL_Q);
    this.qInv = WALL_Q.clone().invert();
    this.shoulder = basePos.clone().add(V(0, 0, A.h0));
    this.port = basePos.clone().add(V(0.2, -0.27, 0));
    this.ghost = new Float64Array(A.rest);
    this.servos = A.rest.map((v, j) => new Servo(v, A.lim[j][0], A.lim[j][1]));
    this.ik = new IKSolver();
    this.kMul = 0.85 + this.rng() * 0.3;
    this.goalIdle = new Goal(); this.goalActive = new Goal(); this.goal = new Goal();
    this.state = {}; this.idleState = { next: 6 + this.rng() * 14, until: 0 };
    this.flash = 0; this.flashQ = [];
    this.ledValue = new Float32Array(A.leds); this.ledRGB = new Float32Array(A.leds * 3).fill(0.6);
    this.ledDirs = []; this.ledWorld = [];
    this.ringPos = V(); this.ringNormal = V(0, 0, 1); this.ringQuat = new THREE.Quaternion();
    this.pElbow = V(); this.pShoulderA = V(); this.pWrist = V();
    this.avgColor = new THREE.Color(); this.mean = 0;
    this.flee = 0; this.cs = { tip: null, aim: V(0, 0, 1), sacc: 0, saccUntil: 0, saccP: V() }; this.temper = null;
    this.build();
  }
  build() {
    const A = CONFIG.arm, G = SHARED.geo, M = SHARED.mat;
    const mk = (g, m, p) => { const o = new THREE.Mesh(g, m); if (p) o.position.set(p[0], p[1], p[2]); return o; };
    const r = this.root;
    r.add(mk(G.plate, M.plate, [0, 0, 0]));
    for (const sx of [-1, 1]) for (const sz of [-1, 1]) r.add(mk(G.bolt, M.accent, [sx * 0.23, 0.03, sz * 0.23]));
    this.tMat = new THREE.MeshBasicMaterial({ color: 0x000000, toneMapped: false });      // Temperament-Anzeige auf der Platte
    r.add(mk(G.dot, this.tMat, [0, 0.03, -0.23]));
    this.gYaw = new THREE.Group(); r.add(this.gYaw);
    this.gYaw.add(mk(G.collar, M.joint, [0, 0.05, 0]));
    this.gYaw.add(mk(G.turret, M.body, [0, A.h0 / 2, 0]));
    this.gSh = new THREE.Group(); this.gSh.position.y = A.h0; this.gYaw.add(this.gSh);
    this.gSh.add(mk(G.jointL, M.joint));
    this.gT1 = new THREE.Group(); this.gSh.add(this.gT1);
    this.gT1.add(mk(G.bone1, M.body, [0, A.L1 / 2, 0]));
    this.gT1.add(mk(G.strip1, M.accent, [0.06, A.L1 / 2, 0]));
    this.gEl = new THREE.Group(); this.gEl.position.y = A.L1; this.gT1.add(this.gEl);
    this.gEl.add(mk(G.jointM, M.joint));
    this.gT2 = new THREE.Group(); this.gEl.add(this.gT2);
    this.gT2.add(mk(G.bone2, M.body, [0, A.L2 / 2, 0]));
    this.gT2.add(mk(G.strip2, M.accent, [0.048, A.L2 / 2, 0]));
    this.gWr = new THREE.Group(); this.gWr.position.y = A.L2; this.gT2.add(this.gWr);
    this.gWr.add(mk(G.jointS, M.joint));
    this.gRoll = new THREE.Group(); this.gWr.add(this.gRoll);
    this.gRoll.add(mk(G.bone3, M.accent, [0, A.L3 / 2, 0]));
    this.gRing = new THREE.Group(); this.gRing.position.y = A.L3; this.gRoll.add(this.gRing);
    this.gRing.add(mk(G.ringFace, M.pcb, [0, 0.009, 0]), mk(G.ringOuter, M.pcb, [0, -0.004, 0]), mk(G.ringInner, M.pcb, [0, -0.004, 0]));
    this.gRing.add(mk(G.hub, M.unlit, [0, -0.01, 0]));
    for (let k = 0; k < 3; k++) {
      const sp = mk(G.spoke, M.unlit, [0, -0.01, 0]); sp.rotation.y = k / 3 * Math.PI * 2;
      sp.geometry = G.spoke; sp.translateX((A.ringR - 0.05) / 2 + 0.02); this.gRing.add(sp);
    }
    this.diffMat = M.diffuser.clone();
    const diff = mk(G.diffuser, this.diffMat, [0, 0.016, 0]); diff.rotation.x = Math.PI / 2; this.gRing.add(diff);
    // Gelenk-Status-LEDs
    this.jMat = new THREE.MeshBasicMaterial({ color: 0x000000, toneMapped: false });
    this.gSh.add(mk(G.dot, this.jMat, [0.155, 0, 0])); this.gEl.add(mk(G.dot, this.jMat, [0.13, 0, 0])); this.gWr.add(mk(G.dot, this.jMat, [0.1, 0, 0]));
    // LED-Ring
    this.leds = new THREE.InstancedMesh(G.led, M.led, A.leds);
    const m = new THREE.Matrix4(), black = new THREE.Color(0, 0, 0);
    for (let i = 0; i < A.leds; i++) {
      const a = i / A.leds * Math.PI * 2;
      m.makeTranslation(Math.cos(a) * A.ringR, 0.034, Math.sin(a) * A.ringR);
      this.leds.setMatrixAt(i, m); this.leds.setColorAt(i, black);
      this.ledDirs.push(V(Math.cos(a), 0, Math.sin(a))); this.ledWorld.push(V());
    }
    this.gRing.add(this.leds);
    this.light = new THREE.PointLight(0xffffff, 0, CONFIG.led.lightDist, 2); this.light.position.y = 0.12; this.gRing.add(this.light);
    // Kabel-Ankerpunkte (drehen mit den Torsionsgelenken mit)
    const anchor = (parent, x, y) => { const o = new THREE.Object3D(); o.position.set(x, y, 0); parent.add(o);
      const c = mk(G.clamp, M.joint, [x * 0.9, y, 0]); c.rotation.z = Math.PI / 2; parent.add(c); return o; };
    this.aElbow = anchor(this.gT1, 0.1, A.L1 - 0.1);
    this.aShoulder = anchor(this.gT1, -0.09, 0.16);
    this.aWrist = anchor(this.gT2, -0.075, A.L2 - 0.08);
    // Blendung
    this.glare = new THREE.Sprite(new THREE.SpriteMaterial({ map: GLARE_TEX, color: 0xffffff, transparent: true, opacity: 0,
      depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false }));
    this.glare.scale.setScalar(0.5);
  }
  solveIK() {
    const g = this.goal;
    _a1.subVectors(g.tip, this.root.position).applyQuaternion(this.qInv);
    _a2.copy(g.aim).applyQuaternion(this.qInv);
    this.ik.solve(this.ghost, _a1, _a2, g);
  }
  update(dt) {
    const g = this.goal, A = CONFIG.arm, s = this.servos;
    for (let j = 0; j < 7; j++) {
      s[j].setTarget(this.ghost[j] + g.offset[j]);
      s[j].step(dt, g.k * this.kMul * A.servoK[j], g.maxVel * A.servoV[j]);
    }
    this.gYaw.rotation.y = s[0].value; this.gSh.rotation.x = s[1].value; this.gT1.rotation.y = s[2].value;
    this.gEl.rotation.x = s[3].value; this.gT2.rotation.y = s[4].value; this.gWr.rotation.x = s[5].value;
    this.gRoll.rotation.y = s[6].value;
    this.activity = s.reduce((a, x) => a + Math.abs(x.vel), 0);
    for (let i = this.flashQ.length - 1; i >= 0; i--) {
      const q = this.flashQ[i]; q.d -= dt;
      if (q.d <= 0) { this.flash = Math.max(this.flash, q.s); this.flashQ.splice(i, 1); }
    }
    this.flash = Math.max(0, this.flash - dt * 2.4);
  }
  readWorld() {
    this.gRing.getWorldPosition(this.ringPos); this.gRing.getWorldQuaternion(this.ringQuat);
    this.ringNormal.set(0, 1, 0).applyQuaternion(this.ringQuat);
    for (let i = 0; i < this.ledDirs.length; i++) this.ledWorld[i].copy(this.ledDirs[i]).applyQuaternion(this.ringQuat);
    this.aElbow.getWorldPosition(this.pElbow); this.aShoulder.getWorldPosition(this.pShoulderA); this.aWrist.getWorldPosition(this.pWrist);
  }
  triggerFlash(s = 1, delay = 0) { this.flashQ.push({ s, d: delay }); }
}

/* =====================================================================
   KABEL — Verlet-Seil + Röhren-Mesh mit festem Topologie-Buffer
   ===================================================================== */
const _cz = V();
class Cable {
  constructor(length, radius) {
    const S = CONFIG.cable.segments; this.n = S + 1; this.seg = length / S; this.r = radius;
    this.p = []; this.o = []; for (let i = 0; i < this.n; i++) { this.p.push(V()); this.o.push(V()); }
    this.init = false; this.RS = 7;
    const nv = this.n * this.RS; this.pos = new Float32Array(nv * 3); this.nor = new Float32Array(nv * 3);
    const idx = [];
    for (let i = 0; i < this.n - 1; i++) for (let k = 0; k < this.RS; k++) {
      const a = i * this.RS + k, b = i * this.RS + (k + 1) % this.RS, c = (i + 1) * this.RS + k, d = (i + 1) * this.RS + (k + 1) % this.RS;
      idx.push(a, c, b, b, c, d);
    }
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.BufferAttribute(this.pos, 3));
    g.setAttribute('normal', new THREE.BufferAttribute(this.nor, 3)); g.setIndex(idx);
    this.mesh = new THREE.Mesh(g, SHARED.mat.cable); this.mesh.frustumCulled = false;
    this.T = V(); this.N = V(); this.B = V(); this.d = V();
  }
  update(a, b, dt) {
    const C = CONFIG.cable, n = this.n, p = this.p, o = this.o, d = this.d, r = this.r;
    if (!this.init) {
      for (let i = 0; i < n; i++) { const t = i / (n - 1); p[i].lerpVectors(a, b, t); p[i].y -= Math.sin(t * Math.PI) * 0.25; o[i].copy(p[i]); }
      this.init = true;
    }
    const sub = 2, h = dt / sub, gr = C.gravity * h * h;
    for (let s = 0; s < sub; s++) {
      for (let i = 1; i < n - 1; i++) {
        d.subVectors(p[i], o[i]).multiplyScalar(C.damping); o[i].copy(p[i]); p[i].add(d); p[i].y -= gr;
      }
      p[0].copy(a); o[0].copy(a); p[n - 1].copy(b); o[n - 1].copy(b);
      for (let it = 0; it < C.iters; it++) {
        for (let i = 0; i < n - 1; i++) {
          const A = p[i], B = p[i + 1]; d.subVectors(B, A);
          const l = d.length() || 1e-6; let diff = (l - this.seg) / l; if (diff < 0) diff *= 0.12;
          const pa = i === 0, pb = i + 1 === n - 1;
          if (pa && pb) continue;
          if (pa) B.addScaledVector(d, -diff); else if (pb) A.addScaledVector(d, diff);
          else { A.addScaledVector(d, diff * 0.5); B.addScaledVector(d, -diff * 0.5); }
        }
        for (let i = 1; i < n - 1; i++) { if (p[i].z < r + 0.008) p[i].z = r + 0.008; if (p[i].y < FLOOR_Y + r) p[i].y = FLOOR_Y + r; }
      }
    }
    this.updateMesh();
  }
  updateMesh() {
    const n = this.n, RS = this.RS, p = this.p, T = this.T, N = this.N, B = this.B, r = this.r;
    for (let i = 0; i < n; i++) {
      T.subVectors(p[Math.min(i + 1, n - 1)], p[Math.max(i - 1, 0)]).normalize();
      if (i === 0) { N.crossVectors(T, _cz.set(0, 0, 1)); if (N.lengthSq() < 1e-4) N.crossVectors(T, _cz.set(1, 0, 0)); }
      else { N.addScaledVector(T, -N.dot(T)); if (N.lengthSq() < 1e-6) N.crossVectors(T, _cz.set(0, 0, 1)); }
      N.normalize(); B.crossVectors(T, N);
      for (let k = 0; k < RS; k++) {
        const ang = k / RS * Math.PI * 2, c = Math.cos(ang), s = Math.sin(ang);
        const dx = N.x * c + B.x * s, dy = N.y * c + B.y * s, dz = N.z * c + B.z * s, j = (i * RS + k) * 3;
        this.pos[j] = p[i].x + dx * r; this.pos[j + 1] = p[i].y + dy * r; this.pos[j + 2] = p[i].z + dz * r;
        this.nor[j] = dx; this.nor[j + 1] = dy; this.nor[j + 2] = dz;
      }
    }
    this.mesh.geometry.attributes.position.needsUpdate = true; this.mesh.geometry.attributes.normal.needsUpdate = true;
  }
}

/* =====================================================================
   LED — Programme (Helligkeit je LED) + Modulatoren (pulse, strobe, sparkle) + Farbe/Hue
   spec = { program, params, color | hue:{base,spread,speed,sat,light}, gain,
            pulse:{rate,depth,shape:'sine'|'heart',phase}, strobe:{rate,duty,amount}, sparkle, eyelid }
   ===================================================================== */
const _l1 = V();
const LED_PROGRAMS = {
  uniform(o, r, p) { o.fill(p.level ?? 1); },
  // Lichtbogen zeigt zu einem Weltpunkt ("Pupille")
  point(o, r, p) {
    const v = _l1; if (p.target) v.subVectors(p.target, r.center); else v.copy(p.dir);
    v.addScaledVector(r.normal, -v.dot(r.normal));
    const focus = smoothstep(p.lo ?? 0.25, p.hi ?? 1.8, v.length()); v.normalize();
    const sharp = p.sharp ?? 6, base = p.base ?? 0.1, center = p.center ?? 0.6;
    for (let i = 0; i < o.length; i++) o[i] = base + (1 - base) * lerp(center, Math.pow((r.u[i].dot(v) + 1) / 2, sharp), focus);
  },
  // Füllstand von "unten"
  fill(o, r, p) {
    const d = _l1.set(0, -1, 0); d.addScaledVector(r.normal, -d.dot(r.normal));
    if (d.lengthSq() < 1e-5) d.copy(r.u[0]); d.normalize();
    const lvl = clamp(p.level ?? 0, 0, 1), base = p.base ?? 0.04;
    for (let i = 0; i < o.length; i++) o[i] = base + (1 - base) * smoothstep(lvl + 0.05, lvl - 0.05, Math.acos(clamp(r.u[i].dot(d), -1, 1)) / Math.PI);
  },
  // rotierende Kometen
  comet(o, r, p) {
    const n = o.length, heads = p.heads ?? 1, tail = p.tail ?? 2, base = p.base ?? 0.04;
    const head = r.t * (p.speed ?? 2) + (p.phase ?? 0);
    for (let i = 0; i < n; i++) {
      let m = 0;
      for (let h = 0; h < heads; h++) { const d = (((head + h * n / heads - i) % n) + n) % n; m = Math.max(m, Math.exp(-d / tail)); }
      o[i] = base + (1 - base) * m;
    }
  },
  // flackerndes Rauschfeld
  noise(o, r, p) {
    const base = p.base ?? 0.05, sp = p.speed ?? 3;
    for (let i = 0; i < o.length; i++) o[i] = base + (1 - base) * Math.pow(noise1(r.t * sp + i * 0.9, r.arm.seed) * 0.5 + 0.5, p.pow ?? 2);
  },
};
function modulation(spec, t) {
  let m = 1;
  const P = spec.pulse;
  if (P) { const ph = t * P.rate + (P.phase || 0); const w = P.shape === 'heart' ? heart(ph) : 0.5 + 0.5 * Math.sin(ph * Math.PI * 2); m *= 1 - P.depth + P.depth * w; }
  const S = spec.strobe;
  if (S && S.amount > 0.01) { const on = ((((t * S.rate + (S.phase || 0)) % 1) + 1) % 1) < (S.duty ?? 0.35); m *= on ? 1 + S.amount * 0.6 : 1 - S.amount; }
  return m;
}

class LedController {
  constructor(arms, face) {
    this.arms = arms; this.face = face; const n = CONFIG.arm.leds;
    this.vA = new Float32Array(n); this.vB = new Float32Array(n); this.cA = new Float32Array(n * 3); this.cB = new Float32Array(n * 3);
    this.tc = new THREE.Color(); this.col = new THREE.Color(); this.sx = V(); this.sy = V(); this.toCam = V();
  }
  evalSpec(spec, ring, v, c, t) {
    (LED_PROGRAMS[spec.program] || LED_PROGRAMS.uniform)(v, ring, spec.params || {});
    const g = (spec.gain ?? 1) * modulation(spec, t), sp = spec.sparkle || 0, n = v.length, H = spec.hue;
    for (let i = 0; i < n; i++) {
      v[i] = Math.max(0, v[i] * g * (sp ? 1 + sp * (Math.random() * 2 - 1) : 1));
      if (H) this.tc.setHSL((((H.base + H.spread * i / n + (H.speed || 0) * t) % 1) + 1) % 1, H.sat ?? 0.85, H.light ?? 0.6);
      else this.tc.copy(spec.color || WHITE);
      c[i * 3] = this.tc.r; c[i * 3 + 1] = this.tc.g; c[i * 3 + 2] = this.tc.b;
    }
  }
  update(dt, t, camPos, glare, glareOn) {
    const f = this.face, p = f.presence, L = CONFIG.led, n = CONFIG.arm.leds;
    // Invertierte Lider: Augen zu → heller, Zukneifen → etwas heller, Augen weit auf → gedimmt
    const eyeMod = Math.max(0.15, 1 + L.eyeClosedBoost * f.blink + L.squintBoost * f.squint - L.wideDim * f.wide);
    const wL = clamp(f.blinkL - f.blinkR, 0, 1), wR = clamp(f.blinkR - f.blinkL, 0, 1);
    for (const arm of this.arms) {
      const ring = { u: arm.ledWorld, center: arm.ringPos, normal: arm.ringNormal, t, arm };
      const I = arm.goalIdle.led, A = arm.goalActive.led;
      this.evalSpec(I, ring, this.vB, this.cB, t);
      if (p > 0.001) this.evalSpec(A, ring, this.vA, this.cA, t); else this.vA.fill(0);
      const sx = this.sx.copy(WORLD_X).addScaledVector(arm.ringNormal, -WORLD_X.dot(arm.ringNormal)).normalize();
      let sum = 0, rs = 0, gs = 0, bs = 0;
      const fl = Math.min(arm.flash, 1);
      const fleeDim = 1 - 0.6 * arm.flee * (arm.temper?.fleeDim ?? 1);
      for (let i = 0; i < n; i++) {
        let act = this.vA[i] * fleeDim;
        if (A.eyelid !== false) {   // Zwinkern: die Ringhälfte auf der Seite des geschlossenen Auges leuchtet auf
          const s = arm.ledWorld[i].dot(sx);
          act *= eyeMod * (1 + 0.9 * wL * smoothstep(0.15, -0.35, s)) * (1 + 0.9 * wR * smoothstep(-0.15, 0.35, s));
        }
        let v = Math.max(lerp(this.vB[i], act, p), arm.flash);
        const cur = arm.ledValue[i];
        v = arm.ledValue[i] = lerp(cur, v, smooth(v > cur ? L.attack : L.release, dt));
        const kc = smooth(10, dt);
        for (let k = 0; k < 3; k++) {
          const tgt = lerp(this.cB[i * 3 + k], this.cA[i * 3 + k], p);
          arm.ledRGB[i * 3 + k] = lerp(arm.ledRGB[i * 3 + k], tgt, kc);
        }
        const r = lerp(arm.ledRGB[i * 3], 1, fl * 0.7), g = lerp(arm.ledRGB[i * 3 + 1], 1, fl * 0.7), b = lerp(arm.ledRGB[i * 3 + 2], 1, fl * 0.7);
        const vb = v * L.boost;
        this.col.setRGB(L.off.r + r * vb, L.off.g + g * vb, L.off.b + b * vb); arm.leds.setColorAt(i, this.col);
        sum += v; rs += r * v; gs += g * v; bs += b * v;
      }
      arm.leds.instanceColor.needsUpdate = true;
      const mean = arm.mean = sum / n;
      if (sum > 1e-4) arm.avgColor.setRGB(rs / sum, gs / sum, bs / sum);
      arm.diffMat.emissive.copy(arm.avgColor).multiplyScalar(mean * 0.4);
      arm.light.color.copy(arm.avgColor); arm.light.intensity = mean * L.light;
      arm.jMat.color.copy(arm.avgColor).multiplyScalar(0.15 + clamp(arm.activity * 0.5, 0, 2) * (0.3 + mean));
      // Blendung: Ring schaut direkt in die Kamera
      const facing = Math.max(0, arm.ringNormal.dot(this.toCam.subVectors(camPos, arm.ringPos).normalize()));
      const s = glareOn ? mean * Math.pow(facing, 6) * (0.15 + 0.5 * glare) * CONFIG.fx.sprite : 0;
      arm.glare.position.copy(arm.ringPos).addScaledVector(arm.ringNormal, 0.04);
      arm.glare.material.color.copy(arm.avgColor).lerp(WHITE, 0.3 * glare);
      arm.glare.material.opacity = clamp(s, 0, 0.6); arm.glare.scale.setScalar(0.74 * (1 + 0.5 * s));
      if (arm.temper) arm.tMat.color.copy(arm.temper.color).multiplyScalar(0.6 + 0.4 * Math.sin(t * 2 + arm.index));
    }
  }
}

/* =====================================================================
   VERHALTEN — Registry
   def = { id, label, info, enter(ctx), exit(ctx), update(ctx), arm(ctx, arm, goal) }
   ctx = { t, dt, face, arms, bus, shared, on(event, fn), fx:{glare} }
   Goal: tip/aim (Welt), wPos/wAim, pref/prefW (Haltung), offset (Eigenbewegung), k/maxVel, led
   ===================================================================== */
const BEHAVIORS = [];
const registerBehavior = def => BEHAVIORS.push(def);

const _h = V();
function reachToward(arm, point, frac, g) {
  _h.subVectors(point, arm.shoulder); const len = _h.length() || 1; _h.divideScalar(len);
  g.tip.copy(arm.shoulder).addScaledVector(_h, Math.min(arm.reach * frac, len * 0.92));
  g.aim.subVectors(point, g.tip).normalize();
}
function aimAt(g, point, w = 1) { _h.subVectors(point, g.tip).normalize(); if (w >= 1) g.aim.copy(_h); else g.aim.lerp(_h, w).normalize(); }
function clampReach(arm, g, frac = 0.95) {
  _h.subVectors(g.tip, arm.shoulder); const l = _h.length(), m = arm.reach * frac;
  if (l > m) g.tip.copy(arm.shoulder).addScaledVector(_h, m / l);
  if (g.tip.z < 0.35) g.tip.z = 0.35;
}
const C = hex => new THREE.Color(hex);
const PAL = { ember: C(0xff9a3c), sun: C(0xffb347), cream: C(0xfff1d6), ice: C(0xd6f3ff), cyan: C(0x7fe3ff),
              red: C(0xff3b30), blush: C(0xff7fa8), teal: C(0x5ff0d0), violet: C(0x9a7bff), deep: C(0x3a5bff) };
const T2 = V(), T3 = V(), T4 = V(), T5 = V();

/* ---- IDLE: Schlafen / Suchen ---------------------------------------- */
const IDLE = {
  arm(ctx, arm, g) {
    const t = ctx.t, s = arm.seed, st = arm.idleState, f = ctx.face;
    const search = f.lostTime < 4.5 ? 1 - smoothstep(2.5, 4.5, f.lostTime) : 0;
    const breath = Math.sin(t * 0.5 + arm.index * 0.7);
    // Schlafhaltung: zur Wand gefaltet, Ring nach unten
    T2.copy(arm.shoulder).add(T3.set(noise1(t * 0.08, s) * 0.15, -0.32 + breath * 0.05, 0.72));
    T4.set(noise1(t * 0.1, s + 1) * 0.3, -0.9, 0.35).normalize();
    // Suchen: Scanbewegung in den Raum
    T5.set(Math.sin(t * 0.7 + arm.index) * 2.4, Math.cos(t * 0.53 + arm.index * 1.3) * 1.3, 4);
    reachToward(arm, T5, 0.7, g);
    g.tip.lerpVectors(T2, g.tip, search); g.aim.lerpVectors(T4, g.aim, search).normalize();
    // Träume: gelegentlich streckt sich ein Arm
    st.next -= ctx.dt;
    if (st.next <= 0) { st.until = t + 2.5 + arm.rng() * 2; st.next = 12 + arm.rng() * 18; }
    const dream = t < st.until ? Math.sin(clamp((st.until - t) / 3, 0, 1) * Math.PI) : 0;
    g.tip.lerp(T2.copy(arm.shoulder).add(T3.set(0, 0.9, 1.1)), dream * 0.8); g.aim.lerp(T3.set(0, 0.6, 1).normalize(), dream).normalize();
    g.pref[2] = noise1(t * 0.12, s + 2) * 0.5; g.pref[4] = noise1(t * 0.1, s + 3) * 0.5;
    g.pref[6] = Math.sin(t * 0.13 + arm.index) * 1.5;
    g.k = 4.5 + 6 * search; g.maxVel = 0.9 + 1.5 * search;
    g.led = search > 0.05
      ? { program: 'comet', params: { speed: 7, tail: 1.6, base: 0.05 }, color: PAL.ice, gain: 0.3 * search + 0.1 }
      : { program: 'comet', params: { speed: 0.8, tail: 3.5, base: 0.08, phase: arm.index * 2 }, color: PAL.ember,
          gain: 0.14 + 0.5 * dream, pulse: { rate: 0.09, depth: 0.7, phase: arm.index * 0.08 }, eyelid: false };
  },
};

/* ---- 1 HELIO -------------------------------------------------------- */
registerBehavior({
  id: 'helio', label: 'Helio',
  info: 'Sonnenblumen-Feld. Die Stiele (Torsionen) drehen sich träge. Nähe = sie strecken sich und werden heller. Lächeln = Blüten öffnen sich (Ringrotation, Gold). Zukneifen = „zu hell" – sie wenden sich ab. Kopfneigung kippt das Feld, offener Mund lässt sie sprechend flackern.',
  variants: [
    { id: 'bloom', label: 'Blüte', info: 'Alle Blüten schauen dich an.' },
    { id: 'follow', label: 'Blickfolge', info: 'Geteilte Aufmerksamkeit: Die Ringe bündeln sich auf die Stelle, die du ansiehst.' },
    { id: 'wind', label: 'Wind', info: 'Deine Kopfrichtung ist Wind: Er läuft als Böe durchs Feld, offener Mund = Sturm, Lächeln = Flaute.' },
    { id: 'shy', label: 'Mondblume', info: 'Umgekehrte Sonnenblumen: Sie drehen sich weg, wenn du sie ansiehst, und folgen dir, sobald du wegschaust.' },
  ],
  update(ctx) { const f = ctx.face; ctx.fx.glare = Math.max(ctx.fx.glare, f.depth * 0.5 * (1 - f.squint) + f.smile * 0.2); },
  arm(ctx, arm, g) {
    const f = ctx.face, t = ctx.t, i = arm.index, cx = arm.col - 1, cy = arm.row - 1, v = ctx.variant;
    const lookAtMe = 1 - smoothstep(0.35, 1.3, Math.hypot(f.gaze.x - arm.ringPos.x, f.gaze.y - arm.ringPos.y));
    reachToward(arm, f.pos, 0.52 + 0.3 * f.depth + 0.1 * f.smile + 0.04 * Math.sin(t * 0.9 + i), g);
    g.tip.x += noise1(t * 0.35, arm.seed) * 0.12 + f.headDir.x * 0.25;
    g.tip.y += noise1(t * 0.3, arm.seed + 4) * 0.12 - f.roll * cx * 0.45 + f.headDir.y * 0.2;
    if (v === 'wind') {
      const gust = 0.25 + 0.9 * f.jaw - 0.2 * f.smile, ph = t * 1.6 - (cx * f.headDir.x + cy * f.headDir.y) * 2.2;
      const w = (Math.sin(ph) * 0.5 + 0.5) * gust;
      g.tip.x += f.headDir.x * 1.4 * w + noise1(t * 2.5, arm.seed + 9) * 0.1 * gust;
      g.tip.y += f.headDir.y * 1.2 * w - w * 0.15;
      g.offset[2] = Math.sin(ph) * 0.4 * gust; g.offset[4] = Math.sin(ph - 0.7) * 0.5 * gust;
    }
    clampReach(arm, g);
    if (v === 'follow') { aimAt(g, f.gaze); aimAt(g, f.pos, 0.15); }
    else if (v === 'shy') { aimAt(g, f.pos); g.aim.lerp(T3.set(cx * 0.8 + 0.01, cy * 0.5 - 0.6, 0.4).normalize(), 0.15 + 0.8 * lookAtMe).normalize(); }
    else aimAt(g, f.pos);
    g.aim.lerp(T3.set(cx * 0.3, -1, 0.3).normalize(), f.squint * 0.7).normalize();
    g.pref[2] = Math.sin(t * 0.4 + i * 0.9) * 0.45; g.pref[4] = -Math.sin(t * 0.33 + i * 1.3) * 0.45;
    g.pref[6] = f.roll * 1.3 + Math.sin(t * 0.8 + i) * f.smile * 1.6;
    g.k = 14; g.maxVel = 3.5;
    const look = v === 'shy' ? -0.6 * lookAtMe : lookAtMe;
    g.led = { program: 'point', params: { target: f.gaze, sharp: 4 + 12 * f.squint, base: 0.06, center: 0.5 + 0.5 * Math.max(0, look) },
      color: v === 'shy' ? new THREE.Color().lerpColors(PAL.ice, PAL.violet, f.smile * 0.6)
        : v === 'wind' ? new THREE.Color().lerpColors(PAL.cream, PAL.teal, f.jaw)
        : new THREE.Color().lerpColors(PAL.sun, PAL.cream, f.smile),
      gain: Math.max(0.05, 0.35 + 0.5 * f.depth + 0.45 * look + 0.3 * f.smile - 0.4 * f.squint),
      strobe: { rate: 11, duty: 0.5, amount: f.jaw * 0.6 }, sparkle: f.jaw * 0.25 };
  },
});

/* ---- 2 SCHWARM ------------------------------------------------------ */
registerBehavior({
  id: 'swarm', label: 'Schwarm',
  info: 'Die Ringspitzen bilden einen Insektenschwarm, die Torsionsgelenke zucken. Der Schwarm hängt an deinem Kopf: Er zieht dorthin, wohin du schaust, und jede Kopfbewegung reißt ihn mit. Alle Ringe blicken in deine Blickrichtung. Neigen = Wirbel, Nicken = Sprung, Schütteln = Auflösung. Lächeln = Zusammenhalt, Zukneifen = Fokus auf dich, Mund auf: auseinanderstieben + Strobo.',
  enter(ctx) {
    const S = ctx.shared; S.scatter = 0;
    S.pts = ctx.arms.map(a => ({ p: a.shoulder.clone().add(V(0, 0.3, 1.3)), v: V() }));
    const burst = (k, dir) => S.pts.forEach(s => s.v.add(dir ? T2.copy(dir).multiplyScalar(k) : V(Math.random() - 0.5, Math.random() - 0.5, Math.random() * 0.5).normalize().multiplyScalar(k)));
    ctx.on('mouthOpen', () => { S.scatter = 1; burst(4); });
    ctx.on('shake', () => { S.scatter = 0.8; burst(3); });
    ctx.on('nod', () => burst(2.5, V(0, -0.6, 0.8)));
  },
  update(ctx) {
    const S = ctx.shared, f = ctx.face, dt = ctx.dt, t = ctx.t;
    S.scatter = Math.max(0, S.scatter - dt * 0.7);
    // Attraktor: Blickpunkt, verstärkt, plus Gesichtsposition
    const attr = T5.set(clamp(f.gaze.x * 1.25 + f.nx * 0.6, -2.8, 2.8), clamp(f.gaze.y * 1.2 + f.ny * 0.4, -2.2, 2.6), 1.4 + 0.5 * f.depth);
    S.attr = (S.attr || V()).copy(attr);
    const cen = T4.set(0, 0, 0); S.pts.forEach(s => cen.add(s.p)); cen.divideScalar(S.pts.length);
    S.pts.forEach((s, i) => {
      const arm = ctx.arms[i], acc = T2.set(0, 0, 0);
      acc.addScaledVector(T3.subVectors(attr, s.p), (2.4 + 3.5 * f.squint) * (1 - S.scatter));
      acc.addScaledVector(T3.subVectors(cen, s.p), 0.3 + 2.2 * f.smile);
      S.pts.forEach((o, j) => { if (j === i) return; T3.subVectors(s.p, o.p); const l = T3.length(); if (l < 0.55 && l > 1e-4) acc.addScaledVector(T3, (0.55 - l) / l * 14 * (1 + S.scatter * 2) * (1 - f.squint * 0.7)); });
      acc.addScaledVector(T3.copy(arm.shoulder).add(V(0, 0, 1.1)).sub(s.p), 0.5);
      acc.x += f.yawVel * 3.2; acc.y += f.pitchVel * 2.6;                     // Kopfbewegung reißt den Schwarm mit
      acc.x += -(s.p.y - attr.y) * f.roll * 8; acc.y += (s.p.x - attr.x) * f.roll * 8;
      acc.x += noise1(t * 0.9, arm.seed) * 1.2; acc.y += noise1(t * 0.9, arm.seed + 3) * 1.2; acc.z += noise1(t * 0.7, arm.seed + 6) * 0.6;
      s.v.addScaledVector(acc, dt).multiplyScalar(1 - 1.8 * dt);
      const vmax = 1.8 + 2.5 * f.depth + 3 * S.scatter; if (s.v.length() > vmax) s.v.setLength(vmax);
      s.p.addScaledVector(s.v, dt);
      T3.subVectors(s.p, arm.shoulder); if (T3.length() > arm.reach * 0.95) s.p.copy(arm.shoulder).addScaledVector(T3.normalize(), arm.reach * 0.95);
      if (s.p.z < 0.5) s.p.z = 0.5;
    });
    ctx.fx.glare = Math.max(ctx.fx.glare, f.squint * 0.95 + S.scatter * 0.3);
  },
  arm(ctx, arm, g) {
    const S = ctx.shared, s = S.pts[arm.index], f = ctx.face, t = ctx.t, sp = s.v.length();
    g.tip.copy(s.p); aimAt(g, f.pos);
    g.aim.add(T3.set(f.headDir.x * 1.6, f.headDir.y * 1.4, 0).multiplyScalar(1 - f.squint)).normalize();   // Ringe drehen in deine Blickrichtung
    if (sp > 0.05) g.aim.lerp(T2.copy(s.v).normalize(), clamp(sp / 5, 0, 0.45) * (1 - f.squint)).normalize();
    g.wPos = 1.2; g.k = 20; g.maxVel = 6;
    g.offset[2] = noise1(t * 7, arm.seed) * 0.2 * (1 + sp * 0.3);
    g.offset[4] = noise1(t * 6.3, arm.seed + 2) * 0.22;
    g.offset[5] = noise1(t * 9, arm.seed + 5) * 0.08 * sp;
    g.pref[6] = Math.sin(t * 3 + arm.index) * clamp(sp, 0, 3) * 0.8;
    g.led = { program: 'comet', params: { speed: 2 + sp * 4, heads: 2, tail: 1.8, base: 0.08 + f.squint * 0.8 },
      hue: { base: 0.52 - f.smile * 0.38 + sp * 0.03, spread: 0.18 * (1 - f.squint), speed: 0.04, sat: 0.85 - 0.6 * f.squint, light: 0.6 },
      gain: 0.35 + sp * 0.12 + f.squint * 0.9, strobe: { rate: 14, duty: 0.35, amount: S.scatter }, sparkle: 0.2 };
  },
});

/* ---- 3 MARIONETTE ---------------------------------------------------- */
registerBehavior({
  id: 'puppet', label: 'Marionette',
  info: 'Direkte Kopplung ohne IK – jede Geste führt ein Gelenk. Kopf drehen → Basis, Nicken → Schulter, Neigen → Torsion Ober-/Unterarm, Mund → Ellbogen, Lächeln → Handgelenk, Brauen → Strecken, Zukneifen → Ringrotation, Nähe → Vorbeugen.',
  variants: [
    { id: 'canon', label: 'Kanon', info: 'Die Bewegung läuft diagonal zeitversetzt durchs Raster.' },
    { id: 'mirror', label: 'Spiegel', info: 'Linke Spalte gespiegelt, obere Reihe invertiert – die Wand faltet deine Mimik symmetrisch.' },
    { id: 'echo', label: 'Echo', info: 'Vom Zentrum nach außen mit langen Verzögerungen – Gesten hallen bis zu einer Sekunde nach.' },
    { id: 'shuffle', label: 'Vertauscht', info: 'Jeder Arm hat seine eigene, zufällige Zuordnung von Gesten zu Gelenken. Neu würfeln: Taste R.' },
  ],
  enter(ctx) {
    const SIG = ['yaw', 'pitch', 'roll', 'jaw', 'smile', 'squint', 'brow', 'depth'];
    ctx.shared.shuffle = () => ctx.arms.forEach(a => {
      a.state.map = [0, 1, 2, 3, 4, 5, 6].map(() => ({ s: SIG[Math.floor(Math.random() * SIG.length)], g: (Math.random() < 0.5 ? -1 : 1) * (0.8 + Math.random() * 1.4) }));
    });
    ctx.shared.shuffle();
    ctx.shared.onKey = e => { if (e.code === 'KeyR' && ctx.variant === 'shuffle') ctx.shared.shuffle(); };
    addEventListener('keydown', ctx.shared.onKey);
  },
  exit(ctx) { removeEventListener('keydown', ctx.shared.onKey); },
  arm(ctx, arm, g) {
    const v = ctx.variant, cx = arm.col - 1, cy = arm.row - 1, t = ctx.t;
    const delay = v === 'echo' ? 0.42 * Math.hypot(cx, cy) : v === 'canon' ? 0.07 * (arm.col + (2 - arm.row)) : 0.03;
    const s = ctx.face.sample(delay), side = arm.col === 1 ? (arm.row - 1 || 1) : cx;
    g.wPos = 0; g.wAim = 0; g.prefW.fill(1.2);
    if (v === 'shuffle') {
      const R = CONFIG.arm.rest;
      for (let j = 0; j < 7; j++) { const m = arm.state.map[j], val = s[m.s] ?? 0; g.pref[j] = R[j] + val * m.g * (j === 6 ? 2 : 1); }
      g.pref[3] = Math.min(g.pref[3], -0.4);
    } else {
      const mx = v === 'mirror' && cx < 0 ? -1 : 1, my = v === 'mirror' && cy > 0 ? -1 : 1;
      g.pref[0] = s.yaw * 1.6 * mx + cx * 0.2 * s.smile;
      g.pref[1] = 0.45 + s.pitch * 1.6 * my + s.depth * 0.55 + s.brow * 0.35;
      g.pref[2] = s.roll * 2.2 * mx;
      g.pref[3] = -1.8 + s.jaw * 1.6 + s.brow * 0.4;
      g.pref[4] = -s.roll * 1.8 * mx + Math.sin(t * 1.2 + arm.index) * 0.12;
      g.pref[5] = 0.35 + s.smile * 0.95 - s.pitch * 0.6 * my;
      g.pref[6] = s.squint * 2.6 * side + s.roll * mx;
    }
    g.k = v === 'echo' ? 16 : 24; g.maxVel = 6;
    g.led = { program: 'fill', params: { level: 0.12 + s.jaw * 0.88, base: 0.05 },
      hue: { base: 0.08 + s.roll * 0.4 + s.smile * 0.12, spread: 0.1 + s.squint * 0.4, speed: s.squint * 0.3, sat: 0.55 + 0.4 * s.smile, light: 0.6 },
      gain: 0.4 + 0.6 * s.jaw + 0.3 * s.smile + 0.3 * s.depth, pulse: { rate: 0.6 + s.brow * 3, depth: 0.35 } };
  },
});

/* ---- 4 NACHAHMER ---------------------------------------------------- */
// Gesten-Clips: (u Sekunden seit Start, amp, goal, ledMod)
const CLIPS = {
  nod:   { dur: 0.9, fn(u, a, g, L) { const w = Math.sin(u * Math.PI * 2 / 0.45) * Math.exp(-u * 2.2) * a; g.offset[5] += w * 0.45; g.offset[3] += w * 0.15; L.hue = 0.33; L.k = Math.max(L.k, Math.exp(-u * 3)); } },
  shake: { dur: 1.1, fn(u, a, g, L) { const w = Math.sin(u * Math.PI * 2 / 0.32) * Math.exp(-u * 2) * a; g.offset[0] += w * 0.3; g.offset[4] += w * 0.5; L.hue = 0.0; L.k = Math.max(L.k, Math.exp(-u * 3)); } },
  smile: { dur: 1.4, fn(u, a, g, L) { const e = Math.exp(-u * 1.6) * a; g.offset[1] += Math.abs(Math.sin(u * 9)) * 0.12 * e; g.offset[6] += Math.sin(u * 4) * 1.2 * e; L.hue = 0.11; L.k = Math.max(L.k, e); } },
  gasp:  { dur: 1.2, fn(u, a, g, L) { const e = (u < 0.12 ? u / 0.12 : Math.exp(-(u - 0.12) * 2.5)) * a; g.offset[1] -= 0.35 * e; g.offset[5] += 0.5 * e; g.offset[3] += 0.25 * e; L.flash = Math.max(L.flash, e); } },
  blink: { dur: 0.35, fn(u, a, g, L) { if (u < 0.18) L.mul *= 0.05; } },
  wink:  { dur: 0.5, fn(u, a, g, L) { if (u < 0.3) L.mul *= 0.4; g.offset[6] += 0.4 * Math.sin(u / 0.5 * Math.PI) * a; } },
};
registerBehavior({
  id: 'imitate', label: 'Nachahmer',
  info: 'Ein Publikum, das dich nachmacht. Gesten laufen als Kaskade vom Arm, den du ansiehst, durch die Wand: Nicken → sie nicken (grün), Kopfschütteln → sie schütteln (rot), Lächeln → freudiges Wippen (gold), Mund auf → sie schnappen nach Luft, Blinzeln/Zwinkern → sie blinzeln zurück. Kopfdrehung und Neigung übernehmen sie zeitversetzt. Stoische reagieren spät und knapp, Nervöse früh und übertrieben.',
  enter(ctx) {
    const S = ctx.shared; S.leader = 4;
    const cue = (type, amp = 1) => {
      const L = ctx.arms[S.leader];
      ctx.arms.forEach(a => {
        const tk = a.temper?.id, d = Math.hypot(a.col - L.col, a.row - L.row);
        const lag = (tk === 'stoic' ? 0.35 : tk === 'nervous' ? -0.04 : 0) + d * 0.13 + a.rand * 0.05;
        const ga = amp * (tk === 'nervous' ? 1.4 : tk === 'stoic' ? 0.5 : 1);
        (a.state.clips || (a.state.clips = [])).push({ type, t0: ctx.t + Math.max(0, lag), amp: ga });
      });
    };
    ctx.on('nod', () => cue('nod')); ctx.on('shake', () => cue('shake')); ctx.on('smile', () => cue('smile'));
    ctx.on('mouthOpen', () => cue('gasp')); ctx.on('blink', () => cue('blink')); ctx.on('wink', () => cue('wink'));
  },
  update(ctx) {
    const f = ctx.face, S = ctx.shared; let bd = Infinity;
    ctx.arms.forEach(a => { const d = Math.hypot(f.gaze.x - a.ringPos.x, f.gaze.y - a.ringPos.y); if (d < bd) { bd = d; S.leader = a.index; } });
  },
  arm(ctx, arm, g) {
    const f = ctx.face, S = ctx.shared, L = ctx.arms[S.leader], t = ctx.t;
    const d = Math.hypot(arm.col - L.col, arm.row - L.row);
    const s = f.sample(0.12 * d + (arm.temper?.id === 'stoic' ? 0.3 : 0));
    reachToward(arm, f.pos, 0.55 + 0.15 * s.depth, g);
    g.tip.x += Math.sin(s.yaw) * 0.45 + s.roll * 0.25; g.tip.y += Math.sin(s.pitch) * 0.35; clampReach(arm, g);
    aimAt(g, f.pos); g.aim.x += Math.sin(s.yaw) * 0.5; g.aim.y += Math.sin(s.pitch) * 0.4; g.aim.normalize();
    g.pref[6] = s.roll * 1.5; g.pref[2] = s.roll * 0.6;
    g.k = 16; g.maxVel = 5;
    const Lm = { hue: null, k: 0, mul: 1, flash: 0 };
    const cl = arm.state.clips || [];
    for (let i = cl.length - 1; i >= 0; i--) {
      const c = cl[i], u = t - c.t0; if (u < 0) continue;
      const CL = CLIPS[c.type]; if (u > CL.dur) { cl.splice(i, 1); continue; }
      CL.fn(u, c.amp, g, Lm);
    }
    if (Lm.flash > 0) arm.flash = Math.max(arm.flash, Lm.flash * 0.8);
    const col = new THREE.Color().lerpColors(PAL.cream, PAL.sun, s.smile);
    if (Lm.hue !== null) col.lerp(new THREE.Color().setHSL(Lm.hue, 0.85, 0.55), Lm.k);
    g.led = { program: 'point', params: { target: f.gaze, sharp: 5 + 10 * s.squint, base: 0.08, center: 0.6 }, color: col,
      gain: (0.35 + 0.35 * s.depth + 0.6 * Lm.k) * Lm.mul };
  },
});

/* ---- 5 AUFMERKSAMKEIT ----------------------------------------------- */
registerBehavior({
  id: 'attention', label: 'Aufmerksamkeit',
  info: 'Neun Wesen konkurrieren um deinen Blick. Wen du ansiehst, der richtet sich auf, wackelt vor Freude und leuchtet warm – lächelst du ihn an, errötet er und schnurrt. Die anderen drängen sich in deine Blickrichtung und blinken um Beachtung: Neugierige winken, Nervöse zappeln, Stoische schmollen und lassen den Kopf hängen. Lange ignoriert, schmollen alle. Mund auf erschreckt sie.',
  enter(ctx) {
    ctx.arms.forEach(a => { a.state.att = 0.5; a.state.w = 0; a.state.startle = 0; });
    ctx.on('mouthOpen', () => ctx.arms.forEach(a => { a.triggerFlash(0.6, a.rand * 0.1); a.state.startle = 1; }));
  },
  arm(ctx, arm, g) {
    const f = ctx.face, s = arm.state, t = ctx.t, dt = ctx.dt, tk = arm.temper?.id;
    const look = 1 - smoothstep(0.5, 1.3, Math.hypot(f.gaze.x - arm.shoulder.x, f.gaze.y - (arm.shoulder.y + 0.3)));
    s.w = lerp(s.w, look, smooth(5, dt));
    s.att = clamp(s.att + (s.w * 0.6 * (1 + f.smile) - 0.06) * dt, 0, 1);
    s.startle = Math.max(0, s.startle - dt * 1.5);
    const needy = (1 - s.w) * (1 - s.att);
    const sulk = tk === 'stoic' ? needy : needy * smoothstep(0.15, 0.0, s.att);
    reachToward(arm, f.pos, 0.5 + 0.38 * s.w, g);
    if (tk !== 'stoic') g.tip.lerp(T2.set(f.gaze.x, f.gaze.y, g.tip.z), needy * 0.35);      // in die Sichtlinie drängen
    g.tip.lerp(T3.copy(arm.shoulder).add(T4.set((arm.col - 1) * 0.3, -0.45, 0.6)), sulk * 0.7);   // Kopf hängen lassen
    clampReach(arm, g);
    aimAt(g, f.pos); g.aim.lerp(T4.set((arm.col - 1) * 0.6, -0.9, 0.2).normalize(), sulk * 0.85).normalize();
    const joy = s.w * (0.6 + 0.4 * f.smile), wave = needy * (1 - sulk) * (tk === 'nervous' ? 1.5 : 1);
    g.offset[2] = Math.sin(t * 7 + arm.seed) * 0.3 * joy; g.offset[4] = Math.sin(t * 7.7 + arm.seed) * 0.3 * joy;
    g.offset[0] += Math.sin(t * (tk === 'nervous' ? 9 : 4) + arm.seed) * 0.22 * wave;
    g.offset[5] += Math.sin(t * 5 + arm.seed) * 0.2 * wave;
    g.offset[1] -= 0.3 * s.startle;
    g.pref[6] = Math.sin(t * 1.5 + arm.seed) * 1.8 * joy;
    g.k = 14 + 10 * s.w; g.maxVel = 4 + 3 * wave;
    const col = new THREE.Color().lerpColors(PAL.ice, PAL.sun, s.att).lerp(PAL.blush, f.smile * s.w);
    g.led = { program: s.w > 0.5 ? 'uniform' : 'comet', params: { level: 1, speed: 3, tail: 2, base: 0.15 }, color: col,
      gain: Math.max(0.04, 0.12 + 0.75 * s.w + 0.25 * s.att * (1 - sulk) + 0.2 * wave - 0.1 * sulk),
      pulse: f.smile * s.w > 0.2 ? { rate: 0.8, depth: 0.5 } : null,
      strobe: { rate: 2.5, duty: 0.5, amount: wave * 0.6 } };
  },
});

/* ---- 6 SCHWEIF ------------------------------------------------------ */
const SNAKE = [0, 1, 2, 5, 4, 3, 6, 7, 8];   // Schlangenpfad durchs Raster
registerBehavior({
  id: 'trail', label: 'Schweif',
  info: 'Die Wand ist ein Gedächtnis deines Kopfes: Jeder Arm zeigt deine Haltung von vor einem Moment, Arm für Arm weiter zurück – eine Schlange, die deinem Kopf folgt. Position, Drehung, Neigung, Mund, Lächeln und Blinzeln wandern als Welle durch die Wand. Nähe verkürzt das Gedächtnis.',
  arm(ctx, arm, g) {
    const f = ctx.face, k = SNAKE.indexOf(arm.index);
    const step = lerp(0.24, 0.1, f.depth) + (arm.temper?.id === 'stoic' ? 0.05 : 0);
    const s = f.sample(k * step), cp = Math.cos(s.pitch);
    g.tip.copy(arm.shoulder).add(T3.set((s.nx ?? 0) * 0.75, (s.ny ?? 0) * 0.6 + 0.15, 0.9 + 0.6 * s.depth)); clampReach(arm, g);
    g.aim.set(Math.sin(s.yaw) * cp, Math.sin(s.pitch), Math.cos(s.yaw) * cp).normalize();
    g.pref[6] = -s.roll * 1.4; g.pref[2] = s.roll * 0.5; g.pref[4] = -s.roll * 0.5;
    g.offset[3] += s.jaw * 0.35;
    g.k = 18; g.maxVel = 5;
    const fade = 1 - k / 9 * 0.6;
    g.led = { program: 'fill', params: { level: 0.15 + 0.85 * s.jaw, base: 0.06 },
      hue: { base: 0.58 - 0.47 * s.smile, spread: 0.05, speed: 0, sat: 0.4 + 0.5 * s.smile, light: 0.6 },
      gain: (0.35 + 0.4 * s.depth + 0.5 * s.jaw) * fade + (s.blink > 0.5 ? 0.9 : 0) };
  },
});

/* ---- 7 BEAT --------------------------------------------------------- */
const POSES = [
  { tip: [0, 0.9, 1.2], aim: [0, 0.3, 1] }, { tip: [0.75, 0.2, 1.4], aim: [0.6, 0, 1] },
  { tip: [-0.75, 0.2, 1.4], aim: [-0.6, 0, 1] }, { tip: [0, -0.6, 1.3], aim: [0, -0.4, 1] },
  { tip: [0, 0.2, 1.9], aim: [0, 0, 1] }, { tip: [0.45, 1.0, 0.8], aim: [0.3, 1, 0.4] },
  { tip: [-0.45, 1.0, 0.8], aim: [-0.3, 1, 0.4] },
];
registerBehavior({
  id: 'beat', label: 'Beat',
  info: 'Nicken oder Blinzeln gibt den Takt – bei jedem Schlag springen die Arme in Schachbrett-Gruppen in neue Choreografie-Posen, die Ringe rasten um. Ohne Takt zählt ein eigenes Metronom. Kopfdrehung lenkt die Posen, Neigung versetzt die Gruppen, Lächeln = Regenbogen, Mund auf = Stroboskop, Zukneifen = volle Blendung, Nähe = Energie.',
  enter(ctx) {
    const S = ctx.shared; Object.assign(S, { beat: 0, last: ctx.t, lastHuman: -99, interval: 0.6, poseA: 0, poseB: 3, hue: 0, hitT: 0 });
    S.hit = human => {
      const now = ctx.t, iv = now - S.last, f = ctx.face;
      if (human && iv > 0.25 && iv < 1.6) S.interval = lerp(S.interval, iv, 0.5);
      if (human) S.lastHuman = now;
      S.last = now; S.beat++; S.hitT = now; S.hue = (S.hue + 0.13) % 1;
      S.poseA = (S.poseA + 1 + Math.floor(Math.random() * 3)) % POSES.length; S.poseB = (S.poseA + 3) % POSES.length;
      ctx.arms.forEach(a => {
        const d = ((a.col + a.row) % 2) * S.interval * 0.5 * smoothstep(0.05, 0.4, Math.abs(f.roll)) + (f.roll > 0 ? a.col : 2 - a.col) * 0.04 * Math.abs(f.roll) * 3;
        a.state.pending = now + d; a.triggerFlash(0.7 + 0.5 * f.depth, d);
      });
    };
    ctx.on('nod', () => S.hit(true)); ctx.on('blink', () => S.hit(true));
  },
  update(ctx) {
    const S = ctx.shared, f = ctx.face;
    if (ctx.t - S.lastHuman > 3 && ctx.t - S.last > S.interval) S.hit(false);
    ctx.fx.glare = Math.max(ctx.fx.glare, f.squint * 1.3 + (ctx.t - S.hitT < 0.1 ? 0.35 * f.depth : 0));
  },
  arm(ctx, arm, g) {
    const S = ctx.shared, f = ctx.face, st = arm.state;
    if (st.pending !== undefined && ctx.t >= st.pending) { st.pose = (arm.col + arm.row) % 2 ? S.poseB : S.poseA; st.hit = ctx.t; st.pending = undefined; }
    const P = POSES[st.pose ?? 0], bounce = Math.exp(-(ctx.t - (st.hit ?? 0)) * 6), energy = 0.65 + 0.55 * f.depth;
    g.tip.copy(arm.shoulder).add(T2.fromArray(P.tip).applyAxisAngle(AY, f.yaw * 0.9).multiplyScalar(energy));
    g.tip.y += bounce * 0.15; clampReach(arm, g);
    g.aim.fromArray(P.aim).applyAxisAngle(AY, f.yaw * 0.9).normalize(); aimAt(g, f.pos, 0.25 + 0.6 * f.squint);
    g.pref[2] = ((st.pose ?? 0) % 2 ? 1 : -1) * 0.8 * bounce + Math.sin(ctx.t * 2 + arm.index) * 0.2; g.pref[4] = -g.pref[2];
    g.pref[6] = (((S.beat + arm.index) % 4) - 1.5) * 0.9;
    g.k = 60; g.maxVel = 12;
    g.led = { program: 'comet', params: { speed: 6 / S.interval, heads: 2, tail: 2, base: 0.1 + bounce * 0.6 + f.squint * 0.9 },
      hue: { base: S.hue + arm.index * 0.11, spread: 0.9 * f.smile, speed: 0.1, sat: 0.2 + 0.75 * f.smile, light: 0.6 },
      gain: (0.3 + 0.9 * bounce) * energy + f.squint * 1.5,
      strobe: { rate: 4 / S.interval, duty: 0.3, amount: f.jaw } };
  },
});

/* =====================================================================
   TEMPERAMENTE — Charakterschicht über jedem Verhalten
   flee: Fluchtbereitschaft · fleeDim: Licht beim Flüchten · apply(): verändert das Goal
   ===================================================================== */
const TEMPERAMENTS = {
  curious: { label: 'Neugierig', color: C(0xffc95a), flee: 0.75, fleeDim: 0.6,
    apply(ctx, arm, g, f) {
      const k = 1 - arm.flee;
      _h.subVectors(f.pos, g.tip).normalize(); g.tip.addScaledVector(_h, 0.2 * k); clampReach(arm, g);
      if (g.wAim > 0) aimAt(g, f.pos, 0.45 * k);
      g.pref[6] += Math.sin(ctx.t * 0.7 + arm.seed) * 0.6;            // "Kopf schief legen"
      g.offset[5] += Math.sin(ctx.t * 0.9 + arm.seed) * 0.12 * k;
    } },
  nervous: { label: 'Nervös', color: C(0xff6a4a), flee: 1.5, fleeDim: 1.2,
    apply(ctx, arm, g, f) {
      const cs = arm.cs, t = ctx.t;
      if (t > cs.sacc) {                                              // Sakkaden: kurze Blicksprünge
        cs.saccP.set(f.pos.x + (Math.random() - 0.5) * 2.4, f.pos.y + (Math.random() - 0.5) * 1.6, f.pos.z * 0.6);
        cs.saccUntil = t + 0.12 + Math.random() * 0.25; cs.sacc = t + 0.35 + Math.random() * 1.1;
      }
      if (t < cs.saccUntil && g.wAim > 0) aimAt(g, cs.saccP, 0.75);
      for (const j of [1, 3, 5]) g.offset[j] += noise1(t * 14, arm.seed + j) * 0.05;
      g.offset[2] += noise1(t * 11, arm.seed + 7) * 0.12; g.offset[4] += noise1(t * 12, arm.seed + 8) * 0.12;
      g.k *= 1.5; g.maxVel *= 1.5;
      g.led.sparkle = Math.max(g.led.sparkle || 0, 0.15 + 0.4 * arm.flee);
    } },
  stoic: { label: 'Stoisch', color: C(0x7fa8ff), flee: 0.25, fleeDim: 0.3,
    apply(ctx, arm, g, f) {
      const cs = arm.cs, a = smooth(0.7, ctx.dt);                      // träge Tiefpass-Verfolgung
      if (!cs.tip) cs.tip = g.tip.clone();
      cs.tip.lerp(g.tip, a); g.tip.copy(cs.tip);
      cs.aim.lerp(g.aim, a).normalize(); g.aim.copy(cs.aim);
      for (let j = 0; j < 7; j++) g.offset[j] *= 0.3;
      g.k *= 0.45; g.maxVel *= 0.5;
    } },
  swarmy: { label: 'Schwarmig', color: C(0x6fe0a0), flee: 1.0, fleeDim: 1,
    apply(ctx, arm, g, f) {                                          // folgt den Nachbarn
      const nb = arm.neighbors; if (!nb.length) return;
      const d = T4.set(0, 0, 0), n = T5.set(0, 0, 0);
      nb.forEach(o => { d.add(_h.subVectors(o.ringPos, o.shoulder)); n.add(o.ringNormal); });
      d.divideScalar(nb.length);
      g.tip.lerp(_h.copy(arm.shoulder).add(d), 0.45); clampReach(arm, g);
      if (g.wAim > 0) g.aim.lerp(n.normalize(), 0.4).normalize();
      g.offset[2] += Math.sin(ctx.t * 1.3 + arm.col * 0.8) * 0.15;
    } },
};
Object.entries(TEMPERAMENTS).forEach(([k, T]) => T.id = k);
const TEMPER_MIX = ['stoic', 'nervous', 'swarmy', 'curious', 'curious', 'nervous', 'swarmy', 'stoic', 'swarmy'];

/* Flucht-Schicht: kommt der Kopf nah, ziehen sich die Ringköpfe zur Wand zurück und wenden sich ab */
function applyFlight(ctx, arm, g) {
  const f = ctx.face, F = CONFIG.flight, T = arm.temper || TEMPERAMENTS.curious, dt = ctx.dt;
  const lat = Math.hypot(f.pos.x - arm.shoulder.x, f.pos.y - arm.shoulder.y);
  let target = smoothstep(F.nearStart, F.nearFull, f.depth) * (1 - 0.55 * smoothstep(0.7, F.lateral, lat));
  target += smoothstep(2.5, 6, f.speed) * 0.4;                         // schnelle Bewegung erschreckt
  target = clamp(target * T.flee * 1.25, 0, 1);
  if (T === TEMPERAMENTS.swarmy && arm.neighbors.length) {            // Flucht steckt an
    target = Math.max(target, arm.neighbors.reduce((a, o) => a + o.flee, 0) / arm.neighbors.length * 0.9);
  }
  arm.flee = lerp(arm.flee, target, smooth(target > arm.flee ? 7 : 0.9, dt));
  const e = arm.flee; if (e < 0.003) return;
  T2.set(arm.shoulder.x - f.pos.x, arm.shoulder.y - f.pos.y, 0);
  if (T2.lengthSq() < 0.04) T2.set(arm.col - 1 || (arm.rand - 0.5), arm.row - 1 || -0.5, 0);
  T2.normalize();
  const fleeTip = T3.copy(arm.shoulder).add(T4.set(T2.x * 0.6, T2.y * 0.45 - 0.15, 0.42));
  const fleeAim = T4.set(T2.x * 0.8, T2.y * 0.5 - 0.45, 0.3).normalize();
  g.tip.lerp(fleeTip, e);
  const peek = T === TEMPERAMENTS.curious ? 0.35 + 0.25 * Math.sin(ctx.t * 1.7 + arm.seed) : 0;   // Neugierige linsen zurück
  g.aim.lerp(fleeAim, e * (1 - peek)).normalize();
  g.wPos = lerp(g.wPos, 1.3, e); g.wAim = lerp(g.wAim, 0.6, e);
  for (let j = 0; j < 7; j++) g.prefW[j] = lerp(g.prefW[j], CONFIG.arm.prefW[j], e);
  g.k = lerp(g.k, 34, e); g.maxVel = lerp(g.maxVel, 9, e);
}

/* =====================================================================
   MANAGER — Idle ↔ Aktiv per face.presence überblendet, danach IK
   ===================================================================== */
function blendGoal(out, I, A, p) {
  out.tip.lerpVectors(I.tip, A.tip, p);
  out.aim.lerpVectors(I.aim, A.aim, p); if (out.aim.lengthSq() < 1e-6) out.aim.set(0, 0, 1); out.aim.normalize();
  out.wPos = lerp(I.wPos, A.wPos, p); out.wAim = lerp(I.wAim, A.wAim, p);
  for (let j = 0; j < 7; j++) { out.pref[j] = lerp(I.pref[j], A.pref[j], p); out.prefW[j] = lerp(I.prefW[j], A.prefW[j], p); out.offset[j] = lerp(I.offset[j], A.offset[j], p); }
  out.k = lerp(I.k, A.k, p); out.maxVel = lerp(I.maxVel, A.maxVel, p);
}
class BehaviorManager {
  constructor(ctx, onChange) { this.ctx = ctx; this.current = null; this.subs = []; this.onChange = onChange; }
  set(id) {
    const def = BEHAVIORS.find(b => b.id === id); if (!def || def === this.current) return;
    this.current?.exit?.(this.ctx); this.subs.forEach(u => u()); this.subs = [];
    this.ctx.shared = {}; this.ctx.arms.forEach(a => { a.state = {}; a.cs.tip = null; });
    this.ctx.on = (e, f) => this.subs.push(this.ctx.bus.on(e, f));
    this.ctx.variant = def.variants?.[0]?.id ?? null;
    this.current = def; def.enter?.(this.ctx); this.onChange?.(def);
  }
  setVariant(id) { this.ctx.variant = id; this.onChange?.(this.current); }
  cycleVariant() {
    const vs = this.current.variants; if (!vs) return;
    const i = vs.findIndex(v => v.id === this.ctx.variant); this.setVariant(vs[(i + 1) % vs.length].id);
  }
  update() {
    const ctx = this.ctx, p = ctx.face.presence;
    if (p > 0.001) this.current.update?.(ctx);
    for (const arm of ctx.arms) {
      arm.goalIdle.reset(); IDLE.arm(ctx, arm, arm.goalIdle);
      if (p > 0.001) {
        const g = arm.goalActive; g.reset(); this.current.arm(ctx, arm, g);
        arm.temper?.apply(ctx, arm, g, ctx.face);      // Charakterschicht
        applyFlight(ctx, arm, g);                     // Nähe → Flucht
      } else arm.flee = lerp(arm.flee, 0, smooth(1, ctx.dt));
      blendGoal(arm.goal, arm.goalIdle, arm.goalActive, p);
      arm.solveIK();
    }
  }
}

/* =====================================================================
   SZENE
   ===================================================================== */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5)); renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0;
document.body.prepend(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x040405); scene.fog = new THREE.Fog(0x040405, 9, 22);
const camera = new THREE.PerspectiveCamera(CONFIG.view.fov, innerWidth / innerHeight, 0.05, 100);
camera.position.copy(CONFIG.view.camPos); camera.lookAt(CONFIG.view.lookAt);

scene.add(new THREE.HemisphereLight(0x7c8796, 0x050505, 0.16));
const key = new THREE.DirectionalLight(0xc8d2e0, 0.25); key.position.set(-3, 6, 5); scene.add(key);
const graze = new THREE.DirectionalLight(0x8090a8, 0.22); graze.position.set(6, 2, 0.6); scene.add(graze);

const wall = new THREE.Mesh(new THREE.PlaneGeometry(30, 16), new THREE.MeshStandardMaterial({ color: 0x0e0f12, roughness: 0.85, metalness: 0.1 }));
wall.position.set(0, 2, 0); scene.add(wall);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(30, 20), new THREE.MeshStandardMaterial({ color: 0x08080a, roughness: 0.9 }));
floor.rotation.x = -Math.PI / 2; floor.position.set(0, FLOOR_Y, 8); scene.add(floor);

const G = CONFIG.grid, arms = [], cables = [];
for (let r = 0; r < G.rows; r++) for (let c = 0; c < G.cols; c++) {
  const base = V((c - (G.cols - 1) / 2) * G.dx, G.cy + (r - (G.rows - 1) / 2) * G.dy, 0.025);
  const arm = new RobotArm(arms.length, c, r, base);
  scene.add(arm.root, arm.glare); arms.push(arm);
  const port = new THREE.Mesh(SHARED.geo.port, SHARED.mat.joint); port.position.copy(arm.port).setZ(0.03); scene.add(port);
  arm.port.z = 0.05;
  const A = CONFIG.arm;
  const c1 = new Cable(arm.port.distanceTo(arm.shoulder) + A.L1 + 0.3, 0.02), c2 = new Cable(A.L1 + A.L2 - 0.1, 0.013);
  c1.arm = c2.arm = arm; c1.kind = 0; c2.kind = 1; cables.push(c1, c2); scene.add(c1.mesh, c2.mesh);
}

arms.forEach(a => a.neighbors = arms.filter(o => o !== a && Math.abs(o.col - a.col) <= 1 && Math.abs(o.row - a.row) <= 1));
function setTemper(mode) {
  arms.forEach((a, i) => { a.temper = TEMPERAMENTS[mode === 'mix' ? TEMPER_MIX[i] : mode]; a.cs.tip = null; });
}
setTemper('mix');

const dbgFace = new THREE.Mesh(new THREE.IcosahedronGeometry(0.16, 1), new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true }));
const dbgGaze = new THREE.Mesh(new THREE.RingGeometry(0.14, 0.18, 40), new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide }));
dbgFace.visible = dbgGaze.visible = false; scene.add(dbgFace, dbgGaze);

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), CONFIG.fx.bloomBase, CONFIG.fx.bloomRadius, CONFIG.fx.bloomThreshold);
composer.addPass(bloom); composer.addPass(new OutputPass());

/* =====================================================================
   WIRING / UI
   ===================================================================== */
const bus = new EventBus();
const face = new FaceState(bus);
const ctx = { t: 0, dt: 0, face, arms, bus, shared: {}, on: () => {}, fx: { glare: 0 } };
const leds = new LedController(arms, face);
bus.on('faceFound', () => arms.forEach(a => a.triggerFlash(0.9, (a.col + (G.rows - 1 - a.row)) * 0.07)));

const $ = id => document.getElementById(id);
const ui = { parallax: true, glare: true, cables: true, lights: true, video: true, debug: false };
const video = $('video');

// Event-Log
const evLog = [];
bus.on('*', e => { if (e === 'faceFound' || e === 'faceLost') return; evLog.unshift(e); evLog.length = Math.min(evLog.length, 4); $('ev').textContent = evLog.map((x, i) => (i ? '' : '● ') + x).join('  ·  '); });

const manager = new BehaviorManager(ctx, def => {
  document.querySelectorAll('#beh button').forEach(b => b.classList.toggle('on', b.dataset.id === def.id));
  const vEl = $('var'); vEl.innerHTML = '';
  (def.variants || []).forEach(v => {
    const b = document.createElement('button'); b.textContent = v.label; b.classList.toggle('on', v.id === ctx.variant);
    b.onclick = () => manager.setVariant(v.id); vEl.appendChild(b);
  });
  const vi = def.variants?.find(v => v.id === ctx.variant);
  $('info').textContent = def.info + (vi ? ' — ' + vi.info : '');
});

// Charakter-Auswahl
const chrEl = $('chr');
[['mix', 'Gemischt', null], ...Object.entries(TEMPERAMENTS).map(([k, T]) => [k, T.label, T.color])].forEach(([k, lab, col], i) => {
  const b = document.createElement('button'); b.dataset.k = k; b.classList.toggle('on', i === 0);
  b.innerHTML = (col ? `<i style="background:#${col.getHexString()}"></i>` : '') + lab;
  b.onclick = () => { setTemper(k); chrEl.querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b)); };
  chrEl.appendChild(b);
});
BEHAVIORS.forEach((b, i) => {
  const el = document.createElement('button'); el.dataset.id = b.id;
  el.innerHTML = `<span>${i + 1}</span>${b.label}`; el.onclick = () => manager.set(b.id); $('beh').appendChild(el);
});
manager.set('helio');

const SIGNALS = [['Präsenz', 'presence'], ['Nähe', 'depth'], ['Mund', 'jaw'], ['Lächeln', 'smile'], ['Zukneifen', 'squint'], ['Augen weit', 'wide'],
  ['Auge L', 'blinkL'], ['Auge R', 'blinkR'], ['Brauen', 'brow'], ['Drehen', 'yaw', 1], ['Nicken', 'pitch', 1], ['Neigen', 'roll', 1]];
const barEls = SIGNALS.map(([lab, k, c]) => {
  const s = document.createElement('span'); s.textContent = lab;
  const b = document.createElement('div'); b.className = 'bar'; const i = document.createElement('i'); b.appendChild(i);
  $('bars').append(s, b); return { k, c, i };
});
function updateBars() {
  for (const b of barEls) {
    const v = face[b.k];
    if (b.c) { const x = clamp(v / 0.8, -1, 1) * 50; b.i.style.left = (x < 0 ? 50 + x : 50) + '%'; b.i.style.width = Math.abs(x) + '%'; }
    else { b.i.style.left = '0'; b.i.style.width = clamp(v, 0, 1) * 100 + '%'; }
  }
}

const mouseSrc = new MouseSource();
let camSrc = null, source = mouseSrc, statusMsg = '';
const setStatus = m => statusMsg = m;
function setSourceUI() {
  document.querySelectorAll('#srcSeg button').forEach(b => b.classList.toggle('on', b.dataset.src === source.kind));
  video.classList.toggle('hidden', !(ui.video && source.kind === 'camera'));
  $('help').innerHTML = source.kind === 'mouse'
    ? 'Maus = Kopfposition · Rad = Nähe<br><kbd>Leertaste</kbd> Mund · <kbd>S</kbd> Lächeln · <kbd>X</kbd> Zukneifen · <kbd>O</kbd> Augen weit · <kbd>B</kbd> Blinzeln · <kbd>A</kbd> Zwinkern · <kbd>W</kbd> Brauen · <kbd>N</kbd> Nicken · <kbd>←→</kbd> Drehen (schnell = Schütteln) · <kbd>↑↓</kbd> Nicken · <kbd>Q</kbd>/<kbd>E</kbd> Neigen · <kbd>H</kbd> Gesicht weg · <kbd>1</kbd>–<kbd>7</kbd> Verhalten · <kbd>V</kbd> Variante'
    : 'Tasten <kbd>1</kbd>–<kbd>7</kbd> Verhalten, <kbd>V</kbd> Variante. Augen zu = Ringe heller, Augen weit = gedimmt. Komm nah – sie flüchten.';
}
async function useCamera() {
  try {
    if (!camSrc) { camSrc = new MediaPipeSource(video); await camSrc.init(setStatus); }
    source = camSrc; setStatus('Kamera aktiv');
  } catch (e) { console.warn(e); camSrc = null; source = mouseSrc; setStatus('Kamera nicht verfügbar – Maus-Simulation'); }
  setSourceUI();
}
document.querySelectorAll('#srcSeg button').forEach(b => b.onclick = () => {
  if (b.dataset.src === 'camera') useCamera(); else { source = mouseSrc; setStatus('Maus-Simulation'); setSourceUI(); }
});
const toggle = (id, k, after) => { $(id).onclick = () => { ui[k] = !ui[k]; $(id).classList.toggle('on', ui[k]); after?.(); }; };
toggle('tParallax', 'parallax'); toggle('tGlare', 'glare'); toggle('tVideo', 'video', setSourceUI); toggle('tDebug', 'debug');
toggle('tCables', 'cables', () => cables.forEach(c => c.mesh.visible = ui.cables));
toggle('tLights', 'lights', () => arms.forEach(a => a.light.visible = ui.lights));
addEventListener('keydown', e => {
  const n = parseInt(e.key, 10); if (n >= 1 && n <= BEHAVIORS.length) manager.set(BEHAVIORS[n - 1].id);
  if (e.code === 'KeyV') manager.cycleVariant();
});
setSourceUI(); useCamera();

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight); composer.setSize(innerWidth, innerHeight);
});

/* =====================================================================
   LOOP
   ===================================================================== */
let last = performance.now(), glareS = 0, fps = 60, frameErr = null;
const camOff = V(), _cam = V();
function frame(now) {
  requestAnimationFrame(frame);
  try { step(now); }
  catch (e) { if (!frameErr) { frameErr = e; console.error(e); $('ev').textContent = '⚠ ' + e.message; } }
}
function step(now) {
  const rawDt = (now - last) / 1000;
  const dt = clamp(rawDt, 0, 1 / 20); last = now;
  if (rawDt > 0) fps = lerp(fps, 1 / rawDt, 0.05);
  ctx.t += dt; ctx.dt = dt; ctx.fx.glare = 0;

  face.update(source.read(now), dt);
  manager.update();
  arms.forEach(a => a.update(dt));
  scene.updateMatrixWorld();
  arms.forEach(a => a.readWorld());
  if (ui.cables) for (const c of cables) c.kind === 0 ? c.update(c.arm.port, c.arm.pElbow, dt) : c.update(c.arm.pShoulderA, c.arm.pWrist, dt);

  // Blendung / Bloom
  glareS = lerp(glareS, (ui.glare ? ctx.fx.glare : 0) * face.presence, smooth(6, dt));
  bloom.strength = CONFIG.fx.bloomBase + glareS * CONFIG.fx.bloomGlare;
  renderer.toneMappingExposure = 1 + glareS * CONFIG.fx.exposureGlare;

  // Parallax-Kamera
  const P = CONFIG.view.parallax, k = ui.parallax ? face.presence : 0;
  _cam.set(face.nx * P.x, face.ny * P.y, face.depth * P.z).multiplyScalar(k);
  camOff.lerp(_cam, smooth(3, dt));
  camera.position.copy(CONFIG.view.camPos).add(camOff); camera.lookAt(CONFIG.view.lookAt);
  camera.updateMatrixWorld();

  leds.update(dt, ctx.t, camera.position, glareS, ui.glare);

  dbgFace.visible = dbgGaze.visible = ui.debug && face.presence > 0.1;
  dbgFace.position.copy(face.pos); dbgGaze.position.copy(face.gaze);

  $('status').textContent = statusMsg + (source.ready ? (face.present ? ' · Gesicht' : ' · kein Gesicht') : '') + ' · ' + Math.round(fps) + ' fps';
  $('dot').classList.toggle('on', face.present);
  updateBars();
  composer.render();
}
requestAnimationFrame(frame);
</script>
</body>
</html>