THE OCTOPUSS

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/6bbbb2b7e1284fd8/" sandbox="allow-scripts allow-forms allow-modals" 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>Oktopus</title>
<style>
  :root { --ink:#1a1a1a; --mute:#8c8c8c; --line:#e9e9e9; }
  html, body { margin:0; height:100%; background:#fff; overflow:hidden; color:var(--ink);
    font:12px/1.35 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
  canvas { display:block; touch-action:none; }
  #ui { position:fixed; top:12px; right:12px; width:272px; max-height:calc(100% - 24px); overflow:auto;
    background:#fff; border:1px solid var(--line); border-radius:8px; padding:10px 12px 8px; box-sizing:border-box; }
  #ui header { display:flex; align-items:center; justify-content:space-between; margin-bottom:6px; }
  #ui header b { font-weight:600; font-size:13px; }
  button { font:inherit; color:var(--ink); background:#fff; border:1px solid #dcdcdc; border-radius:5px;
    padding:3px 9px; cursor:pointer; }
  button:hover { border-color:#111; }
  button:focus-visible, input:focus-visible, summary:focus-visible { outline:2px solid #111; outline-offset:1px; }
  .bar { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px; }
  details { border-top:1px solid var(--line); padding:6px 0 4px; }
  summary { cursor:pointer; font-weight:600; padding:2px 0 4px; list-style:none; }
  summary::-webkit-details-marker { display:none; }
  summary::before { content:"+"; display:inline-block; width:14px; color:var(--mute); }
  details[open] > summary::before { content:"–"; }
  .t { display:flex; align-items:center; gap:7px; padding:2px 0; cursor:pointer; }
  .r { display:grid; grid-template-columns:1fr auto; gap:0 8px; padding:3px 0 2px; }
  .r output { color:var(--mute); font-variant-numeric:tabular-nums; }
  .r input { grid-column:1 / 3; width:100%; margin:2px 0 0; }
  input[type=range], input[type=checkbox] { accent-color:#111; }
  .seed { display:flex; align-items:center; gap:6px; padding:3px 0; }
  .seed input { width:64px; font:inherit; border:1px solid #dcdcdc; border-radius:5px; padding:2px 5px; }
  #status { color:var(--mute); padding:6px 0 2px; border-top:1px solid var(--line); font-variant-numeric:tabular-nums; }
  #hint { position:fixed; left:14px; bottom:12px; color:var(--mute); max-width:60vw; pointer-events:none; }
  #notice { position:fixed; left:14px; top:12px; color:#b3261e; pointer-events:none; }
  #ui.collapsed .body { display:none; }
  .r input.col { grid-column:auto; width:34px; height:20px; padding:0; border:1px solid #dcdcdc; border-radius:4px; background:#fff; margin:0; }
  .file { display:inline-block; margin:4px 0 2px; }
  .file input { display:none; }
</style>
<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/",
    "three-mesh-bvh": "https://cdn.jsdelivr.net/npm/three-mesh-bvh@0.7.3/build/index.module.js"
} }
</script>
</head>
<body>
<div id="notice"></div>
<div id="hint">Linke Maustaste ziehen: Oktopus bewegen · Shift + ziehen: Tiefe · Mausrad: Zoom · Rechte Maustaste: Ansicht drehen · Leertaste: loslassen · GLB hierher ziehen</div>
<div id="ui">
  <header><b>Oktopus</b><button id="fold" title="Menü ein-/ausklappen">–</button></header>
  <div class="body">
    <div class="bar">
      <button id="reroll">Neu würfeln</button>
      <button id="release">Loslassen</button>
      <button id="defaults">Standardwerte</button>
    </div>
    <div class="bar">
      <button id="copy">Als Mesh kopieren</button>
      <button id="clearcopies">Kopien entfernen</button>
    </div>
    <div id="sections"></div>
    <div id="status"></div>
  </div>
</div>

<script type="module">
import * as THREE from 'three';
import { MeshBVH } from 'three-mesh-bvh';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';

// ==== SIM ====
const V3 = THREE.Vector3;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const ease = t => t * t * (3 - 2 * t);
function rng(seed) {
  let a = seed >>> 0;
  return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
}

const IDLE = 0, REACH = 1, WRAP = 2, RELEASE = 3;

const DEFAULT_P = {
  seed: 7, length: 3.4, segments: 26, thickness: 1.0, headSize: 1.0,
  inertia: 0.55, drag: 0.8, stiffness: 1.0, gravity: 0.5, wander: 0.6, curl: 0.9,
  reachDist: 3.6, contactDist: 1.15, spread: 1.0, stagger: 0.10, reachSpeed: 1.0,
  wrapSpeed: 1.2, pitch: 0.35, serp: 0.5, wrapFraction: 0.85, creep: 0.3, grip: 1.0, pull: 1.0, tear: 1.15,
  objectScale: 1.0,
  color: '#2b2d31', contact: '#e0787a', rough: 0.18, metal: 0.25, envInt: 1.0,
};
const DEFAULT_M = { reach: true, wrap: true, pull: true, tear: true, collide: true, wander: true,
  lookAt: true, chroma: true, twin: true, bones: false, debug: false };
const P = { ...DEFAULT_P };
const M = { ...DEFAULT_M };

const W = {
  scene: new THREE.Scene(),
  hp: new V3(0, 2.4, 3.4), hv: new V3(), hq: new THREE.Quaternion(),
  target: new V3(0, 2.4, 3.4), pullAcc: new V3(),
  time: 0, R: 0.5, main: null, head: null, octoRoot: null, tent: [], armMats: [], headMat: null, cBase: new THREE.Color(), cContact: new THREE.Color(), twinRoot: null, headTwin: null, envMap: null, copies: [],
  hd: 1e3, cpHead: new V3(), nHead: new V3(),
  bvh: null, geo: null, raw: null, nSign: 1,
  objMesh: null,
};
W.objMesh = new THREE.Mesh(new THREE.BufferGeometry(),
  new THREE.MeshStandardMaterial({ color: 0xf4f4f4, roughness: 1, metalness: 0, envMapIntensity: 0.6, side: THREE.DoubleSide }));
W.objMesh.castShadow = true; W.objMesh.receiveShadow = true;
W.scene.add(W.objMesh);

// ---------- Objekt und Oberflächenabfrage ----------
function setRawGeometry(g) { W.raw = g; bakeObject(); }
function bakeObject() {
  if (!W.raw) return;
  const g = W.raw.clone();
  for (const k of Object.keys(g.attributes)) if (k !== 'position' && k !== 'normal') g.deleteAttribute(k);
  if (!g.attributes.normal) g.computeVertexNormals();
  g.computeBoundingBox();
  const c = new V3(), s = new V3();
  g.boundingBox.getCenter(c); g.boundingBox.getSize(s);
  const f = 3.8 / Math.max(s.x, s.y, s.z) * P.objectScale;
  g.translate(-c.x, -c.y, -c.z); g.scale(f, f, f);
  const pa = g.attributes.position, na = g.attributes.normal;
  let acc = 0;
  for (let i = 0; i < pa.count; i += 7) acc += pa.getX(i) * na.getX(i) + pa.getY(i) * na.getY(i) + pa.getZ(i) * na.getZ(i);
  W.nSign = acc >= 0 ? 1 : -1;
  W.bvh = new MeshBVH(g);
  W.geo = g;
  W.objMesh.geometry.dispose();
  W.objMesh.geometry = g;
}

const _tgt = { point: new V3(), distance: 0, faceIndex: 0 };
const _sa = new V3(), _sb = new V3(), _sc = new V3(), _na = new V3(), _nb = new V3(), _nc = new V3(), _nn = new V3(), _sd = new V3();
function surf(p, out) {
  const g = W.geo;
  if (!g) { out.cp.copy(p); out.n.set(0, 0, 1); out.sd = 1e3; return out; }
  const r = W.bvh.closestPointToPoint(p, _tgt) || _tgt;
  const f = r.faceIndex, ix = g.index, pa = g.attributes.position, na = g.attributes.normal;
  const i0 = ix ? ix.getX(f * 3) : f * 3, i1 = ix ? ix.getX(f * 3 + 1) : f * 3 + 1, i2 = ix ? ix.getX(f * 3 + 2) : f * 3 + 2;
  _sa.fromBufferAttribute(pa, i0); _sb.fromBufferAttribute(pa, i1); _sc.fromBufferAttribute(pa, i2);
  _na.fromBufferAttribute(na, i0); _nb.fromBufferAttribute(na, i1); _nc.fromBufferAttribute(na, i2);
  THREE.Triangle.getInterpolation(r.point, _sa, _sb, _sc, _na, _nb, _nc, _nn);
  if (_nn.lengthSq() < 1e-12) _nn.set(0, 1, 0);
  _nn.normalize().multiplyScalar(W.nSign);
  out.cp.copy(r.point); out.n.copy(_nn);
  _sd.subVectors(p, r.point);
  out.sd = _sd.dot(_nn) >= 0 ? r.distance : -r.distance;
  return out;
}
const mkS = () => ({ cp: new V3(), n: new V3(), sd: 0 });
const S1 = mkS(), S2 = mkS(), S3 = mkS();

// ---------- Oktopus erzeugen ----------
function radiusAt(T, s) {
  const r0 = 0.30 * W.R * P.thickness;
  return Math.max(0.008, r0 * Math.pow(1 - s, 0.85) * (1 + 0.35 * Math.exp(-s * 10)));
}

function makeTentacle(k, n, rnd, R) {
  const N = P.segments, L = P.length, seg = L / (N - 1);
  const a = (k + (rnd() - 0.5) * 0.3) / n * Math.PI * 2;
  const T = {
    k, N, L, seg, a,
    hand: rnd() < 0.72 ? 1 : -1,
    speedMul: 0.8 + rnd() * 0.45, curlMul: 0.6 + rnd() * 0.9,
    ph: rnd() * 6.283, sp: 0.7 + rnd() * 0.7, tilt: (rnd() - 0.5) * 1.6,
    layer: rnd(), spreadMul: 0.6 + rnd() * 0.8, jit: rnd() * 0.1, jit2: (rnd() - 0.5) * 0.7,
    pitchSign: rnd() < 0.5 ? -1 : 1, wrapHand: rnd() < 0.7 ? 1 : -1,
    wrapAxis: new V3((rnd() - 0.5) * 0.9, 1, (rnd() - 0.5) * 0.9).normalize(),
    state: IDLE, blend: 1, blendDur: 0.4, cool: 0, armT: 0, startDelay: 0, grabT: 0, rank: 0, facing: 0,
    reachE: 0, relT: 0, relCool: 2, tearT: 0, wrapT: 0, ex: 0, zeta: 0.55,
    trailLen: 0, trailPhase: 0, tautRatio: 0, Dfree: 0, Lfree: 0, hasArc: false,
  };
  const rr = R * 0.62;
  T.attach = new V3(Math.cos(a) * rr, -R * 0.62, Math.sin(a) * rr);
  T.dir = new V3(Math.cos(a) * 0.5, -0.86, Math.sin(a) * 0.5).normalize();
  const tan = new V3(-Math.sin(a), 0, Math.cos(a));
  T.axis = tan.applyAxisAngle(T.dir, T.tilt).normalize();
  T.rad = new Float32Array(N);
  for (let i = 0; i < N; i++) T.rad[i] = radiusAt(T, i / (N - 1));
  const mk = () => Array.from({ length: N }, () => new V3());
  T.p = mk(); T.pp = mk(); T.goal = mk(); T.gFrom = mk(); T.gN = mk(); T.gR = mk(); T.cn = mk();
  T.tan = mk(); T.up = mk();
  T.rate = new Float32Array(N); T.rFrom = new Float32Array(N); T.rN = new Float32Array(N); T.rR = new Float32Array(N);
  T.sd = new Float32Array(N).fill(1e3); T.cw = new Float32Array(N); T.onTrail = new Uint8Array(N);
  T.root = new V3(); T.outW = new V3(); T.dirW = new V3(); T.inw = new V3();
  T.target = new V3(); T.targetN = new V3(0, 0, 1); T.tq = new V3(); T.reachStart = new V3();
  T.front = new V3(); T.frontN = new V3(0, 0, 1); T.heading = new V3(1, 0, 0); T.anchor = new V3();
  T.trail = [];
  return T;
}

function attachSkin(T, root) {
  const N = T.N, seg = T.seg, S = 16;
  const rpb = Math.max(2, Math.round(72 / (N - 1)));
  const Rn = (N - 1) * rpb + 1;
  const pos = new Float32Array(Rn * S * 3), sI = new Uint16Array(Rn * S * 4), sW = new Float32Array(Rn * S * 4);
  const col = new Float32Array(Rn * S * 3).fill(1);
  for (let r = 0; r < Rn; r++) {
    const s = r / (Rn - 1), bf = s * (N - 1), b0 = Math.min(Math.floor(bf), N - 2), f = bf - b0;
    const y = s * (N - 1) * seg;
    const rad = lerp(T.rad[b0], T.rad[b0 + 1], f);
    for (let j = 0; j < S; j++) {
      const phi = j / S * Math.PI * 2;
      const o = (r * S + j) * 3;
      pos[o] = T.attach.x + Math.sin(phi) * rad;
      pos[o + 1] = T.attach.y + y;
      pos[o + 2] = T.attach.z + Math.cos(phi) * rad;
      const w = (r * S + j) * 4;
      sI[w] = b0; sI[w + 1] = b0 + 1; sW[w] = 1 - f; sW[w + 1] = f;
    }
  }
  const idx = [];
  for (let r = 0; r < Rn - 1; r++) for (let j = 0; j < S; j++) {
    const a = r * S + j, b = r * S + (j + 1) % S, c = (r + 1) * S + j, d = (r + 1) * S + (j + 1) % S;
    idx.push(a, b, c, b, d, c);
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
  geo.setAttribute('skinIndex', new THREE.BufferAttribute(sI, 4));
  geo.setAttribute('skinWeight', new THREE.BufferAttribute(sW, 4));
  geo.setIndex(idx);
  geo.computeVertexNormals();
  T.rings = Rn; T.S = S; T.colorAttr = geo.attributes.color;

  const bones = [];
  for (let i = 0; i < N; i++) {
    const b = new THREE.Bone();
    if (i === 0) { b.position.copy(T.attach); W.main.add(b); }
    else { b.position.set(0, seg, 0); bones[i - 1].add(b); }
    bones.push(b);
  }
  W.main.updateMatrixWorld(true);
  const skel = new THREE.Skeleton(bones);
  const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, vertexColors: true });
  W.armMats.push(mat);
  const mesh = new THREE.SkinnedMesh(geo, mat);
  mesh.frustumCulled = false; mesh.castShadow = true; mesh.receiveShadow = true;
  mesh.bind(skel, new THREE.Matrix4());
  root.add(mesh);
  T.bones = bones; T.mesh = mesh;
  const tw = new THREE.SkinnedMesh(geo, mat);
  tw.bindMode = THREE.DetachedBindMode;
  tw.frustumCulled = false; tw.castShadow = true; tw.receiveShadow = true;
  tw.bind(skel, new THREE.Matrix4());
  W.twinRoot.add(tw);
  T.meshTwin = tw;

  // Debug: Bones und Pfade
  const lg = new THREE.BufferGeometry();
  lg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
  T.boneLine = new THREE.Line(lg, new THREE.LineBasicMaterial({ color: 0x9a9a9a, depthTest: false, transparent: true }));
  T.boneDots = new THREE.Points(lg, new THREE.PointsMaterial({ color: 0x444444, size: 4, sizeAttenuation: false, depthTest: false }));
  T.boneLine.renderOrder = 10; T.boneDots.renderOrder = 11;
  T.boneLine.visible = T.boneDots.visible = false;
  T.boneLine.frustumCulled = T.boneDots.frustumCulled = false;
  const tg = new THREE.BufferGeometry();
  tg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 6 * 3), 3));
  T.trailLine = new THREE.Line(tg, new THREE.LineBasicMaterial({ color: 0xe5484d, depthTest: false, transparent: true, opacity: 0.8 }));
  T.trailLine.renderOrder = 12; T.trailLine.frustumCulled = false; T.trailLine.visible = false;
  const dg = new THREE.BufferGeometry();
  dg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3));
  T.tDot = new THREE.Points(dg, new THREE.PointsMaterial({ color: 0xe5484d, size: 8, sizeAttenuation: false, depthTest: false }));
  T.tDot.renderOrder = 12; T.tDot.frustumCulled = false; T.tDot.visible = false;
  root.add(T.boneLine, T.boneDots, T.trailLine, T.tDot);
}

