index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MIMICRY — The Floating Body</title>
<style>
:root{
--bg-top:#ffffff;
--bg-bottom:#e9e9ea;
--panel-bg:#ffffff;
--panel-border:#e2e2e4;
--text:#1a1a1a;
--text-dim:#8a8a8e;
--accent:#2f6fed;
--warn:#d0473e;
}
*{ box-sizing:border-box; }
html,body{
margin:0; padding:0; width:100%; height:100%;
background:linear-gradient(to bottom, var(--bg-top) 0%, var(--bg-top) 45%, var(--bg-bottom) 100%);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
color:var(--text);
overflow:hidden;
}
#canvas-wrap{ position:fixed; inset:0; }
canvas{ display:block; width:100%; height:100%; }
#hint{
position:fixed; left:20px; bottom:16px;
font-size:11px; letter-spacing:.02em; color:var(--text-dim);
line-height:1.5; pointer-events:none; user-select:none;
}
#status{
position:fixed; left:20px; top:38px;
font-size:11px; letter-spacing:.02em; color:var(--text-dim);
line-height:1.5; pointer-events:none; user-select:none;
max-width:280px;
}
#title{
position:fixed; left:20px; top:16px;
font-size:11px; font-weight:600; letter-spacing:.06em; color:var(--text);
text-transform:uppercase; pointer-events:none; user-select:none;
}
/* ---------- MAPPING PANEL ---------- */
#mapping-panel{
position:fixed; left:20px; top:60px; width:352px;
max-height:calc(100vh - 100px);
display:none; flex-direction:column;
background:var(--panel-bg);
border:1px solid var(--panel-border);
border-radius:6px;
box-shadow:0 8px 24px rgba(0,0,0,0.06);
font-size:11px; overflow:hidden;
}
#mapping-panel.on{ display:flex; }
#mapping-head{
padding:8px 10px; border-bottom:1px solid var(--panel-border);
display:flex; gap:6px; flex-wrap:wrap; align-items:center;
}
#mapping-head .label{
font-weight:600; letter-spacing:.06em; text-transform:uppercase;
font-size:10px; margin-right:auto;
}
#mapping-panel button{
font:inherit; font-size:10px; padding:3px 8px;
background:#f3f3f4; color:var(--text);
border:1px solid var(--panel-border); border-radius:4px; cursor:pointer;
}
#mapping-panel button:hover{ background:#eaeaec; }
#mapping-list{ overflow-y:auto; max-height:38vh; }
.mrow{
padding:5px 10px; border-bottom:1px solid #f2f2f3; cursor:pointer; line-height:1.45;
}
.mrow:hover{ background:#fafafb; }
.mrow.sel{ background:#f0f5ff; box-shadow:inset 2px 0 0 var(--accent); }
.mname{ font-weight:600; font-size:10.5px; letter-spacing:.01em; }
.mtag{ color:var(--text-dim); font-weight:400; font-size:9.5px; margin-left:5px; }
.mvals{ color:var(--text-dim); font-size:10px; font-variant-numeric:tabular-nums; }
.mvals b{ color:var(--text); font-weight:600; }
.mvals .hot{ color:var(--warn); font-weight:600; }
#mapping-editor{ border-top:1px solid var(--panel-border); }
#mapping-editor .lil-gui{ border:none; box-shadow:none; width:100% !important; max-width:100%; }
#mapping-note{ padding:6px 10px; color:var(--text-dim); font-size:10px; border-top:1px solid var(--panel-border); }
.lil-gui{
--background-color: var(--panel-bg);
--text-color: var(--text);
--title-background-color: #ffffff;
--title-text-color: var(--text);
--widget-color: #f3f3f4;
--hover-color: #eaeaec;
--focus-color: #e2e2e4;
--number-color: #2f6fed;
--string-color: #2f6fed;
--font-size: 11px;
--input-font-size: 11px;
--padding: 4px;
--spacing: 4px;
--widget-height: 18px;
--name-width: 46%;
--slider-knob-width: 2px;
--border-radius: 6px;
border:1px solid var(--panel-border);
box-shadow:0 8px 24px rgba(0,0,0,0.06);
}
.lil-gui.root > .title{ border-radius:6px 6px 0 0; font-weight:600; letter-spacing:.02em; }
.lil-gui .title{ border-bottom:1px solid var(--panel-border); }
.lil-gui input[type=text], .lil-gui input[type=number]{ color:var(--text); }
</style>
</head>
<body>
<div id="canvas-wrap"></div>
<div id="title">MIMICRY — The Floating Body</div>
<div id="status">lade Modell …</div>
<div id="hint"></div>
<div id="mapping-panel">
<div id="mapping-head">
<span class="label">Mapping</span>
<button id="btn-export">JSON speichern</button>
<button id="btn-import">JSON laden</button>
<button id="btn-zero">Pose 0</button>
<button id="btn-extreme">Limit-Test</button>
</div>
<div id="mapping-list"></div>
<div id="mapping-editor"></div>
<div id="mapping-note">Animation und Physik sind aus. Bone anklicken zum Auswählen — im Viewport ziehen bewegt das Gelenk direkt, hart begrenzt durch die Constraints. Beugung/Streckung und Seitlich bilden zusammen einen elliptischen Bewegungskegel.</div>
</div>
<input type="file" id="file-input" accept="application/json,.json" style="display:none" />
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/",
"lil-gui": "https://unpkg.com/lil-gui@0.19/dist/lil-gui.esm.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import GUI from "lil-gui";
/* ============================================================
KONFIGURATION
============================================================ */
const CONFIG = {
modelPath: "./smoothed_body_for_MIMICRY_2.glb",
cubemapDir: "./cubemap/",
cubemapFiles: ["px.jpg", "nx.jpg", "py.jpg", "ny.jpg", "pz.jpg", "nz.jpg"],
normalMapPath: "./normal_map.jpg",
mappingPath: "./bonemapping.json",
};
const HINT_PLAY = "Linksklick + halten auf dem Körper — ziehen · Rechtsklick + ziehen — Kamera orbiten · Mausrad — Zoom (FOV) · graue Punkte = Fokus";
const HINT_MAP = "MAPPING — links ziehen bewegt das Gelenk direkt · Rechtsklick orbiten · Mausrad Zoom";
const statusEl = document.getElementById("status");
const setStatus = (msg) => { statusEl.textContent = msg; };
document.getElementById("hint").textContent = HINT_PLAY;
// bonemapping.json früh anfragen — wird angewandt, sobald das Rig steht.
const mappingPromise = fetch(CONFIG.mappingPath, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
const DEG = THREE.MathUtils.radToDeg;
const RAD = THREE.MathUtils.degToRad;
const clamp = THREE.MathUtils.clamp;
const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
/* ============================================================
PARAMETER
============================================================ */
const params = {
mappingMode: false,
mapDragSpeed: 0.35,
mapChainDepth: 1,
// ---- Choreografie ------------------------------------------------
choreoEnabled: true,
choreoAmount: 1.0, // Gesamt-Intensität
choreoSpeed: 0.35, // Zeitmaßstab aller Felder
fieldScale: 0.8, // räumliche Frequenz des Perlin-Feldes
focusRadius: 1.0, // mittlerer Abstand der Fokuspunkte (× Körpergröße/1.75)
focusSpeed: 0.35, // Tempo der Fokuspunkte im Feld
dialogue: 0.6, // Kopplung der beiden Fokuspunkte (Anziehung / Reibung)
dialogueDist: 1.0, // mittlerer Abstand zwischen den Fokuspunkten
repelShare: 0.4, // Anteil Abstoßung vs. Anziehung bei Armen/Beinen
limbForce: 3.0, // Zug/Schub der Gliedmaßen auf die Fokuspunkte
limbDamping: 3.0,
limbMaxForce: 12,
torsoAim: 0.6, // Körper + Brust richten sich an den Fokuspunkten aus
headLook: 0.5, // Blick
upright: 0.12, // leichte Tendenz zur Aufrichtung (0 = völlig frei)
posture: 0.4, // Wellen/Haltung entlang der Kette (Perlin-Feld im Raum)
postureStiffness: 5,
postureLag: 0.35, // Verzögerung entlang der Kette (s) → Nachschwingen
driftForce: 0.12,
driftSpin: 0.10,
showFocus: true,
// ---- Physik / Muskeln ---------------------------------------------
jointDamping: 2.4, // 1/s — viskose Gelenkdämpfung
bodyDamping: 1.6, // 1/s — Ganzkörper Position + Rotation
bodyMass: 1.0,
jointLimitScale: 1.0,
limitStiffness: 40,
limitSoftZone: 0.7, // ab hier beginnt der weiche Anschlag
limitDamping: 30, // Bremsung der Auswärtsbewegung im Anschlag
compliance: 1.0,
hingeBoost: 2.2,
leafCalm: 0.6,
restReturn: 0.0,
hipReturnStrength: 0.15,
limbInertia: 0.25,
angVelClamp: 25,
// ---- Kollision -----------------------------------------------------
selfCollision: true,
collisionScale: 0.92, // Radius-Skalierung der gemessenen Kapseln
collisionPadding: 0.004,
collisionStiffness: 60,
collisionDamping: 10,
collisionMaxForce: 60,
collisionProject: 0.45, // positionelle Korrektur (0..1) — hält Glieder garantiert auseinander
collisionMaxStep: 0.005, // max. Auseinanderschieben pro Substep in m — verhindert Ruckeln
showColliders: false,
// ---- Ziehen --------------------------------------------------------
attractForce: 30,
dragDamping: 8,
maxForce: 150,
forceRampSeconds: 0.04,
bodyCoupling: 0.25,
torsoDragCoupling: 0.9, // Torso/Hüfte ziehen = ganzer Körper folgt
torqueGain: 1.2,
chainFalloff: 0.85,
dragBoost: 1.6,
dragChoreoFloor: 0.1, // Choreografie-Anteil während des Ziehens
pickScale: 1.0,
color: "#ffffff", metalness: 1.0, roughness: 0.08, envMapIntensity: 1.2,
clearcoat: 0.0, clearcoatRoughness: 0.1, normalMapTiling: 12, normalMapStrength: 0.2,
lightIntensity: 3.2, lightColor: "#ffffff", lightAzimuth: 35, lightElevation: 55, ambient: 0.12,
camLookSpeed: 2.2, camDistance: 3.2, camAzimuth: 25, camElevation: 10, fov: 35,
showOrientation: false,
};
// Diese Werte wandern mit in die JSON (ab version 2).
const GLOBAL_KEYS = [
"choreoEnabled","choreoAmount","choreoSpeed","fieldScale","focusRadius","focusSpeed","dialogue","dialogueDist",
"repelShare","limbForce","limbDamping","limbMaxForce","torsoAim","headLook","upright",
"posture","postureStiffness","postureLag","driftForce","driftSpin",
"jointDamping","bodyDamping","bodyMass","jointLimitScale","limitStiffness","limitSoftZone","limitDamping",
"compliance","hingeBoost","leafCalm","restReturn","hipReturnStrength","limbInertia","angVelClamp",
"selfCollision","collisionScale","collisionPadding","collisionStiffness","collisionDamping","collisionMaxForce","collisionProject","collisionMaxStep",
"attractForce","dragDamping","maxForce","forceRampSeconds","bodyCoupling","torsoDragCoupling","torqueGain","chainFalloff",
"dragBoost","dragChoreoFloor","pickScale",
];
/* ============================================================
SCENE / RENDERER
============================================================ */
const wrap = document.getElementById("canvas-wrap");
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.05, 100);
const cameraRig = { targetLook: new THREE.Vector3(0, 0, 0) };
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.BasicShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
wrap.appendChild(renderer.domElement);
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* ============================================================
LICHT
============================================================ */
const keyLight = new THREE.DirectionalLight(0xffffff, 3.2);
keyLight.position.set(2.4, 3.4, 2.0);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.camera.near = 0.1;
keyLight.shadow.camera.far = 10;
keyLight.shadow.camera.left = -1.5;
keyLight.shadow.camera.right = 1.5;
keyLight.shadow.camera.top = 2;
keyLight.shadow.camera.bottom = -1.5;
keyLight.shadow.bias = -0.0015;
keyLight.shadow.radius = 1;
scene.add(keyLight);
scene.add(keyLight.target);
const fill = new THREE.HemisphereLight(0xffffff, 0x606066, 0.12);
scene.add(fill);
/* ============================================================
MATERIAL
============================================================ */
const chromeMaterial = new THREE.MeshPhysicalMaterial({
color: new THREE.Color(0xffffff),
metalness: 1.0, roughness: 0.08, envMapIntensity: 1.2,
clearcoat: 0.0, clearcoatRoughness: 0.1, reflectivity: 1.0,
normalScale: new THREE.Vector2(1, 1),
});
let normalTex = null;
new THREE.TextureLoader().load(
CONFIG.normalMapPath,
(tex) => {
tex.wrapS = THREE.MirroredRepeatWrapping;
tex.wrapT = THREE.MirroredRepeatWrapping;
tex.repeat.set(params.normalMapTiling, params.normalMapTiling);
normalTex = tex;
chromeMaterial.normalMap = tex;
chromeMaterial.normalScale.set(params.normalMapStrength, params.normalMapStrength);
chromeMaterial.needsUpdate = true;
},
undefined,
() => { console.warn("Normal Map nicht gefunden:", CONFIG.normalMapPath); }
);
/* ============================================================
CUBEMAP
============================================================ */
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();
function buildFallbackEnvironment() {
const geo = new THREE.SphereGeometry(6, 32, 32);
const canvas = document.createElement("canvas");
canvas.width = 8; canvas.height = 256;
const ctx = canvas.getContext("2d");
const grad = ctx.createLinearGradient(0, 0, 0, 256);
grad.addColorStop(0.0, "#ffffff"); grad.addColorStop(0.45, "#f4f4f5");
grad.addColorStop(0.55, "#dcdcdf"); grad.addColorStop(1.0, "#8d8d92");
ctx.fillStyle = grad; ctx.fillRect(0, 0, 8, 256);
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
const mat = new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide });
const s = new THREE.Scene();
s.add(new THREE.Mesh(geo, mat));
const rt = pmrem.fromScene(s, 0.02);
s.remove(s.children[0]); geo.dispose(); mat.dispose(); tex.dispose();
return rt.texture;
}
new THREE.CubeTextureLoader().setPath(CONFIG.cubemapDir).load(
CONFIG.cubemapFiles,
(cubeTex) => {
cubeTex.colorSpace = THREE.SRGBColorSpace;
chromeMaterial.envMap = pmrem.fromCubemap(cubeTex).texture;
chromeMaterial.needsUpdate = true;
cubeTex.dispose();
},
undefined,
() => {
chromeMaterial.envMap = buildFallbackEnvironment();
chromeMaterial.needsUpdate = true;
setStatus("Cubemap in " + CONFIG.cubemapDir + " nicht gefunden — nutze Fallback-Reflektion.");
}
);
/* ============================================================
QUATERNION-MATHE
============================================================ */
const _va = new THREE.Vector3();
const _vb = new THREE.Vector3();
const _vc = new THREE.Vector3();
const _qa = new THREE.Quaternion();
const _qb = new THREE.Quaternion();
const _qc = new THREE.Quaternion();
function angleAxisFromQuat(q, axisOut) {
let x = q.x, y = q.y, z = q.z, w = clamp(q.w, -1, 1);
if (w < 0) { x = -x; y = -y; z = -z; w = -w; }
const angle = 2 * Math.acos(w);
const s = Math.sqrt(Math.max(1 - w * w, 0));
if (s < 1e-7) { axisOut.set(1, 0, 0); return 0; }
axisOut.set(x / s, y / s, z / s);
return angle;
}
function swingTwistDecompose(q, axis, swingOut, twistOut) {
const d = q.x * axis.x + q.y * axis.y + q.z * axis.z;
twistOut.set(axis.x * d, axis.y * d, axis.z * d, q.w);
const len = Math.hypot(twistOut.x, twistOut.y, twistOut.z, twistOut.w);
if (len < 1e-9) twistOut.set(0, 0, 0, 1);
else { twistOut.x /= len; twistOut.y /= len; twistOut.z /= len; twistOut.w /= len; }
if (twistOut.w < 0) twistOut.set(-twistOut.x, -twistOut.y, -twistOut.z, -twistOut.w);
swingOut.copy(q).multiply(_qc.copy(twistOut).invert());
}
function signedTwistAngle(twistQ, axis) {
const d = twistQ.x * axis.x + twistQ.y * axis.y + twistQ.z * axis.z;
return 2 * Math.atan2(d, twistQ.w);
}
function quatFromAngularVelocity(angVel, dt, target) {
const w = angVel.length();
const theta = w * dt;
if (theta < 1e-9) { target.set(0, 0, 0, 1); return target; }
const half = theta * 0.5, s = Math.sin(half) / w;
target.set(angVel.x * s, angVel.y * s, angVel.z * s, Math.cos(half));
return target;
}
function closestRaySegment(rayOrigin, rayDir, segA, segB) {
const ab = new THREE.Vector3().subVectors(segB, segA);
const ao = new THREE.Vector3().subVectors(rayOrigin, segA);
const t = clamp(ao.dot(ab) / Math.max(ab.lengthSq(), 1e-10), 0, 1);
const segPoint = segA.clone().addScaledVector(ab, t);
const s = new THREE.Vector3().subVectors(segPoint, rayOrigin).dot(rayDir);
const rayPoint = rayOrigin.clone().addScaledVector(rayDir, s);
return { s, rayPoint, segPoint, dist: rayPoint.distanceTo(segPoint) };
}
// Segment–Segment: nächstes Punktpaar (Ericson, Real-Time Collision Detection)
function segSegClosest(p1, q1, p2, q2, c1, c2) {
const d1x = q1.x - p1.x, d1y = q1.y - p1.y, d1z = q1.z - p1.z;
const d2x = q2.x - p2.x, d2y = q2.y - p2.y, d2z = q2.z - p2.z;
const rx = p1.x - p2.x, ry = p1.y - p2.y, rz = p1.z - p2.z;
const a = d1x * d1x + d1y * d1y + d1z * d1z;
const e = d2x * d2x + d2y * d2y + d2z * d2z;
const f = d2x * rx + d2y * ry + d2z * rz;
const EPS = 1e-9;
let s, t;
if (a <= EPS && e <= EPS) { s = 0; t = 0; }
else if (a <= EPS) { s = 0; t = clamp(f / e, 0, 1); }
else {
const c = d1x * rx + d1y * ry + d1z * rz;
if (e <= EPS) { t = 0; s = clamp(-c / a, 0, 1); }
else {
const b = d1x * d2x + d1y * d2y + d1z * d2z;
const denom = a * e - b * b;
s = denom > EPS ? clamp((b * f - c * e) / denom, 0, 1) : 0;
t = (b * s + f) / e;
if (t < 0) { t = 0; s = clamp(-c / a, 0, 1); }
else if (t > 1) { t = 1; s = clamp((b - c) / a, 0, 1); }
}
}
c1.set(p1.x + d1x * s, p1.y + d1y * s, p1.z + d1z * s);
c2.set(p2.x + d2x * t, p2.y + d2y * t, p2.z + d2z * t);
return c1.distanceToSquared(c2);
}
function distPointSegment(p, a, b) {
const abx = b.x - a.x, aby = b.y - a.y, abz = b.z - a.z;
const l2 = abx * abx + aby * aby + abz * abz;
let t = l2 > 1e-12 ? ((p.x - a.x) * abx + (p.y - a.y) * aby + (p.z - a.z) * abz) / l2 : 0;
t = clamp(t, 0, 1);
const dx = p.x - (a.x + abx * t), dy = p.y - (a.y + aby * t), dz = p.z - (a.z + abz * t);
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
const wpos = (bone, out) => out.setFromMatrixPosition(bone.matrixWorld);
/* ============================================================
3D PERLIN-NOISE + CURL-FELD
Das Feld ist die zentrale Steuerung: Es schiebt die Fokuspunkte
durch den Raum und liefert die räumlich kohärente Haltung.
============================================================ */
const NOISE_PERM = (() => {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
let seed = 1337;
const rnd = () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; };
for (let i = 255; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [p[i], p[j]] = [p[j], p[i]]; }
const perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
return perm;
})();
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function nlerp(t, a, b) { return a + t * (b - a); }
function grad(hash, x, y, z) {
const h = hash & 15;
const u = h < 8 ? x : y;
const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
}
function noise3(x, y, z) {
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
const u = fade(x), v = fade(y), w = fade(z);
const p = NOISE_PERM;
const A = p[X] + Y, AA = p[A] + Z, AB = p[A + 1] + Z;
const B = p[X + 1] + Y, BA = p[B] + Z, BB = p[B + 1] + Z;
return nlerp(w,
nlerp(v, nlerp(u, grad(p[AA], x, y, z), grad(p[BA], x - 1, y, z)),
nlerp(u, grad(p[AB], x, y - 1, z), grad(p[BB], x - 1, y - 1, z))),
nlerp(v, nlerp(u, grad(p[AA + 1], x, y, z - 1), grad(p[BA + 1], x - 1, y, z - 1)),
nlerp(u, grad(p[AB + 1], x, y - 1, z - 1), grad(p[BB + 1], x - 1, y - 1, z - 1))));
}
// drei entkoppelte Kanäle desselben Feldes (bewusst mit nicht-ganzzahligen Offsets)
function noiseVec3(seed, t, out) {
const a = seed * 0.1371 + 0.31, b = seed * 0.0713 + 0.57;
out.set(
noise3(a, t, b),
noise3(a + 37.7, t + 5.1, b + 11.3),
noise3(a + 71.3, t + 13.9, b + 23.7)
).multiplyScalar(1.45);
return out;
}
// divergenzfreies Feld: Fokuspunkte kreisen und wirbeln, statt zufällig zu zittern
function curlNoise(x, y, z, t, out) {
const e = 0.05;
const P = (k, X, Y, Z) => {
if (k === 0) return noise3(X + 0.0 + t * 0.31, Y + 7.3 + t * 0.17, Z + 1.9 - t * 0.23);
if (k === 1) return noise3(X + 31.7 - t * 0.19, Y + 2.1 + t * 0.29, Z + 9.4 + t * 0.13);
return noise3(X + 12.9 + t * 0.11, Y + 27.3 - t * 0.21, Z + 4.7 + t * 0.27);
};
const inv = 1 / (2 * e);
out.set(
((P(2, x, y + e, z) - P(2, x, y - e, z)) - (P(1, x, y, z + e) - P(1, x, y, z - e))) * inv,
((P(0, x, y, z + e) - P(0, x, y, z - e)) - (P(2, x + e, y, z) - P(2, x - e, y, z))) * inv,
((P(1, x + e, y, z) - P(1, x - e, y, z)) - (P(0, x, y + e, z) - P(0, x, y - e, z))) * inv
);
return out;
}
/* ============================================================
GELENK-SPEZIFIKATION
noiseGain = Anteil dieses Gelenks an der Haltungs-/Wellen-Animation.
============================================================ */
const JOINT_SPECS = [
{ test: /forearm/i, aim: "forward", flex: 150, ext: 3, lat: 8, twist: 70, inertia: 0.35, hinge: true, noiseGain: 1.0 },
{ test: /arm$/i, aim: "forward", flex: 95, ext: 50, lat: 85, twist: 55, inertia: 0.9, noiseGain: 1.0 },
{ test: /shoulder/i, aim: "forward", flex: 18, ext: 14, lat: 16, twist: 10, inertia: 1.6, noiseGain: 0.8 },
{ test: /hand$/i, aim: "forward", flex: 60, ext: 55, lat: 25, twist: 15, inertia: 0.85, noiseGain: 0.22 },
{ test: /upleg/i, aim: "forward", flex: 110, ext: 25, lat: 45, twist: 35, inertia: 1.5, noiseGain: 1.0 },
{ test: /leg$/i, aim: "back", flex: 145, ext: 3, lat: 6, twist: 10, inertia: 0.5, hinge: true, noiseGain: 1.0 },
{ test: /toebase/i, aim: "down", flex: 30, ext: 14, lat: 5, twist: 5, inertia: 0.5, hinge: true, noiseGain: 0.25 },
{ test: /foot/i, aim: "down", flex: 45, ext: 28, lat: 12, twist: 14, inertia: 0.45, noiseGain: 0.6 },
{ test: /neck/i, aim: "forward", flex: 32, ext: 28, lat: 26, twist: 40, inertia: 0.6, noiseGain: 0.7 },
{ test: /head/i, aim: "forward", flex: 22, ext: 18, lat: 18, twist: 30, inertia: 0.7, noiseGain: 0.35 },
{ test: /spine2/i, aim: "forward", flex: 26, ext: 15, lat: 20, twist: 16, inertia: 1.8, noiseGain: 1.0 },
{ test: /spine1/i, aim: "forward", flex: 26, ext: 15, lat: 20, twist: 16, inertia: 1.8, noiseGain: 1.0 },
{ test: /spine/i, aim: "forward", flex: 22, ext: 12, lat: 18, twist: 14, inertia: 2.2, noiseGain: 1.0 },
{ test: /hips$/i, aim: "forward", flex: 20, ext: 16, lat: 16, twist: 24, inertia: 3.0, noiseGain: 1.0 },
];
const EXCLUDE_FROM_SIM = /thumb|index|middle|ring|pinky|_end/i;
function classifyBone(name) { for (const r of JOINT_SPECS) if (r.test.test(name)) return r; return null; }
const RADIUS_RULES = [
{ test: /hips|spine/i, r: 0.16 }, { test: /head/i, r: 0.12 }, { test: /neck/i, r: 0.07 },
{ test: /shoulder/i, r: 0.10 }, { test: /forearm/i, r: 0.07 }, { test: /arm$/i, r: 0.08 },
{ test: /hand/i, r: 0.06 }, { test: /thumb|index|middle|ring|pinky/i, r: 0.03 },
{ test: /upleg/i, r: 0.12 }, { test: /leg$/i, r: 0.08 }, { test: /toebase/i, r: 0.05 },
{ test: /foot/i, r: 0.07 }, { test: /_end/i, r: 0.04 },
];
function radiusForBone(name) { for (const r of RADIUS_RULES) if (r.test.test(name)) return r.r; return 0.06; }
function pickPrimaryChild(bone) {
const kids = bone.children.filter((c) => c.isBone);
if (kids.length === 0) return null;
return kids.find((c) => !/shoulder/i.test(c.name)) || kids[0];
}
function shortName(n) { return n.replace(/^mixamorig\d*[:_]?/i, ""); }
/* ============================================================
CHARACTER ROOT + ZUSTAND
============================================================ */
const characterRoot = new THREE.Group();
scene.add(characterRoot);
const bodyVel = new THREE.Vector3();
const bodyAngVel = new THREE.Vector3();
const WORLD_UP = new THREE.Vector3(0, 1, 0);
let simBones = [];
let boneEntry = new Map();
let colliders = []; // Kapseln zum Greifen (Picking)
let skinnedMesh = null;
let orientationGizmo = null;
let bodyScale = 1;
let bodyForwardLocal = new THREE.Vector3(0, 0, 1);
let choreoBlend = 1; // 1 = Choreografie voll, ↓ beim Ziehen
const _tmpVecA = new THREE.Vector3();
const _tmpVecB = new THREE.Vector3();
// Fokuspunkte (relativ zum Körperzentrum) + Gliedmaßen
const foci = [0, 1].map((i) => ({
rel: new THREE.Vector3(), v: new THREE.Vector3(), world: new THREE.Vector3(), seed: i === 0 ? 3.17 : 8.53,
}));
let limbs = [];
let chestAim = []; // [entry, weight]
let headAim = [];
// Kollisionsmodell
let segs = [];
let segPairs = [];
/* ============================================================
MODELL LADEN + RIG-ANALYSE
============================================================ */
new GLTFLoader().load(
CONFIG.modelPath,
(gltf) => {
const root = gltf.scene;
root.traverse((obj) => {
if (obj.isMesh) {
obj.material = chromeMaterial;
obj.castShadow = true; obj.receiveShadow = true;
obj.raycast = () => {};
if (obj.isSkinnedMesh) skinnedMesh = obj;
}
});
characterRoot.add(root);
scene.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(skinnedMesh || root);
root.position.sub(box.getCenter(new THREE.Vector3()));
scene.updateMatrixWorld(true);
const size = box.getSize(new THREE.Vector3());
params.camDistance = Math.max(size.y * 1.9, size.x * 2.4, 2.2);
bodyScale = Math.max(size.y / 1.75, 0.2);
const bones = skinnedMesh ? skinnedMesh.skeleton.bones : [];
// Blickrichtung des Rigs (Fuß -> Zehen)
const forwardWorld = new THREE.Vector3(0, 0, 1);
const toe = bones.find((b) => /toebase/i.test(b.name));
if (toe && toe.parent) {
const a = new THREE.Vector3(), b = new THREE.Vector3();
toe.parent.getWorldPosition(a); toe.getWorldPosition(b);
const d = b.sub(a); d.y = 0;
if (d.lengthSq() > 1e-8) forwardWorld.copy(d).normalize();
}
bodyForwardLocal.copy(forwardWorld);
const AIM = {
forward: forwardWorld.clone(),
back: forwardWorld.clone().negate(),
down: new THREE.Vector3(0, -1, 0),
};
for (const bone of bones) {
if (EXCLUDE_FROM_SIM.test(bone.name)) continue;
const spec = classifyBone(bone.name);
if (!spec) continue;
const child = pickPrimaryChild(bone);
const axisT = (child && child.position.lengthSq() > 1e-10)
? child.position.clone().normalize() : new THREE.Vector3(0, 1, 0);
const bq = new THREE.Quaternion();
bone.getWorldQuaternion(bq);
const bqInv = bq.clone().invert();
const dLocal = AIM[spec.aim].clone().applyQuaternion(bqInv).normalize();
let axisH = new THREE.Vector3().crossVectors(axisT, dLocal);
if (axisH.lengthSq() < 1e-5) {
const lat = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), AIM[spec.aim]);
axisH = lat.lengthSq() > 1e-6 ? lat.applyQuaternion(bqInv) : new THREE.Vector3(1, 0, 0);
}
axisH.addScaledVector(axisT, -axisH.dot(axisT));
if (axisH.lengthSq() < 1e-8) axisH.set(1, 0, 0);
axisH.normalize();
if (new THREE.Vector3().crossVectors(axisH, axisT).dot(dLocal) < 0) axisH.negate();
const axisS = new THREE.Vector3().crossVectors(axisT, axisH).normalize();
simBones.push({
bone, name: bone.name, short: shortName(bone.name),
rest: bone.quaternion.clone(),
dev: new THREE.Quaternion(),
angVel: new THREE.Vector3(),
wav: new THREE.Vector3(),
restWorld: new THREE.Quaternion(),
restWorldInv: new THREE.Quaternion(),
prevParentWav: new THREE.Vector3(),
axisT, axisH, axisS,
flex: RAD(spec.flex), ext: RAD(spec.ext), lat: RAD(spec.lat), twist: RAD(spec.twist),
inertia: spec.inertia, hinge: !!spec.hinge, noiseGain: spec.noiseGain ?? 1.0,
isLeaf: false, seed: 0, idx: 0,
// Choreografie
tgtH: 0, tgtS: 0, tgtT: 0, depth: 0, isTorso: false, parentEntry: null,
restPos: new THREE.Vector3(), fwdLocal: new THREE.Vector3(0, 0, 1),
});
}
simBones.sort((a, b) => depthOf(a.bone) - depthOf(b.bone));
simBones.forEach((s, i) => { s.idx = i; s.seed = i * 100.0; boneEntry.set(s.bone, s); });
for (const s of simBones) s.isLeaf = !s.bone.children.some((c) => boneEntry.has(c));
// Zusatzdaten für die Choreografie
const hipsBone = (simBones.find((s) => /hips$/i.test(s.name)) || simBones[0]).bone;
const hipsPos = wpos(hipsBone, new THREE.Vector3());
for (const s of simBones) {
s.depth = 0;
for (let o = s.bone.parent; o; o = o.parent) if (boneEntry.has(o)) s.depth++;
s.parentEntry = boneEntry.get(s.bone.parent) || null;
s.isTorso = /hips|spine/i.test(s.name);
wpos(s.bone, s.restPos).sub(hipsPos);
const wq = new THREE.Quaternion();
s.bone.getWorldQuaternion(wq);
s.fwdLocal.copy(forwardWorld).applyQuaternion(wq.invert());
}
// Pick-Kapseln (Radien werden unten durch gemessene Werte ersetzt)
for (const bone of bones) {
const kids = bone.children.filter((c) => c.isBone);
const r = radiusForBone(bone.name) * 0.4;
if (kids.length === 0) colliders.push({ type: "sphere", owner: bone, a: bone, radius: r });
else for (const c of kids) colliders.push({ type: "capsule", owner: bone, a: bone, b: c, radius: r });
}
setupLimbsAndAim();
buildCollisionModel(bones);
initFoci();
buildFocusVisuals();
rebuildColliderViz();
orientationGizmo = buildOrientationGizmo(Math.max(size.y * 0.85, 0.8));
orientationGizmo.visible = params.showOrientation;
scene.add(orientationGizmo);
mappingPromise.then((data) => {
const note = data ? applyBoneMapping(data) : "";
buildGUI();
buildMappingList();
setStatus(`Modell geladen — ${simBones.length} Gelenke, ${segs.length} Kollisionskapseln.` + (note ? " " + note : ""));
setTimeout(() => { if (statusEl.textContent.startsWith("Modell")) statusEl.textContent = ""; }, 6000);
});
},
undefined,
(err) => {
console.error(err);
setStatus("Fehler beim Laden von " + CONFIG.modelPath + " — Datei im selben Verzeichnis ablegen.");
}
);
function depthOf(obj) { let d = 0, o = obj; while (o.parent) { d++; o = o.parent; } return d; }
/* ============================================================
GLIEDMASSEN, AUSRICHTUNG
============================================================ */
function setupLimbsAndAim() {
const find = (re) => simBones.find((s) => re.test(s.name));
const defs = [
{ root: /LeftArm$/, eff: /LeftHand$/, alt: null, kind: "arm", seed: 3.7, lag: 0.0, k: 1.0 },
{ root: /RightArm$/, eff: /RightHand$/, alt: null, kind: "arm", seed: 3.7, lag: 0.9, k: 1.0 },
{ root: /LeftUpLeg$/, eff: /LeftToeBase$/, alt: /LeftFoot$/, kind: "leg", seed: 11.3, lag: 0.5, k: 0.9 },
{ root: /RightUpLeg$/, eff: /RightToeBase$/, alt: /RightFoot$/, kind: "leg", seed: 11.3, lag: 1.4, k: 0.9 },
];
limbs = [];
for (const d of defs) {
const r = find(d.root);
const e = find(d.eff) || (d.alt ? find(d.alt) : null);
if (!r || !e) continue;
const reach = wpos(r.bone, new THREE.Vector3()).distanceTo(wpos(e.bone, new THREE.Vector3()));
limbs.push({
kind: d.kind, root: r.bone, eff: e.bone, reach: Math.max(reach, 0.1), seed: d.seed, lag: d.lag, k: d.k,
pol: 0, aff: 0.5, line: null,
});
}
const w = (re, wt) => { const s = find(re); return s ? [s, wt] : null; };
chestAim = [w(/spine$/i, 0.25), w(/spine1$/i, 0.35), w(/spine2$/i, 0.4)].filter(Boolean);
headAim = [w(/neck$/i, 0.45), w(/head$/i, 0.55)].filter(Boolean);
}
/* ============================================================
KOLLISIONSMODELL (gemessene Kapseln + Paarliste)
============================================================ */
function segmentEnd(bone) {
const kids = bone.children.filter((c) => c.isBone);
if (!kids.length) return null;
const n = bone.name;
const pick = (re) => kids.find((c) => re.test(c.name));
if (/hips$/i.test(n)) return pick(/spine/i) || kids[0];
if (/spine2$/i.test(n)) return pick(/neck/i) || kids[0];
if (/spine/i.test(n)) return kids.find((c) => /spine/i.test(c.name)) || kids[0];
if (/hand$/i.test(n)) return pick(/middle1$/i) || pick(/index1$/i) || kids[0];
if (/head$/i.test(n)) return pick(/headtop|_end/i) || kids[0];
return kids.find((c) => !/shoulder/i.test(c.name)) || kids[0];
}
function groupOf(name) {
const side = /left/i.test(name) ? "L" : /right/i.test(name) ? "R" : "";
if (/arm|hand/i.test(name)) return "arm" + side;
if (/leg|foot|toe/i.test(name)) return "leg" + side;
return "T";
}
const isUpperArm = (n) => /arm$/i.test(n) && !/forearm/i.test(n);
function buildCollisionModel(bones) {
segs = []; segPairs = [];
const segByBone = new Map();
for (const e of simBones) {
if (!/hips|spine|neck|head|arm|hand|leg|foot|toebase/i.test(e.name)) continue;
if (/shoulder/i.test(e.name)) continue;
const end = segmentEnd(e.bone);
if (!end) continue;
const s = {
e, bone: e.bone, end, name: e.short, group: groupOf(e.name),
a: new THREE.Vector3(), b: new THREE.Vector3(),
r: radiusForBone(e.name) * 0.6, measured: false, viz: null,
};
segs.push(s);
segByBone.set(e.bone, s);
}
// Radien direkt aus den Skinning-Gewichten des Meshes messen
try { measureRadii(bones, segByBone); }
catch (err) { console.warn("Radien konnten nicht gemessen werden — nutze Standardwerte.", err); }
for (const s of segs) { wpos(s.bone, s.a); wpos(s.end, s.b); }
for (let i = 0; i < segs.length; i++) {
for (let j = i + 1; j < segs.length; j++) {
const A = segs[i], B = segs[j];
if (A.group === B.group) continue;
if ((isUpperArm(A.name) && /spine2|neck/i.test(B.name)) || (isUpperArm(B.name) && /spine2|neck/i.test(A.name))) continue;
if ((/upleg/i.test(A.name) && /hips|spine$/i.test(B.name)) || (/upleg/i.test(B.name) && /hips|spine$/i.test(A.name))) continue;
const d = Math.sqrt(segSegClosest(A.a, A.b, B.a, B.b, _tmpVecA, _tmpVecB));
// Ruhepose darf nie als "durchdrungen" gelten
segPairs.push({ A, B, restCap: Math.max(d * 0.97, 0.005) });
}
}
// Greif-Kapseln auf gemessene Radien setzen
applyPickRadii();
}
function applyPickRadii() {
for (const c of colliders) {
const s = segs.find((x) => x.bone === c.owner);
if (s) c.radius = Math.max(s.r * params.pickScale, 0.02);
}
}
function measureRadii(bones, segByBone) {
const mesh = skinnedMesh;
if (!mesh || !mesh.geometry.attributes.skinIndex) throw new Error("kein Skinning");
const geo = mesh.geometry;
const pos = geo.attributes.position, si = geo.attributes.skinIndex, sw = geo.attributes.skinWeight;
mesh.updateMatrixWorld(true);
mesh.skeleton.update();
const bm = mesh.skeleton.boneMatrices;
for (const s of segs) { wpos(s.bone, s.a); wpos(s.end, s.b); }
// Knochen -> zuständige Kapsel (Finger → Hand, Schulter → Oberarm, Enden → Elternknochen)
const owner = bones.map((b) => {
for (let o = b; o; o = o.parent) {
const s = segByBone.get(o);
if (s) return s;
if (/shoulder/i.test(o.name)) for (const c of o.children) { const s2 = segByBone.get(c); if (s2) return s2; }
}
return null;
});
const M = new THREE.Matrix4(), v = new THREE.Vector3(), acc = new THREE.Vector3(), tmp = new THREE.Vector3();
const dists = new Map(segs.map((s) => [s, []]));
const cmps = [(i) => si.getX(i), (i) => si.getY(i), (i) => si.getZ(i), (i) => si.getW(i)];
const wcmp = [(i) => sw.getX(i), (i) => sw.getY(i), (i) => sw.getZ(i), (i) => sw.getW(i)];
for (let i = 0; i < pos.count; i++) {
let bestW = 0, bestBone = -1;
for (let k = 0; k < 4; k++) { const w = wcmp[k](i); if (w > bestW) { bestW = w; bestBone = cmps[k](i); } }
if (bestW < 0.3 || bestBone < 0) continue;
const s = owner[bestBone];
if (!s) continue;
v.fromBufferAttribute(pos, i).applyMatrix4(mesh.bindMatrix);
acc.set(0, 0, 0);
for (let k = 0; k < 4; k++) {
const w = wcmp[k](i); if (w === 0) continue;
M.fromArray(bm, cmps[k](i) * 16);
acc.addScaledVector(tmp.copy(v).applyMatrix4(M), w);
}
acc.applyMatrix4(mesh.bindMatrixInverse).applyMatrix4(mesh.matrixWorld);
dists.get(s).push(distPointSegment(acc, s.a, s.b));
}
for (const s of segs) {
const arr = dists.get(s);
if (arr.length < 12) continue;
arr.sort((x, y) => x - y);
const r = arr[Math.floor((arr.length - 1) * 0.72)];
s.r = clamp(r, 0.02 * bodyScale, 0.3 * bodyScale);
s.measured = true;
}
}
/* ---- Debug-Darstellung der Kapseln ---- */
const colliderViz = new THREE.Group();
colliderViz.visible = false;
scene.add(colliderViz);
const _yAxis = new THREE.Vector3(0, 1, 0);
function rebuildColliderViz() {
for (const s of segs) {
if (s.viz) { colliderViz.remove(s.viz); s.viz.geometry.dispose(); s.viz.material.dispose(); s.viz = null; }
}
for (const s of segs) {
const len = Math.max(s.a.distanceTo(s.b), 1e-3);
const r = Math.max(s.r * params.collisionScale, 0.005);
const m = new THREE.Mesh(
new THREE.CapsuleGeometry(r, len, 4, 12),
new THREE.MeshBasicMaterial({ color: 0x2f6fed, wireframe: true, transparent: true, opacity: 0.22, depthTest: false })
);
m.renderOrder = 990; m.frustumCulled = false;
s.viz = m; colliderViz.add(m);
}
}
function updateColliderViz() {
colliderViz.visible = params.showColliders && !params.mappingMode;
if (!colliderViz.visible) return;
for (const s of segs) {
if (!s.viz) continue;
wpos(s.bone, s.a); wpos(s.end, s.b);
s.viz.position.addVectors(s.a, s.b).multiplyScalar(0.5);
_va.subVectors(s.b, s.a);
if (_va.lengthSq() > 1e-10) s.viz.quaternion.setFromUnitVectors(_yAxis, _va.normalize());
}
}
/* ============================================================
BONE-MAPPING — JSON anwenden / erzeugen
============================================================ */
function applyBoneMapping(data) {
if (!data || typeof data !== "object") return "";
let n = 0;
// Globale Werte nur aus neuen (v2) Dateien: die alten Zug-/Dämpfungswerte
// gehörten zu einem anderen Physikmodell und würden das Ziehen wieder träge machen.
let globalsNote = "";
if (data.globals && (data.version || 1) >= 2) {
for (const k of GLOBAL_KEYS) if (k in data.globals) params[k] = data.globals[k];
} else if (data.globals) globalsNote = " (globale Werte der alten JSON ignoriert)";
const table = data.bones || {};
for (const e of simBones) {
const d = table[e.name] || table[e.short];
if (!d) continue;
if ("flex" in d) e.flex = RAD(d.flex);
if ("ext" in d) e.ext = RAD(d.ext);
if ("lat" in d) e.lat = RAD(d.lat);
if ("twist" in d) e.twist = RAD(d.twist);
if ("inertia" in d) e.inertia = d.inertia;
if ("hinge" in d) e.hinge = !!d.hinge;
if ("noiseGain" in d) e.noiseGain = d.noiseGain;
if (Array.isArray(d.axisT) && d.axisT.length === 3) e.axisT.fromArray(d.axisT).normalize();
if (Array.isArray(d.axisH) && d.axisH.length === 3) {
e.axisH.fromArray(d.axisH);
e.axisH.addScaledVector(e.axisT, -e.axisH.dot(e.axisT));
if (e.axisH.lengthSq() < 1e-8) e.axisH.set(1, 0, 0);
e.axisH.normalize();
}
e.axisS.crossVectors(e.axisT, e.axisH).normalize();
n++;
}
return `bonemapping.json angewandt (${n} Gelenke)${globalsNote}.`;
}
function buildBoneMapping() {
const bones = {};
for (const e of simBones) {
bones[e.name] = {
flex: +DEG(e.flex).toFixed(2),
ext: +DEG(e.ext).toFixed(2),
lat: +DEG(e.lat).toFixed(2),
twist: +DEG(e.twist).toFixed(2),
inertia: +e.inertia.toFixed(3),
hinge: e.hinge,
noiseGain: +e.noiseGain.toFixed(3),
axisT: e.axisT.toArray().map((v) => +v.toFixed(6)),
axisH: e.axisH.toArray().map((v) => +v.toFixed(6)),
};
}
const globals = {};
for (const k of GLOBAL_KEYS) globals[k] = params[k];
return { version: 2, generated: new Date().toISOString(), globals, bones };
}
function downloadMapping() {
const blob = new Blob([JSON.stringify(buildBoneMapping(), null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "bonemapping.json";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/* ============================================================
ORIENTIERUNGS-GIZMO
============================================================ */
function buildOrientationGizmo(radius) {
const group = new THREE.Group();
group.renderOrder = -1;
const segments = 72;
const circleMat = new THREE.LineBasicMaterial({ color: 0xbfbfc4, transparent: true, opacity: 0.16, depthWrite: false });
function makeCircle(plane) {
const pts = [];
for (let i = 0; i <= segments; i++) {
const a = (i / segments) * Math.PI * 2;
const x = Math.cos(a) * radius, y = Math.sin(a) * radius;
if (plane === "xy") pts.push(new THREE.Vector3(x, y, 0));
else if (plane === "yz") pts.push(new THREE.Vector3(0, x, y));
else pts.push(new THREE.Vector3(x, 0, y));
}
return new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(pts), circleMat);
}
group.add(makeCircle("xy"), makeCircle("yz"), makeCircle("xz"));
const axisLen = radius * 1.15;
function makeAxis(dir, color) {
const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.4, depthWrite: false });
return new THREE.Line(new THREE.BufferGeometry().setFromPoints(
[dir.clone().multiplyScalar(-axisLen), dir.clone().multiplyScalar(axisLen)]), mat);
}
group.add(makeAxis(new THREE.Vector3(1, 0, 0), 0xd99a8c));
group.add(makeAxis(new THREE.Vector3(0, 1, 0), 0x9ccf9c));
group.add(makeAxis(new THREE.Vector3(0, 0, 1), 0x93b8e0));
return group;
}
/* ============================================================
ACHSEN-GIZMO FÜR DAS AUSGEWÄHLTE GELENK (Mapping-Modus)
============================================================ */
const jointGizmo = new THREE.Group();
jointGizmo.visible = false;
scene.add(jointGizmo);
function axisLine(color) {
const g = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, 1)]);
return new THREE.Line(g, new THREE.LineBasicMaterial({ color, depthTest: false, transparent: true, opacity: 0.95 }));
}
const gizH = axisLine(0x2f6fed);
const gizS = axisLine(0x5fae6a);
const gizT = axisLine(0xd0473e);
[gizH, gizS, gizT].forEach((l) => { l.renderOrder = 998; l.frustumCulled = false; jointGizmo.add(l); });
function updateJointGizmo() {
const e = selectedEntry();
if (!params.mappingMode || !e) { jointGizmo.visible = false; return; }
jointGizmo.visible = true;
const origin = new THREE.Vector3();
e.bone.getWorldPosition(origin);
const L = 0.16;
const set = (line, axisLocal) => {
const dir = axisLocal.clone().applyQuaternion(e.restWorld).multiplyScalar(L);
line.geometry.setFromPoints([origin, origin.clone().add(dir)]);
};
set(gizH, e.axisH); set(gizS, e.axisS); set(gizT, e.axisT);
}
/* ============================================================
FOKUSPUNKTE — Darstellung
============================================================ */
const focusGroup = new THREE.Group();
focusGroup.visible = false;
scene.add(focusGroup);
const focusMeshes = [0, 1].map(() => {
const m = new THREE.Mesh(
new THREE.SphereGeometry(0.02, 16, 12),
new THREE.MeshBasicMaterial({ color: 0x9a9aa0, transparent: true, opacity: 0.9, depthTest: false })
);
m.renderOrder = 996; m.frustumCulled = false; focusGroup.add(m);
return m;
});
function makeLine(color, opacity) {
const l = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]),
new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthTest: false })
);
l.renderOrder = 995; l.frustumCulled = false; focusGroup.add(l);
return l;
}
const dialogueLine = makeLine(0x9a9aa0, 0.35);
const COL_ATTRACT = new THREE.Color(0x2f6fed);
const COL_REPEL = new THREE.Color(0xd0473e);
function buildFocusVisuals() {
focusMeshes.forEach((m) => m.scale.setScalar(bodyScale));
for (const L of limbs) L.line = makeLine(0x2f6fed, 0.6);
}
function updateFocusVisuals() {
const on = params.showFocus && params.choreoEnabled && !params.mappingMode && !!skinnedMesh;
focusGroup.visible = on;
if (!on) return;
focusMeshes[0].position.copy(foci[0].world);
focusMeshes[1].position.copy(foci[1].world);
dialogueLine.geometry.setFromPoints([foci[0].world, foci[1].world]);
for (const L of limbs) {
if (!L.line) continue;
const a = Math.abs(L.pol) * choreoBlend;
L.line.visible = a > 0.05;
if (!L.line.visible) continue;
_tmpVecA.lerpVectors(foci[0].world, foci[1].world, L.aff);
wpos(L.eff, _tmpVecB);
L.line.geometry.setFromPoints([_tmpVecB, _tmpVecA]);
L.line.material.color.copy(L.pol > 0 ? COL_ATTRACT : COL_REPEL);
L.line.material.opacity = Math.min(0.8, a);
}
}
/* ============================================================
MAUS-INTERAKTION
============================================================ */
const raycaster = new THREE.Raycaster();
const mouseNDC = new THREE.Vector2();
const tmpSphere = new THREE.Sphere();
const tmpHit = new THREE.Vector3();
const lastMouse = { x: window.innerWidth / 2, y: window.innerHeight / 2, active: false };
let interaction = null;
let orbitDragging = false;
let lastOrbitX = 0, lastOrbitY = 0;
const hitMarker = new THREE.Mesh(
new THREE.RingGeometry(0.02, 0.028, 28),
new THREE.MeshBasicMaterial({ color: 0x9a9aa0, transparent: true, opacity: 0.95, side: THREE.DoubleSide, depthTest: false })
);
hitMarker.renderOrder = 999; hitMarker.frustumCulled = false; hitMarker.visible = false;
scene.add(hitMarker);
const forceLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]),
new THREE.LineBasicMaterial({ color: 0x2f6fed, transparent: true, opacity: 0.9, depthTest: false })
);
forceLine.renderOrder = 999; forceLine.frustumCulled = false; forceLine.visible = false;
scene.add(forceLine);
const COLOR_HOVER = new THREE.Color(0x9a9aa0);
const COLOR_ATTRACT = new THREE.Color(0x2f6fed);
function orientMarkerToNormal(m, n) { m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), n); }
function pickColliderInternal(clientX, clientY) {
mouseNDC.x = (clientX / window.innerWidth) * 2 - 1;
mouseNDC.y = -(clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouseNDC, camera);
const ro = raycaster.ray.origin, rd = raycaster.ray.direction;
let bestT = Infinity, bestOwner = null, bestPoint = null;
for (const c of colliders) {
if (c.type === "sphere") {
c.a.getWorldPosition(tmpSphere.center);
tmpSphere.radius = c.radius;
const hit = raycaster.ray.intersectSphere(tmpSphere, tmpHit);
if (hit) {
const t = ro.distanceTo(hit);
if (t < bestT) { bestT = t; bestOwner = c.owner; bestPoint = hit.clone(); }
}
} else {
c.a.getWorldPosition(_tmpVecA); c.b.getWorldPosition(_tmpVecB);
const res = closestRaySegment(ro, rd, _tmpVecA, _tmpVecB);
if (res.dist <= c.radius && res.s < bestT) {
let surface;
if (res.dist > 1e-5) {
surface = res.segPoint.clone().addScaledVector(res.rayPoint.clone().sub(res.segPoint).normalize(), c.radius);
} else surface = res.segPoint.clone().addScaledVector(rd, -c.radius);
bestT = res.s; bestOwner = c.owner; bestPoint = surface;
}
}
}
return bestOwner ? { owner: bestOwner, point: bestPoint } : null;
}
function pickNearestBone(clientX, clientY, maxDistance = 0.3) {
mouseNDC.x = (clientX / window.innerWidth) * 2 - 1;
mouseNDC.y = -(clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouseNDC, camera);
const ro = raycaster.ray.origin, rd = raycaster.ray.direction;
let bestDist = maxDistance * bodyScale, bestBone = null, bestPoint = null;
for (const s of simBones) {
const p = new THREE.Vector3();
s.bone.getWorldPosition(p);
const t = new THREE.Vector3().subVectors(p, ro).dot(rd);
if (t < 0) continue;
const onRay = ro.clone().addScaledVector(rd, t);
const d = onRay.distanceTo(p);
if (d < bestDist) { bestDist = d; bestBone = s.bone; bestPoint = onRay.clone(); }
}
return bestBone ? { owner: bestBone, point: bestPoint } : null;
}
function pickCollider(x, y) {
scene.updateMatrixWorld(true);
return pickColliderInternal(x, y) || pickNearestBone(x, y);
}
function screenToPlanePoint(x, y, plane) {
mouseNDC.x = (x / window.innerWidth) * 2 - 1;
mouseNDC.y = -(y / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouseNDC, camera);
return raycaster.ray.intersectPlane(plane, new THREE.Vector3());
}
renderer.domElement.addEventListener("contextmenu", (e) => e.preventDefault());
renderer.domElement.addEventListener("pointerdown", (e) => {
if (e.button === 2) { orbitDragging = true; lastOrbitX = e.clientX; lastOrbitY = e.clientY; return; }
if (e.button !== 0) return;
const hit = pickCollider(e.clientX, e.clientY);
if (!hit) return;
const bone = hit.owner;
const camDir = new THREE.Vector3();
camera.getWorldDirection(camDir);
interaction = {
bone,
localOffset: bone.worldToLocal(hit.point.clone()),
plane: new THREE.Plane().setFromNormalAndCoplanarPoint(camDir, hit.point),
target: hit.point.clone(),
startTime: performance.now(),
};
if (params.mappingMode) {
const se = nearestSimEntry(bone);
if (se) selectBone(se.idx);
}
});
window.addEventListener("pointermove", (e) => {
lastMouse.x = e.clientX; lastMouse.y = e.clientY; lastMouse.active = true;
if (orbitDragging) {
params.camAzimuth -= (e.clientX - lastOrbitX) * 0.35;
params.camElevation = clamp(params.camElevation + (e.clientY - lastOrbitY) * 0.35, -80, 85);
lastOrbitX = e.clientX; lastOrbitY = e.clientY;
}
if (interaction) {
const camDir = new THREE.Vector3();
camera.getWorldDirection(camDir);
interaction.plane.setFromNormalAndCoplanarPoint(camDir, interaction.target);
const p = screenToPlanePoint(e.clientX, e.clientY, interaction.plane);
if (p) interaction.target.copy(p);
}
});
window.addEventListener("pointerleave", () => { lastMouse.active = false; });
window.addEventListener("pointerup", (e) => {
if (e.button === 2) orbitDragging = false;
if (interaction && params.mappingMode) refreshPoseSliders();
interaction = null;
});
window.addEventListener("blur", () => { interaction = null; orbitDragging = false; });
renderer.domElement.addEventListener("wheel", (e) => {
e.preventDefault();
params.fov = clamp(params.fov * Math.exp(e.deltaY * 0.0013), 6, 100);
camera.fov = params.fov;
camera.updateProjectionMatrix();
}, { passive: false });
/* ============================================================
KRAFT-VERTEILUNG
============================================================ */
function simulatedChain(bone) {
const chain = [];
let o = bone;
while (o) { const e = boneEntry.get(o); if (e) chain.push(e); o = o.parent; }
return chain;
}
function nearestSimEntry(bone) {
let o = bone;
while (o) { const e = boneEntry.get(o); if (e) return e; o = o.parent; }
return null;
}
// exakte Punktgeschwindigkeit: Körper + Summe aller Gelenk-Drehungen der Kette
const _pv1 = new THREE.Vector3(), _pv2 = new THREE.Vector3(), _pv3 = new THREE.Vector3(), _pv4 = new THREE.Vector3();
function pointVelocity(bone, P, out) {
out.copy(bodyVel);
_pv1.subVectors(P, characterRoot.position);
out.add(_pv2.crossVectors(bodyAngVel, _pv1));
for (let o = bone; o; o = o.parent) {
const e = boneEntry.get(o);
if (!e) continue;
wpos(e.bone, _pv3);
_pv4.copy(e.angVel).applyQuaternion(e.restWorld);
_pv1.subVectors(P, _pv3);
out.add(_pv2.crossVectors(_pv4, _pv1));
}
return out;
}
const _lever = new THREE.Vector3(), _torq = new THREE.Vector3(), _bp = new THREE.Vector3();
// o: { body, falloff, gain, levels }
function applyForceAtBone(targetBone, F, P, dt, o = {}) {
const chain = simulatedChain(targetBone);
if (o.levels && chain.length > o.levels) chain.length = o.levels;
const share = o.body !== undefined ? o.body : (chain.length > 0 ? params.bodyCoupling : 1.0);
const falloff = o.falloff !== undefined ? o.falloff : params.chainFalloff;
const gm = o.gain !== undefined ? o.gain : 1;
if (share > 0) {
bodyVel.addScaledVector(F, (share * 1.5 / Math.max(params.bodyMass, 0.05)) * dt);
_lever.subVectors(P, characterRoot.position);
bodyAngVel.addScaledVector(_torq.crossVectors(_lever, F), (share * 1.5 * dt) / (_lever.lengthSq() + 0.25));
}
let level = 1.0;
for (const e of chain) {
wpos(e.bone, _bp);
_lever.subVectors(P, _bp);
_torq.crossVectors(_lever, F).applyQuaternion(e.restWorldInv);
const bend = params.compliance * (e.hinge ? params.hingeBoost : 1.0);
const gain = params.torqueGain * gm * level * bend /
((_lever.lengthSq() + 0.03) * Math.max(e.inertia, 0.05));
e.angVel.addScaledVector(_torq, gain * dt);
level *= falloff;
}
}
/* ============================================================
GELENK-LIMITS (elliptischer Bewegungskegel + Verdrehung)
Beugung/Streckung (h) und Seitwärts (s) sind gekoppelt: kein "Eck"-Zustand
mit voller Beugung UND vollem Seitwärts. Anschlag ist weich + gedämpft.
============================================================ */
const _swing = new THREE.Quaternion();
const _twist = new THREE.Quaternion();
const _dec = { h: 0, s: 0, tw: 0 };
function decomposeDev(dev, e, out) {
swingTwistDecompose(dev, e.axisT, _swing, _twist);
out.tw = signedTwistAngle(_twist, e.axisT);
const ang = angleAxisFromQuat(_swing, _va);
out.h = ang * _va.dot(e.axisH);
out.s = ang * _va.dot(e.axisS);
return out;
}
function composeDev(h, s, tw, e, target) {
_vb.set(0, 0, 0).addScaledVector(e.axisH, h).addScaledVector(e.axisS, s);
const a = _vb.length();
if (a < 1e-9) _qa.set(0, 0, 0, 1);
else _qa.setFromAxisAngle(_vb.divideScalar(a), a);
_qb.setFromAxisAngle(e.axisT, tw);
return target.copy(_qa).multiply(_qb).normalize();
}
const _nrm = new THREE.Vector3();
function driveAndLimit(e, h, postureK) {
decomposeDev(e.dev, e, _dec);
const dh = _dec.h, ds = _dec.s, dtw = _dec.tw;
const invI = 1 / Math.max(e.inertia, 0.05);
const sc = params.jointLimitScale;
// --- Haltung: weiches Federziel im Gelenkraum (Ziele liegen innerhalb der Limits)
if (postureK > 0 && e.noiseGain > 0) {
const k = postureK * invI * h;
e.angVel.addScaledVector(e.axisH, (e.tgtH - dh) * k);
e.angVel.addScaledVector(e.axisS, (e.tgtS - ds) * k);
e.angVel.addScaledVector(e.axisT, (e.tgtT - dtw) * k);
}
// --- weicher Anschlag (Swing-Ellipse)
const a = Math.max((dh >= 0 ? e.flex : e.ext) * sc, 1e-4);
const b = Math.max(e.lat * sc, 1e-4);
const r = Math.hypot(dh / a, ds / b);
const f = params.limitSoftZone;
const kL = params.limitStiffness * invI;
if (r > f) {
const over = (r - f) / r;
e.angVel.addScaledVector(e.axisH, -dh * over * kL * h);
e.angVel.addScaledVector(e.axisS, -ds * over * kL * h);
// Auswärtsbewegung zunehmend abbremsen → kein Klappern am Anschlag
_nrm.set(0, 0, 0).addScaledVector(e.axisH, dh / (a * a)).addScaledVector(e.axisS, ds / (b * b));
if (_nrm.lengthSq() > 1e-12) {
_nrm.normalize();
const vOut = e.angVel.dot(_nrm);
if (vOut > 0) {
const depth = clamp((r - f) / Math.max(1 - f, 1e-3), 0, 1.5);
e.angVel.addScaledVector(_nrm, -vOut * (1 - Math.exp(-params.limitDamping * depth * h)));
}
}
}
// --- Verdrehung
const twl = e.twist * sc;
const tf = twl * f;
if (Math.abs(dtw) > tf) {
const sg = Math.sign(dtw);
const overT = Math.abs(dtw) - tf;
e.angVel.addScaledVector(e.axisT, -sg * overT * kL * h);
const depthT = clamp(overT / Math.max(twl * (1 - f), 1e-3), 0, 1.5);
const vo = e.angVel.dot(e.axisT) * sg;
if (vo > 0) e.angVel.addScaledVector(e.axisT, -sg * vo * (1 - Math.exp(-params.limitDamping * depthT * h)));
}
if (params.restReturn > 0) {
const rr = params.restReturn * h * invI;
e.angVel.addScaledVector(e.axisH, -dh * rr);
e.angVel.addScaledVector(e.axisS, -ds * rr);
e.angVel.addScaledVector(e.axisT, -dtw * rr);
}
}
function clampToLimits(e) {
const sc = params.jointLimitScale;
decomposeDev(e.dev, e, _dec);
let h = _dec.h, s = _dec.s, tw = _dec.tw;
const a = Math.max((h >= 0 ? e.flex : e.ext) * sc, 1e-4);
const b = Math.max(e.lat * sc, 1e-4);
const r = Math.hypot(h / a, s / b);
let clamped = false;
if (r > 1) {
h /= r; s /= r;
_nrm.set(0, 0, 0).addScaledVector(e.axisH, h / (a * a)).addScaledVector(e.axisS, s / (b * b));
if (_nrm.lengthSq() > 1e-12) {
_nrm.normalize();
const vOut = e.angVel.dot(_nrm);
if (vOut > 0) e.angVel.addScaledVector(_nrm, -vOut);
}
clamped = true;
}
const twl = e.twist * sc;
if (Math.abs(tw) > twl) {
const sg = Math.sign(tw);
tw = sg * twl;
const vo = e.angVel.dot(e.axisT) * sg;
if (vo > 0) e.angVel.addScaledVector(e.axisT, -sg * vo);
clamped = true;
}
if (clamped) composeDev(h, s, tw, e, e.dev);
}
function refreshFrames() {
for (const e of simBones) {
e.bone.parent.getWorldQuaternion(_qa);
e.restWorld.copy(_qa).multiply(e.rest);
e.restWorldInv.copy(e.restWorld).invert();
}
}
/* ============================================================
CHOREOGRAFIE
Ein einziges 3D-Perlin/Curl-Feld steuert alles gemeinsam:
1. Zwei Fokuspunkte treiben durchs Feld, stehen im Dialog (Anziehung ↔ Reibung).
2. Arme + Beine werden von den Fokuspunkten angezogen oder abgestoßen (Polarität
aus dem Feld, mit Ruhephasen dazwischen, Arme/Beine gegengleich versetzt).
3. Körper richtet sich an Fokus 2, Brust an Fokus 1, Blick wandert dazwischen
→ die Wirbelsäule bekommt die Torsion aus dem "Gespräch" der beiden Punkte.
4. Haltung: räumlich kohärente Zielwinkel aus demselben Feld, mit Verzögerung
entlang der Kette (Welle statt Zappeln).
============================================================ */
let simTime = 0;
const _f1 = new THREE.Vector3(), _f2 = new THREE.Vector3(), _f3 = new THREE.Vector3();
function initFoci() {
const R = params.focusRadius * bodyScale;
foci[0].rel.set(0.8, 0.25, 0.55).normalize().multiplyScalar(R);
foci[1].rel.set(-0.75, -0.1, 0.6).normalize().multiplyScalar(R);
foci.forEach((f) => { f.v.set(0, 0, 0); f.world.copy(characterRoot.position).add(f.rel); });
}
function updateFoci(h) {
const S = bodyScale;
const t = simTime * params.choreoSpeed;
const R0 = params.focusRadius * S;
const fs = params.fieldScale / S;
for (const f of foci) {
curlNoise(f.rel.x * fs + f.seed, f.rel.y * fs, f.rel.z * fs, t * 0.5, _f2);
_f2.multiplyScalar(params.focusSpeed * S * 0.6);
const d = f.rel.length() + 1e-6;
const Rt = R0 * (1 + 0.3 * clamp(noise3(f.seed * 0.7 + 0.3, t * 0.6, 3.3) * 1.4, -1, 1));
_f2.addScaledVector(f.rel, ((Rt - d) / d) * 1.6);
f.v.lerp(_f2, 1 - Math.exp(-2.5 * h));
}
// Dialog: schwingt zwischen Annäherung, Abstoßung und gegenseitigem Umkreisen
if (params.dialogue > 0) {
_f1.subVectors(foci[1].rel, foci[0].rel);
const dist = _f1.length() + 1e-6;
_f1.divideScalar(dist);
const D = params.dialogueDist * S * (0.5 + 0.9 * (0.5 + 0.5 * clamp(noise3(5.5, t * 0.4, 1.7) * 1.6, -1, 1)));
const err = dist - D;
const a = err * params.dialogue * 3.0;
foci[0].v.addScaledVector(_f1, a * h);
foci[1].v.addScaledVector(_f1, -a * h);
// Reibung: wenn nah, gleiten sie aneinander vorbei
const rub = smoothstep(0.85 * D, 0.35 * D, dist) * params.dialogue;
if (rub > 0.001) {
noiseVec3(77.7, t * 0.7, _f3).normalize();
_f2.crossVectors(_f3, _f1).multiplyScalar(rub * S * 0.7 * h * 6);
foci[0].v.add(_f2);
foci[1].v.sub(_f2);
}
}
for (const f of foci) {
f.rel.addScaledVector(f.v, h);
f.world.copy(characterRoot.position).add(f.rel);
}
}
// pro Frame: Polarität/Affinität der Gliedmaßen + Haltungsziele aus dem Feld
function updateChoreoFrame() {
const t = simTime * params.choreoSpeed;
for (const L of limbs) {
const tl = t - L.lag * params.choreoSpeed;
const n = noise3(L.seed, tl * 0.5 + 0.2, 1.3) * 1.6 + (0.5 - params.repelShare) * 0.9;
L.pol = Math.sign(n) * smoothstep(0.12, 0.65, Math.abs(n));
const a = noise3(L.seed + 9.1, tl * 0.3 + 0.7, 4.4) * 2.0;
L.aff = smoothstep(-0.35, 0.35, a);
}
const fsz = params.fieldScale * 0.8;
const amp = params.posture;
for (const e of simBones) {
const tt = (simTime - e.depth * params.postureLag) * params.choreoSpeed * 0.5;
const px = e.restPos.x * fsz, py = e.restPos.y * fsz, pz = e.restPos.z * fsz;
const nx = clamp(noise3(px + 11.3, py + tt, pz + 3.1) * 1.4, -1, 1);
const ny = clamp(noise3(px + 47.7, py + tt * 0.9 + 5.0, pz - 2.2) * 1.4, -1, 1);
const nz = clamp(noise3(px - 23.1, py + tt * 1.1 - 9.0, pz + 7.7) * 1.4, -1, 1);
const fr = amp * e.noiseGain * 0.65;
if (e.hinge) {
e.tgtH = (0.5 + 0.5 * nx) * e.flex * fr * 0.9; e.tgtS = 0; e.tgtT = 0;
} else {
e.tgtH = nx * (nx > 0 ? e.flex : e.ext) * fr;
e.tgtS = ny * e.lat * fr;
e.tgtT = nz * e.twist * fr * 0.7;
}
}
}
const _lr = new THREE.Vector3(), _le = new THREE.Vector3(), _lp = new THREE.Vector3();
const _lt = new THREE.Vector3(), _lf = new THREE.Vector3(), _lv = new THREE.Vector3();
function applyLimbForces(h, cs) {
if (params.limbForce <= 0 || cs < 0.001) return;
for (const L of limbs) {
const pol = L.pol;
if (Math.abs(pol) < 0.002) continue;
wpos(L.root, _lr); wpos(L.eff, _le);
_lp.lerpVectors(foci[0].world, foci[1].world, L.aff);
_lf.set(0, 0, 0);
if (pol > 0) {
// anziehen: Endpunkt strebt zum Fokus, maximal bis zur Reichweite
_lt.subVectors(_lp, _lr);
const dF = _lt.length() + 1e-6;
_lt.multiplyScalar(Math.min(dF, L.reach * 0.97) / dF).add(_lr);
_lf.subVectors(_lt, _le).multiplyScalar(params.limbForce * L.k * pol);
} else {
// abstoßen: weicht dem Fokus aus, solange er nah ist
_lt.subVectors(_le, _lp);
const dE = _lt.length() + 1e-6;
const Rr = L.reach * 1.6;
if (dE < Rr) _lf.copy(_lt).multiplyScalar((1 / dE) * (1 - dE / Rr) * params.limbForce * L.k * (-pol) * L.reach * 1.2);
}
pointVelocity(L.eff, _le, _lv);
_lf.addScaledVector(_lv, -params.limbDamping * Math.min(1, Math.abs(pol) * 3));
_lf.multiplyScalar(cs);
if (_lf.length() > params.limbMaxForce) _lf.setLength(params.limbMaxForce);
applyForceAtBone(L.eff, _lf, _le, h, { body: params.bodyCoupling * 0.3, falloff: 0.78, levels: 5 });
}
}
const _ae1 = new THREE.Vector3(), _ae2 = new THREE.Vector3(), _ae3 = new THREE.Vector3(), _ae4 = new THREE.Vector3();
const _aq = new THREE.Quaternion();
function aimEntry(e, targetWorld, gain, h) {
wpos(e.bone, _ae1);
_ae2.subVectors(targetWorld, _ae1);
const l = _ae2.length();
if (l < 1e-4) return;
_ae2.divideScalar(l);
_aq.copy(e.restWorld).multiply(e.dev);
_ae3.copy(e.fwdLocal).applyQuaternion(_aq);
_ae4.crossVectors(_ae3, _ae2).applyQuaternion(e.restWorldInv);
e.angVel.addScaledVector(_ae4, (gain * h) / Math.max(e.inertia, 0.05));
}
const _bf = new THREE.Vector3(), _bd = new THREE.Vector3(), _bt = new THREE.Vector3(), _gz = new THREE.Vector3();
function applyAim(h, cs, t) {
const ta = params.torsoAim * cs;
if (ta > 0.001) {
// Ganzkörper → Fokus 2 (Achse: Blickrichtung des Rigs)
_bd.subVectors(foci[1].world, characterRoot.position).normalize();
_bf.copy(bodyForwardLocal).applyQuaternion(characterRoot.quaternion);
bodyAngVel.addScaledVector(_bt.crossVectors(_bf, _bd), ta * 2.2 * h);
// Brust → Fokus 1 : Torsion entlang der Wirbelsäule
for (const [e, w] of chestAim) aimEntry(e, foci[0].world, ta * 9 * w, h);
}
const hl = params.headLook * cs;
if (hl > 0.001) {
const ga = smoothstep(-0.3, 0.3, noise3(19.3, t * 0.3 + 0.4, 2.2) * 2);
_gz.lerpVectors(foci[0].world, foci[1].world, ga);
for (const [e, w] of headAim) aimEntry(e, _gz, hl * 8 * w, h);
}
if (params.upright > 0) {
_bf.copy(WORLD_UP).applyQuaternion(characterRoot.quaternion);
bodyAngVel.addScaledVector(_bt.crossVectors(_bf, WORLD_UP), params.upright * 2 * h);
}
}
function applyChoreography(h, cs) {
updateFoci(h);
const t = simTime * params.choreoSpeed;
if (params.driftForce > 0) { noiseVec3(911.0, t * 0.6, _vc); bodyVel.addScaledVector(_vc, params.driftForce * cs * h); }
if (params.driftSpin > 0) { noiseVec3(1723.0, t * 0.5, _vc); bodyAngVel.addScaledVector(_vc, params.driftSpin * cs * h); }
applyLimbForces(h, cs);
applyAim(h, cs, t);
}
/* ============================================================
ZIEHEN
============================================================ */
const _dw = new THREE.Vector3(), _df = new THREE.Vector3(), _dv = new THREE.Vector3();
function applyDrag(h) {
const elapsed = (performance.now() - interaction.startTime) / 1000;
const ramp = Math.min(elapsed / Math.max(params.forceRampSeconds, 0.001), 1);
_dw.copy(interaction.localOffset).applyMatrix4(interaction.bone.matrixWorld);
_df.subVectors(interaction.target, _dw).multiplyScalar(ramp * params.attractForce);
pointVelocity(interaction.bone, _dw, _dv);
_df.addScaledVector(_dv, -params.dragDamping);
if (_df.length() > params.maxForce) _df.setLength(params.maxForce);
const e = nearestSimEntry(interaction.bone);
const body = (!e || e.isTorso) ? params.torsoDragCoupling : params.bodyCoupling;
applyForceAtBone(interaction.bone, _df, _dw, h, { body, gain: params.dragBoost });
}
/* ============================================================
KOLLISION (Kraft + positionelle Korrektur)
============================================================ */
const _cpA = new THREE.Vector3(), _cpB = new THREE.Vector3(), _cn = new THREE.Vector3();
const _cvA = new THREE.Vector3(), _cvB = new THREE.Vector3(), _cF = new THREE.Vector3();
const _n1 = new THREE.Vector3(), _n2 = new THREE.Vector3(), _n3 = new THREE.Vector3();
const _nq1 = new THREE.Quaternion(), _nq2 = new THREE.Quaternion();
// dreht das Gelenk um seinen Ursprung, so dass `point` um ~disp wandert
function nudgeEntry(e, point, disp) {
wpos(e.bone, _n1);
_n2.subVectors(point, _n1);
const L2 = _n2.lengthSq();
if (L2 < 4e-4) return;
_n3.crossVectors(_n2, disp);
const ang = _n3.length() / L2;
if (ang < 1e-6) return;
_n3.normalize();
e.bone.parent.getWorldQuaternion(_nq1);
_nq2.copy(_nq1).multiply(e.rest).invert();
_n3.applyQuaternion(_nq2);
_nq1.setFromAxisAngle(_n3, Math.min(ang, 0.012));
e.dev.premultiply(_nq1).normalize();
clampToLimits(e);
e.bone.quaternion.copy(e.rest).multiply(e.dev).normalize();
e.bone.updateMatrixWorld(true);
}
const CONTACT_LEVELS = { body: 0, falloff: 0.6, levels: 3 };
function resolveCollisions(h) {
if (!params.selfCollision || !segPairs.length) return;
for (const s of segs) { wpos(s.bone, s.a); wpos(s.end, s.b); }
const sc = params.collisionScale, pad = params.collisionPadding;
for (const pr of segPairs) {
const A = pr.A, B = pr.B;
const d2 = segSegClosest(A.a, A.b, B.a, B.b, _cpA, _cpB);
const minD = Math.min((A.r + B.r) * sc + pad, pr.restCap);
if (d2 >= minD * minD) continue;
const d = Math.sqrt(d2);
if (d > 1e-6) _cn.subVectors(_cpA, _cpB).divideScalar(d);
else {
_cn.set((A.a.x + A.b.x) - (B.a.x + B.b.x), (A.a.y + A.b.y) - (B.a.y + B.b.y), (A.a.z + A.b.z) - (B.a.z + B.b.z));
if (_cn.lengthSq() < 1e-10) _cn.set(0, 1, 0);
_cn.normalize();
}
const pen = minD - d;
// 1) Kontaktkraft mit Dämpfung der Annäherung → weiches Abprallen
pointVelocity(A.bone, _cpA, _cvA);
pointVelocity(B.bone, _cpB, _cvB);
const vn = _cvA.sub(_cvB).dot(_cn);
let mag = params.collisionStiffness * pen - params.collisionDamping * Math.min(vn, 0);
mag = clamp(mag, 0, params.collisionMaxForce);
if (mag > 0) {
const gA = A.e.isTorso ? 0.25 : 1, gB = B.e.isTorso ? 0.25 : 1;
_cF.copy(_cn).multiplyScalar(mag);
applyForceAtBone(A.bone, _cF, _cpA, h, { ...CONTACT_LEVELS, gain: gA });
_cF.negate();
applyForceAtBone(B.bone, _cF, _cpB, h, { ...CONTACT_LEVELS, gain: gB });
}
// 2) Positionelle Korrektur: Glieder werden garantiert auseinandergeschoben
if (params.collisionProject > 0) {
const wA = A.e.isTorso ? 0 : 1, wB = B.e.isTorso ? 0 : 1;
const wsum = wA + wB;
if (wsum > 0) {
const push = Math.min(pen * params.collisionProject, params.collisionMaxStep);
if (wA) nudgeChain(A.e, _cpA, _n1.copy(_cn).multiplyScalar(push * wA / wsum));
if (wB) nudgeChain(B.e, _cpB, _n1.copy(_cn).multiplyScalar(-push * wB / wsum));
for (const s of segs) { wpos(s.bone, s.a); wpos(s.end, s.b); }
}
}
}
}
const _nd = new THREE.Vector3();
function nudgeChain(e, point, disp) {
_nd.copy(disp);
nudgeEntry(e, point, _nd.multiplyScalar(0.65));
const p = e.parentEntry;
if (p && !p.isTorso) { _nd.copy(disp); nudgeEntry(p, point, _nd.multiplyScalar(0.35)); }
}
/* ============================================================
PHYSIK
============================================================ */
const clock = new THREE.Clock();
let accumulator = 0;
const FIXED_H = 1 / 180;
function stepPhysics(h) {
simTime += h;
scene.updateMatrixWorld(true);
const jd = Math.exp(-params.jointDamping * h);
const leafD = Math.exp(-params.jointDamping * (1 + 0.35 * params.leafCalm) * h);
const bd = Math.exp(-params.bodyDamping * h);
for (const e of simBones) {
e.bone.parent.getWorldQuaternion(_qa);
e.restWorld.copy(_qa).multiply(e.rest);
e.restWorldInv.copy(e.restWorld).invert();
const pe = boneEntry.get(e.bone.parent);
e.wav.copy(e.angVel).applyQuaternion(e.restWorld).add(pe ? pe.wav : bodyAngVel);
}
// Choreografie fährt beim Ziehen herunter und kommt danach sanft zurück
const blendTarget = interaction ? params.dragChoreoFloor : 1;
choreoBlend += (blendTarget - choreoBlend) * (1 - Math.exp(-h * (interaction ? 10 : 0.9)));
const cs = params.choreoEnabled ? params.choreoAmount * choreoBlend : 0;
if (interaction) applyDrag(h);
if (params.choreoEnabled) applyChoreography(h, cs);
bodyVel.addScaledVector(_va.copy(characterRoot.position).negate(), params.hipReturnStrength * h);
bodyVel.multiplyScalar(bd);
bodyAngVel.multiplyScalar(bd);
if (bodyAngVel.length() > 3) bodyAngVel.setLength(3);
characterRoot.position.addScaledVector(bodyVel, h);
characterRoot.quaternion.premultiply(quatFromAngularVelocity(bodyAngVel, h, _qa)).normalize();
// Trägheits-Kaskade: Kind-Glieder hinken der Bewegung des Elternteils hinterher (Nachschwingen)
for (const e of simBones) {
const pe = boneEntry.get(e.bone.parent);
const pw = pe ? pe.wav : bodyAngVel;
_va.copy(pw).sub(e.prevParentWav);
e.prevParentWav.copy(pw);
if (_va.length() > 2.0) _va.setLength(2.0);
_va.applyQuaternion(e.restWorldInv);
const damp = e.isLeaf ? (1 - params.leafCalm * 0.7) : 1;
e.angVel.addScaledVector(_va, -params.limbInertia * damp / Math.max(e.inertia, 0.05) * 0.25);
}
const postureK = params.posture > 0 ? params.postureStiffness * cs : 0;
for (const e of simBones) {
driveAndLimit(e, h, postureK);
if (e.angVel.length() > params.angVelClamp) e.angVel.setLength(params.angVelClamp);
e.angVel.multiplyScalar(e.isLeaf ? leafD : jd);
e.dev.premultiply(quatFromAngularVelocity(e.angVel, h, _qa)).normalize();
clampToLimits(e);
e.bone.quaternion.copy(e.rest).multiply(e.dev).normalize();
}
scene.updateMatrixWorld(true);
resolveCollisions(h);
}
function updatePhysics(dt) {
if (params.choreoEnabled) updateChoreoFrame();
accumulator += Math.min(dt, 0.05);
let n = 0;
while (accumulator >= FIXED_H && n < 10) { stepPhysics(FIXED_H); accumulator -= FIXED_H; n++; }
if (n === 10) accumulator = 0;
}
function freezeAll() {
bodyVel.set(0, 0, 0);
bodyAngVel.set(0, 0, 0);
for (const e of simBones) { e.angVel.set(0, 0, 0); e.prevParentWav.set(0, 0, 0); }
accumulator = 0;
}
function resetPose() {
freezeAll();
characterRoot.position.set(0, 0, 0);
characterRoot.quaternion.identity();
for (const e of simBones) { e.dev.identity(); e.bone.quaternion.copy(e.rest); }
choreoBlend = 1;
initFoci();
refreshPoseSliders();
}
/* ============================================================
MAPPING-MODUS — direktes Posieren per Maus (CCD, hart begrenzt)
============================================================ */
function mappingDrag() {
if (!interaction) return;
scene.updateMatrixWorld(true);
const chain = simulatedChain(interaction.bone).slice(0, Math.max(1, Math.round(params.mapChainDepth)));
const origin = new THREE.Vector3();
for (let iter = 0; iter < 2; iter++) {
for (const e of chain) {
const worldPoint = interaction.localOffset.clone().applyMatrix4(interaction.bone.matrixWorld);
e.bone.getWorldPosition(origin);
const cur = new THREE.Vector3().subVectors(worldPoint, origin);
const des = new THREE.Vector3().subVectors(interaction.target, origin);
if (cur.lengthSq() < 1e-8 || des.lengthSq() < 1e-8) continue;
cur.normalize(); des.normalize();
const axis = new THREE.Vector3().crossVectors(cur, des);
if (axis.lengthSq() < 1e-10) continue;
axis.normalize();
const angle = Math.acos(clamp(cur.dot(des), -1, 1)) * params.mapDragSpeed;
e.bone.parent.getWorldQuaternion(_qa);
e.restWorld.copy(_qa).multiply(e.rest);
e.restWorldInv.copy(e.restWorld).invert();
axis.applyQuaternion(e.restWorldInv);
e.dev.premultiply(_qb.setFromAxisAngle(axis, angle)).normalize();
clampToLimits(e);
e.bone.quaternion.copy(e.rest).multiply(e.dev).normalize();
scene.updateMatrixWorld(true);
}
}
}
/* ============================================================
MAPPING-PANEL (Liste + Editor)
============================================================ */
const panelEl = document.getElementById("mapping-panel");
const listEl = document.getElementById("mapping-list");
const editorEl = document.getElementById("mapping-editor");
let selectedIdx = 0;
let mapGui = null;
let listRows = [];
const poseProxy = { h: 0, s: 0, tw: 0 };
function selectedEntry() { return simBones[selectedIdx] || null; }
function buildMappingList() {
listEl.innerHTML = "";
listRows = simBones.map((e, i) => {
const row = document.createElement("div");
row.className = "mrow" + (i === selectedIdx ? " sel" : "");
row.innerHTML =
`<div><span class="mname">${e.short}</span>` +
`<span class="mtag">${e.hinge ? "Scharnier" : "Kugel"}${e.isLeaf ? " · Ende" : ""}</span></div>` +
`<div class="mvals"></div>`;
row.addEventListener("click", () => selectBone(i));
listEl.appendChild(row);
return { row, vals: row.querySelector(".mvals") };
});
buildMappingEditor();
updateMappingList(true);
}
function fmt(v) { return (v >= 0 ? " " : "") + v.toFixed(0); }
let listClock = 0;
function updateMappingList(force) {
if (!listRows.length) return;
const now = performance.now();
if (!force && now - listClock < 70) return;
listClock = now;
const sc = params.jointLimitScale;
for (let i = 0; i < simBones.length; i++) {
const e = simBones[i];
decomposeDev(e.dev, e, _dec);
const h = DEG(_dec.h), s = DEG(_dec.s), tw = DEG(_dec.tw);
const lf = DEG(e.flex * sc), le = DEG(e.ext * sc), ll = DEG(e.lat * sc), lt = DEG(e.twist * sc);
const a = Math.max(h >= 0 ? lf : le, 1e-3), b = Math.max(ll, 1e-3);
const swingHot = Math.hypot(h / a, s / b) > 0.985 ? "hot" : "";
const twHot = Math.abs(tw) > lt * 0.985 ? "hot" : "";
listRows[i].vals.innerHTML =
`B <span class="${swingHot}"><b>${fmt(h)}°</b></span> [−${le.toFixed(0)}…${lf.toFixed(0)}] ` +
`S <span class="${swingHot}"><b>${fmt(s)}°</b></span> [±${ll.toFixed(0)}] ` +
`T <span class="${twHot}"><b>${fmt(tw)}°</b></span> [±${lt.toFixed(0)}]`;
}
}
function selectBone(i) {
selectedIdx = clamp(i, 0, simBones.length - 1);
listRows.forEach((r, k) => r.row.classList.toggle("sel", k === selectedIdx));
const r = listRows[selectedIdx];
if (r) r.row.scrollIntoView({ block: "nearest" });
buildMappingEditor();
}
function applyPoseFromSliders() {
const e = selectedEntry();
if (!e) return;
composeDev(RAD(poseProxy.h), RAD(poseProxy.s), RAD(poseProxy.tw), e, e.dev);
clampToLimits(e);
e.bone.quaternion.copy(e.rest).multiply(e.dev).normalize();
scene.updateMatrixWorld(true);
}
function refreshPoseSliders() {
const e = selectedEntry();
if (!e) return;
decomposeDev(e.dev, e, _dec);
poseProxy.h = DEG(_dec.h); poseProxy.s = DEG(_dec.s); poseProxy.tw = DEG(_dec.tw);
}
function buildMappingEditor() {
if (mapGui) { mapGui.destroy(); mapGui = null; }
const e = selectedEntry();
if (!e) return;
refreshPoseSliders();
mapGui = new GUI({ container: editorEl, title: e.short, width: 350 });
const lim = {
flex: DEG(e.flex), ext: DEG(e.ext), lat: DEG(e.lat), twist: DEG(e.twist),
inertia: e.inertia, hinge: e.hinge, noiseGain: e.noiseGain,
};
const fL = mapGui.addFolder("Constraints");
fL.add(lim, "flex", 0, 180, 0.5).name("Beugung + max °").onChange((v) => { e.flex = RAD(v); clampToLimits(e); });
fL.add(lim, "ext", 0, 180, 0.5).name("Streckung − max °").onChange((v) => { e.ext = RAD(v); clampToLimits(e); });
fL.add(lim, "lat", 0, 180, 0.5).name("Seitlich ± °").onChange((v) => { e.lat = RAD(v); clampToLimits(e); });
fL.add(lim, "twist", 0, 180, 0.5).name("Verdrehung ± °").onChange((v) => { e.twist = RAD(v); clampToLimits(e); });
fL.add(lim, "inertia", 0.05, 6, 0.05).name("Trägheit").onChange((v) => (e.inertia = v));
fL.add(lim, "noiseGain", 0, 2, 0.01).name("Haltungs-Anteil").onChange((v) => (e.noiseGain = v));
fL.add(lim, "hinge").name("Scharniergelenk").onChange((v) => { e.hinge = v; buildMappingList(); });
fL.add({ flip: () => {
e.axisH.negate(); e.axisS.crossVectors(e.axisT, e.axisH).normalize();
e.dev.identity(); e.bone.quaternion.copy(e.rest); refreshPoseSliders();
} }, "flip").name("↔ Beugeachse umkehren");
fL.open();
const fP = mapGui.addFolder("Pose von Hand");
fP.add(poseProxy, "h", -180, 180, 0.5).name("Beugung °").listen().onChange(applyPoseFromSliders);
fP.add(poseProxy, "s", -180, 180, 0.5).name("Seitlich °").listen().onChange(applyPoseFromSliders);
fP.add(poseProxy, "tw", -180, 180, 0.5).name("Verdrehung °").listen().onChange(applyPoseFromSliders);
fP.add({ zero: () => { poseProxy.h = poseProxy.s = poseProxy.tw = 0; applyPoseFromSliders(); } }, "zero").name("Gelenk auf 0");
fP.open();
}
function setMappingMode(on) {
params.mappingMode = on;
panelEl.classList.toggle("on", on);
jointGizmo.visible = on;
if (on) { freezeAll(); refreshFrames(); refreshPoseSliders(); updateMappingList(true); }
document.getElementById("hint").textContent = on ? HINT_MAP : HINT_PLAY;
}
document.getElementById("btn-export").addEventListener("click", downloadMapping);
document.getElementById("btn-import").addEventListener("click", () => document.getElementById("file-input").click());
document.getElementById("file-input").addEventListener("change", (ev) => {
const f = ev.target.files && ev.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => {
try {
const note = applyBoneMapping(JSON.parse(r.result));
buildMappingList();
if (gui) gui.controllersRecursive().forEach((c) => c.updateDisplay());
setStatus(note);
setTimeout(() => setStatus(""), 4000);
} catch (err) { setStatus("JSON konnte nicht gelesen werden."); console.error(err); }
};
r.readAsText(f);
ev.target.value = "";
});
document.getElementById("btn-zero").addEventListener("click", () => {
for (const e of simBones) { e.dev.identity(); e.bone.quaternion.copy(e.rest); }
freezeAll(); scene.updateMatrixWorld(true); refreshPoseSliders();
});
// Alle Gelenke auf ihr maximales Beugelimit fahren — zeigt sofort, ob
// eine Achse oder ein Vorzeichen falsch herum sitzt.
document.getElementById("btn-extreme").addEventListener("click", () => {
for (const e of simBones) {
composeDev(e.flex * params.jointLimitScale, 0, 0, e, e.dev);
e.bone.quaternion.copy(e.rest).multiply(e.dev).normalize();
}
freezeAll(); scene.updateMatrixWorld(true); refreshPoseSliders();
});
/* ============================================================
HAUPT-GUI
============================================================ */
let gui;
function buildGUI() {
gui = new GUI({ title: "Parameter" });
gui.add(params, "mappingMode").name("MAPPING-MODUS").onChange(setMappingMode);
const fMap = gui.addFolder("Mapping-Handling");
fMap.add(params, "mapDragSpeed", 0.02, 1, 0.01).name("Zieh-Geschwindigkeit");
fMap.add(params, "mapChainDepth", 1, 4, 1).name("Kettentiefe");
fMap.close();
const fCh = gui.addFolder("Choreografie (Feld + Fokus)");
fCh.add(params, "choreoEnabled").name("Aktiv");
fCh.add(params, "choreoAmount", 0, 2, 0.01).name("Intensität");
fCh.add(params, "choreoSpeed", 0.02, 2, 0.01).name("Tempo");
fCh.add(params, "fieldScale", 0.1, 3, 0.01).name("Feld-Frequenz (räumlich)");
fCh.add(params, "focusRadius", 0.5, 1.8, 0.01).name("Fokus-Abstand");
fCh.add(params, "focusSpeed", 0.02, 2, 0.01).name("Fokus-Tempo");
fCh.add(params, "dialogue", 0, 2, 0.01).name("Dialog / Reibung");
fCh.add(params, "dialogueDist", 0.3, 2, 0.01).name("Dialog-Abstand");
fCh.add(params, "repelShare", 0, 1, 0.01).name("Abstoßung ↔ Anziehung");
fCh.add(params, "limbForce", 0, 12, 0.05).name("Arme / Beine — Stärke");
fCh.add(params, "limbDamping", 0, 12, 0.1).name("Arme / Beine — Dämpfung");
fCh.add(params, "torsoAim", 0, 2, 0.01).name("Körper + Brust ausrichten");
fCh.add(params, "headLook", 0, 2, 0.01).name("Blick");
fCh.add(params, "upright", 0, 1, 0.01).name("Aufrichtung");
fCh.add(params, "posture", 0, 1.5, 0.01).name("Haltung / Wellen");
fCh.add(params, "postureStiffness", 0.5, 30, 0.1).name("Haltung — Steifigkeit");
fCh.add(params, "postureLag", 0, 1.5, 0.01).name("Wellen-Verzögerung (s)");
fCh.add(params, "driftForce", 0, 1, 0.005).name("Drift — Position");
fCh.add(params, "driftSpin", 0, 1, 0.005).name("Drift — Rotation");
fCh.add(params, "showFocus").name("Fokuspunkte zeigen");
const fPhys = gui.addFolder("Physik / Muskeln");
fPhys.add(params, "jointDamping", 0, 10, 0.05).name("Gelenkdämpfung (Öl)");
fPhys.add(params, "bodyDamping", 0, 6, 0.05).name("Ganzkörper-Dämpfung");
fPhys.add(params, "compliance", 0.1, 8, 0.05).name("Beugefreude (global)");
fPhys.add(params, "hingeBoost", 0.5, 10, 0.1).name("Knie / Ellbogen extra");
fPhys.add(params, "leafCalm", 0, 1, 0.01).name("Hände / Kopf beruhigen");
fPhys.add(params, "jointLimitScale", 0.2, 1.6, 0.01).name("Gelenk-Limit");
fPhys.add(params, "limitStiffness", 2, 200, 1).name("Anschlag-Härte");
fPhys.add(params, "limitSoftZone", 0.2, 1.0, 0.01).name("Anschlag-Weichzone");
fPhys.add(params, "limitDamping", 0, 120, 1).name("Anschlag-Dämpfung");
fPhys.add(params, "restReturn", 0, 20, 0.05).name("Rückstellung Ruhepose");
fPhys.add(params, "hipReturnStrength", 0, 3, 0.01).name("Rückzug zum Nullpunkt");
fPhys.add(params, "limbInertia", 0, 3, 0.02).name("Trägheits-Kaskade");
fPhys.add(params, "angVelClamp", 2, 100, 1).name("Sicherheits-Limit (rad/s)");
const fCol = gui.addFolder("Kollision");
fCol.add(params, "selfCollision").name("Selbstkollision");
fCol.add(params, "collisionScale", 0.3, 1.5, 0.01).name("Kapsel-Größe").onChange(rebuildColliderViz);
fCol.add(params, "collisionPadding", 0, 0.05, 0.001).name("Abstand (m)");
fCol.add(params, "collisionStiffness", 0, 300, 1).name("Kontakt-Härte");
fCol.add(params, "collisionDamping", 0, 60, 0.5).name("Kontakt-Dämpfung");
fCol.add(params, "collisionProject", 0, 1, 0.01).name("Auseinanderschieben");
fCol.add(params, "collisionMaxStep", 0.002, 0.05, 0.001).name("Max. Schritt (m)");
fCol.add(params, "showColliders").name("Kapseln zeigen");
const fInt = gui.addFolder("Interaktion (Ziehen)");
fInt.add(params, "attractForce", 1, 120, 0.5).name("Zug-Stärke");
fInt.add(params, "dragDamping", 0, 40, 0.1).name("Zug-Dämpfung");
fInt.add(params, "maxForce", 5, 400, 1).name("Kraft-Deckel");
fInt.add(params, "forceRampSeconds", 0.005, 1, 0.005).name("Kraft-Rampe (s)");
fInt.add(params, "dragBoost", 0.2, 6, 0.05).name("Gelenk-Ansprechen");
fInt.add(params, "torsoDragCoupling", 0, 1, 0.01).name("Torso ziehen → Körper");
fInt.add(params, "bodyCoupling", 0, 1, 0.01).name("Glied ziehen → Körper");
fInt.add(params, "torqueGain", 0.05, 10, 0.05).name("Drehmoment-Übertrag");
fInt.add(params, "chainFalloff", 0.1, 1.0, 0.01).name("Ketten-Abnahme");
fInt.add(params, "dragChoreoFloor", 0, 1, 0.01).name("Choreo-Rest beim Ziehen");
fInt.add(params, "pickScale", 0.4, 2, 0.05).name("Greif-Radius").onChange(applyPickRadii);
const fMat = gui.addFolder("Material (Chrom)");
fMat.addColor(params, "color").name("Farbe").onChange((v) => chromeMaterial.color.set(v));
fMat.add(params, "metalness", 0, 1, 0.01).onChange((v) => (chromeMaterial.metalness = v));
fMat.add(params, "roughness", 0, 1, 0.01).onChange((v) => (chromeMaterial.roughness = v));
fMat.add(params, "envMapIntensity", 0, 3, 0.01).name("Reflexions-Intensität").onChange((v) => (chromeMaterial.envMapIntensity = v));
fMat.add(params, "clearcoat", 0, 1, 0.01).onChange((v) => (chromeMaterial.clearcoat = v));
fMat.add(params, "clearcoatRoughness", 0, 1, 0.01).name("Clearcoat Rauheit").onChange((v) => (chromeMaterial.clearcoatRoughness = v));
fMat.add(params, "normalMapTiling", 0.25, 40, 0.05).name("Normal-Map Tiling").onChange((v) => { if (normalTex) normalTex.repeat.set(v, v); });
fMat.add(params, "normalMapStrength", 0, 3, 0.01).name("Normal-Map Stärke").onChange((v) => chromeMaterial.normalScale.set(v, v));
const fLight = gui.addFolder("Licht");
fLight.add(params, "lightIntensity", 0, 8, 0.1).onChange((v) => (keyLight.intensity = v));
fLight.addColor(params, "lightColor").name("Farbe").onChange((v) => keyLight.color.set(v));
fLight.add(params, "lightAzimuth", 0, 360, 1).name("Azimut").onChange(updateLightPos);
fLight.add(params, "lightElevation", 5, 89, 1).name("Elevation").onChange(updateLightPos);
fLight.add(params, "ambient", 0, 1, 0.01).name("Fülllicht").onChange((v) => (fill.intensity = v));
const fCam = gui.addFolder("Kamera");
fCam.add(params, "fov", 6, 100, 0.5).name("FOV (Mausrad)").listen().onChange((v) => { camera.fov = v; camera.updateProjectionMatrix(); });
fCam.add(params, "camLookSpeed", 0.2, 8, 0.1).name("Folge-Geschwindigkeit");
fCam.add(params, "camDistance", 0.8, 10, 0.05).name("Abstand");
fCam.add(params, "camAzimuth", -180, 180, 1).name("Orbit Azimut").listen();
fCam.add(params, "camElevation", -80, 85, 1).name("Orbit Elevation").listen();
const fScene = gui.addFolder("Szene");
fScene.add(params, "showOrientation").name("Koordinatensystem").onChange((v) => { if (orientationGizmo) orientationGizmo.visible = v; });
gui.add({ save: downloadMapping }, "save").name("⇩ bonemapping.json");
gui.add({ reset: resetPose }, "reset").name("↺ Zurücksetzen (T-Pose)");
fCh.open(); fPhys.close(); fCol.close(); fInt.close();
fMat.close(); fLight.close(); fCam.close(); fScene.close();
}
function updateLightPos() {
const az = RAD(params.lightAzimuth), el = RAD(params.lightElevation), d = 3.2;
keyLight.position.set(Math.cos(az) * Math.cos(el) * d, Math.sin(el) * d, Math.sin(az) * Math.cos(el) * d);
keyLight.target.position.copy(characterRoot.position);
}
/* ============================================================
KAMERA
============================================================ */
function updateCamera(dt) {
const t = 1 - Math.exp(-params.camLookSpeed * dt);
cameraRig.targetLook.lerp(characterRoot.position, t);
const az = RAD(params.camAzimuth), el = RAD(params.camElevation), r = params.camDistance;
camera.position.copy(cameraRig.targetLook).add(new THREE.Vector3(
Math.sin(az) * Math.cos(el) * r, Math.sin(el) * r, Math.cos(az) * Math.cos(el) * r));
camera.lookAt(cameraRig.targetLook);
keyLight.target.position.copy(cameraRig.targetLook);
}
/* ============================================================
INTERAKTIONS-VISUALISIERUNG
============================================================ */
function updateInteractionVisuals() {
if (!skinnedMesh) { hitMarker.visible = false; forceLine.visible = false; return; }
if (interaction) {
const worldPoint = interaction.localOffset.clone().applyMatrix4(interaction.bone.matrixWorld);
const dir = new THREE.Vector3().subVectors(interaction.target, worldPoint);
const normal = dir.lengthSq() > 1e-10 ? dir.clone().normalize() : new THREE.Vector3(0, 0, 1);
hitMarker.position.copy(worldPoint);
orientMarkerToNormal(hitMarker, normal);
hitMarker.material.color.copy(COLOR_ATTRACT);
hitMarker.visible = true;
forceLine.geometry.setFromPoints([worldPoint, interaction.target]);
forceLine.material.color.copy(COLOR_ATTRACT);
forceLine.visible = true;
} else if (lastMouse.active && !orbitDragging) {
forceLine.visible = false;
const hit = pickCollider(lastMouse.x, lastMouse.y);
if (hit) {
const center = new THREE.Vector3();
hit.owner.getWorldPosition(center);
const normal = hit.point.clone().sub(center);
if (normal.lengthSq() < 1e-8) normal.set(0, 0, 1); else normal.normalize();
hitMarker.position.copy(hit.point);
orientMarkerToNormal(hitMarker, normal);
hitMarker.material.color.copy(COLOR_HOVER);
hitMarker.visible = true;
} else hitMarker.visible = false;
} else { hitMarker.visible = false; forceLine.visible = false; }
}
/* ============================================================
RENDER LOOP
============================================================ */
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
if (skinnedMesh) {
if (params.mappingMode) {
mappingDrag();
refreshFrames();
updateMappingList(false);
if (interaction) refreshPoseSliders();
updateJointGizmo();
} else {
updatePhysics(dt);
}
}
updateCamera(dt);
updateInteractionVisuals();
updateFocusVisuals();
updateColliderViz();
renderer.render(scene, camera);
}
camera.fov = params.fov;
camera.updateProjectionMatrix();
updateLightPos();
animate();
</script>
</body>
</html>