floating_body

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/61f534233dea6ef3/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
bonemapping.json
{
  "version": 1,
  "generated": "2026-09-02T21:51:09.871Z",
  "globals": {
    "rotationDamping": 0.99,
    "positionDamping": 0.99,
    "jointLimitScale": 1,
    "limitStiffness": 40,
    "limitSoftZone": 0.7,
    "compliance": 1,
    "hingeBoost": 2.2,
    "leafCalm": 0.6,
    "restReturn": 0,
    "hipReturnStrength": 0.15,
    "colliderScale": 0.4,
    "limbInertia": 0.25,
    "angVelClamp": 25,
    "selfCollision": true,
    "selfCollisionStrength": 10,
    "attractForce": 9,
    "dragDamping": 1.2,
    "maxForce": 60,
    "forceRampSeconds": 0.25,
    "bodyCoupling": 0.25,
    "torqueGain": 1.2,
    "chainFalloff": 0.85,
    "noiseEnabled": true,
    "noiseTorque": 0.15,
    "noiseHipMove": 0.05,
    "noiseHipRotate": 0.05,
    "noiseSpeed": 0.15,
    "noiseSpread": 0.6
  },
  "bones": {
    "mixamorig1Hips": {
      "flex": 20,
      "ext": 16,
      "lat": 16,
      "twist": 24,
      "inertia": 3,
      "hinge": false,
      "noiseGain": 1,
      "axisT": [
        0,
        0.992705,
        -0.120569
      ],
      "axisH": [
        0.996349,
        -0.010293,
        -0.084747
      ]
    },
    "mixamorig1Spine": {
      "flex": 22,
      "ext": 12,
      "lat": 18,
      "twist": 14,
      "inertia": 2.2,
      "hinge": false,
      "noiseGain": 1,
      "axisT": [
        0,
        1,
        0.000002
      ],
      "axisH": [
        0.996349,
        0,
        -0.08537
      ]
    },
    "mixamorig1LeftUpLeg": {
      "flex": 80.5,
      "ext": 131.5,
      "lat": 86,
      "twist": 35,
      "inertia": 1.5,
      "hinge": false,
      "noiseGain": 1,
      "axisT": [
        0,
        1,
        0
      ],
      "axisH": [
        0.995933,
        0,
        0.090102
      ]
    },
    "mixamorig1RightUpLeg": {
      "flex": 80,
      "ext": 130,
      "lat": 86,
      "twist": 35,
      "inertia": 1.5,
      "hinge": false,
      "noiseGain": 1,
      "axisT": [
        0,
        1,
        0
      ],
      "axisH": [
        0.996832,
        0,
        0.079533
      ]
    },
    "mixamorig1Spine1": {
      "flex": 26,
      "ext": 15,
      "lat": 20,
      "twist": 16,
      "inertia": 1.8,
      "hinge": false,
      "noiseGain": 1,
      "axisT": [
        0,
        1,
        0
      ],
      "axisH": [
        0.996349,
        0,
        -0.08537
      ]
    },
    "mixamorig1LeftLeg": {
      "flex": 112,
      "ext": 13.5,
      "lat": 6,
      "twist": 10,
      "inertia": 0.5,
      "hinge": true,
      "noiseGain": 1,
      "axisT": [
        0,
        1,
        0
      ],
      "axisH": [
        -0.995971,
        0,
        -0.089671
      ]
    },
    "mixamorig1RightLeg": {
      "flex": 135,
      "ext": 14.5,
      "lat": 6,
      "twist": 10,
      "inertia": 0.5,
      "hinge": true,
      "noiseGain": 1,
      "axisT": [
        0,
        1,
        0
      ],
      "axisH": [
        -0.996784,
        0,
        -0.080134
      ]
    },
    "mixamorig1Spine2": {
      "flex": 26,
      "ext": 15,
      "lat": 20,
      "twist": 16,
      "inertia": 1.8,
      "hinge": false,
      "noiseGain": 1,
      "axisT": [
        0,
        1,
        -0.000005
      ],
      "axisH": [
        0.996349,
        0,
        -0.08537
      ]
    },
    "mixamorig1LeftFoot": {
      "flex": 45,
      "ext": 28,
      "lat": 12,
      "twist": 14,
      "inertia": 0.45,
      "hinge": false,
      "noiseGain": 0.6,
      "axisT": [
        0,
        1,
        0
      ],
      "axisH": [
        -0.995091,
        0,
        -0.098962
      ]
    },
    "mixamorig1RightFoot": {
      "flex": 45,
      "ext": 28,
      "lat": 12,
      "twist": 14,
      "inertia": 0.45,
      "hinge": false,
      "noiseGain": 0.6,
      "axisT": [
        -0.000001,
        1,
        0
      ],
      "axisH": [
        -0.995108,
        -0.000001,
        0.098796
      ]
    },
    "mixamorig1Neck": {
      "flex": 32,
      "ext": 28,
      "lat": 26,
      "twist": 40,
      "inertia": 0.6,
      "hinge": false,
      "noiseGain": 0.7,
      "axisT": [
        0,
        0.959534,
        0.281592
      ],
      "axisH": [
        0.996094,
        0.024864,
        -0.084726
      ]
    },
    "mixamorig1LeftShoulder": {
      "flex": 18,
      "ext": 14,
      "lat": 16,
      "twist": 10,
      "inertia": 1.6,
      "hinge": false,
      "noiseGain": 0.8,
      "axisT": [
        -0.000007,
        1,
        0.000001
      ],
      "axisH": [
        0.104046,
        0,
        0.994572
      ]
    },
    "mixamorig1RightShoulder": {
      "flex": 18,
      "ext": 14,
      "lat": 16,
      "twist": 10,
      "inertia": 1.6,
      "hinge": false,
      "noiseGain": 0.8,
      "axisT": [
        0.000002,
        1,
        -0.000001
      ],
      "axisH": [
        0.146078,
        -0.000001,
        -0.989273
      ]
    },
    "mixamorig1LeftToeBase": {
      "flex": 30,
      "ext": 14,
      "lat": 5,
      "twist": 5,
      "inertia": 0.5,
      "hinge": true,
      "noiseGain": 0.25,
      "axisT": [
        0,
        1,
        0
      ],
      "axisH": [
        -0.999876,
        0,
        0.015752
      ]
    },
    "mixamorig1RightToeBase": {
      "flex": 30,
      "ext": 14,
      "lat": 5,
      "twist": 5,
      "inertia": 0.5,
      "hinge": true,
      "noiseGain": 0.25,
      "axisT": [
        0,
        1,
        0
      ],
      "axisH": [
        -0.999849,
        0,
        -0.017354
      ]
    },
    "mixamorig1Head": {
      "flex": 22,
      "ext": 52,
      "lat": 52,
      "twist": 61.5,
      "inertia": 0.7,
      "hinge": false,
      "noiseGain": 0.35,
      "axisT": [
        0,
        0.959534,
        0.281592
      ],
      "axisH": [
        0.996094,
        0.024864,
        -0.084726
      ]
    },
    "mixamorig1LeftArm": {
      "flex": 121.5,
      "ext": 50,
      "lat": 85,
      "twist": 55,
      "inertia": 0.9,
      "hinge": false,
      "noiseGain": 0.18,
      "axisT": [
        0.000002,
        1,
        0.000003
      ],
      "axisH": [
        0.111017,
        -0.000003,
        0.993819
      ]
    },
    "mixamorig1RightArm": {
      "flex": 95,
      "ext": 50,
      "lat": 85,
      "twist": 55,
      "inertia": 0.9,
      "hinge": false,
      "noiseGain": 0.16,
      "axisT": [
        -0.000001,
        1,
        0.000004
      ],
      "axisH": [
        0.119616,
        0.000004,
        -0.99282
      ]
    },
    "mixamorig1LeftForeArm": {
      "flex": 130,
      "ext": 3,
      "lat": 8,
      "twist": 70,
      "inertia": 0.35,
      "hinge": true,
      "noiseGain": 1,
      "axisT": [
        -0.000001,
        1,
        0
      ],
      "axisH": [
        0.124964,
        0,
        0.992161
      ]
    },
    "mixamorig1RightForeArm": {
      "flex": 134,
      "ext": 3,
      "lat": 8,
      "twist": 70,
      "inertia": 0.35,
      "hinge": true,
      "noiseGain": 1,
      "axisT": [
        0.000003,
        1,
        0.000002
      ],
      "axisH": [
        0.114442,
        0.000002,
        -0.99343
      ]
    },
    "mixamorig1LeftHand": {
      "flex": 24.5,
      "ext": 27,
      "lat": 10.5,
      "twist": 45,
      "inertia": 0.85,
      "hinge": false,
      "noiseGain": 0.06,
      "axisT": [
        -0.572094,
        0.785178,
        0.237073
      ],
      "axisH": [
        -0.178072,
        -0.401059,
        0.898578
      ]
    },
    "mixamorig1RightHand": {
      "flex": 25,
      "ext": 25,
      "lat": 10,
      "twist": 15,
      "inertia": 0.85,
      "hinge": false,
      "noiseGain": 0.04,
      "axisT": [
        0.563317,
        0.791627,
        0.236645
      ],
      "axisH": [
        -0.100214,
        0.349759,
        -0.931464
      ]
    }
  }
}
index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MIMICRY — The Floating Body</title>
<style>
  :root{
    --bg-top:#ffffff;
    --bg-bottom:#e9e9ea;
    --panel-bg:#ffffff;
    --panel-border:#e2e2e4;
    --text:#1a1a1a;
    --text-dim:#8a8a8e;
    --accent:#2f6fed;
    --warn:#d0473e;
  }
  *{ box-sizing:border-box; }
  html,body{
    margin:0; padding:0; width:100%; height:100%;
    background:linear-gradient(to bottom, var(--bg-top) 0%, var(--bg-top) 45%, var(--bg-bottom) 100%);
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
    color:var(--text);
    overflow:hidden;
  }
  #canvas-wrap{ position:fixed; inset:0; }
  canvas{ display:block; width:100%; height:100%; }

  #hint{
    position:fixed; left:20px; bottom:16px;
    font-size:11px; letter-spacing:.02em; color:var(--text-dim);
    line-height:1.5; pointer-events:none; user-select:none;
  }
  #status{
    position:fixed; left:20px; top:38px;
    font-size:11px; letter-spacing:.02em; color:var(--text-dim);
    line-height:1.5; pointer-events:none; user-select:none;
    max-width:280px;
  }
  #title{
    position:fixed; left:20px; top:16px;
    font-size:11px; font-weight:600; letter-spacing:.06em; color:var(--text);
    text-transform:uppercase; pointer-events:none; user-select:none;
  }

  /* ---------- MAPPING PANEL ---------- */
  #mapping-panel{
    position:fixed; left:20px; top:60px; width:352px;
    max-height:calc(100vh - 100px);
    display:none; flex-direction:column;
    background:var(--panel-bg);
    border:1px solid var(--panel-border);
    border-radius:6px;
    box-shadow:0 8px 24px rgba(0,0,0,0.06);
    font-size:11px; overflow:hidden;
  }
  #mapping-panel.on{ display:flex; }
  #mapping-head{
    padding:8px 10px; border-bottom:1px solid var(--panel-border);
    display:flex; gap:6px; flex-wrap:wrap; align-items:center;
  }
  #mapping-head .label{
    font-weight:600; letter-spacing:.06em; text-transform:uppercase;
    font-size:10px; margin-right:auto;
  }
  #mapping-panel button{
    font:inherit; font-size:10px; padding:3px 8px;
    background:#f3f3f4; color:var(--text);
    border:1px solid var(--panel-border); border-radius:4px; cursor:pointer;
  }
  #mapping-panel button:hover{ background:#eaeaec; }
  #mapping-list{ overflow-y:auto; max-height:38vh; }
  .mrow{
    padding:5px 10px; border-bottom:1px solid #f2f2f3; cursor:pointer; line-height:1.45;
  }
  .mrow:hover{ background:#fafafb; }
  .mrow.sel{ background:#f0f5ff; box-shadow:inset 2px 0 0 var(--accent); }
  .mname{ font-weight:600; font-size:10.5px; letter-spacing:.01em; }
  .mtag{ color:var(--text-dim); font-weight:400; font-size:9.5px; margin-left:5px; }
  .mvals{ color:var(--text-dim); font-size:10px; font-variant-numeric:tabular-nums; }
  .mvals b{ color:var(--text); font-weight:600; }
  .mvals .hot{ color:var(--warn); font-weight:600; }
  #mapping-editor{ border-top:1px solid var(--panel-border); }
  #mapping-editor .lil-gui{ border:none; box-shadow:none; width:100% !important; max-width:100%; }
  #mapping-note{ padding:6px 10px; color:var(--text-dim); font-size:10px; border-top:1px solid var(--panel-border); }

  .lil-gui{
    --background-color: var(--panel-bg);
    --text-color: var(--text);
    --title-background-color: #ffffff;
    --title-text-color: var(--text);
    --widget-color: #f3f3f4;
    --hover-color: #eaeaec;
    --focus-color: #e2e2e4;
    --number-color: #2f6fed;
    --string-color: #2f6fed;
    --font-size: 11px;
    --input-font-size: 11px;
    --padding: 4px;
    --spacing: 4px;
    --widget-height: 18px;
    --name-width: 46%;
    --slider-knob-width: 2px;
    --border-radius: 6px;
    border:1px solid var(--panel-border);
    box-shadow:0 8px 24px rgba(0,0,0,0.06);
  }
  .lil-gui.root > .title{ border-radius:6px 6px 0 0; font-weight:600; letter-spacing:.02em; }
  .lil-gui .title{ border-bottom:1px solid var(--panel-border); }
  .lil-gui input[type=text], .lil-gui input[type=number]{ color:var(--text); }
</style>
</head>
<body>
<div id="canvas-wrap"></div>
<div id="title">MIMICRY — The Floating Body</div>
<div id="status">lade Modell …</div>
<div id="hint"></div>

<div id="mapping-panel">
  <div id="mapping-head">
    <span class="label">Mapping</span>
    <button id="btn-export">JSON speichern</button>
    <button id="btn-import">JSON laden</button>
    <button id="btn-zero">Pose 0</button>
    <button id="btn-extreme">Limit-Test</button>
  </div>
  <div id="mapping-list"></div>
  <div id="mapping-editor"></div>
  <div id="mapping-note">Animation und Physik sind aus. Bone anklicken zum Auswählen — im Viewport ziehen bewegt das Gelenk direkt, hart begrenzt durch die Constraints. Beugung/Streckung und Seitlich bilden zusammen einen elliptischen Bewegungskegel.</div>
</div>
<input type="file" id="file-input" accept="application/json,.json" style="display:none" />

<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/",
    "lil-gui": "https://unpkg.com/lil-gui@0.19/dist/lil-gui.esm.js"
  }
}
</script>

<script type="module">
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import GUI from "lil-gui";

