VERMIS_WORMBOT

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/423a0250b9e65812/" 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>VERMIS Flugwurm</title>
<style>
:root{--bg:#e9e9e9;--ink:#141414;--mute:#8d8d8d;--line:#e4e4e4;--panel:rgba(255,255,255,.95)}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);overflow:hidden;font-family:ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;color:var(--ink)}
#app{position:fixed;inset:0}
#title{position:fixed;left:20px;top:18px;font-size:13px;letter-spacing:.2em;font-weight:600;pointer-events:none}
#title span{display:block;font-weight:400;letter-spacing:.04em;color:var(--mute);font-size:11px;margin-top:5px}
#hint{position:fixed;left:20px;bottom:16px;font-size:11px;color:var(--mute);pointer-events:none}
#toggle{position:fixed;right:16px;top:16px;z-index:5;background:#fff;border:1px solid var(--line);border-radius:10px;padding:8px 14px;font-size:12px;cursor:pointer;color:var(--ink)}
#mode{position:fixed;right:112px;top:16px;z-index:5;background:#fff;border:1px solid var(--line);border-radius:10px;padding:8px 14px;font-size:12px;cursor:pointer;color:var(--ink)}
body.dark{background:#06070a}
body.dark #title{color:#ececec}
body.dark #title span,body.dark #hint{color:#7d7d7d}
#actions{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;gap:8px;z-index:5;flex-wrap:wrap;justify-content:center;max-width:calc(100% - 32px)}
#actions button{background:#fff;border:1px solid var(--line);border-radius:10px;padding:9px 14px;font-size:12px;cursor:pointer;color:var(--ink)}
#actions button:hover{background:#f4f4f4}
#actions button.upd{width:100%;position:sticky;top:0;z-index:3;margin-bottom:10px;background:#f2f2f2;font-weight:600}
button.upd.dirty{background:#111;color:#fff;border-color:#111}
button.on{border-color:var(--ink)}
#panel{position:fixed;right:16px;top:60px;bottom:16px;width:280px;background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow-y:auto;padding:4px 16px 16px;transition:transform .25s,opacity .25s;scrollbar-width:thin}
#panel.hidden{transform:translateX(calc(100% + 24px));opacity:0;pointer-events:none}
details{border-top:1px solid var(--line)}
details:first-of-type{border-top:none}
summary{list-style:none;cursor:pointer;font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);padding:14px 0 6px;user-select:none}
summary::-webkit-details-marker{display:none}
.row{margin:8px 0 10px}
.lab{display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px}
.val{color:var(--mute);font-variant-numeric:tabular-nums}
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:16px;background:transparent;margin:0}
input[type=range]::-webkit-slider-runnable-track{height:2px;background:#dcdcdc;border-radius:2px}
input[type=range]::-moz-range-track{height:2px;background:#dcdcdc;border-radius:2px}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--ink);margin-top:-5px;border:none}
input[type=range]::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--ink);border:none}
select{width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;background:#fff;font-size:12px;color:var(--ink)}
input[type=color]{width:100%;height:24px;border:1px solid var(--line);border-radius:6px;padding:0;background:#fff;cursor:pointer}
.btns{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:14px 0 6px}
.btns button{border:1px solid #dadada;background:#fff;border-radius:8px;padding:8px;font-size:12px;cursor:pointer;color:var(--ink)}
.btns button:hover{background:#f4f4f4}
.nmprev{display:block;width:100%;height:90px;object-fit:cover;border-radius:8px;border:1px solid var(--line);margin:6px 0 4px}
@media (max-width:640px){
  #panel{left:12px;right:12px;width:auto;top:auto;height:46vh;bottom:12px}
  #panel.hidden{transform:translateY(calc(100% + 24px))}
  #hint{display:none}
}
</style>
</head>
<body>
<div id="app"></div>
<div id="title">VERMIS<span>Flugwurm-Generator</span></div>
<div id="hint">Ziehen: drehen · Scrollen: zoomen · Rechtsklick: verschieben</div>
<div id="actions"><button id="aOpen">Panels auf</button><button id="aClose">Panels zu</button><button id="aCircle">Kreisflug</button><button id="aShock">Zucken</button></div>
<button id="mode">Dunkel</button>
<button id="toggle">Parameter</button>
<aside id="panel">
  <div class="btns">
    <button id="bSeed">Neuer Seed</button>
    <button id="bMut">Mutieren</button>
    <button id="bView">Ansicht</button>
    <button id="bPng">PNG</button>
    <button id="bGlb" style="grid-column:span 2">GLB exportieren</button>
  </div>
  <button id="bUpd" class="upd">Update</button>
    <button id="bUpd" class="upd">Update</button>
    <div id="controls"></div>
</aside>

<script type="importmap">
{"imports":{
  "three":"https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
  "three/addons/":"https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';

const V2 = THREE.Vector2, V3 = THREE.Vector3, TAU = Math.PI * 2;

/* ---------------- Parameter ---------------- */
const P = {
  seed: 7, segments: 16, radius: 1.0, segLen: 1.25, taper: 0.55, thorax: 0.5, facets: 24, shading: 'weich',
  ribs: 2, ribDepth: 0.35, collar: 0.4, shell: 0.0,
  panels: 3, panelSize: 0.85, splay: 0.45, panelVar: 0.6,
  greebles: 3, filaments: 70, filLen: 1.0,
  cables: 36, hoseRadius: 0.028, slosh: 1.0,
  eyes: 14, eyeSize: 1.0, antLen: 1.3, tentacles: 14, tentLen: 1.6,
  speed: 1.0, amp: 0.55, wave: 0.45, curl: 0.4, twitch: 0.5,
  nmPattern: 'rau', nmScale: 11, nmStrength: 0.4, nmGrain: 0.5, metal: 0.35, rough: 0.42, glass: 0.4,
  bodyColor: '#0f1011', plateColor: '#1c1d1f', eyeColor: '#ff1c0f', glow: 1.6
};

const SCHEMA = [
  ['Form', [
    ['seed', 'Seed', 1, 999, 1],
    ['segments', 'Glieder', 6, 32, 1],
    ['radius', 'Dicke', 0.4, 2.0, 0.01],
    ['segLen', 'Gliedlänge', 0.6, 2.2, 0.01],
    ['taper', 'Verjüngung', 0, 0.85, 0.01],
    ['thorax', 'Thorax-Wulst', 0, 1, 0.01],
    ['facets', 'Facetten', 5, 48, 1],
    ['shading', 'Schattierung', ['weich', 'hart']],
  ]],
  ['Panzer & Riffelung', [
    ['ribs', 'Rippen', 0, 12, 1],
    ['ribDepth', 'Rippentiefe', 0, 1.6, 0.01],
    ['collar', 'Kragen', 0, 1, 0.01],
    ['shell', 'Rückenpanzer', 0, 1, 0.01],
  ]],
  ['Panels', [
    ['panels', 'Panels pro Glied', 0, 8, 1],
    ['panelSize', 'Größe', 0.2, 1.8, 0.01],
    ['splay', 'Abstehen', 0, 1.3, 0.01],
    ['panelVar', 'Asymmetrie', 0, 1, 0.01],
  ]],
  ['Maschine & Fühlerfäden', [
    ['greebles', 'Maschinenteile', 0, 10, 1],
    ['filaments', 'Fühlerfäden', 0, 200, 1],
    ['filLen', 'Fadenlänge', 0.3, 2.5, 0.01],
  ]],
  ['Kabel', [
    ['cables', 'Kabel gesamt', 0, 100, 1],
    ['hoseRadius', 'Kabeldicke', 0.008, 0.1, 0.001],
    ['slosh', 'Schlabbern & Wind', 0, 2, 0.01],
  ]],
  ['Kopf', [
    ['eyes', 'Augen-Panels', 2, 30, 1],
    ['eyeSize', 'Augengröße', 0.4, 1.8, 0.01],
    ['antLen', 'Fühler', 0, 3, 0.01],
    ['tentacles', 'Tentakel', 0, 24, 1],
    ['tentLen', 'Tentakellänge', 0.3, 3, 0.01],
  ]],
  ['Idle-Animation', [
    ['speed', 'Tempo', 0, 3, 0.01],
    ['amp', 'Amplitude', 0, 1.5, 0.01],
    ['wave', 'Wellenlänge', 0.1, 1.2, 0.01],
    ['curl', 'Krümmung', 0, 1.5, 0.01],
    ['twitch', 'Servo-Zucken', 0, 1, 0.01],
  ]],
  ['Oberfläche', [
    ['nmPattern', 'Normal Map', ['rau', 'voronoi', 'zellen', 'rissig']],
    ['nmScale', 'Skalierung', 1, 30, 0.1],
    ['nmStrength', 'Stärke', 0, 2.5, 0.01],
    ['nmGrain', 'Körnung', 0, 1, 0.01],
    ['metal', 'Metallik', 0, 1, 0.01],
    ['rough', 'Rauheit', 0.05, 1, 0.01],
    ['glass', 'Weißanteil', 0, 1, 0.01],
  ]],
  ['Farbe', [
    ['bodyColor', 'Körper', 'color'],
    ['plateColor', 'Panels', 'color'],
    ['eyeColor', 'Augen', 'color'],
    ['glow', 'Leuchten', 0, 4, 0.01],
  ]],
];
const REBUILD = new Set(['seed','segments','radius','segLen','taper','thorax','facets','ribs','ribDepth','collar','shell','panels','panelSize','panelVar','greebles','filaments','filLen','cables','glass','hoseRadius','eyes','eyeSize','antLen','tentacles','tentLen']);
const NMKEYS = new Set(['seed','nmPattern','nmGrain']);

/* ---------------- Utils ---------------- */
function mulberry32(a){return function(){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 fract = x => x - Math.floor(x);
const hash = n => fract(Math.sin(n * 127.1 + 311.7) * 43758.5453);
const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
const lerp = (a, b, t) => a + (b - a) * t;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));

/* ---------------- Renderer / Szene ---------------- */
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.getElementById('app').appendChild(renderer.domElement);

const BG = 0xe9e9e9;
const scene = new THREE.Scene();
scene.background = new THREE.Color(BG);
scene.fog = new THREE.Fog(BG, 50, 140);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;

const camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 0.1, 600);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

const hemi = new THREE.HemisphereLight(0xffffff, 0xcfcfcf, 0.5);
scene.add(hemi);
const key = new THREE.DirectionalLight(0xffffff, 2.2);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.bias = -0.0004;
key.shadow.normalBias = 0.03;
scene.add(key, key.target);
const rim = new THREE.DirectionalLight(0xffffff, 0.9);
rim.position.set(-20, 8, -25);
scene.add(rim);
// Gegenlicht + Seitenlicht (nur im Dunkel-Modus)
const back = new THREE.DirectionalLight(0xdfe8ff, 5), sideL = new THREE.DirectionalLight(0x9fb4ff, 1.6);
scene.add(back, back.target, sideL, sideL.target);
back.visible = sideL.visible = false;
const KEYDIR = new V3(0.45, 1, 0.35).normalize();

const floor = new THREE.Mesh(new THREE.PlaneGeometry(600, 600), new THREE.ShadowMaterial({ opacity: 0.12 }));
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);

// schwebende Staubpunkte als Bewegungsreferenz beim Kreisflug
const grid = (() => {
  const n = 2600, pos = new Float32Array(n * 3), r = mulberry32(99);
  for (let i = 0; i < n; i++){ pos[i*3] = (r() - 0.5) * 120; pos[i*3+1] = r() * 40; pos[i*3+2] = (r() - 0.5) * 120; }
  const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  const p = new THREE.Points(g, new THREE.PointsMaterial({ color: 0x8f8f8f, size: 0.34, sizeAttenuation: true, transparent: true, opacity: 0, depthWrite: false }));
  p.visible = false; p.frustumCulled = false; scene.add(p); return p;
})();

const world = new THREE.Group();
scene.add(world);

/* ---------------- Generative Normal Map (Noise + Voronoi) ---------------- */
const NM = 512;
const nmCanvas = document.createElement('canvas');
nmCanvas.width = nmCanvas.height = NM;
nmCanvas.className = 'nmprev';
const nmCtx = nmCanvas.getContext('2d');
const normalTex = new THREE.CanvasTexture(nmCanvas);
normalTex.wrapS = normalTex.wrapT = THREE.RepeatWrapping;
normalTex.anisotropy = 8;

function tileNoise(rng, p){
  const g = new Float32Array(p * p);
  for (let i = 0; i < g.length; i++) g[i] = rng();
  return (x, y) => {
    const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
    const x0 = ((xi % p) + p) % p, y0 = ((yi % p) + p) % p, x1 = (x0 + 1) % p, y1 = (y0 + 1) % p;
    const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
    const a = g[y0*p+x0], b = g[y0*p+x1], c = g[y1*p+x0], d = g[y1*p+x1];
    return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
  };
}
// kachelbares Voronoi: liefert F1, F2 und Zell-ID
function voronoiTile(K, rng){
  const pts = new Float32Array(K * K * 2);
  for (let j = 0; j < K; j++) for (let i = 0; i < K; i++){ pts[(j*K+i)*2] = i + rng(); pts[(j*K+i)*2+1] = j + rng(); }
  const out = { f1: 0, f2: 0, id: 0 };
  return (u, v) => {
    const x = u * K, y = v * K, xi = Math.floor(x), yi = Math.floor(y);
    let f1 = 9, f2 = 9, id = 0;
    for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++){
      const cx = xi + dx, cy = yi + dy, wx = ((cx % K) + K) % K, wy = ((cy % K) + K) % K, k = (wy*K+wx)*2;
      const d = Math.hypot(pts[k] - wx + cx - x, pts[k+1] - wy + cy - y);
      if (d < f1){ f2 = f1; f1 = d; id = wy*K+wx; } else if (d < f2) f2 = d;
    }
    out.f1 = f1; out.f2 = f2; out.id = id;
    return out;
  };
}

function generateNormalMap(){
  const S = NM, rng = mulberry32(P.seed * 977 + 31);
  const oct = [];
  for (let o = 0; o < 6; o++) oct.push({ p: 4 << o, a: Math.pow(0.72, o), n: tileNoise(rng, 4 << o) });
  const fbm = (u, v, from = 0) => { let s = 0, w = 0; for (let o = from; o < oct.length; o++){ const O = oct[o]; s += O.n(u*O.p, v*O.p) * O.a; w += O.a; } return s / w; };
  const vA = voronoiTile(8, rng), vB = voronoiTile(18, rng), vC = voronoiTile(4, rng);
  const cellH = Float32Array.from({ length: 400 }, () => rng());
  const H = new Float32Array(S * S), pat = P.nmPattern, G = P.nmGrain;

  for (let y = 0; y < S; y++){
    const v = y / S;
    for (let x = 0; x < S; x++){
      const u = x / S, rough = fbm(u, v), fine = fbm(u, v, 3);
      let h;
      if (pat === 'rau'){
        h = rough * 1.1 + (fine - 0.5) * G * 2.2;
      } else if (pat === 'voronoi'){
        const c = vA(u, v), d = c.f2 - c.f1;
        h = smooth(0, 0.1, d) * 0.75 + cellH[c.id] * 0.3 + (rough - 0.5) * 0.5 + (fine - 0.5) * G * 1.4;
      } else if (pat === 'zellen'){
        const c = vA(u, v), d = c.f2 - c.f1;
        h = Math.sqrt(Math.max(0, 1 - (c.f1 / 0.72) ** 2)) * (0.55 + 0.45 * cellH[c.id]) * smooth(0, 0.07, d) + (rough - 0.5) * 0.35 + (fine - 0.5) * G * 1.2;
      } else {
        const a = vC(u, v), da = a.f2 - a.f1, b = vB(u, v), db = b.f2 - b.f1;
        h = 0.55 * smooth(0, 0.05, da) + 0.35 * smooth(0, 0.09, db) + rough * 0.55 + (fine - 0.5) * G * 1.4;
      }
      H[y * S + x] = h;
    }
  }
  const img = nmCtx.createImageData(S, S), D = img.data, k = 4.5;
  for (let y = 0; y < S; y++){
    const yu = (y - 1 + S) % S, yd = (y + 1) % S;
    for (let x = 0; x < S; x++){
      const xl = (x - 1 + S) % S, xr = (x + 1) % S;
      const nx = -(H[y*S+xr] - H[y*S+xl]) * k, ny = (H[yd*S+x] - H[yu*S+x]) * k;
      const l = Math.hypot(nx, ny, 1), i = (y * S + x) * 4;
      D[i] = (nx / l * 0.5 + 0.5) * 255; D[i+1] = (ny / l * 0.5 + 0.5) * 255; D[i+2] = (1 / l * 0.5 + 0.5) * 255; D[i+3] = 255;
    }
  }
  nmCtx.putImageData(img, 0, 0);
  normalTex.needsUpdate = true;
}

/* ---------------- Materialien ---------------- */
const mats = {
  body: new THREE.MeshStandardMaterial({ normalMap: normalTex }),
  plate: new THREE.MeshStandardMaterial({ normalMap: normalTex, side: THREE.DoubleSide }),
  dark: new THREE.MeshStandardMaterial({ color: 0x0a0a0b, normalMap: normalTex }),
  chain: new THREE.MeshPhysicalMaterial({ color: 0x000000, roughness: 0.22, metalness: 0, clearcoat: 0, specularIntensity: 0.7, envMapIntensity: 0.5 }),
  hose: new THREE.MeshPhysicalMaterial({ color: 0x000000, roughness: 0.2, metalness: 0, clearcoat: 0, specularIntensity: 0.7, envMapIntensity: 0.5, side: THREE.DoubleSide }),
  white: new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.22, metalness: 0.0, roughness: 0.3, side: THREE.DoubleSide, normalMap: normalTex }),
  eye: new THREE.MeshPhysicalMaterial({ roughness: 0.18, metalness: 0.1, clearcoat: 1, clearcoatRoughness: 0.08 }),
};
function updateMaterials(){
  const ns = P.nmStrength, flat = P.shading === 'hart';
  mats.body.color.set(P.bodyColor); mats.body.metalness = P.metal; mats.body.roughness = P.rough; mats.body.normalScale.set(ns, ns);
  mats.plate.color.set(P.plateColor); mats.plate.metalness = P.metal * 0.95; mats.plate.roughness = Math.min(1, P.rough * 0.85 + 0.04); mats.plate.normalScale.set(ns * 0.8, ns * 0.8);
  mats.white.roughness = clamp(P.rough * 0.6 + 0.08, 0.05, 1); mats.white.normalScale.set(ns * 0.3, ns * 0.3);
  mats.dark.metalness = P.metal * 0.7; mats.dark.roughness = Math.min(1, P.rough + 0.08); mats.dark.normalScale.set(ns, ns);
  mats.eye.color.set(P.eyeColor); mats.eye.emissive.set(P.eyeColor); mats.eye.emissiveIntensity = P.glow;
  normalTex.repeat.set(P.nmScale, P.nmScale);
  for (const m of [mats.body, mats.plate, mats.dark]) if (m.flatShading !== flat){ m.flatShading = flat; m.needsUpdate = true; }
}

/* ---------------- Geometrie-Bausteine ---------------- */
function scaleV(g, s){ const uv = g.attributes.uv; for (let i = 0; i < uv.count; i++) uv.setY(i, uv.getY(i) * s); }
function segProfile(t, ribs, depth){
  // glatter Wirbelkörper: breite, abgerundete Enden, eingezogene Taille
  const tc = Math.min(1, Math.max(0, t));
  const waist = 1 - 0.4 * Math.pow(Math.max(0, Math.sin(Math.PI * tc)), 1.4);
  const edge = 1 - 0.2 * Math.pow(Math.abs(2 * tc - 1), 10);
  let r = waist * edge;
  if (ribs > 0){ const rib = 0.5 + 0.5 * Math.cos(TAU * ribs * tc); r += depth * 0.07 * Math.pow(rib, 4) * Math.sin(Math.PI * tc); }
  return r;
}
// Amorpher Wirbel: Spulenform + Wobble + runde Voronoi-Zellen (3D-Zellrauschen) als Oberfläche
function weldNormals(g){
  const pos = g.attributes.position, nor = g.attributes.normal, map = new Map();
  const key = i => Math.round(pos.getX(i) * 1e4) + ',' + Math.round(pos.getY(i) * 1e4) + ',' + Math.round(pos.getZ(i) * 1e4);
  for (let i = 0; i < pos.count; i++){ const k = key(i); let e = map.get(k); if (!e){ e = [0, 0, 0]; map.set(k, e); } e[0] += nor.getX(i); e[1] += nor.getY(i); e[2] += nor.getZ(i); }
  for (let i = 0; i < pos.count; i++){ const e = map.get(key(i)), l = Math.hypot(e[0], e[1], e[2]) || 1; nor.setXYZ(i, e[0] / l, e[1] / l, e[2] / l); }
  nor.needsUpdate = true;
}
function vertebraGeometry(seed, facets){
  const n = 46, pts = [new V2(0, 0)];
  for (let i = 0; i <= n; i++){ const t = i / n; pts.push(new V2(Math.max(0.001, segProfile(t, P.ribs, P.ribDepth)), t)); }
  pts.push(new V2(0, 1));
  const g = new THREE.LatheGeometry(pts, Math.max(28, facets * 2));
  const pos = g.attributes.position, nor = g.attributes.normal;
  const rnd = (ix, iy, iz, k) => fract(Math.sin(ix * 127.1 + iy * 311.7 + iz * 74.7 + k * 19.19 + seed * 5.3) * 43758.5453);
  const cell = 0.5, ph = [rnd(1, 2, 3, 1) * TAU, rnd(4, 5, 6, 2) * TAU, rnd(7, 8, 9, 3) * TAU];
  for (let i = 0; i < pos.count; i++){
    const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i), r = Math.hypot(x, z);
    if (r < 1e-4) continue;
    const qx = x / cell, qy = y / cell * 0.8, qz = z / cell, ix = Math.floor(qx), iy = Math.floor(qy), iz = Math.floor(qz);
    let f1 = 9, f2 = 9, id = 0;
    for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++){
      const cx = ix + dx, cy = iy + dy, cz = iz + dz;
      const d = Math.hypot(cx + rnd(cx, cy, cz, 1) - qx, cy + rnd(cx, cy, cz, 2) - qy, cz + rnd(cx, cy, cz, 3) - qz);
      if (d < f1){ f2 = f1; f1 = d; id = rnd(cx, cy, cz, 4); } else if (d < f2) f2 = d;
    }
    const bump = smooth(0, 0.35, f2 - f1);           // runde, aufgeblähte Zellen mit Rillen an den Grenzen
    const a = Math.atan2(x, z);
    const wob = 1 + 0.14 * Math.sin(2 * a + ph[0]) * Math.sin(3.1 * y + ph[1]) + 0.07 * Math.sin(3 * a + 5 * y + ph[2]);
    const dsp = (0.1 * bump + 0.04 * id - 0.07) * smooth(0, 0.4, r);
    pos.setXYZ(i, x * wob + nor.getX(i) * dsp, y + nor.getY(i) * dsp * 0.6, z * wob + nor.getZ(i) * dsp);
  }
  g.computeVertexNormals(); weldNormals(g);
  scaleV(g, 0.25); g.rotateX(-Math.PI / 2);
  return g;
}
// Einheits-Glied: Radius ~1, Länge 1, entlang -Z
function segmentGeometry(ribs, depth, facets, vs = 0.25, res = 0){
  const n = res || (40 + ribs * 8), pts = [new V2(0, 0)];
  for (let i = 0; i <= n; i++){ const t = i / n; pts.push(new V2(Math.max(0.001, segProfile(t, ribs, depth)), t)); }
  pts.push(new V2(0, 1));
  const g = new THREE.LatheGeometry(pts, facets);
  scaleV(g, vs); g.rotateX(-Math.PI / 2);
  return g;
}
// Rückenpanzer (Tergit): offene Halbschale, hinten aufgeweitet, überlappt das nächste Glied
function shellGeometry(facets, half){
  const n = 22, pts = [];
  for (let i = 0; i <= n; i++){
    const t = -0.05 + i / n * 1.21, tc = clamp(t, 0, 1);
    let r = segProfile(tc, P.ribs, P.ribDepth) * 1.06 + 0.04;
    r += 0.05 * smooth(0.8, 1.16, t);
    r -= 0.06 * smooth(0.1, -0.05, t);
    pts.push(new V2(Math.max(0.02, r), t));
  }
  const g = new THREE.LatheGeometry(pts, Math.max(4, Math.round(facets * half / Math.PI)), -half, half * 2);
  scaleV(g, 0.3); g.rotateX(-Math.PI / 2);
  return g;
}
function headProfile(t){
  return Math.pow(Math.max(0, Math.sin(Math.PI * (0.12 + 0.84 * t))), 0.5) * (1 - 0.2 * t);
}
function headGeometry(headR, headLen, facets){
  const pts = [new V2(0, 0)];
  for (let i = 0; i <= 50; i++){
    const t = i / 50; let r = headProfile(t);
    if (t < 0.45){ const rib = 0.5 + 0.5 * Math.cos(TAU * 4 * t); r += P.ribDepth * 0.06 * Math.pow(rib, 3); }
    pts.push(new V2(Math.max(0.001, r * headR), t * headLen));
  }
  pts.push(new V2(0, headLen));
  const g = new THREE.LatheGeometry(pts, Math.max(8, facets));
  scaleV(g, 0.35); g.rotateX(Math.PI / 2);
  return g;
}
// Panels: Trapez, Spitze, Sechseck oder unregelmäßige Voronoi-Zelle
function panelGeometry(w, len, thick, type, rng){
  const s = new THREE.Shape();
  if (type === 0){ s.moveTo(-w/2, 0); s.lineTo(w/2, 0); s.lineTo(w*0.32, len); s.lineTo(-w*0.32, len); }
  else if (type === 1){ s.moveTo(-w/2, 0); s.lineTo(w/2, 0); s.lineTo(w*0.18, len*0.75); s.lineTo(0, len); s.lineTo(-w*0.18, len*0.75); }
  else if (type === 2){ s.moveTo(-w/2, 0); s.lineTo(w/2, 0); s.lineTo(w/2, len*0.8); s.lineTo(w*0.2, len); s.lineTo(-w*0.2, len); s.lineTo(-w/2, len*0.8); }
  else {
    const j = () => 0.75 + 0.25 * rng();
    s.moveTo(-w/2, 0); s.lineTo(w/2, 0);
    s.lineTo(w * 0.5 * j(), len * (0.25 + 0.25 * rng()));
    s.lineTo(w * 0.32 * j(), len * (0.7 + 0.2 * rng()));
    s.lineTo(w * 0.05 * (rng() - 0.5), len);
    s.lineTo(-w * 0.34 * j(), len * (0.68 + 0.25 * rng()));
    s.lineTo(-w * 0.5 * j(), len * (0.25 + 0.25 * rng()));
  }
  s.closePath();
  const g = new THREE.ExtrudeGeometry(s, { depth: thick, bevelEnabled: true, bevelThickness: thick * 0.35, bevelSize: thick * 0.35, bevelSegments: 1, steps: 1 });
  g.rotateX(-Math.PI / 2);
  return g;
}