function applyMaterial() {
  W.cBase.set(P.color); W.cContact.set(P.contact);
  const rough = clamp(+P.rough || 0, 0.02, 1), metal = clamp(+P.metal || 0, 0, 1), env = clamp(+P.envInt || 0, 0, 3);
  for (const m of [W.headMat, ...W.armMats]) {
    if (!m) continue;
    m.roughness = rough; m.metalness = metal; m.envMapIntensity = env;
    if (m.envMap !== W.envMap) { m.envMap = W.envMap; m.needsUpdate = true; }
  }
  if (W.headMat) W.headMat.color.set(P.color);
}

function disposeOcto() {
  if (!W.octoRoot) return;
  W.octoRoot.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); });
  W.scene.remove(W.octoRoot);
  W.octoRoot = null; W.tent = [];
}

function buildOctopus() {
  disposeOcto();
  const rnd = rng(Math.floor(P.seed) * 7919 + 13);
  const R = 0.5 * P.headSize; W.R = R;
  const n = 7 + Math.floor(rnd() * 3);
  const root = new THREE.Group(); W.octoRoot = root; W.scene.add(root);
  const main = new THREE.Bone(); main.name = 'main'; root.add(main); W.main = main;
  W.twinRoot = new THREE.Group(); W.twinRoot.scale.x = -1; root.add(W.twinRoot);
  W.armMats = [];
  W.headMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
  const head = new THREE.Mesh(new THREE.SphereGeometry(R, 40, 28).scale(1, 1.15, 1), W.headMat);
  head.castShadow = true; head.receiveShadow = true; main.add(head); W.head = head;
  W.headTwin = new THREE.Mesh(head.geometry, W.headMat);
  W.headTwin.castShadow = true; W.headTwin.receiveShadow = true; W.twinRoot.add(W.headTwin);
  for (let k = 0; k < n; k++) W.tent.push(makeTentacle(k, n, rnd, R));
  for (const T of W.tent) attachSkin(T, root);
  main.position.copy(W.hp); main.quaternion.copy(W.hq);
  applyMaterial();
  updateFrames();
  for (const T of W.tent) {
    restGoals(T, T.goal);
    for (let i = 0; i < T.N; i++) {
      T.p[i].copy(T.goal[i]); T.pp[i].copy(T.goal[i]); T.gFrom[i].copy(T.goal[i]);
      T.rate[i] = T.rFrom[i] = 10;
    }
  }
}

