INFLATE_MYSELF_v1.0

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/a423eee520588892/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Head Scan</title>
<style>
:root{--fg:#111;--mut:#8d8d8d;--line:#e8e8e8;--bg:#fff}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:var(--bg);color:var(--fg);font:13px/1.4 ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;-webkit-font-smoothing:antialiased}
#app{display:grid;grid-template-rows:auto 1fr auto;height:100vh;height:100dvh}
header{display:flex;gap:12px;align-items:baseline;padding:14px 18px;border-bottom:1px solid var(--line)}
header b{letter-spacing:.16em;font-weight:600}
header .v{color:var(--mut)}
#status{margin-left:auto;color:var(--mut);text-align:right}
main{display:grid;grid-template-columns:1fr 1fr;min-height:0}
#camWrap{position:relative;border-right:1px solid var(--line);overflow:hidden;background:#fbfbfb;min-height:0}
#video,#overlay{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
.mirror #video,.mirror #overlay{transform:scaleX(-1)}
#flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;transition:opacity .35s}
#flash.on{opacity:.85;transition:none}
#viewWrap{position:relative;min-height:0;overflow:hidden}
#viewWrap canvas{display:block;width:100%;height:100%;touch-action:none}
#prompt{position:absolute;left:50%;top:14px;transform:translateX(-50%);background:rgba(255,255,255,.9);border:1px solid var(--line);border-radius:999px;padding:7px 14px;white-space:nowrap;max-width:calc(100% - 28px);overflow:hidden;text-overflow:ellipsis;display:none}
#guide{position:absolute;left:14px;bottom:14px;display:none;grid-template-columns:repeat(3,18px);grid-template-rows:repeat(3,18px);gap:5px;background:rgba(255,255,255,.9);padding:8px;border:1px solid var(--line);border-radius:10px}
.dot{width:18px;height:18px;border-radius:50%;border:1px solid var(--fg);background:conic-gradient(var(--fg) calc(var(--p,0)*1turn),#fff 0)}
.dot.next{outline:3px solid rgba(0,0,0,.09)}
#startBox{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center;width:min(90%,320px)}
#startBox small{color:var(--mut)}
#insetFrame{position:absolute;left:12px;bottom:12px;border:1px solid var(--line);display:none;pointer-events:none}
#insetFrame span{position:absolute;left:6px;top:4px;font-size:10px;letter-spacing:.12em;color:var(--mut)}
#viewLabel{position:absolute;right:14px;bottom:12px;color:var(--mut);font-size:11px;letter-spacing:.08em;pointer-events:none}
.panel{position:absolute;top:12px;display:none;background:rgba(255,255,255,.94);border:1px solid var(--line);border-radius:12px;padding:10px 12px 12px;width:200px;max-height:calc(100% - 24px);overflow:auto}
#inflatePanel{left:12px} #matPanel{right:12px}
.panel label{display:flex;justify-content:space-between;font-size:11px;color:var(--mut);margin:7px 0 1px}
.panel label span{color:var(--fg)}
.panel input[type=range]{width:100%;accent-color:#111}
.panel input[type=color]{width:100%;height:22px;border:1px solid var(--line);border-radius:6px;background:#fff;padding:0;cursor:pointer}
.panel .row{display:flex;gap:8px;align-items:center;margin-top:10px;font-size:11px;color:var(--mut)}
.panel .row button{padding:5px 11px;font-size:12px}
.panel small{display:block;margin-top:8px;color:var(--mut);font-size:11px}
.panel .seg{display:flex;flex-wrap:wrap;gap:4px;margin:2px 0 4px}
.panel .seg button{padding:4px 9px;font-size:11px}
.panel h4{font-size:10px;letter-spacing:.14em;color:var(--mut);font-weight:500;margin:2px 0 4px}
body.viewmode main{grid-template-columns:1fr}
body.viewmode #camWrap{display:none}
footer{display:flex;flex-wrap:wrap;gap:8px;padding:12px 18px;border-top:1px solid var(--line)}
button{appearance:none;border:1px solid var(--fg);background:#fff;color:var(--fg);font:inherit;padding:8px 15px;border-radius:999px;cursor:pointer}
button:disabled{opacity:.28;cursor:default}
button.on{background:#f1f1f1}
footer .sp{flex:1}
@media (max-width:760px){
  main{grid-template-columns:1fr;grid-template-rows:1fr 1fr}
  #camWrap{border-right:0;border-bottom:1px solid var(--line)}
  header{padding:12px 14px} footer{padding:10px 14px;gap:6px} button{padding:7px 12px}
  .panel{width:168px;padding:8px 10px}
  body.viewmode main{grid-template-rows:1fr}
}
</style>
<script type="importmap">
{"imports":{
  "three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
  "three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}}
</script>
</head>
<body>
<div id="app">
  <header><b>HEAD SCAN</b><span class="v">v0.7</span><span id="status">bereit</span></header>
  <main>
    <div id="camWrap">
      <video id="video" playsinline muted></video>
      <canvas id="overlay"></canvas>
      <div id="flash"></div>
      <div id="prompt"></div>
      <div id="guide">
        <i></i><div class="dot" data-b="u"></div><i></i>
        <div class="dot" data-b="l"></div><div class="dot" data-b="c"></div><div class="dot" data-b="r"></div>
        <i></i><div class="dot" data-b="d"></div><i></i>
      </div>
      <div id="startBox">
        <button id="startBtn">Kamera starten</button>
        <button id="startUpload">Fotos laden</button>
        <small>5 Fotos: zuerst frontal, dann rechts, links, oben, unten (je ca. 30°). Gleichmäßiges Licht.</small>
      </div>
    </div>
    <div id="viewWrap">
      <div id="viewLabel">CANONICAL HEAD</div>
      <div id="insetFrame"><span>UV</span></div>
      <div id="inflatePanel" class="panel">
        <h4>FORM</h4>
        <div class="seg" id="shapeSeg"></div>
        <label>Druck <span id="v_pressure"></span></label><input id="s_pressure" type="range" min="0" max="1" step="0.01" value="0.5">
        <label>Wachstum <span id="v_growth"></span></label><input id="s_growth" type="range" min="1" max="1.8" step="0.01" value="1.25">
        <label>Größe der Form <span id="v_cap"></span></label><input id="s_cap" type="range" min="0.6" max="1.5" step="0.01" value="1">
        <label>Steifigkeit <span id="v_bend"></span></label><input id="s_bend" type="range" min="0" max="1" step="0.01" value="0.25">
        <label>Glätte <span id="v_smooth"></span></label><input id="s_smooth" type="range" min="0" max="1" step="0.01" value="0.35">
        <label>Halt <span id="v_hold"></span></label><input id="s_hold" type="range" min="0" max="1" step="0.01" value="0.5">
        <div class="row" style="margin-top:6px"><label style="margin:0;gap:5px;align-items:center"><input id="selfCol" type="checkbox" checked> Selbstkollision</label></div>
        <div class="row"><button id="simReset">Reset</button><label style="margin:0;gap:5px;align-items:center"><input id="capShow" type="checkbox"> Form zeigen</label></div>
        <small>Mesh ziehen zum Greifen</small>
      </div>
      <div id="matPanel" class="panel">
        <h4>MATERIAL</h4>
        <label>Rauheit <span id="v_rough"></span></label><input id="m_rough" type="range" min="0.05" max="1" step="0.01" value="0.6">
        <label>Samt (Sheen) <span id="v_sheen"></span></label><input id="m_sheen" type="range" min="0" max="1" step="0.01" value="0.35">
        <label>Glanzschicht <span id="v_coat"></span></label><input id="m_coat" type="range" min="0" max="1" step="0.01" value="0.05">
        <label>Subsurface <span id="v_sss"></span></label><input id="m_sss" type="range" min="0" max="2" step="0.01" value="0.8">
        <label>Streufarbe</label><input id="m_sssColor" type="color" value="#e0412a">
        <label>Durchleuchtung <span id="v_thin"></span></label><input id="m_thin" type="range" min="0" max="3" step="0.01" value="1.2">
        <label>Normal: Poren <span id="v_nrm"></span></label><input id="m_nrm" type="range" min="0" max="3" step="0.01" value="1">
        <label>Normal: Kanten <span id="v_edge"></span></label><input id="m_edge" type="range" min="0" max="3" step="0.01" value="1">
        <h4 style="margin-top:12px">OHREN</h4>
        <label>Höhe <span id="v_earY"></span></label><input id="g_earY" type="range" min="-2.5" max="2.5" step="0.05" value="0">
        <label>vor / zurück <span id="v_earZ"></span></label><input id="g_earZ" type="range" min="-2.5" max="2.5" step="0.05" value="0">
        <h4 style="margin-top:12px">LICHT</h4>
        <label>Richtung <span id="v_lightA"></span></label><input id="m_lightA" type="range" min="0" max="360" step="1" value="40">
        <label>Gegenlicht <span id="v_rim"></span></label><input id="m_rim" type="range" min="0" max="4" step="0.01" value="1.4">
      </div>
    </div>
  </main>
  <footer>
    <button id="modeBtn">Ansicht</button>
    <button id="switchBtn" disabled>Kamera wechseln</button>
    <button id="shotBtn" disabled>Auslösen</button>
    <button id="uploadBtn">Fotos laden</button>
    <button id="resetBtn">Neu</button>
    <span class="sp"></span>
    <button id="inflateBtn">Inflate</button>
    <button id="matBtn">Material</button>
    <button id="meshBtn">Mesh</button>
    <button id="saveBtn" disabled>Speichern</button>
    <button id="loadBtn">Laden</button>
    <button id="pngBtn" disabled>Textur PNG</button>
    <button id="glbBtn">GLB</button>
  </footer>
  <input id="loadIn" type="file" accept=".json,.headscan,application/json,image/png,image/jpeg" hidden>
  <input id="fileIn" type="file" accept="image/*" multiple hidden>
</div>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import Delaunator from 'https://cdn.jsdelivr.net/npm/delaunator@5.0.1/+esm';
import { FilesetResolver, FaceLandmarker, ImageSegmenter } from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/vision_bundle.mjs';

const WASM = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm';
const FACE_MODEL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task';
const SEG_MODEL = 'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/latest/selfie_multiclass_256x256.tflite';

const LOWMEM = matchMedia('(pointer:coarse)').matches && (navigator.deviceMemory || 4) <= 4;
const TEX = LOWMEM ? 1024 : 2048;   // UV-Atlas
const STILL_MAX = 2048;             // Fotos werden auf diese Kantenlänge skaliert
const RATIO = 64;                   // Auflösung der Farbangleichung

// ==MATH==
// Kopf-Lokalraum in cm: x rechts, y oben, z nach vorn (Gesicht), Ursprung zwischen den Gesichtsrändern (234/454) auf Augenhöhe.
const NU = 112, NV = 76;            // Gitter → ≈ 8.7k Vertices (Details tragen Textur + Normal Map)
const KU = 0.6, KV = 0.45;          // Textur-Verzerrung: mehr Texel fürs Gesicht
const NEUTRAL = { a: 7.4, zf: 9.2, zb: 9.6, yu: 11.0, yd: 10.2, k: 1 };
const EAR0 = { z: -2.9, h: 6.4, w: 3.9, tilt: 0.3 };
const earOff = { y: 0, z: 0 };      // Feinjustage per Regler

const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
const sstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const gs = (x, s) => Math.exp(-(x / s) * (x / s));
const hw = (x, k) => x + k * Math.sin(Math.PI * x) / Math.PI;
const hinv = (y, k) => { let x = y; for (let i = 0; i < 12; i++) x -= (hw(x, k) - y) / (1 + k * Math.cos(Math.PI * x)); return x; };
function dirToUV(d) {
  const phi = Math.atan2(d.x, d.z), th = Math.asin(clamp(d.y, -1, 1));
  return [0.5 + 0.5 * hw(phi / Math.PI, KU), 0.5 + 0.5 * hw(th / (Math.PI / 2), KV)];
}
function uvToDir(u, v, out) {
  const phi = Math.PI * hinv(2 * u - 1, KU), th = Math.PI / 2 * hinv(2 * v - 1, KV);
  return out.set(Math.cos(th) * Math.sin(phi), Math.sin(th), Math.cos(th) * Math.cos(phi));
}
// Mesh-Gitter (unabhängig von der Textur): dicht an Gesicht und Ohren, locker am Hinterkopf
const GW_N = 2048, GW = new Float32Array(GW_N + 1);
{
  const g = x => 0.42 + 1.25 * Math.exp(-((x / 0.3) ** 2)) + 1.5 * (Math.exp(-(((x - 0.6) / 0.11) ** 2)) + Math.exp(-(((x + 0.6) / 0.11) ** 2)));
  let acc = 0; GW[0] = 0;
  for (let i = 1; i <= GW_N; i++) { const x = -1 + (i - 0.5) * 2 / GW_N; acc += g(x); GW[i] = acc; }
  for (let i = 0; i <= GW_N; i++) GW[i] /= acc;
}
const gw = x => { const f = (x + 1) / 2 * GW_N, i = Math.min(GW_N - 1, Math.floor(f)); return 2 * (GW[i] + (GW[i + 1] - GW[i]) * (f - i)) - 1; };
const ginv = y => {
  const t = (y + 1) / 2; let lo = 0, hi = GW_N;
  while (hi - lo > 1) { const m = (lo + hi) >> 1; if (GW[m] <= t) lo = m; else hi = m; }
  const f = (t - GW[lo]) / Math.max(1e-9, GW[hi] - GW[lo]); return -1 + (lo + f) * 2 / GW_N;
};
function dirToGrid(d) {
  const phi = Math.atan2(d.x, d.z), th = Math.asin(clamp(d.y, -1, 1));
  return [0.5 + 0.5 * gw(phi / Math.PI), 0.5 + 0.5 * hw(th / (Math.PI / 2), KV)];
}
function gridToDir(u, v, out) {
  const phi = Math.PI * ginv(2 * u - 1), th = Math.PI / 2 * hinv(2 * v - 1, KV);
  return out.set(Math.cos(th) * Math.sin(phi), Math.sin(th), Math.cos(th) * Math.cos(phi));
}
function smax(a, b, k) { const h = clamp(0.5 + 0.5 * (a - b) / k, 0, 1); return b + (a - b) * h + k * h * (1 - h); }

function headFrame(P) {
  const O = P[234].clone().add(P[454]).multiplyScalar(0.5);
  const ex = P[454].clone().sub(P[234]);
  const unit = ex.length() / 14;        // Annahme: Gesichtsbreite 234–454 ≈ 14 cm
  ex.normalize();
  const ey = P[10].clone().sub(P[152]); ey.addScaledVector(ex, -ey.dot(ex)).normalize();
  const ez = new THREE.Vector3().crossVectors(ex, ey);
  let flipped = false;
  if (P[1].clone().sub(O).dot(ez) < 0) { ex.negate(); ez.negate(); flipped = true; }
  return { O, ex, ey, ez, unit, flipped };
}
function toLocal(P, f) {
  return P.map(p => { const d = p.clone().sub(f.O); return new THREE.Vector3(d.dot(f.ex), d.dot(f.ey), d.dot(f.ez)).divideScalar(f.unit); });
}

function jacobi4(A) {
  const a = A.map(r => r.slice()), v = [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]];
  for (let sw = 0; sw < 60; sw++) {
    let off = 0; for (let p = 0; p < 4; p++) for (let q = p + 1; q < 4; q++) off += a[p][q] * a[p][q];
    if (off < 1e-20) break;
    for (let p = 0; p < 4; p++) for (let q = p + 1; q < 4; q++) {
      if (Math.abs(a[p][q]) < 1e-30) continue;
      const th = (a[q][q] - a[p][p]) / (2 * a[p][q]);
      const t = (th >= 0 ? 1 : -1) / (Math.abs(th) + Math.sqrt(th * th + 1));
      const c = 1 / Math.sqrt(t * t + 1), s = t * c;
      for (let k = 0; k < 4; k++) { const x = a[k][p], y = a[k][q]; a[k][p] = c * x - s * y; a[k][q] = s * x + c * y; }
      for (let k = 0; k < 4; k++) { const x = a[p][k], y = a[q][k]; a[p][k] = c * x - s * y; a[q][k] = s * x + c * y; }
      for (let k = 0; k < 4; k++) { const x = v[k][p], y = v[k][q]; v[k][p] = c * x - s * y; v[k][q] = s * x + c * y; }
    }
  }
  return { values: [a[0][0], a[1][1], a[2][2], a[3][3]], vectors: v };
}

// Horn (1987) mit Skalierung: P ≈ s·R·Q + t
const _m4 = new THREE.Matrix4();
function horn(Q, P, n) {
  const qc = new THREE.Vector3(), pc = new THREE.Vector3();
  for (let i = 0; i < n; i++) { qc.add(Q[i]); pc.add(P[i]); }
  qc.divideScalar(n); pc.divideScalar(n);
  let Sxx=0,Sxy=0,Sxz=0,Syx=0,Syy=0,Syz=0,Szx=0,Szy=0,Szz=0, den = 0;
  for (let i = 0; i < n; i++) {
    const qx = Q[i].x-qc.x, qy = Q[i].y-qc.y, qz = Q[i].z-qc.z;
    const px = P[i].x-pc.x, py = P[i].y-pc.y, pz = P[i].z-pc.z;
    Sxx+=qx*px; Sxy+=qx*py; Sxz+=qx*pz; Syx+=qy*px; Syy+=qy*py; Syz+=qy*pz; Szx+=qz*px; Szy+=qz*py; Szz+=qz*pz;
    den += qx*qx + qy*qy + qz*qz;
  }
  const N = [
    [Sxx+Syy+Szz, Syz-Szy, Szx-Sxz, Sxy-Syx],
    [Syz-Szy, Sxx-Syy-Szz, Sxy+Syx, Szx+Sxz],
    [Szx-Sxz, Sxy+Syx, -Sxx+Syy-Szz, Syz+Szy],
    [Sxy-Syx, Szx+Sxz, Syz+Szy, -Sxx-Syy+Szz]];
  const { values, vectors } = jacobi4(N);
  let bi = 0; for (let i = 1; i < 4; i++) if (values[i] > values[bi]) bi = i;
  const quat = new THREE.Quaternion(vectors[1][bi], vectors[2][bi], vectors[3][bi], vectors[0][bi]).normalize();
  let num = 0; const tmp = new THREE.Vector3();
  for (let i = 0; i < n; i++) {
    tmp.copy(Q[i]).sub(qc).applyQuaternion(quat);
    num += tmp.x*(P[i].x-pc.x) + tmp.y*(P[i].y-pc.y) + tmp.z*(P[i].z-pc.z);
  }
  const s = num / den;
  const t = pc.clone().sub(qc.clone().applyQuaternion(quat).multiplyScalar(s));
  let err = 0;
  for (let i = 0; i < n; i++) { tmp.copy(Q[i]).applyQuaternion(quat).multiplyScalar(s).add(t); err += tmp.distanceToSquared(P[i]); }
  const R = new THREE.Matrix3().setFromMatrix4(_m4.makeRotationFromQuaternion(quat));
  return { quat, R, s, t, rms: Math.sqrt(err / n) / s };
}

function baseParams(Q) {
  const width = Math.abs(Q[454].x - Q[234].x);
  const a = width * 0.5 * 1.06;
  const zf = clamp(Q[168].z, 4, 11);
  const yd = clamp(-Q[152].y, 7, 15);
  return { a, zf, zb: clamp(2 * a * 1.24 - zf, 8.5, 11.5), yu: clamp(Q[10].y + 4.2, 8.5, 13), yd, k: 1 };
}
// Schädel als Superellipsoid, ohne Hals: unten geschlossen – vorn bis zum Kinn, unter Kiefer und Schädelbasis
// deutlich höher (rund wie ein Ei, gut aufblasbar), Kiefer etwas schmaler als der Schädel
function baseR(d, P) {
  const az = P.zb + (P.zf - P.zb) * sstep(-0.25, 0.25, d.z);
  const ay = d.y >= 0 ? P.yu : P.yd * (0.62 + 0.38 * sstep(-0.2, 0.45, d.z));
  const ax = P.a * (1 - 0.14 * sstep(0.2, 0.9, -d.y));
  const p = 2 + 0.5 * sstep(0, 0.7, d.y);
  return P.k * Math.pow(Math.abs(d.x / ax) ** p + Math.abs(d.y / ay) ** p + Math.abs(d.z / az) ** p, -1 / p);
}

// neutrales Gesicht als radiale Verschiebung (nur Canonical Head ohne Scan)
function neutralFace(x, yRaw, dz) {
  const fw = sstep(0.3, 0.7, dz);
  if (fw <= 0) return 0;
  // Augen bei yRaw 1.9 (Mitte der Kopfhöhe); unterhalb gestreckt, damit Nase–Mund–Kinn anatomische Drittel bilden
  const y = yRaw > 1.9 ? yRaw - 1.4 : 0.5 + (yRaw - 1.9) * 0.82;
  let h = 0;
  for (const s of [-1, 1]) {
    const r = Math.hypot(x - s * 3.2, (y - 0.5) * 1.3);
    h += -1.2 * gs(r, 1.7) + 0.6 * gs(r, 1.0);                   // Augenhöhle + Augapfel
    h += 0.5 * gs(Math.hypot(x - s * 4.5, (y + 1.0) * 1.2), 1.7); // Wangenknochen
    h += 0.42 * gs(Math.hypot(x - s * 1.3, y + 3.75), 0.62);      // Nasenflügel
    h += -0.25 * gs(Math.hypot(x - s * 2.3, y + 5.6), 0.6);       // Mundwinkel
  }
  h += 0.5 * gs(y - 2.0, 0.8) * gs(x, 4.6);                       // Brauenbogen
  const t = clamp((1.2 - y) / 4.4, 0, 1);
  const prof = y > -3.2 ? 0.22 + 1.1 * Math.pow(t, 1.35) : 1.32 * gs(y + 3.2, 0.75);
  h += prof * gs(x, 0.55 + 0.5 * t) * (1 - sstep(1.0, 2.2, y));    // Nasenrücken
  h += 0.3 * gs(Math.hypot(x * 0.9, y + 3.0), 0.7);               // runde Nasenspitze
  h += 0.5 * gs(y + 5.0, 0.5) * gs(x, 2.1);                       // Oberlippe
  h += 0.45 * gs(y + 6.2, 0.55) * gs(x, 1.9);                     // Unterlippe
  h += -0.25 * gs(y + 5.6, 0.17) * gs(x, 2.3);                    // Mundspalte
  h += -0.3 * gs(y + 7.2, 0.5) * gs(x, 2.4);                      // Kinnfurche
  h += 0.65 * gs(Math.hypot(x, (y + 8.7) * 0.9), 1.6);            // Kinn
  h += 0.5 * gs(Math.hypot(x * 0.55, y + 7.6), 2.0);              // Unterkiefer-Masse
  return h * fw;
}

// grobe Ohren als seitliche Ausstülpung (Helix-Rand, Concha, Tragus)
function earHeight(y, z, E) {
  const c = Math.cos(E.tilt), s = Math.sin(E.tilt);
  const ty = y - E.y, tz = z - E.z;
  const U = (ty * c - tz * s) / (E.h / 2);          // nach oben/hinten
  const B = (-ty * s - tz * c) / (E.w / 2);         // nach hinten
  const bn = B / (1 + 0.18 * U);                    // oben breiter als Ohrläppchen
  const e = Math.hypot(U, bn);
  if (e >= 1) return 0;
  const stand = 1.95 * sstep(-0.95, 0.9, bn);
  const rim = 0.38 * Math.exp(-(((e - 0.83) / 0.09) ** 2)) * sstep(-0.6, 0.3, bn + 0.6 * U);
  const concha = -0.85 * Math.exp(-((U + 0.12) ** 2 / 0.16 + (bn + 0.05) ** 2 / 0.10));
  const tragus = 0.3 * Math.exp(-((U + 0.1) ** 2 / 0.02 + (bn + 0.85) ** 2 / 0.01));
  return Math.max(0, 0.45 + stand + rim + concha + tragus) * (1 - sstep(0.62, 1, e));
}

function earFor(Q) {
  // Ohr reicht etwa von Brauenhöhe bis Nasenboden
  const top = Q ? (Q[70].y + Q[300].y) / 2 : 3.6, bot = Q ? Q[2].y : -3.2;
  const h = clamp((top - bot) * 1.08, 5, 8.5);
  return { ...EAR0, h, y: (top + bot) / 2 + earOff.y, z: EAR0.z + earOff.z };
}
function buildHead(Q) {
  const neutral = !Q;
  const P = neutral ? { ...NEUTRAL } : baseParams(Q);
  const EAR = earFor(Q);
  const W1 = NU + 1, H1 = NV + 1, N = W1 * H1;
  const dirs = new Float32Array(N * 3), base = new Float32Array(N), resid = new Float32Array(N);
  const d = new THREE.Vector3();

  let lmDir, lmR, lmUV, del, res;
  if (!neutral) {
    lmDir = []; lmR = []; lmUV = [];
    for (let i = 0; i < 468; i++) {
      const r = Q[i].length(); const dd = Q[i].clone().divideScalar(r);
      lmDir.push(dd); lmR.push(r); lmUV.push(dirToGrid(dd));
    }
    del = Delaunator.from(lmUV);
    const ratios = Array.from(del.hull).map(i => lmR[i] / baseR(lmDir[i], P)).sort((a, b) => a - b);
    P.k = clamp(ratios[ratios.length >> 1], 0.85, 1.2);
    res = lmR.map((r, i) => r - baseR(lmDir[i], P));
  }
  for (let j = 0; j < H1; j++) for (let i = 0; i < W1; i++) {
    const k = j * W1 + i; gridToDir(i / NU, j / NV, d);
    dirs[k*3] = d.x; dirs[k*3+1] = d.y; dirs[k*3+2] = d.z; base[k] = baseR(d, P);
  }

  if (neutral) {
    for (let k = 0; k < N; k++) resid[k] = neutralFace(dirs[k*3] * base[k], dirs[k*3+1] * base[k], dirs[k*3+2]);
  } else {
    // Gesicht: Delaunay der Landmarks im UV-Raum, baryzentrisch rastern
    const inside = new Uint8Array(N), T = del.triangles;
    for (let t = 0; t < T.length; t += 3) {
      const ia = T[t], ib = T[t+1], ic = T[t+2];
      const [ax, ay] = lmUV[ia], [bx, by] = lmUV[ib], [cx, cy] = lmUV[ic];
      const det = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy);
      if (Math.abs(det) < 1e-12) continue;
      const i0 = Math.max(0, Math.floor(Math.min(ax, bx, cx) * NU)), i1 = Math.min(NU, Math.ceil(Math.max(ax, bx, cx) * NU));
      const j0 = Math.max(0, Math.floor(Math.min(ay, by, cy) * NV)), j1 = Math.min(NV, Math.ceil(Math.max(ay, by, cy) * NV));
      for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) {
        const px = i / NU, py = j / NV;
        const l1 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / det;
        const l2 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / det;
        const l3 = 1 - l1 - l2;
        if (l1 < -1e-6 || l2 < -1e-6 || l3 < -1e-6) continue;
        const k = j * W1 + i; resid[k] = l1 * res[ia] + l2 * res[ib] + l3 * res[ic]; inside[k] = 1;
      }
    }
    // außerhalb: Randwerte weich auslaufen lassen
    const hull = Array.from(del.hull), hd = hull.map(i => lmDir[i]), hr = hull.map(i => res[i]);
    for (let k = 0; k < N; k++) {
      if (inside[k]) continue;
      const x = dirs[k*3], y = dirs[k*3+1], z = dirs[k*3+2];
      let sw = 0, s = 0, dmin = 9;
      for (let h = 0; h < hd.length; h++) {
        const ang = Math.acos(clamp(x * hd[h].x + y * hd[h].y + z * hd[h].z, -1, 1));
        if (ang < dmin) dmin = ang;
        const w = Math.exp(-((ang / 0.15) ** 2)); sw += w; s += w * hr[h];
      }
      resid[k] = sw > 1e-30 ? (s / sw) * Math.exp(-((dmin / 0.4) ** 2)) : 0;
    }
    // glätten (Facetten der Triangulation entfernen)
    const tmp = new Float32Array(N);
    for (let it = 0; it < 6; it++) {
      for (let j = 1; j < NV; j++) for (let i = 0; i < NU; i++) {
        const k = j * W1 + i, l = j * W1 + (i === 0 ? NU - 1 : i - 1), r = j * W1 + i + 1;
        tmp[k] = 0.5 * resid[k] + 0.125 * (resid[l] + resid[r] + resid[k - W1] + resid[k + W1]);
      }
      for (let j = 1; j < NV; j++) { for (let i = 0; i < NU; i++) resid[j*W1+i] = tmp[j*W1+i]; resid[j*W1+NU] = resid[j*W1]; }
    }
  }

  const pos = new Float32Array(N * 3), uvs = new Float32Array(N * 2);
  for (let j = 0; j < H1; j++) for (let i = 0; i < W1; i++) {
    const k = j * W1 + i;
    const r = base[k] + (j === 0 || j === NV ? 0 : clamp(resid[k], -4, 4));
    pos[k*3] = dirs[k*3] * r; pos[k*3+1] = dirs[k*3+1] * r; pos[k*3+2] = dirs[k*3+2] * r;
    uvs[k*2] = i === 0 ? 0 : i === NU ? 1 : dirToUV(d.set(dirs[k*3], dirs[k*3+1], dirs[k*3+2]))[0];
    uvs[k*2+1] = j / NV;                            // vertikal identische Verzerrung (KV)
  }
  // Ohren; aThin = wie dünn das Gewebe ist (für Subsurface Scattering)
  const thin = new Float32Array(N);
  for (let k = 0; k < N; k++) thin[k] = sstep(0.6, 1.7, resid[k]) * sstep(0.5, 0.8, dirs[k*3+2]);
  for (let k = 0; k < N; k++) {
    const x = pos[k*3]; if (Math.abs(x) < 3) continue;
    const h = earHeight(pos[k*3+1], pos[k*3+2], EAR);
    if (h > 0) { pos[k*3] += Math.sign(x) * h; thin[k] = Math.max(thin[k], clamp(h / 2.2, 0, 1)); }
  }

  const idx = new Uint32Array(NU * NV * 6); let o = 0;
  for (let j = 0; j < NV; j++) for (let i = 0; i < NU; i++) {
    const a = j * W1 + i, b = a + 1, c = a + W1, e = c + 1;
    idx[o++] = a; idx[o++] = b; idx[o++] = e; idx[o++] = a; idx[o++] = e; idx[o++] = c;
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
  geo.setAttribute('aThin', new THREE.BufferAttribute(thin, 1));
  geo.setIndex(new THREE.BufferAttribute(idx, 1));
  geo.computeVertexNormals();
  const nr = geo.attributes.normal.array;
  for (let j = 0; j < H1; j++) {
    const a = j * W1, b = j * W1 + NU;
    for (let c = 0; c < 3; c++) { const m = (nr[a*3+c] + nr[b*3+c]) * 0.5; nr[a*3+c] = m; nr[b*3+c] = m; }
    const ln = Math.hypot(nr[a*3], nr[a*3+1], nr[a*3+2]) || 1;
    for (let c = 0; c < 3; c++) { nr[a*3+c] /= ln; nr[b*3+c] = nr[a*3+c]; }
  }
  for (const [jp, jn] of [[0, 1], [NV, NV - 1]]) {        // Pole: Mittel der Nachbarring-Normalen
    let sx = 0, sy = 0, sz = 0;
    for (let i = 0; i < NU; i++) { const t = (jn * W1 + i) * 3; sx += nr[t]; sy += nr[t+1]; sz += nr[t+2]; }
    const l = Math.hypot(sx, sy, sz) || 1;
    for (let i = 0; i < W1; i++) { const t = (jp * W1 + i) * 3; nr[t] = sx / l; nr[t+1] = sy / l; nr[t+2] = sz / l; }
  }
  geo.computeBoundingSphere();
  return { geo, params: P };
}

// ---------- Hüllformen als Signed Distance Functions ----------
function sdCone(x, y, z) {                       // Kegel, Spitze oben
  const h = 1, r1 = 0.95, r2 = 0, qx = Math.hypot(x, z), qy = y;
  const k2x = r2 - r1, k2y = 2 * h;
  const cax = qx - Math.min(qx, qy < 0 ? r1 : r2), cay = Math.abs(qy) - h;
  const t = clamp(((r2 - qx) * k2x + (h - qy) * k2y) / (k2x * k2x + k2y * k2y), 0, 1);
  const cbx = qx - r2 + k2x * t, cby = qy - h + k2y * t;
  return (cbx < 0 && cay < 0 ? -1 : 1) * Math.sqrt(Math.min(cax * cax + cay * cay, cbx * cbx + cby * cby));
}
const SHAPES = {
  capsule: { label: 'Capsule', sdf: (x, y, z) => Math.hypot(x, y - clamp(y, -0.42, 0.42), z) - 0.62 },
  sphere:  { label: 'Kugel',   sdf: (x, y, z) => Math.hypot(x, y, z) - 1 },
  box:     { label: 'Würfel',  sdf: (x, y, z) => {
    const qx = Math.abs(x) - 0.54, qy = Math.abs(y) - 0.92, qz = Math.abs(z) - 0.58;
    return Math.hypot(Math.max(qx, 0), Math.max(qy, 0), Math.max(qz, 0)) + Math.min(Math.max(qx, qy, qz), 0) - 0.08; } },
  prism:   { label: 'Dreieck', sdf: (x, y, z) => Math.max(Math.abs(y) - 1, Math.max(Math.abs(x) * 0.866025 + z * 0.5, -z) - 0.45) },
  cone:    { label: 'Kegel',   sdf: sdCone },
};
const _vol = {};
function unitVolume(name) {                      // Volumen der Einheitsform (Gitterzählung)
  if (_vol[name]) return _vol[name];
  const f = SHAPES[name].sdf, n = 48, h = 2.4 / n; let c = 0;
  for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) for (let k = 0; k < n; k++)
    if (f(-1.2 + (i + .5) * h, -1.2 + (j + .5) * h, -1.2 + (k + .5) * h) <= 0) c++;
  return (_vol[name] = c * h * h * h);
}

// ---------- Inflate: Position Based Dynamics ----------
// Partikel = verschweißte Gittervertices (Naht + Pole zusammengeführt)
class Sim {
  constructor(geo) {
    const pos = geo.attributes.position.array, W1 = NU + 1;
    const n = 2 + (NV - 1) * NU; this.n = n;
    const map = new Uint32Array(W1 * (NV + 1));
    for (let j = 0; j <= NV; j++) for (let i = 0; i <= NU; i++)
      map[j * W1 + i] = j === 0 ? 0 : j === NV ? 1 : 2 + (j - 1) * NU + (i % NU);
    this.map = map;
    const x = new Float32Array(n * 3);
    for (let k = 0; k < map.length; k++) { const q = map[k] * 3; x[q] = pos[k*3]; x[q+1] = pos[k*3+1]; x[q+2] = pos[k*3+2]; }
    this.x = x; this.p = x.slice(); this.x0 = x.slice();
    const idx = geo.index.array, tris = [];
    for (let t = 0; t < idx.length; t += 3) {
      const a = map[idx[t]], b = map[idx[t+1]], c = map[idx[t+2]];
      if (a !== b && b !== c && a !== c) tris.push(a, b, c);
    }
    this.tri = new Uint32Array(tris);
    const mk = pairs => {
      const seen = new Set(), out = [];
      for (let q = 0; q < pairs.length; q += 2) {
        const a = pairs[q], b = pairs[q+1]; if (a === b) continue;
        const key = a < b ? a * n + b : b * n + a; if (seen.has(key)) continue;
        seen.add(key); out.push(a, b);
      }
      const e = new Uint32Array(out), r = new Float32Array(out.length / 2);
      for (let q = 0; q < r.length; q++) {
        const a = e[q*2] * 3, b = e[q*2+1] * 3;
        r[q] = Math.hypot(x[b] - x[a], x[b+1] - x[a+1], x[b+2] - x[a+2]);
      }
      return { e, r };
    };
    const ep = []; for (let t = 0; t < tris.length; t += 3) ep.push(tris[t], tris[t+1], tris[t+1], tris[t+2], tris[t+2], tris[t]);
    this.edges = mk(ep);
    const bp = [];
    for (let j = 0; j <= NV; j++) for (let i = 0; i < NU; i++) {
      const a = map[j * W1 + i];
      if (j > 0 && j < NV) bp.push(a, map[j * W1 + (i + 2) % NU]);
      if (j + 2 <= NV) bp.push(a, map[(j + 2) * W1 + i]);
    }
    this.bends = mk(bp);
    this.grad = new Float32Array(n * 3); this.nrm = new Float32Array(n * 3);
    this.V0 = this.volume();
    // Mittelpunkt der Hülle = Bounding-Box-Mitte des Ruhezustands
    let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9, z0 = 1e9, z1 = -1e9;
    for (let i = 0; i < n; i++) {
      x0 = Math.min(x0, x[i*3]); x1 = Math.max(x1, x[i*3]); y0 = Math.min(y0, x[i*3+1]); y1 = Math.max(y1, x[i*3+1]);
      z0 = Math.min(z0, x[i*3+2]); z1 = Math.max(z1, x[i*3+2]);
    }
    this.c = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, cz: (z0 + z1) / 2 };
    this.kCache = {};
    this.initRest();
    this.grab = null;
  }
  volume() {
    const x = this.x, T = this.tri; let V = 0;
    for (let t = 0; t < T.length; t += 3) {
      const a = T[t]*3, b = T[t+1]*3, c = T[t+2]*3;
      V += x[a] * (x[b+1]*x[c+2] - x[b+2]*x[c+1]) + x[a+1] * (x[b+2]*x[c] - x[b]*x[c+2]) + x[a+2] * (x[b]*x[c+1] - x[b+1]*x[c]);
    }
    return V / 6;
  }
  dist(C, g, k) {
    const x = this.x, e = C.e, r = C.r;
    for (let q = 0; q < r.length; q++) {
      const a = e[q*2] * 3, b = e[q*2+1] * 3;
      const dx = x[b] - x[a], dy = x[b+1] - x[a+1], dz = x[b+2] - x[a+2];
      const d = Math.sqrt(dx*dx + dy*dy + dz*dz); if (d < 1e-9) continue;
      const s = (d - r[q] * g) / d * 0.5 * k;
      x[a] += dx*s; x[a+1] += dy*s; x[a+2] += dz*s; x[b] -= dx*s; x[b+1] -= dy*s; x[b+2] -= dz*s;
    }
  }
  pressure(Vt, k) {
    const x = this.x, T = this.tri, G = this.grad; G.fill(0); let V = 0;
    for (let t = 0; t < T.length; t += 3) {
      const a = T[t]*3, b = T[t+1]*3, c = T[t+2]*3;
      const bcx = x[b+1]*x[c+2] - x[b+2]*x[c+1], bcy = x[b+2]*x[c] - x[b]*x[c+2], bcz = x[b]*x[c+1] - x[b+1]*x[c];
      const cax = x[c+1]*x[a+2] - x[c+2]*x[a+1], cay = x[c+2]*x[a] - x[c]*x[a+2], caz = x[c]*x[a+1] - x[c+1]*x[a];
      const abx = x[a+1]*x[b+2] - x[a+2]*x[b+1], aby = x[a+2]*x[b] - x[a]*x[b+2], abz = x[a]*x[b+1] - x[a+1]*x[b];
      V += x[a]*bcx + x[a+1]*bcy + x[a+2]*bcz;
      G[a] += bcx; G[a+1] += bcy; G[a+2] += bcz; G[b] += cax; G[b+1] += cay; G[b+2] += caz; G[c] += abx; G[c+1] += aby; G[c+2] += abz;
    }
    V /= 6; let g2 = 0;
    for (let i = 0; i < G.length; i++) { G[i] /= 6; g2 += G[i] * G[i]; }
    if (g2 < 1e-12) return V;
    const lam = (Vt - V) / g2 * k;
    for (let i = 0; i < G.length; i++) x[i] += lam * G[i];
    return V;
  }
  // Ruhedaten: Nachbarschaft (CSR), Ruhe-Laplace entlang der Normalen, Ruherichtung vom Zentrum
  initRest() {
    const n = this.n, e = this.edges.e, x0 = this.x0, c = this.c;
    const deg = new Uint32Array(n + 1);
    for (let q = 0; q < e.length; q++) deg[e[q] + 1]++;
    for (let i = 0; i < n; i++) deg[i + 1] += deg[i];
    const adj = new Uint32Array(e.length), fill = deg.slice(0, n);
    for (let q = 0; q < e.length; q += 2) { adj[fill[e[q]]++] = e[q+1]; adj[fill[e[q+1]]++] = e[q]; }
    this.adjS = deg; this.adj = adj;
    // Ruhenormalen
    const N = this.vertexNormals(x0);
    this.lap0 = new Float32Array(n);           // vorzeichenbehaftete Krümmung (Abstand zum Nachbarschaftsmittel)
    for (let i = 0; i < n; i++) {
      let ax = 0, ay = 0, az = 0; const a = deg[i], b = deg[i + 1];
      for (let k = a; k < b; k++) { const j = adj[k] * 3; ax += x0[j]; ay += x0[j+1]; az += x0[j+2]; }
      const m = 1 / Math.max(1, b - a), o = i * 3;
      this.lap0[i] = (x0[o] - ax * m) * N[o] + (x0[o+1] - ay * m) * N[o+1] + (x0[o+2] - az * m) * N[o+2];
    }
    this.dir0 = new Float32Array(n * 3);
    for (let i = 0; i < n; i++) {
      const o = i * 3, dx = x0[o] - c.cx, dy = x0[o+1] - c.cy, dz = x0[o+2] - c.cz, l = Math.hypot(dx, dy, dz) || 1;
      this.dir0[o] = dx / l; this.dir0[o+1] = dy / l; this.dir0[o+2] = dz / l;
    }
    let e0 = 0; for (let q = 0; q < this.edges.r.length; q++) e0 += this.edges.r[q];
    this.e0 = e0 / this.edges.r.length;
    // Spatial Hash für Selbstkollision
    this.hSize = 1; while (this.hSize < n * 2) this.hSize <<= 1;
    this.hCount = new Uint32Array(this.hSize + 1); this.hItems = new Uint32Array(n); this.hOf = new Uint32Array(n);
    this.hFill = new Uint32Array(this.hSize); this.hStamp = new Int32Array(this.hSize).fill(-1);
  }
  vertexNormals(x) {
    const T = this.tri, N = this.nrm; N.fill(0);
    for (let t = 0; t < T.length; t += 3) {
      const a = T[t]*3, b = T[t+1]*3, c = T[t+2]*3;
      const ux = x[b] - x[a], uy = x[b+1] - x[a+1], uz = x[b+2] - x[a+2], vx = x[c] - x[a], vy = x[c+1] - x[a+1], vz = x[c+2] - x[a+2];
      const nx = uy*vz - uz*vy, ny = uz*vx - ux*vz, nz = ux*vy - uy*vx;
      N[a] += nx; N[a+1] += ny; N[a+2] += nz; N[b] += nx; N[b+1] += ny; N[b+2] += nz; N[c] += nx; N[c+1] += ny; N[c+2] += nz;
    }
    for (let i = 0; i < N.length; i += 3) { const l = Math.hypot(N[i], N[i+1], N[i+2]) || 1; N[i] /= l; N[i+1] /= l; N[i+2] /= l; }
    return N;
  }
  // 1) Krümmungserhaltung: jeder Punkt will in Ruhekrümmung über dem Mittel seiner Nachbarn liegen.
  //    Glättet Knitter-Spitzen, lässt weiche Falten zu und verhindert, dass Flächen umklappen.
  smooth(k, g) {
    const x = this.x, N = this.vertexNormals(x), adj = this.adj, S = this.adjS, L = this.lap0;
    for (let i = 0; i < this.n; i++) {
      let ax = 0, ay = 0, az = 0; const a = S[i], b = S[i + 1];
      for (let q = a; q < b; q++) { const j = adj[q] * 3; ax += x[j]; ay += x[j+1]; az += x[j+2]; }
      const m = 1 / Math.max(1, b - a), o = i * 3, l = L[i] * g;
      x[o]   += (ax * m + N[o]   * l - x[o])   * k;
      x[o+1] += (ay * m + N[o+1] * l - x[o+1]) * k;
      x[o+2] += (az * m + N[o+2] * l - x[o+2]) * k;
    }
  }
  // 2) Selbstkollision: Punkte mit Mindestabstand, Nachbarn auf der Fläche ausgenommen
  selfCollide(minD, excl) {
    const x = this.x, x0 = this.x0, n = this.n, h = minD, inv = 1 / h, M = this.hSize - 1;
    const cnt = this.hCount, items = this.hItems, of = this.hOf, stamp = this.hStamp;
    const hash = (a, b, c) => {
      let h = Math.imul(a, 73856093) ^ Math.imul(b, 19349663) ^ Math.imul(c, 83492791);
      h ^= h >>> 13; h = Math.imul(h, 0x5bd1e995); h ^= h >>> 15; return h & M;
    };
    cnt.fill(0); stamp.fill(-1);
    for (let i = 0; i < n; i++) {
      const o = i * 3, k = hash(Math.floor(x[o] * inv), Math.floor(x[o+1] * inv), Math.floor(x[o+2] * inv));
      of[i] = k; cnt[k + 1]++;
    }
    for (let k = 0; k < this.hSize; k++) cnt[k + 1] += cnt[k];
    const fill = this.hFill; fill.set(cnt.subarray(0, this.hSize));
    for (let i = 0; i < n; i++) items[fill[of[i]]++] = i;
    const d2 = minD * minD, ex2 = excl * excl;
    for (let i = 0; i < n; i++) {
      const o = i * 3, cx = Math.floor(x[o] * inv), cy = Math.floor(x[o+1] * inv), cz = Math.floor(x[o+2] * inv);
      for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) for (let c = -1; c <= 1; c++) {
        const k = hash(cx + a, cy + b, cz + c);
        if (stamp[k] === i) continue; stamp[k] = i;          // Bucket schon besucht (Hash-Kollision)
        for (let q = cnt[k]; q < cnt[k + 1]; q++) {
          const j = items[q]; if (j <= i) continue;
          const p = j * 3, dx = x[p] - x[o], dy = x[p+1] - x[o+1], dz = x[p+2] - x[o+2], dd = dx*dx + dy*dy + dz*dz;
          if (dd >= d2 || dd < 1e-12) continue;
          const rx = x0[p] - x0[o], ry = x0[p+1] - x0[o+1], rz = x0[p+2] - x0[o+2];
          if (rx*rx + ry*ry + rz*rz < ex2) continue;          // Flächennachbarn
          const d = Math.sqrt(dd), s = (minD - d) / d * 0.5;
          x[o] -= dx * s; x[o+1] -= dy * s; x[o+2] -= dz * s; x[p] += dx * s; x[p+1] += dy * s; x[p+2] += dz * s;
        }
      }
    }
  }
  // 3) Tangentiale Bindung: jeder Punkt darf nur begrenzt von seiner Ruherichtung (vom Zentrum aus) wegwandern.
  //    Radial (aufblasen, eindrücken) frei – verhindert Verdrehen und Zusammenrutschen am Hals.
  tether(maxAng, k) {
    if (maxAng >= Math.PI) return;
    const x = this.x, D = this.dir0, c = this.c, sm = Math.sin(maxAng), cm = Math.cos(maxAng);
    for (let i = 0; i < this.n; i++) {
      const o = i * 3, ux = x[o] - c.cx, uy = x[o+1] - c.cy, uz = x[o+2] - c.cz, r = Math.hypot(ux, uy, uz); if (r < 1e-6) continue;
      const dx = ux / r, dy = uy / r, dz = uz / r, cs = dx * D[o] + dy * D[o+1] + dz * D[o+2];
      if (cs >= cm) continue;
      let tx = dx - D[o] * cs, ty = dy - D[o+1] * cs, tz = dz - D[o+2] * cs; const tl = Math.hypot(tx, ty, tz) || 1;
      tx /= tl; ty /= tl; tz /= tl;
      const nx = D[o] * cm + tx * sm, ny = D[o+1] * cm + ty * sm, nz = D[o+2] * cm + tz * sm;
      x[o] += (c.cx + nx * r - x[o]) * k; x[o+1] += (c.cy + ny * r - x[o+1]) * k; x[o+2] += (c.cz + nz * r - x[o+2]) * k;
    }
  }
  // 4) Gesamtrotation aus der Geschwindigkeit entfernen (kein Eindrehen des ganzen Kopfes)
  removeSpin() {
    const x = this.x, p = this.p, n = this.n;
    let cx = 0, cy = 0, cz = 0; for (let i = 0; i < n; i++) { cx += x[i*3]; cy += x[i*3+1]; cz += x[i*3+2]; }
    cx /= n; cy /= n; cz /= n;
    let Lx = 0, Ly = 0, Lz = 0, Ixx = 0, Iyy = 0, Izz = 0, Ixy = 0, Ixz = 0, Iyz = 0;
    for (let i = 0; i < n; i++) {
      const o = i * 3, rx = x[o] - cx, ry = x[o+1] - cy, rz = x[o+2] - cz, vx = x[o] - p[o], vy = x[o+1] - p[o+1], vz = x[o+2] - p[o+2];
      Lx += ry * vz - rz * vy; Ly += rz * vx - rx * vz; Lz += rx * vy - ry * vx;
      Ixx += ry*ry + rz*rz; Iyy += rx*rx + rz*rz; Izz += rx*rx + ry*ry; Ixy -= rx*ry; Ixz -= rx*rz; Iyz -= ry*rz;
    }
    const I = new THREE.Matrix3().set(Ixx, Ixy, Ixz, Ixy, Iyy, Iyz, Ixz, Iyz, Izz);
    if (Math.abs(I.determinant()) < 1e-9) return;
    const w = new THREE.Vector3(Lx, Ly, Lz).applyMatrix3(I.invert());
    for (let i = 0; i < n; i++) {
      const o = i * 3, rx = x[o] - cx, ry = x[o+1] - cy, rz = x[o+2] - cz;
      p[o] += w.y * rz - w.z * ry; p[o+1] += w.z * rx - w.x * rz; p[o+2] += w.x * ry - w.y * rx;
    }
  }

  // kleinster Maßstab, bei dem die Form alle Punkte in arr umschließt
  enclose(name, arr) {
    const f = SHAPES[name].sdf, c = this.c;
    let lo = 0.5, hi = 120;
    for (let it = 0; it < 36; it++) {
      const k = (lo + hi) / 2; let out = false;
      for (let i = 0; i < this.n && !out; i++) {
        const o = i * 3; if (k * f((arr[o] - c.cx) / k, (arr[o+1] - c.cy) / k, (arr[o+2] - c.cz) / k) > -0.15) out = true;
      }
      if (out) lo = k; else hi = k;
    }
    return hi;
  }
  // Zielgröße: Capsule umschließt den Ruhezustand; andere Formen haben dasselbe Volumen (gleich „eng“)
  targetK(name) {
    if (this.kCache[name]) return this.kCache[name];
    const kc = this.kCache.capsule || (this.kCache.capsule = this.enclose('capsule', this.x0));
    return (this.kCache[name] = kc * Math.cbrt(unitVolume('capsule') / unitVolume(name)));
  }
  contain(name, s, fr) {
    const f = SHAPES[name].sdf, k = this.curK, c = this.c, x = this.x, p = this.p, e = 0.02 / k;
    for (let i = 0; i < this.n; i++) {
      const o = i * 3, ux = (x[o] - c.cx) / k, uy = (x[o+1] - c.cy) / k, uz = (x[o+2] - c.cz) / k;
      const d = f(ux, uy, uz); if (d <= 0) continue;
      const gx = f(ux + e, uy, uz) - f(ux - e, uy, uz), gy = f(ux, uy + e, uz) - f(ux, uy - e, uz), gz = f(ux, uy, uz + e) - f(ux, uy, uz - e);
      const gl = Math.hypot(gx, gy, gz) || 1, m = d * k / gl;
      x[o] -= gx * m; x[o+1] -= gy * m; x[o+2] -= gz * m;
      p[o] += (x[o] - p[o]) * fr; p[o+1] += (x[o+1] - p[o+1]) * fr; p[o+2] += (x[o+2] - p[o+2]) * fr;
    }
  }
  meanStretch(g) {
    const x = this.x, e = this.edges.e, r = this.edges.r; let s = 0;
    for (let q = 0; q < r.length; q += 3) {
      const a = e[q*2] * 3, b = e[q*2+1] * 3;
      s += Math.hypot(x[b] - x[a], x[b+1] - x[a+1], x[b+2] - x[a+2]) / (r[q] * g);
    }
    return s / Math.ceil(r.length / 3);
  }
  startGrab(pt, radius) {
    const x = this.x, list = [];
    for (let i = 0; i < this.n; i++) {
      const o = i * 3, d = Math.hypot(x[o] - pt.x, x[o+1] - pt.y, x[o+2] - pt.z);
      if (d < radius) list.push({ i, ox: x[o] - pt.x, oy: x[o+1] - pt.y, oz: x[o+2] - pt.z, w: Math.pow(1 - d / radius, 1.5) });
    }
    this.grab = { list, tx: pt.x, ty: pt.y, tz: pt.z };
  }
  applyGrab() {
    const G = this.grab; if (!G) return;
    const x = this.x;
    for (const g of G.list) {
      const o = g.i * 3;
      x[o] += (G.tx + g.ox - x[o]) * g.w; x[o+1] += (G.ty + g.oy - x[o+1]) * g.w; x[o+2] += (G.tz + g.oz - x[o+2]) * g.w;
    }
  }
  step(P) {
    const x = this.x, p = this.p, damp = 0.985;
    for (let i = 0; i < x.length; i++) { const v = (x[i] - p[i]) * damp; p[i] = x[i]; x[i] += v; }
    // Druck sanft (Überdruck gegen fast undehnbare Haut), Längen zuletzt -> Haut staucht sich zu Falten
    // Regler: Druck nimmt ab, sobald die Haut im Mittel mehr als 2 % gedehnt ist
    const ms = this.meanStretch(P.growth);
    const Vt = this.V0 * (1 + 2.5 * P.pressure), kP = (0.004 + 0.03 * P.pressure) * clamp(1 - (ms - 1.0) / 0.02, 0, 1);
    // Hülle: beim Start/Formwechsel so groß, dass sie alles umschließt, dann weich auf Zielgröße zusammenziehen
    if (P.shape !== this.shape) { this.shape = P.shape; this.curK = Math.max(this.enclose(P.shape, this.x), this.targetK(P.shape) * P.cap); }
    this.curK += (this.targetK(P.shape) * P.cap - this.curK) * 0.03;
    const minD = this.e0 * P.growth * 0.9, excl = this.e0 * 2.6;
    const maxAng = P.hold > 0 ? THREE.MathUtils.lerp(1.4, 0.06, P.hold) : Math.PI;
    for (let it = 0; it < P.iters; it++) {
      this.pressure(Vt, kP);
      this.applyGrab();
      this.contain(P.shape, P.cap, 0.4);
      if (it % 2 === 0) this.dist(this.bends, P.growth, P.bend);
      if (P.smooth > 0 && it % 2 === 1) this.smooth(P.smooth * 0.12, P.growth);
      this.dist(this.edges, P.growth, 1.0);
      if (P.selfCol && it === P.iters - 2) this.selfCollide(minD, excl);
    }
    this.tether(maxAng, 0.25);
    if (!this.grab) this.removeSpin();
  }
  writeTo(geo) {
    const x = this.x, T = this.tri, N = this.nrm; N.fill(0);
    for (let t = 0; t < T.length; t += 3) {
      const a = T[t]*3, b = T[t+1]*3, c = T[t+2]*3;
      const ux = x[b] - x[a], uy = x[b+1] - x[a+1], uz = x[b+2] - x[a+2], vx = x[c] - x[a], vy = x[c+1] - x[a+1], vz = x[c+2] - x[a+2];
      const nx = uy*vz - uz*vy, ny = uz*vx - ux*vz, nz = ux*vy - uy*vx;
      N[a] += nx; N[a+1] += ny; N[a+2] += nz; N[b] += nx; N[b+1] += ny; N[b+2] += nz; N[c] += nx; N[c+1] += ny; N[c+2] += nz;
    }
    const pos = geo.attributes.position.array, nr = geo.attributes.normal.array, map = this.map;
    for (let k = 0; k < map.length; k++) {
      const q = map[k] * 3, l = Math.hypot(N[q], N[q+1], N[q+2]) || 1;
      pos[k*3] = x[q]; pos[k*3+1] = x[q+1]; pos[k*3+2] = x[q+2];
      nr[k*3] = N[q] / l; nr[k*3+1] = N[q+1] / l; nr[k*3+2] = N[q+2] / l;
    }
    geo.attributes.position.needsUpdate = true; geo.attributes.normal.needsUpdate = true;
  }
}
// ==END MATH==

// ---------- DOM ----------
const $ = id => document.getElementById(id);
const video = $('video'), overlay = $('overlay'), octx = overlay.getContext('2d');
const camWrap = $('camWrap'), viewWrap = $('viewWrap'), promptEl = $('prompt'), guideEl = $('guide');
const proc = document.createElement('canvas'), pctx = proc.getContext('2d');
const small = document.createElement('canvas'), sctx = small.getContext('2d', { willReadFrequently: true });
const setStatus = t => $('status').textContent = t;
const setPrompt = t => { promptEl.style.display = t ? 'block' : 'none'; promptEl.textContent = t; };
const nextFrame = () => new Promise(r => requestAnimationFrame(r));
const deg = THREE.MathUtils.radToDeg;

let landmarker, landmarkerImg, segmenterImg, stream, imageCapture = null, facing = 'user', live = false;
let W = 0, H = 0;

// ---------- Three ----------
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
viewWrap.prepend(renderer.domElement);
const scene = new THREE.Scene(); scene.background = new THREE.Color(0xffffff);
const camera = new THREE.PerspectiveCamera(30, 1, 1, 1000); camera.position.set(0, 2, 70);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, -1, 0); controls.enableDamping = true; controls.autoRotate = true; controls.autoRotateSpeed = 1.2;
renderer.domElement.addEventListener('pointerdown', () => controls.autoRotate = false);
scene.add(new THREE.HemisphereLight(0xffffff, 0xe4e4e4, 1.2));
const dl = new THREE.DirectionalLight(0xffffff, 1.6); scene.add(dl);           // Hauptlicht (Richtung einstellbar)
const rim = new THREE.DirectionalLight(0xfff4ec, 1.4); rim.position.set(-20, 25, -45); scene.add(rim);   // Gegenlicht für SSS
const setLightAngle = deg => { const a = THREE.MathUtils.degToRad(deg); dl.position.set(Math.sin(a) * 55, 35, Math.cos(a) * 55); };
setLightAngle(40);
const ground = new THREE.Mesh(new THREE.RingGeometry(13.6, 13.7, 128), new THREE.MeshBasicMaterial({ color: 0xdddddd }));
ground.rotation.x = -Math.PI / 2; ground.position.y = -16; scene.add(ground);

const mkRT = (w, h, o = {}) => new THREE.WebGLRenderTarget(w, h, Object.assign({
  type: THREE.HalfFloatType, format: THREE.RGBAFormat, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter,
  depthBuffer: false, generateMipmaps: false }, o));
const lin = { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, wrapS: THREE.RepeatWrapping };
const layers = Array.from({ length: 5 }, () => mkRT(TEX, TEX));     // je Foto: rgb + Gewicht
const blendRT = mkRT(TEX, TEX);
const low = [mkRT(RATIO, RATIO), mkRT(RATIO, RATIO)];
const ratioPull = [], ratioFin = [null, [], [], [], []];
for (let s = RATIO; s >= 1; s >>= 1) ratioPull.push(mkRT(s, s));
for (let i = 1; i < 5; i++) for (let s = RATIO; s >= 1; s >>= 1) ratioFin[i].push(mkRT(s, s, lin));
const pull = [], fin = [];
for (let s = TEX; s >= 1; s >>= 1) { pull.push(mkRT(s, s)); fin.push(mkRT(s, s, lin)); }
const comp = mkRT(TEX, TEX, { ...lin, minFilter: THREE.LinearMipmapLinearFilter, generateMipmaps: true });
comp.texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
const patchRT = mkRT(512, 512, { minFilter: THREE.LinearMipmapLinearFilter, magFilter: THREE.LinearFilter,
  wrapS: THREE.MirroredRepeatWrapping, wrapT: THREE.MirroredRepeatWrapping, generateMipmaps: true });
const probeRT = mkRT(8, 1, { type: THREE.UnsignedByteType });
const exportRT = mkRT(TEX, TEX, { type: THREE.UnsignedByteType });
const normalRT = mkRT(TEX, TEX, { type: THREE.UnsignedByteType, minFilter: THREE.LinearMipmapLinearFilter, magFilter: THREE.LinearFilter,
  wrapS: THREE.RepeatWrapping, generateMipmaps: true });
const depthRTs = new Map();
const getDepthRT = (w, h) => {
  const key = Math.round(512 * h / w);
  if (!depthRTs.has(key)) depthRTs.set(key, mkRT(512, key, { depthBuffer: true }));
  return depthRTs.get(key);
};

const fsScene = new THREE.Scene(), fsCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const fsQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2)); fsQuad.frustumCulled = false; fsScene.add(fsQuad);
const FSV = `varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position.xy,0.,1.); }`;
const fsMat = (frag, uniforms) => new THREE.ShaderMaterial({ vertexShader: FSV, fragmentShader: frag, uniforms, depthTest: false, depthWrite: false });
const run = (mat, rt) => { fsQuad.material = mat; renderer.setRenderTarget(rt); renderer.render(fsScene, fsCam); };

const skin = new THREE.Vector3(0.55, 0.38, 0.30), ONE = new THREE.Vector3(1, 1, 1);
const copyMat = fsMat(`uniform sampler2D tSrc; void main(){ gl_FragColor=texelFetch(tSrc,ivec2(gl_FragCoord.xy),0); }`, { tSrc: { value: null } });
// Blockmittel (gewichtet) für die niederfrequente Farbangleichung
const boxMat = fsMat(`uniform sampler2D tSrc; uniform int uN;
void main(){ ivec2 b=ivec2(gl_FragCoord.xy)*uN; int st=max(1,uN/8); vec3 s=vec3(0.); float sa=0., n=0.;
  for(int y=0;y<uN;y+=st) for(int x=0;x<uN;x+=st){ vec4 t=texelFetch(tSrc,b+ivec2(x,y),0); s+=t.rgb*t.a; sa+=t.a; n+=1.; }
  gl_FragColor=vec4(s/max(sa,1e-5), sa/n); }`, { tSrc: { value: null }, uN: { value: TEX / RATIO } });
const ratioMat = fsMat(`uniform sampler2D tA, tB;
void main(){ ivec2 p=ivec2(gl_FragCoord.xy); vec4 a=texelFetch(tA,p,0), b=texelFetch(tB,p,0);
  float c=smoothstep(.15,.45,a.a)*smoothstep(.15,.45,b.a);
  gl_FragColor=vec4(clamp(a.rgb/max(b.rgb,vec3(1e-3)),.4,2.5), c); }`, { tA: { value: null }, tB: { value: null } });
const pullMat = fsMat(`uniform sampler2D tSrc;
void main(){ ivec2 p=ivec2(gl_FragCoord.xy)*2;
  vec4 a=texelFetch(tSrc,p,0), b=texelFetch(tSrc,p+ivec2(1,0),0), c=texelFetch(tSrc,p+ivec2(0,1),0), d=texelFetch(tSrc,p+ivec2(1,1),0);
  float s=a.a+b.a+c.a+d.a;
  vec3 col=(a.rgb*a.a+b.rgb*b.a+c.rgb*c.a+d.rgb*d.a)/max(s,1e-6);
  gl_FragColor=vec4(col,min(1.,s)); }`, { tSrc: { value: null } });
const makePush = top => fsMat(`uniform sampler2D tPull, tCoarse; uniform float uTop, uConf, uSize; uniform vec3 uTopC;
void main(){ vec4 o=texelFetch(tPull,ivec2(gl_FragCoord.xy),0);
  vec3 coarse = uTop>0.5 ? uTopC : texture(tCoarse, gl_FragCoord.xy/uSize).rgb;
  gl_FragColor=vec4(mix(coarse,o.rgb,o.a*uConf),1.); }`,
  { tPull: { value: null }, tCoarse: { value: null }, uTop: { value: 0 }, uConf: { value: 1 }, uSize: { value: 1 }, uTopC: { value: top } });
const pushFill = makePush(skin), pushRatio = makePush(ONE);
// Überblendung aller Ansichten; Seitenansichten werden per Ratio-Map an die Front angeglichen
const blendMat = fsMat(`uniform sampler2D tL0,tL1,tL2,tL3,tL4,tR1,tR2,tR3,tR4; uniform float uFront, uSize;
void main(){ ivec2 p=ivec2(gl_FragCoord.xy); vec2 uv=gl_FragCoord.xy/uSize;
  vec4 l0=texelFetch(tL0,p,0), l1=texelFetch(tL1,p,0), l2=texelFetch(tL2,p,0), l3=texelFetch(tL3,p,0), l4=texelFetch(tL4,p,0);
  float w0=l0.a*uFront;
  vec3 c = l0.rgb*w0 + l1.rgb*texture(tR1,uv).rgb*l1.a + l2.rgb*texture(tR2,uv).rgb*l2.a
         + l3.rgb*texture(tR3,uv).rgb*l3.a + l4.rgb*texture(tR4,uv).rgb*l4.a;
  float sw=w0+l1.a+l2.a+l3.a+l4.a;
  gl_FragColor=vec4(sw>1e-5 ? c/sw : vec3(0.), clamp(sw,0.,1.)); }`,
  { tL0: { value: layers[0].texture }, tL1: { value: layers[1].texture }, tL2: { value: layers[2].texture },
    tL3: { value: layers[3].texture }, tL4: { value: layers[4].texture },
    tR1: { value: ratioFin[1][0].texture }, tR2: { value: ratioFin[2][0].texture }, tR3: { value: ratioFin[3][0].texture },
    tR4: { value: ratioFin[4][0].texture }, uFront: { value: 4 }, uSize: { value: TEX } });
const initMat = fsMat(`uniform sampler2D tSrc; uniform float uThr;
void main(){ vec4 a=texelFetch(tSrc,ivec2(gl_FragCoord.xy),0); gl_FragColor=vec4(a.rgb, smoothstep(0.,uThr,a.a)); }`,
  { tSrc: { value: blendRT.texture }, uThr: { value: 0.3 } });
// Hautdetail: Hochpass eines gescannten Patches, triplanar auf ungescannte Flächen
const compMat = new THREE.ShaderMaterial({ side: THREE.DoubleSide, depthTest: false, depthWrite: false,
  uniforms: { tFin: { value: fin[0].texture }, tCov: { value: pull[0].texture }, tPatch: { value: patchRT.texture },
    uAmt: { value: 1.0 }, uHas: { value: 0 }, uScale: { value: 2.5 } },
  vertexShader: `varying vec2 vUv; varying vec3 vP; varying vec3 vN;
void main(){ vUv=uv; vP=position; vN=normal; gl_Position=vec4(uv*2.-1.,0.,1.); }`,
  fragmentShader: `uniform sampler2D tFin, tCov, tPatch; uniform float uAmt, uHas, uScale;
varying vec2 vUv; varying vec3 vP; varying vec3 vN;
vec3 det(vec2 c){ vec3 hi=texture(tPatch,c).rgb; vec3 lo=textureLod(tPatch,c,7.5).rgb; return clamp(hi/max(lo,vec3(1e-3)),.55,1.6); }
void main(){
  vec3 fill=texture(tFin,vUv).rgb; float conf=texture(tCov,vUv).a;
  vec3 w=pow(abs(normalize(vN)),vec3(4.)); w/=(w.x+w.y+w.z);
  vec3 p=vP/uScale;
  vec3 r=det(p.zy+vec2(.37,.11))*w.x + det(p.xz+vec2(.73,.29))*w.y + det(p.xy)*w.z;
  gl_FragColor=vec4(fill*mix(vec3(1.),r,uAmt*uHas*(1.-conf)),1.);
}` });
const compScene = new THREE.Scene();
const probeMat = fsMat(`uniform sampler2D tCov; uniform vec2 uP[8];
void main(){ int i=int(gl_FragCoord.x); float c=texture(tCov,uP[i]).a; gl_FragColor=vec4(c,c,c,1.); }`,
  { tCov: { value: pull[0].texture }, uP: { value: Array.from({ length: 8 }, () => new THREE.Vector2()) } });
const extractMat = fsMat(`uniform sampler2D tSrc; uniform vec2 uMin, uMax; varying vec2 vUv;
void main(){ gl_FragColor=vec4(texture(tSrc, mix(uMin,uMax,vUv)).rgb,1.); }`,
  { tSrc: { value: fin[0].texture }, uMin: { value: new THREE.Vector2() }, uMax: { value: new THREE.Vector2() } });
const exportMat = fsMat(`uniform sampler2D tFin;
vec3 toS(vec3 c){ c=clamp(c,0.,1.); return mix(c*12.92, 1.055*pow(c,vec3(1./2.4))-0.055, step(vec3(0.0031308),c)); }
void main(){ gl_FragColor=vec4(toS(texelFetch(tFin,ivec2(gl_FragCoord.xy),0).rgb),1.); }`, { tFin: { value: comp.texture } });
const insetMat = fsMat(`uniform sampler2D tFin, tCov; varying vec2 vUv;
void main(){ vec3 c=texture(tFin,vUv).rgb; float a=texture(tCov,vUv).a;
  gl_FragColor=vec4(mix(mix(c,vec3(1.),.45),c,a),1.);
  #include <colorspace_fragment>
}`, { tFin: { value: comp.texture }, tCov: { value: pull[0].texture } });
const insetScene = new THREE.Scene(); const insetQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), insetMat);
insetQuad.frustumCulled = false; insetScene.add(insetQuad);

