ZAPFEN_HULL

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/2c6cb4232caae68f/" sandbox="allow-scripts allow-forms allow-modals" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ZAPFEN</title>
<style>
  html,body{margin:0;height:100%;background:#fff;color:#222;font:12px/1.4 -apple-system,"Helvetica Neue",Arial,sans-serif;overflow:hidden}
  canvas{display:block;touch-action:none}
  #ui{position:fixed;top:16px;left:16px;width:216px;display:flex;flex-direction:column;gap:14px;max-height:calc(100% - 60px);overflow:auto;background:#fff}
  #ui h1{font-size:13px;font-weight:600;margin:0;letter-spacing:.02em}
  .row{display:flex;gap:6px;flex-wrap:wrap}
  button,label.btn{font:inherit;color:#222;background:#fff;border:1px solid #d8d8d8;border-radius:3px;padding:5px 9px;cursor:pointer}
  button:hover,label.btn:hover{border-color:#222}
  button.on{background:#222;color:#fff;border-color:#222}
  .sl{display:grid;grid-template-columns:1fr auto;gap:2px 8px;align-items:center}
  .sl span{color:#666}
  .sl output{color:#999;font-variant-numeric:tabular-nums}
  .sl input{grid-column:1/3;width:100%;margin:0;accent-color:#222}
  .chk{display:flex;gap:6px;align-items:center;color:#666}
  .chk input{accent-color:#222;margin:0}
  select{font:inherit;border:1px solid #d8d8d8;border-radius:3px;padding:4px;background:#fff;color:#222;width:100%}
  #status{position:fixed;left:16px;bottom:14px;color:#999;pointer-events:none}
  #drop{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(255,255,255,.9);color:#222;font-size:14px;z-index:5}
  .sep{height:1px;background:#eee}
</style>
</head>
<body>
<div id="ui">
  <h1>ZAPFEN</h1>
  <div class="row">
    <button id="bPaint" class="on">Malen</button>
    <button id="bWeather">Wetter</button>
    <button id="bPose">Pose</button>
  </div>
  <div id="paintSl" style="display:flex;flex-direction:column;gap:8px"></div>
  <div class="row">
    <button id="bFill">Füllen</button>
    <button id="bClear">Leeren</button>
  </div>
  <div class="row">
    <button id="bUpdate" class="on">Outfit updaten</button>
  </div>
  <div class="sep"></div>
  <div class="row">
    <button id="bResetPose">Pose zurücksetzen</button>
  </div>
  <label class="chk"><input type="checkbox" id="mirror" checked> Pose spiegeln</label>
  <div class="sep"></div>
  <div style="display:flex;flex-direction:column;gap:6px">
    <select id="shape">
      <option value="leaf">Form: Lamelle (detailliert)</option>
      <option value="rhomb">Form: Raute</option>
      <option value="pyr">Form: Pyramide</option>
      <option value="tet">Form: Tetraeder</option>
      <option value="wedge">Form: Keil</option>
    </select>
  </div>
  <div id="sliders" style="display:flex;flex-direction:column;gap:8px"></div>
  <div class="sep"></div>
  <div id="lightSl" style="display:flex;flex-direction:column;gap:8px"></div>
  <label class="chk"><input type="checkbox" id="shadows" checked> Schatten</label>
  <label class="chk"><input type="checkbox" id="real"> real: feucht schließt, trocken öffnet</label>
  <label class="chk"><input type="checkbox" id="showBody" checked> Körper zeigen</label>
  <label class="chk"><input type="checkbox" id="showOutfit" checked> Outfit zeigen</label>
  <div class="row">
    <label class="btn">GLB / FBX laden<input id="file" type="file" accept=".glb,.gltf,.fbx" hidden></label>
  </div>
</div>
<div id="status"></div>
<div id="drop">GLB / FBX hier ablegen</div>

<script type="importmap">
{"imports":{
  "three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
  "three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import * as BGU from 'three/addons/utils/BufferGeometryUtils.js';

/* ---------- Perlin 3D ---------- */
const perm = new Uint8Array(512);
{ const p=[...Array(256).keys()]; let s=1337; const rnd=()=>(s=(s*16807)%2147483647)/2147483647;
  for(let i=255;i>0;i--){const j=Math.floor(rnd()*(i+1));[p[i],p[j]]=[p[j],p[i]];}
  for(let i=0;i<512;i++) perm[i]=p[i&255]; }
const fade=t=>t*t*t*(t*(t*6-15)+10);
const lerp1=(a,b,t)=>a+t*(b-a);
function grad(h,x,y,z){h&=15;const u=h<8?x:y,v=h<4?y:(h===12||h===14?x:z);return((h&1)?-u:u)+((h&2)?-v:v);}
function perlin(x,y,z){
  const X=Math.floor(x)&255,Y=Math.floor(y)&255,Z=Math.floor(z)&255;
  x-=Math.floor(x);y-=Math.floor(y);z-=Math.floor(z);
  const u=fade(x),v=fade(y),w=fade(z);
  const A=perm[X]+Y,AA=perm[A]+Z,AB=perm[A+1]+Z,B=perm[X+1]+Y,BA=perm[B]+Z,BB=perm[B+1]+Z;
  return lerp1(
    lerp1(lerp1(grad(perm[AA],x,y,z),grad(perm[BA],x-1,y,z),u),lerp1(grad(perm[AB],x,y-1,z),grad(perm[BB],x-1,y-1,z),u),v),
    lerp1(lerp1(grad(perm[AA+1],x,y,z-1),grad(perm[BA+1],x-1,y,z-1),u),lerp1(grad(perm[AB+1],x,y-1,z-1),grad(perm[BB+1],x-1,y-1,z-1),u),v),w);
}
const fbm=(x,y,z)=>perlin(x,y,z)*0.72+perlin(x*2.03+31.4,y*2.03+17.2,z*2.03+5.9)*0.28;
const smooth=(a,b,x)=>{const t=Math.min(1,Math.max(0,(x-a)/(b-a)));return t*t*(3-2*t);};
const hash=x=>{const s=Math.sin(x*78.233+1.7)*43758.5453;return s-Math.floor(s);};

/* ---------- Parameter + UI ---------- */
const P = { value:1.0, brush:0.10, count:6000, size:1.6, angle:75, scale:1.2, speed:0.25, amp:0.5, base:0.35, mouseR:0.22,
            lightAz:40, lightEl:55, lightI:2.4 };
function addSliders(container, defs){
  for (const [label,key,min,max,step,onChange,onInput] of defs) {
    const d=document.createElement('div'); d.className='sl';
    d.innerHTML=`<span>${label}</span><output></output><input type="range" min="${min}" max="${max}" step="${step}" value="${P[key]}">`;
    const inp=d.querySelector('input'), out=d.querySelector('output');
    const show=()=>out.textContent=(+inp.value).toFixed(step<1?2:0);
    show();
    inp.addEventListener('input',()=>{P[key]=+inp.value;show();applyUniforms();updateLight();});
    if(onChange) inp.addEventListener('change',()=>regenerate());
    container.appendChild(d);
  }
}
const $=id=>document.getElementById(id);
addSliders($('paintSl'),[
  ['Pinsel: Grauwert','value',0,1,0.01],
  ['Pinsel: Radius','brush',0.03,0.3,0.005],
]);
addSliders($('sliders'),[
  ['Max. Dichte','count',1500,12000,100,true],
  ['Größe','size',0.5,4,0.05],
  ['Öffnungswinkel','angle',20,100,1],
  ['Feld: Skala','scale',0.3,4,0.05],
  ['Feld: Tempo','speed',0,1.5,0.01],
  ['Feld: Stärke','amp',0,1,0.01],
  ['Grundfeuchte','base',0,1,0.01],
  ['Maus-Radius','mouseR',0.05,0.6,0.01],
]);
addSliders($('lightSl'),[
  ['Licht: Richtung','lightAz',0,360,1],
  ['Licht: Höhe','lightEl',5,90,1],
  ['Licht: Stärke','lightI',0,5,0.05],
]);
const statusEl=$('status');
let mode='paint';
const MODE_BTN={paint:'bPaint',weather:'bWeather',pose:'bPose'};
function setMode(m){
  mode=m;
  for(const k in MODE_BTN) $(MODE_BTN[k]).classList.toggle('on',k===m);
  poseVis();
}
$('bPaint').onclick=()=>setMode('paint');
$('bWeather').onclick=()=>setMode('weather');
$('bPose').onclick=()=>setMode('pose');
addEventListener('keydown',e=>{
  if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT') return;
  const k=e.key.toLowerCase();
  if(k==='p')setMode('paint'); if(k==='w')setMode('weather'); if(k==='o')setMode('pose'); if(k==='u')updateOutfit();
});
$('shape').onchange=()=>applyShape();
$('showBody').onchange=()=>{bodyMesh.visible=$('showBody').checked;};
$('showOutfit').onchange=()=>{if(instMesh)instMesh.visible=$('showOutfit').checked;};
$('bUpdate').onclick=()=>updateOutfit();
$('mirror').onchange=()=>poseVis();
$('bResetPose').onclick=()=>resetPose();
$('shadows').onchange=()=>{dl.castShadow=$('shadows').checked;};

/* ---------- Szene ---------- */
const renderer=new THREE.WebGLRenderer({antialias:true,preserveDrawingBuffer:false});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setSize(innerWidth,innerHeight);
renderer.setClearColor(0xffffff);
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const canvas=renderer.domElement;

const scene=new THREE.Scene();
const camera=new THREE.PerspectiveCamera(35,innerWidth/innerHeight,0.05,50);
camera.position.set(0,1.0,4.6);

/* Licht: Hemisphäre als Grundhelligkeit + ein gerichtetes Licht mit Schatten */
scene.add(new THREE.HemisphereLight(0xffffff,0xcfcfcf,0.9));
const dl=new THREE.DirectionalLight(0xffffff,P.lightI);
dl.castShadow=true;
dl.shadow.mapSize.set(2048,2048);
const sc=dl.shadow.camera; sc.left=-1.3;sc.right=1.3;sc.top=1.3;sc.bottom=-1.3;sc.near=1;sc.far=9;
dl.shadow.bias=-0.0006; dl.shadow.normalBias=0.004;
dl.target.position.set(0,0.9,0);
scene.add(dl,dl.target);
function updateLight(){
  const az=P.lightAz*Math.PI/180, el=P.lightEl*Math.PI/180;
  const d=new THREE.Vector3(Math.sin(az)*Math.cos(el),Math.sin(el),Math.cos(az)*Math.cos(el));
  dl.position.copy(dl.target.position).addScaledVector(d,5);
  dl.intensity=P.lightI;
}
updateLight();

/* Events vor den OrbitControls registrieren, damit Malen/Posen die Kamera abschalten kann */
const ndc=new THREE.Vector2(); let pointerIn=false, painting=false, shiftHeld=false, drag=null;
const rc=new THREE.Raycaster();
function setNDC(e){const r=canvas.getBoundingClientRect();ndc.set(((e.clientX-r.left)/r.width)*2-1,-((e.clientY-r.top)/r.height)*2+1);}
function pickBody(){rc.setFromCamera(ndc,camera);return bodyMesh.visible&&bodyGeo?rc.intersectObject(bodyMesh,false)[0]:null;}
function pickHandle(){
  rc.setFromCamera(ndc,camera);
  const vis=handles.filter(h=>h.mesh.visible).map(h=>h.mesh);
  const r=rc.intersectObjects(vis,false)[0];
  return r?handles.find(h=>h.mesh===r.object):null;
}
canvas.addEventListener('pointerdown',e=>{
  setNDC(e); pointerIn=true; shiftHeld=e.shiftKey;
  if(e.button!==0) return;
  if(mode==='paint'){
    const h=pickBody();
    if(h){painting=true;controls.enabled=false;canvas.setPointerCapture(e.pointerId);paintAt(h.point,h.face.normal,shiftHeld,0.35);}
  }else if(mode==='pose'){
    const h=pickHandle();
    if(h){drag={h,x:e.clientX,y:e.clientY,off:new THREE.Vector3()};
      const c=jointPos(h.j,new THREE.Vector3()), q=planePoint(c); if(q) drag.off.copy(q).sub(c);
      controls.enabled=false;canvas.setPointerCapture(e.pointerId);}
  }
});
canvas.addEventListener('pointermove',e=>{
  setNDC(e);pointerIn=true;shiftHeld=e.shiftKey;
  if(drag){
    const dx=e.clientX-drag.x, dy=e.clientY-drag.y; drag.x=e.clientX; drag.y=e.clientY;
    poseDrag(drag,dx,e.shiftKey);
  }
});
const endDrag=()=>{ if(painting||drag){painting=false;drag=null;controls.enabled=true;} };
canvas.addEventListener('pointerup',endDrag);
canvas.addEventListener('pointercancel',endDrag);
canvas.addEventListener('pointerleave',()=>{if(!painting&&!drag)pointerIn=false;});

const controls=new OrbitControls(camera,canvas);
controls.target.set(0,0.9,0);
controls.enableDamping=true;
controls.update();

/* ---------- GLB-Parser (Skelett + Skinning bleiben unter eigener Kontrolle) ---------- */
function parseGLB(buf){
  const dv=new DataView(buf);
  if(dv.byteLength<20||dv.getUint32(0,true)!==0x46546C67) return null;
  const jl=dv.getUint32(12,true);
  const json=JSON.parse(new TextDecoder().decode(new Uint8Array(buf,20,jl)));
  const bin=buf.slice(20+jl+8);
  const CT={5120:Int8Array,5121:Uint8Array,5122:Int16Array,5123:Uint16Array,5125:Uint32Array,5126:Float32Array};
  const NC={SCALAR:1,VEC2:2,VEC3:3,VEC4:4,MAT4:16};
  const read=ai=>{
    const a=json.accessors[ai], bv=json.bufferViews[a.bufferView], T=CT[a.componentType], nc=NC[a.type];
    const off=(bv.byteOffset||0)+(a.byteOffset||0), es=T.BYTES_PER_ELEMENT*nc, stride=bv.byteStride||es;
    let out;
    if(stride===es&&off%T.BYTES_PER_ELEMENT===0) out=new T(bin.slice(off,off+a.count*es));
    else { out=new T(a.count*nc); for(let i=0;i<a.count;i++) out.set(new T(bin.slice(off+i*stride,off+i*stride+es)),i*nc); }
    return {data:out,nc,count:a.count,ct:a.componentType};
  };
  const nodes=json.nodes.map((nd,i)=>{
    const o={name:nd.name||('n'+i),children:nd.children||[],parent:-1,t:new THREE.Vector3(),q:new THREE.Quaternion(),s:new THREE.Vector3(1,1,1),
             W:new THREE.Matrix4(),Q:new THREE.Quaternion()};
    if(nd.matrix) new THREE.Matrix4().fromArray(nd.matrix).decompose(o.t,o.q,o.s);
    else { if(nd.translation)o.t.fromArray(nd.translation); if(nd.rotation)o.q.fromArray(nd.rotation); if(nd.scale)o.s.fromArray(nd.scale); }
    return o;
  });
  nodes.forEach((o,i)=>o.children.forEach(c=>{nodes[c].parent=i;}));
  const order=[]; const visit=i=>{order.push(i);nodes[i].children.forEach(visit);};
  nodes.forEach((o,i)=>{if(o.parent<0)visit(i);});

  const skinIdx=json.nodes.findIndex(nd=>nd.mesh!==undefined&&nd.skin!==undefined);
  if(skinIdx<0) return null;
  const skinId=json.nodes[skinIdx].skin, skin=json.skins[skinId];
  const P_=[],N_=[],J_=[],W_=[],I_=[]; let base=0;
  for(const nd of json.nodes){
    if(nd.mesh===undefined||nd.skin!==skinId) continue;
    for(const pr of json.meshes[nd.mesh].primitives){
      if(pr.mode!==undefined&&pr.mode!==4) continue;
      const A=pr.attributes; if(A.POSITION===undefined||A.JOINTS_0===undefined||A.WEIGHTS_0===undefined||pr.indices===undefined) continue;
      const pos=read(A.POSITION).data, nor=A.NORMAL!==undefined?read(A.NORMAL).data:null;
      const jo=read(A.JOINTS_0), we=read(A.WEIGHTS_0), ix=read(pr.indices).data;
      let w=Float32Array.from(we.data);
      if(we.ct===5121) w=w.map(v=>v/255); else if(we.ct===5123) w=w.map(v=>v/65535);
      P_.push(pos); N_.push(nor||new Float32Array(pos.length)); J_.push(Uint16Array.from(jo.data)); W_.push(w);
      I_.push(Uint32Array.from(ix,v=>v+base)); base+=pos.length/3;
    }
  }
  if(!P_.length) return null;
  const cat=(arrs,T)=>{const n=arrs.reduce((s,a)=>s+a.length,0);const o=new T(n);let k=0;for(const a of arrs){o.set(a,k);k+=a.length;}return o;};
  const rawPos=cat(P_,Float32Array), rawNor=cat(N_,Float32Array), joints=cat(J_,Uint16Array), weights=cat(W_,Float32Array), idx=cat(I_,Uint32Array);
  if(rawNor.every(v=>v===0)){
    const g=new THREE.BufferGeometry(); g.setAttribute('position',new THREE.BufferAttribute(rawPos,3)); g.setIndex(new THREE.BufferAttribute(idx,1)); g.computeVertexNormals();
    rawNor.set(g.attributes.normal.array);
  }
  const ibmRaw=read(skin.inverseBindMatrices).data;
  const ibm=skin.joints.map((_,j)=>new THREE.Matrix4().fromArray(ibmRaw,j*16));
  return {n:rawPos.length/3,idx,rawPos,rawNor,joints,weights,nodes,order,skinJoints:skin.joints,ibm};
}

/* ---------- Körper-Zustand ---------- */
const bodyMat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.95,metalness:0});
const bodyMesh=new THREE.Mesh(new THREE.BufferGeometry(),bodyMat);
bodyMesh.castShadow=true; bodyMesh.receiveShadow=true;
scene.add(bodyMesh);
let bodyGeo=null, paint=null, H=1.8, B=null;   // B: Körperdaten (n, idx, restPos, restNor, optional rig)
let dSp=0.02;                                   // mittlerer Punktabstand der Fibonacci-Verteilung

/* --- Rig --- */
const _m=new THREE.Matrix4(), _v=new THREE.Vector3();
function rigUpdateWorld(rig){
  for(const i of rig.order){
    const o=rig.nodes[i]; _m.compose(o.t,o.q,o.s);
    if(o.parent<0){o.W.copy(_m);o.Q.copy(o.q);}
    else{const p=rig.nodes[o.parent];o.W.multiplyMatrices(p.W,_m);o.Q.multiplyQuaternions(p.Q,o.q);}
  }
}
function rigSkinMatrices(rig){
  for(let j=0;j<rig.joints.length;j++){
    _m.multiplyMatrices(rig.nodes[rig.joints[j]].W,rig.ibm[j]); _m.premultiply(rig.N); _m.toArray(rig.skinM,j*16);
  }
}
function skinVertices(outPos,outNor){
  const M=B.rig.skinM, jo=B.joints, we=B.weights, p0=B.rawPos, n0=B.rawNor, n=B.n;
  for(let i=0;i<n;i++){
    const x=p0[3*i],y=p0[3*i+1],z=p0[3*i+2], nx=n0[3*i],ny=n0[3*i+1],nz=n0[3*i+2];
    let px=0,py=0,pz=0,qx=0,qy=0,qz=0;
    for(let k=0;k<4;k++){
      const w=we[4*i+k]; if(w===0) continue; const o=jo[4*i+k]*16;
      px+=w*(M[o]*x+M[o+4]*y+M[o+8]*z+M[o+12]);
      py+=w*(M[o+1]*x+M[o+5]*y+M[o+9]*z+M[o+13]);
      pz+=w*(M[o+2]*x+M[o+6]*y+M[o+10]*z+M[o+14]);
      qx+=w*(M[o]*nx+M[o+4]*ny+M[o+8]*nz);
      qy+=w*(M[o+1]*nx+M[o+5]*ny+M[o+9]*nz);
      qz+=w*(M[o+2]*nx+M[o+6]*ny+M[o+10]*nz);
    }
    outPos[3*i]=px;outPos[3*i+1]=py;outPos[3*i+2]=pz;
    const l=Math.hypot(qx,qy,qz)||1; outNor[3*i]=qx/l;outNor[3*i+1]=qy/l;outNor[3*i+2]=qz/l;
  }
}
function jointPos(j,out){ const rig=B.rig; return out.setFromMatrixPosition(rig.nodes[rig.joints[j]].W).applyMatrix4(rig.N); }

function setSkinnedBody(pg){
  const rig={nodes:pg.nodes,order:pg.order,joints:pg.skinJoints,ibm:pg.ibm,N:new THREE.Matrix4(),skinM:new Float32Array(pg.skinJoints.length*16)};
  rig.nodes.forEach(o=>{o.t0=o.t.clone();o.q0=o.q.clone();o.s0=o.s.clone();});
  rig.jointOfNode=new Map(rig.joints.map((nd,j)=>[nd,j]));
  rig.jParent=rig.joints.map(nd=>{let p=rig.nodes[nd].parent;while(p>=0&&!rig.jointOfNode.has(p))p=rig.nodes[p].parent;return p<0?-1:rig.jointOfNode.get(p);});
  rig.jChildren=rig.joints.map(()=>[]);
  rig.jParent.forEach((p,j)=>{if(p>=0)rig.jChildren[p].push(j);});
  B={n:pg.n,idx:pg.idx,rawPos:pg.rawPos,rawNor:pg.rawNor,joints:pg.joints,weights:pg.weights,rig,restPos:new Float32Array(pg.n*3),restNor:new Float32Array(pg.n*3)};
  // Ruhepose = Pose in der Datei; auf 1,8 m normieren und auf den Boden stellen
  rigUpdateWorld(rig); rig.N.identity(); rigSkinMatrices(rig);
  const tp=new Float32Array(pg.n*3), tn=new Float32Array(pg.n*3); skinVertices(tp,tn);
  let mnx=1e9,mny=1e9,mnz=1e9,mxx=-1e9,mxy=-1e9,mxz=-1e9;
  for(let i=0;i<pg.n;i++){const x=tp[3*i],y=tp[3*i+1],z=tp[3*i+2];
    if(x<mnx)mnx=x;if(x>mxx)mxx=x;if(y<mny)mny=y;if(y>mxy)mxy=y;if(z<mnz)mnz=z;if(z>mxz)mxz=z;}
  const s=1.8/(mxy-mny);
  rig.N.makeScale(s,s,s).multiply(new THREE.Matrix4().makeTranslation(-(mnx+mxx)/2,-mny,-(mnz+mxz)/2));
  rigSkinMatrices(rig); skinVertices(B.restPos,B.restNor);
  rig.restJoint=rig.joints.map((_,j)=>jointPos(j,new THREE.Vector3()));
  finishBody();
}
function setStaticBody(raw){
  raw.computeBoundingBox();
  const bb=raw.boundingBox, s=1.8/(bb.max.y-bb.min.y);
  raw.translate(-(bb.max.x+bb.min.x)/2,-bb.min.y,-(bb.max.z+bb.min.z)/2); raw.scale(s,s,s);
  const g=BGU.mergeVertices(raw,1e-5); g.computeVertexNormals();
  B={n:g.attributes.position.count,idx:Uint32Array.from(g.index.array),rig:null,
     restPos:Float32Array.from(g.attributes.position.array),restNor:Float32Array.from(g.attributes.normal.array)};
  finishBody();
}
function finishBody(){
  const n=B.n;
  paint=new Float32Array(n);                                    // alles 0: noch keine Lamellen
  const g=new THREE.BufferGeometry();
  g.setAttribute('position',new THREE.BufferAttribute(Float32Array.from(B.restPos),3));
  g.setAttribute('normal',new THREE.BufferAttribute(Float32Array.from(B.restNor),3));
  g.setAttribute('color',new THREE.BufferAttribute(new Float32Array(n*3),3));
  g.setIndex(new THREE.BufferAttribute(B.idx,1));
  g.computeBoundingSphere();
  bodyMesh.geometry.dispose(); bodyMesh.geometry=g; bodyGeo=g;
  H=1.8; updateColors();
  buildRigUI();
}

/* Grauwert v → Anzeige: dunkel = nichts, hell = große/dichte Lamellen */
function updateColors(){
  const c=bodyGeo.attributes.color.array;
  for(let i=0;i<paint.length;i++){
    const g=Math.pow(0.22+0.78*paint[i],2.2);
    c[i*3]=c[i*3+1]=c[i*3+2]=g;
  }
  bodyGeo.attributes.color.needsUpdate=true;
}
function paintAt(pt,hn,erase,strength){
  const r=P.brush, r2=r*r, val=erase?0:P.value;
  const pos=bodyGeo.attributes.position.array, nor=bodyGeo.attributes.normal.array, n=paint.length;
  const k0=Math.min(1,strength);
  for(let i=0;i<n;i++){
    const dx=pos[i*3]-pt.x,dy=pos[i*3+1]-pt.y,dz=pos[i*3+2]-pt.z,d2=dx*dx+dy*dy+dz*dz;
    if(d2>=r2) continue;
    if(nor[i*3]*hn.x+nor[i*3+1]*hn.y+nor[i*3+2]*hn.z<0.15) continue;
    let f=1-Math.sqrt(d2)/r; f=f*f*(3-2*f);
    paint[i]+=(val-paint[i])*f*k0;
  }
  updateColors();
}
$('bFill').onclick=()=>{paint.fill(P.value);updateColors();};
$('bClear').onclick=()=>{paint.fill(0);updateColors();};

/* ---------- Pose ---------- */
let handles=[], skelLines=null, skelPairs=[];
const handleGroup=new THREE.Group(); scene.add(handleGroup);
const HCOL=0x6b6f75, HCOL_ON=0x111111;
const shortName=s=>s.replace(/^.*:/,'');
function mirrorName(s){return s.replace(/Left/,'§').replace(/Right/,'Left').replace('§','Right');}

function buildRigUI(){
  for(const h of handles){h.mesh.geometry.dispose();h.mesh.material.dispose();}
  handleGroup.clear(); handles=[]; skelPairs=[];
  if(skelLines){skelLines.geometry.dispose();skelLines=null;}
  if(!B.rig){ poseVis(); return; }
  const rig=B.rig, geo=new THREE.SphereGeometry(0.02,14,10);
  rig.joints.forEach((nd,j)=>{
    const sn=shortName(rig.nodes[nd].name), pj=rig.jParent[j];
    if(pj<0) return;
    const finger=/Hand(Thumb|Index|Ring|Pinky)|HandMiddle[2-4]/.test(sn)||/_End$/.test(sn)&&!/HeadTop/.test(sn);
    if(!finger) skelPairs.push([pj,j]);
    if(finger||/^(Left|Right)(UpLeg|Shoulder)$/.test(sn)) return;
    const mesh=new THREE.Mesh(geo,new THREE.MeshBasicMaterial({color:HCOL,depthTest:false,transparent:true,opacity:0.95}));
    mesh.renderOrder=20; handleGroup.add(mesh);
    handles.push({mesh,j,pj,name:sn,side:/Left/.test(sn)?'L':/Right/.test(sn)?'R':'C',partner:null});
  });
  for(const h of handles){
    if(h.side==='C') continue;
    const full=rig.nodes[rig.joints[h.j]].name, pn=mirrorName(full);
    const pi=rig.nodes.findIndex(o=>o.name===pn);
    if(pi>=0&&rig.jointOfNode.has(pi)) h.partner=handles.find(o=>o.j===rig.jointOfNode.get(pi))||null;
  }
  const lg=new THREE.BufferGeometry();
  lg.setAttribute('position',new THREE.BufferAttribute(new Float32Array(skelPairs.length*6),3));
  skelLines=new THREE.LineSegments(lg,new THREE.LineBasicMaterial({color:0x8a8e94,depthTest:false,transparent:true,opacity:0.9}));
  skelLines.renderOrder=19; skelLines.frustumCulled=false; handleGroup.add(skelLines);
  updateRigUI(); poseVis();
}
function updateRigUI(){
  if(!B||!B.rig) return;
  const a=new THREE.Vector3();
  for(const h of handles) h.mesh.position.copy(jointPos(h.j,a));
  if(skelLines){
    const arr=skelLines.geometry.attributes.position.array;
    skelPairs.forEach(([p,c],i)=>{ jointPos(p,a); arr.set([a.x,a.y,a.z],i*6); jointPos(c,a); arr.set([a.x,a.y,a.z],i*6+3); });
    skelLines.geometry.attributes.position.needsUpdate=true;
  }
}
function poseVis(){
  const on=mode==='pose'&&B&&B.rig, mir=$('mirror').checked;
  handleGroup.visible=!!on;
  for(const h of handles) h.mesh.visible=!(mir&&h.side==='R');
}
const isCentral=h=>h.side==='C';
function rotateNode(ni,dq){
  const rig=B.rig, o=rig.nodes[ni];
  const worldNew=dq.clone().multiply(o.Q);
  const pq=o.parent<0?new THREE.Quaternion():rig.nodes[o.parent].Q.clone().invert();
  o.q.copy(pq.multiply(worldNew)).normalize();
}
/* Schnittpunkt des Mausstrahls mit der Ebene durch den Punkt c, senkrecht zur Blickrichtung */
function planePoint(c){
  rc.setFromCamera(ndc,camera);
  const pl=new THREE.Plane().setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()),c);
  return rc.ray.intersectPlane(pl,new THREE.Vector3());
}
function poseDrag(dr,dx,shift){
  if(!B||!B.rig) return;
  const h=dr.h, rig=B.rig, mir=$('mirror').checked;
  const pPos=jointPos(h.pj,new THREE.Vector3()), cPos=jointPos(h.j,new THREE.Vector3());
  let dq=new THREE.Quaternion();
  if(shift){                                       // Verdrehen um die eigene Achse
    if(mir&&isCentral(h)) return;                  // würde die Symmetrie der Mittelachse brechen
    dq.setFromAxisAngle(cPos.clone().sub(pPos).normalize(),dx*0.01);
  }else{                                           // Gelenkpunkt folgt der Maus: Elternglied dreht um sein eigenes Gelenk
    const q=planePoint(cPos); if(!q) return;
    const d0=cPos.clone().sub(pPos), d1=q.sub(dr.off).sub(pPos);
    if(d0.lengthSq()<1e-8||d1.lengthSq()<1e-8) return;
    dq.setFromUnitVectors(d0.normalize(),d1.normalize());
    const ang=2*Math.acos(Math.min(1,Math.abs(dq.w)));
    if(ang>0.6){ const k=0.6/ang; dq.slerp(new THREE.Quaternion(),1-k); }   // pro Schritt begrenzen
    if(mir&&isCentral(h)){                         // Mittel-Bones: nur Beugen um die X-Achse
      const s=Math.hypot(dq.x,dq.y,dq.z), an=2*Math.atan2(s,dq.w);
      dq.setFromAxisAngle(new THREE.Vector3(1,0,0),s>1e-9?an*dq.x/s:0);
    }
  }
  rotateNode(rig.joints[h.pj],dq);
  if(mir&&h.partner) rotateNode(rig.joints[h.partner.pj],new THREE.Quaternion(dq.x,-dq.y,-dq.z,dq.w));
  applyPose();
}
function applyPose(){
  const rig=B.rig;
  rigUpdateWorld(rig); rigSkinMatrices(rig);
  const pa=bodyGeo.attributes.position, na=bodyGeo.attributes.normal;
  skinVertices(pa.array,na.array); pa.needsUpdate=true; na.needsUpdate=true;
  bodyGeo.computeBoundingSphere();
  updateRigUI(); refreshInstances();
}
function resetPose(){
  if(!B||!B.rig) return;
  for(const o of B.rig.nodes){o.t.copy(o.t0);o.q.copy(o.q0);o.s.copy(o.s0);}
  applyPose();
}

/* ---------- Formen (x: Breite, y: Länge, z: Höhe über der Haut) ---------- */
const SHAPES={
  pyr(){const a=[-1,0,0],b=[1,0,0],c=[1,1,0],d=[-1,1,0],t=[0,0.55,1];
    return [a,b,c, a,c,d, a,b,t, b,c,t, c,d,t, d,a,t];},
  tet(){const a=[-1,0,0],b=[1,0,0],c=[0,1,0],t=[0,0.3,0.9];
    return [a,b,c, a,b,t, b,c,t, c,a,t];},
  wedge(){const a=[-1,0,0],b=[1,0,0],c=[1,1,0],d=[-1,1,0],r0=[-1,0,0.8],r1=[1,0,0.8];
    return [a,b,c, a,c,d, a,b,r1, a,r1,r0, r0,r1,c, r0,c,d, a,r0,d, b,c,r1];},
  rhomb(){const d0=[0,0,0],d1=[1,0.45,0],d2=[0,1,0],d3=[-1,0.45,0],t=[0,0.45,0.5];
    return [d0,d1,t, d1,d2,t, d2,d3,t, d3,d0,t, d0,d1,d2, d0,d2,d3];},
};
function leafTris(nx=4,ny=8){
  const P0=[],E0=[];
  for(let j=0;j<=ny;j++){
    const y=j/ny;
    let w=Math.sqrt(Math.max(0,1-Math.pow(y,2.6)));
    w*=0.7+0.3*Math.min(1,y*4);
    for(let i=0;i<=nx;i++){ P0.push([(-1+2*i/nx)*w,y,0]); E0.push((i===0||i===nx||j===ny)?1:0); }
  }
  const pos=[],edge=[];
  const push=k=>{pos.push(...P0[k]);edge.push(E0[k]);};
  for(let j=0;j<ny;j++)for(let i=0;i<nx;i++){
    const a=j*(nx+1)+i,b=a+1,c=a+nx+1,d=c+1;
    [a,b,c,b,d,c].forEach(push);
  }
  return {pos,edge};
}
function shapeAttrs(name){
  if(name==='leaf'){
    const {pos,edge}=leafTris();
    return {position:new THREE.Float32BufferAttribute(pos,3),edge:new THREE.Float32BufferAttribute(edge,1)};
  }
  const tris=SHAPES[name]().flat();
  return {position:new THREE.Float32BufferAttribute(tris,3),edge:new THREE.Float32BufferAttribute(new Float32Array(tris.length/3),1)};
}
function setShapeOn(g){
  const name=$('shape').value, a=shapeAttrs(name);
  g.setAttribute('position',a.position); g.setAttribute('aEdge',a.edge);
  U.uBend.value=(name==='leaf')?1:0;
}
function applyShape(){ if(instMesh) setShapeOn(instMesh.geometry); }

/* ---------- Lamellen-Material: Standard-Material (Licht + Schatten), Instanzierung per Shader-Patch ---------- */
const U={uL:{value:0.04},uW:{value:0.02},uTk:{value:0.02},uMax:{value:1.3},uRest:{value:0.12},uBend:{value:1}};
const V_DECL=`
  attribute vec3 aPos; attribute vec3 aN; attribute vec3 aT;
  attribute float aOpen; attribute float aSize; attribute float aRand; attribute float aEdge;
  uniform float uL,uW,uTk,uMax,uRest,uBend;`;
const V_CORE=`
  vec3 Nn=normalize(aN), Tn=normalize(aT), Bn=normalize(cross(Tn,Nn));
  float kk=(0.88+0.24*aRand)*aSize;
  float th=mix(uRest,uMax,aOpen)*(0.92+0.16*aRand);
  float phi=th*mix(1.0,0.45+0.55*position.y,uBend);
  vec3 dirA=Tn*cos(phi)+Nn*sin(phi);
  vec3 pn=Nn*cos(phi)-Tn*sin(phi);
  float cup=-0.22*uW*kk*position.x*position.x*uBend;
  vec3 transformed=aPos+Bn*(position.x*uW*kk)+dirA*(position.y*uL*kk)+pn*(position.z*uTk*kk+cup)+Nn*(0.01+0.05*aRand)*uL*aSize;`;
const lamMat=new THREE.MeshStandardMaterial({color:0xffffff,roughness:0.8,metalness:0,side:THREE.DoubleSide,flatShading:true});
lamMat.onBeforeCompile=sh=>{
  Object.assign(sh.uniforms,U);
  sh.vertexShader=sh.vertexShader
    .replace('#include <common>','#include <common>\n'+V_DECL+'\nvarying vec3 vOutN; varying float vRand; varying float vAO;')
    .replace('#include <begin_vertex>',V_CORE+`
  vOutN=normalize(normalMatrix*pn); vRand=aRand;
  vAO=mix(0.62,1.0,smoothstep(0.0,0.6,position.z*0.8+position.y*0.55));`);
  sh.fragmentShader=sh.fragmentShader
    .replace('#include <common>','#include <common>\nvarying vec3 vOutN; varying float vRand; varying float vAO;')
    .replace('#include <color_fragment>',`#include <color_fragment>
  {
    vec3 vp=-vViewPosition;
    vec3 nf=normalize(cross(dFdx(vp),dFdy(vp)));
    if(dot(nf,vViewPosition)<0.0) nf=-nf;
    float upv=dot(nf,normalize(vOutN));
    float under=smoothstep(0.12,-0.3,upv);              // Unterseite = Seite zum Körper
    vec3 topC=vec3(0.92);
    vec3 undC=vec3(0.045,0.05,0.058);                   // Anthrazit
    diffuseColor.rgb=mix(topC,undC,under)*(0.95+0.05*vRand)*vAO;
  }`);
};
const depthMat=new THREE.MeshDepthMaterial({depthPacking:THREE.RGBADepthPacking});
depthMat.onBeforeCompile=sh=>{
  Object.assign(sh.uniforms,U);
  sh.vertexShader=sh.vertexShader
    .replace('#include <common>','#include <common>\n'+V_DECL)
    .replace('#include <begin_vertex>',V_CORE);
};
function applyUniforms(){
  const L=P.size*dSp*1.25;
  U.uL.value=L; U.uW.value=L*0.5; U.uTk.value=L*0.5; U.uMax.value=P.angle*Math.PI/180;
}

/* ---------- Verteilung: Fibonacci-Spiralen pro Körpersegment ---------- */
const GA=Math.PI*(3-Math.sqrt(5)); // Goldener Winkel
let inst=null, instMesh=null, generating=false;

function buildSegments(){
  const nt=B.idx.length/3, rp=B.restPos, ix=B.idx;
  const area=new Float32Array(nt); let total=0;
  const a=new THREE.Vector3(),b=new THREE.Vector3(),c=new THREE.Vector3();
  for(let t=0;t<nt;t++){
    a.fromArray(rp,3*ix[3*t]); b.fromArray(rp,3*ix[3*t+1]); c.fromArray(rp,3*ix[3*t+2]);
    area[t]=b.sub(a).cross(c.sub(a)).length()*0.5; total+=area[t];
  }
  const segs=[];
  if(!B.rig){
    segs.push({tris:Array.from({length:nt},(_,i)=>i),P0:new THREE.Vector3(),u:new THREE.Vector3(0,1,0),flow:new THREE.Vector3(0,-1,0),name:'körper'});
  }else{
    const rig=B.rig, J=rig.joints.length, jo=B.joints, we=B.weights;
    const lists=Array.from({length:J},()=>[]);
    for(let t=0;t<nt;t++){
      const acc=new Map();
      for(let v=0;v<3;v++){const vi=ix[3*t+v];for(let k=0;k<4;k++){const w=we[4*vi+k];if(w>0){const j=jo[4*vi+k];acc.set(j,(acc.get(j)||0)+w);}}}
      let best=-1,bw=-1; for(const [j,w] of acc) if(w>bw){bw=w;best=j;}
      if(best>=0) lists[best].push(t);
    }
    const jArea=lists.map(l=>l.reduce((s,t)=>s+area[t],0));
    for(let j=0;j<J;j++){
      if(!lists[j].length) continue;
      const P0=rig.restJoint[j], name=shortName(rig.nodes[rig.joints[j]].name);
      let u=null, bestA=-1, ch=null;
      for(const cj of rig.jChildren[j]){
        if(rig.restJoint[cj].distanceTo(P0)<0.015) continue;
        if(jArea[cj]>bestA){bestA=jArea[cj];ch=cj;}
      }
      if(ch!==null) u=rig.restJoint[ch].clone().sub(P0).normalize();
      else if(rig.jParent[j]>=0) u=P0.clone().sub(rig.restJoint[rig.jParent[j]]).normalize();
      else u=new THREE.Vector3(0,1,0);
      const core=/Hips|Spine|Neck|Head/.test(name);
      segs.push({tris:lists[j],P0:P0.clone(),u,flow:core?new THREE.Vector3(0,-1,0):u.clone(),name});
    }
  }
  // Ausdehnung je Segment
  for(const s of segs){
    const seen=new Set(); let t0=1e9,t1=-1e9,rs=0,rmax=0,cnt=0,ar=0;
    for(const t of s.tris){ ar+=area[t];
      for(let v=0;v<3;v++){const vi=ix[3*t+v]; if(seen.has(vi))continue; seen.add(vi);
        a.fromArray(rp,3*vi).sub(s.P0); const tt=a.dot(s.u); const pr=a.addScaledVector(s.u,-tt).length();
        if(tt<t0)t0=tt; if(tt>t1)t1=tt; rs+=pr; cnt++; if(pr>rmax)rmax=pr; }
    }
    s.t0=t0; s.t1=Math.max(t1,t0+1e-3); s.Rmean=rs/Math.max(1,cnt); s.Rmax=rmax; s.area=ar;
  }
  return {segs,total};
}

async function regenerate(){
  if(generating||!bodyGeo) return;
  generating=true;
  statusEl.textContent='verteile Lamellen …';
  await new Promise(r=>setTimeout(r,30));
  const t0=performance.now();
  const {segs,total}=buildSegments();
  const d2=total/P.count; dSp=Math.sqrt(d2);
  const restGeo=new THREE.BufferGeometry();
  restGeo.setAttribute('position',new THREE.BufferAttribute(B.restPos,3));
  const rmat=new THREE.MeshBasicMaterial({side:THREE.DoubleSide});
  const list=[]; let uid=0;
  const rp=B.restPos, rn=B.restNor, ix=B.idx;
  const ray=new THREE.Raycaster();
  const origin=new THREE.Vector3(), dir=new THREE.Vector3(), e1=new THREE.Vector3(), e2=new THREE.Vector3(), rad=new THREE.Vector3();
  const pa=new THREE.Vector3(),pb=new THREE.Vector3(),pc=new THREE.Vector3(),bc=new THREE.Vector3();
  const nT=new THREE.Vector3(), Tt=new THREE.Vector3(), E1=new THREE.Vector3(), E2=new THREE.Vector3(), tmp=new THREE.Vector3();

  function addHit(h,seg){
    const {a,b,c}=h.face;
    pa.fromArray(rp,3*a);pb.fromArray(rp,3*b);pc.fromArray(rp,3*c);
    THREE.Triangle.getBarycoord(h.point,pa,pb,pc,bc);
    if(!isFinite(bc.x+bc.y+bc.z)) return;
    const n=new THREE.Vector3(
      rn[3*a]*bc.x+rn[3*b]*bc.y+rn[3*c]*bc.z,
      rn[3*a+1]*bc.x+rn[3*b+1]*bc.y+rn[3*c+1]*bc.z,
      rn[3*a+2]*bc.x+rn[3*b+2]*bc.y+rn[3*c+2]*bc.z).normalize();
    // Fließrichtung: Kernbereich nach unten, Gliedmaßen vom Körper weg (distal) – wie Dachschindeln
    Tt.copy(seg.flow); Tt.addScaledVector(n,-Tt.dot(n));
    if(Tt.lengthSq()<0.06){ Tt.copy(h.point).sub(seg.P0); Tt.addScaledVector(seg.u,-Tt.dot(seg.u)); Tt.addScaledVector(n,-Tt.dot(n)); }
    if(Tt.lengthSq()<1e-8) Tt.set(1,0,0).addScaledVector(n,-n.x);
    Tt.normalize();
    // Tangente im Dreiecksrahmen speichern, damit sie beim Posen mitwandert
    E1.copy(pb).sub(pa); E2.copy(pc).sub(pa); nT.copy(E1).cross(E2);
    if(nT.lengthSq()<1e-14) return; nT.normalize();
    const g11=E1.dot(E1),g12=E1.dot(E2),g22=E2.dot(E2),det=g11*g22-g12*g12; if(Math.abs(det)<1e-14) return;
    const r1=Tt.dot(E1),r2=Tt.dot(E2);
    list.push({a,b,c,bx:bc.x,by:bc.y,bz:bc.z,al:(r1*g22-r2*g12)/det,be:(r2*g11-r1*g12)/det,ga:Tt.dot(nT),id:uid++});
  }

  let si=0;
  for(const seg of segs){
    if(++si%6===0){ statusEl.textContent=`verteile Lamellen … ${si}/${segs.length}`; await new Promise(r=>setTimeout(r,0)); }
    const len=seg.t1-seg.t0, Rb=seg.Rmax*1.6+0.15;
    // Teilgeometrie nur dieses Segments: Strahlen sehen keine fremden Körperteile
    const sub=new THREE.BufferGeometry();
    sub.setAttribute('position',restGeo.attributes.position);
    const ia=new Uint32Array(seg.tris.length*3);
    seg.tris.forEach((t,k)=>{ia[3*k]=ix[3*t];ia[3*k+1]=ix[3*t+1];ia[3*k+2]=ix[3*t+2];});
    sub.setIndex(new THREE.BufferAttribute(ia,1));
    const mesh=new THREE.Mesh(sub,rmat);
    e1.set(1,0,0); if(Math.abs(seg.u.x)>0.9) e1.set(0,0,1);
    e1.addScaledVector(seg.u,-e1.dot(seg.u)).normalize(); e2.copy(seg.u).cross(e1);
    const phase=hash(si*3.17)*Math.PI*2;
    const cast=(o,dv,test)=>{
      ray.set(o,dv);
      let depth=0;
      for(const h of ray.intersectObject(mesh,false)){
        const front=h.face.normal.dot(dv)<0;
        if(front){ if(depth===0&&(!test||test(h))) addHit(h,seg); depth++; }
        else depth=Math.max(0,depth-1);
      }
    };
    // Mantel: Fibonacci-Spirale (Goldener Winkel) entlang der Achse
    const nCyl=Math.round(2*Math.PI*seg.Rmean*len/d2);
    for(let k=0;k<nCyl;k++){
      const t=seg.t0+len*(k+0.5)/nCyl, th=phase+k*GA;
      rad.copy(e1).multiplyScalar(Math.cos(th)).addScaledVector(e2,Math.sin(th));
      origin.copy(seg.P0).addScaledVector(seg.u,t).addScaledVector(rad,Rb);
      dir.copy(rad).negate();
      cast(origin,dir,null);
    }
    // Kappen an beiden Enden: Fibonacci-Kegel, nur achsparallele Flächen zählen
    if(seg.Rmax>1.5*dSp){
      const m=Math.ceil(Math.PI*seg.Rmax*seg.Rmax/d2*1.2), cmin=Math.cos(65*Math.PI/180);
      for(const sg of [-1,1]){
        const E=seg.P0.clone().addScaledVector(seg.u,sg<0?seg.t0:seg.t1), ax=seg.u.clone().multiplyScalar(sg);
        for(let k=0;k<m;k++){
          const cc=1-(1-cmin)*(k+0.5)/m, ss=Math.sqrt(1-cc*cc), th=phase+k*GA;
          tmp.copy(e1).multiplyScalar(Math.cos(th)).addScaledVector(e2,Math.sin(th)).multiplyScalar(ss).addScaledVector(ax,cc);
          origin.copy(E).addScaledVector(tmp,Rb); dir.copy(tmp).negate();
          cast(origin,dir,h=>h.face.normal.dot(ax)>0.5);
        }
      }
    }
    sub.dispose();
  }
  restGeo.dispose(); rmat.dispose();
  buildInstances(list);
  applyUniforms();
  generating=false;
  updateOutfit();
  statusEl.textContent=`${list.length} mögliche Lamellen (${(performance.now()-t0).toFixed(0)} ms) · Malen: ziehen, Shift = radieren · „Outfit updaten“ (U) übernimmt das Gemalte`;
}

function buildInstances(list){
  const n=list.length;
  const g=new THREE.InstancedBufferGeometry();
  setShapeOn(g);
  const aPos=new THREE.InstancedBufferAttribute(new Float32Array(n*3),3).setUsage(THREE.DynamicDrawUsage);
  const aN=new THREE.InstancedBufferAttribute(new Float32Array(n*3),3).setUsage(THREE.DynamicDrawUsage);
  const aT=new THREE.InstancedBufferAttribute(new Float32Array(n*3),3).setUsage(THREE.DynamicDrawUsage);
  const aOpen=new THREE.InstancedBufferAttribute(new Float32Array(n),1).setUsage(THREE.DynamicDrawUsage);
  const aSize=new THREE.InstancedBufferAttribute(new Float32Array(n),1).setUsage(THREE.DynamicDrawUsage);
  const aRand=new THREE.InstancedBufferAttribute(new Float32Array(n),1);
  const tri=new Uint32Array(n*3), bary=new Float32Array(n*3), tc=new Float32Array(n*3), rank=new Float32Array(n);
  list.forEach((it,i)=>{
    aRand.array[i]=hash(i*1.37);
    rank[i]=hash(it.id+0.5);        // Ausdünnschwelle pro Spiralpunkt
    tri[i*3]=it.a;tri[i*3+1]=it.b;tri[i*3+2]=it.c;
    bary[i*3]=it.bx;bary[i*3+1]=it.by;bary[i*3+2]=it.bz;
    tc[i*3]=it.al;tc[i*3+1]=it.be;tc[i*3+2]=it.ga;
  });
  g.setAttribute('aPos',aPos);g.setAttribute('aN',aN);g.setAttribute('aT',aT);
  g.setAttribute('aOpen',aOpen);g.setAttribute('aSize',aSize);g.setAttribute('aRand',aRand);
  g.instanceCount=n;
  if(instMesh){scene.remove(instMesh);instMesh.geometry.dispose();}
  instMesh=new THREE.Mesh(g,lamMat);
  instMesh.customDepthMaterial=depthMat;
  instMesh.castShadow=true; instMesh.receiveShadow=true; instMesh.frustumCulled=false;
  instMesh.visible=$('showOutfit').checked; scene.add(instMesh);
  inst={n,aPos,aN,aT,aOpen,aSize,tri,bary,tc,rank,target:new Float32Array(n),work:new Int32Array(0)};
  refreshInstances();
}

/* Position/Normale/Tangente aller Lamellen aus dem aktuell geposten Körper */
function refreshInstances(){
  if(!inst||!bodyGeo) return;
  const {n,tri,bary,tc,aPos,aN,aT}=inst;
  const P_=bodyGeo.attributes.position.array, N_=bodyGeo.attributes.normal.array;
  const pp=aPos.array, nn=aN.array, tt=aT.array;
  for(let i=0;i<n;i++){
    const a=tri[3*i]*3,b=tri[3*i+1]*3,c=tri[3*i+2]*3, u=bary[3*i],v=bary[3*i+1],w=bary[3*i+2];
    pp[3*i]=P_[a]*u+P_[b]*v+P_[c]*w; pp[3*i+1]=P_[a+1]*u+P_[b+1]*v+P_[c+1]*w; pp[3*i+2]=P_[a+2]*u+P_[b+2]*v+P_[c+2]*w;
    let nx=N_[a]*u+N_[b]*v+N_[c]*w, ny=N_[a+1]*u+N_[b+1]*v+N_[c+1]*w, nz=N_[a+2]*u+N_[b+2]*v+N_[c+2]*w;
    let l=Math.hypot(nx,ny,nz)||1; nx/=l;ny/=l;nz/=l; nn[3*i]=nx;nn[3*i+1]=ny;nn[3*i+2]=nz;
    const e1x=P_[b]-P_[a],e1y=P_[b+1]-P_[a+1],e1z=P_[b+2]-P_[a+2], e2x=P_[c]-P_[a],e2y=P_[c+1]-P_[a+1],e2z=P_[c+2]-P_[a+2];
    let cx=e1y*e2z-e1z*e2y, cy=e1z*e2x-e1x*e2z, cz=e1x*e2y-e1y*e2x; l=Math.hypot(cx,cy,cz)||1; cx/=l;cy/=l;cz/=l;
    const al=tc[3*i],be=tc[3*i+1],ga=tc[3*i+2];
    let tx=al*e1x+be*e2x+ga*cx, ty=al*e1y+be*e2y+ga*cy, tz=al*e1z+be*e2z+ga*cz;
    const d=tx*nx+ty*ny+tz*nz; tx-=d*nx;ty-=d*ny;tz-=d*nz; l=Math.hypot(tx,ty,tz);
    if(l<1e-9){tx=e1x;ty=e1y;tz=e1z;l=Math.hypot(tx,ty,tz)||1;}
    tt[3*i]=tx/l;tt[3*i+1]=ty/l;tt[3*i+2]=tz/l;
  }
  aPos.needsUpdate=true;aN.needsUpdate=true;aT.needsUpdate=true;
}

/* Outfit aus dem aktuellen Paint ableiten — nur auf Knopfdruck */
function updateOutfit(){
  if(!inst||!paint) return;
  const {n,tri,bary,rank,target,aSize}=inst, sz=aSize.array;
  const work=[]; let active=0;
  for(let i=0;i<n;i++){
    const v=bary[i*3]*paint[tri[i*3]]+bary[i*3+1]*paint[tri[i*3+1]]+bary[i*3+2]*paint[tri[i*3+2]];
    const dens=Math.pow(Math.min(1,Math.max(0,(v-0.03)/0.85)),0.8);   // Helligkeit → Packdichte
    const a=smooth(0,0.12,dens-rank[i]);
    const s=a*(0.3+0.7*v);                                              // Helligkeit → Größe
    target[i]=s<0.001?0:s;
    if(target[i]>0) active++;
    if(target[i]>0||sz[i]>0.001) work.push(i);
  }
  inst.work=Int32Array.from(work);
  if(!generating) statusEl.textContent=`${active} Lamellen aktiv`;
}

/* ---------- Cursor-Ring ---------- */
const ringPts=[]; for(let i=0;i<64;i++){const a=i/64*Math.PI*2;ringPts.push(new THREE.Vector3(Math.cos(a),Math.sin(a),0));}
const ring=new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(ringPts),new THREE.LineBasicMaterial({color:0x999999,depthTest:false,transparent:true}));
ring.renderOrder=10; ring.visible=false; scene.add(ring);

/* ---------- Animation ---------- */
const clock=new THREE.Clock(); let time=0;
const wetPt=new THREE.Vector3(); let wetStr=0;
const zAxis=new THREE.Vector3(0,0,1);
let hoverH=null;

function frame(){
  requestAnimationFrame(frame);
  const dt=Math.min(clock.getDelta(),0.05); time+=dt;
  controls.update();

  let hit=null;
  if(mode!=='pose'&&pointerIn&&bodyGeo) hit=pickBody();
  if(hit){
    const rad=mode==='paint'?P.brush:P.mouseR;
    ring.visible=true; ring.position.copy(hit.point);
    ring.quaternion.setFromUnitVectors(zAxis,hit.face.normal);
    ring.scale.setScalar(rad);
    if(mode==='weather'){ if(wetStr<0.03) wetPt.copy(hit.point); else wetPt.lerp(hit.point,1-Math.exp(-14*dt)); }
    if(painting) paintAt(hit.point,hit.face.normal,shiftHeld,dt*8);
  } else ring.visible=false;
  if(mode==='pose'&&!drag){
    const h=pointerIn?pickHandle():null;
    if(h!==hoverH){ if(hoverH)hoverH.mesh.material.color.setHex(HCOL); if(h)h.mesh.material.color.setHex(HCOL_ON); hoverH=h; }
  }
  const wetGoal=(mode==='weather'&&hit)?1:0;
  wetStr+=(wetGoal-wetStr)*(1-Math.exp(-6*dt));

  if(inst){
    const {aPos,aOpen,aSize,target,work}=inst;
    const pa=aPos.array,op=aOpen.array,sz=aSize.array;
    const real=$('real').checked;
    const f=P.scale, drift=time*P.speed;
    const r2=P.mouseR*P.mouseR;
    const gk=1-Math.exp(-5*dt);
    for(let w=0;w<work.length;w++){
      const i=work[w];
      sz[i]+=(target[i]-sz[i])*gk;               // weich ein-/ausblenden nach Update
      if(target[i]===0&&sz[i]<0.001){sz[i]=0;continue;}
      const x=pa[i*3],y=pa[i*3+1],z=pa[i*3+2];
      const nz=fbm(x*f*2+drift*0.9,y*f*2+drift*0.5,z*f*2+drift*0.3);
      let wet=P.base+P.amp*nz*1.6;
      if(wetStr>0.001){
        const dx=x-wetPt.x,dy=y-wetPt.y,dz=z-wetPt.z;
        wet+=wetStr*Math.exp(-(dx*dx+dy*dy+dz*dz)/r2)*0.9;
      }
      let tg=smooth(0.25,0.75,Math.min(1,Math.max(0,wet)));
      if(real) tg=1-tg;
      const cur=op[i], rate=tg>cur?5.5:2.2;
      op[i]=cur+(tg-cur)*(1-Math.exp(-rate*dt));
    }
    aOpen.needsUpdate=true; aSize.needsUpdate=true;
  }
  renderer.render(scene,camera);
}

/* ---------- Körper laden ---------- */
function makeFallbackBody(){
  const parts=[];
  const add=(g,x,y,z,sx=1,sy=1,sz=1,rz=0)=>{
    g.applyMatrix4(new THREE.Matrix4().compose(new THREE.Vector3(x,y,z),new THREE.Quaternion().setFromEuler(new THREE.Euler(0,0,rz)),new THREE.Vector3(sx,sy,sz)));
    for(const k of Object.keys(g.attributes)) if(k!=='position') g.deleteAttribute(k);
    parts.push(g);
  };
  add(new THREE.SphereGeometry(1,56,40),0,1.22,0,0.19,0.29,0.115);
  add(new THREE.SphereGeometry(1,40,28),0,0.90,0,0.17,0.13,0.11);
  add(new THREE.CylinderGeometry(0.05,0.058,0.16,24,2),0,1.53,0);
  add(new THREE.SphereGeometry(1,48,36),0,1.67,0.005,0.095,0.12,0.105);
  for(const s of [-1,1]){
    add(new THREE.CapsuleGeometry(0.078,0.64,10,28),s*0.105,0.40,0);
    add(new THREE.CapsuleGeometry(0.042,0.50,10,24),s*0.30,1.15,0,1,1,1,s*0.12);
    add(new THREE.SphereGeometry(1,24,18),s*0.22,1.40,0,0.065,0.065,0.065);
  }
  return BGU.mergeGeometries(parts);
}
function fallback(){ setStaticBody(makeFallbackBody()); applyUniforms(); regenerate(); }
function staticFromObject(obj,isGltf){
  obj.updateMatrixWorld(true);
  const geos=[];
  obj.traverse(o=>{
    if(!o.isMesh||!o.geometry) return;
    let g=o.geometry.clone();
    if(!(isGltf&&o.isSkinnedMesh)) g.applyMatrix4(o.matrixWorld);
    for(const k of Object.keys(g.attributes)) if(k!=='position') g.deleteAttribute(k);
    g.morphAttributes={};
    if(g.index) g=g.toNonIndexed();
    geos.push(g);
  });
  if(!geos.length){statusEl.textContent='keine Mesh-Geometrie gefunden';return false;}
  setStaticBody(BGU.mergeGeometries(geos)); applyUniforms(); regenerate();
  return true;
}
function loadBuffer(buf,name){
  const ext=name.split('.').pop().toLowerCase();
  if(ext==='glb'){
    let pg=null; try{ pg=parseGLB(buf); }catch(e){ console.error(e); }
    if(pg){ setSkinnedBody(pg); applyUniforms(); regenerate(); return; }
    new GLTFLoader().parse(buf,'',g=>{staticFromObject(g.scene,true);},e=>{console.error(e);statusEl.textContent='Laden fehlgeschlagen';});
  }else if(ext==='fbx'){
    try{ staticFromObject(new FBXLoader().parse(buf,''),false); }catch(e){console.error(e);statusEl.textContent='Laden fehlgeschlagen';}
  }else{
    new GLTFLoader().parse(buf,'',g=>{staticFromObject(g.scene,true);},e=>{console.error(e);statusEl.textContent='Laden fehlgeschlagen';});
  }
}
async function loadFile(file){
  statusEl.textContent='lade '+file.name+' …';
  loadBuffer(await file.arrayBuffer(),file.name);
}
$('file').onchange=e=>{if(e.target.files[0])loadFile(e.target.files[0]);};
addEventListener('dragover',e=>{e.preventDefault();$('drop').style.display='flex';});
addEventListener('dragleave',e=>{if(!e.relatedTarget)$('drop').style.display='none';});
addEventListener('drop',e=>{e.preventDefault();$('drop').style.display='none';const f=e.dataTransfer.files[0];if(f)loadFile(f);});
addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);});

/* ---------- Start: Körper neben der HTML suchen, sonst per Dialog / Drag & Drop ---------- */
async function autoload(){
  for(const name of ['body_minimal.glb','body.glb','model.glb']){
    try{
      const r=await fetch('./'+name); if(!r.ok) continue;
      const buf=await r.arrayBuffer();
      if(buf.byteLength<20) continue;
      statusEl.textContent='lade '+name+' …';
      loadBuffer(buf,name); return;
    }catch(e){}
  }
  statusEl.textContent='kein Körper gefunden – body_minimal.glb neben die HTML legen oder hierher ziehen';
}
frame();
autoload();
</script>
</body>
</html>
Assets
body.glb  (998 KB)