index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DELAUNAY OUTFIT WEAVER · v4.3</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}
#job{position:fixed;top:56px;left:calc((100% - var(--panel-w))/2);transform:translateX(-50%);display:none;z-index:30;
background:#fff;border:1px solid var(--ink);padding:12px 14px;min-width:300px;letter-spacing:.06em}
#job .track{height:1px;background:var(--line);margin:10px 0 8px} #jobBar{height:1px;background:var(--ink);width:0}
#jobA{color:var(--ink-2);min-height:14px;margin-bottom:8px}
#jobX{margin:0;padding:6px}
#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}
#hint{position:fixed;right:calc(var(--panel-w) + 16px);bottom:14px;color:#c4c4c4;letter-spacing:.04em;pointer-events:none;text-align:right}
#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)} #hint{display:none} }
</style>
</head>
<body>
<div id="view"></div>
<div id="drop">FBX / GLB / GLTF HIER ABLEGEN</div>
<div id="bar">
<button id="bScatter">Nodes streuen</button>
<button id="bMesh">Mesh bauen</button>
<button id="bDelMesh">Mesh löschen</button>
<button id="bDelNode">Node löschen</button>
<button id="bClearNodes">Alle Nodes 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="job"><div id="jobT"></div><div class="track"><div id="jobBar"></div></div><div id="jobA"></div><button id="jobX">Abbrechen</button></div>
<div id="hint">KLICK = NODE · ZIEHEN = VERSCHIEBEN · ALT = FREI · UMSCHALT = ABSTAND · DOPPELKLICK = LÖSCHEN</div>
<div id="panel"><div class="head"><h1>DELAUNAY OUTFIT WEAVER</h1></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,
placeOffset:0.025,
randomCount:40, offMin:0.01, offMax:0.10, replace:true,
maxEdge:0.35, weld:0.010, edgesFromCells:false,
meshMode:'bone', fill:'solid', shrink:0.10, frameWidth:0.22, frameThickness:0.004,
meshColor:'#111111',
boneRadius:0.012, boneNode:1.2, blendF:1.0, nodeCap:0.35, minRadius:0.004, scaleWithSub:true, boneVar:0.3, boneRes:0.008, boneIter:3,
buildSubdiv:1, subMode:'center', subJitter:0.15,
clipBody:true, clearance:0.006, clipRound:0.008, dropDepth:0.01,
exportFormat:'glb', exportBody:false, ownMaterial:false,
showBody:true, bodyOpacity:1.0, showNodes:true, showGhost:true,
showEdges:true, showFaces:true
};
const PICK_PX=11;
// ═════════════════════════════════════════════════════════════════════════════
// 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.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};
}
// ═════════════════════════════════════════════════════════════════════════════
// 3 · 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};
}
// ═════════════════════════════════════════════════════════════════════════════
// 4 · 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));
}
BC=null; markBodyDirty();
clearNodes(); 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);
}
// ── 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();
markBodyDirty(); poseMoved=true;
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=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 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){
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);
}
// ── Punkt-Handles (Ringe)
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'), ringSoft=ringTexture('#d8d8d8');
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;
}
// ═════════════════════════════════════════════════════════════════════════════
// 5 · NODES (Weltkoordinaten, statisch)
// ═════════════════════════════════════════════════════════════════════════════
/** Node: {world:V3, nrm:V3|null, off:number} */
const nodes=[];
let dirtyTri=true, dirtyFilter=true, dirtyPreview=true;
const touchTri=()=>{ dirtyTri=true; clearInterp(); };
const touchPreview=()=>{ dirtyPreview=true; };
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(){ if(!body) return null; 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;
}
function hitNormal(hit){
_nm.getNormalMatrix(body.matrixWorld);
return hit.face? hit.face.normal.clone().applyMatrix3(_nm).normalize() : new V3(0,0,1);
}
function nodeFromHit(hit,off){
const n=hitNormal(hit);
return {world:hit.point.clone().addScaledVector(n,off), nrm:n, off};
}
// ── Darstellung
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 ghostGeo=new THREE.BufferGeometry();
ghostGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const ghostPts=new THREE.Points(ghostGeo,new THREE.PointsMaterial({...pointOpts,map:ringSoft,size:9,opacity:0.55}));
ghostPts.renderOrder=9; ghostPts.frustumCulled=false; scene.add(ghostPts);
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;
function rebuildNodePoints(){
nodeCache=nodes.map((n,i)=>({i,node:n,world:n.world}));
const arr=new Float32Array(Math.max(1,nodes.length)*3);
const gar=new Float32Array(Math.max(1,nodes.length)*3);
nodes.forEach((n,k)=>{
arr[k*3]=n.world.x; arr[k*3+1]=n.world.y; arr[k*3+2]=n.world.z;
gar[k*3]=-n.world.x; gar[k*3+1]=n.world.y; gar[k*3+2]=n.world.z;
});
nodeGeo.setAttribute('position',new THREE.Float32BufferAttribute(arr,3));
nodeGeo.setDrawRange(0,nodes.length);
ghostGeo.setAttribute('position',new THREE.Float32BufferAttribute(gar,3));
ghostGeo.setDrawRange(0,nodes.length);
if(selNode&&!nodes.includes(selNode)) selNode=null;
if(selNode) gizmo.position.copy(selNode.world);
}
function clearNodes(){ nodes.length=0; selNode=null; rebuildNodePoints(); touchTri(); }
function removeNode(n){
const i=nodes.indexOf(n); if(i<0) return;
nodes.splice(i,1); if(selNode===n) selNode=null;
rebuildNodePoints(); touchTri();
}
// ── 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 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 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){
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 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;
}
// ── Zufällige Verteilung auf der Meshoberfläche (nur Quell-Hemisphere x ≥ 0)
function bodyTriangles(){
if(!body) return [];
bodyRoot.updateMatrixWorld(true);
const g=body.geometry, p=g.attributes.position, nAttr=g.attributes.normal, idx=g.index;
const skin=body.isSkinnedMesh&&body.getVertexPosition;
const pos=[], nrm=[], v=new V3();
_nm.getNormalMatrix(body.matrixWorld);
for(let i=0;i<p.count;i++){
if(skin) body.getVertexPosition(i,v); else v.fromBufferAttribute(p,i);
pos.push(body.localToWorld(v.clone()));
if(nAttr) nrm.push(new V3().fromBufferAttribute(nAttr,i).applyMatrix3(_nm).normalize());
}
const tris=[], cnt=idx?idx.count:p.count;
for(let i=0;i<cnt;i+=3){
const a=idx?idx.getX(i):i, b=idx?idx.getX(i+1):i+1, c=idx?idx.getX(i+2):i+2;
const t=[pos[a],pos[b],pos[c]];
let fn=new V3().subVectors(t[1],t[0]).cross(new V3().subVectors(t[2],t[0]));
const area=fn.length()*0.5;
if(area<1e-10) continue;
fn.normalize();
if(nAttr){
const avg=nrm[a].clone().add(nrm[b]).add(nrm[c]);
if(fn.dot(avg)<0) fn.negate();
}
tris.push({t,area,n:fn});
}
return tris;
}
function scatterNodes(count){
if(!body) return;
const B=getBC(), tris=[], acc=[]; let total=0;
for(let t=0;t<B.nt;t++){
const i0=B.idx[t*3]*3, i1=B.idx[t*3+1]*3, i2=B.idx[t*3+2]*3, p=B.pos;
if((p[i0]+p[i1]+p[i2])/3<=0.006) continue;
const e1x=p[i1]-p[i0], e1y=p[i1+1]-p[i0+1], e1z=p[i1+2]-p[i0+2], e2x=p[i2]-p[i0], e2y=p[i2+1]-p[i0+1], e2z=p[i2+2]-p[i0+2];
const ar=0.5*Math.hypot(e1y*e2z-e1z*e2y, e1z*e2x-e1x*e2z, e1x*e2y-e1y*e2x);
if(ar<1e-10) continue;
total+=ar; tris.push(t); acc.push(total);
}
if(!tris.length){ setStatus('Keine Oberfläche auf der Quell-Hemisphere.'); return; }
for(let k=0;k<count;k++){
const r=Math.random()*total; let lo=0, hi=acc.length-1;
while(lo<hi){ const mid=(lo+hi)>>1; if(acc[mid]<r) lo=mid+1; else hi=mid; }
let u=Math.random(), v=Math.random(); if(u+v>1){ u=1-u; v=1-v; }
const off=P.offMin+Math.random()*Math.max(0,P.offMax-P.offMin);
nodes.push(nodeFromRay({t:tris[lo],u,v},off));
}
rebuildNodePoints(); touchTri(); pushHistory();
setStatus(count+' Nodes gestreut.');
}
// ═════════════════════════════════════════════════════════════════════════════
// 6 · POINTER
// ═════════════════════════════════════════════════════════════════════════════
let dragNode=null,dragBone=null,dragAxis=null,orbiting=null,hovered=null;
const 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.world,ax);
if(p) dragAxis={node:selNode,axis:ax,offset:selNode.world.clone().sub(p)};
return;
}
const picked=pickFrom(nodeCache,ev);
if(picked){
selNode=picked.node; gizmo.position.copy(selNode.world);
dragNode=startDrag(selNode,ev); return;
}
const hit=rayBody(raycaster.ray);
if(hit){
const n=nodeFromRay(hit,P.placeOffset);
nodes.push(n); selNode=n; rebuildNodePoints(); touchTri();
gizmo.position.copy(n.world);
dragNode=startDrag(n,ev); return;
}
selNode=null;
}
}
orbiting={x:ev.clientX,y:ev.clientY,mode:(ev.button===1||ev.shiftKey)?'pan':'orbit'};
});
function startDrag(n,ev){
return {n,mode:ev.shiftKey?'offset':(ev.altKey?'free':'surface'),y:ev.clientY};
}
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));
dragAxis.node.a=null;
gizmo.position.copy(dragAxis.node.world);
rebuildNodePoints(); touchTri();
}
return;
}
if(dragNode){
const n=dragNode.n;
if(dragNode.mode==='offset'){
const dy=dragNode.y-ev.clientY; dragNode.y=ev.clientY;
if(!n.a) bindNode(n);
if(n.a){
n.a.l[2]=THREE.MathUtils.clamp(n.a.l[2]+dy*0.0012*cam.dist,-0.4,0.8); n.off=n.a.l[2];
anchorWorld(n.a,n.world);
}
}else{
const hit=(dragNode.mode==='free'||ev.altKey)?null:rayBody(raycaster.ray);
if(hit){
const keep=n.a?n.a.l[2]:n.off;
n.a={t:hit.t,u:hit.u,v:hit.v,l:[0,0,keep]}; n.off=keep;
anchorWorld(n.a,n.world);
}else{
const p=planeAt(n.world);
if(p){ n.world.copy(p); n.a=null; }
}
}
gizmo.position.copy(n.world);
rebuildNodePoints(); touchTri();
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',()=>{
const moved=dragNode?dragNode.n:(dragAxis?dragAxis.node:null);
if(moved&&!moved.a){ bindNode(moved); }
if(dragBone) poseMoved=true;
const changed=!!(dragNode||dragAxis);
dragNode=null; dragAxis=null; dragBone=null; orbiting=null;
if(changed) pushHistory();
setStatus();
});
el.addEventListener('dblclick',ev=>{
if(P.poseMode) return;
setNDC(ev);
const picked=pickFrom(nodeCache,ev);
if(picked){ removeNode(picked.node); 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);
});
// ═════════════════════════════════════════════════════════════════════════════
// 7 · DELAUNAY 3D (Bowyer–Watson)
// ═════════════════════════════════════════════════════════════════════════════
function circumsphere(a,b,c,d){
const ba=new V3().subVectors(b,a), ca=new V3().subVectors(c,a), da=new V3().subVectors(d,a);
const cd=new V3().crossVectors(ca,da);
const den=ba.dot(cd);
if(Math.abs(den)<1e-14) return null;
const m=new V3()
.addScaledVector(cd,ba.lengthSq())
.addScaledVector(new V3().crossVectors(da,ba),ca.lengthSq())
.addScaledVector(new V3().crossVectors(ba,ca),da.lengthSq())
.divideScalar(2*den);
const r2=m.lengthSq();
return {c:m.add(a),r2};
}
function vol6(a,b,c,d){
return new V3().subVectors(b,a).cross(new V3().subVectors(c,a)).dot(new V3().subVectors(d,a));
}
function delaunay3D(pts){
const n=pts.length;
if(n<4) return [];
const bb=new THREE.Box3(); for(const p of pts) bb.expandByPoint(p);
const ctr=bb.getCenter(new V3());
const R=Math.max(bb.getSize(new V3()).length()*0.5,1e-3), s=R*25;
const jp=pts.map((p,i)=>new V3(p.x+(hash3(i,1.3,7.1)-0.5)*4e-5, p.y+(hash3(i,5.9,2.2)-0.5)*4e-5, p.z+(hash3(i,8.4,3.7)-0.5)*4e-5));
const all=jp.concat([new V3(1,1,1),new V3(1,-1,-1),new V3(-1,1,-1),new V3(-1,-1,1)]
.map(v=>v.multiplyScalar(s).add(ctr)));
const mk=(i,j,k,l)=>{
const cs=circumsphere(all[i],all[j],all[k],all[l]);
return cs?{v:[i,j,k,l],c:cs.c,r2:cs.r2}:null;
};
let tets=[mk(n,n+1,n+2,n+3)].filter(Boolean);
for(let pi=0;pi<n;pi++){
const p=all[pi], bad=[], keep=[];
for(const t of tets) (p.distanceToSquared(t.c)<t.r2-1e-12?bad:keep).push(t);
if(!bad.length){
let bi=-1,bd=Infinity;
tets.forEach((t,i)=>{const d=p.distanceToSquared(t.c)-t.r2; if(d<bd){bd=d;bi=i;}});
if(bi<0) continue;
bad.push(tets[bi]); keep.splice(keep.indexOf(tets[bi]),1);
}
const fc=new Map();
for(const t of bad){
const v=t.v;
for(const f of [[v[0],v[1],v[2]],[v[0],v[1],v[3]],[v[0],v[2],v[3]],[v[1],v[2],v[3]]]){
const k=f.slice().sort((a,b)=>a-b).join(',');
const e=fc.get(k); if(e) e.cnt++; else fc.set(k,{f,cnt:1});
}
}
tets=keep;
for(const e of fc.values()){
if(e.cnt!==1) continue;
const t=mk(e.f[0],e.f[1],e.f[2],pi);
if(t) tets.push(t);
}
}
return tets.filter(t=>t.v.every(i=>i<n));
}
async function delaunay3DAsync(pts,job){
const n=pts.length;
if(n<4) return [];
const bb=new THREE.Box3(); for(const p of pts) bb.expandByPoint(p);
const ctr=bb.getCenter(new V3());
const R=Math.max(bb.getSize(new V3()).length()*0.5,1e-3), s=R*25;
// winzige deterministische Störung: verhindert kosphärische Fälle (Spiegelung, Schichten)
const jp=pts.map((p,i)=>new V3(p.x+(hash3(i,1.3,7.1)-0.5)*4e-5, p.y+(hash3(i,5.9,2.2)-0.5)*4e-5, p.z+(hash3(i,8.4,3.7)-0.5)*4e-5));
const all=jp.concat([new V3(1,1,1),new V3(1,-1,-1),new V3(-1,1,-1),new V3(-1,-1,1)]
.map(v=>v.multiplyScalar(s).add(ctr)));
const mk=(i,j,k,l)=>{
const cs=circumsphere(all[i],all[j],all[k],all[l]);
return cs?{v:[i,j,k,l],c:cs.c,r2:cs.r2}:null;
};
let tets=[mk(n,n+1,n+2,n+3)].filter(Boolean);
for(let pi=0;pi<n;pi++){
if(job&&(pi&7)===0) await job.tick(pi/n);
const p=all[pi], bad=[], keep=[];
for(const t of tets) (p.distanceToSquared(t.c)<t.r2-1e-12?bad:keep).push(t);
if(!bad.length){
let bi=-1,bd=Infinity;
tets.forEach((t,i)=>{const d=p.distanceToSquared(t.c)-t.r2; if(d<bd){bd=d;bi=i;}});
if(bi<0) continue;
bad.push(tets[bi]); keep.splice(keep.indexOf(tets[bi]),1);
}
const fc=new Map();
for(const t of bad){
const v=t.v;
for(const f of [[v[0],v[1],v[2]],[v[0],v[1],v[3]],[v[0],v[2],v[3]],[v[1],v[2],v[3]]]){
const k=f.slice().sort((a,b)=>a-b).join(',');
const e=fc.get(k); if(e) e.cnt++; else fc.set(k,{f,cnt:1});
}
}
tets=keep;
for(const e of fc.values()){
if(e.cnt!==1) continue;
const t=mk(e.f[0],e.f[1],e.f[2],pi);
if(t) tets.push(t);
}
}
return tets.filter(t=>t.v.every(i=>i<n));
}
// ── Punktmenge: Quell-Nodes + Spiegelung, Verschmelzung nahe der Achse
let PTS=[], MIRROR=[], SRC=[], TETS=[], KEEP=[], EDGES=[];
const mirrorV=p=>new V3(-p.x,p.y,p.z);
function buildPointSet(){
const eps=Math.max(P.weld,1e-4), e2=eps*eps;
PTS=[]; SRC=[];
const add=(p,src)=>{
for(let i=0;i<PTS.length;i++) if(PTS[i].distanceToSquared(p)<e2) return i;
PTS.push(p.clone()); SRC.push(src); return PTS.length-1;
};
for(const n of nodes) add(n.world,true);
const half=PTS.length;
for(let i=0;i<half;i++){
if(Math.abs(PTS[i].x)<eps*0.5){ PTS[i].x=0; continue; }
add(mirrorV(PTS[i]),false);
}
MIRROR=PTS.map(p=>{
const m=mirrorV(p);
for(let i=0;i<PTS.length;i++) if(PTS[i].distanceToSquared(m)<e2) return i;
return -1;
});
}
const eKey=(a,b)=>a<b?a+','+b:b+','+a;
const tKey=v=>v.slice().sort((a,b)=>a-b).join(',');
const TET_EDGES=[[0,1],[0,2],[0,3],[1,2],[1,3],[2,3]];
const FACES=[[0,1,2,3],[0,1,3,2],[0,2,3,1],[1,2,3,0]]; // Fläche + gegenüberliegender Punkt
const MAXPTS=3000;
function refinePoints(){
const eps=Math.max(P.weld,1e-4), R2=P.maxEdge*P.maxEdge;
const cells=[];
for(const t of TETS){
const v=t.v; let ok=true, sum=0;
for(const [a,b] of TET_EDGES){
const d2=PTS[v[a]].distanceToSquared(PTS[v[b]]);
if(d2>R2){ ok=false; break; }
sum+=Math.sqrt(d2);
}
if(!ok) continue;
const L=sum/6;
if(Math.abs(vol6(PTS[v[0]],PTS[v[1]],PTS[v[2]],PTS[v[3]])/6)<=2e-4*L*L*L) continue;
cells.push({v,L});
}
const cand=[];
const jit=(p,L)=>{
const j=P.subJitter*L, ax=Math.round(Math.abs(p.x)*400), ay=Math.round(p.y*400), az=Math.round(p.z*400);
const sx=p.x<0?-1:1;
return new V3(p.x+sx*(hash3(ax,ay,az)*2-1)*j, p.y+(hash3(ax+1.7,ay+3.1,az+5.3)*2-1)*j,
p.z+(hash3(ax+7.7,ay+2.3,az+9.1)*2-1)*j);
};
for(const {v,L} of cells){
if(P.subMode!=='edges') cand.push({p:jit(centroidOf(v),L),L});
if(P.subMode!=='center') for(const [a,b] of TET_EDGES)
cand.push({p:jit(PTS[v[a]].clone().add(PTS[v[b]]).multiplyScalar(0.5),L),L});
}
let added=0;
for(const {p,L} of cand){
if(PTS.length>=MAXPTS) break;
const md2=Math.max((L*0.3)**2,eps*eps);
const list=[p];
if(Math.abs(p.x)<eps*0.5) p.x=0; else list.push(mirrorV(p));
for(const q of list){
let near=false;
for(let i=0;i<PTS.length;i++) if(PTS[i].distanceToSquared(q)<md2){ near=true; break; }
if(near) continue;
PTS.push(q.clone()); SRC.push(q.x>=0); added++;
}
}
const e2=eps*eps;
MIRROR=PTS.map(p=>{
const m=mirrorV(p);
for(let i=0;i<PTS.length;i++) if(PTS[i].distanceToSquared(m)<e2) return i;
return -1;
});
return added;
}
function computeTriangulation(){
buildPointSet();
TETS=delaunay3D(PTS);
dirtyFilter=true;
}
function centroidOf(v){
const c=new V3();
for(const i of v) c.add(PTS[i]);
return c.divideScalar(v.length);
}
function pointInTet(p,v){
const a=PTS[v[0]],b=PTS[v[1]],c=PTS[v[2]],d=PTS[v[3]];
const s=Math.sign(vol6(a,b,c,d));
if(!s) return false;
const t=1e-12;
return Math.sign(vol6(p,b,c,d))===s && Math.abs(vol6(p,b,c,d))>t
&& Math.sign(vol6(a,p,c,d))===s && Math.abs(vol6(a,p,c,d))>t
&& Math.sign(vol6(a,b,p,d))===s && Math.abs(vol6(a,b,p,d))>t
&& Math.sign(vol6(a,b,c,p))===s && Math.abs(vol6(a,b,c,p))>t;
}
function computeFilter(){
const R=P.maxEdge, R2=R*R;
// Zelle behalten: alle Kanten kurz genug UND kein Splitter (fast volumenlos)
const fits=v=>{
let maxE=0;
for(const [a,b] of TET_EDGES){
const d2=PTS[v[a]].distanceToSquared(PTS[v[b]]);
if(d2>R2) return false;
if(d2>maxE) maxE=d2;
}
const e=Math.sqrt(maxE);
return Math.abs(vol6(PTS[v[0]],PTS[v[1]],PTS[v[2]],PTS[v[3]])/6) > 2e-4*e*e*e;
};
const map=new Map();
for(const t of TETS) if(fits(t.v)) map.set(tKey(t.v),t.v.slice());
// Spiegelzellen nur ergänzen, wenn sie nicht mit einer vorhandenen Zelle überlappen
const base=[...map.values()];
for(const v of base){
if(v.some(i=>MIRROR[i]<0)) continue;
const mv=v.map(i=>MIRROR[i]), k=tKey(mv);
if(map.has(k)) continue;
const c=centroidOf(mv);
let clash=false;
for(const w of map.values()) if(pointInTet(c,w)){ clash=true; break; }
if(!clash) map.set(k,mv);
}
KEEP=[...map.values()];
const es=new Map();
const src=P.edgesFromCells?KEEP:TETS.map(t=>t.v);
for(const v of src) for(const [a,b] of TET_EDGES){
const i=v[a],j=v[b];
if(PTS[i].distanceToSquared(PTS[j])>R2) continue;
es.set(eKey(i,j),[i,j]);
}
if(!TETS.length&&PTS.length>1){
for(let i=0;i<PTS.length;i++) for(let j=i+1;j<PTS.length;j++)
if(PTS[i].distanceToSquared(PTS[j])<=R2) es.set(eKey(i,j),[i,j]);
}
for(const [i,j] of [...es.values()]){
if(MIRROR[i]<0||MIRROR[j]<0) continue;
const k=eKey(MIRROR[i],MIRROR[j]);
if(!es.has(k)) es.set(k,[MIRROR[i],MIRROR[j]]);
}
EDGES=[...es.values()];
updateEdgeLines();
dirtyPreview=true;
}
// ── Kantenvorschau: Quellseite dunkel, Spiegelseite hell
const edgeGeoA=new THREE.BufferGeometry(), edgeGeoB=new THREE.BufferGeometry();
edgeGeoA.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
edgeGeoB.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const edgeA=new THREE.LineSegments(edgeGeoA,new THREE.LineBasicMaterial({color:0x111111}));
const edgeB=new THREE.LineSegments(edgeGeoB,new THREE.LineBasicMaterial({color:0x111111,transparent:true,opacity:0.22}));
edgeA.frustumCulled=edgeB.frustumCulled=false; scene.add(edgeA,edgeB);
function updateEdgeLines(){
const A=[],B=[];
for(const [i,j] of EDGES){
const t=(SRC[i]||SRC[j])?A:B, a=PTS[i], b=PTS[j];
t.push(a.x,a.y,a.z,b.x,b.y,b.z);
}
edgeGeoA.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(A.length?A:[0,0,0]),3));
edgeGeoA.setDrawRange(0,A.length/3);
edgeGeoB.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(B.length?B:[0,0,0]),3));
edgeGeoB.setDrawRange(0,B.length/3);
}
// ═════════════════════════════════════════════════════════════════════════════
// 8 · FLÄCHEN → GEOMETRIE
// ═════════════════════════════════════════════════════════════════════════════
/** Randflächen des Zellverbunds bzw. alle Flächen der Einzelzellen, außen orientiert */
function orientedFaces(){
const out=[];
const emit=(a,b,c,ref,mir)=>{
const n=new V3().subVectors(b,a).cross(new V3().subVectors(c,a));
if(n.dot(new V3().subVectors(a,ref))<0){ const t=b; b=c; c=t; }
out.push({a,b,c,mir});
};
if(P.meshMode==='cells'){
const k=1-P.shrink;
for(const v of KEEP){
const c=centroidOf(v);
const q=v.map(i=>c.clone().addScaledVector(new V3().subVectors(PTS[i],c),k));
const mir=v.every(i=>!SRC[i]);
for(const [a,b,cc,opp] of FACES) emit(q[a],q[b],q[cc],q[opp],mir);
}
}else{
const fm=new Map();
for(const v of KEEP) for(const [a,b,c,opp] of FACES){
const f=[v[a],v[b],v[c]], key=tKey(f);
const e=fm.get(key);
if(e) e.cnt++; else fm.set(key,{f,opp:v[opp],cnt:1});
}
for(const e of fm.values()){
if(e.cnt%2===0) continue; // innen liegend
emit(PTS[e.f[0]].clone(),PTS[e.f[1]].clone(),PTS[e.f[2]].clone(),PTS[e.opp],
e.f.every(i=>!SRC[i]));
}
}
return out;
}
function push3(pos,cols,p,col){
pos.push(p.x,p.y,p.z);
if(cols) cols.push(col.r,col.g,col.b);
}
function quad(pos,cols,p0,p1,p2,p3,col){
push3(pos,cols,p0,col); push3(pos,cols,p1,col); push3(pos,cols,p2,col);
push3(pos,cols,p0,col); push3(pos,cols,p2,col); push3(pos,cols,p3,col);
}
function emitFrame(f,pos,cols,col){
const {a,b,c}=f;
const g=a.clone().add(b).add(c).divideScalar(3);
const w=THREE.MathUtils.clamp(P.frameWidth,0.02,0.48);
const A=a.clone().lerp(g,w), B=b.clone().lerp(g,w), C=c.clone().lerp(g,w);
const n=new V3().subVectors(b,a).cross(new V3().subVectors(c,a)).normalize();
const h=P.frameThickness*0.5;
if(h<1e-6){ // flacher Rahmen ohne Dicke
quad(pos,cols,a,b,B,A,col); quad(pos,cols,b,c,C,B,col); quad(pos,cols,c,a,A,C,col);
return;
}
const up=n.clone().multiplyScalar(h), dn=up.clone().negate();
const o=[a,b,c].map(p=>p.clone().add(up)), i=[A,B,C].map(p=>p.clone().add(up));
const o2=[a,b,c].map(p=>p.clone().add(dn)), i2=[A,B,C].map(p=>p.clone().add(dn));
for(let k=0;k<3;k++){
const k2=(k+1)%3;
quad(pos,cols,o[k],o[k2],i[k2],i[k],col); // Oberseite
quad(pos,cols,i2[k],i2[k2],o2[k2],o2[k],col); // Unterseite
quad(pos,cols,o2[k],o2[k2],o[k2],o[k],col); // Außenwand
quad(pos,cols,i[k],i[k2],i2[k2],i2[k],col); // Fensterlaibung
}
}
function buildOutfitGeometry(withColors,coarse){
if(P.meshMode==='bone') return buildBoneGeometry(withColors,coarse);
const faces=orientedFaces();
if(!faces.length) return null;
const pos=[], cols=withColors?[]:null;
const cSrc=new THREE.Color(P.meshColor);
const cMir=cSrc.clone().lerp(new THREE.Color(0xffffff),0.72);
for(const f of faces){
const col=f.mir?cMir:cSrc;
if(P.fill==='frame') emitFrame(f,pos,cols,col);
else { push3(pos,cols,f.a,col); push3(pos,cols,f.b,col); push3(pos,cols,f.c,col); }
}
if(!pos.length) return null;
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
if(cols) geo.setAttribute('color',new THREE.Float32BufferAttribute(cols,3));
geo.computeVertexNormals(); geo.computeBoundingSphere();
return geo;
}
// ── Gemeinsame Werkzeuge für Distanzfeld-Meshes
function hash3(x,y,z){ const q=Math.sin(x*127.1+y*311.7+z*74.7)*43758.5453; return q-Math.floor(q); }
const isSmoothMode=()=>P.meshMode==='bone';
function makeGrid(bb,pad0,h0,maxCells=4.5e6){
let h=h0, g;
for(;;){
const pad=pad0+2*h;
const ax=Math.max(Math.abs(bb.min.x),Math.abs(bb.max.x))+pad;
const ox=-Math.ceil(ax/h)*h, oy=bb.min.y-pad, oz=bb.min.z-pad;
const nx=Math.round(-2*ox/h)+1;
const ny=Math.ceil((bb.max.y+pad-oy)/h)+1;
const nz=Math.ceil((bb.max.z+pad-oz)/h)+1;
g={h,nx,ny,nz,ox,oy,oz,NXY:nx*ny};
if(nx*ny*nz<=maxCells) return g;
h*=1.15;
}
}
function smoothMin(f,d,k){
const df=Math.abs(f-d);
if(df<k){ const q=(k-df)/k; return Math.min(f,d)-q*q*k*0.25; }
return d<f?d:f;
}
/** Surface Nets + Taubin-Glättung aus einem Distanzfeld (negativ = innen) */
function surfaceFromField(F,g,withColors){
const {h,nx,ny,nz,ox,oy,oz,NXY}=g;
const cx=nx-1, cy=ny-1, cz=nz-1;
const cell=new Int32Array(cx*cy*cz).fill(-1);
const verts=[];
const CORN=[[0,0,0],[1,0,0],[0,1,0],[1,1,0],[0,0,1],[1,0,1],[0,1,1],[1,1,1]];
const CE=[[0,1],[2,3],[4,5],[6,7],[0,2],[1,3],[4,6],[5,7],[0,4],[1,5],[2,6],[3,7]];
const cv=new Float32Array(8);
for(let z=0;z<cz;z++) for(let y=0;y<cy;y++) for(let x=0;x<cx;x++){
const base=x+nx*y+NXY*z;
let mask=0;
for(let c=0;c<8;c++){
const o=CORN[c], v=F[base+o[0]+nx*o[1]+NXY*o[2]];
cv[c]=v; if(v<0) mask|=1<<c;
}
if(mask===0||mask===255) continue;
let sx=0,sy=0,sz=0,n=0;
for(const [ea,eb] of CE){
if(((mask>>ea)&1)===((mask>>eb)&1)) continue;
const t=cv[ea]/(cv[ea]-cv[eb]), A=CORN[ea], B=CORN[eb];
sx+=A[0]+(B[0]-A[0])*t; sy+=A[1]+(B[1]-A[1])*t; sz+=A[2]+(B[2]-A[2])*t; n++;
}
cell[x+cx*(y+cy*z)]=verts.length/3;
verts.push(ox+(x+sx/n)*h, oy+(y+sy/n)*h, oz+(z+sz/n)*h);
}
const tri=[];
const ci=(x,y,z)=>cell[x+cx*(y+cy*z)];
const emit=(q,axis,sgn)=>{
if(q[0]<0||q[1]<0||q[2]<0||q[3]<0) return;
const P0=q[0]*3,P1=q[1]*3,P2=q[2]*3,P3=q[3]*3;
const ux=verts[P2]-verts[P0], uy=verts[P2+1]-verts[P0+1], uz=verts[P2+2]-verts[P0+2];
const vx=verts[P3]-verts[P1], vy=verts[P3+1]-verts[P1+1], vz=verts[P3+2]-verts[P1+2];
const nrm=[uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx];
if(nrm[axis]*sgn<0) q=[q[0],q[3],q[2],q[1]];
tri.push(q[0],q[1],q[2], q[0],q[2],q[3]);
};
for(let z=0;z<nz;z++) for(let y=0;y<ny;y++) for(let x=0;x<nx;x++){
const i0=x+nx*y+NXY*z, f0=F[i0];
if(x<nx-1&&y>=1&&y<=ny-2&&z>=1&&z<=nz-2){
const f1=F[i0+1];
if((f0<0)!==(f1<0)) emit([ci(x,y-1,z-1),ci(x,y,z-1),ci(x,y,z),ci(x,y-1,z)],0,f1>f0?1:-1);
}
if(y<ny-1&&x>=1&&x<=nx-2&&z>=1&&z<=nz-2){
const f1=F[i0+nx];
if((f0<0)!==(f1<0)) emit([ci(x-1,y,z-1),ci(x,y,z-1),ci(x,y,z),ci(x-1,y,z)],1,f1>f0?1:-1);
}
if(z<nz-1&&x>=1&&x<=nx-2&&y>=1&&y<=ny-2){
const f1=F[i0+NXY];
if((f0<0)!==(f1<0)) emit([ci(x-1,y-1,z),ci(x,y-1,z),ci(x,y,z),ci(x-1,y,z)],2,f1>f0?1:-1);
}
}
if(!tri.length) return null;
const nv=verts.length/3;
let V=Float32Array.from(verts);
const nb=Array.from({length:nv},()=>[]);
for(let i=0;i<tri.length;i+=3){
const a=tri[i],b=tri[i+1],c=tri[i+2];
nb[a].push(b,c); nb[b].push(a,c); nb[c].push(a,b);
}
const pass=lam=>{
const W=new Float32Array(V.length);
for(let v=0;v<nv;v++){
const L=nb[v]; let ax=0,ay=0,az=0;
for(const u of L){ ax+=V[u*3]; ay+=V[u*3+1]; az+=V[u*3+2]; }
const n=L.length||1;
W[v*3]=V[v*3]+lam*(ax/n-V[v*3]);
W[v*3+1]=V[v*3+1]+lam*(ay/n-V[v*3+1]);
W[v*3+2]=V[v*3+2]+lam*(az/n-V[v*3+2]);
}
V=W;
};
for(let it=0;it<(P.boneIter|0);it++){ pass(0.5); pass(-0.53); }
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.Float32BufferAttribute(V,3));
if(withColors){
const c=new THREE.Color(P.meshColor), col=new Float32Array(nv*3);
for(let v=0;v<nv;v++){ col[v*3]=c.r; col[v*3+1]=c.g; col[v*3+2]=c.b; }
geo.setAttribute('color',new THREE.BufferAttribute(col,3));
}
geo.setIndex(new THREE.BufferAttribute(Uint32Array.from(tri),1));
geo.computeVertexNormals(); geo.computeBoundingSphere();
return geo;
}
// ── Streben: Delaunay-Kanten verdickt
// Radius in der Mitte nie unter dem Mindestradius, Knoten wachsen höchstens um „Knoten-Zuwachs“ über die harte Vereinigung hinaus,
// Verschmelzung und Zuwachs skalieren mit dem (ggf. durch Unterteilung verkleinerten) Radius.
function meanEdgeLen(){ let s=0; for(const [i,j] of EDGES) s+=PTS[i].distanceTo(PTS[j]); return EDGES.length?s/EDGES.length:0; }
function strutSetup(edges,scale,h0,maxCells){
const base=P.boneRadius*scale, k=Math.max(P.blendF*base,1e-4);
const bb=new THREE.Box3();
for(const [i,j] of edges){ bb.expandByPoint(PTS[i]); bb.expandByPoint(PTS[j]); }
const pad=Math.max(P.minRadius,0.66*h0,base*(1+P.boneVar))*Math.max(1,P.boneNode)+k+h0;
const g=makeGrid(bb,pad,h0,maxCells), n=g.nx*g.ny*g.nz;
const rMin=Math.max(P.minRadius,0.8*g.h); // dünner als ~1,6 Voxel Durchmesser zerfällt die Strebe
return {g,base,rMin,k,cap:P.nodeCap*Math.max(base,rMin),F:new Float32Array(n).fill(1),Fh:new Float32Array(n).fill(1)};
}
function stampStrut(S,a,b){
const {g,base,rMin,k,F,Fh}=S, {nx,ny,nz,ox,oy,oz,NXY,h}=g;
const abx=b.x-a.x, aby=b.y-a.y, abz=b.z-a.z, L2=abx*abx+aby*aby+abz*abz;
if(L2<1e-10) return;
const hv=hash3(Math.round(Math.abs(a.x+b.x)*500),Math.round((a.y+b.y)*500),Math.round((a.z+b.z)*500));
const rs=Math.max(rMin,base*(1+P.boneVar*(hv*2-1))), rn=rs*Math.max(1,P.boneNode), m=rn+k;
const x0=Math.max(0,Math.floor((Math.min(a.x,b.x)-m-ox)/h)), x1=Math.min(nx-1,Math.ceil((Math.max(a.x,b.x)+m-ox)/h));
const y0=Math.max(0,Math.floor((Math.min(a.y,b.y)-m-oy)/h)), y1=Math.min(ny-1,Math.ceil((Math.max(a.y,b.y)+m-oy)/h));
const z0=Math.max(0,Math.floor((Math.min(a.z,b.z)-m-oz)/h)), z1=Math.min(nz-1,Math.ceil((Math.max(a.z,b.z)+m-oz)/h));
for(let z=z0;z<=z1;z++){
const pz=oz+z*h;
for(let y=y0;y<=y1;y++){
const py=oy+y*h;
for(let x=x0;x<=x1;x++){
const px=ox+x*h;
let t=((px-a.x)*abx+(py-a.y)*aby+(pz-a.z)*abz)/L2; t=t<0?0:(t>1?1:t);
const dx=px-(a.x+abx*t), dy=py-(a.y+aby*t), dz=pz-(a.z+abz*t), w=1-Math.sin(Math.PI*t);
const d=Math.sqrt(dx*dx+dy*dy+dz*dz)-(rs+(rn-rs)*w*w), idx=x+nx*y+NXY*z;
F[idx]=smoothMin(F[idx],d,k);
if(d<Fh[idx]) Fh[idx]=d;
}
}
}
}
/** Knoten begrenzen: die weiche Vereinigung darf höchstens „cap“ über die harte hinauswachsen */
function finishStrut(S){
const {F,Fh,cap}=S, comp=0.17*S.g.h; // Ausgleich: die Oberflächen-Extraktion verliert ~0,17 Voxel
for(let i=0;i<F.length;i++){ const v=Fh[i]-cap; F[i]=(v>F[i]?v:F[i])-comp; }
}
function buildBoneGeometry(withColors,coarse){
if(!EDGES.length) return null;
const h0=coarse?Math.max(P.boneRes*1.7,0.022):Math.max(P.boneRes,0.0025);
const S=strutSetup(EDGES,1,h0);
for(const [i,j] of EDGES) stampStrut(S,PTS[i],PTS[j]);
finishStrut(S);
return surfaceFromField(S.F,S.g,withColors);
}
// ═════ KÖRPER: geposte Oberfläche, Abtastung, Innen/Außen ═════
let BC=null, bcDirty=true, SM=null, smDirty=true, poseMoved=false;
function markBodyDirty(){ bcDirty=true; smDirty=true; }
const _pa=new V3(), _pn=new V3();
function mulberry32(a){
return function(){
a|=0; a=a+0x6D2B79F5|0;
let t=Math.imul(a^a>>>15,1|a);
t=t+Math.imul(t^t>>>7,61|t)^t;
return ((t^t>>>14)>>>0)/4294967296;
};
}
const hkey=(x,y,z)=>((x+1024)*2048+(y+1024))*2048+(z+1024);
/** Geposte Weltpositionen + Normalen aller Körper-Vertices */
function getBC(){
if(BC&&!bcDirty) return BC;
const g=body.geometry, pa=g.attributes.position, na=g.attributes.normal, n=pa.count;
const pos=new Float32Array(n*3), nrm=new Float32Array(n*3);
bodyRoot.updateMatrixWorld(true);
const m=body.matrixWorld, nm=new THREE.Matrix3().getNormalMatrix(m);
const v=new V3(), w=new V3(), nn=new V3();
const sk=body.isSkinnedMesh&&typeof body.applyBoneTransform==='function';
for(let i=0;i<n;i++){
v.fromBufferAttribute(pa,i); nn.fromBufferAttribute(na,i);
if(sk){ w.copy(v).addScaledVector(nn,0.01); body.applyBoneTransform(i,v); body.applyBoneTransform(i,w); nn.subVectors(w,v); }
v.applyMatrix4(m); nn.applyMatrix3(nm).normalize();
pos[i*3]=v.x; pos[i*3+1]=v.y; pos[i*3+2]=v.z;
nrm[i*3]=nn.x; nrm[i*3+1]=nn.y; nrm[i*3+2]=nn.z;
}
let idx;
if(g.index) idx=g.index.array; else { idx=new Uint32Array(n); for(let i=0;i<n;i++) idx[i]=i; }
BC={pos,nrm,idx,n,nt:Math.floor(idx.length/3)};
bcDirty=false;
return BC;
}
/** Hautanker {t,u,v} → Weltpunkt + Abstand entlang der interpolierten Normale */
function anchorEval(a,off,out,outN){
const B=getBC(), t=a.t*3, i0=B.idx[t]*3, i1=B.idx[t+1]*3, i2=B.idx[t+2]*3;
const u=a.u, v=a.v, w=1-u-v, p=B.pos, q=B.nrm;
let nx=q[i0]*w+q[i1]*u+q[i2]*v, ny=q[i0+1]*w+q[i1+1]*u+q[i2+1]*v, nz=q[i0+2]*w+q[i1+2]*u+q[i2+2]*v;
const L=Math.hypot(nx,ny,nz)||1; nx/=L; ny/=L; nz/=L;
out.set(p[i0]*w+p[i1]*u+p[i2]*v+nx*off, p[i0+1]*w+p[i1+1]*u+p[i2+1]*v+ny*off, p[i0+2]*w+p[i1+2]*u+p[i2+2]*v+nz*off);
if(outN) outN.set(nx,ny,nz);
return out;
}
// ── Abtastung der Haut + Innen/Außen-Maske
function getSM(){
if(SM&&!smDirty) return SM;
const B=getBC(), N=24000, rnd=mulberry32(12345);
const acc=new Float64Array(B.nt); let tot=0;
for(let t=0;t<B.nt;t++){
const a=B.idx[t*3]*3, b=B.idx[t*3+1]*3, c=B.idx[t*3+2]*3, p=B.pos;
const e1x=p[b]-p[a], e1y=p[b+1]-p[a+1], e1z=p[b+2]-p[a+2], e2x=p[c]-p[a], e2y=p[c+1]-p[a+1], e2z=p[c+2]-p[a+2];
tot+=0.5*Math.hypot(e1y*e2z-e1z*e2y, e1z*e2x-e1x*e2z, e1x*e2y-e1y*e2x); acc[t]=tot;
}
const sp=new Float32Array(N*3), sn=new Float32Array(N*3), st=new Int32Array(N), su=new Float32Array(N), sv=new Float32Array(N);
const cs=0.025, hash=new Map(), an={t:0,u:0,v:0};
for(let k=0;k<N;k++){
const r=rnd()*tot; let lo=0, hi=B.nt-1;
while(lo<hi){ const mid=(lo+hi)>>1; if(acc[mid]<r) lo=mid+1; else hi=mid; }
let u=rnd(), v=rnd(); if(u+v>1){ u=1-u; v=1-v; }
an.t=lo; an.u=u; an.v=v;
anchorEval(an,0,_pa,_pn);
sp[k*3]=_pa.x; sp[k*3+1]=_pa.y; sp[k*3+2]=_pa.z;
sn[k*3]=_pn.x; sn[k*3+1]=_pn.y; sn[k*3+2]=_pn.z;
st[k]=lo; su[k]=u; sv[k]=v;
const key=hkey(Math.floor(_pa.x/cs),Math.floor(_pa.y/cs),Math.floor(_pa.z/cs));
let l=hash.get(key); if(!l) hash.set(key,l=[]); l.push(k);
}
SM={n:N,p:sp,nr:sn,t:st,u:su,v:sv,cs,hash,mask:buildMask(B)};
smDirty=false;
return SM;
}
function buildMask(B){
const mc=0.02, p=B.pos;
let x0=Infinity,y0=Infinity,z0=Infinity,x1=-Infinity,y1=-Infinity,z1=-Infinity;
for(let i=0;i<B.n;i++){
const x=p[i*3],y=p[i*3+1],z=p[i*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;
}
x0-=0.06; y0-=0.06; z0-=0.06;
const nx=Math.ceil((x1+0.06-x0)/mc)+1, ny=Math.ceil((y1+0.06-y0)/mc)+1, nz=Math.ceil((z1+0.06-z0)/mc)+1;
const m=new Uint8Array(nx*ny*nz);
for(let t=0;t<B.nt;t++){
const a=B.idx[t*3]*3, b=B.idx[t*3+1]*3, c=B.idx[t*3+2]*3;
const L=Math.max(Math.hypot(p[b]-p[a],p[b+1]-p[a+1],p[b+2]-p[a+2]),
Math.hypot(p[c]-p[a],p[c+1]-p[a+1],p[c+2]-p[a+2]),
Math.hypot(p[c]-p[b],p[c+1]-p[b+1],p[c+2]-p[b+2]));
const s=Math.max(1,Math.ceil(L/(mc*0.4)));
for(let i=0;i<=s;i++) for(let j=0;j<=s-i;j++){
const u=i/s, v=j/s, w=1-u-v;
const ix=Math.floor((p[a]*w+p[b]*u+p[c]*v-x0)/mc);
const iy=Math.floor((p[a+1]*w+p[b+1]*u+p[c+1]*v-y0)/mc);
const iz=Math.floor((p[a+2]*w+p[b+2]*u+p[c+2]*v-z0)/mc);
m[ix+nx*(iy+ny*iz)]=1;
}
}
const q=new Int32Array(nx*ny*nz); let qh=0, qt=0;
const seed=(x,y,z)=>{ const i=x+nx*(y+ny*z); if(m[i]===0){ m[i]=2; q[qt++]=i; } };
for(let y=0;y<ny;y++) for(let z=0;z<nz;z++){ seed(0,y,z); seed(nx-1,y,z); }
for(let x=0;x<nx;x++) for(let z=0;z<nz;z++){ seed(x,0,z); seed(x,ny-1,z); }
for(let x=0;x<nx;x++) for(let y=0;y<ny;y++){ seed(x,y,0); seed(x,y,nz-1); }
while(qh<qt){
const i=q[qh++], x=i%nx, y=Math.floor(i/nx)%ny, z=Math.floor(i/(nx*ny));
if(x>0) seed(x-1,y,z); if(x<nx-1) seed(x+1,y,z);
if(y>0) seed(x,y-1,z); if(y<ny-1) seed(x,y+1,z);
if(z>0) seed(x,y,z-1); if(z<nz-1) seed(x,y,z+1);
}
// Abstand (in Zellen, 26er-Nachbarschaft) zur nächsten Hautzelle — zum schnellen Überspringen
const dist=new Uint8Array(nx*ny*nz).fill(255); qh=0; qt=0;
for(let i=0;i<m.length;i++) if(m[i]===1){ dist[i]=0; q[qt++]=i; }
while(qh<qt){
const i=q[qh++], d=dist[i]; if(d>=254) continue;
const x=i%nx, y=Math.floor(i/nx)%ny, z=Math.floor(i/(nx*ny));
for(let dz=-1;dz<=1;dz++) for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
const X=x+dx, Y=y+dy, Z=z+dz;
if(X<0||Y<0||Z<0||X>=nx||Y>=ny||Z>=nz) continue;
const j=X+nx*(Y+ny*Z); if(dist[j]>d+1){ dist[j]=d+1; q[qt++]=j; }
}
}
return {mc,x0,y0,z0,nx,ny,nz,m,dist};
}
/** false, wenn die Haut sicher weiter als R entfernt ist */
function nearSkin(x,y,z,R){
const M=SM.mask;
const ix=Math.floor((x-M.x0)/M.mc), iy=Math.floor((y-M.y0)/M.mc), iz=Math.floor((z-M.z0)/M.mc);
if(ix<0||iy<0||iz<0||ix>=M.nx||iy>=M.ny||iz>=M.nz) return false;
return (M.dist[ix+M.nx*(iy+M.ny*iz)]-1)*M.mc<=R;
}
function insideBody(x,y,z){
const M=SM.mask;
const ix=Math.floor((x-M.x0)/M.mc), iy=Math.floor((y-M.y0)/M.mc), iz=Math.floor((z-M.z0)/M.mc);
if(ix<0||iy<0||iz<0||ix>=M.nx||iy>=M.ny||iz>=M.nz) return false;
return M.m[ix+M.nx*(iy+M.ny*iz)]===0;
}
let NEAR_D2=0;
/** nächster Hautpunkt (Abtastung) innerhalb maxR, sonst -1 */
function nearest(px,py,pz,maxR){
const S=getSM(), cs=S.cs, H=S.hash, sp=S.p;
const cx=Math.floor(px/cs), cy=Math.floor(py/cs), cz=Math.floor(pz/cs), R=Math.ceil(maxR/cs);
let best=maxR*maxR, bi=-1;
const scan=(x,y,z)=>{
const l=H.get(hkey(x,y,z)); if(!l) return;
for(let k=0;k<l.length;k++){
const i=l[k], dx=sp[i*3]-px, dy=sp[i*3+1]-py, dz=sp[i*3+2]-pz, d2=dx*dx+dy*dy+dz*dz;
if(d2<best){ best=d2; bi=i; }
}
};
for(let r=0;r<=R;r++){
for(let dz=-r;dz<=r;dz++) for(let dy=-r;dy<=r;dy++){
if(Math.abs(dz)===r||Math.abs(dy)===r) for(let dx=-r;dx<=r;dx++) scan(cx+dx,cy+dy,cz+dz);
else { scan(cx-r,cy+dy,cz+dz); if(r) scan(cx+r,cy+dy,cz+dz); }
}
if(bi>=0&&best<=(r*cs)*(r*cs)) break;
}
NEAR_D2=best;
return bi;
}
const CANCEL={cancel:true};
class Job{
constructor(name){ this.name=name; this.t0=performance.now(); this.last=this.t0; this.cancelled=false; this.phase=''; this.prog=0; this.heavy=false; }
set(phase,prog=0){ this.phase=phase; this.prog=prog; }
async tick(prog){
if(prog!==undefined) this.prog=prog;
if(this.cancelled) throw CANCEL;
const n=performance.now();
if(n-this.last>30){ this.last=n; await new Promise(r=>setTimeout(r,0)); if(this.cancelled) throw CANCEL; this.last=performance.now(); }
}
cancel(){ this.cancelled=true; }
get secs(){ return (performance.now()-this.t0)/1000; }
}
/** Körper aussparen: alles, was näher als „Hautabstand“ an der Haut oder im Körper liegt, wird weich weggeschnitten */
async function clipBody(F,g,job){
const S=getSM(), {h,nx,ny,nz,ox,oy,oz,NXY}=g;
const cl=P.clearance, kc=Math.max(P.clipRound,1e-4), band=kc+2*h, Rb=Math.max(cl,0)+kc+2*h+0.01;
for(let z=0;z<nz;z++){
await job.tick(z/nz);
const pz=oz+z*h;
for(let y=0;y<ny;y++){
const py=oy+y*h;
for(let x=0;x<nx;x++){
const idx=x+nx*y+NXY*z, f=F[idx];
if(f>band) continue;
const px=ox+x*h, si=nearSkin(px,py,pz,Rb)?nearest(px,py,pz,Rb):-1;
let ds;
if(si>=0){
const d=Math.sqrt(NEAR_D2), j=si*3;
const dot=(px-S.p[j])*S.nr[j]+(py-S.p[j+1])*S.nr[j+1]+(pz-S.p[j+2])*S.nr[j+2];
ds=(dot<0||insideBody(px,py,pz))?-d:d;
}else if(insideBody(px,py,pz)) ds=-Rb;
else continue;
F[idx]=-smoothMin(-f,-(cl-ds),kc);
}
}
}
}
/** nach dem Glätten: Vertices, die in den Körper oder unter den Hautabstand gerutscht sind, entlang der Hautnormale herausschieben */
async function pushOut(geo,job){
const S=getSM(), pa=geo.attributes.position, a=pa.array, cl=Math.max(P.clearance,0), R=cl+0.03;
let moved=0;
for(let i=0;i<pa.count;i++){
if((i&4095)===0) await job.tick(i/pa.count);
const x=a[i*3], y=a[i*3+1], z=a[i*3+2];
if(!nearSkin(x,y,z,R)) continue;
const si=nearest(x,y,z,R); if(si<0) continue;
const j=si*3, d=Math.sqrt(NEAR_D2);
const dot=(x-S.p[j])*S.nr[j]+(y-S.p[j+1])*S.nr[j+1]+(z-S.p[j+2])*S.nr[j+2];
const ds=dot<0?-d:d;
if(ds>=cl*0.9) continue;
const k=cl-ds;
a[i*3]+=S.nr[j]*k; a[i*3+1]+=S.nr[j+1]*k; a[i*3+2]+=S.nr[j+2]*k; moved++;
}
if(moved){ pa.needsUpdate=true; geo.computeVertexNormals(); geo.computeBoundingSphere(); }
}
/** Surface Nets + Taubin-Glättung aus einem Distanzfeld (negativ = innen) */
async function surfaceFromFieldAsync(F,g,withColors,iter,job){
const {h,nx,ny,nz,ox,oy,oz,NXY}=g;
const cx=nx-1, cy=ny-1, cz=nz-1;
const cell=new Int32Array(cx*cy*cz).fill(-1);
const verts=[];
const CORN=[[0,0,0],[1,0,0],[0,1,0],[1,1,0],[0,0,1],[1,0,1],[0,1,1],[1,1,1]];
const CE=[[0,1],[2,3],[4,5],[6,7],[0,2],[1,3],[4,6],[5,7],[0,4],[1,5],[2,6],[3,7]];
const cv=new Float32Array(8);
for(let z=0;z<cz;z++){ if(job) await job.tick(0.45*z/cz); for(let y=0;y<cy;y++) for(let x=0;x<cx;x++){
const base=x+nx*y+NXY*z;
let mask=0;
for(let c=0;c<8;c++){
const o=CORN[c], v=F[base+o[0]+nx*o[1]+NXY*o[2]];
cv[c]=v; if(v<0) mask|=1<<c;
}
if(mask===0||mask===255) continue;
let sx=0,sy=0,sz=0,n=0;
for(const [ea,eb] of CE){
if(((mask>>ea)&1)===((mask>>eb)&1)) continue;
const t=cv[ea]/(cv[ea]-cv[eb]), A=CORN[ea], B=CORN[eb];
sx+=A[0]+(B[0]-A[0])*t; sy+=A[1]+(B[1]-A[1])*t; sz+=A[2]+(B[2]-A[2])*t; n++;
}
cell[x+cx*(y+cy*z)]=verts.length/3;
verts.push(ox+(x+sx/n)*h, oy+(y+sy/n)*h, oz+(z+sz/n)*h);
}}
const tri=[];
const ci=(x,y,z)=>cell[x+cx*(y+cy*z)];
const emit=(q,axis,sgn)=>{
if(q[0]<0||q[1]<0||q[2]<0||q[3]<0) return;
const P0=q[0]*3,P1=q[1]*3,P2=q[2]*3,P3=q[3]*3;
const ux=verts[P2]-verts[P0], uy=verts[P2+1]-verts[P0+1], uz=verts[P2+2]-verts[P0+2];
const vx=verts[P3]-verts[P1], vy=verts[P3+1]-verts[P1+1], vz=verts[P3+2]-verts[P1+2];
const nrm=[uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx];
if(nrm[axis]*sgn<0) q=[q[0],q[3],q[2],q[1]];
tri.push(q[0],q[1],q[2], q[0],q[2],q[3]);
};
for(let z=0;z<nz;z++){ if(job) await job.tick(0.45+0.35*z/nz); for(let y=0;y<ny;y++) for(let x=0;x<nx;x++){
const i0=x+nx*y+NXY*z, f0=F[i0];
if(x<nx-1&&y>=1&&y<=ny-2&&z>=1&&z<=nz-2){
const f1=F[i0+1];
if((f0<0)!==(f1<0)) emit([ci(x,y-1,z-1),ci(x,y,z-1),ci(x,y,z),ci(x,y-1,z)],0,f1>f0?1:-1);
}
if(y<ny-1&&x>=1&&x<=nx-2&&z>=1&&z<=nz-2){
const f1=F[i0+nx];
if((f0<0)!==(f1<0)) emit([ci(x-1,y,z-1),ci(x,y,z-1),ci(x,y,z),ci(x-1,y,z)],1,f1>f0?1:-1);
}
if(z<nz-1&&x>=1&&x<=nx-2&&y>=1&&y<=ny-2){
const f1=F[i0+NXY];
if((f0<0)!==(f1<0)) emit([ci(x-1,y-1,z),ci(x,y-1,z),ci(x,y,z),ci(x-1,y,z)],2,f1>f0?1:-1);
}
}}
if(!tri.length) return null;
const nv=verts.length/3;
let V=Float32Array.from(verts);
const nb=Array.from({length:nv},()=>[]);
for(let i=0;i<tri.length;i+=3){
const a=tri[i],b=tri[i+1],c=tri[i+2];
nb[a].push(b,c); nb[b].push(a,c); nb[c].push(a,b);
}
const pass=lam=>{
const W=new Float32Array(V.length);
for(let v=0;v<nv;v++){
const L=nb[v]; let ax=0,ay=0,az=0;
for(const u of L){ ax+=V[u*3]; ay+=V[u*3+1]; az+=V[u*3+2]; }
const n=L.length||1;
W[v*3]=V[v*3]+lam*(ax/n-V[v*3]);
W[v*3+1]=V[v*3+1]+lam*(ay/n-V[v*3+1]);
W[v*3+2]=V[v*3+2]+lam*(az/n-V[v*3+2]);
}
V=W;
};
for(let it=0;it<(iter|0);it++){ if(job) await job.tick(0.8+0.2*it/Math.max(1,iter)); pass(0.5); pass(-0.53); }
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.Float32BufferAttribute(V,3));
if(withColors){
const c=new THREE.Color(P.meshColor), col=new Float32Array(nv*3);
for(let v=0;v<nv;v++){ col[v*3]=c.r; col[v*3+1]=c.g; col[v*3+2]=c.b; }
geo.setAttribute('color',new THREE.BufferAttribute(col,3));
}
geo.setIndex(new THREE.BufferAttribute(Uint32Array.from(tri),1));
geo.computeVertexNormals(); geo.computeBoundingSphere();
return geo;
}
/** Strahl gegen den gepostenen Körper (Möller–Trumbore) */
function rayBody(ray){
if(!body) return null;
const B=getBC(), P3=B.pos, I=B.idx;
const ox=ray.origin.x, oy=ray.origin.y, oz=ray.origin.z, dx=ray.direction.x, dy=ray.direction.y, dz=ray.direction.z;
let best=Infinity, bt=-1, bu=0, bv=0;
for(let t=0;t<B.nt;t++){
const a=I[t*3]*3, b=I[t*3+1]*3, c=I[t*3+2]*3;
const e1x=P3[b]-P3[a], e1y=P3[b+1]-P3[a+1], e1z=P3[b+2]-P3[a+2];
const e2x=P3[c]-P3[a], e2y=P3[c+1]-P3[a+1], e2z=P3[c+2]-P3[a+2];
const px=dy*e2z-dz*e2y, py=dz*e2x-dx*e2z, pz=dx*e2y-dy*e2x;
const det=e1x*px+e1y*py+e1z*pz;
if(det>-1e-12&&det<1e-12) continue;
const inv=1/det, tx=ox-P3[a], ty=oy-P3[a+1], tz=oz-P3[a+2];
const u=(tx*px+ty*py+tz*pz)*inv; if(u<0||u>1) continue;
const qx=ty*e1z-tz*e1y, qy=tz*e1x-tx*e1z, qz=tx*e1y-ty*e1x;
const v=(dx*qx+dy*qy+dz*qz)*inv; if(v<0||u+v>1) continue;
const d=(e2x*qx+e2y*qy+e2z*qz)*inv;
if(d>1e-5&&d<best){ best=d; bt=t; bu=u; bv=v; }
}
return bt<0?null:{t:bt,u:bu,v:bv,dist:best};
}
// ── Hautanker: jeder Node hängt an einem Hautpunkt {t,u,v} und hat lokale Koordinaten l = [Tangente, Bitangente, Normale]
const _fP=new V3(), _fT=new V3(), _fB=new V3(), _fN=new V3();
function frameAt(a){
const B=getBC(), t=a.t*3, i0=B.idx[t]*3, i1=B.idx[t+1]*3, i2=B.idx[t+2]*3;
const u=a.u, v=a.v, w=1-u-v, p=B.pos, q=B.nrm;
_fP.set(p[i0]*w+p[i1]*u+p[i2]*v, p[i0+1]*w+p[i1+1]*u+p[i2+1]*v, p[i0+2]*w+p[i1+2]*u+p[i2+2]*v);
_fN.set(q[i0]*w+q[i1]*u+q[i2]*v, q[i0+1]*w+q[i1+1]*u+q[i2+1]*v, q[i0+2]*w+q[i1+2]*u+q[i2+2]*v).normalize();
_fT.set(p[i1]-p[i0],p[i1+1]-p[i0+1],p[i1+2]-p[i0+2]);
_fT.addScaledVector(_fN,-_fT.dot(_fN));
if(_fT.lengthSq()<1e-14) _fT.set(1,0,0).addScaledVector(_fN,-_fN.x);
_fT.normalize(); _fB.crossVectors(_fN,_fT);
}
function anchorWorld(a,out){
frameAt(a);
return out.copy(_fP).addScaledVector(_fT,a.l[0]).addScaledVector(_fB,a.l[1]).addScaledVector(_fN,a.l[2]);
}
/** Node an den nächsten Hautpunkt binden (nach freiem Verschieben) */
function bindNode(n){
if(!body){ n.a=null; return; }
const si=nearest(n.world.x,n.world.y,n.world.z,1.2);
if(si<0){ n.a=null; return; }
const S=getSM(), a={t:S.t[si],u:S.u[si],v:S.v[si],l:[0,0,0]};
frameAt(a);
const d=n.world.clone().sub(_fP);
a.l=[d.dot(_fT),d.dot(_fB),d.dot(_fN)];
n.a=a; n.off=a.l[2];
}
function nodeFromRay(hit,off){
const a={t:hit.t,u:hit.u,v:hit.v,l:[0,0,off]};
const world=anchorWorld(a,new V3());
return {world,nrm:_fN.clone(),off,a};
}
function refreshNodes(){
if(!body) return;
for(const n of nodes) if(n.a) anchorWorld(n.a,n.world);
rebuildNodePoints();
}
/** Kante verläuft durch den Körper (tiefer als „Eindringtiefe“) */
function edgeInBody(a,b){
const L=a.distanceTo(b), n=Math.max(2,Math.ceil(L/0.008)), S=getSM();
for(let i=1;i<n;i++){
const t=i/n, x=a.x+(b.x-a.x)*t, y=a.y+(b.y-a.y)*t, z=a.z+(b.z-a.z)*t;
if(insideBody(x,y,z)) return true;
const R=P.dropDepth+0.03;
if(!nearSkin(x,y,z,R)) continue;
const si=nearest(x,y,z,R); if(si<0) continue;
const j=si*3, dot=(x-S.p[j])*S.nr[j]+(y-S.p[j+1])*S.nr[j+1]+(z-S.p[j+2])*S.nr[j+2];
if(dot<0&&Math.sqrt(NEAR_D2)>P.dropDepth) return true;
}
return false;
}
/** Streben-Mesh für den Bau: Kanten durch den Körper raus, Distanzfeld, Körper aussparen, Oberfläche */
let BUILD_INFO='', LAST_EDGES=[];
async function buildStrutMeshAsync(job,scale){
let edges=EDGES;
if(P.clipBody&&body){
job.set('Kanten prüfen');
const keep=[]; let n=0, dropped=0;
for(const e of edges){
if((n++&31)===0) await job.tick(n/edges.length);
if(edgeInBody(PTS[e[0]],PTS[e[1]])) dropped++; else keep.push(e);
}
edges=keep; DROPPED=dropped;
}
if(!edges.length) return null;
job.set('Distanzfeld');
// Voxel fein genug für die dünnsten Streben
const thinnest=Math.max(P.minRadius,P.boneRadius*scale*(1-P.boneVar));
const h0=Math.max(0.0025,Math.min(P.boneRes,thinnest*0.9,P.minRadius/0.8));
const S=strutSetup(edges,scale,h0,1.2e7);
LAST_EDGES=edges;
BUILD_INFO=`Radius ${(S.base*1000).toFixed(1)} mm · min ${(S.rMin*1000).toFixed(1)} mm · Voxel ${(S.g.h*1000).toFixed(1)} mm`
+(S.rMin>P.minRadius*1.01?' (Mindestradius wegen Rastergröße angehoben)':'');
let n=0;
for(const [i,j] of edges){
if((n++&7)===0) await job.tick(n/edges.length);
stampStrut(S,PTS[i],PTS[j]);
}
finishStrut(S);
if(P.clipBody&&body){ job.set('Körper aussparen'); await clipBody(S.F,S.g,job); }
job.set('Oberfläche');
const geo=await surfaceFromFieldAsync(S.F,S.g,false,P.boneIter,job);
if(geo&&P.clipBody&&body){ job.set('Hautabstand sichern'); await pushOut(geo,job); }
return geo;
}
// ── unterteilte Punkte des letzten Baus anzeigen
const interpGeo=new THREE.BufferGeometry();
interpGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3)); interpGeo.setDrawRange(0,0);
const interpPts=new THREE.Points(interpGeo,new THREE.PointsMaterial({size:4,sizeAttenuation:false,color:0x8a8a8a}));
interpPts.frustumCulled=false; interpPts.renderOrder=8; scene.add(interpPts);
let INTERP_N=0, DROPPED=0;
function clearInterp(){ if(!INTERP_N) return; INTERP_N=0; interpGeo.setDrawRange(0,0); }
function showInterp(from){
const A=[]; for(let i=from;i<PTS.length;i++) A.push(PTS[i].x,PTS[i].y,PTS[i].z);
interpGeo.setAttribute('position',new THREE.Float32BufferAttribute(A.length?A:[0,0,0],3));
interpGeo.setDrawRange(0,A.length/3); INTERP_N=A.length/3;
}
// ── Job-Anzeige mit Lebenszeichen
let buildJob=null;
const jobEl=document.getElementById('job'), jobT=document.getElementById('jobT'), jobA=document.getElementById('jobA'), jobBar=document.getElementById('jobBar');
document.getElementById('jobX').onclick=()=>{ if(buildJob) buildJob.cancel(); };
function updateJobUI(){
const j=buildJob;
if(!j){ jobEl.style.display='none'; return; }
const s=Math.floor(j.secs);
jobEl.style.display='block';
jobT.textContent=`MESH BAUEN · ${j.phase} · ${Math.round(j.prog*100)} % · ${s} s`;
jobBar.style.width=Math.round(j.prog*100)+'%';
jobA.textContent=j.secs>10?`${s%2?'●':'○'} läuft noch – nicht abgestürzt`:'';
}
setInterval(updateJobUI,250);
// ── Halbtransparente Vorschau
const previewMat=new THREE.MeshStandardMaterial({vertexColors:true,transparent:true,opacity:0.24,
roughness:0.55,metalness:0,side:THREE.DoubleSide,flatShading:true,depthWrite:false});
const preview=new THREE.Mesh(new THREE.BufferGeometry(),previewMat);
preview.frustumCulled=false; preview.renderOrder=5; scene.add(preview);
function updatePreview(){
const flat=!isSmoothMode();
if(previewMat.flatShading!==flat){ previewMat.flatShading=flat; previewMat.needsUpdate=true; }
preview.geometry.dispose();
preview.geometry=buildOutfitGeometry(true,true)||new THREE.BufferGeometry();
}
// ── Gebaute Meshes
let layers=[];
const allLayers=new Set();
async function buildMesh(){
if(buildJob) return;
const job=new Job('Mesh'); buildJob=job; updateJobUI(); DROPPED=0;
try{
job.set('Triangulation');
buildPointSet(); TETS=await delaunay3DAsync(PTS,job); computeFilter();
const nBase=PTS.length, lenBase=meanEdgeLen();
for(let l=0;l<(P.buildSubdiv|0);l++){
job.set('Unterteilen '+(l+1)+'/'+P.buildSubdiv);
if(PTS.length>=MAXPTS||!refinePoints()) break;
TETS=await delaunay3DAsync(PTS,job); computeFilter();
}
const extra=PTS.length-nBase;
const scale=(P.scaleWithSub&&extra&&lenBase>0)?Math.min(1,Math.max(0.15,meanEdgeLen()/lenBase)):1;
BUILD_INFO='';
let geo;
if(P.meshMode==='bone') geo=await buildStrutMeshAsync(job,scale);
else { if(!KEEP.length){ setStatus('Keine Zellen innerhalb der Reichweite.'); return; } geo=buildOutfitGeometry(false,false); }
if(!geo){ setStatus('Keine Geometrie erzeugt.'); return; }
if(extra) showInterp(nBase);
const mat=new THREE.MeshStandardMaterial({color:P.meshColor,roughness:0.45,metalness:0,
side:THREE.DoubleSide,flatShading:!isSmoothMode()});
const mesh=new THREE.Mesh(geo,mat); mesh.frustumCulled=false;
scene.add(mesh);
const layer={geo,mat,mesh,tris:(geo.index?geo.index.count:geo.attributes.position.count)/3};
layers.push(layer); allLayers.add(layer);
pushHistory();
setStatus('Mesh '+layers.length+' · '+job.secs.toFixed(1)+' s'+(extra?' · +'+extra+' Punkte unterteilt':'')+(DROPPED?' · '+DROPPED+' Kanten durch den Körper entfernt':'')+(scale<1?' · Radius ×'+scale.toFixed(2):'')+(BUILD_INFO?' · '+BUILD_INFO:''));
}catch(e){
if(e===CANCEL) setStatus('Abgebrochen.'); else { console.error(e); setStatus('Fehler: '+(e.message||e)); }
}finally{
buildJob=null; updateJobUI();
dirtyTri=true; // Vorschau wieder auf die Leit-Nodes
}
}
function detach(l){ scene.remove(l.mesh); }
function attach(l){ if(!l.mesh.parent) scene.add(l.mesh); }
function clearLastMesh(){ const l=layers.pop(); if(l){ detach(l); pushHistory(); } setStatus(); }
function clearMesh(){ for(const l of layers) detach(l); layers=[]; setStatus(); }
const totalTris=()=>layers.reduce((s,l)=>s+l.tris,0);
function clearAll(){ if(buildJob) buildJob.cancel(); clearNodes(); clearMesh(); pushHistory(); setStatus('Alles gelöscht.'); }
// ═════════════════════════════════════════════════════════════════════════════
// 9 · UNDO / REDO
// ═════════════════════════════════════════════════════════════════════════════
let history=[], histIdx=-1;
function snapshot(){
return {
nodes: nodes.map(n=>({w:n.world.toArray(), o:n.off, a:n.a?{t:n.a.t,u:n.a.u,v:n.a.v,l:n.a.l.slice()}:null})),
layers: layers.slice()
};
}
function gcLayers(){
const keep=new Set(layers);
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>80) history.shift();
histIdx=history.length-1;
gcLayers(); updateBar();
}
function resetHistory(){ history=[]; histIdx=-1; pushHistory(); }
function restore(snap){
nodes.length=0; selNode=null;
for(const d of snap.nodes){
const n={world:new V3().fromArray(d.w),nrm:null,off:d.o,a:d.a?{t:d.a.t,u:d.a.u,v:d.a.v,l:d.a.l.slice()}:null};
if(n.a&&body&&n.a.t<getBC().nt) anchorWorld(n.a,n.world); else n.a=null;
nodes.push(n);
}
rebuildNodePoints(); touchTri();
for(const l of layers) detach(l);
layers=snap.layers.slice();
for(const l of layers) 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.key==='Delete'||ev.key==='Backspace')&&selNode&&!P.poseMode){
ev.preventDefault(); removeNode(selNode); pushHistory(); setStatus(); return;
}
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 bakeBodyGeometry(){
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(){
const out=[];
if(P.exportBody&&body) out.push({name:'body',geo:bakeBodyGeometry()});
layers.forEach((l,i)=>out.push({name:'mesh'+(i+1),geo: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='# DELAUNAY OUTFIT WEAVER\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`;
}else for(let i=0;i<p.count;i+=3){
const a=i+voff,b=i+1+voff,c=i+2+voff;
out+=`f ${a}//${a} ${b}//${b} ${c}//${c}\n`;
}
voff+=p.count;
}
download(new Blob([out],{type:'text/plain'}),'delaunay_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='delaunay_outfit';
for(const {name,geo} of pieces){
const m=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({
color: name==='body'?0xdddddd:P.meshColor, 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'}),'delaunay_outfit.glb');
else download(new Blob([JSON.stringify(res,null,1)],{type:'model/gltf+json'}),'delaunay_outfit.gltf');
setStatus('Export fertig.');
},err=>{ console.error(err); setStatus('Export fehlgeschlagen.'); },{binary});
}
function doExport(){
if(!layers.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){
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);
}
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 doScatter(){
if(P.replace){ nodes.length=0; selNode=null; }
scatterNodes(P.randomCount|0);
}
// ── BODY
{
const g=group('Body',false);
const s=section('Modell',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('Pose',g);
check(p,'Pose-Modus','poseMode',()=>{ selectedBone=null; setStatus(P.poseMode?'Bone ziehen · Umschalt = Twist':''); });
button(p,'Pose zurücksetzen',()=>{ resetPose(); setStatus(); },true);
}
// ── NODES
{
const g=group('Nodes',true);
const s=section('Setzen',g);
slider(s,'Abstand zur Oberfläche','placeOffset',0,0.3,0.005,3);
const r=section('Zufällig verteilen',g);
slider(r,'Anzahl','randomCount',1,150,1,0);
slider(r,'Abstand min','offMin',0,0.3,0.005,3);
slider(r,'Abstand max','offMax',0,0.3,0.005,3);
check(r,'Vorhandene Nodes ersetzen','replace');
button(r,'Nodes streuen',doScatter);
const b=document.createElement('div');b.className='btn2';r.appendChild(b);
button(b,'Nodes leeren',()=>{ if(buildJob) buildJob.cancel(); clearNodes();pushHistory();setStatus('Alle Nodes gelöscht.');},true);
button(b,'Alles leeren',clearAll,true);
}
// ── DELAUNAY
{
const g=group('Delaunay',true);
const s=section(null,g);
slider(s,'Reichweite','maxEdge',0.02,1.2,0.005,3,()=>{dirtyFilter=true;});
slider(s,'Achsen-Verschmelzung','weld',0.001,0.08,0.001,3,touchTri);
check(s,'Nur Kanten gefüllter Zellen','edgesFromCells',()=>{dirtyFilter=true;});
}
// ── MESH
{
const g=group('Mesh',true);
const s=section(null,g);
choice(s,'meshMode',[['bone','Streben (gerundet)'],['hull','Hülle (flach)'],['cells','Zellen (flach)']],touchPreview);
choice(s,'fill',[['solid','Flächen füllen'],['frame','Fenster (Kanten stehen lassen)']],touchPreview);
slider(s,'Schrumpfen (Zellen)','shrink',0,0.6,0.01,2,touchPreview);
slider(s,'Rahmenbreite (Fenster)','frameWidth',0.02,0.48,0.01,2,touchPreview);
slider(s,'Wandstärke (Fenster)','frameThickness',0,0.03,0.0005,4,touchPreview);
const st=section('Streben',g);
slider(st,'Strebenradius','boneRadius',0.002,0.04,0.0005,4,touchPreview);
slider(st,'Mindestradius','minRadius',0.001,0.02,0.0005,4,touchPreview);
slider(st,'Knotenverdickung (Enden)','boneNode',1,2.5,0.05,2,touchPreview);
slider(st,'Verschmelzung (× Radius)','blendF',0,3,0.05,2,touchPreview);
slider(st,'Knoten max. Zuwachs (× Radius)','nodeCap',0,2,0.05,2,touchPreview);
slider(st,'Variation','boneVar',0,0.9,0.01,2,touchPreview);
check(st,'Radius mit Unterteilung verkleinern','scaleWithSub');
slider(st,'Voxelgröße max.','boneRes',0.003,0.03,0.0005,4,touchPreview);
slider(st,'Glättung','boneIter',0,10,1,0,touchPreview);
const c=document.createElement('input');c.type='color';c.value=P.meshColor;
c.addEventListener('input',()=>{
P.meshColor=c.value; touchPreview();
const l=layers[layers.length-1];
if(l) l.mat.color.set(c.value);
});
s.appendChild(c);
const u=section('Vor dem Bauen: Punktwolke unterteilen',g);
slider(u,'Stufen','buildSubdiv',0,3,1,0);
choice(u,'subMode',[['center','Zellmitte'],['edges','Kantenmitten'],['both','Mitte + Kantenmitten']]);
slider(u,'Streuung','subJitter',0,0.4,0.01,2);
const kb=section('Körper beim Bauen',g);
check(kb,'Körper aussparen','clipBody');
slider(kb,'Hautabstand','clearance',0,0.05,0.001,3);
slider(kb,'Kante entfernen ab Eindringtiefe','dropDepth',0,0.05,0.001,3);
slider(kb,'Schnittrundung','clipRound',0.001,0.04,0.001,3);
const bb=section(null,g);
button(bb,'Mesh bauen',()=>buildMesh());
const r=document.createElement('div');r.className='btn2';bb.appendChild(r);
button(r,'Letztes löschen',clearLastMesh,true);
button(r,'Alle löschen',()=>{clearMesh();pushHistory();},true);
}
// ── EXPORT
{
const g=group('Export',false);
const s=section(null,g);
choice(s,'exportFormat',[['glb','GLB (binär)'],['gltf','GLTF (JSON)'],['obj','OBJ']]);
check(s,'Körper mitexportieren','exportBody');
button(s,'Exportieren',doExport);
}
// ── VIEW
{
const g=group('View',false);
const s=section(null,g);
check(s,'Körper','showBody');
slider(s,'Deckkraft Körper','bodyOpacity',0.05,1,0.01,2);
check(s,'Nodes','showNodes');
check(s,'Gespiegelte Nodes','showGhost');
check(s,'Kanten','showEdges');
check(s,'Flächenvorschau','showFaces');
}
// ── Toolbar
const bUndo=document.getElementById('bUndo'), bRedo=document.getElementById('bRedo');
document.getElementById('bScatter').onclick=doScatter;
document.getElementById('bMesh').onclick=()=>buildMesh();
document.getElementById('bDelMesh').onclick=clearLastMesh;
document.getElementById('bClearNodes').onclick=()=>{ if(buildJob) buildJob.cancel(); clearNodes(); clearInterp(); pushHistory(); setStatus('Alle Nodes gelöscht.'); };
document.getElementById('bDelNode').onclick=()=>{ if(selNode){ removeNode(selNode); pushHistory(); } setStatus(); };
bUndo.onclick=undo; bRedo.onclick=redo;
function updateBar(){
bUndo.disabled=histIdx<=0;
bRedo.disabled=histIdx>=history.length-1;
}
function setStatus(msg){
const parts=[`MODELL <b>${modelName}</b>`,`NODES <b>${nodes.length}</b>`,
`KANTEN <b>${EDGES.length}</b>`,`ZELLEN <b>${KEEP.length}</b>`];
if(layers.length) parts.push(`MESHES <b>${layers.length}</b>`,
`DREIECKE <b>${totalTris().toLocaleString('de-DE')}</b>`);
if(msg) parts.push(`<b>${msg}</b>`);
document.getElementById('status').innerHTML=parts.join(' · ');
}
// ═════════════════════════════════════════════════════════════════════════════
// 12 · LOOP
// ═════════════════════════════════════════════════════════════════════════════
function tick(){
requestAnimationFrame(tick);
if(poseMoved&&body&&!buildJob){ poseMoved=false; refreshNodes(); dirtyTri=true; }
if(!buildJob&&dirtyTri){ dirtyTri=false; computeTriangulation(); }
if(!buildJob&&dirtyFilter){ dirtyFilter=false; computeFilter(); setStatus(); }
if(!buildJob&&dirtyPreview&&!(isSmoothMode()&&(dragNode||dragAxis))){ dirtyPreview=false; updatePreview(); }
if(bodyRoot) bodyRoot.visible=P.showBody;
if(bodyMat){ bodyMat.opacity=P.bodyOpacity; bodyMat.transparent=P.bodyOpacity<1; }
nodePts.visible=P.showNodes&&!P.poseMode;
ghostPts.visible=P.showNodes&&P.showGhost&&!P.poseMode;
hoverPt.visible=P.showNodes&&!P.poseMode&&!!hovered;
edgeA.visible=edgeB.visible=P.showEdges;
preview.visible=P.showFaces;
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);
renderer.render(scene,camera);
}
const dummy=buildDummy();
setBody(dummy.group,dummy.mesh,'Dummy');
resize();
scatterNodes(P.randomCount|0);
setStatus(); tick();
autoLoad();
</script>
</body>
</html>