/* ============================================================
   KONFIGURATION
   ============================================================ */
const CONFIG = {
  modelPath: "./smoothed_body_for_MIMICRY_2.glb",
  cubemapDir: "./cubemap/",
  cubemapFiles: ["px.jpg", "nx.jpg", "py.jpg", "ny.jpg", "pz.jpg", "nz.jpg"],
  normalMapPath: "./normal_map.jpg",
  mappingPath: "./bonemapping.json",
};

const HINT_PLAY = "Linksklick + halten auf dem Körper — ziehen · Rechtsklick + ziehen — Kamera orbiten · Mausrad — Zoom (FOV) · graue Punkte = Fokus";
const HINT_MAP  = "MAPPING — links ziehen bewegt das Gelenk direkt · Rechtsklick orbiten · Mausrad Zoom";

const statusEl = document.getElementById("status");
const setStatus = (msg) => { statusEl.textContent = msg; };
document.getElementById("hint").textContent = HINT_PLAY;

// bonemapping.json früh anfragen — wird angewandt, sobald das Rig steht.
const mappingPromise = fetch(CONFIG.mappingPath, { cache: "no-store" })
  .then((r) => (r.ok ? r.json() : null))
  .catch(() => null);

const DEG = THREE.MathUtils.radToDeg;
const RAD = THREE.MathUtils.degToRad;
const clamp = THREE.MathUtils.clamp;
const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };

/* ============================================================
   PARAMETER
   ============================================================ */
const params = {
  mappingMode: false,
  mapDragSpeed: 0.35,
  mapChainDepth: 1,

  // ---- Choreografie ------------------------------------------------
  choreoEnabled: true,
  choreoAmount: 1.0,      // Gesamt-Intensität
  choreoSpeed: 0.35,      // Zeitmaßstab aller Felder
  fieldScale: 0.8,        // räumliche Frequenz des Perlin-Feldes
  focusRadius: 1.0,       // mittlerer Abstand der Fokuspunkte (× Körpergröße/1.75)
  focusSpeed: 0.35,       // Tempo der Fokuspunkte im Feld
  dialogue: 0.6,          // Kopplung der beiden Fokuspunkte (Anziehung / Reibung)
  dialogueDist: 1.0,      // mittlerer Abstand zwischen den Fokuspunkten
  repelShare: 0.4,        // Anteil Abstoßung vs. Anziehung bei Armen/Beinen
  limbForce: 3.0,         // Zug/Schub der Gliedmaßen auf die Fokuspunkte
  limbDamping: 3.0,
  limbMaxForce: 12,
  torsoAim: 0.6,          // Körper + Brust richten sich an den Fokuspunkten aus
  headLook: 0.5,          // Blick
  upright: 0.12,          // leichte Tendenz zur Aufrichtung (0 = völlig frei)
  posture: 0.4,           // Wellen/Haltung entlang der Kette (Perlin-Feld im Raum)
  postureStiffness: 5,
  postureLag: 0.35,       // Verzögerung entlang der Kette (s) → Nachschwingen
  driftForce: 0.12,
  driftSpin: 0.10,
  showFocus: true,

  // ---- Physik / Muskeln ---------------------------------------------
  jointDamping: 2.4,      // 1/s — viskose Gelenkdämpfung
  bodyDamping: 1.6,       // 1/s — Ganzkörper Position + Rotation
  bodyMass: 1.0,
  jointLimitScale: 1.0,
  limitStiffness: 40,
  limitSoftZone: 0.7,     // ab hier beginnt der weiche Anschlag
  limitDamping: 30,       // Bremsung der Auswärtsbewegung im Anschlag
  compliance: 1.0,
  hingeBoost: 2.2,
  leafCalm: 0.6,
  restReturn: 0.0,
  hipReturnStrength: 0.15,
  limbInertia: 0.25,
  angVelClamp: 25,

  // ---- Kollision -----------------------------------------------------
  selfCollision: true,
  collisionScale: 0.92,   // Radius-Skalierung der gemessenen Kapseln
  collisionPadding: 0.004,
  collisionStiffness: 60,
  collisionDamping: 10,
  collisionMaxForce: 60,
  collisionProject: 0.45, // positionelle Korrektur (0..1) — hält Glieder garantiert auseinander
  collisionMaxStep: 0.005, // max. Auseinanderschieben pro Substep in m — verhindert Ruckeln
  showColliders: false,

  // ---- Ziehen --------------------------------------------------------
  attractForce: 30,
  dragDamping: 8,
  maxForce: 150,
  forceRampSeconds: 0.04,
  bodyCoupling: 0.25,
  torsoDragCoupling: 0.9, // Torso/Hüfte ziehen = ganzer Körper folgt
  torqueGain: 1.2,
  chainFalloff: 0.85,
  dragBoost: 1.6,
  dragChoreoFloor: 0.1,   // Choreografie-Anteil während des Ziehens
  pickScale: 1.0,

  color: "#ffffff", metalness: 1.0, roughness: 0.08, envMapIntensity: 1.2,
  clearcoat: 0.0, clearcoatRoughness: 0.1, normalMapTiling: 12, normalMapStrength: 0.2,

  lightIntensity: 3.2, lightColor: "#ffffff", lightAzimuth: 35, lightElevation: 55, ambient: 0.12,

  camLookSpeed: 2.2, camDistance: 3.2, camAzimuth: 25, camElevation: 10, fov: 35,
  showOrientation: false,
};

// Diese Werte wandern mit in die JSON (ab version 2).
const GLOBAL_KEYS = [
  "choreoEnabled","choreoAmount","choreoSpeed","fieldScale","focusRadius","focusSpeed","dialogue","dialogueDist",
  "repelShare","limbForce","limbDamping","limbMaxForce","torsoAim","headLook","upright",
  "posture","postureStiffness","postureLag","driftForce","driftSpin",
  "jointDamping","bodyDamping","bodyMass","jointLimitScale","limitStiffness","limitSoftZone","limitDamping",
  "compliance","hingeBoost","leafCalm","restReturn","hipReturnStrength","limbInertia","angVelClamp",
  "selfCollision","collisionScale","collisionPadding","collisionStiffness","collisionDamping","collisionMaxForce","collisionProject","collisionMaxStep",
  "attractForce","dragDamping","maxForce","forceRampSeconds","bodyCoupling","torsoDragCoupling","torqueGain","chainFalloff",
  "dragBoost","dragChoreoFloor","pickScale",
];

/* ============================================================
   SCENE / RENDERER
   ============================================================ */
const wrap = document.getElementById("canvas-wrap");
const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.05, 100);
const cameraRig = { targetLook: new THREE.Vector3(0, 0, 0) };

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.BasicShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
wrap.appendChild(renderer.domElement);

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

/* ============================================================
   LICHT
   ============================================================ */
const keyLight = new THREE.DirectionalLight(0xffffff, 3.2);
keyLight.position.set(2.4, 3.4, 2.0);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.camera.near = 0.1;
keyLight.shadow.camera.far = 10;
keyLight.shadow.camera.left = -1.5;
keyLight.shadow.camera.right = 1.5;
keyLight.shadow.camera.top = 2;
keyLight.shadow.camera.bottom = -1.5;
keyLight.shadow.bias = -0.0015;
keyLight.shadow.radius = 1;
scene.add(keyLight);
scene.add(keyLight.target);

const fill = new THREE.HemisphereLight(0xffffff, 0x606066, 0.12);
scene.add(fill);

/* ============================================================
   MATERIAL
   ============================================================ */
const chromeMaterial = new THREE.MeshPhysicalMaterial({
  color: new THREE.Color(0xffffff),
  metalness: 1.0, roughness: 0.08, envMapIntensity: 1.2,
  clearcoat: 0.0, clearcoatRoughness: 0.1, reflectivity: 1.0,
  normalScale: new THREE.Vector2(1, 1),
});

let normalTex = null;
new THREE.TextureLoader().load(
  CONFIG.normalMapPath,
  (tex) => {
    tex.wrapS = THREE.MirroredRepeatWrapping;
    tex.wrapT = THREE.MirroredRepeatWrapping;
    tex.repeat.set(params.normalMapTiling, params.normalMapTiling);
    normalTex = tex;
    chromeMaterial.normalMap = tex;
    chromeMaterial.normalScale.set(params.normalMapStrength, params.normalMapStrength);
    chromeMaterial.needsUpdate = true;
  },
  undefined,
  () => { console.warn("Normal Map nicht gefunden:", CONFIG.normalMapPath); }
);

/* ============================================================
   CUBEMAP
   ============================================================ */
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();

function buildFallbackEnvironment() {
  const geo = new THREE.SphereGeometry(6, 32, 32);
  const canvas = document.createElement("canvas");
  canvas.width = 8; canvas.height = 256;
  const ctx = canvas.getContext("2d");
  const grad = ctx.createLinearGradient(0, 0, 0, 256);
  grad.addColorStop(0.0, "#ffffff"); grad.addColorStop(0.45, "#f4f4f5");
  grad.addColorStop(0.55, "#dcdcdf"); grad.addColorStop(1.0, "#8d8d92");
  ctx.fillStyle = grad; ctx.fillRect(0, 0, 8, 256);
  const tex = new THREE.CanvasTexture(canvas);
  tex.colorSpace = THREE.SRGBColorSpace;
  const mat = new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide });
  const s = new THREE.Scene();
  s.add(new THREE.Mesh(geo, mat));
  const rt = pmrem.fromScene(s, 0.02);
  s.remove(s.children[0]); geo.dispose(); mat.dispose(); tex.dispose();
  return rt.texture;
}

new THREE.CubeTextureLoader().setPath(CONFIG.cubemapDir).load(
  CONFIG.cubemapFiles,
  (cubeTex) => {
    cubeTex.colorSpace = THREE.SRGBColorSpace;
    chromeMaterial.envMap = pmrem.fromCubemap(cubeTex).texture;
    chromeMaterial.needsUpdate = true;
    cubeTex.dispose();
  },
  undefined,
  () => {
    chromeMaterial.envMap = buildFallbackEnvironment();
    chromeMaterial.needsUpdate = true;
    setStatus("Cubemap in " + CONFIG.cubemapDir + " nicht gefunden — nutze Fallback-Reflektion.");
  }
);

/* ============================================================
   QUATERNION-MATHE
   ============================================================ */
const _va = new THREE.Vector3();
const _vb = new THREE.Vector3();
const _vc = new THREE.Vector3();
const _qa = new THREE.Quaternion();
const _qb = new THREE.Quaternion();
const _qc = new THREE.Quaternion();

function angleAxisFromQuat(q, axisOut) {
  let x = q.x, y = q.y, z = q.z, w = clamp(q.w, -1, 1);
  if (w < 0) { x = -x; y = -y; z = -z; w = -w; }
  const angle = 2 * Math.acos(w);
  const s = Math.sqrt(Math.max(1 - w * w, 0));
  if (s < 1e-7) { axisOut.set(1, 0, 0); return 0; }
  axisOut.set(x / s, y / s, z / s);
  return angle;
}

function swingTwistDecompose(q, axis, swingOut, twistOut) {
  const d = q.x * axis.x + q.y * axis.y + q.z * axis.z;
  twistOut.set(axis.x * d, axis.y * d, axis.z * d, q.w);
  const len = Math.hypot(twistOut.x, twistOut.y, twistOut.z, twistOut.w);
  if (len < 1e-9) twistOut.set(0, 0, 0, 1);
  else { twistOut.x /= len; twistOut.y /= len; twistOut.z /= len; twistOut.w /= len; }
  if (twistOut.w < 0) twistOut.set(-twistOut.x, -twistOut.y, -twistOut.z, -twistOut.w);
  swingOut.copy(q).multiply(_qc.copy(twistOut).invert());
}

function signedTwistAngle(twistQ, axis) {
  const d = twistQ.x * axis.x + twistQ.y * axis.y + twistQ.z * axis.z;
  return 2 * Math.atan2(d, twistQ.w);
}

function quatFromAngularVelocity(angVel, dt, target) {
  const w = angVel.length();
  const theta = w * dt;
  if (theta < 1e-9) { target.set(0, 0, 0, 1); return target; }
  const half = theta * 0.5, s = Math.sin(half) / w;
  target.set(angVel.x * s, angVel.y * s, angVel.z * s, Math.cos(half));
  return target;
}

function closestRaySegment(rayOrigin, rayDir, segA, segB) {
  const ab = new THREE.Vector3().subVectors(segB, segA);
  const ao = new THREE.Vector3().subVectors(rayOrigin, segA);
  const t = clamp(ao.dot(ab) / Math.max(ab.lengthSq(), 1e-10), 0, 1);
  const segPoint = segA.clone().addScaledVector(ab, t);
  const s = new THREE.Vector3().subVectors(segPoint, rayOrigin).dot(rayDir);
  const rayPoint = rayOrigin.clone().addScaledVector(rayDir, s);
  return { s, rayPoint, segPoint, dist: rayPoint.distanceTo(segPoint) };
}

// Segment–Segment: nächstes Punktpaar (Ericson, Real-Time Collision Detection)
function segSegClosest(p1, q1, p2, q2, c1, c2) {
  const d1x = q1.x - p1.x, d1y = q1.y - p1.y, d1z = q1.z - p1.z;
  const d2x = q2.x - p2.x, d2y = q2.y - p2.y, d2z = q2.z - p2.z;
  const rx = p1.x - p2.x, ry = p1.y - p2.y, rz = p1.z - p2.z;
  const a = d1x * d1x + d1y * d1y + d1z * d1z;
  const e = d2x * d2x + d2y * d2y + d2z * d2z;
  const f = d2x * rx + d2y * ry + d2z * rz;
  const EPS = 1e-9;
  let s, t;
  if (a <= EPS && e <= EPS) { s = 0; t = 0; }
  else if (a <= EPS) { s = 0; t = clamp(f / e, 0, 1); }
  else {
    const c = d1x * rx + d1y * ry + d1z * rz;
    if (e <= EPS) { t = 0; s = clamp(-c / a, 0, 1); }
    else {
      const b = d1x * d2x + d1y * d2y + d1z * d2z;
      const denom = a * e - b * b;
      s = denom > EPS ? clamp((b * f - c * e) / denom, 0, 1) : 0;
      t = (b * s + f) / e;
      if (t < 0) { t = 0; s = clamp(-c / a, 0, 1); }
      else if (t > 1) { t = 1; s = clamp((b - c) / a, 0, 1); }
    }
  }
  c1.set(p1.x + d1x * s, p1.y + d1y * s, p1.z + d1z * s);
  c2.set(p2.x + d2x * t, p2.y + d2y * t, p2.z + d2z * t);
  return c1.distanceToSquared(c2);
}