// ---------- Hilfen pro Frame ----------
const _u = new V3(), _e1 = new V3(), _e2 = new V3(), _q = new V3(), _v = new V3(), _w = new V3();
function updateFrames() {
  for (const T of W.tent) {
    T.root.copy(T.attach).applyQuaternion(W.hq).add(W.hp);
    T.outW.set(Math.cos(T.a), 0, Math.sin(T.a)).applyQuaternion(W.hq);
    T.dirW.copy(T.dir).applyQuaternion(W.hq);
  }
}

// ---------- Ruheform ----------
const _h = new V3(), _pos = new V3();
function restGoals(T, out) {
  const N = T.N, seg = T.seg, t = W.time;
  _pos.copy(T.attach); _h.copy(T.dir);
  out[0].copy(_pos).applyQuaternion(W.hq).add(W.hp);
  for (let i = 1; i < N; i++) {
    const s = i / (N - 1);
    const curv = P.curl * T.curlMul * T.hand * (0.1 + 2.4 * s * s);
    if (M.wander) {
      const w = P.wander * 2.0 * s * seg;
      _h.x += w * Math.sin(t * 0.9 * T.sp + T.ph + i * 0.42);
      _h.y += w * 0.6 * Math.sin(t * 0.7 * T.sp + T.ph * 1.7 + i * 0.37);
      _h.z += w * Math.sin(t * 1.1 * T.sp + T.ph * 0.6 + i * 0.45 + 1.3);
    }
    _h.applyAxisAngle(T.axis, curv * seg).normalize();
    _pos.addScaledVector(_h, seg);
    out[i].copy(_pos).applyQuaternion(W.hq).add(W.hp);
  }
}

// ---------- Bogen von der Wurzel zu einem Ziel (Länge bleibt Lfree) ----------
const _d = new V3(), _dn = new V3(), _lat = new V3(), _lat2 = new V3(), _ctrl = new V3();
function arcPoints(T, root, E, m, Lfree, hint, waveAmp, out) {
  _d.subVectors(E, root);
  const D = Math.max(_d.length(), 1e-6);
  _dn.copy(_d).divideScalar(D);
  _lat.copy(hint).addScaledVector(_dn, -hint.dot(_dn));
  if (_lat.lengthSq() < 1e-6) { _lat.set(0, 1, 0).addScaledVector(_dn, -_dn.y); if (_lat.lengthSq() < 1e-6) _lat.set(1, 0, 0); }
  _lat.normalize();
  _lat2.crossVectors(_dn, _lat);
  const slack = Math.max(0, Lfree - D);
  const h = Math.min(Math.sqrt(3 * D * slack / 8), Lfree * 0.6);
  _ctrl.copy(root).addScaledVector(_d, 0.5).addScaledVector(_lat, 2 * h);
  for (let i = 0; i <= m; i++) {
    const u = i / m, a = (1 - u) * (1 - u), b = 2 * u * (1 - u), c = u * u;
    out[i].set(root.x * a + _ctrl.x * b + E.x * c, root.y * a + _ctrl.y * b + E.y * c, root.z * a + _ctrl.z * b + E.z * c);
    if (waveAmp > 0) {
      const env = Math.sin(Math.PI * u) * waveAmp;
      const ang = 6.283 * (1.4 * u - W.time * 1.3 * T.sp) + T.ph;
      out[i].addScaledVector(_lat, env * Math.sin(ang)).addScaledVector(_lat2, env * Math.cos(ang) * 0.7);
    }
  }
  return D;
}

// ---------- Ausstrecken ----------
const _hov = new V3(), _E = new V3(), _hint = new V3(), _b1 = new V3(), _b2 = new V3();
function reachGoals(T, out, rate) {
  const N = T.N;
  _hov.copy(T.target).addScaledVector(T.targetN, T.rad[N - 1] + 0.09);
  _b1.set(0, 1, 0); if (Math.abs(T.targetN.y) > 0.9) _b1.set(1, 0, 0);
  _b1.addScaledVector(T.targetN, -_b1.dot(T.targetN)).normalize();
  _b2.crossVectors(T.targetN, _b1);
  const tw = W.time * 2.2 * T.sp + T.ph;
  _hov.addScaledVector(_b1, Math.cos(tw) * 0.07).addScaledVector(_b2, Math.sin(tw) * 0.07);
  _d.subVectors(_hov, T.root);
  const dist = _d.length(), Lmax = T.L * 0.985;
  if (dist > Lmax) _E.copy(T.root).addScaledVector(_d, Lmax / dist); else _E.copy(_hov);
  const e = ease(T.reachE);
  _E.lerpVectors(T.reachStart, _E, e);
  _hint.copy(T.outW).multiplyScalar(0.7).addScaledVector(T.targetN, 0.7);
  _hint.y += 0.3;
  arcPoints(T, T.root, _E, N - 1, T.L, _hint, 0.05 * T.L * (1 - 0.6 * e) * (M.wander ? 1 : 0.3), out);
  for (let i = 0; i < N; i++) rate[i] = 6 + 16 * (i / (N - 1));
}