// Normal Map aus der Hauttextur: Hochpass der Helligkeit als Höhe (dunkle Poren = Vertiefung)
const normalMat = fsMat(`uniform sampler2D tC; uniform float uSize, uFine, uMid;
float L(vec2 uv, float lod){ return dot(textureLod(tC,uv,lod).rgb, vec3(.3,.59,.11)); }
void main(){ vec2 uv=gl_FragCoord.xy/uSize, px=vec2(1./uSize,0.), py=vec2(0.,1./uSize);
  // fein: Poren, Hautstruktur (1–8 px)
  float fx = (L(uv+px,0.)-L(uv-px,0.)) - (L(uv+px*8.,3.)-L(uv-px*8.,3.))/8.;
  float fy = (L(uv+py,0.)-L(uv-py,0.)) - (L(uv+py*8.,3.)-L(uv-py*8.,3.))/8.;
  // mittel: Helligkeitskanten wie Brauen, Lidfalte, Lippenlinie, Falten (6–40 px); dunkel = Vertiefung
  float mx = ((L(uv+px*6.,2.5)-L(uv-px*6.,2.5)) - (L(uv+px*40.,5.3)-L(uv-px*40.,5.3))*6./40.) / 6.;
  float my = ((L(uv+py*6.,2.5)-L(uv-py*6.,2.5)) - (L(uv+py*40.,5.3)-L(uv-py*40.,5.3))*6./40.) / 6.;
  vec3 n = normalize(vec3(-(fx*uFine + mx*uMid), -(fy*uFine + my*uMid), 1.));
  gl_FragColor = vec4(n*.5+.5, 1.); }`, { tC: { value: comp.texture }, uSize: { value: TEX }, uFine: { value: 7 }, uMid: { value: 14 } });
