Hairy_Hairbrush

Live ↗ZIP (lokal ausführbar)
Beschreibung

Web-based prototype for painting and cutting hair on a 3D head. The hair is simulated as strands that hang from roots on the scalp and follow gravity, wind and a virtual hand. A flat 2D map shows the head unrolled, with the horizontal axis running around the head and the vertical axis running from top to bottom. Colors painted on this map are projected onto the strands, so a pattern drawn on the flat surface appears on the hair. The pattern logic is based on ikat textile dyeing, with shifted bundles, distortion and frayed edges. Scissors draw a cut line on the map, which sets where each strand ends, and preset cuts such as bob, pony or V-cut are available. The prototype shows a head with animated hair, a painting surface with symmetry and repeat options, and controls for light, volume and density. It can be used for design exploration of hair colors and cuts, for teaching how projection mapping and strand simulation work, and as a starting point for further hair or textile tools.

  • Tech: Three.js r160, WebGL, GLTFLoader, OrbitControls, Canvas 2D
  • Requires: WebGL-capable browser, internet connection for Three.js from a CDN, HEAD_ONLY.glb or another GLB file, a local server for loading the GLB
index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>IKAT HAIR</title>
<style>
:root{--ink:#141414;--mute:#8d8d8d;--line:#ececec;--soft:#f6f6f6}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#fff;color:var(--ink);font:12px/1.45 system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;overflow:hidden}
#title{position:fixed;left:18px;top:14px;z-index:3}
#title h1{font-size:13px;letter-spacing:.22em;font-weight:600;margin:0}
#title div{color:var(--mute);font-size:11px}
#dock{position:fixed;left:18px;top:56px;z-index:2}
#paintBox{position:relative;width:100%;border:1px solid var(--line);cursor:none;touch-action:none;background:#fff}
#paintBox canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
#axis{display:flex;justify-content:space-between;color:var(--mute);font-size:10px;letter-spacing:.08em;text-transform:uppercase;margin-top:5px}
#stage{position:fixed;top:0;bottom:0;right:320px;left:0}
#stage canvas{display:block}
#hint{position:absolute;right:18px;bottom:14px;color:var(--mute);font-size:11px;pointer-events:none;text-align:right}
#note{position:absolute;right:18px;top:16px;color:var(--mute);font-size:11px;max-width:340px;display:none;text-align:right}
#panel{position:fixed;top:0;right:0;width:320px;height:100%;overflow-y:auto;border-left:1px solid var(--line);padding:10px 18px 40px;background:#fff}
.sec{padding:12px 0 14px;border-bottom:1px solid var(--line)}
.sec:last-child{border-bottom:0}
.lab{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);margin-bottom:9px}
.seg{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.seg button{flex:1;border:0;background:#fff;padding:7px 0;font:inherit;cursor:pointer;color:var(--mute)}
.seg button+button{border-left:1px solid var(--line)}
.seg button.on{background:var(--ink);color:#fff}
.row{display:flex;align-items:center;gap:10px;margin:7px 0}
.row span.n{width:86px;color:#444}
.row input[type=range]{flex:1;accent-color:#141414;min-width:0}
.row span.v{width:40px;text-align:right;color:var(--mute);font-variant-numeric:tabular-nums}
.btns{display:flex;gap:7px;margin-top:8px;flex-wrap:wrap}
.btn{flex:1;border:1px solid var(--line);background:#fff;border-radius:6px;padding:7px 6px;font:inherit;cursor:pointer;color:var(--ink);white-space:nowrap}
.btn:hover{background:var(--soft)}
.btn.on{background:var(--ink);color:#fff;border-color:var(--ink)}
.tog{display:flex;align-items:center;justify-content:space-between;margin:6px 0;cursor:pointer;user-select:none;color:#444}
.tog i{width:30px;height:17px;border-radius:9px;background:#ddd;position:relative;transition:.15s;flex:none}
.tog i:after{content:"";position:absolute;left:2px;top:2px;width:13px;height:13px;border-radius:50%;background:#fff;transition:.15s}
.tog.on i{background:var(--ink)}
.tog.on i:after{left:15px}
.pal{display:grid;grid-template-columns:repeat(8,1fr);gap:6px;margin-bottom:8px}
.pal button{aspect-ratio:1;border-radius:50%;border:1px solid rgba(0,0,0,.08);cursor:pointer;padding:0}
.pal button.on{outline:2px solid var(--ink);outline-offset:2px}
.curRow{display:flex;align-items:center;gap:10px}
#curCol{width:28px;height:28px;border-radius:6px;border:1px solid var(--line)}
.curRow input[type=color]{width:28px;height:28px;border:0;padding:0;background:none;cursor:pointer}
.curRow span{color:var(--mute);font-variant-numeric:tabular-nums}
.note{color:var(--mute);font-size:11px;margin-top:6px}
.mt{margin-top:8px}
</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/"}}
</script>
</head>
<body>
<div id="title"><h1>IKAT HAIR</h1><div>färben · schneiden · durchwuscheln</div></div>
<div id="dock">
  <div id="paintBox"><canvas id="paint"></canvas><canvas id="paintOv"></canvas></div>
  <div id="axis"><span>hinten</span><span>rechts</span><span>vorne</span><span>links</span><span>hinten</span></div>
</div>
<div id="stage"><div id="note"></div><div id="hint"></div></div>

<div id="panel">
  <div class="sec">
    <div class="lab">Werkzeug</div>
    <div class="seg" id="toolSeg"><button data-v="brush" class="on">Pinsel</button><button data-v="pick">Pipette</button><button data-v="cut">Schere</button></div>
    <div class="note" id="toolNote"></div>
  </div>

  <div class="sec">
    <div class="lab">Schnitt</div>
    <div class="btns">
      <button class="btn" data-cut="long">Lang</button>
      <button class="btn" data-cut="shoulder">Schulter</button>
      <button class="btn" data-cut="bob">Bob</button>
    </div>
    <div class="btns">
      <button class="btn" data-cut="pony">Pony</button>
      <button class="btn" data-cut="v">V-Schnitt</button>
      <button class="btn" data-cut="asym">Asymmetrisch</button>
    </div>
    <div class="row"><span class="n">Stufen</span><input id="stufen" type="range" min="0" max="1" step="0.01" value="0"><span class="v" id="stufenV"></span></div>
    <div class="row"><span class="n">Spitzen fransig</span><input id="fray" type="range" min="0" max="0.3" step="0.005" value="0.04"><span class="v" id="frayV"></span></div>
    <div class="btns"><button class="btn" id="cutSmooth">Kante glätten</button><button class="btn" id="cutUndo">Schnitt zurück</button></div>
    <div class="note">Schere: Schnittlinie direkt auf der Fläche zeichnen. Unterhalb der Linie ist das Haar ab.</div>
  </div>

  <div class="sec">
    <div class="lab">Frisur</div>
    <div class="row"><span class="n">Scheitel</span><input id="part" type="range" min="-0.55" max="0.55" step="0.01" value="0"><span class="v" id="partV"></span></div>
    <div class="tog" id="tPony"><span>Pony nach vorn kämmen</span><i></i></div>
    <div class="row"><span class="n">Volumen</span><input id="vol" type="range" min="0" max="1.5" step="0.01" value="0.5"><span class="v" id="volV"></span></div>
    <div class="row"><span class="n">Haaransatz</span><input id="hairline" type="range" min="-0.15" max="0.2" step="0.01" value="0"><span class="v" id="hairlineV"></span></div>
    <div class="row"><span class="n">Dichte</span><input id="spl" type="range" min="400" max="4000" step="100" value="1800"><span class="v" id="splV"></span></div>
    <div class="row"><span class="n">Haardicke</span><input id="thick" type="range" min="0.15" max="1.5" step="0.01" value="0.5"><span class="v" id="thickV"></span></div>
  </div>

  <div class="sec">
    <div class="lab">Pinsel</div>
    <div class="seg" id="presetSeg"><button data-v="hard" class="on">Hart</button><button data-v="soft">Weich · lasierend</button></div>
    <div class="row"><span class="n">Größe</span><input id="size" type="range" min="1" max="320" value="50"><span class="v" id="sizeV"></span></div>
    <div class="row"><span class="n">Härte</span><input id="hard" type="range" min="0" max="1" step="0.01" value="0.9"><span class="v" id="hardV"></span></div>
    <div class="row"><span class="n">Deckkraft</span><input id="opacity" type="range" min="0.03" max="1" step="0.01" value="1"><span class="v" id="opacityV"></span></div>
  </div>

  <div class="sec">
    <div class="lab">Symmetrie</div>
    <div class="tog" id="tMx"><span>Spiegeln links ↔ rechts</span><i></i></div>
    <div class="tog" id="tMy"><span>Spiegeln oben ↕ unten</span><i></i></div>
    <div class="row"><span class="n">Rapport X</span><input id="rx" type="range" min="1" max="8" step="1" value="1"><span class="v" id="rxV"></span></div>
    <div class="row"><span class="n">Rapport Y</span><input id="ry" type="range" min="1" max="8" step="1" value="1"><span class="v" id="ryV"></span></div>
    <div class="tog on" id="tGuides"><span>Hilfslinien</span><i></i></div>
    <div class="note">Spiegeln gilt auch für die Schere (um die Mitte vorne).</div>
  </div>

  <div class="sec">
    <div class="lab">Farbe</div>
    <div class="pal" id="pal"></div>
    <div class="curRow"><div id="curCol"></div><input type="color" id="custom" value="#2b3f7a" title="eigene Farbe"><span id="curHex"></span></div>
  </div>

  <div class="sec">
    <div class="lab">Map</div>
    <div class="btns"><button class="btn" id="undo">Rückgängig</button><button class="btn" id="sample">Ikat-Muster</button><button class="btn" id="clear">Grau</button></div>
    <div class="btns"><button class="btn" id="imgBtn">Bild laden</button><button class="btn" id="savePng">PNG</button><button class="btn on" id="dockBtn">Malfläche</button></div>
    <input type="file" id="imgFile" accept="image/*" hidden>
    <div class="note">Die Fläche ist der abgerollte Kopf: x = rundherum, y = Höhe. Strg/Cmd+Z · [ ] Größe · Alt = Pipette</div>
  </div>

  <div class="sec">
    <div class="lab">Ikat Verzug</div>
    <div class="row"><span class="n">Versatz</span><input id="amp" type="range" min="0" max="0.07" step="0.001" value="0.018"><span class="v" id="ampV"></span></div>
    <div class="row"><span class="n">Zerrung</span><input id="zerr" type="range" min="0" max="0.03" step="0.0005" value="0.006"><span class="v" id="zerrV"></span></div>
    <div class="row"><span class="n">Ausfransen</span><input id="blur" type="range" min="0" max="0.03" step="0.0005" value="0.005"><span class="v" id="blurV"></span></div>
    <div class="row"><span class="n">Bündel</span><input id="bundle" type="range" min="1" max="30" step="1" value="10"><span class="v" id="bundleV"></span></div>
  </div>

  <div class="sec">
    <div class="lab">Bewegung</div>
    <div class="tog on" id="tTurb"><span>Turbulenz (Perlin)</span><i></i></div>
    <div class="row"><span class="n">Stärke</span><input id="turb" type="range" min="0" max="3" step="0.01" value="1.0"><span class="v" id="turbV"></span></div>
    <div class="row"><span class="n">Böen</span><input id="gust" type="range" min="0" max="1" step="0.01" value="0.6"><span class="v" id="gustV"></span></div>
    <div class="row"><span class="n">Hand-Radius</span><input id="hand" type="range" min="0.04" max="0.5" step="0.01" value="0.18"><span class="v" id="handV"></span></div>
    <div class="row"><span class="n">Steifigkeit</span><input id="stiff" type="range" min="0" max="0.7" step="0.01" value="0.3"><span class="v" id="stiffV"></span></div>
    <div class="btns"><button class="btn" id="smooth">Frisur neu legen</button></div>
  </div>

  <div class="sec">
    <div class="lab">Kopf</div>
    <div class="tog" id="tCols"><span>Collider zeigen</span><i></i></div>
    <div class="row"><span class="n">Schultern</span><input id="shoulder" type="range" min="0.6" max="3" step="0.05" value="1"><span class="v" id="shoulderV"></span></div>
    <div class="btns"><button class="btn" id="glbBtn">Anderes GLB laden</button></div>
    <input type="file" id="glbFile" accept=".glb" hidden>
  </div>

  <div class="sec">
    <div class="lab">Licht</div>
    <div class="tog on" id="tShadow"><span>Schatten</span><i></i></div>
    <div class="row"><span class="n">Sonne</span><input id="sun" type="range" min="0" max="4" step="0.05" value="2.4"><span class="v" id="sunV"></span></div>
    <div class="row"><span class="n">Winkel</span><input id="sunAng" type="range" min="-60" max="60" step="1" value="14"><span class="v" id="sunAngV"></span></div>
  </div>
</div>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const $ = id => document.getElementById(id);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const TAU = Math.PI * 2;

/* =========================================================
   Perlin + RNG
   ========================================================= */
const perm = new Uint8Array(512);
(() => {
  const p = [...Array(256).keys()];
  let s = 1337; const r = () => (s = (s * 16807) % 2147483647) / 2147483647;
  for (let i = 255; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [p[i], p[j]] = [p[j], p[i]]; }
  for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
})();
const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
const lerp = (t, a, b) => a + t * (b - a);
function grad(h, x, y, z) { h &= 15; const u = h < 8 ? x : y, v = h < 4 ? y : (h === 12 || h === 14 ? x : z); return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); }
function noise3(x, y, z) {
  let X = Math.floor(x), Y = Math.floor(y), Z = Math.floor(z);
  x -= X; y -= Y; z -= Z; X &= 255; Y &= 255; Z &= 255;
  const u = fade(x), v = fade(y), w = fade(z);
  const A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z, B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z;
  return lerp(w,
    lerp(v, lerp(u, grad(perm[AA], x, y, z), grad(perm[BA], x - 1, y, z)), lerp(u, grad(perm[AB], x, y - 1, z), grad(perm[BB], x - 1, y - 1, z))),
    lerp(v, lerp(u, grad(perm[AA + 1], x, y, z - 1), grad(perm[BA + 1], x - 1, y, z - 1)), lerp(u, grad(perm[AB + 1], x, y - 1, z - 1), grad(perm[BB + 1], x - 1, y - 1, z - 1))));
}
function mulberry32(a) { 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; }; }

/* =========================================================
   State
   ========================================================= */
const N = 44;          // nodes per simulated strand
const ND = 120;        // nodes of the rest-drape profile (finer)
const LMAX = 7.0;      // longest possible strand
const SEGD = LMAX / (ND - 1);
const FLOOR = -2.6;
const GROUND = '#d4d4d2';   // light grey dyed hair
const CUTN = 360;           // cut-line resolution (1° per sample)

const P_ = {
  spl: 1800, thick: 0.5, part: 0, pony: false, vol: 0.5, hairline: 0,
  stufen: 0, fray: 0.04,
  amp: 0.018, zerr: 0.006, blur: 0.005, bundle: 10,
  turbOn: true, turb: 1.0, gust: 0.6, hand: 0.18, stiff: 0.3,
  showCols: false, shoulder: 1,
  dock: true, shadow: true, sun: 2.4, sunAng: 14
};
const B = { tool: 'brush', size: 50, hard: 0.9, opacity: 1, mx: false, my: false, rx: 1, ry: 1, guides: true, color: '#2b3f7a' };
const cut = new Float32Array(CUTN);   // normalized map-y (0 = top of head, 1 = floor) where hair ends

/* =========================================================
   Renderer / Scene / Light
   ========================================================= */
const stage = $('stage');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setClearColor(0xffffff, 1);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
stage.appendChild(renderer.domElement);
const cvs = renderer.domElement;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1, 0.05, 100);
camera.position.set(2.6, 0.9, 6.4);
const controls = new OrbitControls(camera, cvs);
controls.target.set(0, -0.15, 0);
controls.enableDamping = true; controls.dampingFactor = 0.08;
controls.minDistance = 1.0; controls.maxDistance = 14;

scene.add(new THREE.HemisphereLight(0xffffff, 0xe2e2e2, 0.95));
const fill = new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-2, 0.5, 3); scene.add(fill);
const sun = new THREE.DirectionalLight(0xffffff, P_.sun);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -2.4, right: 2.4, top: 2.4, bottom: -2.4, near: 1, far: 16 });
sun.shadow.bias = -0.0006;
sun.shadow.radius = 3;
scene.add(sun, sun.target);

const floor = new THREE.Mesh(new THREE.PlaneGeometry(30, 30), new THREE.ShadowMaterial({ color: 0x000000, opacity: 0.1 }));
floor.rotation.x = -Math.PI / 2; floor.position.y = FLOOR; floor.receiveShadow = true; scene.add(floor);

/* ---------- Head model + colliders ---------- */
const headMat = new THREE.MeshStandardMaterial({ color: 0xf0efec, roughness: 0.9 });
const headGrp = new THREE.Group(); scene.add(headGrp);
const modelRoot = new THREE.Group(); headGrp.add(modelRoot);
const fallbackGrp = new THREE.Group(); headGrp.add(fallbackGrp);
const colViz = new THREE.Group(); colViz.visible = false; headGrp.add(colViz);
const colVizMat = new THREE.MeshBasicMaterial({ color: 0x8a8a8a, wireframe: true, transparent: true, opacity: 0.22, depthTest: false });

const NC = {
  head: { c: [0.5, 0.652, 0.472], r: [0.481, 0.359, 0.449] },
  neck: { a: [0.5, 0.14, 0.373], b: [0.5, 0.46, 0.373], r: 0.293 },
  sh: { c: [0.5, 0.092, 0.306], r: [0.523, 0.102, 0.305] }
};
let modelBox = new THREE.Box3(new THREE.Vector3(-0.478, -0.11, -0.557), new THREE.Vector3(0.478, 1.45, 0.557));
let hasModel = false;
let COLS = [];
const headC = new THREE.Vector3(), headR = new THREE.Vector3();
let shoulderTop = 0.2, yTop = 1.5, yBot = FLOOR;

function setColliders() {
  const mn = modelBox.min, sz = modelBox.getSize(new THREE.Vector3());
  const Pt = a => new THREE.Vector3(mn.x + a[0] * sz.x, mn.y + a[1] * sz.y, mn.z + a[2] * sz.z);
  const hc = Pt(NC.head.c); headC.copy(hc);
  headR.set(NC.head.r[0] * sz.x, NC.head.r[1] * sz.y, NC.head.r[2] * sz.z);
  const na = Pt(NC.neck.a), nb = Pt(NC.neck.b), nr = NC.neck.r * sz.x;
  const sc = Pt(NC.sh.c);
  const sr = new THREE.Vector3(NC.sh.r[0] * sz.x * P_.shoulder, NC.sh.r[1] * sz.y, NC.sh.r[2] * sz.z * (0.85 + 0.15 * P_.shoulder));
  const ab = nb.clone().sub(na);
  COLS = [
    { t: 0, cx: hc.x, cy: hc.y, cz: hc.z, rx: headR.x, ry: headR.y, rz: headR.z },
    { t: 1, ax: na.x, ay: na.y, az: na.z, bx: ab.x, by: ab.y, bz: ab.z, ab2: ab.lengthSq(), r: nr },
    { t: 0, cx: sc.x, cy: sc.y, cz: sc.z, rx: sr.x, ry: sr.y, rz: sr.z }
  ];
  shoulderTop = sc.y + sr.y;
  setFaceGuard();
  yTop = headC.y + headR.y * 1.04; yBot = FLOOR;
  const mk = (grp, mat, sh) => {
    grp.clear();
    const e1 = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 20), mat); e1.scale.copy(headR); e1.position.copy(hc);
    const cap = new THREE.Mesh(new THREE.CapsuleGeometry(nr, ab.length(), 8, 24), mat); cap.position.copy(na).addScaledVector(ab, 0.5);
    const e2 = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 20), mat); e2.scale.copy(sr); e2.position.copy(sc);
    [e1, cap, e2].forEach(m => { m.castShadow = sh; m.receiveShadow = sh; grp.add(m); });
  };
  mk(colViz, colVizMat, false);
  colViz.children.forEach(m => m.renderOrder = 5);
  mk(fallbackGrp, headMat, true);
  fallbackGrp.visible = !hasModel;
  if (scalp) { scalp.position.copy(headC); scalp.scale.copy(headR).multiplyScalar(0.997); }
}