// ---------- Umschlingen ----------
const _lp = [], _ln = [], _ld = [];
function wrapGoals(T, out, rate) {
  const N = T.N, seg = T.seg;
  _lp.length = 0; _ln.length = 0; _ld.length = 0;
  _lp.push(T.front); _ln.push(T.frontN); _ld.push(0);
  let acc = 0;
  for (let j = T.trail.length - 1; j >= 0; j--) {
    const e = T.trail[j];
    acc += _lp[_lp.length - 1].distanceTo(e.p);
    _lp.push(e.p); _ln.push(e.n); _ld.push(acc);
  }
  const total = acc; T.trailLen = total;
  T.onTrail.fill(0);
  let k = 0, iS = N - 1;
  for (let i = N - 1; i >= 0; i--) {
    const a = (N - 1 - i) * seg;
    if (a > total + 1e-6) break;
    while (k < _lp.length - 2 && _ld[k + 1] < a) k++;
    const dl = _ld[k + 1] - _ld[k];
    const f = dl > 1e-9 ? clamp((a - _ld[k]) / dl, 0, 1) : 0;
    _v.lerpVectors(_lp[k], _lp[k + 1], f);
    _w.lerpVectors(_ln[k], _ln[k + 1], f).normalize();
    out[i].copy(_v).addScaledVector(_w, T.rad[i] * (1 + 0.3 * T.layer) + 0.006);
    rate[i] = 22 * P.grip; T.onTrail[i] = 1; iS = i;
  }
  T.hasArc = iS > 0;
  if (iS > 0) {
    _E.copy(out[iS]);
    _hint.copy(_ln[_ln.length - 1]).multiplyScalar(0.9).addScaledVector(T.outW, 0.4);
    const Lf = iS * seg;
    const D = arcPoints(T, T.root, _E, iS, Lf, _hint, 0.03 * Lf * (M.wander ? 1 : 0), out);
    for (let i = 0; i < iS; i++) rate[i] = 7;
    T.Dfree = D; T.Lfree = Lf; T.tautRatio = D / Lf; T.anchor.copy(_E);
  } else {
    out[0].copy(T.root); T.tautRatio = 0; T.Dfree = 0; T.Lfree = 0;
  }
}

function measureTrail(T) {
  let acc = 0;
  if (!T.trail.length) return 0;
  acc += T.front.distanceTo(T.trail[T.trail.length - 1].p);
  for (let j = T.trail.length - 1; j > 0; j--) acc += T.trail[j].p.distanceTo(T.trail[j - 1].p);
  return acc;
}

const _t = new V3(), _ap = new V3();
function advanceFront(T, dt) {
  const cap = P.wrapFraction * T.L;
  let speed = P.wrapSpeed * T.speedMul * (0.65 + 0.35 * Math.sin(W.time * 3.1 * T.sp + T.ph));
  if (T.hasArc) speed *= smooth(1.0, 0.86, T.tautRatio);
  let total = measureTrail(T);
  if (total >= cap) speed = P.creep * P.wrapSpeed * T.speedMul * 0.5 * (T.hasArc ? smooth(1.0, 0.86, T.tautRatio) : 1);
  if (speed * dt > 1e-7) {
    const n = T.frontN, ax = T.wrapAxis;
    _t.crossVectors(n, ax).multiplyScalar(T.wrapHand);
    if (_t.length() > 0.25) {
      _t.normalize();
      _ap.copy(ax).addScaledVector(n, -ax.dot(n));
      _t.addScaledVector(_ap, P.pitch * T.pitchSign).normalize();
      if (P.serp > 0) _t.applyAxisAngle(n, P.serp * 0.9 * Math.sin(T.trailPhase));
      T.heading.lerp(_t, 1 - Math.exp(-5 * dt));
    }
    T.heading.addScaledVector(n, -T.heading.dot(n));
    if (T.heading.lengthSq() < 1e-6) T.heading.crossVectors(n, ax);
    if (T.heading.lengthSq() < 1e-6) T.heading.set(1, 0, 0).addScaledVector(n, -n.x);
    T.heading.normalize();
    T.front.addScaledVector(T.heading, speed * dt);
    surf(T.front, S1);
    T.front.copy(S1.cp); T.frontN.copy(S1.n);
    T.trailPhase += speed * dt * 9;
    const last = T.trail[T.trail.length - 1];
    if (last.p.distanceTo(T.front) >= T.seg * 0.55) T.trail.push({ p: T.front.clone(), n: T.frontN.clone() });
    total = measureTrail(T);
  }
  while (total > cap && T.trail.length > 2) {
    total -= T.trail[0].p.distanceTo(T.trail[1].p);
    T.trail.shift();
  }
}

// ---------- Zustände ----------
function setBlend(T, dur) {
  for (let i = 0; i < T.N; i++) { T.gFrom[i].copy(T.goal[i]); T.rFrom[i] = T.rate[i]; }
  T.blend = 0; T.blendDur = dur;
}
function goIdle(T, cool) { T.state = IDLE; T.cool = cool; T.armT = 0; T.grabT = 0; setBlend(T, 0.6); }
function beginRelease(T, cool) {
  if (T.state === WRAP) { T.state = RELEASE; T.relT = 0; T.relCool = cool; }
  else if (T.state === REACH) goIdle(T, cool);
}
function releaseAll(cool = 3.5) { for (const T of W.tent) beginRelease(T, cool); }

function updateTarget(T, dt, first) {
  _u.subVectors(W.cpHead, W.hp);
  const l = _u.length();
  if (l < 1e-4) _u.copy(W.nHead).negate(); else _u.divideScalar(l);
  _e1.set(0, 1, 0); if (Math.abs(_u.y) > 0.9) _e1.set(1, 0, 0);
  _e1.addScaledVector(_u, -_e1.dot(_u)).normalize();
  _e2.crossVectors(_u, _e1);
  const az = Math.atan2(T.outW.dot(_e2), T.outW.dot(_e1)) + T.jit2;
  const sp = P.spread * T.spreadMul;
  _q.copy(W.cpHead).addScaledVector(_e1, Math.cos(az) * sp).addScaledVector(_e2, Math.sin(az) * sp);
  if (first) T.tq.copy(_q); else T.tq.lerp(_q, 1 - Math.exp(-4 * dt));
  surf(T.tq, S2);
  T.target.copy(S2.cp); T.targetN.copy(S2.n);
}

function enterReach(T) {
  T.state = REACH; setBlend(T, 0.45);
  T.reachE = 0; T.grabT = 0;
  T.reachStart.copy(T.p[T.N - 1]);
  updateTarget(T, 0, true);
}

function enterWrap(T) {
  T.state = WRAP; setBlend(T, 0.3);
  T.front.copy(T.target); T.frontN.copy(T.targetN);
  T.trail.length = 0; T.trail.push({ p: T.front.clone(), n: T.frontN.clone() });
  _v.subVectors(T.target, T.root).normalize();
  _v.addScaledVector(T.frontN, -_v.dot(T.frontN));
  if (_v.lengthSq() < 1e-4) _v.crossVectors(T.frontN, T.wrapAxis);
  if (_v.lengthSq() < 1e-4) _v.set(1, 0, 0);
  T.heading.copy(_v).normalize();
  T.trailPhase = T.ph; T.tearT = 0; T.tautRatio = 0; T.wrapT = 0; T.hasArc = false;
}