function bakeNormal() { run(normalMat, normalRT); renderer.setRenderTarget(null); }
let nrmPending = false;
const nrmDirty = () => { if (nrmPending) return; nrmPending = true; requestAnimationFrame(() => { nrmPending = false; if (textured) bakeNormal(); }); };
const loadCopyMat = fsMat(`uniform sampler2D tSrc; varying vec2 vUv; void main(){ gl_FragColor=vec4(texture(tSrc,vUv).rgb,1.); }`, { tSrc: { value: null } });

// Projektion eines Fotos in den UV-Raum
const poseU = {
  uR: { value: new THREE.Matrix3() }, uS: { value: 1 }, uT: { value: new THREE.Vector3() }, uImg: { value: new THREE.Vector2(1, 1) },
  tImg: { value: null }, tMask: { value: null }, tDepth: { value: null }, uEps: { value: 20 }, uJaw: { value: -5 } };
const POSEV = `uniform mat3 uR; uniform float uS; uniform vec3 uT; uniform vec2 uImg;
varying vec2 vImg; varying float vZ; varying float vF; varying float vObjY;
void pose(){ vec3 P=uS*(uR*position)+uT; vImg=vec2(P.x/uImg.x,-P.y/uImg.y); vZ=P.z-uT.z; vF=(uR*normal).z; vObjY=position.y; }`;
const depthMat = new THREE.ShaderMaterial({ uniforms: poseU, side: THREE.DoubleSide,
  vertexShader: POSEV + `void main(){ pose(); gl_Position=vec4(vImg.x*2.-1., 1.-vImg.y*2., -vZ/(uImg.x*1.5), 1.); }`,
  fragmentShader: `varying float vZ; void main(){ gl_FragColor=vec4(vZ+2048.,0.,0.,1.); }` });