/* ---------------- Kabel (dynamische, geriffelte Röhre) ---------------- */
const _t = new V3(), _n = new V3(), _b = new V3(), _pt = new V3(), _ax = new V3(), _X = new V3(1,0,0), _Y = new V3(0,1,0), _Z = new V3(0,0,1);
class Cable {
  constructor(r, mat, T, S, ribs){
    this.r = r; this.T = T; this.S = S; this.ribs = ribs;
    const n = (T + 1) * (S + 1);
    this.pos = new Float32Array(n * 3); this.nor = new Float32Array(n * 3);
    const uv = new Float32Array(n * 2), idx = [];
    for (let i = 0; i <= T; i++) for (let j = 0; j <= S; j++){ const k = i * (S + 1) + j; uv[k*2] = j / S; uv[k*2+1] = i / T * 4; }
    for (let i = 0; i < T; i++) for (let j = 0; j < S; j++){ const a = i*(S+1)+j, b = (i+1)*(S+1)+j; idx.push(a, b, a+1, b, b+1, a+1); }
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.BufferAttribute(this.pos, 3).setUsage(THREE.DynamicDrawUsage));
    g.setAttribute('normal', new THREE.BufferAttribute(this.nor, 3).setUsage(THREE.DynamicDrawUsage));
    g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
    g.setIndex(idx);
    this.geo = g;
    this.mesh = new THREE.Mesh(g, mat);
    this.mesh.frustumCulled = false; this.mesh.castShadow = true; this.mesh.receiveShadow = true;
    this.curve = new THREE.CatmullRomCurve3([new V3(), new V3(), new V3(), new V3()], false, 'catmullrom', 0.5);
    this.samples = Array.from({ length: T + 1 }, () => new V3());
  }
  update(pts){
    const { T, S, pos, nor, samples, curve } = this;
    curve.points = pts;
    for (let i = 0; i <= T; i++) curve.getPoint(i / T, samples[i]);
    for (let i = 0; i <= T; i++){
      _t.subVectors(samples[Math.min(T, i + 1)], samples[Math.max(0, i - 1)]).normalize();
      if (i === 0) _n.crossVectors(_t, Math.abs(_t.y) < 0.9 ? _Y : _X).normalize();
      else {
        _ax.crossVectors(_pt, _t); const l = _ax.length();
        if (l > 1e-6) _n.applyAxisAngle(_ax.divideScalar(l), Math.asin(Math.min(1, l)));
        _n.addScaledVector(_t, -_n.dot(_t)).normalize();
      }
      _pt.copy(_t); _b.crossVectors(_t, _n);
      const c = Math.abs(Math.sin(i / T * this.ribs * Math.PI)), rr = this.r * (0.95 + 0.1 * c * c), p = samples[i];
      for (let j = 0; j <= S; j++){
        const v = j / S * TAU, cv = Math.cos(v), sv = Math.sin(v);
        const nx = _n.x*cv + _b.x*sv, ny = _n.y*cv + _b.y*sv, nz = _n.z*cv + _b.z*sv, k = (i * (S + 1) + j) * 3;
        pos[k] = p.x + nx*rr; pos[k+1] = p.y + ny*rr; pos[k+2] = p.z + nz*rr;
        nor[k] = nx; nor[k+1] = ny; nor[k+2] = nz;
      }
    }
    this.geo.attributes.position.needsUpdate = true;
    this.geo.attributes.normal.needsUpdate = true;
  }
}