function collide(arr, k, f) {
  for (let ci = 0; ci < COLS.length; ci++) {
    const c = COLS[ci];
    if (c.t === 0) {
      const x = (arr[k] - c.cx) / c.rx, y = (arr[k + 1] - c.cy) / c.ry, z = (arr[k + 2] - c.cz) / c.rz;
      const d2 = x * x + y * y + z * z;
      if (d2 < f * f && d2 > 1e-12) {
        const s = f / Math.sqrt(d2);
        arr[k] = c.cx + x * s * c.rx; arr[k + 1] = c.cy + y * s * c.ry; arr[k + 2] = c.cz + z * s * c.rz;
      }
    } else {
      const px = arr[k] - c.ax, py = arr[k + 1] - c.ay, pz = arr[k + 2] - c.az;
      const t = clamp((px * c.bx + py * c.by + pz * c.bz) / c.ab2, 0, 1);
      const qx = px - c.bx * t, qy = py - c.by * t, qz = pz - c.bz * t;
      const d2 = qx * qx + qy * qy + qz * qz, rr = c.r * f;
      if (d2 < rr * rr && d2 > 1e-12) {
        const s = rr / Math.sqrt(d2);
        arr[k] = c.ax + c.bx * t + qx * s; arr[k + 1] = c.ay + c.by * t + qy * s; arr[k + 2] = c.az + c.bz * t + qz * s;
      }
    }
  }
}