function updateArms(dt) {
  const contact = M.wrap && W.hd < P.contactDist;
  const nearEnter = M.reach ? W.hd < P.reachDist : contact;
  const nearKeep = M.reach ? W.hd < P.reachDist * 1.15 : (M.wrap && W.hd < P.contactDist * 1.3);

  _u.subVectors(W.cpHead, W.hp);
  if (_u.lengthSq() > 1e-8) _u.normalize(); else _u.set(0, -1, 0);
  const reachers = [];
  for (const T of W.tent) {
    T.facing = T.dirW.dot(_u);
    if (T.state === REACH) { T.rank = 0; T.rd = T.p[T.N - 1].distanceToSquared(T.target); reachers.push(T); }
  }
  reachers.sort((a, b) => a.rd - b.rd);
  reachers.forEach((T, i) => { T.rank = i; });

  const exT = [0, 0.3, 1, 0.25];
  for (const T of W.tent) {
    T.blend = Math.min(1, T.blend + dt / T.blendDur);
    switch (T.state) {
      case IDLE:
        if (T.cool > 0) T.cool -= dt;
        T.startDelay = P.stagger * W.tent.length * 0.5 * (1 - T.facing) + T.jit;
        if (nearEnter && T.cool <= 0) { T.armT += dt; if (T.armT >= T.startDelay) enterReach(T); }
        else T.armT = 0;
        break;
      case REACH:
        updateTarget(T, dt, false);
        T.reachE = Math.min(1, T.reachE + dt * P.reachSpeed * T.speedMul * 0.8);
        if (!nearKeep) { goIdle(T, 0.2); break; }
        if (contact && T.reachE > 0.3) {
          T.grabT += dt;
          if (T.grabT >= P.stagger * T.rank * 1.2 + T.jit * 0.5) enterWrap(T);
        } else T.grabT = 0;
        break;
      case WRAP:
        T.wrapT += dt;
        advanceFront(T, dt);
        if (!M.wrap) beginRelease(T, 1.0);
        else if (M.tear && T.hasArc && T.tautRatio > P.tear) {
          T.tearT += dt;
          if (T.tearT > 0.15) beginRelease(T, 2.0 + T.jit * 10);
        } else T.tearT = 0;
        break;
      case RELEASE:
        T.relT += dt;
        if (T.relT * 38 > T.N + 5) { T.state = IDLE; T.cool = T.relCool; T.armT = 0; }
        break;
    }
    T.ex += (exT[T.state] - T.ex) * (1 - Math.exp(-3 * dt));
  }
}

const EXIT = [0, 0.7, 0.45, 0.25];
function computeGoals(T) {
  const N = T.N;
  restGoals(T, T.gR);
  for (let i = 0; i < N; i++) T.rR[i] = P.stiffness * (2.5 + 24 * Math.pow(1 - i / (N - 1), 2));
  const G = T.gN, Rt = T.rN;
  switch (T.state) {
    case IDLE:
      for (let i = 0; i < N; i++) { G[i].copy(T.gR[i]); Rt[i] = T.rR[i]; }
      T.zeta = 0.55; break;
    case REACH: reachGoals(T, G, Rt); T.zeta = 0.8; break;
    case WRAP: wrapGoals(T, G, Rt); T.zeta = 1.0; break;
    case RELEASE: {
      wrapGoals(T, G, Rt);
      const w = T.relT * 38;
      for (let i = 0; i < N; i++) {
        const g = clamp(1 - (w - i) / 4, 0, 1);
        G[i].lerpVectors(T.gR[i], G[i], g); Rt[i] = lerp(T.rR[i], Rt[i], g);
      }
      T.zeta = 0.8; break;
    }
  }
  for (let i = 1; i < 4 && i < N; i++) { G[i].lerp(T.gR[i], EXIT[i]); Rt[i] = Math.max(Rt[i], 18); }
  const e = ease(T.blend);
  for (let i = 0; i < N; i++) {
    T.goal[i].lerpVectors(T.gFrom[i], G[i], e);
    T.rate[i] = lerp(T.rFrom[i], Rt[i], e);
  }
  T.goal[0].copy(T.root);
}

// ---------- Kopf ----------
const _ha = new V3(), _f = new V3(), _g = new V3(), _up = new V3(), _x = new V3();
const _mq = new THREE.Matrix4(), _qq = new THREE.Quaternion();
function updateHead(dt) {
  const k = lerp(220, 16, P.inertia), c = Math.sqrt(k);
  _ha.subVectors(W.target, W.hp).multiplyScalar(k).addScaledVector(W.hv, -c).add(W.pullAcc);
  W.hv.addScaledVector(_ha, dt);
  if (W.hv.length() > 14) W.hv.setLength(14);
  W.hp.addScaledVector(W.hv, dt);
  W.pullAcc.set(0, 0, 0);
  const Rc = W.R * 1.1;
  surf(W.hp, S3);
  if (M.collide && S3.sd < Rc) {
    W.hp.addScaledVector(S3.n, Rc - S3.sd);
    const vn = W.hv.dot(S3.n); if (vn < 0) W.hv.addScaledVector(S3.n, -vn);
    S3.sd = Rc;
  }
  W.hd = S3.sd; W.cpHead.copy(S3.cp); W.nHead.copy(S3.n);

  _f.set(0, 0, 1);
  if (M.lookAt && W.hd < P.reachDist) {
    _g.subVectors(W.cpHead, W.hp);
    if (_g.lengthSq() > 1e-8) {
      _g.normalize();
      _f.lerp(_g, 0.5 * smooth(P.reachDist, P.contactDist, W.hd)).normalize();
    }
  }
  _up.set(0, 1, 0).addScaledVector(W.hv, -0.06);
  _up.addScaledVector(_f, -_up.dot(_f));
  if (_up.lengthSq() < 1e-4) _up.set(0, 1, 0);
  _up.normalize();
  _x.crossVectors(_up, _f).normalize();
  _mq.makeBasis(_x, _up, _f);
  _qq.setFromRotationMatrix(_mq);
  W.hq.slerp(_qq, 1 - Math.exp(-5 * dt));
  W.main.position.copy(W.hp); W.main.quaternion.copy(W.hq);
}

function applyPull() {
  if (!M.pull) return;
  for (const T of W.tent) {
    if (T.state !== WRAP || !T.hasArc) continue;
    _v.subVectors(T.anchor, T.root);
    const D = _v.length(); if (D < 1e-5) continue;
    const Lf = T.Lfree;
    const f = P.pull * (14 * Math.max(0, D - 0.85 * Lf) + 40 * Math.max(0, D - Lf));
    W.pullAcc.addScaledVector(_v.divideScalar(D), Math.min(f, 60));
  }
  if (W.pullAcc.length() > 90) W.pullAcc.setLength(90);
}

