AGENTIC_BOID_BODY_DANCE_PLAYGROUND

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/e89f9201375ff7bb/" 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>BODY AGENTS</title>
<style>
:root{--ink:#000;--text:#333;--mute:#8c8c8c;--line:#e7e7e7}
*{box-sizing:border-box}
[hidden]{display:none!important}
html,body{margin:0;height:100%;background:#fff;color:var(--text);font:11px/1.4 ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;overflow:hidden;-webkit-font-smoothing:antialiased}
canvas{position:fixed;inset:0;display:block;outline:none}
#panel{position:fixed;top:12px;left:12px;width:264px;max-height:calc(100% - 84px);overflow-y:auto;background:#fff;border:1px solid var(--line);padding:12px 14px 14px;z-index:5}
#panel::-webkit-scrollbar{width:6px}
#panel::-webkit-scrollbar-thumb{background:#ddd}
.title{font-size:13px;font-weight:600;color:var(--ink)}
.sub{display:block;color:var(--mute);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:400}
#tabs{display:flex;gap:4px;margin:10px 0 4px}
#tabs button{flex:1;padding:5px 0;font:inherit;border:1px solid #d6d6d6;background:#fff;color:var(--mute);cursor:pointer}
#tabs button:hover{border-color:#000}
#tabs button.on{border-color:#000;color:var(--ink);font-weight:600}
.row{margin:4px 0}
.row label{display:flex;justify-content:space-between}
.row output{color:var(--ink);font-variant-numeric:tabular-nums}
input[type=range]{width:100%;height:14px;margin:2px 0 0;background:transparent;-webkit-appearance:none;appearance:none}
input[type=range]::-webkit-slider-runnable-track{height:1px;background:#cfcfcf}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:9px;height:9px;border-radius:50%;background:#000;margin-top:-4px;border:0}
input[type=range]::-moz-range-track{height:1px;background:#cfcfcf}
input[type=range]::-moz-range-thumb{width:9px;height:9px;border-radius:50%;background:#000;border:0}
select,button.btn{font:inherit;color:var(--ink);background:#fff;border:1px solid #d6d6d6;padding:5px 8px}
select{width:100%}
input[type=color]{width:100%;height:22px;padding:0;border:1px solid #d6d6d6;background:#fff;cursor:pointer;margin-top:2px}
button.btn{cursor:pointer}
button.btn:hover,select:hover{border-color:#000}
.btns{display:flex;gap:6px;margin-top:8px}
.btns button{flex:1}
.grp{display:flex;justify-content:space-between;align-items:center;margin:10px 0 1px;padding-bottom:2px;border-bottom:1px solid var(--line);color:var(--ink);font-weight:600}
.grp button{font:inherit;font-size:10px;line-height:1;padding:3px 6px;border:1px solid #d6d6d6;background:#fff;color:var(--mute);cursor:pointer}
.grp button:hover{border-color:#000;color:var(--ink)}
.chk{display:flex;align-items:center;gap:7px;margin:5px 0;cursor:pointer}
.chk input{accent-color:#000;margin:0}
.info{margin-top:6px;color:var(--mute);font-variant-numeric:tabular-nums}
.err{color:#b00020;margin-top:6px}
#bar{position:fixed;left:12px;right:12px;bottom:12px;height:44px;display:flex;align-items:center;gap:12px;padding:0 14px;background:#fff;border:1px solid var(--line);z-index:5}
#bar button{width:30px;height:30px;padding:0;display:grid;place-items:center;border:0;cursor:pointer}
#bar button:hover{background:#f3f3f3}
#bar svg{fill:#000}
#scrub{flex:1}
#bar .t{font-variant-numeric:tabular-nums;color:var(--mute);min-width:34px}
#drop{position:fixed;inset:0;display:grid;place-items:center;z-index:4;pointer-events:none}
#drop .box{pointer-events:auto;text-align:center;padding:28px 34px;border:1px dashed #cfcfcf;background:#fff}
#drop.drag .box{border-color:#000}
#drop .big{font-size:13px;color:var(--ink);margin-bottom:4px}
#drop .small{color:var(--mute);margin-bottom:14px}
</style>
</head>
<body>
<div id="drop">
  <div class="box">
    <div class="big">FBX oder GLB mit Animation hierher ziehen</div>
    <div class="small">Körper und Bewegung, ein File</div>
    <button id="pickBig" class="btn">Datei wählen…</button>
  </div>
</div>

<div id="panel" hidden>
  <div class="title">BODY AGENTS<span class="sub" id="subtitle">–</span></div>
  <div id="tabs">
    <button data-tab="agents" class="on">Agenten</button>
    <button data-tab="body">Körper</button>
    <button data-tab="anim">Animation</button>
    <button data-tab="cam">Kamera</button>
  </div>

  <div id="tab-agents">
    <div id="ctlAgents"></div>
    <div class="btns"><button id="stick" class="btn">Am Körper kleben</button><button id="reseed" class="btn">Neu verteilen</button></div>
  </div>

  <div id="tab-body" hidden>
    <div class="row"><label><span>Körper</span></label></div>
    <select id="bodyMode">
      <option value="wire" selected>Drahtgitter (Wireframe)</option>
      <option value="solid">Voll, mit Schatten</option>
      <option value="off">Aus</option>
    </select>
    <label class="chk" style="margin-top:8px"><input type="checkbox" id="skelOn" checked> Skelett anzeigen</label>
    <label class="chk"><input type="checkbox" id="gridOn" checked> Boden-Gitter</label>
    <label class="chk"><input type="checkbox" id="shadowOn" checked> Schatten</label>
    <label class="chk"><input type="checkbox" id="zup"> Z-Achse ist oben (manche FBX)</label>
    <div class="info" id="bodyInfo">–</div>
  </div>

  <div id="tab-anim" hidden>
    <div id="ctlAnim"></div>
    <div class="info" id="animInfo">–</div>
  </div>

  <div id="tab-cam" hidden>
    <label class="chk"><input type="checkbox" id="autoRot"> Auto-Rotation</label>
    <div id="ctlCam"></div>
    <div class="btns"><button id="camReset" class="btn">Ansicht zurücksetzen</button></div>
  </div>

  <div class="btns"><button id="pick" class="btn">Andere Datei laden…</button></div>
  <div class="err" id="err"></div>
</div>

<div id="bar" hidden>
  <button id="play" title="Play / Pause (Leertaste)"><svg viewBox="0 0 24 24" width="16" height="16"><path id="playIcon" d="M8 5v14l11-7z"/></svg></button>
  <input type="range" id="scrub" min="0" max="1000" value="0">
  <span class="t" id="tCur">0.00</span><span class="t" style="min-width:0">/</span><span class="t" id="tTot">0.00</span>
</div>

<input type="file" id="file" accept=".fbx,.glb,.gltf" hidden>

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

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';

const el = id => document.getElementById(id);
const clamp = (v, a, b) => Math.min(Math.max(v, a), b);
const UP = new THREE.Vector3(0, 1, 0);

/* ============================== Parameter ============================== */
const P = {
  count: 64,          // Agenten, gerade Zahl – paarweise links/rechts gespiegelt
  prim: 'sphere',     // sphere | box | pyramid | lamella
  size: 0.055,        // Grundgröße × Körperhöhe
  formA: 1, formB: 1, // Form-Zusatzparameter je Primitive
  force: 70,          // Feder-Kraft zum Anker (hoch = eng am Knochen)
  damp: 6,            // Dämpfung der Agenten-Bewegung
  rad: 0.06,          // Streukreis um den Knochen × H
  fwd: 0.15,          // Verteilung entlang der Blickrichtung × H
  squash: 0.5,        // Schrumpfen bei hoher Geschwindigkeit
  inertia: 0,         // Trägheit: Kraft wirkt verzögert (0 = sofort)
  mass: 1,            // Masse: hohe Masse = träge Antwort auf die Folge-Kraft
  gravity: 0,         // globale Gravitation × H/s² (0 = aus)
  elastic: 0.5,       // Elastizität beim Aufprall auf den Boden
  followYaw: 0.6,     // weiche Ausrichtung in Bewegungsrichtung (0 = aus)
  snapDeg: 'frei',    // Ausrichtung: 'frei' = frei drehend, 0 = keine Rotation, 15/30/45/90 = Raster
  segLen: 1,          // Verteilung über die um Faktor verlängerte Knochenstrecke
  tile: 8,            // Normalmap-Kachelung
  normalStr: 0.25,    // Normalmap-Stärke
  speed: 1,           // Wiedergabe-Tempo
  fov: 40, rotSpeed: 1.2,
};

/* ============================== Szene ============================== */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
document.body.prepend(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0xffffff);
scene.environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture;
scene.add(new THREE.HemisphereLight(0xffffff, 0xe6e6e6, 0.35));
// Key-Licht von schräg oben, weiche Schatten auf den Boden
const dl = new THREE.DirectionalLight(0xffffff, 1.0);
dl.castShadow = true;
dl.shadow.mapSize.set(2048, 2048);
dl.shadow.radius = 3;
scene.add(dl); scene.add(dl.target);
const shadowPlane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShadowMaterial({ opacity: 0.18 }));
shadowPlane.rotation.x = -Math.PI / 2; shadowPlane.receiveShadow = true; scene.add(shadowPlane);
const _ld = new THREE.Vector3();
function updateLightRig(resize) {
  if (resize) {
    const sc = dl.shadow.camera, r = H * 2.2;
    sc.left = -r; sc.right = r; sc.top = r; sc.bottom = -r; sc.near = H * 3; sc.far = H * 14; sc.updateProjectionMatrix();
    dl.shadow.bias = -0.0004; dl.shadow.normalBias = H * 0.004;
    shadowPlane.scale.setScalar(H * 40);
  }
  _ld.set(0.55, 0.9, 0.65).normalize();
  let cx = 0, cz = 0;
  if (model && hips) { const e = hips.matrixWorld.elements; cx = e[12]; cz = e[14]; }
  dl.target.position.set(cx, H * 0.5, cz); dl.target.updateMatrixWorld();
  dl.position.set(cx, H * 0.5, cz).addScaledVector(_ld, H * 8);
  dl.castShadow = el('shadowOn').checked;
  shadowPlane.visible = dl.castShadow;
  shadowPlane.position.set(cx, H * 0.002, cz);
}

const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 5000);
camera.position.set(0, 1.2, 3.5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.autoRotateSpeed = P.rotSpeed;
controls.target.set(0, 0.9, 0);

function onResize() {
  const w = window.innerWidth, h = window.innerHeight;
  renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix();
}
window.addEventListener('resize', onResize); onResize();

let grid = null;
function rebuildGrid() {
  if (grid) { scene.remove(grid); grid.geometry.dispose(); grid.material.dispose(); }
  grid = new THREE.GridHelper(H * 12, 24, 0xd2d2d2, 0xebebeb);
  grid.visible = el('gridOn').checked;
  scene.add(grid);
}

/* ============================== Zustand ============================== */
let model = null, mixer = null, clip = null, duration = 0, t = 0, playing = true;
let bones = [], boneIdx = new Map(), boneW = new Float32Array(0);
let meshes = [], skinMeshes = [], nodeRig = false;
let drawnPairs = [], hips = null, H = 1;

function disposeModel() {
  if (!model) return;
  scene.remove(model);
  model.traverse(o => { if (o.geometry) o.geometry.dispose(); });
  model = null; mixer = null; drawnPairs = []; hips = null;
  agentsMesh.count = 0; nAg = 0;
  skelLines.visible = false;
}

/* ============================== Pose / Boden / Blickrichtung ============================== */
const _q = new THREE.Quaternion();
const sLocal = new THREE.Vector3(1, 0, 0), fLocal = new THREE.Vector3(0, 0, 1);

function poseAt(tt) {
  const tc = duration > 0 ? clamp(tt, 0, duration - 1e-4) : 0;
  mixer.setTime(tc);
  model.updateMatrixWorld(true);
  for (const m of skinMeshes) m.skeleton.update();
  for (let i = 0; i < bones.length; i++) {
    const e = bones[i].matrixWorld.elements;
    boneW[i*3] = e[12]; boneW[i*3+1] = e[13]; boneW[i*3+2] = e[14];
  }
}

// Offset-/Reference-Bones oberhalb der eigentlichen Körperwurzel abschälen
function computeDrawn() {
  drawnPairs = []; hips = null;
  if (nodeRig && bones.length > 1) { computeDrawnNodeRig(); return; }
  const set = new Set(bones);
  const kids = b => b.children.filter(c => set.has(c));
  const tops = bones.filter(b => !(b.parent && set.has(b.parent)));
  for (const top of tops) {
    let r = top; while (kids(r).length === 1) r = kids(r)[0];
    if (!hips || kids(r).length > kids(hips).length) hips = r;
    const st = [r];
    while (st.length) { const b = st.pop(); for (const c of kids(b)) { drawnPairs.push([b, c]); st.push(c); } }
  }
  if (!hips) hips = model;
  skelGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(drawnPairs.length * 6), 3));
}

// Skelett-only-GLB (z. B. Marker-Mocap): flache Knotenhierarchie ohne Anatomie –
// Segmente daher als Minimal Spanning Tree über die Ruhepose-Positionen.
function computeDrawnNodeRig() {
  model.updateMatrixWorld(true);
  const n = bones.length;
  const Pw = bones.map(b => new THREE.Vector3().setFromMatrixPosition(b.matrixWorld));
  const used = new Array(n).fill(false), dist = new Array(n).fill(Infinity), from = new Array(n).fill(-1);
  const deg = new Array(n).fill(0);
  dist[0] = 0;
  for (let k = 0; k < n; k++) {
    let m = -1;
    for (let i = 0; i < n; i++) if (!used[i] && (m < 0 || dist[i] < dist[m])) m = i;
    used[m] = true;
    if (from[m] >= 0) { drawnPairs.push([bones[from[m]], bones[m]]); deg[from[m]]++; deg[m]++; }
    for (let i = 0; i < n; i++) {
      if (used[i]) continue;
      const d = Pw[m].distanceTo(Pw[i]);
      if (d < dist[i]) { dist[i] = d; from[i] = m; }
    }
  }
  // Hüfte = am besten vernetzter Knoten (bei Gleichstand der untere)
  let bi = 0;
  for (let i = 1; i < n; i++) if (deg[i] > deg[bi] || (deg[i] === deg[bi] && Pw[i].y < Pw[bi].y)) bi = i;
  hips = bones[bi];
  skelGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(drawnPairs.length * 6), 3));
}

function applyUp() {
  model.rotation.set(el('zup').checked ? -Math.PI / 2 : 0, 0, 0);
  model.position.set(0, 0, 0);
  let minY = Infinity, maxY = -Infinity;
  if (bones.length) {
    for (let k = 0; k < 24; k++) {
      poseAt(duration * k / 24);
      for (let i = 0; i < bones.length; i++) minY = Math.min(minY, boneW[i*3+1]);
    }
    model.position.y = -minY;
    poseAt(0);
    let lo = Infinity;
    for (let i = 0; i < bones.length; i++) { lo = Math.min(lo, boneW[i*3+1]); maxY = Math.max(maxY, boneW[i*3+1]); }
    H = Math.max((maxY - lo) * 1.1, 1e-3);
  } else {
    poseAt(0);
    const bb = new THREE.Box3().setFromObject(model);
    model.position.y = -bb.min.y; poseAt(0);
    H = Math.max(bb.max.y - bb.min.y, 1e-3);
  }
  detectForward();
  rebuildGrid();
  camera.near = H * 0.01; camera.far = H * 300; camera.updateProjectionMatrix();
  updateLightRig(true);
  resetView();
  controls.update();
}

function resetView() {
  controls.target.set(0, H * 0.5, 0);
  camera.position.set(H * 0.9, H * 0.75, H * 2.3);
}

function findLegs() {
  const names = bones.map(b => b.name.toLowerCase());
  const leg = k => names.findIndex(n => n.includes(k) && /(upleg|upperleg|thigh)/.test(n));
  let [li, ri] = [leg('left'), leg('right')];
  if (li < 0 || ri < 0) {
    // Sonst: zusammenpassendes L/R-Paar eines Beinknochens (hip_L/knee_R, thigh.L, l_upleg …)
    const side = n => /(^|[^a-z])(l|left)([^a-z]|$)/.test(n) ? 0 : /(^|[^a-z])(r|right)([^a-z]|$)/.test(n) ? 1 : -1;
    const strip = n => n.replace(/(^|[^a-z])(left|right|l|r)([^a-z]|$)/g, '$1$3');
    const isLeg = n => /(upleg|upperleg|upper_leg|thigh|hip|knee|calf|shin|ankle|foot|leg)/.test(n);
    outer: for (let a = 0; a < names.length; a++) {
      const sa = side(names[a]);
      if (sa < 0 || !isLeg(names[a])) continue;
      for (let b = 0; b < names.length; b++) {
        if (b === a || side(names[b]) !== 1 - sa || !isLeg(names[b])) continue;
        if (strip(names[a]) === strip(names[b])) { li = sa === 0 ? a : b; ri = sa === 0 ? b : a; break outer; }
      }
    }
  }
  return [li, ri];
}

function detectForward() {
  const [li, ri] = findLegs();
  hips.getWorldQuaternion(_q);
  sLocal.set(1, 0, 0); fLocal.set(0, 0, 1);   // Fallback: lokale X (links) / Z (vorne) der Hüfte
  if (li >= 0 && ri >= 0) {
    const d = new THREE.Vector3(boneW[li*3] - boneW[ri*3], 0, boneW[li*3+2] - boneW[ri*3+2]);
    if (d.lengthSq() > 1e-12) {
      d.normalize();
      const qi = _q.clone().invert();
      sLocal.copy(d).applyQuaternion(qi);                       // links
      fLocal.copy(d).cross(UP).applyQuaternion(qi);             // links × oben = vorne
    }
  }
}

// Seiten-/Blickachse in Weltkoordinaten (für die Spiegel-Symmetrie der Agenten)
const _sideW = new THREE.Vector3(), _fwdW = new THREE.Vector3();
function refreshSide() {
  if (hips) hips.getWorldQuaternion(_q); else _q.identity();
  _sideW.copy(sLocal).applyQuaternion(_q);
  _fwdW.copy(fLocal).applyQuaternion(_q);
}

/* ============================== Skelett-Anzeige ============================== */
const skelMat = new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.45 });
const skelGeo = new THREE.BufferGeometry();
const skelLines = new THREE.LineSegments(skelGeo, skelMat);
skelLines.frustumCulled = false;
scene.add(skelLines);
function updateSkel() {
  if (!drawnPairs.length) { skelLines.visible = false; return; }
  skelLines.visible = el('skelOn').checked;
  const a = skelGeo.getAttribute('position'), arr = a.array;
  for (let k = 0; k < drawnPairs.length; k++) {
    const i0 = boneIdx.get(drawnPairs[k][0]) * 3, i1 = boneIdx.get(drawnPairs[k][1]) * 3, o = k * 6;
    arr[o] = boneW[i0]; arr[o+1] = boneW[i0+1]; arr[o+2] = boneW[i0+2];
    arr[o+3] = boneW[i1]; arr[o+4] = boneW[i1+1]; arr[o+5] = boneW[i1+2];
  }
  a.needsUpdate = true;
}

/* ============================== Körperdarstellung ============================== */
const wireMat = new THREE.MeshBasicMaterial({ color: 0x000000, wireframe: true, transparent: true, opacity: 0.14, depthWrite: false });
const solidMat = new THREE.MeshStandardMaterial({ color: 0xf0f0f0, roughness: 0.85, metalness: 0, side: THREE.DoubleSide });
function applyBodyMode() {
  const mode = el('bodyMode').value;
  for (const m of meshes) {
    m.visible = mode !== 'off';
    m.material = mode === 'solid' ? solidMat : wireMat;
    m.castShadow = mode === 'solid';
  }
}
el('bodyMode').onchange = applyBodyMode;
el('zup').onchange = () => { if (model) { applyUp(); buildAgents(); } };
el('gridOn').onchange = () => { grid.visible = el('gridOn').checked; };

/* ============================== Datei laden (FBX / GLB / glTF) ============================== */
const drop = el('drop'), errBox = el('err');
const fail = m => { errBox.textContent = m; };

// glTF/GLB-Loader; Draco-, Meshopt- und KTX2-Dekompressoren werden nur bei Bedarf vom CDN geladen
const gltfLoader = new GLTFLoader()
  .setDRACOLoader(new DRACOLoader().setDecoderPath('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/draco/gltf/'))
  .setMeshoptDecoder(MeshoptDecoder)
  .setKTX2Loader(new KTX2Loader().setTranscoderPath('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/basis/').detectSupport(renderer));

['pick', 'pickBig'].forEach(id => el(id).onclick = () => el('file').click());
el('file').onchange = e => { const f = e.target.files[0]; if (f) readFile(f); e.target.value = ''; };
['dragenter', 'dragover'].forEach(ev => window.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('drag'); }));
['dragleave', 'drop'].forEach(ev => window.addEventListener(ev, e => { e.preventDefault(); if (ev === 'drop' || e.relatedTarget === null) drop.classList.remove('drag'); }));
window.addEventListener('drop', e => {
  const f = e.dataTransfer.files[0];
  if (f && /\.(fbx|glb|gltf)$/i.test(f.name)) readFile(f); else if (f) fail('Das ist keine FBX-, GLB- oder GLTF-Datei.');
});
function readFile(file) {
  fail('');
  const r = new FileReader();
  r.onload = () => loadBuffer(r.result, file.name).catch(err => { fail('Datei konnte nicht gelesen werden: ' + err.message); console.error(err); });
  r.onerror = () => fail('Datei konnte nicht gelesen werden.');
  r.readAsArrayBuffer(file);
}
// liegt eine unknown.fbx / unknown.glb / unknown.gltf neben der HTML, wird sie automatisch geladen
(async () => {
  for (const n of ['unknown.fbx', 'unknown.glb', 'unknown.gltf']) {
    try { const r = await fetch(n); if (!r.ok) continue; await loadBuffer(await r.arrayBuffer(), n); return; } catch { /* nächsten Namen versuchen */ }
  }
})();

// Format am Inhalt erkennen (GLB-Magic 'glTF', JSON beginnt mit '{'), sonst FBX
function peekMagic(buffer) {
  if (buffer.byteLength < 4) return '';
  const h = new Uint8Array(buffer, 0, 4);
  return String.fromCharCode(h[0], h[1], h[2], h[3]);
}
function startsWithJson(buffer) {
  const h = new Uint8Array(buffer, 0, Math.min(64, buffer.byteLength));
  for (const c of h) { if (c === 32 || c === 9 || c === 10 || c === 13) continue; return c === 123; }
  return false;
}

async function loadBuffer(buffer, name) {
  const magic = peekMagic(buffer);
  if (magic === 'glTF') {                                       // GLB (binär)
    const gltf = await new Promise((res, rej) => gltfLoader.parse(buffer, '', res, rej));
    setupModel(gltf.scene || gltf.scenes[0], gltf.animations || [], name);
  } else if (/\.gltf$/i.test(name) || startsWithJson(buffer)) { // glTF (JSON)
    const gltf = await new Promise((res, rej) => gltfLoader.parse(new TextDecoder().decode(buffer), '', res, rej));
    setupModel(gltf.scene || gltf.scenes[0], gltf.animations || [], name);
  } else {                                                      // FBX
    const fbx = new FBXLoader().parse(buffer, '');
    setupModel(fbx, fbx.animations || [], name);
  }
}

function setupModel(root, animations, name) {
  disposeModel();
  model = root; scene.add(model);

  bones = []; meshes = []; skinMeshes = []; nodeRig = false;
  model.traverse(o => {
    if (o.isBone) bones.push(o);
    if (o.isMesh) {
      meshes.push(o); o.frustumCulled = false;
      if (o.isSkinnedMesh && o.geometry.attributes.skinWeight) { o.normalizeSkinWeights(); skinMeshes.push(o); }
    }
  });
  if (!bones.length) skinMeshes.forEach(m => m.skeleton.bones.forEach(b => bones.push(b)));
  if (!bones.length && !meshes.length) {
    // GLB ohne Skin und Mesh (z. B. Marker-Mocap): die animierten Knoten sind das Rig
    nodeRig = true;
    const animNames = new Set();
    for (const c of animations) for (const tr of c.tracks) animNames.add(tr.name.split('.')[0]);
    model.traverse(o => {
      if (o !== model && !o.isLight && !o.isCamera && (!animNames.size || animNames.has(o.name))) bones.push(o);
    });
  }
  boneIdx = new Map(bones.map((b, i) => [b, i]));
  boneW = new Float32Array(bones.length * 3);

  clip = animations[0] || null;
  mixer = new THREE.AnimationMixer(model); duration = 0;
  if (clip) { mixer.clipAction(clip).play(); duration = clip.duration; }
  t = 0;

  computeDrawn();   // Segmente + Hüfte (vor applyUp, braucht detectForward)
  applyBodyMode();
  applyUp();        // Boden, Körperhöhe H, Blickrichtung
  buildAgents();    // braucht Segmente, H und Blickrichtung

  el('subtitle').textContent = `${name} · ${bones.length} Knochen · ${duration.toFixed(2)} s`;
  el('bodyInfo').textContent = meshes.length
    ? `${meshes.length} Mesh${meshes.length > 1 ? 'es' : ''} · ${bones.length} Knochen`
    : `Kein Mesh – Agenten sitzen auf dem Skelett (${bones.length} Knochen)`;
  el('animInfo').textContent = clip
    ? `Clip "${clip.name || 'Animation'}" · ${duration.toFixed(2)} s · wird geloopt`
    : 'Keine Animation in der Datei';
  el('tTot').textContent = duration.toFixed(2);
  el('tCur').textContent = '0.00';
  el('scrub').value = 0;
  drop.hidden = true; el('panel').hidden = false; el('bar').hidden = false;
  setPlaying(duration > 0);
  fail('');
}

/* ============================== Agenten ============================== */
const MAXAG = 666;
const geoms = {
  sphere: new THREE.SphereGeometry(0.5, 24, 16),
  box: new THREE.BoxGeometry(1, 1, 1),
  pyramid: new THREE.ConeGeometry(0.55, 1, 4, 1),
  lamella: new THREE.BoxGeometry(1, 1, 1),
  cone: new THREE.ConeGeometry(0.5, 1, 20),
  spike: new THREE.ConeGeometry(0.26, 1, 6),
  shell: new THREE.SphereGeometry(0.5, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2),   // offene Halbschale
  leaf: (() => { const g = new THREE.SphereGeometry(0.5, 18, 12); g.scale(1, 0.12, 0.72); return g; })(),  // flaches Blatt
  torus: new THREE.TorusGeometry(0.38, 0.13, 10, 24),
  tetra: new THREE.TetrahedronGeometry(0.6),
};
const agentMat = new THREE.MeshStandardMaterial({ color: 0x141414, roughness: 0.55, metalness: 0.1 });
const agentsMesh = new THREE.InstancedMesh(geoms.sphere, agentMat, MAXAG);
agentsMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
agentsMesh.castShadow = true; agentsMesh.frustumCulled = false;
scene.add(agentsMesh);

// Normalmap optional aus dem selben Ordner (normal_map.jpg): erst fetch→Blob (funktioniert auch
// bei file:// mit Server-CORS), sonst direkter Bild-Loader. Fehlt sie, bleibt alles still.
let agentNormalTex = null;
function applyNormalMap() {
  agentMat.normalMap = agentNormalTex;
  if (agentNormalTex) agentNormalTex.repeat.set(P.tile, P.tile);
  agentMat.normalScale.set(P.normalStr, P.normalStr);
  agentMat.needsUpdate = true;
}
function setNormalTex(tx) {
  tx.wrapS = tx.wrapT = THREE.RepeatWrapping;
  tx.colorSpace = THREE.NoColorSpace;
  tx.anisotropy = 4;
  agentNormalTex = tx;
  applyNormalMap();
}
(async () => {
  try {
    const r = await fetch('normal_map.jpg');
    if (!r.ok) throw new Error(r.status);
    const url = URL.createObjectURL(await r.blob());
    new THREE.TextureLoader().load(url, tx => { URL.revokeObjectURL(url); setNormalTex(tx); }, undefined, () => {});
  } catch {
    new THREE.TextureLoader().load('normal_map.jpg', setNormalTex, undefined, () => {});
  }
})();

// Pro Agent: Anker (Knochenpaar + Position darauf + Versatz in Körperkoordinaten) und Bewegungszustand.
// Gerade Indizes = linke Körperhälfte, ungerade = gespiegelt: gegenüberliegender Anker-Knochen,
// seitlicher Versatz und Drehung negiert.
const A = {
  iA: new Int32Array(MAXAG), iB: new Int32Array(MAXAG), segT: new Float32Array(MAXAG),
  gS: new Float32Array(MAXAG), gU: new Float32Array(MAXAG), fF: new Float32Array(MAXAG),
  yaw: new Float32Array(MAXAG), szVar: new Float32Array(MAXAG), sc: new Float32Array(MAXAG),
  px: new Float32Array(MAXAG), py: new Float32Array(MAXAG), pz: new Float32Array(MAXAG),
  vx: new Float32Array(MAXAG), vy: new Float32Array(MAXAG), vz: new Float32Array(MAXAG),
  fx: new Float32Array(MAXAG), fy: new Float32Array(MAXAG), fz: new Float32Array(MAXAG),   // träge gefilterte Kraft
  yawC: new Float32Array(MAXAG), pitC: new Float32Array(MAXAG),   // geglättete Ausrichtung
  tYaw: new Float32Array(MAXAG), tPit: new Float32Array(MAXAG),   // aktueller Zielwinkel der Ausrichtung
  hvx: new Float32Array(MAXAG), hvy: new Float32Array(MAXAG), hvz: new Float32Array(MAXAG),   // geglättete Bewegungsrichtung
};
let nAg = 0;
const segCum = [];
let mirIdx = new Int32Array(0);
const gauss = () => (Math.random() + Math.random() + Math.random()) / 1.5 - 1;  // ≈[-1,1], glockig

// Spiegel-Map Bone → gegenüberliegender Bone: erst über Namen (Left/Right …),
// sonst über die an der Körpermitte gespiegelte Ruheposition (Marker-Rigs).
function swapLR(name) {
  if (!name) return name;
  for (const [a, b] of [['Left', 'Right'], ['left', 'right'], ['LEFT', 'RIGHT']]) {
    if (name.includes(a)) return name.replace(a, b);
    if (name.includes(b)) return name.replace(b, a);
  }
  return name;
}
function buildMirrorMap() {
  const n = bones.length;
  mirIdx = new Int32Array(n).fill(-1);
  const byName = new Map(bones.map((b, i) => [b.name, i]));
  for (let i = 0; i < n; i++) {
    const nm = swapLR(bones[i].name);
    if (nm !== bones[i].name && byName.has(nm)) mirIdx[i] = byName.get(nm);
  }
  refreshSide();
  const hIdx = hips && boneIdx.has(hips) ? boneIdx.get(hips) : -1;
  const mx = hIdx >= 0 ? boneW[hIdx*3] : 0, my = hIdx >= 0 ? boneW[hIdx*3+1] : H * 0.5, mz = hIdx >= 0 ? boneW[hIdx*3+2] : 0;
  const tol = 0.05 * H;
  for (let i = 0; i < n; i++) {
    if (mirIdx[i] >= 0) continue;
    const d = (boneW[i*3] - mx) * _sideW.x + (boneW[i*3+1] - my) * _sideW.y + (boneW[i*3+2] - mz) * _sideW.z;
    if (Math.abs(d) < 1e-4 * H) continue;   // liegt auf der Mittelebene (Spine, Head …)
    const rx = boneW[i*3] - 2 * d * _sideW.x, ry = boneW[i*3+1] - 2 * d * _sideW.y, rz = boneW[i*3+2] - 2 * d * _sideW.z;
    let best = -1, bd = tol;
    for (let j = 0; j < n; j++) {
      if (j === i) continue;
      const dd = Math.hypot(boneW[j*3] - rx, boneW[j*3+1] - ry, boneW[j*3+2] - rz);
      if (dd < bd) { bd = dd; best = j; }
    }
    mirIdx[i] = best;   // -1 = Mittellinien-Knochen: Partner nutzt denselben Knochen
  }
}

const _tp = new THREE.Vector3();
function targetOf(i, out) {
  const ia = A.iA[i], ib = A.iB[i];
  let x, y, z;
  if (ia >= 0) {
    const u = (A.segT[i] - 0.5) * P.segLen + 0.5;   // segLen > 1: über die Knochenenden hinaus verteilen
    x = boneW[ia*3] + (boneW[ib*3] - boneW[ia*3]) * u;
    y = boneW[ia*3+1] + (boneW[ib*3+1] - boneW[ia*3+1]) * u;
    z = boneW[ia*3+2] + (boneW[ib*3+2] - boneW[ia*3+2]) * u;
  } else {
    // kein Segment (weniger als 2 Knochen): Hüfte bzw. Körpermitte
    const hi = hips && boneIdx.has(hips) ? boneIdx.get(hips) * 3 : -1;
    x = hi >= 0 ? boneW[hi] : 0;
    y = hi >= 0 ? boneW[hi+1] : H * 0.5;
    z = hi >= 0 ? boneW[hi+2] : 0;
  }
  const so = A.gS[i] * P.rad * H * ((i & 1) ? -1 : 1);   // Spiegelung an der Körpermitte
  const uo = A.gU[i] * P.rad * H;
  const fo = A.fF[i] * P.fwd * H;                        // entlang der Blickrichtung
  out.set(x + _sideW.x * so + _fwdW.x * fo, y + uo + _fwdW.y * fo, z + _sideW.z * so + _fwdW.z * fo);
}

function buildAgents() {
  if (!model) return;
  nAg = Math.max(2, Math.round(P.count / 2) * 2);
  agentsMesh.count = nAg;
  buildMirrorMap();
  // Segmentlängen (aktueller Pose) für längengewichtete, gleichmäßige Verteilung
  segCum.length = 0;
  let tot = 0;
  for (const [b0, b1] of drawnPairs) {
    const i0 = boneIdx.get(b0) * 3, i1 = boneIdx.get(b1) * 3;
    tot += (Math.hypot(boneW[i0] - boneW[i1], boneW[i0+1] - boneW[i1+1], boneW[i0+2] - boneW[i1+2]) + 1e-6) * P.segLen;
    segCum.push(tot);
  }
  refreshSide();
  for (let p = 0; p < nAg / 2; p++) {
    let s = -1;
    if (segCum.length) {
      const r = Math.random() * tot;
      s = 0;
      while (s < segCum.length - 1 && segCum[s] < r) s++;
    }
    // Zufallswerte einmal pro Paar ziehen → beide Hälften bleiben spiegelgleich
    const tR = Math.random(), gS = gauss(), gU = gauss() * 0.7, fF = Math.random() * 2 - 1;
    const yawR = Math.random() * Math.PI, szV = 0.75 + Math.random() * 0.5;
    const a0 = s >= 0 ? boneIdx.get(drawnPairs[s][0]) : -1;
    const b0 = s >= 0 ? boneIdx.get(drawnPairs[s][1]) : -1;
    for (let m = 0; m < 2; m++) {
      const i = p * 2 + m;
      A.segT[i] = tR; A.gS[i] = gS; A.gU[i] = gU; A.fF[i] = fF; A.yaw[i] = yawR; A.szVar[i] = szV;
      if (m === 0) { A.iA[i] = a0; A.iB[i] = b0; }
      else {
        A.iA[i] = a0 >= 0 && mirIdx[a0] >= 0 ? mirIdx[a0] : a0;
        A.iB[i] = b0 >= 0 && mirIdx[b0] >= 0 ? mirIdx[b0] : b0;
      }
      A.sc[i] = 1; A.vx[i] = A.vy[i] = A.vz[i] = 0;
      A.fx[i] = A.fy[i] = A.fz[i] = 0; A.hvx[i] = A.hvy[i] = A.hvz[i] = 0;
      A.yawC[i] = A.tYaw[i] = yawR * ((i & 1) ? -1 : 1); A.pitC[i] = A.tPit[i] = 0;
      targetOf(i, _tp);
      A.px[i] = _tp.x; A.py[i] = _tp.y; A.pz[i] = _tp.z;
    }
  }
}

const _m = new THREE.Matrix4(), _pv = new THREE.Vector3(), _sv = new THREE.Vector3(), _qv = new THREE.Quaternion(), _e = new THREE.Euler();
const angDiff = (a, b) => Math.atan2(Math.sin(a - b), Math.cos(a - b));
// Skalierung je Primitive: (Grundgröße, formA, formB) → Lokalmaßstab x/y/z
const PRIMSCALE = {
  sphere:  (b, a, c, o) => o.set(b, b, b),
  box:     (b, a, c, o) => o.set(b, b * a, b * c),
  pyramid: (b, a, c, o) => o.set(b, b * a, b),
  lamella: (b, a, c, o) => o.set(b, b * c, b * a),
  cone:    (b, a, c, o) => o.set(b, b * a, b),
  spike:   (b, a, c, o) => o.set(b * 0.45, b * a, b * 0.45),
  shell:   (b, a, c, o) => o.set(b, b * 0.55 * a, b),
  leaf:    (b, a, c, o) => o.set(b, b * a, b * c),
  torus:   (b, a, c, o) => o.set(b, b, b * (0.35 + 0.65 * a)),
  tetra:   (b, a, c, o) => o.set(b, b * a, b),
};
function updateAgents(dt) {
  if (!nAg) return;
  refreshSide();
  const acc0 = P.force / Math.max(P.mass, 0.05);
  const dmp = Math.exp(-P.damp * dt), vRef = H * 0.9, kS = Math.min(1, dt * 7);
  const kI = P.inertia <= 0 ? 1 : 1 - Math.exp(-dt * (1 - P.inertia) * 25);   // Trägheit: Kraftfilter
  const g = P.gravity * H;
  const snap = typeof P.snapDeg === 'number' && P.snapDeg > 0 ? P.snapDeg * Math.PI / 180 : 0;   // Winkel-Raster
  const lock = P.snapDeg === 0;                                   // 0 = keine Rotation
  const kHead = Math.min(1, dt * (1.5 + 9 * P.followYaw));        // Glättung der Bewegungsrichtung
  const kYaw = Math.min(1, dt * (0.5 + 3.5 * P.followYaw));       // träges Nachdrehen zum Zielwinkel
  for (let i = 0; i < nAg; i++) {
    targetOf(i, _tp);
    // Feder-Kraft zum Anker (durch Masse geteilt), optional träge gefiltert, plus globale Gravitation
    A.fx[i] += ((_tp.x - A.px[i]) * acc0 - A.fx[i]) * kI;
    A.fy[i] += ((_tp.y - A.py[i]) * acc0 - A.fy[i]) * kI;
    A.fz[i] += ((_tp.z - A.pz[i]) * acc0 - A.fz[i]) * kI;
    A.vx[i] += A.fx[i] * dt;
    A.vy[i] += (A.fy[i] - g) * dt;
    A.vz[i] += A.fz[i] * dt;
    A.vx[i] *= dmp; A.vy[i] *= dmp; A.vz[i] *= dmp;
    A.px[i] += A.vx[i] * dt; A.py[i] += A.vy[i] * dt; A.pz[i] += A.vz[i] * dt;
    // Größe: je schneller der Agent, desto kleiner (weich nachgeführt)
    const sp = Math.hypot(A.vx[i], A.vy[i], A.vz[i]);
    A.sc[i] += (1 - P.squash * Math.min(1, sp / vRef) - A.sc[i]) * kS;
    const base = P.size * H * A.sc[i] * A.szVar[i];
    // Boden: aufliegen und mit Elastizität abprallen
    const r = 0.5 * base;
    if (A.py[i] < r) {
      A.py[i] = r;
      if (A.vy[i] < 0) A.vy[i] = -A.vy[i] * P.elastic;
    }
    // Ausrichtung: nur bei echter Bewegung ein neues Ziel ziehen; die Drehung selbst läuft
    // träge zum Ziel weiter (kein Ziel-Flackern → kein permanentes Kreisen). Raster: Yaw rastet ein.
    if (!lock && (P.followYaw > 0 || snap)) {
      A.hvx[i] += (A.vx[i] - A.hvx[i]) * kHead;
      A.hvy[i] += (A.vy[i] - A.hvy[i]) * kHead;
      A.hvz[i] += (A.vz[i] - A.hvz[i]) * kHead;
      const hs = Math.hypot(A.hvx[i], A.hvz[i]);
      if (hs > H * 0.02) {
        let ty = Math.atan2(A.hvx[i], A.hvz[i]);
        let tpi = snap ? 0 : -Math.atan2(A.hvy[i], hs) * P.followYaw * 0.5;
        if (snap) ty = Math.round(ty / snap) * snap;   // Raster: Yaw einrasten, aufrecht bleiben
        A.tYaw[i] = ty; A.tPit[i] = tpi;
      }
      A.yawC[i] += angDiff(A.tYaw[i], A.yawC[i]) * kYaw;
      A.pitC[i] += (A.tPit[i] - A.pitC[i]) * kYaw;
    }
    PRIMSCALE[P.prim](base, P.formA, P.formB, _sv);
    _e.set(A.pitC[i], A.yawC[i], 0, 'YXZ');
    _qv.setFromEuler(_e);
    _m.compose(_pv.set(A.px[i], A.py[i], A.pz[i]), _qv, _sv);
    agentsMesh.setMatrixAt(i, _m);
  }
  agentsMesh.instanceMatrix.needsUpdate = true;
}

// Form-Zusatzparameter je Primitive
const FORM = {
  sphere:  { a: null,        b: null      },
  box:     { a: 'Höhe',      b: 'Tiefe'   },
  pyramid: { a: 'Höhe',      b: null      },
  lamella: { a: 'Breite',    b: 'Dicke'   },
  cone:    { a: 'Höhe',      b: null      },
  spike:   { a: 'Länge',     b: null      },
  shell:   { a: 'Wölbung',   b: null      },
  leaf:    { a: 'Wölbung',   b: 'Länge'   },
  torus:   { a: 'Flachheit', b: null      },
  tetra:   { a: 'Höhe',      b: null      },
};
let rA = null, rB = null;
function applyPrimitive() {
  agentsMesh.geometry = geoms[P.prim];
  agentMat.flatShading = P.prim === 'box' || P.prim === 'pyramid' || P.prim === 'lamella' || P.prim === 'spike' || P.prim === 'tetra';
  agentMat.side = P.prim === 'shell' || P.prim === 'leaf' ? THREE.DoubleSide : THREE.FrontSide;
  agentMat.needsUpdate = true;
  const f = FORM[P.prim];
  rA.row.hidden = !f.a; rB.row.hidden = !f.b;
  if (f.a) rA.span.textContent = f.a;
  if (f.b) rB.span.textContent = f.b;
}

/* ============================== UI-Bausteine ============================== */
const fmtVal = (v, step) => (+v).toFixed(step >= 1 ? 0 : Math.min(3, Math.max(1, Math.ceil(-Math.log10(step)))));
function addRange(parent, key, label, min, max, step, cb) {
  const row = document.createElement('div'); row.className = 'row';
  const lab = document.createElement('label');
  const span = document.createElement('span'); span.textContent = label;
  const out = document.createElement('output'); out.textContent = fmtVal(P[key], step);
  lab.append(span, out);
  const inp = document.createElement('input');
  inp.type = 'range'; inp.min = min; inp.max = max; inp.step = step; inp.value = P[key];
  RNG[key] = { inp, out, step };   // für die ↺-Reset-Buttons
  inp.addEventListener('input', () => { P[key] = parseFloat(inp.value); out.textContent = fmtVal(P[key], step); if (cb) cb(); });
  row.append(lab, inp);
  parent.appendChild(row);
  return { row, span, inp, out };
}
function addSelect(parent, label, opts, val, cb) {
  const row = document.createElement('div'); row.className = 'row';
  row.innerHTML = `<label><span>${label}</span></label>`;
  const sel = document.createElement('select');
  for (const [v, txt] of opts) { const o = document.createElement('option'); o.value = v; o.textContent = txt; sel.appendChild(o); }
  sel.value = val;
  sel.onchange = () => cb(sel.value);
  row.appendChild(sel);
  parent.appendChild(row);
  return { row, sel };
}
function addColor(parent, label, val, cb) {
  const row = document.createElement('div'); row.className = 'row';
  row.innerHTML = `<label><span>${label}</span></label>`;
  const inp = document.createElement('input'); inp.type = 'color'; inp.value = val;
  inp.oninput = () => cb(inp.value);
  row.appendChild(inp);
  parent.appendChild(row);
  return { row, inp };
}

// Gruppen-Überschrift mit optionalem ↺-Reset-Button
const RNG = {};   // Registrierung aller Range-Slider (für die Resets)
let snapSel = null;
function grp(parent, title, rst) {
  const d = document.createElement('div'); d.className = 'grp';
  const s = document.createElement('span'); s.textContent = title;
  d.append(s);
  if (rst) {
    const b = document.createElement('button'); b.type = 'button'; b.textContent = '↺';
    b.title = title + ' zurücksetzen';
    b.onclick = () => applyPreset(RESETS[rst]);
    d.append(b);
  }
  parent.appendChild(d);
}
// Werks-Presets für die ↺-Buttons; STICK = „am Körper kleben“
const RESETS = {
  Verteilung:  { rad: 0.06, fwd: 0.15, segLen: 1 },
  Form:        { size: 0.055, formA: 1, formB: 1 },
  Physik:      { force: 70, damp: 6, inertia: 0, mass: 1, gravity: 0, elastic: 0.5, squash: 0.5 },
  Ausrichtung: { followYaw: 0.6, snapDeg: 'frei' },
  Material:    { tile: 8, normalStr: 0.25 },
};
const STICK = { rad: 0, fwd: 0.15, segLen: 1, force: 120, damp: 8, inertia: 0, mass: 1,
  gravity: 0, elastic: 0.5, followYaw: 0.6, snapDeg: 'frei' };
function applyPreset(p) {
  for (const k in p) {
    P[k] = p[k];
    const r = RNG[k];
    if (r) { r.inp.value = P[k]; r.out.textContent = fmtVal(P[k], r.step); }
  }
  if ('snapDeg' in p && snapSel) snapSel.value = String(P.snapDeg);
}

/* ============================== Tabs ============================== */
el('tabs').addEventListener('click', e => {
  const b = e.target.closest('button'); if (!b) return;
  for (const tb of el('tabs').children) tb.classList.toggle('on', tb === b);
  for (const id of ['agents', 'body', 'anim', 'cam']) el('tab-' + id).hidden = id !== b.dataset.tab;
});

/* ============================== Regler ============================== */
const cA = el('ctlAgents');
grp(cA, 'Verteilung', 'Verteilung');
addRange(cA, 'count', 'Anzahl Agenten', 2, MAXAG, 2, buildAgents);
addRange(cA, 'rad', 'Streukreis um die Knochen (× H)', 0, 0.3, 0.005);
addRange(cA, 'fwd', 'Verteilung vor/zurück (× H)', 0, 0.5, 0.005);
addRange(cA, 'segLen', 'Längung: Verteilung × Knochenlänge', 1, 8, 0.1);
grp(cA, 'Körper & Form', 'Form');
addSelect(cA, 'Primitive', [
  ['sphere', 'Kugel'], ['box', 'Quader'], ['pyramid', 'Pyramide'], ['lamella', 'Lamelle'],
  ['cone', 'Kegel'], ['spike', 'Spitze'], ['shell', 'Halbschale'], ['leaf', 'Blatt'],
  ['torus', 'Torus'], ['tetra', 'Tetraeder'],
], P.prim, v => { P.prim = v; applyPrimitive(); });
addRange(cA, 'size', 'Größe (× Körperhöhe)', 0.01, 0.25, 0.005);
rA = addRange(cA, 'formA', 'Höhe', 0.1, 3, 0.05);
rB = addRange(cA, 'formB', 'Tiefe', 0.1, 3, 0.05);
grp(cA, 'Physik', 'Physik');
addRange(cA, 'force', 'Folge-Kraft', 10, 150, 1);
addRange(cA, 'inertia', 'Trägheit', 0, 0.95, 0.01);
addRange(cA, 'mass', 'Masse', 0.2, 8, 0.1);
addRange(cA, 'damp', 'Dämpfung', 2, 15, 0.5);
addRange(cA, 'gravity', 'Gravitation (× H/s²)', 0, 3, 0.05);
addRange(cA, 'elastic', 'Elastizität (Boden)', 0, 0.95, 0.05);
addRange(cA, 'squash', 'Schrumpfen bei Tempo', 0, 0.9, 0.05);
grp(cA, 'Ausrichtung', 'Ausrichtung');
addRange(cA, 'followYaw', 'Folgen der Bewegungsrichtung', 0, 1, 0.05);
snapSel = addSelect(cA, 'Winkel-Raster', [
  ['frei', 'Frei drehend'], ['90', '90°'], ['45', '45°'], ['30', '30°'], ['15', '15°'], ['0', 'Keine Rotation'],
], String(P.snapDeg), v => { P.snapDeg = v === 'frei' ? 'frei' : +v; }).sel;
grp(cA, 'Material', 'Material');
addColor(cA, 'Farbe', '#141414', v => agentMat.color.set(v));
addRange(cA, 'tile', 'Normalmap-Kachelung', 1, 40, 1, applyNormalMap);
addRange(cA, 'normalStr', 'Normalmap-Stärke', 0, 2, 0.05, applyNormalMap);
el('stick').onclick = () => applyPreset(STICK);
el('reseed').onclick = () => buildAgents();

addRange(el('ctlAnim'), 'speed', 'Wiedergabe-Tempo', 0.1, 3, 0.05);

el('autoRot').onchange = () => { controls.autoRotate = el('autoRot').checked; };
addRange(el('ctlCam'), 'rotSpeed', 'Rotations-Tempo', 0.2, 6, 0.1, () => controls.autoRotateSpeed = P.rotSpeed);
addRange(el('ctlCam'), 'fov', 'Sichtfeld (FOV)', 20, 90, 1, () => { camera.fov = P.fov; camera.updateProjectionMatrix(); });
el('camReset').onclick = () => resetView();
applyPrimitive();

/* ============================== Transport / Loop ============================== */
const ICON_PLAY = 'M8 5v14l11-7z', ICON_PAUSE = 'M6 5h4v14H6zM14 5h4v14h-4z';
function setPlaying(b) { playing = b; el('playIcon').setAttribute('d', b ? ICON_PAUSE : ICON_PLAY); }
el('play').onclick = () => setPlaying(!playing);
window.addEventListener('keydown', e => { if (e.code === 'Space' && model) { e.preventDefault(); setPlaying(!playing); } });
let scrubbing = false;
el('scrub').addEventListener('pointerdown', () => scrubbing = true);
window.addEventListener('pointerup', () => scrubbing = false);
el('scrub').addEventListener('input', () => { if (duration > 0) t = (el('scrub').value / 1000) * duration; });

rebuildGrid();
let last = performance.now();
renderer.setAnimationLoop(now => {
  const dt = clamp((now - last) / 1000, 0, 0.05); last = now;
  if (model) {
    if (playing && duration > 0) t = (t + dt * P.speed) % duration;
    poseAt(t);
    updateSkel();
    updateAgents(dt);
    el('tCur').textContent = Math.min(t, duration).toFixed(2);
    if (duration > 0 && !scrubbing) el('scrub').value = Math.round(t / duration * 1000);
  }
  updateLightRig(false);
  controls.update();
  renderer.render(scene, camera);
});
</script>

</body>
</html>
Assets
normal_map.jpg  (1,4 MB)