const layerMat = new THREE.ShaderMaterial({ uniforms: poseU, side: THREE.DoubleSide, depthTest: false, depthWrite: false,
  vertexShader: POSEV + `void main(){ pose(); gl_Position=vec4(uv*2.-1.,0.,1.); }`,
  fragmentShader: `uniform sampler2D tImg, tMask, tDepth; uniform float uEps, uJaw;
varying vec2 vImg; varying float vZ; varying float vF; varying float vObjY;
void main(){
  if(vImg.x<0.||vImg.x>1.||vImg.y<0.||vImg.y>1.) discard;
  float w = smoothstep(.12,.7,clamp(vF,0.,1.)); w*=w;
  if(w<.005) discard;
  float d = texture(tDepth, vec2(vImg.x,1.-vImg.y)).r;
  if(vZ+2048. < d-uEps) discard;
  vec2 mk = texture(tMask,vImg).rg;
  // Körperhaut (Ohren, Glatze) nur oberhalb der Kieferlinie – Hals/Nacken kommen nie in die Textur
  w *= smoothstep(.35,.85, max(mk.r, mk.g * smoothstep(uJaw-.8, uJaw+.8, vObjY)));
  vec2 e = min(vImg,1.-vImg); w *= smoothstep(0.,.03,min(e.x,e.y));
  if(w<.005) discard;
  gl_FragColor = vec4(texture(tImg, vec2(vImg.x,1.-vImg.y)).rgb, w);
}` });

