index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MECHA RIG</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/TransformControls.js"></script>
<style>
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%; overflow: hidden;
background: #f2f2f2; color: #111; user-select: none;
font: 11px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
#webgl-container { position: fixed; inset: 0; }
canvas { display: block; }
#title {
position: fixed; top: 16px; left: 16px; pointer-events: none;
font-size: 12px; font-weight: 600; letter-spacing: .18em;
}
#title small { display: block; margin-top: 3px; font-size: 10px; font-weight: 400; letter-spacing: .04em; color: #999; }
#sel { margin-top: 10px; font-size: 11px; font-weight: 400; letter-spacing: 0; color: #111; min-height: 16px; }
#bar {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 2px; padding: 4px;
background: #fff; border: 1px solid #e6e6e6; border-radius: 999px;
}
#bar button {
font: inherit; color: #111; background: transparent; border: 0; border-radius: 999px;
padding: 7px 14px; cursor: pointer; white-space: nowrap;
}
#bar button:hover { background: #f4f4f4; }
#bar button.on { background: #ececec; }
#bar button:disabled { color: #bbb; cursor: default; background: transparent; }
#bar .sep { width: 1px; height: 14px; background: #e6e6e6; margin: 0 4px; }
#panel {
position: fixed; top: 16px; right: 16px; width: 272px; max-height: calc(100vh - 32px);
overflow-y: auto; background: #fff; border: 1px solid #e6e6e6; border-radius: 8px;
}
#panel::-webkit-scrollbar { width: 6px; }
#panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }
.fhead {
display: flex; justify-content: space-between; width: 100%;
font: inherit; font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: #111;
background: none; border: 0; border-top: 1px solid #f0f0f0; padding: 9px 12px; cursor: pointer; text-align: left;
}
#panel > .folder:first-child > .fhead { border-top: 0; }
.fhead::after { content: "+"; color: #aaa; }
.folder.open > .fhead::after { content: "–"; }
.fbody { display: none; padding: 0 0 8px; }
.folder.open > .fbody { display: block; }
.fbody .folder > .fhead { padding-left: 20px; letter-spacing: .04em; text-transform: none; font-size: 11px; color: #444; }
.fbody .folder .fbody { padding-left: 8px; }
.row { display: grid; grid-template-columns: 92px 1fr 38px; align-items: center; gap: 8px; padding: 3px 12px; }
.row.simple { grid-template-columns: 1fr auto; }
.row label { color: #777; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row output { text-align: right; color: #111; font-size: 10px; font-variant-numeric: tabular-nums; }
.row input[type=checkbox] { accent-color: #111; margin: 0; }
.row input[type=color] { width: 26px; height: 14px; padding: 0; border: 1px solid #ddd; border-radius: 3px; background: #fff; cursor: pointer; }
.note { padding: 8px 12px 6px; color: #aaa; font-size: 10px; }
#frame { position: fixed; pointer-events: none; border: 1px dashed #b5b5b5; display: none; }
.row select { font: inherit; color: #111; background: #fff; border: 1px solid #e6e6e6; border-radius: 4px; padding: 2px 4px; }
.btnrow { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; padding: 6px 12px 0; }
.btnrow .btn { width: auto; margin: 0; padding: 4px 2px; }
.btn {
display: block; width: calc(100% - 24px); margin: 4px 12px 0; font: inherit; color: #111;
background: #fff; border: 1px solid #e6e6e6; border-radius: 6px; padding: 5px 8px; cursor: pointer;
}
.btn:hover { background: #f6f6f6; }
input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; height: 14px; margin: 0; background: transparent; cursor: pointer; }
input[type=range]::-webkit-slider-runnable-track { height: 1px; background: #d2d2d2; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 9px; height: 9px; margin-top: -4px; border-radius: 50%; background: #111; }
input[type=range]::-moz-range-track { height: 1px; background: #d2d2d2; }
input[type=range]::-moz-range-thumb { width: 9px; height: 9px; border: 0; border-radius: 50%; background: #111; }
</style>
</head>
<body>
<div id="webgl-container"></div>
<div id="frame"></div>
<div id="title">MECHA RIG
<small>Bone anklicken · W bewegen · E drehen · N alle Nodes</small>
<div id="sel"></div>
</div>
<div id="bar">
<button id="add-arm-btn">+ Arm</button>
<button id="add-leg-btn">+ Bein</button>
<button id="add-head-btn">+ Kopf</button>
<span class="sep"></span>
<button id="rnd-anatomy-btn">Zufall Anatomie</button>
<button id="rnd-pose-btn">Zufall Pose</button>
<span class="sep"></span>
<button id="mode-translate-btn">Bewegen</button>
<button id="mode-rotate-btn">Drehen</button>
<span class="sep"></span>
<button id="nodes-btn">Nodes</button>
<button id="screenshot-btn">Snapshot</button>
</div>
<div id="panel"></div>
<script>
// ============================================================
// STATE
// ============================================================
let scene, camera, renderer, controls, tc, keyLight;
let spineCurve, spineLine, spineNodes = [];
let nodeHandles = [], hipHandle;
let raycaster, mouse, draggedObject = null;
let downX = 0, downY = 0, gizmoDown = false;
let normalMapTexture;
let mechaMaterial, shellMaterial, nerveCableMaterial, glowMaterial, hydraulicMaterial;
let markerMats = {};
let floorMesh;
let spineRest = [];
let exporting = false;
let fileMsg = '';
let vertM, vertH, nerveMeshL, nerveMeshR;
// Alles Körperliche hängt unter bodyRoot (Ursprung = Hüfte = erster Spline-Knoten).
const bodyRoot = new THREE.Group();
const spineGroup = new THREE.Group();
const ribCagesGroup = new THREE.Group();
const nerveTrunksGroup = new THREE.Group();
const cablesGroup = new THREE.Group();
bodyRoot.add(spineGroup, ribCagesGroup, nerveTrunksGroup, cablesGroup);
const limbs = [];
const heads = [];
const ribEntries = [];
const MAX_VERTEBRAE = 41;
const LINE_SEG = 60;
const config = {
vertebraeCount: 24,
ribCageCount: 2,
ribsPerCage: 6,
showNodes: true,
showAllNodes: false, // Modus: alle anklickbaren Nodes (Gelenke, Spine-Anker) sichtbar
exportFormat: 'win', // Export-Ausschnitt: 'win' = ganzes Fenster, sonst Seitenverhältnis
exportH: 1024,
mirror: true, // Symmetrie: Gelenkdrehung wird auf die Gegenseite gespiegelt
mode: 'rotate', // 'rotate' | 'translate'
shellVisible: true,
shellOpenFactor: 0.0,
nerveThickness: 0.025,
mechaColor: '#080808',
mechaMetalness: 0.95,
mechaRoughness: 0.25,
shellColor: '#f1f5f9',
shellRoughness: 0.1,
shellClearcoat: 1.0,
nerveColor: '#ffffff',
glowColor: '#00f0ff',
normalScale: 0.25
};
const UP = new THREE.Vector3(0, 1, 0);
const _p = new THREE.Vector3(), _t = new THREE.Vector3(), _s = new THREE.Vector3();
const _xa = new THREE.Vector3(), _za = new THREE.Vector3();
const _q = new THREE.Quaternion(), _m = new THREE.Matrix4();
const _w1 = new THREE.Vector3(), _w2 = new THREE.Vector3();
const _plane = new THREE.Plane(), _camDir = new THREE.Vector3();
const _inv = new THREE.Matrix4(), _grav = new THREE.Vector3();
// ============================================================
// RENDER ON DEMAND
// ============================================================
let renderQueued = false, spineDirty = false;
function requestRender() {
if (renderQueued) return;
renderQueued = true;
requestAnimationFrame(frame);
}
function frame() {
renderQueued = false;
if (spineDirty) { spineDirty = false; updateSpine(); }
renderer.render(scene, camera);
}
// ============================================================
// GEOMETRIE-HELFER
// ============================================================
const geoCache = new Map();
function cached(key, make) {
let g = geoCache.get(key);
if (!g) { g = make(); g.userData.shared = true; geoCache.set(key, g); }
return g;
}
function disposeTree(obj) {
obj.traverse(o => { if (o.geometry && !o.geometry.userData.shared) o.geometry.dispose(); });
}
function clearGroup(g) {
while (g.children.length) { const c = g.children[0]; g.remove(c); disposeTree(c); }
}
const mx = (px, py, pz, rx = 0, ry = 0, rz = 0) =>
new THREE.Matrix4().compose(new THREE.Vector3(px, py, pz),
new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(1, 1, 1));
function concat(arrays) {
let n = 0; for (const a of arrays) n += a.length;
const out = new Float32Array(n); let o = 0;
for (const a of arrays) { out.set(a, o); o += a.length; }
return out;
}
function mergeGeos(parts) {
const P = [], N = [], U = [];
for (const { geo, matrix } of parts) {
const g = geo.index ? geo.toNonIndexed() : geo.clone();
if (matrix) g.applyMatrix4(matrix);
P.push(g.attributes.position.array);
N.push(g.attributes.normal.array);
U.push(g.attributes.uv.array);
}
const out = new THREE.BufferGeometry();
out.setAttribute('position', new THREE.BufferAttribute(concat(P), 3));
out.setAttribute('normal', new THREE.BufferAttribute(concat(N), 3));
out.setAttribute('uv', new THREE.BufferAttribute(concat(U), 2));
return out;
}
function buildLeaf(width, height, curvature) {
const shape = new THREE.Shape();
shape.moveTo(0, height / 2);
shape.bezierCurveTo(width * 0.85, height * 0.35, width, -height * 0.15, 0, -height / 2);
shape.bezierCurveTo(-width, -height * 0.15, -width * 0.85, height * 0.35, 0, height / 2);
const geo = new THREE.ExtrudeGeometry(shape, {
steps: 1, depth: 0.025, bevelEnabled: true, bevelThickness: 0.02, bevelSize: 0.02, bevelSegments: 2
});
geo.center();
const pa = geo.attributes.position;
for (let i = 0; i < pa.count; i++) {
const x = pa.getX(i), y = pa.getY(i);
const d = 1.0 - (Math.abs(y) / (height / 2));
pa.setZ(i, pa.getZ(i) + Math.cos((x / width) * Math.PI * 0.5) * curvature * (d + 0.2));
}
geo.computeVertexNormals();
return geo;
}
const leafGeo = (w, h, c) => cached(`leaf|${w}|${h}|${c}`, () => buildLeaf(w, h, c));
const jointGeoM = r => cached('jM|' + r, () =>
mergeGeos([{ geo: new THREE.TorusGeometry(r * 0.9, r * 0.3, 10, 16), matrix: mx(0, 0, 0, Math.PI / 2, 0, 0) }]));
const jointGeoH = r => cached('jH|' + r, () => {
const parts = [{ geo: new THREE.CylinderGeometry(r * 0.55, r * 0.55, r * 1.6, 12), matrix: mx(0, 0, 0, 0, 0, Math.PI / 2) }];
for (const s of [-1, 1])
parts.push({ geo: new THREE.CylinderGeometry(r * 0.18, r * 0.18, r * 1.4, 8), matrix: mx(s * r * 0.7, 0, r * 0.4, 0.3, 0, 0) });
return mergeGeos(parts);
});
// info = Auswahl-Ziel (anklickbar), optional
function createBiomorphicJoint(r, info) {
const g = new THREE.Group();
const a = new THREE.Mesh(jointGeoM(r), mechaMaterial);
const b = new THREE.Mesh(jointGeoH(r), hydraulicMaterial);
a.castShadow = b.castShadow = true;
a.userData.seg = b.userData.seg = 'gelenk';
if (info) a.userData.sel = b.userData.sel = info;
g.add(a, b);
return g;
}
// Node-Marker: sichtbar im „Nodes“-Modus, klickbar wie der Bone selbst
// spine = grün (an der Spine verschiebbar), joint = violett (Gelenk, drehbar)
function makeMarker(type, info) {
const geo = cached('mk' + type, () => new THREE.SphereGeometry(type === 'spine' ? 0.22 : 0.15, 14, 10));
const m = new THREE.Mesh(geo, markerMats[type]);
m.renderOrder = 12;
m.userData = { sel: info, isMarker: true };
m.visible = config.showAllNodes;
return m;
}
const vertGeoM = () => cached('vM', () => mergeGeos([
{ geo: new THREE.CylinderGeometry(0.18, 0.14, 0.15, 10) },
{ geo: leafGeo(0.12, 0.38, 0.08), matrix: mx(0, 0, -0.22, Math.PI / 2, 0, 0) }
]));
const vertGeoH = () => cached('vH', () => mergeGeos([-1, 1].map(s => ({
geo: new THREE.CylinderGeometry(0.04, 0.04, 0.14, 8), matrix: mx(s * 0.16, 0, 0)
}))));
// ============================================================
// INIT
// ============================================================
function init() {
const container = document.getElementById('webgl-container');
scene = new THREE.Scene();
scene.background = new THREE.Color('#f2f2f2');
scene.add(bodyRoot);
bodyRoot.position.set(0, 1.87, 0);
camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 5, 19);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
container.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = false;
controls.target.set(0, 3.4, 0);
controls.addEventListener('change', requestRender);
// Gizmo für den angeklickten Bone
tc = new THREE.TransformControls(camera, renderer.domElement);
tc.setSize(0.8);
tc.addEventListener('change', requestRender);
tc.addEventListener('dragging-changed', e => { controls.enabled = !e.value; });
tc.addEventListener('objectChange', () => { if (selection) selection.onChange(selection.mode); });
scene.add(tc);
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
generateProceduralNormalMap();
setupStudioLighting();
setupStudioFloor();
buildMaterials();
setupBezierSpine();
buildMultipleRibCages();
addHead();
addLimbPair('arm', 0.85, 'Oberer Mantis-Arm');
addLimbPair('arm', 0.68, 'Zweiter Bio-Arm');
addLimbPair('leg', 0.20, 'Hexapod-Hauptbein');
addLimbPair('leg', 0.08, 'Unterer Stabilisator');
addEventListener('resize', onWindowResize);
addEventListener('keydown', onKeyDown);
const cv = renderer.domElement;
cv.addEventListener('pointerdown', onPointerDown);
cv.addEventListener('pointermove', onPointerMove);
cv.addEventListener('pointerup', onPointerUp);
cv.addEventListener('pointercancel', onPointerUp);
document.getElementById('add-arm-btn').addEventListener('click', () => addLimbPair('arm', 0.5, `Arm-Paar ${limbs.length + 1}`));
document.getElementById('add-leg-btn').addEventListener('click', () => addLimbPair('leg', 0.1, `Bein-Paar ${limbs.length + 1}`));
document.getElementById('add-head-btn').addEventListener('click', () => addHead());
document.getElementById('mode-translate-btn').addEventListener('click', () => setMode('translate'));
document.getElementById('mode-rotate-btn').addEventListener('click', () => setMode('rotate'));
document.getElementById('nodes-btn').addEventListener('click', () => setNodesMode(!config.showAllNodes));
document.getElementById('rnd-anatomy-btn').addEventListener('click', randomAnatomy);
document.getElementById('rnd-pose-btn').addEventListener('click', randomPose);
document.getElementById('screenshot-btn').addEventListener('click', takeScreenshot);
updateSpine();
applyShellsAll();
updateLight();
updateModeButtons();
updateFrame();
rebuildGUI();
requestRender();
}
// ============================================================
// TEXTUR / LICHT / BODEN / MATERIAL
// ============================================================
function generateProceduralNormalMap() {
const S = 256;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = S;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(S, S);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const nx = (Math.random() - 0.5) * 2.0, ny = (Math.random() - 0.5) * 2.0;
const len = Math.sqrt(nx * nx + ny * ny + 1.0);
d[i] = ((nx / len) * 0.5 + 0.5) * 255;
d[i + 1] = ((ny / len) * 0.5 + 0.5) * 255;
d[i + 2] = ((1 / len) * 0.5 + 0.5) * 255;
d[i + 3] = 255;
}
ctx.putImageData(img, 0, 0);
normalMapTexture = new THREE.CanvasTexture(canvas);
normalMapTexture.wrapS = normalMapTexture.wrapT = THREE.RepeatWrapping;
normalMapTexture.repeat.set(12, 12);
}
function setupStudioLighting() {
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
keyLight = new THREE.DirectionalLight(0xffffff, 1.8);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
const sc = keyLight.shadow.camera;
sc.near = 1; sc.far = 40;
sc.left = -8; sc.right = 8; sc.top = 8; sc.bottom = -8;
keyLight.shadow.bias = -0.0003;
keyLight.shadow.normalBias = 0.02;
scene.add(keyLight, keyLight.target);
const rim = new THREE.DirectionalLight(0x00f0ff, 1.2);
rim.position.set(-8, 6, -6);
scene.add(rim);
const fill = new THREE.DirectionalLight(0xffffff, 0.5);
fill.position.set(8, 4, 8);
scene.add(fill);
}
// Key-Light folgt der Hüfte, damit der Körper immer mittig im Shadow-Frustum liegt
function updateLight() {
const p = bodyRoot.position;
keyLight.target.position.set(p.x, 0, p.z);
keyLight.position.set(p.x + 1.5, 16, p.z + 2.5);
}
function setupStudioFloor() {
const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), new THREE.ShadowMaterial({ opacity: 0.2 }));
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
floorMesh = floor;
}
function buildMaterials() {
mechaMaterial = new THREE.MeshStandardMaterial({
color: config.mechaColor, metalness: config.mechaMetalness, roughness: config.mechaRoughness,
normalMap: normalMapTexture, normalScale: new THREE.Vector2(config.normalScale, config.normalScale)
});
shellMaterial = new THREE.MeshPhysicalMaterial({
color: config.shellColor, metalness: 0.05, roughness: config.shellRoughness,
clearcoat: config.shellClearcoat, clearcoatRoughness: 0.05, side: THREE.DoubleSide,
normalMap: normalMapTexture, normalScale: new THREE.Vector2(config.normalScale * 0.5, config.normalScale * 0.5)
});
nerveCableMaterial = new THREE.MeshStandardMaterial({
color: config.nerveColor, metalness: 0.1, roughness: 0.35,
normalMap: normalMapTexture, normalScale: new THREE.Vector2(0.15, 0.15)
});
hydraulicMaterial = new THREE.MeshStandardMaterial({ color: '#e2e8f0', metalness: 0.98, roughness: 0.08 });
glowMaterial = new THREE.MeshBasicMaterial({ color: config.glowColor });
const mm = c => new THREE.MeshBasicMaterial({ color: c, depthTest: false, transparent: true, opacity: 0.92 });
markerMats = { spine: mm(0x16a34a), joint: mm(0x7c3aed) };
}
// ============================================================
// SPINE
// ============================================================
function setupBezierSpine() {
spineNodes = [
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 1.5, -0.4),
new THREE.Vector3(0, 3.1, 0.3),
new THREE.Vector3(0, 4.6, 0)
];
spineCurve = new THREE.CubicBezierCurve3(spineNodes[0], spineNodes[1], spineNodes[2], spineNodes[3]);
spineCurve.arcLengthDivisions = 100;
spineRest = [1, 2, 3].map(i => spineNodes[i].clone()); // Ausgangsform für „Zufall Pose“
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((LINE_SEG + 1) * 3), 3));
spineLine = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x2563eb, depthTest: false, transparent: true, opacity: 0.8 }));
spineLine.frustumCulled = false;
spineLine.renderOrder = 9;
spineGroup.add(spineLine);
vertM = new THREE.InstancedMesh(vertGeoM(), mechaMaterial, MAX_VERTEBRAE);
vertH = new THREE.InstancedMesh(vertGeoH(), hydraulicMaterial, MAX_VERTEBRAE);
for (const v of [vertM, vertH]) { v.frustumCulled = false; v.castShadow = true; v.userData.seg = 'wirbel'; spineGroup.add(v); }
nerveMeshL = new THREE.Mesh(new THREE.BufferGeometry(), nerveCableMaterial);
nerveMeshR = new THREE.Mesh(new THREE.BufferGeometry(), nerveCableMaterial);
nerveMeshL.userData.seg = nerveMeshR.userData.seg = 'nerv';
nerveTrunksGroup.add(nerveMeshL, nerveMeshR);
// Spline-Knoten 1–3 (blau, per Drag)
const nodeGeo = new THREE.SphereGeometry(0.26, 16, 12);
const nodeMat = new THREE.MeshBasicMaterial({ color: 0x2563eb, depthTest: false, transparent: true, opacity: 0.9 });
for (let i = 1; i < spineNodes.length; i++) {
const h = new THREE.Mesh(nodeGeo, nodeMat);
h.position.copy(spineNodes[i]);
h.renderOrder = 10;
h.userData = { isNode: true, nodeIndex: i };
bodyRoot.add(h);
nodeHandles.push(h);
}
// Hip-Bone (schwarze Raute, anklickbar -> Bewegen/Drehen des ganzen Körpers)
hipHandle = new THREE.Mesh(
new THREE.OctahedronGeometry(0.4),
new THREE.MeshBasicMaterial({ color: 0x111111, depthTest: false, transparent: true, opacity: 0.92 })
);
hipHandle.renderOrder = 11;
hipHandle.userData = { sel: { kind: 'hip' } };
bodyRoot.add(hipHandle);
}
function updateSpine() {
spineCurve.updateArcLengths();
const lp = spineLine.geometry.attributes.position.array;
for (let i = 0; i <= LINE_SEG; i++) {
spineCurve.getPoint(i / LINE_SEG, _p);
lp[i * 3] = _p.x; lp[i * 3 + 1] = _p.y; lp[i * 3 + 2] = _p.z;
}
spineLine.geometry.attributes.position.needsUpdate = true;
for (const h of nodeHandles) h.position.copy(spineNodes[h.userData.nodeIndex]);
const n = config.vertebraeCount;
vertM.count = vertH.count = n + 1;
const nl = [], nr = [];
for (let i = 0; i <= n; i++) {
const t = i / n;
spineCurve.getPointAt(t, _p);
spineCurve.getTangentAt(t, _t);
const s = 1.0 - Math.abs(t - 0.5) * 0.25;
_q.setFromUnitVectors(UP, _t);
_m.compose(_p, _q, _s.set(s, s, s));
vertM.setMatrixAt(i, _m);
vertH.setMatrixAt(i, _m);
_xa.set(1, 0, 0).applyQuaternion(_q);
_za.set(0, 0, -1).applyQuaternion(_q);
nl.push(_p.clone().addScaledVector(_xa, 0.15 * s).addScaledVector(_za, 0.05));
nr.push(_p.clone().addScaledVector(_xa, -0.15 * s).addScaledVector(_za, 0.05));
}
vertM.instanceMatrix.needsUpdate = true;
vertH.instanceMatrix.needsUpdate = true;
nerveMeshL.geometry.dispose();
nerveMeshR.geometry.dispose();
nerveMeshL.geometry = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(nl), 40, config.nerveThickness, 6, false);
nerveMeshR.geometry = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(nr), 40, config.nerveThickness, 6, false);
updateRibPositions();
for (const h of heads) h.updatePositionOnSpine();
for (const l of limbs) l.updatePositionOnSpine();
}
// nächster Spine-Parameter t zu einem Punkt (bodyRoot-lokal) – für „entlang der Spine schieben“
function nearestSpineT(p, tMin, tMax) {
let best = 0, bestD = Infinity;
const N = 80;
for (let i = 0; i <= N; i++) {
spineCurve.getPointAt(i / N, _w1);
const d = _w1.distanceToSquared(p);
if (d < bestD) { bestD = d; best = i / N; }
}
const lo = Math.max(0, best - 1 / N), hi = Math.min(1, best + 1 / N);
for (let i = 0; i <= 10; i++) {
const t = lo + (hi - lo) * i / 10;
spineCurve.getPointAt(t, _w1);
const d = _w1.distanceToSquared(p);
if (d < bestD) { bestD = d; best = t; }
}
return Math.min(tMax, Math.max(tMin, best));
}
// ============================================================
// SHELLS
// ============================================================
function applyShells(root) {
root.traverse(o => {
if (o.userData.shellFn) { o.visible = config.shellVisible; o.userData.shellFn(config.shellOpenFactor); }
});
}
function applyShellsAll() {
for (const h of heads) applyShells(h.root);
applyShells(ribCagesGroup);
for (const l of limbs) applyShells(l.rootGroup);
}
// ============================================================
// KÖPFE: elliptische Halbkugel-Schale (Shell-Material) auf einem Neck
// ============================================================
class HeadUnit {
constructor(name, t, rot) {
this.name = name;
this.t = t;
this.rot = rot; // Euler des Neck-Pivots (per Gizmo gesetzt)
this.restRot = rot.slice(); // Ausgangsdrehung für „Zufall Pose“
this.neckLength = 0.5;
this.w = 0.34; this.h = 0.44; this.d = 0.3; // Halbachsen der Ellipse (Breite, Höhe, Tiefe)
this.root = new THREE.Group();
bodyRoot.add(this.root);
this.build();
this.updatePositionOnSpine();
}
build() {
clearGroup(this.root);
const info = { kind: 'head', head: this };
const pivot = new THREE.Group();
pivot.rotation.set(this.rot[0], this.rot[1], this.rot[2]);
this.pivot = pivot;
this.root.add(pivot);
pivot.add(createBiomorphicJoint(0.12, info));
pivot.add(makeMarker('spine', info));
const len = this.neckLength;
const neck = new THREE.Mesh(cached('neck', () => new THREE.CylinderGeometry(0.07, 0.09, 1, 10)), mechaMaterial);
neck.scale.y = len;
neck.position.y = len / 2;
neck.castShadow = true;
neck.userData.sel = info;
neck.userData.seg = 'hals';
pivot.add(neck);
const top = new THREE.Group();
top.position.y = len;
top.add(createBiomorphicJoint(0.1, info));
pivot.add(top);
this.top = top;
// Halbkugel: Kuppel zeigt nach +z (Gesichtsseite), offene Seite zum Neck
const shell = new THREE.Mesh(
cached('hemi', () => new THREE.SphereGeometry(1, 28, 14, 0, Math.PI * 2, 0, Math.PI / 2)),
shellMaterial
);
shell.scale.set(this.w, this.d, this.h);
shell.position.set(0, this.h * 0.45, -0.04);
shell.castShadow = true;
shell.userData.sel = info;
shell.userData.seg = 'kopf';
shell.userData.shellFn = o => { shell.rotation.x = Math.PI / 2 - o * 0.7; };
top.add(shell);
applyShells(this.root);
refreshSelection();
}
updatePositionOnSpine() {
spineCurve.getPointAt(this.t, this.root.position);
spineCurve.getTangentAt(this.t, _t);
this.root.quaternion.setFromUnitVectors(UP, _t);
}
slideTo() { // Gizmo hat root verschoben -> auf nächsten Spine-Punkt projizieren
this.t = nearestSpineT(this.root.position, 0.1, 1.0);
this.updatePositionOnSpine();
}
readRot() {
const r = this.pivot.rotation;
this.rot = [r.x, r.y, r.z];
}
remove() {
bodyRoot.remove(this.root);
disposeTree(this.root);
}
}
function addHead() {
const n = heads.length;
const side = n % 2 ? 1 : -1;
const h = new HeadUnit(`Kopf ${n + 1}`, n === 0 ? 1.0 : 0.9, n === 0 ? [0, 0, 0] : [0, 0, side * 0.5 * Math.ceil(n / 2)]);
heads.push(h);
rebuildGUI();
requestRender();
return h;
}
function removeHead(i) {
if (!heads[i]) return;
if (selection && selection.owner === heads[i]) clearSelection();
heads[i].remove();
heads.splice(i, 1);
rebuildGUI();
requestRender();
}
// ============================================================
// BRUSTKÖRBE
// ============================================================
function buildMultipleRibCages() {
clearGroup(ribCagesGroup);
ribEntries.length = 0;
for (let c = 0; c < config.ribCageCount; c++) {
const cageStartT = 0.45 + c * 0.22;
if (cageStartT > 0.9) break;
const cage = new THREE.Group();
for (let i = 0; i < config.ribsPerCage; i++) {
const factor = i / config.ribsPerCage;
const t = cageStartT + factor * 0.16;
if (t > 0.95) break;
const radius = (0.65 + Math.sin(factor * Math.PI) * 0.35) * (1.0 - c * 0.15);
const ribs = [], nerves = [];
for (const side of [-1, 1]) {
const curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(side * radius * 0.65, 0.08, radius * 0.55),
new THREE.Vector3(side * radius, -0.1, 0),
new THREE.Vector3(side * radius * 0.6, -0.25, -radius * 0.35)
]);
ribs.push({ geo: new THREE.TubeGeometry(curve, 14, 0.028, 6, false) });
nerves.push({ geo: new THREE.TubeGeometry(curve, 14, 0.012, 5, false) });
}
const pair = new THREE.Group();
const rm = new THREE.Mesh(mergeGeos(ribs), mechaMaterial);
rm.castShadow = true;
rm.userData.seg = 'rippe';
const rn = new THREE.Mesh(mergeGeos(nerves), nerveCableMaterial);
rn.userData.seg = 'nerv';
pair.add(rm, rn);
cage.add(pair);
ribEntries.push({ obj: pair, t });
}
const chest = new THREE.Group();
for (const side of [-1, 1]) {
const leaf = new THREE.Mesh(leafGeo(0.46, 0.9, 0.2), shellMaterial);
leaf.castShadow = true;
leaf.userData.seg = 'brust_shell';
leaf.userData.shellFn = o => {
const a = o * 0.8;
leaf.position.set(side * (0.28 + a * 0.4), 0.05, 0.38 + a * 0.2);
leaf.rotation.y = side * (0.45 + a);
};
chest.add(leaf);
}
cage.add(chest);
ribEntries.push({ obj: chest, t: cageStartT + 0.1 });
ribCagesGroup.add(cage);
}
updateRibPositions();
}
function updateRibPositions() {
for (const e of ribEntries) spineCurve.getPointAt(e.t, e.obj.position);
}
function rebuildRibs() {
buildMultipleRibCages();
applyShells(ribCagesGroup);
}
// ============================================================
// NERVENKABEL: Bündel dünner Bezier-Kabel Spine -> Schulter -> Femur -> Tibia -> Ende
// Pro Strang zufällig Skelett- oder Shell-Material. Quasi-statisch, nur bei Änderungen neu berechnet.
// ============================================================
const CABLE_STEPS = 10;
const CABLE_RAD = 4;
const CABLE_COUNT = 4 * CABLE_STEPS + 1;
const _cbuf = new Float32Array(CABLE_COUNT * 3);
const _ta = new THREE.Vector3(), _tb = new THREE.Vector3(), _c1 = new THREE.Vector3(), _c2 = new THREE.Vector3();
const CR = [1, 0, -1, 0], SR = [0, 1, 0, -1];
const hash = (a, b) => { const x = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453; return x - Math.floor(x); };
function sampleCable(pts, outDir, slack, grav, buf) {
let o = 0;
const n = pts.length;
for (let i = 0; i < n - 1; i++) {
const a = pts[i], b = pts[i + 1];
const len = a.distanceTo(b);
const ta = i === 0 ? outDir : _ta.copy(pts[i + 1]).sub(pts[i - 1]).normalize();
const tb = (i + 1 === n - 1) ? _tb.copy(b).sub(a).normalize() : _tb.copy(pts[i + 2]).sub(a).normalize();
const k = i === 0 ? 0.45 : 0.33;
const sag = len * (i === 0 ? 0.35 : 0.06) * slack;
_c1.copy(a).addScaledVector(ta, len * k).addScaledVector(grav, sag);
_c2.copy(b).addScaledVector(tb, -len * k).addScaledVector(grav, sag);
for (let j = (i === 0 ? 0 : 1); j <= CABLE_STEPS; j++) {
const u = j / CABLE_STEPS, v = 1 - u;
const w0 = v * v * v, w1 = 3 * v * v * u, w2 = 3 * v * u * u, w3 = u * u * u;
buf[o++] = w0 * a.x + w1 * _c1.x + w2 * _c2.x + w3 * b.x;
buf[o++] = w0 * a.y + w1 * _c1.y + w2 * _c2.y + w3 * b.y;
buf[o++] = w0 * a.z + w1 * _c1.z + w2 * _c2.z + w3 * b.z;
}
}
}
function writeTube(P, n, radius, pos, nor, uv, vBase) {
let tx = P[3] - P[0], ty = P[4] - P[1], tz = P[5] - P[2];
let l = Math.hypot(tx, ty, tz) || 1; tx /= l; ty /= l; tz /= l;
let ax = Math.abs(tx) < 0.9 ? 1 : 0, ay = ax ? 0 : 1;
let d = ax * tx + ay * ty;
let nx = ax - tx * d, ny = ay - ty * d, nz = -tz * d;
l = Math.hypot(nx, ny, nz) || 1; nx /= l; ny /= l; nz /= l;
for (let j = 0; j < n; j++) {
const i0 = Math.max(j - 1, 0) * 3, i1 = Math.min(j + 1, n - 1) * 3;
tx = P[i1] - P[i0]; ty = P[i1 + 1] - P[i0 + 1]; tz = P[i1 + 2] - P[i0 + 2];
l = Math.hypot(tx, ty, tz) || 1; tx /= l; ty /= l; tz /= l;
d = nx * tx + ny * ty + nz * tz;
nx -= tx * d; ny -= ty * d; nz -= tz * d;
l = Math.hypot(nx, ny, nz) || 1; nx /= l; ny /= l; nz /= l;
const bx = ty * nz - tz * ny, by = tz * nx - tx * nz, bz = tx * ny - ty * nx;
for (let r = 0; r < CABLE_RAD; r++) {
const dx = nx * CR[r] + bx * SR[r], dy = ny * CR[r] + by * SR[r], dz = nz * CR[r] + bz * SR[r];
const vi = vBase + j * CABLE_RAD + r, v = vi * 3;
pos[v] = P[j * 3] + dx * radius; pos[v + 1] = P[j * 3 + 1] + dy * radius; pos[v + 2] = P[j * 3 + 2] + dz * radius;
nor[v] = dx; nor[v + 1] = dy; nor[v + 2] = dz;
uv[vi * 2] = j * 0.2; uv[vi * 2 + 1] = r * 0.25;
}
}
}
function updateAllCables() { for (const l of limbs) l.updateCables(); }
// ============================================================
// IK-NODES (Ziel = rot, Knick/Pole = blau-türkis)
// ============================================================
let _ikAssets = null;
function ikAssets() {
if (!_ikAssets) {
const m = c => new THREE.MeshBasicMaterial({ color: c, depthTest: false, transparent: true, opacity: 0.95 });
_ikAssets = {
targetGeo: new THREE.BoxGeometry(0.24, 0.24, 0.24), targetMat: m(0xef4444),
poleGeo: new THREE.SphereGeometry(0.13, 14, 10), poleMat: m(0x0ea5e9),
lineMat: new THREE.LineBasicMaterial({ color: 0x94a3b8, depthTest: false, transparent: true, opacity: 0.8 })
};
}
return _ikAssets;
}
// ============================================================
// GLIEDMASSEN
// ============================================================
const JOINTS = ['sh', 'fe', 'ti'];
const JOINT_NAMES = { sh: 'Schulter', fe: 'Femur', ti: 'Tibia' };
class LimbPair {
constructor(type, tOnSpine, name) {
this.type = type;
this.tOnSpine = tOnSpine;
this.name = name || (type === 'arm' ? '3-Gelenk Arm' : 'Hexapod Bein');
this.shoulderWidth = type === 'arm' ? 1.6 : 1.2;
this.lengthScale = 1.0;
// Pose pro Seite und Gelenk als Euler [x,y,z] – wird per Gizmo gesetzt, überlebt Rebuilds
const roll = type === 'arm' ? 0.4 : 0.8;
const fe = type === 'arm' ? 0.9 : -0.7;
const ti = type === 'arm' ? 0.8 : 1.2;
this.pose = {
1: { sh: [0.1, 0, roll], fe: [fe, 0, 0], ti: [ti, 0, 0] },
'-1': { sh: [0.1, 0, -roll], fe: [fe, 0, 0], ti: [ti, 0, 0] }
};
this.cables = { on: true, count: 7, slack: 0.9, thickness: 0.014, spread: 0.1, mix: 0.5 };
// IK: mode 'off' (FK per Gizmo) | 'ik' (Ziel + Knick) | 'ground' (Ziel auf den Boden gezwungen + Knick)
this.ik = { mode: 'off', groundY: type === 'arm' ? 0.55 : 0.4 };
const A = ikAssets();
this.ikNodes = {};
for (const side of [1, -1]) {
const target = new THREE.Mesh(A.targetGeo, A.targetMat);
const pole = new THREE.Mesh(A.poleGeo, A.poleMat);
target.userData = { sel: { kind: 'ikTarget', limb: this, side }, isIK: true };
pole.userData = { sel: { kind: 'ikPole', limb: this, side }, isIK: true };
for (const n of [target, pole]) { n.renderOrder = 13; n.visible = false; bodyRoot.add(n); }
this.ikNodes[side] = { target, pole };
}
this.ikLine = new THREE.LineSegments(
new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(new Float32Array(4 * 2 * 3), 3)), A.lineMat);
this.ikLine.userData = { isIK: true };
this.ikLine.frustumCulled = false;
this.ikLine.renderOrder = 12;
this.ikLine.visible = false;
bodyRoot.add(this.ikLine);
this.rootGroup = new THREE.Group();
bodyRoot.add(this.rootGroup);
this.cableMesh = new THREE.Mesh(new THREE.BufferGeometry(), [mechaMaterial, shellMaterial]);
this.cableMesh.frustumCulled = false;
this.cableMesh.userData.seg = 'kabel';
cablesGroup.add(this.cableMesh);
this.buildLimbPair();
this.updatePositionOnSpine();
this.setIKMode('ik'); // IK ist Standard (Ziel + Knick)
}
limbOf(side) { return side === 1 ? this.leftLimb : this.rightLimb; }
jointOf(side, j) {
const L = this.limbOf(side);
return j === 'sh' ? L.shoulderJoint : j === 'fe' ? L.femurGroup : L.tibiaGroup;
}
buildLimbPair() {
clearGroup(this.rootGroup);
const rootInfo = { kind: 'root', limb: this };
const girdle = new THREE.Mesh(leafGeo(0.22, 1, 0.1), mechaMaterial);
girdle.scale.y = this.shoulderWidth * 1.1;
girdle.rotation.z = Math.PI / 2;
girdle.userData.sel = rootInfo;
girdle.userData.seg = 'huefte_gurt';
this.rootGroup.add(girdle);
this.rootGroup.add(makeMarker('spine', rootInfo));
this.leftLimb = this.createSingleLimb(1);
this.rightLimb = this.createSingleLimb(-1);
this.rootGroup.add(this.leftLimb.shoulderJoint, this.rightLimb.shoulderJoint);
applyShells(this.rootGroup);
this.applyArticulation();
refreshSelection();
}
createSingleLimb(side) {
const isArm = this.type === 'arm';
const l1 = (isArm ? 0.5 : 0.6) * this.lengthScale;
const l2 = (isArm ? 1.1 : 1.4) * this.lengthScale;
const l3 = (isArm ? 1.0 : 1.3) * this.lengthScale;
const boneC = cached('boneC', () => new THREE.CylinderGeometry(0.05, 0.045, 1, 10));
const boneF = cached('boneF', () => new THREE.CylinderGeometry(0.045, 0.035, 1, 10));
const boneT = cached('boneT', () => new THREE.CylinderGeometry(0.05, 0.032, 1, 10));
const sel = j => ({ kind: 'joint', limb: this, side, joint: j });
const shoulderJoint = new THREE.Group();
shoulderJoint.position.set(side * (this.shoulderWidth / 2), 0, 0);
shoulderJoint.add(createBiomorphicJoint(0.18, sel('sh')));
shoulderJoint.add(makeMarker('joint', sel('sh')));
const coxaBone = new THREE.Mesh(boneC, mechaMaterial);
coxaBone.scale.y = l1;
coxaBone.position.y = -l1 / 2;
coxaBone.castShadow = true;
coxaBone.userData.sel = sel('sh');
coxaBone.userData.seg = 'coxa';
shoulderJoint.add(coxaBone);
const femurGroup = new THREE.Group();
femurGroup.position.set(0, -l1, 0);
shoulderJoint.add(femurGroup);
femurGroup.add(createBiomorphicJoint(0.15, sel('fe')));
femurGroup.add(makeMarker('joint', sel('fe')));
const femurBone = new THREE.Mesh(boneF, mechaMaterial);
femurBone.scale.y = l2;
femurBone.position.y = -l2 / 2;
femurBone.castShadow = true;
femurBone.userData.sel = sel('fe');
femurBone.userData.seg = 'femur';
femurGroup.add(femurBone);
const femurShell = new THREE.Mesh(leafGeo(0.32, 1, 0.14), shellMaterial);
femurShell.scale.y = l2 * 0.85;
femurShell.castShadow = true;
femurShell.userData.sel = sel('fe');
femurShell.userData.seg = 'femur_shell';
femurShell.userData.shellFn = o => {
const a = o * 0.7;
femurShell.position.set(side * a * 0.2, -l2 * 0.45, side * (0.08 + a * 0.15));
femurShell.rotation.y = side * (0.3 + a);
};
femurGroup.add(femurShell);
const tibiaGroup = new THREE.Group();
tibiaGroup.position.set(0, -l2, 0);
femurGroup.add(tibiaGroup);
tibiaGroup.add(createBiomorphicJoint(0.13, sel('ti')));
tibiaGroup.add(makeMarker('joint', sel('ti')));
const tibiaBone = new THREE.Mesh(boneT, mechaMaterial);
tibiaBone.scale.y = l3;
tibiaBone.position.y = -l3 / 2;
tibiaBone.castShadow = true;
tibiaBone.userData.sel = sel('ti');
tibiaBone.userData.seg = 'tibia';
tibiaGroup.add(tibiaBone);
const tibiaShell = new THREE.Mesh(leafGeo(0.26, 1, 0.12), shellMaterial);
tibiaShell.scale.y = l3 * 0.8;
tibiaShell.castShadow = true;
tibiaShell.userData.sel = sel('ti');
tibiaShell.userData.seg = 'tibia_shell';
tibiaShell.userData.shellFn = o => {
const a = o * 0.7;
tibiaShell.position.set(side * a * 0.2, -l3 * 0.4, side * (0.06 + a * 0.15));
tibiaShell.rotation.y = side * (0.25 + a);
};
tibiaGroup.add(tibiaShell);
const nerveCurve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0, 0, 0.04),
new THREE.Vector3(side * 0.06, -l2 * 0.5, 0.05),
new THREE.Vector3(0, -l2, -0.04),
new THREE.Vector3(-side * 0.04, -l2 - l3 * 0.6, 0.04),
new THREE.Vector3(0, -l2 - l3, 0)
]);
const nerveTube = new THREE.Mesh(new THREE.TubeGeometry(nerveCurve, 16, 0.02, 5, false), nerveCableMaterial);
nerveTube.userData.seg = 'nerv';
femurGroup.add(nerveTube);
const endJoint = new THREE.Group();
endJoint.position.set(0, -l3, 0);
tibiaGroup.add(endJoint);
endJoint.add(createBiomorphicJoint(0.1)); // Handgelenk / Fußgelenk
if (isArm) {
// Hand: Handballen am Gelenk + Mittelfinger + zwei gespreizte Seitenfinger (Shell-Material)
const palm = new THREE.Mesh(cached('palm', () => new THREE.SphereGeometry(0.09, 12, 10)), mechaMaterial);
palm.scale.set(1, 0.85, 1.1);
palm.position.y = -0.06;
palm.castShadow = true;
palm.userData.seg = 'hand';
endJoint.add(palm);
const mid = new THREE.Mesh(leafGeo(0.15, 0.55, 0.1), shellMaterial);
mid.position.set(0, -0.3, 0.03);
mid.rotation.x = 0.3;
mid.castShadow = true;
mid.userData.seg = 'finger';
endJoint.add(mid);
for (const s of [-1, 1]) {
const pivot = new THREE.Group();
pivot.position.set(0, -0.07, 0);
pivot.rotation.z = s * 0.5;
const f = new THREE.Mesh(leafGeo(0.1, 0.42, 0.08), shellMaterial);
f.position.set(0, -0.22, 0.03);
f.rotation.x = 0.3;
f.castShadow = true;
f.userData.seg = 'finger';
pivot.add(f);
endJoint.add(pivot);
}
} else {
const foot = new THREE.Mesh(cached('foot', () => new THREE.SphereGeometry(0.07, 10, 8)), mechaMaterial);
foot.position.y = -0.04;
foot.userData.seg = 'fuss';
endJoint.add(foot);
const spike = new THREE.Mesh(cached('spike', () => new THREE.ConeGeometry(0.045, 0.4, 8)), mechaMaterial);
spike.position.y = -0.2;
spike.rotation.x = Math.PI;
spike.castShadow = true;
spike.userData.seg = 'fuss';
endJoint.add(spike);
}
return { shoulderJoint, femurGroup, tibiaGroup, endJoint, side, l1, l2, l3 };
}
updatePositionOnSpine() {
spineCurve.getPointAt(this.tOnSpine, this.rootGroup.position);
spineCurve.getTangentAt(this.tOnSpine, _t);
this.rootGroup.quaternion.setFromUnitVectors(UP, _t);
this.updateCables();
}
slideTo() {
this.tOnSpine = nearestSpineT(this.rootGroup.position, 0.05, 0.95);
this.updatePositionOnSpine();
}
// Pose -> Gelenke
applyArticulation() {
if (!this.leftLimb || !this.rightLimb) return;
for (const side of [1, -1]) {
if (this.ik.mode !== 'off') { this.solveSide(side); continue; }
for (const j of JOINTS) this.jointOf(side, j).rotation.set(...this.pose[side][j]);
}
this.updateIKLines();
this.updateCables();
}
// Gelenkpositionen [Schulter, Femur, Tibia, Ende] im Körper-Koordinatensystem
chainLocal(side) {
bodyRoot.updateWorldMatrix(true, false);
this.rootGroup.updateMatrixWorld(true);
_inv.copy(bodyRoot.matrixWorld).invert();
const L = this.limbOf(side);
return [L.shoulderJoint, L.femurGroup, L.tibiaGroup, L.endJoint]
.map(j => new THREE.Vector3().setFromMatrixPosition(new THREE.Matrix4().multiplyMatrices(_inv, j.matrixWorld)));
}
updateIKVisibility() {
const on = this.ik.mode !== 'off';
for (const side of [1, -1]) { this.ikNodes[side].target.visible = on; this.ikNodes[side].pole.visible = on; }
this.ikLine.visible = on;
}
// Nodes aus der aktuellen FK-Pose initialisieren: Ziel = Ende, Knick = Knie nach außen versetzt
initIKNodes() {
for (const side of [1, -1]) {
const [S, F, K, E] = this.chainLocal(side);
this.ikNodes[side].target.position.copy(E);
let v = K.clone().sub(S.clone().add(E).multiplyScalar(0.5));
if (v.length() < 0.05) v.set(side, 0.6, 0);
this.ikNodes[side].pole.position.copy(K).addScaledVector(v.normalize(), 1.0);
}
}
setIKMode(mode) {
const was = this.ik.mode;
this.ik.mode = mode;
if (mode !== 'off' && was === 'off') this.initIKNodes();
this.updateIKVisibility();
if (selection && selection.owner === this) clearSelection();
this.applyArticulation();
requestRender();
}
onIKEdited() { this.applyArticulation(); }
// IK für eine Seite: FABRIK über [Coxa, Femur, Tibia] + Drehung der Gelenkkette in die Pole-Ebene,
// danach Gelenkrotationen mit minimaler Verdrehung aus den Knochenrichtungen
solveSide(side) {
const L = this.limbOf(side), nodes = this.ikNodes[side];
const p = this.chainLocal(side);
const S = p[0];
const T = nodes.target.position.clone();
if (this.ik.mode === 'ground') { // Ziel in Weltkoordinaten auf Bodenhöhe zwingen
bodyRoot.localToWorld(T);
T.y = this.ik.groundY;
const Tdes = T.clone();
bodyRoot.worldToLocal(Tdes);
nodes.target.position.copy(Tdes); // Node bleibt an der gewünschten Stelle auf dem Boden
// außer Reichweite: Fuß rutscht horizontal zum Körper, damit der Bodenkontakt erhalten bleibt
const Sw = bodyRoot.localToWorld(S.clone());
const reach = L.l1 + L.l2 + L.l3 - 0.01, dy = T.y - Sw.y;
const maxH = Math.sqrt(Math.max(0, reach * reach - dy * dy));
const hx = T.x - Sw.x, hz = T.z - Sw.z, hl = Math.hypot(hx, hz);
if (hl > maxH && hl > 1e-6) { T.x = Sw.x + hx / hl * maxH; T.z = Sw.z + hz / hl * maxH; }
bodyRoot.worldToLocal(T);
}
const P = nodes.pole.position;
const len = [L.l1, L.l2, L.l3];
const dir = (a, b, fb) => { const v = new THREE.Vector3().subVectors(a, b); return v.lengthSq() < 1e-10 ? fb.clone() : v.normalize(); };
const axis = dir(T, S, new THREE.Vector3(0, -1, 0));
const dist = T.distanceTo(S), total = len[0] + len[1] + len[2];
// Knickrichtung (Pole senkrecht zur Achse)
const perp = (v, ax) => v.clone().addScaledVector(ax, -v.dot(ax));
if (dist >= total - 1e-4) {
let c = 0;
for (let i = 1; i <= 3; i++) { c += len[i - 1]; p[i] = S.clone().addScaledVector(axis, c); }
} else {
// Start ohne Biegung vermeiden -> Richtung Pole anschieben
const kOff = perp(p[2].clone().sub(S), axis);
if (kOff.length() < 0.05) {
const pp = perp(P.clone().sub(S), axis);
if (pp.lengthSq() > 1e-8) { pp.normalize(); p[1].addScaledVector(pp, 0.2); p[2].addScaledVector(pp, 0.3); }
}
for (let it = 0; it < 16; it++) {
p[3].copy(T);
for (let i = 2; i >= 1; i--) p[i] = p[i + 1].clone().addScaledVector(dir(p[i], p[i + 1], axis), len[i]);
p[0].copy(S);
for (let i = 1; i <= 3; i++) p[i] = p[i - 1].clone().addScaledVector(dir(p[i], p[i - 1], axis), len[i - 1]);
if (p[3].distanceTo(T) < 1e-3) break;
}
}
// Gelenkkette um die Achse Schulter->Ende in die Ebene (Schulter, Ende, Pole) drehen
const ax2 = dir(p[3], p[0], axis);
const vK = perp(p[2].clone().sub(p[0]), ax2), vP = perp(P.clone().sub(p[0]), ax2);
if (vK.length() > 1e-3 && vP.length() > 1e-3) {
const ang = Math.atan2(ax2.dot(vK.clone().cross(vP)), vK.dot(vP));
for (const i of [1, 2]) p[i] = p[i].clone().sub(p[0]).applyAxisAngle(ax2, ang).add(p[0]);
}
// Rotationen: -y des Gelenks zeigt entlang des Knochens; kürzester Bogen vom Eltern-Frame
const down = new THREE.Vector3(0, -1, 0);
let Wprev = this.rootGroup.quaternion.clone();
JOINTS.forEach((j, k) => {
const d = dir(p[k + 1], p[k], down);
const ref = down.clone().applyQuaternion(Wprev);
const Wk = new THREE.Quaternion().setFromUnitVectors(ref, d).multiply(Wprev);
const local = Wprev.clone().invert().multiply(Wk);
const e = new THREE.Euler().setFromQuaternion(local, 'XYZ');
this.pose[side][j] = [e.x, e.y, e.z];
this.jointOf(side, j).rotation.set(e.x, e.y, e.z);
Wprev = Wk;
});
this._ikK = this._ikK || {}; this._ikK[side] = { K: p[2].clone(), E: p[3].clone() };
}
updateIKLines() {
if (this.ik.mode === 'off' || !this._ikK) return;
const a = this.ikLine.geometry.attributes.position.array;
let o = 0;
for (const side of [1, -1]) {
const k = this._ikK[side]; if (!k) continue;
const n = this.ikNodes[side];
for (const v of [k.K, n.pole.position, k.E, n.target.position]) { a[o++] = v.x; a[o++] = v.y; a[o++] = v.z; }
}
this.ikLine.geometry.attributes.position.needsUpdate = true;
}
restoreIK(d) {
if (!d) return;
const isNum3 = v => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
if (Number.isFinite(d.groundY)) this.ik.groundY = d.groundY;
for (const side of ['1', '-1']) {
if (d.targets && isNum3(d.targets[side])) this.ikNodes[side].target.position.fromArray(d.targets[side]);
if (d.poles && isNum3(d.poles[side])) this.ikNodes[side].pole.position.fromArray(d.poles[side]);
}
this.ik.mode = ['off', 'ik', 'ground'].includes(d.mode) ? d.mode : 'off';
this.updateIKVisibility();
this.applyArticulation();
}
// Gizmo hat ein Gelenk gedreht -> Pose speichern (+ optional spiegeln)
onJointEdited(side, j) {
const r = this.jointOf(side, j).rotation;
this.pose[side][j] = [r.x, r.y, r.z];
if (config.mirror) {
this.pose[-side][j] = [r.x, -r.y, -r.z];
this.jointOf(-side, j).rotation.set(...this.pose[-side][j]);
}
this.updateCables();
}
updateCables() {
const c = this.cables;
this.cableMesh.visible = c.on;
if (!c.on || !this.leftLimb) return;
this.rootGroup.updateMatrixWorld(true);
_inv.copy(bodyRoot.matrixWorld).invert(); // relativ zu bodyRoot -> unabhängig von Hip-Transform
_grav.set(0, -1, 0).applyQuaternion(_q.copy(bodyRoot.quaternion).invert());
const rootM = this.rootGroup.matrix;
const N = Math.round(c.count);
const ctx = {}, specs = [];
for (const side of [1, -1]) {
const L = this.limbOf(side);
ctx[side] = {
mats: [L.shoulderJoint, L.femurGroup, L.tibiaGroup, L.endJoint]
.map(j => new THREE.Matrix4().multiplyMatrices(_inv, j.matrixWorld)),
out: new THREE.Vector3(side, 0, 0).applyQuaternion(this.rootGroup.quaternion)
};
for (let k = 0; k < N; k++) specs.push({ side, k, shell: hash(k + 1, side * 3.7) < c.mix });
}
// Skelett-Material-Stränge zuerst, dann Shell-Material-Stränge -> zwei Material-Gruppen
const mecha = specs.filter(s => !s.shell), shellS = specs.filter(s => s.shell);
const ordered = mecha.concat(shellS);
const S = ordered.length, vPer = CABLE_COUNT * CABLE_RAD;
const idxPer = (CABLE_COUNT - 1) * CABLE_RAD * 6;
const pos = new Float32Array(S * vPer * 3), nor = new Float32Array(S * vPer * 3), uv = new Float32Array(S * vPer * 2);
const idx = new Uint16Array(S * idxPer);
let ii = 0;
ordered.forEach((sp, strand) => {
const { mats, out } = ctx[sp.side];
const k = sp.k;
const ang = k * 2.39996, rad = c.spread * Math.sqrt((k + 0.5) / N);
const ox = Math.cos(ang) * rad, oz = Math.sin(ang) * rad;
const pts = [new THREE.Vector3(ox * 0.5, (k - (N - 1) / 2) * 0.03, -0.06 + oz * 0.3).applyMatrix4(rootM)];
for (const m of mats) pts.push(new THREE.Vector3(ox, 0, -0.11 + oz).applyMatrix4(m));
sampleCable(pts, out, c.slack * (0.7 + 0.6 * ((k * 0.618) % 1)), _grav, _cbuf);
const vBase = strand * vPer;
writeTube(_cbuf, CABLE_COUNT, c.thickness, pos, nor, uv, vBase);
for (let j = 0; j < CABLE_COUNT - 1; j++) {
for (let r = 0; r < CABLE_RAD; r++) {
const r1 = (r + 1) % CABLE_RAD;
const a = vBase + j * CABLE_RAD + r, b = vBase + j * CABLE_RAD + r1;
const cc = vBase + (j + 1) * CABLE_RAD + r, dd = vBase + (j + 1) * CABLE_RAD + r1;
idx[ii++] = a; idx[ii++] = b; idx[ii++] = cc;
idx[ii++] = b; idx[ii++] = dd; idx[ii++] = cc;
}
}
});
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
g.setIndex(new THREE.BufferAttribute(idx, 1));
if (mecha.length) g.addGroup(0, mecha.length * idxPer, 0);
if (shellS.length) g.addGroup(mecha.length * idxPer, shellS.length * idxPer, 1);
this.cableMesh.geometry.dispose();
this.cableMesh.geometry = g;
}
remove() {
bodyRoot.remove(this.rootGroup);
disposeTree(this.rootGroup);
cablesGroup.remove(this.cableMesh);
this.cableMesh.geometry.dispose();
for (const side of [1, -1]) bodyRoot.remove(this.ikNodes[side].target, this.ikNodes[side].pole);
bodyRoot.remove(this.ikLine);
this.ikLine.geometry.dispose();
}
}
function addLimbPair(type, t, name) {
const l = new LimbPair(type, t, name);
limbs.push(l);
rebuildGUI();
requestRender();
return l;
}
function removeLimbPair(i) {
if (!limbs[i]) return;
if (selection && selection.owner === limbs[i]) clearSelection();
limbs[i].remove();
limbs.splice(i, 1);
rebuildGUI();
requestRender();
}
// ============================================================
// AUSWAHL + GIZMO (Bone anklicken -> Bewegen / Drehen)
// ============================================================
let selection = null;
function makeSelection(info) {
switch (info.kind) {
case 'hip':
return {
owner: 'hip', label: 'Hip', modes: ['translate', 'rotate'],
objFor: () => bodyRoot,
onChange: mode => { updateLight(); if (mode === 'rotate') updateAllCables(); }
};
case 'joint': {
const { limb, side, joint } = info;
return {
owner: limb, label: `${limb.name} · ${JOINT_NAMES[joint]} ${side === 1 ? 'L' : 'R'}`, modes: ['rotate'],
objFor: () => limb.jointOf(side, joint),
onChange: () => limb.onJointEdited(side, joint)
};
}
case 'ikTarget':
case 'ikPole': {
const { limb, side } = info, isT = info.kind === 'ikTarget';
return {
owner: limb, label: `${limb.name} · ${isT ? 'IK-Ziel' : 'Knick'} ${side === 1 ? 'L' : 'R'}`, modes: ['translate'],
objFor: () => isT ? limb.ikNodes[side].target : limb.ikNodes[side].pole,
onChange: () => limb.onIKEdited()
};
}
case 'root': {
const { limb } = info;
return {
owner: limb, label: `${limb.name} · Position an der Spine`, modes: ['translate'],
objFor: () => limb.rootGroup,
onChange: () => limb.slideTo()
};
}
case 'head': {
const { head } = info;
return {
owner: head, label: head.name, modes: ['rotate', 'translate'],
objFor: mode => mode === 'translate' ? head.root : head.pivot,
onChange: mode => { if (mode === 'translate') head.slideTo(); else head.readRot(); }
};
}
}
}
function selectInfo(info) {
// Gelenke einer IK-gesteuerten Gliedmaße: stattdessen das IK-Ziel auswählen
if (info.kind === 'joint' && info.limb.ik.mode !== 'off') info = { kind: 'ikTarget', limb: info.limb, side: info.side };
selection = makeSelection(info);
refreshSelection();
}
function clearSelection() {
selection = null;
refreshSelection();
}
function refreshSelection() {
if (!tc) return;
const label = document.getElementById('sel');
if (!selection) {
tc.detach();
label.textContent = '';
} else {
const mode = selection.modes.includes(config.mode) ? config.mode : selection.modes[0];
selection.mode = mode;
tc.setMode(mode);
tc.setSpace(mode === 'translate' ? 'world' : 'local');
tc.attach(selection.objFor(mode));
label.textContent = selection.label;
}
updateModeButtons();
requestRender();
}
function setMode(m) {
config.mode = m;
refreshSelection();
}
function updateModeButtons() {
const eff = selection ? selection.mode : config.mode;
const bt = document.getElementById('mode-translate-btn'), br = document.getElementById('mode-rotate-btn');
bt.classList.toggle('on', eff === 'translate');
br.classList.toggle('on', eff === 'rotate');
bt.disabled = !!selection && !selection.modes.includes('translate');
br.disabled = !!selection && !selection.modes.includes('rotate');
}
function onKeyDown(e) {
if (e.target.tagName === 'INPUT') return;
const k = e.key.toLowerCase();
if (k === 'w') setMode('translate');
else if (k === 'e') setMode('rotate');
else if (k === 'n') setNodesMode(!config.showAllNodes);
else if (k === 'escape') clearSelection();
}
// ============================================================
// EXPORT: Pose-Skeleton (OpenPose-Stil), Tiefe, Komponenten-Maske, Farbbild -> PNG / ZIP
// Alles wird exakt aus der 3D-Szene gerendert – keine KI-Modelle nötig.
// ============================================================
const SEG = {
wirbel: '#e6194b', rippe: '#3cb44b', brust_shell: '#4363d8', nerv: '#f58231', kopf: '#911eb4',
hals: '#46f0f0', gelenk: '#f032e6', huefte_gurt: '#bcf60c', coxa: '#fabebe', femur: '#008080',
femur_shell: '#e6beff', tibia: '#9a6324', tibia_shell: '#fffac8', hand: '#800000', finger: '#aaffc3',
fuss: '#808000', kabel: '#ffd8b1'
};
const segMats = {};
const segMat = c => segMats[c] || (segMats[c] = new THREE.MeshBasicMaterial({ color: SEG[c] || '#808080', toneMapped: false, side: THREE.DoubleSide }));
const OP = ['#ff0000', '#ff5500', '#ffaa00', '#ffff00', '#aaff00', '#55ff00', '#00ff00', '#00ff55', '#00ffaa',
'#00ffff', '#00aaff', '#0055ff', '#0000ff', '#5500ff', '#aa00ff', '#ff00ff', '#ff00aa', '#ff0055'];
// Export-Ausschnitt in Fenster-Pixeln (entspricht dem gestrichelten Rahmen)
function exportRect() {
const ww = innerWidth, hh = innerHeight;
if (config.exportFormat === 'win') return { x: 0, y: 0, w: ww, h: hh };
const [a, b] = config.exportFormat.split(':').map(Number);
const asp = a / b;
let w, h;
if (asp > ww / hh) { w = ww * 0.94; h = w / asp; } else { h = hh * 0.94; w = h * asp; }
return { x: (ww - w) / 2, y: (hh - h) / 2, w, h };
}
function updateFrame() {
const f = document.getElementById('frame');
if (config.exportFormat === 'win') { f.style.display = 'none'; return; }
const r = exportRect();
Object.assign(f.style, { display: 'block', left: r.x + 'px', top: r.y + 'px', width: r.w + 'px', height: r.h + 'px' });
}
// Bones als Linien: Spine, Hals/Kopf, pro Limb-Paar Gürtel -> Schulter -> Femur -> Tibia -> Ende
function collectBones() {
bodyRoot.updateMatrixWorld(true);
const W = v => bodyRoot.localToWorld(v.clone());
const wp = o => o.getWorldPosition(new THREE.Vector3());
const bones = [];
const sp = [];
for (let i = 0; i <= 8; i++) sp.push(W(spineCurve.getPointAt(i / 8)));
for (let i = 0; i < 8; i++) bones.push({ a: sp[i], b: sp[i + 1], color: OP[(i * 2) % 18] });
heads.forEach(h => {
const base = wp(h.root), top = wp(h.top);
const face = h.top.localToWorld(new THREE.Vector3(0, h.h * 0.45, h.d * 0.6));
bones.push({ a: base, b: top, color: OP[15] }, { a: top, b: face, color: OP[16] });
});
limbs.forEach((l, li) => {
const root = wp(l.rootGroup);
for (const side of [1, -1]) {
const L = l.limbOf(side);
const pts = [root, wp(L.shoulderJoint), wp(L.femurGroup), wp(L.tibiaGroup), wp(L.endJoint)];
const start = (li * 3 + (side === 1 ? 0 : 9)) % 18;
for (let k = 0; k < 4; k++) bones.push({ a: pts[k], b: pts[k + 1], color: OP[(start + k) % 18] });
}
});
return bones;
}
const hexA = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)},${parseInt(hex.slice(3, 5), 16)},${parseInt(hex.slice(5, 7), 16)},${a})`;
function drawPose(W, H, r) {
const cv = document.createElement('canvas');
cv.width = W; cv.height = H;
const g = cv.getContext('2d');
g.fillStyle = '#000'; g.fillRect(0, 0, W, H);
camera.updateMatrixWorld();
const sw = Math.max(2, H / 128), kx = W / r.w, ky = H / r.h;
const P = v => {
const p = v.clone().project(camera);
return p.z > 1 ? null : [((p.x + 1) / 2 * innerWidth - r.x) * kx, ((1 - p.y) / 2 * innerHeight - r.y) * ky];
};
const bones = collectBones();
for (const b of bones) {
const a = P(b.a), c = P(b.b);
if (!a || !c) continue;
const dx = c[0] - a[0], dy = c[1] - a[1], len = Math.hypot(dx, dy);
if (len < 1) continue;
g.save();
g.translate((a[0] + c[0]) / 2, (a[1] + c[1]) / 2);
g.rotate(Math.atan2(dy, dx));
g.beginPath(); g.ellipse(0, 0, len / 2, sw, 0, 0, Math.PI * 2);
g.fillStyle = hexA(b.color, 0.6); g.fill();
g.restore();
}
for (const b of bones) for (const v of [b.a, b.b]) {
const p = P(v); if (!p) continue;
g.beginPath(); g.arc(p[0], p[1], sw * 0.9, 0, Math.PI * 2);
g.fillStyle = b.color; g.fill();
}
return cv;
}
// Render in Offscreen-Target ohne MSAA/Tone-Mapping (exakte Farben/Tiefe) -> Canvas
function renderRT(W, H) {
const rt = new THREE.WebGLRenderTarget(W, H, {
minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, format: THREE.RGBAFormat, type: THREE.UnsignedByteType
});
renderer.setRenderTarget(rt);
renderer.render(scene, camera);
renderer.setRenderTarget(null);
const px = new Uint8Array(W * H * 4);
renderer.readRenderTargetPixels(rt, 0, 0, W, H, px);
rt.dispose();
const cv = document.createElement('canvas');
cv.width = W; cv.height = H;
const ctx = cv.getContext('2d'), img = ctx.createImageData(W, H);
for (let y = 0; y < H; y++) img.data.set(px.subarray((H - 1 - y) * W * 4, (H - y) * W * 4), y * W * 4);
ctx.putImageData(img, 0, 0);
return cv;
}
function renderColor(W, H) {
const pr = renderer.getPixelRatio();
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
renderer.render(scene, camera);
const cv = document.createElement('canvas');
cv.width = W; cv.height = H;
cv.getContext('2d').drawImage(renderer.domElement, 0, 0);
renderer.setPixelRatio(pr);
renderer.setSize(innerWidth, innerHeight);
return cv;
}
function renderDepth(W, H) {
// Near/Far eng an die tatsächliche Tiefenausdehnung der Figur legen -> voller Kontrast (nah = weiß, fern = schwarz)
camera.getWorldDirection(_camDir);
let zmin = Infinity, zmax = -Infinity;
collectBones().forEach(b => [b.a, b.b].forEach(v => {
const z = _w1.copy(v).sub(camera.position).dot(_camDir);
zmin = Math.min(zmin, z); zmax = Math.max(zmax, z);
}));
camera.near = Math.max(0.05, zmin - 0.5); camera.far = zmax + 0.5;
camera.updateProjectionMatrix();
const mat = new THREE.MeshDepthMaterial({ side: THREE.DoubleSide });
scene.overrideMaterial = mat;
scene.background = new THREE.Color(0x000000);
const fv = floorMesh.visible; floorMesh.visible = false;
const cv = renderRT(W, H);
floorMesh.visible = fv;
scene.overrideMaterial = null;
mat.dispose();
return cv;
}
function renderMask(W, H) {
const undo = [];
bodyRoot.traverse(o => {
if (!o.isMesh) return;
if (o.userData.seg) {
const m = o.material, sm = segMat(o.userData.seg);
undo.push(() => { o.material = m; });
o.material = Array.isArray(m) ? m.map(() => sm) : sm;
} else if (o.visible) {
undo.push(() => { o.visible = true; });
o.visible = false;
}
});
scene.background = new THREE.Color(0x000000);
const fv = floorMesh.visible; floorMesh.visible = false;
const cv = renderRT(W, H);
floorMesh.visible = fv;
undo.forEach(f => f());
return cv;
}
function hideHelpers() {
const st = [];
const hide = o => { st.push([o, o.visible]); o.visible = false; };
nodeHandles.forEach(hide); hide(hipHandle); hide(spineLine); hide(tc);
bodyRoot.traverse(o => { if (o.userData.isMarker || o.userData.isIK) hide(o); });
return () => st.forEach(([o, v]) => { o.visible = v; });
}
async function buildMaps(which) {
const r = exportRect(), H = config.exportH, W = Math.round(H * r.w / r.h);
if (spineDirty) { spineDirty = false; updateSpine(); }
bodyRoot.updateMatrixWorld(true);
const out = {};
if (which.includes('pose')) out.pose = drawPose(W, H, r);
const restoreHelpers = hideHelpers();
const oldBg = scene.background, oldNear = camera.near, oldFar = camera.far;
camera.setViewOffset(innerWidth, innerHeight, r.x, r.y, r.w, r.h);
try {
if (which.includes('color')) out.color = renderColor(W, H);
if (which.includes('depth')) out.depth = renderDepth(W, H);
if (which.includes('mask')) out.mask = renderMask(W, H);
} finally {
camera.clearViewOffset();
camera.near = oldNear; camera.far = oldFar;
camera.updateProjectionMatrix();
scene.background = oldBg;
restoreHelpers();
requestRender();
}
return out;
}
const canvasBlob = cv => new Promise(res => cv.toBlob(res, 'image/png'));
function saveBlob(blob, name) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
// minimaler ZIP-Writer (STORE, ohne Kompression – PNGs sind schon komprimiert)
const CRC_T = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
function crc32(b) { let c = 0xFFFFFFFF; for (let i = 0; i < b.length; i++) c = CRC_T[(c ^ b[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; }
function makeZip(files) {
const enc = new TextEncoder(), parts = [], central = [];
let offset = 0;
for (const f of files) {
const name = enc.encode(f.name), crc = crc32(f.data), size = f.data.length;
const h = new DataView(new ArrayBuffer(30));
h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(6, 0x0800, true);
h.setUint16(12, 0x21, true); h.setUint32(14, crc, true); h.setUint32(18, size, true); h.setUint32(22, size, true);
h.setUint16(26, name.length, true);
parts.push(new Uint8Array(h.buffer), name, f.data);
const c = new DataView(new ArrayBuffer(46));
c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(8, 0x0800, true);
c.setUint16(14, 0x21, true); c.setUint32(16, crc, true); c.setUint32(20, size, true); c.setUint32(24, size, true);
c.setUint16(28, name.length, true); c.setUint32(42, offset, true);
central.push(new Uint8Array(c.buffer), name);
offset += 30 + name.length + size;
}
const cdSize = central.reduce((s, a) => s + a.length, 0);
const e = new DataView(new ArrayBuffer(22));
e.setUint32(0, 0x06054b50, true); e.setUint16(8, files.length, true); e.setUint16(10, files.length, true);
e.setUint32(12, cdSize, true); e.setUint32(16, offset, true);
return new Blob([...parts, ...central, new Uint8Array(e.buffer)], { type: 'application/zip' });
}
async function exportOne(kind) {
if (exporting) return;
exporting = true;
try {
const m = await buildMaps([kind]);
saveBlob(await canvasBlob(m[kind]), `mecha-rig-${kind}-${Date.now()}.png`);
} finally { exporting = false; }
}
async function exportZip() {
if (exporting) return;
exporting = true;
try {
const m = await buildMaps(['color', 'pose', 'depth', 'mask']);
const files = [];
for (const k of ['color', 'pose', 'depth', 'mask'])
files.push({ name: `${k}.png`, data: new Uint8Array(await (await canvasBlob(m[k])).arrayBuffer()) });
files.push({ name: 'mask_legend.json', data: new TextEncoder().encode(JSON.stringify(SEG, null, 2)) });
saveBlob(makeZip(files), `mecha-rig-maps-${Date.now()}.zip`);
} finally { exporting = false; }
}
// ============================================================
// ZUFALL: Anatomie (Struktur) und Pose
// ============================================================
const rndR = (a, b) => a + Math.random() * (b - a);
const rndI = (a, b) => Math.floor(rndR(a, b + 1));
function randomPose() {
// Spine leicht biegen (relativ zur Ausgangsform, kein Abdriften), Köpfe drehen, Limbs neu ausrichten
spineRest.forEach((v, i) => spineNodes[i + 1].set(v.x + rndR(-0.35, 0.35), v.y + rndR(-0.15, 0.15), v.z + rndR(-0.45, 0.45)));
heads.forEach(h => {
h.rot = [h.restRot[0] + rndR(-0.3, 0.3), h.restRot[1] + rndR(-0.6, 0.6), h.restRot[2] + rndR(-0.25, 0.25)];
h.pivot.rotation.set(...h.rot);
});
updateSpine();
for (const l of limbs) {
for (const side of [1, -1]) {
const L = l.limbOf(side), n = l.ikNodes[side];
if (l.ik.mode === 'off') {
l.pose[side] = {
sh: [rndR(-0.5, 0.6), side * rndR(-0.3, 0.3), side * rndR(0.1, 1.2)],
fe: [rndR(-1.3, 1.2), rndR(-0.2, 0.2), 0],
ti: [rndR(0.1, 1.9), 0, 0]
};
continue;
}
const S = l.chainLocal(side)[0], reach = L.l1 + L.l2 + L.l3;
if (l.ik.mode === 'ground')
n.target.position.set(S.x + side * rndR(0.25, 0.75) * reach, S.y - reach * 0.7, S.z + rndR(-0.5, 0.5) * reach * 0.6);
else
n.target.position.copy(S).add(new THREE.Vector3(side * rndR(0.2, 1), rndR(-1, 0.4), rndR(-0.5, 1)).normalize().multiplyScalar(rndR(0.5, 0.95) * reach));
n.pole.position.copy(S).add(new THREE.Vector3(side * rndR(0.5, 1.6), rndR(0.3, 1.6), rndR(-1, 1)));
}
l.applyArticulation();
}
requestRender();
}
function randomAnatomy() {
clearSelection();
config.vertebraeCount = rndI(14, 34);
config.ribCageCount = rndI(1, 3);
config.ribsPerCage = rndI(4, 9);
spineNodes[1].set(rndR(-0.5, 0.5), rndR(1.2, 2.0), rndR(-0.8, 0.8));
spineNodes[2].set(rndR(-0.6, 0.6), rndR(2.6, 3.6), rndR(-0.8, 0.8));
spineNodes[3].set(rndR(-0.5, 0.5), rndR(4.0, 5.4), rndR(-0.4, 0.4));
spineRest = [1, 2, 3].map(i => spineNodes[i].clone());
limbs.forEach(l => l.remove()); limbs.length = 0;
heads.forEach(h => h.remove()); heads.length = 0;
const nH = rndI(1, 3);
for (let i = 0; i < nH; i++) {
const rot = i === 0 ? [rndR(-0.2, 0.2), rndR(-0.4, 0.4), rndR(-0.2, 0.2)]
: [rndR(-0.3, 0.3), rndR(-0.6, 0.6), (Math.random() < 0.5 ? -1 : 1) * rndR(0.3, 0.9)];
const h = new HeadUnit(`Kopf ${i + 1}`, i === 0 ? 1.0 : rndR(0.78, 0.96), rot);
h.neckLength = rndR(0.25, 1.0); h.w = rndR(0.2, 0.6); h.h = rndR(0.25, 0.7); h.d = rndR(0.15, 0.5);
h.build();
heads.push(h);
}
const nA = rndI(0, 3), nL = rndI(1, 3);
const make = (type, t, name) => {
const l = new LimbPair(type, t, name);
l.lengthScale = rndR(0.7, 1.6);
l.shoulderWidth = rndR(1.0, 2.4);
Object.assign(l.cables, { count: rndI(3, 10), mix: Math.random(), slack: rndR(0.4, 1.3) });
l.buildLimbPair();
l.initIKNodes(); // Nodes an die neue Gliedmaße anpassen
if (type === 'leg' && Math.random() < 0.8) l.setIKMode('ground');
else l.applyArticulation();
limbs.push(l);
};
for (let i = 0; i < nA; i++) make('arm', 0.5 + i * 0.12 + rndR(0, 0.08), `Arm-Paar ${i + 1}`);
for (let i = 0; i < nL; i++) make('leg', 0.06 + i * 0.1 + rndR(0, 0.06), `Bein-Paar ${i + 1}`);
rebuildRibs();
bodyRoot.rotation.set(0, 0, 0);
updateSpine();
applyShellsAll();
// Hüfthöhe so wählen, dass Bein-Füße (Boden-Modus) bequem den Boden erreichen
// (nach dem kürzesten Bein ausrichten, damit alle Boden-Beine den Boden erreichen)
let y = Infinity;
for (const l of limbs) if (l.ik.mode === 'ground') {
const L = l.leftLimb, reach = L.l1 + L.l2 + L.l3;
y = Math.min(y, 0.85 * reach + l.ik.groundY - l.rootGroup.position.y);
}
if (y < Infinity) bodyRoot.position.y = Math.max(0.6, y);
updateLight();
randomPose();
rebuildGUI();
requestRender();
}
// ============================================================
// NODES-MODUS
// ============================================================
function setNodesMode(v) {
config.showAllNodes = v;
bodyRoot.traverse(o => { if (o.userData.isMarker) o.visible = v; });
document.getElementById('nodes-btn').classList.toggle('on', v);
rebuildGUI();
requestRender();
}
// ============================================================
// EXPORT / IMPORT (einfaches JSON)
// ============================================================
const CONFIG_KEYS = ['vertebraeCount', 'ribCageCount', 'ribsPerCage', 'mirror', 'shellVisible', 'shellOpenFactor',
'mechaColor', 'shellColor', 'nerveColor', 'normalScale'];
function exportState() {
const r = bodyRoot.rotation;
const cfg = {}; for (const k of CONFIG_KEYS) cfg[k] = config[k];
return {
app: 'mecha-rig', version: 1,
config: cfg,
body: { position: bodyRoot.position.toArray(), rotation: [r.x, r.y, r.z] },
spine: [1, 2, 3].map(i => spineNodes[i].toArray()),
heads: heads.map(h => ({ name: h.name, t: h.t, rot: h.rot, neckLength: h.neckLength, w: h.w, h: h.h, d: h.d })),
limbs: limbs.map(l => ({
type: l.type, name: l.name, tOnSpine: l.tOnSpine, shoulderWidth: l.shoulderWidth, lengthScale: l.lengthScale,
pose: l.pose, cables: l.cables,
ik: { mode: l.ik.mode, groundY: l.ik.groundY,
targets: { 1: l.ikNodes[1].target.position.toArray(), '-1': l.ikNodes[-1].target.position.toArray() },
poles: { 1: l.ikNodes[1].pole.position.toArray(), '-1': l.ikNodes[-1].pole.position.toArray() } }
}))
};
}
function downloadJSON() {
const blob = new Blob([JSON.stringify(exportState(), null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `mecha-rig-${Date.now()}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
fileMsg = 'Exportiert.';
rebuildGUI();
}
function importState(d) {
const isNum3 = v => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
if (!d || d.app !== 'mecha-rig') throw new Error('keine mecha-rig Datei');
clearSelection();
const c = d.config || {};
for (const k of CONFIG_KEYS) if (k in c) config[k] = c[k];
mechaMaterial.color.set(config.mechaColor);
shellMaterial.color.set(config.shellColor);
nerveCableMaterial.color.set(config.nerveColor);
mechaMaterial.normalScale.set(config.normalScale, config.normalScale);
shellMaterial.normalScale.set(config.normalScale * 0.5, config.normalScale * 0.5);
if (d.body && isNum3(d.body.position) && isNum3(d.body.rotation)) {
bodyRoot.position.fromArray(d.body.position);
bodyRoot.rotation.set(...d.body.rotation);
}
if (Array.isArray(d.spine) && d.spine.length === 3 && d.spine.every(isNum3)) {
d.spine.forEach((v, i) => spineNodes[i + 1].fromArray(v));
spineRest = d.spine.map(v => new THREE.Vector3().fromArray(v));
}
limbs.forEach(l => l.remove()); limbs.length = 0;
heads.forEach(h => h.remove()); heads.length = 0;
for (const hd of d.heads || []) {
const h = new HeadUnit(String(hd.name || `Kopf ${heads.length + 1}`), +hd.t || 1, isNum3(hd.rot) ? hd.rot : [0, 0, 0]);
for (const k of ['neckLength', 'w', 'h', 'd']) if (Number.isFinite(hd[k])) h[k] = hd[k];
h.build();
heads.push(h);
}
for (const ld of d.limbs || []) {
const l = new LimbPair(ld.type === 'leg' ? 'leg' : 'arm', Number.isFinite(ld.tOnSpine) ? ld.tOnSpine : 0.5, String(ld.name || 'Limb'));
l.ik.mode = 'off'; l.updateIKVisibility(); // Pose aus der Datei gilt; IK wird danach ggf. aus ld.ik wiederhergestellt
for (const k of ['shoulderWidth', 'lengthScale']) if (Number.isFinite(ld[k])) l[k] = ld[k];
if (ld.pose) for (const side of ['1', '-1']) for (const j of JOINTS)
if (ld.pose[side] && isNum3(ld.pose[side][j])) l.pose[side][j] = ld.pose[side][j].slice();
if (ld.cables) Object.assign(l.cables, ld.cables);
l.buildLimbPair();
limbs.push(l);
l.restoreIK(ld.ik);
}
rebuildRibs();
updateSpine();
applyShellsAll();
updateLight();
fileMsg = `Geladen: ${heads.length} Köpfe, ${limbs.length} Limb-Paare.`;
rebuildGUI();
requestRender();
}
// ============================================================
// POINTER
// ============================================================
function setMouse(e) {
mouse.x = (e.clientX / innerWidth) * 2 - 1;
mouse.y = -(e.clientY / innerHeight) * 2 + 1;
}
function onPointerDown(e) {
downX = e.clientX; downY = e.clientY;
gizmoDown = tc.axis !== null || tc.dragging; // Klick auf Gizmo-Handle -> TransformControls übernimmt
if (gizmoDown) return;
setMouse(e);
bodyRoot.updateMatrixWorld(true);
raycaster.setFromCamera(mouse, camera);
const hit = config.showNodes ? raycaster.intersectObjects(nodeHandles) : [];
if (hit.length) {
draggedObject = hit[0].object;
controls.enabled = false;
}
}
function onPointerMove(e) {
if (!draggedObject) return;
setMouse(e);
draggedObject.getWorldPosition(_w1);
camera.getWorldDirection(_camDir).negate();
_plane.setFromNormalAndCoplanarPoint(_camDir, _w1);
raycaster.setFromCamera(mouse, camera);
if (!raycaster.ray.intersectPlane(_plane, _w2)) return;
bodyRoot.worldToLocal(_w2); // Spline-Knoten leben im Körper-Koordinatensystem
spineNodes[draggedObject.userData.nodeIndex].copy(_w2);
draggedObject.position.copy(_w2);
spineDirty = true;
requestRender();
}
function onPointerUp(e) {
if (draggedObject) {
draggedObject = null;
controls.enabled = true;
return;
}
if (gizmoDown) return;
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 5) return; // war ein Orbit-Drag, kein Klick
setMouse(e);
bodyRoot.updateMatrixWorld(true);
raycaster.setFromCamera(mouse, camera);
const list = [];
bodyRoot.traverse(o => { if (o.userData.sel && o.visible) list.push(o); });
const hit = raycaster.intersectObjects(list, false);
if (hit.length) selectInfo(hit[0].object.userData.sel);
else clearSelection();
}
// ============================================================
// MINIMAL-PANEL
// ============================================================
const panel = document.getElementById('panel');
const openState = {};
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
function folder(parent, title, openDefault) {
const wrap = el('div', 'folder');
const head = el('button', 'fhead', title);
const bodyEl = el('div', 'fbody');
wrap.classList.toggle('open', openState[title] ?? openDefault);
head.onclick = () => { wrap.classList.toggle('open'); openState[title] = wrap.classList.contains('open'); };
wrap.append(head, bodyEl);
parent.appendChild(wrap);
return bodyEl;
}
function slider(parent, obj, key, min, max, step, label, onChange) {
const row = el('div', 'row');
const out = el('output');
const inp = el('input');
inp.type = 'range'; inp.min = min; inp.max = max; inp.step = step; inp.value = obj[key];
const dec = step >= 1 ? 0 : (step < 0.01 ? 3 : 2);
out.textContent = (+obj[key]).toFixed(dec);
inp.oninput = () => {
obj[key] = parseFloat(inp.value);
out.textContent = obj[key].toFixed(dec);
if (onChange) onChange(obj[key]);
requestRender();
};
row.append(el('label', '', label), inp, out);
parent.appendChild(row);
}
function checkbox(parent, obj, key, label, onChange) {
const row = el('div', 'row simple');
const inp = el('input'); inp.type = 'checkbox'; inp.checked = obj[key];
inp.onchange = () => { obj[key] = inp.checked; if (onChange) onChange(inp.checked); requestRender(); };
row.append(el('label', '', label), inp);
parent.appendChild(row);
}
function color(parent, obj, key, label, onChange) {
const row = el('div', 'row simple');
const inp = el('input'); inp.type = 'color'; inp.value = obj[key];
inp.oninput = () => { obj[key] = inp.value; onChange(inp.value); requestRender(); };
row.append(el('label', '', label), inp);
parent.appendChild(row);
}
function button(parent, label, fn) {
const b = el('button', 'btn', label);
b.onclick = fn;
parent.appendChild(b);
}
function selectRow(parent, obj, key, options, label, onChange) {
const row = el('div', 'row simple');
const sel = el('select');
for (const [v, t] of options) { const o = el('option', '', t); o.value = v; sel.appendChild(o); }
sel.value = obj[key];
sel.onchange = () => { obj[key] = sel.value; if (onChange) onChange(sel.value); };
row.append(el('label', '', label), sel);
parent.appendChild(row);
}
function note(parent, text) { parent.appendChild(el('div', 'note', text)); }
function rebuildGUI() {
const scroll = panel.scrollTop;
panel.innerHTML = '';
const fView = folder(panel, 'Ansicht', true);
checkbox(fView, { v: config.showAllNodes }, 'v', 'Alle Nodes (N)', setNodesMode);
note(fView, 'grün = an der Spine verschiebbar · violett = Gelenk (drehen) · blau = Spline (ziehen) · schwarz = Hip · IK: rot = Ziel, hellblau = Knick');
const fFile = folder(panel, 'Datei', true);
button(fFile, 'Export JSON', downloadJSON);
const fin = el('input'); fin.type = 'file'; fin.accept = '.json,application/json'; fin.style.display = 'none';
fin.onchange = () => {
const f = fin.files[0]; if (!f) return;
const rd = new FileReader();
rd.onload = () => {
try { importState(JSON.parse(rd.result)); }
catch (err) { fileMsg = 'Import fehlgeschlagen: ' + err.message; rebuildGUI(); }
};
rd.readAsText(f);
};
fFile.appendChild(fin);
button(fFile, 'Import JSON', () => { fin.value = ''; fin.click(); });
if (fileMsg) note(fFile, fileMsg);
const fExp = folder(panel, 'Export Maps', true);
selectRow(fExp, config, 'exportFormat', [['win', 'Fenster'], ['1:1', '1:1'], ['3:4', '3:4'], ['4:3', '4:3'], ['9:16', '9:16'], ['16:9', '16:9']], 'Ausschnitt', updateFrame);
slider(fExp, config, 'exportH', 512, 2048, 128, 'Höhe (px)');
button(fExp, 'Alle Maps (ZIP)', exportZip);
const row4 = el('div', 'btnrow');
for (const [label, kind] of [['Farbe', 'color'], ['Pose', 'pose'], ['Tiefe', 'depth'], ['Maske', 'mask']]) {
const b = el('button', 'btn', label);
b.onclick = () => exportOne(kind);
row4.appendChild(b);
}
fExp.appendChild(row4);
note(fExp, 'ZIP: color · pose · depth · mask + mask_legend.json. Der gestrichelte Rahmen zeigt den Ausschnitt.');
const fSpine = folder(panel, 'Spine & Brustkorb', true);
slider(fSpine, config, 'vertebraeCount', 10, 40, 1, 'Wirbel', () => { spineDirty = true; });
slider(fSpine, config, 'ribCageCount', 1, 4, 1, 'Brustkörbe', rebuildRibs);
slider(fSpine, config, 'ribsPerCage', 3, 12, 1, 'Rippen / Korb', rebuildRibs);
checkbox(fSpine, config, 'showNodes', 'Spline-Knoten', v => nodeHandles.forEach(h => h.visible = v));
const fHeads = folder(panel, 'Köpfe', true);
heads.forEach((h, i) => {
const f = folder(fHeads, `${i + 1}. ${h.name}`, false);
slider(f, h, 'neckLength', 0.15, 1.5, 0.01, 'Halslänge', () => h.build());
slider(f, h, 'w', 0.12, 0.8, 0.01, 'Breite', () => h.build());
slider(f, h, 'h', 0.15, 1.0, 0.01, 'Höhe', () => h.build());
slider(f, h, 'd', 0.08, 0.7, 0.01, 'Tiefe', () => h.build());
button(f, 'Kopf löschen', () => removeHead(i));
});
const fShell = folder(panel, 'Shells', false);
checkbox(fShell, config, 'shellVisible', 'Anzeigen', applyShellsAll);
slider(fShell, config, 'shellOpenFactor', 0, 1, 0.01, 'Aufklappen', applyShellsAll);
const fMat = folder(panel, 'Material', false);
color(fMat, config, 'mechaColor', 'Skelett', c => mechaMaterial.color.set(c));
color(fMat, config, 'shellColor', 'Shell', c => shellMaterial.color.set(c));
color(fMat, config, 'nerveColor', 'Nerven', c => nerveCableMaterial.color.set(c));
slider(fMat, config, 'normalScale', 0, 1, 0.05, 'Normal Map', v => {
mechaMaterial.normalScale.set(v, v);
shellMaterial.normalScale.set(v * 0.5, v * 0.5);
});
const fLimbs = folder(panel, 'Limbs', true);
checkbox(fLimbs, config, 'mirror', 'Symmetrie (FK)');
checkbox(fLimbs, { v: limbs.length > 0 && limbs.every(l => l.ik.mode === 'ground') }, 'v', 'Alle: Boden als IK-Ziel',
v => { limbs.forEach(l => l.setIKMode(v ? 'ground' : 'ik')); rebuildGUI(); });
limbs.forEach((limb, i) => {
const f = folder(fLimbs, `${i + 1}. ${limb.name}`, false);
slider(f, limb, 'shoulderWidth', 0.8, 3.0, 0.05, 'Breite', () => limb.buildLimbPair());
slider(f, limb, 'lengthScale', 0.6, 1.8, 0.05, 'Länge', () => limb.buildLimbPair());
checkbox(f, { v: limb.ik.mode !== 'off' }, 'v', 'IK (Ziel + Knick)', v => { limb.setIKMode(v ? 'ik' : 'off'); rebuildGUI(); });
checkbox(f, { v: limb.ik.mode === 'ground' }, 'v', 'Boden als IK-Ziel', v => { limb.setIKMode(v ? 'ground' : 'ik'); rebuildGUI(); });
slider(f, limb.ik, 'groundY', 0, 1.2, 0.01, 'Boden-Höhe', () => limb.applyArticulation());
checkbox(f, limb.cables, 'on', 'Nervenkabel', () => limb.updateCables());
slider(f, limb.cables, 'count', 1, 12, 1, 'Kabel', () => limb.updateCables());
slider(f, limb.cables, 'mix', 0, 1, 0.05, 'Shell-Anteil', () => limb.updateCables());
slider(f, limb.cables, 'slack', 0, 1.5, 0.05, 'Durchhang', () => limb.updateCables());
slider(f, limb.cables, 'spread', 0.01, 0.2, 0.005, 'Streuung', () => limb.updateCables());
slider(f, limb.cables, 'thickness', 0.004, 0.03, 0.001, 'Dicke', () => limb.updateCables());
button(f, 'Limb löschen', () => removeLimbPair(i));
});
panel.scrollTop = scroll;
}
// ============================================================
// SNAPSHOT / RESIZE
// ============================================================
function takeScreenshot() {
const vis = nodeHandles.map(h => h.visible);
nodeHandles.forEach(h => h.visible = false);
hipHandle.visible = false;
spineLine.visible = false;
tc.visible = false;
const mk = [];
bodyRoot.traverse(o => { if (o.userData.isMarker || o.userData.isIK) { mk.push([o, o.visible]); o.visible = false; } });
if (spineDirty) { spineDirty = false; updateSpine(); }
renderer.render(scene, camera);
const a = document.createElement('a');
a.download = `mecha-rig-${Date.now()}.png`;
a.href = renderer.domElement.toDataURL('image/png');
a.click();
nodeHandles.forEach((h, i) => h.visible = vis[i]);
hipHandle.visible = true;
spineLine.visible = true;
tc.visible = true;
mk.forEach(([o, v]) => o.visible = v);
requestRender();
}
function onWindowResize() {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
updateFrame();
requestRender();
}
window.onload = init;
</script>
</body>
</html>