index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Oktopus</title>
<style>
:root { --ink:#1a1a1a; --mute:#8c8c8c; --line:#e9e9e9; }
html, body { margin:0; height:100%; background:#fff; overflow:hidden; color:var(--ink);
font:12px/1.35 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
canvas { display:block; touch-action:none; }
#ui { position:fixed; top:12px; right:12px; width:272px; max-height:calc(100% - 24px); overflow:auto;
background:#fff; border:1px solid var(--line); border-radius:8px; padding:10px 12px 8px; box-sizing:border-box; }
#ui header { display:flex; align-items:center; justify-content:space-between; margin-bottom:6px; }
#ui header b { font-weight:600; font-size:13px; }
button { font:inherit; color:var(--ink); background:#fff; border:1px solid #dcdcdc; border-radius:5px;
padding:3px 9px; cursor:pointer; }
button:hover { border-color:#111; }
button:focus-visible, input:focus-visible, summary:focus-visible { outline:2px solid #111; outline-offset:1px; }
.bar { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px; }
details { border-top:1px solid var(--line); padding:6px 0 4px; }
summary { cursor:pointer; font-weight:600; padding:2px 0 4px; list-style:none; }
summary::-webkit-details-marker { display:none; }
summary::before { content:"+"; display:inline-block; width:14px; color:var(--mute); }
details[open] > summary::before { content:"–"; }
.t { display:flex; align-items:center; gap:7px; padding:2px 0; cursor:pointer; }
.r { display:grid; grid-template-columns:1fr auto; gap:0 8px; padding:3px 0 2px; }
.r output { color:var(--mute); font-variant-numeric:tabular-nums; }
.r input { grid-column:1 / 3; width:100%; margin:2px 0 0; }
input[type=range], input[type=checkbox] { accent-color:#111; }
.seed { display:flex; align-items:center; gap:6px; padding:3px 0; }
.seed input { width:64px; font:inherit; border:1px solid #dcdcdc; border-radius:5px; padding:2px 5px; }
#status { color:var(--mute); padding:6px 0 2px; border-top:1px solid var(--line); font-variant-numeric:tabular-nums; }
#hint { position:fixed; left:14px; bottom:12px; color:var(--mute); max-width:60vw; pointer-events:none; }
#notice { position:fixed; left:14px; top:12px; color:#b3261e; pointer-events:none; }
#ui.collapsed .body { display:none; }
.r input.col { grid-column:auto; width:34px; height:20px; padding:0; border:1px solid #dcdcdc; border-radius:4px; background:#fff; margin:0; }
.file { display:inline-block; margin:4px 0 2px; }
.file input { display:none; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"three-mesh-bvh": "https://cdn.jsdelivr.net/npm/three-mesh-bvh@0.7.3/build/index.module.js"
} }
</script>
</head>
<body>
<div id="notice"></div>
<div id="hint">Linke Maustaste ziehen: Oktopus bewegen · Shift + ziehen: Tiefe · Mausrad: Zoom · Rechte Maustaste: Ansicht drehen · Leertaste: loslassen · GLB hierher ziehen</div>
<div id="ui">
<header><b>Oktopus</b><button id="fold" title="Menü ein-/ausklappen">–</button></header>
<div class="body">
<div class="bar">
<button id="reroll">Neu würfeln</button>
<button id="release">Loslassen</button>
<button id="defaults">Standardwerte</button>
</div>
<div class="bar">
<button id="copy">Als Mesh kopieren</button>
<button id="clearcopies">Kopien entfernen</button>
</div>
<div id="sections"></div>
<div id="status"></div>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import { MeshBVH } from 'three-mesh-bvh';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
// ==== SIM ====
const V3 = THREE.Vector3;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const ease = t => t * t * (3 - 2 * t);
function rng(seed) {
let a = seed >>> 0;
return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
}
const IDLE = 0, REACH = 1, WRAP = 2, RELEASE = 3;
const DEFAULT_P = {
seed: 7, length: 3.4, segments: 26, thickness: 1.0, headSize: 1.0,
inertia: 0.55, drag: 0.8, stiffness: 1.0, gravity: 0.5, wander: 0.6, curl: 0.9,
reachDist: 3.6, contactDist: 1.15, spread: 1.0, stagger: 0.10, reachSpeed: 1.0,
wrapSpeed: 1.2, pitch: 0.35, serp: 0.5, wrapFraction: 0.85, creep: 0.3, grip: 1.0, pull: 1.0, tear: 1.15,
objectScale: 1.0,
color: '#2b2d31', contact: '#e0787a', rough: 0.18, metal: 0.25, envInt: 1.0,
};
const DEFAULT_M = { reach: true, wrap: true, pull: true, tear: true, collide: true, wander: true,
lookAt: true, chroma: true, twin: true, bones: false, debug: false };
const P = { ...DEFAULT_P };
const M = { ...DEFAULT_M };
const W = {
scene: new THREE.Scene(),
hp: new V3(0, 2.4, 3.4), hv: new V3(), hq: new THREE.Quaternion(),
target: new V3(0, 2.4, 3.4), pullAcc: new V3(),
time: 0, R: 0.5, main: null, head: null, octoRoot: null, tent: [], armMats: [], headMat: null, cBase: new THREE.Color(), cContact: new THREE.Color(), twinRoot: null, headTwin: null, envMap: null, copies: [],
hd: 1e3, cpHead: new V3(), nHead: new V3(),
bvh: null, geo: null, raw: null, nSign: 1,
objMesh: null,
};
W.objMesh = new THREE.Mesh(new THREE.BufferGeometry(),
new THREE.MeshStandardMaterial({ color: 0xf4f4f4, roughness: 1, metalness: 0, envMapIntensity: 0.6, side: THREE.DoubleSide }));
W.objMesh.castShadow = true; W.objMesh.receiveShadow = true;
W.scene.add(W.objMesh);
// ---------- Objekt und Oberflächenabfrage ----------
function setRawGeometry(g) { W.raw = g; bakeObject(); }
function bakeObject() {
if (!W.raw) return;
const g = W.raw.clone();
for (const k of Object.keys(g.attributes)) if (k !== 'position' && k !== 'normal') g.deleteAttribute(k);
if (!g.attributes.normal) g.computeVertexNormals();
g.computeBoundingBox();
const c = new V3(), s = new V3();
g.boundingBox.getCenter(c); g.boundingBox.getSize(s);
const f = 3.8 / Math.max(s.x, s.y, s.z) * P.objectScale;
g.translate(-c.x, -c.y, -c.z); g.scale(f, f, f);
const pa = g.attributes.position, na = g.attributes.normal;
let acc = 0;
for (let i = 0; i < pa.count; i += 7) acc += pa.getX(i) * na.getX(i) + pa.getY(i) * na.getY(i) + pa.getZ(i) * na.getZ(i);
W.nSign = acc >= 0 ? 1 : -1;
W.bvh = new MeshBVH(g);
W.geo = g;
W.objMesh.geometry.dispose();
W.objMesh.geometry = g;
}
const _tgt = { point: new V3(), distance: 0, faceIndex: 0 };
const _sa = new V3(), _sb = new V3(), _sc = new V3(), _na = new V3(), _nb = new V3(), _nc = new V3(), _nn = new V3(), _sd = new V3();
function surf(p, out) {
const g = W.geo;
if (!g) { out.cp.copy(p); out.n.set(0, 0, 1); out.sd = 1e3; return out; }
const r = W.bvh.closestPointToPoint(p, _tgt) || _tgt;
const f = r.faceIndex, ix = g.index, pa = g.attributes.position, na = g.attributes.normal;
const i0 = ix ? ix.getX(f * 3) : f * 3, i1 = ix ? ix.getX(f * 3 + 1) : f * 3 + 1, i2 = ix ? ix.getX(f * 3 + 2) : f * 3 + 2;
_sa.fromBufferAttribute(pa, i0); _sb.fromBufferAttribute(pa, i1); _sc.fromBufferAttribute(pa, i2);
_na.fromBufferAttribute(na, i0); _nb.fromBufferAttribute(na, i1); _nc.fromBufferAttribute(na, i2);
THREE.Triangle.getInterpolation(r.point, _sa, _sb, _sc, _na, _nb, _nc, _nn);
if (_nn.lengthSq() < 1e-12) _nn.set(0, 1, 0);
_nn.normalize().multiplyScalar(W.nSign);
out.cp.copy(r.point); out.n.copy(_nn);
_sd.subVectors(p, r.point);
out.sd = _sd.dot(_nn) >= 0 ? r.distance : -r.distance;
return out;
}
const mkS = () => ({ cp: new V3(), n: new V3(), sd: 0 });
const S1 = mkS(), S2 = mkS(), S3 = mkS();
// ---------- Oktopus erzeugen ----------
function radiusAt(T, s) {
const r0 = 0.30 * W.R * P.thickness;
return Math.max(0.008, r0 * Math.pow(1 - s, 0.85) * (1 + 0.35 * Math.exp(-s * 10)));
}
function makeTentacle(k, n, rnd, R) {
const N = P.segments, L = P.length, seg = L / (N - 1);
const a = (k + (rnd() - 0.5) * 0.3) / n * Math.PI * 2;
const T = {
k, N, L, seg, a,
hand: rnd() < 0.72 ? 1 : -1,
speedMul: 0.8 + rnd() * 0.45, curlMul: 0.6 + rnd() * 0.9,
ph: rnd() * 6.283, sp: 0.7 + rnd() * 0.7, tilt: (rnd() - 0.5) * 1.6,
layer: rnd(), spreadMul: 0.6 + rnd() * 0.8, jit: rnd() * 0.1, jit2: (rnd() - 0.5) * 0.7,
pitchSign: rnd() < 0.5 ? -1 : 1, wrapHand: rnd() < 0.7 ? 1 : -1,
wrapAxis: new V3((rnd() - 0.5) * 0.9, 1, (rnd() - 0.5) * 0.9).normalize(),
state: IDLE, blend: 1, blendDur: 0.4, cool: 0, armT: 0, startDelay: 0, grabT: 0, rank: 0, facing: 0,
reachE: 0, relT: 0, relCool: 2, tearT: 0, wrapT: 0, ex: 0, zeta: 0.55,
trailLen: 0, trailPhase: 0, tautRatio: 0, Dfree: 0, Lfree: 0, hasArc: false,
};
const rr = R * 0.62;
T.attach = new V3(Math.cos(a) * rr, -R * 0.62, Math.sin(a) * rr);
T.dir = new V3(Math.cos(a) * 0.5, -0.86, Math.sin(a) * 0.5).normalize();
const tan = new V3(-Math.sin(a), 0, Math.cos(a));
T.axis = tan.applyAxisAngle(T.dir, T.tilt).normalize();
T.rad = new Float32Array(N);
for (let i = 0; i < N; i++) T.rad[i] = radiusAt(T, i / (N - 1));
const mk = () => Array.from({ length: N }, () => new V3());
T.p = mk(); T.pp = mk(); T.goal = mk(); T.gFrom = mk(); T.gN = mk(); T.gR = mk(); T.cn = mk();
T.tan = mk(); T.up = mk();
T.rate = new Float32Array(N); T.rFrom = new Float32Array(N); T.rN = new Float32Array(N); T.rR = new Float32Array(N);
T.sd = new Float32Array(N).fill(1e3); T.cw = new Float32Array(N); T.onTrail = new Uint8Array(N);
T.root = new V3(); T.outW = new V3(); T.dirW = new V3(); T.inw = new V3();
T.target = new V3(); T.targetN = new V3(0, 0, 1); T.tq = new V3(); T.reachStart = new V3();
T.front = new V3(); T.frontN = new V3(0, 0, 1); T.heading = new V3(1, 0, 0); T.anchor = new V3();
T.trail = [];
return T;
}
function attachSkin(T, root) {
const N = T.N, seg = T.seg, S = 16;
const rpb = Math.max(2, Math.round(72 / (N - 1)));
const Rn = (N - 1) * rpb + 1;
const pos = new Float32Array(Rn * S * 3), sI = new Uint16Array(Rn * S * 4), sW = new Float32Array(Rn * S * 4);
const col = new Float32Array(Rn * S * 3).fill(1);
for (let r = 0; r < Rn; r++) {
const s = r / (Rn - 1), bf = s * (N - 1), b0 = Math.min(Math.floor(bf), N - 2), f = bf - b0;
const y = s * (N - 1) * seg;
const rad = lerp(T.rad[b0], T.rad[b0 + 1], f);
for (let j = 0; j < S; j++) {
const phi = j / S * Math.PI * 2;
const o = (r * S + j) * 3;
pos[o] = T.attach.x + Math.sin(phi) * rad;
pos[o + 1] = T.attach.y + y;
pos[o + 2] = T.attach.z + Math.cos(phi) * rad;
const w = (r * S + j) * 4;
sI[w] = b0; sI[w + 1] = b0 + 1; sW[w] = 1 - f; sW[w + 1] = f;
}
}
const idx = [];
for (let r = 0; r < Rn - 1; r++) for (let j = 0; j < S; j++) {
const a = r * S + j, b = r * S + (j + 1) % S, c = (r + 1) * S + j, d = (r + 1) * S + (j + 1) % S;
idx.push(a, b, c, b, d, c);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
geo.setAttribute('skinIndex', new THREE.BufferAttribute(sI, 4));
geo.setAttribute('skinWeight', new THREE.BufferAttribute(sW, 4));
geo.setIndex(idx);
geo.computeVertexNormals();
T.rings = Rn; T.S = S; T.colorAttr = geo.attributes.color;
const bones = [];
for (let i = 0; i < N; i++) {
const b = new THREE.Bone();
if (i === 0) { b.position.copy(T.attach); W.main.add(b); }
else { b.position.set(0, seg, 0); bones[i - 1].add(b); }
bones.push(b);
}
W.main.updateMatrixWorld(true);
const skel = new THREE.Skeleton(bones);
const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, vertexColors: true });
W.armMats.push(mat);
const mesh = new THREE.SkinnedMesh(geo, mat);
mesh.frustumCulled = false; mesh.castShadow = true; mesh.receiveShadow = true;
mesh.bind(skel, new THREE.Matrix4());
root.add(mesh);
T.bones = bones; T.mesh = mesh;
const tw = new THREE.SkinnedMesh(geo, mat);
tw.bindMode = THREE.DetachedBindMode;
tw.frustumCulled = false; tw.castShadow = true; tw.receiveShadow = true;
tw.bind(skel, new THREE.Matrix4());
W.twinRoot.add(tw);
T.meshTwin = tw;
// Debug: Bones und Pfade
const lg = new THREE.BufferGeometry();
lg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
T.boneLine = new THREE.Line(lg, new THREE.LineBasicMaterial({ color: 0x9a9a9a, depthTest: false, transparent: true }));
T.boneDots = new THREE.Points(lg, new THREE.PointsMaterial({ color: 0x444444, size: 4, sizeAttenuation: false, depthTest: false }));
T.boneLine.renderOrder = 10; T.boneDots.renderOrder = 11;
T.boneLine.visible = T.boneDots.visible = false;
T.boneLine.frustumCulled = T.boneDots.frustumCulled = false;
const tg = new THREE.BufferGeometry();
tg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 6 * 3), 3));
T.trailLine = new THREE.Line(tg, new THREE.LineBasicMaterial({ color: 0xe5484d, depthTest: false, transparent: true, opacity: 0.8 }));
T.trailLine.renderOrder = 12; T.trailLine.frustumCulled = false; T.trailLine.visible = false;
const dg = new THREE.BufferGeometry();
dg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3));
T.tDot = new THREE.Points(dg, new THREE.PointsMaterial({ color: 0xe5484d, size: 8, sizeAttenuation: false, depthTest: false }));
T.tDot.renderOrder = 12; T.tDot.frustumCulled = false; T.tDot.visible = false;
root.add(T.boneLine, T.boneDots, T.trailLine, T.tDot);
}
function applyMaterial() {
W.cBase.set(P.color); W.cContact.set(P.contact);
const rough = clamp(+P.rough || 0, 0.02, 1), metal = clamp(+P.metal || 0, 0, 1), env = clamp(+P.envInt || 0, 0, 3);
for (const m of [W.headMat, ...W.armMats]) {
if (!m) continue;
m.roughness = rough; m.metalness = metal; m.envMapIntensity = env;
if (m.envMap !== W.envMap) { m.envMap = W.envMap; m.needsUpdate = true; }
}
if (W.headMat) W.headMat.color.set(P.color);
}
function disposeOcto() {
if (!W.octoRoot) return;
W.octoRoot.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); });
W.scene.remove(W.octoRoot);
W.octoRoot = null; W.tent = [];
}
function buildOctopus() {
disposeOcto();
const rnd = rng(Math.floor(P.seed) * 7919 + 13);
const R = 0.5 * P.headSize; W.R = R;
const n = 7 + Math.floor(rnd() * 3);
const root = new THREE.Group(); W.octoRoot = root; W.scene.add(root);
const main = new THREE.Bone(); main.name = 'main'; root.add(main); W.main = main;
W.twinRoot = new THREE.Group(); W.twinRoot.scale.x = -1; root.add(W.twinRoot);
W.armMats = [];
W.headMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
const head = new THREE.Mesh(new THREE.SphereGeometry(R, 40, 28).scale(1, 1.15, 1), W.headMat);
head.castShadow = true; head.receiveShadow = true; main.add(head); W.head = head;
W.headTwin = new THREE.Mesh(head.geometry, W.headMat);
W.headTwin.castShadow = true; W.headTwin.receiveShadow = true; W.twinRoot.add(W.headTwin);
for (let k = 0; k < n; k++) W.tent.push(makeTentacle(k, n, rnd, R));
for (const T of W.tent) attachSkin(T, root);
main.position.copy(W.hp); main.quaternion.copy(W.hq);
applyMaterial();
updateFrames();
for (const T of W.tent) {
restGoals(T, T.goal);
for (let i = 0; i < T.N; i++) {
T.p[i].copy(T.goal[i]); T.pp[i].copy(T.goal[i]); T.gFrom[i].copy(T.goal[i]);
T.rate[i] = T.rFrom[i] = 10;
}
}
}
// ---------- Hilfen pro Frame ----------
const _u = new V3(), _e1 = new V3(), _e2 = new V3(), _q = new V3(), _v = new V3(), _w = new V3();
function updateFrames() {
for (const T of W.tent) {
T.root.copy(T.attach).applyQuaternion(W.hq).add(W.hp);
T.outW.set(Math.cos(T.a), 0, Math.sin(T.a)).applyQuaternion(W.hq);
T.dirW.copy(T.dir).applyQuaternion(W.hq);
}
}
// ---------- Ruheform ----------
const _h = new V3(), _pos = new V3();
function restGoals(T, out) {
const N = T.N, seg = T.seg, t = W.time;
_pos.copy(T.attach); _h.copy(T.dir);
out[0].copy(_pos).applyQuaternion(W.hq).add(W.hp);
for (let i = 1; i < N; i++) {
const s = i / (N - 1);
const curv = P.curl * T.curlMul * T.hand * (0.1 + 2.4 * s * s);
if (M.wander) {
const w = P.wander * 2.0 * s * seg;
_h.x += w * Math.sin(t * 0.9 * T.sp + T.ph + i * 0.42);
_h.y += w * 0.6 * Math.sin(t * 0.7 * T.sp + T.ph * 1.7 + i * 0.37);
_h.z += w * Math.sin(t * 1.1 * T.sp + T.ph * 0.6 + i * 0.45 + 1.3);
}
_h.applyAxisAngle(T.axis, curv * seg).normalize();
_pos.addScaledVector(_h, seg);
out[i].copy(_pos).applyQuaternion(W.hq).add(W.hp);
}
}
// ---------- Bogen von der Wurzel zu einem Ziel (Länge bleibt Lfree) ----------
const _d = new V3(), _dn = new V3(), _lat = new V3(), _lat2 = new V3(), _ctrl = new V3();
function arcPoints(T, root, E, m, Lfree, hint, waveAmp, out) {
_d.subVectors(E, root);
const D = Math.max(_d.length(), 1e-6);
_dn.copy(_d).divideScalar(D);
_lat.copy(hint).addScaledVector(_dn, -hint.dot(_dn));
if (_lat.lengthSq() < 1e-6) { _lat.set(0, 1, 0).addScaledVector(_dn, -_dn.y); if (_lat.lengthSq() < 1e-6) _lat.set(1, 0, 0); }
_lat.normalize();
_lat2.crossVectors(_dn, _lat);
const slack = Math.max(0, Lfree - D);
const h = Math.min(Math.sqrt(3 * D * slack / 8), Lfree * 0.6);
_ctrl.copy(root).addScaledVector(_d, 0.5).addScaledVector(_lat, 2 * h);
for (let i = 0; i <= m; i++) {
const u = i / m, a = (1 - u) * (1 - u), b = 2 * u * (1 - u), c = u * u;
out[i].set(root.x * a + _ctrl.x * b + E.x * c, root.y * a + _ctrl.y * b + E.y * c, root.z * a + _ctrl.z * b + E.z * c);
if (waveAmp > 0) {
const env = Math.sin(Math.PI * u) * waveAmp;
const ang = 6.283 * (1.4 * u - W.time * 1.3 * T.sp) + T.ph;
out[i].addScaledVector(_lat, env * Math.sin(ang)).addScaledVector(_lat2, env * Math.cos(ang) * 0.7);
}
}
return D;
}
// ---------- Ausstrecken ----------
const _hov = new V3(), _E = new V3(), _hint = new V3(), _b1 = new V3(), _b2 = new V3();
function reachGoals(T, out, rate) {
const N = T.N;
_hov.copy(T.target).addScaledVector(T.targetN, T.rad[N - 1] + 0.09);
_b1.set(0, 1, 0); if (Math.abs(T.targetN.y) > 0.9) _b1.set(1, 0, 0);
_b1.addScaledVector(T.targetN, -_b1.dot(T.targetN)).normalize();
_b2.crossVectors(T.targetN, _b1);
const tw = W.time * 2.2 * T.sp + T.ph;
_hov.addScaledVector(_b1, Math.cos(tw) * 0.07).addScaledVector(_b2, Math.sin(tw) * 0.07);
_d.subVectors(_hov, T.root);
const dist = _d.length(), Lmax = T.L * 0.985;
if (dist > Lmax) _E.copy(T.root).addScaledVector(_d, Lmax / dist); else _E.copy(_hov);
const e = ease(T.reachE);
_E.lerpVectors(T.reachStart, _E, e);
_hint.copy(T.outW).multiplyScalar(0.7).addScaledVector(T.targetN, 0.7);
_hint.y += 0.3;
arcPoints(T, T.root, _E, N - 1, T.L, _hint, 0.05 * T.L * (1 - 0.6 * e) * (M.wander ? 1 : 0.3), out);
for (let i = 0; i < N; i++) rate[i] = 6 + 16 * (i / (N - 1));
}
// ---------- Umschlingen ----------
const _lp = [], _ln = [], _ld = [];
function wrapGoals(T, out, rate) {
const N = T.N, seg = T.seg;
_lp.length = 0; _ln.length = 0; _ld.length = 0;
_lp.push(T.front); _ln.push(T.frontN); _ld.push(0);
let acc = 0;
for (let j = T.trail.length - 1; j >= 0; j--) {
const e = T.trail[j];
acc += _lp[_lp.length - 1].distanceTo(e.p);
_lp.push(e.p); _ln.push(e.n); _ld.push(acc);
}
const total = acc; T.trailLen = total;
T.onTrail.fill(0);
let k = 0, iS = N - 1;
for (let i = N - 1; i >= 0; i--) {
const a = (N - 1 - i) * seg;
if (a > total + 1e-6) break;
while (k < _lp.length - 2 && _ld[k + 1] < a) k++;
const dl = _ld[k + 1] - _ld[k];
const f = dl > 1e-9 ? clamp((a - _ld[k]) / dl, 0, 1) : 0;
_v.lerpVectors(_lp[k], _lp[k + 1], f);
_w.lerpVectors(_ln[k], _ln[k + 1], f).normalize();
out[i].copy(_v).addScaledVector(_w, T.rad[i] * (1 + 0.3 * T.layer) + 0.006);
rate[i] = 22 * P.grip; T.onTrail[i] = 1; iS = i;
}
T.hasArc = iS > 0;
if (iS > 0) {
_E.copy(out[iS]);
_hint.copy(_ln[_ln.length - 1]).multiplyScalar(0.9).addScaledVector(T.outW, 0.4);
const Lf = iS * seg;
const D = arcPoints(T, T.root, _E, iS, Lf, _hint, 0.03 * Lf * (M.wander ? 1 : 0), out);
for (let i = 0; i < iS; i++) rate[i] = 7;
T.Dfree = D; T.Lfree = Lf; T.tautRatio = D / Lf; T.anchor.copy(_E);
} else {
out[0].copy(T.root); T.tautRatio = 0; T.Dfree = 0; T.Lfree = 0;
}
}
function measureTrail(T) {
let acc = 0;
if (!T.trail.length) return 0;
acc += T.front.distanceTo(T.trail[T.trail.length - 1].p);
for (let j = T.trail.length - 1; j > 0; j--) acc += T.trail[j].p.distanceTo(T.trail[j - 1].p);
return acc;
}
const _t = new V3(), _ap = new V3();
function advanceFront(T, dt) {
const cap = P.wrapFraction * T.L;
let speed = P.wrapSpeed * T.speedMul * (0.65 + 0.35 * Math.sin(W.time * 3.1 * T.sp + T.ph));
if (T.hasArc) speed *= smooth(1.0, 0.86, T.tautRatio);
let total = measureTrail(T);
if (total >= cap) speed = P.creep * P.wrapSpeed * T.speedMul * 0.5 * (T.hasArc ? smooth(1.0, 0.86, T.tautRatio) : 1);
if (speed * dt > 1e-7) {
const n = T.frontN, ax = T.wrapAxis;
_t.crossVectors(n, ax).multiplyScalar(T.wrapHand);
if (_t.length() > 0.25) {
_t.normalize();
_ap.copy(ax).addScaledVector(n, -ax.dot(n));
_t.addScaledVector(_ap, P.pitch * T.pitchSign).normalize();
if (P.serp > 0) _t.applyAxisAngle(n, P.serp * 0.9 * Math.sin(T.trailPhase));
T.heading.lerp(_t, 1 - Math.exp(-5 * dt));
}
T.heading.addScaledVector(n, -T.heading.dot(n));
if (T.heading.lengthSq() < 1e-6) T.heading.crossVectors(n, ax);
if (T.heading.lengthSq() < 1e-6) T.heading.set(1, 0, 0).addScaledVector(n, -n.x);
T.heading.normalize();
T.front.addScaledVector(T.heading, speed * dt);
surf(T.front, S1);
T.front.copy(S1.cp); T.frontN.copy(S1.n);
T.trailPhase += speed * dt * 9;
const last = T.trail[T.trail.length - 1];
if (last.p.distanceTo(T.front) >= T.seg * 0.55) T.trail.push({ p: T.front.clone(), n: T.frontN.clone() });
total = measureTrail(T);
}
while (total > cap && T.trail.length > 2) {
total -= T.trail[0].p.distanceTo(T.trail[1].p);
T.trail.shift();
}
}
// ---------- Zustände ----------
function setBlend(T, dur) {
for (let i = 0; i < T.N; i++) { T.gFrom[i].copy(T.goal[i]); T.rFrom[i] = T.rate[i]; }
T.blend = 0; T.blendDur = dur;
}
function goIdle(T, cool) { T.state = IDLE; T.cool = cool; T.armT = 0; T.grabT = 0; setBlend(T, 0.6); }
function beginRelease(T, cool) {
if (T.state === WRAP) { T.state = RELEASE; T.relT = 0; T.relCool = cool; }
else if (T.state === REACH) goIdle(T, cool);
}
function releaseAll(cool = 3.5) { for (const T of W.tent) beginRelease(T, cool); }
function updateTarget(T, dt, first) {
_u.subVectors(W.cpHead, W.hp);
const l = _u.length();
if (l < 1e-4) _u.copy(W.nHead).negate(); else _u.divideScalar(l);
_e1.set(0, 1, 0); if (Math.abs(_u.y) > 0.9) _e1.set(1, 0, 0);
_e1.addScaledVector(_u, -_e1.dot(_u)).normalize();
_e2.crossVectors(_u, _e1);
const az = Math.atan2(T.outW.dot(_e2), T.outW.dot(_e1)) + T.jit2;
const sp = P.spread * T.spreadMul;
_q.copy(W.cpHead).addScaledVector(_e1, Math.cos(az) * sp).addScaledVector(_e2, Math.sin(az) * sp);
if (first) T.tq.copy(_q); else T.tq.lerp(_q, 1 - Math.exp(-4 * dt));
surf(T.tq, S2);
T.target.copy(S2.cp); T.targetN.copy(S2.n);
}
function enterReach(T) {
T.state = REACH; setBlend(T, 0.45);
T.reachE = 0; T.grabT = 0;
T.reachStart.copy(T.p[T.N - 1]);
updateTarget(T, 0, true);
}
function enterWrap(T) {
T.state = WRAP; setBlend(T, 0.3);
T.front.copy(T.target); T.frontN.copy(T.targetN);
T.trail.length = 0; T.trail.push({ p: T.front.clone(), n: T.frontN.clone() });
_v.subVectors(T.target, T.root).normalize();
_v.addScaledVector(T.frontN, -_v.dot(T.frontN));
if (_v.lengthSq() < 1e-4) _v.crossVectors(T.frontN, T.wrapAxis);
if (_v.lengthSq() < 1e-4) _v.set(1, 0, 0);
T.heading.copy(_v).normalize();
T.trailPhase = T.ph; T.tearT = 0; T.tautRatio = 0; T.wrapT = 0; T.hasArc = false;
}
function updateArms(dt) {
const contact = M.wrap && W.hd < P.contactDist;
const nearEnter = M.reach ? W.hd < P.reachDist : contact;
const nearKeep = M.reach ? W.hd < P.reachDist * 1.15 : (M.wrap && W.hd < P.contactDist * 1.3);
_u.subVectors(W.cpHead, W.hp);
if (_u.lengthSq() > 1e-8) _u.normalize(); else _u.set(0, -1, 0);
const reachers = [];
for (const T of W.tent) {
T.facing = T.dirW.dot(_u);
if (T.state === REACH) { T.rank = 0; T.rd = T.p[T.N - 1].distanceToSquared(T.target); reachers.push(T); }
}
reachers.sort((a, b) => a.rd - b.rd);
reachers.forEach((T, i) => { T.rank = i; });
const exT = [0, 0.3, 1, 0.25];
for (const T of W.tent) {
T.blend = Math.min(1, T.blend + dt / T.blendDur);
switch (T.state) {
case IDLE:
if (T.cool > 0) T.cool -= dt;
T.startDelay = P.stagger * W.tent.length * 0.5 * (1 - T.facing) + T.jit;
if (nearEnter && T.cool <= 0) { T.armT += dt; if (T.armT >= T.startDelay) enterReach(T); }
else T.armT = 0;
break;
case REACH:
updateTarget(T, dt, false);
T.reachE = Math.min(1, T.reachE + dt * P.reachSpeed * T.speedMul * 0.8);
if (!nearKeep) { goIdle(T, 0.2); break; }
if (contact && T.reachE > 0.3) {
T.grabT += dt;
if (T.grabT >= P.stagger * T.rank * 1.2 + T.jit * 0.5) enterWrap(T);
} else T.grabT = 0;
break;
case WRAP:
T.wrapT += dt;
advanceFront(T, dt);
if (!M.wrap) beginRelease(T, 1.0);
else if (M.tear && T.hasArc && T.tautRatio > P.tear) {
T.tearT += dt;
if (T.tearT > 0.15) beginRelease(T, 2.0 + T.jit * 10);
} else T.tearT = 0;
break;
case RELEASE:
T.relT += dt;
if (T.relT * 38 > T.N + 5) { T.state = IDLE; T.cool = T.relCool; T.armT = 0; }
break;
}
T.ex += (exT[T.state] - T.ex) * (1 - Math.exp(-3 * dt));
}
}
const EXIT = [0, 0.7, 0.45, 0.25];
function computeGoals(T) {
const N = T.N;
restGoals(T, T.gR);
for (let i = 0; i < N; i++) T.rR[i] = P.stiffness * (2.5 + 24 * Math.pow(1 - i / (N - 1), 2));
const G = T.gN, Rt = T.rN;
switch (T.state) {
case IDLE:
for (let i = 0; i < N; i++) { G[i].copy(T.gR[i]); Rt[i] = T.rR[i]; }
T.zeta = 0.55; break;
case REACH: reachGoals(T, G, Rt); T.zeta = 0.8; break;
case WRAP: wrapGoals(T, G, Rt); T.zeta = 1.0; break;
case RELEASE: {
wrapGoals(T, G, Rt);
const w = T.relT * 38;
for (let i = 0; i < N; i++) {
const g = clamp(1 - (w - i) / 4, 0, 1);
G[i].lerpVectors(T.gR[i], G[i], g); Rt[i] = lerp(T.rR[i], Rt[i], g);
}
T.zeta = 0.8; break;
}
}
for (let i = 1; i < 4 && i < N; i++) { G[i].lerp(T.gR[i], EXIT[i]); Rt[i] = Math.max(Rt[i], 18); }
const e = ease(T.blend);
for (let i = 0; i < N; i++) {
T.goal[i].lerpVectors(T.gFrom[i], G[i], e);
T.rate[i] = lerp(T.rFrom[i], Rt[i], e);
}
T.goal[0].copy(T.root);
}
// ---------- Kopf ----------
const _ha = new V3(), _f = new V3(), _g = new V3(), _up = new V3(), _x = new V3();
const _mq = new THREE.Matrix4(), _qq = new THREE.Quaternion();
function updateHead(dt) {
const k = lerp(220, 16, P.inertia), c = Math.sqrt(k);
_ha.subVectors(W.target, W.hp).multiplyScalar(k).addScaledVector(W.hv, -c).add(W.pullAcc);
W.hv.addScaledVector(_ha, dt);
if (W.hv.length() > 14) W.hv.setLength(14);
W.hp.addScaledVector(W.hv, dt);
W.pullAcc.set(0, 0, 0);
const Rc = W.R * 1.1;
surf(W.hp, S3);
if (M.collide && S3.sd < Rc) {
W.hp.addScaledVector(S3.n, Rc - S3.sd);
const vn = W.hv.dot(S3.n); if (vn < 0) W.hv.addScaledVector(S3.n, -vn);
S3.sd = Rc;
}
W.hd = S3.sd; W.cpHead.copy(S3.cp); W.nHead.copy(S3.n);
_f.set(0, 0, 1);
if (M.lookAt && W.hd < P.reachDist) {
_g.subVectors(W.cpHead, W.hp);
if (_g.lengthSq() > 1e-8) {
_g.normalize();
_f.lerp(_g, 0.5 * smooth(P.reachDist, P.contactDist, W.hd)).normalize();
}
}
_up.set(0, 1, 0).addScaledVector(W.hv, -0.06);
_up.addScaledVector(_f, -_up.dot(_f));
if (_up.lengthSq() < 1e-4) _up.set(0, 1, 0);
_up.normalize();
_x.crossVectors(_up, _f).normalize();
_mq.makeBasis(_x, _up, _f);
_qq.setFromRotationMatrix(_mq);
W.hq.slerp(_qq, 1 - Math.exp(-5 * dt));
W.main.position.copy(W.hp); W.main.quaternion.copy(W.hq);
}
function applyPull() {
if (!M.pull) return;
for (const T of W.tent) {
if (T.state !== WRAP || !T.hasArc) continue;
_v.subVectors(T.anchor, T.root);
const D = _v.length(); if (D < 1e-5) continue;
const Lf = T.Lfree;
const f = P.pull * (14 * Math.max(0, D - 0.85 * Lf) + 40 * Math.max(0, D - Lf));
W.pullAcc.addScaledVector(_v.divideScalar(D), Math.min(f, 60));
}
if (W.pullAcc.length() > 90) W.pullAcc.setLength(90);
}
// ---------- Physik ----------
const _dv = new V3();
function solve(a, b, len, fixA) {
_dv.subVectors(b, a);
const d = _dv.length(); if (d < 1e-9) return;
const diff = (d - len) / d;
if (fixA) b.addScaledVector(_dv, -diff);
else { a.addScaledVector(_dv, 0.5 * diff); b.addScaledVector(_dv, -0.5 * diff); }
}
function constrain(T, iters) {
const N = T.N, p = T.p, seg = T.seg;
for (let it = 0; it < iters; it++) {
for (let i = 0; i < N - 1; i++) solve(p[i], p[i + 1], seg, i === 0);
for (let i = N - 2; i >= 0; i--) solve(p[i], p[i + 1], seg, i === 0);
}
}
function integrate(T, h) {
const N = T.N, p = T.p, pp = T.pp, g = T.goal;
const drag = Math.max(0, 1 - P.drag * h), z2 = 2 * T.zeta, hh = h * h;
pp[0].copy(p[0]); p[0].copy(T.root);
for (let i = 1; i < N; i++) {
const x = p[i], xp = pp[i], r = T.rate[i], k = r * r, c = z2 * r;
const vx = x.x - xp.x, vy = x.y - xp.y, vz = x.z - xp.z;
const ax = k * (g[i].x - x.x) - c * vx / h;
const ay = k * (g[i].y - x.y) - c * vy / h - P.gravity;
const az = k * (g[i].z - x.z) - c * vz / h;
xp.copy(x);
x.x += vx * drag + ax * hh; x.y += vy * drag + ay * hh; x.z += vz * drag + az * hh;
}
}
function collide(T) {
const N = T.N;
if (W.hd > T.L + 3 || !W.geo) { T.sd.fill(1e3); return; }
for (let i = 1; i < N; i++) {
surf(T.p[i], S1);
const rr = T.rad[i] * 1.08 + 0.006;
if (M.collide && S1.sd < rr) { T.p[i].addScaledVector(S1.n, rr - S1.sd); S1.sd = rr; }
T.sd[i] = S1.sd; T.cn[i].copy(S1.n);
}
}
function simulate(dt) {
const subs = dt > 0.024 ? 3 : 2, h = dt / subs;
for (let s = 0; s < subs; s++) {
for (const T of W.tent) {
integrate(T, h);
constrain(T, 6);
collide(T);
constrain(T, 2);
}
}
}
// ---------- Bones und Darstellung ----------
const _pq = new THREE.Quaternion(), _qw = new THREE.Quaternion(), _inv = new THREE.Quaternion();
const _X = new V3(), _Y = new V3(), _Z = new V3(), _uu = new V3(), _tp = new V3(), _tv = new V3();
const _bm = new THREE.Matrix4();
function poseBones(T) {
const N = T.N, p = T.p;
for (let i = 0; i < N; i++) {
T.tan[i].subVectors(p[Math.min(i + 1, N - 1)], p[Math.max(i - 1, 0)]);
if (T.tan[i].lengthSq() < 1e-12) T.tan[i].set(0, 1, 0);
T.tan[i].normalize();
const w = clamp((T.rad[i] * 2.6 - T.sd[i]) / (T.rad[i] * 1.6), 0, 1);
T.cw[i] = i === 0 ? 0 : w;
}
T.inw.set(-T.attach.x, 0, -T.attach.z);
if (T.inw.lengthSq() < 1e-8) T.inw.set(0, 0, 1);
T.inw.normalize().applyQuaternion(W.hq);
_uu.copy(T.inw);
for (let i = 0; i < N; i++) {
const t = T.tan[i];
_uu.addScaledVector(t, -_uu.dot(t));
if (_uu.lengthSq() < 1e-8) { _uu.set(1, 0, 0).addScaledVector(t, -t.x); if (_uu.lengthSq() < 1e-8) _uu.set(0, 0, 1); }
_uu.normalize();
const w = T.cw[i];
if (w > 0.001) {
_tp.copy(T.cn[i]).negate().addScaledVector(t, T.cn[i].dot(t));
if (_tp.lengthSq() > 1e-6) { _tp.normalize(); _uu.lerp(_tp, w); if (_uu.lengthSq() < 1e-6) _uu.copy(_tp); _uu.normalize(); }
}
T.up[i].copy(_uu);
}
_pq.copy(W.hq);
for (let i = 0; i < N; i++) {
_Y.copy(T.tan[i]); _Z.copy(T.up[i]);
_X.crossVectors(_Y, _Z).normalize();
_bm.makeBasis(_X, _Y, _Z);
_qw.setFromRotationMatrix(_bm);
_inv.copy(_pq).invert();
const b = T.bones[i];
b.quaternion.copy(_inv).multiply(_qw);
if (i > 0) b.position.copy(_tv.subVectors(p[i], p[i - 1]).applyQuaternion(_inv));
_pq.copy(_qw);
}
}
function updateColors(T) {
const arr = T.colorAttr.array, Rn = T.rings, S = T.S, N = T.N;
const c0 = W.cBase, c1 = W.cContact;
for (let r = 0; r < Rn; r++) {
const s = r / (Rn - 1), bf = s * (N - 1), b0 = Math.min(Math.floor(bf), N - 2), f = bf - b0;
const w = lerp(T.cw[b0], T.cw[b0 + 1], f);
const k = M.chroma ? clamp(T.ex * (0.2 * s + 0.9 * w), 0, 1) * 0.85 : 0;
const R_ = lerp(c0.r, c1.r, k), G_ = lerp(c0.g, c1.g, k), B_ = lerp(c0.b, c1.b, k);
for (let j = 0; j < S; j++) {
const o = (r * S + j) * 3;
arr[o] = R_; arr[o + 1] = G_; arr[o + 2] = B_;
}
}
T.colorAttr.needsUpdate = true;
}
function updateDebug(T) {
T.boneLine.visible = T.boneDots.visible = M.bones;
if (M.bones) {
const a = T.boneLine.geometry.attributes.position;
for (let i = 0; i < T.N; i++) a.setXYZ(i, T.p[i].x, T.p[i].y, T.p[i].z);
a.needsUpdate = true;
}
const showT = M.debug && (T.state === REACH || T.state === WRAP || T.state === RELEASE);
T.tDot.visible = showT;
if (showT) {
const a = T.tDot.geometry.attributes.position;
const q = T.state === REACH ? T.target : T.front;
a.setXYZ(0, q.x, q.y, q.z); a.needsUpdate = true;
}
const showTr = M.debug && (T.state === WRAP || T.state === RELEASE) && T.trail.length > 0;
T.trailLine.visible = showTr;
if (showTr) {
const a = T.trailLine.geometry.attributes.position;
const cap = a.count;
const n = Math.min(T.trail.length, cap - 1);
const off = T.trail.length - n;
for (let i = 0; i < n; i++) { const q = T.trail[off + i].p; a.setXYZ(i, q.x, q.y, q.z); }
a.setXYZ(n, T.front.x, T.front.y, T.front.z);
a.needsUpdate = true;
T.trailLine.geometry.setDrawRange(0, n + 1);
}
}
function step(dt) {
W.time += dt;
updateHead(dt);
W.twinRoot.visible = M.twin;
W.headTwin.position.copy(W.main.position); W.headTwin.quaternion.copy(W.main.quaternion);
updateFrames();
updateArms(dt);
for (const T of W.tent) computeGoals(T);
applyPull();
simulate(dt);
for (const T of W.tent) { poseBones(T); updateColors(T); updateDebug(T); }
}
function statusCounts() {
const c = [0, 0, 0, 0];
for (const T of W.tent) c[T.state]++;
return c;
}
// ---------- Kopie als reines Mesh ----------
function bakeSkinned(mesh) {
mesh.skeleton.update();
const g = mesh.geometry, n = g.attributes.position.count, out = new Float32Array(n * 3), v = new V3();
for (let i = 0; i < n; i++) {
mesh.getVertexPosition(i, v).applyMatrix4(mesh.matrixWorld);
out[i * 3] = v.x; out[i * 3 + 1] = v.y; out[i * 3 + 2] = v.z;
}
const ng = new THREE.BufferGeometry();
ng.setAttribute('position', new THREE.BufferAttribute(out, 3));
ng.setAttribute('color', new THREE.BufferAttribute(new Float32Array(g.attributes.color.array), 3));
const idx = Array.from(g.index.array);
if (mesh.matrixWorld.determinant() < 0) for (let i = 0; i < idx.length; i += 3) { const t = idx[i + 1]; idx[i + 1] = idx[i + 2]; idx[i + 2] = t; }
ng.setIndex(idx);
ng.computeVertexNormals();
return ng;
}
function bakeStatic(mesh) {
const g = mesh.geometry.clone();
g.applyMatrix4(mesh.matrixWorld);
if (mesh.matrixWorld.determinant() < 0 && g.index) {
const a = g.index.array;
for (let i = 0; i < a.length; i += 3) { const t = a[i + 1]; a[i + 1] = a[i + 2]; a[i + 2] = t; }
g.index.needsUpdate = true;
}
return g;
}
function snapshotCopy() {
if (!W.tent.length) return 0;
W.scene.updateMatrixWorld(true);
const grp = new THREE.Group(); grp.name = 'kopie';
const stdMat = () => new THREE.MeshStandardMaterial({ color: P.color, roughness: clamp(P.rough, 0.02, 1), metalness: P.metal,
envMap: W.envMap, envMapIntensity: P.envInt });
const armMat = () => { const m = stdMat(); m.color.set(0xffffff); m.vertexColors = true; return m; };
const add = (geo, mat) => { const m = new THREE.Mesh(geo, mat); m.castShadow = true; m.receiveShadow = true; grp.add(m); };
const sides = M.twin ? [false, true] : [false];
for (const twin of sides) {
add(bakeStatic(twin ? W.headTwin : W.head), stdMat());
for (const T of W.tent) add(bakeSkinned(twin ? T.meshTwin : T.mesh), armMat());
}
W.scene.add(grp); W.copies.push(grp);
return W.copies.length;
}
function clearCopies() {
for (const g of W.copies) {
g.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); });
W.scene.remove(g);
}
W.copies.length = 0;
}
// ==== APP ====
const notice = document.getElementById('notice');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const scene = W.scene;
scene.background = new THREE.Color(0xffffff);
const camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0.6, 10.5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableZoom = true; controls.zoomSpeed = 0.8; controls.minDistance = 4; controls.maxDistance = 28;
controls.enablePan = false; controls.enableDamping = true;
controls.mouseButtons = { LEFT: -1, MIDDLE: -1, RIGHT: THREE.MOUSE.ROTATE };
controls.touches = { ONE: -1, TWO: THREE.TOUCH.ROTATE };
controls.target.set(0, 0, 0);
const pmrem = new THREE.PMREMGenerator(renderer);
W.envMap = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
W.objMesh.material.envMap = W.envMap; W.objMesh.material.needsUpdate = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0xc9ccd2, 0.55));
const sun = new THREE.DirectionalLight(0xffffff, 1.1);
sun.position.set(-4, 7, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -7, right: 7, top: 7, bottom: -7, near: 1, far: 24 });
sun.shadow.bias = -0.0004; sun.shadow.normalBias = 0.02; sun.shadow.radius = 4;
scene.add(sun);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), new THREE.ShadowMaterial({ opacity: 0.09 }));
floor.rotation.x = -Math.PI / 2; floor.position.y = -3.1; floor.receiveShadow = true;
scene.add(floor);
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// Maus -> Zielpunkt des Hauptbones (nur beim Ziehen)
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(), fwd = new V3(), grabOff = new V3(), _mp = new V3();
const cv = renderer.domElement;
let dragging = false, depthDist = 7;
function mousePoint(out) {
ray.setFromCamera(ndc, camera);
camera.getWorldDirection(fwd);
const t = depthDist / Math.max(0.2, ray.ray.direction.dot(fwd));
return out.copy(ray.ray.origin).addScaledVector(ray.ray.direction, t);
}
function setNdc(e) { ndc.set(e.clientX / innerWidth * 2 - 1, -(e.clientY / innerHeight) * 2 + 1); }
cv.style.cursor = 'grab';
cv.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
dragging = true; cv.setPointerCapture(e.pointerId); cv.style.cursor = 'grabbing';
setNdc(e);
camera.getWorldDirection(fwd);
depthDist = Math.max(3, _mp.subVectors(W.hp, camera.position).dot(fwd));
mousePoint(_mp);
grabOff.subVectors(W.hp, _mp);
grabOff.addScaledVector(fwd, -grabOff.dot(fwd));
});
cv.addEventListener('pointermove', e => {
if (!dragging) return;
if (e.shiftKey) depthDist = clamp(depthDist - e.movementY * 0.03, 3, 18); else setNdc(e);
});
const endDrag = e => { if (!dragging) return; dragging = false; cv.style.cursor = 'grab'; if (cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId); };
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
cv.addEventListener('contextmenu', e => e.preventDefault());
addEventListener('keydown', e => { if (e.code === 'Space' && e.target === document.body) { e.preventDefault(); releaseAll(); } });
function updateMouseTarget() {
if (dragging) {
mousePoint(_mp).add(grabOff);
W.target.copy(_mp);
W.target.x = clamp(W.target.x, -9, 9); W.target.y = clamp(W.target.y, -6, 7); W.target.z = clamp(W.target.z, -9, 9);
} else {
W.target.copy(W.hp); // frei: keine Federkraft zur Maus, nur Trägheit und Zug der Arme
}
}
// Modell laden
function useGltf(gltf) {
gltf.scene.updateMatrixWorld(true);
const geos = [];
gltf.scene.traverse(o => {
if (!o.isMesh) return;
const g = o.geometry.clone();
g.applyMatrix4(o.matrixWorld);
for (const k of Object.keys(g.attributes)) if (k !== 'position' && k !== 'normal') g.deleteAttribute(k);
if (!g.attributes.normal) g.computeVertexNormals();
geos.push(g);
});
if (!geos.length) { notice.textContent = 'Im Modell wurde kein Mesh gefunden.'; return; }
let geo = geos[0];
if (geos.length > 1) {
const flat = geos.map(g => g.index ? g.toNonIndexed() : g);
geo = mergeGeometries(flat);
}
notice.textContent = '';
setRawGeometry(geo);
}
function fallbackObject(msg) {
notice.textContent = msg;
setRawGeometry(new THREE.TorusKnotGeometry(1.1, 0.42, 220, 36));
}
const loader = new GLTFLoader();
const meshUrl = new URLSearchParams(location.search).get('mesh') || 'HEAD_ONLY.glb';
loader.load(meshUrl, useGltf, undefined, () =>
fallbackObject('„' + meshUrl + '“ nicht gefunden – GLB auf die Seite ziehen. Bis dahin: Testobjekt.'));
function loadFile(file) {
file.arrayBuffer().then(buf => loader.parse(buf, '', useGltf, err => { notice.textContent = 'Datei konnte nicht gelesen werden.'; console.error(err); }));
}
addEventListener('dragover', e => e.preventDefault());
addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) loadFile(f); });
// UI
const UI = [
{ title: 'Module', open: true, items: [
['toggle', 'reach', 'Ausstrecken bei Nähe'],
['toggle', 'wrap', 'Umschlingen bei Kontakt'],
['toggle', 'pull', 'Zugkraft zum Objekt'],
['toggle', 'tear', 'Abreißen bei Überdehnung'],
['toggle', 'collide', 'Kollision mit Objekt'],
['toggle', 'wander', 'Wanderbewegung'],
['toggle', 'lookAt', 'Kopf blickt zum Objekt'],
['toggle', 'chroma', 'Verfärbung am Kontakt'],
['toggle', 'twin', 'Gespiegelter Zwilling'],
['toggle', 'bones', 'Bones zeigen'],
['toggle', 'debug', 'Ziele und Pfade zeigen'],
] },
{ title: 'Ausstrecken', open: true, items: [
['range', 'reachDist', 'Reichweite ab Abstand', 1, 7, 0.05],
['range', 'contactDist', 'Kontakt ab Abstand', 0.7, 3, 0.05],
['range', 'spread', 'Streuung der Ziele', 0, 2.5, 0.05],
['range', 'stagger', 'Versatz zwischen Armen', 0, 0.4, 0.01],
['range', 'reachSpeed', 'Ausstrecktempo', 0.2, 3, 0.05],
] },
{ title: 'Umschlingen', open: true, items: [
['range', 'wrapSpeed', 'Wickeltempo', 0.2, 4, 0.05],
['range', 'pitch', 'Steigung der Windung', -1.5, 1.5, 0.05],
['range', 'serp', 'Schlängeln', 0, 1.5, 0.05],
['range', 'wrapFraction', 'Wickelanteil der Länge', 0.2, 1, 0.02],
['range', 'creep', 'Nachkriechen', 0, 1, 0.02],
['range', 'grip', 'Haftung', 0.2, 2.5, 0.05],
['range', 'pull', 'Zugkraft', 0, 3, 0.05],
['range', 'tear', 'Abrissgrenze', 1, 1.6, 0.01],
] },
{ title: 'Bewegung', items: [
['range', 'inertia', 'Trägheit des Kopfs', 0, 1, 0.01],
['range', 'drag', 'Dämpfung', 0, 4, 0.05],
['range', 'stiffness', 'Steifigkeit der Arme', 0.3, 3, 0.05],
['range', 'gravity', 'Schwerkraft', -0.5, 2, 0.05],
['range', 'wander', 'Wanderstärke', 0, 2, 0.05],
['range', 'curl', 'Einrollung der Spitzen', 0, 2, 0.05],
] },
{ title: 'Oktopus', items: [
['seed'],
['range', 'length', 'Armlänge', 1.5, 5, 0.05, true],
['range', 'segments', 'Bones pro Arm', 12, 44, 1, true],
['range', 'thickness', 'Armdicke', 0.5, 1.8, 0.05, true],
['range', 'headSize', 'Kopfgröße', 0.7, 1.6, 0.05, true],
] },
{ title: 'Material Oktopus', items: [
['color', 'color', 'Farbe'],
['color', 'contact', 'Farbe am Kontakt'],
['range', 'rough', 'Rauheit (klein = glänzend)', 0, 1, 0.01, 'mat'],
['range', 'metal', 'Metallanteil', 0, 1, 0.01, 'mat'],
['range', 'envInt', 'Reflexion der Umgebung', 0, 3, 0.05, 'mat'],
] },
{ title: 'Objekt', items: [
['range', 'objectScale', 'Größe', 0.5, 1.8, 0.05, 'bake'],
['file'],
] },
];
const syncers = [];
const secEl = document.getElementById('sections');
for (const sec of UI) {
const d = document.createElement('details'); d.open = !!sec.open;
const sm = document.createElement('summary'); sm.textContent = sec.title; d.appendChild(sm);
for (const it of sec.items) {
if (it[0] === 'toggle') {
const [, key, label] = it;
const l = document.createElement('label'); l.className = 't';
const c = document.createElement('input'); c.type = 'checkbox';
const s = document.createElement('span'); s.textContent = label;
l.append(c, s); d.appendChild(l);
c.addEventListener('change', () => { M[key] = c.checked; });
syncers.push(() => { c.checked = M[key]; });
} else if (it[0] === 'color') {
const [, key, label] = it;
const l = document.createElement('label'); l.className = 'r';
const sp = document.createElement('span'); sp.textContent = label;
const c = document.createElement('input'); c.type = 'color'; c.className = 'col';
l.append(sp, c); d.appendChild(l);
c.addEventListener('input', () => { P[key] = c.value; applyMaterial(); });
syncers.push(() => { c.value = P[key]; });
} else if (it[0] === 'range') {
const [, key, label, min, max, stp, deferred] = it;
const l = document.createElement('label'); l.className = 'r';
const s = document.createElement('span'); s.textContent = label;
const o = document.createElement('output');
const r = document.createElement('input'); r.type = 'range'; r.min = min; r.max = max; r.step = stp;
l.append(s, o, r); d.appendChild(l);
const dec = stp >= 1 ? 0 : (stp >= 0.1 ? 1 : 2);
const show = () => { o.textContent = Number(P[key]).toFixed(dec); };
r.addEventListener('input', () => { P[key] = parseFloat(r.value); show(); if (deferred === 'mat') applyMaterial(); });
if (deferred && deferred !== 'mat') r.addEventListener('change', () => { deferred === 'bake' ? bakeObject() : buildOctopus(); });
syncers.push(() => { r.value = P[key]; show(); });
} else if (it[0] === 'seed') {
const l = document.createElement('div'); l.className = 'seed';
const s = document.createElement('span'); s.textContent = 'Seed';
const n = document.createElement('input'); n.type = 'number'; n.step = 1;
l.append(s, n); d.appendChild(l);
n.addEventListener('change', () => { P.seed = Math.floor(parseFloat(n.value) || 0); buildOctopus(); });
syncers.push(() => { n.value = P.seed; });
} else if (it[0] === 'file') {
const l = document.createElement('label'); l.className = 'file';
const b = document.createElement('button'); b.textContent = 'Modell laden (GLB)';
const f = document.createElement('input'); f.type = 'file'; f.accept = '.glb,.gltf';
b.addEventListener('click', () => f.click());
f.addEventListener('change', () => { if (f.files[0]) loadFile(f.files[0]); });
l.append(b, f); d.appendChild(l);
}
}
secEl.appendChild(d);
}
const syncAll = () => syncers.forEach(f => f());
syncAll();
document.getElementById('reroll').addEventListener('click', () => {
P.seed = Math.floor(Math.random() * 100000); syncAll(); buildOctopus();
});
document.getElementById('release').addEventListener('click', () => releaseAll());
document.getElementById('copy').addEventListener('click', () => snapshotCopy());
document.getElementById('clearcopies').addEventListener('click', () => clearCopies());
document.getElementById('defaults').addEventListener('click', () => {
const seed = P.seed;
Object.assign(P, DEFAULT_P, { seed }); Object.assign(M, DEFAULT_M);
syncAll(); applyMaterial(); bakeObject(); buildOctopus();
});
const ui = document.getElementById('ui'), fold = document.getElementById('fold');
fold.addEventListener('click', () => { ui.classList.toggle('collapsed'); fold.textContent = ui.classList.contains('collapsed') ? '+' : '–'; });
const statusEl = document.getElementById('status');
let statusT = 0;
buildOctopus();
let last = performance.now();
renderer.setAnimationLoop(now => {
const dt = Math.min((now - last) / 1000, 1 / 20);
last = now;
updateMouseTarget();
step(dt);
controls.update();
renderer.render(scene, camera);
statusT += dt;
if (statusT > 0.2) {
statusT = 0;
const c = statusCounts();
statusEl.textContent = 'Abstand ' + (W.hd > 50 ? '–' : W.hd.toFixed(2)) + ' · frei ' + c[0] + ' · strecken ' + c[1] + ' · umschlingen ' + c[2] + (c[3] ? ' · lösen ' + c[3] : '') + (W.copies.length ? ' · Kopien ' + W.copies.length : '');
}
});
</script>
</body>
</html>