let headGeo = null, headMesh = null, depthScene = null, layerScene = null, Qref = null, refFlip = false, textured = false, wire = false;
// Haut: MeshPhysicalMaterial + Subsurface-Näherung (Wrap-Lighting + Durchleuchtung, dünne Stellen stärker)
const sssU = { uSssColor: { value: new THREE.Color('#e0412a') }, uSss: { value: 0.8 }, uThin: { value: 1.2 }, uSssPow: { value: 2.5 }, uSssDist: { value: 0.4 }, uWrap: { value: 0.6 } };
function addSSS(mat) {
  mat.onBeforeCompile = sh => {
    Object.assign(sh.uniforms, sssU);
    sh.vertexShader = 'attribute float aThin;\nvarying float vThin;\n' + sh.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\nvThin = aThin;');
    sh.fragmentShader = 'uniform vec3 uSssColor; uniform float uSss, uThin, uSssPow, uSssDist, uWrap;\nvarying float vThin;\n' +
      sh.fragmentShader.replace('#include <lights_fragment_end>', `#include <lights_fragment_end>
      #if NUM_DIR_LIGHTS > 0
      for (int i = 0; i < NUM_DIR_LIGHTS; i++) {
        vec3 Ld = directionalLights[i].direction;
        float nl = dot(normal, Ld);
        float wrap = max(0., (nl + uWrap) / (1. + uWrap)) - max(0., nl);
        vec3 Ls = normalize(Ld + normal * uSssDist);
        float tr = pow(saturate(dot(geometryViewDir, -Ls)), uSssPow) * (0.15 + uThin * vThin);
        reflectedLight.directDiffuse += diffuseColor.rgb * uSssColor * directionalLights[i].color * uSss * (wrap * 0.7 + tr);
      }
      #endif`);
  };
  return mat;
}
const headMat = addSSS(new THREE.MeshPhysicalMaterial({ map: comp.texture, normalMap: normalRT.texture, roughness: 0.6, metalness: 0,
  sheen: 0.35, sheenRoughness: 0.55, sheenColor: new THREE.Color('#ffd9cc'), clearcoat: 0.05, clearcoatRoughness: 0.35 }));