/* ---------------- Kreatur bauen ---------------- */
let creature = null;
const _inv = new THREE.Matrix4();

function build(){
  if (creature){ world.remove(creature.root); creature.root.traverse(o => { if (o.geometry) o.geometry.dispose(); }); }
  const rng = mulberry32(P.seed * 7919 + 17);
  const R0 = P.radius, N = P.segments, F = P.facets;
  const C = { root: new THREE.Group(), spine: [], panels: [], collars: [], spinners: [], eyes: [], chains: [],
              ropes: [], wraps: [], caps: [], steps: [], ringAng: [], spinDir: 0, ca: 0, phi: 0, waveT: 0, shock: 0, fx: 0, fz: 0, curlPh: rng() * TAU, acc: 0, simT: 0 };
  const collarSides = Math.max(6, Math.round(F / 3));
  const G = {
    verts: [0, 1, 2].map(i => vertebraGeometry(P.seed * 13 + i, F)),
    chain: segmentGeometry(0, 0, 6, 1, 8),
    box: new THREE.BoxGeometry(1, 1, 1),
    cyl6: new THREE.CylinderGeometry(1, 1, 1, 6),
    cyl10: new THREE.CylinderGeometry(1, 1, 1, 10),
    disc: new THREE.CylinderGeometry(1, 1, 1, 28).rotateX(Math.PI / 2),
    collar: new THREE.CylinderGeometry(1, 1, 1, collarSides, 1).rotateX(Math.PI / 2),
    sphere: new THREE.IcosahedronGeometry(1, 1),
    ball: new THREE.SphereGeometry(1, 20, 14),
    dome: new THREE.SphereGeometry(1, 24, 12, 0, TAU, 0, Math.PI * 0.5),
    eye: new THREE.IcosahedronGeometry(1, 2),
    torus: new THREE.TorusGeometry(1, 0.18, 6, 24),
    cone: new THREE.ConeGeometry(1, 1, 6).rotateX(-Math.PI / 2).translate(0, 0, -0.5),
  };
  const M = (geo, mat, parent, shadow = true) => { const m = new THREE.Mesh(geo, mat); m.castShadow = shadow; m.receiveShadow = true; if (parent) parent.add(m); return m; };
  let pid = 0;
  const shellHalf = 0.5 + P.shell * 1.1;
  const G_shell = P.shell > 0.02 ? shellGeometry(F, shellHalf) : null;

  function addPanel(bone, a, z, rb, w, len, thick, type, mat, k){
    if (mat === mats.plate && rng() < P.glass * 0.8) mat = mats.white;
    const pv = new THREE.Group(); pv.rotation.z = a - Math.PI / 2; pv.position.z = z; bone.add(pv);
    const hinge = new THREE.Group(); hinge.position.y = rb; pv.add(hinge);
    const pin = M(G.cyl6, mats.dark, hinge); pin.scale.set(thick * 1.1, w * 0.75, thick * 1.1); pin.rotation.z = Math.PI / 2;
    const plate = M(panelGeometry(w, len, thick, type, rng), mat, hinge); plate.position.y = thick * 0.3;
    const rib = M(G.box, mats.dark, hinge); rib.scale.set(w * 0.1, thick * 1.4, len * 0.7); rib.position.set(0, thick * 1.3, -len * 0.4);
    C.panels.push({ hinge, k, ph: rng() * TAU, rate: 0.25 + rng() * 0.6, id: pid++, v: 0 });
  }
  function makeChain(parent, Mn, sl, r0, matA, matTip, ant){
    let par = parent; const bones = [];
    for (let m = 0; m < Mn; m++){
      const tb = new THREE.Bone(); tb.position.z = m === 0 ? 0 : -sl * 0.92; par.add(tb);
      const f = m / Math.max(1, Mn - 1), tr = r0 * (1 - 0.8 * Math.pow(f, 0.8)) + r0 * 0.12;
      M(G.chain, matA, tb, false).scale.set(tr, tr, sl);
      bones.push(tb); par = tb;
    }
    const tip = M(G.cone, matTip, par, false); tip.scale.set(r0 * 0.2, r0 * 0.2, sl * 1.4); tip.position.z = -sl * 0.85;
    C.chains.push({ bones, ph: rng() * TAU, ph2: rng() * TAU, ant });
  }

  /* --- Kopf --- */
  const headR = R0 * 0.85, headLen = R0 * 1.9, hShift = headLen * 0.24;
  const HSX = 1.3, HSY = 0.78; // breiter, flacher Käferschädel
  const head = new THREE.Bone(); C.root.add(head); C.head = head;
  const body0 = new THREE.Group(); head.add(body0); C.body0 = body0;
  const hm = M(headGeometry(headR, headLen, F), mats.body, head); hm.position.z = -hShift; hm.scale.set(HSX, HSY, 1);
  const hR = t => headR * headProfile(t), hZ = t => t * headLen - hShift;
  C.ca0 = 0;
  C.caps.push({ bone: head, local: new V3(0, 0, headLen * 0.35 - hShift), r: headR * 1.05, p: new V3() });
  const onHead = (tz, phi, lift = 1) => new V3(Math.cos(phi) * hR(tz) * lift * HSX, Math.sin(phi) * hR(tz) * lift * HSY, hZ(tz));
  const headNormal = (tz, phi) => new V3(Math.cos(phi) / HSX, Math.sin(phi) / HSY, -(hR(tz + 0.01) - hR(tz - 0.01)) / (0.02 * headLen)).normalize();

  // Frontöffnung: konzentrische Ringe, dunkler Kern, Lamellenkranz
  for (let i = 0; i < 3; i++){
    const t = M(G.torus, i % 2 ? mats.dark : mats.plate, head); t.scale.set(hR(0.9) * (0.85 - 0.2 * i) * HSX, hR(0.9) * (0.85 - 0.2 * i) * HSY, hR(0.9) * (0.85 - 0.2 * i)); t.position.z = hZ(0.95 + 0.012 * i);
  }
  M(G.sphere, mats.dark, head).scale.setScalar(hR(0.95) * 0.3);
  head.children[head.children.length - 1].position.z = hZ(0.975);
  for (let i = 0; i < 10; i++){
    const g = new THREE.Group(); g.rotation.z = i / 10 * TAU; g.position.z = hZ(0.985); head.add(g);
    const fin = M(G.box, mats.plate, g, false); fin.scale.set(hR(0.9) * 0.07, hR(0.9) * 0.28, hR(0.9) * 0.05); fin.position.y = hR(0.9) * 0.5;
  }

  // Ringbänder um den Kopf (leicht verkippt, mit Bolzen)
  for (const [tz, mat] of [[0.2, mats.plate], [0.33, mats.dark], [0.46, mats.plate], [0.6, mats.dark], [0.74, mats.plate]]){
    const tr = M(G.torus, mat === mats.plate && rng() < P.glass * 0.7 ? mats.white : mat, head); tr.scale.set(hR(tz) * 1.01 * HSX, hR(tz) * 1.01 * HSY, hR(tz) * 1.01); tr.position.z = hZ(tz);
    tr.rotation.x = (rng() - 0.5) * 0.12; tr.rotation.y = (rng() - 0.5) * 0.12;
    for (let i = 0; i < 6; i++){
      const a = rng() * TAU, bolt = M(G.cyl6, mats.dark, head, false);
      bolt.scale.set(R0 * 0.03, R0 * 0.05, R0 * 0.03); bolt.position.copy(onHead(tz, a, 1.1)); bolt.quaternion.setFromUnitVectors(_Y, new V3(Math.cos(a), Math.sin(a), 0));
    }
  }

  // Lüftungsschlitze seitlich unten
  for (const s of [-1, 1]) for (let i = 0; i < 4 + Math.floor(rng() * 3); i++){
    const phi = s > 0 ? -0.5 : Math.PI + 0.5, tz = 0.3 + i * 0.055;
    const g = new THREE.Group(); g.position.copy(onHead(tz, phi, 1.0)); g.rotation.z = phi - Math.PI / 2; head.add(g);
    M(G.box, mats.dark, g, false).scale.set(R0 * 0.28, R0 * 0.05, R0 * 0.03);
  }

  // Rückenkamm und Wangenpanels (Voronoi-Zellen)
  if (P.panels > 0){
    for (let i = 0; i < 6; i++){
      const tz = 0.14 + i * 0.12;
      addPanel(head, Math.PI / 2 + (rng() - 0.5) * 0.3, hZ(tz), hR(tz) * 0.95 * HSY, headR * (0.2 + 0.2 * rng()) * P.panelSize, headLen * (0.14 + 0.12 * rng()) * P.panelSize, R0 * 0.045, 3, mats.plate, 0.5 + rng() * 0.6);
    }
    for (const s of [-1, 1]) for (let i = 0; i < 2; i++)
      addPanel(head, Math.PI / 2 + s * (0.9 + 0.5 * rng()), hZ(0.22 + i * 0.22), hR(0.3) * 0.93 * 1.1, headR * 0.45 * P.panelSize, headLen * 0.3 * P.panelSize, R0 * 0.05, 3, mats.plate, 0.8);
  }

  // Maschinenteile am Kopf
  for (let i = 0; i < 8; i++){
    const tz = 0.2 + 0.6 * rng(), phi = rng() * TAU, g = new THREE.Group();
    g.position.copy(onHead(tz, phi, 0.98)); g.rotation.z = phi - Math.PI / 2; head.add(g);
    const w = R0 * (0.08 + 0.12 * rng()), h = R0 * (0.06 + 0.1 * rng());
    const part = M(rng() < 0.5 ? G.box : G.cyl6, rng() < 0.5 ? mats.dark : mats.plate, g); part.scale.set(w, h, R0 * (0.08 + 0.12 * rng())); part.position.y = h * 0.4;
  }

  // Augen-Panels: runde Scheiben mit Facettenaugen, viele, unterschiedlich groß und leicht schief (wie Fliegenaugen, aber seltsam)
  const pods = [];
  for (let tries = 0; pods.length < P.eyes && tries < 600; tries++){
    const tz = 0.38 + 0.54 * Math.pow(rng(), 0.85), phi = rng() * TAU;
    if (Math.sin(phi) < -0.7 && tz > 0.38 && tz < 0.62) continue;
    const rad = headR * (rng() < 0.22 ? 0.26 + 0.08 * rng() : 0.09 + 0.12 * rng()) * P.eyeSize;
    const p = onHead(tz, phi, 1);
    if (pods.some(e => e.p.distanceTo(p) < (e.rad + rad) * 1.1)) continue;
    pods.push({ p, rad, tz, phi });
  }
  for (const pd of pods){
    const nrm = headNormal(pd.tz, pd.phi);
    if (rng() < 0.4) nrm.add(new V3(rng() - 0.5, rng() - 0.5, rng() - 0.5).multiplyScalar(0.5)).normalize();
    const rad = pd.rad, stand = rad * (0.45 + 0.5 * rng()), org = pd.p.clone().multiplyScalar(0.97);
    // Sockel auf dem Kopf + Kugelgelenk, daran Halterung (Mittelstrebe + zwei Gabelstreben zum Scheibenrand)
    const sock = M(G.cyl10, mats.dark, head, false); sock.scale.set(rad * 0.5, rad * 0.12, rad * 0.5); sock.position.copy(org); sock.quaternion.setFromUnitVectors(_Y, nrm);
    const pivot = new THREE.Group(); pivot.position.copy(org); pivot.quaternion.setFromUnitVectors(_Z, nrm); head.add(pivot);
    M(G.sphere, mats.plate, pivot, false).scale.setScalar(rad * 0.24);
    const strut = (p0, p1, r, mat) => {
      const d = new V3().subVectors(p1, p0), m = M(G.cyl6, mat, pivot, false);
      m.scale.set(r, d.length(), r); m.position.copy(p0).addScaledVector(d, 0.5); m.quaternion.setFromUnitVectors(_Y, d.normalize());
    };
    strut(new V3(0, 0, 0), new V3(0, 0, stand), rad * 0.1, mats.dark);
    for (const sd of [-1, 1]) strut(new V3(sd * rad * 0.15, 0, stand * 0.25), new V3(sd * rad * 1.1, 0, stand), rad * 0.06, mats.plate);
    const eg = new THREE.Group(); eg.position.z = stand; pivot.add(eg);
    const disc = M(G.disc, rng() < P.glass * 0.6 ? mats.white : mats.plate, eg); disc.scale.set(rad * 1.18, rad * 1.18, rad * 0.2); disc.position.z = rad * 0.04;
    const rim = M(G.torus, mats.dark, eg); rim.scale.setScalar(rad * 1.12); rim.position.z = rad * 0.14;
    // viele kleine rote Lichter in Fibonacci-Anordnung (Phyllotaxis) auf der Scheibe
    const nL = clamp(Math.round(34 * Math.pow(rad / (headR * 0.2), 2)), 12, 140), RL = rad * 0.9, cc = RL / Math.sqrt(nL), dr = cc * 0.44;
    const lights = new THREE.InstancedMesh(G.sphere, mats.eye, nL), _dm = new THREE.Object3D();
    for (let i = 0; i < nL; i++){
      const rr = cc * Math.sqrt(i + 0.5), a = i * 2.399963;
      _dm.position.set(Math.cos(a) * rr, Math.sin(a) * rr, rad * 0.14 + rad * 0.1 * (1 - (rr / RL) ** 2));
      _dm.scale.set(dr, dr, dr * 0.85); _dm.updateMatrix(); lights.setMatrixAt(i, _dm.matrix);
    }
    lights.instanceMatrix.needsUpdate = true; lights.castShadow = false; eg.add(lights);
    C.eyes.push({ g: pivot, q0: pivot.quaternion.clone(), ph: rng() * 10, rate: 0.15 + rng() * 0.35, id: pid++, v: 0, v2: 0 });
  }

  // Käferschädel: heller Hinterhaupt-Helm und keilförmiges Kinn (Clypeus)
  { const helm = M(G.dome, mats.white, head); helm.scale.set(hR(0.22) * HSX * 1.06, hR(0.22) * HSY * 1.06, headLen * 0.26); helm.position.z = hZ(0.22);
    const cl = new THREE.Group(); cl.rotation.y = Math.PI; cl.position.set(0, -headR * HSY * 0.55, hZ(0.9)); head.add(cl);
    M(G.cone, mats.white, cl).scale.set(headR * HSX * 0.5, headR * HSY * 0.3, headLen * 0.4); }

  // Fühler
  if (P.antLen > 0.05) for (const s of [-1, 1]){
    const tz = 0.72, r = hR(tz), base = new THREE.Group();
    base.position.set(s * r * 0.35 * HSX, r * 0.9 * HSY, hZ(tz)); head.add(base);
    base.quaternion.setFromUnitVectors(new V3(0, 0, -1), new V3(s * 0.4, 0.55, 0.75).normalize());
    M(G.sphere, mats.dark, base).scale.setScalar(R0 * 0.08);
    const Mn = Math.round(8 + 6 * P.antLen), L = R0 * 3.2 * P.antLen;
    makeChain(base, Mn, L / Mn / 0.92, R0 * 0.035, mats.chain, mats.plate, true);
  }

  // Tentakel: lang, fein, quallenartig
  for (let j = 0; j < P.tentacles; j++){
    const phi = -Math.PI / 2 + ((j + 0.5) / P.tentacles - 0.5) * Math.PI * 1.7 + (rng() - 0.5) * 0.15;
    const tz = 0.5 + (rng() - 0.5) * 0.1, r = hR(tz) * 0.9;
    const radial = new V3(Math.cos(phi), Math.sin(phi), 0);
    const base = new THREE.Group(); base.position.set(radial.x * r * HSX, radial.y * r * HSY, hZ(tz)); head.add(base);
    base.quaternion.setFromUnitVectors(new V3(0, 0, -1), radial.clone().multiplyScalar(0.5).add(new V3(0, -0.3, -0.8)).normalize());
    const r0 = R0 * 0.055 * (0.8 + 0.4 * rng());
    M(G.sphere, mats.plate, base, false).scale.setScalar(r0 * 1.8);
    const Mn = clamp(Math.round(12 + 10 * P.tentLen), 12, 40), L = R0 * 4.5 * P.tentLen * (0.75 + 0.5 * rng());
    makeChain(base, Mn, L / Mn / 0.92, r0, mats.chain, mats.plate, false);
  }

  /* --- Körper (Bone-Kette) --- */
  let parent = body0, prevL = 0, total = headLen - hShift;
  const segs = [];
  for (let k = 0; k < N; k++){
    const f = N > 1 ? k / (N - 1) : 0, tp = Math.pow(f, 1.35);
    const Ri = R0 * (1 - P.taper * tp) * (1 + P.thorax * 0.3 * Math.max(0, 1 - k / 3.5));
    const Li = P.segLen * (0.5 * Ri + 0.5 * R0) * (1 - P.taper * 0.3 * tp);
    const b = new THREE.Bone();
    const step = k === 0 ? hShift * 0.85 : prevL * 0.9;
    b.position.z = -step; total += step; parent.add(b); C.steps.push(step);

    M(G.verts[Math.floor(rng() * 3)], mats.body, b).scale.set(Ri, Ri, Li * (0.92 + 0.16 * rng()));
    // Wirbelfortsätze: Dornfortsatz oben, Querfortsätze seitlich
    const spn = M(G.ball, mats.white, b); spn.scale.set(Ri * 0.09, Ri * 0.5, Li * 0.3); spn.position.set(0, Ri * 0.78, -Li * 0.45);
    for (const sd of [-1, 1]){ const tp = M(G.ball, mats.white, b); tp.scale.set(Ri * 0.5, Ri * 0.1, Li * 0.16); tp.position.set(sd * Ri * 0.72, -Ri * 0.05, -Li * 0.4); tp.rotation.y = sd * 0.35; }
    if (G_shell){ const sh = M(G_shell, rng() < P.glass * 0.6 ? mats.white : mats.plate, b); sh.scale.set(Ri, Ri, Li); }
    C.caps.push({ bone: b, local: new V3(0, 0, -0.45 * Li), r: Ri * 1.08, p: new V3() });

    // Kragen mit Bolzen (dreht asynchron wie ein Servo)
    if (P.collar > 0.01){
      const cg = new THREE.Group(); b.add(cg);
      const cr = Ri * (0.58 + 0.26 * P.collar), ch = Li * (0.05 + 0.12 * P.collar);
      M(G.collar, rng() < P.glass * 0.5 ? mats.white : mats.plate, cg).scale.set(cr, cr, ch);
      const nb = Math.max(3, Math.round(collarSides / 2));
      for (let i = 0; i < nb; i++){
        const a = i / nb * TAU, bm = M(G.box, mats.dark, cg);
        bm.scale.set(cr * 0.16, cr * 0.12, ch * 1.25); bm.position.set(Math.cos(a) * cr, Math.sin(a) * cr, 0); bm.rotation.z = a;
      }
      C.collars.push({ g: cg, sides: collarSides, id: pid++, rate: 0.2 + rng() * 0.4, ph: rng() * 10, v: 0 });
    }

    // Panels (Voronoi-Zellen bevorzugt)
    if (k < N - 1 && P.panels > 0){
      const np = Math.max(0, P.panels + Math.round((rng() - 0.5) * 2 * P.panelVar));
      const off = rng() * TAU * P.panelVar * 0.3;
      for (let j = 0; j < np; j++){
        const a = Math.PI / 2 + j / np * TAU + off + (rng() - 0.5) * P.panelVar * 0.5;
        const w = Ri * P.panelSize * (0.6 + 0.5 * rng() * (0.3 + P.panelVar));
        const len = Li * P.panelSize * (0.75 + 0.7 * rng() * (0.3 + P.panelVar));
        const rad = Ri * segProfile(0.18, P.ribs, P.ribDepth) * 0.93 + (G_shell && Math.sin(a) > 0 ? Ri * 0.12 : 0);
        addPanel(b, a, -Li * 0.18, rad, w, len, Ri * 0.045, rng() < 0.5 ? 3 : Math.floor(rng() * 3), rng() < 0.75 ? mats.plate : mats.dark, 0.7 + 0.6 * rng());
      }
    }

    // Maschinenteile
    const ng = Math.max(0, P.greebles + Math.round((rng() - 0.5) * 2));
    for (let g = 0; g < ng; g++){
      const a = rng() * TAU, t = 0.3 + 0.45 * rng(), r = Ri * segProfile(t, P.ribs, P.ribDepth) * 0.96;
      const gg = new THREE.Group(); gg.rotation.z = a - Math.PI / 2; gg.position.z = -t * Li; b.add(gg);
      const ty = rng();
      if (ty < 0.35){
        const w = Ri * (0.14 + 0.16 * rng()), h = Ri * (0.08 + 0.12 * rng()), d = Li * (0.1 + 0.18 * rng());
        const bx = M(G.box, rng() < 0.5 ? mats.dark : mats.plate, gg); bx.scale.set(w, h, d); bx.position.y = r + h * 0.35;
      } else if (ty < 0.7){
        const rr = Ri * (0.12 + 0.1 * rng()), h = Ri * 0.07;
        const disc = M(G.cyl10, mats.plate, gg); disc.scale.set(rr, h, rr); disc.position.y = r + h * 0.4;
        M(G.cyl6, mats.dark, disc).scale.set(0.4, 1.6, 0.4);
        M(G.box, mats.dark, disc).scale.set(1.7, 1.3, 0.18);
        C.spinners.push({ mesh: disc, w: (rng() - 0.5) * 4 });
      } else {
        const rr = Ri * (0.05 + 0.05 * rng()), h = Ri * (0.15 + 0.15 * rng());
        const stem = M(G.cyl6, mats.dark, gg); stem.scale.set(rr, h, rr); stem.position.y = r + h / 2;
        const nut = M(G.cyl6, mats.plate, gg); nut.scale.set(rr * 2, rr * 0.9, rr * 2); nut.position.y = r + h;
      }
    }
    segs.push({ b, Ri, Li }); C.spine.push(b); parent = b; prevL = Li;
  }
  total += prevL;
  // Ring-Pose: Krümmung so verteilt, dass der Körper fast einen Vollkreis schließt (Schwanz berührt beinahe den Kopf)
  { const sum = C.steps.reduce((x, y) => x + y, 0), c = TAU * 0.93 / sum;
    C.ringAng = C.steps.map((st, k) => c * (st + (C.steps[k + 1] !== undefined ? C.steps[k + 1] : st * 0.5)) / 2); }

  /* --- Schwanz --- */
  const last = segs[segs.length - 1];
  const spike = M(G.cone, mats.plate, last.b); spike.scale.set(last.Ri * 0.42, last.Ri * 0.42, last.Ri * 2.8); spike.position.z = -last.Li * 0.82;
  for (let i = 0; i < 3; i++)
    addPanel(last.b, Math.PI / 2 + i / 3 * TAU, -last.Li * 0.55, last.Ri * 0.5, last.Ri * 0.7, last.Li * 1.4 * P.panelSize + 0.3 * R0, last.Ri * 0.05, 3, mats.plate, 0.35);
  C.caps.push({ bone: last.b, local: new V3(0, 0, -last.Li), r: last.Ri * 0.55, p: new V3() });

  C.total = total;
  C.root.position.z = total / 2 - (headLen - hShift);
  C.baseZ = C.root.position.z;
  world.add(C.root);
  creature = C;
  C.root.updateMatrixWorld(true);

  /* --- Kabel & Fühlerfäden --- */
  if (P.cables > 0 || P.filaments > 0){
    const inv0 = C.root.matrixWorld.clone().invert();
    const cr0 = P.hoseRadius * R0;
    const toRoot = (bone, v) => v.clone().applyMatrix4(bone.matrixWorld).applyMatrix4(inv0);
    const clusters = Array.from({ length: 5 + Math.floor(rng() * 3) }, () => Math.floor(rng() * N));
    const pickSeg = () => clamp(rng() < 0.6 ? clusters[Math.floor(rng() * clusters.length)] + Math.round((rng() - 0.5) * 2) : Math.floor(rng() * N), 0, N - 1);
    const sideBias = rng() * 2 - 1;

    function surfacePoint(k, a, t){
      const dir = new V3(Math.cos(a), Math.sin(a), 0);
      if (k < 0){ const tt = 0.1 + t * 0.5, r = hR(tt) * 0.98; return { bone: head, pos: new V3(dir.x * r * HSX, dir.y * r * HSY, hZ(tt)), dir, Ri: headR }; }
      const { b, Ri, Li } = segs[k];
      const lift = G_shell && Math.sin(a) > 0.2 ? Ri * 0.16 : 0;
      const r = Ri * segProfile(t, P.ribs, P.ribDepth) * 1.02 + lift;
      return { bone: b, pos: dir.clone().multiplyScalar(r).setZ(-t * Li), dir, Ri };
    }
    function socket(sp, hr, mini){
      const q = new THREE.Quaternion().setFromUnitVectors(_Y, sp.dir);
      const s1 = M(G.cyl6, mats.plate, sp.bone, false); s1.scale.set(hr * (mini ? 2.2 : 1.9), hr * (mini ? 1.4 : 1.6), hr * (mini ? 2.2 : 1.9)); s1.position.copy(sp.pos); s1.quaternion.copy(q);
      if (mini) return sp.pos.clone().addScaledVector(sp.dir, hr * 1.2);
      const s2 = M(G.cyl6, mats.dark, sp.bone, false); s2.scale.set(hr * 1.35, hr * 2.2, hr * 1.35); s2.position.copy(sp.pos).addScaledVector(sp.dir, hr * 1.1); s2.quaternion.copy(q);
      return sp.pos.clone().addScaledVector(sp.dir, hr * 2.1);
    }

    function makeRope(spA, spB, L, hr, plug, fil){
      const LA = socket(spA, hr, fil), LB = spB ? socket(spB, hr) : null;
      const wA = toRoot(spA.bone, LA), wB = spB ? toRoot(spB.bone, LB) : null;
      const dirW = spA.dir.clone().transformDirection(spA.bone.matrixWorld).transformDirection(inv0);
      const n = fil ? clamp(Math.round(L / (0.16 * R0)), 6, 14) : clamp(Math.round(L / (0.11 * R0)), 8, 30), p = [], q = [];
      const sag = spB ? 0.5 * Math.sqrt(Math.max(0, L * L - wA.distanceToSquared(wB))) : 0;
      for (let i = 0; i < n; i++){
        const u = i / (n - 1);
        const v = spB ? wA.clone().lerp(wB, u).add(new V3(0, -sag * Math.sin(Math.PI * u), 0))
                      : fil ? wA.clone().addScaledVector(dirW, u * L)
                            : wA.clone().add(new V3((rng() - 0.5) * 0.1, -u * L, (rng() - 0.5) * 0.1));
        p.push(v); q.push(v.clone());
      }
      const cable = fil ? new Cable(hr, mats.chain, (n - 1) * 2, 4, 0) : new Cable(hr, mats.hose, (n - 1) * 2, 6, Math.max(3, Math.round(L / (hr * 2.3))));
      C.root.add(cable.mesh);
      let plugG = null;
      if (plug){
        plugG = new THREE.Group(); C.root.add(plugG);
        const a = M(G.cyl10, mats.plate, plugG, false); a.scale.set(hr * 2.2, hr * 3.2, hr * 2.2); a.position.y = -hr * 1.6;
        const b = M(G.cyl10, mats.dark, plugG, false); b.scale.set(hr * 2.7, hr * 0.7, hr * 2.7); b.position.y = -hr * 3.4;
        const c = M(G.cyl6, mats.dark, plugG, false); c.scale.set(hr * 1.3, hr * 2.2, hr * 1.3); c.position.y = -hr * 4.6;
      }
      C.ropes.push({ n, l: L / (n - 1), p, q, bA: spA.bone, LA, bB: spB ? spB.bone : null, LB, pinB: !!spB, wA, wB: wB || new V3(),
                     cable, plug: plugG, hr, ph: rng() * TAU, fil: !!fil, dirL: spA.dir.clone(), dirW,
                     gf: fil ? 0.1 : 1, wf: fil ? 3.2 : 1 });
    }

    const nLoop = P.cables;

    // unregelmäßige Verbindungen zwischen beliebigen Gliedern (mal straff, mal durchhängend)
    for (let c = 0; c < nLoop; c++){
      const kA = rng() < 0.12 ? -1 : pickSeg();
      const kB = kA < 0 ? Math.floor(rng() * Math.min(N, 6)) : clamp(kA + (1 + Math.floor(rng() * 6)) * (rng() < 0.5 ? -1 : 1), 0, N - 1);
      if (kA === kB && kA >= 0) continue;
      const hr = cr0 * (0.8 + 0.7 * rng());
      const spA = surfacePoint(kA, rng() * TAU, 0.15 + 0.7 * rng()), spB = surfacePoint(kB, rng() * TAU, 0.15 + 0.7 * rng());
      const d = toRoot(spA.bone, spA.pos).distanceTo(toRoot(spB.bone, spB.pos));
      const slack = rng() < 0.3 ? 1.03 + 0.1 * rng() : 1.25 + 0.9 * rng();
      makeRope(spA, spB, d * slack + R0 * 0.2, hr, false, false);
    }

    // dünne, steife Fühlerfäden, die im Wind mitschwingen
    for (let c = 0; c < P.filaments; c++){
      const k = rng() < 0.1 ? -1 : pickSeg();
      const L = R0 * (0.6 + rng() * 1.6) * P.filLen;
      makeRope(surfacePoint(k, rng() * TAU, 0.15 + 0.7 * rng()), null, L, R0 * 0.008 * (0.7 + 0.6 * rng()), false, true);
    }
  }

  C.root.updateMatrixWorld(true);
  for (let i = 0; i < 100; i++) stepRopes(C, 1 / 60);
  updateStage();
}