// ---------- Physik ----------
const _dv = new V3();
function solve(a, b, len, fixA) {
  _dv.subVectors(b, a);
  const d = _dv.length(); if (d < 1e-9) return;
  const diff = (d - len) / d;
  if (fixA) b.addScaledVector(_dv, -diff);
  else { a.addScaledVector(_dv, 0.5 * diff); b.addScaledVector(_dv, -0.5 * diff); }
}
function constrain(T, iters) {
  const N = T.N, p = T.p, seg = T.seg;
  for (let it = 0; it < iters; it++) {
    for (let i = 0; i < N - 1; i++) solve(p[i], p[i + 1], seg, i === 0);
    for (let i = N - 2; i >= 0; i--) solve(p[i], p[i + 1], seg, i === 0);
  }
}
function integrate(T, h) {
  const N = T.N, p = T.p, pp = T.pp, g = T.goal;
  const drag = Math.max(0, 1 - P.drag * h), z2 = 2 * T.zeta, hh = h * h;
  pp[0].copy(p[0]); p[0].copy(T.root);
  for (let i = 1; i < N; i++) {
    const x = p[i], xp = pp[i], r = T.rate[i], k = r * r, c = z2 * r;
    const vx = x.x - xp.x, vy = x.y - xp.y, vz = x.z - xp.z;
    const ax = k * (g[i].x - x.x) - c * vx / h;
    const ay = k * (g[i].y - x.y) - c * vy / h - P.gravity;
    const az = k * (g[i].z - x.z) - c * vz / h;
    xp.copy(x);
    x.x += vx * drag + ax * hh; x.y += vy * drag + ay * hh; x.z += vz * drag + az * hh;
  }
}
function collide(T) {
  const N = T.N;
  if (W.hd > T.L + 3 || !W.geo) { T.sd.fill(1e3); return; }
  for (let i = 1; i < N; i++) {
    surf(T.p[i], S1);
    const rr = T.rad[i] * 1.08 + 0.006;
    if (M.collide && S1.sd < rr) { T.p[i].addScaledVector(S1.n, rr - S1.sd); S1.sd = rr; }
    T.sd[i] = S1.sd; T.cn[i].copy(S1.n);
  }
}
function simulate(dt) {
  const subs = dt > 0.024 ? 3 : 2, h = dt / subs;
  for (let s = 0; s < subs; s++) {
    for (const T of W.tent) {
      integrate(T, h);
      constrain(T, 6);
      collide(T);
      constrain(T, 2);
    }
  }
}

// ---------- Bones und Darstellung ----------
const _pq = new THREE.Quaternion(), _qw = new THREE.Quaternion(), _inv = new THREE.Quaternion();
const _X = new V3(), _Y = new V3(), _Z = new V3(), _uu = new V3(), _tp = new V3(), _tv = new V3();
const _bm = new THREE.Matrix4();
function poseBones(T) {
  const N = T.N, p = T.p;
  for (let i = 0; i < N; i++) {
    T.tan[i].subVectors(p[Math.min(i + 1, N - 1)], p[Math.max(i - 1, 0)]);
    if (T.tan[i].lengthSq() < 1e-12) T.tan[i].set(0, 1, 0);
    T.tan[i].normalize();
    const w = clamp((T.rad[i] * 2.6 - T.sd[i]) / (T.rad[i] * 1.6), 0, 1);
    T.cw[i] = i === 0 ? 0 : w;
  }
  T.inw.set(-T.attach.x, 0, -T.attach.z);
  if (T.inw.lengthSq() < 1e-8) T.inw.set(0, 0, 1);
  T.inw.normalize().applyQuaternion(W.hq);
  _uu.copy(T.inw);
  for (let i = 0; i < N; i++) {
    const t = T.tan[i];
    _uu.addScaledVector(t, -_uu.dot(t));
    if (_uu.lengthSq() < 1e-8) { _uu.set(1, 0, 0).addScaledVector(t, -t.x); if (_uu.lengthSq() < 1e-8) _uu.set(0, 0, 1); }
    _uu.normalize();
    const w = T.cw[i];
    if (w > 0.001) {
      _tp.copy(T.cn[i]).negate().addScaledVector(t, T.cn[i].dot(t));
      if (_tp.lengthSq() > 1e-6) { _tp.normalize(); _uu.lerp(_tp, w); if (_uu.lengthSq() < 1e-6) _uu.copy(_tp); _uu.normalize(); }
    }
    T.up[i].copy(_uu);
  }
  _pq.copy(W.hq);
  for (let i = 0; i < N; i++) {
    _Y.copy(T.tan[i]); _Z.copy(T.up[i]);
    _X.crossVectors(_Y, _Z).normalize();
    _bm.makeBasis(_X, _Y, _Z);
    _qw.setFromRotationMatrix(_bm);
    _inv.copy(_pq).invert();
    const b = T.bones[i];
    b.quaternion.copy(_inv).multiply(_qw);
    if (i > 0) b.position.copy(_tv.subVectors(p[i], p[i - 1]).applyQuaternion(_inv));
    _pq.copy(_qw);
  }
}

function updateColors(T) {
  const arr = T.colorAttr.array, Rn = T.rings, S = T.S, N = T.N;
  const c0 = W.cBase, c1 = W.cContact;
  for (let r = 0; r < Rn; r++) {
    const s = r / (Rn - 1), bf = s * (N - 1), b0 = Math.min(Math.floor(bf), N - 2), f = bf - b0;
    const w = lerp(T.cw[b0], T.cw[b0 + 1], f);
    const k = M.chroma ? clamp(T.ex * (0.2 * s + 0.9 * w), 0, 1) * 0.85 : 0;
    const R_ = lerp(c0.r, c1.r, k), G_ = lerp(c0.g, c1.g, k), B_ = lerp(c0.b, c1.b, k);
    for (let j = 0; j < S; j++) {
      const o = (r * S + j) * 3;
      arr[o] = R_; arr[o + 1] = G_; arr[o + 2] = B_;
    }
  }
  T.colorAttr.needsUpdate = true;
}

function updateDebug(T) {
  T.boneLine.visible = T.boneDots.visible = M.bones;
  if (M.bones) {
    const a = T.boneLine.geometry.attributes.position;
    for (let i = 0; i < T.N; i++) a.setXYZ(i, T.p[i].x, T.p[i].y, T.p[i].z);
    a.needsUpdate = true;
  }
  const showT = M.debug && (T.state === REACH || T.state === WRAP || T.state === RELEASE);
  T.tDot.visible = showT;
  if (showT) {
    const a = T.tDot.geometry.attributes.position;
    const q = T.state === REACH ? T.target : T.front;
    a.setXYZ(0, q.x, q.y, q.z); a.needsUpdate = true;
  }
  const showTr = M.debug && (T.state === WRAP || T.state === RELEASE) && T.trail.length > 0;
  T.trailLine.visible = showTr;
  if (showTr) {
    const a = T.trailLine.geometry.attributes.position;
    const cap = a.count;
    const n = Math.min(T.trail.length, cap - 1);
    const off = T.trail.length - n;
    for (let i = 0; i < n; i++) { const q = T.trail[off + i].p; a.setXYZ(i, q.x, q.y, q.z); }
    a.setXYZ(n, T.front.x, T.front.y, T.front.z);
    a.needsUpdate = true;
    T.trailLine.geometry.setDrawRange(0, n + 1);
  }
}

function step(dt) {
  W.time += dt;
  updateHead(dt);
  W.twinRoot.visible = M.twin;
  W.headTwin.position.copy(W.main.position); W.headTwin.quaternion.copy(W.main.quaternion);
  updateFrames();
  updateArms(dt);
  for (const T of W.tent) computeGoals(T);
  applyPull();
  simulate(dt);
  for (const T of W.tent) { poseBones(T); updateColors(T); updateDebug(T); }
}