function distPointSegment(p, a, b) {
  const abx = b.x - a.x, aby = b.y - a.y, abz = b.z - a.z;
  const l2 = abx * abx + aby * aby + abz * abz;
  let t = l2 > 1e-12 ? ((p.x - a.x) * abx + (p.y - a.y) * aby + (p.z - a.z) * abz) / l2 : 0;
  t = clamp(t, 0, 1);
  const dx = p.x - (a.x + abx * t), dy = p.y - (a.y + aby * t), dz = p.z - (a.z + abz * t);
  return Math.sqrt(dx * dx + dy * dy + dz * dz);
}

const wpos = (bone, out) => out.setFromMatrixPosition(bone.matrixWorld);

/* ============================================================
   3D PERLIN-NOISE + CURL-FELD
   Das Feld ist die zentrale Steuerung: Es schiebt die Fokuspunkte
   durch den Raum und liefert die räumlich kohärente Haltung.
   ============================================================ */
const NOISE_PERM = (() => {
  const p = new Uint8Array(256);
  for (let i = 0; i < 256; i++) p[i] = i;
  let seed = 1337;
  const rnd = () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; };
  for (let i = 255; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [p[i], p[j]] = [p[j], p[i]]; }
  const perm = new Uint8Array(512);
  for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
  return perm;
})();
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function nlerp(t, a, b) { return a + t * (b - a); }
function grad(hash, x, y, z) {
  const h = hash & 15;
  const u = h < 8 ? x : y;
  const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
  return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
}
function noise3(x, y, z) {
  const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
  x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
  const u = fade(x), v = fade(y), w = fade(z);
  const p = NOISE_PERM;
  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 nlerp(w,
    nlerp(v, nlerp(u, grad(p[AA], x, y, z), grad(p[BA], x - 1, y, z)),
             nlerp(u, grad(p[AB], x, y - 1, z), grad(p[BB], x - 1, y - 1, z))),
    nlerp(v, nlerp(u, grad(p[AA + 1], x, y, z - 1), grad(p[BA + 1], x - 1, y, z - 1)),
             nlerp(u, grad(p[AB + 1], x, y - 1, z - 1), grad(p[BB + 1], x - 1, y - 1, z - 1))));
}
// drei entkoppelte Kanäle desselben Feldes (bewusst mit nicht-ganzzahligen Offsets)
function noiseVec3(seed, t, out) {
  const a = seed * 0.1371 + 0.31, b = seed * 0.0713 + 0.57;
  out.set(
    noise3(a, t, b),
    noise3(a + 37.7, t + 5.1, b + 11.3),
    noise3(a + 71.3, t + 13.9, b + 23.7)
  ).multiplyScalar(1.45);
  return out;
}
// divergenzfreies Feld: Fokuspunkte kreisen und wirbeln, statt zufällig zu zittern
function curlNoise(x, y, z, t, out) {
  const e = 0.05;
  const P = (k, X, Y, Z) => {
    if (k === 0) return noise3(X + 0.0 + t * 0.31, Y + 7.3 + t * 0.17, Z + 1.9 - t * 0.23);
    if (k === 1) return noise3(X + 31.7 - t * 0.19, Y + 2.1 + t * 0.29, Z + 9.4 + t * 0.13);
    return noise3(X + 12.9 + t * 0.11, Y + 27.3 - t * 0.21, Z + 4.7 + t * 0.27);
  };
  const inv = 1 / (2 * e);
  out.set(
    ((P(2, x, y + e, z) - P(2, x, y - e, z)) - (P(1, x, y, z + e) - P(1, x, y, z - e))) * inv,
    ((P(0, x, y, z + e) - P(0, x, y, z - e)) - (P(2, x + e, y, z) - P(2, x - e, y, z))) * inv,
    ((P(1, x + e, y, z) - P(1, x - e, y, z)) - (P(0, x, y + e, z) - P(0, x, y - e, z))) * inv
  );
  return out;
}

/* ============================================================
   GELENK-SPEZIFIKATION
   noiseGain = Anteil dieses Gelenks an der Haltungs-/Wellen-Animation.
   ============================================================ */
const JOINT_SPECS = [
  { test: /forearm/i,  aim: "forward", flex: 150, ext: 3,  lat: 8,  twist: 70, inertia: 0.35, hinge: true, noiseGain: 1.0 },
  { test: /arm$/i,     aim: "forward", flex: 95,  ext: 50, lat: 85, twist: 55, inertia: 0.9,  noiseGain: 1.0 },
  { test: /shoulder/i, aim: "forward", flex: 18,  ext: 14, lat: 16, twist: 10, inertia: 1.6,  noiseGain: 0.8 },
  { test: /hand$/i,    aim: "forward", flex: 60,  ext: 55, lat: 25, twist: 15, inertia: 0.85, noiseGain: 0.22 },
  { test: /upleg/i,    aim: "forward", flex: 110, ext: 25, lat: 45, twist: 35, inertia: 1.5,  noiseGain: 1.0 },
  { test: /leg$/i,     aim: "back",    flex: 145, ext: 3,  lat: 6,  twist: 10, inertia: 0.5,  hinge: true, noiseGain: 1.0 },
  { test: /toebase/i,  aim: "down",    flex: 30,  ext: 14, lat: 5,  twist: 5,  inertia: 0.5,  hinge: true, noiseGain: 0.25 },
  { test: /foot/i,     aim: "down",    flex: 45,  ext: 28, lat: 12, twist: 14, inertia: 0.45, noiseGain: 0.6 },
  { test: /neck/i,     aim: "forward", flex: 32,  ext: 28, lat: 26, twist: 40, inertia: 0.6,  noiseGain: 0.7 },
  { test: /head/i,     aim: "forward", flex: 22,  ext: 18, lat: 18, twist: 30, inertia: 0.7,  noiseGain: 0.35 },
  { test: /spine2/i,   aim: "forward", flex: 26,  ext: 15, lat: 20, twist: 16, inertia: 1.8,  noiseGain: 1.0 },
  { test: /spine1/i,   aim: "forward", flex: 26,  ext: 15, lat: 20, twist: 16, inertia: 1.8,  noiseGain: 1.0 },
  { test: /spine/i,    aim: "forward", flex: 22,  ext: 12, lat: 18, twist: 14, inertia: 2.2,  noiseGain: 1.0 },
  { test: /hips$/i,    aim: "forward", flex: 20,  ext: 16, lat: 16, twist: 24, inertia: 3.0,  noiseGain: 1.0 },
];
const EXCLUDE_FROM_SIM = /thumb|index|middle|ring|pinky|_end/i;
function classifyBone(name) { for (const r of JOINT_SPECS) if (r.test.test(name)) return r; return null; }

const RADIUS_RULES = [
  { test: /hips|spine/i, r: 0.16 }, { test: /head/i, r: 0.12 }, { test: /neck/i, r: 0.07 },
  { test: /shoulder/i, r: 0.10 }, { test: /forearm/i, r: 0.07 }, { test: /arm$/i, r: 0.08 },
  { test: /hand/i, r: 0.06 }, { test: /thumb|index|middle|ring|pinky/i, r: 0.03 },
  { test: /upleg/i, r: 0.12 }, { test: /leg$/i, r: 0.08 }, { test: /toebase/i, r: 0.05 },
  { test: /foot/i, r: 0.07 }, { test: /_end/i, r: 0.04 },
];
function radiusForBone(name) { for (const r of RADIUS_RULES) if (r.test.test(name)) return r.r; return 0.06; }
function pickPrimaryChild(bone) {
  const kids = bone.children.filter((c) => c.isBone);
  if (kids.length === 0) return null;
  return kids.find((c) => !/shoulder/i.test(c.name)) || kids[0];
}
function shortName(n) { return n.replace(/^mixamorig\d*[:_]?/i, ""); }

/* ============================================================
   CHARACTER ROOT + ZUSTAND
   ============================================================ */
const characterRoot = new THREE.Group();
scene.add(characterRoot);

const bodyVel = new THREE.Vector3();
const bodyAngVel = new THREE.Vector3();
const WORLD_UP = new THREE.Vector3(0, 1, 0);

let simBones = [];
let boneEntry = new Map();
let colliders = [];          // Kapseln zum Greifen (Picking)
let skinnedMesh = null;
let orientationGizmo = null;
let bodyScale = 1;
let bodyForwardLocal = new THREE.Vector3(0, 0, 1);
let choreoBlend = 1;         // 1 = Choreografie voll, ↓ beim Ziehen
const _tmpVecA = new THREE.Vector3();
const _tmpVecB = new THREE.Vector3();

// Fokuspunkte (relativ zum Körperzentrum) + Gliedmaßen
const foci = [0, 1].map((i) => ({
  rel: new THREE.Vector3(), v: new THREE.Vector3(), world: new THREE.Vector3(), seed: i === 0 ? 3.17 : 8.53,
}));
let limbs = [];
let chestAim = [];   // [entry, weight]
let headAim = [];

// Kollisionsmodell
let segs = [];
let segPairs = [];

/* ============================================================
   MODELL LADEN + RIG-ANALYSE
   ============================================================ */
new GLTFLoader().load(
  CONFIG.modelPath,
  (gltf) => {
    const root = gltf.scene;

    root.traverse((obj) => {
      if (obj.isMesh) {
        obj.material = chromeMaterial;
        obj.castShadow = true; obj.receiveShadow = true;
        obj.raycast = () => {};
        if (obj.isSkinnedMesh) skinnedMesh = obj;
      }
    });

    characterRoot.add(root);
    scene.updateMatrixWorld(true);

    const box = new THREE.Box3().setFromObject(skinnedMesh || root);
    root.position.sub(box.getCenter(new THREE.Vector3()));
    scene.updateMatrixWorld(true);

    const size = box.getSize(new THREE.Vector3());
    params.camDistance = Math.max(size.y * 1.9, size.x * 2.4, 2.2);
    bodyScale = Math.max(size.y / 1.75, 0.2);

    const bones = skinnedMesh ? skinnedMesh.skeleton.bones : [];

    // Blickrichtung des Rigs (Fuß -> Zehen)
    const forwardWorld = new THREE.Vector3(0, 0, 1);
    const toe = bones.find((b) => /toebase/i.test(b.name));
    if (toe && toe.parent) {
      const a = new THREE.Vector3(), b = new THREE.Vector3();
      toe.parent.getWorldPosition(a); toe.getWorldPosition(b);
      const d = b.sub(a); d.y = 0;
      if (d.lengthSq() > 1e-8) forwardWorld.copy(d).normalize();
    }
    bodyForwardLocal.copy(forwardWorld);
    const AIM = {
      forward: forwardWorld.clone(),
      back: forwardWorld.clone().negate(),
      down: new THREE.Vector3(0, -1, 0),
    };

    for (const bone of bones) {
      if (EXCLUDE_FROM_SIM.test(bone.name)) continue;
      const spec = classifyBone(bone.name);
      if (!spec) continue;

      const child = pickPrimaryChild(bone);
      const axisT = (child && child.position.lengthSq() > 1e-10)
        ? child.position.clone().normalize() : new THREE.Vector3(0, 1, 0);

      const bq = new THREE.Quaternion();
      bone.getWorldQuaternion(bq);
      const bqInv = bq.clone().invert();
      const dLocal = AIM[spec.aim].clone().applyQuaternion(bqInv).normalize();

      let axisH = new THREE.Vector3().crossVectors(axisT, dLocal);
      if (axisH.lengthSq() < 1e-5) {
        const lat = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), AIM[spec.aim]);
        axisH = lat.lengthSq() > 1e-6 ? lat.applyQuaternion(bqInv) : new THREE.Vector3(1, 0, 0);
      }
      axisH.addScaledVector(axisT, -axisH.dot(axisT));
      if (axisH.lengthSq() < 1e-8) axisH.set(1, 0, 0);
      axisH.normalize();
      if (new THREE.Vector3().crossVectors(axisH, axisT).dot(dLocal) < 0) axisH.negate();
      const axisS = new THREE.Vector3().crossVectors(axisT, axisH).normalize();

      simBones.push({
        bone, name: bone.name, short: shortName(bone.name),
        rest: bone.quaternion.clone(),
        dev: new THREE.Quaternion(),
        angVel: new THREE.Vector3(),
        wav: new THREE.Vector3(),
        restWorld: new THREE.Quaternion(),
        restWorldInv: new THREE.Quaternion(),
        prevParentWav: new THREE.Vector3(),
        axisT, axisH, axisS,
        flex: RAD(spec.flex), ext: RAD(spec.ext), lat: RAD(spec.lat), twist: RAD(spec.twist),
        inertia: spec.inertia, hinge: !!spec.hinge, noiseGain: spec.noiseGain ?? 1.0,
        isLeaf: false, seed: 0, idx: 0,
        // Choreografie
        tgtH: 0, tgtS: 0, tgtT: 0, depth: 0, isTorso: false, parentEntry: null,
        restPos: new THREE.Vector3(), fwdLocal: new THREE.Vector3(0, 0, 1),
      });
    }

    simBones.sort((a, b) => depthOf(a.bone) - depthOf(b.bone));
    simBones.forEach((s, i) => { s.idx = i; s.seed = i * 100.0; boneEntry.set(s.bone, s); });
    for (const s of simBones) s.isLeaf = !s.bone.children.some((c) => boneEntry.has(c));

    // Zusatzdaten für die Choreografie
    const hipsBone = (simBones.find((s) => /hips$/i.test(s.name)) || simBones[0]).bone;
    const hipsPos = wpos(hipsBone, new THREE.Vector3());
    for (const s of simBones) {
      s.depth = 0;
      for (let o = s.bone.parent; o; o = o.parent) if (boneEntry.has(o)) s.depth++;
      s.parentEntry = boneEntry.get(s.bone.parent) || null;
      s.isTorso = /hips|spine/i.test(s.name);
      wpos(s.bone, s.restPos).sub(hipsPos);
      const wq = new THREE.Quaternion();
      s.bone.getWorldQuaternion(wq);
      s.fwdLocal.copy(forwardWorld).applyQuaternion(wq.invert());
    }

    // Pick-Kapseln (Radien werden unten durch gemessene Werte ersetzt)
    for (const bone of bones) {
      const kids = bone.children.filter((c) => c.isBone);
      const r = radiusForBone(bone.name) * 0.4;
      if (kids.length === 0) colliders.push({ type: "sphere", owner: bone, a: bone, radius: r });
      else for (const c of kids) colliders.push({ type: "capsule", owner: bone, a: bone, b: c, radius: r });
    }

    setupLimbsAndAim();
    buildCollisionModel(bones);
    initFoci();
    buildFocusVisuals();
    rebuildColliderViz();

    orientationGizmo = buildOrientationGizmo(Math.max(size.y * 0.85, 0.8));
    orientationGizmo.visible = params.showOrientation;
    scene.add(orientationGizmo);

    mappingPromise.then((data) => {
      const note = data ? applyBoneMapping(data) : "";
      buildGUI();
      buildMappingList();
      setStatus(`Modell geladen — ${simBones.length} Gelenke, ${segs.length} Kollisionskapseln.` + (note ? " " + note : ""));
      setTimeout(() => { if (statusEl.textContent.startsWith("Modell")) statusEl.textContent = ""; }, 6000);
    });
  },
  undefined,
  (err) => {
    console.error(err);
    setStatus("Fehler beim Laden von " + CONFIG.modelPath + " — Datei im selben Verzeichnis ablegen.");
  }
);