/* face keep-out: hair (except combed-forward bangs) is pushed to the sides of the face */
let FG = { x: 0.3, y0: 0, y1: 1, z0: 0, z1: 2, cx: 0 };
function setFaceGuard() {
  FG = { cx: headC.x, x: headR.x * 0.68, y0: headC.y - headR.y * 1.05, y1: headC.y + headR.y * 0.45, z0: headC.z + headR.z * 0.1, z1: headC.z + headR.z * 1.9 };
}
function faceGuard(arr, k, sg) {
  const y = arr[k + 1], z = arr[k + 2];
  if (y < FG.y0 || y > FG.y1 || z < FG.z0 || z > FG.z1) return;
  const dx = arr[k] - FG.cx;
  // soft taper at the bottom (chin) so hair can close in below the face
  const w = FG.x * (0.75 + 0.25 * smooth(FG.y0, FG.y0 + (FG.y1 - FG.y0) * 0.35, y)) * Math.sqrt(smooth(FG.y1, FG.y1 - (FG.y1 - FG.y0) * 0.3, y));  // arch at the forehead
  if (Math.abs(dx) < w) arr[k] = FG.cx + (dx === 0 ? sg : Math.sign(dx)) * w;
}

/* =========================================================
   Ikat map (unrolled head: x = azimuth, y = height)
   ========================================================= */
const MW = 1024, MH = 1024;
const pc = $('paint'); pc.width = MW; pc.height = MH;
const pctx = pc.getContext('2d', { willReadFrequently: true });
const ov = $('paintOv'); ov.width = MW; ov.height = MH;
const octx = ov.getContext('2d');
const baseC = document.createElement('canvas'); baseC.width = MW; baseC.height = MH;
const bctx = baseC.getContext('2d', { willReadFrequently: true });
const strokeC = document.createElement('canvas'); strokeC.width = MW; strokeC.height = MH;
const sctx = strokeC.getContext('2d');

const tex = new THREE.CanvasTexture(pc);
tex.colorSpace = THREE.SRGBColorSpace;
tex.generateMipmaps = false;               // sub-pixel strands → no mips (otherwise blocky in y)
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.wrapS = THREE.RepeatWrapping;          // azimuth wraps around the head
tex.wrapT = THREE.ClampToEdgeWrapping;
let mapDirty = true, stroking = false;

