index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CELL_MESH_BODY</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</button>
<button id="bLayer2">Ebene 2</button>
<span class="gap"></span>
<button id="bPose">Pose</button>
<span class="gap"></span>
<button id="bGrow">Struktur erzeugen</button>
<button id="bMesh">Mesh bauen</button>
<button id="bDelSph">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>CELL_MESH_BODY</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,
// Struktur
mode:'voronoi', rMin:0.006, rMax:0.036, gamma:1.6, jitter:0.18,
packing:0.55, samples:45000, densityBias:0.35, alphaMin:0.10,
relax:8, gap:0.0, regular:0.15, ringPoints:8, seed:1,
// Wuchs (Ebene 2 → Höhe)
growHeight:0.12, growSteps:6, wobble:0.55, noiseScale:3.2, straighten:0.55,
align:0.35, cohesion:0.0, sepKeep:0.85, relaxIter:3, levels:1,
// Wände & Adern
showWalls:true, wallThick:0.0, capTop:false,
veinScale:0.16, taper:0.35, tubeSides:4, showVeins:true, showJoints:false, jointScale:1.4,
sphereDetail:1, sphereColor:'#111111', tipColor:'#b4b4b4', showPreview:true,
// Ansicht
poseMirror:true,
showBody:true, ghost:0.16, showPaint:true, showTissue: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;
function bodyShader(){
return new THREE.ShaderMaterial({
transparent:true, depthWrite:true, side:THREE.FrontSide,
uniforms:{
map:{value:null}, uGhost:{value:P.ghost}, uShow:{value:1},
uLayer:{value:P.layer}, uBase:{value:new THREE.Color(0xdadada)}, uL:{value:LIGHT_DIR.clone()}
},
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,uLayer;
uniform vec3 uBase,uL;
varying vec2 vUv; varying vec3 vN;
void main(){
vec4 t=texture2D(map,vUv);
vec2 A=(uLayer<0.5)?t.rg:t.ba; // Wert und Deckung der aktiven Ebene
float a=A.y*uShow;
vec3 col=mix(uBase,vec3(A.x),a);
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;
gl_FragColor=vec4(col*sh, mix(1.0, mix(uGhost,1.0,a), 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);
}
/** Quelle (ggf. skinned + posiert) → Weltkoordinaten in die Anzeigegeometrie schreiben */
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;
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;
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;
}
if(texelIds&&texelIds.length) texelSpacing=Math.sqrt(triArea/texelIds.length);
}
/** volle Neuberechnung nach Posenänderung */
function refreshAfterPose(){
buildTriangleList();
tGrid=null;
updateTexelPositions();
buildTexelHash();
if(seeds.length) generate();
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; clearTissue();
setStatus('Keine UV-Koordinaten.');
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();
clearTissue();
await buildPaintMap(P.mapRes);
buildTriangleList();
resetHistory();
await autoLoadMap();
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.'); 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;
/** Achsensymmetrie nur nutzen, solange der Körper wirklich symmetrisch steht */
const poseAsym=()=>!!(srcMesh?.isSkinnedMesh&&!P.poseMirror&&poseQ.size>0);
const mirrorOn=()=>P.mirror&&!poseAsym();
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();
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?' · UV überlappt':''));
await frameP();
}
/** Texel-Weltpositionen aus der aktuellen (ggf. posierten) Geometrie */
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(), all=P.layer===0;
for(let i=0;i<R*R;i++){
paintData[i*4+vo]=0; paintData[i*4+ao]=0;
if(all){ paintData[i*4+2]=0; paintData[i*4+3]=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';
const lay2=P.layer===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;
const k=1-Math.sqrt(d2)/r;
let f=clamp(k/soft,0,1); f=f*f*(3-2*f);
let 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; }
if(!lay2){ // Ebene 1 weg = Ebene 2 dort sinnlos
paintData[o+3]=Math.max(0,paintData[o+3]-w*255);
if(paintData[o+3]<2){ paintData[o+2]=0; paintData[o+3]=0; }
}
}else{
if(lay2){
const base=paintData[o+1]/255; // nur wo Ebene 1 gedeckt ist
if(base<=0.004) return;
w*=base;
}
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);
if(lay2&&paintData[o+ao]>paintData[o+1]) paintData[o+ao]=paintData[o+1];
}
});
paintDirty=true;
}
/** Glätten: separabler Boxfilter, Kernel skaliert mit dem Pinselradius */
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)=>{ // Präfixsummen je Zeile/Spalte
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;
if(P.layer===1&&paintData[o+3]>paintData[o+1]) paintData[o+3]=paintData[o+1];
}
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(mirrorOn()){
_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);
}
}
/** bilinear → [v1,a1,v2,a2] in 0..1 */
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;
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};
setStatus(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;
}
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 · STRUKTUR & GEWEBE
// Ebene 1 → Zellgröße (schwarz große Zellen, weiß feine)
// Ebene 2 → Wuchshöhe über der Haut (schwarz weit, weiß nah, unbemalt = flach)
// ═════════════════════════════════════════════════════════════════════════════
// ── Perlin / Curl
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=[], agents=[], edges=[], loops=[], jointList=[];
let tissue=null, joints=null, preFaces=null, preLines=null;
const tisGroup=new THREE.Group(); scene.add(tisGroup);
const preGroup=new THREE.Group(); scene.add(preGroup);
let busy=false;
// ── Fortschrittsanzeige
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 clearTissue(){
tisGroup.clear(); preGroup.clear();
if(tissue){ tissue.geometry.dispose(); tissue.material.dispose(); tissue=null; }
if(joints){ joints.geometry.dispose(); joints.material.dispose(); joints=null; }
if(preFaces){ preFaces.geometry.dispose(); preFaces.material.dispose(); preFaces=null; }
if(preLines){ preLines.geometry.dispose(); preLines.material.dispose(); preLines=null; }
seeds=[]; agents=[]; edges=[]; loops=[]; jointList=[]; setStatus();
}
function clearMeshOnly(){
if(tissue){ tisGroup.remove(tissue); tissue.geometry.dispose(); tissue.material.dispose(); tissue=null; }
tissueT=null;
if(joints){ tisGroup.remove(joints); joints.geometry.dispose(); joints.material.dispose(); joints=null; }
jointList=[];
}
// ═══ Oberflächen-Projektion: Dreiecksraster + nächster Punkt ═══
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){ // nächster Punkt auf Dreieck 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;
}
// Kreisradius und Wuchshöhe aus den beiden Karten
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;
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.growHeight;
return out;
}
const _mm={v:0,a:0,r:0,h:0};
// ═══ 1 · Samen setzen (Dichte aus Ebene 1) ═══
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;
if(mirrorOn()&&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(mirrorOn()){
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;
}
}
// ═══ 2 · Relaxation: Abstoßung + Rückprojektion (Lloyd-Ersatz) ═══
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; // Spiegelbilder folgen ihren Quellen
if(mirrorOn()&&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))); // Streuung des Samens behalten
A.h=_mm.h; A.s=spacingOf(A);
if(mirrorOn()&&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('Ausgleich '+(it+1)+' / '+iters,0.1+0.5*(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));
}
// ── Spiegelpaare finden und Positionen exakt symmetrisch ziehen
function mirrorPairs(list,tol){
const n=list.length, pair=new Int32Array(n).fill(-2);
const cell=Math.max(tol*2,0.003);
const key=(x,y,z)=>(x+2048)+(y+2048)*4096+(z+2048)*16777216;
const grid=new Map();
for(let i=0;i<n;i++){
const A=list[i];
const k=key(Math.floor(A.x/cell),Math.floor(A.y/cell),Math.floor(A.z/cell));
let arr=grid.get(k); if(!arr){arr=[];grid.set(k,arr);} arr.push(i);
}
for(let i=0;i<n;i++){
if(pair[i]!==-2) continue;
const A=list[i];
if(Math.abs(A.x)<tol*0.6){ pair[i]=i; continue; }
const mx=-A.x, my=A.y, mz=A.z;
const gx=Math.floor(mx/cell),gy=Math.floor(my/cell),gz=Math.floor(mz/cell);
let best=-1,bd=tol*tol;
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||pair[j]!==-2) continue;
const B=list[j];
const d=(B.x-mx)**2+(B.y-my)**2+(B.z-mz)**2;
if(d<bd){ bd=d; best=j; }
}
}
if(best>=0){ pair[i]=best; pair[best]=i; } else pair[i]=-1;
}
return pair;
}
function symmetrize(list,tol){
if(!mirrorOn()) return null;
const pair=mirrorPairs(list,tol);
for(let i=0;i<list.length;i++){
const j=pair[i];
if(j<0) continue;
const A=list[i];
if(j===i){
A.x=0;
if(A.nx!==undefined){ A.nx=0; const l=Math.hypot(A.ny,A.nz)||1; A.ny/=l; A.nz/=l; }
continue;
}
if(j<i) continue;
const B=list[j];
const x=(A.x-B.x)*0.5, y=(A.y+B.y)*0.5, z=(A.z+B.z)*0.5;
A.x=x; A.y=y; A.z=z; B.x=-x; B.y=y; B.z=z;
if(A.nx!==undefined&&B.nx!==undefined){
let nx=(A.nx-B.nx)*0.5, ny=(A.ny+B.ny)*0.5, nz=(A.nz+B.nz)*0.5;
const l=Math.hypot(nx,ny,nz)||1; nx/=l; ny/=l; nz/=l;
A.nx=nx; A.ny=ny; A.nz=nz; B.nx=-nx; B.ny=ny; B.nz=nz;
}
}
return pair;
}
// ═══ 3 · Zellen: Voronoi im Tangentialraum ═══
function clipHalf(poly,nx,ny,c){
const out=[]; const n=poly.length;
for(let i=0;i<n;i++){
const A=poly[i], B=poly[(i+1)%n];
const da=A.x*nx+A.y*ny-c, db=B.x*nx+B.y*ny-c;
if(da<=0) out.push(A);
if((da<0&&db>0)||(da>0&&db<0)){
const t=da/(da-db);
out.push({x:A.x+(B.x-A.x)*t, y:A.y+(B.y-A.y)*t});
}
}
return out;
}
function buildCells(){
const n=seeds.length;
const cell=Math.max(0.01,2*P.rMax*(1+P.jitter)*P.packing*2.2);
const key=(x,y,z)=>(x+512)+(y+512)*1024+(z+512)*1048576;
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);
}
const cells=[], near=[];
const up=new V3(0,1,0), tu=new V3(), tv=new V3(), nn=new V3(), tmp=new V3();
for(let i=0;i<n;i++){
const A=seeds[i];
nn.set(A.nx,A.ny,A.nz);
tu.copy(up).addScaledVector(nn,-up.dot(nn));
if(tu.lengthSq()<1e-8) tu.set(1,0,0).addScaledVector(nn,-nn.x);
tu.normalize(); tv.crossVectors(nn,tu).normalize();
near.length=0;
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) near.push(j);
}
const R=A.s*1.4;
let poly=[{x:-R,y:-R},{x:R,y:-R},{x:R,y:R},{x:-R,y:R}];
const used=[];
for(const j of near){
const B=seeds[j];
if(A.nx*B.nx+A.ny*B.ny+A.nz*B.nz<0.0) continue; // andere Körperseite
tmp.set(B.x-A.x,B.y-A.y,B.z-A.z);
const dx=tmp.dot(tu), dy=tmp.dot(tv);
const d2=dx*dx+dy*dy;
if(d2<1e-12||d2>(A.s+B.s)*(A.s+B.s)) continue;
poly=clipHalf(poly,dx,dy,d2*0.5);
if(poly.length<3) break;
used.push({j,dx,dy,c:d2*0.5,len:Math.sqrt(d2)});
}
if(poly.length<3){ cells.push(null); continue; }
// Nachbarn bestimmen: welche Halbebene liegt auf welcher Kante?
const nb=new Array(poly.length).fill(-1);
for(let k=0;k<poly.length;k++){
const A2=poly[k], B2=poly[(k+1)%poly.length];
const mx=(A2.x+B2.x)*0.5, my=(A2.y+B2.y)*0.5;
let bj=-1,bd=1e9;
for(const u of used){
const d=Math.abs(mx*u.dx+my*u.dy-u.c)/u.len;
if(d<bd){bd=d;bj=u.j;}
}
nb[k]=(bd<A.s*0.02)?bj:-1;
}
cells.push({i,poly,nb,tu:tu.clone(),tv:tv.clone(),nn:nn.clone()});
}
return cells;
}
// ═══ 4 · Agenten und Kanten je nach Modus ═══
function regularize(pts,loops,iters,strength){
if(strength<=0||iters<=0) return;
const n=pts.length;
const tx=new Float64Array(n),ty=new Float64Array(n),tz=new Float64Array(n),tw=new Float64Array(n);
for(let it=0;it<iters;it++){
tx.fill(0);ty.fill(0);tz.fill(0);tw.fill(0);
for(const lp of loops){
const m=lp.length; if(m<3) continue;
let cx=0,cy=0,cz=0;
for(const i of lp){ cx+=pts[i].x; cy+=pts[i].y; cz+=pts[i].z; }
cx/=m; cy/=m; cz/=m;
let rr=0;
for(const i of lp) rr+=Math.hypot(pts[i].x-cx,pts[i].y-cy,pts[i].z-cz);
rr/=m;
for(const i of lp){
let ex=pts[i].x-cx, ey=pts[i].y-cy, ez=pts[i].z-cz;
const d=Math.hypot(ex,ey,ez)||1;
tx[i]+=cx+ex/d*rr; ty[i]+=cy+ey/d*rr; tz[i]+=cz+ez/d*rr; tw[i]++;
}
}
for(let i=0;i<n;i++){
if(!tw[i]) continue;
const p=pts[i];
let nx=p.x+(tx[i]/tw[i]-p.x)*strength;
let ny=p.y+(ty[i]/tw[i]-p.y)*strength;
let nz=p.z+(tz[i]/tw[i]-p.z)*strength;
const r=onSurface(nx,ny,nz);
if(r.ok){ p.x=r.x; p.y=r.y; p.z=r.z; } else { p.x=nx; p.y=ny; p.z=nz; }
if(mirrorOn()&&Math.abs(p.x)<1e-4) p.x=0;
}
}
}
function buildTopology(){
agents=[]; edges=[]; loops=[];
const finish=()=>{
if(!agents.length) return;
let mean=0; for(const a of agents) mean+=a.s||0.02; mean/=agents.length;
symmetrize(agents,mean*0.45);
};
const asAgent=A=>({x:A.x,y:A.y,z:A.z,nx:A.nx,ny:A.ny,nz:A.nz,s:A.s||spacingOf(A),h:A.h,r:A.r});
if(P.mode==='circle'){ // Kreispackung als eigenständige Ringe
const K=Math.max(3,P.ringPoints|0);
const up=new V3(0,1,0), tu=new V3(), tv=new V3(), nn=new V3();
for(const A of seeds){
nn.set(A.nx,A.ny,A.nz);
tu.copy(up).addScaledVector(nn,-up.dot(nn));
if(tu.lengthSq()<1e-8) tu.set(1,0,0).addScaledVector(nn,-nn.x);
tu.normalize(); tv.crossVectors(nn,tu).normalize();
const rad=A.r*P.packing*(1-P.gap);
const loop=[];
for(let k=0;k<K;k++){
const a2=k/K*Math.PI*2;
const wx=A.x+tu.x*Math.cos(a2)*rad+tv.x*Math.sin(a2)*rad;
const wy=A.y+tu.y*Math.cos(a2)*rad+tv.y*Math.sin(a2)*rad;
const wz=A.z+tu.z*Math.cos(a2)*rad+tv.z*Math.sin(a2)*rad;
const r=onSurface(wx,wy,wz);
const px=r.ok?r.x:wx, py=r.ok?r.y:wy, pz=r.ok?r.z:wz;
let h=A.h, sS=A.s||spacingOf(A);
if(r.ok){ readMaps(r.u,r.v,_mm); h=_mm.h; }
loop.push(agents.length);
agents.push({x:px,y:py,z:pz,
nx:r.ok?r.nx:A.nx,ny:r.ok?r.ny:A.ny,nz:r.ok?r.nz:A.nz,
s:sS,h,r:A.r});
}
for(let k=0;k<loop.length;k++) edges.push(loop[k],loop[(k+1)%loop.length]);
loops.push(loop);
}
finish();
return;
}
const cells=buildCells();
if(P.mode==='delaunay'){
for(const A of seeds) agents.push(asAgent(A));
const seen=new Set();
for(const c of cells){
if(!c) continue;
for(const j of c.nb){
if(j<0) continue;
const a=Math.min(c.i,j), b=Math.max(c.i,j), k=a*1000003+b;
if(seen.has(k)) continue;
seen.add(k); edges.push(a,b);
}
}
finish();
return;
}
// Voronoi: Zellecken benachbarter Zellen verschweißen
let meanS=0; for(const A of seeds) meanS+=(A.s||spacingOf(A)); meanS/=Math.max(1,seeds.length);
const weld=P.gap<=0.001;
const tol=meanS*0.32, wc=Math.max(tol*2,0.003);
const wkey=(x,y,z)=>(x+2048)+(y+2048)*4096+(z+2048)*16777216;
const wgrid=new Map();
const pts=[];
function addPoint(x,y,z){
if(weld){
const gx=Math.floor(x/wc),gy=Math.floor(y/wc),gz=Math.floor(z/wc);
let best=-1,bd=tol*tol;
for(let a2=gx-1;a2<=gx+1;a2++)for(let b2=gy-1;b2<=gy+1;b2++)for(let c2=gz-1;c2<=gz+1;c2++){
const arr=wgrid.get(wkey(a2,b2,c2)); if(!arr) continue;
for(const i of arr){
const p=pts[i];
const d=(p.x-x)**2+(p.y-y)**2+(p.z-z)**2;
if(d<bd){ bd=d; best=i; }
}
}
if(best>=0){
const p=pts[best];
p.x=(p.x*p.n+x)/(p.n+1); p.y=(p.y*p.n+y)/(p.n+1); p.z=(p.z*p.n+z)/(p.n+1); p.n++;
return best;
}
}
const id=pts.length;
pts.push({x,y,z,n:1});
if(weld){
const k=wkey(Math.floor(x/wc),Math.floor(y/wc),Math.floor(z/wc));
let arr=wgrid.get(k); if(!arr){arr=[];wgrid.set(k,arr);} arr.push(id);
}
return id;
}
const g=P.gap;
for(const c of cells){
if(!c) continue;
const A=seeds[c.i], poly=c.poly;
if(poly.length<3) continue;
let cx=0,cy=0;
for(const q of poly){cx+=q.x;cy+=q.y;} cx/=poly.length; cy/=poly.length;
const loop=[];
for(const q of poly){
const x2=weld?q.x:cx+(q.x-cx)*(1-g), y2=weld?q.y:cy+(q.y-cy)*(1-g);
const wx=A.x+c.tu.x*x2+c.tv.x*y2;
const wy=A.y+c.tu.y*x2+c.tv.y*y2;
const wz=A.z+c.tu.z*x2+c.tv.z*y2;
const id=addPoint(wx,wy,wz);
if(!loop.length||loop[loop.length-1]!==id) loop.push(id);
}
while(loop.length>1&&loop[0]===loop[loop.length-1]) loop.pop();
if(loop.length<3) continue;
loops.push(loop);
}
if(!pts.length) return;
regularize(pts,loops,3,clamp(P.regular,0,1));
for(const p of pts){
const r=onSurface(p.x,p.y,p.z);
let x=p.x,y=p.y,z=p.z,nx=0,ny=1,nz=0,rr=meanS*0.5,h=0;
if(r.ok){
x=r.x;y=r.y;z=r.z; nx=r.nx;ny=r.ny;nz=r.nz;
readMaps(r.u,r.v,_mm); rr=_mm.r; h=_mm.h;
}
agents.push({x,y,z,nx,ny,nz,s:2*rr*P.packing,h,r:rr});
}
const seen=new Set();
for(const lp of loops){
for(let k=0;k<lp.length;k++){
const i=lp[k], j=lp[(k+1)%lp.length];
if(i===j) continue;
const a=Math.min(i,j), b=Math.max(i,j), kk=a*1000003+b;
if(seen.has(kk)) continue;
seen.add(kk); edges.push(a,b);
}
}
finish();
}
// ═══ 5 · Agenten wachsen mit Schwarmverhalten ═══
const veinR=(a,f)=>Math.max(0.0006, a.s*P.veinScale*(1-P.taper*f));
function growAgents(){
const S=Math.max(1,P.growSteps|0), n=agents.length;
const px=new Float64Array(n),py=new Float64Array(n),pz=new Float64Array(n);
const dx=new Float64Array(n),dy=new Float64Array(n),dz=new Float64Array(n);
const ax=new Float64Array(n),ay=new Float64Array(n),az=new Float64Array(n);
const stepLen=new Float64Array(n);
const b0x=new Float64Array(n),b0y=new Float64Array(n),b0z=new Float64Array(n);
for(let i=0;i<n;i++){
const a=agents[i], off=veinR(a,0);
px[i]=a.x+a.nx*off; py[i]=a.y+a.ny*off; pz[i]=a.z+a.nz*off;
b0x[i]=a.x; b0y[i]=a.y; b0z[i]=a.z;
dx[i]=a.nx; dy[i]=a.ny; dz[i]=a.nz;
stepLen[i]=a.h/S;
a.path=[new V3(px[i],py[i],pz[i])];
}
// Nachbarschaft für Gleichrichtung und Kohäsion
let adjStart,adjIdx;
let meanS=0; for(let i=0;i<n;i++) meanS+=agents[i].s; meanS=n?meanS/n:0.03;
const cellN=clamp(meanS*0.6,0.005,0.08);
const key=(x,y,z)=>(x+512)+(y+512)*1024+(z+512)*1048576;
if(edges.length){
adjStart=new Int32Array(n+1); adjIdx=new Int32Array(edges.length);
const cnt=new Int32Array(n);
for(let e=0;e<edges.length;e++) cnt[edges[e]]++;
for(let i=0;i<n;i++) adjStart[i+1]=adjStart[i]+cnt[i];
const cur=adjStart.slice(0,n);
for(let e=0;e<edges.length;e+=2){
const i=edges[e], j=edges[e+1];
adjIdx[cur[i]++]=j; adjIdx[cur[j]++]=i;
}
}else{
adjStart=new Int32Array(n+1); adjIdx=new Int32Array(0);
}
let meanA=0; for(let i=0;i<n;i++) meanA+=agents[i].s||0.02; meanA=n?meanA/n:0.02;
const mpair=mirrorOn()?mirrorPairs(agents,meanA*0.45):null;
const keepSym=()=>{
if(!mpair) return;
for(let i=0;i<n;i++){
const j=mpair[i];
if(j<0) continue;
if(j===i){ px[i]=0; continue; }
if(j<i) continue;
const x=(px[i]-px[j])*0.5, y=(py[i]+py[j])*0.5, z=(pz[i]+pz[j])*0.5;
px[i]=x; py[i]=y; pz[i]=z;
px[j]=-x; py[j]=y; pz[j]=z;
const dxs=(dx[i]-dx[j])*0.5, dys=(dy[i]+dy[j])*0.5, dzs=(dz[i]+dz[j])*0.5;
dx[i]=dxs; dy[i]=dys; dz[i]=dzs;
dx[j]=-dxs; dy[j]=dys; dz[j]=dzs;
}
};
const grid=new Map();
const f=new V3(), q=new V3(), pp=new V3();
const near=[];
for(let k=1;k<=S;k++){
for(let i=0;i<n;i++){
if(stepLen[i]<=0) continue;
const a=agents[i];
pp.set(px[i],py[i],pz[i]);
if(mirrorOn()&&pp.x<0){ q.set(-pp.x,pp.y,pp.z); curl(q,f); f.x=-f.x; } else curl(pp,f);
let ux=dx[i]+f.x*P.wobble+a.nx*P.straighten;
let uy=dy[i]+f.y*P.wobble+a.ny*P.straighten;
let uz=dz[i]+f.z*P.wobble+a.nz*P.straighten;
const l=Math.hypot(ux,uy,uz);
if(l>1e-9){ ux/=l; uy/=l; uz/=l; } else { ux=a.nx; uy=a.ny; uz=a.nz; }
ax[i]=ux; ay[i]=uy; az[i]=uz;
}
for(let i=0;i<n;i++){
if(stepLen[i]<=0) continue;
let ux=ax[i],uy=ay[i],uz=az[i];
const s0=adjStart[i], s1=adjStart[i+1];
if(s1>s0){
if(P.align>0){
let sx=0,sy=0,sz=0;
for(let m=s0;m<s1;m++){ const j=adjIdx[m]; sx+=ax[j]; sy+=ay[j]; sz+=az[j]; }
const c=s1-s0; sx/=c; sy/=c; sz/=c;
ux+=(sx-ux)*P.align; uy+=(sy-uy)*P.align; uz+=(sz-uz)*P.align;
}
if(P.cohesion>0){
let sx=0,sy=0,sz=0;
for(let m=s0;m<s1;m++){ const j=adjIdx[m]; sx+=px[j]; sy+=py[j]; sz+=pz[j]; }
const c=s1-s0; sx=sx/c-px[i]; sy=sy/c-py[i]; sz=sz/c-pz[i];
const ll=Math.hypot(sx,sy,sz);
if(ll>1e-9){ ux+=sx/ll*P.cohesion; uy+=sy/ll*P.cohesion; uz+=sz/ll*P.cohesion; }
}
}
const l=Math.hypot(ux,uy,uz);
if(l>1e-9){ ux/=l; uy/=l; uz/=l; }
dx[i]=ux; dy[i]=uy; dz[i]=uz;
px[i]+=ux*stepLen[i]; py[i]+=uy*stepLen[i]; pz[i]+=uz*stepLen[i];
}
for(let it=0;it<(P.relaxIter|0);it++){
grid.clear();
for(let i=0;i<n;i++){
const kk=key(Math.floor(px[i]/cellN),Math.floor(py[i]/cellN),Math.floor(pz[i]/cellN));
let arr=grid.get(kk); if(!arr){arr=[];grid.set(kk,arr);} arr.push(i);
}
for(let i=0;i<n;i++){
near.length=0;
const gx=Math.floor(px[i]/cellN),gy=Math.floor(py[i]/cellN),gz=Math.floor(pz[i]/cellN);
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) near.push(j);
}
for(const j of near){
const wi=stepLen[i]>0?1:0, wj=stepLen[j]>0?1:0;
if(!wi&&!wj) continue;
const d0=Math.hypot(b0x[j]-b0x[i],b0y[j]-b0y[i],b0z[j]-b0z[i]);
let minD=d0*P.sepKeep;
if(minD>cellN) minD=cellN;
let ex=px[j]-px[i], ey=py[j]-py[i], ez=pz[j]-pz[i];
const d=Math.hypot(ex,ey,ez);
if(d>=minD) continue;
if(d<1e-9){ ex=agents[i].nx*1e-4; ey=agents[i].ny*1e-4; ez=agents[i].nz*1e-4; }
else { ex/=d; ey/=d; ez/=d; }
const push=(minD-d)*0.7/(wi+wj);
if(wi){ px[i]-=ex*push; py[i]-=ey*push; pz[i]-=ez*push; }
if(wj){ px[j]+=ex*push; py[j]+=ey*push; pz[j]+=ez*push; }
}
}
}
keepSym();
for(let i=0;i<n;i++) agents[i].path.push(new V3(px[i],py[i],pz[i]));
}
}
// ═══ 6 · Geometrie ═══
function addTube(pts,r0,r1,sides,pos,idx,tt,t0,t1){
const N=pts.length; if(N<2) return;
let len=0;
for(let i=1;i<N;i++) len+=pts[i].distanceTo(pts[i-1]);
if(len<1e-6) return;
const F=frames(pts), base=pos.length/3;
for(let i=0;i<N;i++){
const t=i/(N-1), r=r0+(r1-r0)*t;
const c=pts[i], nn=F.N[i], bb=F.B[i];
const tv=(t0===undefined)?t:(t0+(t1-t0)*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);
if(tt) tt.push(tv);
}
}
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);
}
}
function addWall(A,B,t,pos,idx,tt){
const N=A.length; if(N<2) return;
let h=0;
for(let i=1;i<N;i++) h+=A[i].distanceTo(A[i-1])+B[i].distanceTo(B[i-1]);
if(h<1e-5) return;
const base=pos.length/3;
if(t<=1e-6){
for(let i=0;i<N;i++){
pos.push(A[i].x,A[i].y,A[i].z, B[i].x,B[i].y,B[i].z);
if(tt){ const u=i/(N-1); tt.push(u,u); }
}
for(let i=0;i<N-1;i++){ const a=base+i*2; idx.push(a,a+1,a+3, a,a+3,a+2); }
return;
}
const e=new V3(), g=new V3(), nn=new V3(), tmp=new V3(), half=t*0.5;
for(let i=0;i<N;i++){
const i0=Math.max(0,i-1), i1=Math.min(N-1,i+1);
e.subVectors(B[i],A[i]);
g.subVectors(A[i1],A[i0]); tmp.subVectors(B[i1],B[i0]); g.add(tmp);
nn.crossVectors(e,g);
if(nn.lengthSq()<1e-14) nn.set(0,1,0); else nn.normalize();
pos.push(A[i].x+nn.x*half,A[i].y+nn.y*half,A[i].z+nn.z*half,
B[i].x+nn.x*half,B[i].y+nn.y*half,B[i].z+nn.z*half,
B[i].x-nn.x*half,B[i].y-nn.y*half,B[i].z-nn.z*half,
A[i].x-nn.x*half,A[i].y-nn.y*half,A[i].z-nn.z*half);
if(tt){ const u=i/(N-1); tt.push(u,u,u,u); }
}
for(let i=0;i<N-1;i++) for(let j=0;j<4;j++){
const a=base+i*4+j, b=base+i*4+(j+1)%4;
idx.push(a,b,b+4, a,b+4,a+4);
}
const last=base+(N-1)*4;
idx.push(base,base+2,base+1, base,base+3,base+2);
idx.push(last,last+1,last+2, last,last+2,last+3);
}
function tissueMat(){
return new THREE.MeshStandardMaterial({color:0xffffff,vertexColors:true,
roughness:0.45,metalness:0,side:THREE.DoubleSide});
}
let tissueT=null; // Wuchsanteil je Vertex, für den Farbverlauf
const _cA=new THREE.Color(), _cB=new THREE.Color();
function gradientColors(tArr){
_cA.set(P.sphereColor); _cB.set(P.tipColor);
const c=new Float32Array(tArr.length*3);
for(let i=0;i<tArr.length;i++){
const t=clamp(tArr[i],0,1);
c[i*3 ]=_cA.r+(_cB.r-_cA.r)*t;
c[i*3+1]=_cA.g+(_cB.g-_cA.g)*t;
c[i*3+2]=_cA.b+(_cB.b-_cA.b)*t;
}
return c;
}
function refreshTissueColors(){
if(tissue&&tissueT){
tissue.geometry.setAttribute('color',new THREE.Float32BufferAttribute(gradientColors(tissueT),3));
}
if(joints&&jointList.length){
_cA.set(P.sphereColor); _cB.set(P.tipColor);
const col=new THREE.Color();
jointList.forEach((j,i)=>{
const t=clamp(j.t||0,0,1);
col.setRGB(_cA.r+(_cB.r-_cA.r)*t,_cA.g+(_cB.g-_cA.g)*t,_cA.b+(_cB.b-_cA.b)*t);
joints.setColorAt(i,col);
});
if(joints.instanceColor) joints.instanceColor.needsUpdate=true;
}
}
// ── Vorschau: flache Flächen und Linien, schnell
function buildPreview(){
preGroup.clear();
if(preFaces){ preFaces.geometry.dispose(); preFaces.material.dispose(); preFaces=null; }
if(preLines){ preLines.geometry.dispose(); preLines.material.dispose(); preLines=null; }
if(!agents.length) return;
const S=Math.max(1,P.growSteps|0);
const pos=[], idx=[], lin=[], tt=[];
for(let e=0;e<edges.length;e+=2){
const A=agents[edges[e]], B=agents[edges[e+1]];
addWall(A.path,B.path,0,pos,idx,tt);
const a=A.path[S], b=B.path[S];
lin.push(a.x,a.y,a.z,b.x,b.y,b.z);
const a0=A.path[0], b0=B.path[0];
lin.push(a0.x,a0.y,a0.z,b0.x,b0.y,b0.z);
}
for(const a of agents){
if(a.h<=1e-4) continue;
for(let k=0;k<S;k++){
const p=a.path[k], q=a.path[k+1];
lin.push(p.x,p.y,p.z,q.x,q.y,q.z);
}
}
if(pos.length){
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
g.setIndex((pos.length/3)>65535?new THREE.Uint32BufferAttribute(idx,1):new THREE.Uint16BufferAttribute(idx,1));
g.computeVertexNormals(); g.computeBoundingSphere();
g.setAttribute('color',new THREE.Float32BufferAttribute(gradientColors(Float32Array.from(tt)),3));
preFaces=new THREE.Mesh(g,new THREE.MeshStandardMaterial({color:0xffffff,vertexColors:true,
roughness:0.7,metalness:0,side:THREE.DoubleSide,transparent:true,opacity:0.35,depthWrite:false}));
preFaces.frustumCulled=false; preGroup.add(preFaces);
}
if(lin.length){
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.Float32BufferAttribute(lin,3));
preLines=new THREE.LineSegments(g,new THREE.LineBasicMaterial({color:0x111111,transparent:true,opacity:0.75}));
preLines.frustumCulled=false; preGroup.add(preLines);
}
}
// ── Mesh: Wände mit Stärke, Adern, Knoten
async function buildMesh(){
if(busy||!agents.length) return;
busy=true;
try{
clearMeshOnly();
await step('Mesh: Wände',0.1);
const S=Math.max(1,P.growSteps|0), L=Math.max(1,P.levels|0), sides=Math.max(3,P.tubeSides|0);
const pos=[], idx=[], tt=[];
const lvl=[];
for(let l=0;l<L;l++) lvl.push({f:(l+1)/L, k:Math.max(1,Math.round((l+1)/L*S))});
if(P.showWalls){
for(let e=0;e<edges.length;e+=2){
const A=agents[edges[e]], B=agents[edges[e+1]];
addWall(A.path,B.path,P.wallThick,pos,idx,tt);
if((e&2047)===0) await step('Mesh: Wände',0.1+0.4*e/Math.max(1,edges.length));
}
}
if(P.capTop&&loops.length){
for(const lp of loops){
if(lp.length<3) continue;
const base=pos.length/3;
let ok=true;
for(const ai of lp){ const p=agents[ai].path[S]; if(agents[ai].h<=1e-4) ok=false; pos.push(p.x,p.y,p.z); tt.push(1); }
if(!ok){ pos.length=base*3; tt.length=base; continue; }
for(let k=1;k<lp.length-1;k++) idx.push(base,base+k,base+k+1);
}
}
await step('Mesh: Adern',0.55);
if(P.showVeins){
for(const a of agents){
if(a.h<=1e-4) continue;
addTube(a.path,veinR(a,0),veinR(a,1),sides,pos,idx,tt,0,1);
}
for(const {f,k} of lvl){
for(let e=0;e<edges.length;e+=2){
const A=agents[edges[e]], B=agents[edges[e+1]];
addTube([A.path[k],B.path[k]],veinR(A,f),veinR(B,f),sides,pos,idx,tt,f,f);
}
await step('Mesh: Adern',0.55+0.3*(f));
}
}
jointList=[];
if(P.showJoints&&P.showVeins){
const seen=new Set();
for(const a of agents){
seen.clear();
const put=(k,f)=>{
if(seen.has(k)) return; seen.add(k);
const p=a.path[k];
jointList.push({x:p.x,y:p.y,z:p.z,r:veinR(a,f)*P.jointScale,t:f});
};
put(0,0);
if(a.h>1e-4) for(const {f,k} of lvl) put(k,f);
}
}
await step('Mesh: zusammenbauen',0.92);
const mat=tissueMat();
if(pos.length){
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
geo.setIndex((pos.length/3)>65535?new THREE.Uint32BufferAttribute(idx,1):new THREE.Uint16BufferAttribute(idx,1));
geo.computeVertexNormals(); geo.computeBoundingSphere();
tissueT=Float32Array.from(tt);
geo.setAttribute('color',new THREE.Float32BufferAttribute(gradientColors(tissueT),3));
tissue=new THREE.Mesh(geo,mat); tissue.frustumCulled=false; tisGroup.add(tissue);
}
if(jointList.length){
const jg=new THREE.IcosahedronGeometry(1,P.sphereDetail|0);
const im=new THREE.InstancedMesh(jg,mat,jointList.length);
im.frustumCulled=false;
const mtx=new THREE.Matrix4(), q=new QT(), sc=new V3(), pv=new V3();
jointList.forEach((j,i)=>{ pv.set(j.x,j.y,j.z); sc.setScalar(j.r); mtx.compose(pv,q,sc); im.setMatrixAt(i,mtx); });
im.instanceMatrix.needsUpdate=true;
im.instanceColor=new THREE.InstancedBufferAttribute(new Float32Array(jointList.length*3),3);
joints=im; tisGroup.add(im);
refreshTissueColors();
}
P.showPreview=false; syncUI();
setStatus('Mesh gebaut.');
} finally { busy=false; progressEnd(); }
}
function tissueTris(){
let t=tissue?tissue.geometry.index.count/3:0;
if(joints) t+=jointList.length*20*Math.pow(4,P.sphereDetail|0);
return t;
}
// ── Gesamtlauf
async function generate(){
if(busy) return;
if(!paintData){ setStatus('Erst ein Modell laden.'); return; }
busy=true;
try{
clearTissue();
await step('Samen setzen',0.02);
buildTriGrid();
if(!sampleSeeds()){ setStatus('Ebene 1 ist leer.'); return; }
await step('Samen setzen · '+seeds.length,0.06);
await relaxSeeds();
await step('Zellen aufbauen',0.74);
buildTopology();
if(!agents.length){ setStatus('Keine Zellen.'); return; }
await step('Wachstum',0.85);
growAgents();
await step('Vorschau',0.95);
buildPreview();
P.showPreview=true; syncUI();
setStatus('Struktur bereit.');
} 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 mergedJointGeometry(){
if(!jointList.length) return null;
const base=new THREE.IcosahedronGeometry(1,P.sphereDetail|0);
const bp=base.attributes.position, bn=base.attributes.normal;
const vc=bp.count;
const bi=base.index?base.index.array:(()=>{const a=new Uint32Array(vc);for(let i=0;i<vc;i++)a[i]=i;return a;})();
const ic=bi.length, nS=jointList.length;
const pos=new Float32Array(nS*vc*3), nor=new Float32Array(nS*vc*3);
const idx=(nS*vc>65535)?new Uint32Array(nS*ic):new Uint16Array(nS*ic);
for(let s=0;s<nS;s++){
const S=jointList[s], r=S.r, vo=s*vc;
for(let i=0;i<vc;i++){
pos[(vo+i)*3 ]=bp.getX(i)*r+S.x;
pos[(vo+i)*3+1]=bp.getY(i)*r+S.y;
pos[(vo+i)*3+2]=bp.getZ(i)*r+S.z;
nor[(vo+i)*3 ]=bn.getX(i); nor[(vo+i)*3+1]=bn.getY(i); nor[(vo+i)*3+2]=bn.getZ(i);
}
for(let i=0;i<ic;i++) idx[s*ic+i]=bi[i]+vo;
}
base.dispose();
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
g.setAttribute('normal',new THREE.Float32BufferAttribute(nor,3));
g.setIndex(new THREE.BufferAttribute(idx,1));
return g;
}
function exportPieces(){
const out=[];
if(tissue) out.push({name:'tissue',geo:tissue.geometry});
const jg=mergedJointGeometry();
if(jg) out.push({name:'joints',geo:jg});
if(P.exportBody&&body) out.push({name:'body',geo:body.geometry});
return out;
}
function exportOBJ(pieces){
let out='# CELL_MESH_BODY\n', voff=1;
for(const {name,geo} of pieces){
const p=geo.attributes.position,n=geo.attributes.normal,ix=geo.index;
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)}\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'}),'cell_mesh_body.obj');
}
async function exportGLTF(pieces,binary){
if(!GLTFExporter){
const m=await addon('exporters/GLTFExporter.js');
if(!m){ setStatus('GLTFExporter fehlt.'); return; }
GLTFExporter=m.GLTFExporter;
}
const root=new THREE.Group(); root.name='cell_mesh_body';
for(const {name,geo} of pieces){
const m=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({
color: name==='body'?0xdddddd:0xffffff,
vertexColors: name!=='body'&&!!geo.attributes.color,
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'}),'cell_mesh_body.glb');
else download(new Blob([JSON.stringify(res,null,1)],{type:'model/gltf+json'}),'cell_mesh_body.gltf');
setStatus('Export fertig.');
},err=>{ console.error(err); setStatus('Export fehlgeschlagen.'); },{binary});
}
function doExport(){
const pieces=exportPieces();
if(!pieces.length){ setStatus('Nichts zu exportieren.'); return; }
if(P.exportFormat==='obj') exportOBJ(pieces);
else exportGLTF(pieces,P.exportFormat==='glb');
}
/** Kartendatei: eine PNG, 2R × R, links Ebene 1, rechts Ebene 2.
* Je Kachel: Rot = Helligkeit, Grün = Deckung, Blau frei, Alpha immer deckend
* (Alpha im Bild würde beim Zurücklesen durch die Canvas-Vorvervielfachung Werte verlieren). */
function paintMapCanvas(){
const cv=document.createElement('canvas'); cv.width=R*2; cv.height=R;
const ctx=cv.getContext('2d',{willReadFrequently:true});
const img=ctx.createImageData(R*2,R);
for(let y=0;y<R;y++) for(let x=0;x<R;x++){
const src=(y*R+x)*4;
const dA=(y*R*2+x)*4, dB=(y*R*2+R+x)*4;
img.data[dA]=paintData[src]; img.data[dA+1]=paintData[src+1]; img.data[dA+2]=0; img.data[dA+3]=255;
img.data[dB]=paintData[src+2]; img.data[dB+1]=paintData[src+3]; img.data[dB+2]=0; img.data[dB+3]=255;
}
ctx.putImageData(img,0,0);
return cv;
}
function exportMapPNG(){
if(!paintData) return;
paintMapCanvas().toBlob(b=>{ download(b,'meshbodypaint.png'); setStatus('Karte gesichert.'); });
}
function applyMapImage(img,quiet){
if(!paintData) return false;
const two=img.width>=img.height*1.8;
const w=two?img.width/2:img.width;
const cv=document.createElement('canvas'); cv.width=R*(two?2:1); cv.height=R;
const ctx=cv.getContext('2d',{willReadFrequently:true});
ctx.clearRect(0,0,cv.width,cv.height);
ctx.drawImage(img,0,0,cv.width,cv.height);
const d=ctx.getImageData(0,0,cv.width,cv.height).data;
if(two){
for(let y=0;y<R;y++) for(let x=0;x<R;x++){
const dst=(y*R+x)*4;
const sA=(y*R*2+x)*4, sB=(y*R*2+R+x)*4;
paintData[dst]=d[sA]; paintData[dst+1]=d[sA+1];
paintData[dst+2]=d[sB]; paintData[dst+3]=Math.min(d[sB+1],d[sA+1]);
}
}else{
const vo=vOff(), ao=aOff();
for(let i=0;i<R*R;i++){
const al=d[i*4+3];
const v=al>0?(d[i*4]*0.299+d[i*4+1]*0.587+d[i*4+2]*0.114):0;
paintData[i*4+vo]=v; paintData[i*4+ao]=al;
if(P.layer===1&&paintData[i*4+3]>paintData[i*4+1]) paintData[i*4+3]=paintData[i*4+1];
}
}
paintDirty=true; pushHistory();
if(!quiet) setStatus(two?'Karte geladen.':'Ebene '+(P.layer+1)+' geladen.');
return true;
}
function importMapPNG(file){
if(!paintData){ setStatus('Erst ein Modell laden.'); return; }
const url=URL.createObjectURL(file);
const img=new Image();
img.onload=()=>{ applyMapImage(img,false); URL.revokeObjectURL(url); };
img.onerror=()=>{ setStatus('Bild konnte nicht gelesen werden.'); URL.revokeObjectURL(url); };
img.src=url;
}
async function autoLoadMap(){
if(!paintData) return false;
try{
const r=await fetch('./meshbodypaint.png',{cache:'no-store'});
if(!r.ok) return false;
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const img=new Image();
const ok=await new Promise(res=>{ img.onload=()=>res(true); img.onerror=()=>res(false); img.src=url; });
URL.revokeObjectURL(url);
if(!ok) return false;
applyMapImage(img,true);
resetHistory();
setStatus('meshbodypaint.png geladen.');
return true;
}catch(e){ return false; }
}
// ═════════════════════════════════════════════════════════════════════════════
// 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); });
}
/** Graustufen-Regler: links weiß, rechts schwarz */
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);
}
const syncUI=()=>syncers.forEach(f=>f());
let layerNote=null;
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);
if(layerNote) layerNote.textContent = i===0
? 'schwarz groß · weiß fein · leer nichts'
: 'schwarz weit · weiß nah · leer flach';
syncUI(); setStatus();
}
function setPoseMode(on){
P.poseMode=on;
document.getElementById('bPose').classList.toggle('on',on);
if(on&&!srcMesh?.isSkinnedMesh){ setStatus('Kein Skelett.'); }
else setStatus(on?'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',()=>{ 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(); setStatus(); });
button(p,'Pose zurücksetzen',resetPose,true);
const m=section('Auflösung',g);
const sel=choice(m,'mapRes',[['512','512 × 512'],['1024','1024 × 1024'],['2048','2048 × 2048']]);
sel.value=String(P.mapRes);
sel.addEventListener('change',()=>{P.mapRes=parseInt(sel.value);});
button(m,'Karte neu',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 · Zellgröße'],[1,'2 · Höhe']],()=>setLayer(P.layer));
layerNote=document.createElement('p'); layerNote.className='note'; s.appendChild(layerNote);
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 X','mirror');
const note=document.createElement('p'); note.className='note';
note.textContent='Ziehen malt · Umschalt dreht · Alt+Rad Radius';
b.appendChild(note);
const r=document.createElement('div');r.className='btn2';b.appendChild(r);
button(r,'Ebene leeren',clearLayer,true);
button(r,'Karte sichern',exportMapPNG,true);
const mi=document.createElement('input');
mi.type='file'; mi.accept='image/png,image/jpeg,image/webp'; mi.style.display='none';
mi.addEventListener('change',()=>{ const f=mi.files[0]; if(f) importMapPNG(f); mi.value=''; });
b.appendChild(mi);
button(b,'Karte laden',()=>mi.click(),true);
}
// ── STRUKTUR
{
const g=group('Struktur',true);
const m=section('Muster',g);
segmented(m,'mode',[['voronoi','Voronoi'],['delaunay','Delaunay'],['circle','Kreise']]);
slider(m,'Regelmäßigkeit','regular',0,1,0.01,2);
slider(m,'Zellspalt','gap',0,0.4,0.01,2);
slider(m,'Kreispunkte','ringPoints',3,16,1,0);
const s2=section('Packung · Ebene 1',g);
slider(s2,'Minimum','rMin',0.001,0.04,0.0005,4);
slider(s2,'Maximum','rMax',0.004,0.12,0.0005,4);
slider(s2,'Gamma','gamma',0.2,4,0.05,2);
slider(s2,'Streuung','jitter',0,0.6,0.01,2);
slider(s2,'Packdichte','packing',0.1,1,0.01,2);
slider(s2,'Kandidaten','samples',2000,250000,1000,0);
slider(s2,'Dichte nach Wert','densityBias',0,1,0.01,2);
slider(s2,'Schwelle Deckung','alphaMin',0.01,0.9,0.01,2);
slider(s2,'Ausgleich','relax',0,30,1,0);
slider(s2,'Seed','seed',1,999,1,0);
button(s2,'Neu würfeln',()=>{P.seed=1+((Math.random()*998)|0);syncUI();generate();},true);
const w=section('Wuchs · Ebene 2',g);
slider(w,'Wuchshöhe','growHeight',0,0.5,0.005,3);
slider(w,'Schritte','growSteps',1,16,1,0);
slider(w,'Wobble','wobble',0,1.5,0.01,2);
slider(w,'Feldskalierung','noiseScale',0.3,12,0.1,1);
slider(w,'Aufrichtung','straighten',0,2,0.01,2);
slider(w,'Gleichrichtung','align',0,1,0.01,2);
slider(w,'Kohäsion','cohesion',0,1,0.01,2);
slider(w,'Abstand halten','sepKeep',0,1.2,0.01,2);
slider(w,'Entflechtung','relaxIter',0,6,1,0);
const v=section('Wände & Adern',g);
check(v,'Zellwände','showWalls');
slider(v,'Wandstärke','wallThick',0,0.012,0.0002,4);
check(v,'Deckel oben','capTop');
check(v,'Adern','showVeins');
slider(v,'Aderdicke','veinScale',0.01,0.4,0.002,3);
slider(v,'Verjüngung','taper',0,0.9,0.01,2);
slider(v,'Röhrenkanten','tubeSides',3,10,1,0);
slider(v,'Ringe','levels',1,4,1,0);
check(v,'Knotenkugeln','showJoints');
slider(v,'Knotengröße','jointScale',0.8,2.5,0.05,2);
slider(v,'Knotendetail','sphereDetail',0,3,1,0);
const cw=document.createElement('div'); cw.className='btn2'; v.appendChild(cw);
const c=document.createElement('input');c.type='color';c.value=P.sphereColor;
c.title='Farbe an der Haut';
c.addEventListener('input',()=>{ P.sphereColor=c.value; refreshTissueColors(); });
const c2=document.createElement('input');c2.type='color';c2.value=P.tipColor;
c2.title='Farbe an der Spitze';
c2.addEventListener('input',()=>{ P.tipColor=c2.value; refreshTissueColors(); });
cw.append(c,c2);
const r=document.createElement('div');r.className='btn2';v.appendChild(r);
button(r,'Erzeugen',generate);
button(r,'Mesh bauen',buildMesh);
button(v,'Löschen',clearTissue,true);
}
// ── ANSICHT
{
const g=group('Ansicht',false);
const s=section(null,g);
check(s,'Körper','showBody');
slider(s,'Deckkraft','ghost',0,1,0.01,2);
check(s,'Malfläche zeigen','showPaint');
check(s,'Gewebe','showTissue');
check(s,'Vorschau','showPreview');
}
// ── EXPORT
{
const g=group('Export',false);
const s=section(null,g);
choice(s,'exportFormat',[['glb','GLB'],['gltf','GLTF'],['obj','OBJ']]);
check(s,'Körper mit','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('bDelSph').onclick=clearTissue;
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(`SAMEN <b>${seeds.length.toLocaleString('de-DE')}</b>`,
`AGENTEN <b>${agents.length.toLocaleString('de-DE')}</b>`,
`KANTEN <b>${(edges.length/2).toLocaleString('de-DE')}</b>`);
const t=tissueTris();
if(t) parts.push(`DREIECKE <b>${Math.round(t).toLocaleString('de-DE')}</b>`);
}
if(P.mirror&&poseAsym()) parts.push('<b>SPIEGELUNG AUS</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;
}
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&&mirrorOn();
bonePts.visible=skelLines.visible=pose;
selPt.visible=pose&&!!selectedBone;
tisGroup.visible=P.showTissue;
preGroup.visible=P.showPreview;
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>