function depthOf(obj) { let d = 0, o = obj; while (o.parent) { d++; o = o.parent; } return d; }

/* ============================================================
   GLIEDMASSEN, AUSRICHTUNG
   ============================================================ */
function setupLimbsAndAim() {
  const find = (re) => simBones.find((s) => re.test(s.name));
  const defs = [
    { root: /LeftArm$/,     eff: /LeftHand$/,     alt: null,          kind: "arm", seed: 3.7,  lag: 0.0, k: 1.0 },
    { root: /RightArm$/,    eff: /RightHand$/,    alt: null,          kind: "arm", seed: 3.7,  lag: 0.9, k: 1.0 },
    { root: /LeftUpLeg$/,   eff: /LeftToeBase$/,  alt: /LeftFoot$/,   kind: "leg", seed: 11.3, lag: 0.5, k: 0.9 },
    { root: /RightUpLeg$/,  eff: /RightToeBase$/, alt: /RightFoot$/,  kind: "leg", seed: 11.3, lag: 1.4, k: 0.9 },
  ];
  limbs = [];
  for (const d of defs) {
    const r = find(d.root);
    const e = find(d.eff) || (d.alt ? find(d.alt) : null);
    if (!r || !e) continue;
    const reach = wpos(r.bone, new THREE.Vector3()).distanceTo(wpos(e.bone, new THREE.Vector3()));
    limbs.push({
      kind: d.kind, root: r.bone, eff: e.bone, reach: Math.max(reach, 0.1), seed: d.seed, lag: d.lag, k: d.k,
      pol: 0, aff: 0.5, line: null,
    });
  }
  const w = (re, wt) => { const s = find(re); return s ? [s, wt] : null; };
  chestAim = [w(/spine$/i, 0.25), w(/spine1$/i, 0.35), w(/spine2$/i, 0.4)].filter(Boolean);
  headAim = [w(/neck$/i, 0.45), w(/head$/i, 0.55)].filter(Boolean);
}

/* ============================================================
   KOLLISIONSMODELL (gemessene Kapseln + Paarliste)
   ============================================================ */
function segmentEnd(bone) {
  const kids = bone.children.filter((c) => c.isBone);
  if (!kids.length) return null;
  const n = bone.name;
  const pick = (re) => kids.find((c) => re.test(c.name));
  if (/hips$/i.test(n))   return pick(/spine/i) || kids[0];
  if (/spine2$/i.test(n)) return pick(/neck/i) || kids[0];
  if (/spine/i.test(n))   return kids.find((c) => /spine/i.test(c.name)) || kids[0];
  if (/hand$/i.test(n))   return pick(/middle1$/i) || pick(/index1$/i) || kids[0];
  if (/head$/i.test(n))   return pick(/headtop|_end/i) || kids[0];
  return kids.find((c) => !/shoulder/i.test(c.name)) || kids[0];
}
function groupOf(name) {
  const side = /left/i.test(name) ? "L" : /right/i.test(name) ? "R" : "";
  if (/arm|hand/i.test(name)) return "arm" + side;
  if (/leg|foot|toe/i.test(name)) return "leg" + side;
  return "T";
}
const isUpperArm = (n) => /arm$/i.test(n) && !/forearm/i.test(n);

function buildCollisionModel(bones) {
  segs = []; segPairs = [];
  const segByBone = new Map();
  for (const e of simBones) {
    if (!/hips|spine|neck|head|arm|hand|leg|foot|toebase/i.test(e.name)) continue;
    if (/shoulder/i.test(e.name)) continue;
    const end = segmentEnd(e.bone);
    if (!end) continue;
    const s = {
      e, bone: e.bone, end, name: e.short, group: groupOf(e.name),
      a: new THREE.Vector3(), b: new THREE.Vector3(),
      r: radiusForBone(e.name) * 0.6, measured: false, viz: null,
    };
    segs.push(s);
    segByBone.set(e.bone, s);
  }

  // Radien direkt aus den Skinning-Gewichten des Meshes messen
  try { measureRadii(bones, segByBone); }
  catch (err) { console.warn("Radien konnten nicht gemessen werden — nutze Standardwerte.", err); }

  for (const s of segs) { wpos(s.bone, s.a); wpos(s.end, s.b); }

  for (let i = 0; i < segs.length; i++) {
    for (let j = i + 1; j < segs.length; j++) {
      const A = segs[i], B = segs[j];
      if (A.group === B.group) continue;
      if ((isUpperArm(A.name) && /spine2|neck/i.test(B.name)) || (isUpperArm(B.name) && /spine2|neck/i.test(A.name))) continue;
      if ((/upleg/i.test(A.name) && /hips|spine$/i.test(B.name)) || (/upleg/i.test(B.name) && /hips|spine$/i.test(A.name))) continue;
      const d = Math.sqrt(segSegClosest(A.a, A.b, B.a, B.b, _tmpVecA, _tmpVecB));
      // Ruhepose darf nie als "durchdrungen" gelten
      segPairs.push({ A, B, restCap: Math.max(d * 0.97, 0.005) });
    }
  }

  // Greif-Kapseln auf gemessene Radien setzen
  applyPickRadii();
}

function applyPickRadii() {
  for (const c of colliders) {
    const s = segs.find((x) => x.bone === c.owner);
    if (s) c.radius = Math.max(s.r * params.pickScale, 0.02);
  }
}

function measureRadii(bones, segByBone) {
  const mesh = skinnedMesh;
  if (!mesh || !mesh.geometry.attributes.skinIndex) throw new Error("kein Skinning");
  const geo = mesh.geometry;
  const pos = geo.attributes.position, si = geo.attributes.skinIndex, sw = geo.attributes.skinWeight;
  mesh.updateMatrixWorld(true);
  mesh.skeleton.update();
  const bm = mesh.skeleton.boneMatrices;

  for (const s of segs) { wpos(s.bone, s.a); wpos(s.end, s.b); }

  // Knochen -> zuständige Kapsel (Finger → Hand, Schulter → Oberarm, Enden → Elternknochen)
  const owner = bones.map((b) => {
    for (let o = b; o; o = o.parent) {
      const s = segByBone.get(o);
      if (s) return s;
      if (/shoulder/i.test(o.name)) for (const c of o.children) { const s2 = segByBone.get(c); if (s2) return s2; }
    }
    return null;
  });

  const M = new THREE.Matrix4(), v = new THREE.Vector3(), acc = new THREE.Vector3(), tmp = new THREE.Vector3();
  const dists = new Map(segs.map((s) => [s, []]));
  const cmps = [(i) => si.getX(i), (i) => si.getY(i), (i) => si.getZ(i), (i) => si.getW(i)];
  const wcmp = [(i) => sw.getX(i), (i) => sw.getY(i), (i) => sw.getZ(i), (i) => sw.getW(i)];

  for (let i = 0; i < pos.count; i++) {
    let bestW = 0, bestBone = -1;
    for (let k = 0; k < 4; k++) { const w = wcmp[k](i); if (w > bestW) { bestW = w; bestBone = cmps[k](i); } }
    if (bestW < 0.3 || bestBone < 0) continue;
    const s = owner[bestBone];
    if (!s) continue;

    v.fromBufferAttribute(pos, i).applyMatrix4(mesh.bindMatrix);
    acc.set(0, 0, 0);
    for (let k = 0; k < 4; k++) {
      const w = wcmp[k](i); if (w === 0) continue;
      M.fromArray(bm, cmps[k](i) * 16);
      acc.addScaledVector(tmp.copy(v).applyMatrix4(M), w);
    }
    acc.applyMatrix4(mesh.bindMatrixInverse).applyMatrix4(mesh.matrixWorld);
    dists.get(s).push(distPointSegment(acc, s.a, s.b));
  }

  for (const s of segs) {
    const arr = dists.get(s);
    if (arr.length < 12) continue;
    arr.sort((x, y) => x - y);
    const r = arr[Math.floor((arr.length - 1) * 0.72)];
    s.r = clamp(r, 0.02 * bodyScale, 0.3 * bodyScale);
    s.measured = true;
  }
}

/* ---- Debug-Darstellung der Kapseln ---- */
const colliderViz = new THREE.Group();
colliderViz.visible = false;
scene.add(colliderViz);
const _yAxis = new THREE.Vector3(0, 1, 0);
function rebuildColliderViz() {
  for (const s of segs) {
    if (s.viz) { colliderViz.remove(s.viz); s.viz.geometry.dispose(); s.viz.material.dispose(); s.viz = null; }
  }
  for (const s of segs) {
    const len = Math.max(s.a.distanceTo(s.b), 1e-3);
    const r = Math.max(s.r * params.collisionScale, 0.005);
    const m = new THREE.Mesh(
      new THREE.CapsuleGeometry(r, len, 4, 12),
      new THREE.MeshBasicMaterial({ color: 0x2f6fed, wireframe: true, transparent: true, opacity: 0.22, depthTest: false })
    );
    m.renderOrder = 990; m.frustumCulled = false;
    s.viz = m; colliderViz.add(m);
  }
}
function updateColliderViz() {
  colliderViz.visible = params.showColliders && !params.mappingMode;
  if (!colliderViz.visible) return;
  for (const s of segs) {
    if (!s.viz) continue;
    wpos(s.bone, s.a); wpos(s.end, s.b);
    s.viz.position.addVectors(s.a, s.b).multiplyScalar(0.5);
    _va.subVectors(s.b, s.a);
    if (_va.lengthSq() > 1e-10) s.viz.quaternion.setFromUnitVectors(_yAxis, _va.normalize());
  }
}

/* ============================================================
   BONE-MAPPING — JSON anwenden / erzeugen
   ============================================================ */
function applyBoneMapping(data) {
  if (!data || typeof data !== "object") return "";
  let n = 0;
  // Globale Werte nur aus neuen (v2) Dateien: die alten Zug-/Dämpfungswerte
  // gehörten zu einem anderen Physikmodell und würden das Ziehen wieder träge machen.
  let globalsNote = "";
  if (data.globals && (data.version || 1) >= 2) {
    for (const k of GLOBAL_KEYS) if (k in data.globals) params[k] = data.globals[k];
  } else if (data.globals) globalsNote = " (globale Werte der alten JSON ignoriert)";
  const table = data.bones || {};
  for (const e of simBones) {
    const d = table[e.name] || table[e.short];
    if (!d) continue;
    if ("flex" in d) e.flex = RAD(d.flex);
    if ("ext" in d) e.ext = RAD(d.ext);
    if ("lat" in d) e.lat = RAD(d.lat);
    if ("twist" in d) e.twist = RAD(d.twist);
    if ("inertia" in d) e.inertia = d.inertia;
    if ("hinge" in d) e.hinge = !!d.hinge;
    if ("noiseGain" in d) e.noiseGain = d.noiseGain;
    if (Array.isArray(d.axisT) && d.axisT.length === 3) e.axisT.fromArray(d.axisT).normalize();
    if (Array.isArray(d.axisH) && d.axisH.length === 3) {
      e.axisH.fromArray(d.axisH);
      e.axisH.addScaledVector(e.axisT, -e.axisH.dot(e.axisT));
      if (e.axisH.lengthSq() < 1e-8) e.axisH.set(1, 0, 0);
      e.axisH.normalize();
    }
    e.axisS.crossVectors(e.axisT, e.axisH).normalize();
    n++;
  }
  return `bonemapping.json angewandt (${n} Gelenke)${globalsNote}.`;
}

function buildBoneMapping() {
  const bones = {};
  for (const e of simBones) {
    bones[e.name] = {
      flex: +DEG(e.flex).toFixed(2),
      ext: +DEG(e.ext).toFixed(2),
      lat: +DEG(e.lat).toFixed(2),
      twist: +DEG(e.twist).toFixed(2),
      inertia: +e.inertia.toFixed(3),
      hinge: e.hinge,
      noiseGain: +e.noiseGain.toFixed(3),
      axisT: e.axisT.toArray().map((v) => +v.toFixed(6)),
      axisH: e.axisH.toArray().map((v) => +v.toFixed(6)),
    };
  }
  const globals = {};
  for (const k of GLOBAL_KEYS) globals[k] = params[k];
  return { version: 2, generated: new Date().toISOString(), globals, bones };
}

function downloadMapping() {
  const blob = new Blob([JSON.stringify(buildBoneMapping(), null, 2)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url; a.download = "bonemapping.json";
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

/* ============================================================
   ORIENTIERUNGS-GIZMO
   ============================================================ */
function buildOrientationGizmo(radius) {
  const group = new THREE.Group();
  group.renderOrder = -1;
  const segments = 72;
  const circleMat = new THREE.LineBasicMaterial({ color: 0xbfbfc4, transparent: true, opacity: 0.16, depthWrite: false });
  function makeCircle(plane) {
    const pts = [];
    for (let i = 0; i <= segments; i++) {
      const a = (i / segments) * Math.PI * 2;
      const x = Math.cos(a) * radius, y = Math.sin(a) * radius;
      if (plane === "xy") pts.push(new THREE.Vector3(x, y, 0));
      else if (plane === "yz") pts.push(new THREE.Vector3(0, x, y));
      else pts.push(new THREE.Vector3(x, 0, y));
    }
    return new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(pts), circleMat);
  }
  group.add(makeCircle("xy"), makeCircle("yz"), makeCircle("xz"));
  const axisLen = radius * 1.15;
  function makeAxis(dir, color) {
    const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.4, depthWrite: false });
    return new THREE.Line(new THREE.BufferGeometry().setFromPoints(
      [dir.clone().multiplyScalar(-axisLen), dir.clone().multiplyScalar(axisLen)]), mat);
  }
  group.add(makeAxis(new THREE.Vector3(1, 0, 0), 0xd99a8c));
  group.add(makeAxis(new THREE.Vector3(0, 1, 0), 0x9ccf9c));
  group.add(makeAxis(new THREE.Vector3(0, 0, 1), 0x93b8e0));
  return group;
}

/* ============================================================
   ACHSEN-GIZMO FÜR DAS AUSGEWÄHLTE GELENK (Mapping-Modus)
   ============================================================ */
const jointGizmo = new THREE.Group();
jointGizmo.visible = false;
scene.add(jointGizmo);
function axisLine(color) {
  const g = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, 1)]);
  return new THREE.Line(g, new THREE.LineBasicMaterial({ color, depthTest: false, transparent: true, opacity: 0.95 }));
}
const gizH = axisLine(0x2f6fed);
const gizS = axisLine(0x5fae6a);
const gizT = axisLine(0xd0473e);
[gizH, gizS, gizT].forEach((l) => { l.renderOrder = 998; l.frustumCulled = false; jointGizmo.add(l); });