function composite() {
  pctx.globalAlpha = 1;
  pctx.drawImage(baseC, 0, 0);
  if (stroking) { pctx.globalAlpha = B.opacity; pctx.drawImage(strokeC, 0, 0); pctx.globalAlpha = 1; }
  mapDirty = true;
}
function hexA(hex, a) { const n = parseInt(hex.slice(1), 16); return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`; }

function diamond(c, cx, cy, w, h, col) {
  c.fillStyle = col; c.beginPath();
  c.moveTo(cx, cy - h / 2); c.lineTo(cx + w / 2, cy); c.lineTo(cx, cy + h / 2); c.lineTo(cx - w / 2, cy); c.closePath(); c.fill();
}
function drawSample() {
  pushUndo();
  const c = bctx;
  c.fillStyle = GROUND; c.fillRect(0, 0, MW, MH);
  const cols = 8, cw = MW / cols, rows = 7, rh = MH / rows;
  for (let r = 0; r < rows; r++) {
    const cy = rh * (r + 0.5);
    for (let k = -1; k <= cols; k++) {
      const cx = cw * (k + 0.5) + (r % 2 ? cw * 0.5 : 0);
      diamond(c, cx, cy, cw * 0.86, rh * 0.82, '#2b3f7a');
      diamond(c, cx, cy, cw * 0.55, rh * 0.52, GROUND);
      diamond(c, cx, cy, cw * 0.26, rh * 0.25, '#9e2b25');
    }
    c.fillStyle = '#c98f2a'; c.fillRect(0, rh * (r + 1) - 6, MW, 9);
  }
  composite();
}
function greyPaint() { pushUndo(); bctx.fillStyle = GROUND; bctx.fillRect(0, 0, MW, MH); composite(); }

const undoStack = [];
function pushUndo() { undoStack.push(bctx.getImageData(0, 0, MW, MH)); if (undoStack.length > 20) undoStack.shift(); }
function undo() { const d = undoStack.pop(); if (d) { bctx.putImageData(d, 0, 0); composite(); } }

/* symmetry */
let sym = null;
function makeSym(p) {
  const cw = MW / B.rx, ch = MH / B.ry;
  const s = { cw, ch, sx: Math.floor(clamp(p.x, 0, MW - 1) / cw) * cw, sy: Math.floor(clamp(p.y, 0, MH - 1) / ch) * ch, T: [] };
  for (let ix = 0; ix < B.rx; ix++) for (let iy = 0; iy < B.ry; iy++)
    for (const mx of (B.mx ? [0, 1] : [0])) for (const my of (B.my ? [0, 1] : [0]))
      s.T.push({ ox: ix * cw, oy: iy * ch, mx, my });
  return s;
}
function applyT(tr, x, y) {
  let lx = x - sym.sx, ly = y - sym.sy;
  if (tr.mx) lx = sym.cw - lx;
  if (tr.my) ly = sym.ch - ly;
  return [tr.ox + lx, tr.oy + ly];
}

/* round brush */
function stampAt(x, y) {
  const r = B.size / 2, c = B.color, h = B.hard;
  if (h >= 0.98 || r < 1.2) { sctx.fillStyle = c; sctx.beginPath(); sctx.arc(x, y, r, 0, TAU); sctx.fill(); return; }
  const dabA = 0.25 + 0.75 * h;
  const g = sctx.createRadialGradient(x, y, 0, x, y, r);
  g.addColorStop(0, hexA(c, dabA));
  for (let i = 0; i <= 8; i++) {
    const t = i / 8, pos = h + (1 - h) * t, a = 1 - t * t * (3 - 2 * t);
    g.addColorStop(pos, hexA(c, dabA * a));
  }
  sctx.fillStyle = g; sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawDab(x, y) {
  for (const tr of sym.T) {
    const [tx, ty] = applyT(tr, x, y);
    stampAt(tx, ty);
    // wrap around the seam at the back of the head
    if (tx < B.size) stampAt(tx + MW, ty); else if (tx > MW - B.size) stampAt(tx - MW, ty);
  }
}
let residual = 0;
const brushStep = () => Math.max(1, (0.06 + 0.1 * B.hard) * B.size);
function strokeSeg(a, b) {
  const step = brushStep();
  const dx = b.x - a.x, dy = b.y - a.y, dist = Math.hypot(dx, dy);
  let t = residual;
  while (t <= dist) { const f = dist ? t / dist : 0; drawDab(a.x + dx * f, a.y + dy * f); t += step; }
  residual = t - dist;
}

/* scissors: write the cut line */
const cutUndo = [];
let lastCut = null;
function setCutSpan(x0, y0, x1, y1) {
  const i0 = Math.round(x0 / MW * CUTN), i1 = Math.round(x1 / MW * CUTN);
  const n = Math.max(1, Math.abs(i1 - i0));
  for (let k = 0; k <= n; k++) {
    const i = Math.round(i0 + (i1 - i0) * k / n), y = clamp((y0 + (y1 - y0) * k / n) / MH, 0, 1);
    const ii = ((i % CUTN) + CUTN) % CUTN;
    cut[ii] = y;
    if (B.mx) cut[((CUTN - ii) % CUTN)] = y;          // mirror around front (u = 0.5)
  }
  cutDirty = true;
}
function cutAtU(u) {           // u in [0,1) → normalized y, linear interp, wraps
  const f = (((u % 1) + 1) % 1) * CUTN, i = Math.floor(f) % CUTN, t = f - Math.floor(f);
  return cut[i] * (1 - t) + cut[(i + 1) % CUTN] * t;
}
const yToN = y => (yTop - y) / (yTop - yBot);
const nToY = n => yTop - n * (yTop - yBot);

let lastP = null, hoverP = null, altDown = false;
const pBox = $('paintBox');
const toMap = e => { const r = pBox.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width * MW, y: (e.clientY - r.top) / r.height * MH }; };
const curTool = () => altDown ? 'pick' : B.tool;
function pickColor(p) {
  const d = pctx.getImageData(clamp(p.x | 0, 0, MW - 1), clamp(p.y | 0, 0, MH - 1), 1, 1).data;
  setColor('#' + [d[0], d[1], d[2]].map(v => v.toString(16).padStart(2, '0')).join(''));
}
pBox.addEventListener('pointerdown', e => {
  const p = toMap(e);
  pBox.setPointerCapture(e.pointerId);
  const t = curTool();
  if (t === 'pick') { pickColor(p); return; }
  if (t === 'cut') { cutUndo.push(cut.slice()); if (cutUndo.length > 30) cutUndo.shift(); lastCut = p; setCutSpan(p.x, p.y, p.x, p.y); drawOverlay(); return; }
  pushUndo();
  sym = makeSym(p); stroking = true; lastP = p;
  sctx.clearRect(0, 0, MW, MH);
  drawDab(p.x, p.y); residual = brushStep();
  composite();
});
pBox.addEventListener('pointermove', e => {
  const p = toMap(e); hoverP = p;
  if (lastCut && e.buttons) { setCutSpan(lastCut.x, lastCut.y, p.x, p.y); lastCut = p; }
  else if (stroking) {
    const evs = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
    for (const ce of evs) { const q = toMap(ce); strokeSeg(lastP, q); lastP = q; }
    composite();
  } else if (curTool() === 'pick' && e.buttons) pickColor(p);
  drawOverlay();
});
const endStroke = () => {
  lastCut = null;
  if (!stroking) return;
  composite();
  bctx.drawImage(pc, 0, 0);
  stroking = false; sctx.clearRect(0, 0, MW, MH); composite();
};
pBox.addEventListener('pointerup', endStroke);
pBox.addEventListener('pointercancel', endStroke);
pBox.addEventListener('pointerleave', () => { hoverP = null; drawOverlay(); });

const hyAt = c => 0.22 + 0.35 * c - 0.17 * c * c + P_.hairline;   // hairline height on the unit head, c = cos(azimuth)
function circle(x, y, r) { octx.beginPath(); octx.arc(x, y, r, 0, TAU); octx.stroke(); }
function drawOverlay() {
  octx.clearRect(0, 0, MW, MH);
  const lw = MW / 420;
  // hairline (where the roots stop)
  octx.setLineDash([3, 7]); octx.lineWidth = lw; octx.strokeStyle = 'rgba(0,0,0,0.28)'; octx.beginPath();
  for (let i = 0; i <= 120; i++) {
    const u = i / 120, az = (u - 0.5) * TAU, hy = hyAt(Math.cos(az));
    const y = yToN(headC.y + hy * headR.y) * MH; i ? octx.lineTo(u * MW, y) : octx.moveTo(u * MW, y);
  }
  octx.stroke();
  // reference heights
  octx.setLineDash([]); octx.fillStyle = 'rgba(0,0,0,0.32)'; octx.font = `${Math.round(MW / 52)}px system-ui`;
  [['Kinn', headC.y - headR.y * 0.75], ['Schulter', shoulderTop], ['Boden', FLOOR + 0.02]].forEach(([t, y]) => {
    const yy = yToN(y) * MH; octx.fillRect(0, yy, MW * 0.012, lw); octx.fillText(t, MW * 0.016, yy + MW / 140);
  });
  if (B.guides) {
    const cw = MW / B.rx, ch = MH / B.ry;
    octx.lineWidth = lw; octx.strokeStyle = 'rgba(0,0,0,0.18)';
    octx.beginPath();
    for (let i = 1; i < B.rx; i++) { octx.moveTo(i * cw, 0); octx.lineTo(i * cw, MH); }
    for (let i = 1; i < B.ry; i++) { octx.moveTo(0, i * ch); octx.lineTo(MW, i * ch); }
    octx.stroke();
    octx.strokeStyle = 'rgba(0,0,0,0.28)'; octx.setLineDash([9, 9]); octx.beginPath();
    if (B.mx) for (let i = 0; i < B.rx; i++) { octx.moveTo((i + 0.5) * cw, 0); octx.lineTo((i + 0.5) * cw, MH); }
    if (B.my) for (let i = 0; i < B.ry; i++) { octx.moveTo(0, (i + 0.5) * ch); octx.lineTo(MW, (i + 0.5) * ch); }
    octx.stroke(); octx.setLineDash([]);
  }
  // cut line + cut-away zone
  octx.fillStyle = B.tool === 'cut' ? 'rgba(255,255,255,0.72)' : 'rgba(255,255,255,0.5)';
  octx.beginPath(); octx.moveTo(0, MH);
  for (let i = 0; i <= CUTN; i++) octx.lineTo(i / CUTN * MW, cut[i % CUTN] * MH);
  octx.lineTo(MW, MH); octx.closePath(); octx.fill();
  octx.strokeStyle = '#141414'; octx.lineWidth = lw * (B.tool === 'cut' ? 2.2 : 1.3); octx.beginPath();
  for (let i = 0; i <= CUTN; i++) { const x = i / CUTN * MW, y = cut[i % CUTN] * MH; i ? octx.lineTo(x, y) : octx.moveTo(x, y); }
  octx.stroke();

  if (hoverP) {
    const t = curTool();
    octx.setLineDash([]);
    if (t === 'cut') {
      octx.strokeStyle = 'rgba(0,0,0,0.35)'; octx.lineWidth = lw;
      octx.beginPath(); octx.moveTo(hoverP.x, 0); octx.lineTo(hoverP.x, MH); octx.moveTo(0, hoverP.y); octx.lineTo(MW, hoverP.y); octx.stroke();
      if (B.mx) { octx.beginPath(); octx.moveTo(MW - hoverP.x, 0); octx.lineTo(MW - hoverP.x, MH); octx.stroke(); }
      octx.strokeStyle = '#141414'; octx.lineWidth = lw * 1.5; circle(hoverP.x, hoverP.y, 7);
      return;
    }
    const pick = t === 'pick';
    const r = pick ? 9 : Math.max(2, B.size / 2);
    octx.strokeStyle = 'rgba(255,255,255,0.9)'; octx.lineWidth = lw * 2.6; circle(hoverP.x, hoverP.y, r);
    octx.strokeStyle = '#141414'; octx.lineWidth = lw; circle(hoverP.x, hoverP.y, r);
    if (!pick && B.hard < 0.95 && r > 6) { octx.strokeStyle = 'rgba(0,0,0,0.35)'; octx.setLineDash([4, 5]); circle(hoverP.x, hoverP.y, Math.max(1, r * B.hard)); octx.setLineDash([]); }
    if (pick) { octx.beginPath(); octx.moveTo(hoverP.x - 16, hoverP.y); octx.lineTo(hoverP.x + 16, hoverP.y); octx.moveTo(hoverP.x, hoverP.y - 16); octx.lineTo(hoverP.x, hoverP.y + 16); octx.stroke(); }
    if (!pick && B.guides && (B.mx || B.my || B.rx > 1 || B.ry > 1) && !stroking) {
      const s0 = sym; sym = makeSym(hoverP);
      octx.strokeStyle = 'rgba(0,0,0,0.25)'; octx.lineWidth = lw;
      for (const tr of sym.T) { const [x, y] = applyT(tr, hoverP.x, hoverP.y); circle(x, y, r); }
      sym = s0;
    }
  }
}

/* palette */
const PAL = ['#f4f4f2', '#e4e4e2', GROUND, '#b8b8b5', '#8e8e8b', '#62625f', '#3a3a38', '#1b1a18',
  '#1f2a4f', '#2b3f7a', '#4f6ea8', '#a9c1dd', '#5b1f1c', '#9e2b25', '#c2413a', '#d98c8c',
  '#e8b9b0', '#d0672f', '#c98f2a', '#e2b84a', '#3f6b4f', '#8fa98b', '#4a2a45', '#a99ac0'];
const palEl = $('pal');
PAL.forEach(c => {
  const b = document.createElement('button'); b.style.background = c; b.dataset.c = c;
  b.title = c === GROUND ? 'Grundgrau' : c;
  b.onclick = () => setColor(c);
  palEl.appendChild(b);
});
function setColor(c) {
  B.color = c; $('curCol').style.background = c; $('curHex').textContent = c; $('custom').value = c;
  palEl.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.c === c));
}
$('custom').oninput = e => setColor(e.target.value);
setColor(B.color);

$('undo').onclick = undo;
$('sample').onclick = drawSample;
$('clear').onclick = greyPaint;
$('imgBtn').onclick = () => $('imgFile').click();
$('imgFile').onchange = e => {
  const f = e.target.files[0]; if (!f) return;
  const img = new Image();
  img.onload = () => { pushUndo(); bctx.drawImage(img, 0, 0, MW, MH); composite(); URL.revokeObjectURL(img.src); };
  img.src = URL.createObjectURL(f);
};
$('savePng').onclick = () => { const a = document.createElement('a'); a.download = 'ikat_map.png'; a.href = pc.toDataURL('image/png'); a.click(); };
$('dockBtn').onclick = () => { P_.dock = !P_.dock; $('dockBtn').classList.toggle('on', P_.dock); resize(); };
window.addEventListener('keydown', e => {
  if (e.key === 'Alt') { altDown = true; drawOverlay(); }
  if (e.target.tagName === 'INPUT' && e.target.type !== 'range') return;
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); B.tool === 'cut' ? undoCut() : undo(); }
  if (e.key === '[' || e.key === ']') {
    B.size = clamp(B.size + (e.key === ']' ? 8 : -8), 1, 320); $('size').value = B.size; $('sizeV').textContent = B.size; drawOverlay();
  }
});
window.addEventListener('keyup', e => { if (e.key === 'Alt') { altDown = false; drawOverlay(); } });
window.addEventListener('blur', () => { altDown = false; });

bctx.fillStyle = GROUND; bctx.fillRect(0, 0, MW, MH); composite();

/* ---------- cut presets ---------- */
function presetCut(name) {
  cutUndo.push(cut.slice());
  const chin = headC.y - headR.y * 0.75, brow = headC.y + headR.y * 0.22;
  for (let i = 0; i < CUTN; i++) {
    const u = i / CUTN, c = Math.cos(u * TAU);       // c = 1 at the back, -1 at the front
    let y;
    switch (name) {
      case 'long': y = -1.45 - 0.18 * c; break;
      case 'shoulder': y = shoulderTop - 0.12 - 0.05 * c; break;
      case 'bob': y = chin + 0.03 + 0.09 * c; break;
      case 'pony': { const front = 1 - smooth(0.075, 0.105, Math.abs(u - 0.5)); y = (-1.35 - 0.15 * c) * (1 - front) + brow * front; break; }
      case 'v': y = -1.25 - 0.8 * c; break;
      case 'asym': y = -0.2 - 0.95 * Math.sin(u * TAU) - 0.25 * c; break;
    }
    cut[i] = clamp(yToN(y), 0, 1);
  }
  cutDirty = true; drawOverlay();
}
function smoothCut() {
  cutUndo.push(cut.slice());
  const tmp = cut.slice(), R = 6;
  for (let i = 0; i < CUTN; i++) { let s = 0, w = 0; for (let k = -R; k <= R; k++) { const g = Math.exp(-k * k / (R * R * 0.5)); s += tmp[(i + k + CUTN) % CUTN] * g; w += g; } cut[i] = s / w; }
  cutDirty = true; drawOverlay();
}
function undoCut() { const c = cutUndo.pop(); if (c) { cut.set(c); cutDirty = true; drawOverlay(); } }
document.querySelectorAll('[data-cut]').forEach(b => b.onclick = () => {
  if (b.dataset.cut === 'pony' && !P_.pony) { P_.pony = true; $('tPony').classList.add('on'); presetCut('pony'); build(); return; }
  presetCut(b.dataset.cut);
});
$('cutSmooth').onclick = smoothCut;
$('cutUndo').onclick = undoCut;

/* =========================================================
   Hair materials
   ========================================================= */
const ribbonVS = `
  attribute vec3 aTan; attribute float aSide; attribute float aT; attribute float aShade;
  uniform float uWidth; uniform float uWScale;
  varying vec2 vUv; varying vec3 vTan; varying vec3 vView; varying float vT; varying float vShade; varying vec3 vWorld;
  void main(){
    vec3 wp = (modelMatrix*vec4(position,1.0)).xyz;
    vec3 V = normalize(cameraPosition - wp);
    vec3 T = normalize(aTan);
    vec3 S = cross(T, V); float sl = length(S);
    S = sl > 1e-4 ? S/sl : vec3(1.0,0.0,0.0);
    wp += S * aSide * uWidth * uWScale * mix(1.0, 0.35, aT*aT);
    vUv = uv; vTan = T; vView = V; vT = aT; vShade = aShade; vWorld = wp;
    gl_Position = projectionMatrix * viewMatrix * vec4(wp,1.0);
  }`;

// textureLod everywhere: no implicit derivatives (fixes X3595 warning in the PCF loop)
const hairMat = new THREE.ShaderMaterial({
  side: THREE.DoubleSide,
  uniforms: {
    uMap: { value: tex }, uWidth: { value: 0.0015 }, uWScale: { value: 1 }, uBlur: { value: P_.blur },
    uL: { value: new THREE.Vector3(0, 1, 0) }, uSun: { value: P_.sun },
    uShadow: { value: null }, uShadowMat: { value: new THREE.Matrix4() }, uShadowOn: { value: 0 }, uTexel: { value: 1 / 2048 }
  },
  vertexShader: ribbonVS,
  fragmentShader: `
    #include <packing>
    uniform sampler2D uMap; uniform float uBlur; uniform vec3 uL; uniform float uSun;
    uniform sampler2D uShadow; uniform mat4 uShadowMat; uniform float uShadowOn; uniform float uTexel;
    varying vec2 vUv; varying vec3 vTan; varying vec3 vView; varying float vT; varying float vShade; varying vec3 vWorld;

    float shadowAt(vec3 wp){
      vec4 sc = uShadowMat * vec4(wp, 1.0); sc.xyz /= sc.w;
      float inside = step(0.0, sc.x) * step(sc.x, 1.0) * step(0.0, sc.y) * step(sc.y, 1.0) * step(sc.z, 1.0);
      float z = sc.z - 0.0012, s = 0.0;
      for (int i = -2; i <= 2; i++) for (int j = -2; j <= 2; j++)
        s += step(z, unpackRGBAToDepth(textureLod(uShadow, sc.xy + vec2(float(i), float(j)) * uTexel * 1.3, 0.0)));
      return mix(1.0, s / 25.0, inside * uShadowOn);
    }
    void main(){
      vec3 c = textureLod(uMap, vUv, 0.0).rgb * 0.36
             + (textureLod(uMap, vUv + vec2(0.0, uBlur*0.5), 0.0).rgb + textureLod(uMap, vUv - vec2(0.0, uBlur*0.5), 0.0).rgb) * 0.22
             + (textureLod(uMap, vUv + vec2(0.0, uBlur), 0.0).rgb + textureLod(uMap, vUv - vec2(0.0, uBlur), 0.0).rgb) * 0.10;
      vec3 T = normalize(vTan), V = normalize(vView), Lg = normalize(uL);
      float tl = dot(T, Lg); float diff = sqrt(max(0.0, 1.0 - tl*tl));
      vec3 H = normalize(Lg + V); float th = dot(T, H);
      float spec = pow(sqrt(max(0.0, 1.0 - th*th)), 80.0);
      float sh = shadowAt(vWorld);
      float sunK = uSun / 2.4;
      float root = mix(0.78, 1.0, smoothstep(0.0, 0.06, vT));
      float light = 0.40 + 0.62 * diff * mix(0.2, 1.0, sh) * sunK;
      vec3 col = c * light * vShade * root + vec3(spec * 0.2 * sh * sunK);
      gl_FragColor = vec4(col, 1.0);
      #include <colorspace_fragment>
    }`
});
const hairDepthMat = new THREE.ShaderMaterial({
  side: THREE.DoubleSide,
  uniforms: { uWidth: hairMat.uniforms.uWidth, uWScale: { value: 2.2 } },
  vertexShader: ribbonVS,
  fragmentShader: `#include <packing>
    void main(){ gl_FragColor = packDepthToRGBA(gl_FragCoord.z); }`
});

/* scalp: painted map projected on the head where hair grows (fills gaps between roots) */
const scalpMat = new THREE.ShaderMaterial({
  uniforms: { uMap: { value: tex }, uHL: { value: 0 }, uC: { value: headC }, uR: { value: headR }, uYTop: { value: 1.5 }, uYBot: { value: FLOOR }, uL: hairMat.uniforms.uL },
  vertexShader: `varying vec3 vD; void main(){ vD = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
  fragmentShader: `
    uniform sampler2D uMap; uniform float uHL; uniform vec3 uC; uniform vec3 uR; uniform float uYTop; uniform float uYBot; uniform vec3 uL;
    varying vec3 vD;
    void main(){
      vec3 d = normalize(vD);
      float rr = max(length(d.xz), 1e-4); float c = d.z / rr;
      float hy = 0.22 + 0.35*c - 0.17*c*c + uHL;
      if (d.y < hy + 0.015) discard;
      float az = atan(d.x, d.z);
      float wy = uC.y + d.y * uR.y;
      vec2 uv = vec2(az / 6.2831853 + 0.5, 1.0 - (uYTop - wy) / (uYTop - uYBot));
      vec3 col = textureLod(uMap, uv, 0.0).rgb;
      vec3 n = normalize(d / uR);
      float lam = 0.38 + 0.4 * max(0.0, dot(n, normalize(uL)));
      gl_FragColor = vec4(col * lam * 0.85, 1.0);
      #include <colorspace_fragment>
    }`
});
const scalp = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 40), scalpMat);
headGrp.add(scalp);

