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>