BODY_SHELL_v1

Live ↗ZIP (lokal ausführbar)
Beschreibung

Web-based prototype for posing a human body model and generating a shell hull around it. The body is driven by an inverse kinematics rig built on a Mixamo skeleton, with handles for the hips, spine, head, hands and feet. The shell is generated by a Voronoi-style split of the body surface into regions, one region per bone group, and each region becomes a separate plate. The plates are offset outward along their normals and can be exploded away from the body, with rods and balls drawn between the plates and the skeleton. A procedural spine with vertebrae, spikes and optional ribs is drawn inside the body and follows the pose. The prototype shows the body, the inner skeleton and the shell together, and it can be used for design exploration, teaching anatomy and rigging, prototyping character concepts, and as a starting point for further tools. Poses can be saved and loaded as JSON, and the view can be exported as a PNG image.

  • Version: v1.0
  • Inverse kinematics posing with draggable handles for hips, spine, head, hands and feet
  • Mirror mode for symmetric poses and a follow option for limbs
  • Procedural spine with adjustable vertebra count, size, taper and spikes
  • Optional ribs generated along the spine curve
  • Voronoi-based shell plates with size, gap, thickness and organic noise controls
  • Shell explosion slider with connecting rods and balls
  • Camouflage pattern with four colors and a scale control
  • Pose save and load as JSON, PNG export
  • Tech: Three.js r0.169.0, WebGL, GLTFLoader, OrbitControls, TransformControls, RoomEnvironment
  • Requires: a GLB model with a Mixamo skeleton, WebGL-capable browser, internet connection for libraries loaded from a CDN