function placeSun() {
  const a = P_.sunAng * Math.PI / 180;
  sun.position.set(Math.sin(a) * 6.5 * 0.5 + 0.3, Math.cos(a) * 7, 1.2);
  sun.target.position.set(0, -0.3, 0);
  sun.intensity = P_.sun;
  hairMat.uniforms.uL.value.copy(sun.position).normalize();
  hairMat.uniforms.uSun.value = P_.sun;
}
placeSun();

/* =========================================================
   Hair: roots on the scalp, rest drape, cut, simulation
   ========================================================= */
let S = 0, P = null, V, PR;
let guard, sideSg, rootDir, inflate, rank, rLen, rShift, rU, rPh, rShade, bundleOf, slen, segLen, windW;
let prof = null;              // rest drape: S × ND × 3
let mesh = null, geo = null, aPos, aTan;
let time = 0, cutDirty = false;
const CORR = new Float32Array(N * 3);
const TPOW = new Float32Array(N); for (let i = 0; i < N; i++) TPOW[i] = Math.pow(i / (N - 1), 0.8);

function makeRoots() {
  const target = P_.spl, GA = Math.PI * (3 - Math.sqrt(5));
  const keep = (y, x, z) => { const rr = Math.hypot(x, z) || 1e-6; return y > hyAt(z / rr); };
  let M = Math.round(target * 2.6);
  for (let it = 0; it < 3; it++) {
    let k = 0;
    for (let i = 0; i < M; i++) { const y = 1 - 2 * (i + 0.5) / M, r = Math.sqrt(1 - y * y), ph = i * GA; if (keep(y, Math.cos(ph) * r, Math.sin(ph) * r)) k++; }
    M = Math.max(50, Math.round(M * target / Math.max(1, k)));
  }
  const rnd = mulberry32(11 + target), jit = Math.sqrt(4 * Math.PI / M) * 0.35;
  const pts = [];
  for (let i = 0; i < M; i++) {
    const y0 = 1 - 2 * (i + 0.5) / M, r0 = Math.sqrt(1 - y0 * y0), ph = i * GA;
    let x = Math.cos(ph) * r0 + (rnd() - 0.5) * jit, y = y0 + (rnd() - 0.5) * jit, z = Math.sin(ph) * r0 + (rnd() - 0.5) * jit;
    const l = Math.hypot(x, y, z); x /= l; y /= l; z /= l;
    if (keep(y, x, z)) pts.push([x, y, z]);
  }
  return pts;
}