function updateStage(){
  const C = creature, R0 = P.radius, s = C.total * 0.65 + R0 * 6;
  floor.position.y = -(R0 * 3.2 + 1.5 + C.total * 0.08);
  grid.position.y = floor.position.y;
  key.position.copy(KEYDIR).multiplyScalar(C.total + 30);
  const cam = key.shadow.camera;
  cam.left = -s; cam.right = s; cam.top = s; cam.bottom = -s; cam.near = 1; cam.far = C.total * 3 + 80;
  cam.updateProjectionMatrix();
}
function fitView(){
  const d = creature.total * 0.95 + P.radius * 8;
  resetFocus();
  camera.position.set(d * 0.78, d * 0.3, d * 0.62).add(focus);
  scene.fog.near = d * 1.3; scene.fog.far = d * 3.6;
  controls.update();
}

/* ---------------- Kabel-Physik (Verlet) ---------------- */
const _q = new V3(), _ab = new V3(), _ap = new V3();
function collide(p, caps, cr){
  for (let j = 0; j < caps.length - 1; j++){
    const a = caps[j].p, b = caps[j + 1].p;
    _ab.subVectors(b, a); const l2 = _ab.lengthSq(); if (l2 < 1e-8) continue;
    _ap.subVectors(p, a); const t = clamp(_ap.dot(_ab) / l2, 0, 1);
    _q.copy(a).addScaledVector(_ab, t);
    const rad = lerp(caps[j].r, caps[j + 1].r, t) + cr;
    _ap.subVectors(p, _q); const d2 = _ap.lengthSq();
    if (d2 < rad * rad){ const d = Math.sqrt(d2) || 1e-5; p.copy(_q).addScaledVector(_ap, rad / d); }
  }
}
function stepRopes(C, dt){
  C.simT += dt;
  const g = 11, S = 5 * P.slosh, damp = lerp(0.97, 0.995, P.slosh / 2), dt2 = dt * dt;
  for (const r of C.ropes){
    const { n, p, q, l } = r, last = r.pinB ? n - 1 : n;
    p[0].copy(r.wA); if (r.pinB) p[n - 1].copy(r.wB);
    for (let i = 1; i < last; i++){
      const pi = p[i], qi = q[i], f = (0.3 + i / n) * r.wf;
      const wx = Math.sin(C.simT * 0.7 + r.ph + pi.z * 0.4) * S, wy = Math.sin(C.simT * 0.9 + pi.x * 0.5) * S * 0.35, wz = Math.cos(C.simT * 0.5 + r.ph * 1.7 + pi.z * 0.3) * S;
      const vx = (pi.x - qi.x) * damp, vy = (pi.y - qi.y) * damp, vz = (pi.z - qi.z) * damp;
      qi.copy(pi);
      const fi = 0.4 + i / n;
      pi.x += vx + (wx * f + C.fx * fi) * dt2; pi.y += vy + (wy * f - g * r.gf) * dt2; pi.z += vz + (wz * f + C.fz * fi) * dt2;
      if (r.fil){
        const k = (1 - i / n) * 0.09 + 0.012;
        pi.x += (r.wA.x + r.dirW.x * i * l - pi.x) * k; pi.y += (r.wA.y + r.dirW.y * i * l - pi.y) * k; pi.z += (r.wA.z + r.dirW.z * i * l - pi.z) * k;
      }
    }
    for (let it = 0; it < 5; it++){
      p[0].copy(r.wA); if (r.pinB) p[n - 1].copy(r.wB);
      for (let i = 0; i < n - 1; i++){
        const a = p[i], b = p[i + 1], dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z, d = Math.hypot(dx, dy, dz);
        if (d < 1e-6) continue;
        const k = (d - l) / d, aF = i === 0, bF = r.pinB && i + 1 === n - 1;
        if (aF && bF) continue;
        const wa = aF ? 0 : bF ? 1 : 0.5, wb = bF ? 0 : aF ? 1 : 0.5;
        a.x += dx * k * wa; a.y += dy * k * wa; a.z += dz * k * wa;
        b.x -= dx * k * wb; b.y -= dy * k * wb; b.z -= dz * k * wb;
      }
      for (let i = 1; i < last; i++) collide(p[i], C.caps, r.hr);
    }
  }
}
function updateCables(C, dt){
  _inv.copy(C.root.matrixWorld).invert();
  for (const c of C.caps) c.p.copy(c.local).applyMatrix4(c.bone.matrixWorld).applyMatrix4(_inv);
  for (const r of C.ropes){
    r.wA.copy(r.LA).applyMatrix4(r.bA.matrixWorld).applyMatrix4(_inv);
    if (r.fil) r.dirW.copy(r.dirL).transformDirection(r.bA.matrixWorld).transformDirection(_inv);
    if (r.pinB) r.wB.copy(r.LB).applyMatrix4(r.bB.matrixWorld).applyMatrix4(_inv);
  }
  C.acc += dt; let steps = 0;
  while (C.acc >= 1 / 60 && steps < 4){ stepRopes(C, 1 / 60); C.acc -= 1 / 60; steps++; }
  if (C.acc > 1 / 60) C.acc = 0;
  for (const r of C.ropes){
    r.cable.update(r.p);
    if (r.plug){
      const e = r.p[r.n - 1];
      r.plug.position.copy(e);
      _ab.subVectors(e, r.p[r.n - 2]).normalize();
      r.plug.quaternion.setFromUnitVectors(new V3(0, -1, 0), _ab);
    }
  }
  for (const w of C.wraps){
    for (let j = 0; j < w.samples.length; j++) w.pts[j].copy(w.samples[j].local).applyMatrix4(w.samples[j].bone.matrixWorld).applyMatrix4(_inv);
    w.cable.update(w.pts);
  }
}

