index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BOID OUTFIT WEAVER</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%}
.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}
#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: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}
#tabs{display:flex;flex-wrap:wrap;border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:2}
#tabs button{width:auto;flex:1 0 auto;margin:0;padding:9px 8px;border:0;border-bottom:2px solid transparent;
background:#fff;color:var(--ink-3);font-size:9px;letter-spacing:.13em}
#tabs button:hover{background:#f7f7f7;color:var(--ink)}
#tabs button.on{color:var(--ink);border-bottom-color:var(--ink)}
.ixrow{display:flex;align-items:center;gap:7px;padding:6px 7px;border:1px solid var(--line);
margin:0 0 4px;cursor:pointer;color:var(--ink-2)}
.ixrow:hover{background:#f7f7f7}
.ixrow.on{border-color:var(--ink);color:var(--ink)}
.ixrow .dot{width:8px;height:8px;border-radius:50%;flex:none}
.ixrow .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ixrow .x{color:var(--ink-3);padding:0 2px}
.ixrow .x:hover{color:#b00}
.empty{color:var(--ink-3);padding:2px 0 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}
@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">FBX / GLB / GLTF HIER ABLEGEN</div>
<div id="bar">
<button id="bGrow">Wachsen</button>
<button id="bMesh">Mesh bauen</button>
<button id="bDelMesh">Mesh löschen</button>
<button id="bDelLine">Linie löschen</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>BOID OUTFIT WEAVER</h1></div><div id="tabs"></div><div id="ui"></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;
// ═════════════════════════════════════════════════════════════════════════════
// 1 · PARAMETER
// ═════════════════════════════════════════════════════════════════════════════
const P = {
poseMode:false, nodeCount:8, drawMode:'seed',
ixMode:'attract', ixRadius:0.25, ixStrength:1.0, ixFalloff:2.0, ixShowField:true,
boidsPerCurve:140, spawnSpread:1.0, spawnJitter:0.006,
lifetime:2.4, lifeJitter:0.45, timeScale:1.6,
maxSpeed:0.55, normalPush:0.65, escape:0.25, drag:0.9, gravity:0.0, spiral:0.0,
neighborRadius:0.09, separation:1.4, alignment:0.7, cohesion:0.45,
noiseScale:2.2, noiseStrength:1.1, noiseSpeed:0.12, curl:true,
trailRate:26, trailMax:44,
meshMode:'ribbon', width:0.009, twist:0, radialSeg:6, smooth:2, minTrail:5,
profile:[1,0.95,0.85,0.7,0.5,0.25],
colorA:'#111111', colorB:'#c8c8c8', autoBuild:false,
exportFormat:'glb', exportBody:false,
showBody:true, bodyOpacity:1.0, showStrokes:true, showNodes:true, showIx:true,
showBoids:true, showTrails:true, mirror:true, ownMaterial:false
};
const MAX_BOIDS=14000, PICK_PX=11;
// ═════════════════════════════════════════════════════════════════════════════
// 2 · PERLIN NOISE
// ═════════════════════════════════════════════════════════════════════════════
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), lerp=(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 lerp(w,lerp(v,lerp(u,grad(PERM[AA],x,y,z),grad(PERM[BA],x-1,y,z)),
lerp(u,grad(PERM[AB],x,y-1,z),grad(PERM[BB],x-1,y-1,z))),
lerp(v,lerp(u,grad(PERM[AA+1],x,y,z-1),grad(PERM[BA+1],x-1,y,z-1)),
lerp(u,grad(PERM[AB+1],x,y-1,z-1),grad(PERM[BB+1],x-1,y-1,z-1))));
}
const O=[[0,0,0],[37.1,12.3,5.2],[-19.7,51.9,-3.4]];
const psi=(i,x,y,z)=>noise3(x+O[i][0],y+O[i][1],z+O[i][2]);
function noiseForce(p,t,out){
const s=P.noiseScale,x=p.x*s,y=p.y*s,z=p.z*s+t*P.noiseSpeed*s;
if(!P.curl) return out.set(psi(0,x,y,z),psi(1,x,y,z),psi(2,x,y,z));
const e=0.14;
return 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-e,y,z))/(2*e) - (psi(0,x,y+e,z)-psi(0,x,y-e,z))/(2*e));
}
// ═════════════════════════════════════════════════════════════════════════════
// 3 · 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.5); 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 cam={target:new V3(0,1.05,0),theta:0.5,phi:1.32,dist:3.0};
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);
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(THREE.MathUtils.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};
}
// ═════════════════════════════════════════════════════════════════════════════
// 4 · DUMMY-KÖRPER
// ═════════════════════════════════════════════════════════════════════════════
const M='mixamorig:';
const BONE_DEFS=[
[M+'Hips',null,[0,1.00,0]],[M+'Spine',M+'Hips',[0,1.10,0]],[M+'Spine1',M+'Spine',[0,1.20,0]],
[M+'Spine2',M+'Spine1',[0,1.32,0]],[M+'Neck',M+'Spine2',[0,1.46,0]],[M+'Head',M+'Neck',[0,1.55,0]],
[M+'HeadTop_End',M+'Head',[0,1.78,0]],
[M+'LeftShoulder',M+'Spine2',[0.05,1.44,0]],[M+'LeftArm',M+'LeftShoulder',[0.17,1.44,0]],
[M+'LeftForeArm',M+'LeftArm',[0.45,1.44,0]],[M+'LeftHand',M+'LeftForeArm',[0.70,1.44,0]],
[M+'LeftHandEnd',M+'LeftHand',[0.88,1.44,0]],
[M+'RightShoulder',M+'Spine2',[-0.05,1.44,0]],[M+'RightArm',M+'RightShoulder',[-0.17,1.44,0]],
[M+'RightForeArm',M+'RightArm',[-0.45,1.44,0]],[M+'RightHand',M+'RightForeArm',[-0.70,1.44,0]],
[M+'RightHandEnd',M+'RightHand',[-0.88,1.44,0]],
[M+'LeftUpLeg',M+'Hips',[0.10,0.95,0]],[M+'LeftLeg',M+'LeftUpLeg',[0.11,0.53,0]],
[M+'LeftFoot',M+'LeftLeg',[0.11,0.09,0]],[M+'LeftToeBase',M+'LeftFoot',[0.11,0.025,0.09]],
[M+'LeftToe_End',M+'LeftToeBase',[0.11,0.025,0.19]],
[M+'RightUpLeg',M+'Hips',[-0.10,0.95,0]],[M+'RightLeg',M+'RightUpLeg',[-0.11,0.53,0]],
[M+'RightFoot',M+'RightLeg',[-0.11,0.09,0]],[M+'RightToeBase',M+'RightFoot',[-0.11,0.025,0.09]],
[M+'RightToe_End',M+'RightToeBase',[-0.11,0.025,0.19]],
];
const _t1=new V3(),_t2=new V3(),_t3=new V3();
function distToSeg(p,a,b){
const ab=_t1.subVectors(b,a),ap=_t2.subVectors(p,a);
const L=ab.lengthSq(); if(L<1e-12) return ap.length();
const t=THREE.MathUtils.clamp(ap.dot(ab)/L,0,1);
return _t3.copy(a).addScaledVector(ab,t).distanceTo(p);
}
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*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));
}
for(let i=0;i<pts.length-1;i++) for(let j=0;j<radial;j++){
const a=i*radial+j,b=i*radial+(j+1)%radial;
idx.push(a,b,b+radial, a,b+radial,a+radial);
}
const cs=pos.length/3; pos.push(pts[0].x,pts[0].y,pts[0].z); uv.push(0.5,0);
for(let j=0;j<radial;j++) idx.push(cs,(j+1)%radial,j);
const last=(pts.length-1)*radial, ce=pos.length/3, lp=pts[pts.length-1];
pos.push(lp.x,lp.y,lp.z); uv.push(0.5,1);
for(let j=0;j<radial;j++) idx.push(ce,last+j,last+(j+1)%radial);
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 defaultBodyMaterial(){
return new THREE.MeshStandardMaterial({color:0xededed,roughness:0.92,metalness:0,transparent:true,opacity:1});
}
function buildDummy(){
const bones=[],byName=new Map(),wp=new Map();
for(const [name,parent,pos] of BONE_DEFS){
const b=new THREE.Bone(); b.name=name;
const w=new V3().fromArray(pos); wp.set(name,w);
if(parent){ byName.get(parent).add(b); b.position.copy(w).sub(wp.get(parent)); } else b.position.copy(w);
byName.set(name,b); bones.push(b);
}
const root=byName.get(M+'Hips'); root.updateMatrixWorld(true);
const segs=bones.map((b,i)=>{
const child=BONE_DEFS.find(d=>d[1]===b.name), a=wp.get(b.name);
return {i,a,b:child?wp.get(child[0]):a.clone()};
});
const pos=[],idx=[],uv=[]; let off=0;
for(const part of PARTS){
const g=loft(part.s,part.radial,part.sub);
for(const v of g.pos) pos.push(v);
for(const t of g.uv) uv.push(t);
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 n=pos.length/3, si=new Uint16Array(n*4), sw=new Float32Array(n*4), p=new V3(), cand=[];
for(let v=0;v<n;v++){
p.set(pos[v*3],pos[v*3+1],pos[v*3+2]); cand.length=0;
for(const s of segs) cand.push([s.i,distToSeg(p,s.a,s.b)]);
cand.sort((a,b)=>a[1]-b[1]);
let sum=0; const w=[];
for(let k=0;k<4;k++){const d=Math.max(cand[k][1],1e-4),ww=1/(d*d*d); w.push(ww); sum+=ww;}
for(let k=0;k<4;k++){si[v*4+k]=cand[k][0]; sw[v*4+k]=w[k]/sum;}
}
geo.setAttribute('skinIndex',new THREE.Uint16BufferAttribute(si,4));
geo.setAttribute('skinWeight',new THREE.Float32BufferAttribute(sw,4));
const mesh=new THREE.SkinnedMesh(geo,defaultBodyMaterial());
const group=new THREE.Group(); group.add(mesh); group.add(root);
group.updateMatrixWorld(true);
mesh.bind(new THREE.Skeleton(bones));
return {group,mesh};
}
// ═════════════════════════════════════════════════════════════════════════════
// 5 · KÖRPER + POSE
// ═════════════════════════════════════════════════════════════════════════════
let bodyRoot=null, body=null, bodyMat=null, modelName='Dummy';
let restQ=new Map(), poseQ=new Map(), orderedBones=[], useNameSide=false;
function disposeBody(){
if(!bodyRoot) return;
scene.remove(bodyRoot);
bodyRoot.traverse(o=>{ if(o.isMesh) o.geometry?.dispose?.(); });
bodyRoot=null; body=null;
}
function setBody(group,mesh,name){
disposeBody();
bodyRoot=group; body=mesh; modelName=name;
body.frustumCulled=false;
const g=body.geometry;
if(!g.attributes.normal) g.computeVertexNormals();
g.computeBoundingSphere(); g.boundingSphere.radius*=1.9;
bodyMat=Array.isArray(body.material)?body.material[0]:body.material;
scene.add(bodyRoot); bodyRoot.updateMatrixWorld(true);
restQ.clear(); poseQ.clear(); orderedBones=[];
if(body.isSkinnedMesh){
const bones=body.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));
}
clearStrokes(); clearSim(); clearMesh(); rebuildBoneHandles(); resetHistory();
setStatus('Modell: '+name);
}
function normalizeModel(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);
}
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);
normalizeModel(group);
if(!P.ownMaterial) obj.traverse(o=>{ if(o.isMesh) o.material=defaultBodyMaterial(); });
setBody(group,best.mesh,name);
}
// ── Bind-Space ↔ Pose-Space (damit gezeichnete Knoten der Pose folgen)
const _mA=new THREE.Matrix4(),_mB=new THREE.Matrix4(),_si=new THREE.Vector4(),_sw=new THREE.Vector4();
function skinMatrix(index,out){
if(!body?.isSkinnedMesh||index<0) return out.identity();
const g=body.geometry, sk=body.skeleton;
if(!g.attributes.skinIndex) return out.identity();
_si.fromBufferAttribute(g.attributes.skinIndex,index);
_sw.fromBufferAttribute(g.attributes.skinWeight,index);
const e=out.elements; for(let k=0;k<16;k++) e[k]=0;
for(let i=0;i<4;i++){
const w=_sw.getComponent(i); if(w===0) continue;
const bi=_si.getComponent(i), bone=sk.bones[bi]; if(!bone) continue;
_mB.multiplyMatrices(bone.matrixWorld,sk.boneInverses[bi]);
const b=_mB.elements; for(let k=0;k<16;k++) e[k]+=b[k]*w;
}
out.multiplyMatrices(out,body.bindMatrix);
return out.premultiply(body.bindMatrixInverse);
}
// ── Pose: Weltraum-Deltas je Bone, immer gespiegelt
const _qp=new QT(),_qtmp=new QT();
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 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 body.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(!body?.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);
}
bodyRoot.updateMatrixWorld(true);
body.skeleton.update();
rebuildBoneHandles();
if(typeof refreshBindings==='function') refreshBindings();
}
function twistAboutX(q){ // nur X-Drehung ist selbstspiegelnd
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=isCenter(bone)?twistAboutX(q):q;
const next=d.clone().multiply(poseQ.get(bone.uuid)||new QT());
poseQ.set(bone.uuid,next);
const cp=counterpart(bone);
if(cp) poseQ.set(cp.uuid,mirrorQ(next));
applyPose();
}
function rotateBone(bone,axis,angle){
applyWorldDelta(bone,_qtmp.setFromAxisAngle(axis,angle).clone());
}
function resetPose(){ poseQ.clear(); applyPose(); }
const worldPosOf=b=>new V3().setFromMatrixPosition(b.matrixWorld);
function firstChildBone(b){ return b.children.find(x=>x.isBone)||null; }
function boneAxis(b){ // Eigenachse für Twist
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);
}
// ── Bone-Handles
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 ringDark=ringTexture('#111');
const pointOpts={sizeAttenuation:false,map:ringDark,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);
let boneHandles=[], selectedBone=null;
function rebuildBoneHandles(){
boneHandles=[];
if(body?.isSkinnedMesh){
for(const b of body.skeleton.bones){
if(SKIP.test(b.name)) continue;
if(!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(body?.isSkinnedMesh){
const set=new Set(body.skeleton.bones);
for(const b of body.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;
}
}
// ── Loader
let GLTFLoader=null,FBXLoader=null,GLTFExporter=null;
async function addon(path){
if(!addonsAvailable) return null;
try{ return await import('three/addons/'+path); }
catch(e){ console.warn(e); addonsAvailable=false; return null; }
}
async function ensureLoaders(){
if(GLTFLoader&&FBXLoader) return true;
const a=await addon('loaders/GLTFLoader.js'); if(!a) return false;
const b=await addon('loaders/FBXLoader.js'); if(!b) return false;
GLTFLoader=a.GLTFLoader; FBXLoader=b.FBXLoader; return true;
}
async function loadFromBuffer(buf,name){
if(!await ensureLoaders()){ setStatus('Loader nicht verfügbar — Dummy bleibt aktiv.'); return; }
setStatus('Lade '+name+' …');
try{
if(/\.fbx$/i.test(name)) adoptModel(new FBXLoader().parse(buf,''),name);
else await new Promise((res,rej)=>new GLTFLoader().parse(buf,'',g=>{adoptModel(g.scene,name);res();},rej));
}catch(e){ console.error(e); setStatus('Datei konnte nicht gelesen werden.'); }
}
async function loadFromURL(url){
let buf;
try{
const r=await fetch(url); if(!r.ok) return false;
buf=await r.arrayBuffer(); if(buf.byteLength<64) return false;
}catch(e){ return false; }
await loadFromBuffer(buf,url.split('/').pop());
return true;
}
async function autoLoad(){
for(const f of ['body.glb','body.gltf','body.fbx','model.glb','model.fbx'])
if(await loadFromURL('./'+f)) return;
}
// ═════════════════════════════════════════════════════════════════════════════
// 6 · STRICHE — Seeds (Emission) und Interactors (Kraftfelder)
// ═════════════════════════════════════════════════════════════════════════════
/** Knoten: {world:V3, nrm:V3|null} — nrm gesetzt = auf der Haut gezeichnet */
const strokes=[], interactors=[];
const strokeGroup=new THREE.Group(); scene.add(strokeGroup);
const strokeMirror=new THREE.Group(); strokeMirror.scale.x=-1; scene.add(strokeMirror);
const ixGroup=new THREE.Group(); scene.add(ixGroup);
const ixMirror=new THREE.Group(); ixMirror.scale.x=-1; scene.add(ixMirror);
const IX_MODES=[['attract','Anziehen'],['repel','Abstoßen'],['curl','Umwirbeln'],['flow','Mitziehen']];
const IX_COLOR={attract:0x2a6bd6,repel:0xd63a2a,curl:0x1e9e6a,flow:0xd98a17};
const isSeedMode=()=>P.drawMode==='seed';
const activeList=()=>isSeedMode()?strokes:interactors;
const raycaster=new THREE.Raycaster();
const ndc=new THREE.Vector2();
const dragPlane=new THREE.Plane();
const _cd=new V3(),_pr=new V3(),_nm=new THREE.Matrix3();
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 hitBody(){ const h=raycaster.intersectObject(body,false); return h.length?h[0]:null; }
function planeAt(anchor){
camera.getWorldDirection(_cd);
dragPlane.setFromNormalAndCoplanarPoint(_cd.clone().negate(),anchor);
const p=new V3();
return raycaster.ray.intersectPlane(dragPlane,p)?p:null;
}
const _bv=new V3(),_ba=new V3(),_bb=new V3();
function nearestVertex(hit){
const pos=body.geometry.attributes.position;
let best=hit.face.a,bd=Infinity;
for(const i of [hit.face.a,hit.face.b,hit.face.c]){
_bv.fromBufferAttribute(pos,i).applyMatrix4(skinMatrix(i,_mA));
body.localToWorld(_bv);
const d=_bv.distanceToSquared(hit.point);
if(d<bd){bd=d;best=i;}
}
return best;
}
function surfaceNode(hit){
_nm.getNormalMatrix(body.matrixWorld);
const nrm=hit.face? hit.face.normal.clone().applyMatrix3(_nm).normalize() : new V3(0,0,1);
const n={world:hit.point.clone(),nrm,vi:-1,bind:null,bindNrm:null};
if(hit.face&&body){ // an die Haut binden
const vi=nearestVertex(hit);
const m=skinMatrix(vi,_mA).clone().invert();
n.vi=vi;
n.bind=body.worldToLocal(hit.point.clone()).applyMatrix4(m);
n.bindNrm=hit.face.normal.clone().transformDirection(m).normalize();
}
return n;
}
const freeNode=w=>({world:w,nrm:null,vi:-1,bind:null,bindNrm:null});
const nodeWorld=n=>n.world.clone();
function refreshNode(n){ // Bindung → aktuelle Weltposition
if(n.vi<0||!n.bind||!body) return;
const m=skinMatrix(n.vi,_mA);
_ba.copy(n.bind).applyMatrix4(m);
_bb.copy(n.bind).addScaledVector(n.bindNrm,0.02).applyMatrix4(m);
body.localToWorld(_ba); body.localToWorld(_bb);
n.world.copy(_ba);
if(n.nrm) n.nrm.copy(_bb).sub(_ba).normalize();
}
function refreshBindings(){
if(!body) return;
for(const list of [strokes,interactors]){
for(const s of list){
let moved=false;
for(const n of s.nodes) if(n.vi>=0){ refreshNode(n); moved=true; }
if(moved) updateStrokeLine(s);
}
}
rebuildNodePoints();
if(selNode) gizmo.position.copy(selNode.node.world);
}
function strokeNormals(s){
const out=s.nodes.map(n=>n.nrm?n.nrm.clone():null);
let carry=out.find(v=>v)||new V3(0,0,1);
for(let i=0;i<out.length;i++){ if(out[i]) carry=out[i]; else out[i]=carry.clone(); }
return out;
}
function strokeCurve(s){
if(s.nodes.length<2) return null;
return new THREE.CatmullRomCurve3(s.nodes.map(nodeWorld),false,'centripetal',0.5);
}
function newStroke(kind,firstNode){
const s={kind,nodes:[firstNode],line:null,lineM:null,tube:null,tubeM:null};
if(kind==='ix') Object.assign(s,{mode:P.ixMode,radius:P.ixRadius,strength:P.ixStrength,
falloff:P.ixFalloff,on:true});
(kind==='seed'?strokes:interactors).push(s);
return s;
}
const groupsOf=s=>s.kind==='ix'?[ixGroup,ixMirror]:[strokeGroup,strokeMirror];
function updateStrokeLine(s){
const [G,GM]=groupsOf(s);
for(const k of ['line','lineM','tube','tubeM']) if(s[k]){
(k.endsWith('M')?GM:G).remove(s[k]); s[k].geometry.dispose(); s[k]=null;
}
const c=strokeCurve(s); if(!c) return;
const col=s.kind==='ix'?(s.on?IX_COLOR[s.mode]:0x999999):0x111111;
const g=new THREE.BufferGeometry().setFromPoints(c.getPoints(Math.max(24,s.nodes.length*14)));
s.line=new THREE.Line(g,new THREE.LineBasicMaterial({color:col}));
s.lineM=new THREE.Line(g,new THREE.LineBasicMaterial({color:col,transparent:true,opacity:0.4}));
G.add(s.line); GM.add(s.lineM);
if(s.kind==='ix'&&P.ixShowField&&s===selIx&&s.radius>0.001){
const tg=new THREE.TubeGeometry(c,Math.max(8,s.nodes.length*6),s.radius,10,false);
const tm=new THREE.MeshBasicMaterial({color:col,transparent:true,opacity:0.09,
side:THREE.DoubleSide,depthWrite:false});
s.tube=new THREE.Mesh(tg,tm);
s.tubeM=new THREE.Mesh(tg,tm);
G.add(s.tube); GM.add(s.tubeM);
}
}
function removeStroke(s){
const list=s.kind==='ix'?interactors:strokes;
const i=list.indexOf(s); if(i<0) return;
list.splice(i,1);
const [G,GM]=groupsOf(s);
for(const k of ['line','tube']) if(s[k]) G.remove(s[k]);
for(const k of ['lineM','tubeM']) if(s[k]) GM.remove(s[k]);
if(selIx===s) selIx=null;
}
function refreshLines(list){ for(const s of list) updateStrokeLine(s); }
function clearStrokes(){
strokes.length=0; interactors.length=0;
strokeGroup.clear(); strokeMirror.clear(); ixGroup.clear(); ixMirror.clear();
selNode=null; selIx=null; rebuildNodePoints();
}
const nodeGeo=new THREE.BufferGeometry();
nodeGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const nodePts=new THREE.Points(nodeGeo,new THREE.PointsMaterial({...pointOpts,size:11}));
nodePts.renderOrder=10; nodePts.frustumCulled=false; scene.add(nodePts);
const nodePtsM=new THREE.Points(nodeGeo,new THREE.PointsMaterial({...pointOpts,size:11,opacity:0.4}));
nodePtsM.renderOrder=10; nodePtsM.frustumCulled=false; nodePtsM.scale.x=-1; scene.add(nodePtsM);
const hoverGeo=new THREE.BufferGeometry();
hoverGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const hoverPt=new THREE.Points(hoverGeo,new THREE.PointsMaterial({...pointOpts,size:17,color:0xff2200}));
hoverPt.renderOrder=11; hoverPt.frustumCulled=false; hoverPt.visible=false; scene.add(hoverPt);
let nodeCache=[], selNode=null, selIx=null;
function rebuildNodePoints(){
nodeCache=[];
for(const s of activeList()) s.nodes.forEach((n,i)=>nodeCache.push({s,i,node:n,world:nodeWorld(n)}));
const arr=new Float32Array(Math.max(1,nodeCache.length)*3);
nodeCache.forEach((e,k)=>{arr[k*3]=e.world.x;arr[k*3+1]=e.world.y;arr[k*3+2]=e.world.z;});
nodeGeo.setAttribute('position',new THREE.Float32BufferAttribute(arr,3));
nodeGeo.setDrawRange(0,nodeCache.length);
if(selNode&&!nodeCache.some(e=>e.node===selNode.node)) selNode=null;
if(selNode) gizmo.position.copy(selNode.node.world);
}
// ── Achsen-Gizmo
const AXES=[{d:new V3(1,0,0),c:0xcc4444},{d:new V3(0,1,0),c:0x449944},{d:new V3(0,0,1),c:0x4466cc}];
const gizmo=new THREE.Group(); gizmo.visible=false; gizmo.renderOrder=20; scene.add(gizmo);
for(const a of AXES){
const g=new THREE.BufferGeometry().setFromPoints([new V3(),a.d.clone()]);
const line=new THREE.Line(g,new THREE.LineBasicMaterial({color:a.c,depthTest:false}));
line.renderOrder=20; gizmo.add(line);
const tip=new THREE.Mesh(new THREE.ConeGeometry(0.055,0.18,10),
new THREE.MeshBasicMaterial({color:a.c,depthTest:false}));
tip.position.copy(a.d).multiplyScalar(1.02);
tip.quaternion.setFromUnitVectors(new V3(0,1,0),a.d);
tip.renderOrder=20; gizmo.add(tip);
}
function segDist2(px,py,ax,ay,bx,by){
const dx=bx-ax,dy=by-ay,L=dx*dx+dy*dy;
const t=L<1e-9?0:THREE.MathUtils.clamp(((px-ax)*dx+(py-ay)*dy)/L,0,1);
return (px-ax-dx*t)**2+(py-ay-dy*t)**2;
}
function pickAxis(ev){
if(!gizmo.visible) return null;
const o=gizmo.position, sc=gizmo.scale.x;
const [ox,oy]=toScreen(o);
let best=null,bd=100;
for(const a of AXES){
const [ex,ey]=toScreen(o.clone().addScaledVector(a.d,sc));
const d=segDist2(ev.clientX,ev.clientY,ox,oy,ex,ey);
if(d<bd){bd=d;best=a.d;}
}
return best;
}
function rayAxisPoint(origin,dir){ // Punkt auf der Achse, der dem Mausstrahl am nächsten liegt
const ro=raycaster.ray.origin, rd=raycaster.ray.direction;
const w0=new V3().subVectors(origin,ro);
const a=dir.dot(dir),b=dir.dot(rd),c=rd.dot(rd),d=dir.dot(w0),e=rd.dot(w0);
const den=a*c-b*b;
if(Math.abs(den)<1e-9) return null;
return origin.clone().addScaledVector(dir,(b*e-c*d)/den);
}
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=PICK_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 pickCurve(ev){
let best=null,bd=(PICK_PX+3)**2;
for(const s of activeList()){
const c=strokeCurve(s); if(!c) continue;
for(let i=0;i<=120;i++){
const t=i/120,[x,y]=toScreen(c.getPoint(t));
const d=(x-ev.clientX)**2+(y-ev.clientY)**2;
if(d<bd){bd=d;best={s,t};}
}
}
return best;
}
function decimate(raw,count){
if(raw.length<=count) return raw.slice();
const acc=[0];
for(let i=1;i<raw.length;i++) acc.push(acc[i-1]+raw[i].world.distanceTo(raw[i-1].world));
const total=acc[acc.length-1]||1, out=[], used=new Set();
for(let k=0;k<count;k++){
const target=total*k/(count-1);
let bi=0,bd=Infinity;
for(let i=0;i<acc.length;i++){const d=Math.abs(acc[i]-target); if(d<bd){bd=d;bi=i;}}
while(used.has(bi)&&bi<raw.length-1) bi++;
used.add(bi); out.push(raw[bi]);
}
return out;
}
// ═════════════════════════════════════════════════════════════════════════════
// 7 · POINTER
// ═════════════════════════════════════════════════════════════════════════════
let drawing=null,dragNode=null,dragBone=null,dragAxis=null,orbiting=null,hovered=null;
const MIN_SPACING=0.016, ROT_SPEED=0.012;
el.addEventListener('pointerdown',ev=>{
el.setPointerCapture(ev.pointerId);
setNDC(ev);
if(ev.button===0){
if(P.poseMode){
const h=pickFrom(boneHandles,ev,14);
if(h){
selectedBone=h.bone; rebuildBoneHandles();
const set=new Set(body.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((ev.shiftKey?'Twist: ':'Bone: ')+h.bone.name.replace('mixamorig:',''));
return;
}
}else{
const ax=pickAxis(ev);
if(ax&&selNode){
const p=rayAxisPoint(selNode.node.world,ax);
if(p) dragAxis={node:selNode.node,axis:ax,offset:selNode.node.world.clone().sub(p)};
return;
}
const n=pickFrom(nodeCache,ev);
if(n){
selNode=n; gizmo.position.copy(n.node.world);
if(!isSeedMode()) selectIx(n.s);
dragNode=n; return;
}
if(!isSeedMode()){
const c=pickCurve(ev);
if(c){ selNode=null; selectIx(c.s); return; }
}
const hit=hitBody();
if(hit){ selNode=null; drawing=newStroke(isSeedMode()?'seed':'ix',surfaceNode(hit)); return; }
if(!isSeedMode()){ // Interactors dürfen frei im Raum starten
const p=planeAt(cam.target);
if(p){ selNode=null; drawing=newStroke('ix',freeNode(p)); selectIx(drawing); return; }
}
selNode=null;
}
}
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(dragAxis){
const p=rayAxisPoint(dragAxis.node.world,dragAxis.axis);
if(p){
dragAxis.node.world.copy(p.add(dragAxis.offset));
gizmo.position.copy(dragAxis.node.world);
updateStrokeLine(selNode.s); rebuildNodePoints();
}
return;
}
if(drawing){
const lastW=drawing.nodes[drawing.nodes.length-1].world;
const hit=(drawing.kind==='seed')?hitBody():null;
let node=null;
if(hit) node=surfaceNode(hit);
else{ const p=planeAt(lastW); if(p) node=freeNode(p); }
if(node&&node.world.distanceTo(lastW)>=MIN_SPACING){
drawing.nodes.push(node); updateStrokeLine(drawing);
}
return;
}
if(dragNode){
const n=dragNode.s.nodes[dragNode.i];
const hit=(ev.altKey||dragNode.s.kind==='ix')?null:hitBody();
if(hit) Object.assign(n,surfaceNode(hit));
else{ const p=planeAt(n.world); if(p) Object.assign(n,freeNode(p)); }
updateStrokeLine(dragNode.s); rebuildNodePoints();
dragNode.world=n.world.clone();
gizmo.position.copy(n.world);
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=THREE.MathUtils.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;
}
hovered=P.poseMode?pickFrom(boneHandles,ev,14):pickFrom(nodeCache,ev);
if(hovered&&!P.poseMode){
const a=hoverGeo.attributes.position.array;
a[0]=hovered.world.x; a[1]=hovered.world.y; a[2]=hovered.world.z;
hoverGeo.attributes.position.needsUpdate=true;
}
el.style.cursor=hovered?'grab':'default';
});
addEventListener('pointerup',()=>{
let changed=!!(dragNode||dragAxis);
if(drawing){
if(drawing.nodes.length<2) removeStroke(drawing);
else{
drawing.nodes=decimate(drawing.nodes,Math.max(3,P.nodeCount|0));
updateStrokeLine(drawing); changed=true;
if(drawing.kind==='ix') selectIx(drawing);
}
rebuildNodePoints();
}
drawing=null; dragNode=null; dragAxis=null; dragBone=null; orbiting=null;
if(changed) pushHistory();
setStatus();
});
el.addEventListener('dblclick',ev=>{
if(P.poseMode) return;
setNDC(ev);
const n=pickFrom(nodeCache,ev);
if(n){
if(n.s.nodes.length<=2) removeStroke(n.s);
else{ n.s.nodes.splice(n.i,1); updateStrokeLine(n.s); }
rebuildNodePoints(); pushHistory(); setStatus(); return;
}
const c=pickCurve(ev);
if(c){
const hit=hitBody();
const node=hit?surfaceNode(hit):freeNode(strokeCurve(c.s).getPoint(c.t));
const idx=Math.max(1,Math.min(c.s.nodes.length-1,Math.round(c.t*(c.s.nodes.length-1))));
c.s.nodes.splice(idx,0,node);
updateStrokeLine(c.s); rebuildNodePoints(); pushHistory(); setStatus();
}
});
el.addEventListener('wheel',ev=>{
ev.preventDefault();
cam.dist=THREE.MathUtils.clamp(cam.dist*Math.exp(ev.deltaY*0.0011),0.4,20);
applyCam();
},{passive:false});
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);
});
// ═════════════════════════════════════════════════════════════════════════════
// 7b · KRAFTFELD DER INTERACTORS
// ═════════════════════════════════════════════════════════════════════════════
/* Jede Interactor-Kurve wird in Stützpunkte zerlegt; pro Boid wirkt je Interactor
nur der nächstgelegene Stützpunkt, damit die Stärke nicht von der Abtastdichte
abhängt. Bei aktiver Spiegelung kommen gespiegelte Stützpunkte dazu; das
Umwirbeln bekommt dabei ein Vorzeichen, weil ein Kreuzprodukt unter Spiegelung
die Orientierung wechselt — nur so bleibt das Feld symmetrisch. */
const FIELD={samples:[],grid:new Map(),cell:0.25,active:[]};
function buildField(){
FIELD.samples=[]; FIELD.grid.clear();
FIELD.active=interactors.filter(it=>it.on&&it.nodes.length>1&&it.strength!==0&&it.radius>0.001);
let maxR=0.05;
FIELD.active.forEach((it,k)=>{
const c=strokeCurve(it); if(!c) return;
maxR=Math.max(maxR,it.radius);
const n=THREE.MathUtils.clamp(Math.ceil(c.getLength()/0.02),2,400);
for(let i=0;i<=n;i++){
const t=i/n, p=c.getPoint(t), tan=c.getTangent(t).normalize();
FIELD.samples.push({p,tan,k,sgn:1});
if(P.mirror) FIELD.samples.push({p:new V3(-p.x,p.y,p.z),tan:new V3(-tan.x,tan.y,tan.z),k,sgn:-1});
}
});
FIELD.cell=maxR;
for(const s of FIELD.samples){
const key=cellKey(Math.floor(s.p.x/FIELD.cell),Math.floor(s.p.y/FIELD.cell),Math.floor(s.p.z/FIELD.cell));
let a=FIELD.grid.get(key); if(!a){a=[];FIELD.grid.set(key,a);} a.push(s);
}
}
const _best=[],_rel=new V3(),_ixf=new V3();
function interactorForce(p,out){
out.set(0,0,0);
if(!FIELD.samples.length) return out;
const cs=FIELD.cell;
for(let i=0;i<FIELD.active.length;i++) _best[i]=null;
const gx=Math.floor(p.x/cs),gy=Math.floor(p.y/cs),gz=Math.floor(p.z/cs);
for(let ox=-1;ox<=1;ox++)for(let oy=-1;oy<=1;oy++)for(let oz=-1;oz<=1;oz++){
const cell=FIELD.grid.get(cellKey(gx+ox,gy+oy,gz+oz)); if(!cell) continue;
for(const s of cell){
const d2=p.distanceToSquared(s.p);
const b=_best[s.k];
if(!b||d2<b.d2) _best[s.k]={d2,s};
}
}
for(let i=0;i<FIELD.active.length;i++){
const b=_best[i]; if(!b) continue;
const it=FIELD.active[i];
const d=Math.sqrt(b.d2);
if(d>it.radius) continue;
const w=Math.pow(1-d/it.radius,it.falloff)*it.strength;
_rel.subVectors(p,b.s.p);
if(it.mode==='flow'){ out.addScaledVector(b.s.tan,w); continue; }
if(it.mode==='curl'){
_ixf.crossVectors(b.s.tan,_rel);
if(_ixf.lengthSq()>1e-12) out.addScaledVector(_ixf.normalize(),w*b.s.sgn);
continue;
}
if(_rel.lengthSq()<1e-12) continue;
_rel.normalize();
out.addScaledVector(_rel, it.mode==='repel'? w : -w);
}
return out;
}
// ═════════════════════════════════════════════════════════════════════════════
// 8 · BOIDS
// ═════════════════════════════════════════════════════════════════════════════
let boids=[],alive=0,running=false,simTime=0;
const boidGeo=new THREE.BufferGeometry();
boidGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const boidPts=new THREE.Points(boidGeo,new THREE.PointsMaterial({color:0x111111,size:2.4,sizeAttenuation:false}));
boidPts.frustumCulled=false; scene.add(boidPts);
const boidPtsM=new THREE.Points(boidGeo,new THREE.PointsMaterial({color:0x111111,size:2.4,sizeAttenuation:false,transparent:true,opacity:0.5}));
boidPtsM.frustumCulled=false; boidPtsM.scale.x=-1; scene.add(boidPtsM);
let trailGeo=null,trailLines=null,trailLinesM=null,trailWrite=0;
const trailGroup=new THREE.Group(); scene.add(trailGroup);
function clearSim(){
boids=[]; alive=0; running=false; simTime=0;
trailGroup.clear();
if(trailGeo){trailGeo.dispose(); trailGeo=null;}
trailLines=trailLinesM=null; trailWrite=0;
boidGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
boidGeo.setDrawRange(0,0);
}
function seedBoids(){
clearSim();
if(!strokes.length){ setStatus('Erst eine Seed-Linie auf den Körper zeichnen.'); return; }
buildField();
for(const s of strokes){
const curve=strokeCurve(s); if(!curve) continue;
const nrms=strokeNormals(s), n=P.boidsPerCurve;
for(let i=0;i<n;i++){
if(boids.length>=MAX_BOIDS) break;
const t=THREE.MathUtils.clamp((n===1?0:i/(n-1))*P.spawnSpread,0,1);
const pos=curve.getPoint(t), tan=curve.getTangent(t);
const f=t*(nrms.length-1),i0=Math.floor(f),i1=Math.min(nrms.length-1,i0+1);
const nrm=nrms[i0].clone().lerp(nrms[i1],f-i0).normalize();
pos.addScaledVector(nrm,0.004);
pos.x+=(Math.random()-0.5)*P.spawnJitter;
pos.y+=(Math.random()-0.5)*P.spawnJitter;
pos.z+=(Math.random()-0.5)*P.spawnJitter;
const vel=nrm.clone().multiplyScalar(P.maxSpeed*0.5)
.addScaledVector(tan,P.maxSpeed*0.25*(Math.random()<0.5?-1:1));
boids.push({p:pos,v:vel,n:nrm,s:pos.clone(),age:0,
c:new V3(Math.random()*2-1,Math.random()*2-1,Math.random()*2-1).normalize(),
life:P.lifetime*(1+(Math.random()*2-1)*P.lifeJitter),acc:0,trail:[pos.clone()],alive:true});
}
}
alive=boids.length;
if(!alive){ setStatus('Keine gültige Kurve.'); return; }
const cap=boids.length*P.trailMax*2*3;
trailGeo=new THREE.BufferGeometry();
trailGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(cap),3));
trailGeo.setDrawRange(0,0);
trailLines=new THREE.LineSegments(trailGeo,new THREE.LineBasicMaterial({color:0x999999,transparent:true,opacity:0.55}));
trailLinesM=new THREE.LineSegments(trailGeo,new THREE.LineBasicMaterial({color:0x999999,transparent:true,opacity:0.3}));
trailLinesM.scale.x=-1; trailLines.frustumCulled=trailLinesM.frustumCulled=false;
trailGroup.add(trailLines,trailLinesM);
boidGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(boids.length*3),3));
running=true; setStatus();
}
const grid=new Map();
const cellKey=(x,y,z)=>(x+512)+(y+512)*1024+(z+512)*1048576;
const _f=new V3(),_sep=new V3(),_ali=new V3(),_coh=new V3(),_d=new V3(),_sp=new V3(),_ixOut=new V3();
function step(dt){
const cs=Math.max(P.neighborRadius,0.01), r2=P.neighborRadius**2;
grid.clear();
for(const b of boids){
if(!b.alive) continue;
const k=cellKey(Math.floor(b.p.x/cs),Math.floor(b.p.y/cs),Math.floor(b.p.z/cs));
let a=grid.get(k); if(!a){a=[];grid.set(k,a);} a.push(b);
}
simTime+=dt;
for(const b of boids){
if(!b.alive) continue;
_sep.set(0,0,0);_ali.set(0,0,0);_coh.set(0,0,0);
let cnt=0;
const gx=Math.floor(b.p.x/cs),gy=Math.floor(b.p.y/cs),gz=Math.floor(b.p.z/cs);
for(let ox=-1;ox<=1;ox++)for(let oy=-1;oy<=1;oy++)for(let oz=-1;oz<=1;oz++){
const cell=grid.get(cellKey(gx+ox,gy+oy,gz+oz)); if(!cell) continue;
for(const o of cell){
if(o===b) continue;
_d.subVectors(b.p,o.p);
const d2=_d.lengthSq();
if(d2>r2||d2<1e-10) continue;
_sep.addScaledVector(_d,1/d2); _ali.add(o.v); _coh.add(o.p); cnt++;
}
}
_f.set(0,0,0);
if(cnt){
_f.addScaledVector(_sep.normalize(),P.separation);
_f.addScaledVector(_ali.divideScalar(cnt).sub(b.v),P.alignment);
_f.addScaledVector(_coh.divideScalar(cnt).sub(b.p),P.cohesion);
}
noiseForce(b.p,simTime,_d); _f.addScaledVector(_d,P.noiseStrength);
if(FIELD.samples.length){ interactorForce(b.p,_ixOut); _f.add(_ixOut); }
_f.addScaledVector(b.n,P.normalPush);
_d.subVectors(b.p,b.s);
if(_d.lengthSq()>1e-8) _f.addScaledVector(_d.normalize(),P.escape);
if(P.spiral) _f.addScaledVector(_sp.crossVectors(b.c,b.v),P.spiral);
_f.y-=P.gravity;
b.v.addScaledVector(_f,dt).multiplyScalar(Math.exp(-P.drag*dt));
const sp=b.v.length(); if(sp>P.maxSpeed) b.v.multiplyScalar(P.maxSpeed/sp);
b.p.addScaledVector(b.v,dt);
b.acc+=dt;
if(b.acc>=1/P.trailRate&&b.trail.length<P.trailMax){
b.acc=0;
const prev=b.trail[b.trail.length-1];
b.trail.push(b.p.clone());
if(trailGeo){
const arr=trailGeo.attributes.position.array;
if(trailWrite+6<=arr.length){
arr[trailWrite++]=prev.x;arr[trailWrite++]=prev.y;arr[trailWrite++]=prev.z;
arr[trailWrite++]=b.p.x; arr[trailWrite++]=b.p.y; arr[trailWrite++]=b.p.z;
trailGeo.setDrawRange(0,trailWrite/3);
trailGeo.attributes.position.needsUpdate=true;
}
}
}
b.age+=dt;
if(b.age>=b.life){b.alive=false; alive--;}
}
const pa=boidGeo.attributes.position.array; let w=0;
for(const b of boids){ if(!b.alive) continue; pa[w++]=b.p.x;pa[w++]=b.p.y;pa[w++]=b.p.z; }
boidGeo.setDrawRange(0,w/3);
boidGeo.attributes.position.needsUpdate=true;
if(alive<=0&&running){ running=false; if(P.autoBuild) buildMesh(); else setStatus('Flug beendet.'); }
}
// ═════════════════════════════════════════════════════════════════════════════
// 9 · MESH-EBENEN
// ═════════════════════════════════════════════════════════════════════════════
let outfitLayers=[];
function smoothPts(pts,iter){
for(let k=0;k<iter;k++){
if(pts.length<3) break;
const out=[pts[0]];
for(let i=1;i<pts.length-1;i++) out.push(pts[i-1].clone().add(pts[i]).add(pts[i+1]).multiplyScalar(1/3));
out.push(pts[pts.length-1]); pts=out;
}
return pts;
}
// ── Dickenprofil entlang der Flugbahn
let profLUT=null;
const catmull=(p0,p1,p2,p3,t)=>0.5*(2*p1+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t);
function buildProfLUT(){
const p=P.profile,n=p.length;
profLUT=new Float32Array(129);
for(let k=0;k<=128;k++){
const u=k/128*(n-1), i=Math.min(n-2,Math.floor(u)), f=u-i;
profLUT[k]=THREE.MathUtils.clamp(
catmull(p[Math.max(0,i-1)],p[i],p[i+1],p[Math.min(n-1,i+2)],f),0.02,1.6);
}
}
function profileAt(t){
if(!profLUT) buildProfLUT();
const k=THREE.MathUtils.clamp(t,0,1)*128, i=Math.floor(k);
return i>=128?profLUT[128]:profLUT[i]+(profLUT[i+1]-profLUT[i])*(k-i);
}
function buildMesh(){
const pos=[],idx=[],col=[];
const cA=new THREE.Color(P.colorA).convertSRGBToLinear();
const cB=new THREE.Color(P.colorB).convertSRGBToLinear();
const _c=new THREE.Color();
const push=(p,t)=>{
pos.push(p.x,p.y,p.z);
_c.copy(cA).lerp(cB,THREE.MathUtils.clamp(t,0,1));
col.push(_c.r,_c.g,_c.b);
};
const mode=P.meshMode;
const sides= mode==='tube'?Math.max(3,P.radialSeg|0) : mode==='quad'?4 : mode==='tri'?3 : 0;
const flat = mode==='quad'||mode==='tri';
const off0 = mode==='quad'?Math.PI/4 : mode==='tri'?-Math.PI/2 : 0;
for(const b of boids){
if(b.trail.length<P.minTrail) continue;
const pts=smoothPts(b.trail.map(p=>p.clone()),P.smooth|0);
if(pts.length<2) continue;
const F=frames(pts),N=pts.length,R=[],NN=[],BB=[];
for(let i=0;i<N;i++){
const t=N>1?i/(N-1):0;
R.push(P.width*profileAt(t));
const ang=P.twist*Math.PI*2*t; // Verdrehung um die Flugrichtung
NN.push(F.N[i].clone().applyAxisAngle(F.T[i],ang));
BB.push(F.B[i].clone().applyAxisAngle(F.T[i],ang));
}
if(mode==='ribbon'){
const base=pos.length/3;
for(let i=0;i<N;i++){
const t=N>1?i/(N-1):0;
push(pts[i].clone().addScaledVector(BB[i],R[i]),t);
push(pts[i].clone().addScaledVector(BB[i],-R[i]),t);
}
for(let i=0;i<N-1;i++){ const a=base+i*2; idx.push(a,a+2,a+1, a+1,a+2,a+3); }
continue;
}
const ring=[];
for(let i=0;i<N;i++){
const r=[];
for(let j=0;j<sides;j++){
const a=off0+j/sides*Math.PI*2;
r.push(pts[i].clone().addScaledVector(NN[i],Math.cos(a)*R[i])
.addScaledVector(BB[i],Math.sin(a)*R[i]));
}
ring.push(r);
}
if(flat){ // harte Kanten: eigener Streifen je Fläche
for(let j=0;j<sides;j++){
const base=pos.length/3, j2=(j+1)%sides;
for(let i=0;i<N;i++){
const t=N>1?i/(N-1):0;
push(ring[i][j],t); push(ring[i][j2],t);
}
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); }
}
}else{ // rund: geteilte Ringpunkte
const base=pos.length/3;
for(let i=0;i<N;i++){
const t=N>1?i/(N-1):0;
for(let j=0;j<sides;j++) push(ring[i][j],t);
}
for(let i=0;i<N-1;i++) for(let j=0;j<sides;j++){
const a=base+i*sides+j, b2=base+i*sides+(j+1)%sides;
idx.push(a,b2,b2+sides, a,b2+sides,a+sides);
}
}
for(const [ri,end] of [[0,false],[N-1,true]]){ // Kappen, damit der Körper geschlossen ist
const cb=pos.length/3, t=end?1:0;
push(pts[ri],t);
for(let j=0;j<sides;j++) push(ring[ri][j],t);
for(let j=0;j<sides;j++){
const a=cb+1+j, b2=cb+1+(j+1)%sides;
if(end) idx.push(cb,a,b2); else idx.push(cb,b2,a);
}
}
}
if(!pos.length){ setStatus('Keine Spuren lang genug.'); return; }
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
geo.setAttribute('color',new THREE.Float32BufferAttribute(col,3));
geo.setIndex((pos.length/3)>65535?new THREE.Uint32BufferAttribute(idx,1):new THREE.Uint16BufferAttribute(idx,1));
geo.computeVertexNormals(); geo.computeBoundingSphere();
const mat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.45,metalness:0,side:THREE.DoubleSide});
const mesh=new THREE.Mesh(geo,mat);
const meshM=new THREE.Mesh(geo,mat); meshM.scale.x=-1; meshM.visible=P.mirror;
scene.add(mesh,meshM);
const layer={geo,mat,mesh,meshM,tris:idx.length/3};
outfitLayers.push(layer); allLayers.add(layer);
pushHistory();
setStatus('Mesh '+outfitLayers.length+' gebaut.');
}
const allLayers=new Set();
function detach(l){ scene.remove(l.mesh); scene.remove(l.meshM); }
function attach(l){ if(!l.mesh.parent) scene.add(l.mesh,l.meshM); }
function clearLastMesh(){ const l=outfitLayers.pop(); if(l){ detach(l); pushHistory(); } setStatus(); }
function clearMesh(){ for(const l of outfitLayers) detach(l); outfitLayers=[]; setStatus(); }
const totalTris=()=>outfitLayers.reduce((s,l)=>s+l.tris,0)*(P.mirror?2:1);
// ── Undo / Redo
let history=[], histIdx=-1;
function snapshot(){
const nodesOf=s=>s.nodes.map(n=>({w:n.world.toArray(), n:n.nrm?n.nrm.toArray():null,
vi:n.vi??-1, b:n.bind?n.bind.toArray():null, bn:n.bindNrm?n.bindNrm.toArray():null}));
return {
strokes: strokes.map(nodesOf),
ix: interactors.map(s=>({nodes:nodesOf(s),mode:s.mode,radius:s.radius,
strength:s.strength,falloff:s.falloff,on:s.on})),
layers: outfitLayers.slice()
};
}
function gcLayers(){
const keep=new Set(outfitLayers);
for(const h of history) for(const l of h.layers) keep.add(l);
for(const l of allLayers) if(!keep.has(l)){ detach(l); l.geo.dispose(); l.mat.dispose(); allLayers.delete(l); }
}
function pushHistory(){
history=history.slice(0,histIdx+1);
history.push(snapshot());
if(history.length>60) history.shift();
histIdx=history.length-1;
gcLayers(); updateBar();
}
function resetHistory(){ history=[]; histIdx=-1; pushHistory(); }
function restore(snap){
strokes.length=0; interactors.length=0;
strokeGroup.clear(); strokeMirror.clear(); ixGroup.clear(); ixMirror.clear();
selNode=null; selIx=null;
const mkNodes=sd=>sd.map(d=>({world:new V3().fromArray(d.w),
nrm:d.n?new V3().fromArray(d.n):null, vi:d.vi??-1,
bind:d.b?new V3().fromArray(d.b):null, bindNrm:d.bn?new V3().fromArray(d.bn):null}));
for(const sd of snap.strokes){
const s={kind:'seed',nodes:mkNodes(sd),line:null,lineM:null,tube:null,tubeM:null};
strokes.push(s); updateStrokeLine(s);
}
for(const d of (snap.ix||[])){
const s={kind:'ix',nodes:mkNodes(d.nodes),line:null,lineM:null,tube:null,tubeM:null,
mode:d.mode,radius:d.radius,strength:d.strength,falloff:d.falloff,on:d.on};
interactors.push(s); updateStrokeLine(s);
}
rebuildNodePoints(); syncIxPanel();
for(const l of outfitLayers) detach(l);
outfitLayers=snap.layers.slice();
for(const l of outfitLayers) attach(l);
updateBar(); setStatus();
}
function undo(){ if(histIdx<=0) return; histIdx--; restore(history[histIdx]); }
function redo(){ if(histIdx>=history.length-1) return; histIdx++; restore(history[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(); }
});
// ═════════════════════════════════════════════════════════════════════════════
// 10 · EXPORT
// ═════════════════════════════════════════════════════════════════════════════
function mirrorGeometry(geo){
const g=geo.clone();
const p=g.attributes.position;
for(let i=0;i<p.count;i++) p.setX(i,-p.getX(i));
if(g.index){ const a=g.index.array; for(let i=0;i<a.length;i+=3){const t=a[i+1];a[i+1]=a[i+2];a[i+2]=t;} }
g.computeVertexNormals();
return g;
}
function bakeBodyGeometry(){ // posierter Körper als statisches Mesh (Weltkoordinaten)
const g=body.geometry, p=g.attributes.position;
const arr=new Float32Array(p.count*3), v=new V3();
for(let i=0;i<p.count;i++){
if(body.getVertexPosition) body.getVertexPosition(i,v); else v.fromBufferAttribute(p,i);
body.localToWorld(v);
arr[i*3]=v.x; arr[i*3+1]=v.y; arr[i*3+2]=v.z;
}
const out=new THREE.BufferGeometry();
out.setAttribute('position',new THREE.Float32BufferAttribute(arr,3));
if(g.attributes.uv) out.setAttribute('uv',g.attributes.uv.clone());
if(g.index) out.setIndex(g.index.clone());
out.computeVertexNormals();
return out;
}
function exportPieces(){ // [{name, geo}]
const out=[];
if(P.exportBody&&body) out.push({name:'body',geo:bakeBodyGeometry()});
outfitLayers.forEach((l,i)=>{
out.push({name:'mesh'+(i+1)+'_A',geo:l.geo});
if(P.mirror) out.push({name:'mesh'+(i+1)+'_B',geo:mirrorGeometry(l.geo)});
});
return out;
}
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 exportOBJ(pieces){
let out='# BOID OUTFIT WEAVER\n', voff=1;
for(const {name,geo} of pieces){
const p=geo.attributes.position,n=geo.attributes.normal,c=geo.attributes.color,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)}`;
if(c) out+=` ${c.getX(i).toFixed(4)} ${c.getY(i).toFixed(4)} ${c.getZ(i).toFixed(4)}`;
out+='\n';
}
for(let i=0;i<n.count;i++) out+=`vn ${n.getX(i).toFixed(4)} ${n.getY(i).toFixed(4)} ${n.getZ(i).toFixed(4)}\n`;
if(ix) for(let i=0;i<ix.count;i+=3){
const a=ix.getX(i)+voff,b=ix.getX(i+1)+voff,c2=ix.getX(i+2)+voff;
out+=`f ${a}//${a} ${b}//${b} ${c2}//${c2}\n`;
}
voff+=p.count;
}
download(new Blob([out],{type:'text/plain'}),'boid_outfit.obj');
}
async function exportGLTF(pieces,binary){
if(!GLTFExporter){
const m=await addon('exporters/GLTFExporter.js');
if(!m){ setStatus('GLTFExporter nicht verfügbar — OBJ nutzen.'); return; }
GLTFExporter=m.GLTFExporter;
}
const root=new THREE.Group(); root.name='boid_outfit';
for(const {name,geo} of pieces){
const hasCol=!!geo.attributes.color;
const m=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({
color: hasCol?0xffffff:(name==='body'?0xdddddd:0xcccccc),
vertexColors: hasCol, roughness:0.6, metalness:0, side:THREE.DoubleSide}));
m.name=name; root.add(m);
}
new GLTFExporter().parse(root,res=>{
if(binary) download(new Blob([res],{type:'model/gltf-binary'}),'boid_outfit.glb');
else download(new Blob([JSON.stringify(res,null,1)],{type:'model/gltf+json'}),'boid_outfit.gltf');
setStatus('Export fertig.');
},err=>{ console.error(err); setStatus('Export fehlgeschlagen.'); },{binary});
}
function doExport(){
if(!outfitLayers.length&&!P.exportBody){ setStatus('Nichts zu exportieren.'); return; }
const pieces=exportPieces();
if(P.exportFormat==='obj') exportOBJ(pieces);
else exportGLTF(pieces,P.exportFormat==='glb');
}
// ═════════════════════════════════════════════════════════════════════════════
// 11 · UI
// ═════════════════════════════════════════════════════════════════════════════
const ui=document.getElementById('ui');
function group(title,open,parent){
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); (parent||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);
}
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);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);
}
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 profileEditor(par){
const W=258,H=74,PAD=7;
const cv=document.createElement('canvas');
cv.width=W*2; cv.height=H*2; cv.style.width=W+'px'; cv.style.height=H+'px';
cv.style.cssText+=';display:block;border:1px solid var(--line);margin:0 0 8px;cursor:ns-resize;touch-action:none';
par.appendChild(cv);
const g=cv.getContext('2d'); g.scale(2,2);
const px=i=>PAD+i/(P.profile.length-1)*(W-2*PAD);
const py=v=>H-PAD-v/1.6*(H-2*PAD);
function draw(){
g.clearRect(0,0,W,H);
g.strokeStyle='#eee'; g.lineWidth=1;
for(const v of [0.5,1.0]){ g.beginPath(); g.moveTo(PAD,py(v)); g.lineTo(W-PAD,py(v)); g.stroke(); }
g.strokeStyle='#111'; g.lineWidth=1.4; g.beginPath();
for(let k=0;k<=80;k++){
const t=k/80, x=PAD+t*(W-2*PAD), y=py(profileAt(t));
k?g.lineTo(x,y):g.moveTo(x,y);
}
g.stroke();
g.fillStyle='#fff'; g.strokeStyle='#111'; g.lineWidth=1.4;
P.profile.forEach((v,i)=>{
g.beginPath(); g.arc(px(i),py(v),3.4,0,Math.PI*2); g.fill(); g.stroke();
});
}
let grab=-1;
const local=e=>{const r=cv.getBoundingClientRect(); return [e.clientX-r.left,e.clientY-r.top];};
function set(i,y){
P.profile[i]=THREE.MathUtils.clamp((H-PAD-y)/(H-2*PAD)*1.6,0.02,1.6);
buildProfLUT(); draw();
}
cv.addEventListener('pointerdown',e=>{
cv.setPointerCapture(e.pointerId);
const [x,y]=local(e);
let bd=1e9;
P.profile.forEach((v,i)=>{const d=Math.abs(px(i)-x); if(d<bd){bd=d;grab=i;}});
set(grab,y);
});
cv.addEventListener('pointermove',e=>{ if(grab<0) return; set(grab,local(e)[1]); });
addEventListener('pointerup',()=>{grab=-1;});
buildProfLUT(); draw();
return {draw,reset(v){ P.profile=P.profile.map((_,i)=>v(i,P.profile.length)); buildProfLUT(); draw(); }};
}
function dropStroke(){
const list=activeList();
if(!list.length) return;
removeStroke(list[list.length-1]);
rebuildNodePoints(); syncIxPanel(); pushHistory(); setStatus();
}
function dropAll(kind){
const list=kind==='ix'?interactors:strokes;
while(list.length) removeStroke(list[list.length-1]);
rebuildNodePoints(); syncIxPanel(); pushHistory(); setStatus();
}
function clearAll(){ clearStrokes(); clearSim(); clearMesh(); syncIxPanel(); pushHistory(); setStatus(); }
// ── Tabs
const tabsEl=document.getElementById('tabs');
const TABS=[];
function tab(label,mode){
const b=document.createElement('button'); b.textContent=label;
const pane=document.createElement('div'); pane.style.display='none';
ui.appendChild(pane);
const t={b,pane,mode};
b.addEventListener('click',()=>selectTab(t));
tabsEl.appendChild(b); TABS.push(t); return pane;
}
function selectTab(t){
for(const o of TABS){ o.b.classList.toggle('on',o===t); o.pane.style.display=o===t?'block':'none'; }
if(t.mode&&t.mode!==P.drawMode){
P.drawMode=t.mode; selNode=null; gizmo.visible=false;
rebuildNodePoints(); refreshLines(interactors); syncIxPanel();
}
setStatus();
}
const paneSeeds=tab('Seeds','seed');
const paneIx=tab('Kräfte','interactor');
const paneMesh=tab('Mesh',null);
const paneExport=tab('Export',null);
const paneBody=tab('Body',null);
const paneView=tab('View',null);
// ── SEEDS
{
const g=group('Linien',true,paneSeeds);
const st=section(null,g);
slider(st,'Knoten je Linie','nodeCount',3,24,1,0);
const r=document.createElement('div');r.className='btn2';st.appendChild(r);
button(r,'Letzte löschen',dropStroke,true);
button(r,'Alle löschen',()=>{ dropAll('seed'); },true);
const e=section('Emission',group('Emission',true,paneSeeds));
slider(e,'Boids pro Kurve','boidsPerCurve',10,600,10,0);
slider(e,'Verteilung entlang Kurve','spawnSpread',0,1,0.01,2);
slider(e,'Startstreuung','spawnJitter',0,0.05,0.001,3);
slider(e,'Lebensdauer (s)','lifetime',0.05,8,0.05,2);
slider(e,'Streuung Lebensdauer','lifeJitter',0,1,0.01,2);
slider(e,'Zeitraffer','timeScale',0.25,5,0.05,2);
const m=section(null,group('Bewegung',false,paneSeeds));
slider(m,'Maximaltempo','maxSpeed',0.05,2,0.01,2);
slider(m,'Abstoßung Oberfläche','normalPush',0,3,0.01,2);
slider(m,'Flucht vom Ursprung','escape',0,3,0.01,2);
slider(m,'Dämpfung','drag',0,4,0.01,2);
slider(m,'Drall / Spirale','spiral',0,6,0.05,2);
slider(m,'Schwerkraft','gravity',-8,8,0.05,2);
const f=section(null,group('Schwarm',false,paneSeeds));
slider(f,'Nachbarradius','neighborRadius',0.01,0.4,0.005,3);
slider(f,'Separation','separation',0,4,0.01,2);
slider(f,'Ausrichtung','alignment',0,3,0.01,2);
slider(f,'Kohäsion','cohesion',0,3,0.01,2);
const n=section(null,group('Perlin-Noise',false,paneSeeds));
slider(n,'Feldskalierung','noiseScale',0.2,10,0.05,2);
slider(n,'Stärke','noiseStrength',0,5,0.01,2);
slider(n,'Feldbewegung','noiseSpeed',0,1,0.01,2);
check(n,'Curl-Noise','curl');
const tr=section(null,group('Spur',false,paneSeeds));
slider(tr,'Abtastrate (Hz)','trailRate',4,60,1,0);
slider(tr,'Max. Punkte pro Spur','trailMax',6,120,1,0);
}
// ── KRÄFTE (Interactors)
let ixListEl=null, ixEditEl=null;
function selectIx(s){ selIx=s; refreshLines(interactors); syncIxPanel(); }
function syncIxPanel(){
if(!ixListEl) return;
ixListEl.textContent='';
if(!interactors.length){
const e=document.createElement('div'); e.className='empty';
e.textContent='Noch keine Kraftlinie gezeichnet.';
ixListEl.appendChild(e);
}
interactors.forEach((it,i)=>{
const row=document.createElement('div');
row.className='ixrow'+(it===selIx?' on':'');
const dot=document.createElement('span'); dot.className='dot';
dot.style.background=it.on?'#'+IX_COLOR[it.mode].toString(16).padStart(6,'0'):'#ccc';
const nm=document.createElement('span'); nm.className='nm';
nm.textContent=(i+1)+' · '+(IX_MODES.find(m=>m[0]===it.mode)||['','?'])[1];
const x=document.createElement('span'); x.className='x'; x.textContent='×';
x.addEventListener('click',ev=>{ ev.stopPropagation(); removeStroke(it);
rebuildNodePoints(); pushHistory(); syncIxPanel(); setStatus(); });
row.append(dot,nm,x);
row.addEventListener('click',()=>selectIx(it));
ixListEl.appendChild(row);
});
ixEditEl.style.display=selIx?'block':'none';
if(selIx) for(const f of ixFields) f.sync();
}
const ixFields=[];
function ixSlider(par,label,key,min,max,st,fmt){
const row=document.createElement('div');row.className='row';
const l=document.createElement('label');l.textContent=label;
const v=document.createElement('span');v.className='val';
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.addEventListener('input',()=>{
if(!selIx) return;
selIx[key]=parseFloat(i.value); P['ix'+key[0].toUpperCase()+key.slice(1)]=selIx[key];
v.textContent=selIx[key].toFixed(fmt);
updateStrokeLine(selIx);
});
i.addEventListener('change',()=>pushHistory());
par.appendChild(i);
ixFields.push({sync(){ if(!selIx) return; i.value=selIx[key]; v.textContent=(+selIx[key]).toFixed(fmt); }});
}
{
const g=group('Kraftlinien',true,paneIx);
const s=section(null,g);
ixListEl=document.createElement('div'); s.appendChild(ixListEl);
const r=document.createElement('div');r.className='btn2';s.appendChild(r);
button(r,'Letzte löschen',dropStroke,true);
button(r,'Alle löschen',()=>{ dropAll('ix'); },true);
check(s,'Reichweite anzeigen','ixShowField',()=>refreshLines(interactors));
ixEditEl=section(null,g); ixEditEl.style.display='none';
const sel=document.createElement('select');
for(const [v,t] of IX_MODES){const o=document.createElement('option');o.value=v;o.textContent=t;sel.appendChild(o);}
sel.addEventListener('change',()=>{
if(!selIx) return;
selIx.mode=sel.value; P.ixMode=sel.value;
updateStrokeLine(selIx); syncIxPanel(); pushHistory();
});
ixEditEl.appendChild(sel);
ixFields.push({sync(){ if(selIx) sel.value=selIx.mode; }});
ixSlider(ixEditEl,'Reichweite','radius',0.02,1.5,0.01,2);
ixSlider(ixEditEl,'Stärke','strength',0,8,0.05,2);
ixSlider(ixEditEl,'Abfall (Härte)','falloff',0.3,6,0.1,1);
const onBox=document.createElement('label'); onBox.className='check';
const onIn=document.createElement('input'); onIn.type='checkbox';
const onTx=document.createElement('span'); onTx.textContent='Aktiv';
onIn.addEventListener('change',()=>{
if(!selIx) return;
selIx.on=onIn.checked; updateStrokeLine(selIx); syncIxPanel(); pushHistory();
});
onBox.append(onIn,onTx); ixEditEl.appendChild(onBox);
ixFields.push({sync(){ if(selIx) onIn.checked=selIx.on; }});
}
// ── MESH
{
const g=group('Form',true,paneMesh);
const s=section(null,g);
choice(s,'meshMode',[['ribbon','Band'],['tube','Rohr rund'],['quad','Rohr 4-kantig'],['tri','Rohr 3-kantig']]);
slider(s,'Dicke','width',0.001,0.05,0.0005,4);
const prof=profileEditor(s);
const rp=document.createElement('div');rp.className='btn2';s.appendChild(rp);
button(rp,'Profil linear',()=>prof.reset((i,n)=>1-0.75*i/(n-1)),true);
button(rp,'Profil bauchig',()=>prof.reset((i,n)=>0.25+0.9*Math.sin(Math.PI*i/(n-1))),true);
slider(s,'Verdrehung (Umdrehungen)','twist',-3,3,0.05,2);
slider(s,'Kanten (Rohr rund)','radialSeg',3,12,1,0);
slider(s,'Glättung','smooth',0,6,1,0);
slider(s,'Min. Spurlänge','minTrail',2,40,1,0);
const grad=document.createElement('div');
grad.style.cssText='display:flex;gap:6px;align-items:center;margin:2px 0 8px';
const mkCol=(key,title)=>{
const c=document.createElement('input'); c.type='color'; c.value=P[key]; c.title=title;
c.style.cssText='flex:1;margin:0';
c.addEventListener('input',()=>{ P[key]=c.value; updateGradPreview(); });
return c;
};
const cA=mkCol('colorA','Start (Ursprung)'), cB=mkCol('colorB','Ende (Tod)');
const prev=document.createElement('div');
prev.style.cssText='flex:1.4;height:24px;border:1px solid var(--line)';
function updateGradPreview(){ prev.style.background=`linear-gradient(90deg,${P.colorA},${P.colorB})`; }
updateGradPreview();
grad.append(cA,prev,cB); s.appendChild(grad);
button(s,'Farben tauschen',()=>{
const t=P.colorA; P.colorA=P.colorB; P.colorB=t;
cA.value=P.colorA; cB.value=P.colorB; updateGradPreview();
},true);
const s2=section(null,group('Ebenen',true,paneMesh));
check(s2,'Spiegeln an X = 0','mirror',()=>setStatus());
check(s2,'Mesh nach dem Flug automatisch bauen','autoBuild');
const r=document.createElement('div');r.className='btn2';s2.appendChild(r);
button(r,'Letztes löschen',clearLastMesh,true);
button(r,'Alles leeren',clearAll,true);
}
// ── EXPORT
{
const s=section(null,group('Datei',true,paneExport));
choice(s,'exportFormat',[['glb','GLB (binär)'],['gltf','GLTF (JSON)'],['obj','OBJ']]);
check(s,'Körper mitexportieren','exportBody');
button(s,'Exportieren',doExport);
}
// ── BODY
{
const g=group('Modell',true,paneBody);
const s=section(null,g);
const inp=document.createElement('input');
inp.type='file'; inp.accept='.fbx,.glb,.gltf'; 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.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,'Zurück zum Dummy',()=>{const d=buildDummy(); setBody(d.group,d.mesh,'Dummy');},true);
check(s,'Originalmaterial des Modells','ownMaterial');
const p=section(null,group('Pose',true,paneBody));
check(p,'Pose-Modus','poseMode',()=>{ selectedBone=null; setStatus(P.poseMode?'Bone ziehen · Umschalt = Twist':''); });
button(p,'Pose zurücksetzen',()=>{ resetPose(); setStatus(); },true);
}
// ── VIEW
{
const s=section(null,group('Anzeige',true,paneView));
check(s,'Körper','showBody');
slider(s,'Deckkraft Körper','bodyOpacity',0.05,1,0.01,2);
check(s,'Seed-Linien','showStrokes');
check(s,'Kraftlinien','showIx');
check(s,'Knoten','showNodes');
check(s,'Boids','showBoids');
check(s,'Spuren','showTrails');
}
selectTab(TABS[0]);
syncIxPanel();
// ── Toolbar
const bUndo=document.getElementById('bUndo'), bRedo=document.getElementById('bRedo');
document.getElementById('bGrow').onclick=seedBoids;
document.getElementById('bMesh').onclick=()=>buildMesh();
document.getElementById('bDelMesh').onclick=clearLastMesh;
document.getElementById('bDelLine').onclick=dropStroke;
bUndo.onclick=undo; bRedo.onclick=redo;
function updateBar(){
bUndo.disabled=histIdx<=0;
bRedo.disabled=histIdx>=history.length-1;
}
function setStatus(msg){
const parts=[`MODUS <b>${isSeedMode()?'SEEDS':'KRÄFTE'}</b>`,
`SEEDS <b>${strokes.length}</b>`,`KRÄFTE <b>${interactors.length}</b>`,
`BOIDS <b>${alive}/${boids.length}</b>`];
if(outfitLayers.length) parts.push(`MESHES <b>${outfitLayers.length}</b>`,
`DREIECKE <b>${totalTris().toLocaleString('de-DE')}</b>`);
if(msg) parts.push(`<b>${msg}</b>`);
document.getElementById('status').innerHTML=parts.join(' · ');
}
// ═════════════════════════════════════════════════════════════════════════════
// 12 · LOOP
// ═════════════════════════════════════════════════════════════════════════════
const clock=new THREE.Clock(); let tickCount=0;
function tick(){
requestAnimationFrame(tick);
const dt=Math.min(clock.getDelta(),1/30)*P.timeScale;
if(running) step(dt);
if(bodyRoot) bodyRoot.visible=P.showBody;
if(bodyMat){ bodyMat.opacity=P.bodyOpacity; bodyMat.transparent=P.bodyOpacity<1; }
strokeGroup.visible=P.showStrokes;
strokeMirror.visible=P.showStrokes&&P.mirror;
ixGroup.visible=P.showIx;
ixMirror.visible=P.showIx&&P.mirror;
nodePts.visible=P.showNodes&&!P.poseMode;
nodePtsM.visible=P.showNodes&&P.mirror&&!P.poseMode;
hoverPt.visible=P.showNodes&&!P.poseMode&&!!hovered&&!drawing;
bonePts.visible=skelLines.visible=P.poseMode;
selPt.visible=P.poseMode&&!!selectedBone;
gizmo.visible=!P.poseMode&&!!selNode&&P.showNodes;
if(gizmo.visible) gizmo.scale.setScalar(camera.position.distanceTo(gizmo.position)*0.09);
boidPts.visible=P.showBoids; boidPtsM.visible=P.showBoids&&P.mirror;
trailGroup.visible=P.showTrails;
if(trailLinesM) trailLinesM.visible=P.mirror;
for(const l of outfitLayers) l.meshM.visible=P.mirror;
if(running&&(tickCount=(tickCount+1)%10)===0) setStatus();
renderer.render(scene,camera);
}
const dummy=buildDummy();
setBody(dummy.group,dummy.mesh,'Dummy');
resize(); setStatus(); tick();
autoLoad();
</script>
</body>
</html>