function combDir(d, out) {
  const [x, y, z] = d;
  // ellipsoid normal
  let nx = x / headR.x, ny = y / headR.y, nz = z / headR.z; const nl = Math.hypot(nx, ny, nz); nx /= nl; ny /= nl; nz /= nl;
  const side = x - P_.part, s = side >= 0 ? 1 : -1;
  const front = smooth(-0.3, 0.5, z);
  let gx = s * 1.6 * front * (0.4 + 0.6 * smooth(0.0, 0.25, Math.abs(side) + 0.05)), gy = -1, gz = -0.3;
  if (P_.pony) { const pw = smooth(0.2, 0.55, z) * smooth(0.1, 0.45, y); gx = gx * (1 - pw) + s * 0.12 * pw; gy = gy * (1 - pw) - 0.5 * pw; gz = gz * (1 - pw) + 1.2 * pw; }
  const gn = gx * nx + gy * ny + gz * nz;
  let tx = gx - nx * gn, ty = gy - ny * gn, tz = gz - nz * gn; const tl = Math.hypot(tx, ty, tz) || 1;
  tx /= tl; ty /= tl; tz /= tl;
  const lift = 0.22 + 0.25 * P_.vol * 0.5;
  let dx = tx + nx * lift, dy = ty + ny * lift, dz = tz + nz * lift; const dl = Math.hypot(dx, dy, dz);
  out[0] = dx / dl; out[1] = dy / dl; out[2] = dz / dl;
}

/* rest drape per strand (marching with stiffness memory + colliders) */
function computeProfiles() {
  prof = new Float32Array(S * ND * 3);
  const q = new Float32Array(3);
  for (let s = 0; s < S; s++) {
    const o = s * ND * 3, f = inflate[s];
    prof[o] = P[s * N * 3]; prof[o + 1] = P[s * N * 3 + 1]; prof[o + 2] = P[s * N * 3 + 2];
    let dx = rootDir[s * 3], dy = rootDir[s * 3 + 1], dz = rootDir[s * 3 + 2];
    for (let j = 1; j < ND; j++) {
      const k = o + j * 3, kp = k - 3;
      const w = 2.4 * Math.exp(-j * SEGD / 0.35) * (0.4 + P_.stiff * 2);
      let ox = prof[kp] - headC.x, oz = prof[kp + 2] - headC.z; const ol = Math.hypot(ox, oz) || 1; ox /= ol; oz /= ol;
      let cx = dx * w + ox * 0.03, cy = dy * w - 1, cz = dz * w + oz * 0.03; const cl = Math.hypot(cx, cy, cz) || 1;
      q[0] = prof[kp] + cx / cl * SEGD; q[1] = prof[kp + 1] + cy / cl * SEGD; q[2] = prof[kp + 2] + cz / cl * SEGD;
      collide(q, 0, f);
      if (guard[s]) faceGuard(q, 0, sideSg[s]);
      if (q[1] < FLOOR + 0.004) q[1] = FLOOR + 0.004;
      let ex = q[0] - prof[kp], ey = q[1] - prof[kp + 1], ez = q[2] - prof[kp + 2]; let el = Math.hypot(ex, ey, ez);
      if (el < 1e-6) { ex = ox; ey = 0; ez = oz; el = 1; }
      ex /= el; ey /= el; ez /= el;
      prof[k] = prof[kp] + ex * SEGD; prof[k + 1] = Math.max(FLOOR + 0.004, prof[kp + 1] + ey * SEGD); prof[k + 2] = prof[kp + 2] + ez * SEGD;
      dx = ex; dy = ey; dz = ez;
    }
  }
}
const _pa = new Float32Array(3);
function profAt(s, arc) {
  const f = clamp(arc / SEGD, 0, ND - 1 - 1e-6), j = Math.floor(f), t = f - j, k = (s * ND + j) * 3;
  _pa[0] = prof[k] + (prof[k + 3] - prof[k]) * t; _pa[1] = prof[k + 1] + (prof[k + 4] - prof[k + 1]) * t; _pa[2] = prof[k + 2] + (prof[k + 5] - prof[k + 2]) * t;
  return _pa;
}
const azU = (x, z) => Math.atan2(x - headC.x, z - headC.z) / TAU + 0.5;

/* strand length = where its rest drape crosses the cut line (cut is a line in space) */
function computeLengths() {
  for (let s = 0; s < S; s++) {
    const o = s * ND * 3, rootY = prof[o + 1];
    let L = (ND - 1) * SEGD;
    const tipAt = (x, z) => {
      const base = nToY(cutAtU(azU(x, z)));
      return rootY > base ? base + P_.stufen * rank[s] * 0.6 * (rootY - base) : base;
    };
    if (rootY <= tipAt(prof[o], prof[o + 2])) L = 0.03;
    else for (let j = 1; j < ND; j++) {
      const k = o + j * 3, y = prof[k + 1], t = tipAt(prof[k], prof[k + 2]);
      if (y <= t) { const yp = prof[k - 2], a = (yp - t) / Math.max(1e-6, yp - y); L = (j - 1 + clamp(a, 0, 1)) * SEGD; break; }
    }
    L *= 1 - P_.fray * rLen[s];
    slen[s] = Math.max(0.03, L);
    segLen[s] = slen[s] / (N - 1);
    windW[s] = Math.min(1, slen[s] / 1.2);
  }
}

function build() {
  const pts = makeRoots();
  S = pts.length;
  P = new Float32Array(S * N * 3); V = new Float32Array(S * N * 3); PR = new Float32Array(S * N * 3);
  rootDir = new Float32Array(S * 3); inflate = new Float32Array(S); rank = new Float32Array(S);
  guard = new Uint8Array(S); sideSg = new Float32Array(S);
  rLen = new Float32Array(S); rShift = new Float32Array(S); rU = new Float32Array(S); rPh = new Float32Array(S); rShade = new Float32Array(S);
  bundleOf = new Float32Array(S); slen = new Float32Array(S); segLen = new Float32Array(S); windW = new Float32Array(S);
  const rnd = mulberry32(5 + S), tmp = [0, 0, 0];
  for (let s = 0; s < S; s++) {
    const [x, y, z] = pts[s], rr = Math.hypot(x, z) || 1e-6, hy = hyAt(z / rr);
    rank[s] = clamp((y - hy) / (1 - hy), 0, 1);
    inflate[s] = 1.012 + P_.vol * (0.012 + 0.085 * rank[s]);
    combDir(pts[s], tmp);
    sideSg[s] = (x - P_.part) >= 0 ? 1 : -1;
    guard[s] = (P_.pony && z > 0.2 && y > 0.05) ? 0 : 1;
    rootDir[s * 3] = tmp[0]; rootDir[s * 3 + 1] = tmp[1]; rootDir[s * 3 + 2] = tmp[2];
    const b = s * N * 3;
    P[b] = headC.x + x * headR.x; P[b + 1] = headC.y + y * headR.y; P[b + 2] = headC.z + z * headR.z;
    rLen[s] = rnd(); rShift[s] = rnd() - 0.5; rU[s] = rnd() - 0.5; rPh[s] = rnd() * 100;
    rShade[s] = 0.84 + rnd() * 0.2;
    const az = Math.atan2(x, z) / TAU + 0.5;
    bundleOf[s] = Math.floor(az * Math.max(4, S / (P_.bundle * 3))) + 1000 * Math.floor(rank[s] * 3);
  }
  computeProfiles();
  computeLengths();
  for (let s = 0; s < S; s++) for (let i = 1; i < N; i++) {
    const p = profAt(s, i * segLen[s]), k = (s * N + i) * 3;
    P[k] = p[0]; P[k + 1] = p[1]; P[k + 2] = p[2];
  }

  if (mesh) { scene.remove(mesh); geo.dispose(); }
  const NP = S * N;
  geo = new THREE.BufferGeometry();
  aPos = new Float32Array(NP * 6); aTan = new Float32Array(NP * 6);
  const side = new Float32Array(NP * 2), at = new Float32Array(NP * 2), sh = new Float32Array(NP * 2), uv = new Float32Array(NP * 4);
  const idx = new Uint32Array(S * (N - 1) * 6);
  let q = 0;
  for (let s = 0; s < S; s++) for (let i = 0; i < N; i++) {
    const p = s * N + i;
    side[p * 2] = -1; side[p * 2 + 1] = 1;
    at[p * 2] = at[p * 2 + 1] = i / (N - 1);
    sh[p * 2] = sh[p * 2 + 1] = rShade[s];
    if (i < N - 1) { const a = p * 2, b = a + 1, c = a + 2, d = a + 3; idx[q++] = a; idx[q++] = b; idx[q++] = c; idx[q++] = b; idx[q++] = d; idx[q++] = c; }
  }
  geo.setAttribute('position', new THREE.BufferAttribute(aPos, 3).setUsage(THREE.DynamicDrawUsage));
  geo.setAttribute('aTan', new THREE.BufferAttribute(aTan, 3).setUsage(THREE.DynamicDrawUsage));
  geo.setAttribute('aSide', new THREE.BufferAttribute(side, 1));
  geo.setAttribute('aT', new THREE.BufferAttribute(at, 1));
  geo.setAttribute('aShade', new THREE.BufferAttribute(sh, 1));
  geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
  geo.setIndex(new THREE.BufferAttribute(idx, 1));
  mesh = new THREE.Mesh(geo, hairMat); mesh.frustumCulled = false;
  mesh.castShadow = true; mesh.customDepthMaterial = hairDepthMat;
  scene.add(mesh);

  scalpMat.uniforms.uHL.value = P_.hairline;
  scalpMat.uniforms.uYTop.value = yTop; scalpMat.uniforms.uYBot.value = yBot;
  updateWidth(); updateUV(); updateGeom(); drawOverlay();
}
function updateWidth() { hairMat.uniforms.uWidth.value = 0.003 * P_.thick; }
function recut() { if (!P) return; computeLengths(); updateUV(); }