/* ---------------- Animation ---------------- */
function servo(o, t){
  const idx = Math.floor(t * o.rate + o.ph);
  const target = hash(idx * 1.37 + o.id) * 2 - 1;
  o.v += (target - o.v) * 0.12;
  return o.v;
}
const _eq = new THREE.Quaternion(), _ee = new THREE.Euler();
let RDT = 0.016, panelMode = null, circ = false;
function animateCreature(t, dt){
  const C = creature; if (!C) return;
  const ca = (C.ca += ((circ ? 1 : 0) - C.ca) * Math.min(1, RDT * 1.6)), sh = (C.shock *= Math.exp(-RDT * 2.2));
  C.waveT += dt * (1 + 1.6 * ca);
  const A = P.amp * (1 + 0.5 * ca + 1.2 * sh) * (1 - 0.55 * ca), tw = C.waveT;
  world.position.set(0, 0, 0); world.rotation.set(0, 0, 0);
  if (grid) grid.visible = false;
  C.fx = 2 * ca; C.fz = -4 * ca;
  const Rr = C.steps.reduce((x, y) => x + y, 0) / (TAU * 0.93);
  C.root.position.set(Rr * ca, Math.sin(t * 0.55) * 0.3 * P.radius * (0.3 + A), C.baseZ * (1 - ca) - C.total * 0.5 * ca + Rr * 0 );
  C.root.rotation.z = Math.sin(t * 0.37) * 0.04 * A + 0.25 * ca;
  C.root.rotation.y = 0.5 * ca * Math.sin(t * 0.8) * 0.2 + t * 0.9 * ca * 0;
  C.head.rotation.y = Math.sin(t * 0.45) * 0.28 * A;
  C.head.rotation.x = Math.sin(t * 0.31 + 1.3) * 0.14 * A - 0.04;
  C.body0.quaternion.copy(C.head.quaternion).invert();
  C.spine.forEach((b, k) => {
    const ph = k * P.wave;
    b.rotation.y = (P.curl * 0.2 * Math.sin(k * 0.5 + C.curlPh) + A * 0.15 * Math.sin(tw * 1.1 - ph)) * (1 - 0.7 * ca) + C.ringAng[k] * ca + 0.03 * ca * Math.sin(tw * 2.2 - ph);
    b.rotation.x = P.curl * 0.06 * Math.sin(k * 0.33 + C.curlPh * 1.7) + A * 0.09 * Math.sin(tw * 0.8 - ph * 1.3 + 1.7);
    b.rotation.z = A * 0.05 * Math.sin(tw * 0.6 - ph * 0.7);
  });
  for (const p of C.panels){
    const idle = P.splay * p.k + A * 0.08 * Math.sin(t * 1.6 + p.ph) + P.twitch * 0.22 * servo(p, t);
    const target = panelMode === 'open' ? 1.25 * (0.7 + 0.5 * p.k) : panelMode === 'closed' ? -0.04 : idle;
    p.cur = p.cur === undefined ? target : p.cur + (target - p.cur) * Math.min(1, RDT * (panelMode ? 16 : 10));
    p.hinge.rotation.x = p.cur + sh * 0.5 * Math.sin(t * 25 + p.ph);
  }
  for (const c of C.collars){
    const idx = Math.floor(t * c.rate + c.ph);
    const target = Math.floor(hash(idx + c.id * 7.1) * 5 - 2) * (TAU / c.sides);
    c.v += (target - c.v) * 0.1;
    c.g.rotation.z = c.v * P.twitch;
  }
  for (const s of C.spinners) s.mesh.rotation.y += dt * s.w;
  for (const e of C.eyes){
    const sv = servo(e, t), idx = Math.floor(t * e.rate * 0.7 + e.ph);
    e.v2 += ((hash(idx * 2.1 + e.id) * 2 - 1) - e.v2) * 0.08;
    e.g.quaternion.copy(e.q0).multiply(_eq.setFromEuler(_ee.set(sv * 0.22 * P.twitch + 0.03 * Math.sin(t + e.ph), e.v2 * 0.22 * P.twitch, 0)));
  }
  for (const ch of C.chains){
    const n = ch.bones.length;
    ch.bones.forEach((b, j) => {
      const w = 0.5 + j / n;
      if (ch.ant){
        b.rotation.x = A * 0.07 * Math.sin(t * 1.6 - j * 0.5 + ch.ph) * w;
        b.rotation.y = A * 0.08 * Math.sin(t * 1.2 - j * 0.45 + ch.ph2) * w;
      } else {
        b.rotation.x = 0.03 + A * 0.1 * Math.sin(t * 1.0 - j * 0.38 + ch.ph) * w;
        b.rotation.y = A * 0.09 * Math.sin(t * 0.75 - j * 0.3 + ch.ph2) * w;
      }
    });
  }
  C.root.updateMatrixWorld(true);
  updateCables(C, dt);
}