index.html
<!doctype html>
<html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>POSE:SHELL v1.0</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#f1f1f1;color:#111;font:12px/1.35 system-ui,-apple-system,Segoe UI,Helvetica,Arial,sans-serif;overflow:hidden}
#view{background:#f1f1f1;position:fixed;left:0;top:0;right:300px;bottom:0}
#view canvas{display:block}
#panel{position:fixed;right:0;top:0;bottom:0;width:300px;background:#fff;border-left:1px solid #e8e8e8;overflow-y:auto;padding:12px 14px 40px}
#panel h1{font-size:12px;letter-spacing:.14em;margin:0 0 2px;font-weight:600}
#status{color:#888;font-size:11px;margin-bottom:8px;min-height:15px}
details{border-top:1px solid #eee;padding:7px 0}
summary{cursor:pointer;font-weight:600;letter-spacing:.08em;font-size:11px;list-style:none;user-select:none}
summary::-webkit-details-marker{display:none}
summary:before{content:"+ ";color:#999}details[open] summary:before{content:"– "}
.row{display:flex;align-items:center;gap:8px;margin:5px 0}
.row label{flex:0 0 108px;color:#555}
.row input[type=range]{flex:1;min-width:0;accent-color:#111;height:14px}
.row output{flex:0 0 38px;text-align:right;color:#888;font-variant-numeric:tabular-nums;font-size:11px}
.row input[type=color]{width:34px;height:20px;border:1px solid #ddd;padding:0;background:#fff}
.chk{display:flex;align-items:center;gap:7px;margin:5px 0;color:#333}.chk input{accent-color:#111}
button{font:inherit;background:#fff;border:1px solid #ccc;padding:4px 9px;cursor:pointer;color:#111}
button:hover{border-color:#111}button.on{background:#111;color:#fff;border-color:#111}
.btns{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0}
h4{margin:9px 0 2px;font-size:10px;font-weight:600;color:#999;letter-spacing:.1em;text-transform:uppercase}
#hud{position:fixed;left:14px;bottom:12px;color:#999;font-size:11px;pointer-events:none;max-width:60vw}
#tip{position:fixed;left:14px;top:12px;color:#111;font-size:11px;letter-spacing:.06em;pointer-events:none}
#drop{position:fixed;inset:0;right:300px;display:none;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#fff;z-index:5;color:#555}
#drop.show{display:flex}
</style>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"}}</script>
</head><body>
<div id="view"></div><div id="tip">POSE MODE</div>
<div id="hud">Klick = Handle wählen · W bewegen · E drehen · Linksdrag = Kamera · Rechtsdrag = verschieben</div>
<div id="drop"><div>GLB hier hineinziehen oder wählen<br><span style="color:#aaa">(Mixamo-Skelett, z. B. smoothed_body_for_MIMICRY_2.glb)</span></div><input type="file" id="file" accept=".glb,.gltf"></div>
<aside id="panel"><h1>POSE:SHELL v1.0</h1><div id="status">lade …</div><div id="ui"></div></aside>
<script type="module">
import * as THREE from 'three';
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {TransformControls} from 'three/addons/controls/TransformControls.js';
import {RoomEnvironment} from 'three/addons/environments/RoomEnvironment.js';
const V3=THREE.Vector3,Q=THREE.Quaternion,M4=THREE.Matrix4;
const $=id=>document.getElementById(id);
const status=t=>$('status').textContent=t;

/* ---------- Zustand ---------- */
const S={floor:true,mirror:true,follow:true,handles:true,shoulder:.35,
 skel:{show:true,count:26,size:.022,taper:.6,back:.03,ribs:false,ribCount:0,limbs:true},
 spike:{show:true,len:.28,thick:.012,taper:.8,round:.3,bend:.4,pitch:18,sizeDep:.6,every:1,from:0,to:1},
 body:{show:true,color:'#efeeea',rough:.45,metal:0,clear:.3,opacity:1},
 skin:{color:'#0d0d0d',rough:.5,metal:.35,rod:'#0a0a0a'},
 shell:{on:false,explode:0,size:.14,gap:.008,thick:.006,organic:.025,seed:7,camo:true,scale:6,rod:.004,
  colors:['#d9d5c9','#b8b3a2','#8b8a7d','#5a5a55']}};
let dirty=true,rebuildSpikes=true,rebuildVert=true,rebuildShell=false;

/* ---------- Szene ---------- */
const view=$('view');
const renderer=new THREE.WebGLRenderer({antialias:true,preserveDrawingBuffer:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.shadowMap.enabled=true;renderer.shadowMap.type=THREE.PCFSoftShadowMap;
view.appendChild(renderer.domElement);
const scene=new THREE.Scene();scene.background=new THREE.Color('#f1f1f1');
const pm=new THREE.PMREMGenerator(renderer);
scene.environment=pm.fromScene(new RoomEnvironment(),.04).texture;scene.environmentIntensity=.55;
const camera=new THREE.PerspectiveCamera(35,1,.05,60);camera.position.set(1.6,1.5,3.4);
const orbit=new OrbitControls(camera,renderer.domElement);orbit.target.set(0,1.05,0);orbit.enableDamping=true;orbit.dampingFactor=.12;
scene.add(new THREE.HemisphereLight(0xffffff,0xd8d8d8,.7));
const sun=new THREE.DirectionalLight(0xffffff,2.2);sun.position.set(2.5,5,3);sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);Object.assign(sun.shadow.camera,{left:-2.5,right:2.5,top:2.5,bottom:-2.5,near:.5,far:14});
sun.shadow.bias=-.0004;sun.shadow.normalBias=.01;scene.add(sun);
const floor=new THREE.Mesh(new THREE.PlaneGeometry(14,14),new THREE.ShadowMaterial({opacity:.14}));
floor.rotation.x=-Math.PI/2;floor.receiveShadow=true;scene.add(floor);
const grid=new THREE.GridHelper(14,28,0xe6e6e6,0xf0f0f0);grid.position.y=.001;scene.add(grid);
function resize(){const w=view.clientWidth,h=view.clientHeight;renderer.setSize(w,h);camera.aspect=w/h;camera.updateProjectionMatrix()}
addEventListener('resize',resize);resize();

/* ---------- Materialien (gemeinsamer Shader-Hook: Shell-Explosion + Camouflage) ---------- */
const NSH=128;
const U={uShellOff:{value:Array.from({length:NSH},()=>new V3())},uCamo:{value:0},uCamoScale:{value:6},
 uC:{value:[0,1,2,3].map(()=>new THREE.Color())}};
const VPARS='attribute float aShell;attribute vec3 aBind;varying vec3 vBind;uniform vec3 uShellOff['+NSH+'];\n';
function hookVertex(sh,withBind){
 sh.uniforms.uShellOff=U.uShellOff;
 sh.vertexShader=VPARS+sh.vertexShader
  .replace('#include <begin_vertex>','#include <begin_vertex>\n vBind=aBind;')
  .replace('#include <skinning_vertex>','#include <skinning_vertex>\n transformed+=uShellOff[int(aShell+.5)];');
}
const bodyMat=new THREE.MeshPhysicalMaterial({color:S.body.color,roughness:S.body.rough,clearcoat:S.body.clear,clearcoatRoughness:.3});
bodyMat.onBeforeCompile=sh=>{
 hookVertex(sh);sh.uniforms.uCamo=U.uCamo;sh.uniforms.uCamoScale=U.uCamoScale;sh.uniforms.uC=U.uC;
 sh.fragmentShader=`varying vec3 vBind;uniform float uCamo,uCamoScale;uniform vec3 uC[4];
 float h31(vec3 p){p=fract(p*.3183099+.1);p*=17.;return fract(p.x*p.y*p.z*(p.x+p.y+p.z));}
 float vn(vec3 x){vec3 i=floor(x),f=fract(x);f=f*f*(3.-2.*f);
  return mix(mix(mix(h31(i),h31(i+vec3(1,0,0)),f.x),mix(h31(i+vec3(0,1,0)),h31(i+vec3(1,1,0)),f.x),f.y),
   mix(mix(h31(i+vec3(0,0,1)),h31(i+vec3(1,0,1)),f.x),mix(h31(i+vec3(0,1,1)),h31(i+vec3(1,1,1)),f.x),f.y),f.z);}
 float fb(vec3 p){return vn(p)*.6+vn(p*2.1+7.)*.3+vn(p*4.3+3.)*.1;}
 vec3 camo(vec3 p){p*=uCamoScale;vec3 c=uC[0];float w=.012;
  c=mix(c,uC[1],smoothstep(.5-w,.5+w,fb(p+11.)));
  c=mix(c,uC[2],smoothstep(.56-w,.56+w,fb(p*1.3+31.)));
  c=mix(c,uC[3],smoothstep(.62-w,.62+w,fb(p*1.9+53.)));return c;}
 `+sh.fragmentShader.replace('#include <color_fragment>','#include <color_fragment>\n if(uCamo>.5)diffuseColor.rgb=camo(vBind);');
};
bodyMat.customProgramCacheKey=()=>'pshell';
const depthMat=new THREE.MeshDepthMaterial({depthPacking:THREE.RGBADepthPacking});
depthMat.onBeforeCompile=sh=>hookVertex(sh);
const skelMat=new THREE.MeshStandardMaterial({color:S.skin.color,roughness:S.skin.rough,metalness:S.skin.metal});
const rodMat=new THREE.MeshStandardMaterial({color:S.skin.rod,roughness:.4,metalness:.5});

/* ---------- Modell laden ---------- */
let skinned,root,bn={},bones=[],REST={},FWD=new V3(0,0,1),bindWorld,wg;
const strip=n=>n.replace(/^mixamorig\d*:?/,'');
const need=['Hips','Spine','Spine1','Spine2','Neck','Head','HeadTop_End'];
for(const s of ['Left','Right'])for(const n of ['Shoulder','Arm','ForeArm','Hand','UpLeg','Leg','Foot','ToeBase','Toe_End'])need.push(s+n);
const tmpV=new V3,tmpQ=new Q,tmpQ2=new Q,tmpV2=new V3;
const wpos=(b,t=new V3)=>t.setFromMatrixPosition(b.matrixWorld);
const wquat=(b,t=new Q)=>{b.updateWorldMatrix(true,false);return b.matrixWorld.decompose(tmpV,t,tmpV2),t};

function setupModel(gltf){
 root=gltf.scene;scene.add(root);
 root.traverse(o=>{if(o.isSkinnedMesh)skinned=o});
 bones=skinned.skeleton.bones;bn={};bones.forEach(b=>bn[strip(b.name)]=b);
 const miss=need.filter(n=>!bn[n]);if(miss.length){status('Fehlende Bones: '+miss.join(', '));return false}
 root.updateMatrixWorld(true);skinned.skeleton.update();
 REST.q={};REST.wq={};REST.wp={};REST.wm={};
 bones.forEach(b=>{const n=strip(b.name);REST.q[n]=b.quaternion.clone();REST.wq[n]=wquat(b,new Q);REST.wp[n]=wpos(b,new V3);REST.wm[n]=b.matrixWorld.clone()});
 REST.hipsPos=bn.Hips.position.clone();
 FWD.set(0,0,Math.sign(REST.wp.LeftToeBase.z-REST.wp.LeftFoot.z)||1);
 skinned.frustumCulled=false;skinned.castShadow=skinned.receiveShadow=true;
 skinned.material=bodyMat;skinned.customDepthMaterial=depthMat;
 // Bind-Pose Weltpositionen + Gruppengewichte
 const g=skinned.geometry,n=g.attributes.position.count;bindWorld=new Float32Array(n*3);
 const t=new V3;for(let i=0;i<n;i++){t.fromBufferAttribute(g.attributes.position,i);skinned.applyBoneTransform(i,t);t.applyMatrix4(skinned.matrixWorld);t.toArray(bindWorld,i*3)}
 g.setAttribute('aBind',new THREE.BufferAttribute(bindWorld.slice(),3));
 g.setAttribute('aShell',new THREE.BufferAttribute(new Float32Array(n),1));
 return true;
}

/* ---------- Handles ---------- */
const H={},hlist=[];let sel=null,dragging=null;
const handleGroup=new THREE.Group();scene.add(handleGroup);
const hMat=c=>new THREE.MeshBasicMaterial({color:c,depthTest:false,transparent:true,opacity:.92});
const GEO={sphere:new THREE.SphereGeometry(1,16,12),box:new THREE.BoxGeometry(1.5,1.5,1.5),oct:new THREE.OctahedronGeometry(1.2),ring:new THREE.TorusGeometry(1,.06,8,32).rotateX(Math.PI/2)};
function mkHandle(id,label,type,geo,size,pos,color='#111'){
 const m=new THREE.Mesh(GEO[geo],hMat(color));m.scale.setScalar(size);m.position.copy(pos);m.renderOrder=999;
 const h={id,label,type,mesh:m,color,rest:{p:pos.clone(),q:new Q()}};m.userData.h=h;
 handleGroup.add(m);H[id]=h;hlist.push(h);return h}
function lineObj(n){const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.BufferAttribute(new Float32Array(n*3),3));
 const l=new THREE.Line(g,new THREE.LineBasicMaterial({color:0x999999,depthTest:false,transparent:true,opacity:.7}));l.renderOrder=998;l.frustumCulled=false;handleGroup.add(l);return l}
const LIMBS=[],partner={};
let spineLine,spineLens,headRel={p:new V3(),q:new Q()},fwdLocal={};

function buildRig(){
 const R=REST.wp;
 mkHandle('hip','Hüfte','hip','ring',.14,R.Hips,'#111');
 mkHandle('mid','Taille (Spine-Node)','spine','box',.02,R.Spine1);
 mkHandle('mid2','Rückenmitte (Spine-Node)','spine','box',.02,R.Spine2);
 mkHandle('chest','Brust (Spine-Node)','spine','box',.026,R.Neck);
 mkHandle('head','Kopf','head','box',.03,R.Head);
 for(const s of ['L','R']){
  const P=s==='L'?'Left':'Right';
  const defs=[{k:'arm',sh:bn[P+'Shoulder'],b:[bn[P+'Arm'],bn[P+'ForeArm'],bn[P+'Hand']],pd:FWD.clone().negate(),lab:['Hand','Ellbogen']},
   {k:'leg',sh:null,b:[bn[P+'UpLeg'],bn[P+'Leg'],bn[P+'Foot']],pd:FWD.clone(),lab:['Fuß','Knie']}];
  for(const d of defs){
   const [b0,b1,b2]=d.b,nm=[b0,b1,b2].map(b=>strip(b.name));
   const L={id:d.k+s,sh:d.sh,b0,b1,b2,l1:R[nm[0]].distanceTo(R[nm[1]]),l2:R[nm[1]].distanceTo(R[nm[2]])};
   const side=s==='L'?'links':'rechts';
   L.T=mkHandle('t_'+L.id,d.lab[0]+' '+side,'ik','sphere',d.k==='arm'?.026:.03,R[nm[2]],'#ff6a00');
   const polePos=R[nm[1]].clone().addScaledVector(d.pd,.32);
   L.P=mkHandle('p_'+L.id,d.lab[1]+' '+side+' (Knick)','pole','oct',.022,polePos,'#ffb27a');
   const dir0=R[nm[2]].clone().sub(R[nm[0]]).normalize();
   const perp0=d.pd.clone().addScaledVector(dir0,-d.pd.dot(dir0)).normalize();
   const h0=new V3().crossVectors(perp0,dir0).normalize();
   L.h0=h0.clone().applyQuaternion(REST.wq[nm[0]].clone().invert());
   L.h1=h0.clone().applyQuaternion(REST.wq[nm[1]].clone().invert());
   if(L.sh){const rsh=REST.wp[strip(L.sh.name)];L.refLocal=R[nm[2]].clone().sub(rsh).normalize().applyQuaternion(REST.wq.Spine2.clone().invert())}
   if(d.k==='arm'){const od=R[nm[2]].clone().sub(R[nm[1]]).normalize();L.Rot=mkHandle('r_'+L.id,'Hand-Rotation '+side,'rot','ring',.055,R[nm[2]].clone().addScaledVector(od,.09),'#ff6a00')}
   L.line=lineObj(2);LIMBS.push(L);
  }
 }
 partner.r_armL='r_armR';partner.t_armL='t_armR';partner.p_armL='p_armR';partner.t_legL='t_legR';partner.p_legL='p_legR';
 for(const k of Object.keys(partner))partner[partner[k]]=k;
 spineLine=lineObj(4);
 spineLens=[REST.wp.Spine.distanceTo(R.Spine1),R.Spine1.distanceTo(R.Spine2),R.Spine2.distanceTo(R.Neck)];
 for(const n of ['Spine','Spine1','Spine2','Neck','Head','Hips'])fwdLocal[n]=FWD.clone().applyQuaternion(REST.wq[n].clone().invert());
 mkHandle('look','Blickrichtung (Kopf)','look','sphere',.022,R.Head.clone().add(new V3(0,.1,0)).addScaledVector(FWD,.6),'#ff6a00');
 captureHeadRel();
}
const lookRel=new V3();
function captureLookRel(){const q=wquat(bn.Spine2,new Q),p=wpos(bn.Spine2,new V3);lookRel.copy(H.look.mesh.position).sub(p).applyQuaternion(q.invert())}
function captureHeadRel(){captureLookRel();
 const q=wquat(bn.Spine2,new Q),p=wpos(bn.Spine2,new V3),hq=H.head.mesh.quaternion;
 headRel.p.copy(H.head.mesh.position).sub(p).applyQuaternion(q.clone().invert());
 headRel.q.copy(q).invert().multiply(hq)}
function resetPose(){for(const h of hlist){h.mesh.position.copy(h.rest.p);h.mesh.quaternion.identity()}
 lastHip.p.copy(H.hip.mesh.position);lastHip.q.identity();captureHeadRel();dirty=true}
const lastHip={p:new V3(),q:new Q()};

/* ---------- Solver ---------- */
const _a=new V3,_b=new V3,_c=new V3,_q=new Q,_wq=new Q,_pq=new Q;
function setWorldQuat(b,wq){b.parent.updateWorldMatrix(true,false);wquat(b.parent,_pq);b.quaternion.copy(_pq.invert().multiply(wq));b.updateMatrixWorld(true)}
function aimAt(b,child,target){wpos(b,_a);wpos(child,_b);_b.sub(_a);_c.copy(target).sub(_a);if(_b.lengthSq()<1e-12||_c.lengthSq()<1e-12)return;
 _q.setFromUnitVectors(_b.normalize(),_c.normalize());wquat(b,_wq);_wq.premultiply(_q);setWorldQuat(b,_wq)}
function rotAbout(b,axis,ang){wquat(b,_wq);_q.setFromAxisAngle(axis,ang);_wq.premultiply(_q);setWorldQuat(b,_wq)}
function alignHinge(b,hLocal,axis,n){wquat(b,_wq);const hc=hLocal.clone().applyQuaternion(_wq);
 const p1=hc.addScaledVector(axis,-hc.dot(axis)),p2=n.clone().addScaledVector(axis,-n.dot(axis));
 if(p1.lengthSq()<1e-10||p2.lengthSq()<1e-10)return;p1.normalize();p2.normalize();
 rotAbout(b,axis,Math.atan2(axis.dot(new V3().crossVectors(p1,p2)),p1.dot(p2)))}

function fabrik(p,lens,target,mid,mid2,w){
 const root=p[0].clone(),tot=lens[0]+lens[1]+lens[2],d=target.clone().sub(root),dl=d.length();
 if(dl>tot)target=root.clone().addScaledVector(d,tot/dl);
 for(let it=0;it<16;it++){
  p[3].copy(target);
  _c.copy(p[2]).sub(p[3]).normalize();p[2].copy(p[3]).addScaledVector(_c,lens[2]);p[2].lerp(mid2,w);
  _c.copy(p[1]).sub(p[2]).normalize();p[1].copy(p[2]).addScaledVector(_c,lens[1]);p[1].lerp(mid,w);
  _c.copy(p[0]).sub(p[1]).normalize();p[0].copy(p[1]).addScaledVector(_c,lens[0]);
  p[0].copy(root);for(let i=1;i<4;i++){_c.copy(p[i]).sub(p[i-1]).normalize();p[i].copy(p[i-1]).addScaledVector(_c,lens[i-1])}
 }}
let snapChest=false;
let solveCount=0;
function solve(){
 bones.forEach(b=>b.quaternion.copy(REST.q[strip(b.name)]));
 const hp=H.hip.mesh.position,hq=H.hip.mesh.quaternion;
 root.updateMatrixWorld(true);
 const par=bn.Hips.parent;par.updateWorldMatrix(true,false);
 bn.Hips.position.copy(par.worldToLocal(hp.clone()));
 setWorldQuat(bn.Hips,hq.clone().multiply(REST.wq.Hips));
 bn.Hips.updateMatrixWorld(true);
 /* Wirbelsäule */
 const J=['Spine','Spine1','Spine2','Neck'].map(n=>wpos(bn[n],new V3));
 const drag=dragging;
 fabrik(J,spineLens,H.chest.mesh.position,H.mid.mesh.position,H.mid2.mesh.position,.9);
 aimAt(bn.Spine,bn.Spine1,J[1]);aimAt(bn.Spine1,bn.Spine2,J[2]);aimAt(bn.Spine2,bn.Neck,J[3]);
 // Brust-Twist (relativ zur Hüfte, ohne Singularität)
 {const rel=hq.clone().invert().multiply(H.chest.mesh.quaternion);const phi=2*Math.atan2(rel.y,rel.w)/3;
  const ph=Math.abs(phi)>Math.PI/3?phi-Math.sign(phi)*2*Math.PI/3:phi;
  for(const [b,c2] of [['Spine','Spine1'],['Spine1','Spine2'],['Spine2','Neck']]){
   wpos(bn[b],_a);wpos(bn[c2],_b);rotAbout(bn[b],_b.sub(_a).normalize(),ph)}}
 // Kopf relativ zur Brust + Blickrichtung
 {const sq=wquat(bn.Spine2,new Q),sp=wpos(bn.Spine2,new V3);
  if(dragging!==H.head){H.head.mesh.position.copy(headRel.p).applyQuaternion(sq).add(sp)}
  if(dragging!==H.look)H.look.mesh.position.copy(lookRel).applyQuaternion(sq).add(sp);
  aimAt(bn.Neck,bn.Head,H.head.mesh.position);
  const hq0=wquat(bn.Head,new Q),cur=fwdLocal.Head.clone().applyQuaternion(hq0),des=H.look.mesh.position.clone().sub(wpos(bn.Head,new V3)).normalize(),ang=cur.angleTo(des);
  if(ang>1e-4){_q.setFromUnitVectors(cur,des);if(ang>1.4)tmpQ2.identity().slerp(_q,1.4/ang);else tmpQ2.copy(_q);hq0.premultiply(tmpQ2);setWorldQuat(bn.Head,hq0)}}
 if(snapChest&&dragging!==H.chest){H.chest.mesh.position.copy(wpos(bn.Neck,new V3));snapChest=false}
 /* Gliedmaßen */
 for(const L of LIMBS){
  if(L.sh&&S.shoulder>0){wpos(L.sh,_a);const ref=L.refLocal.clone().applyQuaternion(wquat(bn.Spine2,new Q)),des=L.T.mesh.position.clone().sub(_a).normalize();
   const ang=ref.angleTo(des);if(ang>1e-4){const k=Math.min(S.shoulder,.5/ang);_q.setFromUnitVectors(ref,des);tmpQ2.identity().slerp(_q,k);wquat(L.sh,_wq);_wq.premultiply(tmpQ2);setWorldQuat(L.sh,_wq)}}
  const A=wpos(L.b0,new V3),T=L.T.mesh.position,Pm=L.P.mesh.position;
  const dv=T.clone().sub(A),d=dv.length(),dc=Math.min(Math.max(d,1e-3),(L.l1+L.l2)*.9995),dir=dv.divideScalar(Math.max(d,1e-6));
  const a=(L.l1*L.l1-L.l2*L.l2+dc*dc)/(2*dc),h=Math.sqrt(Math.max(L.l1*L.l1-a*a,0));
  const v=Pm.clone().sub(A);let perp=v.addScaledVector(dir,-v.dot(dir));if(perp.lengthSq()<1e-8)perp=new V3(0,1,0).addScaledVector(dir,-dir.y);perp.normalize();
  const elbow=A.clone().addScaledVector(dir,a).addScaledVector(perp,h),endP=A.clone().addScaledVector(dir,dc);
  const n=new V3().crossVectors(perp,dir).normalize();
  aimAt(L.b0,L.b1,elbow);alignHinge(L.b0,L.h0,elbow.clone().sub(A).normalize(),n);
  wpos(L.b1,_b);aimAt(L.b1,L.b2,endP);alignHinge(L.b1,L.h1,endP.clone().sub(wpos(L.b1,new V3)).normalize(),n);
  if(L.Rot){wquat(L.b2,_wq);_wq.premultiply(L.Rot.mesh.quaternion);setWorldQuat(L.b2,_wq);
   L.Rot.mesh.position.copy(wpos(L.b2,new V3)).addScaledVector(wpos(L.b2,new V3).sub(wpos(L.b1,new V3)).normalize(),.09)}
  else setWorldQuat(L.b2,L.T.mesh.quaternion.clone().multiply(REST.wq[strip(L.b2.name)]));
  const pa=L.line.geometry.attributes.position;wpos(L.b1,_a).toArray(pa.array,0);Pm.toArray(pa.array,3);pa.needsUpdate=true;
 }
 root.updateMatrixWorld(true);
 const sa=spineLine.geometry.attributes.position;
 ['Spine','Spine1','Spine2','Neck'].forEach((n,i)=>wpos(bn[n],_a).toArray(sa.array,i*3));sa.needsUpdate=true;
}

/* ---------- Interaktion ---------- */
const tc=new TransformControls(camera,renderer.domElement);tc.setSize(.8);scene.add(tc.getHelper());
tc.addEventListener('dragging-changed',e=>{orbit.enabled=!e.value;dragging=e.value?sel:null;if(!e.value){dirty=true;if(sel===H.chest)snapChest=true}});
const canRot=h=>h.type==='hip'||h.type==='rot'||h.id==='chest'||(h.type==='ik'&&h.id.includes('leg'));
function select(h){sel=h;if(!h){tc.detach();return}
 tc.attach(h.mesh);if(h.type==='rot')tc.setMode('rotate');else if(!canRot(h)||tc.mode==='rotate'&&h.type!=='hip'&&!canRot(h))tc.setMode('translate');
 colorize();$('hud').textContent=h.label+' · W bewegen · E drehen · Esc abwählen'}
function colorize(){for(const h of hlist)h.mesh.material.color.set(h===sel?'#2457ff':h.color)}
addEventListener('keydown',e=>{if(e.target.tagName==='INPUT')return;
 if((e.key==='w'||e.key==='W')&&!(sel&&sel.type==='rot'))tc.setMode('translate');
 if(e.key==='e'||e.key==='E'){if(sel&&canRot(sel))tc.setMode('rotate')}
 if(e.key==='Escape')select(null)});
const ray=new THREE.Raycaster(),mouse=new THREE.Vector2();let downAt=null;
const ptr=e=>{const r=renderer.domElement.getBoundingClientRect();mouse.set((e.clientX-r.left)/r.width*2-1,-(e.clientY-r.top)/r.height*2+1);ray.setFromCamera(mouse,camera);
 const hits=ray.intersectObjects(hlist.filter(h=>h.mesh.visible).map(h=>h.mesh),false);return hits.length?hits[0].object.userData.h:null};
renderer.domElement.addEventListener('pointerdown',e=>{downAt=[e.clientX,e.clientY]});
renderer.domElement.addEventListener('pointerup',e=>{if(!downAt||tc.dragging)return;
 if(Math.hypot(e.clientX-downAt[0],e.clientY-downAt[1])>4)return;
 if(tc.axis)return;const h=ptr(e);if(h)select(h);else select(null)});
renderer.domElement.addEventListener('pointermove',e=>{if(tc.dragging)return;const h=ptr(e);renderer.domElement.style.cursor=h?'pointer':'';if(h&&!sel)$('hud').textContent=h.label});

const mirP=new V3,mirQ=new Q;
function mirrorOf(h){const hp=H.hip.mesh.position,hq=H.hip.mesh.quaternion,iq=hq.clone().invert();
 const p=h.mesh.position.clone().sub(hp).applyQuaternion(iq);p.x=-p.x;
 const ql=iq.clone().multiply(h.mesh.quaternion);ql.set(ql.x,-ql.y,-ql.z,ql.w);
 return {p:p.applyQuaternion(hq).add(hp),q:hq.clone().multiply(ql)}}
tc.addEventListener('objectChange',()=>{if(!sel)return;dirty=true;
 if(sel.type==='hip'){const np=H.hip.mesh.position,nq=H.hip.mesh.quaternion;
  const dq=nq.clone().multiply(lastHip.q.clone().invert());
  const apply=h=>{h.mesh.position.sub(lastHip.p).applyQuaternion(dq).add(np);h.mesh.quaternion.premultiply(dq)};
  for(const k of ['mid','mid2','chest'])apply(H[k]);
  if(S.follow)for(const L of LIMBS){apply(L.T);apply(L.P);if(L.Rot){const q=L.Rot.mesh.quaternion;q.copy(dq).multiply(q).multiply(dq.clone().invert())}}
  lastHip.p.copy(np);lastHip.q.copy(nq);
  // Kopf (relativ) folgt automatisch
 }else if(sel.id==='head'){captureHeadRel()}else if(sel.id==='look'){captureLookRel()}
 if(S.mirror&&partner[sel.id]){const m=mirrorOf(sel),o=H[partner[sel.id]];o.mesh.position.copy(m.p);if(sel.type==='ik'||sel.type==='rot')o.mesh.quaternion.copy(m.q)}});

/* ---------- Skelett (Innenleben) ---------- */
import {mergeGeometries} from 'three/addons/utils/BufferGeometryUtils.js';
const skelGroup=new THREE.Group();scene.add(skelGroup);
const spikeGroup=new THREE.Group(),ribGroup=new THREE.Group();skelGroup.add(spikeGroup,ribGroup);
const sh=(g,sx,sy,sz,x,y,z,rx=0,rz=0)=>{g.scale(sx,sy,sz);g.rotateX(rx);g.rotateZ(rz);g.translate(x,y,z);return g};
const vertGeo=(()=>{const lp=[[0,-.5],[.78,-.5],[1,-.4],[.84,0],[1,.4],[.78,.5],[0,.5]].map(a=>new THREE.Vector2(a[0],a[1]));
 const body=new THREE.LatheGeometry(lp,10);
 return mergeGeometries([body,sh(new THREE.SphereGeometry(1,8,6),1.1,.28,.4,1.7,0,0),sh(new THREE.SphereGeometry(1,8,6),1.1,.28,.4,-1.7,0,0),sh(new THREE.SphereGeometry(1,8,6),.5,.42,.55,0,.05,-.95)])})();
const shaftGeo=(()=>{const lp=[[0,-.5],[.8,-.5],[1,-.44],[.62,-.25],[.55,0],[.62,.25],[1,.44],[.8,.5],[0,.5]].map(a=>new THREE.Vector2(a[0],a[1]));return new THREE.LatheGeometry(lp,8)})();
const ballGeo=new THREE.SphereGeometry(1,10,8),cylGeo=new THREE.CylinderGeometry(1,1,1,8);
function inst(geo,mat,n){const m=new THREE.InstancedMesh(geo,mat,Math.max(n,1));m.count=n;m.frustumCulled=false;m.castShadow=true;m.receiveShadow=true;skelGroup.add(m);return m}
const _m=new M4,_s=new V3,_Y=new V3(0,1,0),_qq=new Q;
function setShaft(im,i,a,b,r){_c.copy(b).sub(a);const l=_c.length();if(l<1e-6){_m.makeScale(0,0,0)}else{_qq.setFromUnitVectors(_Y,_c.divideScalar(l));_m.compose(_s.copy(a).add(b).multiplyScalar(.5),_qq,new V3(r,l,r))}im.setMatrixAt(i,_m)}

let limbPairs=[],limbShafts,limbBalls,pelvis,skull,vertIM,spacerIM,ribIM,ribShaftIM,sCounts={};
let vf=[],ribs=[],torso={y:[],hw:[],zb:[],zf:[]};
function measureTorso(){
 const g=skinned.geometry,si=g.attributes.skinIndex,sw=g.attributes.skinWeight,n=si.count;
 const tor=new Set(['Hips','Spine','Spine1','Spine2'].map(k=>bones.indexOf(bn[k])));
 const tw=new Float32Array(n);const a=new THREE.Vector4(),b=new THREE.Vector4();
 for(let i=0;i<n;i++){a.fromBufferAttribute(si,i);b.fromBufferAttribute(sw,i);let s=0;for(let k=0;k<4;k++)if(tor.has(a.getComponent(k)))s+=b.getComponent(k);tw[i]=s}
 const y0=REST.wp.Hips.y,y1=REST.wp.Neck.y,N=30;
 for(let k=0;k<=N;k++){const y=y0+(y1-y0)*k/N;let hw=0,zb=1e9,zf=-1e9;
  for(let i=0;i<n;i++){if(tw[i]<.6)continue;const yy=bindWorld[i*3+1];if(Math.abs(yy-y)>.012)continue;const x=bindWorld[i*3],z=bindWorld[i*3+2];if(Math.abs(x)>.3)continue;
   hw=Math.max(hw,Math.abs(x));zb=Math.min(zb,z);zf=Math.max(zf,z)}
  if(zb>1e8){hw=.15;zb=-.1;zf=.1}
  torso.y.push(y);torso.hw.push(hw);torso.zb.push(zb);torso.zf.push(zf)}
}
function torsoAt(y){const t=torso;let k=0;while(k<t.y.length-2&&y>t.y[k+1])k++;const f=Math.min(Math.max((y-t.y[k])/(t.y[k+1]-t.y[k]),0),1);
 const L=a=>a[k]+(a[k+1]-a[k])*f;return {hw:L(t.hw),zb:L(t.zb),zf:L(t.zf)}}

function buildSkeleton(){
 measureTorso();
 // Gliedmaßen-Schäfte
 const P=[],add=(a,b,r)=>{if(bn[a]&&bn[b])P.push([bn[a],bn[b],r])};
 for(const s of ['Left','Right']){
  add('Neck',s+'Shoulder',.011);add(s+'Shoulder',s+'Arm',.012);add(s+'Arm',s+'ForeArm',.016);add(s+'ForeArm',s+'Hand',.013);
  add('Hips',s+'UpLeg',.016);add(s+'UpLeg',s+'Leg',.026);add(s+'Leg',s+'Foot',.02);add(s+'Foot',s+'ToeBase',.014);add(s+'ToeBase',s+'Toe_End',.009);
  for(const f of ['Thumb','Index','Middle','Ring','Pinky']){add(s+'Hand',s+'Hand'+f+'1',.0035);for(let i=1;i<4;i++)add(s+'Hand'+f+i,s+'Hand'+f+(i+1),.0042-i*.0004)}}
 limbPairs=P;
 limbShafts=inst(shaftGeo,skelMat,P.length);
 const bs=[...new Set(P.map(p=>p[0]))];limbBalls=inst(ballGeo,skelMat,bs.length);limbBalls.userData.list=bs;limbBalls.userData.r=bs.map(b=>Math.max(...P.filter(p=>p[0]===b).map(p=>p[2]))*1.3);
 // Becken
 const pg=mergeGeometries([(()=>{const t=new THREE.TorusGeometry(.105,.02,6,16);t.rotateX(Math.PI/2-.2);t.scale(1,1,.75);t.translate(0,.02,-.01*FWD.z);return t})(),
  sh(new THREE.SphereGeometry(1,8,6),.065,.09,.03,.095,.05,-.005*FWD.z,0,-.4),sh(new THREE.SphereGeometry(1,8,6),.065,.09,.03,-.095,.05,-.005*FWD.z,0,.4),
  sh(new THREE.SphereGeometry(1,8,6),.04,.06,.022,0,.0,-.045*FWD.z)]);
 pelvis=new THREE.Mesh(pg,skelMat);
 const sg=mergeGeometries([sh(new THREE.SphereGeometry(1,12,10),.04,.052,.047,0,.07,.02*FWD.z),sh(new THREE.SphereGeometry(1,10,8),.027,.021,.031,0,.03,.03*FWD.z)]);
 skull=new THREE.Mesh(sg,skelMat);
 for(const m of [pelvis,skull]){m.castShadow=m.receiveShadow=true;m.frustumCulled=false;skelGroup.add(m)}
 skelGroup.userData.restHeadWP=REST.wp.Head;
 spacerIM=inst(shaftGeo,skelMat,60);ribShaftIM=inst(shaftGeo,skelMat,60);
 vertIM=inst(vertGeo,skelMat,64);
}

/* Wirbelprofil + Dornen + Rippen (bei Parameteränderung) */
function spikeGeo(len,R,p){
 const nS=14,nR=8,pos=[],idx=[],pitch=p.pitch*Math.PI/180,bend=p.bend*1.4;
 let c=new V3(0,0,0);const step=len/nS;
 for(let k=0;k<=nS;k++){const s=k/nS,th=pitch+bend*Math.pow(s,1.5),dir=new V3(0,Math.sin(th),-Math.cos(th)),N=new V3(0,Math.cos(th),Math.sin(th));
  if(k>0)c=c.clone().addScaledVector(dir,step);
  const cone=R*(1-s*p.taper),rnd=R*Math.sqrt(Math.max(0,1-s*s))*(1-s*p.taper*.5);
  const r=Math.max(THREE.MathUtils.lerp(cone,rnd,p.round),.0003);
  for(let j=0;j<nR;j++){const a=j/nR*Math.PI*2;pos.push(c.x+Math.cos(a)*r,c.y+Math.sin(a)*r*N.y,c.z+Math.sin(a)*r*N.z)}}
 const tipI=pos.length/3;pos.push(c.x,c.y,c.z);
 for(let k=0;k<nS;k++)for(let j=0;j<nR;j++){const a=k*nR+j,b=k*nR+(j+1)%nR,c2=a+nR,d=b+nR;idx.push(a,c2,b,b,c2,d)}
 for(let j=0;j<nR;j++)idx.push(nS*nR+j,tipI,nS*nR+(j+1)%nR);
 const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));g.setIndex(idx);g.computeVertexNormals();return g}

function rebuildVertebrae(){
 const k=S.skel,N=k.count,w=[];let sum=0;const sizes=[];
 for(let i=0;i<N;i++){const t=(i+.5)/N,s=.32+.68*Math.pow(1-t,k.taper*2);sizes.push(s);const ww=Math.pow(s,.7);w.push(ww);sum+=ww}
 const rc=new THREE.CatmullRomCurve3(['Hips','Spine','Spine1','Spine2','Neck','Head'].map(n=>REST.wp[n].clone()),false,'centripetal');
 vf=[];let cum=0;
 for(let i=0;i<N;i++){const uc=(cum+w[i]/2)/sum,h=w[i]/sum;cum+=w[i];
  const rp=rc.getPointAt(uc),y=rp.y,d=torsoAt(y);
  vf.push({zc:rp.z,u:uc,hf:h,s:sizes[i],R:k.size*sizes[i],dims:d,p:new V3(),X:new V3(),Y:new V3(),Z:new V3()})}
 vertIM.count=N;spacerIM.count=Math.max(N-1,0);
 rebuildSpikes=true;
 // Rippen
 for(const m of ribs){ribGroup.remove(m.L,m.Rr);m.L.geometry.dispose();m.Rr.geometry.dispose()}ribs=[];
 if(k.ribs&&k.ribCount>0&&N>6){
  for(let r=0;r<k.ribCount;r++){const f=k.ribCount===1?.5:r/(k.ribCount-1),idx=Math.min(N-1,Math.round(N*(.3+.45*f)));
   const d=vf[idx].dims,zc=vf[idx].zc,zB=(d.zb-zc+k.back)*.9,zF=(d.zf-zc+k.back)*.88,a=d.hw*.92;
   const mk=sd=>{const pts=[[.03,0,-.015],[.8*a,-.012,zB*.5],[a,-.028,(zB+zF)/2],[.72*a,-.045,zF*.75],[.09*a,-.055,zF]].map(q=>new V3(sd*q[0],q[1],q[2]));
    const g=new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts),22,.0055,5,false);g.userData={end:pts[4].clone()};const m=new THREE.Mesh(g,skelMat);m.castShadow=true;m.frustumCulled=false;m.matrixAutoUpdate=false;ribGroup.add(m);return m};
   ribs.push({idx,L:mk(1),Rr:mk(-1)})}}
 ribShaftIM.count=Math.max(0,(ribs.length-1)*2+ribs.length);
 rebuildVert=false;dirty=true}

function rebuildSpikeMeshes(){
 for(const c of [...spikeGroup.children]){spikeGroup.remove(c);c.geometry.dispose()}
 const p=S.spike,n=vf.length;
 for(let i=0;i<n;i++){if(i%p.every)continue;const t=n<2?0:i/(n-1);if(t<p.from-1e-6||t>p.to+1e-6)continue;
  const v=vf[i],sc=Math.pow(v.s,p.sizeDep);
  const m=new THREE.Mesh(spikeGeo(p.len*sc,p.thick*v.s,p),skelMat);
  m.castShadow=true;m.frustumCulled=false;m.matrixAutoUpdate=false;m.userData.i=i;spikeGroup.add(m)}
 rebuildSpikes=false}

/* Pro Frame: Wirbel/Spikes/Rippen/Schäfte an Pose binden */
const _X=new V3,_Z=new V3,_f=new V3;
function updateSkeleton(){
 const vis=S.skel.show;skelGroup.visible=vis;if(!vis)return;
 // Becken & Schädel
 pelvis.position.copy(wpos(bn.Hips,new V3));pelvis.quaternion.copy(wquat(bn.Hips,new Q)).multiply(REST.wq.Hips.clone().invert());
 pelvis.position.copy(wpos(bn.Hips,new V3));
 // Pelvis-Geometrie liegt um Ursprung (Hips-Gelenk) in Rest-Weltachsen
 skull.position.copy(wpos(bn.Head,new V3));skull.quaternion.copy(wquat(bn.Head,new Q)).multiply(REST.wq.Head.clone().invert());
 // Kurve
 const pts=['Hips','Spine','Spine1','Spine2','Neck','Head'].map(n=>wpos(bn[n],new V3));
 const cur=new THREE.CatmullRomCurve3(pts,false,'centripetal'),L=cur.getLength();
 const uk=[0];for(let i=1;i<6;i++)uk.push(uk[i-1]+pts[i].distanceTo(pts[i-1]));for(let i=0;i<6;i++)uk[i]/=uk[5];
 const fw=['Hips','Spine','Spine1','Spine2','Neck','Head'].map(n=>fwdLocal[n].clone().applyQuaternion(wquat(bn[n],new Q)));
 const sp=S.skel;
 for(let i=0;i<vf.length;i++){const v=vf[i];cur.getPointAt(v.u,v.p);cur.getTangentAt(v.u,v.Y);
  let k=0;while(k<4&&v.u>uk[k+1])k++;const f=Math.min(Math.max((v.u-uk[k])/(uk[k+1]-uk[k]),0),1);
  _f.copy(fw[k]).lerp(fw[k+1],f);_f.addScaledVector(v.Y,-_f.dot(v.Y)).normalize();
  v.Z.copy(_f);v.X.crossVectors(v.Y,v.Z).normalize();v.p.addScaledVector(v.Z,-sp.back);
  _m.makeBasis(v.X,v.Y,v.Z);_m.setPosition(v.p);const sm=new M4().makeScale(v.R,v.hf*L*.8,v.R);
  vertIM.setMatrixAt(i,_m.clone().multiply(sm));
  if(i>0){const u=vf[i-1];setShaft(spacerIM,i-1,u.p,v.p,v.R*.3)}}
 vertIM.instanceMatrix.needsUpdate=true;spacerIM.instanceMatrix.needsUpdate=true;
 // Dornen
 spikeGroup.visible=S.spike.show;
 for(const m of spikeGroup.children){const v=vf[m.userData.i];m.matrix.makeBasis(v.X,v.Y,v.Z).setPosition(v.p);m.matrixWorldNeedsUpdate=true;m.matrixWorld.copy(m.matrix)}
 // Rippen
 ribGroup.visible=sp.ribs;
 let rs=0;const endL=[],endR=[];
 for(const r of ribs){const v=vf[r.idx];for(const m of [r.L,r.Rr]){m.matrix.makeBasis(v.X,v.Y,v.Z).setPosition(v.p);m.matrixWorld.copy(m.matrix)}
  const eL=r.L.geometry.userData.end.clone().applyMatrix4(r.L.matrix),eR=r.Rr.geometry.userData.end.clone().applyMatrix4(r.Rr.matrix);endL.push(eL);endR.push(eR);
  setShaft(ribShaftIM,rs++,eL,eR,.004)}
 for(let i=1;i<endL.length;i++){setShaft(ribShaftIM,rs++,endL[i-1],endL[i],.004);setShaft(ribShaftIM,rs++,endR[i-1],endR[i],.004)}
 ribShaftIM.visible=sp.ribs;ribShaftIM.instanceMatrix.needsUpdate=true;
 // Glieder
 limbShafts.visible=limbBalls.visible=sp.limbs;
 for(let i=0;i<limbPairs.length;i++){const [a,b,r]=limbPairs[i];setShaft(limbShafts,i,wpos(a,_a),wpos(b,_b),r)}
 const lst=limbBalls.userData.list;for(let i=0;i<lst.length;i++){const r=limbBalls.userData.r[i];_m.compose(wpos(lst[i],_a),_qq.identity(),_s.set(r,r,r));limbBalls.setMatrixAt(i,_m)}
 limbShafts.instanceMatrix.needsUpdate=true;limbBalls.instanceMatrix.needsUpdate=true;
}

/* ---------- Shells ---------- */
let shellMesh=null,shells=[],rodIM,ballIM,plateIM;
const rngf=s=>()=>{s|=0;s=s+0x6D2B79F5|0;let t=Math.imul(s^s>>>15,1|s);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296};
const fr=x=>x-Math.floor(x);
const hs=(x,y,z)=>fr(Math.sin(x*127.1+y*311.7+z*74.7)*43758.5453);
function vnoise(x,y,z){const ix=Math.floor(x),iy=Math.floor(y),iz=Math.floor(z);let fx=x-ix,fy=y-iy,fz=z-iz;fx*=fx*(3-2*fx);fy*=fy*(3-2*fy);fz*=fz*(3-2*fz);
 const L=THREE.MathUtils.lerp;return L(L(L(hs(ix,iy,iz),hs(ix+1,iy,iz),fx),L(hs(ix,iy+1,iz),hs(ix+1,iy+1,iz),fx),fy),L(L(hs(ix,iy,iz+1),hs(ix+1,iy,iz+1),fx),L(hs(ix,iy+1,iz+1),hs(ix+1,iy+1,iz+1),fx),fy),fz)}
const GDEF=[['torso',['Hips','Spine','Spine1','Spine2'],1,0],['head',['Neck','Head','HeadTop_End'],.8,0],
 ['upperArm',['Shoulder','Arm'],.9,1],['foreArm',['ForeArm'],.9,1],['hand',['Hand'],3,1],['thigh',['UpLeg'],1,1],['shin',['Leg'],.9,1],['foot',['Foot','ToeBase','Toe_End'],1.6,1]];
let GR=[],boneGroup=null;
function setupGroups(){
 GR=[];GDEF.forEach((d,i)=>{if(!d[3])GR.push({n:d[0],m:d[2],side:0,mirror:-1,names:d[1]});else{GR.push({n:d[0]+'L',m:d[2],side:1,names:d[1].map(x=>'Left'+x),mirror:GR.length+1});GR.push({n:d[0]+'R',m:d[2],side:2,names:d[1].map(x=>'Right'+x),mirror:GR.length-1})}});
 boneGroup=bones.map(b=>{const n=strip(b.name);for(let g=0;g<GR.length;g++){const G=GR[g];if(G.n.startsWith('hand')&&/^(Left|Right)Hand.+/.test(n)&&n.startsWith(G.side===1?'Left':'Right'))return g}
  for(let g=0;g<GR.length;g++)if(GR[g].names.includes(n))return g;return 0})}
function skinGroups(){
 const g=skinned.geometry,si=g.attributes.skinIndex,sw=g.attributes.skinWeight,n=si.count,NG=GR.length;
 const w=new Float32Array(n*NG),a=new THREE.Vector4(),b=new THREE.Vector4();
 for(let i=0;i<n;i++){a.fromBufferAttribute(si,i);b.fromBufferAttribute(sw,i);for(let k=0;k<4;k++){const gi=boneGroup[a.getComponent(k)];w[i*NG+gi]+=b.getComponent(k)}}
 return w}

function genSeeds(wgt,size,rng){
 const NG=GR.length,seeds=Array.from({length:NG},()=>[]),n=wgt.length/NG;
 for(let g=0;g<NG;g++){const G=GR[g];if(G.side===2)continue;const r=size*G.m,cand=[];
  for(let i=0;i<n;i++)if(wgt[i*NG+g]>.8&&(G.side!==0||bindWorld[i*3]>=0))cand.push(i);
  if(!cand.length)continue;const list=seeds[g];
  const mir=G.side===0;let fails=0;
  while(fails<14&&list.length<70){let best=null,bd=-1;
   for(let t=0;t<24;t++){const i=cand[rng()*cand.length|0];const p=[bindWorld[i*3],bindWorld[i*3+1],bindWorld[i*3+2]];
    if(mir&&Math.abs(p[0])<r*.3)p[0]=0;
    let md=1e9;for(const s of list){md=Math.min(md,Math.hypot(p[0]-s[0],p[1]-s[1],p[2]-s[2]));if(mir&&p[0]!==0)md=Math.min(md,Math.hypot(p[0]+s[0],p[1]-s[1],p[2]-s[2]))}
    if(mir&&p[0]!==0)md=Math.min(md,2*Math.abs(p[0]));
    if(!list.length)md=1e9;if(md>bd){bd=md;best=p}}
   if(bd<r*.9&&list.length){fails++;continue}fails=0;list.push(best);if(mir&&best[0]!==0)list.push([-best[0],best[1],best[2]])}
  if(G.side===1){seeds[G.mirror]=list.map(s=>[-s[0],s[1],s[2]])}}
 return seeds}

function buildShells(){
 if(shellMesh){shellMesh.parent.remove(shellMesh);shellMesh.geometry.dispose();shellMesh=null}
 for(const m of [rodIM,ballIM,plateIM])if(m){skelGroup.remove(m);m.dispose()}
 const P=S.shell,rng=rngf(P.seed*977+13),g=skinned.geometry,n=g.attributes.position.count,NG=GR.length;
 if(!wg)wg=skinGroups();
 let seeds,size=P.size,tot=0;
 for(let tr=0;tr<6;tr++){seeds=genSeeds(wg,size,rngf(P.seed*977+13));tot=seeds.reduce((a,s)=>a+s.length,0);if(tot<NSH-1)break;size*=1.2}
 const sid=seeds.map((l,gi)=>l.map(()=>0));let cnt=0;seeds.forEach((l,gi)=>l.forEach((s,k)=>{sid[gi][k]=++cnt}));
 // gewarpte Positionen (symmetrisch)
 const wpP=new Float32Array(n*3),amp=P.organic,fq=9;
 for(let i=0;i<n;i++){const x=bindWorld[i*3],y=bindWorld[i*3+1],z=bindWorld[i*3+2],ax=Math.abs(x);
  let dx=(vnoise(ax*fq,y*fq,z*fq)-.5)*2*amp,dy=(vnoise(ax*fq+31,y*fq,z*fq)-.5)*2*amp,dz=(vnoise(ax*fq+57,y*fq,z*fq)-.5)*2*amp;if(x<0)dx=-dx;
  wpP[i*3]=x+dx;wpP[i*3+1]=y+dy;wpP[i*3+2]=z+dz}
 const pos=g.attributes.position,nor=g.attributes.normal,si=g.attributes.skinIndex,sw=g.attributes.skinWeight,idx=g.index.array;
 const O={p:[],n:[],si:[],sw:[],b:[],s:[]},vmap=new Map(),tris=Array.from({length:cnt+1},()=>[]);
 const gapH=P.gap/2;
 const getV=(sh,a,bb,t)=>{if(bb>=0&&a>bb){const x=a;a=bb;bb=x;t=1-t}const key=bb<0?sh*n+a:-((sh*n+a)*n+bb)-1;let r=vmap.get(key);if(r!==undefined)return r;
  const pa=new V3().fromBufferAttribute(pos,a),na=new V3().fromBufferAttribute(nor,a);let p,nn,bd,mix={};
  const sa=new THREE.Vector4().fromBufferAttribute(si,a),wa=new THREE.Vector4().fromBufferAttribute(sw,a);
  const ba=new V3(bindWorld[a*3],bindWorld[a*3+1],bindWorld[a*3+2]);
  if(bb<0){p=pa;nn=na;bd=ba;for(let k=0;k<4;k++)mix[sa.getComponent(k)]=(mix[sa.getComponent(k)]||0)+wa.getComponent(k)}
  else{const pb=new V3().fromBufferAttribute(pos,bb),nb=new V3().fromBufferAttribute(nor,bb);p=pa.lerp(pb,t);nn=na.lerp(nb,t).normalize();
   bd=ba.lerp(new V3(bindWorld[bb*3],bindWorld[bb*3+1],bindWorld[bb*3+2]),t);
   const sb=new THREE.Vector4().fromBufferAttribute(si,bb),wb=new THREE.Vector4().fromBufferAttribute(sw,bb);
   for(let k=0;k<4;k++){mix[sa.getComponent(k)]=(mix[sa.getComponent(k)]||0)+wa.getComponent(k)*(1-t);mix[sb.getComponent(k)]=(mix[sb.getComponent(k)]||0)+wb.getComponent(k)*t}}
  const top=Object.entries(mix).sort((x,y)=>y[1]-x[1]).slice(0,4);let ws=top.reduce((q,e)=>q+e[1],0)||1;
  r=O.p.length/3;O.p.push(p.x,p.y,p.z);O.n.push(nn.x,nn.y,nn.z);O.b.push(bd.x,bd.y,bd.z);O.s.push(sh);
  for(let k=0;k<4;k++){O.si.push(top[k]?+top[k][0]:0);O.sw.push(top[k]?top[k][1]/ws:0)}
  vmap.set(key,r);return r};
 const f3=new Float32Array(3),near=new Int32Array(3);
 for(let t=0;t<idx.length;t+=3){const tv=[idx[t],idx[t+1],idx[t+2]];
  let bg=0,bw=-1;for(let gi=0;gi<NG;gi++){let s=0;for(const v of tv)s+=wg[v*NG+gi];if(s>bw){bw=s;bg=gi}}
  const SG=seeds[bg];if(!SG.length)continue;
  const D=tv.map(v=>SG.map(s=>Math.hypot(wpP[v*3]-s[0],wpP[v*3+1]-s[1],wpP[v*3+2]-s[2])));
  const nr=D.map(d=>d.indexOf(Math.min(...d)));
  for(const c of new Set(nr)){
   const F=tv.map((v,k)=>{const d=D[k];let m=1;for(let s=0;s<d.length;s++)if(s!==c)m=Math.min(m,(d[s]-d[c])*.5);
    return Math.min(m,(wg[v*NG+bg]-.5)*.12)-gapH});
   if(F[0]<0&&F[1]<0&&F[2]<0)continue;
   const poly=[];for(let i=0;i<3;i++){const j=(i+1)%3,ia=F[i]>=0,ib=F[j]>=0;if(ia)poly.push([tv[i],-1,0]);if(ia!==ib){poly.push([tv[i],tv[j],F[i]/(F[i]-F[j])])}}
   if(poly.length<3)continue;const shn=sid[bg][c],vs=poly.map(q=>getV(shn,q[0],q[1],q[2]));
   for(let k=1;k<vs.length-1;k++)tris[shn].push(vs[0],vs[k],vs[k+1])}}
 // Dicke + Rand
 const Vo=O.p.length/3,th=P.thick,IDX=[];
 for(let i=0;i<Vo;i++){O.p.push(O.p[i*3]-O.n[i*3]*th,O.p[i*3+1]-O.n[i*3+1]*th,O.p[i*3+2]-O.n[i*3+2]*th);O.n.push(-O.n[i*3],-O.n[i*3+1],-O.n[i*3+2]);O.b.push(O.b[i*3],O.b[i*3+1],O.b[i*3+2]);O.s.push(O.s[i]);
  for(let k=0;k<4;k++){O.si.push(O.si[i*4+k]);O.sw.push(O.sw[i*4+k])}}
 for(let sh=1;sh<=cnt;sh++){const T=tris[sh],E=new Map();
  for(let t=0;t<T.length;t+=3){IDX.push(T[t],T[t+1],T[t+2],T[t]+Vo,T[t+2]+Vo,T[t+1]+Vo);
   for(let k=0;k<3;k++){const a=T[t+k],b=T[t+(k+1)%3],key=a<b?a*1e6+b:b*1e6+a,e=E.get(key);if(e)e.c++;else E.set(key,{a,b,c:1})}}
  for(const e of E.values()){if(e.c!==1)continue;const a=e.a,b=e.b,pa=new V3(O.p[a*3],O.p[a*3+1],O.p[a*3+2]),pb=new V3(O.p[b*3],O.p[b*3+1],O.p[b*3+2]);
   const nA=new V3(O.n[a*3],O.n[a*3+1],O.n[a*3+2]).add(new V3(O.n[b*3],O.n[b*3+1],O.n[b*3+2])).normalize(),fn=new V3().crossVectors(pb.clone().sub(pa),nA).normalize();
   const base=O.p.length/3;
   for(const v of [a,b,a+Vo,b+Vo]){O.p.push(O.p[v*3],O.p[v*3+1],O.p[v*3+2]);O.n.push(fn.x,fn.y,fn.z);O.b.push(O.b[v*3],O.b[v*3+1],O.b[v*3+2]);O.s.push(sh);for(let k=0;k<4;k++){O.si.push(O.si[v*4+k]);O.sw.push(O.sw[v*4+k])}}
   IDX.push(base,base+2,base+3,base,base+3,base+1)}}
 const ng=new THREE.BufferGeometry();
 ng.setAttribute('position',new THREE.Float32BufferAttribute(O.p,3));ng.setAttribute('normal',new THREE.Float32BufferAttribute(O.n,3));
 ng.setAttribute('skinIndex',new THREE.Uint16BufferAttribute(O.si,4));ng.setAttribute('skinWeight',new THREE.Float32BufferAttribute(O.sw,4));
 ng.setAttribute('aShell',new THREE.Float32BufferAttribute(O.s,1));ng.setAttribute('aBind',new THREE.Float32BufferAttribute(O.b,3));ng.setIndex(new THREE.Uint32BufferAttribute(IDX,1));
 shellMesh=new THREE.SkinnedMesh(ng,bodyMat);shellMesh.bind(skinned.skeleton,skinned.bindMatrix);
 shellMesh.position.copy(skinned.position);shellMesh.quaternion.copy(skinned.quaternion);shellMesh.scale.copy(skinned.scale);
 shellMesh.frustumCulled=false;shellMesh.castShadow=shellMesh.receiveShadow=true;shellMesh.customDepthMaterial=depthMat;
 skinned.parent.add(shellMesh);
 // Shell-Daten: Zentrum, Normale, Anker am Skelett
 const segs=[['Hips','Spine'],['Spine','Spine1'],['Spine1','Spine2'],['Spine2','Neck'],['Neck','Head'],['Head','HeadTop_End']].map(a=>[bn[a[0]],bn[a[1]]]).concat(limbPairs.map(p=>[p[0],p[1]]));
 shells=[];const acc=Array.from({length:cnt+1},()=>({c:new V3(),n:new V3(),k:0}));
 for(let i=0;i<Vo;i++){const a=acc[O.s[i]];a.c.x+=O.b[i*3];a.c.y+=O.b[i*3+1];a.c.z+=O.b[i*3+2];a.n.x+=O.n[i*3];a.n.y+=O.n[i*3+1];a.n.z+=O.n[i*3+2];a.k++}
 const r2=rngf(P.seed*31+5);
 for(let s=1;s<=cnt;s++){const a=acc[s];if(!a.k){shells.push(null);continue}a.c.divideScalar(a.k);
  let best=null,bdist=1e9;for(const [b0,b1] of segs){const A=REST.wp[strip(b0.name)],B=REST.wp[strip(b1.name)],ab=B.clone().sub(A),t=Math.min(Math.max(a.c.clone().sub(A).dot(ab)/Math.max(ab.lengthSq(),1e-9),0),1),pt=A.clone().addScaledVector(ab,t),d=pt.distanceTo(a.c);if(d<bdist){bdist=d;best={b:b0,pt}}}
  const nm=strip(best.b.name),rad=a.c.clone().sub(best.pt).normalize(),nw=a.n.clone().normalize(),dir=rad.multiplyScalar(.6).addScaledVector(nw,.4).normalize();
  const iq=REST.wq[nm].clone().invert();
  shells.push({bone:best.b,aL:best.pt.clone().applyMatrix4(REST.wm[nm].clone().invert()),cL:a.c.clone().applyMatrix4(REST.wm[nm].clone().invert()),dL:dir.clone().applyQuaternion(iq),nL:nw.clone().applyQuaternion(iq),k:.75+r2()*.5})}
 const N=shells.length;
 rodIM=inst(cylGeo,rodMat,N*3);ballIM=inst(ballGeo,rodMat,N);plateIM=inst(cylGeo,rodMat,N);
 rebuildShell=false;applyShellVis();dirty=true;
 status('Shells: '+cnt+' · '+(IDX.length/3|0)+' Dreiecke')}
function applyShellVis(){const on=S.shell.on&&shellMesh;skinned.visible=S.body.show&&!on;if(shellMesh)shellMesh.visible=S.body.show&&on;
 for(const m of [rodIM,ballIM,plateIM])if(m)m.visible=!!on}

const _inv3=new THREE.Matrix3(),_o=new V3,_A=new V3,_D=new V3,_C=new V3,_Sx=new V3,_N=new V3;
function updateShells(){
 for(let i=0;i<NSH;i++)U.uShellOff.value[i].set(0,0,0);
 if(!S.shell.on||!shellMesh)return;
 const ex=S.shell.explode,md=.5,rod=S.shell.rod;_inv3.setFromMatrix4(skinned.matrixWorld.clone().invert());
 for(let i=0;i<shells.length;i++){const d=shells[i];if(!d)continue;
  const bq=wquat(d.bone,new Q);_A.copy(d.aL).applyMatrix4(d.bone.matrixWorld);_C.copy(d.cL).applyMatrix4(d.bone.matrixWorld);
  _D.copy(d.dL).applyQuaternion(bq);_N.copy(d.nL).applyQuaternion(bq);
  _o.copy(_D).multiplyScalar(ex*md*d.k);U.uShellOff.value[i+1].copy(_o).applyMatrix3(_inv3);
  _Sx.copy(_C).add(_o).addScaledVector(_N,-S.shell.thick);
  const vis=ex>.003;_c.copy(_Sx).sub(_A);const L=_c.length();
  if(!vis||L<1e-4){for(let k=0;k<3;k++)rodIM.setMatrixAt(i*3+k,new M4().makeScale(0,0,0));ballIM.setMatrixAt(i,new M4().makeScale(0,0,0));plateIM.setMatrixAt(i,new M4().makeScale(0,0,0));continue}
  _c.divideScalar(L);
  for(let k=0;k<3;k++){const s0=k*.27*L,s1=s0+.46*L;setShaft(rodIM,i*3+k,_a.copy(_A).addScaledVector(_c,s0),_b.copy(_A).addScaledVector(_c,s1),rod*[1,.75,.55][k])}
  _m.compose(_A,_qq.identity(),_s.set(rod*1.9,rod*1.9,rod*1.9));ballIM.setMatrixAt(i,_m);
  setShaft(plateIM,i,_a.copy(_Sx).addScaledVector(_c,-.003),_Sx,rod*3.2)}
 for(const m of [rodIM,ballIM,plateIM])m.instanceMatrix.needsUpdate=true;
}

/* ---------- UI ---------- */
const ui=$('ui');let cur=ui;
function sec(t,open=false){const d=document.createElement('details');if(open)d.open=true;d.innerHTML='<summary>'+t+'</summary>';ui.appendChild(d);cur=d;return d}
function sub(t){const h=document.createElement('h4');h.textContent=t;cur.appendChild(h)}
function slider(lab,obj,key,min,max,step,cb){const r=document.createElement('div');r.className='row';
 r.innerHTML='<label>'+lab+'</label><input type=range min='+min+' max='+max+' step='+step+'><output></output>';
 const i=r.children[1],o=r.children[2],f=()=>o.textContent=(+obj[key]).toFixed(step<.01?3:step<1?2:0);
 i.value=obj[key];f();i.oninput=()=>{obj[key]=+i.value;f();dirty=true;cb&&cb('input')};i.onchange=()=>cb&&cb('change');cur.appendChild(r);return i}
function check(lab,obj,key,cb){const r=document.createElement('label');r.className='chk';r.innerHTML='<input type=checkbox><span>'+lab+'</span>';
 const i=r.children[0];i.checked=!!obj[key];i.onchange=()=>{obj[key]=i.checked;dirty=true;cb&&cb()};cur.appendChild(r)}
function color(lab,obj,key,cb){const r=document.createElement('div');r.className='row';r.innerHTML='<label>'+lab+'</label><input type=color>';
 const i=r.children[1];i.value=obj[key];i.oninput=()=>{obj[key]=i.value;dirty=true;cb&&cb()};cur.appendChild(r)}
function btns(list){const d=document.createElement('div');d.className='btns';for(const [t,f] of list){const b=document.createElement('button');b.textContent=t;b.onclick=f;d.appendChild(b)}cur.appendChild(d)}
const vert=()=>{rebuildVert=true};const spk=k=>{if(k==='change'||k==='input')rebuildSpikes=true};
const dl=(name,blob)=>{const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;a.click()};

function buildUI(){
 sec('POSE',true);
 btns([['Pose zurücksetzen',()=>resetPose()],['PNG',()=>{renderer.render(scene,camera);renderer.domElement.toBlob(b=>dl('pose.png',b))}]]);
 check('Spiegeln (symmetrisch posen)',S,'mirror');check('Gliedmaßen folgen der Hüfte',S,'follow');
 check('Handles zeigen',S,'handles',()=>{handleGroup.visible=S.handles;if(!S.handles)select(null)});
 slider('Schulter-Mitgang',S,'shoulder',0,1,.01);
 check('Boden / Schatten',S,'floor',()=>{floor.visible=grid.visible=S.floor});
 btns([['Pose speichern',()=>dl('pose.json',new Blob([JSON.stringify(hlist.map(h=>({id:h.id,p:h.mesh.position.toArray(),q:h.mesh.quaternion.toArray()})))],{type:'application/json'}))],
  ['Pose laden',()=>{const i=document.createElement('input');i.type='file';i.accept='.json';i.onchange=async()=>{const a=JSON.parse(await i.files[0].text());for(const o of a){const h=H[o.id];if(h){h.mesh.position.fromArray(o.p);h.mesh.quaternion.fromArray(o.q)}}lastHip.p.copy(H.hip.mesh.position);lastHip.q.copy(H.hip.mesh.quaternion);captureHeadRel();dirty=true};i.click()}]]);
 sec('SKELETT');
 check('Skelett zeigen',S.skel,'show');check('Gliedmaßen-Knochen',S.skel,'limbs');
 slider('Wirbel (Anzahl)',S.skel,'count',10,48,1,k=>k==='change'&&vert());
 slider('Wirbelgröße',S.skel,'size',.012,.034,.001,k=>k==='input'&&vert());
 slider('Verjüngung',S.skel,'taper',0,1.5,.01,k=>k==='input'&&vert());
 slider('Wirbel nach hinten',S.skel,'back',0,.07,.001,k=>k==='input'&&vert());
 sec('DORNEN');
 check('Dornen zeigen',S.spike,'show');
 slider('Länge',S.spike,'len',.02,.7,.005,spk);slider('Dicke',S.spike,'thick',.002,.035,.001,spk);
 slider('Verjüngung',S.spike,'taper',0,1,.01,spk);slider('Spitze rund',S.spike,'round',0,1,.01,spk);
 slider('Krümmung',S.spike,'bend',-1,1,.01,spk);slider('Neigung (°)',S.spike,'pitch',-40,70,1,spk);
 slider('Größenabhängigkeit',S.spike,'sizeDep',0,1.5,.01,spk);
 slider('jeder n-te Wirbel',S.spike,'every',1,4,1,spk);slider('ab (Becken→Nacken)',S.spike,'from',0,1,.01,spk);slider('bis',S.spike,'to',0,1,.01,spk);
 sec('MATERIAL');
 sub('Körper');check('Körper zeigen',S.body,'show',applyShellVis);
 color('Farbe',S.body,'color',()=>bodyMat.color.set(S.body.color));
 slider('Rauheit',S.body,'rough',0,1,.01,()=>bodyMat.roughness=S.body.rough);slider('Metall',S.body,'metal',0,1,.01,()=>bodyMat.metalness=S.body.metal);
 slider('Clearcoat',S.body,'clear',0,1,.01,()=>bodyMat.clearcoat=S.body.clear);
 slider('Deckkraft (Röntgen)',S.body,'opacity',.05,1,.01,()=>{bodyMat.opacity=S.body.opacity;bodyMat.transparent=S.body.opacity<1;bodyMat.depthWrite=S.body.opacity>=.999;bodyMat.needsUpdate=true});
 sub('Skelett');color('Farbe',S.skin,'color',()=>skelMat.color.set(S.skin.color));
 slider('Rauheit',S.skin,'rough',0,1,.01,()=>skelMat.roughness=S.skin.rough);slider('Metall',S.skin,'metal',0,1,.01,()=>skelMat.metalness=S.skin.metal);
 color('Stangen',S.skin,'rod',()=>rodMat.color.set(S.skin.rod));
 sec('SHELLS');
 check('Shells aktiv',S.shell,'on',()=>{if(S.shell.on&&!shellMesh)buildShells();applyShellVis();applyCamo()});
 slider('Explodieren',S.shell,'explode',0,1,.005);
 slider('Shell-Größe',S.shell,'size',.07,.3,.005,k=>k==='change'&&shellMesh&&buildShells());
 slider('Spalt',S.shell,'gap',0,.03,.001,k=>k==='change'&&shellMesh&&buildShells());
 slider('Dicke',S.shell,'thick',.002,.02,.001,k=>k==='change'&&shellMesh&&buildShells());
 slider('Organik',S.shell,'organic',0,.06,.001,k=>k==='change'&&shellMesh&&buildShells());
 btns([['Neu würfeln',()=>{S.shell.seed=Math.floor(Math.random()*9999);if(shellMesh)buildShells()}]]);
 slider('Stangen-Dicke',S.shell,'rod',.002,.012,.0005);
 check('Camouflage',S.shell,'camo',applyCamo);slider('Muster-Maßstab',S.shell,'scale',1,20,.5,applyCamo);
 S.shell.colors.forEach((c,i)=>{const o={c};const r=document.createElement('div');color('Tarn '+(i+1),S.shell.colors,i,applyCamo)});
}
function applyCamo(){U.uCamo.value=S.shell.camo&&S.shell.on?1:0;U.uCamoScale.value=S.shell.scale;S.shell.colors.forEach((c,i)=>U.uC.value[i].set(c))}

/* ---------- Start ---------- */
async function start(buf){
 try{const gltf=await new Promise((res,rej)=>new GLTFLoader().parse(buf,'',res,rej));
  if(!setupModel(gltf))return;
  setupGroups();buildRig();buildSkeleton();lastHip.p.copy(H.hip.mesh.position);
  buildUI();applyCamo();rebuildVertebrae();rebuildSpikeMeshes();
  $('drop').classList.remove('show');status('bereit · '+bones.length+' Bones');dirty=true;loop()}
 catch(e){console.error(e);status('Fehler: '+e.message)}}
let started=false;
async function tryLoad(){
 for(const n of ['smoothed_body_for_MIMICRY_2.glb','smoothed_body.glb','body.glb']){try{const r=await fetch(n);if(r.ok){started=true;return start(await r.arrayBuffer())}}catch(e){}}
 $('drop').classList.add('show');status('GLB wählen')}
const fileIn=async f=>{if(!f||started)return;started=true;start(await f.arrayBuffer())};
$('file').onchange=e=>fileIn(e.target.files[0]);
addEventListener('dragover',e=>e.preventDefault());addEventListener('drop',e=>{e.preventDefault();fileIn(e.dataTransfer.files[0])});
function loop(){renderer.setAnimationLoop(()=>{
 if(rebuildVert)rebuildVertebrae();if(rebuildSpikes)rebuildSpikeMeshes();
 if(dirty||tc.dragging){dirty=false;solve();updateSkeleton();updateShells()}
 orbit.update();renderer.render(scene,camera)})}
tryLoad();
window.__app={markDirty:()=>{dirty=true},S,H,hlist,bn:()=>bn,select,resetPose,buildShells,camera,orbit,scene,renderer,solve,get shells(){return shells}};
</script></body></html>
Assets
smoothed_body_for_MIMICRY_2.glb  (998 KB)