BIOMORPH CREATURE RIG DESIGNER

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/f962e83b597128b9/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MECHA RIG</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/TransformControls.js"></script>
<style>
    * { box-sizing: border-box; }
    html, body {
        margin: 0; height: 100%; overflow: hidden;
        background: #f2f2f2; color: #111; user-select: none;
        font: 11px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    }
    #webgl-container { position: fixed; inset: 0; }
    canvas { display: block; }

    #title {
        position: fixed; top: 16px; left: 16px; pointer-events: none;
        font-size: 12px; font-weight: 600; letter-spacing: .18em;
    }
    #title small { display: block; margin-top: 3px; font-size: 10px; font-weight: 400; letter-spacing: .04em; color: #999; }
    #sel { margin-top: 10px; font-size: 11px; font-weight: 400; letter-spacing: 0; color: #111; min-height: 16px; }

    #bar {
        position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
        display: flex; align-items: center; gap: 2px; padding: 4px;
        background: #fff; border: 1px solid #e6e6e6; border-radius: 999px;
    }
    #bar button {
        font: inherit; color: #111; background: transparent; border: 0; border-radius: 999px;
        padding: 7px 14px; cursor: pointer; white-space: nowrap;
    }
    #bar button:hover { background: #f4f4f4; }
    #bar button.on { background: #ececec; }
    #bar button:disabled { color: #bbb; cursor: default; background: transparent; }
    #bar .sep { width: 1px; height: 14px; background: #e6e6e6; margin: 0 4px; }

    #panel {
        position: fixed; top: 16px; right: 16px; width: 272px; max-height: calc(100vh - 32px);
        overflow-y: auto; background: #fff; border: 1px solid #e6e6e6; border-radius: 8px;
    }
    #panel::-webkit-scrollbar { width: 6px; }
    #panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }

    .fhead {
        display: flex; justify-content: space-between; width: 100%;
        font: inherit; font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: #111;
        background: none; border: 0; border-top: 1px solid #f0f0f0; padding: 9px 12px; cursor: pointer; text-align: left;
    }
    #panel > .folder:first-child > .fhead { border-top: 0; }
    .fhead::after { content: "+"; color: #aaa; }
    .folder.open > .fhead::after { content: "–"; }
    .fbody { display: none; padding: 0 0 8px; }
    .folder.open > .fbody { display: block; }
    .fbody .folder > .fhead { padding-left: 20px; letter-spacing: .04em; text-transform: none; font-size: 11px; color: #444; }
    .fbody .folder .fbody { padding-left: 8px; }

    .row { display: grid; grid-template-columns: 92px 1fr 38px; align-items: center; gap: 8px; padding: 3px 12px; }
    .row.simple { grid-template-columns: 1fr auto; }
    .row label { color: #777; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .row output { text-align: right; color: #111; font-size: 10px; font-variant-numeric: tabular-nums; }
    .row input[type=checkbox] { accent-color: #111; margin: 0; }
    .row input[type=color] { width: 26px; height: 14px; padding: 0; border: 1px solid #ddd; border-radius: 3px; background: #fff; cursor: pointer; }
    .note { padding: 8px 12px 6px; color: #aaa; font-size: 10px; }
    #frame { position: fixed; pointer-events: none; border: 1px dashed #b5b5b5; display: none; }
    .row select { font: inherit; color: #111; background: #fff; border: 1px solid #e6e6e6; border-radius: 4px; padding: 2px 4px; }
    .btnrow { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; padding: 6px 12px 0; }
    .btnrow .btn { width: auto; margin: 0; padding: 4px 2px; }
    .btn {
        display: block; width: calc(100% - 24px); margin: 4px 12px 0; font: inherit; color: #111;
        background: #fff; border: 1px solid #e6e6e6; border-radius: 6px; padding: 5px 8px; cursor: pointer;
    }
    .btn:hover { background: #f6f6f6; }

    input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; height: 14px; margin: 0; background: transparent; cursor: pointer; }
    input[type=range]::-webkit-slider-runnable-track { height: 1px; background: #d2d2d2; }
    input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 9px; height: 9px; margin-top: -4px; border-radius: 50%; background: #111; }
    input[type=range]::-moz-range-track { height: 1px; background: #d2d2d2; }
    input[type=range]::-moz-range-thumb { width: 9px; height: 9px; border: 0; border-radius: 50%; background: #111; }
</style>
</head>
<body>

<div id="webgl-container"></div>
<div id="frame"></div>

<div id="title">MECHA RIG
    <small>Bone anklicken · W bewegen · E drehen · N alle Nodes</small>
    <div id="sel"></div>
</div>

<div id="bar">
    <button id="add-arm-btn">+ Arm</button>
    <button id="add-leg-btn">+ Bein</button>
    <button id="add-head-btn">+ Kopf</button>
    <span class="sep"></span>
    <button id="rnd-anatomy-btn">Zufall Anatomie</button>
    <button id="rnd-pose-btn">Zufall Pose</button>
    <span class="sep"></span>
    <button id="mode-translate-btn">Bewegen</button>
    <button id="mode-rotate-btn">Drehen</button>
    <span class="sep"></span>
    <button id="nodes-btn">Nodes</button>
    <button id="screenshot-btn">Snapshot</button>
</div>

<div id="panel"></div>

<script>
// ============================================================
//  STATE
// ============================================================
let scene, camera, renderer, controls, tc, keyLight;
let spineCurve, spineLine, spineNodes = [];
let nodeHandles = [], hipHandle;
let raycaster, mouse, draggedObject = null;
let downX = 0, downY = 0, gizmoDown = false;
let normalMapTexture;
let mechaMaterial, shellMaterial, nerveCableMaterial, glowMaterial, hydraulicMaterial;
let markerMats = {};
let floorMesh;
let spineRest = [];
let exporting = false;
let fileMsg = '';
let vertM, vertH, nerveMeshL, nerveMeshR;

// Alles Körperliche hängt unter bodyRoot (Ursprung = Hüfte = erster Spline-Knoten).
const bodyRoot = new THREE.Group();
const spineGroup = new THREE.Group();
const ribCagesGroup = new THREE.Group();
const nerveTrunksGroup = new THREE.Group();
const cablesGroup = new THREE.Group();
bodyRoot.add(spineGroup, ribCagesGroup, nerveTrunksGroup, cablesGroup);

const limbs = [];
const heads = [];
const ribEntries = [];

const MAX_VERTEBRAE = 41;
const LINE_SEG = 60;

const config = {
    vertebraeCount: 24,
    ribCageCount: 2,
    ribsPerCage: 6,
    showNodes: true,
    showAllNodes: false,        // Modus: alle anklickbaren Nodes (Gelenke, Spine-Anker) sichtbar
    exportFormat: 'win',        // Export-Ausschnitt: 'win' = ganzes Fenster, sonst Seitenverhältnis
    exportH: 1024,
    mirror: true,               // Symmetrie: Gelenkdrehung wird auf die Gegenseite gespiegelt
    mode: 'rotate',             // 'rotate' | 'translate'

    shellVisible: true,
    shellOpenFactor: 0.0,

    nerveThickness: 0.025,

    mechaColor: '#080808',
    mechaMetalness: 0.95,
    mechaRoughness: 0.25,
    shellColor: '#f1f5f9',
    shellRoughness: 0.1,
    shellClearcoat: 1.0,
    nerveColor: '#ffffff',
    glowColor: '#00f0ff',
    normalScale: 0.25
};

const UP = new THREE.Vector3(0, 1, 0);
const _p = new THREE.Vector3(), _t = new THREE.Vector3(), _s = new THREE.Vector3();
const _xa = new THREE.Vector3(), _za = new THREE.Vector3();
const _q = new THREE.Quaternion(), _m = new THREE.Matrix4();
const _w1 = new THREE.Vector3(), _w2 = new THREE.Vector3();
const _plane = new THREE.Plane(), _camDir = new THREE.Vector3();
const _inv = new THREE.Matrix4(), _grav = new THREE.Vector3();

// ============================================================
//  RENDER ON DEMAND
// ============================================================
let renderQueued = false, spineDirty = false;

function requestRender() {
    if (renderQueued) return;
    renderQueued = true;
    requestAnimationFrame(frame);
}
function frame() {
    renderQueued = false;
    if (spineDirty) { spineDirty = false; updateSpine(); }
    renderer.render(scene, camera);
}

// ============================================================
//  GEOMETRIE-HELFER
// ============================================================
const geoCache = new Map();
function cached(key, make) {
    let g = geoCache.get(key);
    if (!g) { g = make(); g.userData.shared = true; geoCache.set(key, g); }
    return g;
}
function disposeTree(obj) {
    obj.traverse(o => { if (o.geometry && !o.geometry.userData.shared) o.geometry.dispose(); });
}
function clearGroup(g) {
    while (g.children.length) { const c = g.children[0]; g.remove(c); disposeTree(c); }
}
const mx = (px, py, pz, rx = 0, ry = 0, rz = 0) =>
    new THREE.Matrix4().compose(new THREE.Vector3(px, py, pz),
        new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(1, 1, 1));

function concat(arrays) {
    let n = 0; for (const a of arrays) n += a.length;
    const out = new Float32Array(n); let o = 0;
    for (const a of arrays) { out.set(a, o); o += a.length; }
    return out;
}
function mergeGeos(parts) {
    const P = [], N = [], U = [];
    for (const { geo, matrix } of parts) {
        const g = geo.index ? geo.toNonIndexed() : geo.clone();
        if (matrix) g.applyMatrix4(matrix);
        P.push(g.attributes.position.array);
        N.push(g.attributes.normal.array);
        U.push(g.attributes.uv.array);
    }
    const out = new THREE.BufferGeometry();
    out.setAttribute('position', new THREE.BufferAttribute(concat(P), 3));
    out.setAttribute('normal', new THREE.BufferAttribute(concat(N), 3));
    out.setAttribute('uv', new THREE.BufferAttribute(concat(U), 2));
    return out;
}

function buildLeaf(width, height, curvature) {
    const shape = new THREE.Shape();
    shape.moveTo(0, height / 2);
    shape.bezierCurveTo(width * 0.85, height * 0.35, width, -height * 0.15, 0, -height / 2);
    shape.bezierCurveTo(-width, -height * 0.15, -width * 0.85, height * 0.35, 0, height / 2);
    const geo = new THREE.ExtrudeGeometry(shape, {
        steps: 1, depth: 0.025, bevelEnabled: true, bevelThickness: 0.02, bevelSize: 0.02, bevelSegments: 2
    });
    geo.center();
    const pa = geo.attributes.position;
    for (let i = 0; i < pa.count; i++) {
        const x = pa.getX(i), y = pa.getY(i);
        const d = 1.0 - (Math.abs(y) / (height / 2));
        pa.setZ(i, pa.getZ(i) + Math.cos((x / width) * Math.PI * 0.5) * curvature * (d + 0.2));
    }
    geo.computeVertexNormals();
    return geo;
}
const leafGeo = (w, h, c) => cached(`leaf|${w}|${h}|${c}`, () => buildLeaf(w, h, c));

const jointGeoM = r => cached('jM|' + r, () =>
    mergeGeos([{ geo: new THREE.TorusGeometry(r * 0.9, r * 0.3, 10, 16), matrix: mx(0, 0, 0, Math.PI / 2, 0, 0) }]));
const jointGeoH = r => cached('jH|' + r, () => {
    const parts = [{ geo: new THREE.CylinderGeometry(r * 0.55, r * 0.55, r * 1.6, 12), matrix: mx(0, 0, 0, 0, 0, Math.PI / 2) }];
    for (const s of [-1, 1])
        parts.push({ geo: new THREE.CylinderGeometry(r * 0.18, r * 0.18, r * 1.4, 8), matrix: mx(s * r * 0.7, 0, r * 0.4, 0.3, 0, 0) });
    return mergeGeos(parts);
});
// info = Auswahl-Ziel (anklickbar), optional
function createBiomorphicJoint(r, info) {
    const g = new THREE.Group();
    const a = new THREE.Mesh(jointGeoM(r), mechaMaterial);
    const b = new THREE.Mesh(jointGeoH(r), hydraulicMaterial);
    a.castShadow = b.castShadow = true;
    a.userData.seg = b.userData.seg = 'gelenk';
    if (info) a.userData.sel = b.userData.sel = info;
    g.add(a, b);
    return g;
}

// Node-Marker: sichtbar im „Nodes“-Modus, klickbar wie der Bone selbst
// spine = grün (an der Spine verschiebbar), joint = violett (Gelenk, drehbar)
function makeMarker(type, info) {
    const geo = cached('mk' + type, () => new THREE.SphereGeometry(type === 'spine' ? 0.22 : 0.15, 14, 10));
    const m = new THREE.Mesh(geo, markerMats[type]);
    m.renderOrder = 12;
    m.userData = { sel: info, isMarker: true };
    m.visible = config.showAllNodes;
    return m;
}

const vertGeoM = () => cached('vM', () => mergeGeos([
    { geo: new THREE.CylinderGeometry(0.18, 0.14, 0.15, 10) },
    { geo: leafGeo(0.12, 0.38, 0.08), matrix: mx(0, 0, -0.22, Math.PI / 2, 0, 0) }
]));
const vertGeoH = () => cached('vH', () => mergeGeos([-1, 1].map(s => ({
    geo: new THREE.CylinderGeometry(0.04, 0.04, 0.14, 8), matrix: mx(s * 0.16, 0, 0)
}))));

// ============================================================
//  INIT
// ============================================================
function init() {
    const container = document.getElementById('webgl-container');

    scene = new THREE.Scene();
    scene.background = new THREE.Color('#f2f2f2');
    scene.add(bodyRoot);
    bodyRoot.position.set(0, 1.87, 0);

    camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
    camera.position.set(0, 5, 19);

    renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(innerWidth, innerHeight);
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFShadowMap;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.1;
    container.appendChild(renderer.domElement);

    controls = new THREE.OrbitControls(camera, renderer.domElement);
    controls.enableDamping = false;
    controls.target.set(0, 3.4, 0);
    controls.addEventListener('change', requestRender);

    // Gizmo für den angeklickten Bone
    tc = new THREE.TransformControls(camera, renderer.domElement);
    tc.setSize(0.8);
    tc.addEventListener('change', requestRender);
    tc.addEventListener('dragging-changed', e => { controls.enabled = !e.value; });
    tc.addEventListener('objectChange', () => { if (selection) selection.onChange(selection.mode); });
    scene.add(tc);

    raycaster = new THREE.Raycaster();
    mouse = new THREE.Vector2();

    generateProceduralNormalMap();
    setupStudioLighting();
    setupStudioFloor();
    buildMaterials();
    setupBezierSpine();
    buildMultipleRibCages();

    addHead();
    addLimbPair('arm', 0.85, 'Oberer Mantis-Arm');
    addLimbPair('arm', 0.68, 'Zweiter Bio-Arm');
    addLimbPair('leg', 0.20, 'Hexapod-Hauptbein');
    addLimbPair('leg', 0.08, 'Unterer Stabilisator');

    addEventListener('resize', onWindowResize);
    addEventListener('keydown', onKeyDown);
    const cv = renderer.domElement;
    cv.addEventListener('pointerdown', onPointerDown);
    cv.addEventListener('pointermove', onPointerMove);
    cv.addEventListener('pointerup', onPointerUp);
    cv.addEventListener('pointercancel', onPointerUp);

    document.getElementById('add-arm-btn').addEventListener('click', () => addLimbPair('arm', 0.5, `Arm-Paar ${limbs.length + 1}`));
    document.getElementById('add-leg-btn').addEventListener('click', () => addLimbPair('leg', 0.1, `Bein-Paar ${limbs.length + 1}`));
    document.getElementById('add-head-btn').addEventListener('click', () => addHead());
    document.getElementById('mode-translate-btn').addEventListener('click', () => setMode('translate'));
    document.getElementById('mode-rotate-btn').addEventListener('click', () => setMode('rotate'));
    document.getElementById('nodes-btn').addEventListener('click', () => setNodesMode(!config.showAllNodes));
    document.getElementById('rnd-anatomy-btn').addEventListener('click', randomAnatomy);
    document.getElementById('rnd-pose-btn').addEventListener('click', randomPose);
    document.getElementById('screenshot-btn').addEventListener('click', takeScreenshot);

    updateSpine();
    applyShellsAll();
    updateLight();
    updateModeButtons();
    updateFrame();
    rebuildGUI();
    requestRender();
}

// ============================================================
//  TEXTUR / LICHT / BODEN / MATERIAL
// ============================================================
function generateProceduralNormalMap() {
    const S = 256;
    const canvas = document.createElement('canvas');
    canvas.width = canvas.height = S;
    const ctx = canvas.getContext('2d');
    const img = ctx.createImageData(S, S);
    const d = img.data;
    for (let i = 0; i < d.length; i += 4) {
        const nx = (Math.random() - 0.5) * 2.0, ny = (Math.random() - 0.5) * 2.0;
        const len = Math.sqrt(nx * nx + ny * ny + 1.0);
        d[i]     = ((nx / len) * 0.5 + 0.5) * 255;
        d[i + 1] = ((ny / len) * 0.5 + 0.5) * 255;
        d[i + 2] = ((1 / len) * 0.5 + 0.5) * 255;
        d[i + 3] = 255;
    }
    ctx.putImageData(img, 0, 0);
    normalMapTexture = new THREE.CanvasTexture(canvas);
    normalMapTexture.wrapS = normalMapTexture.wrapT = THREE.RepeatWrapping;
    normalMapTexture.repeat.set(12, 12);
}

function setupStudioLighting() {
    scene.add(new THREE.AmbientLight(0xffffff, 0.6));

    keyLight = new THREE.DirectionalLight(0xffffff, 1.8);
    keyLight.castShadow = true;
    keyLight.shadow.mapSize.set(2048, 2048);
    const sc = keyLight.shadow.camera;
    sc.near = 1; sc.far = 40;
    sc.left = -8; sc.right = 8; sc.top = 8; sc.bottom = -8;
    keyLight.shadow.bias = -0.0003;
    keyLight.shadow.normalBias = 0.02;
    scene.add(keyLight, keyLight.target);

    const rim = new THREE.DirectionalLight(0x00f0ff, 1.2);
    rim.position.set(-8, 6, -6);
    scene.add(rim);

    const fill = new THREE.DirectionalLight(0xffffff, 0.5);
    fill.position.set(8, 4, 8);
    scene.add(fill);
}
// Key-Light folgt der Hüfte, damit der Körper immer mittig im Shadow-Frustum liegt
function updateLight() {
    const p = bodyRoot.position;
    keyLight.target.position.set(p.x, 0, p.z);
    keyLight.position.set(p.x + 1.5, 16, p.z + 2.5);
}

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

function buildMaterials() {
    mechaMaterial = new THREE.MeshStandardMaterial({
        color: config.mechaColor, metalness: config.mechaMetalness, roughness: config.mechaRoughness,
        normalMap: normalMapTexture, normalScale: new THREE.Vector2(config.normalScale, config.normalScale)
    });
    shellMaterial = new THREE.MeshPhysicalMaterial({
        color: config.shellColor, metalness: 0.05, roughness: config.shellRoughness,
        clearcoat: config.shellClearcoat, clearcoatRoughness: 0.05, side: THREE.DoubleSide,
        normalMap: normalMapTexture, normalScale: new THREE.Vector2(config.normalScale * 0.5, config.normalScale * 0.5)
    });
    nerveCableMaterial = new THREE.MeshStandardMaterial({
        color: config.nerveColor, metalness: 0.1, roughness: 0.35,
        normalMap: normalMapTexture, normalScale: new THREE.Vector2(0.15, 0.15)
    });
    hydraulicMaterial = new THREE.MeshStandardMaterial({ color: '#e2e8f0', metalness: 0.98, roughness: 0.08 });
    glowMaterial = new THREE.MeshBasicMaterial({ color: config.glowColor });
    const mm = c => new THREE.MeshBasicMaterial({ color: c, depthTest: false, transparent: true, opacity: 0.92 });
    markerMats = { spine: mm(0x16a34a), joint: mm(0x7c3aed) };
}

// ============================================================
//  SPINE
// ============================================================
function setupBezierSpine() {
    spineNodes = [
        new THREE.Vector3(0, 0, 0),
        new THREE.Vector3(0, 1.5, -0.4),
        new THREE.Vector3(0, 3.1, 0.3),
        new THREE.Vector3(0, 4.6, 0)
    ];
    spineCurve = new THREE.CubicBezierCurve3(spineNodes[0], spineNodes[1], spineNodes[2], spineNodes[3]);
    spineCurve.arcLengthDivisions = 100;
    spineRest = [1, 2, 3].map(i => spineNodes[i].clone());     // Ausgangsform für „Zufall Pose“

    const lineGeo = new THREE.BufferGeometry();
    lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((LINE_SEG + 1) * 3), 3));
    spineLine = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x2563eb, depthTest: false, transparent: true, opacity: 0.8 }));
    spineLine.frustumCulled = false;
    spineLine.renderOrder = 9;
    spineGroup.add(spineLine);

    vertM = new THREE.InstancedMesh(vertGeoM(), mechaMaterial, MAX_VERTEBRAE);
    vertH = new THREE.InstancedMesh(vertGeoH(), hydraulicMaterial, MAX_VERTEBRAE);
    for (const v of [vertM, vertH]) { v.frustumCulled = false; v.castShadow = true; v.userData.seg = 'wirbel'; spineGroup.add(v); }

    nerveMeshL = new THREE.Mesh(new THREE.BufferGeometry(), nerveCableMaterial);
    nerveMeshR = new THREE.Mesh(new THREE.BufferGeometry(), nerveCableMaterial);
    nerveMeshL.userData.seg = nerveMeshR.userData.seg = 'nerv';
    nerveTrunksGroup.add(nerveMeshL, nerveMeshR);

    // Spline-Knoten 1–3 (blau, per Drag)
    const nodeGeo = new THREE.SphereGeometry(0.26, 16, 12);
    const nodeMat = new THREE.MeshBasicMaterial({ color: 0x2563eb, depthTest: false, transparent: true, opacity: 0.9 });
    for (let i = 1; i < spineNodes.length; i++) {
        const h = new THREE.Mesh(nodeGeo, nodeMat);
        h.position.copy(spineNodes[i]);
        h.renderOrder = 10;
        h.userData = { isNode: true, nodeIndex: i };
        bodyRoot.add(h);
        nodeHandles.push(h);
    }

    // Hip-Bone (schwarze Raute, anklickbar -> Bewegen/Drehen des ganzen Körpers)
    hipHandle = new THREE.Mesh(
        new THREE.OctahedronGeometry(0.4),
        new THREE.MeshBasicMaterial({ color: 0x111111, depthTest: false, transparent: true, opacity: 0.92 })
    );
    hipHandle.renderOrder = 11;
    hipHandle.userData = { sel: { kind: 'hip' } };
    bodyRoot.add(hipHandle);
}

function updateSpine() {
    spineCurve.updateArcLengths();

    const lp = spineLine.geometry.attributes.position.array;
    for (let i = 0; i <= LINE_SEG; i++) {
        spineCurve.getPoint(i / LINE_SEG, _p);
        lp[i * 3] = _p.x; lp[i * 3 + 1] = _p.y; lp[i * 3 + 2] = _p.z;
    }
    spineLine.geometry.attributes.position.needsUpdate = true;

    for (const h of nodeHandles) h.position.copy(spineNodes[h.userData.nodeIndex]);

    const n = config.vertebraeCount;
    vertM.count = vertH.count = n + 1;
    const nl = [], nr = [];
    for (let i = 0; i <= n; i++) {
        const t = i / n;
        spineCurve.getPointAt(t, _p);
        spineCurve.getTangentAt(t, _t);
        const s = 1.0 - Math.abs(t - 0.5) * 0.25;

        _q.setFromUnitVectors(UP, _t);
        _m.compose(_p, _q, _s.set(s, s, s));
        vertM.setMatrixAt(i, _m);
        vertH.setMatrixAt(i, _m);

        _xa.set(1, 0, 0).applyQuaternion(_q);
        _za.set(0, 0, -1).applyQuaternion(_q);
        nl.push(_p.clone().addScaledVector(_xa, 0.15 * s).addScaledVector(_za, 0.05));
        nr.push(_p.clone().addScaledVector(_xa, -0.15 * s).addScaledVector(_za, 0.05));
    }
    vertM.instanceMatrix.needsUpdate = true;
    vertH.instanceMatrix.needsUpdate = true;

    nerveMeshL.geometry.dispose();
    nerveMeshR.geometry.dispose();
    nerveMeshL.geometry = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(nl), 40, config.nerveThickness, 6, false);
    nerveMeshR.geometry = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(nr), 40, config.nerveThickness, 6, false);

    updateRibPositions();
    for (const h of heads) h.updatePositionOnSpine();
    for (const l of limbs) l.updatePositionOnSpine();
}

// nächster Spine-Parameter t zu einem Punkt (bodyRoot-lokal) – für „entlang der Spine schieben“
function nearestSpineT(p, tMin, tMax) {
    let best = 0, bestD = Infinity;
    const N = 80;
    for (let i = 0; i <= N; i++) {
        spineCurve.getPointAt(i / N, _w1);
        const d = _w1.distanceToSquared(p);
        if (d < bestD) { bestD = d; best = i / N; }
    }
    const lo = Math.max(0, best - 1 / N), hi = Math.min(1, best + 1 / N);
    for (let i = 0; i <= 10; i++) {
        const t = lo + (hi - lo) * i / 10;
        spineCurve.getPointAt(t, _w1);
        const d = _w1.distanceToSquared(p);
        if (d < bestD) { bestD = d; best = t; }
    }
    return Math.min(tMax, Math.max(tMin, best));
}

// ============================================================
//  SHELLS
// ============================================================
function applyShells(root) {
    root.traverse(o => {
        if (o.userData.shellFn) { o.visible = config.shellVisible; o.userData.shellFn(config.shellOpenFactor); }
    });
}
function applyShellsAll() {
    for (const h of heads) applyShells(h.root);
    applyShells(ribCagesGroup);
    for (const l of limbs) applyShells(l.rootGroup);
}

// ============================================================
//  KÖPFE: elliptische Halbkugel-Schale (Shell-Material) auf einem Neck
// ============================================================
class HeadUnit {
    constructor(name, t, rot) {
        this.name = name;
        this.t = t;
        this.rot = rot;               // Euler des Neck-Pivots (per Gizmo gesetzt)
        this.restRot = rot.slice();   // Ausgangsdrehung für „Zufall Pose“
        this.neckLength = 0.5;
        this.w = 0.34; this.h = 0.44; this.d = 0.3;      // Halbachsen der Ellipse (Breite, Höhe, Tiefe)
        this.root = new THREE.Group();
        bodyRoot.add(this.root);
        this.build();
        this.updatePositionOnSpine();
    }

    build() {
        clearGroup(this.root);
        const info = { kind: 'head', head: this };

        const pivot = new THREE.Group();
        pivot.rotation.set(this.rot[0], this.rot[1], this.rot[2]);
        this.pivot = pivot;
        this.root.add(pivot);

        pivot.add(createBiomorphicJoint(0.12, info));
        pivot.add(makeMarker('spine', info));

        const len = this.neckLength;
        const neck = new THREE.Mesh(cached('neck', () => new THREE.CylinderGeometry(0.07, 0.09, 1, 10)), mechaMaterial);
        neck.scale.y = len;
        neck.position.y = len / 2;
        neck.castShadow = true;
        neck.userData.sel = info;
        neck.userData.seg = 'hals';
        pivot.add(neck);

        const top = new THREE.Group();
        top.position.y = len;
        top.add(createBiomorphicJoint(0.1, info));
        pivot.add(top);
        this.top = top;

        // Halbkugel: Kuppel zeigt nach +z (Gesichtsseite), offene Seite zum Neck
        const shell = new THREE.Mesh(
            cached('hemi', () => new THREE.SphereGeometry(1, 28, 14, 0, Math.PI * 2, 0, Math.PI / 2)),
            shellMaterial
        );
        shell.scale.set(this.w, this.d, this.h);
        shell.position.set(0, this.h * 0.45, -0.04);
        shell.castShadow = true;
        shell.userData.sel = info;
        shell.userData.seg = 'kopf';
        shell.userData.shellFn = o => { shell.rotation.x = Math.PI / 2 - o * 0.7; };
        top.add(shell);

        applyShells(this.root);
        refreshSelection();
    }

    updatePositionOnSpine() {
        spineCurve.getPointAt(this.t, this.root.position);
        spineCurve.getTangentAt(this.t, _t);
        this.root.quaternion.setFromUnitVectors(UP, _t);
    }
    slideTo() {                       // Gizmo hat root verschoben -> auf nächsten Spine-Punkt projizieren
        this.t = nearestSpineT(this.root.position, 0.1, 1.0);
        this.updatePositionOnSpine();
    }
    readRot() {
        const r = this.pivot.rotation;
        this.rot = [r.x, r.y, r.z];
    }
    remove() {
        bodyRoot.remove(this.root);
        disposeTree(this.root);
    }
}

function addHead() {
    const n = heads.length;
    const side = n % 2 ? 1 : -1;
    const h = new HeadUnit(`Kopf ${n + 1}`, n === 0 ? 1.0 : 0.9, n === 0 ? [0, 0, 0] : [0, 0, side * 0.5 * Math.ceil(n / 2)]);
    heads.push(h);
    rebuildGUI();
    requestRender();
    return h;
}
function removeHead(i) {
    if (!heads[i]) return;
    if (selection && selection.owner === heads[i]) clearSelection();
    heads[i].remove();
    heads.splice(i, 1);
    rebuildGUI();
    requestRender();
}

// ============================================================
//  BRUSTKÖRBE
// ============================================================
function buildMultipleRibCages() {
    clearGroup(ribCagesGroup);
    ribEntries.length = 0;

    for (let c = 0; c < config.ribCageCount; c++) {
        const cageStartT = 0.45 + c * 0.22;
        if (cageStartT > 0.9) break;

        const cage = new THREE.Group();

        for (let i = 0; i < config.ribsPerCage; i++) {
            const factor = i / config.ribsPerCage;
            const t = cageStartT + factor * 0.16;
            if (t > 0.95) break;

            const radius = (0.65 + Math.sin(factor * Math.PI) * 0.35) * (1.0 - c * 0.15);
            const ribs = [], nerves = [];
            for (const side of [-1, 1]) {
                const curve = new THREE.CatmullRomCurve3([
                    new THREE.Vector3(0, 0, 0),
                    new THREE.Vector3(side * radius * 0.65, 0.08, radius * 0.55),
                    new THREE.Vector3(side * radius, -0.1, 0),
                    new THREE.Vector3(side * radius * 0.6, -0.25, -radius * 0.35)
                ]);
                ribs.push({ geo: new THREE.TubeGeometry(curve, 14, 0.028, 6, false) });
                nerves.push({ geo: new THREE.TubeGeometry(curve, 14, 0.012, 5, false) });
            }
            const pair = new THREE.Group();
            const rm = new THREE.Mesh(mergeGeos(ribs), mechaMaterial);
            rm.castShadow = true;
            rm.userData.seg = 'rippe';
            const rn = new THREE.Mesh(mergeGeos(nerves), nerveCableMaterial);
            rn.userData.seg = 'nerv';
            pair.add(rm, rn);
            cage.add(pair);
            ribEntries.push({ obj: pair, t });
        }

        const chest = new THREE.Group();
        for (const side of [-1, 1]) {
            const leaf = new THREE.Mesh(leafGeo(0.46, 0.9, 0.2), shellMaterial);
            leaf.castShadow = true;
            leaf.userData.seg = 'brust_shell';
            leaf.userData.shellFn = o => {
                const a = o * 0.8;
                leaf.position.set(side * (0.28 + a * 0.4), 0.05, 0.38 + a * 0.2);
                leaf.rotation.y = side * (0.45 + a);
            };
            chest.add(leaf);
        }
        cage.add(chest);
        ribEntries.push({ obj: chest, t: cageStartT + 0.1 });

        ribCagesGroup.add(cage);
    }
    updateRibPositions();
}
function updateRibPositions() {
    for (const e of ribEntries) spineCurve.getPointAt(e.t, e.obj.position);
}
function rebuildRibs() {
    buildMultipleRibCages();
    applyShells(ribCagesGroup);
}

// ============================================================
//  NERVENKABEL: Bündel dünner Bezier-Kabel Spine -> Schulter -> Femur -> Tibia -> Ende
//  Pro Strang zufällig Skelett- oder Shell-Material. Quasi-statisch, nur bei Änderungen neu berechnet.
// ============================================================
const CABLE_STEPS = 10;
const CABLE_RAD = 4;
const CABLE_COUNT = 4 * CABLE_STEPS + 1;
const _cbuf = new Float32Array(CABLE_COUNT * 3);
const _ta = new THREE.Vector3(), _tb = new THREE.Vector3(), _c1 = new THREE.Vector3(), _c2 = new THREE.Vector3();
const CR = [1, 0, -1, 0], SR = [0, 1, 0, -1];
const hash = (a, b) => { const x = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453; return x - Math.floor(x); };

function sampleCable(pts, outDir, slack, grav, buf) {
    let o = 0;
    const n = pts.length;
    for (let i = 0; i < n - 1; i++) {
        const a = pts[i], b = pts[i + 1];
        const len = a.distanceTo(b);
        const ta = i === 0 ? outDir : _ta.copy(pts[i + 1]).sub(pts[i - 1]).normalize();
        const tb = (i + 1 === n - 1) ? _tb.copy(b).sub(a).normalize() : _tb.copy(pts[i + 2]).sub(a).normalize();
        const k = i === 0 ? 0.45 : 0.33;
        const sag = len * (i === 0 ? 0.35 : 0.06) * slack;
        _c1.copy(a).addScaledVector(ta, len * k).addScaledVector(grav, sag);
        _c2.copy(b).addScaledVector(tb, -len * k).addScaledVector(grav, sag);
        for (let j = (i === 0 ? 0 : 1); j <= CABLE_STEPS; j++) {
            const u = j / CABLE_STEPS, v = 1 - u;
            const w0 = v * v * v, w1 = 3 * v * v * u, w2 = 3 * v * u * u, w3 = u * u * u;
            buf[o++] = w0 * a.x + w1 * _c1.x + w2 * _c2.x + w3 * b.x;
            buf[o++] = w0 * a.y + w1 * _c1.y + w2 * _c2.y + w3 * b.y;
            buf[o++] = w0 * a.z + w1 * _c1.z + w2 * _c2.z + w3 * b.z;
        }
    }
}
function writeTube(P, n, radius, pos, nor, uv, vBase) {
    let tx = P[3] - P[0], ty = P[4] - P[1], tz = P[5] - P[2];
    let l = Math.hypot(tx, ty, tz) || 1; tx /= l; ty /= l; tz /= l;
    let ax = Math.abs(tx) < 0.9 ? 1 : 0, ay = ax ? 0 : 1;
    let d = ax * tx + ay * ty;
    let nx = ax - tx * d, ny = ay - ty * d, nz = -tz * d;
    l = Math.hypot(nx, ny, nz) || 1; nx /= l; ny /= l; nz /= l;
    for (let j = 0; j < n; j++) {
        const i0 = Math.max(j - 1, 0) * 3, i1 = Math.min(j + 1, n - 1) * 3;
        tx = P[i1] - P[i0]; ty = P[i1 + 1] - P[i0 + 1]; tz = P[i1 + 2] - P[i0 + 2];
        l = Math.hypot(tx, ty, tz) || 1; tx /= l; ty /= l; tz /= l;
        d = nx * tx + ny * ty + nz * tz;
        nx -= tx * d; ny -= ty * d; nz -= tz * d;
        l = Math.hypot(nx, ny, nz) || 1; nx /= l; ny /= l; nz /= l;
        const bx = ty * nz - tz * ny, by = tz * nx - tx * nz, bz = tx * ny - ty * nx;
        for (let r = 0; r < CABLE_RAD; r++) {
            const dx = nx * CR[r] + bx * SR[r], dy = ny * CR[r] + by * SR[r], dz = nz * CR[r] + bz * SR[r];
            const vi = vBase + j * CABLE_RAD + r, v = vi * 3;
            pos[v] = P[j * 3] + dx * radius; pos[v + 1] = P[j * 3 + 1] + dy * radius; pos[v + 2] = P[j * 3 + 2] + dz * radius;
            nor[v] = dx; nor[v + 1] = dy; nor[v + 2] = dz;
            uv[vi * 2] = j * 0.2; uv[vi * 2 + 1] = r * 0.25;
        }
    }
}
function updateAllCables() { for (const l of limbs) l.updateCables(); }

// ============================================================
//  IK-NODES (Ziel = rot, Knick/Pole = blau-türkis)
// ============================================================
let _ikAssets = null;
function ikAssets() {
    if (!_ikAssets) {
        const m = c => new THREE.MeshBasicMaterial({ color: c, depthTest: false, transparent: true, opacity: 0.95 });
        _ikAssets = {
            targetGeo: new THREE.BoxGeometry(0.24, 0.24, 0.24), targetMat: m(0xef4444),
            poleGeo: new THREE.SphereGeometry(0.13, 14, 10), poleMat: m(0x0ea5e9),
            lineMat: new THREE.LineBasicMaterial({ color: 0x94a3b8, depthTest: false, transparent: true, opacity: 0.8 })
        };
    }
    return _ikAssets;
}

// ============================================================
//  GLIEDMASSEN
// ============================================================
const JOINTS = ['sh', 'fe', 'ti'];
const JOINT_NAMES = { sh: 'Schulter', fe: 'Femur', ti: 'Tibia' };

class LimbPair {
    constructor(type, tOnSpine, name) {
        this.type = type;
        this.tOnSpine = tOnSpine;
        this.name = name || (type === 'arm' ? '3-Gelenk Arm' : 'Hexapod Bein');
        this.shoulderWidth = type === 'arm' ? 1.6 : 1.2;
        this.lengthScale = 1.0;

        // Pose pro Seite und Gelenk als Euler [x,y,z] – wird per Gizmo gesetzt, überlebt Rebuilds
        const roll = type === 'arm' ? 0.4 : 0.8;
        const fe = type === 'arm' ? 0.9 : -0.7;
        const ti = type === 'arm' ? 0.8 : 1.2;
        this.pose = {
            1:  { sh: [0.1, 0, roll],  fe: [fe, 0, 0], ti: [ti, 0, 0] },
            '-1': { sh: [0.1, 0, -roll], fe: [fe, 0, 0], ti: [ti, 0, 0] }
        };
        this.cables = { on: true, count: 7, slack: 0.9, thickness: 0.014, spread: 0.1, mix: 0.5 };

        // IK: mode 'off' (FK per Gizmo) | 'ik' (Ziel + Knick) | 'ground' (Ziel auf den Boden gezwungen + Knick)
        this.ik = { mode: 'off', groundY: type === 'arm' ? 0.55 : 0.4 };
        const A = ikAssets();
        this.ikNodes = {};
        for (const side of [1, -1]) {
            const target = new THREE.Mesh(A.targetGeo, A.targetMat);
            const pole = new THREE.Mesh(A.poleGeo, A.poleMat);
            target.userData = { sel: { kind: 'ikTarget', limb: this, side }, isIK: true };
            pole.userData = { sel: { kind: 'ikPole', limb: this, side }, isIK: true };
            for (const n of [target, pole]) { n.renderOrder = 13; n.visible = false; bodyRoot.add(n); }
            this.ikNodes[side] = { target, pole };
        }
        this.ikLine = new THREE.LineSegments(
            new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(new Float32Array(4 * 2 * 3), 3)), A.lineMat);
        this.ikLine.userData = { isIK: true };
        this.ikLine.frustumCulled = false;
        this.ikLine.renderOrder = 12;
        this.ikLine.visible = false;
        bodyRoot.add(this.ikLine);

        this.rootGroup = new THREE.Group();
        bodyRoot.add(this.rootGroup);

        this.cableMesh = new THREE.Mesh(new THREE.BufferGeometry(), [mechaMaterial, shellMaterial]);
        this.cableMesh.frustumCulled = false;
        this.cableMesh.userData.seg = 'kabel';
        cablesGroup.add(this.cableMesh);

        this.buildLimbPair();
        this.updatePositionOnSpine();
        this.setIKMode('ik');                     // IK ist Standard (Ziel + Knick)
    }

    limbOf(side) { return side === 1 ? this.leftLimb : this.rightLimb; }
    jointOf(side, j) {
        const L = this.limbOf(side);
        return j === 'sh' ? L.shoulderJoint : j === 'fe' ? L.femurGroup : L.tibiaGroup;
    }

    buildLimbPair() {
        clearGroup(this.rootGroup);
        const rootInfo = { kind: 'root', limb: this };

        const girdle = new THREE.Mesh(leafGeo(0.22, 1, 0.1), mechaMaterial);
        girdle.scale.y = this.shoulderWidth * 1.1;
        girdle.rotation.z = Math.PI / 2;
        girdle.userData.sel = rootInfo;
        girdle.userData.seg = 'huefte_gurt';
        this.rootGroup.add(girdle);
        this.rootGroup.add(makeMarker('spine', rootInfo));

        this.leftLimb = this.createSingleLimb(1);
        this.rightLimb = this.createSingleLimb(-1);
        this.rootGroup.add(this.leftLimb.shoulderJoint, this.rightLimb.shoulderJoint);

        applyShells(this.rootGroup);
        this.applyArticulation();
        refreshSelection();
    }

    createSingleLimb(side) {
        const isArm = this.type === 'arm';
        const l1 = (isArm ? 0.5 : 0.6) * this.lengthScale;
        const l2 = (isArm ? 1.1 : 1.4) * this.lengthScale;
        const l3 = (isArm ? 1.0 : 1.3) * this.lengthScale;
        const boneC = cached('boneC', () => new THREE.CylinderGeometry(0.05, 0.045, 1, 10));
        const boneF = cached('boneF', () => new THREE.CylinderGeometry(0.045, 0.035, 1, 10));
        const boneT = cached('boneT', () => new THREE.CylinderGeometry(0.05, 0.032, 1, 10));
        const sel = j => ({ kind: 'joint', limb: this, side, joint: j });

        const shoulderJoint = new THREE.Group();
        shoulderJoint.position.set(side * (this.shoulderWidth / 2), 0, 0);
        shoulderJoint.add(createBiomorphicJoint(0.18, sel('sh')));
        shoulderJoint.add(makeMarker('joint', sel('sh')));

        const coxaBone = new THREE.Mesh(boneC, mechaMaterial);
        coxaBone.scale.y = l1;
        coxaBone.position.y = -l1 / 2;
        coxaBone.castShadow = true;
        coxaBone.userData.sel = sel('sh');
        coxaBone.userData.seg = 'coxa';
        shoulderJoint.add(coxaBone);

        const femurGroup = new THREE.Group();
        femurGroup.position.set(0, -l1, 0);
        shoulderJoint.add(femurGroup);
        femurGroup.add(createBiomorphicJoint(0.15, sel('fe')));
        femurGroup.add(makeMarker('joint', sel('fe')));

        const femurBone = new THREE.Mesh(boneF, mechaMaterial);
        femurBone.scale.y = l2;
        femurBone.position.y = -l2 / 2;
        femurBone.castShadow = true;
        femurBone.userData.sel = sel('fe');
        femurBone.userData.seg = 'femur';
        femurGroup.add(femurBone);

        const femurShell = new THREE.Mesh(leafGeo(0.32, 1, 0.14), shellMaterial);
        femurShell.scale.y = l2 * 0.85;
        femurShell.castShadow = true;
        femurShell.userData.sel = sel('fe');
        femurShell.userData.seg = 'femur_shell';
        femurShell.userData.shellFn = o => {
            const a = o * 0.7;
            femurShell.position.set(side * a * 0.2, -l2 * 0.45, side * (0.08 + a * 0.15));
            femurShell.rotation.y = side * (0.3 + a);
        };
        femurGroup.add(femurShell);

        const tibiaGroup = new THREE.Group();
        tibiaGroup.position.set(0, -l2, 0);
        femurGroup.add(tibiaGroup);
        tibiaGroup.add(createBiomorphicJoint(0.13, sel('ti')));
        tibiaGroup.add(makeMarker('joint', sel('ti')));

        const tibiaBone = new THREE.Mesh(boneT, mechaMaterial);
        tibiaBone.scale.y = l3;
        tibiaBone.position.y = -l3 / 2;
        tibiaBone.castShadow = true;
        tibiaBone.userData.sel = sel('ti');
        tibiaBone.userData.seg = 'tibia';
        tibiaGroup.add(tibiaBone);

        const tibiaShell = new THREE.Mesh(leafGeo(0.26, 1, 0.12), shellMaterial);
        tibiaShell.scale.y = l3 * 0.8;
        tibiaShell.castShadow = true;
        tibiaShell.userData.sel = sel('ti');
        tibiaShell.userData.seg = 'tibia_shell';
        tibiaShell.userData.shellFn = o => {
            const a = o * 0.7;
            tibiaShell.position.set(side * a * 0.2, -l3 * 0.4, side * (0.06 + a * 0.15));
            tibiaShell.rotation.y = side * (0.25 + a);
        };
        tibiaGroup.add(tibiaShell);

        const nerveCurve = new THREE.CatmullRomCurve3([
            new THREE.Vector3(0, 0, 0.04),
            new THREE.Vector3(side * 0.06, -l2 * 0.5, 0.05),
            new THREE.Vector3(0, -l2, -0.04),
            new THREE.Vector3(-side * 0.04, -l2 - l3 * 0.6, 0.04),
            new THREE.Vector3(0, -l2 - l3, 0)
        ]);
        const nerveTube = new THREE.Mesh(new THREE.TubeGeometry(nerveCurve, 16, 0.02, 5, false), nerveCableMaterial);
        nerveTube.userData.seg = 'nerv';
        femurGroup.add(nerveTube);

        const endJoint = new THREE.Group();
        endJoint.position.set(0, -l3, 0);
        tibiaGroup.add(endJoint);
        endJoint.add(createBiomorphicJoint(0.1));            // Handgelenk / Fußgelenk

        if (isArm) {
            // Hand: Handballen am Gelenk + Mittelfinger + zwei gespreizte Seitenfinger (Shell-Material)
            const palm = new THREE.Mesh(cached('palm', () => new THREE.SphereGeometry(0.09, 12, 10)), mechaMaterial);
            palm.scale.set(1, 0.85, 1.1);
            palm.position.y = -0.06;
            palm.castShadow = true;
            palm.userData.seg = 'hand';
            endJoint.add(palm);

            const mid = new THREE.Mesh(leafGeo(0.15, 0.55, 0.1), shellMaterial);
            mid.position.set(0, -0.3, 0.03);
            mid.rotation.x = 0.3;
            mid.castShadow = true;
            mid.userData.seg = 'finger';
            endJoint.add(mid);

            for (const s of [-1, 1]) {
                const pivot = new THREE.Group();
                pivot.position.set(0, -0.07, 0);
                pivot.rotation.z = s * 0.5;
                const f = new THREE.Mesh(leafGeo(0.1, 0.42, 0.08), shellMaterial);
                f.position.set(0, -0.22, 0.03);
                f.rotation.x = 0.3;
                f.castShadow = true;
                f.userData.seg = 'finger';
                pivot.add(f);
                endJoint.add(pivot);
            }
        } else {
            const foot = new THREE.Mesh(cached('foot', () => new THREE.SphereGeometry(0.07, 10, 8)), mechaMaterial);
            foot.position.y = -0.04;
            foot.userData.seg = 'fuss';
            endJoint.add(foot);
            const spike = new THREE.Mesh(cached('spike', () => new THREE.ConeGeometry(0.045, 0.4, 8)), mechaMaterial);
            spike.position.y = -0.2;
            spike.rotation.x = Math.PI;
            spike.castShadow = true;
            spike.userData.seg = 'fuss';
            endJoint.add(spike);
        }

        return { shoulderJoint, femurGroup, tibiaGroup, endJoint, side, l1, l2, l3 };
    }

    updatePositionOnSpine() {
        spineCurve.getPointAt(this.tOnSpine, this.rootGroup.position);
        spineCurve.getTangentAt(this.tOnSpine, _t);
        this.rootGroup.quaternion.setFromUnitVectors(UP, _t);
        this.updateCables();
    }
    slideTo() {
        this.tOnSpine = nearestSpineT(this.rootGroup.position, 0.05, 0.95);
        this.updatePositionOnSpine();
    }

    // Pose -> Gelenke
    applyArticulation() {
        if (!this.leftLimb || !this.rightLimb) return;
        for (const side of [1, -1]) {
            if (this.ik.mode !== 'off') { this.solveSide(side); continue; }
            for (const j of JOINTS) this.jointOf(side, j).rotation.set(...this.pose[side][j]);
        }
        this.updateIKLines();
        this.updateCables();
    }

    // Gelenkpositionen [Schulter, Femur, Tibia, Ende] im Körper-Koordinatensystem
    chainLocal(side) {
        bodyRoot.updateWorldMatrix(true, false);
        this.rootGroup.updateMatrixWorld(true);
        _inv.copy(bodyRoot.matrixWorld).invert();
        const L = this.limbOf(side);
        return [L.shoulderJoint, L.femurGroup, L.tibiaGroup, L.endJoint]
            .map(j => new THREE.Vector3().setFromMatrixPosition(new THREE.Matrix4().multiplyMatrices(_inv, j.matrixWorld)));
    }

    updateIKVisibility() {
        const on = this.ik.mode !== 'off';
        for (const side of [1, -1]) { this.ikNodes[side].target.visible = on; this.ikNodes[side].pole.visible = on; }
        this.ikLine.visible = on;
    }
    // Nodes aus der aktuellen FK-Pose initialisieren: Ziel = Ende, Knick = Knie nach außen versetzt
    initIKNodes() {
        for (const side of [1, -1]) {
            const [S, F, K, E] = this.chainLocal(side);
            this.ikNodes[side].target.position.copy(E);
            let v = K.clone().sub(S.clone().add(E).multiplyScalar(0.5));
            if (v.length() < 0.05) v.set(side, 0.6, 0);
            this.ikNodes[side].pole.position.copy(K).addScaledVector(v.normalize(), 1.0);
        }
    }
    setIKMode(mode) {
        const was = this.ik.mode;
        this.ik.mode = mode;
        if (mode !== 'off' && was === 'off') this.initIKNodes();
        this.updateIKVisibility();
        if (selection && selection.owner === this) clearSelection();
        this.applyArticulation();
        requestRender();
    }
    onIKEdited() { this.applyArticulation(); }

    // IK für eine Seite: FABRIK über [Coxa, Femur, Tibia] + Drehung der Gelenkkette in die Pole-Ebene,
    // danach Gelenkrotationen mit minimaler Verdrehung aus den Knochenrichtungen
    solveSide(side) {
        const L = this.limbOf(side), nodes = this.ikNodes[side];
        const p = this.chainLocal(side);
        const S = p[0];
        const T = nodes.target.position.clone();
        if (this.ik.mode === 'ground') {                 // Ziel in Weltkoordinaten auf Bodenhöhe zwingen
            bodyRoot.localToWorld(T);
            T.y = this.ik.groundY;
            const Tdes = T.clone();
            bodyRoot.worldToLocal(Tdes);
            nodes.target.position.copy(Tdes);            // Node bleibt an der gewünschten Stelle auf dem Boden
            // außer Reichweite: Fuß rutscht horizontal zum Körper, damit der Bodenkontakt erhalten bleibt
            const Sw = bodyRoot.localToWorld(S.clone());
            const reach = L.l1 + L.l2 + L.l3 - 0.01, dy = T.y - Sw.y;
            const maxH = Math.sqrt(Math.max(0, reach * reach - dy * dy));
            const hx = T.x - Sw.x, hz = T.z - Sw.z, hl = Math.hypot(hx, hz);
            if (hl > maxH && hl > 1e-6) { T.x = Sw.x + hx / hl * maxH; T.z = Sw.z + hz / hl * maxH; }
            bodyRoot.worldToLocal(T);
        }
        const P = nodes.pole.position;
        const len = [L.l1, L.l2, L.l3];
        const dir = (a, b, fb) => { const v = new THREE.Vector3().subVectors(a, b); return v.lengthSq() < 1e-10 ? fb.clone() : v.normalize(); };
        const axis = dir(T, S, new THREE.Vector3(0, -1, 0));
        const dist = T.distanceTo(S), total = len[0] + len[1] + len[2];

        // Knickrichtung (Pole senkrecht zur Achse)
        const perp = (v, ax) => v.clone().addScaledVector(ax, -v.dot(ax));
        if (dist >= total - 1e-4) {
            let c = 0;
            for (let i = 1; i <= 3; i++) { c += len[i - 1]; p[i] = S.clone().addScaledVector(axis, c); }
        } else {
            // Start ohne Biegung vermeiden -> Richtung Pole anschieben
            const kOff = perp(p[2].clone().sub(S), axis);
            if (kOff.length() < 0.05) {
                const pp = perp(P.clone().sub(S), axis);
                if (pp.lengthSq() > 1e-8) { pp.normalize(); p[1].addScaledVector(pp, 0.2); p[2].addScaledVector(pp, 0.3); }
            }
            for (let it = 0; it < 16; it++) {
                p[3].copy(T);
                for (let i = 2; i >= 1; i--) p[i] = p[i + 1].clone().addScaledVector(dir(p[i], p[i + 1], axis), len[i]);
                p[0].copy(S);
                for (let i = 1; i <= 3; i++) p[i] = p[i - 1].clone().addScaledVector(dir(p[i], p[i - 1], axis), len[i - 1]);
                if (p[3].distanceTo(T) < 1e-3) break;
            }
        }
        // Gelenkkette um die Achse Schulter->Ende in die Ebene (Schulter, Ende, Pole) drehen
        const ax2 = dir(p[3], p[0], axis);
        const vK = perp(p[2].clone().sub(p[0]), ax2), vP = perp(P.clone().sub(p[0]), ax2);
        if (vK.length() > 1e-3 && vP.length() > 1e-3) {
            const ang = Math.atan2(ax2.dot(vK.clone().cross(vP)), vK.dot(vP));
            for (const i of [1, 2]) p[i] = p[i].clone().sub(p[0]).applyAxisAngle(ax2, ang).add(p[0]);
        }

        // Rotationen: -y des Gelenks zeigt entlang des Knochens; kürzester Bogen vom Eltern-Frame
        const down = new THREE.Vector3(0, -1, 0);
        let Wprev = this.rootGroup.quaternion.clone();
        JOINTS.forEach((j, k) => {
            const d = dir(p[k + 1], p[k], down);
            const ref = down.clone().applyQuaternion(Wprev);
            const Wk = new THREE.Quaternion().setFromUnitVectors(ref, d).multiply(Wprev);
            const local = Wprev.clone().invert().multiply(Wk);
            const e = new THREE.Euler().setFromQuaternion(local, 'XYZ');
            this.pose[side][j] = [e.x, e.y, e.z];
            this.jointOf(side, j).rotation.set(e.x, e.y, e.z);
            Wprev = Wk;
        });
        this._ikK = this._ikK || {}; this._ikK[side] = { K: p[2].clone(), E: p[3].clone() };
    }
    updateIKLines() {
        if (this.ik.mode === 'off' || !this._ikK) return;
        const a = this.ikLine.geometry.attributes.position.array;
        let o = 0;
        for (const side of [1, -1]) {
            const k = this._ikK[side]; if (!k) continue;
            const n = this.ikNodes[side];
            for (const v of [k.K, n.pole.position, k.E, n.target.position]) { a[o++] = v.x; a[o++] = v.y; a[o++] = v.z; }
        }
        this.ikLine.geometry.attributes.position.needsUpdate = true;
    }
    restoreIK(d) {
        if (!d) return;
        const isNum3 = v => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
        if (Number.isFinite(d.groundY)) this.ik.groundY = d.groundY;
        for (const side of ['1', '-1']) {
            if (d.targets && isNum3(d.targets[side])) this.ikNodes[side].target.position.fromArray(d.targets[side]);
            if (d.poles && isNum3(d.poles[side])) this.ikNodes[side].pole.position.fromArray(d.poles[side]);
        }
        this.ik.mode = ['off', 'ik', 'ground'].includes(d.mode) ? d.mode : 'off';
        this.updateIKVisibility();
        this.applyArticulation();
    }
    // Gizmo hat ein Gelenk gedreht -> Pose speichern (+ optional spiegeln)
    onJointEdited(side, j) {
        const r = this.jointOf(side, j).rotation;
        this.pose[side][j] = [r.x, r.y, r.z];
        if (config.mirror) {
            this.pose[-side][j] = [r.x, -r.y, -r.z];
            this.jointOf(-side, j).rotation.set(...this.pose[-side][j]);
        }
        this.updateCables();
    }

    updateCables() {
        const c = this.cables;
        this.cableMesh.visible = c.on;
        if (!c.on || !this.leftLimb) return;

        this.rootGroup.updateMatrixWorld(true);
        _inv.copy(bodyRoot.matrixWorld).invert();                    // relativ zu bodyRoot -> unabhängig von Hip-Transform
        _grav.set(0, -1, 0).applyQuaternion(_q.copy(bodyRoot.quaternion).invert());
        const rootM = this.rootGroup.matrix;
        const N = Math.round(c.count);

        const ctx = {}, specs = [];
        for (const side of [1, -1]) {
            const L = this.limbOf(side);
            ctx[side] = {
                mats: [L.shoulderJoint, L.femurGroup, L.tibiaGroup, L.endJoint]
                    .map(j => new THREE.Matrix4().multiplyMatrices(_inv, j.matrixWorld)),
                out: new THREE.Vector3(side, 0, 0).applyQuaternion(this.rootGroup.quaternion)
            };
            for (let k = 0; k < N; k++) specs.push({ side, k, shell: hash(k + 1, side * 3.7) < c.mix });
        }
        // Skelett-Material-Stränge zuerst, dann Shell-Material-Stränge -> zwei Material-Gruppen
        const mecha = specs.filter(s => !s.shell), shellS = specs.filter(s => s.shell);
        const ordered = mecha.concat(shellS);

        const S = ordered.length, vPer = CABLE_COUNT * CABLE_RAD;
        const idxPer = (CABLE_COUNT - 1) * CABLE_RAD * 6;
        const pos = new Float32Array(S * vPer * 3), nor = new Float32Array(S * vPer * 3), uv = new Float32Array(S * vPer * 2);
        const idx = new Uint16Array(S * idxPer);
        let ii = 0;

        ordered.forEach((sp, strand) => {
            const { mats, out } = ctx[sp.side];
            const k = sp.k;
            const ang = k * 2.39996, rad = c.spread * Math.sqrt((k + 0.5) / N);
            const ox = Math.cos(ang) * rad, oz = Math.sin(ang) * rad;

            const pts = [new THREE.Vector3(ox * 0.5, (k - (N - 1) / 2) * 0.03, -0.06 + oz * 0.3).applyMatrix4(rootM)];
            for (const m of mats) pts.push(new THREE.Vector3(ox, 0, -0.11 + oz).applyMatrix4(m));

            sampleCable(pts, out, c.slack * (0.7 + 0.6 * ((k * 0.618) % 1)), _grav, _cbuf);
            const vBase = strand * vPer;
            writeTube(_cbuf, CABLE_COUNT, c.thickness, pos, nor, uv, vBase);
            for (let j = 0; j < CABLE_COUNT - 1; j++) {
                for (let r = 0; r < CABLE_RAD; r++) {
                    const r1 = (r + 1) % CABLE_RAD;
                    const a = vBase + j * CABLE_RAD + r, b = vBase + j * CABLE_RAD + r1;
                    const cc = vBase + (j + 1) * CABLE_RAD + r, dd = vBase + (j + 1) * CABLE_RAD + r1;
                    idx[ii++] = a; idx[ii++] = b; idx[ii++] = cc;
                    idx[ii++] = b; idx[ii++] = dd; idx[ii++] = cc;
                }
            }
        });

        const g = new THREE.BufferGeometry();
        g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
        g.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
        g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
        g.setIndex(new THREE.BufferAttribute(idx, 1));
        if (mecha.length) g.addGroup(0, mecha.length * idxPer, 0);
        if (shellS.length) g.addGroup(mecha.length * idxPer, shellS.length * idxPer, 1);
        this.cableMesh.geometry.dispose();
        this.cableMesh.geometry = g;
    }

    remove() {
        bodyRoot.remove(this.rootGroup);
        disposeTree(this.rootGroup);
        cablesGroup.remove(this.cableMesh);
        this.cableMesh.geometry.dispose();
        for (const side of [1, -1]) bodyRoot.remove(this.ikNodes[side].target, this.ikNodes[side].pole);
        bodyRoot.remove(this.ikLine);
        this.ikLine.geometry.dispose();
    }
}

function addLimbPair(type, t, name) {
    const l = new LimbPair(type, t, name);
    limbs.push(l);
    rebuildGUI();
    requestRender();
    return l;
}
function removeLimbPair(i) {
    if (!limbs[i]) return;
    if (selection && selection.owner === limbs[i]) clearSelection();
    limbs[i].remove();
    limbs.splice(i, 1);
    rebuildGUI();
    requestRender();
}

// ============================================================
//  AUSWAHL + GIZMO (Bone anklicken -> Bewegen / Drehen)
// ============================================================
let selection = null;

function makeSelection(info) {
    switch (info.kind) {
        case 'hip':
            return {
                owner: 'hip', label: 'Hip', modes: ['translate', 'rotate'],
                objFor: () => bodyRoot,
                onChange: mode => { updateLight(); if (mode === 'rotate') updateAllCables(); }
            };
        case 'joint': {
            const { limb, side, joint } = info;
            return {
                owner: limb, label: `${limb.name} · ${JOINT_NAMES[joint]} ${side === 1 ? 'L' : 'R'}`, modes: ['rotate'],
                objFor: () => limb.jointOf(side, joint),
                onChange: () => limb.onJointEdited(side, joint)
            };
        }
        case 'ikTarget':
        case 'ikPole': {
            const { limb, side } = info, isT = info.kind === 'ikTarget';
            return {
                owner: limb, label: `${limb.name} · ${isT ? 'IK-Ziel' : 'Knick'} ${side === 1 ? 'L' : 'R'}`, modes: ['translate'],
                objFor: () => isT ? limb.ikNodes[side].target : limb.ikNodes[side].pole,
                onChange: () => limb.onIKEdited()
            };
        }
        case 'root': {
            const { limb } = info;
            return {
                owner: limb, label: `${limb.name} · Position an der Spine`, modes: ['translate'],
                objFor: () => limb.rootGroup,
                onChange: () => limb.slideTo()
            };
        }
        case 'head': {
            const { head } = info;
            return {
                owner: head, label: head.name, modes: ['rotate', 'translate'],
                objFor: mode => mode === 'translate' ? head.root : head.pivot,
                onChange: mode => { if (mode === 'translate') head.slideTo(); else head.readRot(); }
            };
        }
    }
}
function selectInfo(info) {
    // Gelenke einer IK-gesteuerten Gliedmaße: stattdessen das IK-Ziel auswählen
    if (info.kind === 'joint' && info.limb.ik.mode !== 'off') info = { kind: 'ikTarget', limb: info.limb, side: info.side };
    selection = makeSelection(info);
    refreshSelection();
}
function clearSelection() {
    selection = null;
    refreshSelection();
}
function refreshSelection() {
    if (!tc) return;
    const label = document.getElementById('sel');
    if (!selection) {
        tc.detach();
        label.textContent = '';
    } else {
        const mode = selection.modes.includes(config.mode) ? config.mode : selection.modes[0];
        selection.mode = mode;
        tc.setMode(mode);
        tc.setSpace(mode === 'translate' ? 'world' : 'local');
        tc.attach(selection.objFor(mode));
        label.textContent = selection.label;
    }
    updateModeButtons();
    requestRender();
}
function setMode(m) {
    config.mode = m;
    refreshSelection();
}
function updateModeButtons() {
    const eff = selection ? selection.mode : config.mode;
    const bt = document.getElementById('mode-translate-btn'), br = document.getElementById('mode-rotate-btn');
    bt.classList.toggle('on', eff === 'translate');
    br.classList.toggle('on', eff === 'rotate');
    bt.disabled = !!selection && !selection.modes.includes('translate');
    br.disabled = !!selection && !selection.modes.includes('rotate');
}
function onKeyDown(e) {
    if (e.target.tagName === 'INPUT') return;
    const k = e.key.toLowerCase();
    if (k === 'w') setMode('translate');
    else if (k === 'e') setMode('rotate');
    else if (k === 'n') setNodesMode(!config.showAllNodes);
    else if (k === 'escape') clearSelection();
}

// ============================================================
//  EXPORT: Pose-Skeleton (OpenPose-Stil), Tiefe, Komponenten-Maske, Farbbild -> PNG / ZIP
//  Alles wird exakt aus der 3D-Szene gerendert – keine KI-Modelle nötig.
// ============================================================
const SEG = {
    wirbel: '#e6194b', rippe: '#3cb44b', brust_shell: '#4363d8', nerv: '#f58231', kopf: '#911eb4',
    hals: '#46f0f0', gelenk: '#f032e6', huefte_gurt: '#bcf60c', coxa: '#fabebe', femur: '#008080',
    femur_shell: '#e6beff', tibia: '#9a6324', tibia_shell: '#fffac8', hand: '#800000', finger: '#aaffc3',
    fuss: '#808000', kabel: '#ffd8b1'
};
const segMats = {};
const segMat = c => segMats[c] || (segMats[c] = new THREE.MeshBasicMaterial({ color: SEG[c] || '#808080', toneMapped: false, side: THREE.DoubleSide }));
const OP = ['#ff0000', '#ff5500', '#ffaa00', '#ffff00', '#aaff00', '#55ff00', '#00ff00', '#00ff55', '#00ffaa',
            '#00ffff', '#00aaff', '#0055ff', '#0000ff', '#5500ff', '#aa00ff', '#ff00ff', '#ff00aa', '#ff0055'];

// Export-Ausschnitt in Fenster-Pixeln (entspricht dem gestrichelten Rahmen)
function exportRect() {
    const ww = innerWidth, hh = innerHeight;
    if (config.exportFormat === 'win') return { x: 0, y: 0, w: ww, h: hh };
    const [a, b] = config.exportFormat.split(':').map(Number);
    const asp = a / b;
    let w, h;
    if (asp > ww / hh) { w = ww * 0.94; h = w / asp; } else { h = hh * 0.94; w = h * asp; }
    return { x: (ww - w) / 2, y: (hh - h) / 2, w, h };
}
function updateFrame() {
    const f = document.getElementById('frame');
    if (config.exportFormat === 'win') { f.style.display = 'none'; return; }
    const r = exportRect();
    Object.assign(f.style, { display: 'block', left: r.x + 'px', top: r.y + 'px', width: r.w + 'px', height: r.h + 'px' });
}

// Bones als Linien: Spine, Hals/Kopf, pro Limb-Paar Gürtel -> Schulter -> Femur -> Tibia -> Ende
function collectBones() {
    bodyRoot.updateMatrixWorld(true);
    const W = v => bodyRoot.localToWorld(v.clone());
    const wp = o => o.getWorldPosition(new THREE.Vector3());
    const bones = [];

    const sp = [];
    for (let i = 0; i <= 8; i++) sp.push(W(spineCurve.getPointAt(i / 8)));
    for (let i = 0; i < 8; i++) bones.push({ a: sp[i], b: sp[i + 1], color: OP[(i * 2) % 18] });

    heads.forEach(h => {
        const base = wp(h.root), top = wp(h.top);
        const face = h.top.localToWorld(new THREE.Vector3(0, h.h * 0.45, h.d * 0.6));
        bones.push({ a: base, b: top, color: OP[15] }, { a: top, b: face, color: OP[16] });
    });
    limbs.forEach((l, li) => {
        const root = wp(l.rootGroup);
        for (const side of [1, -1]) {
            const L = l.limbOf(side);
            const pts = [root, wp(L.shoulderJoint), wp(L.femurGroup), wp(L.tibiaGroup), wp(L.endJoint)];
            const start = (li * 3 + (side === 1 ? 0 : 9)) % 18;
            for (let k = 0; k < 4; k++) bones.push({ a: pts[k], b: pts[k + 1], color: OP[(start + k) % 18] });
        }
    });
    return bones;
}
const hexA = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)},${parseInt(hex.slice(3, 5), 16)},${parseInt(hex.slice(5, 7), 16)},${a})`;

function drawPose(W, H, r) {
    const cv = document.createElement('canvas');
    cv.width = W; cv.height = H;
    const g = cv.getContext('2d');
    g.fillStyle = '#000'; g.fillRect(0, 0, W, H);
    camera.updateMatrixWorld();
    const sw = Math.max(2, H / 128), kx = W / r.w, ky = H / r.h;
    const P = v => {
        const p = v.clone().project(camera);
        return p.z > 1 ? null : [((p.x + 1) / 2 * innerWidth - r.x) * kx, ((1 - p.y) / 2 * innerHeight - r.y) * ky];
    };
    const bones = collectBones();
    for (const b of bones) {
        const a = P(b.a), c = P(b.b);
        if (!a || !c) continue;
        const dx = c[0] - a[0], dy = c[1] - a[1], len = Math.hypot(dx, dy);
        if (len < 1) continue;
        g.save();
        g.translate((a[0] + c[0]) / 2, (a[1] + c[1]) / 2);
        g.rotate(Math.atan2(dy, dx));
        g.beginPath(); g.ellipse(0, 0, len / 2, sw, 0, 0, Math.PI * 2);
        g.fillStyle = hexA(b.color, 0.6); g.fill();
        g.restore();
    }
    for (const b of bones) for (const v of [b.a, b.b]) {
        const p = P(v); if (!p) continue;
        g.beginPath(); g.arc(p[0], p[1], sw * 0.9, 0, Math.PI * 2);
        g.fillStyle = b.color; g.fill();
    }
    return cv;
}

// Render in Offscreen-Target ohne MSAA/Tone-Mapping (exakte Farben/Tiefe) -> Canvas
function renderRT(W, H) {
    const rt = new THREE.WebGLRenderTarget(W, H, {
        minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, format: THREE.RGBAFormat, type: THREE.UnsignedByteType
    });
    renderer.setRenderTarget(rt);
    renderer.render(scene, camera);
    renderer.setRenderTarget(null);
    const px = new Uint8Array(W * H * 4);
    renderer.readRenderTargetPixels(rt, 0, 0, W, H, px);
    rt.dispose();
    const cv = document.createElement('canvas');
    cv.width = W; cv.height = H;
    const ctx = cv.getContext('2d'), img = ctx.createImageData(W, H);
    for (let y = 0; y < H; y++) img.data.set(px.subarray((H - 1 - y) * W * 4, (H - y) * W * 4), y * W * 4);
    ctx.putImageData(img, 0, 0);
    return cv;
}
function renderColor(W, H) {
    const pr = renderer.getPixelRatio();
    renderer.setPixelRatio(1);
    renderer.setSize(W, H, false);
    renderer.render(scene, camera);
    const cv = document.createElement('canvas');
    cv.width = W; cv.height = H;
    cv.getContext('2d').drawImage(renderer.domElement, 0, 0);
    renderer.setPixelRatio(pr);
    renderer.setSize(innerWidth, innerHeight);
    return cv;
}
function renderDepth(W, H) {
    // Near/Far eng an die tatsächliche Tiefenausdehnung der Figur legen -> voller Kontrast (nah = weiß, fern = schwarz)
    camera.getWorldDirection(_camDir);
    let zmin = Infinity, zmax = -Infinity;
    collectBones().forEach(b => [b.a, b.b].forEach(v => {
        const z = _w1.copy(v).sub(camera.position).dot(_camDir);
        zmin = Math.min(zmin, z); zmax = Math.max(zmax, z);
    }));
    camera.near = Math.max(0.05, zmin - 0.5); camera.far = zmax + 0.5;
    camera.updateProjectionMatrix();

    const mat = new THREE.MeshDepthMaterial({ side: THREE.DoubleSide });
    scene.overrideMaterial = mat;
    scene.background = new THREE.Color(0x000000);
    const fv = floorMesh.visible; floorMesh.visible = false;
    const cv = renderRT(W, H);
    floorMesh.visible = fv;
    scene.overrideMaterial = null;
    mat.dispose();
    return cv;
}
function renderMask(W, H) {
    const undo = [];
    bodyRoot.traverse(o => {
        if (!o.isMesh) return;
        if (o.userData.seg) {
            const m = o.material, sm = segMat(o.userData.seg);
            undo.push(() => { o.material = m; });
            o.material = Array.isArray(m) ? m.map(() => sm) : sm;
        } else if (o.visible) {
            undo.push(() => { o.visible = true; });
            o.visible = false;
        }
    });
    scene.background = new THREE.Color(0x000000);
    const fv = floorMesh.visible; floorMesh.visible = false;
    const cv = renderRT(W, H);
    floorMesh.visible = fv;
    undo.forEach(f => f());
    return cv;
}

function hideHelpers() {
    const st = [];
    const hide = o => { st.push([o, o.visible]); o.visible = false; };
    nodeHandles.forEach(hide); hide(hipHandle); hide(spineLine); hide(tc);
    bodyRoot.traverse(o => { if (o.userData.isMarker || o.userData.isIK) hide(o); });
    return () => st.forEach(([o, v]) => { o.visible = v; });
}

async function buildMaps(which) {
    const r = exportRect(), H = config.exportH, W = Math.round(H * r.w / r.h);
    if (spineDirty) { spineDirty = false; updateSpine(); }
    bodyRoot.updateMatrixWorld(true);
    const out = {};
    if (which.includes('pose')) out.pose = drawPose(W, H, r);

    const restoreHelpers = hideHelpers();
    const oldBg = scene.background, oldNear = camera.near, oldFar = camera.far;
    camera.setViewOffset(innerWidth, innerHeight, r.x, r.y, r.w, r.h);
    try {
        if (which.includes('color')) out.color = renderColor(W, H);
        if (which.includes('depth')) out.depth = renderDepth(W, H);
        if (which.includes('mask')) out.mask = renderMask(W, H);
    } finally {
        camera.clearViewOffset();
        camera.near = oldNear; camera.far = oldFar;
        camera.updateProjectionMatrix();
        scene.background = oldBg;
        restoreHelpers();
        requestRender();
    }
    return out;
}

const canvasBlob = cv => new Promise(res => cv.toBlob(res, 'image/png'));
function saveBlob(blob, name) {
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = name;
    a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}

// minimaler ZIP-Writer (STORE, ohne Kompression – PNGs sind schon komprimiert)
const CRC_T = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
function crc32(b) { let c = 0xFFFFFFFF; for (let i = 0; i < b.length; i++) c = CRC_T[(c ^ b[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; }
function makeZip(files) {
    const enc = new TextEncoder(), parts = [], central = [];
    let offset = 0;
    for (const f of files) {
        const name = enc.encode(f.name), crc = crc32(f.data), size = f.data.length;
        const h = new DataView(new ArrayBuffer(30));
        h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(6, 0x0800, true);
        h.setUint16(12, 0x21, true); h.setUint32(14, crc, true); h.setUint32(18, size, true); h.setUint32(22, size, true);
        h.setUint16(26, name.length, true);
        parts.push(new Uint8Array(h.buffer), name, f.data);
        const c = new DataView(new ArrayBuffer(46));
        c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(8, 0x0800, true);
        c.setUint16(14, 0x21, true); c.setUint32(16, crc, true); c.setUint32(20, size, true); c.setUint32(24, size, true);
        c.setUint16(28, name.length, true); c.setUint32(42, offset, true);
        central.push(new Uint8Array(c.buffer), name);
        offset += 30 + name.length + size;
    }
    const cdSize = central.reduce((s, a) => s + a.length, 0);
    const e = new DataView(new ArrayBuffer(22));
    e.setUint32(0, 0x06054b50, true); e.setUint16(8, files.length, true); e.setUint16(10, files.length, true);
    e.setUint32(12, cdSize, true); e.setUint32(16, offset, true);
    return new Blob([...parts, ...central, new Uint8Array(e.buffer)], { type: 'application/zip' });
}

async function exportOne(kind) {
    if (exporting) return;
    exporting = true;
    try {
        const m = await buildMaps([kind]);
        saveBlob(await canvasBlob(m[kind]), `mecha-rig-${kind}-${Date.now()}.png`);
    } finally { exporting = false; }
}
async function exportZip() {
    if (exporting) return;
    exporting = true;
    try {
        const m = await buildMaps(['color', 'pose', 'depth', 'mask']);
        const files = [];
        for (const k of ['color', 'pose', 'depth', 'mask'])
            files.push({ name: `${k}.png`, data: new Uint8Array(await (await canvasBlob(m[k])).arrayBuffer()) });
        files.push({ name: 'mask_legend.json', data: new TextEncoder().encode(JSON.stringify(SEG, null, 2)) });
        saveBlob(makeZip(files), `mecha-rig-maps-${Date.now()}.zip`);
    } finally { exporting = false; }
}

// ============================================================
//  ZUFALL: Anatomie (Struktur) und Pose
// ============================================================
const rndR = (a, b) => a + Math.random() * (b - a);
const rndI = (a, b) => Math.floor(rndR(a, b + 1));

function randomPose() {
    // Spine leicht biegen (relativ zur Ausgangsform, kein Abdriften), Köpfe drehen, Limbs neu ausrichten
    spineRest.forEach((v, i) => spineNodes[i + 1].set(v.x + rndR(-0.35, 0.35), v.y + rndR(-0.15, 0.15), v.z + rndR(-0.45, 0.45)));
    heads.forEach(h => {
        h.rot = [h.restRot[0] + rndR(-0.3, 0.3), h.restRot[1] + rndR(-0.6, 0.6), h.restRot[2] + rndR(-0.25, 0.25)];
        h.pivot.rotation.set(...h.rot);
    });
    updateSpine();
    for (const l of limbs) {
        for (const side of [1, -1]) {
            const L = l.limbOf(side), n = l.ikNodes[side];
            if (l.ik.mode === 'off') {
                l.pose[side] = {
                    sh: [rndR(-0.5, 0.6), side * rndR(-0.3, 0.3), side * rndR(0.1, 1.2)],
                    fe: [rndR(-1.3, 1.2), rndR(-0.2, 0.2), 0],
                    ti: [rndR(0.1, 1.9), 0, 0]
                };
                continue;
            }
            const S = l.chainLocal(side)[0], reach = L.l1 + L.l2 + L.l3;
            if (l.ik.mode === 'ground')
                n.target.position.set(S.x + side * rndR(0.25, 0.75) * reach, S.y - reach * 0.7, S.z + rndR(-0.5, 0.5) * reach * 0.6);
            else
                n.target.position.copy(S).add(new THREE.Vector3(side * rndR(0.2, 1), rndR(-1, 0.4), rndR(-0.5, 1)).normalize().multiplyScalar(rndR(0.5, 0.95) * reach));
            n.pole.position.copy(S).add(new THREE.Vector3(side * rndR(0.5, 1.6), rndR(0.3, 1.6), rndR(-1, 1)));
        }
        l.applyArticulation();
    }
    requestRender();
}

function randomAnatomy() {
    clearSelection();
    config.vertebraeCount = rndI(14, 34);
    config.ribCageCount = rndI(1, 3);
    config.ribsPerCage = rndI(4, 9);
    spineNodes[1].set(rndR(-0.5, 0.5), rndR(1.2, 2.0), rndR(-0.8, 0.8));
    spineNodes[2].set(rndR(-0.6, 0.6), rndR(2.6, 3.6), rndR(-0.8, 0.8));
    spineNodes[3].set(rndR(-0.5, 0.5), rndR(4.0, 5.4), rndR(-0.4, 0.4));
    spineRest = [1, 2, 3].map(i => spineNodes[i].clone());

    limbs.forEach(l => l.remove()); limbs.length = 0;
    heads.forEach(h => h.remove()); heads.length = 0;

    const nH = rndI(1, 3);
    for (let i = 0; i < nH; i++) {
        const rot = i === 0 ? [rndR(-0.2, 0.2), rndR(-0.4, 0.4), rndR(-0.2, 0.2)]
                            : [rndR(-0.3, 0.3), rndR(-0.6, 0.6), (Math.random() < 0.5 ? -1 : 1) * rndR(0.3, 0.9)];
        const h = new HeadUnit(`Kopf ${i + 1}`, i === 0 ? 1.0 : rndR(0.78, 0.96), rot);
        h.neckLength = rndR(0.25, 1.0); h.w = rndR(0.2, 0.6); h.h = rndR(0.25, 0.7); h.d = rndR(0.15, 0.5);
        h.build();
        heads.push(h);
    }

    const nA = rndI(0, 3), nL = rndI(1, 3);
    const make = (type, t, name) => {
        const l = new LimbPair(type, t, name);
        l.lengthScale = rndR(0.7, 1.6);
        l.shoulderWidth = rndR(1.0, 2.4);
        Object.assign(l.cables, { count: rndI(3, 10), mix: Math.random(), slack: rndR(0.4, 1.3) });
        l.buildLimbPair();
        l.initIKNodes();                                  // Nodes an die neue Gliedmaße anpassen
        if (type === 'leg' && Math.random() < 0.8) l.setIKMode('ground');
        else l.applyArticulation();
        limbs.push(l);
    };
    for (let i = 0; i < nA; i++) make('arm', 0.5 + i * 0.12 + rndR(0, 0.08), `Arm-Paar ${i + 1}`);
    for (let i = 0; i < nL; i++) make('leg', 0.06 + i * 0.1 + rndR(0, 0.06), `Bein-Paar ${i + 1}`);

    rebuildRibs();
    bodyRoot.rotation.set(0, 0, 0);
    updateSpine();
    applyShellsAll();

    // Hüfthöhe so wählen, dass Bein-Füße (Boden-Modus) bequem den Boden erreichen
    // (nach dem kürzesten Bein ausrichten, damit alle Boden-Beine den Boden erreichen)
    let y = Infinity;
    for (const l of limbs) if (l.ik.mode === 'ground') {
        const L = l.leftLimb, reach = L.l1 + L.l2 + L.l3;
        y = Math.min(y, 0.85 * reach + l.ik.groundY - l.rootGroup.position.y);
    }
    if (y < Infinity) bodyRoot.position.y = Math.max(0.6, y);
    updateLight();

    randomPose();
    rebuildGUI();
    requestRender();
}

// ============================================================
//  NODES-MODUS
// ============================================================
function setNodesMode(v) {
    config.showAllNodes = v;
    bodyRoot.traverse(o => { if (o.userData.isMarker) o.visible = v; });
    document.getElementById('nodes-btn').classList.toggle('on', v);
    rebuildGUI();
    requestRender();
}

// ============================================================
//  EXPORT / IMPORT (einfaches JSON)
// ============================================================
const CONFIG_KEYS = ['vertebraeCount', 'ribCageCount', 'ribsPerCage', 'mirror', 'shellVisible', 'shellOpenFactor',
                     'mechaColor', 'shellColor', 'nerveColor', 'normalScale'];

function exportState() {
    const r = bodyRoot.rotation;
    const cfg = {}; for (const k of CONFIG_KEYS) cfg[k] = config[k];
    return {
        app: 'mecha-rig', version: 1,
        config: cfg,
        body: { position: bodyRoot.position.toArray(), rotation: [r.x, r.y, r.z] },
        spine: [1, 2, 3].map(i => spineNodes[i].toArray()),
        heads: heads.map(h => ({ name: h.name, t: h.t, rot: h.rot, neckLength: h.neckLength, w: h.w, h: h.h, d: h.d })),
        limbs: limbs.map(l => ({
            type: l.type, name: l.name, tOnSpine: l.tOnSpine, shoulderWidth: l.shoulderWidth, lengthScale: l.lengthScale,
            pose: l.pose, cables: l.cables,
            ik: { mode: l.ik.mode, groundY: l.ik.groundY,
                  targets: { 1: l.ikNodes[1].target.position.toArray(), '-1': l.ikNodes[-1].target.position.toArray() },
                  poles: { 1: l.ikNodes[1].pole.position.toArray(), '-1': l.ikNodes[-1].pole.position.toArray() } }
        }))
    };
}
function downloadJSON() {
    const blob = new Blob([JSON.stringify(exportState(), null, 2)], { type: 'application/json' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = `mecha-rig-${Date.now()}.json`;
    a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 1000);
    fileMsg = 'Exportiert.';
    rebuildGUI();
}
function importState(d) {
    const isNum3 = v => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
    if (!d || d.app !== 'mecha-rig') throw new Error('keine mecha-rig Datei');

    clearSelection();
    const c = d.config || {};
    for (const k of CONFIG_KEYS) if (k in c) config[k] = c[k];
    mechaMaterial.color.set(config.mechaColor);
    shellMaterial.color.set(config.shellColor);
    nerveCableMaterial.color.set(config.nerveColor);
    mechaMaterial.normalScale.set(config.normalScale, config.normalScale);
    shellMaterial.normalScale.set(config.normalScale * 0.5, config.normalScale * 0.5);

    if (d.body && isNum3(d.body.position) && isNum3(d.body.rotation)) {
        bodyRoot.position.fromArray(d.body.position);
        bodyRoot.rotation.set(...d.body.rotation);
    }
    if (Array.isArray(d.spine) && d.spine.length === 3 && d.spine.every(isNum3)) {
        d.spine.forEach((v, i) => spineNodes[i + 1].fromArray(v));
        spineRest = d.spine.map(v => new THREE.Vector3().fromArray(v));
    }

    limbs.forEach(l => l.remove()); limbs.length = 0;
    heads.forEach(h => h.remove()); heads.length = 0;

    for (const hd of d.heads || []) {
        const h = new HeadUnit(String(hd.name || `Kopf ${heads.length + 1}`), +hd.t || 1, isNum3(hd.rot) ? hd.rot : [0, 0, 0]);
        for (const k of ['neckLength', 'w', 'h', 'd']) if (Number.isFinite(hd[k])) h[k] = hd[k];
        h.build();
        heads.push(h);
    }
    for (const ld of d.limbs || []) {
        const l = new LimbPair(ld.type === 'leg' ? 'leg' : 'arm', Number.isFinite(ld.tOnSpine) ? ld.tOnSpine : 0.5, String(ld.name || 'Limb'));
        l.ik.mode = 'off'; l.updateIKVisibility();      // Pose aus der Datei gilt; IK wird danach ggf. aus ld.ik wiederhergestellt
        for (const k of ['shoulderWidth', 'lengthScale']) if (Number.isFinite(ld[k])) l[k] = ld[k];
        if (ld.pose) for (const side of ['1', '-1']) for (const j of JOINTS)
            if (ld.pose[side] && isNum3(ld.pose[side][j])) l.pose[side][j] = ld.pose[side][j].slice();
        if (ld.cables) Object.assign(l.cables, ld.cables);
        l.buildLimbPair();
        limbs.push(l);
        l.restoreIK(ld.ik);
    }
    rebuildRibs();
    updateSpine();
    applyShellsAll();
    updateLight();
    fileMsg = `Geladen: ${heads.length} Köpfe, ${limbs.length} Limb-Paare.`;
    rebuildGUI();
    requestRender();
}

// ============================================================
//  POINTER
// ============================================================
function setMouse(e) {
    mouse.x = (e.clientX / innerWidth) * 2 - 1;
    mouse.y = -(e.clientY / innerHeight) * 2 + 1;
}
function onPointerDown(e) {
    downX = e.clientX; downY = e.clientY;
    gizmoDown = tc.axis !== null || tc.dragging;       // Klick auf Gizmo-Handle -> TransformControls übernimmt
    if (gizmoDown) return;

    setMouse(e);
    bodyRoot.updateMatrixWorld(true);
    raycaster.setFromCamera(mouse, camera);
    const hit = config.showNodes ? raycaster.intersectObjects(nodeHandles) : [];
    if (hit.length) {
        draggedObject = hit[0].object;
        controls.enabled = false;
    }
}
function onPointerMove(e) {
    if (!draggedObject) return;
    setMouse(e);
    draggedObject.getWorldPosition(_w1);
    camera.getWorldDirection(_camDir).negate();
    _plane.setFromNormalAndCoplanarPoint(_camDir, _w1);
    raycaster.setFromCamera(mouse, camera);
    if (!raycaster.ray.intersectPlane(_plane, _w2)) return;

    bodyRoot.worldToLocal(_w2);                         // Spline-Knoten leben im Körper-Koordinatensystem
    spineNodes[draggedObject.userData.nodeIndex].copy(_w2);
    draggedObject.position.copy(_w2);
    spineDirty = true;
    requestRender();
}
function onPointerUp(e) {
    if (draggedObject) {
        draggedObject = null;
        controls.enabled = true;
        return;
    }
    if (gizmoDown) return;
    if (Math.hypot(e.clientX - downX, e.clientY - downY) > 5) return;     // war ein Orbit-Drag, kein Klick

    setMouse(e);
    bodyRoot.updateMatrixWorld(true);
    raycaster.setFromCamera(mouse, camera);
    const list = [];
    bodyRoot.traverse(o => { if (o.userData.sel && o.visible) list.push(o); });
    const hit = raycaster.intersectObjects(list, false);
    if (hit.length) selectInfo(hit[0].object.userData.sel);
    else clearSelection();
}

// ============================================================
//  MINIMAL-PANEL
// ============================================================
const panel = document.getElementById('panel');
const openState = {};

function el(tag, cls, text) {
    const e = document.createElement(tag);
    if (cls) e.className = cls;
    if (text !== undefined) e.textContent = text;
    return e;
}
function folder(parent, title, openDefault) {
    const wrap = el('div', 'folder');
    const head = el('button', 'fhead', title);
    const bodyEl = el('div', 'fbody');
    wrap.classList.toggle('open', openState[title] ?? openDefault);
    head.onclick = () => { wrap.classList.toggle('open'); openState[title] = wrap.classList.contains('open'); };
    wrap.append(head, bodyEl);
    parent.appendChild(wrap);
    return bodyEl;
}
function slider(parent, obj, key, min, max, step, label, onChange) {
    const row = el('div', 'row');
    const out = el('output');
    const inp = el('input');
    inp.type = 'range'; inp.min = min; inp.max = max; inp.step = step; inp.value = obj[key];
    const dec = step >= 1 ? 0 : (step < 0.01 ? 3 : 2);
    out.textContent = (+obj[key]).toFixed(dec);
    inp.oninput = () => {
        obj[key] = parseFloat(inp.value);
        out.textContent = obj[key].toFixed(dec);
        if (onChange) onChange(obj[key]);
        requestRender();
    };
    row.append(el('label', '', label), inp, out);
    parent.appendChild(row);
}
function checkbox(parent, obj, key, label, onChange) {
    const row = el('div', 'row simple');
    const inp = el('input'); inp.type = 'checkbox'; inp.checked = obj[key];
    inp.onchange = () => { obj[key] = inp.checked; if (onChange) onChange(inp.checked); requestRender(); };
    row.append(el('label', '', label), inp);
    parent.appendChild(row);
}
function color(parent, obj, key, label, onChange) {
    const row = el('div', 'row simple');
    const inp = el('input'); inp.type = 'color'; inp.value = obj[key];
    inp.oninput = () => { obj[key] = inp.value; onChange(inp.value); requestRender(); };
    row.append(el('label', '', label), inp);
    parent.appendChild(row);
}
function button(parent, label, fn) {
    const b = el('button', 'btn', label);
    b.onclick = fn;
    parent.appendChild(b);
}

function selectRow(parent, obj, key, options, label, onChange) {
    const row = el('div', 'row simple');
    const sel = el('select');
    for (const [v, t] of options) { const o = el('option', '', t); o.value = v; sel.appendChild(o); }
    sel.value = obj[key];
    sel.onchange = () => { obj[key] = sel.value; if (onChange) onChange(sel.value); };
    row.append(el('label', '', label), sel);
    parent.appendChild(row);
}
function note(parent, text) { parent.appendChild(el('div', 'note', text)); }

function rebuildGUI() {
    const scroll = panel.scrollTop;
    panel.innerHTML = '';

    const fView = folder(panel, 'Ansicht', true);
    checkbox(fView, { v: config.showAllNodes }, 'v', 'Alle Nodes (N)', setNodesMode);
    note(fView, 'grün = an der Spine verschiebbar · violett = Gelenk (drehen) · blau = Spline (ziehen) · schwarz = Hip · IK: rot = Ziel, hellblau = Knick');

    const fFile = folder(panel, 'Datei', true);
    button(fFile, 'Export JSON', downloadJSON);
    const fin = el('input'); fin.type = 'file'; fin.accept = '.json,application/json'; fin.style.display = 'none';
    fin.onchange = () => {
        const f = fin.files[0]; if (!f) return;
        const rd = new FileReader();
        rd.onload = () => {
            try { importState(JSON.parse(rd.result)); }
            catch (err) { fileMsg = 'Import fehlgeschlagen: ' + err.message; rebuildGUI(); }
        };
        rd.readAsText(f);
    };
    fFile.appendChild(fin);
    button(fFile, 'Import JSON', () => { fin.value = ''; fin.click(); });
    if (fileMsg) note(fFile, fileMsg);

    const fExp = folder(panel, 'Export Maps', true);
    selectRow(fExp, config, 'exportFormat', [['win', 'Fenster'], ['1:1', '1:1'], ['3:4', '3:4'], ['4:3', '4:3'], ['9:16', '9:16'], ['16:9', '16:9']], 'Ausschnitt', updateFrame);
    slider(fExp, config, 'exportH', 512, 2048, 128, 'Höhe (px)');
    button(fExp, 'Alle Maps (ZIP)', exportZip);
    const row4 = el('div', 'btnrow');
    for (const [label, kind] of [['Farbe', 'color'], ['Pose', 'pose'], ['Tiefe', 'depth'], ['Maske', 'mask']]) {
        const b = el('button', 'btn', label);
        b.onclick = () => exportOne(kind);
        row4.appendChild(b);
    }
    fExp.appendChild(row4);
    note(fExp, 'ZIP: color · pose · depth · mask + mask_legend.json. Der gestrichelte Rahmen zeigt den Ausschnitt.');

    const fSpine = folder(panel, 'Spine & Brustkorb', true);
    slider(fSpine, config, 'vertebraeCount', 10, 40, 1, 'Wirbel', () => { spineDirty = true; });
    slider(fSpine, config, 'ribCageCount', 1, 4, 1, 'Brustkörbe', rebuildRibs);
    slider(fSpine, config, 'ribsPerCage', 3, 12, 1, 'Rippen / Korb', rebuildRibs);
    checkbox(fSpine, config, 'showNodes', 'Spline-Knoten', v => nodeHandles.forEach(h => h.visible = v));

    const fHeads = folder(panel, 'Köpfe', true);
    heads.forEach((h, i) => {
        const f = folder(fHeads, `${i + 1}. ${h.name}`, false);
        slider(f, h, 'neckLength', 0.15, 1.5, 0.01, 'Halslänge', () => h.build());
        slider(f, h, 'w', 0.12, 0.8, 0.01, 'Breite', () => h.build());
        slider(f, h, 'h', 0.15, 1.0, 0.01, 'Höhe', () => h.build());
        slider(f, h, 'd', 0.08, 0.7, 0.01, 'Tiefe', () => h.build());
        button(f, 'Kopf löschen', () => removeHead(i));
    });

    const fShell = folder(panel, 'Shells', false);
    checkbox(fShell, config, 'shellVisible', 'Anzeigen', applyShellsAll);
    slider(fShell, config, 'shellOpenFactor', 0, 1, 0.01, 'Aufklappen', applyShellsAll);

    const fMat = folder(panel, 'Material', false);
    color(fMat, config, 'mechaColor', 'Skelett', c => mechaMaterial.color.set(c));
    color(fMat, config, 'shellColor', 'Shell', c => shellMaterial.color.set(c));
    color(fMat, config, 'nerveColor', 'Nerven', c => nerveCableMaterial.color.set(c));
    slider(fMat, config, 'normalScale', 0, 1, 0.05, 'Normal Map', v => {
        mechaMaterial.normalScale.set(v, v);
        shellMaterial.normalScale.set(v * 0.5, v * 0.5);
    });

    const fLimbs = folder(panel, 'Limbs', true);
    checkbox(fLimbs, config, 'mirror', 'Symmetrie (FK)');
    checkbox(fLimbs, { v: limbs.length > 0 && limbs.every(l => l.ik.mode === 'ground') }, 'v', 'Alle: Boden als IK-Ziel',
        v => { limbs.forEach(l => l.setIKMode(v ? 'ground' : 'ik')); rebuildGUI(); });
    limbs.forEach((limb, i) => {
        const f = folder(fLimbs, `${i + 1}. ${limb.name}`, false);
        slider(f, limb, 'shoulderWidth', 0.8, 3.0, 0.05, 'Breite', () => limb.buildLimbPair());
        slider(f, limb, 'lengthScale', 0.6, 1.8, 0.05, 'Länge', () => limb.buildLimbPair());
        checkbox(f, { v: limb.ik.mode !== 'off' }, 'v', 'IK (Ziel + Knick)', v => { limb.setIKMode(v ? 'ik' : 'off'); rebuildGUI(); });
        checkbox(f, { v: limb.ik.mode === 'ground' }, 'v', 'Boden als IK-Ziel', v => { limb.setIKMode(v ? 'ground' : 'ik'); rebuildGUI(); });
        slider(f, limb.ik, 'groundY', 0, 1.2, 0.01, 'Boden-Höhe', () => limb.applyArticulation());
        checkbox(f, limb.cables, 'on', 'Nervenkabel', () => limb.updateCables());
        slider(f, limb.cables, 'count', 1, 12, 1, 'Kabel', () => limb.updateCables());
        slider(f, limb.cables, 'mix', 0, 1, 0.05, 'Shell-Anteil', () => limb.updateCables());
        slider(f, limb.cables, 'slack', 0, 1.5, 0.05, 'Durchhang', () => limb.updateCables());
        slider(f, limb.cables, 'spread', 0.01, 0.2, 0.005, 'Streuung', () => limb.updateCables());
        slider(f, limb.cables, 'thickness', 0.004, 0.03, 0.001, 'Dicke', () => limb.updateCables());
        button(f, 'Limb löschen', () => removeLimbPair(i));
    });

    panel.scrollTop = scroll;
}

// ============================================================
//  SNAPSHOT / RESIZE
// ============================================================
function takeScreenshot() {
    const vis = nodeHandles.map(h => h.visible);
    nodeHandles.forEach(h => h.visible = false);
    hipHandle.visible = false;
    spineLine.visible = false;
    tc.visible = false;
    const mk = [];
    bodyRoot.traverse(o => { if (o.userData.isMarker || o.userData.isIK) { mk.push([o, o.visible]); o.visible = false; } });
    if (spineDirty) { spineDirty = false; updateSpine(); }

    renderer.render(scene, camera);
    const a = document.createElement('a');
    a.download = `mecha-rig-${Date.now()}.png`;
    a.href = renderer.domElement.toDataURL('image/png');
    a.click();

    nodeHandles.forEach((h, i) => h.visible = vis[i]);
    hipHandle.visible = true;
    spineLine.visible = true;
    tc.visible = true;
    mk.forEach(([o, v]) => o.visible = v);
    requestRender();
}
function onWindowResize() {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
    updateFrame();
    requestRender();
}

window.onload = init;
</script>
</body>
</html>
script.js
console.log('läuft');
style.css
body {
  font-family: system-ui, sans-serif;
  margin: 2rem;
}