/* projection mapping: each node takes the color at its rest azimuth + rest height, then ikat distortion */
function updateUV() {
  const uv = geo.attributes.uv.array, amp = P_.amp, zr = P_.zerr, H = yTop - yBot;
  for (let s = 0; s < S; s++) {
    const bShift = noise3(bundleOf[s] * 0.37 + 0.5, 0.5, 0.5) * 1.5;
    const sShift = amp * (bShift * 0.75 + rShift[s] * 0.6);
    let pu = null;
    for (let i = 0; i < N; i++) {
      const arc = i * segLen[s], p = profAt(s, arc), ph = rPh[s];
      let u = azU(p[0], p[2]);
      if (pu !== null) { while (u - pu > 0.5) u -= 1; while (u - pu < -0.5) u += 1; }
      pu = u;
      const my = (yTop - p[1]) / H + sShift + amp * 0.55 * noise3(arc * 0.9, ph, 0.3);
      const uu = u + rU[s] * zr + zr * 0.9 * noise3(arc * 1.3, ph + 11.3, 0.7);
      const o = (s * N + i) * 4;
      uv[o] = uu; uv[o + 1] = 1 - my; uv[o + 2] = uu; uv[o + 3] = 1 - my;
    }
  }
  geo.attributes.uv.needsUpdate = true;
}

function updateGeom() {
  for (let s = 0; s < S; s++) {
    const base = s * N;
    for (let i = 0; i < N; i++) {
      const p = base + i, k = p * 3;
      const ka = (base + Math.max(0, i - 1)) * 3, kb = (base + Math.min(N - 1, i + 1)) * 3;
      const o = p * 6;
      aPos[o] = aPos[o + 3] = P[k]; aPos[o + 1] = aPos[o + 4] = P[k + 1]; aPos[o + 2] = aPos[o + 5] = P[k + 2];
      aTan[o] = aTan[o + 3] = P[kb] - P[ka]; aTan[o + 1] = aTan[o + 4] = P[kb + 1] - P[ka + 1]; aTan[o + 2] = aTan[o + 5] = P[kb + 2] - P[ka + 2];
    }
  }
  geo.attributes.position.needsUpdate = true;
  geo.attributes.aTan.needsUpdate = true;
}

/* ---------- mouse in 3D ---------- */
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2(9, 9), prevNdc = new THREE.Vector2(9, 9);
let hover = false, dragging = false;
const setNdc = e => { const r = cvs.getBoundingClientRect(); ndc.set((e.clientX - r.left) / r.width * 2 - 1, -(e.clientY - r.top) / r.height * 2 + 1); };
cvs.addEventListener('pointermove', e => { setNdc(e); if (!hover) prevNdc.copy(ndc); hover = true; });
cvs.addEventListener('pointerleave', () => hover = false);
cvs.addEventListener('pointerdown', () => dragging = true);
window.addEventListener('pointerup', () => { dragging = false; prevNdc.copy(ndc); });

/* ---------- Dynamic Follow-the-Leader step ---------- */
function step(dt) {
  time += dt;
  const g = 9.8, damp = 0.982, fy = FLOOR + 0.004;
  let mOn = false, ox = 0, oy = 0, oz = 0, dx = 0, dy = 0, dz = 0, mx = 0, my = 0, mz = 0, md = 0;
  if (hover && !dragging) {
    ray.setFromCamera(ndc, camera);
    ox = ray.ray.origin.x; oy = ray.ray.origin.y; oz = ray.ray.origin.z;
    dx = ray.ray.direction.x; dy = ray.ray.direction.y; dz = ray.ray.direction.z;
    ray.setFromCamera(prevNdc, camera);
    mx = dx - ray.ray.direction.x; my = dy - ray.ray.direction.y; mz = dz - ray.ray.direction.z;
    md = Math.hypot(mx, my, mz);
    mOn = true;
  }
  prevNdc.copy(ndc);
  const rH = P_.hand, rH2 = rH * rH;

  let ts = 0, swX = 0, swZ = 0;
  if (P_.turbOn) {
    const gn = noise3(time * 0.17, 7.7, 1.1);
    const gust = 1 + P_.gust * 1.8 * Math.max(0, gn + 0.15);
    ts = P_.turb * 9 * gust;
    swX = noise3(time * 0.09, 3.3, 0.0) * P_.turb * 5 * gust;
    swZ = noise3(time * 0.07, 9.1, 4.2) * P_.turb * 3.5 * gust;
  }
  const tt = time * 0.5, stiff = P_.stiff;

  for (let s = 0; s < S; s++) {
    const base = s * N * 3, seg = segLen[s], f = inflate[s], ww = windW[s], gd = guard[s], sgn = sideSg[s];
    PR[base] = P[base]; PR[base + 1] = P[base + 1]; PR[base + 2] = P[base + 2];
    let nxv = 0, nzv = 0;

    for (let i = 1; i < N; i++) {
      const k = base + i * 3, tw = TPOW[i] * ww;
      let ax = 0, az = 0;
      if (ts > 0) {
        if ((i & 1) === 1) {
          const px = P[k], py = P[k + 1], pz = P[k + 2];
          nxv = noise3(px * 1.3 + pz * 0.4, py * 0.7 - tt * 0.6, tt) + 0.5 * noise3(px * 3.1, py * 1.9, tt * 1.7 + 5);
          nzv = noise3(px * 1.3 + 40, py * 0.7 + pz * 0.4, tt + 20) + 0.5 * noise3(pz * 3.1 + 9, py * 1.9, tt * 1.7 + 13);
        }
        ax = (nxv * ts + swX) * tw;
        az = (nzv * ts + swZ) * tw;
      }
      const vx = (V[k] + ax * dt) * damp, vy = (V[k + 1] - g * dt) * damp, vz = (V[k + 2] + az * dt) * damp;
      let qx = P[k] + vx * dt, qy = P[k + 1] + vy * dt, qz = P[k + 2] + vz * dt;
      if (mOn) {
        const wx = qx - ox, wy = qy - oy, wz = qz - oz, pj = wx * dx + wy * dy + wz * dz;
        if (pj > 0) {
          const rx = wx - dx * pj, ry = wy - dy * pj, rz = wz - dz * pj, d2 = rx * rx + ry * ry + rz * rz;
          if (d2 < rH2) {
            const dist = Math.sqrt(d2) + 1e-6, fall = 1 - dist / rH, push = (rH - dist) * 0.35 / dist;
            const mv = Math.min(1, 0.14 / (md * pj + 1e-6)) * pj * fall * 0.9;
            qx += rx * push + mx * mv; qy += ry * push + my * mv; qz += rz * push + mz * mv;
          }
        }
      }
      PR[k] = qx; PR[k + 1] = qy < fy ? fy : qy; PR[k + 2] = qz;
      collide(PR, k, f);
      if (gd) faceGuard(PR, k, sgn);
    }

    let ex0 = rootDir[s * 3], ey0 = rootDir[s * 3 + 1], ez0 = rootDir[s * 3 + 2];
    for (let i = 1; i < N; i++) {
      const k = base + i * 3, kp = k - 3, c = i * 3;
      const arc = i * seg;
      let ex = PR[k] - PR[kp], ey = PR[k + 1] - PR[kp + 1], ez = PR[k + 2] - PR[kp + 2];
      let len = Math.sqrt(ex * ex + ey * ey + ez * ez) || 1e-6;
      ex /= len; ey /= len; ez /= len;
      const st = stiff * Math.exp(-arc / 0.45) + 0.01;      // stiffness near the root in world units
      ex += (ex0 - ex) * st; ey += (ey0 - ey) * st; ez += (ez0 - ez) * st;
      len = Math.sqrt(ex * ex + ey * ey + ez * ez) || 1e-6;
      ex /= len; ey /= len; ez /= len;
      const nx = PR[kp] + ex * seg, ny = PR[kp + 1] + ey * seg, nz = PR[kp + 2] + ez * seg;
      CORR[c] = nx - PR[k]; CORR[c + 1] = ny - PR[k + 1]; CORR[c + 2] = nz - PR[k + 2];
      PR[k] = nx; PR[k + 1] = ny; PR[k + 2] = nz;
      ex0 = ex; ey0 = ey; ez0 = ez;
    }

    for (let i = 1; i < N; i++) {
      const k = base + i * 3, c = i * 3;
      let vx = (PR[k] - P[k]) / dt, vy = (PR[k + 1] - P[k + 1]) / dt, vz = (PR[k + 2] - P[k + 2]) / dt;
      if (i < N - 1) { vx -= 0.9 * CORR[c + 3] / dt; vy -= 0.9 * CORR[c + 4] / dt; vz -= 0.9 * CORR[c + 5] / dt; }
      P[k] = PR[k]; P[k + 1] = PR[k + 1]; P[k + 2] = PR[k + 2];
      collide(P, k, f);
      if (gd) faceGuard(P, k, sgn);
      if (P[k + 1] < fy) { P[k + 1] = fy; vx *= 0.7; vz *= 0.7; if (vy < 0) vy = 0; }
      V[k] = vx; V[k + 1] = vy; V[k + 2] = vz;
    }
  }
}