function updateJointGizmo() {
  const e = selectedEntry();
  if (!params.mappingMode || !e) { jointGizmo.visible = false; return; }
  jointGizmo.visible = true;
  const origin = new THREE.Vector3();
  e.bone.getWorldPosition(origin);
  const L = 0.16;
  const set = (line, axisLocal) => {
    const dir = axisLocal.clone().applyQuaternion(e.restWorld).multiplyScalar(L);
    line.geometry.setFromPoints([origin, origin.clone().add(dir)]);
  };
  set(gizH, e.axisH); set(gizS, e.axisS); set(gizT, e.axisT);
}

/* ============================================================
   FOKUSPUNKTE — Darstellung
   ============================================================ */
const focusGroup = new THREE.Group();
focusGroup.visible = false;
scene.add(focusGroup);
const focusMeshes = [0, 1].map(() => {
  const m = new THREE.Mesh(
    new THREE.SphereGeometry(0.02, 16, 12),
    new THREE.MeshBasicMaterial({ color: 0x9a9aa0, transparent: true, opacity: 0.9, depthTest: false })
  );
  m.renderOrder = 996; m.frustumCulled = false; focusGroup.add(m);
  return m;
});
function makeLine(color, opacity) {
  const l = new THREE.Line(
    new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]),
    new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthTest: false })
  );
  l.renderOrder = 995; l.frustumCulled = false; focusGroup.add(l);
  return l;
}
const dialogueLine = makeLine(0x9a9aa0, 0.35);
const COL_ATTRACT = new THREE.Color(0x2f6fed);
const COL_REPEL = new THREE.Color(0xd0473e);
function buildFocusVisuals() {
  focusMeshes.forEach((m) => m.scale.setScalar(bodyScale));
  for (const L of limbs) L.line = makeLine(0x2f6fed, 0.6);
}
function updateFocusVisuals() {
  const on = params.showFocus && params.choreoEnabled && !params.mappingMode && !!skinnedMesh;
  focusGroup.visible = on;
  if (!on) return;
  focusMeshes[0].position.copy(foci[0].world);
  focusMeshes[1].position.copy(foci[1].world);
  dialogueLine.geometry.setFromPoints([foci[0].world, foci[1].world]);
  for (const L of limbs) {
    if (!L.line) continue;
    const a = Math.abs(L.pol) * choreoBlend;
    L.line.visible = a > 0.05;
    if (!L.line.visible) continue;
    _tmpVecA.lerpVectors(foci[0].world, foci[1].world, L.aff);
    wpos(L.eff, _tmpVecB);
    L.line.geometry.setFromPoints([_tmpVecB, _tmpVecA]);
    L.line.material.color.copy(L.pol > 0 ? COL_ATTRACT : COL_REPEL);
    L.line.material.opacity = Math.min(0.8, a);
  }
}

/* ============================================================
   MAUS-INTERAKTION
   ============================================================ */
const raycaster = new THREE.Raycaster();
const mouseNDC = new THREE.Vector2();
const tmpSphere = new THREE.Sphere();
const tmpHit = new THREE.Vector3();
const lastMouse = { x: window.innerWidth / 2, y: window.innerHeight / 2, active: false };

let interaction = null;
let orbitDragging = false;
let lastOrbitX = 0, lastOrbitY = 0;

const hitMarker = new THREE.Mesh(
  new THREE.RingGeometry(0.02, 0.028, 28),
  new THREE.MeshBasicMaterial({ color: 0x9a9aa0, transparent: true, opacity: 0.95, side: THREE.DoubleSide, depthTest: false })
);
hitMarker.renderOrder = 999; hitMarker.frustumCulled = false; hitMarker.visible = false;
scene.add(hitMarker);

const forceLine = new THREE.Line(
  new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]),
  new THREE.LineBasicMaterial({ color: 0x2f6fed, transparent: true, opacity: 0.9, depthTest: false })
);
forceLine.renderOrder = 999; forceLine.frustumCulled = false; forceLine.visible = false;
scene.add(forceLine);

const COLOR_HOVER = new THREE.Color(0x9a9aa0);
const COLOR_ATTRACT = new THREE.Color(0x2f6fed);
function orientMarkerToNormal(m, n) { m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), n); }

function pickColliderInternal(clientX, clientY) {
  mouseNDC.x = (clientX / window.innerWidth) * 2 - 1;
  mouseNDC.y = -(clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouseNDC, camera);
  const ro = raycaster.ray.origin, rd = raycaster.ray.direction;
  let bestT = Infinity, bestOwner = null, bestPoint = null;
  for (const c of colliders) {
    if (c.type === "sphere") {
      c.a.getWorldPosition(tmpSphere.center);
      tmpSphere.radius = c.radius;
      const hit = raycaster.ray.intersectSphere(tmpSphere, tmpHit);
      if (hit) {
        const t = ro.distanceTo(hit);
        if (t < bestT) { bestT = t; bestOwner = c.owner; bestPoint = hit.clone(); }
      }
    } else {
      c.a.getWorldPosition(_tmpVecA); c.b.getWorldPosition(_tmpVecB);
      const res = closestRaySegment(ro, rd, _tmpVecA, _tmpVecB);
      if (res.dist <= c.radius && res.s < bestT) {
        let surface;
        if (res.dist > 1e-5) {
          surface = res.segPoint.clone().addScaledVector(res.rayPoint.clone().sub(res.segPoint).normalize(), c.radius);
        } else surface = res.segPoint.clone().addScaledVector(rd, -c.radius);
        bestT = res.s; bestOwner = c.owner; bestPoint = surface;
      }
    }
  }
  return bestOwner ? { owner: bestOwner, point: bestPoint } : null;
}

function pickNearestBone(clientX, clientY, maxDistance = 0.3) {
  mouseNDC.x = (clientX / window.innerWidth) * 2 - 1;
  mouseNDC.y = -(clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouseNDC, camera);
  const ro = raycaster.ray.origin, rd = raycaster.ray.direction;
  let bestDist = maxDistance * bodyScale, bestBone = null, bestPoint = null;
  for (const s of simBones) {
    const p = new THREE.Vector3();
    s.bone.getWorldPosition(p);
    const t = new THREE.Vector3().subVectors(p, ro).dot(rd);
    if (t < 0) continue;
    const onRay = ro.clone().addScaledVector(rd, t);
    const d = onRay.distanceTo(p);
    if (d < bestDist) { bestDist = d; bestBone = s.bone; bestPoint = onRay.clone(); }
  }
  return bestBone ? { owner: bestBone, point: bestPoint } : null;
}

function pickCollider(x, y) {
  scene.updateMatrixWorld(true);
  return pickColliderInternal(x, y) || pickNearestBone(x, y);
}

function screenToPlanePoint(x, y, plane) {
  mouseNDC.x = (x / window.innerWidth) * 2 - 1;
  mouseNDC.y = -(y / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouseNDC, camera);
  return raycaster.ray.intersectPlane(plane, new THREE.Vector3());
}

renderer.domElement.addEventListener("contextmenu", (e) => e.preventDefault());

renderer.domElement.addEventListener("pointerdown", (e) => {
  if (e.button === 2) { orbitDragging = true; lastOrbitX = e.clientX; lastOrbitY = e.clientY; return; }
  if (e.button !== 0) return;
  const hit = pickCollider(e.clientX, e.clientY);
  if (!hit) return;
  const bone = hit.owner;
  const camDir = new THREE.Vector3();
  camera.getWorldDirection(camDir);
  interaction = {
    bone,
    localOffset: bone.worldToLocal(hit.point.clone()),
    plane: new THREE.Plane().setFromNormalAndCoplanarPoint(camDir, hit.point),
    target: hit.point.clone(),
    startTime: performance.now(),
  };
  if (params.mappingMode) {
    const se = nearestSimEntry(bone);
    if (se) selectBone(se.idx);
  }
});

window.addEventListener("pointermove", (e) => {
  lastMouse.x = e.clientX; lastMouse.y = e.clientY; lastMouse.active = true;
  if (orbitDragging) {
    params.camAzimuth -= (e.clientX - lastOrbitX) * 0.35;
    params.camElevation = clamp(params.camElevation + (e.clientY - lastOrbitY) * 0.35, -80, 85);
    lastOrbitX = e.clientX; lastOrbitY = e.clientY;
  }
  if (interaction) {
    const camDir = new THREE.Vector3();
    camera.getWorldDirection(camDir);
    interaction.plane.setFromNormalAndCoplanarPoint(camDir, interaction.target);
    const p = screenToPlanePoint(e.clientX, e.clientY, interaction.plane);
    if (p) interaction.target.copy(p);
  }
});
window.addEventListener("pointerleave", () => { lastMouse.active = false; });
window.addEventListener("pointerup", (e) => {
  if (e.button === 2) orbitDragging = false;
  if (interaction && params.mappingMode) refreshPoseSliders();
  interaction = null;
});
window.addEventListener("blur", () => { interaction = null; orbitDragging = false; });

renderer.domElement.addEventListener("wheel", (e) => {
  e.preventDefault();
  params.fov = clamp(params.fov * Math.exp(e.deltaY * 0.0013), 6, 100);
  camera.fov = params.fov;
  camera.updateProjectionMatrix();
}, { passive: false });

/* ============================================================
   KRAFT-VERTEILUNG
   ============================================================ */
function simulatedChain(bone) {
  const chain = [];
  let o = bone;
  while (o) { const e = boneEntry.get(o); if (e) chain.push(e); o = o.parent; }
  return chain;
}
function nearestSimEntry(bone) {
  let o = bone;
  while (o) { const e = boneEntry.get(o); if (e) return e; o = o.parent; }
  return null;
}

// exakte Punktgeschwindigkeit: Körper + Summe aller Gelenk-Drehungen der Kette
const _pv1 = new THREE.Vector3(), _pv2 = new THREE.Vector3(), _pv3 = new THREE.Vector3(), _pv4 = new THREE.Vector3();
function pointVelocity(bone, P, out) {
  out.copy(bodyVel);
  _pv1.subVectors(P, characterRoot.position);
  out.add(_pv2.crossVectors(bodyAngVel, _pv1));
  for (let o = bone; o; o = o.parent) {
    const e = boneEntry.get(o);
    if (!e) continue;
    wpos(e.bone, _pv3);
    _pv4.copy(e.angVel).applyQuaternion(e.restWorld);
    _pv1.subVectors(P, _pv3);
    out.add(_pv2.crossVectors(_pv4, _pv1));
  }
  return out;
}

const _lever = new THREE.Vector3(), _torq = new THREE.Vector3(), _bp = new THREE.Vector3();
// o: { body, falloff, gain, levels }
function applyForceAtBone(targetBone, F, P, dt, o = {}) {
  const chain = simulatedChain(targetBone);
  if (o.levels && chain.length > o.levels) chain.length = o.levels;
  const share = o.body !== undefined ? o.body : (chain.length > 0 ? params.bodyCoupling : 1.0);
  const falloff = o.falloff !== undefined ? o.falloff : params.chainFalloff;
  const gm = o.gain !== undefined ? o.gain : 1;

  if (share > 0) {
    bodyVel.addScaledVector(F, (share * 1.5 / Math.max(params.bodyMass, 0.05)) * dt);
    _lever.subVectors(P, characterRoot.position);
    bodyAngVel.addScaledVector(_torq.crossVectors(_lever, F), (share * 1.5 * dt) / (_lever.lengthSq() + 0.25));
  }

  let level = 1.0;
  for (const e of chain) {
    wpos(e.bone, _bp);
    _lever.subVectors(P, _bp);
    _torq.crossVectors(_lever, F).applyQuaternion(e.restWorldInv);
    const bend = params.compliance * (e.hinge ? params.hingeBoost : 1.0);
    const gain = params.torqueGain * gm * level * bend /
                 ((_lever.lengthSq() + 0.03) * Math.max(e.inertia, 0.05));
    e.angVel.addScaledVector(_torq, gain * dt);
    level *= falloff;
  }
}

/* ============================================================
   GELENK-LIMITS  (elliptischer Bewegungskegel + Verdrehung)
   Beugung/Streckung (h) und Seitwärts (s) sind gekoppelt: kein "Eck"-Zustand
   mit voller Beugung UND vollem Seitwärts. Anschlag ist weich + gedämpft.
   ============================================================ */
const _swing = new THREE.Quaternion();
const _twist = new THREE.Quaternion();
const _dec = { h: 0, s: 0, tw: 0 };

function decomposeDev(dev, e, out) {
  swingTwistDecompose(dev, e.axisT, _swing, _twist);
  out.tw = signedTwistAngle(_twist, e.axisT);
  const ang = angleAxisFromQuat(_swing, _va);
  out.h = ang * _va.dot(e.axisH);
  out.s = ang * _va.dot(e.axisS);
  return out;
}

function composeDev(h, s, tw, e, target) {
  _vb.set(0, 0, 0).addScaledVector(e.axisH, h).addScaledVector(e.axisS, s);
  const a = _vb.length();
  if (a < 1e-9) _qa.set(0, 0, 0, 1);
  else _qa.setFromAxisAngle(_vb.divideScalar(a), a);
  _qb.setFromAxisAngle(e.axisT, tw);
  return target.copy(_qa).multiply(_qb).normalize();
}

const _nrm = new THREE.Vector3();
function driveAndLimit(e, h, postureK) {
  decomposeDev(e.dev, e, _dec);
  const dh = _dec.h, ds = _dec.s, dtw = _dec.tw;
  const invI = 1 / Math.max(e.inertia, 0.05);
  const sc = params.jointLimitScale;

  // --- Haltung: weiches Federziel im Gelenkraum (Ziele liegen innerhalb der Limits)
  if (postureK > 0 && e.noiseGain > 0) {
    const k = postureK * invI * h;
    e.angVel.addScaledVector(e.axisH, (e.tgtH - dh) * k);
    e.angVel.addScaledVector(e.axisS, (e.tgtS - ds) * k);
    e.angVel.addScaledVector(e.axisT, (e.tgtT - dtw) * k);
  }

  // --- weicher Anschlag (Swing-Ellipse)
  const a = Math.max((dh >= 0 ? e.flex : e.ext) * sc, 1e-4);
  const b = Math.max(e.lat * sc, 1e-4);
  const r = Math.hypot(dh / a, ds / b);
  const f = params.limitSoftZone;
  const kL = params.limitStiffness * invI;
  if (r > f) {
    const over = (r - f) / r;
    e.angVel.addScaledVector(e.axisH, -dh * over * kL * h);
    e.angVel.addScaledVector(e.axisS, -ds * over * kL * h);
    // Auswärtsbewegung zunehmend abbremsen → kein Klappern am Anschlag
    _nrm.set(0, 0, 0).addScaledVector(e.axisH, dh / (a * a)).addScaledVector(e.axisS, ds / (b * b));
    if (_nrm.lengthSq() > 1e-12) {
      _nrm.normalize();
      const vOut = e.angVel.dot(_nrm);
      if (vOut > 0) {
        const depth = clamp((r - f) / Math.max(1 - f, 1e-3), 0, 1.5);
        e.angVel.addScaledVector(_nrm, -vOut * (1 - Math.exp(-params.limitDamping * depth * h)));
      }
    }
  }
  // --- Verdrehung
  const twl = e.twist * sc;
  const tf = twl * f;
  if (Math.abs(dtw) > tf) {
    const sg = Math.sign(dtw);
    const overT = Math.abs(dtw) - tf;
    e.angVel.addScaledVector(e.axisT, -sg * overT * kL * h);
    const depthT = clamp(overT / Math.max(twl * (1 - f), 1e-3), 0, 1.5);
    const vo = e.angVel.dot(e.axisT) * sg;
    if (vo > 0) e.angVel.addScaledVector(e.axisT, -sg * vo * (1 - Math.exp(-params.limitDamping * depthT * h)));
  }

  if (params.restReturn > 0) {
    const rr = params.restReturn * h * invI;
    e.angVel.addScaledVector(e.axisH, -dh * rr);
    e.angVel.addScaledVector(e.axisS, -ds * rr);
    e.angVel.addScaledVector(e.axisT, -dtw * rr);
  }
}

