SUPERCUTE_3D_FIGURE_GENERATOR

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/9fa3b4f0dc5c238e/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>BLOB</title>
<style>
  :root{--line:#e8e6e3;--ink:#1b1b1b;--mute:#8a8782}
  *{box-sizing:border-box}
  html,body{margin:0;height:100%}
  body{font:12px/1.4 -apple-system,"Helvetica Neue",Arial,sans-serif;color:var(--ink);background:#fff;overflow:hidden}
  #app{display:grid;grid-template-columns:1fr 300px;height:100vh;height:100dvh}
  #stage{position:relative;min-width:0;touch-action:none;cursor:grab}
  #stage canvas{display:block;width:100%;height:100%}
  #hint{position:absolute;left:16px;bottom:14px;color:var(--mute);font-size:11px;pointer-events:none}
  #top{position:fixed;left:16px;top:16px;z-index:5;display:flex;gap:8px}
  #panel{border-left:1px solid var(--line);overflow:auto;padding:6px 16px 48px;background:#fff}
  h3{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--mute);font-weight:500;margin:22px 0 8px}
  .row{display:grid;grid-template-columns:82px 1fr 34px;gap:8px;align-items:center;margin:4px 0}
  .row .val{color:var(--mute);text-align:right;font-variant-numeric:tabular-nums}
  .chk{display:flex;gap:8px;align-items:center;margin:8px 0;cursor:pointer}
  .chk input{accent-color:#1b1b1b}
  input[type=range]{-webkit-appearance:none;appearance:none;height:16px;background:transparent;width:100%;margin:0}
  input[type=range]::-webkit-slider-runnable-track{height:1px;background:#cfcbc6}
  input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:#fff;border:1px solid #1b1b1b;margin-top:-6px}
  input[type=range]::-moz-range-track{height:1px;background:#cfcbc6}
  input[type=range]::-moz-range-thumb{width:11px;height:11px;border-radius:50%;background:#fff;border:1px solid #1b1b1b}
  .btns{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0;align-items:center}
  button{font:inherit;color:var(--ink);border:1px solid var(--line);background:#fff;padding:5px 11px;border-radius:999px;cursor:pointer}
  button:hover{border-color:#bdb8b2}
  button.on{border-color:var(--ink)}
  button.sw{width:22px;height:22px;padding:0;border:1px solid rgba(0,0,0,.14)}
  button.sw:hover{transform:scale(1.12)}
  input.pick{width:24px;height:24px;border:0;padding:0;background:none;cursor:pointer}
  #top button{padding:8px 16px}
  @media(max-width:760px){
    #app{grid-template-columns:1fr;grid-template-rows:1fr 46vh}
    #panel{border-left:0;border-top:1px solid var(--line)}
  }
</style>
<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>
</head>
<body>
<div id="app">
  <div id="stage"><div id="hint">auf den Körper ziehen = malen · daneben = drehen</div></div>
  <aside id="panel"></aside>
</div>
<div id="top"><button id="rnd">Neu würfeln</button><button id="glb">GLB herunterladen</button></div>

<script type="module">
import * as THREE from 'three';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {GLTFExporter} from 'three/addons/exporters/GLTFExporter.js';

/* ───────────── State ───────────── */
const S={
  sx:.88,sy:1.12,sz:.85,egg:.1,tri:0,flat:.3,wb:1,wm:1,wt:1,
  feet:true,footSize:.26,footSpread:.42,footFwd:.5,
  tail:false,tailType:'rund',tailSize:.42,tailY:-.72,
  hands:false,handSize:.26,handY:-.2,handFwd:.3,
  ears:false,earMode:'hood',earType:'rund',earSize:.3,earSpread:.55,earUp:.8,earW:.22,earInner:true,earCol:'#fbf7f1',
  faceDark:false,faceW:.82,faceH:.52,faceY:.02,faceN:2.4,rim:.05,cheek:.5,
  fDepth:.1,fSoft:.2,lip:.1,over:.05,fold:.5,foldN:16,crease:.015,fRound:.05,fBrow:.025,fCheek:.04,fChin:.03,
  eyeSize:.07,eyeGap:.29,eyeY:.07,eyeStretch:1.15,glint:true,
  socket:true,socketD:.03,
  brows:false,browUp:.085,browLen:1.7,browTilt:-.1,browH:.012,
  mouth:'smile',mouthSize:.08,mouthY:-.12,mouthDepth:.06,
  cm:5.5,base:'#e8b9ad',brush:'#b8483a',tool:'paint',tip:'round',size:20,sym:true,
  pat:'none',patStripe:'h',patScale:12,patSize:.45,patSeed:7,patCols:['#d9c7a0','#9aa57f','#5f6b4f']
};
const PRESETS={
  bohne:{sx:.88,sy:1.12,sz:.85,egg:.1,tri:0,flat:.3,wb:1,wm:1,wt:1,faceY:.02},
  mochi:{sx:1.14,sy:.9,sz:.88,egg:0,tri:.12,flat:.6,wb:1,wm:1,wt:1,faceY:.02},
  tropfen:{sx:.9,sy:1.14,sz:.86,egg:.75,tri:.15,flat:.35,wb:1,wm:1,wt:1,faceY:.02},
  reisball:{sx:1.0,sy:1.02,sz:.84,egg:.25,tri:.4,flat:.5,wb:1,wm:1,wt:1,faceY:.02},
  onigiri:{sx:1.05,sy:.98,sz:.78,egg:0,tri:.75,flat:.7,wb:1,wm:1,wt:1,faceY:.02},
  spitz:{sx:1.08,sy:1.0,sz:.76,egg:.1,tri:1.0,flat:.75,wb:1,wm:1,wt:1,faceY:.02},
  sanduhr:{sx:.92,sy:1.2,sz:.84,egg:0,tri:0,flat:.35,wb:1.12,wm:.7,wt:1.12,faceY:.14},
  erdnuss:{sx:.86,sy:1.22,sz:.8,egg:.05,tri:0,flat:.3,wb:1.1,wm:.78,wt:.98,faceY:.14},
  schneemann:{sx:.9,sy:1.2,sz:.84,egg:0,tri:0,flat:.4,wb:1.3,wm:.8,wt:.78,faceY:.22},
  birne:{sx:.9,sy:1.16,sz:.85,egg:.3,tri:0,flat:.4,wb:1.25,wm:.98,wt:.74,faceY:.12},
  knubbel:{sx:.95,sy:1.1,sz:.85,egg:0,tri:0,flat:.3,wb:.82,wm:.95,wt:1.25,faceY:.05}
};
const rand=(a,b)=>a+Math.random()*(b-a);
const pickOf=a=>a[Math.floor(Math.random()*a.length)];
const mod1=x=>((x%1)+1)%1;

/* ───────────── Renderer / Szene ───────────── */
const stage=document.getElementById('stage');
const renderer=new THREE.WebGLRenderer({antialias:true,preserveDrawingBuffer:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
stage.appendChild(renderer.domElement);
const scene=new THREE.Scene();
scene.background=new THREE.Color('#ffffff');
const camera=new THREE.PerspectiveCamera(34,1,.1,50);
camera.position.set(0,1.2,5.4);
const controls=new OrbitControls(camera,renderer.domElement);
controls.target.set(0,1,0);
controls.enableDamping=true;controls.enablePan=false;
controls.minDistance=2.6;controls.maxDistance=9;
controls.minPolarAngle=.25;controls.maxPolarAngle=Math.PI*.56;

scene.add(new THREE.HemisphereLight(0xffffff,0xece6df,1.1));
const key=new THREE.DirectionalLight(0xfff8f0,1.6);key.position.set(2.5,4,4);scene.add(key);
const rimLight=new THREE.DirectionalLight(0xe8f0ff,.5);rimLight.position.set(-3,2,-3);scene.add(rimLight);

function resize(){
  const w=stage.clientWidth,h=stage.clientHeight;
  renderer.setSize(w,h,false);
  camera.aspect=w/h;camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(stage);

/* ───────────── Mal-Textur ───────────── */
const TW=2048,TH=1024;
const paintC=document.createElement('canvas');paintC.width=TW;paintC.height=TH;
const pctx=paintC.getContext('2d',{willReadFrequently:true});
const compC=document.createElement('canvas');compC.width=TW;compC.height=TH;
const cctx=compC.getContext('2d');
const fillC=document.createElement('canvas');fillC.width=TW;fillC.height=TH;
const fctx=fillC.getContext('2d');
const tex=new THREE.CanvasTexture(compC);
tex.colorSpace=THREE.SRGBColorSpace;tex.anisotropy=8;
let dirty=true;
function composite(){
  cctx.globalCompositeOperation='source-over';
  cctx.fillStyle=S.base;cctx.fillRect(0,0,TW,TH);
  cctx.drawImage(fillC,0,0);
  cctx.drawImage(paintC,0,0);
  tex.needsUpdate=true;
}

/* ───────────── Körper ───────────── */
const U={
  uFace:{value:new THREE.Vector4()},uFaceCol:{value:new THREE.Color()},
  uRim:{value:0},uCheek:{value:new THREE.Vector4()},uCheekCol:{value:new THREE.Color()},
  uMouth:{value:new THREE.Vector4()},uMouthT:{value:0},uHole:{value:new THREE.Color()},uTongue:{value:new THREE.Color()},
  uEar:{value:new THREE.Vector4()},uEarOn:{value:0},
  uBrow:{value:new THREE.Vector4()},uBrowP:{value:new THREE.Vector2()},uBrowCol:{value:new THREE.Color()}
};
const bodyMat=new THREE.MeshPhysicalMaterial({
  map:tex,roughness:.82,metalness:0,sheen:.5,sheenRoughness:.8,sheenColor:new THREE.Color('#ffffff')
});
bodyMat.onBeforeCompile=sh=>{
  Object.assign(sh.uniforms,U);
  sh.vertexShader=sh.vertexShader
    .replace('#include <common>','#include <common>\nattribute vec3 aDir;\nvarying vec3 vDir;')
    .replace('#include <begin_vertex>','#include <begin_vertex>\nvDir=aDir;');
  sh.fragmentShader=sh.fragmentShader
    .replace('#include <common>',`#include <common>
varying vec3 vDir;
uniform vec4 uFace;uniform vec3 uFaceCol;uniform float uRim;
uniform vec4 uCheek;uniform vec3 uCheekCol;
uniform vec4 uMouth;uniform float uMouthT;uniform vec3 uHole;uniform vec3 uTongue;uniform vec4 uEar;uniform float uEarOn;
uniform vec4 uBrow;uniform vec2 uBrowP;uniform vec3 uBrowCol;`)
    .replace('#include <map_fragment>',`#include <map_fragment>
vec3 dd=normalize(vDir);
float fr=pow(pow(abs(dd.x)/uFace.x,uFace.w)+pow(abs(dd.y-uFace.z)/uFace.y,uFace.w),1.0/uFace.w);
float aa=fwidth(fr)*1.2+0.0005;
float front=smoothstep(0.0,0.08,dd.z);
float faceMask=(1.0-smoothstep(1.0-aa,1.0+aa,fr))*front;
float rimMask=(1.0-faceMask)*(1.0-smoothstep(1.0+uRim-aa,1.0+uRim+aa,fr))*front*step(0.0001,uRim);
diffuseColor.rgb=mix(diffuseColor.rgb,diffuseColor.rgb*0.8,rimMask);
vec2 q1=(dd.xy-vec2(uCheek.x,uCheek.y))/(vec2(0.085,0.05)*uCheek.w);
vec2 q2=(dd.xy-vec2(-uCheek.x,uCheek.y))/(vec2(0.085,0.05)*uCheek.w);
float ch=max(1.0-smoothstep(0.0,1.0,length(q1)),1.0-smoothstep(0.0,1.0,length(q2)));
vec3 fc=mix(uFaceCol,uCheekCol,ch*uCheek.z);
diffuseColor.rgb=mix(diffuseColor.rgb,fc,faceMask);
if(uMouthT>0.5){
  vec2 mp=(dd.xy-vec2(0.0,uMouth.y))/uMouth.zw;
  float md=length(mp);
  float maa=fwidth(md)*1.2+0.002;
  if(uMouthT>1.5)md=max(md,1.0+40.0*mp.y);
  float hole=(1.0-smoothstep(1.0-maa,1.0+maa,md))*faceMask;
  vec3 hc=uHole;
  if(uMouthT>1.5){
    float td=length((mp-vec2(0.0,-0.78))/vec2(0.62,0.42));
    hc=mix(hc,uTongue,1.0-smoothstep(0.9,1.05,td));
  }
  diffuseColor.rgb=mix(diffuseColor.rgb,hc,hole);
}
if(uBrowP.y>0.5){
  vec2 pb=vec2(abs(dd.x)-uBrow.x,dd.y-uBrow.y);
  float cT=cos(uBrowP.x),sT=sin(uBrowP.x);
  vec2 lb2=vec2(pb.x*cT+pb.y*sT,-pb.x*sT+pb.y*cT);
  float bd=length(lb2/uBrow.zw);
  float baa=fwidth(bd)*1.2+0.01;
  float bm=(1.0-smoothstep(1.0-baa,1.0+baa,bd))*faceMask*0.9;
  diffuseColor.rgb=mix(diffuseColor.rgb,uBrowCol,bm);
}
if(uEarOn>0.5){
  vec3 de=vec3(abs(dd.x),dd.y,dd.z);
  float ang=sqrt(max(0.0,2.0*(1.0-dot(de,uEar.xyz))));
  if(uEarOn>1.5){
    float hm=1.0-smoothstep(uEar.w*0.9,uEar.w*1.02,ang);
    diffuseColor.rgb=mix(diffuseColor.rgb,uHole,hm);
  }else{
    float em=(1.0-smoothstep(0.42*uEar.w,0.56*uEar.w,ang))*0.85;
    diffuseColor.rgb=mix(diffuseColor.rgb,uCheekCol,em);
  }
}`)
    .replace('#include <roughnessmap_fragment>','#include <roughnessmap_fragment>\nroughnessFactor=mix(roughnessFactor,0.38,faceMask);');
};

const geo=new THREE.SphereGeometry(1,192,144);
const unit=geo.attributes.position.array.slice();
geo.setAttribute('aDir',new THREE.BufferAttribute(unit.slice(),3));
const bodyMesh=new THREE.Mesh(geo,bodyMat);
const feat=new THREE.Group();
const body=new THREE.Group();body.add(bodyMesh,feat);
const rig=new THREE.Group();rig.add(body);scene.add(rig);

// Bodenschatten
(()=>{
  const c=document.createElement('canvas');c.width=c.height=128;
  const x=c.getContext('2d');const g=x.createRadialGradient(64,64,0,64,64,64);
  g.addColorStop(0,'rgba(60,40,30,.20)');g.addColorStop(.6,'rgba(60,40,30,.07)');g.addColorStop(1,'rgba(60,40,30,0)');
  x.fillStyle=g;x.fillRect(0,0,128,128);
  const m=new THREE.Mesh(new THREE.PlaneGeometry(1,1),new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(c),transparent:true,depthWrite:false}));
  m.rotation.x=-Math.PI/2;m.position.y=.002;m.name='shadow';scene.add(m);
})();
const shadow=scene.getObjectByName('shadow');

/* Gesichtsraum: Augen- und Mundhöhe sind relativ zur Gesichtslage */
const EY=()=>S.faceY+S.eyeY, MY=()=>S.faceY+S.mouthY;
/* Mund-Öffnungen: t: 0 keine, 1 Ellipse (Schlitz), 2 Halbellipse (offen) */
let MS={t:0,w:1,h:1};
function mouthShape(){
  const ms=S.mouthSize,m=S.mouth;
  if(m==='slit')return{t:1,w:ms*1.4,h:ms*.28};
  if(m==='open')return{t:2,w:ms*1.25,h:ms*1.1};
  if(m==='wide')return{t:2,w:ms*1.9,h:ms*.85};
  if(m==='gross')return{t:2,w:ms*2.6,h:ms*2.0};
  if(m==='riesig')return{t:2,w:ms*3.6,h:ms*1.6};
  if(m==='schnute')return{t:1,w:ms*.8,h:ms*.6};
  if(m==='ente')return{t:1,w:ms*1.9,h:ms*.8};
  return{t:0,w:1,h:1};
}
function mouthDist(dx,dy){
  const px=dx/MS.w,py=(dy-MY())/MS.h,l=Math.hypot(px,py);
  return MS.t===2?Math.max(l,1+40*py):l;
}
function browD(dx,dy){
  const pbx=Math.abs(dx)-S.eyeGap,pby=dy-(EY()+S.browUp);
  const c=Math.cos(S.browTilt),sn=Math.sin(S.browTilt);
  return Math.hypot((pbx*c+pby*sn)/(S.eyeSize*S.browLen),(-pbx*sn+pby*c)/(S.eyeSize*.34));
}
/* Füßchen, Hände, Ohren: runde Ausbuchtungen der Körperfläche (Gauß auf der Kugel), spiegelsymmetrisch */
const LD={tail:[0,-.4,-1],tail2:[0,0,-1],tailA:[.3,-.5,-1],ear:[0,1,0],foot:[0,-1,0],hand:[1,0,0],knob:[]};
const EH={R:.2,H:.1,ang:.17};
let SKIP_EH=false;
let GROUND=-1e9,PUSHF=0,PUSHT=0;const SH={fw:0,tw:0};
function updateLimbs(){
  const nz=v=>{const n=Math.hypot(v[0],v[1],v[2])||1;return[v[0]/n,v[1]/n,v[2]/n];};
  LD.ear=nz([S.earSpread,S.earUp,0]);
  LD.foot=nz([S.footSpread,-.85,S.footFwd]);
  LD.tail=nz([0,S.tailY,-1]);LD.tail2=nz([0,S.tailY+.4,-1]);LD.tailA=nz([.3,S.tailY,-1]);
  LD.hand=nz([.95,S.handY,S.handFwd]);
  LD.knob=[[.04,.16,.14],[.04,0,.2],[.04,-.16,.14]].map(o=>nz([LD.hand[0]+o[0],LD.hand[1]+o[1],LD.hand[2]+o[2]]));
  const sp=S.earSpread,up=S.earUp;
  LD.e2=nz([sp*1.1,up+.45,0]);LD.e3=nz([sp*1.2,up+.95,0]);
  LD.h1=nz([1,.45,0]);LD.h2=nz([1,.1,0]);LD.h3=nz([1,-.2,0]);
  LD.w1=nz([1,.55,0]);LD.w2=nz([1.15,.25,0]);
  EH.R=S.earW*.85;EH.H=EH.R*(.15+1.3*S.earSize);
  EH.ang=Math.max(Math.sqrt(Math.max(0,EH.R*EH.R-EH.H*EH.H)),EH.R*.45)*1.08;
}
const EAR_INNER=['rund','katze','hase'];
const m2=(d,c,s2)=>lb(d,c,s2,1)+lb(d,c,s2,-1);
function earBump(d){
  const W=S.earW,W2=W*W;
  switch(S.earType){
    case 'katze':return .55*m2(d,LD.ear,W2*1.3)+1.0*m2(d,LD.ear,W2*.2);
    case 'hase':return .9*m2(d,LD.ear,W2*.8)+.9*m2(d,LD.e2,W2*.55)+.8*m2(d,LD.e3,W2*.35);
    case 'haengend':return .9*m2(d,LD.h1,W2*.9)+.8*m2(d,LD.h2,W2*.8)+.65*m2(d,LD.h3,W2*.6);
    case 'fledermaus':return .6*m2(d,LD.ear,W2*.8)+.9*m2(d,LD.w1,W2*.45)+.8*m2(d,LD.w2,W2*.18);
    case 'knubbel':return .7*m2(d,LD.ear,W2*.5);
    default:return m2(d,LD.ear,W2);
  }
}
function earInnerDir(){
  const e=(S.earType==='hase'?LD.e2:LD.ear).slice();e[2]+=.9*S.earW;
  const n=Math.hypot(e[0],e[1],e[2]);return[e[0]/n,e[1]/n,e[2]/n];
}
const lb=(d,c,s2,sg)=>Math.exp(-2*(1-(sg*d.x*c[0]+d.y*c[1]+d.z*c[2]))/s2);

/* Gesichtsrelief: Gesicht eingesenkt, dicker Textilwulst mit Falten und Überhang als Kapuzenrand */
const sst=(a,b,x)=>{const t=Math.min(1,Math.max(0,(x-a)/(b-a)));return t*t*(3-2*t);};
const gauss=(u,v,cx,cy,sx,sy)=>Math.exp(-((u-cx)*(u-cx)/(sx*sx)+(v-cy)*(v-cy)/(sy*sy)));
const FO={rw:0};
function faceOffset(d){
  FO.rw=0;
  const front=sst(0,.2,d.z);
  if(front<=0)return 0;
  const n=S.faceN,u=d.x/S.faceW,v=(d.y-S.faceY)/S.faceH,e=S.fSoft;
  const fr=Math.pow(Math.pow(Math.abs(u),n)+Math.pow(Math.abs(v),n),1/n);
  if(fr>1.95)return 0;
  const inset=1-sst(1-e,1+e,fr);               // Einsenkung mit weicher Wand
  const inner=1-sst(.72,1,fr);                 // Wölbungen klingen zum Rand hin ab
  const au=Math.abs(u);
  const bumps=S.fRound*Math.max(0,1-fr*fr)
    +S.fCheek*gauss(au,v,.55,-.3,.38,.32)
    +S.fBrow*gauss(u,v,0,.62,.6,.3)
    +S.fChin*gauss(u,v,0,-.72,.4,.24);
  // aufgeblasener Textilwulst: ringförmig um die Öffnung, durch Falten in kleine Kissen gegliedert
  const fn=2*Math.round(S.foldN/2),a=Math.atan2(v,au);
  const rimW=Math.exp(-Math.pow((fr-1.08)/.14,2));
  const foldW=sst(1.1,1.32,fr)*(1-sst(1.45,1.9,fr));              // Falten nur im Stoff, nie im Gesicht
  const lip=S.lip*rimW+S.fold*.07*foldW*Math.cos(fn*a);
  const crease=-S.crease*Math.exp(-Math.pow((fr-.95)/.05,2));     // Falte zwischen Wulst und Gesicht
  let r=-S.fDepth*inset+inner*bumps+lip+crease;
  if(fr<1.1){
    if(S.socket){                                                  // Augenhöhlen
      const rs=S.eyeSize*2.3,q=Math.hypot((Math.abs(d.x)-S.eyeGap)/rs,(d.y-EY())/(rs*.95));
      r-=S.socketD*(1-sst(.55,1.25,q));
    }
    if(S.brows)r+=S.browH*(1-sst(.2,1,browD(d.x,d.y)));           // Brauen-Relief
    if(MS.t){
      const md=mouthDist(d.x,d.y);
      r-=S.mouthDepth*(1-sst(.55,1.12,md));                         // Mundhöhle
      if(S.mouth==='schnute'||S.mouth==='ente')r+=.032*Math.exp(-Math.pow((md-1.38)/.4,2));  // vorgestülpte Lippen (Schnute)
    }
  }
  FO.rw=front*rimW;
  return front*r;
}

/* Breitenprofil über die Höhe: unten / Mitte / oben, weich (Kosinus) interpoliert */
const cosI=(a,b,t)=>a+(b-a)*(1-Math.cos(Math.PI*t))/2;
function wprof(t){
  const t0=-.5,t1=.05,t2=.6;
  if(t<=t0)return S.wb;
  if(t<=t1)return cosI(S.wb,S.wm,(t-t0)/(t1-t0));
  if(t<=t2)return cosI(S.wm,S.wt,(t-t1)/(t2-t1));
  return S.wt;
}

/* Formfunktion: Einheitsrichtung → Oberflächenpunkt */
function shape(d,out){
  let x=d.x,y=d.y,z=d.z;
  const rxy=Math.hypot(x,y);
  if(S.tri>.001&&rxy>1e-6){
    const a=Math.atan2(y,x);
    const f=1+S.tri*.3*Math.cos(3*(a-Math.PI/2))*rxy;
    x*=f;y*=f;
  }
  const k=1-S.egg*.3*d.y,w=wprof(d.y);
  let px=x*S.sx*k*w,py=y*S.sy,pz=z*S.sz*k*w;
  const fo=1+faceOffset(d);px*=fo;py*=fo;pz*=fo;
  if(FO.rw>.001&&S.over>0){                                        // Überhang: Wulst schiebt sich über den Gesichtsrand
    const ux=-d.x,uy=-(d.y-S.faceY),L=Math.hypot(ux,uy)||1;
    px+=S.over*FO.rw*ux/L*S.sx;py+=S.over*FO.rw*uy/L*S.sy;
  }
  let lr=1,fw=0;
  if(S.ears)lr+=S.earSize*earBump(d);
  let tw=0;
  if(S.feet){fw=lb(d,LD.foot,.045,1)+lb(d,LD.foot,.045,-1);lr+=S.footSize*fw;}
  if(S.tail){
    const T=S.tailType;
    if(T==='rund')tw=lb(d,LD.tail,.07,1);
    else if(T==='spitz')tw=.75*lb(d,LD.tail,.06,1)+.7*lb(d,LD.tail,.012,1);
    else if(T==='hoch')tw=.8*lb(d,LD.tail,.05,1)+lb(d,LD.tail2,.04,1);
    else tw=lb(d,LD.tailA,.03,1)+lb(d,LD.tailA,.03,-1);
    lr+=S.tailSize*tw;
  }
  SH.fw=fw;SH.tw=tw;
  if(S.hands){                                                     // knubbelige Hände: Hauptklumpen + drei Knubbel
    let hm=lb(d,LD.hand,.06,1)+lb(d,LD.hand,.06,-1),hk=0;
    for(const kn of LD.knob)hk+=lb(d,kn,.012,1)+lb(d,kn,.012,-1);
    lr+=S.handSize*(hm+.4*hk);
  }
  px*=lr;py*=lr;pz*=lr;
  if(PUSHF||PUSHT){py-=PUSHF*sst(0,.9,fw)+PUSHT*sst(0,.9,tw);}  // Füße/Schwanz reichen bis zur Standebene
  if(py<GROUND)py=GROUND+(py-GROUND)*.1;                           // gemeinsame ebene Standfläche
  return out.set(px,py,pz);
}
function normalAt(d){
  const up=Math.abs(d.y)>.99?new THREE.Vector3(1,0,0):new THREE.Vector3(0,1,0);
  const t1=new THREE.Vector3().crossVectors(d,up).normalize();
  const t2=new THREE.Vector3().crossVectors(d,t1).normalize();
  const e=.01,v=()=>new THREE.Vector3();
  const a=shape(d.clone().addScaledVector(t1,e).normalize(),v());
  const b=shape(d.clone().addScaledVector(t1,-e).normalize(),v());
  const c=shape(d.clone().addScaledVector(t2,e).normalize(),v());
  const f=shape(d.clone().addScaledVector(t2,-e).normalize(),v());
  const n=new THREE.Vector3().crossVectors(a.sub(b),c.sub(f)).normalize();
  if(n.dot(d)<0)n.negate();
  return n;
}
function surf(dx,dy){
  const z=Math.sqrt(Math.max(.0001,1-dx*dx-dy*dy));
  const d=new THREE.Vector3(dx,dy,z).normalize();
  return{P:shape(d,new THREE.Vector3()),N:normalAt(d)};
}
function rebuildBody(){
  updateLimbs();MS=mouthShape();
  const pos=geo.attributes.position,nor=geo.attributes.normal;
  const d=new THREE.Vector3(),p=new THREE.Vector3();
  let minY=1e9;
  GROUND=-1e9;PUSHF=PUSHT=0;
  let lowF=1e9,lowT=1e9;
  if(S.feet||S.tail){
    for(let i=0;i<pos.count;i++){
      d.set(unit[i*3],unit[i*3+1],unit[i*3+2]);shape(d,p);
      if(SH.fw>.9&&p.y<lowF)lowF=p.y;
      if(SH.tw>.9&&p.y<lowT)lowT=p.y;
    }
  }
  const bodyG=-S.sy*(1-S.flat*.3);
  GROUND=Math.min(lowF,lowT,S.feet||S.tail?1e9:bodyG);
  if(GROUND>1e8)GROUND=bodyG;
  if(lowF<1e8)PUSHF=lowF-GROUND;
  if(lowT<1e8)PUSHT=lowT-GROUND;
  for(let i=0;i<pos.count;i++){
    d.set(unit[i*3],unit[i*3+1],unit[i*3+2]);
    shape(d,p);pos.setXYZ(i,p.x,p.y,p.z);
    const n=normalAt(d);nor.setXYZ(i,n.x,n.y,n.z);
    if(p.y<minY)minY=p.y;
  }
  pos.needsUpdate=true;nor.needsUpdate=true;
  geo.computeBoundingSphere();geo.computeBoundingBox();
  body.position.y=-minY;
  const H=geo.boundingBox.max.y-geo.boundingBox.min.y;               // Kamera passt sich hohen Ohren an
  controls.target.y=H/2;
  const need=Math.max(5.4,H*2.5),cd=camera.position.distanceTo(controls.target);
  if(cd<need)camera.position.sub(controls.target).setLength(need).add(controls.target);
  controls.update();
  shadow.scale.set(2.7*Math.max(S.sx,.9)*Math.max(S.wb,S.wt,1),2.7*S.sz,1);
}

/* Augen, Mund, Ohren */
const sphereGeo=new THREE.SphereGeometry(1,28,18);
const eyeMat=new THREE.MeshStandardMaterial({roughness:.12,metalness:0});
const mouthMat=new THREE.MeshStandardMaterial({roughness:.5,metalness:0});
const glintMat=new THREE.MeshBasicMaterial({color:'#ffffff'});
const earMat=new THREE.MeshStandardMaterial({roughness:.8,metalness:0});
const earInMat=new THREE.MeshStandardMaterial({roughness:.7,metalness:0,color:'#f1a79d'});
let eyes=[];
function orient(m,N){
  const up=new THREE.Vector3(0,1,0);
  const x=new THREE.Vector3().crossVectors(up,N).normalize();
  const y=new THREE.Vector3().crossVectors(N,x);
  m.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(x,y,N));
}
function arc(cx,hw,my,k){
  const pts=[];
  for(let i=0;i<=16;i++){
    const t=i/8-1;
    const {P,N}=surf(cx+t*hw,my+k*t*t);
    pts.push(P.addScaledVector(N,-.003));                          // Linie sitzt halb im Mesh
  }
  return pts;
}
function addTube(pts){
  const rad=.011;
  const m=new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts),28,rad,8,false),mouthMat);
  feat.add(m);
  for(const p of [pts[0],pts[pts.length-1]]){
    const s=new THREE.Mesh(sphereGeo,mouthMat);s.scale.setScalar(rad);s.position.copy(p);feat.add(s);
  }
}
/* Ohren, die durch Löcher in der Kapuze herausragen */
function addHoleEars(){
  SKIP_EH=true;
  earMat.color.set(S.earCol);
  for(const sg of [-1,1]){
    const d=new THREE.Vector3(sg*LD.ear[0],LD.ear[1],LD.ear[2]);
    const P=shape(d,new THREE.Vector3()),N=normalAt(d),R=EH.R;
    const g=new THREE.Group();g.name=sg<0?'Ear_L':'Ear_R';
    const e=new THREE.Mesh(sphereGeo,earMat);e.scale.set(R,R*1.08,R*.6);g.add(e);
    if(S.earInner){
      const ie=new THREE.Mesh(sphereGeo,earInMat);
      ie.scale.set(R*.58,R*.62,R*.2);ie.position.set(0,0,R*.6*.82);g.add(ie);
    }
    orient(g,N.clone().add(new THREE.Vector3(0,0,.9)).normalize());
    g.position.copy(P).addScaledVector(N,EH.H);
    feat.add(g);
  }
  SKIP_EH=false;
}
function rebuildFeatures(){
  feat.children.forEach(c=>{if(c.geometry&&c.geometry!==sphereGeo)c.geometry.dispose();});
  feat.clear();eyes=[];
  const col=S.faceDark?'#f6efe4':'#231e1c';
  eyeMat.color.set(col);mouthMat.color.set(col);
  for(const sg of [-1,1]){
    const {P,N}=surf(sg*S.eyeGap,EY());
    const r=S.eyeSize;
    const g=new THREE.Mesh(sphereGeo,eyeMat);
    g.scale.set(r,r*S.eyeStretch,r*.6);
    orient(g,N);
    g.position.copy(P).addScaledVector(N,r*.15);
    g.userData.sy=r*S.eyeStretch;
    if(!S.faceDark&&S.glint){
      const gl=new THREE.Mesh(sphereGeo,glintMat);
      gl.scale.set(.2,.2,.2);gl.position.set(-.3,.34,.78);g.add(gl);
    }
    feat.add(g);eyes.push(g);
  }
  const ms=S.mouthSize,my=MY();
  if(S.mouth==='dot'){
    const {P,N}=surf(0,my);
    const m=new THREE.Mesh(sphereGeo,mouthMat);
    m.scale.set(ms*.28,ms*.28,ms*.18);orient(m,N);m.position.copy(P).addScaledVector(N,-ms*.04);feat.add(m);
  }else if(S.mouth==='smile'){
    addTube(arc(0,ms,my,ms*.5));
  }else if(S.mouth==='cat'){
    addTube(arc(-ms*.55,ms*.55,my,ms*.45));
    addTube(arc(ms*.55,ms*.55,my,ms*.45));
  }
  if(S.ears&&S.earMode==='hole')addHoleEars();
}
function updateUniforms(){
  U.uFace.value.set(S.faceW,S.faceH,S.faceY,S.faceN);
  U.uFaceCol.value.set(S.faceDark?'#1f1b1a':'#fbf7f1');
  U.uRim.value=S.rim;
  U.uCheek.value.set(S.faceW*.55,S.faceY-.3*S.faceH,S.cheek*(S.faceDark?.75:1),S.faceW/.5);
  U.uCheekCol.value.set(S.faceDark?'#9a4f4a':'#f1a79d');
  U.uMouth.value.set(0,MY(),MS.w,MS.h);U.uMouthT.value=MS.t;
  U.uHole.value.set('#0b0807');U.uTongue.value.set('#d4706a');
  if(S.ears&&S.earMode==='hole'){U.uEar.value.set(LD.ear[0],LD.ear[1],LD.ear[2],EH.ang);U.uEarOn.value=2;}
  else{const ei=earInnerDir();U.uEar.value.set(ei[0],ei[1],ei[2],S.earW);U.uEarOn.value=(S.ears&&S.earInner&&EAR_INNER.includes(S.earType))?1:0;}
  U.uBrow.value.set(S.eyeGap,EY()+S.browUp,S.eyeSize*S.browLen,S.eyeSize*.34);
  U.uBrowP.value.set(S.browTilt,S.brows?1:0);
  U.uBrowCol.value.set(S.faceDark?'#f0e8dc':'#4a3a35');
  dirty=true;
}
function apply(lvl=2){
  if(lvl>=2)rebuildBody();
  if(lvl>=1)rebuildFeatures();
  updateUniforms();
}
let _pa=-1,_raf=0;                                                  // Slider-Updates pro Frame bündeln
function schedApply(l){_pa=Math.max(_pa,l);if(!_raf)_raf=requestAnimationFrame(()=>{_raf=0;const q=_pa;_pa=-1;apply(q);});}

/* ───────────── Malen ───────────── */
function hexToRgba(h,a){
  const n=parseInt(h.slice(1),16);
  return `rgba(${n>>16&255},${n>>8&255},${n&255},${a})`;
}
function stamp(x,y,sxf,r,tip,color,erase){
  pctx.save();
  pctx.globalCompositeOperation=erase?'destination-out':'source-over';
  for(const off of [-TW,0,TW]){
    const cx=x+off;
    if(cx+r*sxf<0||cx-r*sxf>TW)continue;
    pctx.save();pctx.translate(cx,y);pctx.scale(sxf,1);
    pctx.fillStyle=color;
    if(tip==='soft'){
      const g=pctx.createRadialGradient(0,0,0,0,0,r);
      g.addColorStop(0,hexToRgba(color,.9));g.addColorStop(.5,hexToRgba(color,.5));g.addColorStop(1,hexToRgba(color,0));
      pctx.fillStyle=g;pctx.beginPath();pctx.arc(0,0,r,0,Math.PI*2);pctx.fill();
    }else if(tip==='flower'){
      for(let k=0;k<5;k++){
        pctx.save();pctx.rotate(k*Math.PI*2/5);
        pctx.beginPath();pctx.ellipse(0,-r*.5,r*.36,r*.52,0,0,Math.PI*2);pctx.fill();
        pctx.restore();
      }
    }else if(tip==='diamond'){
      pctx.rotate(Math.PI/4);pctx.fillRect(-r*.7,-r*.7,r*1.4,r*1.4);
    }else{
      pctx.beginPath();pctx.arc(0,0,r,0,Math.PI*2);pctx.fill();
    }
    pctx.restore();
  }
  pctx.restore();
}
const spacing=()=>Math.max(1,S.size*(['dot','flower','diamond'].includes(S.tip)?2.4:.3));
let last=[null,null];
function strokeTo(u,v,first){
  const pts=[[u,v]];
  if(S.sym)pts.push([mod1(.5-u),v]);
  const erase=S.tool==='erase';
  const tip=S.tip==='dot'?'round':S.tip;
  pts.forEach((p,i)=>{
    const x=p[0]*TW,y=(1-p[1])*TH;
    const sxf=1/Math.max(Math.sin(Math.PI*p[1]),.2);
    const sp=spacing();
    let L=last[i];
    if(!L||first){
      stamp(x,y,sxf,S.size,tip,S.brush,erase);
      last[i]={x,y,rem:sp};return;
    }
    let dx=x-L.x;
    if(dx>TW/2)L.x+=TW;else if(dx<-TW/2)L.x-=TW;
    dx=x-L.x;const dy=y-L.y;
    const dist=Math.hypot(dx,dy);
    let d=L.rem;
    while(d<=dist){
      const t=d/dist;
      stamp(L.x+dx*t,L.y+dy*t,sxf,S.size,tip,S.brush,erase);
      d+=sp;
    }
    L.rem=d-dist;L.x=x;L.y=y;
  });
  dirty=true;
}
const undoStack=[];
function pushUndo(){undoStack.push(pctx.getImageData(0,0,TW,TH));if(undoStack.length>12)undoStack.shift();}
function doUndo(){const d=undoStack.pop();if(d){pctx.putImageData(d,0,0);dirty=true;}}
function clearPaint(){pushUndo();pctx.clearRect(0,0,TW,TH);dirty=true;}


/* ───────────── Muster-Füllung (unter der Bemalung, immer symmetrisch) ───────────── */
const hexRGB=h=>{const n=parseInt(h.slice(1),16);return[n>>16&255,n>>8&255,n&255];};
const sstep=(a,b,x)=>{const t=Math.min(1,Math.max(0,(x-a)/(b-a)));return t*t*(3-2*t);};
function hash3(x,y,z,s){
  let h=(Math.imul(x,374761393)+Math.imul(y,668265263)+Math.imul(z,1274126177)+Math.imul(s,1103515245))|0;
  h=Math.imul(h^(h>>>13),1274126177);h^=h>>>16;
  return(h>>>0)/4294967295;
}
const fade=t=>t*t*t*(t*(t*6-15)+10);
function vnoise(x,y,z,s){
  const xi=Math.floor(x),yi=Math.floor(y),zi=Math.floor(z);
  const fx=fade(x-xi),fy=fade(y-yi),fz=fade(z-zi);
  const a=hash3(xi,yi,zi,s),b=hash3(xi+1,yi,zi,s),c=hash3(xi,yi+1,zi,s),d=hash3(xi+1,yi+1,zi,s);
  const e=hash3(xi,yi,zi+1,s),f=hash3(xi+1,yi,zi+1,s),g=hash3(xi,yi+1,zi+1,s),h=hash3(xi+1,yi+1,zi+1,s);
  const x1=a+(b-a)*fx,x2=c+(d-c)*fx,x3=e+(f-e)*fx,x4=g+(h-g)*fx;
  const y1=x1+(x2-x1)*fy,y2=x3+(x4-x3)*fy;
  return y1+(y2-y1)*fz;
}
const fbm=(x,y,z,s)=>vnoise(x,y,z,s)*.57+vnoise(x*2.03,y*2.03,z*2.03,s+17)*.29+vnoise(x*4.1,y*4.1,z*4.1,s+31)*.14;
const fillImg=fctx.createImageData(TW,TH);
let fillTimer=0;
function scheduleFill(){clearTimeout(fillTimer);fillTimer=setTimeout(genFill,120);}
function genFill(){
  clearTimeout(fillTimer);
  if(S.pat==='none'){fctx.clearRect(0,0,TW,TH);dirty=true;return;}
  const D=fillImg.data,cols=S.patCols.map(hexRGB),PI=Math.PI;
  const cph=new Float32Array(TW),sph=new Float32Array(TW);
  for(let i=0;i<TW;i++){const p=(i+.5)/TW*2*PI;cph[i]=Math.cos(p);sph[i]=Math.sin(p);}
  const A=S.patSize,N=S.patScale,mode=S.pat;
  const cf=N*.12,cthr=.5+(.5-A)*.35,seed=S.patSeed|0;           // Camouflage
  const R=Math.max(2,Math.round(N)),ds=PI/R,dr=ds*.55*A;          // Punkte
  const SN=Math.max(1,Math.round(N)),sm=S.patStripe;              // Streifen
  let r=0,g=0,b=0,cov=0;
  // nur die rechte Körperhälfte (x>=0) berechnen, die linke wird gespiegelt → exakt symmetrisch
  for(let j=0;j<TH;j++){
    const th=(j+.5)/TH*PI,st=Math.sin(th),ct=Math.cos(th);
    for(let i=TW/4;i<TW*3/4;i++){
      const ph=(i+.5)/TW*2*PI,x=-cph[i]*st,z=sph[i]*st;
      if(mode==='camo'){
        r=g=b=0;cov=0;
        for(let k=0;k<3;k++){
          const n=fbm(x*cf,ct*cf,z*cf,seed+k*101),t=cthr+k*.03;
          const c=sstep(t-.02,t+.02,n);
          if(c>0){
            const col=cols[k],na=c+cov*(1-c);
            r=(col[0]*c+r*cov*(1-c))/na;g=(col[1]*c+g*cov*(1-c))/na;b=(col[2]*c+b*cov*(1-c))/na;
            cov=na;
          }
        }
      }else if(mode==='dots'){
        let best=-2;
        const r0=Math.floor(th/ds);
        for(let rr=r0-1;rr<=r0+1;rr++){
          if(rr<0||rr>=R)continue;
          const tr=(rr+.5)*ds,str=Math.sin(tr),ctr=Math.cos(tr);
          const n=Math.max(1,Math.round(2*PI*str/ds)),step=2*PI/n,off=(rr&1)*.5;
          const jj=Math.round((ph-PI/2)/step-off),dp=PI/2+(jj+off)*step;
          const cd=ct*ctr+st*str*Math.cos(ph-dp);
          if(cd>best)best=cd;
        }
        const d=Math.acos(Math.min(1,best));
        cov=1-sstep(dr-.003,dr+.003,d);
        r=cols[0][0];g=cols[0][1];b=cols[0][2];
      }else{
        let t;
        if(sm==='h')t=th/PI*SN;
        else if(sm==='v')t=(ph-PI/2)/(2*PI)*SN;
        else{const dl=ph-PI/2;t=(th+Math.abs(Math.atan2(Math.sin(dl),Math.cos(dl)))*.6)/PI*SN;}
        const dt=Math.abs(t-Math.round(t));
        const aw=sm==='h'?SN/TH:sm==='v'?SN/TW*1.5:SN/TH*1.2;
        cov=1-sstep(A*.5-aw,A*.5+aw,dt);
        r=cols[0][0];g=cols[0][1];b=cols[0][2];
      }
      const o=(j*TW+i)*4,o2=(j*TW+((TW/2-1-i+TW)%TW))*4,al=cov*255;
      D[o]=r;D[o+1]=g;D[o+2]=b;D[o+3]=al;
      D[o2]=r;D[o2+1]=g;D[o2+2]=b;D[o2+3]=al;
    }
  }
  fctx.putImageData(fillImg,0,0);dirty=true;
}

/* ───────────── GLB-Export ───────────── */
function download(blob,name){
  const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;
  document.body.appendChild(a);a.click();a.remove();
  setTimeout(()=>URL.revokeObjectURL(a.href),4000);
}
/* Gesicht, Saum und Wangen sind im Viewer Shader – für das GLB werden sie in die Textur gebacken */
function bakeTextures(){
  composite();
  const c=document.createElement('canvas');c.width=TW;c.height=TH;
  const x=c.getContext('2d');x.drawImage(compC,0,0);
  const img=x.getImageData(0,0,TW,TH),D=img.data;
  const rc=document.createElement('canvas');rc.width=TW;rc.height=TH;
  const rx=rc.getContext('2d');const rimg=rx.createImageData(TW,TH),RD=rimg.data;
  const PI=Math.PI,fcol=hexRGB(S.faceDark?'#1f1b1a':'#fbf7f1'),ccol=hexRGB(S.faceDark?'#9a4f4a':'#f1a79d');
  const cs=S.cheek*(S.faceDark?.75:1),ccx=S.faceW*.55,ccy=S.faceY-.3*S.faceH,cw=S.faceW/.5;
  const W=S.faceW,H=S.faceH,Y=S.faceY,Nn=S.faceN,rim=S.rim,aa=.008;
  const hcol=hexRGB('#0b0807'),tcol=hexRGB('#d4706a'),earOn=S.ears&&S.earInner&&EAR_INNER.includes(S.earType),ei=earInnerDir();
  const holeEar=S.ears&&S.earMode==='hole',bcol=hexRGB(S.faceDark?'#f0e8dc':'#4a3a35');
  const bcx=S.eyeGap,bcy=EY()+S.browUp,bhx=S.eyeSize*S.browLen,bhy=S.eyeSize*.34,bc=Math.cos(S.browTilt),bsn=Math.sin(S.browTilt),mY=MY();
  const cph=new Float32Array(TW),sph=new Float32Array(TW);
  for(let i=0;i<TW;i++){const p=(i+.5)/TW*2*PI;cph[i]=Math.cos(p);sph[i]=Math.sin(p);}
  for(let j=0;j<TH;j++){
    const th=(j+.5)/TH*PI,st=Math.sin(th),dy=Math.cos(th);
    for(let i=0;i<TW;i++){
      const o=(j*TW+i)*4;
      RD[o]=0;RD[o+1]=209;RD[o+2]=0;RD[o+3]=255;              // Rauheit .82
      const z=sph[i]*st,dx=-cph[i]*st;
      if(holeEar){                                             // Ohr-Loch
        const dt=Math.abs(dx)*LD.ear[0]+dy*LD.ear[1]+z*LD.ear[2];
        const hm=1-sstep(EH.ang*.9,EH.ang*1.02,Math.sqrt(Math.max(0,2*(1-dt))));
        if(hm>0)for(let m=0;m<3;m++)D[o+m]=D[o+m]+(hcol[m]-D[o+m])*hm;
      }else if(earOn){                                         // Innenohr
        const dt=Math.abs(dx)*ei[0]+dy*ei[1]+z*ei[2];
        const em=(1-sstep(.42*S.earW,.56*S.earW,Math.sqrt(Math.max(0,2*(1-dt)))))*.85;
        if(em>0)for(let m=0;m<3;m++)D[o+m]=D[o+m]+(ccol[m]-D[o+m])*em;
      }
      if(z<=0)continue;
      const ax=Math.abs(dx)/W,ay=Math.abs(dy-Y)/H;
      if(ax>1.6||ay>1.6)continue;
      const fr=Math.pow(Math.pow(ax,Nn)+Math.pow(ay,Nn),1/Nn);
      if(fr>1+rim+aa)continue;
      const front=sstep(0,.08,z);
      const fm=(1-sstep(1-aa,1+aa,fr))*front;
      const rm=rim>.0001?(1-fm)*(1-sstep(1+rim-aa,1+rim+aa,fr))*front:0;
      const k=1-.2*rm;
      const q1=Math.hypot((dx-ccx)/(.085*cw),(dy-ccy)/(.05*cw)),q2=Math.hypot((dx+ccx)/(.085*cw),(dy-ccy)/(.05*cw));
      const ch=Math.max(1-sstep(0,1,q1),1-sstep(0,1,q2))*cs;
      for(let m=0;m<3;m++){
        const f=fcol[m]+(ccol[m]-fcol[m])*ch,cur=D[o+m]*k;
        D[o+m]=cur+(f-cur)*fm;
      }
      if(S.brows){                                             // Augenbrauen
        const pbx=Math.abs(dx)-bcx,pby=dy-bcy;
        const bd=Math.hypot((pbx*bc+pby*bsn)/bhx,(-pbx*bsn+pby*bc)/bhy);
        const bm=(1-sstep(.9,1.05,bd))*fm*.9;
        if(bm>0)for(let m=0;m<3;m++)D[o+m]=D[o+m]+(bcol[m]-D[o+m])*bm;
      }
      if(MS.t){                                                // Mund-Öffnung
        const px=dx/MS.w,py=(dy-mY)/MS.h;
        let md=Math.hypot(px,py);if(MS.t===2)md=Math.max(md,1+40*py);
        const hm=(1-sstep(.95,1.05,md))*fm;
        if(hm>0){
          let hc=hcol;
          if(MS.t===2){const tm=1-sstep(.9,1.05,Math.hypot(px/.62,(py+.78)/.42));hc=[0,1,2].map(q=>hcol[q]+(tcol[q]-hcol[q])*tm);}
          for(let m=0;m<3;m++)D[o+m]=D[o+m]+(hc[m]-D[o+m])*hm;
        }
      }
      RD[o+1]=Math.round(209+(97-209)*fm);                    // Gesicht glatter (.38)
    }
  }
  x.putImageData(img,0,0);rx.putImageData(rimg,0,0);
  return{color:c,rough:rc};
}
function exportGLB(){
  const {color,rough}=bakeTextures();
  const map=new THREE.CanvasTexture(color);map.colorSpace=THREE.SRGBColorSpace;
  const rmap=new THREE.CanvasTexture(rough);
  const mat=new THREE.MeshStandardMaterial({map,roughnessMap:rmap,metalnessMap:rmap,roughness:1,metalness:1});
  const eg=new THREE.BufferGeometry();
  eg.setAttribute('position',geo.attributes.position.clone());
  eg.setAttribute('normal',geo.attributes.normal.clone());
  eg.setAttribute('uv',geo.attributes.uv.clone());
  eg.setIndex(geo.index.clone());
  const bodyM=new THREE.Mesh(eg,mat);bodyM.name='Body';
  const face=feat.clone(true);face.name='Face';
  const root=new THREE.Group();root.name='Blob';root.add(bodyM,face);
  const bb=geo.boundingBox,sc=(S.cm/100)/(bb.max.y-bb.min.y);       // 1 Einheit = 1 m, Figurhöhe = S.cm
  root.scale.setScalar(sc);
  root.position.y=body.position.y*sc;
  const done=()=>{eg.dispose();mat.dispose();map.dispose();rmap.dispose();};
  new GLTFExporter().parse(root,res=>{
    download(new Blob([res],{type:'model/gltf-binary'}),'blob.glb');done();
  },err=>{console.error(err);alert('GLB-Export fehlgeschlagen');done();},{binary:true});
}

/* Pointer */
const ray=new THREE.Raycaster(),ndc=new THREE.Vector2();
function pick(e){
  const r=renderer.domElement.getBoundingClientRect();
  ndc.set((e.clientX-r.left)/r.width*2-1,-((e.clientY-r.top)/r.height)*2+1);
  ray.setFromCamera(ndc,camera);
  const h=ray.intersectObject(bodyMesh,false)[0];
  return h&&h.uv?h:null;
}
let painting=false,lost=false,activeId=null;
stage.addEventListener('pointerdown',e=>{
  if(e.button!==0||!e.isPrimary){controls.enabled=true;return;}
  const h=S.tool==='orbit'?null:pick(e);
  if(h){
    painting=true;lost=false;activeId=e.pointerId;controls.enabled=false;
    pushUndo();last=[null,null];strokeTo(h.uv.x,h.uv.y,true);
    stage.setPointerCapture(e.pointerId);
    e.stopPropagation();e.preventDefault();
  }else controls.enabled=true;
},true);
stage.addEventListener('pointermove',e=>{
  if(painting&&e.pointerId===activeId){
    const h=pick(e);
    if(h){strokeTo(h.uv.x,h.uv.y,lost);lost=false;}else lost=true;
  }else if(!painting){
    stage.style.cursor=(S.tool!=='orbit'&&pick(e))?'crosshair':'grab';
  }
});
const endStroke=e=>{if(painting&&e.pointerId===activeId){painting=false;controls.enabled=true;}};
stage.addEventListener('pointerup',endStroke);
stage.addEventListener('pointercancel',endStroke);
addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();doUndo();}});

/* ───────────── UI ───────────── */
const panel=document.getElementById('panel');
const syncs=[];
const syncAll=()=>syncs.forEach(f=>f());
const el=(t,c)=>{const e=document.createElement(t);if(c)e.className=c;return e;};
function sec(title){
  const h=el('h3');h.textContent=title;panel.appendChild(h);
  const d=el('div');panel.appendChild(d);return d;
}
function slider(p,k,label,min,max,step,lvl){
  const row=el('div','row'),l=el('span'),i=el('input'),v=el('span','val');
  l.textContent=label;i.type='range';i.min=min;i.max=max;i.step=step;
  const dg=step>=1?0:2;
  i.oninput=()=>{S[k]=+i.value;v.textContent=(+i.value).toFixed(dg);if(lvl==='fill')scheduleFill();else if(lvl!==null)schedApply(lvl);};
  syncs.push(()=>{i.value=S[k];v.textContent=(+S[k]).toFixed(dg);});
  row.append(l,i,v);p.appendChild(row);
}
function choice(p,opts,get,set){
  const w=el('div','btns');
  const bs=opts.map(([val,label])=>{
    const b=el('button');b.textContent=label;b.dataset.v=val;
    b.onclick=()=>{set(val);syncAll();};w.appendChild(b);return b;
  });
  syncs.push(()=>bs.forEach(b=>b.classList.toggle('on',b.dataset.v===String(get()))));
  p.appendChild(w);
}
function swatches(p,cols,get,set){
  const w=el('div','btns');
  cols.forEach(c=>{
    const b=el('button','sw');b.style.background=c;b.title=c;
    b.onclick=()=>{set(c);syncAll();};w.appendChild(b);
  });
  const pk=el('input','pick');pk.type='color';pk.oninput=()=>set(pk.value);
  syncs.push(()=>{pk.value=get();});
  w.appendChild(pk);p.appendChild(w);
}
function check(p,k,label,lvl){
  const w=el('label','chk'),i=el('input'),s=el('span');
  i.type='checkbox';s.textContent=label;
  i.onchange=()=>{S[k]=i.checked;if(lvl!==null&&lvl!==undefined)apply(lvl);};
  syncs.push(()=>{i.checked=!!S[k];});
  w.append(i,s);p.appendChild(w);
}
function actions(p,list){
  const w=el('div','btns');
  list.forEach(([label,fn])=>{const b=el('button');b.textContent=label;b.onclick=()=>{fn();syncAll();};w.appendChild(b);});
  p.appendChild(w);
}
const PASTEL=['#e8b9ad','#f2d9a6','#c4d9b4','#b3cde0','#d3c1e0','#f3e9db','#cf5a43','#e0a93b','#4f7a68','#3f5d7a','#8a5a3c','#2a2625'];

let s=sec('Form');
choice(s,[['bohne','Bohne'],['mochi','Mochi'],['tropfen','Tropfen'],['reisball','Reisball'],['onigiri','Onigiri'],['spitz','Spitz'],['sanduhr','Sanduhr'],['erdnuss','Erdnuss'],['schneemann','Schneemann'],['birne','Birne'],['knubbel','Knubbel']],()=>'',v=>{Object.assign(S,PRESETS[v]);apply(2);});
slider(s,'sx','Breite',.6,1.4,.01,2);
slider(s,'sy','Höhe',.6,1.4,.01,2);
slider(s,'sz','Tiefe',.6,1.4,.01,2);
slider(s,'egg','Ei-Spitze',0,1,.01,2);
slider(s,'tri','Onigiri-Ecken',0,1.2,.01,2);
slider(s,'flat','Standfläche',0,1,.01,2);
slider(s,'wt','Oben dick',.6,1.4,.01,2);
slider(s,'wm','Mitte dick',.5,1.3,.01,2);
slider(s,'wb','Unten dick',.6,1.5,.01,2);

s=sec('Füßchen');
check(s,'feet','Füßchen anzeigen',2);
slider(s,'footSize','Größe',.1,.5,.01,2);
slider(s,'footSpread','Abstand',.2,.6,.01,2);
slider(s,'footFwd','Vorne',.1,.7,.01,2);

s=sec('Ohren');
check(s,'ears','Ohren anzeigen',2);
slider(s,'earSize','Größe',.1,.8,.01,2);
slider(s,'earSpread','Abstand',.3,.9,.01,2);
slider(s,'earUp','Höhe',.4,.95,.01,2);
slider(s,'earW','Breite',.14,.45,.01,2);
choice(s,[['rund','Bär'],['katze','Katze'],['hase','Hase'],['haengend','Hängend'],['fledermaus','Fledermaus'],['knubbel','Knubbel']],()=>S.earType,v=>{S.earType=v;apply(2);});
check(s,'earInner','Innenohr rosa',2);

s=sec('Schwanz');
check(s,'tail','Stummelschwanz',2);
choice(s,[['rund','Rund'],['spitz','Spitz'],['hoch','Hoch'],['doppel','Doppel']],()=>S.tailType,v=>{S.tailType=v;apply(2);});
slider(s,'tailSize','Größe',.1,.6,.01,2);
slider(s,'tailY','Höhe',-.9,0,.01,2);

s=sec('Hände');
check(s,'hands','Knubbelhände anzeigen',2);
slider(s,'handSize','Größe',.1,.5,.01,2);
slider(s,'handY','Höhe',-.5,.2,.01,2);
slider(s,'handFwd','Vorne',0,.6,.01,2);

s=sec('Gesicht');
choice(s,[[false,'Weiß'],[true,'Schwarz']],()=>S.faceDark,v=>{S.faceDark=v;apply(1);});
slider(s,'faceW','Breite',.3,1.05,.01,2);
slider(s,'faceH','Höhe',.2,.9,.01,2);
slider(s,'faceY','Lage',-.2,.3,.01,2);
slider(s,'faceN','Eckigkeit',2,7,.1,2);
slider(s,'fDepth','Einsatztiefe',0,.2,.005,2);
slider(s,'fSoft','Kapuzenrand',.08,.4,.01,2);
slider(s,'lip','Randwulst',0,.18,.002,2);
slider(s,'over','Überhang',0,.15,.005,2);
slider(s,'fold','Falten',0,1,.01,2);
slider(s,'foldN','Faltenzahl',4,36,2,2);
slider(s,'crease','Rille',0,.03,.001,2);
slider(s,'fRound','Wölbung',0,.12,.002,2);
slider(s,'fBrow','Stirn',0,.08,.002,2);
slider(s,'fCheek','Wangenform',0,.1,.002,2);
slider(s,'fChin','Kinn',0,.08,.002,2);
slider(s,'rim','Saumschatten',0,.12,.005,0);
slider(s,'cheek','Wangenröte',0,1,.01,0);

s=sec('Augen');
slider(s,'eyeSize','Größe',.02,.1,.002,1);
slider(s,'eyeGap','Abstand',.06,.5,.005,1);
slider(s,'eyeY','Höhe',-.15,.3,.005,1);
slider(s,'eyeStretch','Streckung',.8,1.8,.01,1);
check(s,'glint','Glanzpunkt',1);
check(s,'socket','Augenhöhlen',2);
slider(s,'socketD','Höhle Tiefe',0,.06,.002,2);

s=sec('Mund');
choice(s,[['none','Keiner'],['dot','Punkt'],['smile','Lächeln'],['cat','Katze'],['open','Offen'],['wide','Breit offen'],['gross','Groß offen'],['riesig','Riesig breit'],['schnute','Schnute'],['ente','Entenschnute'],['slit','Schlitz']],()=>S.mouth,v=>{S.mouth=v;apply(2);});
slider(s,'mouthSize','Größe',.02,.2,.002,2);
slider(s,'mouthY','Höhe',-.25,.15,.005,2);
slider(s,'mouthDepth','Tiefe',0,.12,.002,2);

s=sec('Grundfarbe');
swatches(s,PASTEL,()=>S.base,c=>{S.base=c;dirty=true;});

s=sec('Muster füllen');
choice(s,[['none','Kein'],['camo','Camouflage'],['dots','Punkte'],['stripes','Streifen']],()=>S.pat,v=>{S.pat=v;genFill();});
choice(s,[['h','Quer'],['v','Längs'],['d','Schräg']],()=>S.patStripe,v=>{S.patStripe=v;if(S.pat==='stripes')genFill();});
slider(s,'patScale','Dichte',2,40,1,'fill');
slider(s,'patSize','Anteil',.1,.9,.01,'fill');
{
  const w=el('div','btns');
  S.patCols.forEach((c,k)=>{
    const i=el('input','pick');i.type='color';
    i.oninput=()=>{S.patCols[k]=i.value;scheduleFill();};
    syncs.push(()=>{i.value=S.patCols[k];});
    w.appendChild(i);
  });
  s.appendChild(w);
  const PALS={Moos:['#a9b58d','#7d8b62','#4f5b45'],Sand:['#e6d3ad','#c9a36b','#8a6a45'],Beere:['#e9a9a0','#cf5a43','#7a2f2c'],Meer:['#b9d0dc','#7ea3b8','#3f5d7a']};
  const pw=el('div','btns');
  Object.entries(PALS).forEach(([n,cc])=>{
    const b=el('button');b.textContent=n;
    b.onclick=()=>{S.patCols=cc.slice();syncAll();if(S.pat!=='none')genFill();};pw.appendChild(b);
  });
  const nb=el('button');nb.textContent='Camo neu mischen';
  nb.onclick=()=>{S.patSeed=Math.floor(Math.random()*1e6);if(S.pat==='camo')genFill();};pw.appendChild(nb);
  s.appendChild(pw);
}

s=sec('Malen');
choice(s,[['paint','Pinsel'],['erase','Radierer'],['orbit','Drehen']],()=>S.tool,v=>{S.tool=v;});
choice(s,[['round','Rund'],['soft','Weich'],['dot','Punkte'],['flower','Blume'],['diamond','Raute']],()=>S.tip,v=>{S.tip=v;});
slider(s,'size','Größe',3,70,1,null);
swatches(s,PASTEL,()=>S.brush,c=>{S.brush=c;});
check(s,'sym','Symmetrisch malen');
actions(s,[['Rückgängig',doUndo],['Bemalung löschen',clearPaint]]);


s=sec('Export');
slider(s,'cm','Figurhöhe im GLB (cm)',3,12,.5,null);
actions(s,[
  ['GLB herunterladen',exportGLB],
  ['PNG speichern',()=>{renderer.render(scene,camera);renderer.domElement.toBlob(b=>download(b,'blob.png'));}],
  ['Textur speichern',()=>{bakeTextures().color.toBlob(b=>download(b,'blob_textur.png'));}]
]);

/* ───────────── Würfeln ───────────── */
function randomize(){
  Object.assign(S,PRESETS[pickOf(Object.keys(PRESETS))]);
  S.sx*=rand(.94,1.06);S.sy*=rand(.94,1.06);S.sz*=rand(.94,1.06);
  S.feet=true;S.footSize=rand(.2,.3);S.footSpread=rand(.36,.48);S.footFwd=rand(.42,.58);
  S.ears=Math.random()<.5;S.earSize=rand(.24,.36);S.earSpread=rand(.45,.62);S.earUp=rand(.74,.86);S.earW=rand(.18,.26);S.earInner=Math.random()<.7;
  S.wb*=rand(.94,1.06);S.wm*=rand(.94,1.06);S.wt*=rand(.94,1.06);
  S.lip=rand(.08,.13);S.over=rand(.03,.08);S.fold=rand(.2,.6);S.foldN=pickOf([12,14,16,20]);
  S.earType=pickOf(['rund','katze','hase','haengend','fledermaus','knubbel']);
  S.hands=Math.random()<.4;S.handSize=rand(.2,.3);S.handY=rand(-.3,-.1);S.handFwd=rand(.2,.4);
  S.socket=Math.random()<.7;S.brows=false;
  S.tail=Math.random()<.4;S.tailType=pickOf(['rund','spitz','hoch','doppel']);S.tailSize=rand(.34,.5);S.tailY=rand(-.85,-.6);
  S.mouthDepth=rand(.04,.08);
  S.faceDark=Math.random()<.25;
  S.faceW=rand(.74,.9);S.faceH=rand(.46,.58);S.faceY=rand(-.02,.08);S.faceN=rand(2,3.6);
  S.fDepth=rand(.07,.12);S.fCheek=rand(.025,.055);S.fBrow=rand(.015,.035);S.fChin=rand(.02,.04);S.fRound=rand(.03,.06);
  S.rim=Math.random()<.7?rand(.03,.08):0;
  S.eyeGap=rand(.24,.34);S.eyeY=rand(.03,.09);
  S.eyeSize=rand(.06,.08);S.eyeStretch=rand(1,1.5);
  S.mouth=pickOf(['dot','smile','smile','cat','open','wide','gross','riesig','schnute','ente','slit']);
  S.mouthSize=rand(.05,.09);
  S.mouthY=Math.max(-S.faceH*.7,S.eyeY-rand(.15,.2));
  S.cheek=rand(.2,.8);
  const h=Math.random();
  S.base='#'+new THREE.Color().setHSL(h,rand(.4,.65),rand(.76,.86)).getHexString();
  S.brush='#'+new THREE.Color().setHSL((h+rand(.42,.58))%1,rand(.35,.55),rand(.48,.6)).getHexString();
  pushUndo();pctx.clearRect(0,0,TW,TH);
  S.pat=pickOf(['none','camo','camo','dots','dots','stripes','stripes']);
  S.patStripe=pickOf(['h','v','d']);S.patScale=Math.round(rand(7,16));S.patSize=rand(.3,.55);
  S.patSeed=Math.floor(Math.random()*1e6);
  const L=(dh,s,l)=>'#'+new THREE.Color().setHSL((h+dh+1)%1,s,l).getHexString();
  S.patCols=S.pat==='camo'?[L(.08,.3,.62),L(0,.3,.5),L(-.08,.28,.38)]:[L(.5,.4,.6),L(.45,.38,.5),L(.55,.35,.4)];
  apply(2);syncAll();genFill();
}
document.getElementById('rnd').onclick=randomize;
document.getElementById('glb').onclick=exportGLB;

/* ───────────── Loop (statisch, keine Animation) ───────────── */
function tick(){
  requestAnimationFrame(tick);
  if(dirty){composite();dirty=false;}
  controls.update();
  renderer.render(scene,camera);
}
apply(2);syncAll();resize();tick();
</script>
</body>
</html>
script.js
console.log('läuft');
style.css
body {
  font-family: system-ui, sans-serif;
  margin: 2rem;
}