/* ---------------- UI ---------------- */
let needBuild = false, needNM = false;
const ui = {};
const pending = {};
const updBtn = document.getElementById('bUpd');
function markPending(k, v){
  pending[k] = v; updBtn.classList.add('dirty'); updBtn.textContent = 'Update (' + Object.keys(pending).length + ')';
}
function applyPending(){
  for (const k in pending){ setParam(k, pending[k]); delete pending[k]; }
  updBtn.classList.remove('dirty'); updBtn.textContent = 'Update';
}
function setParam(k, v){
  P[k] = v;
  if (REBUILD.has(k)) needBuild = true;
  if (NMKEYS.has(k)) needNM = true;
  updateMaterials();
}
function buildUI(){
  const root = document.getElementById('controls');
  for (const [title, items] of SCHEMA){
    const det = document.createElement('details'); det.open = true;
    const sum = document.createElement('summary'); sum.textContent = title; det.appendChild(sum);
    for (const it of items){
      const [key, label, a] = it;
      const row = document.createElement('div'); row.className = 'row';
      const lab = document.createElement('div'); lab.className = 'lab';
      const l = document.createElement('span'); l.textContent = label;
      const val = document.createElement('span'); val.className = 'val';
      lab.append(l, val); row.appendChild(lab);
      let inp, fmt = null;
      if (Array.isArray(a)){
        inp = document.createElement('select');
        for (const o of a){ const op = document.createElement('option'); op.value = o; op.textContent = o; inp.appendChild(op); }
        inp.value = P[key]; inp.onchange = () => markPending(key, inp.value);
      } else if (a === 'color'){
        inp = document.createElement('input'); inp.type = 'color'; inp.value = P[key];
        inp.oninput = () => markPending(key, inp.value);
      } else {
        const [, , min, max, step] = it;
        inp = document.createElement('input'); inp.type = 'range'; inp.min = min; inp.max = max; inp.step = step; inp.value = P[key];
        fmt = v => step >= 1 ? String(Math.round(v)) : Number(v).toFixed(step < 0.01 ? 3 : 2);
        val.textContent = fmt(P[key]);
        inp.oninput = () => { const v = parseFloat(inp.value); val.textContent = fmt(v); markPending(key, v); };
      }
      ui[key] = { inp, val, fmt };
      row.appendChild(inp); det.appendChild(row);
    }
    if (title === 'Oberfläche') det.insertBefore(nmCanvas, det.children[2]);
    root.appendChild(det);
  }
}
function syncUI(){ for (const k in pending) delete pending[k]; updBtn.classList.remove('dirty'); updBtn.textContent = 'Update'; for (const k in ui){ const u = ui[k]; u.inp.value = P[k]; if (u.fmt) u.val.textContent = u.fmt(P[k]); } }