function clampToLimits(e) {
  const sc = params.jointLimitScale;
  decomposeDev(e.dev, e, _dec);
  let h = _dec.h, s = _dec.s, tw = _dec.tw;
  const a = Math.max((h >= 0 ? e.flex : e.ext) * sc, 1e-4);
  const b = Math.max(e.lat * sc, 1e-4);
  const r = Math.hypot(h / a, s / b);
  let clamped = false;
  if (r > 1) {
    h /= r; s /= r;
    _nrm.set(0, 0, 0).addScaledVector(e.axisH, h / (a * a)).addScaledVector(e.axisS, s / (b * b));
    if (_nrm.lengthSq() > 1e-12) {
      _nrm.normalize();
      const vOut = e.angVel.dot(_nrm);
      if (vOut > 0) e.angVel.addScaledVector(_nrm, -vOut);
    }
    clamped = true;
  }
  const twl = e.twist * sc;
  if (Math.abs(tw) > twl) {
    const sg = Math.sign(tw);
    tw = sg * twl;
    const vo = e.angVel.dot(e.axisT) * sg;
    if (vo > 0) e.angVel.addScaledVector(e.axisT, -sg * vo);
    clamped = true;
  }
  if (clamped) composeDev(h, s, tw, e, e.dev);
}

function refreshFrames() {
  for (const e of simBones) {
    e.bone.parent.getWorldQuaternion(_qa);
    e.restWorld.copy(_qa).multiply(e.rest);
    e.restWorldInv.copy(e.restWorld).invert();
  }
}

/* ============================================================
   CHOREOGRAFIE
   Ein einziges 3D-Perlin/Curl-Feld steuert alles gemeinsam:
   1. Zwei Fokuspunkte treiben durchs Feld, stehen im Dialog (Anziehung ↔ Reibung).
   2. Arme + Beine werden von den Fokuspunkten angezogen oder abgestoßen (Polarität
      aus dem Feld, mit Ruhephasen dazwischen, Arme/Beine gegengleich versetzt).
   3. Körper richtet sich an Fokus 2, Brust an Fokus 1, Blick wandert dazwischen
      → die Wirbelsäule bekommt die Torsion aus dem "Gespräch" der beiden Punkte.
   4. Haltung: räumlich kohärente Zielwinkel aus demselben Feld, mit Verzögerung
      entlang der Kette (Welle statt Zappeln).
   ============================================================ */
let simTime = 0;
const _f1 = new THREE.Vector3(), _f2 = new THREE.Vector3(), _f3 = new THREE.Vector3();

function initFoci() {
  const R = params.focusRadius * bodyScale;
  foci[0].rel.set(0.8, 0.25, 0.55).normalize().multiplyScalar(R);
  foci[1].rel.set(-0.75, -0.1, 0.6).normalize().multiplyScalar(R);
  foci.forEach((f) => { f.v.set(0, 0, 0); f.world.copy(characterRoot.position).add(f.rel); });
}

function updateFoci(h) {
  const S = bodyScale;
  const t = simTime * params.choreoSpeed;
  const R0 = params.focusRadius * S;
  const fs = params.fieldScale / S;

  for (const f of foci) {
    curlNoise(f.rel.x * fs + f.seed, f.rel.y * fs, f.rel.z * fs, t * 0.5, _f2);
    _f2.multiplyScalar(params.focusSpeed * S * 0.6);
    const d = f.rel.length() + 1e-6;
    const Rt = R0 * (1 + 0.3 * clamp(noise3(f.seed * 0.7 + 0.3, t * 0.6, 3.3) * 1.4, -1, 1));
    _f2.addScaledVector(f.rel, ((Rt - d) / d) * 1.6);
    f.v.lerp(_f2, 1 - Math.exp(-2.5 * h));
  }

  // Dialog: schwingt zwischen Annäherung, Abstoßung und gegenseitigem Umkreisen
  if (params.dialogue > 0) {
    _f1.subVectors(foci[1].rel, foci[0].rel);
    const dist = _f1.length() + 1e-6;
    _f1.divideScalar(dist);
    const D = params.dialogueDist * S * (0.5 + 0.9 * (0.5 + 0.5 * clamp(noise3(5.5, t * 0.4, 1.7) * 1.6, -1, 1)));
    const err = dist - D;
    const a = err * params.dialogue * 3.0;
    foci[0].v.addScaledVector(_f1, a * h);
    foci[1].v.addScaledVector(_f1, -a * h);

    // Reibung: wenn nah, gleiten sie aneinander vorbei
    const rub = smoothstep(0.85 * D, 0.35 * D, dist) * params.dialogue;
    if (rub > 0.001) {
      noiseVec3(77.7, t * 0.7, _f3).normalize();
      _f2.crossVectors(_f3, _f1).multiplyScalar(rub * S * 0.7 * h * 6);
      foci[0].v.add(_f2);
      foci[1].v.sub(_f2);
    }
  }

  for (const f of foci) {
    f.rel.addScaledVector(f.v, h);
    f.world.copy(characterRoot.position).add(f.rel);
  }
}

// pro Frame: Polarität/Affinität der Gliedmaßen + Haltungsziele aus dem Feld
function updateChoreoFrame() {
  const t = simTime * params.choreoSpeed;
  for (const L of limbs) {
    const tl = t - L.lag * params.choreoSpeed;
    const n = noise3(L.seed, tl * 0.5 + 0.2, 1.3) * 1.6 + (0.5 - params.repelShare) * 0.9;
    L.pol = Math.sign(n) * smoothstep(0.12, 0.65, Math.abs(n));
    const a = noise3(L.seed + 9.1, tl * 0.3 + 0.7, 4.4) * 2.0;
    L.aff = smoothstep(-0.35, 0.35, a);
  }
  const fsz = params.fieldScale * 0.8;
  const amp = params.posture;
  for (const e of simBones) {
    const tt = (simTime - e.depth * params.postureLag) * params.choreoSpeed * 0.5;
    const px = e.restPos.x * fsz, py = e.restPos.y * fsz, pz = e.restPos.z * fsz;
    const nx = clamp(noise3(px + 11.3, py + tt, pz + 3.1) * 1.4, -1, 1);
    const ny = clamp(noise3(px + 47.7, py + tt * 0.9 + 5.0, pz - 2.2) * 1.4, -1, 1);
    const nz = clamp(noise3(px - 23.1, py + tt * 1.1 - 9.0, pz + 7.7) * 1.4, -1, 1);
    const fr = amp * e.noiseGain * 0.65;
    if (e.hinge) {
      e.tgtH = (0.5 + 0.5 * nx) * e.flex * fr * 0.9; e.tgtS = 0; e.tgtT = 0;
    } else {
      e.tgtH = nx * (nx > 0 ? e.flex : e.ext) * fr;
      e.tgtS = ny * e.lat * fr;
      e.tgtT = nz * e.twist * fr * 0.7;
    }
  }
}

const _lr = new THREE.Vector3(), _le = new THREE.Vector3(), _lp = new THREE.Vector3();
const _lt = new THREE.Vector3(), _lf = new THREE.Vector3(), _lv = new THREE.Vector3();
function applyLimbForces(h, cs) {
  if (params.limbForce <= 0 || cs < 0.001) return;
  for (const L of limbs) {
    const pol = L.pol;
    if (Math.abs(pol) < 0.002) continue;
    wpos(L.root, _lr); wpos(L.eff, _le);
    _lp.lerpVectors(foci[0].world, foci[1].world, L.aff);
    _lf.set(0, 0, 0);
    if (pol > 0) {
      // anziehen: Endpunkt strebt zum Fokus, maximal bis zur Reichweite
      _lt.subVectors(_lp, _lr);
      const dF = _lt.length() + 1e-6;
      _lt.multiplyScalar(Math.min(dF, L.reach * 0.97) / dF).add(_lr);
      _lf.subVectors(_lt, _le).multiplyScalar(params.limbForce * L.k * pol);
    } else {
      // abstoßen: weicht dem Fokus aus, solange er nah ist
      _lt.subVectors(_le, _lp);
      const dE = _lt.length() + 1e-6;
      const Rr = L.reach * 1.6;
      if (dE < Rr) _lf.copy(_lt).multiplyScalar((1 / dE) * (1 - dE / Rr) * params.limbForce * L.k * (-pol) * L.reach * 1.2);
    }
    pointVelocity(L.eff, _le, _lv);
    _lf.addScaledVector(_lv, -params.limbDamping * Math.min(1, Math.abs(pol) * 3));
    _lf.multiplyScalar(cs);
    if (_lf.length() > params.limbMaxForce) _lf.setLength(params.limbMaxForce);
    applyForceAtBone(L.eff, _lf, _le, h, { body: params.bodyCoupling * 0.3, falloff: 0.78, levels: 5 });
  }
}

const _ae1 = new THREE.Vector3(), _ae2 = new THREE.Vector3(), _ae3 = new THREE.Vector3(), _ae4 = new THREE.Vector3();
const _aq = new THREE.Quaternion();
function aimEntry(e, targetWorld, gain, h) {
  wpos(e.bone, _ae1);
  _ae2.subVectors(targetWorld, _ae1);
  const l = _ae2.length();
  if (l < 1e-4) return;
  _ae2.divideScalar(l);
  _aq.copy(e.restWorld).multiply(e.dev);
  _ae3.copy(e.fwdLocal).applyQuaternion(_aq);
  _ae4.crossVectors(_ae3, _ae2).applyQuaternion(e.restWorldInv);
  e.angVel.addScaledVector(_ae4, (gain * h) / Math.max(e.inertia, 0.05));
}

const _bf = new THREE.Vector3(), _bd = new THREE.Vector3(), _bt = new THREE.Vector3(), _gz = new THREE.Vector3();
function applyAim(h, cs, t) {
  const ta = params.torsoAim * cs;
  if (ta > 0.001) {
    // Ganzkörper → Fokus 2 (Achse: Blickrichtung des Rigs)
    _bd.subVectors(foci[1].world, characterRoot.position).normalize();
    _bf.copy(bodyForwardLocal).applyQuaternion(characterRoot.quaternion);
    bodyAngVel.addScaledVector(_bt.crossVectors(_bf, _bd), ta * 2.2 * h);
    // Brust → Fokus 1 : Torsion entlang der Wirbelsäule
    for (const [e, w] of chestAim) aimEntry(e, foci[0].world, ta * 9 * w, h);
  }
  const hl = params.headLook * cs;
  if (hl > 0.001) {
    const ga = smoothstep(-0.3, 0.3, noise3(19.3, t * 0.3 + 0.4, 2.2) * 2);
    _gz.lerpVectors(foci[0].world, foci[1].world, ga);
    for (const [e, w] of headAim) aimEntry(e, _gz, hl * 8 * w, h);
  }
  if (params.upright > 0) {
    _bf.copy(WORLD_UP).applyQuaternion(characterRoot.quaternion);
    bodyAngVel.addScaledVector(_bt.crossVectors(_bf, WORLD_UP), params.upright * 2 * h);
  }
}

function applyChoreography(h, cs) {
  updateFoci(h);
  const t = simTime * params.choreoSpeed;
  if (params.driftForce > 0) { noiseVec3(911.0, t * 0.6, _vc); bodyVel.addScaledVector(_vc, params.driftForce * cs * h); }
  if (params.driftSpin > 0)  { noiseVec3(1723.0, t * 0.5, _vc); bodyAngVel.addScaledVector(_vc, params.driftSpin * cs * h); }
  applyLimbForces(h, cs);
  applyAim(h, cs, t);
}

/* ============================================================
   ZIEHEN
   ============================================================ */
const _dw = new THREE.Vector3(), _df = new THREE.Vector3(), _dv = new THREE.Vector3();
function applyDrag(h) {
  const elapsed = (performance.now() - interaction.startTime) / 1000;
  const ramp = Math.min(elapsed / Math.max(params.forceRampSeconds, 0.001), 1);
  _dw.copy(interaction.localOffset).applyMatrix4(interaction.bone.matrixWorld);
  _df.subVectors(interaction.target, _dw).multiplyScalar(ramp * params.attractForce);
  pointVelocity(interaction.bone, _dw, _dv);
  _df.addScaledVector(_dv, -params.dragDamping);
  if (_df.length() > params.maxForce) _df.setLength(params.maxForce);
  const e = nearestSimEntry(interaction.bone);
  const body = (!e || e.isTorso) ? params.torsoDragCoupling : params.bodyCoupling;
  applyForceAtBone(interaction.bone, _df, _dw, h, { body, gain: params.dragBoost });
}

/* ============================================================
   KOLLISION  (Kraft + positionelle Korrektur)
   ============================================================ */
const _cpA = new THREE.Vector3(), _cpB = new THREE.Vector3(), _cn = new THREE.Vector3();
const _cvA = new THREE.Vector3(), _cvB = new THREE.Vector3(), _cF = new THREE.Vector3();
const _n1 = new THREE.Vector3(), _n2 = new THREE.Vector3(), _n3 = new THREE.Vector3();
const _nq1 = new THREE.Quaternion(), _nq2 = new THREE.Quaternion();

// dreht das Gelenk um seinen Ursprung, so dass `point` um ~disp wandert
function nudgeEntry(e, point, disp) {
  wpos(e.bone, _n1);
  _n2.subVectors(point, _n1);
  const L2 = _n2.lengthSq();
  if (L2 < 4e-4) return;
  _n3.crossVectors(_n2, disp);
  const ang = _n3.length() / L2;
  if (ang < 1e-6) return;
  _n3.normalize();
  e.bone.parent.getWorldQuaternion(_nq1);
  _nq2.copy(_nq1).multiply(e.rest).invert();
  _n3.applyQuaternion(_nq2);
  _nq1.setFromAxisAngle(_n3, Math.min(ang, 0.012));
  e.dev.premultiply(_nq1).normalize();
  clampToLimits(e);
  e.bone.quaternion.copy(e.rest).multiply(e.dev).normalize();
  e.bone.updateMatrixWorld(true);
}