const neutralMat = addSSS(new THREE.MeshPhysicalMaterial({ color: 0xeeeeec, roughness: 0.6, metalness: 0,
  sheen: 0.35, sheenRoughness: 0.55, sheenColor: new THREE.Color('#ffffff'), clearcoat: 0.05, clearcoatRoughness: 0.35 }));
const matCtl = {
  rough: v => { headMat.roughness = neutralMat.roughness = v; },
  sheen: v => { headMat.sheen = neutralMat.sheen = v; },
  coat: v => { headMat.clearcoat = neutralMat.clearcoat = v; },
  sss: v => { sssU.uSss.value = v; },
  thin: v => { sssU.uThin.value = v; },
  nrm: v => { normalMat.uniforms.uFine.value = 7 * v; nrmDirty(); },
  edge: v => { normalMat.uniforms.uMid.value = 14 * v; nrmDirty(); },
  lightA: v => setLightAngle(v),
  rim: v => { rim.intensity = v; },
};
for (const k in matCtl) {
  const el = $('m_' + k), out = $('v_' + k);
  const upd = () => { const v = parseFloat(el.value); matCtl[k](v); out.textContent = k === 'lightA' ? Math.round(v) + '°' : v.toFixed(2); };
  el.oninput = upd; upd();
}
// Ohren verschieben: Geometrie neu bauen, vorhandene Fotos neu projizieren
function rebuildHead() {
  if (Qref) { installHead(buildHead(Qref).geo, true); for (let i = 0; i < 5; i++) bakeSlot(i); if (slotData[0]) recomposite(); }
  else installHead(buildHead(null).geo, textured);
}
let rebuildPending = false;
for (const [id, key] of [['earY', 'y'], ['earZ', 'z']]) {
  const el = $('g_' + id), out = $('v_' + id);
  el.oninput = () => {
    earOff[key] = parseFloat(el.value); out.textContent = (earOff[key] > 0 ? '+' : '') + earOff[key].toFixed(1) + ' cm';
    if (rebuildPending) return; rebuildPending = true;
    requestAnimationFrame(() => { rebuildPending = false; rebuildHead(); });
  };
  out.textContent = '0.0 cm';
}
const setEarSliders = () => { $('g_earY').value = earOff.y; $('g_earZ').value = earOff.z;
  $('v_earY').textContent = earOff.y.toFixed(1) + ' cm'; $('v_earZ').textContent = earOff.z.toFixed(1) + ' cm'; };
$('m_sssColor').oninput = e => sssU.uSssColor.value.set(e.target.value);
$('matBtn').onclick = () => { const p = $('matPanel'), on = p.style.display !== 'block'; p.style.display = on ? 'block' : 'none'; $('matBtn').classList.toggle('on', on); };

function installHead(geo, isTextured) {
  stopInflate();
  if (headMesh) { scene.remove(headMesh); headGeo.dispose(); }
  headGeo = geo; textured = isTextured;
  geo.computeBoundingBox(); ground.position.y = geo.boundingBox.min.y - 0.6;
  headMesh = new THREE.Mesh(geo, isTextured ? headMat : neutralMat); scene.add(headMesh);
  depthScene = new THREE.Scene(); const dm = new THREE.Mesh(geo, depthMat); dm.frustumCulled = false; depthScene.add(dm);
  layerScene = new THREE.Scene(); const lm = new THREE.Mesh(geo, layerMat); lm.frustumCulled = false; layerScene.add(lm);
  compScene.clear(); const cm = new THREE.Mesh(geo, compMat); cm.frustumCulled = false; compScene.add(cm);
  $('insetFrame').style.display = isTextured ? 'block' : 'none';
  $('viewLabel').textContent = isTextured ? 'SCAN' : 'CANONICAL HEAD';
  $('pngBtn').disabled = !isTextured; $('saveBtn').disabled = !isTextured;
  headMat.wireframe = neutralMat.wireframe = wire;
}

// ---------- Komposition ----------
function pullPushChain(pulls, fins, pushM, softTop) {
  for (let l = 0; l < pulls.length - 1; l++) { pullMat.uniforms.tSrc.value = pulls[l].texture; run(pullMat, pulls[l + 1]); }
  const top = pulls.length - 1;
  for (let l = top; l >= 0; l--) {
    const size = pulls[l].width, u = pushM.uniforms;
    u.tPull.value = pulls[l].texture; u.tCoarse.value = l === top ? null : fins[l + 1].texture;
    u.uTop.value = l === top ? 1 : 0; u.uConf.value = softTop && size <= 32 ? 0.5 : 1; u.uSize.value = size;
    run(pushM, fins[l]);
  }
}
function clearRT(rt) { renderer.setClearColor(0x000000, 0); renderer.setRenderTarget(rt); renderer.clear(); renderer.setClearColor(0xffffff, 1); }
function compose() {
  copyMat.uniforms.tSrc.value = fin[0].texture; run(copyMat, comp);
  renderer.autoClear = false; renderer.setRenderTarget(comp); renderer.render(compScene, fsCam); renderer.autoClear = true;
  renderer.setRenderTarget(null);
}
function computeRatio(i) {
  boxMat.uniforms.tSrc.value = layers[0].texture; run(boxMat, low[0]);
  boxMat.uniforms.tSrc.value = layers[i].texture; run(boxMat, low[1]);
  ratioMat.uniforms.tA.value = low[0].texture; ratioMat.uniforms.tB.value = low[1].texture; run(ratioMat, ratioPull[0]);
  pullPushChain(ratioPull, ratioFin[i], pushRatio, false);
}
function recomposite() {
  for (let i = 1; i < 5; i++) if (slotData[i] && slotData[0]) computeRatio(i);
  run(blendMat, blendRT);
  run(initMat, pull[0]);
  pullPushChain(pull, fin, pushFill, true);
  updatePatch();
  compose();
  bakeNormal();
}
// Haut-Patch (Stirn, sonst Wange) für die Detailübertragung
const PATCH_LM = [151, 108, 337, 50, 280, 205, 425, 9];
function updatePatch() {
  compMat.uniforms.uHas.value = 0;
  if (!Qref) return;
  PATCH_LM.forEach((i, k) => { const [u, v] = dirToUV(Qref[i].clone().normalize()); probeMat.uniforms.uP.value[k].set(u, v); });
  run(probeMat, probeRT);
  const px = new Uint8Array(32); renderer.readRenderTargetPixels(probeRT, 0, 0, 8, 1, px); renderer.setRenderTarget(null);
  let best = -1, bv = 0.9 * 255;
  for (let k = 0; k < 8; k++) if (px[k * 4] >= bv) { bv = px[k * 4]; best = k; if (bv >= 254) break; }
  if (best < 0) return;
  const p = Qref[PATCH_LM[best]], us = [], vs = [];
  for (const o of [[-1.25, 0], [1.25, 0], [0, -1.25], [0, 1.25]]) {
    const [u, v] = dirToUV(p.clone().add(new THREE.Vector3(o[0], o[1], 0)).normalize()); us.push(u); vs.push(v);
  }
  extractMat.uniforms.uMin.value.set(Math.min(...us), Math.min(...vs));
  extractMat.uniforms.uMax.value.set(Math.max(...us), Math.max(...vs));
  run(extractMat, patchRT);
  compMat.uniforms.uHas.value = 1;
}