function mutate(){
  const r = Math.random;
  P.seed = 1 + Math.floor(r() * 999);
  for (const [, items] of SCHEMA) for (const it of items){
    const k = it[0];
    if (k === 'seed' || typeof it[2] !== 'number') continue;
    if (!REBUILD.has(k) && k !== 'splay' && k !== 'curl') continue;
    const [, , min, max, step] = it;
    let v = min + (max - min) * (r() + r()) / 2;
    v = Math.round(v / step) * step;
    P[k] = +v.toFixed(4);
  }
  needBuild = needNM = true; updateMaterials(); syncUI();
}

document.getElementById('toggle').onclick = () => document.getElementById('panel').classList.toggle('hidden');
updBtn.onclick = applyPending;
document.getElementById('bSeed').onclick = () => { P.seed = 1 + Math.floor(Math.random() * 999); needBuild = needNM = true; syncUI(); };
document.getElementById('bMut').onclick = mutate;
document.getElementById('bView').onclick = fitView;
document.getElementById('bPng').onclick = () => {
  renderer.render(scene, camera);
  const a = document.createElement('a'); a.href = renderer.domElement.toDataURL('image/png'); a.download = `vermis_${P.seed}.png`; a.click();
};
document.getElementById('bGlb').onclick = () => {
  new GLTFExporter().parse(creature.root, res => {
    const url = URL.createObjectURL(new Blob([res], { type: 'model/gltf-binary' }));
    const a = document.createElement('a'); a.href = url; a.download = `vermis_${P.seed}.glb`; a.click();
    setTimeout(() => URL.revokeObjectURL(url), 2000);
  }, err => console.error(err), { binary: true });
};

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

