index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anemone</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">Maus über dem Kopf: Tentakel weichen aus · Linke Maustaste halten: anziehen · P: Malmodus · Rechte Maustaste: Ansicht drehen · Mausrad: Zoom · GLB hierher ziehen: Kopf · Bild hierher ziehen: Normal Map</div>
<div id="ui">
<header><b>Anemone</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="defaults">Standardwerte</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';
import { ImprovedNoise } from 'three/addons/math/ImprovedNoise.js';
// ==== Grundlagen ====
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;
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 DEFAULT_P = {
seed: 3, count: 120, bottom: 0.35, objectScale: 1.0,
brushValue: 1.0, brushSize: 0.35, brushStrength: 0.5,
length: 0.6, bones: 7, radius: 0.04, taper: 0.85, tip: 1.35, radial: 6, lenVar: 0.25,
stiffness: 1.0, drag: 1.2, gravity: 0.0, flow: 2.5, flowScale: 0.8, flowSpeed: 0.35,
mouseRadius: 0.9, avoid: 7, attract: 9,
headColor: '#121212', headRough: 1.0,
tentColor: '#ffffff', tentRough: 0.12, coat: 1.0, envInt: 0.6,
sun: 0.7, hemi: 0.35, normalScale: 0.3, normalRepeat: 1.0,
};
const DEFAULT_M = { flow: true, mouse: true, collide: true, mirrorFlow: false, facet: false,
tentacles: true, seeds: false, cursor: false, bones: false, coatNormal: true,
paint: false, sym: true, usePaint: true, paintPreview: false };
const P = { ...DEFAULT_P };
const M = { ...DEFAULT_M };
const W = {
scene: new THREE.Scene(), time: 0,
raw: null, geo: null, bvh: null, nSign: 1, box: new THREE.Box3(),
seeds: [], tents: [], bones: [], hair: null, hairMesh: null, skel: null, seedPts: null, boneLines: null,
mouse: { ndc: new THREE.Vector2(), over: false, down: false, amp: 0, pos: new V3(), fresh: true,
hit: false, hp: new V3(), hn: new V3(0, 1, 0) },
// Vertex Paint: ein Grauwert pro Kopf-Vertex (0 = schwarz = keine Tentakel)
paint: new Float32Array(0), paintImg: null, paintDown: false, paintDirty: false,
};
// Materialien: Kopf schwarz und stumpf, Tentakel weiß und glänzend
const headMat = new THREE.MeshStandardMaterial({ color: P.headColor, roughness: 1, metalness: 0, envMapIntensity: 0.25 });
const tentMat = new THREE.MeshPhysicalMaterial({ color: P.tentColor, roughness: P.tentRough, metalness: 0,
clearcoat: 1, clearcoatRoughness: 0.05 });
const paintMat = new THREE.MeshStandardMaterial({ color: 0xffffff, vertexColors: true, roughness: 0.9, metalness: 0, envMapIntensity: 0.4 });
const head = new THREE.Mesh(new THREE.BufferGeometry(), headMat);
// Pinsel-Anzeige (Original + Spiegelung)
const ringMat = new THREE.MeshBasicMaterial({ color: 0xe5484d, transparent: true, opacity: 0.9, depthTest: false, side: THREE.DoubleSide });
const brushRing = new THREE.Mesh(new THREE.RingGeometry(0.92, 1, 48), ringMat);
const brushMirror = new THREE.Mesh(brushRing.geometry, ringMat);
brushRing.renderOrder = brushMirror.renderOrder = 20;
brushRing.visible = brushMirror.visible = false;
W.scene.add(brushRing, brushMirror);
head.castShadow = true; head.receiveShadow = true;
W.scene.add(head);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), new THREE.ShadowMaterial({ opacity: 0.08 }));
floor.rotation.x = -Math.PI / 2; floor.position.y = -2.4; floor.receiveShadow = true;
W.scene.add(floor);
const cursor = new THREE.Mesh(new THREE.SphereGeometry(1, 28, 18),
new THREE.MeshBasicMaterial({ color: 0x111111, transparent: true, opacity: 0.06, depthWrite: false }));
cursor.visible = false;
W.scene.add(cursor);
// ==== Kopfgeometrie und Oberflächenabfrage ====
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);
for (const k of ['position', 'normal']) { const at = g.attributes[k]; if (at && at.isInterleavedBufferAttribute) g.setAttribute(k, at.clone()); }
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.6 / Math.max(s.x, s.y, s.z) * P.objectScale;
g.translate(-c.x, -c.y, -c.z); g.scale(f, f, f); // Symmetrieachse liegt danach bei x = 0
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;
g.computeBoundingBox(); W.box.copy(g.boundingBox);
g.setAttribute('color', new THREE.Float32BufferAttribute(new Float32Array(pa.count * 3), 3));
head.geometry.dispose(); head.geometry = g;
floor.position.y = W.box.min.y - 0.35;
// gleiche Vertexanzahl (z. B. nur Größe geändert): Bemalung behalten, sonst neu anlegen
if (W.paint.length !== pa.count) {
W.paint = new Float32Array(pa.count);
if (W.paintImg) applyPaintImage();
}
updateHeadColors();
}
// ==== Vertex Paint ====
function updateHeadColors() {
if (!W.geo) return;
const col = W.geo.attributes.color, a = col.array, p = W.paint;
for (let i = 0; i < p.length; i++) { const v = Math.pow(clamp(p[i], 0, 1), 2.2); a[i * 3] = a[i * 3 + 1] = a[i * 3 + 2] = v; }
col.needsUpdate = true;
}
function brush(dt) {
if (!W.geo || !W.mouse.hit) return;
const pa = W.geo.attributes.position.array, R = P.brushSize, R2 = R * R, c = W.mouse.hp;
const k = 1 - Math.exp(-P.brushStrength * 14 * dt), target = P.brushValue, p = W.paint;
let changed = false;
for (let i = 0, o = 0; i < p.length; i++, o += 3) {
const dy = pa[o + 1] - c.y, dz = pa[o + 2] - c.z, yz = dy * dy + dz * dz;
if (yz >= R2) continue;
const dx1 = pa[o] - c.x, dx2 = pa[o] + c.x;
let d = dx1 * dx1 + yz;
if (M.sym) d = Math.min(d, dx2 * dx2 + yz);
if (d >= R2) continue;
const t = 1 - d / R2;
p[i] += (target - p[i]) * k * t * t;
changed = true;
}
if (changed) { updateHeadColors(); W.paintDirty = true; }
}
// Paint-Map als Panorama (equirektangular) um die Kopfmitte: unabhängig von Vertexanzahl und Größe
function dirToUV(x, y, z) {
const l = Math.hypot(x, y, z) || 1;
const lon = Math.atan2(x / l, z / l), lat = Math.asin(clamp(y / l, -1, 1));
return [(lon + Math.PI) / (2 * Math.PI), (Math.PI / 2 - lat) / Math.PI];
}
function applyPaintImage() {
const img = W.paintImg;
if (!img || !W.geo) return;
const pa = W.geo.attributes.position, p = W.paint, { w, h, data } = img;
const px = (x, y) => data[(clamp(y, 0, h - 1) * w + ((x % w) + w) % w) * 4] / 255;
for (let i = 0; i < p.length; i++) {
const [u, v] = dirToUV(pa.getX(i), pa.getY(i), pa.getZ(i));
const fx = u * w - 0.5, fy = v * h - 0.5, x0 = Math.floor(fx), y0 = Math.floor(fy), tx = fx - x0, ty = fy - y0;
p[i] = lerp(lerp(px(x0, y0), px(x0 + 1, y0), tx), lerp(px(x0, y0 + 1), px(x0 + 1, y0 + 1), tx), ty);
}
updateHeadColors();
}
async function loadPaintBlob(blob) {
const bmp = await createImageBitmap(blob);
const c = document.createElement('canvas'); c.width = bmp.width; c.height = bmp.height;
const ctx = c.getContext('2d'); ctx.drawImage(bmp, 0, 0);
W.paintImg = { w: c.width, h: c.height, data: ctx.getImageData(0, 0, c.width, c.height).data };
if (W.geo) { applyPaintImage(); reseed(); }
}
function savePaintMap() {
if (!W.geo) return;
const Wd = 512, Hd = 256, g = W.geo, ix = g.index, p = W.paint, pa = g.attributes.position;
const c = document.createElement('canvas'); c.width = Wd; c.height = Hd;
const ctx = c.getContext('2d'), img = ctx.createImageData(Wd, Hd);
const ray = new THREE.Ray(new V3(), new V3()), tri = new THREE.Triangle(), bc = new V3();
for (let y = 0; y < Hd; y++) {
const lat = Math.PI / 2 - (y + 0.5) / Hd * Math.PI;
for (let x = 0; x < Wd; x++) {
const lon = (x + 0.5) / Wd * 2 * Math.PI - Math.PI;
ray.direction.set(Math.cos(lat) * Math.sin(lon), Math.sin(lat), Math.cos(lat) * Math.cos(lon));
const hits = W.bvh.raycast(ray, THREE.DoubleSide);
let best = null;
for (const hh of hits) if (!best || hh.distance > best.distance) best = hh; // äußerste Oberfläche
let v = 0;
if (best) {
const f = best.faceIndex;
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;
tri.setFromAttributeAndIndices(pa, i0, i1, i2);
tri.getBarycoord(best.point, bc);
v = bc.x * p[i0] + bc.y * p[i1] + bc.z * p[i2];
}
const o = (y * Wd + x) * 4, b = Math.round(clamp(v, 0, 1) * 255);
img.data[o] = img.data[o + 1] = img.data[o + 2] = b; img.data[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
W.paintImg = { w: Wd, h: Hd, data: img.data };
c.toBlob(b => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b); a.download = 'vertex_paint_map.jpg';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}, 'image/jpeg', 0.95);
}
function fillPaint(v) { W.paint.fill(v); updateHeadColors(); reseed(); }
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, 1, 0); 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();
// ==== Seeds: Dichte aus der Paint-Map, optional gespiegelt an der vertikalen Achse ====
const PAINT_MIN = 0.06; // darunter (schwarz) keine Tentakel
const density = v => M.usePaint ? Math.pow(clamp(v, 0, 1), 1.5) : 1;
function sampleCandidates(count, rnd) {
const g = W.geo, pa = g.attributes.position, na = g.attributes.normal, ix = g.index, pt = W.paint;
const usePaint = M.usePaint;
const triCount = (ix ? ix.count : pa.count) / 3;
const cum = new Float64Array(triCount);
const a = new V3(), b = new V3(), c = new V3(), e1 = new V3(), e2 = new V3();
const vi = t => [ix ? ix.getX(t * 3) : t * 3, ix ? ix.getX(t * 3 + 1) : t * 3 + 1, ix ? ix.getX(t * 3 + 2) : t * 3 + 2];
let tot = 0;
for (let t = 0; t < triCount; t++) {
const [i0, i1, i2] = vi(t);
a.fromBufferAttribute(pa, i0); b.fromBufferAttribute(pa, i1); c.fromBufferAttribute(pa, i2);
let w = e1.subVectors(b, a).cross(e2.subVectors(c, a)).length() * 0.5;
if (usePaint) { const mx = Math.max(pt[i0], pt[i1], pt[i2]); w *= mx < PAINT_MIN ? 0 : 0.1 + mx; }
tot += w; cum[t] = tot;
}
if (tot <= 0) return [];
const yMin = usePaint ? -Infinity : lerp(W.box.min.y, W.box.max.y, P.bottom);
const out = [];
let tries = 0;
while (out.length < count && tries < count * 25) {
tries++;
const r = rnd() * tot;
let lo = 0, hi = triCount - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (cum[m] < r) lo = m + 1; else hi = m; }
const [i0, i1, i2] = vi(lo);
const r1 = Math.sqrt(rnd()), r2 = rnd();
const w0 = 1 - r1, w1 = r1 * (1 - r2), w2 = r1 * r2;
a.fromBufferAttribute(pa, i0); b.fromBufferAttribute(pa, i1); c.fromBufferAttribute(pa, i2);
const p = new V3().addScaledVector(a, w0).addScaledVector(b, w1).addScaledVector(c, w2);
if ((M.sym && p.x < 0) || p.y < yMin) continue;
const v = usePaint ? w0 * pt[i0] + w1 * pt[i1] + w2 * pt[i2] : 1;
if (usePaint && v < PAINT_MIN) continue;
_na.fromBufferAttribute(na, i0); _nb.fromBufferAttribute(na, i1); _nc.fromBufferAttribute(na, i2);
const n = new V3().addScaledVector(_na, w0).addScaledVector(_nb, w1).addScaledVector(_nc, w2);
if (n.lengthSq() < 1e-10) continue;
n.normalize().multiplyScalar(W.nSign);
out.push({ p, n, v, rs2: 1 / Math.max(1e-3, density(v)) });
}
return out;
}
function snapMid(p) {
surf(new V3(0, p.y, p.z), S1);
const q = S1.cp.clone(); q.x = 0;
const n = S1.n.clone(); n.x = 0;
if (n.lengthSq() < 1e-6) n.set(0, 1, 0);
return { p: q, n: n.normalize() };
}
// Farthest-Point-Sampling mit variablem Abstand: helle Stellen → kleiner Abstand → dicht
function distributeSeeds() {
if (!W.geo) return [];
const rnd = rng(Math.floor(P.seed) * 7919 + 11);
const target = Math.round(P.count);
const C = sampleCandidates(clamp(target * 40, 3000, 20000), rnd);
if (!C.length) return [];
const md = new Float32Array(C.length).fill(Infinity);
let best = 0, bs = -Infinity;
for (let j = 0; j < C.length; j++) {
const s = C[j].v * 4 + C[j].p.y - (M.sym ? 2 * C[j].p.x : 0);
if (s > bs) { bs = s; best = j; }
}
const seeds = [];
let pair = 0;
while (seeds.length < target && best >= 0) {
const c = C[best], sp = Math.sqrt(md[best]);
const placed = [];
if (!M.sym) {
placed.push({ p: c.p.clone(), n: c.n.clone(), v: c.v, pair, side: 2 });
} else if (seeds.length ? 2 * c.p.x < 0.55 * sp : c.p.x < 0.05) {
const q = snapMid(c.p);
placed.push({ p: q.p, n: q.n, v: c.v, pair, side: 0 });
} else {
placed.push({ p: c.p.clone(), n: c.n.clone(), v: c.v, pair, side: 1 });
placed.push({ p: new V3(-c.p.x, c.p.y, c.p.z), n: new V3(-c.n.x, c.n.y, c.n.z), v: c.v, pair, side: -1 });
}
pair++;
for (const s of placed) {
seeds.push(s);
for (let j = 0; j < C.length; j++) { const d = C[j].p.distanceToSquared(s.p); if (d < md[j]) md[j] = d; }
}
let bm = -1; best = -1;
for (let j = 0; j < C.length; j++) { const sc = md[j] / C[j].rs2; if (sc > bm) { bm = sc; best = j; } }
if (bm < 1e-8) break;
}
return seeds;
}
// ==== Tentakel: ein SkinnedMesh, ein Skelett, Knochenketten pro Seed ====
function disposeHair() {
if (W.hair) {
W.scene.remove(W.hair);
if (W.hairMesh) W.hairMesh.geometry.dispose();
if (W.skel) W.skel.dispose();
if (W.boneLines) W.boneLines.geometry.dispose();
if (W.seedPts) W.seedPts.geometry.dispose();
}
W.hair = W.hairMesh = W.skel = W.boneLines = W.seedPts = null;
W.tents = []; W.bones = [];
}
function buildTentacles() {
disposeHair();
const group = new THREE.Group(); W.hair = group; W.scene.add(group);
const seeds = W.seeds;
if (!seeds.length) return;
const N = Math.max(3, Math.round(P.bones)), S = Math.max(3, Math.round(P.radial)), RPB = 2, CAP = 4;
const bones = [], pos = [], uv = [], sI = [], sW = [], idx = [], welds = [];
for (const s of seeds) {
const r = rng(s.pair * 977 + Math.floor(P.seed) * 31 + 5); // Paare teilen sich die Zufallswerte
const pv = M.usePaint ? clamp(s.v, 0, 1) : 1; // weiß: lang und dick, anthrazit: klein und dünn
const L = P.length * lerp(0.15, 1, Math.pow(pv, 1.3)) * (1 + (r() * 2 - 1) * P.lenVar);
const seg = L / (N - 1);
const r0 = P.radius * lerp(0.4, 1, pv) * (0.85 + r() * 0.3);
const T = { N, L, seg, r0, rTip: 0, root: s.p.clone(), n: s.n.clone(), base: bones.length,
ref: new V3(), lean: new V3(), p: [], pp: [], g: [], F: [], tan: [] };
// Neigung in Ruhe, symmetrisch für beide Seiten
const n0 = s.side < 0 ? new V3(-s.n.x, s.n.y, s.n.z) : s.n;
const up0 = Math.abs(n0.y) > 0.9 ? new V3(1, 0, 0) : new V3(0, 1, 0);
const ref0 = up0.clone().addScaledVector(n0, -up0.dot(n0)).normalize();
const side0 = new V3().crossVectors(n0, ref0);
const la = r() * Math.PI * 2, lm = 0.1 + 0.25 * r();
T.lean.copy(ref0).multiplyScalar(Math.cos(la)).addScaledVector(side0, Math.sin(la)).multiplyScalar(lm * L);
if (s.side < 0) T.lean.x = -T.lean.x;
if (s.side === 0) T.lean.x = 0;
const up = Math.abs(T.n.y) > 0.9 ? new V3(1, 0, 0) : new V3(0, 1, 0);
T.ref.copy(up).addScaledVector(T.n, -up.dot(T.n)).normalize();
for (let i = 0; i < N; i++) {
const u = i / (N - 1);
const g = T.root.clone().addScaledVector(T.n, i * seg).addScaledVector(T.lean, u * u);
T.g.push(g); T.p.push(g.clone()); T.pp.push(g.clone()); T.F.push(new V3()); T.tan.push(new V3());
}
for (let i = 0; i < N; i++) {
const b = new THREE.Bone();
if (i === 0) { b.position.copy(T.root); group.add(b); }
else { b.position.set(0, seg, 0); bones[T.base + i - 1].add(b); }
bones.push(b);
}
// Ringe: versenkter Ansatz, Schaft, runde Kuppe
const rings = [];
rings.push({ y: -r0 * 1.6, rad: r0, b: 0, f: 0 });
const Rn = (N - 1) * RPB + 1;
for (let q = 0; q < Rn; q++) {
const u = q / (Rn - 1), bf = u * (N - 1), b0 = Math.min(Math.floor(bf), N - 2);
rings.push({ y: u * L, rad: r0 * lerp(1, P.taper, u), b: b0, f: bf - b0 });
}
const rEnd = r0 * P.taper, rc = rEnd * Math.max(1, P.tip);
const ph0 = -Math.acos(clamp(rEnd / rc, 0, 1));
const cy = L - rc * Math.sin(ph0);
for (let j = 1; j < CAP; j++) {
const ph = ph0 + (Math.PI / 2 - ph0) * j / CAP;
rings.push({ y: cy + rc * Math.sin(ph), rad: rc * Math.cos(ph), b: N - 1, f: 0 });
}
T.rTip = rc;
// UV: u rund herum, v entlang der Bogenlänge (in Umfängen) → quadratische Texel
const v0 = pos.length / 3, C = S + 1, circ = 2 * Math.PI * r0;
let arc = 0, prevR = null;
for (const R of rings) {
if (prevR) arc += Math.hypot(R.y - prevR.y, R.rad - prevR.rad);
prevR = R;
const vr = pos.length / 3;
for (let j = 0; j < C; j++) {
const phi = j / S * Math.PI * 2;
pos.push(T.root.x + Math.sin(phi) * R.rad, T.root.y + R.y, T.root.z + Math.cos(phi) * R.rad);
uv.push(j / S, arc / circ);
sI.push(T.base + R.b, T.base + Math.min(R.b + 1, N - 1), 0, 0);
sW.push(1 - R.f, R.f, 0, 0);
}
welds.push([vr, vr + S]);
}
const poleArc = arc + Math.hypot(cy + rc - prevR.y, prevR.rad);
const pole0 = pos.length / 3, poleGroup = [];
for (let j = 0; j < S; j++) {
pos.push(T.root.x, T.root.y + cy + rc, T.root.z);
uv.push((j + 0.5) / S, poleArc / circ);
sI.push(T.base + N - 1, T.base + N - 1, 0, 0); sW.push(1, 0, 0, 0);
poleGroup.push(pole0 + j);
}
welds.push(poleGroup);
const nr = rings.length;
for (let q = 0; q < nr - 1; q++) for (let j = 0; j < S; j++) {
const a = v0 + q * C + j, b = a + 1, c = v0 + (q + 1) * C + j, d = c + 1;
idx.push(a, b, c, b, d, c);
}
const last = v0 + (nr - 1) * C;
for (let j = 0; j < S; j++) idx.push(last + j, last + j + 1, pole0 + j);
W.tents.push(T);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
geo.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(sI, 4));
geo.setAttribute('skinWeight', new THREE.Float32BufferAttribute(sW, 4));
geo.setIndex(idx);
geo.computeVertexNormals();
const nA = geo.attributes.normal, wt = new V3(), wu = new V3();
for (const grp of welds) {
wt.set(0, 0, 0);
for (const i of grp) wt.add(wu.fromBufferAttribute(nA, i));
if (wt.lengthSq() < 1e-12) continue;
wt.normalize();
for (const i of grp) nA.setXYZ(i, wt.x, wt.y, wt.z);
}
group.updateMatrixWorld(true);
const skel = new THREE.Skeleton(bones);
const mesh = new THREE.SkinnedMesh(geo, tentMat);
mesh.frustumCulled = false; mesh.castShadow = true; mesh.receiveShadow = true;
group.add(mesh);
mesh.bind(skel, new THREE.Matrix4());
W.hairMesh = mesh; W.skel = skel; W.bones = bones;
// Hilfsdarstellung: Seeds und Bones
const sp = new Float32Array(seeds.length * 3);
seeds.forEach((s, i) => { sp[i * 3] = s.p.x + s.n.x * 0.01; sp[i * 3 + 1] = s.p.y + s.n.y * 0.01; sp[i * 3 + 2] = s.p.z + s.n.z * 0.01; });
const sg = new THREE.BufferGeometry(); sg.setAttribute('position', new THREE.BufferAttribute(sp, 3));
W.seedPts = new THREE.Points(sg, new THREE.PointsMaterial({ color: 0xe5484d, size: 5, sizeAttenuation: false }));
W.seedPts.frustumCulled = false;
const lg = new THREE.BufferGeometry();
lg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(W.tents.length * (N - 1) * 6), 3));
W.boneLines = new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ color: 0xe5484d, depthTest: false, transparent: true, opacity: 0.8 }));
W.boneLines.renderOrder = 10; W.boneLines.frustumCulled = false;
group.add(W.seedPts, W.boneLines);
for (const T of W.tents) pose(T);
updateDebug();
}
function reseed() { W.seeds = distributeSeeds(); buildTentacles(); }
// ==== Kräfte ====
const NZ = new ImprovedNoise();
function flowAt(p, t, out) {
const f = P.flowScale, s = t * P.flowSpeed;
let x = p.x;
const mir = M.mirrorFlow && x < 0;
if (mir) x = -x;
out.set(
NZ.noise(x * f + s, p.y * f, p.z * f),
NZ.noise(x * f + 19.1, p.y * f + s, p.z * f + 7.3),
NZ.noise(x * f + 3.7, p.y * f + 41.2, p.z * f + s));
if (mir) out.x = -out.x;
return out.multiplyScalar(2);
}
const _mv = new V3();
function mouseForce(p, s, out) {
out.set(0, 0, 0);
const m = W.mouse;
if (m.amp < 1e-3) return out;
_mv.subVectors(p, m.pos);
const d = _mv.length();
if (d < 1e-5) return out;
if (m.down) {
const R = P.mouseRadius * 1.8;
if (d > R) return out;
const q = 1 - d / R;
out.copy(_mv).multiplyScalar(-P.attract * q * Math.min(1, d / 0.15) / d);
} else {
const R = P.mouseRadius;
if (d > R) return out;
const q = 1 - d / R;
out.copy(_mv).multiplyScalar(P.avoid * q * q / d);
}
return out.multiplyScalar(m.amp * (0.3 + 0.7 * s));
}
// ==== Physik ====
function integrate(T, h) {
const N = T.N, p = T.p, pp = T.pp, g = T.g, keep = Math.exp(-P.drag * h), hh = h * h;
for (let i = 1; i < N; i++) {
const s = i / (N - 1), k = P.stiffness * (2.5 + 70 * (1 - s) * (1 - s)), c = 0.7 * Math.sqrt(k);
const x = p[i], xp = pp[i], F = T.F[i];
const damp = keep * Math.max(0, 1 - c * h);
const vx = (x.x - xp.x) * damp, vy = (x.y - xp.y) * damp, vz = (x.z - xp.z) * damp;
const ax = k * (g[i].x - x.x) + F.x;
const ay = k * (g[i].y - x.y) + F.y;
const az = k * (g[i].z - x.z) + F.z;
xp.copy(x);
x.x += vx + ax * hh; x.y += vy + ay * hh; x.z += vz + az * hh;
}
p[0].copy(T.root);
}
const _dv = new V3();
function constrain(T, iters) {
const N = T.N, p = T.p, seg = T.seg;
const solve = (a, b, fixA) => {
_dv.subVectors(b, a);
const d = _dv.length(); if (d < 1e-9) return;
const diff = (d - seg) / d;
if (fixA) b.addScaledVector(_dv, -diff);
else { a.addScaledVector(_dv, 0.5 * diff); b.addScaledVector(_dv, -0.5 * diff); }
};
for (let it = 0; it < iters; it++) {
for (let i = 0; i < N - 1; i++) solve(p[i], p[i + 1], i === 0);
for (let i = N - 2; i >= 0; i--) solve(p[i], p[i + 1], i === 0);
}
}
const _ko = new V3();
function keepOut(T) {
const m = T.r0 * 1.3;
for (let i = 1; i < T.N; i++) {
const d = _ko.subVectors(T.p[i], T.root).dot(T.n);
if (d < m) T.p[i].addScaledVector(T.n, m - d);
}
}
function collide(T) {
for (let i = 2; i < T.N; i++) {
surf(T.p[i], S1);
const rr = (i === T.N - 1 ? T.rTip : T.r0) * 1.1 + 0.005;
if (S1.sd < rr) T.p[i].addScaledVector(S1.n, rr - S1.sd);
}
}
// ==== Bones ausrichten ====
const _pq = new THREE.Quaternion(), _qw = new THREE.Quaternion(), _inv = new THREE.Quaternion();
const _X = new V3(), _uu = new V3(), _tv = new V3(), _bm = new THREE.Matrix4();
function pose(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].copy(T.n);
T.tan[i].normalize();
}
_uu.copy(T.ref);
_pq.identity();
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();
_X.crossVectors(t, _uu).normalize();
_bm.makeBasis(_X, t, _uu);
_qw.setFromRotationMatrix(_bm);
_inv.copy(_pq).invert();
const b = W.bones[T.base + i];
b.quaternion.copy(_inv).multiply(_qw);
if (i === 0) b.position.copy(p[0]);
else b.position.copy(_tv.subVectors(p[i], p[i - 1]).applyQuaternion(_inv));
_pq.copy(_qw);
}
}
function updateDebug() {
if (W.hairMesh) W.hairMesh.visible = M.tentacles && (!M.paint || M.paintPreview);
if (W.seedPts) W.seedPts.visible = M.seeds;
if (W.boneLines) {
W.boneLines.visible = M.bones;
if (M.bones) {
const a = W.boneLines.geometry.attributes.position;
let k = 0;
for (const T of W.tents) for (let i = 0; i < T.N - 1; i++) {
a.setXYZ(k++, T.p[i].x, T.p[i].y, T.p[i].z);
a.setXYZ(k++, T.p[i + 1].x, T.p[i + 1].y, T.p[i + 1].z);
}
a.needsUpdate = true;
}
}
}
const _fl = new V3(), _mf = new V3();
function step(dt) {
W.time += dt;
updateMouse(dt);
if (M.paint && W.paintDown) brush(dt);
const t = W.time;
for (const T of W.tents) for (let i = 1; i < T.N; i++) {
const s = i / (T.N - 1);
const F = T.F[i].set(0, -P.gravity, 0);
if (M.flow) F.addScaledVector(flowAt(T.p[i], t, _fl), P.flow * (0.25 + 0.75 * s));
if (M.mouse) F.add(mouseForce(T.p[i], s, _mf));
}
const subs = 2, h = dt / subs;
for (let k = 0; k < subs; k++) for (const T of W.tents) { integrate(T, h); constrain(T, 4); keepOut(T); }
if (M.collide && W.bvh) for (const T of W.tents) collide(T);
for (const T of W.tents) {
let ok = true;
for (const q of T.p) if (!Number.isFinite(q.x + q.y + q.z)) { ok = false; break; }
if (!ok) for (let i = 0; i < T.N; i++) { T.p[i].copy(T.g[i]); T.pp[i].copy(T.g[i]); }
}
for (const T of W.tents) pose(T);
updateDebug();
}
function applyMaterial() {
headMat.color.set(P.headColor); headMat.roughness = clamp(P.headRough, 0.02, 1);
tentMat.color.set(P.tentColor); tentMat.roughness = clamp(P.tentRough, 0.02, 1);
tentMat.clearcoat = Math.max(0.001, P.coat); tentMat.envMapIntensity = P.envInt;
headMat.envMapIntensity = 0.25 * P.envInt;
tentMat.normalScale.set(P.normalScale, P.normalScale);
tentMat.clearcoatNormalScale.set(P.normalScale * 0.6, P.normalScale * 0.6);
if (W.normalTex) {
W.normalTex.repeat.set(P.normalRepeat, P.normalRepeat);
const want = M.coatNormal ? W.normalTex : null;
if (tentMat.clearcoatNormalMap !== want) { tentMat.clearcoatNormalMap = want; tentMat.needsUpdate = true; }
}
if (tentMat.flatShading !== M.facet) { tentMat.flatShading = M.facet; tentMat.needsUpdate = true; }
sun.intensity = P.sun; hemi.intensity = P.hemi;
}
// ==== 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.4, 9.5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableZoom = true; controls.zoomSpeed = 0.8; controls.minDistance = 3; controls.maxDistance = 24;
controls.enablePan = false; controls.enableDamping = true;
controls.mouseButtons = { LEFT: -1, MIDDLE: -1, RIGHT: THREE.MOUSE.ROTATE };
controls.touches = { ONE: -1, TWO: THREE.TOUCH.DOLLY_ROTATE };
const pmrem = new THREE.PMREMGenerator(renderer);
const envMap = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
headMat.envMap = envMap; tentMat.envMap = envMap; paintMat.envMap = envMap;
headMat.needsUpdate = tentMat.needsUpdate = paintMat.needsUpdate = true;
const hemi = new THREE.HemisphereLight(0xffffff, 0xc9ccd2, P.hemi);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffffff, P.sun);
sun.position.set(-4, 7, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -5, right: 5, top: 5, bottom: -5, near: 1, far: 24 });
sun.shadow.bias = -0.0004; sun.shadow.normalBias = 0.02; sun.shadow.radius = 4;
scene.add(sun);
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// Maus: Präsenz = meiden, gedrückt = anziehen · im Malmodus: linke Maustaste malt
const ray = new THREE.Raycaster(), _mt = new V3(), _zAx = new V3(0, 0, 1), _mn = new V3();
const cv = renderer.domElement;
function setNdc(e) { W.mouse.ndc.set(e.clientX / innerWidth * 2 - 1, -(e.clientY / innerHeight) * 2 + 1); }
function updateMouse(dt) {
const m = W.mouse;
const want = (m.over || m.down) && M.mouse && !M.paint ? 1 : 0;
m.amp += (want - m.amp) * (1 - Math.exp(-8 * dt));
m.hit = false;
if (m.over || m.down || W.paintDown) {
ray.setFromCamera(m.ndc, camera);
const hit = W.bvh ? W.bvh.raycastFirst(ray.ray, THREE.DoubleSide) : null;
if (hit) {
m.hit = true; m.hp.copy(hit.point);
surf(hit.point, S1); m.hn.copy(S1.n);
_mt.copy(hit.point).addScaledVector(ray.ray.direction, -P.mouseRadius * 0.3);
} else {
const tt = Math.max(0, -ray.ray.origin.dot(ray.ray.direction));
_mt.copy(ray.ray.origin).addScaledVector(ray.ray.direction, tt);
}
if (m.fresh) { m.pos.copy(_mt); m.fresh = false; } else m.pos.lerp(_mt, 1 - Math.exp(-20 * dt));
}
cursor.visible = M.cursor && M.mouse && !M.paint && m.amp > 0.05;
cursor.position.copy(m.pos);
cursor.scale.setScalar(m.down ? P.mouseRadius * 1.8 : P.mouseRadius);
// Pinselring
const showRing = M.paint && m.hit;
brushRing.visible = showRing;
brushMirror.visible = showRing && M.sym;
if (showRing) {
brushRing.position.copy(m.hp).addScaledVector(m.hn, 0.01);
brushRing.quaternion.setFromUnitVectors(_zAx, m.hn);
brushRing.scale.setScalar(P.brushSize);
_mn.set(-m.hn.x, m.hn.y, m.hn.z);
brushMirror.position.set(-m.hp.x, m.hp.y, m.hp.z).addScaledVector(_mn, 0.01);
brushMirror.quaternion.setFromUnitVectors(_zAx, _mn);
brushMirror.scale.setScalar(P.brushSize);
}
}
function setPaintMode() {
head.material = M.paint ? paintMat : headMat;
cv.style.cursor = M.paint ? 'crosshair' : 'default';
W.paintDown = false;
if (!M.paint && W.paintDirty) { W.paintDirty = false; reseed(); }
updateDebug();
}
cv.addEventListener('pointermove', e => { setNdc(e); W.mouse.over = true; });
cv.addEventListener('pointerleave', () => { if (!W.mouse.down && !W.paintDown) { W.mouse.over = false; W.mouse.fresh = true; } });
cv.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
setNdc(e); W.mouse.over = true;
if (M.paint) W.paintDown = true; else W.mouse.down = true;
cv.setPointerCapture(e.pointerId);
});
const endDown = e => {
if (!W.mouse.down && !W.paintDown) return;
const wasPaint = W.paintDown;
W.mouse.down = false; W.paintDown = false;
if (cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
if (e.pointerType === 'touch') { W.mouse.over = false; W.mouse.fresh = true; }
if (wasPaint && M.paintPreview && W.paintDirty) { W.paintDirty = false; reseed(); }
};
cv.addEventListener('pointerup', endDown);
cv.addEventListener('pointercancel', endDown);
cv.addEventListener('contextmenu', e => e.preventDefault());
addEventListener('keydown', e => {
if (e.target !== document.body) return;
if (e.code === 'KeyP') { M.paint = !M.paint; syncAll(); setPaintMode(); }
});
// Modell laden
function setRawGeometry(g) { W.raw = g; bakeObject(); reseed(); }
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) geo = mergeGeometries(geos.map(g => g.index ? g.toNonIndexed() : g));
notice.textContent = '';
setRawGeometry(geo);
}
function fallbackObject(msg) {
notice.textContent = msg;
setRawGeometry(new THREE.SphereGeometry(1, 48, 32).scale(0.85, 1.1, 0.95));
}
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: Testform.'));
function loadFile(file) {
file.arrayBuffer().then(buf => loader.parse(buf, '', useGltf, err => { notice.textContent = 'Datei konnte nicht gelesen werden.'; console.error(err); }));
}
// Normal Map für die Tentakel
const texLoader = new THREE.TextureLoader();
let normalLabel = null;
function setNormalTexture(tex, name) {
if (W.normalTex) W.normalTex.dispose();
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.NoColorSpace;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
W.normalTex = tex;
tentMat.normalMap = tex;
tentMat.clearcoatNormalMap = M.coatNormal ? tex : null;
tentMat.needsUpdate = true;
applyMaterial();
if (normalLabel) normalLabel.textContent = name;
}
function clearNormalTexture() {
if (!W.normalTex) return;
W.normalTex.dispose(); W.normalTex = null;
tentMat.normalMap = null; tentMat.clearcoatNormalMap = null; tentMat.needsUpdate = true;
if (normalLabel) normalLabel.textContent = 'keine';
}
function loadNormalFile(file) {
const url = URL.createObjectURL(file);
texLoader.load(url, tex => { setNormalTexture(tex, file.name); URL.revokeObjectURL(url); }, undefined,
() => { notice.textContent = 'Bild konnte nicht gelesen werden.'; URL.revokeObjectURL(url); });
}
const normalUrl = new URLSearchParams(location.search).get('normal') || 'normal_map.jpg';
texLoader.load(normalUrl, tex => setNormalTexture(tex, normalUrl), undefined, () => {});
const paintUrl = new URLSearchParams(location.search).get('paint') || 'vertex_paint_map.jpg';
fetch(paintUrl).then(r => r.ok ? r.blob() : null).then(b => { if (b && b.type.startsWith('image/')) return loadPaintBlob(b); }).catch(() => {});
addEventListener('dragover', e => e.preventDefault());
addEventListener('drop', e => {
e.preventDefault();
const f = e.dataTransfer.files[0]; if (!f) return;
const isImg = f.type.startsWith('image/') || /\.(jpe?g|png|webp)$/i.test(f.name);
if (isImg && /paint/i.test(f.name)) loadPaintBlob(f);
else if (isImg) loadNormalFile(f);
else loadFile(f);
});
// UI
const UI = [
{ title: 'Module', open: true, items: [
['toggle', 'flow', 'Flowfield (Perlin)'],
['toggle', 'mouse', 'Maus: meiden, gedrückt anziehen'],
['toggle', 'collide', 'Kollision mit Kopf'],
['toggle', 'mirrorFlow', 'Bewegung spiegeln'],
['toggle', 'facet', 'Facettiert'],
['toggle', 'tentacles', 'Tentakel zeigen'],
['toggle', 'seeds', 'Seeds zeigen'],
['toggle', 'cursor', 'Mausbereich zeigen'],
['toggle', 'bones', 'Bones zeigen'],
] },
{ title: 'Vertex Paint', open: true, items: [
['toggle', 'paint', 'Malmodus (Taste P)'],
['toggle', 'sym', 'Achssymmetrisch (Malen und Verteilung)'],
['toggle', 'usePaint', 'Verteilung nach Paint-Map'],
['toggle', 'paintPreview', 'Tentakel im Malmodus zeigen'],
['swatches'],
['range', 'brushValue', 'Grauwert', 0, 1, 0.01],
['range', 'brushSize', 'Pinselgröße', 0.05, 1.5, 0.01],
['range', 'brushStrength', 'Pinselstärke', 0.05, 1, 0.01],
['paintio'],
] },
{ title: 'Verteilung', open: true, items: [
['seed'],
['range', 'count', 'Anzahl Tentakel', 10, 500, 1, 'seed'],
['range', 'bottom', 'Untere Grenze (ohne Paint-Map)', 0, 0.9, 0.01, 'seed'],
] },
{ title: 'Tentakel', open: true, items: [
['range', 'length', 'Länge', 0.15, 1.5, 0.01, 'build'],
['range', 'lenVar', 'Längenstreuung', 0, 0.6, 0.01, 'build'],
['range', 'radius', 'Dicke', 0.01, 0.1, 0.002, 'build'],
['range', 'taper', 'Verjüngung', 0.4, 1.3, 0.01, 'build'],
['range', 'tip', 'Kuppe', 1, 2.5, 0.05, 'build'],
['range', 'bones', 'Bones pro Tentakel', 3, 14, 1, 'build'],
['range', 'radial', 'Radialsegmente', 4, 12, 1, 'build'],
] },
{ title: 'Bewegung', open: true, items: [
['range', 'flow', 'Flowfield-Stärke', 0, 8, 0.05],
['range', 'flowScale', 'Flowfield-Maßstab', 0.1, 3, 0.05],
['range', 'flowSpeed', 'Flowfield-Tempo', 0, 2, 0.01],
['range', 'stiffness', 'Steifigkeit', 0.2, 3, 0.05],
['range', 'drag', 'Dämpfung', 0, 5, 0.05],
['range', 'gravity', 'Schwerkraft', -2, 3, 0.05],
] },
{ title: 'Maus', items: [
['range', 'mouseRadius', 'Radius', 0.2, 2.5, 0.05],
['range', 'avoid', 'Ausweichen', 0, 20, 0.1],
['range', 'attract', 'Anziehen (gedrückt)', 0, 25, 0.1],
] },
{ title: 'Material', items: [
['color', 'headColor', 'Kopf'],
['range', 'headRough', 'Kopf Rauheit', 0, 1, 0.01, 'mat'],
['color', 'tentColor', 'Tentakel'],
['range', 'tentRough', 'Tentakel Rauheit (klein = glänzend)', 0, 1, 0.01, 'mat'],
['range', 'coat', 'Klarlack', 0, 1, 0.01, 'mat'],
] },
{ title: 'Oberfläche Tentakel', items: [
['normal'],
['range', 'normalScale', 'Stärke', 0, 1.5, 0.01, 'mat'],
['range', 'normalRepeat', 'Kachelung', 0.25, 6, 0.05, 'mat'],
['toggle', 'coatNormal', 'Auch im Klarlack sichtbar'],
] },
{ title: 'Licht', items: [
['range', 'sun', 'Hauptlicht', 0, 2, 0.01, 'mat'],
['range', 'hemi', 'Umgebungslicht', 0, 1.5, 0.01, 'mat'],
['range', 'envInt', 'Reflexion der Umgebung', 0, 2, 0.05, 'mat'],
] },
{ title: 'Kopf', items: [
['range', 'objectScale', 'Größe', 0.5, 1.8, 0.05, 'bake'],
['file'],
] },
];
const syncers = [];
const toggleHooks = {
paint: setPaintMode,
sym: reseed,
usePaint: reseed,
paintPreview: () => { if (M.paint && M.paintPreview && W.paintDirty) { W.paintDirty = false; reseed(); } },
};
const mkBtn = (txt, fn) => { const b = document.createElement('button'); b.textContent = txt; b.addEventListener('click', fn); return b; };
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; applyMaterial(); toggleHooks[key]?.(); updateDebug(); });
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, mode] = 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 : stp >= 0.01 ? 2 : 3;
const show = () => { o.textContent = Number(P[key]).toFixed(dec); };
r.addEventListener('input', () => { P[key] = parseFloat(r.value); show(); if (mode === 'mat') applyMaterial(); });
if (mode === 'build') r.addEventListener('change', buildTentacles);
if (mode === 'seed') r.addEventListener('change', reseed);
if (mode === 'bake') r.addEventListener('change', () => { bakeObject(); reseed(); });
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); reseed(); });
syncers.push(() => { n.value = P.seed; });
} else if (it[0] === 'swatches') {
const bar = document.createElement('div'); bar.className = 'bar'; bar.style.margin = '4px 0 2px';
for (const [name, v] of [['Schwarz', 0], ['Anthrazit', 0.22], ['Grau', 0.5], ['Weiß', 1]]) {
const b = mkBtn(name, () => { P.brushValue = v; syncAll(); });
const sw = document.createElement('span');
const g = Math.round(v * 255);
sw.style.cssText = `display:inline-block;width:9px;height:9px;border:1px solid #bbb;border-radius:2px;margin-right:5px;vertical-align:-1px;background:rgb(${g},${g},${g})`;
b.prepend(sw); bar.appendChild(b);
}
d.appendChild(bar);
} else if (it[0] === 'paintio') {
const bar = document.createElement('div'); bar.className = 'bar'; bar.style.margin = '6px 0 4px';
const f = document.createElement('input'); f.type = 'file'; f.accept = 'image/*'; f.style.display = 'none';
f.addEventListener('change', () => { if (f.files[0]) loadPaintBlob(f.files[0]); f.value = ''; });
bar.append(
mkBtn('Speichern (JPG)', savePaintMap),
mkBtn('Laden', () => f.click()),
mkBtn('Füllen', () => fillPaint(P.brushValue)),
mkBtn('Leeren', () => fillPaint(0)), f);
d.appendChild(bar);
} else if (it[0] === 'normal') {
const info = document.createElement('div'); info.className = 'seed';
const s = document.createElement('span'); s.textContent = 'Normal Map:';
normalLabel = document.createElement('span'); normalLabel.style.color = 'var(--mute)';
normalLabel.textContent = W.normalTex ? normalUrl : 'keine';
info.append(s, normalLabel); d.appendChild(info);
const bar = document.createElement('div'); bar.className = 'bar'; bar.style.margin = '2px 0 4px';
const b = document.createElement('button'); b.textContent = 'Bild laden';
const x = document.createElement('button'); x.textContent = 'Entfernen';
const f = document.createElement('input'); f.type = 'file'; f.accept = 'image/*'; f.style.display = 'none';
b.addEventListener('click', () => f.click());
x.addEventListener('click', clearNormalTexture);
f.addEventListener('change', () => { if (f.files[0]) loadNormalFile(f.files[0]); f.value = ''; });
bar.append(b, x, f); d.appendChild(bar);
} 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();
applyMaterial();
document.getElementById('reroll').addEventListener('click', () => {
P.seed = Math.floor(Math.random() * 100000); syncAll(); reseed();
});
document.getElementById('defaults').addEventListener('click', () => {
const seed = P.seed;
Object.assign(P, DEFAULT_P, { seed }); Object.assign(M, DEFAULT_M);
syncAll(); applyMaterial(); bakeObject(); setPaintMode(); reseed();
});
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, last = performance.now();
renderer.setAnimationLoop(now => {
const dt = clamp((now - last) / 1000, 0, 1 / 30);
last = now;
if (dt > 1e-4) step(dt);
controls.update();
renderer.render(scene, camera);
statusT += dt;
if (statusT > 0.25) {
statusT = 0;
const ms = !M.mouse ? 'Maus aus' : W.mouse.down ? 'anziehen' : W.mouse.over ? 'meiden' : 'Maus –';
const empty = M.usePaint && W.geo && !W.tents.length ? ' · Keine bemalte Fläche – im Malmodus hell malen' : '';
statusEl.textContent = (M.paint ? 'Malmodus · ' : '') + 'Tentakel ' + W.tents.length + ' · Bones ' + W.bones.length + ' · ' + ms + empty;
}
});
</script>
</body>
</html>