index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ROOT MESH DESIGNER</title>
<script type="importmap">
{"imports":{
"three":"https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.module.min.js",
"three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"
}}
</script>
<style>
:root{ --ink:#111; --ink-2:#555; --ink-3:#8a8a8a; --line:#e2e2e2; --line-2:#f0f0f0; --panel-w:292px; }
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#fff;color:var(--ink);
font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:11px;-webkit-font-smoothing:antialiased}
#view{position:fixed;inset:0 var(--panel-w) 0 0}
canvas{display:block;touch-action:none}
#panel{position:fixed;top:0;right:0;bottom:0;width:var(--panel-w);background:#fff;
border-left:1px solid var(--line);overflow-y:auto;overscroll-behavior:contain}
#panel::-webkit-scrollbar{width:6px} #panel::-webkit-scrollbar-thumb{background:#ddd}
.head{padding:16px 16px 12px;border-bottom:1px solid var(--line)}
.head h1{margin:0;font-size:11px;letter-spacing:.14em;font-weight:600}
.sec{border-bottom:1px solid var(--line-2);padding:10px 16px 14px}
.sec>h2{margin:0 0 10px;font-size:9px;letter-spacing:.18em;color:var(--ink-3);font-weight:600;text-transform:uppercase}
.row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 3px}
.row label{color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row .val{color:var(--ink);font-variant-numeric:tabular-nums;min-width:44px;text-align:right}
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:14px;background:transparent;margin:0 0 9px;cursor:ew-resize}
input[type=range]::-webkit-slider-runnable-track{height:1px;background:var(--line)}
input[type=range]::-moz-range-track{height:1px;background:var(--line)}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:9px;height:9px;background:var(--ink);border-radius:50%;margin-top:-4px}
input[type=range]::-moz-range-thumb{width:9px;height:9px;background:var(--ink);border:0;border-radius:50%}
input[type=range].gray{height:20px;margin:0 0 9px}
input[type=range].gray::-webkit-slider-runnable-track{height:16px;border:1px solid var(--line);
background:linear-gradient(90deg,#fff 0%,#111 100%)}
input[type=range].gray::-moz-range-track{height:16px;border:1px solid var(--line);
background:linear-gradient(90deg,#fff 0%,#111 100%)}
input[type=range].gray::-webkit-slider-thumb{-webkit-appearance:none;width:5px;height:24px;border-radius:0;
background:#fff;border:1px solid var(--ink);margin-top:-5px;box-shadow:0 0 0 1px #fff}
input[type=range].gray::-moz-range-thumb{width:5px;height:24px;border-radius:0;background:#fff;border:1px solid var(--ink)}
.chip{width:11px;height:11px;border:1px solid var(--line);flex:none}
.check{display:flex;align-items:center;gap:8px;margin:0 0 7px;cursor:pointer;color:var(--ink-2)}
.check input{appearance:none;width:11px;height:11px;border:1px solid #bbb;background:#fff;margin:0;cursor:pointer;flex:none}
.check input:checked{background:var(--ink);border-color:var(--ink)}
select,input[type=color],input[type=text]{width:100%;font:inherit;color:var(--ink);background:#fff;
border:1px solid var(--line);padding:5px 6px;margin:0 0 8px}
input[type=color]{padding:2px;height:24px;cursor:pointer} select{cursor:pointer}
button{width:100%;font:inherit;letter-spacing:.08em;color:var(--ink);background:#fff;border:1px solid var(--ink);
padding:8px;margin:0 0 6px;cursor:pointer;text-transform:uppercase}
button:hover{background:var(--ink);color:#fff}
button.ghost{border-color:var(--line);color:var(--ink-2)}
button.ghost:hover{background:#f6f6f6;color:var(--ink);border-color:#ccc}
.btn2{display:flex;gap:6px}
.grp{border-bottom:1px solid var(--line)}
.grph{display:flex;align-items:center;justify-content:space-between;width:100%;margin:0;padding:11px 16px;
border:0;background:#fff;color:var(--ink);font:inherit;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer}
.grph:hover{background:#f7f7f7;color:var(--ink)}
.grph i{font-style:normal;color:var(--ink-3);font-size:11px}
.grpb{border-top:1px solid var(--line-2)}
.grpb .sec:last-child{border-bottom:0}
.swatch{display:flex;gap:4px;margin:0 0 8px}
.swatch button{width:auto;flex:1;padding:7px 0;margin:0;border-color:var(--line);font-size:9px;letter-spacing:.06em}
.swatch button.on{border-color:var(--ink);background:var(--ink);color:#fff}
.note{color:var(--ink-3);line-height:1.6;margin:2px 0 0}
#bar{position:fixed;top:12px;left:12px;display:flex;gap:4px;z-index:20}
#bar button{width:auto;margin:0;padding:7px 11px;font-size:10px;letter-spacing:.1em;
border:1px solid var(--line);background:rgba(255,255,255,.94);color:var(--ink-2)}
#bar button:hover{background:var(--ink);color:#fff;border-color:var(--ink)}
#bar button.on{background:var(--ink);color:#fff;border-color:var(--ink)}
#bar button:disabled{opacity:.3} #bar button:disabled:hover{background:rgba(255,255,255,.94);color:var(--ink-2);border-color:var(--line)}
#bar .gap{width:8px}
#status{position:fixed;left:16px;bottom:14px;color:var(--ink-3);letter-spacing:.04em;pointer-events:none}
#status b{color:var(--ink);font-weight:600}
#drop{position:fixed;inset:0 var(--panel-w) 0 0;display:none;place-items:center;background:rgba(255,255,255,.92);
border:1px dashed #bbb;color:var(--ink);letter-spacing:.1em}
#err{position:fixed;inset:0;display:none;place-items:center;padding:40px;text-align:center;color:#b00;background:#fff;line-height:1.7}
#ovl{position:fixed;inset:0 var(--panel-w) 0 0;display:none;place-items:center;background:rgba(255,255,255,.82);z-index:40}
#ovl .box{width:250px;text-align:left}
#ovl .lab{color:var(--ink);letter-spacing:.1em;margin:0 0 9px}
#ovl .bar{height:1px;background:var(--line)}
#ovl .bar i{display:block;height:1px;background:var(--ink);width:0%;transition:width .12s linear}
@media (max-width:820px){ :root{--panel-w:100%} #view,#drop{inset:0 0 52% 0} #panel{top:48%;border-left:0;border-top:1px solid var(--line)} }
</style>
</head>
<body>
<div id="view"></div>
<div id="drop">GLB / GLTF / FBX HIER ABLEGEN</div>
<div id="bar">
<button id="bLayer1">Ebene 1 · Dichte</button>
<button id="bLayer2">Ebene 2 · Abstand</button>
<span class="gap"></span>
<button id="bPose">Pose</button>
<span class="gap"></span>
<button id="bGrow">Wurzeln wachsen</button>
<button id="bMesh">Mesh bauen</button>
<button id="bDel">Löschen</button>
<button id="bClearMap">Ebene leeren</button>
<span class="gap"></span>
<button id="bUndo" title="Rückgängig (Strg+Z)">↶</button>
<button id="bRedo" title="Wiederholen (Strg+Umschalt+Z)">↷</button>
</div>
<div id="status"></div>
<div id="panel"><div class="head"><h1>ROOT MESH DESIGNER</h1></div><div id="ui"></div></div>
<div id="ovl"><div class="box"><div class="lab" id="ovlTxt">…</div><div class="bar"><i id="ovlBar"></i></div></div></div>
<div id="err"></div>
<script type="module">
// ═════════════════════════════════════════════════════════════════════════════
// 0 · THREE
// ═════════════════════════════════════════════════════════════════════════════
let THREE, addonsAvailable = true;
try{
THREE = await import('three');
}catch(e){
addonsAvailable = false;
try{ THREE = await import('https://unpkg.com/three@0.160.0/build/three.module.js'); }
catch(e2){
const d=document.getElementById('err'); d.style.display='grid';
d.textContent='three.js konnte nicht geladen werden. Netzwerk oder CDN prüfen.';
throw e2;
}
}
const V3=THREE.Vector3, QT=THREE.Quaternion;
const clamp=THREE.MathUtils.clamp;
// ═════════════════════════════════════════════════════════════════════════════
// 1 · PARAMETER
// ═════════════════════════════════════════════════════════════════════════════
const P = {
mapRes:1024, layer:0, poseMode:false,
// Pinsel
brushMode:'paint', brushRadius:0.055, hardness:0.35, flow:0.55, target:0.15,
normalLimit:0.25, mirror:true,
// Saat · Kreispackung aus Ebene 1
rMin:0.005, rMax:0.030, gamma:1.6, jitter:0.25, packing:0.85,
samples:30000, densityBias:0.35, alphaMin:0.10, relax:6, seed:1,
// Wuchsraum: Haut = Boden, Ebene 2 = Deckenhöhe
lift:0.05, hug:0.004, stick:0.85, settle:0.55, steps:110, stepLen:0.005, turn:0.35,
curlAmp:0.40, noiseScale:5.5, gravity:0.0, killOffPaint:true,
// Stützen (gedachte Zylinder auf der Haut)
pillarsOn:true, pMode:'grid', pSpace:1.0, pCols:0.045, pRows:0.045,
pFill:0.55, pHeight:1.2, pPush:2.2, pSwirl:0.9, pSwirlRange:0.022, showPillars:true,
// Platzsuche & Verdichtung
senseDist:0.016, senseAngle:0.6, senseForce:1.20,
occCell:0.006, occMax:6, fuse:true,
oscAmp:0.30, oscFreq:0.45, oscJitter:0.45,
// Schwarm
nbRadius:0.024, sepDist:0.008, sepForce:1.20, alignForce:0.25, cohForce:0.15, twistForce:0.10,
// Verzweigung
branchProb:0.055, branchAngle:0.85, branchDecay:0.80, maxDepth:4, maxAgents:7000,
// Querschnitt
rootScale:0.16, rootMin:0.0006, thick:[[0,1],[0.35,0.72],[1,0.30]], tubeSides:4, simplify:0.14,
tipSpheres:false, tipScale:1.8, tipDetail:0,
shootsOn:false, shootProb:0.30, shootLen:0.055, shootSteps:16,
shootThick:1.2, shootWobble:0.9, bladeWidth:2.5, bladeThick:0.30,
bladeRise:0.004, bladeSides:6,
rootColor:'#1a1a1a', stemColor:'#2f7d4f', shade:0.45,
// Ansicht
poseMirror:true, showBody:true, ghost:0.16, showPaint:true, otherLayer:0.45,
showRoots:true, showPreview:true,
// Export
exportFormat:'glb', exportBody:false
};
// ═════════════════════════════════════════════════════════════════════════════
// 2 · SZENE
// ═════════════════════════════════════════════════════════════════════════════
const viewEl=document.getElementById('view');
const renderer=new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setClearColor(0xffffff,1);
viewEl.appendChild(renderer.domElement);
const el=renderer.domElement;
const scene=new THREE.Scene();
const camera=new THREE.PerspectiveCamera(38,1,0.01,100);
scene.add(new THREE.HemisphereLight(0xffffff,0xdedede,2.1));
const keyL=new THREE.DirectionalLight(0xffffff,1.4); keyL.position.set(1.4,2.2,1.8); scene.add(keyL);
const fillL=new THREE.DirectionalLight(0xffffff,0.5); fillL.position.set(-1.6,0.6,-1.2); scene.add(fillL);
const LIGHT_DIR=new V3(0.55,0.72,0.62).normalize();
const cam={target:new V3(0,0.95,0),theta:0.5,phi:1.32,dist:2.8};
function applyCam(){
const s=Math.sin(cam.phi),r=cam.dist;
camera.position.set(cam.target.x+r*s*Math.sin(cam.theta),
cam.target.y+r*Math.cos(cam.phi),
cam.target.z+r*s*Math.cos(cam.theta));
camera.lookAt(cam.target); camera.updateMatrixWorld();
}
function resize(){
const w=viewEl.clientWidth,h=viewEl.clientHeight;
renderer.setSize(w,h); camera.aspect=w/h; camera.updateProjectionMatrix(); applyCam();
}
addEventListener('resize',resize);
let rndState=1;
function srand(s){ rndState=(s>>>0)||1; }
function rnd(){
rndState|=0; rndState=rndState+0x6D2B79F5|0;
let t=Math.imul(rndState^rndState>>>15,1|rndState);
t=t+Math.imul(t^t>>>7,61|t)^t;
return ((t^t>>>14)>>>0)/4294967296;
}
// ═════════════════════════════════════════════════════════════════════════════
// 3 · DUMMY-KÖRPER (Rückfallebene, UV-Atlas ohne Überlappung)
// ═════════════════════════════════════════════════════════════════════════════
function frames(pts){
const N=pts.length,T=[],Nr=[],B=[];
for(let i=0;i<N;i++){
const t=new V3().subVectors(pts[Math.min(N-1,i+1)],pts[Math.max(0,i-1)]);
if(t.lengthSq()<1e-14) t.set(0,1,0);
T.push(t.normalize());
}
let n0=new V3(0,1,0); if(Math.abs(T[0].dot(n0))>0.9) n0.set(1,0,0);
Nr.push(new V3().crossVectors(T[0],n0).normalize());
for(let i=1;i<N;i++){
const ax=new V3().crossVectors(T[i-1],T[i]); const n=Nr[i-1].clone();
if(ax.lengthSq()>1e-12) n.applyAxisAngle(ax.normalize(),Math.acos(clamp(T[i-1].dot(T[i]),-1,1)));
Nr.push(n.normalize());
}
for(let i=0;i<N;i++) B.push(new V3().crossVectors(T[i],Nr[i]).normalize());
return {T,N:Nr,B};
}
function loft(sections,radial,sub){
const ctrl=sections.map(s=>new V3(s[0],s[1],s[2]));
const rad=sections.map(s=>new V3(s[3],s[4],0));
const cC=new THREE.CatmullRomCurve3(ctrl,false,'centripetal',0.5);
const rC=new THREE.CatmullRomCurve3(rad,false,'catmullrom',0.5);
const steps=Math.max(2,(sections.length-1)*sub), pts=[], rs=[];
for(let i=0;i<=steps;i++){const t=i/steps; pts.push(cC.getPoint(t)); rs.push(rC.getPoint(t));}
const F=frames(pts), pos=[], idx=[], uv=[];
for(let i=0;i<pts.length;i++) for(let j=0;j<=radial;j++){
const a=(j%radial)/radial*Math.PI*2;
const v=pts[i].clone().addScaledVector(F.N[i],Math.cos(a)*Math.max(rs[i].x,0.001))
.addScaledVector(F.B[i],Math.sin(a)*Math.max(rs[i].y,0.001));
pos.push(v.x,v.y,v.z); uv.push(j/radial,i/(pts.length-1));
}
const W=radial+1;
for(let i=0;i<pts.length-1;i++) for(let j=0;j<radial;j++){
const a=i*W+j,b=i*W+j+1;
idx.push(a,b,b+W, a,b+W,a+W);
}
return {pos,idx,uv};
}
const PARTS=[
{radial:22,sub:4,s:[[0,0.90,0,0.095,0.125],[0,0.98,0,0.115,0.155],[0,1.06,0,0.101,0.140],
[0,1.14,0,0.095,0.130],[0,1.24,0,0.110,0.155],[0,1.34,0,0.116,0.176],[0,1.43,0,0.104,0.170],[0,1.47,0,0.075,0.092]]},
{radial:20,sub:4,s:[[0,1.44,0,0.055,0.055],[0,1.53,0,0.058,0.058],[0,1.58,0.004,0.082,0.078],
[0,1.64,0.006,0.098,0.090],[0,1.70,0.004,0.093,0.086],[0,1.755,0,0.062,0.058],[0,1.78,0,0.02,0.02]]},
{radial:14,sub:4,s:[[0.14,1.44,0,0.060,0.060],[0.30,1.44,0,0.048,0.048],[0.45,1.44,0,0.042,0.042],
[0.58,1.44,0,0.036,0.036],[0.70,1.44,0,0.031,0.031],[0.79,1.44,0,0.040,0.040],[0.88,1.44,0,0.014,0.014]]},
{radial:14,sub:4,s:[[-0.14,1.44,0,0.060,0.060],[-0.30,1.44,0,0.048,0.048],[-0.45,1.44,0,0.042,0.042],
[-0.58,1.44,0,0.036,0.036],[-0.70,1.44,0,0.031,0.031],[-0.79,1.44,0,0.040,0.040],[-0.88,1.44,0,0.014,0.014]]},
{radial:16,sub:4,s:[[0.10,0.97,0,0.098,0.098],[0.10,0.76,0,0.083,0.083],[0.11,0.55,0,0.062,0.062],
[0.11,0.36,0,0.053,0.053],[0.11,0.12,0,0.042,0.042],[0.11,0.045,0.02,0.042,0.042],[0.11,0.03,0.17,0.026,0.030]]},
{radial:16,sub:4,s:[[-0.10,0.97,0,0.098,0.098],[-0.10,0.76,0,0.083,0.083],[-0.11,0.55,0,0.062,0.062],
[-0.11,0.36,0,0.053,0.053],[-0.11,0.12,0,0.042,0.042],[-0.11,0.045,0.02,0.042,0.042],[-0.11,0.03,0.17,0.026,0.030]]},
];
function buildDummy(){
const pos=[],idx=[],uv=[]; let off=0;
PARTS.forEach((part,pi)=>{
const g=loft(part.s,part.radial,part.sub);
for(const v of g.pos) pos.push(v);
const col=pi%3,row=(pi/3)|0;
for(let k=0;k<g.uv.length;k+=2){
uv.push(0.012+col*0.333+g.uv[k]*0.309, 0.012+row*0.5+g.uv[k+1]*0.476);
}
for(const i of g.idx) idx.push(i+off);
off=pos.length/3;
});
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
geo.setAttribute('uv',new THREE.Float32BufferAttribute(uv,2));
geo.setIndex(idx); geo.computeVertexNormals();
const mesh=new THREE.Mesh(geo,new THREE.MeshBasicMaterial());
const root=new THREE.Group(); root.add(mesh); root.updateMatrixWorld(true);
return {root,mesh};
}
// ═════════════════════════════════════════════════════════════════════════════
// 4 · KÖRPER
// ═════════════════════════════════════════════════════════════════════════════
let body=null, bodyMat=null, modelName='—';
let srcRoot=null, srcMesh=null;
let triA=null,triB=null,triC=null,triCDF=null,triArea=0,texelSpacing=0.002,uvScale=1;
function bodyShader(){
return new THREE.ShaderMaterial({
transparent:true, depthWrite:true, side:THREE.FrontSide,
uniforms:{
map:{value:null}, uGhost:{value:P.ghost}, uShow:{value:1}, uOther:{value:P.otherLayer},
uLayer:{value:P.layer}, uBase:{value:new THREE.Color(0xdadada)}, uL:{value:LIGHT_DIR.clone()},
uOtherCol:{value:new THREE.Color(0.09,0.60,0.34)}
},
vertexShader:`
varying vec2 vUv; varying vec3 vN;
void main(){
vUv=uv;
vN=normalize(mat3(modelMatrix)*normal);
gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);
}`,
fragmentShader:`
uniform sampler2D map; uniform float uGhost,uShow,uOther,uLayer;
uniform vec3 uBase,uL,uOtherCol;
varying vec2 vUv; varying vec3 vN;
void main(){
vec4 t=texture2D(map,vUv);
vec2 A=(uLayer<0.5)?t.rg:t.ba;
vec2 B=(uLayer<0.5)?t.ba:t.rg;
float aA=A.y*uShow, aB=B.y*uOther*uShow;
vec3 col=uBase;
col=mix(col,uOtherCol*(0.35+0.65*B.x),aB);
col=mix(col,vec3(A.x),aA);
vec3 n=normalize(vN);
float key=max(dot(n,normalize(uL)),0.0);
float hemi=0.5+0.5*n.y;
float sh=0.34+0.42*key+0.24*hemi;
float cover=max(aA,aB);
gl_FragColor=vec4(col*sh, mix(1.0, mix(uGhost,1.0,cover), uShow));
}`
});
}
function disposeBody(){
if(!body) return;
scene.remove(body); body.geometry.dispose(); bodyMat.dispose(); body=null;
}
function normalizeObject(obj,targetHeight=1.78){
obj.updateMatrixWorld(true);
const box=new THREE.Box3().setFromObject(obj), size=new V3();
box.getSize(size);
if(size.y>1e-6) obj.scale.multiplyScalar(targetHeight/size.y);
obj.updateMatrixWorld(true); box.setFromObject(obj);
const c=new V3(); box.getCenter(c);
obj.position.x-=c.x; obj.position.z-=c.z; obj.position.y-=box.min.y;
obj.updateMatrixWorld(true);
}
const _bv=new V3();
function deform(){
srcRoot.updateMatrixWorld(true);
if(srcMesh.isSkinnedMesh) srcMesh.skeleton.update();
const sp=srcMesh.geometry.attributes.position;
const pa=body.geometry.attributes.position;
const skinned=srcMesh.isSkinnedMesh&&srcMesh.getVertexPosition;
for(let i=0;i<sp.count;i++){
if(skinned) srcMesh.getVertexPosition(i,_bv); else _bv.fromBufferAttribute(sp,i);
srcMesh.localToWorld(_bv);
pa.setXYZ(i,_bv.x,_bv.y,_bv.z);
}
pa.needsUpdate=true;
body.geometry.computeVertexNormals();
body.geometry.computeBoundingSphere();
}
function buildTriangleList(){
const g=body.geometry, p=g.attributes.position, ix=g.index, uvA=g.attributes.uv;
const nt=ix.count/3;
if(!triA||triA.length!==nt*3){
triA=new Float32Array(nt*3); triB=new Float32Array(nt*3); triC=new Float32Array(nt*3);
triCDF=new Float32Array(nt);
}
triArea=0;
let uvArea=0;
const a=new V3(),b=new V3(),c=new V3(),e1=new V3(),e2=new V3();
for(let t=0;t<nt;t++){
const i0=ix.getX(t*3),i1=ix.getX(t*3+1),i2=ix.getX(t*3+2);
a.fromBufferAttribute(p,i0); b.fromBufferAttribute(p,i1); c.fromBufferAttribute(p,i2);
triA[t*3]=a.x;triA[t*3+1]=a.y;triA[t*3+2]=a.z;
triB[t*3]=b.x;triB[t*3+1]=b.y;triB[t*3+2]=b.z;
triC[t*3]=c.x;triC[t*3+1]=c.y;triC[t*3+2]=c.z;
e1.subVectors(b,a); e2.subVectors(c,a);
triArea+=0.5*e1.cross(e2).length();
triCDF[t]=triArea;
const du1=uvA.getX(i1)-uvA.getX(i0), dv1=uvA.getY(i1)-uvA.getY(i0);
const du2=uvA.getX(i2)-uvA.getX(i0), dv2=uvA.getY(i2)-uvA.getY(i0);
uvArea+=0.5*Math.abs(du1*dv2-du2*dv1);
}
uvScale=uvArea>1e-12?Math.sqrt(triArea/uvArea):1; // Weltmaß je UV-Einheit
if(texelIds&&texelIds.length) texelSpacing=Math.sqrt(triArea/texelIds.length);
}
function refreshAfterPose(){
buildTriangleList();
tGrid=null; skinLevels=null;
updateTexelPositions();
buildTexelHash();
if(roots.length){ clearRoots(); setStatus('Pose geändert — Wurzeln neu wachsen lassen.'); }
else setStatus();
}
async function setBody(root,mesh,name){
disposeBody();
srcRoot=root; srcMesh=mesh; srcRoot.updateMatrixWorld(true);
const sg=mesh.geometry;
if(!sg.attributes.uv){
paintData=null; texelIds=null; hMap=null; R=0; clearRoots();
setStatus('Modell hat keine UV-Koordinaten — Malen nicht möglich.');
return;
}
const n=sg.attributes.position.count;
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(n*3),3));
geo.setAttribute('uv',new THREE.Float32BufferAttribute(sg.attributes.uv.array.slice(),2));
if(sg.index) geo.setIndex(new THREE.BufferAttribute(sg.index.array.slice(),1));
else{ const idx=new Uint32Array(n); for(let i=0;i<n;i++) idx[i]=i; geo.setIndex(new THREE.BufferAttribute(idx,1)); }
bodyMat=bodyShader();
body=new THREE.Mesh(geo,bodyMat);
body.frustumCulled=false;
scene.add(body);
modelName=name;
initPose();
deform();
buildTriangleList();
clearRoots();
await buildPaintMap(P.mapRes);
buildTriangleList();
resetHistory();
await autoBodypaint();
setStatus('Modell geladen: '+name);
}
function adoptModel(obj,name){
let best=null;
obj.traverse(o=>{
if(!o.isMesh||!o.geometry?.attributes?.position) return;
const score=o.geometry.attributes.position.count*(o.isSkinnedMesh?10:1);
if(!best||score>best.score) best={mesh:o,score};
});
if(!best){ setStatus('Kein Mesh in der Datei gefunden.'); return; }
const group=new THREE.Group(); group.add(obj);
normalizeObject(group);
setBody(group,best.mesh,name);
}
// ── Loader
let GLTFLoader=null,FBXLoader=null,GLTFExporter=null;
async function addon(path){
if(!addonsAvailable) return null;
try{ return await import('three/addons/'+path); }
catch(e){ console.warn(e); addonsAvailable=false; return null; }
}
async function ensureLoaders(){
if(GLTFLoader&&FBXLoader) return true;
const a=await addon('loaders/GLTFLoader.js'); if(!a) return false;
const b=await addon('loaders/FBXLoader.js'); if(!b) return false;
GLTFLoader=a.GLTFLoader; FBXLoader=b.FBXLoader; return true;
}
async function loadFromBuffer(buf,name){
if(!await ensureLoaders()){ setStatus('Loader nicht verfügbar — Dummy bleibt aktiv.'); return; }
setStatus('Lade '+name+' …');
try{
if(/\.fbx$/i.test(name)) adoptModel(new FBXLoader().parse(buf,''),name);
else await new Promise((res,rej)=>new GLTFLoader().parse(buf,'',g=>{adoptModel(g.scene,name);res();},rej));
}catch(e){ console.error(e); setStatus('Datei konnte nicht gelesen werden.'); }
}
async function loadFromURL(url){
let buf;
try{
const r=await fetch(url); if(!r.ok) return false;
buf=await r.arrayBuffer(); if(buf.byteLength<64) return false;
}catch(e){ return false; }
await loadFromBuffer(buf,url.split('/').pop());
return true;
}
async function autoLoad(){
for(const f of ['body_minimal.glb','body.glb','body.gltf','body.fbx','model.glb'])
if(await loadFromURL('./'+f)) return;
}
// ═════════════════════════════════════════════════════════════════════════════
// 5 · POSE
// ═════════════════════════════════════════════════════════════════════════════
let restQ=new Map(), poseQ=new Map(), orderedBones=[], useNameSide=false;
let boneHandles=[], selectedBone=null;
const _qp=new QT(),_qtmp=new QT(),_t1=new V3();
const mirrorQ=q=>new QT(q.x,-q.y,-q.z,q.w);
const SKIP=/(_End$|End$|Index|Middle|Ring|Pinky|Thumb|Eye|Jaw|Toe_)/i;
function initPose(){
restQ.clear(); poseQ.clear(); orderedBones=[]; selectedBone=null;
if(srcMesh.isSkinnedMesh){
const bones=srcMesh.skeleton.bones;
for(const b of bones) restQ.set(b.uuid,b.quaternion.clone());
const depth=b=>{let d=0,p=b.parent; while(p){d++;p=p.parent;} return d;};
orderedBones=bones.slice().sort((a,b)=>depth(a)-depth(b));
useNameSide=bones.some(b=>/Left|Right/i.test(b.name));
}
rebuildBoneHandles();
}
function counterpart(bone){
const n=bone.name; let m=null;
if(/Left/.test(n)) m=n.replace('Left','Right');
else if(/Right/.test(n)) m=n.replace('Right','Left');
else if(/\.L$/.test(n)) m=n.replace(/\.L$/,'.R');
else if(/\.R$/.test(n)) m=n.replace(/\.R$/,'.L');
if(!m) return null;
return srcMesh.skeleton.bones.find(b=>b.name===m)||null;
}
function isSourceSide(bone){
if(useNameSide){
if(/Right/.test(bone.name)||/\.R$/.test(bone.name)) return false;
return true;
}
return _t1.setFromMatrixPosition(bone.matrixWorld).x>=-0.01;
}
const isCenter=bone=>counterpart(bone)===null;
function applyPose(){
if(!srcMesh?.isSkinnedMesh) return;
for(const b of orderedBones){
const rest=restQ.get(b.uuid); if(!rest) continue;
const d=poseQ.get(b.uuid);
if(!d){ b.quaternion.copy(rest); continue; }
if(b.parent) b.parent.getWorldQuaternion(_qp); else _qp.identity();
b.quaternion.copy(_qp).invert().multiply(d).multiply(_qp).multiply(rest);
}
srcRoot.updateMatrixWorld(true);
srcMesh.skeleton.update();
deform();
rebuildBoneHandles();
}
function twistAboutX(q){
const t=new QT(q.x,0,0,q.w);
return t.lengthSq()<1e-12 ? new QT() : t.normalize();
}
function applyWorldDelta(bone,q){
const d=(P.poseMirror&&isCenter(bone))?twistAboutX(q):q;
const next=d.clone().multiply(poseQ.get(bone.uuid)||new QT());
poseQ.set(bone.uuid,next);
const cp=P.poseMirror?counterpart(bone):null;
if(cp) poseQ.set(cp.uuid,mirrorQ(next));
applyPose();
}
function resetPose(){ poseQ.clear(); applyPose(); refreshAfterPose(); }
const worldPosOf=b=>new V3().setFromMatrixPosition(b.matrixWorld);
function firstChildBone(b){ return b.children.find(x=>x.isBone)||null; }
function boneAxis(b){
const o=worldPosOf(b), c=firstChildBone(b);
if(c) return worldPosOf(c).sub(o).normalize();
if(b.parent?.isBone) return o.clone().sub(worldPosOf(b.parent)).normalize();
return new V3(0,1,0);
}
function ringTexture(stroke){
const c=document.createElement('canvas'); c.width=c.height=64;
const x=c.getContext('2d');
x.beginPath(); x.arc(32,32,25,0,Math.PI*2);
x.fillStyle='#fff'; x.fill(); x.lineWidth=8; x.strokeStyle=stroke; x.stroke();
const t=new THREE.CanvasTexture(c); t.needsUpdate=true; return t;
}
const pointOpts={sizeAttenuation:false,map:ringTexture('#111'),transparent:true,alphaTest:0.35,depthTest:false};
const boneGeo=new THREE.BufferGeometry();
boneGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const bonePts=new THREE.Points(boneGeo,new THREE.PointsMaterial({...pointOpts,size:12}));
bonePts.renderOrder=12; bonePts.frustumCulled=false; scene.add(bonePts);
const selGeo=new THREE.BufferGeometry();
selGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const selPt=new THREE.Points(selGeo,new THREE.PointsMaterial({...pointOpts,size:18,color:0xff2200}));
selPt.renderOrder=13; selPt.frustumCulled=false; selPt.visible=false; scene.add(selPt);
const skelGeo=new THREE.BufferGeometry();
skelGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const skelLines=new THREE.LineSegments(skelGeo,new THREE.LineBasicMaterial({color:0xbbbbbb,depthTest:false}));
skelLines.renderOrder=11; skelLines.frustumCulled=false; scene.add(skelLines);
function rebuildBoneHandles(){
boneHandles=[];
if(srcMesh?.isSkinnedMesh){
for(const b of srcMesh.skeleton.bones){
if(SKIP.test(b.name)) continue;
if(P.poseMirror&&!isSourceSide(b)) continue;
boneHandles.push({bone:b,world:new V3().setFromMatrixPosition(b.matrixWorld)});
}
}
const arr=new Float32Array(Math.max(1,boneHandles.length)*3);
boneHandles.forEach((h,i)=>{arr[i*3]=h.world.x;arr[i*3+1]=h.world.y;arr[i*3+2]=h.world.z;});
boneGeo.setAttribute('position',new THREE.Float32BufferAttribute(arr,3));
boneGeo.setDrawRange(0,boneHandles.length);
const seg=[];
if(srcMesh?.isSkinnedMesh){
const set=new Set(srcMesh.skeleton.bones);
for(const b of srcMesh.skeleton.bones){
if(!b.parent||!set.has(b.parent)) continue;
const a=new V3().setFromMatrixPosition(b.parent.matrixWorld);
const c=new V3().setFromMatrixPosition(b.matrixWorld);
seg.push(a.x,a.y,a.z,c.x,c.y,c.z);
}
}
skelGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(seg.length?seg:[0,0,0]),3));
skelGeo.setDrawRange(0,seg.length/3);
if(selectedBone){
const h=boneHandles.find(x=>x.bone===selectedBone);
if(h){
const a=selGeo.attributes.position.array;
a[0]=h.world.x; a[1]=h.world.y; a[2]=h.world.z;
selGeo.attributes.position.needsUpdate=true;
}else selectedBone=null;
}
}
// ═════════════════════════════════════════════════════════════════════════════
// 6 · MALKARTE — zwei Ebenen in einer RGBA-Textur
// R = Wert Ebene 1 · G = Deckung Ebene 1 · B = Wert Ebene 2 · A = Deckung Ebene 2
// ═════════════════════════════════════════════════════════════════════════════
let R=0;
let texPos=null,texNrm=null,texValid=null,texelIds=null,texTri=null,texBary=null;
let paintData=null,paintTex=null,paintDirty=false;
let hMap=null,hStart=null,hIdx=null;
const HCELL=0.035;
const cellKey=(x,y,z)=>(x+512)+(y+512)*1024+(z+512)*1048576;
const frameP=()=>new Promise(r=>requestAnimationFrame(r));
const vOff=()=>P.layer*2, aOff=()=>P.layer*2+1;
async function buildPaintMap(res){
if(paintTex){ paintTex.dispose(); paintTex=null; }
R=res|0;
const N=R*R;
texValid=new Uint8Array(N); texTri=new Int32Array(N); texBary=new Float32Array(N*2);
texPos=new Float32Array(N*3); texNrm=new Float32Array(N*3);
paintData=new Uint8Array(N*4);
const uv=body.geometry.attributes.uv, ix=body.geometry.index;
const nt=ix.count/3;
let overdraw=0;
setStatus('Karte wird aufgebaut …');
await frameP();
for(let t=0;t<nt;t++){
const i0=ix.getX(t*3),i1=ix.getX(t*3+1),i2=ix.getX(t*3+2);
const u0=uv.getX(i0)*R, v0=uv.getY(i0)*R;
const u1=uv.getX(i1)*R, v1=uv.getY(i1)*R;
const u2=uv.getX(i2)*R, v2=uv.getY(i2)*R;
const den=(v1-v2)*(u0-u2)+(u2-u1)*(v0-v2);
if(Math.abs(den)<1e-12) continue;
const inv=1/den;
const x0=Math.max(0,Math.floor(Math.min(u0,u1,u2))-1), x1=Math.min(R-1,Math.ceil(Math.max(u0,u1,u2))+1);
const y0=Math.max(0,Math.floor(Math.min(v0,v1,v2))-1), y1=Math.min(R-1,Math.ceil(Math.max(v0,v1,v2))+1);
for(let y=y0;y<=y1;y++){
const py=y+0.5;
for(let x=x0;x<=x1;x++){
const px=x+0.5;
let l0=((v1-v2)*(px-u2)+(u2-u1)*(py-v2))*inv;
let l1=((v2-v0)*(px-u2)+(u0-u2)*(py-v2))*inv;
let l2=1-l0-l1;
const inside=(l0>=-0.002&&l1>=-0.002&&l2>=-0.002);
const k=y*R+x;
if(!inside){
if(texValid[k]) continue;
l0=clamp(l0,0,1); l1=clamp(l1,0,1); l2=clamp(l2,0,1);
const s=l0+l1+l2||1; l0/=s; l1/=s; l2/=s;
if(Math.hypot(px-(u0*l0+u1*l1+u2*l2),py-(v0*l0+v1*l1+v2*l2))>1.6) continue;
}else if(texValid[k]===1) overdraw++;
texTri[k]=t; texBary[k*2]=l0; texBary[k*2+1]=l1;
texValid[k]=inside?1:2;
}
}
if((t&2047)===0){ setStatus('Karte wird aufgebaut … '+Math.round(t/nt*100)+'%'); await frameP(); }
}
let cnt=0; for(let i=0;i<N;i++) if(texValid[i]) cnt++;
texelIds=new Int32Array(cnt);
let w=0; for(let i=0;i<N;i++) if(texValid[i]) texelIds[w++]=i;
updateTexelPositions();
buildTexelHash();
skinLevels=null;
paintTex=new THREE.DataTexture(paintData,R,R,THREE.RGBAFormat,THREE.UnsignedByteType);
paintTex.magFilter=THREE.LinearFilter; paintTex.minFilter=THREE.LinearFilter;
paintTex.generateMipmaps=false; paintTex.wrapS=paintTex.wrapT=THREE.ClampToEdgeWrapping;
paintTex.needsUpdate=true;
bodyMat.uniforms.map.value=paintTex;
setStatus('Karte '+R+'×'+R+' · '+cnt.toLocaleString('de-DE')+' Texel'
+(overdraw>cnt*0.05?' · Achtung: UV-Inseln überlappen':''));
await frameP();
}
function updateTexelPositions(){
if(!texelIds) return;
const g=body.geometry;
const pa=g.attributes.position.array, na=g.attributes.normal.array, ia=g.index.array;
for(let k=0;k<texelIds.length;k++){
const i=texelIds[k], t=texTri[i];
const l0=texBary[i*2], l1=texBary[i*2+1], l2=1-l0-l1;
const a=ia[t*3]*3, b=ia[t*3+1]*3, c=ia[t*3+2]*3;
texPos[i*3 ]=pa[a ]*l0+pa[b ]*l1+pa[c ]*l2;
texPos[i*3+1]=pa[a+1]*l0+pa[b+1]*l1+pa[c+1]*l2;
texPos[i*3+2]=pa[a+2]*l0+pa[b+2]*l1+pa[c+2]*l2;
let nx=na[a]*l0+na[b]*l1+na[c]*l2;
let ny=na[a+1]*l0+na[b+1]*l1+na[c+1]*l2;
let nz=na[a+2]*l0+na[b+2]*l1+na[c+2]*l2;
const ln=Math.hypot(nx,ny,nz)||1;
texNrm[i*3]=nx/ln; texNrm[i*3+1]=ny/ln; texNrm[i*3+2]=nz/ln;
}
}
function buildTexelHash(){
const n=texelIds.length;
hMap=new Map();
const bucketOf=new Int32Array(n);
const counts=[];
for(let k=0;k<n;k++){
const i=texelIds[k];
const key=cellKey(Math.floor(texPos[i*3]/HCELL),Math.floor(texPos[i*3+1]/HCELL),Math.floor(texPos[i*3+2]/HCELL));
let b=hMap.get(key);
if(b===undefined){ b=counts.length; hMap.set(key,b); counts.push(0); }
bucketOf[k]=b; counts[b]++;
}
hStart=new Int32Array(counts.length+1);
for(let b=0;b<counts.length;b++) hStart[b+1]=hStart[b]+counts[b];
const cursor=hStart.slice(0,counts.length);
hIdx=new Int32Array(n);
for(let k=0;k<n;k++){ const b=bucketOf[k]; hIdx[cursor[b]++]=texelIds[k]; }
}
function forTexelsNear(c,r,cb){
if(!hMap) return;
const gx0=Math.floor((c.x-r)/HCELL),gx1=Math.floor((c.x+r)/HCELL);
const gy0=Math.floor((c.y-r)/HCELL),gy1=Math.floor((c.y+r)/HCELL);
const gz0=Math.floor((c.z-r)/HCELL),gz1=Math.floor((c.z+r)/HCELL);
for(let x=gx0;x<=gx1;x++)for(let y=gy0;y<=gy1;y++)for(let z=gz0;z<=gz1;z++){
const b=hMap.get(cellKey(x,y,z)); if(b===undefined) continue;
for(let k=hStart[b];k<hStart[b+1];k++) cb(hIdx[k]);
}
}
function clearLayer(){
if(!paintData) return;
const vo=vOff(), ao=aOff();
for(let i=0;i<R*R;i++){ paintData[i*4+vo]=0; paintData[i*4+ao]=0; }
paintDirty=true; pushHistory(); setStatus('Ebene '+(P.layer+1)+' geleert.');
}
// ── Pinsel
function stampAt(c,n){
const r=P.brushRadius, r2=r*r;
const soft=Math.max(1e-3,1-P.hardness);
const nl=P.normalLimit, flow=P.flow;
const target=Math.round(clamp(P.target,0,1)*255);
const vo=vOff(), ao=aOff();
const erase=P.brushMode==='erase';
forTexelsNear(c,r,i=>{
const dx=texPos[i*3]-c.x, dy=texPos[i*3+1]-c.y, dz=texPos[i*3+2]-c.z;
const d2=dx*dx+dy*dy+dz*dz; if(d2>r2) return;
const dot=texNrm[i*3]*n.x+texNrm[i*3+1]*n.y+texNrm[i*3+2]*n.z;
if(dot<nl) return;
const k=1-Math.sqrt(d2)/r;
let f=clamp(k/soft,0,1); f=f*f*(3-2*f);
const w=f*flow;
if(w<=0) return;
const o=i*4;
if(erase){
paintData[o+ao]=Math.max(0,paintData[o+ao]-w*255);
if(paintData[o+ao]<2){ paintData[o+vo]=0; paintData[o+ao]=0; }
}else{
const v=paintData[o+vo], a=paintData[o+ao];
paintData[o+vo]=v+(target-v)*w;
paintData[o+ao]=Math.min(255,a+(255-a)*w);
}
});
paintDirty=true;
}
function smoothAt(c,n){
const r=P.brushRadius, r2=r*r, nl=P.normalLimit;
const vo=vOff(), ao=aOff();
const ids=[], ws=[];
let bx0=1e9,bx1=-1,by0=1e9,by1=-1;
forTexelsNear(c,r,i=>{
const dx=texPos[i*3]-c.x, dy=texPos[i*3+1]-c.y, dz=texPos[i*3+2]-c.z;
const d2=dx*dx+dy*dy+dz*dz; if(d2>r2) return;
const dot=texNrm[i*3]*n.x+texNrm[i*3+1]*n.y+texNrm[i*3+2]*n.z;
if(dot<nl) return;
ids.push(i); ws.push(clamp((1-Math.sqrt(d2)/r)*1.8,0,1));
const x=i%R, y=(i/R)|0;
if(x<bx0)bx0=x; if(x>bx1)bx1=x; if(y<by0)by0=y; if(y>by1)by1=y;
});
if(!ids.length) return;
const kr=clamp(Math.round(r/Math.max(texelSpacing,1e-6)*0.5),1,48);
const px0=Math.max(0,bx0-kr), px1=Math.min(R-1,bx1+kr);
const py0=Math.max(0,by0-kr), py1=Math.min(R-1,by1+kr);
const W=px1-px0+1, H=py1-py0+1;
const sv=new Float32Array(W*H), sa=new Float32Array(W*H), mk=new Float32Array(W*H);
for(let y=0;y<H;y++)for(let x=0;x<W;x++){
const gi=(py0+y)*R+(px0+x); if(!texValid[gi]) continue;
const o=gi*4, a=paintData[o+ao];
const q=y*W+x;
mk[q]=1; sa[q]=a; sv[q]=paintData[o+vo]*(a/255);
}
const box=(src,dst,w,h,horiz)=>{
const len=horiz?w:h, cross=horiz?h:w;
const pre=new Float32Array(len+1);
for(let c2=0;c2<cross;c2++){
for(let i=0;i<len;i++){
const q=horiz?(c2*w+i):(i*w+c2);
pre[i+1]=pre[i]+src[q];
}
for(let i=0;i<len;i++){
const a=Math.max(0,i-kr), b=Math.min(len,i+kr+1);
const q=horiz?(c2*w+i):(i*w+c2);
dst[q]=pre[b]-pre[a];
}
}
};
const t1=new Float32Array(W*H),t2=new Float32Array(W*H),t3=new Float32Array(W*H);
const u1=new Float32Array(W*H),u2=new Float32Array(W*H),u3=new Float32Array(W*H);
box(sv,t1,W,H,true); box(sa,t2,W,H,true); box(mk,t3,W,H,true);
box(t1,u1,W,H,false); box(t2,u2,W,H,false); box(t3,u3,W,H,false);
const str=clamp(P.flow*1.8,0,1);
for(let q=0;q<ids.length;q++){
const i=ids[q], x=i%R, y=(i/R)|0;
const p=(y-py0)*W+(x-px0);
const m=u3[p]; if(m<1e-6) continue;
const aB=u2[p]/m;
const vB=(u2[p]>1e-4)?(u1[p]/(u2[p]/255)):paintData[i*4+vo];
const w=ws[q]*str;
const o=i*4;
paintData[o+vo]=paintData[o+vo]+(vB-paintData[o+vo])*w;
paintData[o+ao]=paintData[o+ao]+(aB-paintData[o+ao])*w;
}
paintDirty=true;
}
const _mn2=new V3();
function paintAt(hit){
const c=hit.point, n=hit.normal;
if(P.brushMode==='smooth') smoothAt(c,n); else stampAt(c,n);
if(P.mirror){
_mn2.set(-n.x,n.y,n.z);
const cm={x:-c.x,y:c.y,z:c.z};
if(P.brushMode==='smooth') smoothAt(cm,_mn2); else stampAt(cm,_mn2);
}
}
const _s=[0,0,0,0];
function sampleMap(u,v){
const x=clamp(u,0,1)*R-0.5, y=clamp(v,0,1)*R-0.5;
const x0=Math.floor(x), y0=Math.floor(y), fx=x-x0, fy=y-y0;
_s[0]=_s[1]=_s[2]=_s[3]=0;
for(let j=0;j<2;j++)for(let i=0;i<2;i++){
const xx=clamp(x0+i,0,R-1), yy=clamp(y0+j,0,R-1);
const w=(i?fx:1-fx)*(j?fy:1-fy);
const o=(yy*R+xx)*4;
_s[0]+=paintData[o]*w; _s[1]+=paintData[o+1]*w;
_s[2]+=paintData[o+2]*w; _s[3]+=paintData[o+3]*w;
}
_s[0]/=255; _s[1]/=255; _s[2]/=255; _s[3]/=255;
return _s;
}
function paintedRatio(layer){
if(!texelIds) return 0;
const ao=layer*2+1;
let c=0;
for(let k=0;k<texelIds.length;k++) if(paintData[texelIds[k]*4+ao]>25) c++;
return c/texelIds.length;
}
// ═════════════════════════════════════════════════════════════════════════════
// 7 · UNDO
// ═════════════════════════════════════════════════════════════════════════════
let history=[], histIdx=-1;
function pushHistory(){
if(!paintData) return;
history=history.slice(0,histIdx+1);
history.push(paintData.slice());
const cap=R>1024?4:10;
while(history.length>cap) history.shift();
histIdx=history.length-1; updateBar();
}
function resetHistory(){ history=[]; histIdx=-1; pushHistory(); }
function restore(i){ paintData.set(history[i]); paintDirty=true; updateBar(); setStatus(); }
function undo(){ if(histIdx<=0) return; histIdx--; restore(histIdx); }
function redo(){ if(histIdx>=history.length-1) return; histIdx++; restore(histIdx); }
addEventListener('keydown',ev=>{
if(!(ev.ctrlKey||ev.metaKey)) return;
const k=ev.key.toLowerCase();
if(k==='z'){ ev.preventDefault(); ev.shiftKey?redo():undo(); }
else if(k==='y'){ ev.preventDefault(); redo(); }
});
// ═════════════════════════════════════════════════════════════════════════════
// 8 · PINSELZEIGER
// ═════════════════════════════════════════════════════════════════════════════
function ringMesh(op){
const g=new THREE.RingGeometry(0.965,1,64);
const m=new THREE.MeshBasicMaterial({color:0x111111,transparent:true,opacity:op,
side:THREE.DoubleSide,depthTest:false});
const r=new THREE.Mesh(g,m); r.renderOrder=30; r.frustumCulled=false; r.visible=false;
scene.add(r); return r;
}
const cursor=ringMesh(0.9), cursorM=ringMesh(0.35);
const _up=new V3(0,0,1), _mp=new V3(), _mn=new V3();
function placeCursor(hit){
cursor.position.copy(hit.point).addScaledVector(hit.normal,0.004);
cursor.quaternion.setFromUnitVectors(_up,hit.normal);
cursor.scale.setScalar(P.brushRadius);
_mn.set(-hit.normal.x,hit.normal.y,hit.normal.z);
_mp.set(-hit.point.x,hit.point.y,hit.point.z).addScaledVector(_mn,0.004);
cursorM.position.copy(_mp);
cursorM.quaternion.setFromUnitVectors(_up,_mn);
cursorM.scale.setScalar(P.brushRadius);
}
// ═════════════════════════════════════════════════════════════════════════════
// 9 · POINTER
// ═════════════════════════════════════════════════════════════════════════════
const raycaster=new THREE.Raycaster();
const ndc=new THREE.Vector2();
const _nm=new THREE.Matrix3(), _cd=new V3(), _pr=new V3();
let painting=false, orbiting=null, hoverHit=null, strokeTouched=false, dragBone=null;
const ROT_SPEED=0.012, ROOT_SPEED=0.005;
function setNDC(ev){
const r=el.getBoundingClientRect();
ndc.x=((ev.clientX-r.left)/r.width)*2-1;
ndc.y=-((ev.clientY-r.top)/r.height)*2+1;
raycaster.setFromCamera(ndc,camera);
}
function toScreen(w){
_pr.copy(w).project(camera);
const r=el.getBoundingClientRect();
return [(_pr.x*0.5+0.5)*r.width+r.left,(-_pr.y*0.5+0.5)*r.height+r.top];
}
function pickFrom(list,ev,px){
let best=null,bd=px*px;
for(const e of list){
const [x,y]=toScreen(e.world);
const d=(x-ev.clientX)**2+(y-ev.clientY)**2;
if(d<bd){bd=d;best=e;}
}
return best;
}
function hitBody(){
if(!body) return null;
const h=raycaster.intersectObject(body,false);
if(!h.length) return null;
const it=h[0];
_nm.getNormalMatrix(body.matrixWorld);
const n=it.face?it.face.normal.clone().applyMatrix3(_nm).normalize():new V3(0,0,1);
return {point:it.point.clone(),normal:n};
}
el.addEventListener('pointerdown',ev=>{
el.setPointerCapture(ev.pointerId);
setNDC(ev);
if(ev.button===0&&!ev.altKey){
if(P.poseMode){
const h=pickFrom(boneHandles,ev,14);
if(h){
selectedBone=h.bone; rebuildBoneHandles();
const set=new Set(srcMesh.skeleton.bones);
const parent=(h.bone.parent&&set.has(h.bone.parent))?h.bone.parent:null;
const plane=new THREE.Plane();
camera.getWorldDirection(_cd);
plane.setFromNormalAndCoplanarPoint(_cd.clone().negate(),h.world.clone());
dragBone={handle:h.bone,rot:ev.shiftKey?h.bone:(parent||h.bone),plane,
twist:ev.shiftKey,x:ev.clientX,y:ev.clientY,
// Der Wurzelknochen hat keinen brauchbaren Hebelarm — sein
// erstes Kind sitzt fast im selben Punkt. Deshalb dort über
// das Mausdelta drehen statt über die Zielrichtung.
ball:!ev.shiftKey&&!parent};
setStatus((ev.shiftKey?'Twist: ':'Bone: ')+h.bone.name.replace(/^mixamorig\d*:?/,''));
return;
}
}else if(!ev.shiftKey&&paintData){
const hit=hitBody();
if(hit){ painting=true; strokeTouched=true; paintAt(hit); placeCursor(hit); return; }
}
}
orbiting={x:ev.clientX,y:ev.clientY,mode:(ev.button===1||ev.shiftKey)?'pan':'orbit'};
});
el.addEventListener('pointermove',ev=>{
setNDC(ev);
if(dragBone){
const b=dragBone.rot;
if(dragBone.twist){
const dx=ev.clientX-dragBone.x; dragBone.x=ev.clientX;
if(dx) applyWorldDelta(b,_qtmp.setFromAxisAngle(boneAxis(b),dx*ROT_SPEED).clone());
return;
}
if(dragBone.ball){
const dx=ev.clientX-dragBone.x, dy=ev.clientY-dragBone.y;
dragBone.x=ev.clientX; dragBone.y=ev.clientY;
if(!dx&&!dy) return;
const right=new V3().setFromMatrixColumn(camera.matrixWorld,0).normalize();
const q=new QT().setFromAxisAngle(new V3(0,1,0),-dx*ROOT_SPEED);
if(dy) q.multiply(_qtmp.setFromAxisAngle(right,dy*ROOT_SPEED));
applyWorldDelta(b,q);
return;
}
const target=new V3();
if(!raycaster.ray.intersectPlane(dragBone.plane,target)) return;
const O=worldPosOf(b);
const tipBone=(dragBone.handle!==b)?dragBone.handle:firstChildBone(b);
if(!tipBone) return;
const v0=worldPosOf(tipBone).sub(O), v1=target.sub(O);
if(v0.lengthSq()<1e-8||v1.lengthSq()<1e-8) return;
applyWorldDelta(b,new QT().setFromUnitVectors(v0.normalize(),v1.normalize()));
return;
}
if(painting){
const hit=hitBody();
if(hit){ paintAt(hit); placeCursor(hit); hoverHit=hit; }
return;
}
if(orbiting){
const dx=ev.clientX-orbiting.x,dy=ev.clientY-orbiting.y;
orbiting.x=ev.clientX; orbiting.y=ev.clientY;
if(orbiting.mode==='orbit'){
cam.theta-=dx*0.006;
cam.phi=clamp(cam.phi-dy*0.006,0.08,Math.PI-0.08);
}else{
const right=new V3().setFromMatrixColumn(camera.matrixWorld,0);
const up=new V3().setFromMatrixColumn(camera.matrixWorld,1);
cam.target.addScaledVector(right,-dx*0.0016*cam.dist).addScaledVector(up,dy*0.0016*cam.dist);
}
applyCam(); return;
}
if(P.poseMode){ hoverHit=null; el.style.cursor=pickFrom(boneHandles,ev,14)?'grab':'default'; return; }
hoverHit=hitBody();
if(hoverHit) placeCursor(hoverHit);
});
addEventListener('pointerup',()=>{
if(painting&&strokeTouched) pushHistory();
if(dragBone){ dragBone=null; refreshAfterPose(); }
painting=false; strokeTouched=false; orbiting=null; setStatus();
});
el.addEventListener('wheel',ev=>{
ev.preventDefault();
if(ev.altKey){
P.brushRadius=clamp(P.brushRadius*Math.exp(-ev.deltaY*0.0016),0.004,0.4);
syncUI(); return;
}
cam.dist=clamp(cam.dist*Math.exp(ev.deltaY*0.0011),0.4,20);
applyCam();
},{passive:false});
el.addEventListener('pointerleave',()=>{ if(!painting) hoverHit=null; });
el.addEventListener('contextmenu',e=>e.preventDefault());
const dropEl=document.getElementById('drop');
['dragenter','dragover'].forEach(t=>addEventListener(t,e=>{e.preventDefault(); dropEl.style.display='grid';}));
addEventListener('dragleave',e=>{ if(e.target===dropEl) dropEl.style.display='none'; });
addEventListener('drop',async e=>{
e.preventDefault(); dropEl.style.display='none';
const f=e.dataTransfer?.files?.[0]; if(!f) return;
loadFromBuffer(await f.arrayBuffer(),f.name);
});
// ═════════════════════════════════════════════════════════════════════════════
// 10 · WURZELWERK
// Ebene 1 → Dichte und Stärke (weiß = fein und dicht, schwarz = grob und weit)
// Ebene 2 → Abstand des Ansatzes von der Haut (schwarz = weit, weiß = knapp,
// unbemalt = direkt auf der Haut)
// ═════════════════════════════════════════════════════════════════════════════
const PERM=(()=>{const p=[151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,
240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,88,237,149,56,87,174,
20,125,136,171,168,68,175,74,165,71,134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,
41,55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89,18,169,200,196,135,130,116,188,
159,86,164,100,109,198,173,186,3,64,52,217,226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,
47,16,58,17,182,189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,43,172,9,129,22,39,
253,19,98,108,110,79,113,224,232,178,185,112,104,218,246,97,228,251,34,242,193,238,210,144,12,191,179,162,241,
81,51,145,235,249,14,239,107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,150,254,138,236,
205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180];
const a=new Uint8Array(512);for(let i=0;i<512;i++)a[i]=p[i&255];return a;})();
const fade=t=>t*t*t*(t*(t*6-15)+10), lerpN=(t,a,b)=>a+t*(b-a);
function grad(h,x,y,z){h&=15;const u=h<8?x:y,v=h<4?y:(h===12||h===14?x:z);return((h&1)?-u:u)+((h&2)?-v:v);}
function noise3(x,y,z){
const X=Math.floor(x)&255,Y=Math.floor(y)&255,Z=Math.floor(z)&255;
x-=Math.floor(x);y-=Math.floor(y);z-=Math.floor(z);
const u=fade(x),v=fade(y),w=fade(z);
const A=PERM[X]+Y,AA=PERM[A]+Z,AB=PERM[A+1]+Z,B=PERM[X+1]+Y,BA=PERM[B]+Z,BB=PERM[B+1]+Z;
return lerpN(w,lerpN(v,lerpN(u,grad(PERM[AA],x,y,z),grad(PERM[BA],x-1,y,z)),
lerpN(u,grad(PERM[AB],x,y-1,z),grad(PERM[BB],x-1,y-1,z))),
lerpN(v,lerpN(u,grad(PERM[AA+1],x,y,z-1),grad(PERM[BA+1],x-1,y,z-1)),
lerpN(u,grad(PERM[AB+1],x,y-1,z-1),grad(PERM[BB+1],x-1,y-1,z-1))));
}
const NO=[[0,0,0],[37.1,12.3,5.2],[-19.7,51.9,-3.4]];
const psi=(i,x,y,z)=>noise3(x+NO[i][0],y+NO[i][1],z+NO[i][2]);
function curl(p,out){
const s=P.noiseScale,x=p.x*s,y=p.y*s,z=p.z*s,e=0.14;
out.set(
(psi(2,x,y+e,z)-psi(2,x,y-e,z))/(2*e) - (psi(1,x,y,z+e)-psi(1,x,y,z-e))/(2*e),
(psi(0,x,y,z+e)-psi(0,x,y,z-e))/(2*e) - (psi(2,x+e,y,z)-psi(2,x-e,y,z))/(2*e),
(psi(1,x+e,y,z)-psi(1,x,y,z-e))/(2*e) - (psi(0,x,y+e,z)-psi(0,x,y-e,z))/(2*e));
const l=out.length(); if(l>1e-6) out.multiplyScalar(1/l);
return out;
}
let seeds=[], roots=[], shoots=[], rootPts=0, busy=false;
let rootMesh=null, preLines=null, pillarLines=null;
const rootGroup=new THREE.Group(); scene.add(rootGroup);
const preGroup=new THREE.Group(); scene.add(preGroup);
const ovlEl=document.getElementById('ovl'), ovlTxt=document.getElementById('ovlTxt'),
ovlBar=document.getElementById('ovlBar');
function progress(t,f){ ovlEl.style.display='grid'; ovlTxt.textContent=t; ovlBar.style.width=Math.round(clamp(f,0,1)*100)+'%'; }
function progressEnd(){ ovlEl.style.display='none'; }
const step=async(t,f)=>{ progress(t,f); await frameP(); };
function clearMeshOnly(){
if(rootMesh){ rootGroup.remove(rootMesh); rootMesh.geometry.dispose(); rootMesh.material.dispose(); rootMesh=null; }
rootG=null; rootC=null;
}
function clearRoots(){
clearMeshOnly();
if(preLines){ preGroup.remove(preLines); preLines.geometry.dispose(); preLines.material.dispose(); preLines=null; }
if(pillarLines){ preGroup.remove(pillarLines); pillarLines.geometry.dispose(); pillarLines.material.dispose(); pillarLines=null; }
rootGroup.clear(); preGroup.clear();
seeds=[]; roots=[]; shoots=[]; rootPts=0; pillars=[]; pGrid=null; pBlock=null; occ=null; setStatus();
}
// ═══ Oberflächen-Projektion (Dreiecksraster) — für die Saat ═══
let tGrid=null, tCell=0.03;
const tkey=(x,y,z)=>(x+1024)+(y+1024)*2048+(z+1024)*4194304;
function buildTriGrid(){
tCell=Math.max(0.02,Math.sqrt(triArea/Math.max(1,triCDF.length))*3);
tGrid=new Map();
const nt=triCDF.length;
for(let t=0;t<nt;t++){
let x0=1e9,y0=1e9,z0=1e9,x1=-1e9,y1=-1e9,z1=-1e9;
for(const A of [triA,triB,triC]){
const x=A[t*3],y=A[t*3+1],z=A[t*3+2];
if(x<x0)x0=x; if(y<y0)y0=y; if(z<z0)z0=z;
if(x>x1)x1=x; if(y>y1)y1=y; if(z>z1)z1=z;
}
for(let x=Math.floor(x0/tCell);x<=Math.floor(x1/tCell);x++)
for(let y=Math.floor(y0/tCell);y<=Math.floor(y1/tCell);y++)
for(let z=Math.floor(z0/tCell);z<=Math.floor(z1/tCell);z++){
const k=tkey(x,y,z); let arr=tGrid.get(k); if(!arr){arr=[];tGrid.set(k,arr);} arr.push(t);
}
}
}
const _pt={d2:0,l0:0,l1:0,l2:0,x:0,y:0,z:0};
function ptTri(px,py,pz,t){
const o=t*3;
const ax=triA[o],ay=triA[o+1],az=triA[o+2];
const bx=triB[o],by=triB[o+1],bz=triB[o+2];
const cx=triC[o],cy=triC[o+1],cz=triC[o+2];
const abx=bx-ax,aby=by-ay,abz=bz-az, acx=cx-ax,acy=cy-ay,acz=cz-az;
const apx=px-ax,apy=py-ay,apz=pz-az;
const d1=abx*apx+aby*apy+abz*apz, d2=acx*apx+acy*apy+acz*apz;
let l0,l1,l2;
if(d1<=0&&d2<=0){ l0=1;l1=0;l2=0; }
else{
const bpx=px-bx,bpy=py-by,bpz=pz-bz;
const d3=abx*bpx+aby*bpy+abz*bpz, d4=acx*bpx+acy*bpy+acz*bpz;
if(d3>=0&&d4<=d3){ l0=0;l1=1;l2=0; }
else{
const vc=d1*d4-d3*d2;
if(vc<=0&&d1>=0&&d3<=0){ const v=d1/(d1-d3); l0=1-v;l1=v;l2=0; }
else{
const cpx=px-cx,cpy=py-cy,cpz=pz-cz;
const d5=abx*cpx+aby*cpy+abz*cpz, d6=acx*cpx+acy*cpy+acz*cpz;
if(d6>=0&&d5<=d6){ l0=0;l1=0;l2=1; }
else{
const vb=d5*d2-d1*d6;
if(vb<=0&&d2>=0&&d6<=0){ const w=d2/(d2-d6); l0=1-w;l1=0;l2=w; }
else{
const va=d3*d6-d5*d4;
if(va<=0&&(d4-d3)>=0&&(d5-d6)>=0){ const w=(d4-d3)/((d4-d3)+(d5-d6)); l0=0;l1=1-w;l2=w; }
else{ const den=1/(va+vb+vc), v=vb*den, w=vc*den; l0=1-v-w;l1=v;l2=w; }
}
}
}
}
}
const qx=ax*l0+bx*l1+cx*l2, qy=ay*l0+by*l1+cy*l2, qz=az*l0+bz*l1+cz*l2;
_pt.d2=(qx-px)**2+(qy-py)**2+(qz-pz)**2;
_pt.l0=l0;_pt.l1=l1;_pt.l2=l2;_pt.x=qx;_pt.y=qy;_pt.z=qz;
return _pt;
}
const _sp={x:0,y:0,z:0,nx:0,ny:1,nz:0,u:0,v:0,t:-1,ok:false};
function onSurface(px,py,pz){
_sp.ok=false;
if(!tGrid) buildTriGrid();
const gx=Math.floor(px/tCell),gy=Math.floor(py/tCell),gz=Math.floor(pz/tCell);
let best=1e9,bt=-1,bl0=0,bl1=0,bl2=0,bx=0,by=0,bz=0;
for(let ring=0;ring<=4;ring++){
if(bt>=0&&Math.sqrt(best)<=ring*tCell) break;
for(let x=gx-ring;x<=gx+ring;x++)for(let y=gy-ring;y<=gy+ring;y++)for(let z=gz-ring;z<=gz+ring;z++){
if(ring>0&&Math.abs(x-gx)<ring&&Math.abs(y-gy)<ring&&Math.abs(z-gz)<ring) continue;
const arr=tGrid.get(tkey(x,y,z)); if(!arr) continue;
for(const t of arr){
const r=ptTri(px,py,pz,t);
if(r.d2<best){ best=r.d2;bt=t;bl0=r.l0;bl1=r.l1;bl2=r.l2;bx=r.x;by=r.y;bz=r.z; }
}
}
}
if(bt<0) return _sp;
const g=body.geometry, ia=g.index.array, na=g.attributes.normal.array, ua=g.attributes.uv.array;
const i0=ia[bt*3],i1=ia[bt*3+1],i2=ia[bt*3+2];
let nx=na[i0*3]*bl0+na[i1*3]*bl1+na[i2*3]*bl2;
let ny=na[i0*3+1]*bl0+na[i1*3+1]*bl1+na[i2*3+1]*bl2;
let nz=na[i0*3+2]*bl0+na[i1*3+2]*bl1+na[i2*3+2]*bl2;
const l=Math.hypot(nx,ny,nz)||1;
_sp.x=bx;_sp.y=by;_sp.z=bz;_sp.nx=nx/l;_sp.ny=ny/l;_sp.nz=nz/l;
_sp.u=ua[i0*2]*bl0+ua[i1*2]*bl1+ua[i2*2]*bl2;
_sp.v=ua[i0*2+1]*bl0+ua[i1*2+1]*bl1+ua[i2*2+1]*bl2;
_sp.t=bt;_sp.ok=true;
return _sp;
}
// ═══ Hautfeld: zweistufiges Punktraster für schnelle Nächster-Punkt-Abfragen ═══
let skinLevels=null;
function buildSkinLevel(cell){
const acc=new Map();
const g=body.geometry, ua=g.attributes.uv.array, ia=g.index.array;
for(let k=0;k<texelIds.length;k++){
const i=texelIds[k];
const x=texPos[i*3],y=texPos[i*3+1],z=texPos[i*3+2];
const kk=cellKey(Math.floor(x/cell),Math.floor(y/cell),Math.floor(z/cell));
let e=acc.get(kk);
if(!e){
// Leittexel der Zelle liefert UV — nicht mitteln, sonst wird an
// UV-Nähten über zwei Inseln hinweg gemischt
const t=texTri[i];
const l0=texBary[i*2], l1=texBary[i*2+1], l2=1-l0-l1;
const i0=ia[t*3],i1=ia[t*3+1],i2=ia[t*3+2];
e={x:0,y:0,z:0,nx:0,ny:0,nz:0,n:0,
u:ua[i0*2]*l0+ua[i1*2]*l1+ua[i2*2]*l2,
v:ua[i0*2+1]*l0+ua[i1*2+1]*l1+ua[i2*2+1]*l2};
acc.set(kk,e);
}
e.x+=x; e.y+=y; e.z+=z;
e.nx+=texNrm[i*3]; e.ny+=texNrm[i*3+1]; e.nz+=texNrm[i*3+2];
e.n++;
}
const n=acc.size;
const L={cell,map:new Map(),
x:new Float32Array(n),y:new Float32Array(n),z:new Float32Array(n),
nx:new Float32Array(n),ny:new Float32Array(n),nz:new Float32Array(n),
u:new Float32Array(n),v:new Float32Array(n)};
let i=0;
for(const [k,e] of acc){
L.x[i]=e.x/e.n; L.y[i]=e.y/e.n; L.z[i]=e.z/e.n;
const l=Math.hypot(e.nx,e.ny,e.nz)||1;
L.nx[i]=e.nx/l; L.ny[i]=e.ny/l; L.nz[i]=e.nz/l;
L.u[i]=e.u; L.v[i]=e.v;
L.map.set(k,i); i++;
}
return L;
}
function buildSkinField(){
skinLevels=[buildSkinLevel(0.010),buildSkinLevel(0.055)];
}
const _sk={ok:false,x:0,y:0,z:0,nx:0,ny:1,nz:0,u:0,v:0,d:0};
function queryLevel(L,x,y,z,rings){
const c=L.cell;
const gx=Math.floor(x/c),gy=Math.floor(y/c),gz=Math.floor(z/c);
let best=1e9,bi=-1;
for(let ring=0;ring<=rings;ring++){
if(bi>=0&&best<=((ring-1)*c)*((ring-1)*c)) break;
for(let a=gx-ring;a<=gx+ring;a++)for(let b=gy-ring;b<=gy+ring;b++)for(let d=gz-ring;d<=gz+ring;d++){
if(ring>0&&Math.abs(a-gx)<ring&&Math.abs(b-gy)<ring&&Math.abs(d-gz)<ring) continue;
const id=L.map.get(cellKey(a,b,d)); if(id===undefined) continue;
const q=(L.x[id]-x)**2+(L.y[id]-y)**2+(L.z[id]-z)**2;
if(q<best){ best=q; bi=id; }
}
}
return bi<0?-1:(_qi=bi,_qd=best,bi);
}
let _qi=-1,_qd=0;
function nearestSkin(x,y,z){
_sk.ok=false;
if(!skinLevels) return _sk;
let L=skinLevels[0];
let id=queryLevel(L,x,y,z,2);
if(id<0){ L=skinLevels[1]; id=queryLevel(L,x,y,z,4); }
if(id<0) return _sk;
_sk.ok=true;
_sk.x=L.x[id]; _sk.y=L.y[id]; _sk.z=L.z[id];
_sk.nx=L.nx[id]; _sk.ny=L.ny[id]; _sk.nz=L.nz[id];
_sk.u=L.u[id]; _sk.v=L.v[id];
_sk.d=Math.sqrt(_qd);
return _sk;
}
// ═══ 1 · Saat: Kreispackung aus Ebene 1 ═══
const radiusFor=v=>P.rMin+(P.rMax-P.rMin)*Math.pow(clamp(1-v,0,1),P.gamma);
const spacingOf=A=>2*A.r*P.packing;
const _mm={v:0,a:0,r:0,h:0};
function readMaps(u,v,out){
const m=sampleMap(u,v);
out.v=m[0]; out.a=m[1];
out.r=radiusFor(m[0]);
out.h=m[3]*(1-m[2])*P.lift;
return out;
}
function pickTriangle(){
const t=rnd()*triArea;
let lo=0,hi=triCDF.length-1;
while(lo<hi){ const m=(lo+hi)>>1; if(triCDF[m]<t) lo=m+1; else hi=m; }
return lo;
}
function surfaceSample(){
const t=pickTriangle();
let b1=rnd(),b2=rnd(); if(b1+b2>1){b1=1-b1;b2=1-b2;}
const b0=1-b1-b2;
const g=body.geometry, ia=g.index.array, ua=g.attributes.uv.array, na=g.attributes.normal.array;
const i0=ia[t*3],i1=ia[t*3+1],i2=ia[t*3+2], o=t*3;
const x=triA[o]*b0+triB[o]*b1+triC[o]*b2;
const y=triA[o+1]*b0+triB[o+1]*b1+triC[o+1]*b2;
const z=triA[o+2]*b0+triB[o+2]*b1+triC[o+2]*b2;
let nx=na[i0*3]*b0+na[i1*3]*b1+na[i2*3]*b2;
let ny=na[i0*3+1]*b0+na[i1*3+1]*b1+na[i2*3+1]*b2;
let nz=na[i0*3+2]*b0+na[i1*3+2]*b1+na[i2*3+2]*b2;
const l=Math.hypot(nx,ny,nz)||1;
return {x,y,z,nx:nx/l,ny:ny/l,nz:nz/l,
u:ua[i0*2]*b0+ua[i1*2]*b1+ua[i2*2]*b2,
v:ua[i0*2+1]*b0+ua[i1*2+1]*b1+ua[i2*2+1]*b2};
}
function sampleSeeds(){
srand(P.seed);
const cand=[];
for(let i=0;i<P.samples;i++){
const p=surfaceSample();
readMaps(p.u,p.v,_mm);
if(_mm.a<P.alphaMin) continue; // radiert / unbemalt → keine Wurzel
if(P.mirror&&p.x<-0.001) continue;
if(P.densityBias>0 && rnd()>1-P.densityBias+P.densityBias*(1-_mm.v)) continue;
let r=_mm.r*(1+(rnd()*2-1)*P.jitter);
r=Math.max(0.0008,r*Math.min(1,_mm.a*1.6));
cand.push({x:p.x,y:p.y,z:p.z,nx:p.nx,ny:p.ny,nz:p.nz,u:p.u,v:p.v,r,h:_mm.h});
}
cand.sort((A,B)=>B.r-A.r);
const cell=Math.max(0.006,2*P.rMax*(1+P.jitter)*1.02);
const key=(x,y,z)=>(x+2048)+(y+2048)*4096+(z+2048)*16777216;
const grid=new Map();
const acc=[];
const fits=t=>{
const gx=Math.floor(t.x/cell),gy=Math.floor(t.y/cell),gz=Math.floor(t.z/cell);
for(let x=gx-1;x<=gx+1;x++)for(let y=gy-1;y<=gy+1;y++)for(let z=gz-1;z<=gz+1;z++){
const arr=grid.get(key(x,y,z)); if(!arr) continue;
for(const o of arr){
const dx=o.x-t.x,dy=o.y-t.y,dz=o.z-t.z;
const lim=(o.r+t.r)*P.packing;
if(dx*dx+dy*dy+dz*dz<lim*lim) return false;
}
}
return true;
};
const insert=t=>{
const k=key(Math.floor(t.x/cell),Math.floor(t.y/cell),Math.floor(t.z/cell));
let arr=grid.get(k); if(!arr){arr=[];grid.set(k,arr);} arr.push(t);
t.s=spacingOf(t); acc.push(t);
};
for(const t of cand){
if(P.mirror){
if(Math.abs(t.x)<t.r*P.packing){
t.x=0; t.nx=0;
const l=Math.hypot(t.ny,t.nz)||1; t.ny/=l; t.nz/=l;
if(fits(t)) insert(t);
}else{
const m={x:-t.x,y:t.y,z:t.z,nx:-t.nx,ny:t.ny,nz:t.nz,u:t.u,v:t.v,r:t.r,h:t.h,mir:true,srcRef:t};
if(fits(t)&&fits(m)){ insert(t); insert(m); }
}
}else if(fits(t)) insert(t);
}
seeds=acc;
return seeds.length>2;
}
function syncMirrors(){
for(const A of seeds){
if(!A.mir) continue;
const S=A.srcRef;
A.x=-S.x; A.y=S.y; A.z=S.z;
A.nx=-S.nx; A.ny=S.ny; A.nz=S.nz;
A.r=S.r; A.h=S.h; A.s=S.s;
}
}
async function relaxSeeds(){
const iters=P.relax|0; if(iters<=0){ syncMirrors(); return; }
const n=seeds.length;
const fx=new Float64Array(n),fy=new Float64Array(n),fz=new Float64Array(n);
const cell=Math.max(0.006,2*P.rMax*(1+P.jitter)*1.05);
const key=(x,y,z)=>(x+2048)+(y+2048)*4096+(z+2048)*16777216;
for(let it=0;it<iters;it++){
syncMirrors();
const grid=new Map();
for(let i=0;i<n;i++){
const k=key(Math.floor(seeds[i].x/cell),Math.floor(seeds[i].y/cell),Math.floor(seeds[i].z/cell));
let arr=grid.get(k); if(!arr){arr=[];grid.set(k,arr);} arr.push(i);
}
fx.fill(0);fy.fill(0);fz.fill(0);
for(let i=0;i<n;i++){
const A=seeds[i];
const gx=Math.floor(A.x/cell),gy=Math.floor(A.y/cell),gz=Math.floor(A.z/cell);
for(let x=gx-1;x<=gx+1;x++)for(let y=gy-1;y<=gy+1;y++)for(let z=gz-1;z<=gz+1;z++){
const arr=grid.get(key(x,y,z)); if(!arr) continue;
for(const j of arr){
if(j<=i) continue;
const B=seeds[j];
let ex=B.x-A.x, ey=B.y-A.y, ez=B.z-A.z;
const d=Math.hypot(ex,ey,ez);
const want=(A.r+B.r)*P.packing;
if(d>=want||d<1e-9) continue;
ex/=d; ey/=d; ez/=d;
const push=(want-d)*0.3;
fx[i]-=ex*push; fy[i]-=ey*push; fz[i]-=ez*push;
fx[j]+=ex*push; fy[j]+=ey*push; fz[j]+=ez*push;
}
}
}
for(let i=0;i<n;i++){
const A=seeds[i];
if(A.mir) continue;
if(P.mirror&&A.x<1e-4&&fx[i]<0) fx[i]=0;
const r=onSurface(A.x+fx[i],A.y+fy[i],A.z+fz[i]);
if(!r.ok) continue;
A.x=r.x;A.y=r.y;A.z=r.z;A.nx=r.nx;A.ny=r.ny;A.nz=r.nz;A.u=r.u;A.v=r.v;
readMaps(A.u,A.v,_mm);
A.r=_mm.r*(A.r/Math.max(1e-6,radiusFor(_mm.v)));
A.h=_mm.h; A.s=spacingOf(A);
if(P.mirror&&A.x<-0.0005){ A.x=0; A.nx=0; const l=Math.hypot(A.ny,A.nz)||1; A.ny/=l; A.nz/=l; }
}
await step('Saat ausgleichen '+(it+1)+' / '+iters,0.06+0.24*(it+1)/iters);
}
syncMirrors();
const alive=new Set();
for(const A of seeds){
if(A.mir) continue;
readMaps(A.u,A.v,_mm);
if(_mm.a>=P.alphaMin) alive.add(A);
}
seeds=seeds.filter(A=>A.mir?alive.has(A.srcRef):alive.has(A));
}
// ═══ 2 · Wachstum: Agenten mit Schwarmverhalten und Hautablenkung ═══
// ═══ 2 · Stützen: gedachte Zylinder, senkrecht auf der Haut ═══
// Sie sind Hindernis, nicht Geometrie. Die Wurzeln müssen sich durch die
// Gassen dazwischen fädeln — das erzeugt die Verdichtung und lässt die
// Zylinderräume als Luftkanäle frei.
let pillars=[], pGrid=null, pCell=0.05, pBlock=null;
let occ=null, occCellSz=0.006;
const occKeyAt=(x,y,z)=>cellKey(Math.floor(x/occCellSz),Math.floor(y/occCellSz),Math.floor(z/occCellSz));
const occAt=(x,y,z)=>occ.get(occKeyAt(x,y,z))||0;
function occAdd(x,y,z){
const k=occKeyAt(x,y,z);
occ.set(k,(occ.get(k)||0)+1);
}
/** Rastermodus: gleichmäßige Zeilen und Spalten im UV-Raum der Haut.
* Der Abstand ist in Weltmaß angegeben und über uvScale umgerechnet. */
function gridPillars(){
const su=Math.max(1e-5,P.pCols/Math.max(1e-6,uvScale));
const sv=Math.max(1e-5,P.pRows/Math.max(1e-6,uvScale));
const rr=Math.max(0.0006,Math.min(P.pCols,P.pRows)*0.5*P.pFill);
const best=new Map();
for(let k=0;k<texelIds.length;k++){
const i=texelIds[k];
const px=texPos[i*3];
if(P.mirror&&px<-0.001) continue;
const u=((i%R)+0.5)/R, v=(((i/R)|0)+0.5)/R;
const gi=Math.floor(u/su), gj=Math.floor(v/sv);
const d=(u-(gi+0.5)*su)**2+(v-(gj+0.5)*sv)**2;
const kk=gi*1048576+gj;
const e=best.get(kk);
if(!e||d<e.d) best.set(kk,{d,i,u,v});
}
for(const e of best.values()){
readMaps(e.u,e.v,_mm);
if(_mm.a<P.alphaMin) continue;
const i=e.i;
const t={x:texPos[i*3],y:texPos[i*3+1],z:texPos[i*3+2],
nx:texNrm[i*3],ny:texNrm[i*3+1],nz:texNrm[i*3+2],
r:rr,rr,h:P.hug+_mm.h*P.pHeight+0.004};
pillars.push(t);
if(P.mirror&&Math.abs(t.x)>=rr){
const p=onSurface(-t.x,t.y,t.z);
if(!p.ok) continue;
readMaps(p.u,p.v,_mm);
if(_mm.a<P.alphaMin) continue;
pillars.push({x:p.x,y:p.y,z:p.z,nx:p.nx,ny:p.ny,nz:p.nz,
r:rr,rr,h:P.hug+_mm.h*P.pHeight+0.004});
}
}
}
function samplePillars(){
pillars=[]; pGrid=null;
if(!P.pillarsOn) return;
if(P.pMode==='grid'){ gridPillars(); return; }
srand(P.seed*104729+17);
const cand=[];
const N=Math.max(2000,(P.samples*0.7)|0);
for(let i=0;i<N;i++){
const p=surfaceSample();
readMaps(p.u,p.v,_mm);
if(_mm.a<P.alphaMin) continue;
if(P.mirror&&p.x<-0.001) continue;
const r=Math.max(0.0015,_mm.r*P.pSpace*(1+(rnd()*2-1)*P.jitter*0.5));
cand.push({x:p.x,y:p.y,z:p.z,nx:p.nx,ny:p.ny,nz:p.nz,r,
rr:Math.max(0.0006,r*P.pFill),
h:P.hug+_mm.h*P.pHeight+0.004});
}
cand.sort((A,B)=>B.r-A.r);
const cell=Math.max(0.006,2*P.rMax*P.pSpace*(1+P.jitter)*1.05);
const key=(x,y,z)=>(x+2048)+(y+2048)*4096+(z+2048)*16777216;
const grid=new Map();
const fits=t=>{
const gx=Math.floor(t.x/cell),gy=Math.floor(t.y/cell),gz=Math.floor(t.z/cell);
for(let x=gx-1;x<=gx+1;x++)for(let y=gy-1;y<=gy+1;y++)for(let z=gz-1;z<=gz+1;z++){
const arr=grid.get(key(x,y,z)); if(!arr) continue;
for(const o of arr){
const dx=o.x-t.x,dy=o.y-t.y,dz=o.z-t.z;
const lim=o.r+t.r;
if(dx*dx+dy*dy+dz*dz<lim*lim) return false;
}
}
return true;
};
const insert=t=>{
const k=key(Math.floor(t.x/cell),Math.floor(t.y/cell),Math.floor(t.z/cell));
let arr=grid.get(k); if(!arr){arr=[];grid.set(k,arr);} arr.push(t);
pillars.push(t);
};
for(const t of cand){
if(P.mirror&&Math.abs(t.x)>=t.r){
// Die Spiegelseite kann bei asymmetrischer Pose woanders liegen —
// deshalb den gespiegelten Punkt auf die echte Oberfläche zurückholen
const p=onSurface(-t.x,t.y,t.z);
if(!p.ok) continue;
readMaps(p.u,p.v,_mm);
if(_mm.a<P.alphaMin) continue;
const m={x:p.x,y:p.y,z:p.z,nx:p.nx,ny:p.ny,nz:p.nz,r:t.r,rr:t.rr,
h:P.hug+_mm.h*P.pHeight+0.004};
if(fits(t)&&fits(m)){ insert(t); insert(m); }
}else if(fits(t)) insert(t);
}
}
function buildPillarGrid(){
occCellSz=Math.max(0.002,P.occCell);
let maxR=0.004;
for(const q of pillars) if(q.rr>maxR) maxR=q.rr;
pCell=Math.max(0.012,maxR*2.2);
pGrid=new Map();
pBlock=new Set();
const seen=new Set();
const tu=new V3(), tv=new V3(), up=new V3();
for(let i=0;i<pillars.length;i++){
const q=pillars[i];
seen.clear();
const steps=Math.max(1,Math.ceil(q.h/(pCell*0.5)));
for(let s=0;s<=steps;s++){
const t=q.h*s/steps;
const k=cellKey(Math.floor((q.x+q.nx*t)/pCell),
Math.floor((q.y+q.ny*t)/pCell),
Math.floor((q.z+q.nz*t)/pCell));
if(seen.has(k)) continue;
seen.add(k);
let arr=pGrid.get(k); if(!arr){arr=[];pGrid.set(k,arr);} arr.push(i);
}
// grobe Rasterung fürs Fühlern: Achse plus ein Ring, damit die Spitzen
// den Zylinder als besetzt lesen, ohne ihn jedes Mal neu zu suchen
up.set(q.nx,q.ny,q.nz);
tu.set(0,1,0); if(Math.abs(up.y)>0.9) tu.set(1,0,0);
tu.addScaledVector(up,-tu.dot(up)).normalize();
tv.crossVectors(up,tu).normalize();
const az=Math.max(1,Math.ceil(q.h/(occCellSz*0.6)));
const rr=q.rr*0.7;
for(let s=0;s<=az;s++){
const t=q.h*s/az;
const bx=q.x+q.nx*t, by=q.y+q.ny*t, bz=q.z+q.nz*t;
pBlock.add(occKeyAt(bx,by,bz));
for(let k=0;k<6;k++){
const a=k/6*Math.PI*2, c=Math.cos(a)*rr, sn=Math.sin(a)*rr;
pBlock.add(occKeyAt(bx+tu.x*c+tv.x*sn, by+tu.y*c+tv.y*sn, bz+tu.z*c+tv.z*sn));
}
}
}
}
/** Abstoßung vom Zylindermantel plus Wirbel um dessen Achse; schreibt in _pf.
* Der Wirbel wirkt über den Mantel hinaus bis swirlR und dreht bei allen
* Zylindern im selben Sinn — daher sind die Walzen achssymmetrisch. */
const _pf={x:0,y:0,z:0,hit:0,ox:0,oy:0,oz:0,depth:0,sx:0,sy:0,sz:0};
function pillarField(x,y,z,rad,swirlR){
_pf.x=_pf.y=_pf.z=0; _pf.hit=0; _pf.depth=0;
_pf.sx=_pf.sy=_pf.sz=0;
if(!pGrid) return _pf;
const sw=swirlR||0;
const gx=Math.floor(x/pCell),gy=Math.floor(y/pCell),gz=Math.floor(z/pCell);
const rng=sw>pCell?2:1;
for(let a=gx-rng;a<=gx+rng;a++)for(let b=gy-rng;b<=gy+rng;b++)for(let c=gz-rng;c<=gz+rng;c++){
const arr=pGrid.get(cellKey(a,b,c)); if(!arr) continue;
for(const i of arr){
const q=pillars[i];
const ex=x-q.x, ey=y-q.y, ez=z-q.z;
const t=ex*q.nx+ey*q.ny+ez*q.nz;
if(t<-0.006||t>q.h) continue; // ober- oder unterhalb vorbei
let rx=ex-q.nx*t, ry=ey-q.ny*t, rz=ez-q.nz*t;
let d=Math.hypot(rx,ry,rz);
if(d<1e-7){ rx=1; ry=0; rz=0; d=1e-7; }
rx/=d; ry/=d; rz/=d;
if(sw>0&&d<q.rr+sw){
// Umfangsrichtung = Achse × Radialrichtung
const w=1-clamp((d-q.rr)/sw,0,1);
_pf.sx+=(q.ny*rz-q.nz*ry)*w;
_pf.sy+=(q.nz*rx-q.nx*rz)*w;
_pf.sz+=(q.nx*ry-q.ny*rx)*w;
}
const want=q.rr+rad;
if(d>=want) continue;
const dep=want-d;
_pf.x+=rx*dep/want; _pf.y+=ry*dep/want; _pf.z+=rz*dep/want;
_pf.hit=1;
if(dep>_pf.depth){ _pf.depth=dep; _pf.ox=rx; _pf.oy=ry; _pf.oz=rz; }
}
}
return _pf;
}
const insidePillar=(x,y,z,rad)=>pillarField(x,y,z,rad,0).hit===1;
// ═══ 3 · Wachstum ═══
const _gp=new V3(), _gf=new V3(), _gq=new V3();
const _rv=[0,0,0];
function rotAround(vx,vy,vz,ax,ay,az,ang){
const c=Math.cos(ang), s=Math.sin(ang);
const dot=ax*vx+ay*vy+az*vz;
const cx=ay*vz-az*vy, cy=az*vx-ax*vz, cz=ax*vy-ay*vx;
_rv[0]=vx*c+cx*s+ax*dot*(1-c);
_rv[1]=vy*c+cy*s+ay*dot*(1-c);
_rv[2]=vz*c+cz*s+az*dot*(1-c);
return _rv;
}
function newAgent(x,y,z,dx,dy,dz,r0,life,depth,ph,g0){
return {x,y,z,dx,dy,dz,r0,life:Math.max(2,life|0),age:0,depth,off:0,ph,g0,
pts:[{x,y,z,b:r0,t:0,g:g0}]};
}
/** Dickenprofil: 0 = Ansatz, 1 = Spitze */
function curveAt(t){
const c=P.thick;
if(!c||!c.length) return 1;
t=clamp(t,0,1);
if(t<=c[0][0]) return c[0][1];
for(let i=1;i<c.length;i++){
if(t<=c[i][0]){
const a=c[i-1], b=c[i], d=b[0]-a[0];
return d<1e-6?b[1]:a[1]+(b[1]-a[1])*((t-a[0])/d);
}
}
return c[c.length-1][1];
}
const thickAt=(b,t)=>Math.max(P.rootMin,b*curveAt(t));
function radiusAt(a){ return thickAt(a.r0,a.age/a.life); }
/** Belegung entlang einer Fühlerrichtung, zwei Stützstellen */
function probeCost(x,y,z,dx,dy,dz,dist){
const x1=x+dx*dist*0.55, y1=y+dy*dist*0.55, z1=z+dz*dist*0.55;
const x2=x+dx*dist, y2=y+dy*dist, z2=z+dz*dist;
let c=occAt(x1,y1,z1)*0.6+occAt(x2,y2,z2);
// Zylinder zählen als voll besetzt — die Spitze sucht sich die Gasse
if(pBlock&&pBlock.size){
if(pBlock.has(occKeyAt(x1,y1,z1))) c+=P.occMax;
if(pBlock.has(occKeyAt(x2,y2,z2))) c+=P.occMax*1.5;
}
return c;
}
async function growRoots(){
roots=[]; rootPts=0;
const S=Math.max(2,P.steps|0);
srand(P.seed*7919+13);
occCellSz=Math.max(0.002,P.occCell);
occ=new Map();
const maxA=P.maxAgents|0;
let act=[];
for(const A of seeds){
if(act.length>=maxA) break;
const s=nearestSkin(A.x,A.y,A.z);
if(!s.ok) continue;
const base=Math.max(P.rootMin*2, A.r*P.rootScale);
const off=P.hug+base;
const x=s.x+s.nx*off, y=s.y+s.ny*off, z=s.z+s.nz*off;
if(pGrid&&insidePillar(x,y,z,base)) continue; // Saat steht nicht im Zylinder
// Startrichtung: zufällig in der Tangentialebene
let ux=rnd()*2-1, uy=rnd()*2-1, uz=rnd()*2-1;
const dn=ux*s.nx+uy*s.ny+uz*s.nz;
ux-=s.nx*dn; uy-=s.ny*dn; uz-=s.nz*dn;
const l=Math.hypot(ux,uy,uz)||1;
act.push(newAgent(x,y,z,ux/l,uy/l,uz/l,base,S,0,rnd()*Math.PI*2,0));
occAdd(x,y,z);
}
if(!act.length) return;
const cellN=Math.max(P.nbRadius,0.006);
const key=(x,y,z)=>(x+512)+(y+512)*1024+(z+512)*1048576;
const grid=new Map();
const spawn=[];
for(let st=0; st<S && act.length; st++){
grid.clear();
for(let i=0;i<act.length;i++){
const a=act[i];
const k=key(Math.floor(a.x/cellN),Math.floor(a.y/cellN),Math.floor(a.z/cellN));
let arr=grid.get(k); if(!arr){arr=[];grid.set(k,arr);} arr.push(i);
}
const nr2=P.nbRadius*P.nbRadius, sd=P.sepDist, sd2=sd*sd;
spawn.length=0;
const next=[];
for(let i=0;i<act.length;i++){
const a=act[i];
const r=radiusAt(a);
const s=nearestSkin(a.x,a.y,a.z);
let dead=false, floorH=0, ceilH=0, nx=0,ny=1,nz=0, sdist=0;
let fx=0,fy=0,fz=0;
if(!s.ok) dead=true;
else{
nx=s.nx; ny=s.ny; nz=s.nz;
// ── Ebene 2 spannt die Decke auf, die Haut ist der Boden
let lift=0;
if(paintData){
const m=sampleMap(s.u,s.v);
lift=m[3]*(1-m[2])*P.lift;
if(P.killOffPaint){
if(m[1]<P.alphaMin){ a.off++; if(a.off>3) dead=true; } else a.off=0;
}
}
floorH=P.hug+r; ceilH=floorH+lift;
sdist=(a.x-s.x)*nx+(a.y-s.y)*ny+(a.z-s.z)*nz;
// Findet die Spitze keine plausible Haut mehr — etwa weil der nächste
// Punkt auf der Gegenseite eines dünnen Glieds liegt — endet sie hier.
// Sonst schleudert die Rückholkraft sie als Stachel vom Körper weg.
if(s.d>ceilH+0.03||sdist<-0.02) dead=true;
else{
if(sdist<floorH) { const e=(floorH-sdist)*3; fx+=nx*e; fy+=ny*e; fz+=nz*e; }
else if(sdist>ceilH){ const e=(sdist-ceilH)*3; fx-=nx*e; fy-=ny*e; fz-=nz*e; }
// ── Bodenzug: die Schale füllt sich von der Haut aufwärts, statt
// dass alles an der Decke hängt und über der Haut zu schweben scheint
if(P.settle>0&&lift>1e-6){
const up=clamp((sdist-floorH)/lift,0,1)*P.settle;
fx-=nx*up; fy-=ny*up; fz-=nz*up;
}
}
// ── Platzsuche: fünf Fühler, der freieste gewinnt
if(P.senseForce>0){
let bx=a.dx,by=a.dy,bz=a.dz;
let best=probeCost(a.x,a.y,a.z,a.dx,a.dy,a.dz,P.senseDist);
let ox=ny*a.dz-nz*a.dy, oy=nz*a.dx-nx*a.dz, oz=nx*a.dy-ny*a.dx;
const ol=Math.hypot(ox,oy,oz);
const axes=[[nx,ny,nz]];
if(ol>1e-6) axes.push([ox/ol,oy/ol,oz/ol]);
for(const ax of axes){
for(const sgn of [1,-1]){
const v=rotAround(a.dx,a.dy,a.dz,ax[0],ax[1],ax[2],P.senseAngle*sgn);
const c=probeCost(a.x,a.y,a.z,v[0],v[1],v[2],P.senseDist);
if(c<best-1e-9){ best=c; bx=v[0]; by=v[1]; bz=v[2]; }
}
}
fx+=(bx-a.dx)*P.senseForce; fy+=(by-a.dy)*P.senseForce; fz+=(bz-a.dz)*P.senseForce;
}
// ── Zylinder: Abstoßung vom Mantel und Wirbel um die Achse
if(pGrid&&(P.pPush>0||P.pSwirl!==0)){
const pf=pillarField(a.x,a.y,a.z,r+P.sepDist*0.5,P.pSwirlRange);
if(pf.hit&&P.pPush>0){ fx+=pf.x*P.pPush; fy+=pf.y*P.pPush; fz+=pf.z*P.pPush; }
if(P.pSwirl!==0){
const sl=Math.hypot(pf.sx,pf.sy,pf.sz);
if(sl>1e-9){
const w=P.pSwirl*Math.min(1,sl);
fx+=pf.sx/sl*w; fy+=pf.sy/sl*w; fz+=pf.sz/sl*w;
}
}
}
// ── Oszillation quer zur Bahn
let bx2=ny*a.dz-nz*a.dy, by2=nz*a.dx-nx*a.dz, bz2=nx*a.dy-ny*a.dx;
const bl=Math.hypot(bx2,by2,bz2);
if(bl>1e-9&&P.oscAmp>0){
const o=Math.sin(a.ph)*P.oscAmp;
fx+=bx2/bl*o; fy+=by2/bl*o; fz+=bz2/bl*o;
}
a.ph+=P.oscFreq*(1+(rnd()*2-1)*P.oscJitter);
}
// ── Perlin-Curl
_gp.set(a.x,a.y,a.z);
if(P.mirror&&_gp.x<0){ _gq.set(-_gp.x,_gp.y,_gp.z); curl(_gq,_gf); _gf.x=-_gf.x; }
else curl(_gp,_gf);
fx+=_gf.x*P.curlAmp; fy+=_gf.y*P.curlAmp; fz+=_gf.z*P.curlAmp;
fy-=P.gravity;
// ── Schwarm
let cnt=0,cx=0,cy=0,cz=0,vx=0,vy=0,vz=0,px=0,py=0,pz=0;
const gx=Math.floor(a.x/cellN),gy=Math.floor(a.y/cellN),gz=Math.floor(a.z/cellN);
for(let bx=gx-1;bx<=gx+1;bx++)for(let by=gy-1;by<=gy+1;by++)for(let bz=gz-1;bz<=gz+1;bz++){
const arr=grid.get(key(bx,by,bz)); if(!arr) continue;
for(const j of arr){
if(j===i) continue;
const b=act[j];
const ex=b.x-a.x, ey=b.y-a.y, ez=b.z-a.z;
const q=ex*ex+ey*ey+ez*ez;
if(q>nr2||q<1e-12) continue;
cnt++; cx+=b.x; cy+=b.y; cz+=b.z; vx+=b.dx; vy+=b.dy; vz+=b.dz;
if(q<sd2){
const d=Math.sqrt(q), w=(sd-d)/sd;
px-=ex/d*w; py-=ey/d*w; pz-=ez/d*w;
}
}
}
fx+=px*P.sepForce; fy+=py*P.sepForce; fz+=pz*P.sepForce;
if(cnt){
fx+=(vx/cnt-a.dx)*P.alignForce;
fy+=(vy/cnt-a.dy)*P.alignForce;
fz+=(vz/cnt-a.dz)*P.alignForce;
let ox=cx/cnt-a.x, oy=cy/cnt-a.y, oz=cz/cnt-a.z;
const ol=Math.hypot(ox,oy,oz)||1; ox/=ol; oy/=ol; oz/=ol;
fx+=ox*P.cohForce; fy+=oy*P.cohForce; fz+=oz*P.cohForce;
let tx=a.dy*oz-a.dz*oy, ty=a.dz*ox-a.dx*oz, tz=a.dx*oy-a.dy*ox;
const tl=Math.hypot(tx,ty,tz);
if(tl>1e-6){ fx+=tx/tl*P.twistForce; fy+=ty/tl*P.twistForce; fz+=tz/tl*P.twistForce; }
}
// Tote Spitzen enden hier — kein letzter, ungebremster Schritt mehr
if(dead){ roots.push(a.pts); rootPts+=a.pts.length; continue; }
// ── Richtung; an Boden und Decke wird der austretende Anteil gekappt
let ux=a.dx+fx*P.turn, uy=a.dy+fy*P.turn, uz=a.dz+fz*P.turn;
if(s.ok){
const dn=ux*nx+uy*ny+uz*nz;
if((sdist<=floorH&&dn<0)||(sdist>=ceilH&&dn>0)){ ux-=nx*dn; uy-=ny*dn; uz-=nz*dn; }
}
const ul=Math.hypot(ux,uy,uz);
if(ul>1e-9){ ux/=ul; uy/=ul; uz/=ul; } else { ux=a.dx; uy=a.dy; uz=a.dz; }
a.dx=ux; a.dy=uy; a.dz=uz;
a.x+=ux*P.stepLen; a.y+=uy*P.stepLen; a.z+=uz*P.stepLen;
// ── in die Schale zurückholen, höchstens um eine Schrittlänge:
// ein großer Sprung hier wäre der Stachel, der vom Körper wegsticht
if(s.ok){
const lim=P.stepLen*1.5;
const sd2n=(a.x-s.x)*nx+(a.y-s.y)*ny+(a.z-s.z)*nz;
let corr=0;
if(sd2n<floorH) corr=(floorH-sd2n)*P.stick;
else if(sd2n>ceilH) corr=(ceilH-sd2n)*P.stick;
corr=clamp(corr,-lim,lim);
if(corr){ a.x+=nx*corr; a.y+=ny*corr; a.z+=nz*corr; }
}
// ── aus dem Zylinder schieben, damit nichts durchdringt
if(pGrid){
const pf=pillarField(a.x,a.y,a.z,r,0);
if(pf.hit&&pf.depth>0){
a.x+=pf.ox*pf.depth; a.y+=pf.oy*pf.depth; a.z+=pf.oz*pf.depth;
}
}
a.age++;
// ── Anastomose: trifft die Spitze auf dichtes Geflecht, verwächst sie damit
if(!dead&&P.fuse&&a.age>3&&occAt(a.x,a.y,a.z)>P.occMax) dead=true;
occAdd(a.x,a.y,a.z);
const gNow=clamp(a.g0+a.age*P.stepLen/Math.max(1e-6,S*P.stepLen),0,1);
a.pts.push({x:a.x,y:a.y,z:a.z,b:a.r0,t:a.age/a.life,g:gNow});
// ── Verzweigung
if(!dead && s.ok && a.depth<(P.maxDepth|0) && a.age<a.life-2
&& act.length+spawn.length+next.length<maxA && rnd()<P.branchProb){
let ax=nx,ay=ny,az=nz;
if(rnd()<0.5){
const ox=ny*a.dz-nz*a.dy, oy=nz*a.dx-nx*a.dz, oz=nx*a.dy-ny*a.dx;
const ol=Math.hypot(ox,oy,oz);
if(ol>1e-6){ ax=ox/ol; ay=oy/ol; az=oz/ol; }
}
const ang=P.branchAngle*(0.4+0.6*rnd())*(rnd()<0.5?1:-1);
const v=rotAround(a.dx,a.dy,a.dz,ax,ay,az,ang);
spawn.push(newAgent(a.x,a.y,a.z,v[0],v[1],v[2],
Math.max(P.rootMin,radiusAt(a)*P.branchDecay),
Math.max(3,Math.round((a.life-a.age)*(0.5+0.5*rnd()))),a.depth+1,rnd()*Math.PI*2,gNow));
}
if(dead||a.age>=a.life){ roots.push(a.pts); rootPts+=a.pts.length; }
else next.push(a);
}
act=next.concat(spawn);
if((st&3)===0) await step('Wurzeln wachsen '+(st+1)+' / '+S+' · '+act.length+' Spitzen',0.32+0.5*st/S);
}
for(const a of act){ roots.push(a.pts); rootPts+=a.pts.length; }
}
// ═══ 5 · Pflanzenkörper: Sprosse aus der Saat, nach außen statt in die Schale.
// Gleiches Curl-Feld, gleiche Dickenkurve wie die Wurzeln.
// ═══ 5 · Halme: liegen flach über der Wurzelmatte und winden sich mit ihr.
// Gleiches Curl-Feld und Schlingern wie die Wurzeln, nur eine Lage höher.
function growShoots(){
shoots=[];
if(!P.shootsOn||!seeds.length) return;
srand(P.seed*31337+5);
const S=Math.max(2,P.shootSteps|0);
const dl=P.shootLen/S;
// Einheitliche Stärke aus dem Mittel der Saat — sonst erbt jeder Halm den
// Radius seines Saatkorns und einzelne werden klobig.
let mr=0; for(const A of seeds) mr+=A.r; mr/=seeds.length;
const base=Math.max(P.rootMin*2, mr*P.rootScale*P.shootThick);
const f=new V3(), p=new V3(), q=new V3();
for(const A of seeds){
if(rnd()>P.shootProb) continue;
let s=nearestSkin(A.x,A.y,A.z);
if(!s.ok) continue;
let lift=0;
if(paintData){
const m=sampleMap(s.u,s.v);
if(m[1]<P.alphaMin) continue;
lift=m[3]*(1-m[2])*P.lift;
}
const h0=P.hug+lift+P.bladeRise;
let x=s.x+s.nx*h0, y=s.y+s.ny*h0, z=s.z+s.nz*h0;
// Startrichtung tangential, nicht nach außen
let dx=rnd()*2-1, dy=rnd()*2-1, dz=rnd()*2-1;
let dn=dx*s.nx+dy*s.ny+dz*s.nz;
dx-=s.nx*dn; dy-=s.ny*dn; dz-=s.nz*dn;
let dl0=Math.hypot(dx,dy,dz)||1; dx/=dl0; dy/=dl0; dz/=dl0;
let ph=rnd()*Math.PI*2;
const pts=[];
for(let k=0;k<=S;k++){
s=nearestSkin(x,y,z);
if(!s.ok) break;
let lf=0;
if(paintData){
const m=sampleMap(s.u,s.v);
if(m[1]<P.alphaMin) break;
lf=m[3]*(1-m[2])*P.lift;
}
// auf die Sollhöhe über der Matte ziehen, begrenzt auf eine Schrittlänge
const want=P.hug+lf+P.bladeRise;
const sd=(x-s.x)*s.nx+(y-s.y)*s.ny+(z-s.z)*s.nz;
const corr=clamp((want-sd)*0.8,-dl,dl);
x+=s.nx*corr; y+=s.ny*corr; z+=s.nz*corr;
pts.push({x,y,z,b:base,t:k/S,g:k/S,nx:s.nx,ny:s.ny,nz:s.nz});
if(k===S) break;
p.set(x,y,z);
if(P.mirror&&p.x<0){ q.set(-p.x,p.y,p.z); curl(q,f); f.x=-f.x; } else curl(p,f);
let fx=f.x*P.shootWobble, fy=f.y*P.shootWobble, fz=f.z*P.shootWobble;
let bx=s.ny*dz-s.nz*dy, by=s.nz*dx-s.nx*dz, bz=s.nx*dy-s.ny*dx;
const bl=Math.hypot(bx,by,bz);
if(bl>1e-9&&P.oscAmp>0){
const o=Math.sin(ph)*P.oscAmp;
fx+=bx/bl*o; fy+=by/bl*o; fz+=bz/bl*o;
}
ph+=P.oscFreq*(1+(rnd()*2-1)*P.oscJitter);
let ux=dx+fx*P.turn, uy=dy+fy*P.turn, uz=dz+fz*P.turn;
dn=ux*s.nx+uy*s.ny+uz*s.nz; // flach halten
ux-=s.nx*dn; uy-=s.ny*dn; uz-=s.nz*dn;
const l=Math.hypot(ux,uy,uz);
if(l>1e-9){ ux/=l; uy/=l; uz/=l; } else { ux=dx; uy=dy; uz=dz; }
dx=ux; dy=uy; dz=uz;
x+=ux*dl; y+=uy*dl; z+=uz*dl;
}
if(pts.length>2) shoots.push(pts);
}
}
// ═══ 6 · Vorschau (Linien) ═══
function buildPillarPreview(){
if(pillarLines){ preGroup.remove(pillarLines); pillarLines.geometry.dispose(); pillarLines.material.dispose(); pillarLines=null; }
if(!pillars.length) return;
const K=6;
const lin=new Float32Array(pillars.length*(K*2+1)*6);
let w=0;
const up=new V3(), tu=new V3(), tv=new V3();
for(const q of pillars){
up.set(q.nx,q.ny,q.nz);
tu.set(0,1,0);
if(Math.abs(up.y)>0.9) tu.set(1,0,0);
tu.addScaledVector(up,-tu.dot(up)).normalize();
tv.crossVectors(up,tu).normalize();
const bx=q.x, by=q.y, bz=q.z;
const tx=q.x+q.nx*q.h, ty=q.y+q.ny*q.h, tz=q.z+q.nz*q.h;
lin[w++]=bx; lin[w++]=by; lin[w++]=bz; lin[w++]=tx; lin[w++]=ty; lin[w++]=tz;
for(let k=0;k<K;k++){
const a0=k/K*Math.PI*2, a1=(k+1)/K*Math.PI*2;
const c0=Math.cos(a0)*q.rr, s0=Math.sin(a0)*q.rr;
const c1=Math.cos(a1)*q.rr, s1=Math.sin(a1)*q.rr;
for(const h of [0,q.h]){
lin[w++]=q.x+q.nx*h+tu.x*c0+tv.x*s0;
lin[w++]=q.y+q.ny*h+tu.y*c0+tv.y*s0;
lin[w++]=q.z+q.nz*h+tu.z*c0+tv.z*s0;
lin[w++]=q.x+q.nx*h+tu.x*c1+tv.x*s1;
lin[w++]=q.y+q.ny*h+tu.y*c1+tv.y*s1;
lin[w++]=q.z+q.nz*h+tu.z*c1+tv.z*s1;
}
}
}
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.Float32BufferAttribute(lin,3));
g.setDrawRange(0,w/3);
pillarLines=new THREE.LineSegments(g,new THREE.LineBasicMaterial({
color:0x111111,transparent:true,opacity:0.18}));
pillarLines.frustumCulled=false; preGroup.add(pillarLines);
}
function buildPreview(){
if(preLines){ preGroup.remove(preLines); preLines.geometry.dispose(); preLines.material.dispose(); preLines=null; }
const all=shoots.length?roots.concat(shoots):roots;
if(!all.length) return;
let segs=0;
for(const p of all) segs+=Math.max(0,p.length-1);
const lin=new Float32Array(Math.max(1,segs*6));
let w=0;
for(const p of all){
for(let i=0;i<p.length-1;i++){
lin[w++]=p[i].x; lin[w++]=p[i].y; lin[w++]=p[i].z;
lin[w++]=p[i+1].x; lin[w++]=p[i+1].y; lin[w++]=p[i+1].z;
}
}
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.Float32BufferAttribute(lin,3));
g.setDrawRange(0,w/3);
preLines=new THREE.LineSegments(g,new THREE.LineBasicMaterial({color:0x111111,transparent:true,opacity:0.8}));
preLines.frustumCulled=false; preGroup.add(preLines);
}
// ═══ 6 · Mesh: Low-Poly-Röhren, jede Wurzel geschlossen ═══
function decimate(p){
const thr=Math.cos(clamp(P.simplify,0,1.2));
if(P.simplify<=0.001||p.length<3) return p;
const out=[p[0]];
for(let i=1;i<p.length-1;i++){
const a=out[out.length-1], b=p[i], c=p[i+1];
let ax=b.x-a.x, ay=b.y-a.y, az=b.z-a.z;
let cx=c.x-b.x, cy=c.y-b.y, cz=c.z-b.z;
const al=Math.hypot(ax,ay,az)||1, cl=Math.hypot(cx,cy,cz)||1;
const dot=(ax*cx+ay*cy+az*cz)/(al*cl);
if(dot<thr) out.push(b);
}
out.push(p[p.length-1]);
return out;
}
function addRoot(pts,sides,pos,idx,gs,cs,cls){
const N=pts.length; if(N<2) return;
const P3=[]; for(const p of pts) P3.push(new V3(p.x,p.y,p.z));
const F=frames(P3);
const base=pos.length/3;
for(let i=0;i<N;i++){
const c=P3[i], nn=F.N[i], bb=F.B[i], r=thickAt(pts[i].b,pts[i].t);
for(let j=0;j<sides;j++){
const a=j/sides*Math.PI*2, ca=Math.cos(a)*r, sa=Math.sin(a)*r;
pos.push(c.x+nn.x*ca+bb.x*sa, c.y+nn.y*ca+bb.y*sa, c.z+nn.z*ca+bb.z*sa);
gs.push(pts[i].g||0); cs.push(cls);
}
}
for(let i=0;i<N-1;i++)for(let j=0;j<sides;j++){
const a=base+i*sides+j, b=base+i*sides+(j+1)%sides;
idx.push(a,b,b+sides, a,b+sides,a+sides);
}
const c0=base+N*sides, c1=c0+1;
pos.push(P3[0].x,P3[0].y,P3[0].z, P3[N-1].x,P3[N-1].y,P3[N-1].z);
gs.push(pts[0].g||0, pts[N-1].g||0); cs.push(cls,cls);
const last=base+(N-1)*sides;
for(let j=0;j<sides;j++){
const a=base+j, b=base+(j+1)%sides;
idx.push(c0,b,a);
const a2=last+j, b2=last+(j+1)%sides;
idx.push(c1,a2,b2);
}
}
/** Kugelvorlage: IcosahedronGeometry ist unindiziert — hier einmal
* verschweißen, sonst kostet jede Kugel das Fünffache an Ecken. */
function sphereTemplate(detail){
const g=new THREE.IcosahedronGeometry(1,clamp(detail|0,0,2));
const p=g.attributes.position;
if(g.index){
const pt={pos:new Float32Array(p.array), idx:Array.from(g.index.array)};
g.dispose(); return pt;
}
const pos=[], idx=new Array(p.count), map=new Map();
for(let i=0;i<p.count;i++){
const x=p.getX(i), y=p.getY(i), z=p.getZ(i);
const k=Math.round(x*1e4)+'_'+Math.round(y*1e4)+'_'+Math.round(z*1e4);
let id=map.get(k);
if(id===undefined){ id=pos.length/3; map.set(k,id); pos.push(x,y,z); }
idx[i]=id;
}
g.dispose();
return {pos:new Float32Array(pos), idx};
}
/** Kugel am Bahnende, in dieselben Puffer wie die Röhren */
function addSphere(tpl,x,y,z,r,g,pos,idx,gs,cs,cls){
const vc=tpl.pos.length/3;
const base=pos.length/3;
for(let i=0;i<vc;i++){
pos.push(tpl.pos[i*3]*r+x, tpl.pos[i*3+1]*r+y, tpl.pos[i*3+2]*r+z);
gs.push(g); cs.push(cls);
}
for(let i=0;i<tpl.idx.length;i++) idx.push(tpl.idx[i]+base);
}
/** Halm: flacher Querschnitt, dessen breite Seite an der Hautnormalen
* ausgerichtet ist — sonst läge das Band in zufälliger Verdrehung. */
function addBlade(pts,sides,pos,idx,gs,cs,cls){
const N=pts.length; if(N<2) return;
const P3=[]; for(const p of pts) P3.push(new V3(p.x,p.y,p.z));
const base=pos.length/3;
const T=new V3(), Nn=new V3(), Sd=new V3(), Up=new V3();
for(let i=0;i<N;i++){
T.subVectors(P3[Math.min(N-1,i+1)],P3[Math.max(0,i-1)]);
if(T.lengthSq()<1e-14) T.set(1,0,0);
T.normalize();
Nn.set(pts[i].nx||0,pts[i].ny||1,pts[i].nz||0);
Sd.crossVectors(T,Nn);
if(Sd.lengthSq()<1e-12) Sd.set(1,0,0); else Sd.normalize();
Up.crossVectors(Sd,T).normalize();
const r=thickAt(pts[i].b,pts[i].t);
const w=r*P.bladeWidth, th=Math.max(1e-5,r*P.bladeThick);
for(let j=0;j<sides;j++){
const a=j/sides*Math.PI*2, c=Math.cos(a)*w, sn=Math.sin(a)*th;
pos.push(P3[i].x+Sd.x*c+Up.x*sn, P3[i].y+Sd.y*c+Up.y*sn, P3[i].z+Sd.z*c+Up.z*sn);
gs.push(pts[i].g||0); cs.push(cls);
}
}
for(let i=0;i<N-1;i++)for(let j=0;j<sides;j++){
const a=base+i*sides+j, b=base+i*sides+(j+1)%sides;
idx.push(a,b,b+sides, a,b+sides,a+sides);
}
const c0=base+N*sides, c1=c0+1;
pos.push(P3[0].x,P3[0].y,P3[0].z, P3[N-1].x,P3[N-1].y,P3[N-1].z);
gs.push(pts[0].g||0, pts[N-1].g||0); cs.push(cls,cls);
const last=base+(N-1)*sides;
for(let j=0;j<sides;j++){
const a=base+j, b=base+(j+1)%sides;
idx.push(c0,b,a);
const a2=last+j, b2=last+(j+1)%sides;
idx.push(c1,a2,b2);
}
}
let rootG=null, rootC=null;
/** Je Klasse eine eigene Farbe. Der Verlauf innerhalb einer Klasse ist nur
* noch eine Aufhellung derselben Farbe — dadurch bleiben Wurzelwerk,
* Halme auf den ersten Blick auseinanderzuhalten. */
function shadePair(hex){
const c=new THREE.Color(hex), h={};
c.getHSL(h);
const s=clamp(P.shade,0,0.9);
return [new THREE.Color().setHSL(h.h,h.s,clamp(h.l*(1-s*0.75),0.02,0.95)),
new THREE.Color().setHSL(h.h,h.s*(1-0.3*s),clamp(h.l+(1-h.l)*s,0.05,0.97))];
}
function applyRootColors(){
if(!rootMesh||!rootG) return;
const pal=[shadePair(P.rootColor),shadePair(P.stemColor)];
const arr=rootMesh.geometry.attributes.color.array;
for(let i=0;i<rootG.length;i++){
const t=rootG[i];
const p=pal[(rootC&&rootC[i])|0]||pal[0];
const c1=p[0], c2=p[1];
arr[i*3 ]=c1.r+(c2.r-c1.r)*t;
arr[i*3+1]=c1.g+(c2.g-c1.g)*t;
arr[i*3+2]=c1.b+(c2.b-c1.b)*t;
}
rootMesh.geometry.attributes.color.needsUpdate=true;
}
async function buildMesh(){
if(busy||!roots.length) return;
busy=true;
try{
clearMeshOnly();
const sides=Math.max(3,P.tubeSides|0);
const pos=[], idx=[], gs=[], cs=[];
const tipTpl=P.tipSpheres?sphereTemplate(P.tipDetail):null;
for(let k=0;k<roots.length;k++){
const p=decimate(roots[k]);
addRoot(p,sides,pos,idx,gs,cs,0);
if(tipTpl&&p.length){
const q=p[p.length-1];
addSphere(tipTpl,q.x,q.y,q.z,thickAt(q.b,q.t)*P.tipScale,q.g||0,pos,idx,gs,cs,0);
}
if((k&255)===0) await step('Mesh: Wurzeln '+k+' / '+roots.length,0.05+0.75*k/roots.length);
}
for(let k=0;k<shoots.length;k++){
addBlade(shoots[k],Math.max(3,P.bladeSides|0),pos,idx,gs,cs,1);
if((k&255)===0) await step('Mesh: Halme '+k+' / '+shoots.length,0.8+0.1*k/Math.max(1,shoots.length));
}
if(!pos.length){ setStatus('Nichts zu bauen.'); return; }
await step('Mesh: zusammenbauen',0.94);
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
geo.setAttribute('color',new THREE.Float32BufferAttribute(new Float32Array(gs.length*3),3));
geo.setIndex(new THREE.Uint32BufferAttribute(idx,1));
geo.computeVertexNormals(); geo.computeBoundingSphere();
rootMesh=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({
vertexColors:true,roughness:0.5,metalness:0,side:THREE.FrontSide}));
rootMesh.frustumCulled=false; rootGroup.add(rootMesh);
rootG=new Float32Array(gs); rootC=new Uint8Array(cs);
applyRootColors();
P.showPreview=false; syncUI();
setStatus('Mesh gebaut.');
}catch(e){
console.error(e);
setStatus('Mesh fehlgeschlagen: '+(e&&e.message?e.message:e));
} finally { busy=false; progressEnd(); }
}
function meshTris(){ return rootMesh?rootMesh.geometry.index.count/3:0; }
// ── Gesamtlauf
async function generate(){
if(busy) return;
if(!paintData){ setStatus('Erst ein Modell laden.'); return; }
busy=true;
try{
clearRoots();
await step('Saat setzen',0.02);
buildTriGrid();
if(!sampleSeeds()){ setStatus('Keine bemalte Fläche auf Ebene 1 — erst malen.'); return; }
await step('Saat setzen · '+seeds.length,0.05);
await relaxSeeds();
await step('Hautfeld aufbauen',0.28);
buildSkinField();
await step('Stützen setzen',0.31);
samplePillars();
if(pillars.length) buildPillarGrid();
await growRoots();
await step('Pflanzenkörper',0.86);
growShoots();
await step('Vorschau',0.9);
buildPillarPreview();
buildPreview();
P.showPreview=true; syncUI();
setStatus('Wurzeln gewachsen — Mesh bauen.');
}catch(e){
console.error(e);
setStatus('Fehlgeschlagen: '+(e&&e.message?e.message:e));
} finally { busy=false; progressEnd(); }
}
// ═════════════════════════════════════════════════════════════════════════════
// 11 · EXPORT
// ═════════════════════════════════════════════════════════════════════════════
function download(blob,name){
const a=document.createElement('a');
a.href=URL.createObjectURL(blob); a.download=name; a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
}
function exportPieces(){
const out=[];
if(rootMesh) out.push({name:'roots',geo:rootMesh.geometry});
if(P.exportBody&&body) out.push({name:'body',geo:body.geometry});
return out;
}
function exportOBJ(pieces){
let out='# ROOT MESH DESIGNER\n', voff=1;
for(const {name,geo} of pieces){
const p=geo.attributes.position,n=geo.attributes.normal,ix=geo.index;
const col=geo.attributes.color;
out+='o '+name+'\n';
for(let i=0;i<p.count;i++){
out+=`v ${p.getX(i).toFixed(5)} ${p.getY(i).toFixed(5)} ${p.getZ(i).toFixed(5)}`;
if(col) out+=` ${col.getX(i).toFixed(4)} ${col.getY(i).toFixed(4)} ${col.getZ(i).toFixed(4)}`;
out+='\n';
}
for(let i=0;i<n.count;i++) out+=`vn ${n.getX(i).toFixed(4)} ${n.getY(i).toFixed(4)} ${n.getZ(i).toFixed(4)}\n`;
if(ix) for(let i=0;i<ix.count;i+=3){
const a=ix.getX(i)+voff,b=ix.getX(i+1)+voff,c=ix.getX(i+2)+voff;
out+=`f ${a}//${a} ${b}//${b} ${c}//${c}\n`;
}
voff+=p.count;
}
download(new Blob([out],{type:'text/plain'}),'root_mesh.obj');
}
async function exportGLTF(pieces,binary){
if(!GLTFExporter){
const m=await addon('exporters/GLTFExporter.js');
if(!m){ setStatus('GLTFExporter nicht verfügbar — OBJ nutzen.'); return; }
GLTFExporter=m.GLTFExporter;
}
const root=new THREE.Group(); root.name='root_mesh';
for(const {name,geo} of pieces){
const vc=name==='roots'&&!!geo.attributes.color;
const m=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({
color: name==='body'?0xdddddd:0xffffff, vertexColors:vc,
roughness:0.55, metalness:0}));
m.name=name; root.add(m);
}
new GLTFExporter().parse(root,res=>{
if(binary) download(new Blob([res],{type:'model/gltf-binary'}),'root_mesh.glb');
else download(new Blob([JSON.stringify(res,null,1)],{type:'model/gltf+json'}),'root_mesh.gltf');
setStatus('Export fertig.');
},err=>{ console.error(err); setStatus('Export fehlgeschlagen.'); },{binary});
}
function doExport(){
const pieces=exportPieces();
if(!pieces.length){ setStatus('Nichts zu exportieren — erst Mesh bauen.'); return; }
if(P.exportFormat==='obj') exportOBJ(pieces);
else exportGLTF(pieces,P.exportFormat==='glb');
}
// ── bodypaint.png: R = Ebene 1 Wert · G = Ebene 2 Wert
// B = Deckung beider Ebenen, je 4 Bit (hoch = Ebene 1, tief = Ebene 2)
// Alphakanal bleibt voll, damit die Datei verlustfrei durch Canvas läuft.
function applyBodypaint(img){
const w=img.naturalWidth||img.width, h=img.naturalHeight||img.height;
if(!w||!h||!paintData) return false;
const cv=document.createElement('canvas'); cv.width=w; cv.height=h;
const ctx=cv.getContext('2d',{willReadFrequently:true});
ctx.drawImage(img,0,0);
const d=ctx.getImageData(0,0,w,h).data;
for(let y=0;y<R;y++){
const sy=Math.min(h-1,Math.floor((y+0.5)/R*h));
for(let x=0;x<R;x++){
const sx=Math.min(w-1,Math.floor((x+0.5)/R*w)); // nächster Nachbar,
const o=(sy*w+sx)*4, t=(y*R+x)*4; // sonst zerfließen die Nibbles
const b=d[o+2];
paintData[t ]=d[o];
paintData[t+1]=(b>>4)*17;
paintData[t+2]=d[o+1];
paintData[t+3]=(b&15)*17;
}
}
paintDirty=true; pushHistory();
setStatus('bodypaint geladen ('+w+'×'+h+').');
return true;
}
function importBodypaint(file){
if(!paintData){ setStatus('Erst ein Modell laden.'); return; }
const url=URL.createObjectURL(file);
const img=new Image();
img.onload=()=>{ applyBodypaint(img); URL.revokeObjectURL(url); };
img.onerror=()=>{ setStatus('Bild konnte nicht gelesen werden.'); URL.revokeObjectURL(url); };
img.src=url;
}
function exportBodypaint(){
if(!paintData) return;
const cv=document.createElement('canvas'); cv.width=cv.height=R;
const ctx=cv.getContext('2d');
const img=ctx.createImageData(R,R);
for(let i=0;i<R*R;i++){
const o=i*4;
img.data[o ]=paintData[o];
img.data[o+1]=paintData[o+2];
img.data[o+2]=(Math.round(paintData[o+1]/17)<<4)|Math.round(paintData[o+3]/17);
img.data[o+3]=255;
}
ctx.putImageData(img,0,0);
cv.toBlob(b=>download(b,'bodypaint.png'));
}
async function autoBodypaint(){
if(!paintData) return;
try{
const r=await fetch('./bodypaint.png');
if(!r.ok) return;
const url=URL.createObjectURL(await r.blob());
await new Promise(res=>{
const img=new Image();
img.onload=()=>{ applyBodypaint(img); URL.revokeObjectURL(url); res(); };
img.onerror=()=>{ URL.revokeObjectURL(url); res(); };
img.src=url;
});
}catch(e){ /* keine Datei vorhanden */ }
}
// ═════════════════════════════════════════════════════════════════════════════
// 12 · UI
// ═════════════════════════════════════════════════════════════════════════════
const ui=document.getElementById('ui');
const syncers=[];
function group(title,open){
const wrap=document.createElement('div'); wrap.className='grp';
const h=document.createElement('button'); h.className='grph';
const t=document.createElement('span'); t.textContent=title;
const i=document.createElement('i'); i.textContent=open?'−':'+';
h.append(t,i);
const bodyEl=document.createElement('div'); bodyEl.className='grpb';
bodyEl.style.display=open?'block':'none';
h.addEventListener('click',()=>{
const o=bodyEl.style.display==='none';
bodyEl.style.display=o?'block':'none'; i.textContent=o?'−':'+';
});
wrap.append(h,bodyEl); ui.appendChild(wrap); return bodyEl;
}
function section(t,parent){
const d=document.createElement('div'); d.className='sec';
if(t){ const h=document.createElement('h2'); h.textContent=t; d.appendChild(h); }
(parent||ui).appendChild(d); return d;
}
function slider(par,label,k,min,max,st,fmt=2,cb){
const row=document.createElement('div');row.className='row';
const l=document.createElement('label');l.textContent=label;
const v=document.createElement('span');v.className='val';v.textContent=(+P[k]).toFixed(fmt);
row.append(l,v);par.appendChild(row);
const i=document.createElement('input');i.type='range';i.min=min;i.max=max;i.step=st;i.value=P[k];
i.addEventListener('input',()=>{P[k]=parseFloat(i.value);v.textContent=P[k].toFixed(fmt);cb&&cb();});
par.appendChild(i);
syncers.push(()=>{ i.value=P[k]; v.textContent=(+P[k]).toFixed(fmt); });
}
function graySlider(par,label,k){
const row=document.createElement('div');row.className='row';
const l=document.createElement('label');l.textContent=label;
const chip=document.createElement('span');chip.className='chip';
const v=document.createElement('span');v.className='val';
const right=document.createElement('span');
right.style.cssText='display:flex;align-items:center;gap:6px';
right.append(chip,v);
row.append(l,right);par.appendChild(row);
const i=document.createElement('input');
i.type='range';i.min=0;i.max=1;i.step=0.01;i.className='gray';i.value=1-P[k];
const paint=()=>{
const g=Math.round(P[k]*255);
chip.style.background=`rgb(${g},${g},${g})`;
v.textContent=P[k].toFixed(2);
};
i.addEventListener('input',()=>{ P[k]=1-parseFloat(i.value); paint(); });
par.appendChild(i); paint();
syncers.push(()=>{ i.value=1-P[k]; paint(); });
}
function check(par,label,k,cb){
const w=document.createElement('label');w.className='check';
const i=document.createElement('input');i.type='checkbox';i.checked=P[k];
const s=document.createElement('span');s.textContent=label;
i.addEventListener('change',()=>{P[k]=i.checked;cb&&cb();});
w.append(i,s);par.appendChild(w);
syncers.push(()=>{ i.checked=P[k]; });
return i;
}
function choice(par,k,opts,cb){
const s=document.createElement('select');
for(const [v,t] of opts){const o=document.createElement('option');o.value=v;o.textContent=t;s.appendChild(o);}
s.value=P[k];s.addEventListener('change',()=>{P[k]=s.value;cb&&cb();});par.appendChild(s);
return s;
}
function button(par,label,fn,ghost){
const b=document.createElement('button');b.textContent=label;if(ghost)b.className='ghost';
b.addEventListener('click',fn);par.appendChild(b);return b;
}
function segmented(par,k,opts,cb){
const w=document.createElement('div'); w.className='swatch';
const bs=[];
for(const [v,t] of opts){
const b=document.createElement('button'); b.textContent=t;
b.addEventListener('click',()=>{P[k]=v;bs.forEach(x=>x[0].classList.toggle('on',x[1]===P[k]));cb&&cb();});
w.appendChild(b); bs.push([b,v]);
}
const upd=()=>bs.forEach(x=>x[0].classList.toggle('on',x[1]===P[k]));
upd(); syncers.push(upd);
par.appendChild(w);
}
/** Kurveneditor: Dickenprofil vom Ansatz (links) zur Spitze (rechts).
* Ziehen verschiebt, Klick auf freie Fläche setzt einen Punkt,
* Rechtsklick entfernt ihn. Die Endpunkte bleiben auf x = 0 und x = 1. */
function curveEditor(par,k){
const W=260,H=84,PAD=7;
const cv=document.createElement('canvas');
cv.width=W*2; cv.height=H*2;
cv.style.cssText='width:100%;height:'+H+'px;border:1px solid var(--line);margin:0 0 8px;cursor:crosshair;display:block';
par.appendChild(cv);
const g=cv.getContext('2d'); g.scale(2,2);
const X=t=>PAD+t*(W-2*PAD), Y=v=>H-PAD-v*(H-2*PAD);
const iX=px=>clamp((px-PAD)/(W-2*PAD),0,1), iY=py=>clamp((H-PAD-py)/(H-2*PAD),0,1);
let drag=-1;
function draw(){
const c=P[k];
g.clearRect(0,0,W,H);
g.fillStyle='#fff'; g.fillRect(0,0,W,H);
g.strokeStyle='#f0f0f0'; g.lineWidth=1;
for(let i=1;i<4;i++){
g.beginPath(); g.moveTo(X(i/4),PAD); g.lineTo(X(i/4),H-PAD); g.stroke();
}
g.strokeStyle='#e2e2e2';
g.beginPath(); g.moveTo(PAD,H-PAD); g.lineTo(W-PAD,H-PAD); g.stroke();
// Profil als Silhouette, an der Mittellinie gespiegelt
const mid=H-PAD;
g.fillStyle='#ececec'; g.beginPath();
g.moveTo(X(c[0][0]),mid);
for(const p of c) g.lineTo(X(p[0]),Y(p[1]*0.92));
g.lineTo(X(c[c.length-1][0]),mid);
g.closePath(); g.fill();
g.strokeStyle='#111'; g.lineWidth=1.2; g.beginPath();
c.forEach((p,i)=>i?g.lineTo(X(p[0]),Y(p[1]*0.92)):g.moveTo(X(p[0]),Y(p[1]*0.92)));
g.stroke();
g.fillStyle='#111';
for(const p of c){
g.beginPath(); g.arc(X(p[0]),Y(p[1]*0.92),3,0,Math.PI*2); g.fill();
}
}
const local=ev=>{
const r=cv.getBoundingClientRect();
return [(ev.clientX-r.left)/r.width*W,(ev.clientY-r.top)/r.height*H];
};
const nearest=(px,py)=>{
const c=P[k]; let best=-1,bd=100;
c.forEach((p,i)=>{
const d=(X(p[0])-px)**2+(Y(p[1]*0.92)-py)**2;
if(d<bd){bd=d;best=i;}
});
return best;
};
cv.addEventListener('contextmenu',ev=>{
ev.preventDefault();
const [px,py]=local(ev), i=nearest(px,py);
if(i>0&&i<P[k].length-1){ P[k].splice(i,1); draw(); }
});
cv.addEventListener('pointerdown',ev=>{
if(ev.button!==0) return;
cv.setPointerCapture(ev.pointerId);
const [px,py]=local(ev);
let i=nearest(px,py);
if(i<0||(X(P[k][i][0])-px)**2+(Y(P[k][i][1]*0.92)-py)**2>90){
const p=[iX(px),iY(py)/0.92];
P[k].push(p); P[k].sort((a,b)=>a[0]-b[0]);
i=P[k].indexOf(p);
}
drag=i; draw();
});
cv.addEventListener('pointermove',ev=>{
if(drag<0) return;
const c=P[k], [px,py]=local(ev);
c[drag][1]=clamp(iY(py)/0.92,0,1);
if(drag>0&&drag<c.length-1){
c[drag][0]=clamp(iX(px),c[drag-1][0]+0.01,c[drag+1][0]-0.01);
}
draw();
});
const up=()=>{ drag=-1; };
cv.addEventListener('pointerup',up); cv.addEventListener('pointercancel',up);
draw();
syncers.push(draw);
return draw;
}
const syncUI=()=>syncers.forEach(f=>f());
function setLayer(i){
P.layer=i;
if(bodyMat) bodyMat.uniforms.uLayer.value=i;
document.getElementById('bLayer1').classList.toggle('on',i===0);
document.getElementById('bLayer2').classList.toggle('on',i===1);
syncUI(); setStatus();
}
function setPoseMode(on){
P.poseMode=on;
document.getElementById('bPose').classList.toggle('on',on);
if(on&&!srcMesh?.isSkinnedMesh){ setStatus('Modell hat kein Skelett — Pose nicht möglich.'); }
else setStatus(on?'Bone ziehen · Umschalt = Twist':'');
syncUI();
}
// ── KÖRPER
{
const g=group('Körper',false);
const s=section('Modell',g);
const inp=document.createElement('input');
inp.type='file'; inp.accept='.glb,.gltf,.fbx'; inp.style.display='none';
inp.addEventListener('change',async()=>{const f=inp.files[0]; if(f) loadFromBuffer(await f.arrayBuffer(),f.name);});
s.appendChild(inp);
button(s,'Datei wählen',()=>inp.click(),true);
const path=document.createElement('input');
path.type='text'; path.placeholder='./body_minimal.glb'; s.appendChild(path);
button(s,'Pfad laden',async()=>{
const v=path.value.trim(); if(!v) return;
if(!await loadFromURL(v)) setStatus('Nicht gefunden: '+v);
},true);
button(s,'Dummy verwenden',()=>{ const d=buildDummy(); setBody(d.root,d.mesh,'Dummy'); },true);
const p=section('Pose',g);
check(p,'Pose-Modus','poseMode',()=>setPoseMode(P.poseMode));
check(p,'Pose spiegeln','poseMirror',()=>{ rebuildBoneHandles(); });
button(p,'Pose zurücksetzen',resetPose,true);
const m=section('Auflösung der Karte',g);
const sel=choice(m,'mapRes',[['512','512 × 512'],['1024','1024 × 1024'],['2048','2048 × 2048 (schwer)']]);
sel.value=String(P.mapRes);
sel.addEventListener('change',()=>{P.mapRes=parseInt(sel.value);});
button(m,'Karte neu aufbauen',async()=>{
if(!body) return;
await buildPaintMap(P.mapRes); buildTriangleList(); resetHistory();
},true);
}
// ── PINSEL
{
const g=group('Pinsel',true);
const s=section('Ebene',g);
segmented(s,'layer',[[0,'1 · Dichte'],[1,'2 · Höhe']],()=>setLayer(P.layer));
const b=section('Werkzeug',g);
segmented(b,'brushMode',[['paint','Malen'],['erase','Radieren'],['smooth','Glätten']]);
graySlider(b,'Pinselwert','target');
slider(b,'Radius','brushRadius',0.004,0.25,0.001,3);
slider(b,'Härte','hardness',0,1,0.01,2);
slider(b,'Fluss','flow',0.02,1,0.01,2);
slider(b,'Normalengrenze','normalLimit',-1,0.9,0.01,2);
check(b,'Spiegeln an X = 0','mirror');
const r=document.createElement('div');r.className='btn2';b.appendChild(r);
button(r,'Ebene leeren',clearLayer,true);
button(r,'bodypaint.png',exportBodypaint,true);
const mi=document.createElement('input');
mi.type='file'; mi.accept='image/png'; mi.style.display='none';
mi.addEventListener('change',()=>{ const f=mi.files[0]; if(f) importBodypaint(f); mi.value=''; });
b.appendChild(mi);
button(b,'bodypaint laden',()=>mi.click(),true);
}
// ── SAAT
{
const g=group('Saat · Ebene 1',true);
const s=section('Kreispackung',g);
slider(s,'Minimum (weiß)','rMin',0.001,0.04,0.0005,4);
slider(s,'Maximum (schwarz)','rMax',0.004,0.10,0.0005,4);
slider(s,'Kontrast (Gamma)','gamma',0.2,4,0.05,2);
slider(s,'Streuung','jitter',0,0.6,0.01,2);
slider(s,'Packdichte','packing',0.1,1.2,0.01,2);
slider(s,'Kandidaten','samples',2000,200000,1000,0);
slider(s,'Dichte folgt Dunkelheit','densityBias',0,1,0.01,2);
slider(s,'Schwelle Deckung','alphaMin',0.01,0.9,0.01,2);
slider(s,'Ausgleich','relax',0,20,1,0);
slider(s,'Startwert (Seed)','seed',1,999,1,0);
button(s,'Neu würfeln',()=>{P.seed=1+((Math.random()*998)|0);syncUI();generate();},true);
}
// ── WUCHS
{
const g=group('Wuchs · Ebene 2',true);
const s=section('Bahn',g);
slider(s,'Schritte','steps',6,240,1,0);
slider(s,'Schrittlänge','stepLen',0.002,0.03,0.0005,4);
slider(s,'Lenkung','turn',0.05,1.2,0.01,2);
const h=section('Wuchsraum',g);
slider(h,'Bodenabstand','hug',0,0.02,0.0005,4);
slider(h,'Deckenhöhe max. (Ebene 2)','lift',0,0.3,0.005,3);
slider(h,'Rückholkraft','stick',0,1,0.01,2);
slider(h,'Bodenzug','settle',0,2,0.01,2);
check(h,'Außerhalb der Bemalung stoppen','killOffPaint');
const z=section('Stützen',g);
check(z,'Zylinderraster','pillarsOn');
segmented(z,'pMode',[['grid','Raster'],['pack','Packung']]);
slider(z,'Spaltenabstand','pCols',0.006,0.2,0.001,3);
slider(z,'Zeilenabstand','pRows',0.006,0.2,0.001,3);
slider(z,'Rasterweite (Packung)','pSpace',0.3,3,0.02,2);
slider(z,'Zylinderdicke','pFill',0.1,0.9,0.01,2);
slider(z,'Zylinderhöhe','pHeight',0,2,0.02,2);
slider(z,'Abstoßung','pPush',0,8,0.1,1);
slider(z,'Wirbel um die Achse','pSwirl',-2,2,0.01,2);
slider(z,'Wirbelreichweite','pSwirlRange',0.002,0.08,0.001,3);
const pl=section('Platzsuche',g);
slider(pl,'Fühlerweite','senseDist',0.004,0.06,0.001,3);
slider(pl,'Fühlerwinkel','senseAngle',0.1,1.4,0.01,2);
slider(pl,'Zug zum freien Raum','senseForce',0,3,0.01,2);
slider(pl,'Rasterweite Belegung','occCell',0.002,0.02,0.0005,4);
slider(pl,'Verdichtungsgrenze','occMax',1,40,1,0);
check(pl,'Anastomose (Spitzen verwachsen)','fuse');
const f=section('Feld',g);
slider(f,'Perlin-Curl','curlAmp',0,2,0.01,2);
slider(f,'Feldskalierung','noiseScale',0.3,16,0.1,1);
slider(f,'Schwerkraft','gravity',0,1,0.01,2);
slider(f,'Oszillation','oscAmp',0,2,0.01,2);
slider(f,'Schwingweite','oscFreq',0.05,1.6,0.01,2);
slider(f,'Unruhe','oscJitter',0,1,0.01,2);
const b=section('Schwarm',g);
slider(b,'Nachbarradius','nbRadius',0.006,0.08,0.001,3);
slider(b,'Sicherheitsabstand','sepDist',0.002,0.04,0.0005,4);
slider(b,'Trennung','sepForce',0,3,0.01,2);
slider(b,'Gleichrichtung','alignForce',0,1.5,0.01,2);
slider(b,'Kohäsion','cohForce',0,1.5,0.01,2);
slider(b,'Verflechtung','twistForce',0,1.5,0.01,2);
const v=section('Verzweigung',g);
slider(v,'Verzweigungsrate','branchProb',0,0.3,0.005,3);
slider(v,'Winkel','branchAngle',0.1,1.6,0.01,2);
slider(v,'Stärkeabnahme','branchDecay',0.3,0.95,0.01,2);
slider(v,'Maximale Tiefe','maxDepth',0,6,1,0);
slider(v,'Maximale Spitzen','maxAgents',500,30000,500,0);
const r=document.createElement('div');r.className='btn2';v.appendChild(r);
button(r,'Wurzeln wachsen',generate);
button(r,'Mesh bauen',buildMesh);
button(v,'Löschen',clearRoots,true);
}
// ── QUERSCHNITT
{
const g=group('Röhren & Mesh',true);
const s=section(null,g);
slider(s,'Grundstärke','rootScale',0.02,0.5,0.005,3);
slider(s,'Mindeststärke','rootMin',0.0002,0.004,0.0001,4);
const cs=section('Dickenprofil · Ansatz → Spitze',g);
curveEditor(cs,'thick');
const s2=section(null,g);
slider(s2,'Kanten je Röhre','tubeSides',3,10,1,0);
slider(s2,'Vereinfachen','simplify',0,0.6,0.01,2);
check(s2,'Kugel am Bahnende','tipSpheres');
slider(s2,'Kugelgröße','tipScale',0.6,4,0.05,2);
slider(s2,'Detail Kugel','tipDetail',0,2,1,0);
const cs2=section('Farben · Wurzel · Halm',g);
const cw=document.createElement('div'); cw.className='btn2'; cs2.appendChild(cw);
for(const k of ['rootColor','stemColor']){
const c=document.createElement('input'); c.type='color'; c.value=P[k];
c.style.margin='0';
c.addEventListener('input',()=>{ P[k]=c.value; applyRootColors(); });
cw.appendChild(c);
}
slider(cs2,'Aufhellung im Verlauf','shade',0,0.9,0.01,2,()=>applyRootColors());
button(cs2,'Mesh bauen',buildMesh);
const pf=section('Halme',g);
check(pf,'Halme','shootsOn');
slider(pf,'Anteil der Saat','shootProb',0,1,0.01,2);
slider(pf,'Länge','shootLen',0.01,0.2,0.0025,4);
slider(pf,'Schritte','shootSteps',2,40,1,0);
slider(pf,'Stärke','shootThick',0.3,4,0.05,2);
slider(pf,'Breite','bladeWidth',1,8,0.1,2);
slider(pf,'Dicke','bladeThick',0.05,1,0.01,2);
slider(pf,'Höhe über der Matte','bladeRise',0,0.03,0.0005,4);
slider(pf,'Windung','shootWobble',0,2,0.01,2);
slider(pf,'Kanten je Halm','bladeSides',3,10,1,0);
}
// ── ANSICHT
{
const g=group('Ansicht',false);
const s=section(null,g);
check(s,'Körper','showBody');
slider(s,'Deckkraft unbemalt','ghost',0,1,0.01,2);
check(s,'Malfläche zeigen','showPaint');
slider(s,'Andere Ebene (grün)','otherLayer',0,1,0.01,2);
check(s,'Wurzel-Mesh','showRoots');
check(s,'Linienvorschau','showPreview');
check(s,'Stützen zeigen','showPillars');
}
// ── EXPORT
{
const g=group('Export',false);
const s=section(null,g);
choice(s,'exportFormat',[['glb','GLB (binär)'],['gltf','GLTF (JSON)'],['obj','OBJ']]);
check(s,'Körper mitexportieren','exportBody');
button(s,'Exportieren',doExport);
}
// ── Toolbar / Status
const bUndo=document.getElementById('bUndo'), bRedo=document.getElementById('bRedo');
document.getElementById('bGrow').onclick=generate;
document.getElementById('bMesh').onclick=buildMesh;
document.getElementById('bDel').onclick=clearRoots;
document.getElementById('bClearMap').onclick=clearLayer;
document.getElementById('bLayer1').onclick=()=>setLayer(0);
document.getElementById('bLayer2').onclick=()=>setLayer(1);
document.getElementById('bPose').onclick=()=>setPoseMode(!P.poseMode);
bUndo.onclick=undo; bRedo.onclick=redo;
function updateBar(){
bUndo.disabled=histIdx<=0;
bRedo.disabled=histIdx>=history.length-1;
}
let ratio=[0,0];
function setStatus(msg){
if(paintData) ratio=[paintedRatio(0),paintedRatio(1)];
const parts=[`MODELL <b>${modelName}</b>`];
if(R) parts.push(`KARTE <b>${R}×${R}</b>`,
`EBENE 1 <b>${(ratio[0]*100).toFixed(1)}%</b>`,
`EBENE 2 <b>${(ratio[1]*100).toFixed(1)}%</b>`);
if(seeds.length) parts.push(`SAAT <b>${seeds.length.toLocaleString('de-DE')}</b>`);
if(pillars.length) parts.push(`STÜTZEN <b>${pillars.length.toLocaleString('de-DE')}</b>`);
if(roots.length){
parts.push(`WURZELN <b>${roots.length.toLocaleString('de-DE')}</b>`,
`PUNKTE <b>${rootPts.toLocaleString('de-DE')}</b>`);
const t=meshTris();
if(shoots.length) parts.push(`SPROSSE <b>${shoots.length.toLocaleString('de-DE')}</b>`);
if(t) parts.push(`DREIECKE <b>${Math.round(t).toLocaleString('de-DE')}</b>`);
}
if(msg) parts.push(`<b>${msg}</b>`);
document.getElementById('status').innerHTML=parts.join(' · ');
}
// ═════════════════════════════════════════════════════════════════════════════
// 13 · LOOP
// ═════════════════════════════════════════════════════════════════════════════
let frameN=0;
function tick(){
requestAnimationFrame(tick);
if(body){
body.visible=P.showBody;
bodyMat.uniforms.uGhost.value=P.ghost;
bodyMat.uniforms.uShow.value=P.showPaint?1:0;
bodyMat.uniforms.uOther.value=P.otherLayer;
}
if(paintDirty&&((frameN++&1)===0)){ paintTex.needsUpdate=true; paintDirty=false; }
const pose=P.poseMode&&!!srcMesh?.isSkinnedMesh;
const showCur=!!hoverHit&&!!paintData&&!orbiting&&!P.poseMode;
cursor.visible=showCur; cursorM.visible=showCur&&P.mirror;
bonePts.visible=skelLines.visible=pose;
selPt.visible=pose&&!!selectedBone;
rootGroup.visible=P.showRoots;
preGroup.visible=P.showPreview||P.showPillars;
if(preLines) preLines.visible=P.showPreview;
if(pillarLines) pillarLines.visible=P.showPillars;
renderer.render(scene,camera);
}
const d=buildDummy();
await setBody(d.root,d.mesh,'Dummy');
setLayer(0); setPoseMode(false);
resize(); setStatus(); tick();
autoLoad();
</script>
</body>
</html>