const CONTACT_LEVELS = { body: 0, falloff: 0.6, levels: 3 };
function resolveCollisions(h) {
  if (!params.selfCollision || !segPairs.length) return;
  for (const s of segs) { wpos(s.bone, s.a); wpos(s.end, s.b); }
  const sc = params.collisionScale, pad = params.collisionPadding;

  for (const pr of segPairs) {
    const A = pr.A, B = pr.B;
    const d2 = segSegClosest(A.a, A.b, B.a, B.b, _cpA, _cpB);
    const minD = Math.min((A.r + B.r) * sc + pad, pr.restCap);
    if (d2 >= minD * minD) continue;
    const d = Math.sqrt(d2);
    if (d > 1e-6) _cn.subVectors(_cpA, _cpB).divideScalar(d);
    else {
      _cn.set((A.a.x + A.b.x) - (B.a.x + B.b.x), (A.a.y + A.b.y) - (B.a.y + B.b.y), (A.a.z + A.b.z) - (B.a.z + B.b.z));
      if (_cn.lengthSq() < 1e-10) _cn.set(0, 1, 0);
      _cn.normalize();
    }
    const pen = minD - d;

    // 1) Kontaktkraft mit Dämpfung der Annäherung → weiches Abprallen
    pointVelocity(A.bone, _cpA, _cvA);
    pointVelocity(B.bone, _cpB, _cvB);
    const vn = _cvA.sub(_cvB).dot(_cn);
    let mag = params.collisionStiffness * pen - params.collisionDamping * Math.min(vn, 0);
    mag = clamp(mag, 0, params.collisionMaxForce);
    if (mag > 0) {
      const gA = A.e.isTorso ? 0.25 : 1, gB = B.e.isTorso ? 0.25 : 1;
      _cF.copy(_cn).multiplyScalar(mag);
      applyForceAtBone(A.bone, _cF, _cpA, h, { ...CONTACT_LEVELS, gain: gA });
      _cF.negate();
      applyForceAtBone(B.bone, _cF, _cpB, h, { ...CONTACT_LEVELS, gain: gB });
    }

    // 2) Positionelle Korrektur: Glieder werden garantiert auseinandergeschoben
    if (params.collisionProject > 0) {
      const wA = A.e.isTorso ? 0 : 1, wB = B.e.isTorso ? 0 : 1;
      const wsum = wA + wB;
      if (wsum > 0) {
        const push = Math.min(pen * params.collisionProject, params.collisionMaxStep);
        if (wA) nudgeChain(A.e, _cpA, _n1.copy(_cn).multiplyScalar(push * wA / wsum));
        if (wB) nudgeChain(B.e, _cpB, _n1.copy(_cn).multiplyScalar(-push * wB / wsum));
        for (const s of segs) { wpos(s.bone, s.a); wpos(s.end, s.b); }
      }
    }
  }
}
const _nd = new THREE.Vector3();
function nudgeChain(e, point, disp) {
  _nd.copy(disp);
  nudgeEntry(e, point, _nd.multiplyScalar(0.65));
  const p = e.parentEntry;
  if (p && !p.isTorso) { _nd.copy(disp); nudgeEntry(p, point, _nd.multiplyScalar(0.35)); }
}

/* ============================================================
   PHYSIK
   ============================================================ */
const clock = new THREE.Clock();
let accumulator = 0;
const FIXED_H = 1 / 180;

function stepPhysics(h) {
  simTime += h;
  scene.updateMatrixWorld(true);

  const jd = Math.exp(-params.jointDamping * h);
  const leafD = Math.exp(-params.jointDamping * (1 + 0.35 * params.leafCalm) * h);
  const bd = Math.exp(-params.bodyDamping * h);

  for (const e of simBones) {
    e.bone.parent.getWorldQuaternion(_qa);
    e.restWorld.copy(_qa).multiply(e.rest);
    e.restWorldInv.copy(e.restWorld).invert();
    const pe = boneEntry.get(e.bone.parent);
    e.wav.copy(e.angVel).applyQuaternion(e.restWorld).add(pe ? pe.wav : bodyAngVel);
  }

  // Choreografie fährt beim Ziehen herunter und kommt danach sanft zurück
  const blendTarget = interaction ? params.dragChoreoFloor : 1;
  choreoBlend += (blendTarget - choreoBlend) * (1 - Math.exp(-h * (interaction ? 10 : 0.9)));
  const cs = params.choreoEnabled ? params.choreoAmount * choreoBlend : 0;

  if (interaction) applyDrag(h);
  if (params.choreoEnabled) applyChoreography(h, cs);

  bodyVel.addScaledVector(_va.copy(characterRoot.position).negate(), params.hipReturnStrength * h);
  bodyVel.multiplyScalar(bd);
  bodyAngVel.multiplyScalar(bd);
  if (bodyAngVel.length() > 3) bodyAngVel.setLength(3);
  characterRoot.position.addScaledVector(bodyVel, h);
  characterRoot.quaternion.premultiply(quatFromAngularVelocity(bodyAngVel, h, _qa)).normalize();

  // Trägheits-Kaskade: Kind-Glieder hinken der Bewegung des Elternteils hinterher (Nachschwingen)
  for (const e of simBones) {
    const pe = boneEntry.get(e.bone.parent);
    const pw = pe ? pe.wav : bodyAngVel;
    _va.copy(pw).sub(e.prevParentWav);
    e.prevParentWav.copy(pw);
    if (_va.length() > 2.0) _va.setLength(2.0);
    _va.applyQuaternion(e.restWorldInv);
    const damp = e.isLeaf ? (1 - params.leafCalm * 0.7) : 1;
    e.angVel.addScaledVector(_va, -params.limbInertia * damp / Math.max(e.inertia, 0.05) * 0.25);
  }

  const postureK = params.posture > 0 ? params.postureStiffness * cs : 0;
  for (const e of simBones) {
    driveAndLimit(e, h, postureK);
    if (e.angVel.length() > params.angVelClamp) e.angVel.setLength(params.angVelClamp);
    e.angVel.multiplyScalar(e.isLeaf ? leafD : jd);
    e.dev.premultiply(quatFromAngularVelocity(e.angVel, h, _qa)).normalize();
    clampToLimits(e);
    e.bone.quaternion.copy(e.rest).multiply(e.dev).normalize();
  }

  scene.updateMatrixWorld(true);
  resolveCollisions(h);
}

function updatePhysics(dt) {
  if (params.choreoEnabled) updateChoreoFrame();
  accumulator += Math.min(dt, 0.05);
  let n = 0;
  while (accumulator >= FIXED_H && n < 10) { stepPhysics(FIXED_H); accumulator -= FIXED_H; n++; }
  if (n === 10) accumulator = 0;
}

function freezeAll() {
  bodyVel.set(0, 0, 0);
  bodyAngVel.set(0, 0, 0);
  for (const e of simBones) { e.angVel.set(0, 0, 0); e.prevParentWav.set(0, 0, 0); }
  accumulator = 0;
}

function resetPose() {
  freezeAll();
  characterRoot.position.set(0, 0, 0);
  characterRoot.quaternion.identity();
  for (const e of simBones) { e.dev.identity(); e.bone.quaternion.copy(e.rest); }
  choreoBlend = 1;
  initFoci();
  refreshPoseSliders();
}

/* ============================================================
   MAPPING-MODUS — direktes Posieren per Maus (CCD, hart begrenzt)
   ============================================================ */
function mappingDrag() {
  if (!interaction) return;
  scene.updateMatrixWorld(true);

  const chain = simulatedChain(interaction.bone).slice(0, Math.max(1, Math.round(params.mapChainDepth)));
  const origin = new THREE.Vector3();
  for (let iter = 0; iter < 2; iter++) {
    for (const e of chain) {
      const worldPoint = interaction.localOffset.clone().applyMatrix4(interaction.bone.matrixWorld);
      e.bone.getWorldPosition(origin);
      const cur = new THREE.Vector3().subVectors(worldPoint, origin);
      const des = new THREE.Vector3().subVectors(interaction.target, origin);
      if (cur.lengthSq() < 1e-8 || des.lengthSq() < 1e-8) continue;
      cur.normalize(); des.normalize();
      const axis = new THREE.Vector3().crossVectors(cur, des);
      if (axis.lengthSq() < 1e-10) continue;
      axis.normalize();
      const angle = Math.acos(clamp(cur.dot(des), -1, 1)) * params.mapDragSpeed;
      e.bone.parent.getWorldQuaternion(_qa);
      e.restWorld.copy(_qa).multiply(e.rest);
      e.restWorldInv.copy(e.restWorld).invert();
      axis.applyQuaternion(e.restWorldInv);
      e.dev.premultiply(_qb.setFromAxisAngle(axis, angle)).normalize();
      clampToLimits(e);
      e.bone.quaternion.copy(e.rest).multiply(e.dev).normalize();
      scene.updateMatrixWorld(true);
    }
  }
}

/* ============================================================
   MAPPING-PANEL (Liste + Editor)
   ============================================================ */
const panelEl = document.getElementById("mapping-panel");
const listEl = document.getElementById("mapping-list");
const editorEl = document.getElementById("mapping-editor");
let selectedIdx = 0;
let mapGui = null;
let listRows = [];
const poseProxy = { h: 0, s: 0, tw: 0 };

function selectedEntry() { return simBones[selectedIdx] || null; }

function buildMappingList() {
  listEl.innerHTML = "";
  listRows = simBones.map((e, i) => {
    const row = document.createElement("div");
    row.className = "mrow" + (i === selectedIdx ? " sel" : "");
    row.innerHTML =
      `<div><span class="mname">${e.short}</span>` +
      `<span class="mtag">${e.hinge ? "Scharnier" : "Kugel"}${e.isLeaf ? " · Ende" : ""}</span></div>` +
      `<div class="mvals"></div>`;
    row.addEventListener("click", () => selectBone(i));
    listEl.appendChild(row);
    return { row, vals: row.querySelector(".mvals") };
  });
  buildMappingEditor();
  updateMappingList(true);
}

function fmt(v) { return (v >= 0 ? " " : "") + v.toFixed(0); }

let listClock = 0;
function updateMappingList(force) {
  if (!listRows.length) return;
  const now = performance.now();
  if (!force && now - listClock < 70) return;
  listClock = now;
  const sc = params.jointLimitScale;
  for (let i = 0; i < simBones.length; i++) {
    const e = simBones[i];
    decomposeDev(e.dev, e, _dec);
    const h = DEG(_dec.h), s = DEG(_dec.s), tw = DEG(_dec.tw);
    const lf = DEG(e.flex * sc), le = DEG(e.ext * sc), ll = DEG(e.lat * sc), lt = DEG(e.twist * sc);
    const a = Math.max(h >= 0 ? lf : le, 1e-3), b = Math.max(ll, 1e-3);
    const swingHot = Math.hypot(h / a, s / b) > 0.985 ? "hot" : "";
    const twHot = Math.abs(tw) > lt * 0.985 ? "hot" : "";
    listRows[i].vals.innerHTML =
      `B <span class="${swingHot}"><b>${fmt(h)}°</b></span> [−${le.toFixed(0)}…${lf.toFixed(0)}] &nbsp; ` +
      `S <span class="${swingHot}"><b>${fmt(s)}°</b></span> [±${ll.toFixed(0)}] &nbsp; ` +
      `T <span class="${twHot}"><b>${fmt(tw)}°</b></span> [±${lt.toFixed(0)}]`;
  }
}

function selectBone(i) {
  selectedIdx = clamp(i, 0, simBones.length - 1);
  listRows.forEach((r, k) => r.row.classList.toggle("sel", k === selectedIdx));
  const r = listRows[selectedIdx];
  if (r) r.row.scrollIntoView({ block: "nearest" });
  buildMappingEditor();
}

function applyPoseFromSliders() {
  const e = selectedEntry();
  if (!e) return;
  composeDev(RAD(poseProxy.h), RAD(poseProxy.s), RAD(poseProxy.tw), e, e.dev);
  clampToLimits(e);
  e.bone.quaternion.copy(e.rest).multiply(e.dev).normalize();
  scene.updateMatrixWorld(true);
}

function refreshPoseSliders() {
  const e = selectedEntry();
  if (!e) return;
  decomposeDev(e.dev, e, _dec);
  poseProxy.h = DEG(_dec.h); poseProxy.s = DEG(_dec.s); poseProxy.tw = DEG(_dec.tw);
}

function buildMappingEditor() {
  if (mapGui) { mapGui.destroy(); mapGui = null; }
  const e = selectedEntry();
  if (!e) return;
  refreshPoseSliders();

  mapGui = new GUI({ container: editorEl, title: e.short, width: 350 });

  const lim = {
    flex: DEG(e.flex), ext: DEG(e.ext), lat: DEG(e.lat), twist: DEG(e.twist),
    inertia: e.inertia, hinge: e.hinge, noiseGain: e.noiseGain,
  };
  const fL = mapGui.addFolder("Constraints");
  fL.add(lim, "flex", 0, 180, 0.5).name("Beugung + max °").onChange((v) => { e.flex = RAD(v); clampToLimits(e); });
  fL.add(lim, "ext", 0, 180, 0.5).name("Streckung − max °").onChange((v) => { e.ext = RAD(v); clampToLimits(e); });
  fL.add(lim, "lat", 0, 180, 0.5).name("Seitlich ± °").onChange((v) => { e.lat = RAD(v); clampToLimits(e); });
  fL.add(lim, "twist", 0, 180, 0.5).name("Verdrehung ± °").onChange((v) => { e.twist = RAD(v); clampToLimits(e); });
  fL.add(lim, "inertia", 0.05, 6, 0.05).name("Trägheit").onChange((v) => (e.inertia = v));
  fL.add(lim, "noiseGain", 0, 2, 0.01).name("Haltungs-Anteil").onChange((v) => (e.noiseGain = v));
  fL.add(lim, "hinge").name("Scharniergelenk").onChange((v) => { e.hinge = v; buildMappingList(); });
  fL.add({ flip: () => {
    e.axisH.negate(); e.axisS.crossVectors(e.axisT, e.axisH).normalize();
    e.dev.identity(); e.bone.quaternion.copy(e.rest); refreshPoseSliders();
  } }, "flip").name("↔ Beugeachse umkehren");
  fL.open();

  const fP = mapGui.addFolder("Pose von Hand");
  fP.add(poseProxy, "h", -180, 180, 0.5).name("Beugung °").listen().onChange(applyPoseFromSliders);
  fP.add(poseProxy, "s", -180, 180, 0.5).name("Seitlich °").listen().onChange(applyPoseFromSliders);
  fP.add(poseProxy, "tw", -180, 180, 0.5).name("Verdrehung °").listen().onChange(applyPoseFromSliders);
  fP.add({ zero: () => { poseProxy.h = poseProxy.s = poseProxy.tw = 0; applyPoseFromSliders(); } }, "zero").name("Gelenk auf 0");
  fP.open();
}