function statusCounts() {
  const c = [0, 0, 0, 0];
  for (const T of W.tent) c[T.state]++;
  return c;
}
// ---------- Kopie als reines Mesh ----------
function bakeSkinned(mesh) {
  mesh.skeleton.update();
  const g = mesh.geometry, n = g.attributes.position.count, out = new Float32Array(n * 3), v = new V3();
  for (let i = 0; i < n; i++) {
    mesh.getVertexPosition(i, v).applyMatrix4(mesh.matrixWorld);
    out[i * 3] = v.x; out[i * 3 + 1] = v.y; out[i * 3 + 2] = v.z;
  }
  const ng = new THREE.BufferGeometry();
  ng.setAttribute('position', new THREE.BufferAttribute(out, 3));
  ng.setAttribute('color', new THREE.BufferAttribute(new Float32Array(g.attributes.color.array), 3));
  const idx = Array.from(g.index.array);
  if (mesh.matrixWorld.determinant() < 0) for (let i = 0; i < idx.length; i += 3) { const t = idx[i + 1]; idx[i + 1] = idx[i + 2]; idx[i + 2] = t; }
  ng.setIndex(idx);
  ng.computeVertexNormals();
  return ng;
}
function bakeStatic(mesh) {
  const g = mesh.geometry.clone();
  g.applyMatrix4(mesh.matrixWorld);
  if (mesh.matrixWorld.determinant() < 0 && g.index) {
    const a = g.index.array;
    for (let i = 0; i < a.length; i += 3) { const t = a[i + 1]; a[i + 1] = a[i + 2]; a[i + 2] = t; }
    g.index.needsUpdate = true;
  }
  return g;
}
function snapshotCopy() {
  if (!W.tent.length) return 0;
  W.scene.updateMatrixWorld(true);
  const grp = new THREE.Group(); grp.name = 'kopie';
  const stdMat = () => new THREE.MeshStandardMaterial({ color: P.color, roughness: clamp(P.rough, 0.02, 1), metalness: P.metal,
    envMap: W.envMap, envMapIntensity: P.envInt });
  const armMat = () => { const m = stdMat(); m.color.set(0xffffff); m.vertexColors = true; return m; };
  const add = (geo, mat) => { const m = new THREE.Mesh(geo, mat); m.castShadow = true; m.receiveShadow = true; grp.add(m); };
  const sides = M.twin ? [false, true] : [false];
  for (const twin of sides) {
    add(bakeStatic(twin ? W.headTwin : W.head), stdMat());
    for (const T of W.tent) add(bakeSkinned(twin ? T.meshTwin : T.mesh), armMat());
  }
  W.scene.add(grp); W.copies.push(grp);
  return W.copies.length;
}
function clearCopies() {
  for (const g of W.copies) {
    g.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); });
    W.scene.remove(g);
  }
  W.copies.length = 0;
}

// ==== APP ====

const notice = document.getElementById('notice');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

const scene = W.scene;
scene.background = new THREE.Color(0xffffff);
const camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0.6, 10.5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableZoom = true; controls.zoomSpeed = 0.8; controls.minDistance = 4; controls.maxDistance = 28;
controls.enablePan = false; controls.enableDamping = true;
controls.mouseButtons = { LEFT: -1, MIDDLE: -1, RIGHT: THREE.MOUSE.ROTATE };
controls.touches = { ONE: -1, TWO: THREE.TOUCH.ROTATE };
controls.target.set(0, 0, 0);

const pmrem = new THREE.PMREMGenerator(renderer);
W.envMap = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
W.objMesh.material.envMap = W.envMap; W.objMesh.material.needsUpdate = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0xc9ccd2, 0.55));
const sun = new THREE.DirectionalLight(0xffffff, 1.1);
sun.position.set(-4, 7, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -7, right: 7, top: 7, bottom: -7, near: 1, far: 24 });
sun.shadow.bias = -0.0004; sun.shadow.normalBias = 0.02; sun.shadow.radius = 4;
scene.add(sun);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), new THREE.ShadowMaterial({ opacity: 0.09 }));
floor.rotation.x = -Math.PI / 2; floor.position.y = -3.1; floor.receiveShadow = true;
scene.add(floor);

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

// Maus -> Zielpunkt des Hauptbones (nur beim Ziehen)
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(), fwd = new V3(), grabOff = new V3(), _mp = new V3();
const cv = renderer.domElement;
let dragging = false, depthDist = 7;
function mousePoint(out) {
  ray.setFromCamera(ndc, camera);
  camera.getWorldDirection(fwd);
  const t = depthDist / Math.max(0.2, ray.ray.direction.dot(fwd));
  return out.copy(ray.ray.origin).addScaledVector(ray.ray.direction, t);
}
function setNdc(e) { ndc.set(e.clientX / innerWidth * 2 - 1, -(e.clientY / innerHeight) * 2 + 1); }
cv.style.cursor = 'grab';
cv.addEventListener('pointerdown', e => {
  if (e.button !== 0) return;
  dragging = true; cv.setPointerCapture(e.pointerId); cv.style.cursor = 'grabbing';
  setNdc(e);
  camera.getWorldDirection(fwd);
  depthDist = Math.max(3, _mp.subVectors(W.hp, camera.position).dot(fwd));
  mousePoint(_mp);
  grabOff.subVectors(W.hp, _mp);
  grabOff.addScaledVector(fwd, -grabOff.dot(fwd));
});
cv.addEventListener('pointermove', e => {
  if (!dragging) return;
  if (e.shiftKey) depthDist = clamp(depthDist - e.movementY * 0.03, 3, 18); else setNdc(e);
});
const endDrag = e => { if (!dragging) return; dragging = false; cv.style.cursor = 'grab'; if (cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId); };
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
cv.addEventListener('contextmenu', e => e.preventDefault());
addEventListener('keydown', e => { if (e.code === 'Space' && e.target === document.body) { e.preventDefault(); releaseAll(); } });
function updateMouseTarget() {
  if (dragging) {
    mousePoint(_mp).add(grabOff);
    W.target.copy(_mp);
    W.target.x = clamp(W.target.x, -9, 9); W.target.y = clamp(W.target.y, -6, 7); W.target.z = clamp(W.target.z, -9, 9);
  } else {
    W.target.copy(W.hp);   // frei: keine Federkraft zur Maus, nur Trägheit und Zug der Arme
  }
}

// Modell laden
function useGltf(gltf) {
  gltf.scene.updateMatrixWorld(true);
  const geos = [];
  gltf.scene.traverse(o => {
    if (!o.isMesh) return;
    const g = o.geometry.clone();
    g.applyMatrix4(o.matrixWorld);
    for (const k of Object.keys(g.attributes)) if (k !== 'position' && k !== 'normal') g.deleteAttribute(k);
    if (!g.attributes.normal) g.computeVertexNormals();
    geos.push(g);
  });
  if (!geos.length) { notice.textContent = 'Im Modell wurde kein Mesh gefunden.'; return; }
  let geo = geos[0];
  if (geos.length > 1) {
    const flat = geos.map(g => g.index ? g.toNonIndexed() : g);
    geo = mergeGeometries(flat);
  }
  notice.textContent = '';
  setRawGeometry(geo);
}
function fallbackObject(msg) {
  notice.textContent = msg;
  setRawGeometry(new THREE.TorusKnotGeometry(1.1, 0.42, 220, 36));
}
const loader = new GLTFLoader();
const meshUrl = new URLSearchParams(location.search).get('mesh') || 'HEAD_ONLY.glb';
loader.load(meshUrl, useGltf, undefined, () =>
  fallbackObject('„' + meshUrl + '“ nicht gefunden – GLB auf die Seite ziehen. Bis dahin: Testobjekt.'));
function loadFile(file) {
  file.arrayBuffer().then(buf => loader.parse(buf, '', useGltf, err => { notice.textContent = 'Datei konnte nicht gelesen werden.'; console.error(err); }));
}
addEventListener('dragover', e => e.preventDefault());
addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) loadFile(f); });