/* ---------------- Kamera: weich, auf Körpermitte ausgerichtet ---------------- */
const focus = new V3(), _m = new V3(), _pf = new V3();
function midPos(out){ const s = creature.spine; return s[Math.floor(s.length / 2)].getWorldPosition(out); }
function resetFocus(){ creature.root.updateMatrixWorld(true); midPos(focus); controls.target.copy(focus); }
function followCam(rdt){
  midPos(_m); _pf.copy(focus); focus.lerp(_m, 1 - Math.exp(-rdt * (1.4 + 4 * creature.ca)));
  _m.subVectors(focus, _pf); camera.position.add(_m); controls.target.add(_m);
}

/* ---------------- Hell / Dunkel ---------------- */
let dark = false;
function setMode(d){
  dark = d; document.body.classList.toggle('dark', d);
  const c = d ? 0x06070a : BG; scene.background.set(c); scene.fog.color.set(c);
  hemi.intensity = d ? 0.06 : 0.5; key.intensity = d ? 0.45 : 2.2; rim.intensity = d ? 0.2 : 0.9;
  scene.environmentIntensity = d ? 0.15 : 1; floor.material.opacity = d ? 0 : 0.12;
  back.visible = sideL.visible = d; grid.material.color.set(d ? 0x3a3f48 : 0x8f8f8f);
  document.getElementById('mode').textContent = d ? 'Hell' : 'Dunkel';
}
const _cd = new V3(), _rt = new V3();
function updateBackLight(){
  if (!dark) return;
  _cd.subVectors(controls.target, camera.position).setY(0);
  if (_cd.lengthSq() < 1e-6) _cd.set(0, 0, 1);
  _cd.normalize(); _rt.crossVectors(_cd, _Y);
  back.position.copy(controls.target).addScaledVector(_cd, 40).y += 12; back.target.position.copy(controls.target);
  sideL.position.copy(controls.target).addScaledVector(_rt, -35).y += 10; sideL.target.position.copy(controls.target);
}
document.getElementById('mode').onclick = () => setMode(!dark);

/* ---------------- Aktions-Buttons ---------------- */
const aOpen = document.getElementById('aOpen'), aClose = document.getElementById('aClose'), aCircle = document.getElementById('aCircle');
function syncActions(){ aOpen.classList.toggle('on', panelMode === 'open'); aClose.classList.toggle('on', panelMode === 'closed'); aCircle.classList.toggle('on', circ); }
aOpen.onclick = () => { panelMode = panelMode === 'open' ? null : 'open'; syncActions(); };
aClose.onclick = () => { panelMode = panelMode === 'closed' ? null : 'closed'; syncActions(); };
aCircle.onclick = () => { circ = !circ; syncActions(); };
document.getElementById('aShock').onclick = () => { creature.shock = 1; };
addEventListener('keydown', e => { if (e.key === 'd' || e.key === 'D') setMode(!dark); });

/* ---------------- Start ---------------- */
buildUI();
generateNormalMap();
updateMaterials();
build();
fitView();
window.__ready = true; window.__cam = camera; window.__ctl = controls; window.__mode = setMode;

const clock = new THREE.Clock();
let time = 0;
function loop(){
  requestAnimationFrame(loop);
  const rdt = Math.min(clock.getDelta(), 0.05), dt = rdt * P.speed;
  RDT = rdt;
  time += dt;
  if (needNM){ generateNormalMap(); needNM = false; }
  if (needBuild){ build(); needBuild = false; }
  animateCreature(time, dt);
  followCam(rdt);
  updateBackLight();
  controls.update();
  renderer.render(scene, camera);
}
loop();
</script>
</body>
</html>
script.js
console.log('läuft');
style.css
body {
  font-family: system-ui, sans-serif;
  margin: 2rem;
}