/* ---------- 3D cut ring (visible while the scissors are active) ---------- */
const RING = 180;
const ring = new THREE.LineLoop(new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(new Float32Array(RING * 3), 3)),
  new THREE.LineBasicMaterial({ color: 0x141414, transparent: true, opacity: 0.55, depthTest: false }));
ring.renderOrder = 12; ring.frustumCulled = false; ring.visible = false; scene.add(ring);
function updateRing() {
  const a = ring.geometry.attributes.position.array;
  for (let i = 0; i < RING; i++) {
    const u = i / RING, az = (u - 0.5) * TAU, y = nToY(cutAtU(u));
    const r = y > shoulderTop ? 0.62 : 0.7;
    a[i * 3] = headC.x + Math.sin(az) * r; a[i * 3 + 1] = y; a[i * 3 + 2] = headC.z + Math.cos(az) * r * 0.85;
  }
  ring.geometry.attributes.position.needsUpdate = true;
}

/* =========================================================
   Head loading
   ========================================================= */
function fitModel(obj) {
  modelRoot.clear();
  obj.traverse(o => {
    if (o.isMesh) { o.material = headMat; o.castShadow = true; o.receiveShadow = true; if (!o.geometry.attributes.normal) o.geometry.computeVertexNormals(); }
  });
  const box = new THREE.Box3().setFromObject(obj);
  const size = box.getSize(new THREE.Vector3()), ctr = box.getCenter(new THREE.Vector3());
  const s = 1.56 / size.y;
  obj.scale.setScalar(s);
  obj.position.set(-ctr.x * s, -0.11 - box.min.y * s, -ctr.z * s);
  modelRoot.add(obj);
  obj.updateMatrixWorld(true);
  modelBox = new THREE.Box3().setFromObject(obj);
  hasModel = true;
  $('note').style.display = 'none';
  setColliders();
  build();
}
const loader = new GLTFLoader();
loader.load('HEAD_ONLY.glb', g => fitModel(g.scene), undefined, () => {
  $('note').textContent = 'HEAD_ONLY.glb nicht gefunden (Seite über einen Server öffnen oder „Anderes GLB laden“) – Platzhalter aus Collidern aktiv.';
  $('note').style.display = 'block';
});
$('glbBtn').onclick = () => $('glbFile').click();
$('glbFile').onchange = e => {
  const f = e.target.files[0]; if (!f) return;
  f.arrayBuffer().then(buf => loader.parse(buf, '', g => fitModel(g.scene)));
};

/* =========================================================
   UI wiring
   ========================================================= */
function bindRange(id, obj, key, fmt, cb, evt = 'input') {
  const el = $(id), out = $(id + 'V');
  const show = () => out.textContent = fmt(+el.value);
  el.addEventListener(evt, () => { obj[key] = +el.value; show(); cb && cb(); });
  if (evt !== 'input') el.addEventListener('input', show);
  obj[key] = +el.value; show();
}
function bindTog(id, obj, key, cb) {
  const el = $(id);
  el.classList.toggle('on', !!obj[key]);
  el.onclick = () => { obj[key] = !obj[key]; el.classList.toggle('on', obj[key]); cb && cb(); };
}
function bindSeg(id, obj, key, cb) {
  const el = $(id);
  el.querySelectorAll('button').forEach(b => b.onclick = () => {
    obj[key] = b.dataset.v;
    el.querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b));
    cb && cb(b.dataset.v);
  });
}
function setSlider(id, obj, key, v, fmt) { obj[key] = v; $(id).value = v; $(id + 'V').textContent = fmt(v); }
const f3 = v => v.toFixed(3), f2 = v => v.toFixed(2), fi = v => String(v);

const TOOLNOTE = {
  brush: 'Malt auf den abgerollten Kopf – die Farbe wandert mit jeder Strähne nach unten.',
  pick: 'Farbe von der Fläche aufnehmen.',
  cut: 'Schnittlinie zeichnen (von links nach rechts oder einzelne Partien). Die Linie erscheint auch als Ring in 3D.'
};
function toolChanged() { $('toolNote').textContent = TOOLNOTE[B.tool]; drawOverlay(); updateHint(); }
bindSeg('toolSeg', B, 'tool', toolChanged);
const presets = {}; bindSeg('presetSeg', presets, 'p', v => {
  if (v === 'hard') { setSlider('hard', B, 'hard', 0.9, f2); setSlider('opacity', B, 'opacity', 1, f2); }
  else { setSlider('hard', B, 'hard', 0, f2); setSlider('opacity', B, 'opacity', 0.35, f2); if (B.size < 90) setSlider('size', B, 'size', 150, fi); }
  B.tool = 'brush'; $('toolSeg').querySelectorAll('button').forEach(x => x.classList.toggle('on', x.dataset.v === 'brush'));
  toolChanged();
});
bindRange('size', B, 'size', fi, drawOverlay);
bindRange('hard', B, 'hard', f2, drawOverlay);
bindRange('opacity', B, 'opacity', f2);
bindTog('tMx', B, 'mx', drawOverlay);
bindTog('tMy', B, 'my', drawOverlay);
bindRange('rx', B, 'rx', fi, drawOverlay);
bindRange('ry', B, 'ry', fi, drawOverlay);
bindTog('tGuides', B, 'guides', drawOverlay);

bindRange('stufen', P_, 'stufen', f2, () => cutDirty = true);
bindRange('fray', P_, 'fray', f3, () => cutDirty = true);

bindRange('part', P_, 'part', f2, build, 'change');
bindTog('tPony', P_, 'pony', build);
bindRange('vol', P_, 'vol', f2, build, 'change');
bindRange('hairline', P_, 'hairline', f2, build, 'change');
bindRange('spl', P_, 'spl', fi, build, 'change');
bindRange('thick', P_, 'thick', f2, updateWidth);

bindRange('amp', P_, 'amp', f3, () => P && updateUV());
bindRange('zerr', P_, 'zerr', f3, () => P && updateUV());
bindRange('blur', P_, 'blur', f3, () => hairMat.uniforms.uBlur.value = P_.blur);
bindRange('bundle', P_, 'bundle', fi, build, 'change');
bindTog('tTurb', P_, 'turbOn');
bindRange('turb', P_, 'turb', f2);
bindRange('gust', P_, 'gust', f2);
bindRange('hand', P_, 'hand', f2);
bindRange('stiff', P_, 'stiff', f2);
$('smooth').onclick = build;

bindTog('tCols', P_, 'showCols', () => colViz.visible = P_.showCols);
bindRange('shoulder', P_, 'shoulder', f2, () => { setColliders(); colViz.visible = P_.showCols; build(); }, 'change');

bindTog('tShadow', P_, 'shadow', () => { sun.castShadow = P_.shadow; floor.visible = P_.shadow; });
bindRange('sun', P_, 'sun', f2, placeSun);
bindRange('sunAng', P_, 'sunAng', v => v + '°', placeSun);

function updateHint() {
  $('hint').innerHTML = 'bewegen — durch die Haare fahren &nbsp;·&nbsp; ziehen — Kamera drehen &nbsp;·&nbsp; scrollen — Zoom';
}

/* =========================================================
   Layout / loop
   ========================================================= */
const dock = $('dock');
function resize() {
  const s = Math.round(Math.max(220, Math.min(innerHeight - 56 - 40, (innerWidth - 320) * 0.44)));
  dock.style.width = s + 'px'; pBox.style.height = s + 'px';
  dock.style.display = P_.dock ? 'block' : 'none';
  stage.style.left = (P_.dock ? 18 + s + 12 : 0) + 'px';
  const sw = stage.clientWidth, sh = stage.clientHeight;
  renderer.setSize(sw, sh); camera.aspect = sw / sh; camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
setColliders();
presetCut('long'); cutUndo.length = 0;
build();
toolChanged();

function loop() {
  if (cutDirty) { cutDirty = false; recut(); drawOverlay(); }
  ring.visible = B.tool === 'cut';
  if (ring.visible) updateRing();
  step(1 / 60);
  updateGeom();
  if (mapDirty) { tex.needsUpdate = true; mapDirty = false; }
  controls.update();
  const u = hairMat.uniforms;
  if (P_.shadow && sun.shadow.map) {
    u.uShadow.value = sun.shadow.map.texture;
    u.uShadowMat.value = sun.shadow.matrix;
    u.uTexel.value = 1 / sun.shadow.mapSize.x;
    u.uShadowOn.value = 1;
  } else u.uShadowOn.value = 0;
  renderer.render(scene, camera);
  requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>
script.js
console.log('läuft');
style.css
body {
  font-family: system-ui, sans-serif;
  margin: 2rem;
}
Assets
HEAD_ONLY.glb  (777 KB)