// UI
const UI = [
  { title: 'Module', open: true, items: [
    ['toggle', 'reach', 'Ausstrecken bei Nähe'],
    ['toggle', 'wrap', 'Umschlingen bei Kontakt'],
    ['toggle', 'pull', 'Zugkraft zum Objekt'],
    ['toggle', 'tear', 'Abreißen bei Überdehnung'],
    ['toggle', 'collide', 'Kollision mit Objekt'],
    ['toggle', 'wander', 'Wanderbewegung'],
    ['toggle', 'lookAt', 'Kopf blickt zum Objekt'],
    ['toggle', 'chroma', 'Verfärbung am Kontakt'],
    ['toggle', 'twin', 'Gespiegelter Zwilling'],
    ['toggle', 'bones', 'Bones zeigen'],
    ['toggle', 'debug', 'Ziele und Pfade zeigen'],
  ] },
  { title: 'Ausstrecken', open: true, items: [
    ['range', 'reachDist', 'Reichweite ab Abstand', 1, 7, 0.05],
    ['range', 'contactDist', 'Kontakt ab Abstand', 0.7, 3, 0.05],
    ['range', 'spread', 'Streuung der Ziele', 0, 2.5, 0.05],
    ['range', 'stagger', 'Versatz zwischen Armen', 0, 0.4, 0.01],
    ['range', 'reachSpeed', 'Ausstrecktempo', 0.2, 3, 0.05],
  ] },
  { title: 'Umschlingen', open: true, items: [
    ['range', 'wrapSpeed', 'Wickeltempo', 0.2, 4, 0.05],
    ['range', 'pitch', 'Steigung der Windung', -1.5, 1.5, 0.05],
    ['range', 'serp', 'Schlängeln', 0, 1.5, 0.05],
    ['range', 'wrapFraction', 'Wickelanteil der Länge', 0.2, 1, 0.02],
    ['range', 'creep', 'Nachkriechen', 0, 1, 0.02],
    ['range', 'grip', 'Haftung', 0.2, 2.5, 0.05],
    ['range', 'pull', 'Zugkraft', 0, 3, 0.05],
    ['range', 'tear', 'Abrissgrenze', 1, 1.6, 0.01],
  ] },
  { title: 'Bewegung', items: [
    ['range', 'inertia', 'Trägheit des Kopfs', 0, 1, 0.01],
    ['range', 'drag', 'Dämpfung', 0, 4, 0.05],
    ['range', 'stiffness', 'Steifigkeit der Arme', 0.3, 3, 0.05],
    ['range', 'gravity', 'Schwerkraft', -0.5, 2, 0.05],
    ['range', 'wander', 'Wanderstärke', 0, 2, 0.05],
    ['range', 'curl', 'Einrollung der Spitzen', 0, 2, 0.05],
  ] },
  { title: 'Oktopus', items: [
    ['seed'],
    ['range', 'length', 'Armlänge', 1.5, 5, 0.05, true],
    ['range', 'segments', 'Bones pro Arm', 12, 44, 1, true],
    ['range', 'thickness', 'Armdicke', 0.5, 1.8, 0.05, true],
    ['range', 'headSize', 'Kopfgröße', 0.7, 1.6, 0.05, true],
  ] },
  { title: 'Material Oktopus', items: [
    ['color', 'color', 'Farbe'],
    ['color', 'contact', 'Farbe am Kontakt'],
    ['range', 'rough', 'Rauheit (klein = glänzend)', 0, 1, 0.01, 'mat'],
    ['range', 'metal', 'Metallanteil', 0, 1, 0.01, 'mat'],
    ['range', 'envInt', 'Reflexion der Umgebung', 0, 3, 0.05, 'mat'],
  ] },
  { title: 'Objekt', items: [
    ['range', 'objectScale', 'Größe', 0.5, 1.8, 0.05, 'bake'],
    ['file'],
  ] },
];

const syncers = [];
const secEl = document.getElementById('sections');
for (const sec of UI) {
  const d = document.createElement('details'); d.open = !!sec.open;
  const sm = document.createElement('summary'); sm.textContent = sec.title; d.appendChild(sm);
  for (const it of sec.items) {
    if (it[0] === 'toggle') {
      const [, key, label] = it;
      const l = document.createElement('label'); l.className = 't';
      const c = document.createElement('input'); c.type = 'checkbox';
      const s = document.createElement('span'); s.textContent = label;
      l.append(c, s); d.appendChild(l);
      c.addEventListener('change', () => { M[key] = c.checked; });
      syncers.push(() => { c.checked = M[key]; });
    } else if (it[0] === 'color') {
      const [, key, label] = it;
      const l = document.createElement('label'); l.className = 'r';
      const sp = document.createElement('span'); sp.textContent = label;
      const c = document.createElement('input'); c.type = 'color'; c.className = 'col';
      l.append(sp, c); d.appendChild(l);
      c.addEventListener('input', () => { P[key] = c.value; applyMaterial(); });
      syncers.push(() => { c.value = P[key]; });
    } else if (it[0] === 'range') {
      const [, key, label, min, max, stp, deferred] = it;
      const l = document.createElement('label'); l.className = 'r';
      const s = document.createElement('span'); s.textContent = label;
      const o = document.createElement('output');
      const r = document.createElement('input'); r.type = 'range'; r.min = min; r.max = max; r.step = stp;
      l.append(s, o, r); d.appendChild(l);
      const dec = stp >= 1 ? 0 : (stp >= 0.1 ? 1 : 2);
      const show = () => { o.textContent = Number(P[key]).toFixed(dec); };
      r.addEventListener('input', () => { P[key] = parseFloat(r.value); show(); if (deferred === 'mat') applyMaterial(); });
      if (deferred && deferred !== 'mat') r.addEventListener('change', () => { deferred === 'bake' ? bakeObject() : buildOctopus(); });
      syncers.push(() => { r.value = P[key]; show(); });
    } else if (it[0] === 'seed') {
      const l = document.createElement('div'); l.className = 'seed';
      const s = document.createElement('span'); s.textContent = 'Seed';
      const n = document.createElement('input'); n.type = 'number'; n.step = 1;
      l.append(s, n); d.appendChild(l);
      n.addEventListener('change', () => { P.seed = Math.floor(parseFloat(n.value) || 0); buildOctopus(); });
      syncers.push(() => { n.value = P.seed; });
    } else if (it[0] === 'file') {
      const l = document.createElement('label'); l.className = 'file';
      const b = document.createElement('button'); b.textContent = 'Modell laden (GLB)';
      const f = document.createElement('input'); f.type = 'file'; f.accept = '.glb,.gltf';
      b.addEventListener('click', () => f.click());
      f.addEventListener('change', () => { if (f.files[0]) loadFile(f.files[0]); });
      l.append(b, f); d.appendChild(l);
    }
  }
  secEl.appendChild(d);
}
const syncAll = () => syncers.forEach(f => f());
syncAll();

document.getElementById('reroll').addEventListener('click', () => {
  P.seed = Math.floor(Math.random() * 100000); syncAll(); buildOctopus();
});
document.getElementById('release').addEventListener('click', () => releaseAll());
document.getElementById('copy').addEventListener('click', () => snapshotCopy());
document.getElementById('clearcopies').addEventListener('click', () => clearCopies());
document.getElementById('defaults').addEventListener('click', () => {
  const seed = P.seed;
  Object.assign(P, DEFAULT_P, { seed }); Object.assign(M, DEFAULT_M);
  syncAll(); applyMaterial(); bakeObject(); buildOctopus();
});
const ui = document.getElementById('ui'), fold = document.getElementById('fold');
fold.addEventListener('click', () => { ui.classList.toggle('collapsed'); fold.textContent = ui.classList.contains('collapsed') ? '+' : '–'; });

const statusEl = document.getElementById('status');
let statusT = 0;

buildOctopus();

let last = performance.now();
renderer.setAnimationLoop(now => {
  const dt = Math.min((now - last) / 1000, 1 / 20);
  last = now;
  updateMouseTarget();
  step(dt);
  controls.update();
  renderer.render(scene, camera);
  statusT += dt;
  if (statusT > 0.2) {
    statusT = 0;
    const c = statusCounts();
    statusEl.textContent = 'Abstand ' + (W.hd > 50 ? '–' : W.hd.toFixed(2)) + ' · frei ' + c[0] + ' · strecken ' + c[1] + ' · umschlingen ' + c[2] + (c[3] ? ' · lösen ' + c[3] : '') + (W.copies.length ? ' · Kopien ' + W.copies.length : '');
  }
});
</script>
</body>
</html>
Assets
HEAD_ONLY.glb  (777 KB)