function setMappingMode(on) {
  params.mappingMode = on;
  panelEl.classList.toggle("on", on);
  jointGizmo.visible = on;
  if (on) { freezeAll(); refreshFrames(); refreshPoseSliders(); updateMappingList(true); }
  document.getElementById("hint").textContent = on ? HINT_MAP : HINT_PLAY;
}

document.getElementById("btn-export").addEventListener("click", downloadMapping);
document.getElementById("btn-import").addEventListener("click", () => document.getElementById("file-input").click());
document.getElementById("file-input").addEventListener("change", (ev) => {
  const f = ev.target.files && ev.target.files[0];
  if (!f) return;
  const r = new FileReader();
  r.onload = () => {
    try {
      const note = applyBoneMapping(JSON.parse(r.result));
      buildMappingList();
      if (gui) gui.controllersRecursive().forEach((c) => c.updateDisplay());
      setStatus(note);
      setTimeout(() => setStatus(""), 4000);
    } catch (err) { setStatus("JSON konnte nicht gelesen werden."); console.error(err); }
  };
  r.readAsText(f);
  ev.target.value = "";
});
document.getElementById("btn-zero").addEventListener("click", () => {
  for (const e of simBones) { e.dev.identity(); e.bone.quaternion.copy(e.rest); }
  freezeAll(); scene.updateMatrixWorld(true); refreshPoseSliders();
});
// Alle Gelenke auf ihr maximales Beugelimit fahren — zeigt sofort, ob
// eine Achse oder ein Vorzeichen falsch herum sitzt.
document.getElementById("btn-extreme").addEventListener("click", () => {
  for (const e of simBones) {
    composeDev(e.flex * params.jointLimitScale, 0, 0, e, e.dev);
    e.bone.quaternion.copy(e.rest).multiply(e.dev).normalize();
  }
  freezeAll(); scene.updateMatrixWorld(true); refreshPoseSliders();
});

/* ============================================================
   HAUPT-GUI
   ============================================================ */
let gui;
function buildGUI() {
  gui = new GUI({ title: "Parameter" });

  gui.add(params, "mappingMode").name("MAPPING-MODUS").onChange(setMappingMode);

  const fMap = gui.addFolder("Mapping-Handling");
  fMap.add(params, "mapDragSpeed", 0.02, 1, 0.01).name("Zieh-Geschwindigkeit");
  fMap.add(params, "mapChainDepth", 1, 4, 1).name("Kettentiefe");
  fMap.close();

  const fCh = gui.addFolder("Choreografie (Feld + Fokus)");
  fCh.add(params, "choreoEnabled").name("Aktiv");
  fCh.add(params, "choreoAmount", 0, 2, 0.01).name("Intensität");
  fCh.add(params, "choreoSpeed", 0.02, 2, 0.01).name("Tempo");
  fCh.add(params, "fieldScale", 0.1, 3, 0.01).name("Feld-Frequenz (räumlich)");
  fCh.add(params, "focusRadius", 0.5, 1.8, 0.01).name("Fokus-Abstand");
  fCh.add(params, "focusSpeed", 0.02, 2, 0.01).name("Fokus-Tempo");
  fCh.add(params, "dialogue", 0, 2, 0.01).name("Dialog / Reibung");
  fCh.add(params, "dialogueDist", 0.3, 2, 0.01).name("Dialog-Abstand");
  fCh.add(params, "repelShare", 0, 1, 0.01).name("Abstoßung ↔ Anziehung");
  fCh.add(params, "limbForce", 0, 12, 0.05).name("Arme / Beine — Stärke");
  fCh.add(params, "limbDamping", 0, 12, 0.1).name("Arme / Beine — Dämpfung");
  fCh.add(params, "torsoAim", 0, 2, 0.01).name("Körper + Brust ausrichten");
  fCh.add(params, "headLook", 0, 2, 0.01).name("Blick");
  fCh.add(params, "upright", 0, 1, 0.01).name("Aufrichtung");
  fCh.add(params, "posture", 0, 1.5, 0.01).name("Haltung / Wellen");
  fCh.add(params, "postureStiffness", 0.5, 30, 0.1).name("Haltung — Steifigkeit");
  fCh.add(params, "postureLag", 0, 1.5, 0.01).name("Wellen-Verzögerung (s)");
  fCh.add(params, "driftForce", 0, 1, 0.005).name("Drift — Position");
  fCh.add(params, "driftSpin", 0, 1, 0.005).name("Drift — Rotation");
  fCh.add(params, "showFocus").name("Fokuspunkte zeigen");

  const fPhys = gui.addFolder("Physik / Muskeln");
  fPhys.add(params, "jointDamping", 0, 10, 0.05).name("Gelenkdämpfung (Öl)");
  fPhys.add(params, "bodyDamping", 0, 6, 0.05).name("Ganzkörper-Dämpfung");
  fPhys.add(params, "compliance", 0.1, 8, 0.05).name("Beugefreude (global)");
  fPhys.add(params, "hingeBoost", 0.5, 10, 0.1).name("Knie / Ellbogen extra");
  fPhys.add(params, "leafCalm", 0, 1, 0.01).name("Hände / Kopf beruhigen");
  fPhys.add(params, "jointLimitScale", 0.2, 1.6, 0.01).name("Gelenk-Limit");
  fPhys.add(params, "limitStiffness", 2, 200, 1).name("Anschlag-Härte");
  fPhys.add(params, "limitSoftZone", 0.2, 1.0, 0.01).name("Anschlag-Weichzone");
  fPhys.add(params, "limitDamping", 0, 120, 1).name("Anschlag-Dämpfung");
  fPhys.add(params, "restReturn", 0, 20, 0.05).name("Rückstellung Ruhepose");
  fPhys.add(params, "hipReturnStrength", 0, 3, 0.01).name("Rückzug zum Nullpunkt");
  fPhys.add(params, "limbInertia", 0, 3, 0.02).name("Trägheits-Kaskade");
  fPhys.add(params, "angVelClamp", 2, 100, 1).name("Sicherheits-Limit (rad/s)");

  const fCol = gui.addFolder("Kollision");
  fCol.add(params, "selfCollision").name("Selbstkollision");
  fCol.add(params, "collisionScale", 0.3, 1.5, 0.01).name("Kapsel-Größe").onChange(rebuildColliderViz);
  fCol.add(params, "collisionPadding", 0, 0.05, 0.001).name("Abstand (m)");
  fCol.add(params, "collisionStiffness", 0, 300, 1).name("Kontakt-Härte");
  fCol.add(params, "collisionDamping", 0, 60, 0.5).name("Kontakt-Dämpfung");
  fCol.add(params, "collisionProject", 0, 1, 0.01).name("Auseinanderschieben");
  fCol.add(params, "collisionMaxStep", 0.002, 0.05, 0.001).name("Max. Schritt (m)");
  fCol.add(params, "showColliders").name("Kapseln zeigen");

  const fInt = gui.addFolder("Interaktion (Ziehen)");
  fInt.add(params, "attractForce", 1, 120, 0.5).name("Zug-Stärke");
  fInt.add(params, "dragDamping", 0, 40, 0.1).name("Zug-Dämpfung");
  fInt.add(params, "maxForce", 5, 400, 1).name("Kraft-Deckel");
  fInt.add(params, "forceRampSeconds", 0.005, 1, 0.005).name("Kraft-Rampe (s)");
  fInt.add(params, "dragBoost", 0.2, 6, 0.05).name("Gelenk-Ansprechen");
  fInt.add(params, "torsoDragCoupling", 0, 1, 0.01).name("Torso ziehen → Körper");
  fInt.add(params, "bodyCoupling", 0, 1, 0.01).name("Glied ziehen → Körper");
  fInt.add(params, "torqueGain", 0.05, 10, 0.05).name("Drehmoment-Übertrag");
  fInt.add(params, "chainFalloff", 0.1, 1.0, 0.01).name("Ketten-Abnahme");
  fInt.add(params, "dragChoreoFloor", 0, 1, 0.01).name("Choreo-Rest beim Ziehen");
  fInt.add(params, "pickScale", 0.4, 2, 0.05).name("Greif-Radius").onChange(applyPickRadii);

  const fMat = gui.addFolder("Material (Chrom)");
  fMat.addColor(params, "color").name("Farbe").onChange((v) => chromeMaterial.color.set(v));
  fMat.add(params, "metalness", 0, 1, 0.01).onChange((v) => (chromeMaterial.metalness = v));
  fMat.add(params, "roughness", 0, 1, 0.01).onChange((v) => (chromeMaterial.roughness = v));
  fMat.add(params, "envMapIntensity", 0, 3, 0.01).name("Reflexions-Intensität").onChange((v) => (chromeMaterial.envMapIntensity = v));
  fMat.add(params, "clearcoat", 0, 1, 0.01).onChange((v) => (chromeMaterial.clearcoat = v));
  fMat.add(params, "clearcoatRoughness", 0, 1, 0.01).name("Clearcoat Rauheit").onChange((v) => (chromeMaterial.clearcoatRoughness = v));
  fMat.add(params, "normalMapTiling", 0.25, 40, 0.05).name("Normal-Map Tiling").onChange((v) => { if (normalTex) normalTex.repeat.set(v, v); });
  fMat.add(params, "normalMapStrength", 0, 3, 0.01).name("Normal-Map Stärke").onChange((v) => chromeMaterial.normalScale.set(v, v));

  const fLight = gui.addFolder("Licht");
  fLight.add(params, "lightIntensity", 0, 8, 0.1).onChange((v) => (keyLight.intensity = v));
  fLight.addColor(params, "lightColor").name("Farbe").onChange((v) => keyLight.color.set(v));
  fLight.add(params, "lightAzimuth", 0, 360, 1).name("Azimut").onChange(updateLightPos);
  fLight.add(params, "lightElevation", 5, 89, 1).name("Elevation").onChange(updateLightPos);
  fLight.add(params, "ambient", 0, 1, 0.01).name("Fülllicht").onChange((v) => (fill.intensity = v));

  const fCam = gui.addFolder("Kamera");
  fCam.add(params, "fov", 6, 100, 0.5).name("FOV (Mausrad)").listen().onChange((v) => { camera.fov = v; camera.updateProjectionMatrix(); });
  fCam.add(params, "camLookSpeed", 0.2, 8, 0.1).name("Folge-Geschwindigkeit");
  fCam.add(params, "camDistance", 0.8, 10, 0.05).name("Abstand");
  fCam.add(params, "camAzimuth", -180, 180, 1).name("Orbit Azimut").listen();
  fCam.add(params, "camElevation", -80, 85, 1).name("Orbit Elevation").listen();

  const fScene = gui.addFolder("Szene");
  fScene.add(params, "showOrientation").name("Koordinatensystem").onChange((v) => { if (orientationGizmo) orientationGizmo.visible = v; });

  gui.add({ save: downloadMapping }, "save").name("⇩ bonemapping.json");
  gui.add({ reset: resetPose }, "reset").name("↺ Zurücksetzen (T-Pose)");

  fCh.open(); fPhys.close(); fCol.close(); fInt.close();
  fMat.close(); fLight.close(); fCam.close(); fScene.close();
}

function updateLightPos() {
  const az = RAD(params.lightAzimuth), el = RAD(params.lightElevation), d = 3.2;
  keyLight.position.set(Math.cos(az) * Math.cos(el) * d, Math.sin(el) * d, Math.sin(az) * Math.cos(el) * d);
  keyLight.target.position.copy(characterRoot.position);
}

/* ============================================================
   KAMERA
   ============================================================ */
function updateCamera(dt) {
  const t = 1 - Math.exp(-params.camLookSpeed * dt);
  cameraRig.targetLook.lerp(characterRoot.position, t);
  const az = RAD(params.camAzimuth), el = RAD(params.camElevation), r = params.camDistance;
  camera.position.copy(cameraRig.targetLook).add(new THREE.Vector3(
    Math.sin(az) * Math.cos(el) * r, Math.sin(el) * r, Math.cos(az) * Math.cos(el) * r));
  camera.lookAt(cameraRig.targetLook);
  keyLight.target.position.copy(cameraRig.targetLook);
}

/* ============================================================
   INTERAKTIONS-VISUALISIERUNG
   ============================================================ */
function updateInteractionVisuals() {
  if (!skinnedMesh) { hitMarker.visible = false; forceLine.visible = false; return; }
  if (interaction) {
    const worldPoint = interaction.localOffset.clone().applyMatrix4(interaction.bone.matrixWorld);
    const dir = new THREE.Vector3().subVectors(interaction.target, worldPoint);
    const normal = dir.lengthSq() > 1e-10 ? dir.clone().normalize() : new THREE.Vector3(0, 0, 1);
    hitMarker.position.copy(worldPoint);
    orientMarkerToNormal(hitMarker, normal);
    hitMarker.material.color.copy(COLOR_ATTRACT);
    hitMarker.visible = true;
    forceLine.geometry.setFromPoints([worldPoint, interaction.target]);
    forceLine.material.color.copy(COLOR_ATTRACT);
    forceLine.visible = true;
  } else if (lastMouse.active && !orbitDragging) {
    forceLine.visible = false;
    const hit = pickCollider(lastMouse.x, lastMouse.y);
    if (hit) {
      const center = new THREE.Vector3();
      hit.owner.getWorldPosition(center);
      const normal = hit.point.clone().sub(center);
      if (normal.lengthSq() < 1e-8) normal.set(0, 0, 1); else normal.normalize();
      hitMarker.position.copy(hit.point);
      orientMarkerToNormal(hitMarker, normal);
      hitMarker.material.color.copy(COLOR_HOVER);
      hitMarker.visible = true;
    } else hitMarker.visible = false;
  } else { hitMarker.visible = false; forceLine.visible = false; }
}

/* ============================================================
   RENDER LOOP
   ============================================================ */
function animate() {
  requestAnimationFrame(animate);
  const dt = clock.getDelta();
  if (skinnedMesh) {
    if (params.mappingMode) {
      mappingDrag();
      refreshFrames();
      updateMappingList(false);
      if (interaction) refreshPoseSliders();
      updateJointGizmo();
    } else {
      updatePhysics(dt);
    }
  }
  updateCamera(dt);
  updateInteractionVisuals();
  updateFocusVisuals();
  updateColliderViz();
  renderer.render(scene, camera);
}
camera.fov = params.fov;
camera.updateProjectionMatrix();
updateLightPos();
animate();
</script>
</body>
</html>
Assets
cubemap/nx.jpg  (115 KB)
cubemap/ny.jpg  (42 KB)
cubemap/nz.jpg  (102 KB)
cubemap/px.jpg  (59 KB)
cubemap/py.jpg  (92 KB)
cubemap/pz.jpg  (33 KB)
smoothed_body_for_MIMICRY_2.glb  (998 KB)