// ---------- Foto-Slots ----------
const SLOTS = ['c', 'r', 'l', 'u', 'd'];
const NAMES = { c: 'Front', r: 'Rechts', l: 'Links', u: 'Oben', d: 'Unten' };
const TARGET = { c: [0, 0], r: [35, 0], l: [-35, 0], u: [0, 25], d: [0, -22] };
const PROMPT = { c: 'Gerade in die Kamera schauen', r: 'Kopf ca. 35° nach rechts drehen →', l: '← Kopf ca. 35° nach links drehen',
  u: '↑ Kinn heben', d: '↓ Kinn senken' };
const slotData = [null, null, null, null, null];
function inTarget(slot, yaw, pitch) {
  const ay = Math.abs(yaw), ap = Math.abs(pitch);
  switch (slot) {
    case 'c': return ay < 7 && ap < 8;
    case 'r': return yaw > 26 && yaw < 50 && ap < 14;
    case 'l': return yaw < -26 && yaw > -50 && ap < 14;
    case 'u': return pitch > 15 && pitch < 38 && ay < 12;
    case 'd': return pitch < -13 && pitch > -35 && ay < 12;
  }
}
const angDist = (s, yaw, pitch) => Math.hypot(yaw - TARGET[s][0], pitch - TARGET[s][1]);
// Kopfrichtung aus der Transformationsmatrix; Vorzeichen der Drehung über die Nase abgesichert
function poseAngles(res, lm) {
  const m = res.facialTransformationMatrixes && res.facialTransformationMatrixes[0] && res.facialTransformationMatrixes[0].data;
  if (!m) return null;
  const f = new THREE.Vector3(m[8], m[9], m[10]).normalize();
  let yaw = deg(Math.atan2(f.x, f.z)); const pitch = deg(Math.atan2(f.y, Math.hypot(f.x, f.z)));
  const s = Math.sign(lm[1].x - (lm[234].x + lm[454].x) / 2);
  if (s !== 0 && Math.abs(yaw) > 4 && Math.sign(yaw) !== s) yaw = -yaw;
  return { yaw, pitch };
}
const toP = (lm, w, h) => lm.map(p => new THREE.Vector3(p.x * w, -p.y * h, -p.z * w));
function detectOn(cv) {
  const r = landmarkerImg.detect(cv);
  if (!r.faceLandmarks || !r.faceLandmarks.length) return null;
  const lm = r.faceLandmarks[0];
  return { lm, P: toP(lm, cv.width, cv.height), ang: poseAngles(r, lm) };
}
function mirrorCanvas(cv) {
  const t = document.createElement('canvas'); t.width = cv.width; t.height = cv.height;
  const c = t.getContext('2d'); c.setTransform(-1, 0, 0, 1, cv.width, 0); c.drawImage(cv, 0, 0); return t;
}
function toCanvas(src, w, h) {
  const sc = Math.min(1, STILL_MAX / Math.max(w, h)), c = document.createElement('canvas');
  c.width = Math.round(w * sc); c.height = Math.round(h * sc);
  c.getContext('2d').drawImage(src, 0, 0, c.width, c.height); return c;
}
function makeSlot(cv, det) {
  const mask = { tex: null };
  segmenterImg.segment(cv, r => fillMask(mask, r));
  const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.minFilter = THREE.LinearFilter; tex.generateMipmaps = false;
  return { cv, tex, mask, P: det.P, lm: det.lm, w: cv.width, h: cv.height };
}
function fillMask(store, r) {
  const cm = r.confidenceMasks; if (!cm || cm.length < 4) return;
  const mw = cm[3].width, mh = cm[3].height;
  const face = cm[3].getAsFloat32Array(), body = cm[2].getAsFloat32Array();
  const m = new Uint8Array(mw * mh * 2);
  for (let i = 0; i < mw * mh; i++) { m[i*2] = Math.min(255, face[i] * 255); m[i*2+1] = Math.min(255, body[i] * 255); }
  store.tex = new THREE.DataTexture(m, mw, mh, THREE.RGFormat, THREE.UnsignedByteType);
  store.tex.minFilter = store.tex.magFilter = THREE.LinearFilter; store.tex.unpackAlignment = 1; store.tex.needsUpdate = true;
}
function disposeSlot(s) { if (!s) return; s.tex.dispose(); if (s.mask.tex) s.mask.tex.dispose(); }

const SKIN_LM = [151, 9, 108, 337, 50, 280, 205, 425, 101, 330, 36, 266];
const s2l = c => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
function sampleSkin(lm, src) {
  const sw = 192, sh = Math.round(192 * src.height / src.width); small.width = sw; small.height = sh;
  sctx.drawImage(src, 0, 0, sw, sh);
  const img = sctx.getImageData(0, 0, sw, sh).data; let r = 0, g = 0, b = 0, n = 0;
  for (const i of SKIN_LM) {
    const cx = Math.round(lm[i].x * sw), cy = Math.round(lm[i].y * sh);
    for (let y = cy - 1; y <= cy + 1; y++) for (let x = cx - 1; x <= cx + 1; x++) {
      if (x < 0 || y < 0 || x >= sw || y >= sh) continue;
      const o = (y * sw + x) * 4; r += img[o]; g += img[o + 1]; b += img[o + 2]; n++;
    }
  }
  if (n) skin.set(s2l(r / n), s2l(g / n), s2l(b / n));
}

function bakeSlot(i) {
  clearRT(layers[i]);
  const s = slotData[i];
  if (!s || !Qref || !s.mask.tex) return;
  const pose = horn(Qref, s.P, 468); s.rms = pose.rms;
  const drt = getDepthRT(s.w, s.h);
  poseU.tImg.value = s.tex; poseU.tMask.value = s.mask.tex; poseU.tDepth.value = drt.texture;
  poseU.uImg.value.set(s.w, s.h); poseU.uR.value.copy(pose.R); poseU.uS.value = pose.s; poseU.uT.value.copy(pose.t);
  poseU.uEps.value = pose.s * 0.8; poseU.uJaw.value = 0.55 * Qref[152].y;
  clearRT(drt); renderer.setClearColor(0x000000, 0);
  renderer.setRenderTarget(drt); renderer.render(depthScene, fsCam);
  renderer.autoClear = false; renderer.setRenderTarget(layers[i]); renderer.render(layerScene, fsCam); renderer.autoClear = true;
  renderer.setRenderTarget(null); renderer.setClearColor(0xffffff, 1);
}
function calibrateFromFront() {
  const s = slotData[0], f = headFrame(s.P);
  Qref = toLocal(s.P, f); refFlip = f.flipped;
  sampleSkin(s.lm, s.cv);
  installHead(buildHead(Qref).geo, true);
}
function setSlots(entries) {          // entries: [[index, slot], …]
  let front = false;
  for (const [i, s] of entries) { disposeSlot(slotData[i]); slotData[i] = s; if (i === 0) front = true; }
  if (front) { calibrateFromFront(); for (let i = 0; i < 5; i++) bakeSlot(i); }
  else for (const [i] of entries) bakeSlot(i);
  if (slotData[0]) recomposite();
  loadedTex = false;
  updateGuide();
  if (slotData.every(Boolean)) setTimeout(() => setMode('view'), 600);   // alle 5 Fotos da -> Kamera aus
  const n = slotData.filter(Boolean).length, bad = slotData.filter(s => s && s.rms > 0.9).length;
  setStatus(slotData[0] ? `${n}/5 Fotos · ${(headGeo.attributes.position.count / 1000).toFixed(1)}k Vertices` + (bad ? ' · ein Foto passt schlecht' : '')
                        : `${n}/5 Fotos · Frontfoto fehlt`);
}
// Ansicht-Modus: Kamera aus, 3D-Ansicht in voller Breite
function setMode(m) {
  const view = m === 'view';
  document.body.classList.toggle('viewmode', view);
  $('modeBtn').textContent = view ? 'Scan' : 'Ansicht';
  if (view && stream) { stream.getTracks().forEach(t => t.stop()); stream = null; live = false; video.srcObject = null;
    $('startBox').style.display = 'flex'; $('startBtn').disabled = false; guideEl.style.display = 'none'; setPrompt('');
    $('switchBtn').disabled = true; $('shotBtn').disabled = true; }
  requestAnimationFrame(resize);
}
$('modeBtn').onclick = () => setMode(document.body.classList.contains('viewmode') ? 'scan' : 'view');

function resetScan() {
  for (let i = 0; i < 5; i++) { disposeSlot(slotData[i]); slotData[i] = null; clearRT(layers[i]); }
  Qref = null;
  installHead(buildHead(null).geo, false);
  updateGuide(); setStatus('Canonical Head');
}

// ---------- Modelle & Kamera ----------
async function loadModels() {
  setStatus('lade Modelle …');
  const fs = await FilesetResolver.forVisionTasks(WASM);
  const mk = async (Cls, opts) => {
    try { return await Cls.createFromOptions(fs, { ...opts, baseOptions: { ...opts.baseOptions, delegate: 'GPU' } }); }
    catch (e) { return await Cls.createFromOptions(fs, { ...opts, baseOptions: { ...opts.baseOptions, delegate: 'CPU' } }); }
  };
  const lmOpts = { baseOptions: { modelAssetPath: FACE_MODEL }, numFaces: 1, outputFacialTransformationMatrixes: true };
  landmarker = await mk(FaceLandmarker, { ...lmOpts, runningMode: 'VIDEO' });
  landmarkerImg = await mk(FaceLandmarker, { ...lmOpts, runningMode: 'IMAGE' });
  segmenterImg = await mk(ImageSegmenter, { baseOptions: { modelAssetPath: SEG_MODEL }, runningMode: 'IMAGE',
    outputConfidenceMasks: true, outputCategoryMask: false });
  setStatus('bereit');
}
const ensureModels = async () => { if (!landmarker) await loadModels(); };
async function startCamera() {
  if (stream) stream.getTracks().forEach(t => t.stop());
  stream = await navigator.mediaDevices.getUserMedia({ audio: false,
    video: { facingMode: facing, width: { ideal: 1920 }, height: { ideal: 1080 } } });
  video.srcObject = stream; await video.play();
  camWrap.classList.toggle('mirror', facing === 'user');
  imageCapture = ('ImageCapture' in window) ? new ImageCapture(stream.getVideoTracks()[0]) : null;
  W = video.videoWidth; H = video.videoHeight; proc.width = overlay.width = W; proc.height = overlay.height = H;
  live = true;
}
$('startBtn').onclick = async () => {
  $('startBtn').disabled = true;
  try {
    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) throw new Error('Kamera braucht https');
    setMode('scan'); await ensureModels(); await startCamera();
    $('startBox').style.display = 'none'; guideEl.style.display = 'grid';
    $('switchBtn').disabled = false; $('shotBtn').disabled = false;
    updateGuide();
  } catch (e) { console.error(e); setStatus('Fehler: ' + e.message); $('startBtn').disabled = false; }
};
$('switchBtn').onclick = async () => { facing = facing === 'user' ? 'environment' : 'user'; await startCamera(); };
$('resetBtn').onclick = () => resetScan();
$('uploadBtn').onclick = $('startUpload').onclick = () => $('fileIn').click();
$('fileIn').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) handleFiles(files); };
$('meshBtn').onclick = () => { wire = !wire; headMat.wireframe = neutralMat.wireframe = wire; };

// ---------- Live-Aufnahme ----------
let lastVT = -1, prevLm = null, holdSince = 0, capturing = false, curAng = null;
const nextSlot = () => SLOTS.find((s, i) => !slotData[i]);
function updateGuide() {
  const next = nextSlot();
  guideEl.querySelectorAll('.dot').forEach(el => {
    const i = SLOTS.indexOf(el.dataset.b);
    el.style.setProperty('--p', slotData[i] ? 1 : 0);
    el.classList.toggle('next', el.dataset.b === next);
  });
}
function processFrame() {
  if (video.videoWidth !== W || video.videoHeight !== H) { W = video.videoWidth; H = video.videoHeight; proc.width = overlay.width = W; proc.height = overlay.height = H; }
  pctx.drawImage(video, 0, 0, W, H);
  const ts = performance.now();
  const res = landmarker.detectForVideo(proc, ts);
  octx.clearRect(0, 0, W, H);
  if (!res.faceLandmarks || !res.faceLandmarks.length) { setPrompt('Kein Gesicht erkannt'); prevLm = null; holdSince = 0; curAng = null; return; }
  const lm = res.faceLandmarks[0];
  octx.fillStyle = 'rgba(17,17,17,.45)';
  const r = Math.max(1.5, W / 650);
  for (let i = 0; i < 468; i += 3) octx.fillRect(lm[i].x * W - r / 2, lm[i].y * H - r / 2, r, r);
  let motion = 1;
  if (prevLm) { let s = 0; for (let i = 0; i < 468; i += 8) s += Math.hypot(lm[i].x - prevLm[i].x, lm[i].y - prevLm[i].y); motion = s / 59; }
  prevLm = lm;
  const a = poseAngles(res, lm); if (!a) return;
  const yaw = facing === 'user' ? -a.yaw : a.yaw;      // Bildschirmrichtung
  curAng = { yaw, pitch: a.pitch };
  const next = nextSlot();
  if (!next) { setPrompt('Alle Fotos da · „Auslösen“ ersetzt das passende Foto'); return; }
  const ok = inTarget(next, yaw, a.pitch), still = motion < 0.0025;
  if (ok && still && !capturing) {
    if (!holdSince) holdSince = ts;
    const left = 700 - (ts - holdSince);
    setPrompt(left > 0 ? `${NAMES[next]}: halten …` : `${NAMES[next]}: Foto`);
    if (left <= 0) { holdSince = 0; capture(next); }
  } else { holdSince = 0; setPrompt(capturing ? 'verarbeite …' : PROMPT[next]); }
}
async function grabStill() {
  if (imageCapture) {
    try { const b = await imageCapture.takePhoto(); const bmp = await createImageBitmap(b); const c = toCanvas(bmp, bmp.width, bmp.height); bmp.close(); return c; }
    catch (e) { console.warn('takePhoto', e); }
  }
  return toCanvas(proc, W, H);
}
async function capture(slot) {
  capturing = true;
  const fl = $('flash'); fl.classList.add('on'); requestAnimationFrame(() => fl.classList.remove('on'));
  try {
    let cv = await grabStill(), det = detectOn(cv);
    if (det && Qref && headFrame(det.P).flipped !== refFlip) { cv = mirrorCanvas(cv); det = detectOn(cv); }
    if (!det) setStatus('kein Gesicht im Foto – nochmal');
    else setSlots([[SLOTS.indexOf(slot), makeSlot(cv, det)]]);
  } catch (e) { console.error(e); setStatus('Fehler: ' + e.message); }
  capturing = false;
}
$('shotBtn').onclick = () => {
  if (capturing) return;
  let slot = nextSlot();
  if (curAng) { slot = SLOTS.reduce((b, s) => angDist(s, curAng.yaw, curAng.pitch) < angDist(b, curAng.yaw, curAng.pitch) ? s : b, 'c'); }
  capture(slot || 'c');
};

