Jellyfish Generator

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/093ce35d856b2e09/" 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>Medusoid – spekulativer Quallen-Generator</title>
<style>
  :root{--ink:#000;--mut:#8c8c8c;--line:#e8e8e8;}
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;background:#fff;color:var(--ink);
    font:12px/1.45 "Helvetica Neue",Helvetica,Arial,sans-serif;overflow:hidden}
  #app{display:flex;height:100%}
  #stage{flex:1;position:relative;min-width:0;background:#2d3033}
  #stage canvas{display:block;cursor:grab}
  #stage canvas:active{cursor:grabbing}
  #hint{position:absolute;left:16px;bottom:14px;color:#9aa0a6;pointer-events:none}
  #status{position:absolute;left:16px;top:14px;color:#c9cdd1;pointer-events:none}
  #panel{width:300px;border-left:1px solid var(--line);overflow-y:auto;background:#fff}
  #head{padding:14px 14px 12px;border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:2}
  .brand{font-size:15px;letter-spacing:.01em;margin-bottom:10px}
  .brand span{color:var(--mut);font-size:12px;margin-left:4px}
  .btns{display:flex;flex-wrap:wrap;gap:5px}
  button{font:inherit;background:#fff;color:var(--ink);border:1px solid #d9d9d9;border-radius:2px;
    padding:4px 9px;cursor:pointer}
  button:hover{border-color:#000}
  button:focus-visible,summary:focus-visible,input:focus-visible{outline:1px solid #000;outline-offset:2px}
  .seedrow{display:flex;align-items:center;gap:8px;margin-top:10px;color:var(--mut)}
  .seedrow input{width:84px;font:inherit;border:1px solid #d9d9d9;border-radius:2px;padding:3px 6px;color:var(--ink);background:#fff}
  details{border-bottom:1px solid var(--line)}
  summary{cursor:pointer;padding:10px 14px;list-style:none;font-size:13px}
  summary::-webkit-details-marker{display:none}
  summary::after{content:'+';float:right;color:var(--mut)}
  details[open] summary::after{content:'–'}
  details[open]{padding-bottom:8px}
  .row{display:grid;grid-template-columns:1fr auto;gap:1px 8px;padding:4px 14px;align-items:center}
  .row .val{color:var(--mut);font-variant-numeric:tabular-nums}
  .row.inline{padding-top:6px;padding-bottom:6px}
  .row input[type=range]{grid-column:1/3;width:100%;margin:0;height:16px;background:transparent;-webkit-appearance:none;appearance:none}
  .row input[type=range]::-webkit-slider-runnable-track{height:1px;background:#cfcfcf}
  .row input[type=range]::-moz-range-track{height:1px;background:#cfcfcf}
  .row input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:9px;height:9px;border-radius:50%;background:#000;margin-top:-4px;border:0}
  .row input[type=range]::-moz-range-thumb{width:9px;height:9px;border-radius:50%;background:#000;border:0}
  .row input[type=color]{width:34px;height:18px;border:1px solid #d9d9d9;border-radius:2px;padding:0;background:#fff}
  .row input[type=checkbox]{accent-color:#000;margin:0}
  @media (max-width:760px){
    #app{flex-direction:column}
    #panel{width:auto;height:46vh;border-left:0;border-top:1px solid var(--line)}
  }
</style>
</head>
<body>
<div id="app">
  <div id="stage">
    <div id="status"></div>
    <div id="hint">Ziehen dreht, Scrollen zoomt, Leertaste pausiert</div>
  </div>
  <aside id="panel">
    <div id="head">
      <div class="brand">Medusoid<span>v0.4</span></div>
      <div class="btns">
        <button id="bRand">Würfeln</button>
        <button id="bMut">Mutieren</button>
        <button id="bReset">Zurücksetzen</button>
        <button id="bPause">Pause</button>
        <button id="bCopy">Parameter kopieren</button>
        <button id="bLoad">Parameter laden</button>
      </div>
      <div class="seedrow"><label for="seed">Seed</label><input id="seed" type="number" min="1" max="99999" step="1"></div>
    </div>
    <div id="groups"></div>
  </aside>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
'use strict';

/* ------------------------------------------------------------------ */
/*  Hilfsfunktionen                                                    */
/* ------------------------------------------------------------------ */
const TAU = Math.PI * 2;
const clamp = (x,a,b) => x < a ? a : (x > b ? b : x);
const lerp  = (a,b,t) => a + (b - a) * t;
function sstep(a,b,x){ x = clamp((x-a)/(b-a),0,1); return x*x*(3-2*x); }
function sgnpow(x,p){ return (x < 0 ? -1 : 1) * Math.pow(Math.abs(x), p); }
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;
  };
}

/* Perlin-Noise 3D (Ken Perlin, improved) mit Seed */
function makeNoise(seed){
  const rnd = mulberry32(seed);
  const perm = new Uint8Array(256);
  for(let i=0;i<256;i++) perm[i] = i;
  for(let i=255;i>0;i--){ const j = Math.floor(rnd()*(i+1)); const t = perm[i]; perm[i] = perm[j]; perm[j] = t; }
  const p = new Uint8Array(512);
  for(let i=0;i<512;i++) p[i] = perm[i & 255];
  const fade = t => t*t*t*(t*(t*6-15)+10);
  const mix  = (a,b,t) => a + t*(b-a);
  function grad(h,x,y,z){
    h &= 15;
    const u = h < 8 ? x : y;
    const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
    return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
  }
  return function(x,y,z){
    let X = Math.floor(x), Y = Math.floor(y), Z = Math.floor(z);
    x -= X; y -= Y; z -= Z;
    X &= 255; Y &= 255; Z &= 255;
    const u = fade(x), v = fade(y), w = fade(z);
    const A = p[X]+Y, AA = p[A]+Z, AB = p[A+1]+Z;
    const B = p[X+1]+Y, BA = p[B]+Z, BB = p[B+1]+Z;
    return mix(
      mix(mix(grad(p[AA],x,y,z),     grad(p[BA],x-1,y,z),   u),
          mix(grad(p[AB],x,y-1,z),   grad(p[BB],x-1,y-1,z), u), v),
      mix(mix(grad(p[AA+1],x,y,z-1),   grad(p[BA+1],x-1,y,z-1),   u),
          mix(grad(p[AB+1],x,y-1,z-1), grad(p[BB+1],x-1,y-1,z-1), u), v), w);
  };
}

/* ------------------------------------------------------------------ */
/*  Parameter-Schema (daraus wird die Oberfläche erzeugt)              */
/*  rr = Bereich für "Würfeln", p0 = Chance auf 0, rb = Neuaufbau      */
/* ------------------------------------------------------------------ */
const NUM  = (k,l,min,max,v,o) => Object.assign({k:k,l:l,min:min,max:max,v:v,step:(o&&o.int)?1:(max-min)/400}, o||{});
const BOOL = (k,l,v) => ({k:k,l:l,v:v,type:'bool'});
const COL  = (k,l,v) => ({k:k,l:l,v:v,type:'color'});

const SCHEMA = [
 {t:'Schirm: Grundform', open:true, items:[
  NUM('bellRadius','Radius',0.3,2.2,0.6,{rr:[0.4,0.95]}),
  NUM('bellHeight','Höhe',0.2,3.5,2.0,{rr:[1.3,3.0]}),
  NUM('capsule','Kapselform (0 = Kuppel, 1 = Kapsel)',0,1,0.9,{rr:[0.5,1]}),
  NUM('capFrac','Kapsel: Höhe der Kuppel',0.15,0.95,0.3,{rr:[0.2,0.6]}),
  NUM('wallTaper','Kapsel: Verjüngung der Wand',-0.6,0.6,0.08,{rr:[-0.2,0.3]}),
  NUM('coverage','Umgriff (nur Kuppelform)',0.7,1.35,1.0,{rr:[0.85,1.2]}),
  NUM('domePower','Profil (zylindrisch bis flach)',0.3,3,1.0,{rr:[0.6,2.0]}),
  NUM('shoulder','Schulter (kantig bis konisch)',0.4,1.8,1.0,{rr:[0.6,1.4]}),
  NUM('flare','Randkrempe',-0.4,1,0.1,{rr:[-0.1,0.5]}),
  NUM('curl','Rand heben / senken',-0.8,0.8,0,{rr:[-0.3,0.4]}),
  NUM('lobeCount','Randlappen',0,16,8,{int:1,rr:[0,14]}),
  NUM('lobeDepth','Lappentiefe',0,1.2,0.2,{rr:[0,0.5]}),
  NUM('ribCount','Rippen',0,24,8,{int:1,rr:[3,16]}),
  NUM('ribDepth','Rippentiefe',0,0.5,0,{rr:[0.05,0.25],p0:0.5}),
  NUM('aniso','Elliptizität',-0.5,0.5,0,{rr:[-0.25,0.25]}),
  NUM('skew','Randneigung',-0.8,0.8,0,{rr:[-0.3,0.3]}),
  NUM('shellThick','Wandstärke',0.02,0.5,0.12,{rr:[0.06,0.22]}),
  NUM('bellRings','Auflösung Ringe',5,160,44,{int:1,rb:'bell'}),
  NUM('bellSegs','Auflösung Segmente',6,256,112,{int:1,rb:'bell'})
 ]},
 {t:'Schichten und Krausen', open:true, items:[
  NUM('layerCount','Zusatzschirme (Schichten)',0,4,1,{int:1,rb:'layers',rr:[0,3]}),
  NUM('layerScale','Schicht: Radius je Stufe',0.4,1.3,0.74,{rr:[0.55,0.92]}),
  NUM('layerHeight','Schicht: Höhe je Stufe',0.4,1.4,0.85,{rr:[0.6,1.1]}),
  NUM('layerDrop','Schicht: Absenkung je Stufe',-0.5,0.8,0.12,{rr:[-0.1,0.35]}),
  NUM('layerRot','Schicht: Verdrehung je Stufe',0,3.2,0.4,{rr:[0,3]}),
  NUM('layerLag','Schicht: Pulsverzögerung',0,0.4,0.12,{rr:[0.03,0.3]}),
  NUM('layerAlpha','Schicht: Deckkraft',0,0.9,0.32),
  NUM('frillCount','Krausen-Bänder',0,3,2,{int:1,rb:'frills',rr:[0,3]}),
  NUM('frillRes','Krausen: Auflösung',24,256,128,{int:1,rb:'frills'}),
  NUM('frillLen','Krausen: Länge',0.05,1.5,0.45,{rr:[0.15,0.9]}),
  NUM('frillFlare','Krausen: Ausstellung',0,1.5,0.6,{rr:[0.2,1.1]}),
  NUM('frillDroop','Krausen: Herabhängen',0,1.5,0.7,{rr:[0.3,1.2]}),
  NUM('frillAmp','Krausen: Faltenhöhe',0,1,0.35,{rr:[0.1,0.7]}),
  NUM('frillFreq','Krausen: Falten (grob)',2,60,16,{int:1,rr:[6,40]}),
  NUM('frillFreq2','Krausen: Falten (fein)',2,90,37,{int:1,rr:[20,70]}),
  NUM('frillTwist','Krausen: Verdrehung',0,4,1.2,{rr:[0,3]}),
  NUM('frillSpeed','Krausen: Wellentempo',0,2,0.3,{rr:[0.1,0.8]}),
  NUM('frillFlow','Krausen: Flowfield',0,2,0.8,{rr:[0.3,1.5]}),
  NUM('frillAlpha','Krausen: Deckkraft',0,0.9,0.4)
 ]},
 {t:'Mutation (spekulativ)', items:[
  NUM('lumpAmp','Wucherung: Stärke',0,0.6,0.05,{rr:[0.02,0.3],p0:0.25}),
  NUM('lumpFreq','Wucherung: Frequenz',0.3,4,1.4,{rr:[0.5,3]}),
  NUM('spikeAmp','Auswüchse: Stärke',0,0.8,0,{rr:[0.1,0.5],p0:0.6}),
  NUM('spikeFreq','Auswüchse: Frequenz',0.5,6,2.5,{rr:[1,5]}),
  NUM('spikeThr','Auswüchse: Schwelle',0,0.6,0.3,{rr:[0.1,0.5]})
 ]},
 {t:'Puls und Bewegung', open:true, items:[
  NUM('pulseRate','Pulsrate',0.1,2.5,0.5,{rr:[0.25,1.1]}),
  NUM('pulseAmp','Kontraktion',0,0.7,0.28,{rr:[0.15,0.5]}),
  NUM('pulseAsym','Asymmetrie (schnell zusammen, langsam auf)',0.12,0.7,0.3,{rr:[0.18,0.5]}),
  NUM('pulseLag','Wellenlauf Apex zu Rand',0,0.6,0.2,{rr:[0,0.4]}),
  NUM('pulseElastic','Nachschwingen',0,1,0.35,{rr:[0,0.8]}),
  NUM('pulseIrr','Unregelmäßigkeit',0,1,0.2,{rr:[0,0.6]}),
  NUM('flutterAmp','Randflattern: Stärke',0,1,0.35,{rr:[0.1,0.8]}),
  NUM('flutterCount','Randflattern: Wellen',0,24,7,{int:1,rr:[3,16]}),
  NUM('flutterSpeed','Randflattern: Tempo',0,2,0.35,{rr:[0.1,0.9]}),
  NUM('thrust','Schub',0,4,1.6,{rr:[0.8,2.6]}),
  NUM('bodyTilt','Neigung des Körpers',0,1,0.18,{rr:[0.05,0.5]}),
  NUM('drift','Treiben im Flowfield',0,2,0.5,{rr:[0.1,1.2]})
 ]},
 {t:'Schwimmen und Kamera', open:true, items:[
  NUM('swimYaw','Schwimmrichtung: Seite (Grad)',-180,180,0,{rr:[-180,180]}),
  NUM('swimElev','Schwimmrichtung: Steigung (Grad)',-80,80,30,{rr:[-30,60]}),
  NUM('steer','Richtungswechsel (Wandern)',0,1,0.3,{rr:[0.1,0.7]}),
  NUM('turnRate','Wendigkeit',0.1,4,1.2,{rr:[0.6,2.5]}),
  BOOL('camFollow','Kamera folgt der Qualle',true),
  NUM('camSmooth','Kamera: Trägheit (Sekunden)',0.1,4,1.3),
  NUM('snowAmount','Schwebeteilchen: Menge',0,1,0.5),
  NUM('snowSize','Schwebeteilchen: Größe',0.01,0.15,0.05)
 ]},
 {t:'Flowfield (Perlin 3D)', items:[
  NUM('flowStrength','Stärke',0,2,0.8,{rr:[0.4,1.4]}),
  NUM('flowScale','Skala',0.1,2,0.4,{rr:[0.2,0.9]}),
  NUM('flowSpeed','Zeitgeschwindigkeit',0,2,0.3,{rr:[0.1,0.7]}),
  NUM('flowOct','Oktaven',1,3,2,{int:1}),
  NUM('flowUp','Auftrieb im Feld',-0.5,0.5,0),
  NUM('flowSwirl','Wirbel um die Achse',-1,1,0,{rr:[-0.4,0.4]}),
  NUM('bellFlow','Einfluss auf den Schirm',0,1.5,0.6,{rr:[0.2,1.0]}),
  BOOL('showField','Feld anzeigen',false)
 ]},
 {t:'Randtentakel', open:true, items:[
  NUM('tentCount','Anzahl',0,120,26,{int:1,rb:'tent',rr:[8,60]}),
  NUM('tentSeg','Segmente',4,100,40,{int:1,rb:'tent'}),
  NUM('tentLen','Länge',0.2,6,2.6,{rr:[1.2,4.5]}),
  NUM('tentLenVar','Längenvarianz',0,1,0.35,{rr:[0,0.7]}),
  NUM('tentAlt','Wechsel lang / kurz',0,0.8,0,{rr:[0.2,0.6],p0:0.7}),
  NUM('tentThick','Dicke',0.004,0.08,0.02,{rr:[0.008,0.04]}),
  NUM('tentTaper','Verjüngung',0,1,0.75,{rr:[0.4,0.95]}),
  NUM('tentSides','Seiten (Querschnitt)',3,12,6,{int:1,rb:'tent'}),
  NUM('tentRingT','Ansatz am Schirm',0.75,1,0.97),
  NUM('tentSpread','Spreizung nach außen',0,1.5,0.5,{rr:[0,1.2]}),
  NUM('tentCluster','Bündelung an Lappen',0,1,0,{rr:[0.3,0.9],p0:0.5}),
  NUM('tentStiff','Steifigkeit',0,0.6,0.18,{rr:[0.08,0.3]}),
  NUM('tentFlow','Flowfield-Einfluss',0,3,0.6,{rr:[0.2,1.4]}),
  NUM('tentCoil','Spirale: Stärke',0,2,0,{rr:[0.2,1.0],p0:0.6}),
  NUM('tentCoilFreq','Spirale: Windungen',0.2,6,2.5,{rr:[1,5]}),
  NUM('tentCoilSpeed','Spirale: Tempo',0,6,1,{rr:[0.3,2]}),
  NUM('tentBeads','Perlen: Stärke',0,1,0,{rr:[0.2,0.9],p0:0.6}),
  NUM('beadFreq','Perlen: Anzahl',2,30,10,{rr:[4,20]})
 ]},
 {t:'Mundarme', items:[
  NUM('armCount','Anzahl',0,10,4,{int:1,rb:'arms',rr:[0,8]}),
  NUM('armSeg','Segmente',4,80,32,{int:1,rb:'arms'}),
  NUM('armLen','Länge',0.2,6,2.6,{rr:[1.5,4]}),
  NUM('armWidth','Breite',0.02,0.5,0.14,{rr:[0.05,0.3]}),
  NUM('armTaper','Verjüngung',0,1,0.6,{rr:[0.2,0.9]}),
  NUM('armAttach','Ansatz (Mitte bis außen)',0,0.7,0.12,{rr:[0,0.35]}),
  NUM('armRuffle','Kräuselung',0,1.5,0.5,{rr:[0,1.0]}),
  NUM('armRuffleFreq','Kräuselfrequenz',0,20,7,{rr:[3,14]}),
  NUM('armTwist','Verdrehung',0,3,0.6,{rr:[0,2]}),
  NUM('armStiff','Steifigkeit',0,0.6,0.16,{rr:[0.06,0.3]}),
  NUM('armFlow','Flowfield-Einfluss',0,3,0.5,{rr:[0.2,1.2]})
 ]},
 {t:'Innenleben', items:[
  NUM('organCount','Gonaden: Anzahl',0,8,4,{int:1,rb:'organs',rr:[0,8]}),
  NUM('organSize','Gonaden: Größe',0.04,0.5,0.17,{rr:[0.08,0.3]}),
  NUM('organT','Gonaden: Position',0.1,0.8,0.42,{rr:[0.25,0.6]}),
  NUM('canalCount','Radialkanäle',0,32,8,{int:1,rb:'canals',rr:[0,24]}),
  NUM('canalAlpha','Kanäle: Deckkraft',0,1,0.45)
 ]},
 {t:'Physik und Zeit', items:[
  NUM('timeScale','Zeitlupe bis Zeitraffer',0,2,1),
  NUM('inertia','Reaktionsträgheit (Sekunden)',0.05,3,0.7,{rr:[0.4,1.6]}),
  NUM('drag','Wasserwiderstand',0.01,0.4,0.1,{rr:[0.06,0.2]}),
  NUM('tentGravity','Schwerkraft / Auftrieb',-1,1,0.2,{rr:[-0.1,0.5]})
 ]},
 {t:'Licht und Material', open:true, items:[
  NUM('lightAz','Licht: Richtung (Grad)',-180,180,40,{}),
  NUM('lightEl','Licht: Höhe (Grad)',5,89,55,{}),
  BOOL('lightFollow','Licht folgt der Kamera',false),
  NUM('lightStrength','Licht: Stärke',0,1.5,1,{}),
  NUM('specStr','Glanz: Stärke',0,2,1,{}),
  NUM('gloss','Glanz: Schärfe',8,400,140,{}),
  NUM('envStr','Spiegelung der Umgebung',0,2,0.8,{}),
  BOOL('swap','Hell und Dunkel vertauschen',false),
  COL('cLight','Farbe Hell','#ffffff'),
  COL('cDark','Farbe Dunkel','#000000'),
  COL('cBg','Hintergrund','#2d3033')
 ]},
 {t:'Darstellung', items:[
  BOOL('flat','Facetten (Low-Poly-Look)',false),
  NUM('wire','Gitter',0,1,0),
  NUM('bellAlpha','Schirm: Deckkraft',0,0.8,0.18),
  NUM('innerAlpha','Innenwand: Deckkraft',0,0.9,0.45),
  NUM('rimAlpha','Randleuchten',0,1,0.75),
  NUM('fresnel','Randschärfe',0.5,5,2.6),
  NUM('tentAlpha','Tentakel: Deckkraft',0,1,0.92),
  NUM('tipFade','Ausblenden zur Spitze',0,1,0.35),
  NUM('autoRot','Kamera dreht sich',0,1,0.12)
 ]}
];

const P = {};
SCHEMA.forEach(g => g.items.forEach(it => { P[it.k] = it.v; }));
P.seed = 1234;
const DEFAULTS = JSON.parse(JSON.stringify(P));

/* ------------------------------------------------------------------ */
/*  Szene                                                              */
/* ------------------------------------------------------------------ */
const stage = document.getElementById('stage');
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x2d3033, 1);
stage.appendChild(renderer.domElement);

const scene  = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 200);
const group  = new THREE.Group();   // der Quallenkörper (Schirm, Schichten, Krausen, Innenleben)
scene.add(group);

let az = 1.25, pol = 1.4, dist = 9.5;
const target = new THREE.Vector3(0,-0.7,0), camGoal = new THREE.Vector3();
function updateCamera(){
  camera.position.set(
    target.x + dist*Math.sin(pol)*Math.sin(az),
    target.y + dist*Math.cos(pol),
    target.z + dist*Math.sin(pol)*Math.cos(az));
  camera.lookAt(target);
}
function resize(){
  const w = Math.max(1, stage.clientWidth), h = Math.max(1, stage.clientHeight);
  renderer.setSize(w,h);
  camera.aspect = w/h;
  camera.updateProjectionMatrix();
}
if(window.ResizeObserver) new ResizeObserver(resize).observe(stage);
else window.addEventListener('resize', resize);
resize();

(function(){
  const cv = renderer.domElement; let drag = null;
  cv.addEventListener('pointerdown', e => { drag = {x:e.clientX,y:e.clientY}; try{cv.setPointerCapture(e.pointerId);}catch(_){} });
  cv.addEventListener('pointermove', e => {
    if(!drag) return;
    az -= (e.clientX-drag.x)*0.006;
    pol = clamp(pol - (e.clientY-drag.y)*0.006, 0.12, Math.PI-0.12);
    drag.x = e.clientX; drag.y = e.clientY;
  });
  const end = () => { drag = null; };
  cv.addEventListener('pointerup', end); cv.addEventListener('pointercancel', end);
  cv.addEventListener('wheel', e => { e.preventDefault(); dist = clamp(dist*Math.exp(e.deltaY*0.001), 2.5, 24); }, {passive:false});
})();

/* ------------------------------------------------------------------ */
/*  Materialien                                                        */
/* ------------------------------------------------------------------ */
const VS = `
attribute float aT;
varying vec3 vN; varying vec3 vV; varying float vT;
void main(){
  vec4 mv = modelViewMatrix * vec4(position,1.0);
  vN = normalize(normalMatrix * normal);
  vV = mv.xyz;
  vT = aT;
  gl_Position = projectionMatrix * mv;
}`;
const FS = `
uniform vec3 uA; uniform vec3 uB; uniform vec3 uRim;
uniform float uAlpha; uniform float uRimAlpha; uniform float uRimMix;
uniform float uPow; uniform float uFlat; uniform float uTip;
uniform vec3 uL; uniform vec3 uL2; uniform vec3 uUp;
uniform float uSpec; uniform float uGloss; uniform float uEnv; uniform float uLit;
varying vec3 vN; varying vec3 vV; varying float vT;
void main(){
  vec3 vd = normalize(-vV);
  vec3 ns = normalize(vN);
  if(dot(ns,vd) < 0.0) ns = -ns;
  vec3 nf = normalize(cross(dFdx(vV), dFdy(vV)));
  if(dot(nf,vd) < 0.0) nf = -nf;
  vec3 n = normalize(mix(ns, nf, uFlat));
  float ndv = clamp(dot(n,vd), 0.0, 1.0);
  float f = pow(1.0 - ndv, uPow);
  float t = clamp(vT, 0.0, 1.0);
  vec3 base = mix(uA, uB, t);
  // Licht: gerichtetes Hauptlicht (diffus, weich umgriffen) plus schwaches Gegenlicht
  float dl = 0.5 + 0.5*dot(n, uL);
  float diff = 0.4 + 0.6*dl*uLit;
  vec3 col = base*diff;
  col = mix(col, uRim, f*uRimMix);
  // Glanz: Blinn-Phong fürs Hauptlicht, breiter für das Gegenlicht
  vec3 h  = normalize(uL + vd);
  vec3 h2 = normalize(uL2 + vd);
  float s1 = pow(max(dot(n,h), 0.0), uGloss);
  float s2 = 0.35*pow(max(dot(n,h2), 0.0), uGloss*0.5);
  float spec = (s1 + s2)*uSpec*uLit;
  // Spiegelung: weicher Himmel oben, heller Horizontstreifen, vor allem am Rand
  vec3 r = reflect(-vd, n);
  float up = dot(r, uUp);
  float env = (smoothstep(-0.1, 1.0, up)*0.6 + pow(max(1.0 - abs(up), 0.0), 6.0)*0.4)*f*uEnv*0.5;
  col += vec3(spec + env);
  float a = (uAlpha + f*uRimAlpha)*(1.0 - uTip*t);
  float hl = clamp(spec + env, 0.0, 1.0);
  a = a + hl*(1.0 - a);
  gl_FragColor = vec4(col, a);
}`;
/* gemeinsame Licht-Uniforms für alle Materialien */
const SH = {
  uL:{value:new THREE.Vector3(0,1,0)}, uL2:{value:new THREE.Vector3(0,0.3,-1)}, uUp:{value:new THREE.Vector3(0,1,0)},
  uSpec:{value:1}, uGloss:{value:140}, uEnv:{value:0.8}, uLit:{value:1}
};
function jelly(){
  return new THREE.ShaderMaterial({
    transparent:true, depthWrite:false, side:THREE.DoubleSide, extensions:{derivatives:true},
    uniforms:Object.assign({
      uA:{value:new THREE.Color()}, uB:{value:new THREE.Color()}, uRim:{value:new THREE.Color()},
      uAlpha:{value:0.2}, uRimAlpha:{value:0.5}, uRimMix:{value:1}, uPow:{value:2}, uFlat:{value:0}, uTip:{value:0}
    }, SH),
    vertexShader:VS, fragmentShader:FS
  });
}
const matOuter = jelly(), matInner = jelly(), matTent = jelly(), matArm = jelly(), matLayer = jelly(), matFrill = jelly(), matOrgan = jelly();
const matWire  = new THREE.MeshBasicMaterial({color:0xffffff, wireframe:true, transparent:true, opacity:0.18, depthWrite:false});
const matCanal = new THREE.LineBasicMaterial({color:0xffffff, transparent:true, opacity:0.45, depthWrite:false});
const matField = new THREE.LineBasicMaterial({color:0xd0d0d0, transparent:true, opacity:0.5, depthWrite:false});
const organGeo = new THREE.IcosahedronGeometry(1,2);

const _ld = new THREE.Vector3(), _ld2 = new THREE.Vector3();
function applyMaterials(){
  const Lc = P.swap ? P.cDark : P.cLight, Dc = P.swap ? P.cLight : P.cDark;
  const set = (m,a,b,rim,alpha,ra,rm,tip) => {
    const u = m.uniforms;
    u.uA.value.set(a); u.uB.value.set(b); u.uRim.value.set(rim);
    u.uAlpha.value = alpha; u.uRimAlpha.value = ra; u.uRimMix.value = rm; u.uPow.value = P.fresnel;
    u.uFlat.value = P.flat ? 1 : 0; u.uTip.value = tip;
  };
  //            innen  außen  Rand  Deckkraft        Randalpha        Randfarbe  Spitzenausblendung
  set(matOuter, Lc,    Lc,    Lc,   P.bellAlpha,     P.rimAlpha,      1.0,       0);
  set(matInner, Dc,    Dc,    Lc,   P.innerAlpha,    P.rimAlpha*0.3,  0.25,      0);
  set(matLayer, Dc,    Lc,    Lc,   P.layerAlpha,    P.rimAlpha*0.5,  0.5,       0);
  set(matFrill, Lc,    Dc,    Lc,   P.frillAlpha,    0.4,             0.6,       P.tipFade);
  set(matTent,  Dc,    Lc,    Lc,   P.tentAlpha,     0.1,             0.45,      P.tipFade);
  set(matArm,   Lc,    Lc,    Lc,   P.tentAlpha*0.6, 0.3,             0.7,       P.tipFade*0.6);
  set(matOrgan, Dc,    Dc,    Lc,   0.88,           0.1,             0.35,      0);
  matWire.color.set(Lc); matWire.opacity = P.wire; matWire.visible = P.wire > 0.005;
  matCanal.color.set(Lc); matCanal.opacity = P.canalAlpha;
  renderer.setClearColor(P.cBg, 1);
  stage.style.background = P.cBg;
  // Licht: Richtung in Weltkoordinaten (oder relativ zur Kamera), dann in den Kameraraum
  const az = P.lightAz*Math.PI/180, el = P.lightEl*Math.PI/180;
  _ld.set(Math.cos(el)*Math.sin(az), Math.sin(el), Math.cos(el)*Math.cos(az)).normalize();
  _ld2.set(-_ld.x, 0.25, -_ld.z).normalize();
  SH.uUp.value.set(0,1,0).transformDirection(camera.matrixWorldInverse);
  if(P.lightFollow){ SH.uL.value.copy(_ld); SH.uL2.value.copy(_ld2); }
  else { SH.uL.value.copy(_ld).transformDirection(camera.matrixWorldInverse); SH.uL2.value.copy(_ld2).transformDirection(camera.matrixWorldInverse); }
  SH.uSpec.value = P.specStr; SH.uGloss.value = P.gloss; SH.uEnv.value = P.envStr; SH.uLit.value = P.lightStrength;
}

/* ------------------------------------------------------------------ */
/*  Noise / Flowfield                                                  */
/* ------------------------------------------------------------------ */
let noiseF = makeNoise(1), noiseS = makeNoise(2);
function seedNoise(){ noiseF = makeNoise(P.seed*3+11); noiseS = makeNoise(P.seed); }
seedNoise();

let time = 0, phase = 0, prevC0 = 0;
const _F = {x:0,y:0,z:0}, _Fb = {x:0,y:0,z:0};

function flowVec(x,y,z,t,o,oct){
  const s = P.flowScale, tt = t*P.flowSpeed;
  const ox = tt*0.37, oy = tt*0.23, oz = tt*0.31;
  const no = oct || P.flowOct;
  let fx=0, fy=0, fz=0, amp=1, fr=s, norm=0;
  for(let k=0;k<no;k++){
    fx += amp*noiseF(x*fr+ox,       y*fr+oy,       z*fr+oz);
    fy += amp*noiseF(x*fr+ox+31.7,  y*fr+oy+17.3,  z*fr+oz+7.1);
    fz += amp*noiseF(x*fr+ox+11.9,  y*fr+oy+43.1,  z*fr+oz+23.7);
    norm += amp; amp *= 0.5; fr *= 2.03;
  }
  const g = 1.6*P.flowStrength/norm;
  fx *= g; fy *= g; fz *= g;
  fy += P.flowUp*P.flowStrength;
  if(P.flowSwirl !== 0){ fx += -z*P.flowSwirl*0.3*P.flowStrength; fz += x*P.flowSwirl*0.3*P.flowStrength; }
  o.x = fx; o.y = fy; o.z = fz;
}

/* ------------------------------------------------------------------ */
/*  Schirm                                                             */
/* ------------------------------------------------------------------ */
/* Kontraktion an Position t (0 Apex, 1 Rand); extra = zusätzliche Verzögerung (in Zyklen) */
function contractionAt(t, extra){
  let u = phase - P.pulseLag*t - (extra || 0);
  u -= Math.floor(u);
  const a = P.pulseAsym;
  if(u < a) return 0.5-0.5*Math.cos(Math.PI*u/a);
  const d = (u-a)/(1-a);
  let c = 0.5+0.5*Math.cos(Math.PI*d);
  if(P.pulseElastic > 0.001) c += P.pulseElastic*0.3*Math.sin(TAU*2*d)*Math.exp(-4*d)*(1-d);
  return c;
}

/* Alles, was nur von der Ringposition abhängt, einmal pro Ring */
function ringBase(t, c, rb){
  const R = P.bellRadius, H = P.bellHeight;
  const phi = t*Math.PI*0.5*P.coverage;
  const s = Math.sin(phi), co = Math.cos(phi);
  const w = sstep(0.6,1.0,t), w2 = w*w;
  rb.t = t; rb.c = c; rb.w = w; rb.w2 = w2;
  let r0 = R*Math.pow(Math.max(s,0), P.shoulder);
  let y0 = H*sgnpow(co, P.domePower);
  if(P.capsule > 0.001){
    // Kapsel: Halbkugel-Kuppel plus gerade (leicht konische) Wand, gleichmäßig nach Bogenlänge verteilt
    const hc = H*P.capFrac, wall = H-hc;
    const arc = 1.5708*Math.sqrt((R*R+hc*hc)*0.5);
    const tc = arc/(arc+wall+1e-6);
    let rc, yc;
    if(t < tc){ const ph = (t/tc)*1.5708; rc = R*Math.sin(ph); yc = wall+hc*Math.cos(ph); }
    else { const v = (t-tc)/Math.max(1-tc,1e-4); rc = R*(1-P.wallTaper*v); yc = wall*(1-v); }
    r0 = lerp(r0, rc, P.capsule); y0 = lerp(y0, yc, P.capsule);
  }
  rb.r0 = r0 + R*P.flare*w2;
  rb.y0 = y0 + H*P.curl*w2;
  rb.rib = sstep(0.05,0.5,t);
  rb.kp = 1 - P.pulseAmp*c*(0.25+0.75*t);
  rb.dy = -H*P.pulseAmp*0.35*c*t;
  rb.wob = P.bellFlow*0.22*(0.1+0.9*t);
}
/* Punkt auf dem Schirm für Ring rb und Winkel th */
function bellPoint(rb, th, o){
  const R = P.bellRadius, H = P.bellHeight;
  const w = rb.w, w2 = rb.w2;
  let r = rb.r0, y = rb.y0;
  if(P.lobeDepth > 0.001 && P.lobeCount > 0 && w > 0){
    const lb = 0.5+0.5*Math.cos(P.lobeCount*th);          // 1 an den Lappenspitzen
    const gap = Math.pow(1-lb, 1.2);                      // 1 in den Einkerbungen
    y += H*P.lobeDepth*gap*w;
    r *= 1 - 0.3*P.lobeDepth*gap*w2;
  }
  if(P.ribDepth > 0.001) r *= 1 + P.ribDepth*Math.cos(P.ribCount*th)*rb.rib;
  let x = r*Math.cos(th), z = r*Math.sin(th);
  x *= 1+P.aniso; z *= 1-P.aniso;
  y += P.skew*x*w;
  if(P.lumpAmp > 0.001){
    const f = P.lumpFreq;
    const n = noiseS(x*f+13.1, y*f+7.7, z*f+3.3);
    const dx = x, dy = y-H*0.35, dz = z, dl = Math.hypot(dx,dy,dz) || 1;
    const a = P.lumpAmp*R*n/dl;
    x += dx*a; y += dy*a; z += dz*a;
  }
  if(P.spikeAmp > 0.001){
    const f = P.spikeFreq;
    const n = noiseS(x*f+51.7, y*f+11.3, z*f+29.9);
    let sp = (n-P.spikeThr)/(0.75-P.spikeThr);
    if(sp > 0){
      sp = Math.min(sp,1);
      const dx = x, dy = y-H*0.35, dz = z, dl = Math.hypot(dx,dy,dz) || 1;
      const a = P.spikeAmp*R*sp*sp/dl;
      x += dx*a; y += dy*a; z += dz*a;
    }
  }
  // Pulsieren
  x *= rb.kp; z *= rb.kp; y += rb.dy;
  // Randflattern: laufende Wellen am Rand, stärker während der Kontraktion
  if(P.flutterAmp > 0.001 && w2 > 0.0001){
    const fa = P.flutterAmp*w2*(0.4+0.6*rb.c);
    const ph = P.flutterCount*th - time*P.flutterSpeed*TAU;
    y += H*0.22*fa*Math.sin(ph);
    const rr = 1 + 0.06*fa*Math.sin(ph+1.4);
    x *= rr; z *= rr;
  }
  // Flowfield-Wabern (eine Oktave, langsam)
  if(rb.wob > 0.0005){
    flowVec(x*0.8, y*0.8, z*0.8, time, _Fb, 1);
    x += _Fb.x*rb.wob; y += _Fb.y*rb.wob; z += _Fb.z*rb.wob;
  }
  o.x = x; o.y = y; o.z = z;
}
const RB1 = {};
function bellVertex(t, th, c, o){ ringBase(t, c, RB1); bellPoint(RB1, th, o); }

const B = {outerM:null,innerM:null,wireM:null,canalM:null,organs:[],layers:[],frills:[],R:0,S:0,out:null,inn:null,canalCols:[]};
function disposeObj(o){ if(!o) return; if(o.parent) o.parent.remove(o); if(o.geometry && o.geometry !== organGeo) o.geometry.dispose(); }

function gridIndex(R,S){
  const idx = [];
  for(let i=0;i<R;i++) for(let j=0;j<S;j++){
    const a = i*S+j, b = i*S+(j+1)%S, c = (i+1)*S+j, d = (i+1)*S+(j+1)%S;
    idx.push(a,c,b, b,c,d);
  }
  return idx;
}
function gridGeo(R,S,idx){
  const n = (R+1)*S;
  const g = new THREE.BufferGeometry();
  const pos = new THREE.BufferAttribute(new Float32Array(n*3),3); pos.setUsage(THREE.DynamicDrawUsage);
  const nor = new THREE.BufferAttribute(new Float32Array(n*3),3); nor.setUsage(THREE.DynamicDrawUsage);
  g.setAttribute('position',pos); g.setAttribute('normal',nor);
  const t = new Float32Array(n);
  for(let i=0;i<=R;i++) for(let j=0;j<S;j++) t[i*S+j] = i/R;
  g.setAttribute('aT', new THREE.BufferAttribute(t,1));
  g.setIndex(idx);
  return g;
}

function buildBell(){
  disposeObj(B.outerM); disposeObj(B.innerM); disposeObj(B.wireM);
  const R = P.bellRings, S = P.bellSegs, idx = gridIndex(R,S);
  const go = gridGeo(R,S,idx), gi = gridGeo(R,S,idx);
  B.R = R; B.S = S; B.out = go.attributes.position.array; B.inn = gi.attributes.position.array;
  B.outerM = new THREE.Mesh(go, matOuter); B.innerM = new THREE.Mesh(gi, matInner);
  B.wireM  = new THREE.Mesh(go, matWire);
  B.innerM.renderOrder = 1; B.outerM.renderOrder = 3; B.wireM.renderOrder = 4;
  [B.outerM,B.innerM,B.wireM].forEach(m => { m.frustumCulled = false; group.add(m); });
}

function fixApex(g,S){
  const nrm = g.attributes.normal.array;
  let x=0,y=0,z=0;
  for(let j=0;j<S;j++){ x+=nrm[j*3]; y+=nrm[j*3+1]; z+=nrm[j*3+2]; }
  const l = Math.hypot(x,y,z) || 1; x/=l; y/=l; z/=l;
  for(let j=0;j<S;j++){ nrm[j*3]=x; nrm[j*3+1]=y; nrm[j*3+2]=z; }
  g.attributes.normal.needsUpdate = true;
}

const _o = {x:0,y:0,z:0};
const RBm = {};
function updateBell(){
  if(!B.outerM) return;
  const R = B.R, S = B.S, out = B.out, inn = B.inn;
  const py = P.bellHeight*0.5, k = 1-P.shellThick;
  for(let i=0;i<=R;i++){
    const t = i/R;
    ringBase(t, contractionAt(t), RBm);
    for(let j=0;j<S;j++){
      bellPoint(RBm, j/S*TAU, _o);
      const q = (i*S+j)*3;
      out[q] = _o.x; out[q+1] = _o.y; out[q+2] = _o.z;
      inn[q] = _o.x*k; inn[q+1] = py+(_o.y-py)*k; inn[q+2] = _o.z*k;
    }
  }
  const go = B.outerM.geometry, gi = B.innerM.geometry;
  go.attributes.position.needsUpdate = true; gi.attributes.position.needsUpdate = true;
  go.computeVertexNormals(); gi.computeVertexNormals();
  fixApex(go,S); fixApex(gi,S);
}

/* Zusatzschirme (Schichten) */
function buildLayers(){
  B.layers.forEach(L => disposeObj(L.mesh)); B.layers = [];
  const R = P.bellRings, S = P.bellSegs, idx = gridIndex(R,S);
  for(let i=0;i<P.layerCount;i++){
    const g = gridGeo(R,S,idx);
    const m = new THREE.Mesh(g, matLayer);
    m.frustumCulled = false; m.renderOrder = 2.5;
    group.add(m);
    B.layers.push({mesh:m, geo:g, arr:g.attributes.position.array});
  }
}
function updateLayers(){
  const R = P.bellRings, S = P.bellSegs;
  for(let li=0; li<B.layers.length; li++){
    const L = B.layers[li], n = li+1;
    const rs = Math.pow(P.layerScale,n), hs = Math.pow(P.layerHeight,n);
    const dy = -n*P.layerDrop, rot = n*P.layerRot, lag = n*P.layerLag;
    const arr = L.arr;
    for(let i=0;i<=R;i++){
      const t = i/R;
      ringBase(t, contractionAt(t,lag), RBm);
      for(let j=0;j<S;j++){
        bellPoint(RBm, j/S*TAU+rot, _o);
        const q = (i*S+j)*3;
        arr[q] = _o.x*rs; arr[q+1] = _o.y*hs+dy; arr[q+2] = _o.z*rs;
      }
    }
    L.geo.attributes.position.needsUpdate = true;
    L.geo.computeVertexNormals(); fixApex(L.geo,S);
  }
}

/* Krausen: gefaltete Bänder, die vom Schirm herabhängen */
const BAND_T = [1.0, 0.72, 0.45];
const FROW = 16;
function buildFrills(){
  B.frills.forEach(F => disposeObj(F.mesh)); B.frills = [];
  const cols = P.frillRes, rows = FROW;
  for(let b=0;b<P.frillCount;b++){
    const n = (rows+1)*cols;
    const idx = [];
    for(let i=0;i<rows;i++) for(let j=0;j<cols;j++){
      const a=i*cols+j, bb=i*cols+(j+1)%cols, c=(i+1)*cols+j, d=(i+1)*cols+(j+1)%cols;
      idx.push(a,c,bb, bb,c,d);
    }
    const at = new Float32Array(n);
    for(let i=0;i<=rows;i++) for(let j=0;j<cols;j++) at[i*cols+j] = i/rows;
    const g = makeDynGeo(new Float32Array(n*3), new Float32Array(n*3), at, idx);
    const m = new THREE.Mesh(g, matFrill);
    m.frustumCulled = false; m.renderOrder = 2.2;
    group.add(m);
    B.frills.push({mesh:m, geo:g, arr:g.attributes.position.array, cols:cols});
  }
}
const RBf = {}, _rowC = new Float32Array(FROW+1);
function updateFrills(){
  const rows = FROW;
  const f1 = Math.round(P.frillFreq), f2 = Math.round(P.frillFreq2);
  for(let b=0; b<B.frills.length; b++){
    const F = B.frills[b], cols = F.cols, arr = F.arr;
    const tb = BAND_T[b];
    ringBase(tb, contractionAt(tb), RBf);
    const len = P.frillLen*(1-0.18*b);
    for(let i=0;i<=rows;i++) _rowC[i] = contractionAt(tb, 0.25*(i/rows));
    for(let j=0;j<cols;j++){
      const th = j/cols*TAU, ct = Math.cos(th), st = Math.sin(th);
      bellPoint(RBf, th, _o);
      const x0 = _o.x, y0 = _o.y, z0 = _o.z;
      let fx=0, fy=0, fz=0;
      if(P.frillFlow > 0.001){ flowVec(x0*0.8,y0*0.8,z0*0.8,time,_Fb,1); fx=_Fb.x; fy=_Fb.y; fz=_Fb.z; }
      for(let i=0;i<=rows;i++){
        const s = i/rows;
        const out = len*s*P.frillFlare*(1-0.5*P.pulseAmp*_rowC[i]);
        const down = len*s*P.frillDroop;
        const amp = P.frillAmp*len*Math.pow(s,1.2);
        const ph1 = th*f1 + s*P.frillTwist*3 - time*P.frillSpeed*TAU*0.5 + b*1.7;
        const ph2 = th*f2 - s*P.frillTwist*2 + time*P.frillSpeed*TAU*0.37;
        const rad = amp*(Math.sin(ph1) + 0.4*Math.sin(ph2));
        const tan = amp*0.3*Math.sin(ph2+0.6);
        const fl = P.frillFlow*0.6*len*s*s;
        const q = (i*cols+j)*3;
        arr[q]   = x0 + ct*(out+rad) - st*tan + fx*fl;
        arr[q+1] = y0 - down + amp*0.45*Math.cos(ph1+1.2) + fy*fl;
        arr[q+2] = z0 + st*(out+rad) + ct*tan + fz*fl;
      }
    }
    F.geo.attributes.position.needsUpdate = true;
    F.geo.computeVertexNormals();
  }
}

/* Innenleben: Gonaden und Radialkanäle */
function buildOrgans(){
  B.organs.forEach(m => { if(m.parent) m.parent.remove(m); });
  B.organs = [];
  for(let k=0;k<P.organCount;k++){
    const m = new THREE.Mesh(organGeo, matOrgan);
    m.renderOrder = 2; m.frustumCulled = false;
    group.add(m); B.organs.push(m);
  }
}
function buildCanals(){
  disposeObj(B.canalM); B.canalM = null; B.canalCols = [];
  const cc = P.canalCount, R = P.bellRings, S = P.bellSegs;
  if(cc < 1) return;
  for(let c=0;c<cc;c++) B.canalCols.push(Math.round(c*S/cc) % S);
  const segs = cc*R + S;
  const g = new THREE.BufferGeometry();
  const a = new THREE.BufferAttribute(new Float32Array(segs*6),3); a.setUsage(THREE.DynamicDrawUsage);
  g.setAttribute('position',a);
  B.canalM = new THREE.LineSegments(g, matCanal);
  B.canalM.frustumCulled = false; B.canalM.renderOrder = 2;
  group.add(B.canalM);
}
function updateInner(){
  if(!B.out) return;
  const R = B.R, S = B.S, o = B.out, n = B.inn;
  for(let k=0;k<B.organs.length;k++){
    const m = B.organs[k];
    const j = Math.round(k*S/B.organs.length) % S;
    const i = clamp(Math.round(P.organT*R),1,R);
    const q = (i*S+j)*3;
    m.position.set(0.35*o[q]+0.65*n[q], 0.35*o[q+1]+0.65*n[q+1], 0.35*o[q+2]+0.65*n[q+2]);
    m.rotation.set(0, -(j/S*TAU), 0);
    m.scale.set(P.organSize*0.8, P.organSize*0.5, P.organSize*1.3);
  }
  if(B.canalM){
    const arr = B.canalM.geometry.attributes.position.array;
    let w = 0;
    const mid = (i,j) => { const q = (i*S+j)*3; return [0.5*(o[q]+n[q]), 0.5*(o[q+1]+n[q+1]), 0.5*(o[q+2]+n[q+2])]; };
    for(let c=0;c<B.canalCols.length;c++){
      const j = B.canalCols[c];
      for(let i=0;i<R;i++){
        const a = mid(i,j), b = mid(i+1,j);
        arr[w++]=a[0];arr[w++]=a[1];arr[w++]=a[2];arr[w++]=b[0];arr[w++]=b[1];arr[w++]=b[2];
      }
    }
    for(let j=0;j<S;j++){
      const a = mid(R,j), b = mid(R,(j+1)%S);
      arr[w++]=a[0];arr[w++]=a[1];arr[w++]=a[2];arr[w++]=b[0];arr[w++]=b[1];arr[w++]=b[2];
    }
    B.canalM.geometry.attributes.position.needsUpdate = true;
  }
}

/* ------------------------------------------------------------------ */
/*  Ketten-Physik (Tentakel und Mundarme) – träge und weich           */
/* ------------------------------------------------------------------ */
const FS_FLOW = 3.5, FS_GRAV = 14, FS_COIL = 9;
/*  Feste Teilschritte (ca. 1/90 s), Anker wird zwischen den Frames interpoliert,
    Flow-Kräfte werden nur einmal pro Frame gesampelt und zeitlich geglättet.     */
function stepChain(it, n, len, ax, ay, az_, dx, dy, dz, dt, o){
  const sub = clamp(Math.round(dt/(1/90)), 1, 4), hh = dt/sub;
  const damp = Math.pow(1-o.drag, hh*60), h2 = hh*hh;
  const stEff = 1 - Math.pow(1-o.stiff, hh*60);
  const pos = it.pos, prv = it.prv, ff = it.ff, a0 = it.a0;
  const kf = 1 - Math.exp(-dt/Math.max(0.02, o.lag));
  for(let i=1;i<=n;i++){
    const ix = i*3;
    flowVec(pos[ix],pos[ix+1],pos[ix+2],time,_F,1);
    ff[ix]   += (_F.x*o.flow*FS_FLOW - ff[ix])*kf;
    ff[ix+1] += (_F.y*o.flow*FS_FLOW - ff[ix+1])*kf;
    ff[ix+2] += (_F.z*o.flow*FS_FLOW - ff[ix+2])*kf;
  }
  const gy = -o.grav*FS_GRAV;
  for(let s=1;s<=sub;s++){
    const f = s/sub;
    pos[0] = lerp(a0[0],ax,f); pos[1] = lerp(a0[1],ay,f); pos[2] = lerp(a0[2],az_,f);
    prv[0] = pos[0]; prv[1] = pos[1]; prv[2] = pos[2];
    let tx=dx, ty=dy, tz=dz;
    for(let i=1;i<=n;i++){
      const ix = i*3, jx = ix-3;
      let px=pos[ix], py=pos[ix+1], pz=pos[ix+2];
      const vx=(px-prv[ix])*damp, vy=(py-prv[ix+1])*damp, vz=(pz-prv[ix+2])*damp;
      prv[ix]=px; prv[ix+1]=py; prv[ix+2]=pz;
      let fx=ff[ix], fy=ff[ix+1]+gy, fz=ff[ix+2];
      if(i>=2){
        tx=pos[jx]-pos[jx-3]; ty=pos[jx+1]-pos[jx-2]; tz=pos[jx+2]-pos[jx-1];
        const l = Math.hypot(tx,ty,tz) || 1; tx/=l; ty/=l; tz/=l;
      }
      if(o.coil > 0.001){
        let rx=0, ry=0, rz=1; if(Math.abs(tz) > 0.9){ rx=1; rz=0; }
        let n1x=ty*rz-tz*ry, n1y=tz*rx-tx*rz, n1z=tx*ry-ty*rx;
        const nl = Math.hypot(n1x,n1y,n1z) || 1; n1x/=nl; n1y/=nl; n1z/=nl;
        const n2x=ty*n1z-tz*n1y, n2y=tz*n1x-tx*n1z, n2z=tx*n1y-ty*n1x;
        const a = (i/n)*o.coilFreq*TAU - time*o.coilSpeed + o.phase;
        const ca = Math.cos(a)*o.coil*FS_COIL, sa = Math.sin(a)*o.coil*FS_COIL;
        fx += n1x*ca+n2x*sa; fy += n1y*ca+n2y*sa; fz += n1z*ca+n2z*sa;
      }
      px += vx+fx*h2; py += vy+fy*h2; pz += vz+fz*h2;
      const q1x=px, q1y=py, q1z=pz;   // Position vor den Bedingungen
      // weiche Steifigkeit: Richtung der Verlängerung des vorherigen Segments
      const st = stEff*(1-0.6*(i/n));
      px += (pos[jx]+tx*len-px)*st; py += (pos[jx+1]+ty*len-py)*st; pz += (pos[jx+2]+tz*len-pz)*st;
      // Seil-Bedingung (nur Dehnung wird verhindert)
      const ex=px-pos[jx], ey=py-pos[jx+1], ez=pz-pos[jx+2], d=Math.hypot(ex,ey,ez);
      if(d > len){ const k=len/d; px=pos[jx]+ex*k; py=pos[jx+1]+ey*k; pz=pos[jx+2]+ez*k; }
      // Bedingungen wirken nur auf die Position, nicht auf die Geschwindigkeit:
      // dadurch entsteht kein Peitscheneffekt, die Kette zieht träge nach
      prv[ix]+=px-q1x; prv[ix+1]+=py-q1y; prv[ix+2]+=pz-q1z;
      pos[ix]=px; pos[ix+1]=py; pos[ix+2]=pz;
    }
  }
  a0[0]=ax; a0[1]=ay; a0[2]=az_;
}

/* Koordinatensysteme entlang einer Kette (Parallel Transport) */
const FR = new Float32Array(128*9);
function frames(p,n){
  let nx=0, ny=0, nz=0;
  for(let i=0;i<=n;i++){
    const a = Math.max(0,i-1)*3, b = Math.min(n,i+1)*3;
    let tx=p[b]-p[a], ty=p[b+1]-p[a+1], tz=p[b+2]-p[a+2];
    let tl = Math.hypot(tx,ty,tz);
    if(tl < 1e-6){ tx=0; ty=-1; tz=0; tl=1; }
    tx/=tl; ty/=tl; tz/=tl;
    if(i === 0){
      let rx=0, ry=0, rz=1; if(Math.abs(tz) > 0.9){ rx=1; rz=0; }
      nx=ty*rz-tz*ry; ny=tz*rx-tx*rz; nz=tx*ry-ty*rx;
    } else {
      const d = nx*tx+ny*ty+nz*tz; nx-=tx*d; ny-=ty*d; nz-=tz*d;
    }
    let nl = Math.hypot(nx,ny,nz);
    if(nl < 1e-5){
      let rx=1, ry=0, rz=0; if(Math.abs(tx) > 0.9){ rx=0; ry=1; }
      nx=ty*rz-tz*ry; ny=tz*rx-tx*rz; nz=tx*ry-ty*rx; nl = Math.hypot(nx,ny,nz) || 1;
    }
    nx/=nl; ny/=nl; nz/=nl;
    const o = i*9;
    FR[o]=tx; FR[o+1]=ty; FR[o+2]=tz;
    FR[o+3]=nx; FR[o+4]=ny; FR[o+5]=nz;
    FR[o+6]=ty*nz-tz*ny; FR[o+7]=tz*nx-tx*nz; FR[o+8]=tx*ny-ty*nx;
  }
}

function makeDynGeo(posArr, norArr, atArr, idx){
  const g = new THREE.BufferGeometry();
  const pa = new THREE.BufferAttribute(posArr,3); pa.setUsage(THREE.DynamicDrawUsage);
  const na = new THREE.BufferAttribute(norArr,3); na.setUsage(THREE.DynamicDrawUsage);
  g.setAttribute('position',pa); g.setAttribute('normal',na);
  g.setAttribute('aT', new THREE.BufferAttribute(atArr,1));
  g.setIndex(idx);
  return g;
}
function newChainItem(n, extra){
  return Object.assign({pos:new Float32Array((n+1)*3), prv:new Float32Array((n+1)*3),
                        ff:new Float32Array((n+1)*3), a0:new Float32Array(3), init:true}, extra);
}
function initChain(it, n, len, v, d){
  for(let i=0;i<=n;i++){
    const q=i*3;
    it.pos[q]=v.x+d.x*len*i; it.pos[q+1]=v.y+d.y*len*i; it.pos[q+2]=v.z+d.z*len*i;
    it.prv[q]=it.pos[q]; it.prv[q+1]=it.pos[q+1]; it.prv[q+2]=it.pos[q+2];
  }
  it.ff.fill(0);
  it.a0[0]=v.x; it.a0[1]=v.y; it.a0[2]=v.z;
  it.init = false;
}

/* ---- Tentakel ---- */
const T = {mesh:null, items:[], posA:null, norA:null, opt:{flow:1,grav:0.2,drag:0.1,stiff:0.18,lag:0.7,coil:0,coilFreq:2,coilSpeed:1,phase:0}};
function buildTents(){
  disposeObj(T.mesh); T.mesh = null; T.items = [];
  const Nn = P.tentCount; if(Nn < 1) return;
  const n = P.tentSeg, sd = P.tentSides, rng = mulberry32(P.seed*13+5);
  const vp = (n+1)*sd, total = Nn*vp;
  const pos = new Float32Array(total*3), nor = new Float32Array(total*3), at = new Float32Array(total);
  const idx = [];
  for(let k=0;k<Nn;k++){
    const b0 = k*vp;
    for(let i=0;i<=n;i++) for(let m=0;m<sd;m++) at[b0+i*sd+m] = i/n;
    for(let i=0;i<n;i++) for(let m=0;m<sd;m++){
      const a=b0+i*sd+m, b=b0+i*sd+(m+1)%sd, c=b0+(i+1)*sd+m, d=b0+(i+1)*sd+(m+1)%sd;
      idx.push(a,c,b, b,c,d);
    }
    const r2 = rng()*2-1;
    T.items.push(newChainItem(n, {th0:(k+0.5)/Nn*TAU + r2*0.15*(TAU/Nn), r1:rng()*2-1, r2:r2, ph:rng()*TAU}));
  }
  T.posA = pos; T.norA = nor;
  T.mesh = new THREE.Mesh(makeDynGeo(pos,nor,at,idx), matTent);
  T.mesh.frustumCulled = false; T.mesh.renderOrder = 0;
  scene.add(T.mesh);
}

const _v = new THREE.Vector3(), _d = new THREE.Vector3(), _up = new THREE.Vector3();
function updateTents(dt, stepping){
  if(!T.mesh) return;
  const Nn = T.items.length, n = P.tentSeg, sd = P.tentSides, o = T.opt;
  o.flow = P.tentFlow; o.grav = P.tentGravity; o.drag = P.drag; o.stiff = P.tentStiff; o.lag = P.inertia;
  o.coil = P.tentCoil; o.coilFreq = P.tentCoilFreq; o.coilSpeed = P.tentCoilSpeed;
  const cRing = contractionAt(P.tentRingT);
  const pos = T.posA, nor = T.norA;
  for(let k=0;k<Nn;k++){
    const it = T.items[k];
    let th = it.th0;
    if(P.tentCluster > 0.001 && P.lobeCount > 0){
      const st = TAU/P.lobeCount, c = Math.round(th/st)*st;
      th = lerp(th,c,P.tentCluster) + it.r2*st*0.06;
    }
    bellVertex(P.tentRingT, th, cRing, _o);
    _v.set(_o.x,_o.y,_o.z).applyMatrix4(group.matrixWorld);
    _d.set(Math.cos(th)*P.tentSpread, -1, Math.sin(th)*P.tentSpread).transformDirection(group.matrixWorld);
    const lenMul = Math.max(0.08, (1+P.tentLenVar*it.r1)*(1-P.tentAlt*(k&1)));
    const len = P.tentLen*lenMul/n;
    if(it.init) initChain(it, n, len, _v, _d);
    if(stepping){ o.phase = it.ph; stepChain(it,n,len,_v.x,_v.y,_v.z,_d.x,_d.y,_d.z,dt,o); }
    else { it.pos[0]=_v.x; it.pos[1]=_v.y; it.pos[2]=_v.z; }
    // Röhre aufbauen
    frames(it.pos,n);
    const base = k*(n+1)*sd, p = it.pos;
    for(let i=0;i<=n;i++){
      const f = i*9, s = i/n;
      let r = P.tentThick*(1-P.tentTaper*s*0.92);
      if(P.tentBeads > 0.001){ const bb = 0.5+0.5*Math.sin(s*P.beadFreq*TAU+it.ph); r *= 1+P.tentBeads*2*bb*bb*bb*bb; }
      for(let m=0;m<sd;m++){
        const ang = m/sd*TAU, ca = Math.cos(ang), sa = Math.sin(ang);
        const ex = FR[f+3]*ca+FR[f+6]*sa, ey = FR[f+4]*ca+FR[f+7]*sa, ez = FR[f+5]*ca+FR[f+8]*sa;
        const vi = (base+i*sd+m)*3;
        pos[vi]=p[i*3]+ex*r; pos[vi+1]=p[i*3+1]+ey*r; pos[vi+2]=p[i*3+2]+ez*r;
        nor[vi]=ex; nor[vi+1]=ey; nor[vi+2]=ez;
      }
    }
  }
  T.mesh.geometry.attributes.position.needsUpdate = true;
  T.mesh.geometry.attributes.normal.needsUpdate = true;
}

/* ---- Mundarme (gekräuselte Bänder, 9 Stützpunkte quer) ---- */
const ACOLS = 9;
const A = {mesh:null, items:[], posA:null, norA:null, opt:{flow:1,grav:0.2,drag:0.1,stiff:0.16,lag:0.7,coil:0,coilFreq:1,coilSpeed:1,phase:0}};
function buildArms(){
  disposeObj(A.mesh); A.mesh = null; A.items = [];
  const Na = P.armCount; if(Na < 1) return;
  const n = P.armSeg, rng = mulberry32(P.seed*29+3);
  const vp = (n+1)*ACOLS, total = Na*vp;
  const pos = new Float32Array(total*3), nor = new Float32Array(total*3), at = new Float32Array(total);
  const idx = [];
  for(let k=0;k<Na;k++){
    const b0 = k*vp;
    for(let i=0;i<=n;i++) for(let c=0;c<ACOLS;c++) at[b0+i*ACOLS+c] = i/n;
    for(let i=0;i<n;i++) for(let c=0;c<ACOLS-1;c++){
      const a=b0+i*ACOLS+c, b=a+1, c2=b0+(i+1)*ACOLS+c, d=c2+1;
      idx.push(a,c2,b, b,c2,d);
    }
    A.items.push(newChainItem(n, {th0:k/Na*TAU + 0.3, r1:rng()*2-1, ph:rng()*TAU}));
  }
  A.posA = pos; A.norA = nor;
  A.mesh = new THREE.Mesh(makeDynGeo(pos,nor,at,idx), matArm);
  A.mesh.frustumCulled = false; A.mesh.renderOrder = 0.5;
  scene.add(A.mesh);
}
function updateArms(dt, stepping){
  if(!A.mesh) return;
  const Na = A.items.length, n = P.armSeg, o = A.opt;
  o.flow = P.armFlow; o.grav = P.tentGravity; o.drag = P.drag; o.stiff = P.armStiff; o.lag = P.inertia;
  const py = P.bellHeight*0.5, kk = 1-P.shellThick;
  const tA = Math.max(0.02, P.armAttach), cA = contractionAt(tA);
  const pos = A.posA, nor = A.norA;
  for(let k=0;k<Na;k++){
    const it = A.items[k], th = it.th0;
    bellVertex(tA, th, cA, _o);
    _v.set(_o.x*kk, py+(_o.y-py)*kk, _o.z*kk).applyMatrix4(group.matrixWorld);
    _d.set(Math.cos(th)*0.15, -1, Math.sin(th)*0.15).transformDirection(group.matrixWorld);
    const len = P.armLen*(1+0.2*it.r1)/n;
    if(it.init) initChain(it, n, len, _v, _d);
    if(stepping){ o.phase = it.ph; stepChain(it,n,len,_v.x,_v.y,_v.z,_d.x,_d.y,_d.z,dt,o); }
    else { it.pos[0]=_v.x; it.pos[1]=_v.y; it.pos[2]=_v.z; }
    frames(it.pos,n);
    const base = k*(n+1)*ACOLS, p = it.pos;
    for(let i=0;i<=n;i++){
      const f = i*9, s = i/n;
      const nx=FR[f+3], ny=FR[f+4], nz=FR[f+5], bx=FR[f+6], by=FR[f+7], bz=FR[f+8];
      const ang = P.armTwist*TAU*s, ca = Math.cos(ang), sa = Math.sin(ang);
      const sx = nx*ca+bx*sa, sy = ny*ca+by*sa, sz = nz*ca+bz*sa;     // Querrichtung
      const dx = -nx*sa+bx*ca, dy = -ny*sa+by*ca, dz = -nz*sa+bz*ca;   // Flächennormale
      const w = P.armWidth*(1-P.armTaper*s);
      const ro = P.armRuffle*w*(Math.sin(s*P.armRuffleFreq*TAU+time*1.3+it.ph)
               + 0.35*Math.sin(s*P.armRuffleFreq*2.3*TAU-time*0.9+it.ph*2))*(0.25+0.75*s);
      const cx = p[i*3], cy = p[i*3+1], cz = p[i*3+2];
      for(let c=0;c<ACOLS;c++){
        const u = c/(ACOLS-1)*2-1;
        const off = ro*u*Math.abs(u) + w*0.10*Math.sin(u*3+s*5+it.ph);
        const v0 = (base+i*ACOLS+c)*3;
        pos[v0]   = cx+sx*w*u+dx*off;
        pos[v0+1] = cy+sy*w*u+dy*off;
        pos[v0+2] = cz+sz*w*u+dz*off;
        nor[v0]=dx; nor[v0+1]=dy; nor[v0+2]=dz;
      }
    }
  }
  A.mesh.geometry.attributes.position.needsUpdate = true;
  A.mesh.geometry.attributes.normal.needsUpdate = true;
}

/* ---- Flowfield-Anzeige ---- */
const FV = {mesh:null, base:[]};
(function(){
  const nx=5, ny=6, nz=5;
  for(let a=0;a<nx;a++) for(let b=0;b<ny;b++) for(let c=0;c<nz;c++){
    FV.base.push([lerp(-3,3,a/(nx-1)), lerp(1.5,-4,b/(ny-1)), lerp(-3,3,c/(nz-1))]);
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(FV.base.length*6),3));
  FV.mesh = new THREE.LineSegments(g, matField);
  FV.mesh.frustumCulled = false; FV.mesh.renderOrder = 5;
  scene.add(FV.mesh);
})();
function updateField(){
  FV.mesh.visible = !!P.showField;
  if(!P.showField) return;
  const arr = FV.mesh.geometry.attributes.position.array;
  const ox = Math.round(target.x/1.5)*1.5, oy = Math.round(target.y/1.1)*1.1, oz = Math.round(target.z/1.5)*1.5;
  for(let i=0;i<FV.base.length;i++){
    const b = FV.base[i], bx = b[0]+ox, by = b[1]+oy, bz = b[2]+oz;
    flowVec(bx,by,bz,time,_F);
    arr[i*6]=bx; arr[i*6+1]=by; arr[i*6+2]=bz;
    arr[i*6+3]=bx+_F.x*0.7; arr[i*6+4]=by+_F.y*0.7; arr[i*6+5]=bz+_F.z*0.7;
  }
  FV.mesh.geometry.attributes.position.needsUpdate = true;
}

/* ------------------------------------------------------------------ */
/*  Simulation                                                         */
/* ------------------------------------------------------------------ */
const bodyPos = new THREE.Vector3(), vel = new THREE.Vector3();
const qBody = new THREE.Quaternion(), qTarget = new THREE.Quaternion(), qWob = new THREE.Quaternion();
const eWob = new THREE.Euler(), heading = new THREE.Vector3(), _Y = new THREE.Vector3(0,1,0), _vp = new THREE.Vector3();
let paused = false, spin = 0;

function step(dt, stepping){
  if(stepping){
    time += dt;
    const irr = 1 + P.pulseIrr*0.6*noiseF(time*0.23, 3.3, 8.8);
    phase += P.pulseRate*irr*dt;
    const c0 = contractionAt(0.4), dc = c0 - prevC0; prevC0 = c0;
    // Schwimmrichtung: Grundrichtung plus langsames Wandern (Perlin)
    const yaw = P.swimYaw*Math.PI/180 + P.steer*1.3*noiseF(time*0.04, 4.1, 2.7);
    const el  = clamp(P.swimElev*Math.PI/180 + P.steer*0.5*noiseF(time*0.05, 8.3, 1.9), -1.35, 1.35);
    heading.set(Math.cos(el)*Math.sin(yaw), Math.sin(el), Math.cos(el)*Math.cos(yaw));
    qTarget.setFromUnitVectors(_Y, heading);
    qBody.slerp(qTarget, 1-Math.exp(-dt*P.turnRate));
    // leichtes Taumeln und langsames Drehen um die eigene Achse
    spin += P.bodyTilt*0.6*noiseF(time*0.1, 9.1, 3.3)*dt;
    eWob.set(P.bodyTilt*0.9*noiseF(time*0.17,1.7,2.2), spin, P.bodyTilt*0.9*noiseF(time*0.15,7.7,4.1));
    qWob.setFromEuler(eWob);
    group.quaternion.copy(qBody).multiply(qWob);
    // Schub entlang der Körperachse, nur während der Kontraktion
    _up.copy(_Y).applyQuaternion(group.quaternion);
    if(dc > 0) vel.addScaledVector(_up, P.thrust*dc);
    flowVec(bodyPos.x,bodyPos.y,bodyPos.z,time,_F,1);
    vel.x += _F.x*P.drift*dt*0.8; vel.y += _F.y*P.drift*dt*0.8; vel.z += _F.z*P.drift*dt*0.8;
    vel.y -= 0.12*dt;
    // seitliches Rutschen stärker bremsen als Vorwärtsbewegung
    _vp.copy(_up).multiplyScalar(vel.dot(_up));
    vel.sub(_vp).multiplyScalar(Math.exp(-3.0*dt)).add(_vp);
    vel.multiplyScalar(Math.exp(-1.0*dt));
    bodyPos.addScaledVector(vel, dt);
  }
  group.position.copy(bodyPos);
  group.updateMatrixWorld(true);
  updateBell();
  updateLayers();
  updateFrills();
  updateTents(dt, stepping);
  updateArms(dt, stepping);
  updateInner();
  updateField();
}

/* Schwebeteilchen: ruhen in der Welt und zeigen die Fahrt der Kamera */
const SNOW_N = 1400, SNOW_BOX = 26;
const snow = {pos:new Float32Array(SNOW_N*3), pts:null};
(function(){
  const rnd = mulberry32(77);
  for(let i=0;i<SNOW_N*3;i++) snow.pos[i] = (rnd()-0.5)*SNOW_BOX;
  const g = new THREE.BufferGeometry();
  const a = new THREE.BufferAttribute(snow.pos,3); a.setUsage(THREE.DynamicDrawUsage);
  g.setAttribute('position', a);
  let map = null;
  try{
    const cv = document.createElement('canvas'); cv.width = cv.height = 32;
    const cx = cv.getContext('2d');
    if(cx){
      const gr = cx.createRadialGradient(16,16,0,16,16,16);
      gr.addColorStop(0,'rgba(255,255,255,1)'); gr.addColorStop(0.5,'rgba(255,255,255,0.6)'); gr.addColorStop(1,'rgba(255,255,255,0)');
      cx.fillStyle = gr; cx.fillRect(0,0,32,32);
      map = new THREE.CanvasTexture(cv);
    }
  }catch(e){}
  const m = new THREE.PointsMaterial({size:0.05, map:map, transparent:true, opacity:0.55, depthWrite:false, sizeAttenuation:true, color:0xffffff});
  snow.mat = m;
  snow.pts = new THREE.Points(g, m);
  snow.pts.frustumCulled = false; snow.pts.renderOrder = -1;
  scene.add(snow.pts);
})();
function updateSnow(dt, stepping){
  const n = Math.floor(SNOW_N*P.snowAmount);
  snow.pts.visible = n > 0;
  if(n < 1) return;
  snow.pts.geometry.setDrawRange(0, n);
  snow.mat.size = P.snowSize;
  snow.mat.color.set(P.swap ? P.cDark : P.cLight);
  const p = snow.pos, cx = target.x, cy = target.y, cz = target.z;
  for(let i=0;i<n;i++){
    const q = i*3;
    let x = p[q], y = p[q+1], z = p[q+2];
    if(stepping){
      flowVec(x,y,z,time,_F,1);
      x += _F.x*0.15*dt; y += _F.y*0.15*dt; z += _F.z*0.15*dt;
    }
    let dx = x-cx, dy = y-cy, dz = z-cz;
    dx -= SNOW_BOX*Math.round(dx/SNOW_BOX); dy -= SNOW_BOX*Math.round(dy/SNOW_BOX); dz -= SNOW_BOX*Math.round(dz/SNOW_BOX);
    p[q] = cx+dx; p[q+1] = cy+dy; p[q+2] = cz+dz;
  }
  snow.pts.geometry.attributes.position.needsUpdate = true;
}

/* ------------------------------------------------------------------ */
/*  Neuaufbau-Warteschlange                                            */
/* ------------------------------------------------------------------ */
const pend = new Set();
function queue(k){
  if(k === 'all'){ ['bell','layers','frills','tent','arms','organs','canals'].forEach(x => pend.add(x)); return; }
  pend.add(k);
  if(k === 'bell'){ pend.add('canals'); pend.add('layers'); }
}
function flush(){
  if(!pend.size) return;
  if(pend.has('bell'))   buildBell();
  if(pend.has('layers')) buildLayers();
  if(pend.has('frills')) buildFrills();
  if(pend.has('tent'))   buildTents();
  if(pend.has('arms'))   buildArms();
  if(pend.has('organs')) buildOrgans();
  if(pend.has('canals')) buildCanals();
  pend.clear();
}

/* ------------------------------------------------------------------ */
/*  Zufall, Mutation, Palette                                          */
/* ------------------------------------------------------------------ */
function randomize(){
  P.seed = 1 + Math.floor(Math.random()*99998);
  SCHEMA.forEach(g => g.items.forEach(it => {
    if(it.type || !it.rr) return;
    let v;
    if(it.p0 && Math.random() < it.p0) v = Math.max(it.min, 0);
    else v = lerp(it.rr[0], it.rr[1], Math.random());
    if(it.int) v = Math.round(v);
    P[it.k] = clamp(v, it.min, it.max);
  }));
  seedNoise(); queue('all'); resetChains(); refreshUI();
}
function mutate(){
  SCHEMA.forEach(g => g.items.forEach(it => {
    if(it.rb) return;
    if(it.type === 'bool') return;
    if(it.type === 'color') return;
    let v = P[it.k] + (Math.random()-0.5)*2*0.12*(it.max-it.min);
    if(it.int) v = Math.round(v);
    P[it.k] = clamp(v, it.min, it.max);
  }));
  refreshUI();
}
function resetChains(){ T.items.forEach(i => { i.init = true; }); A.items.forEach(i => { i.init = true; }); }

/* ------------------------------------------------------------------ */
/*  Oberfläche                                                         */
/* ------------------------------------------------------------------ */
const UI = {};
function fmt(it,v){
  if(it.int) return String(v);
  const r = it.max-it.min, d = r >= 50 ? 0 : r >= 5 ? 1 : r >= 0.5 ? 2 : 3;
  return (+v).toFixed(d);
}
function buildUI(){
  const host = document.getElementById('groups');
  SCHEMA.forEach(g => {
    const det = document.createElement('details'); if(g.open) det.open = true;
    const sum = document.createElement('summary'); sum.textContent = g.t; det.appendChild(sum);
    g.items.forEach(it => {
      const row = document.createElement('div'); row.className = 'row';
      const lbl = document.createElement('span'); lbl.textContent = it.l;
      const val = document.createElement('span'); val.className = 'val';
      const inp = document.createElement('input');
      if(it.type === 'bool'){
        inp.type = 'checkbox'; inp.checked = !!P[it.k]; row.classList.add('inline');
        inp.addEventListener('input', () => { P[it.k] = inp.checked; });
        row.appendChild(lbl); row.appendChild(inp);
      } else if(it.type === 'color'){
        inp.type = 'color'; inp.value = P[it.k]; row.classList.add('inline');
        inp.addEventListener('input', () => { P[it.k] = inp.value; });
        row.appendChild(lbl); row.appendChild(inp);
      } else {
        inp.type = 'range'; inp.min = it.min; inp.max = it.max; inp.step = it.step; inp.value = P[it.k];
        val.textContent = fmt(it,P[it.k]);
        inp.addEventListener('input', () => {
          const v = it.int ? parseInt(inp.value,10) : parseFloat(inp.value);
          P[it.k] = v; val.textContent = fmt(it,v);
          if(it.rb) queue(it.rb);
        });
        row.appendChild(lbl); row.appendChild(val); row.appendChild(inp);
      }
      UI[it.k] = {it:it, inp:inp, val:val};
      det.appendChild(row);
    });
    host.appendChild(det);
  });
}
function refreshUI(){
  for(const k in UI){
    const u = UI[k], v = P[k];
    if(u.it.type === 'bool') u.inp.checked = !!v;
    else if(u.it.type === 'color') u.inp.value = v;
    else { u.inp.value = v; u.val.textContent = fmt(u.it,v); }
  }
  document.getElementById('seed').value = P.seed;
}
buildUI();

document.getElementById('seed').addEventListener('change', e => {
  P.seed = clamp(parseInt(e.target.value,10) || 1, 1, 99999);
  e.target.value = P.seed;
  seedNoise(); queue('all'); resetChains();
});
document.getElementById('bRand').addEventListener('click', randomize);
document.getElementById('bMut').addEventListener('click', mutate);
document.getElementById('bReset').addEventListener('click', () => {
  Object.keys(DEFAULTS).forEach(k => { P[k] = DEFAULTS[k]; });
  seedNoise(); queue('all'); resetChains(); refreshUI();
});
const bPause = document.getElementById('bPause');
function setPause(v){ paused = v; bPause.textContent = paused ? 'Weiter' : 'Pause'; }
bPause.addEventListener('click', () => setPause(!paused));
window.addEventListener('keydown', e => {
  if(e.code === 'Space' && !/^(INPUT|BUTTON|TEXTAREA)$/.test(e.target.tagName)){ e.preventDefault(); setPause(!paused); }
});
document.getElementById('bCopy').addEventListener('click', () => {
  const txt = JSON.stringify(P);
  const done = () => flash('Parameter kopiert');
  if(navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done, () => window.prompt('Parameter kopieren:', txt));
  else window.prompt('Parameter kopieren:', txt);
});
document.getElementById('bLoad').addEventListener('click', () => {
  const txt = window.prompt('Parameter-JSON einfügen:');
  if(!txt) return;
  try{
    const o = JSON.parse(txt);
    Object.keys(P).forEach(k => {
      if(!(k in o) || typeof o[k] !== typeof P[k]) return;
      const u = UI[k];
      P[k] = (u && !u.it.type) ? clamp(o[k], u.it.min, u.it.max) : o[k];
    });
    P.seed = clamp(Math.round(P.seed) || 1, 1, 99999);
    seedNoise(); queue('all'); resetChains(); refreshUI(); flash('Parameter geladen');
  }catch(err){ flash('Das war kein gültiges JSON'); }
});
const statusEl = document.getElementById('status'); let flashT = 0;
function flash(msg){ statusEl.textContent = msg; clearTimeout(flashT); flashT = setTimeout(() => { statusEl.textContent = ''; }, 1800); }

/* ------------------------------------------------------------------ */
/*  Start                                                              */
/* ------------------------------------------------------------------ */
queue('all');
refreshUI();
let last = performance.now();
function frame(now){
  requestAnimationFrame(frame);
  let dt = clamp((now-last)/1000, 0, 1/30); last = now;
  flush();
  if(P.autoRot > 0) az += dt*P.autoRot*0.5;
  updateCamera();
  camera.updateMatrixWorld();
  camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
  applyMaterials();
  const sdt = paused ? 0 : dt*P.timeScale;
  step(sdt, sdt > 1e-5);
  if(P.camFollow){
    camGoal.set(bodyPos.x, bodyPos.y-0.7, bodyPos.z);
    target.lerp(camGoal, 1-Math.exp(-dt/Math.max(0.05,P.camSmooth)));
  }
  updateSnow(sdt, sdt > 1e-5);
  renderer.render(scene, camera);
}
requestAnimationFrame(frame);

window.__medusoid = {P:P, T:T, A:A, B:B};   // zum Debuggen in der Konsole
})();
</script>
</body>
</html>