// ---------- Fotos laden ----------
const loadBitmap = f => createImageBitmap(f, { imageOrientation: 'from-image' }).catch(() => createImageBitmap(f));
async function handleFiles(files) {
  try {
    await ensureModels();
    $('startBox').style.display = 'none';
    const cands = [];
    for (let i = 0; i < files.length; i++) {
      setStatus(`analysiere Foto ${i + 1}/${files.length}`);
      const bmp = await loadBitmap(files[i]); const cv = toCanvas(bmp, bmp.width, bmp.height); bmp.close();
      const det = detectOn(cv);
      if (det && det.ang) cands.push({ cv, det });
      await nextFrame();
    }
    if (!cands.length) { setStatus('in den Fotos wurde kein Gesicht gefunden'); return; }
    // Front = frontalstes Foto, Rest auf die nächstgelegene Richtung verteilen
    const best = {};
    const hasFront = !!slotData[0];
    cands.sort((a, b) => Math.hypot(a.det.ang.yaw, a.det.ang.pitch) - Math.hypot(b.det.ang.yaw, b.det.ang.pitch));
    if (!hasFront) best.c = cands.shift();
    for (const c of cands) {
      const s = SLOTS.filter(k => k !== 'c' || hasFront).reduce((b, k) => angDist(k, c.det.ang.yaw, c.det.ang.pitch) < angDist(b, c.det.ang.yaw, c.det.ang.pitch) ? k : b);
      const d = angDist(s, c.det.ang.yaw, c.det.ang.pitch);
      if (!best[s] || d < best[s].d) best[s] = { ...c, d };
    }
    const entries = [];
    if (best.c) entries.push([0, makeSlot(best.c.cv, best.c.det)]);
    if (best.c) { setSlots(entries.splice(0)); }
    for (const s of ['r', 'l', 'u', 'd']) {
      if (!best[s]) continue;
      let { cv, det } = best[s];
      if (Qref && headFrame(det.P).flipped !== refFlip) { cv = mirrorCanvas(cv); det = detectOn(cv); if (!det) continue; }
      entries.push([SLOTS.indexOf(s), makeSlot(cv, det)]);
    }
    if (entries.length) setSlots(entries);
  } catch (e) { console.error(e); setStatus('Fehler: ' + e.message); }
}

// ---------- Inflate ----------
let sim = null, simGeo = null, inflating = false, capMesh = null;
const simP = { pressure: 0.5, growth: 1.25, cap: 1.0, bend: 0.25, smooth: 0.35, hold: 0.5, selfCol: true, iters: 6, shape: 'capsule' };
// Sichtbare Hilfsgeometrie je Form (Einheitsgröße, passend zu den SDFs)
const GHOST = {
  capsule: () => new THREE.CapsuleGeometry(0.62, 0.84, 10, 40),
  sphere: () => new THREE.SphereGeometry(1, 40, 24),
  box: () => new THREE.BoxGeometry(1.24, 2, 1.32, 4, 6, 4),
  prism: () => new THREE.CylinderGeometry(0.9, 0.9, 2, 3, 6),
  cone: () => new THREE.ConeGeometry(0.95, 2, 40, 6),
};
const ghostMat = new THREE.MeshBasicMaterial({ color: 0xaaaaaa, wireframe: true, transparent: true, opacity: 0.22 });
function updateGhost() {
  if (!sim) return;
  if (!capMesh || capMesh.userData.shape !== simP.shape) {
    if (capMesh) { scene.remove(capMesh); capMesh.geometry.dispose(); }
    capMesh = new THREE.Mesh(GHOST[simP.shape](), ghostMat); capMesh.userData.shape = simP.shape; scene.add(capMesh);
  }
  capMesh.position.set(sim.c.cx, sim.c.cy, sim.c.cz); capMesh.scale.setScalar(sim.curK || 1);
  capMesh.visible = inflating && $('capShow').checked;
}
for (const k in SHAPES) {
  const b = document.createElement('button'); b.textContent = SHAPES[k].label; b.dataset.shape = k;
  b.onclick = () => { simP.shape = k; $('shapeSeg').querySelectorAll('button').forEach(x => x.classList.toggle('on', x.dataset.shape === k)); };
  $('shapeSeg').appendChild(b);
}
$('shapeSeg').querySelector('button').classList.add('on');
function startInflate() {
  if (!headGeo) return;
  simGeo = headGeo.clone(); sim = new Sim(headGeo);
  headMesh.geometry = simGeo; inflating = true;
  $('insetFrame').style.display = 'none';
  $('inflatePanel').style.display = 'block'; $('inflateBtn').classList.add('on');
  controls.autoRotate = false;
}
function stopInflate() {
  if (!inflating) return;
  headMesh.geometry = headGeo; simGeo.dispose(); sim = null; simGeo = null; inflating = false;
  $('insetFrame').style.display = textured ? 'block' : 'none';
  if (capMesh) capMesh.visible = false;
  $('inflatePanel').style.display = 'none'; $('inflateBtn').classList.remove('on');
}
$('inflateBtn').onclick = () => inflating ? stopInflate() : startInflate();
$('simReset').onclick = () => { if (inflating) { stopInflate(); startInflate(); } };
$('capShow').onchange = () => updateGhost();
$('selfCol').onchange = e => { simP.selfCol = e.target.checked; };
for (const k of ['pressure', 'growth', 'cap', 'bend', 'smooth', 'hold']) {
  const el = $('s_' + k), out = $('v_' + k);
  const upd = () => { simP[k] = parseFloat(el.value); out.textContent = simP[k].toFixed(2); };
  el.oninput = upd; upd();
}
// Greifen: Pointer auf dem Mesh zieht eine weiche Region mit
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(), dragPlane = new THREE.Plane(), hitTmp = new THREE.Vector3();
let dragging = false;
function setNdc(e) { const r = renderer.domElement.getBoundingClientRect(); ndc.set((e.clientX - r.left) / r.width * 2 - 1, -(e.clientY - r.top) / r.height * 2 + 1); }
viewWrap.addEventListener('pointerdown', e => {
  if (!inflating || e.target !== renderer.domElement) return;
  setNdc(e); ray.setFromCamera(ndc, camera);
  simGeo.computeBoundingSphere();
  const hit = ray.intersectObject(headMesh)[0];
  if (!hit) return;
  e.stopPropagation(); controls.enabled = false; dragging = true;
  sim.startGrab(hit.point, 2.6);
  dragPlane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()).negate(), hit.point);
  renderer.domElement.setPointerCapture(e.pointerId);
}, { capture: true });
window.addEventListener('pointermove', e => {
  if (!dragging || !sim || !sim.grab) return;
  setNdc(e); ray.setFromCamera(ndc, camera);
  if (ray.ray.intersectPlane(dragPlane, hitTmp)) { sim.grab.tx = hitTmp.x; sim.grab.ty = hitTmp.y; sim.grab.tz = hitTmp.z; }
});
window.addEventListener('pointerup', () => { if (!dragging) return; dragging = false; controls.enabled = true; if (sim) sim.grab = null; });

// ---------- Speichern / Laden ----------
let loadedTex = false;
$('saveBtn').onclick = () => {
  const data = { app: 'headscan', v: 2, flip: refFlip, skin: skin.toArray(), ear: { ...earOff },
    Q: Qref ? Qref.flatMap(v => [+v.x.toFixed(4), +v.y.toFixed(4), +v.z.toFixed(4)]) : null,
    tex: textureCanvas().toDataURL('image/png') };
  download(new Blob([JSON.stringify(data)], { type: 'application/json' }), `head_scan_${new Date().toISOString().slice(0, 10)}.headscan.json`);
};
$('loadBtn').onclick = () => $('loadIn').click();
$('loadIn').onchange = async e => {
  const f = e.target.files[0]; e.target.value = ''; if (!f) return;
  try {
    if (f.type.startsWith('image/')) { await applyTexture(URL.createObjectURL(f)); setStatus('Textur geladen (auf aktuelle Kopfform)'); return; }
    const d = JSON.parse(await f.text());
    if (d.app !== 'headscan') throw new Error('keine Head-Scan-Datei');
    for (let i = 0; i < 5; i++) { disposeSlot(slotData[i]); slotData[i] = null; clearRT(layers[i]); }
    updateGuide();
    if (d.skin) skin.fromArray(d.skin);
    earOff.y = d.ear ? d.ear.y : 0; earOff.z = d.ear ? d.ear.z : 0; setEarSliders();
    refFlip = !!d.flip;
    Qref = d.Q ? Array.from({ length: d.Q.length / 3 }, (_, i) => new THREE.Vector3(d.Q[i*3], d.Q[i*3+1], d.Q[i*3+2])) : null;
    installHead(buildHead(Qref).geo, true);
    await applyTexture(d.tex);
    setMode('view');
    setStatus(`geladen: ${f.name}`);
  } catch (err) { console.error(err); setStatus('Laden fehlgeschlagen: ' + err.message); }
};
// Textur-Bild (sRGB PNG im UV-Layout) direkt in den Atlas schreiben
async function applyTexture(src) {
  const img = new Image(); img.src = src; await img.decode();
  const c = document.createElement('canvas'); c.width = c.height = TEX; c.getContext('2d').drawImage(img, 0, 0, TEX, TEX);
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace;
  if (!textured) installHead(headGeo.clone(), true);
  loadCopyMat.uniforms.tSrc.value = t; run(loadCopyMat, comp);
  renderer.setClearColor(0x000000, 1); renderer.setRenderTarget(pull[0]); renderer.clear(); renderer.setClearColor(0xffffff, 1);
  renderer.setRenderTarget(null);
  t.dispose(); bakeNormal(); loadedTex = true;
}

// ---------- Export ----------
function rtCanvas(rt) {
  const buf = new Uint8Array(TEX * TEX * 4);
  renderer.readRenderTargetPixels(rt, 0, 0, TEX, TEX, buf); renderer.setRenderTarget(null);
  const c = document.createElement('canvas'); c.width = c.height = TEX;
  const ctx = c.getContext('2d'), id = ctx.createImageData(TEX, TEX), row = TEX * 4;
  for (let y = 0; y < TEX; y++) id.data.set(buf.subarray((TEX - 1 - y) * row, (TEX - y) * row), y * row);
  ctx.putImageData(id, 0, 0); return c;
}
function textureCanvas() {
  run(exportMat, exportRT);
  const buf = new Uint8Array(TEX * TEX * 4);
  renderer.readRenderTargetPixels(exportRT, 0, 0, TEX, TEX, buf);
  renderer.setRenderTarget(null);
  const c = document.createElement('canvas'); c.width = c.height = TEX;
  const ctx = c.getContext('2d'), id = ctx.createImageData(TEX, TEX), row = TEX * 4;
  for (let y = 0; y < TEX; y++) id.data.set(buf.subarray((TEX - 1 - y) * row, (TEX - y) * row), y * row);
  ctx.putImageData(id, 0, 0);
  return c;
}
const download = (blob, name) => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); };
$('pngBtn').onclick = () => textureCanvas().toBlob(b => download(b, 'head_scan_texture.png'), 'image/png');
$('glbBtn').onclick = () => {
  const g = headMesh.geometry; let m, name;
  if (textured) {
    const tex = new THREE.CanvasTexture(textureCanvas()); tex.colorSpace = THREE.SRGBColorSpace;
    const nt = new THREE.CanvasTexture(rtCanvas(normalRT));
    m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ map: tex, normalMap: nt, normalScale: headMat.normalScale.clone(), roughness: headMat.roughness }));
    name = inflating ? 'head_inflated.glb' : 'head_scan.glb';
  } else {
    const gc = g.clone(), uv = gc.attributes.uv;
    for (let i = 0; i < uv.count; i++) uv.setY(i, 1 - uv.getY(i));
    m = new THREE.Mesh(gc, new THREE.MeshStandardMaterial({ color: 0xeeeeec, roughness: 0.7 }));
    name = inflating ? 'canonical_inflated.glb' : 'canonical_head.glb';
  }
  m.name = name.replace('.glb', ''); m.scale.setScalar(0.01);
  new GLTFExporter().parse(m, glb => download(new Blob([glb], { type: 'model/gltf-binary' }), name),
    e => setStatus('Export-Fehler: ' + e.message), { binary: true });
};

// ---------- Loop ----------
function resize() {
  const w = viewWrap.clientWidth, h = viewWrap.clientHeight;
  renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix();
  const s = Math.round(Math.min(150, w * 0.3));
  Object.assign($('insetFrame').style, { width: s + 'px', height: s + 'px' });
}
new ResizeObserver(resize).observe(viewWrap); resize();

function render() {
  renderer.setRenderTarget(null);
  renderer.render(scene, camera);
  if (textured && !inflating) {
    const s = Math.round(Math.min(150, viewWrap.clientWidth * 0.3));
    renderer.autoClear = false; renderer.setScissorTest(true);
    renderer.setViewport(12, 12, s, s); renderer.setScissor(12, 12, s, s);
    renderer.render(insetScene, fsCam);
    renderer.setScissorTest(false); renderer.setViewport(0, 0, viewWrap.clientWidth, viewWrap.clientHeight);
    renderer.autoClear = true;
  }
}
function loop() {
  requestAnimationFrame(loop);
  controls.update();
  if (inflating) { sim.step(simP); sim.writeTo(simGeo); updateGhost(); }
  if (live && landmarker && video.readyState >= 2 && video.currentTime !== lastVT) {
    lastVT = video.currentTime;
    try { processFrame(); } catch (e) { console.error(e); }
  }
  render();
}
installHead(buildHead(null).geo, false);
compose();
setStatus(`Canonical Head · ${(headGeo.attributes.position.count / 1000).toFixed(1)}k Vertices`);
loop();
</script>
</body>
</html>