Zum Inhalt
← 3D & WebGL

Interaktives 3D-Objekt im Hero

Ein gedrechselter Holzkreisel als Hero-Objekt: neigt sich zum Zeiger, lässt sich mit Nachlauf drehen und läuft angeworfen mit Präzession aus.

Ein einzelnes 3D-Objekt mit inhaltlichem Bezug steht im Hero: ein Produkt, ein Logo-Körper, ein Maskottchen oder eine Metapher. Es reagiert auf den Zeiger (gelerpte Neigung), auf Ziehen (begrenzte Drehung mit Nachlauf) und auf Scroll. Studio-Licht aus einer Environment-Map und ein weicher Kontaktschatten machen es greifbar. Im Ruhezustand schwebt es leicht und dreht sich langsam. Das Material trägt die Wirkung: Holz, Glas, Chrom, Kork, Stoff oder Toon-Shading.

Die Interaktion ist sofort greifbar. Das Objekt zeigt Handwerk und lädt zum Spielen ein, ohne die Seite zu übernehmen.

Wann einsetzen

Für Produkte, für Marken mit einem ikonischen Objekt und für Studio-Portfolios.

Wann nicht

Wenn das Objekt austauschbare Deko ohne Bezug ist, etwa ein Torus oder eine Low-Poly-Kugel. Dieser Stil klingt 2026 deutlich ab.

Varianten

  • Neigen: Das Objekt kippt gelerpt zum Zeiger, dazu Schweben und langsames Eigendrehen.
  • Ziehen: Drehen per Drag mit Nachlauf, senkrecht begrenzt, federt beim Loslassen zurück (PresentationControls).
  • Physik-Spielzeug: Das Objekt wird angeworfen und läuft physikalisch aus (eigene Formel oder Rapier).
  • Ziehbarer Logo-Körper, auch im ASCII-Look.
  • Blob mit verformbarem Material (14islands Blobmixer).
  • Retro-Computer mit funktionierendem Bildschirm (Henry Heffernan).

Umsetzung

Three.js direkt oder React Three Fiber mit drei (Environment, ContactShadows, Float, PresentationControls). Das Modell bleibt komprimiert unter 1 bis 2 MB. Ein Poster-Bild steht da, bis das erste Bild gerendert ist, und bleibt ohne WebGL stehen. Die Canvas ist nur so groß wie nötig. Ruht das Objekt, wird nicht weiter gerendert (in R3F frameloop="demand"). Die Neigung wird bildratenunabhängig gelerpt: 1 − (1 − lerp)^(dt · 60).

Robust umsetzen

Telefon. Neigen per Antippen und Ziehen oder per Gyroskop (auf iOS nur nach Erlaubnis). Waagerechtes Ziehen dreht, senkrecht scrollt die Seite weiter (touch-action: pan-y). Pixelratio begrenzen. Alternativ ein statisches Rendering.

Ohne Bewegung. Kein Schweben, keine Eigendrehung, kein Nachlauf. Das Objekt steht still und dreht sich nur, wenn jemand es bewegt.

Barrierefrei. Die Canvas ist aria-hidden. Ein fokussierbares Element mit role="img" und aria-label beschreibt das Objekt. Ziehen ist nicht das einzige Bedienmittel: Pfeiltasten drehen und neigen, die Leertaste wirft an. Der Bedienhinweis steht als Text daneben.

Code

<!DOCTYPE html>
<!--
  Interaktives 3D-Objekt im Hero
  Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/interaktives-3d-hero/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Interaktives 3D-Objekt im Hero · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }

:root {
  --grund: #ebe5da; --tief: #e2dbcd; --text: #1c1915; --leise: rgba(28, 25, 21, .66); --linie: rgba(28, 25, 21, .16);
  --lack: #b5382a;
  --kurve: cubic-bezier(0.16, 1, 0.3, 1);   /* expo.out für Ein- und Umblenden */
  --rand: clamp(20px, 4vw, 56px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }

/* ---------- Bühne ---------- */
.buehne { position: relative; height: 100svh; min-height: 620px; overflow: hidden; isolation: isolate; touch-action: pan-y;
  background: radial-gradient(60% 70% at 70% 52%, #f4efe6 0%, var(--grund) 60%, var(--tief) 100%); }
.buehne canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; display: block; opacity: 0; transition: opacity 1.2s var(--kurve); }
.gl-bereit .buehne canvas { opacity: 1; }

.kopf { position: absolute; z-index: 3; inset: 0 0 auto; display: flex; justify-content: space-between; align-items: center; padding: clamp(18px, 3vw, 34px) var(--rand); }
.marke { font-size: 17px; font-weight: 600; letter-spacing: -.01em; }
.marke span { font: italic 400 19px/1 'Instrument Serif', serif; color: var(--leise); margin-left: 5px; letter-spacing: 0; }
.kopf nav { display: flex; gap: 28px; }
.kopf nav a { color: var(--leise); text-decoration: none; transition: color .15s linear; }
.kopf nav a:hover, .kopf nav a:focus-visible { color: var(--text); }

/* Senkrechte Hilfslinien wie auf einer Werkzeichnung */
.raster { position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background: linear-gradient(90deg, transparent calc(50% - .5px), var(--linie) calc(50% - .5px), var(--linie) calc(50% + .5px), transparent calc(50% + .5px)); opacity: .55; }

.titel { position: absolute; z-index: 2; left: var(--rand); top: 50%; translate: 0 -58%; max-width: 52%; }
.titel .mono { margin-bottom: 22px; }
h1 { font-family: 'Instrument Serif', serif; font-weight: 400; font-size: clamp(64px, 8.6vw, 148px); line-height: .9; letter-spacing: -.03em; }
h1 em { display: block; padding-left: .55em; color: var(--lack); }

/* Maßangaben neben dem Objekt */
.mass { position: absolute; z-index: 2; right: var(--rand); top: 50%; translate: 0 -50%; display: grid; gap: 14px; text-align: right; }
.mass b { display: block; color: var(--text); font-weight: 500; margin-top: 3px; }

.griff { position: absolute; z-index: 2; left: 55%; top: 18%; width: 30%; height: 66%; border-radius: 40%; outline: none; cursor: grab; }
.griff:focus-visible { box-shadow: 0 0 0 1px var(--text) inset; }
.ziehen .griff:active { cursor: grabbing; }
.hinweis { position: absolute; z-index: 2; left: 70%; translate: -50% 0; bottom: 21%; white-space: nowrap; display: flex; align-items: center; gap: 10px; transition: opacity .6s var(--kurve); }
.hinweis i { width: 18px; height: 1px; background: currentColor; display: block; }
.laufzeit { font-variant-numeric: tabular-nums; color: var(--text); }

.fuss { position: absolute; z-index: 3; inset: auto 0 0; display: flex; justify-content: space-between; align-items: end; gap: 24px; padding: clamp(18px, 3vw, 34px) var(--rand); }
.text p { max-width: 36ch; font-size: 15px; line-height: 1.5; color: var(--leise); }
.text a { display: inline-flex; gap: 10px; align-items: center; margin-top: 14px; color: var(--text); text-decoration: none; font-size: 15px; border-bottom: 1px solid var(--linie); padding-bottom: 4px; transition: border-color .15s linear; }
.text a:hover, .text a:focus-visible { border-color: var(--text); }
.steuer { display: grid; justify-items: end; gap: 12px; }
.modi { display: flex; gap: 6px; }
/* Bewegung anhalten (WCAG 2.2.2): das Objekt schwebt ohne Zutun weiter */
.steuer-zeile { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px 14px; }
.halt { color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: border-color .15s linear, color .15s linear; }
.halt:hover { color: var(--text); border-color: var(--leise); }
.halt[aria-pressed='true'] { color: var(--text); border-color: var(--text); }
.halt:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.vorschau .halt { display: none; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: rgba(235, 229, 218, .6); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: border-color .15s linear; }
.modi button:hover { color: var(--text); border-color: var(--leise); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 16px; max-width: 500px; }
.werte b { color: var(--text); font-weight: 500; }

/* Poster: steht da, bis WebGL das erste Bild gemalt hat, und bleibt ohne WebGL stehen */
.poster { position: absolute; z-index: 1; left: 70%; top: 50%; width: clamp(180px, 19vw, 300px); translate: -50% -50%; transition: opacity .8s var(--kurve); }
.gl-bereit .poster { opacity: 0; }

/* Gedachter Zeiger in der Vorführung */
.zeiger { position: absolute; z-index: 4; left: 0; top: 0; width: 28px; height: 28px; margin: -14px 0 0 -14px; border: 1px solid rgba(28, 25, 21, .75); border-radius: 50%; pointer-events: none; display: none; transition: scale .15s linear, background-color .15s linear; }
.zeiger.drueckt { scale: .7; background: rgba(28, 25, 21, .18); }
.vorfuehrung .zeiger { display: block; }

/* ---------- Unter der Bühne ---------- */
.mehr { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(20px, 4vw, 56px); padding: clamp(56px, 8vw, 112px) var(--rand); border-top: 1px solid var(--linie); }
.mehr h2 { font: italic 400 34px/1 'Instrument Serif', serif; margin: 10px 0 12px; letter-spacing: -.01em; }
.mehr p:not(.mono) { color: var(--leise); font-size: 15px; line-height: 1.55; max-width: 36ch; }

.vorschau body { overflow: hidden; }
.vorschau .mehr, .vorschau .kopf nav, .vorschau .text, .vorschau .mass { display: none; }
.vorschau .buehne { height: 100vh; }
.vorschau .fuss { justify-content: flex-end; }

:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

@media (max-width: 1100px) { .mass { display: none; } }
@media (max-width: 760px) {
  .buehne { min-height: 700px; background: radial-gradient(90% 50% at 50% 30%, #f4efe6 0%, var(--grund) 60%, var(--tief) 100%); }
  .kopf nav, .raster { display: none; }
  .titel { top: auto; bottom: 232px; translate: none; max-width: none; right: var(--rand); }
  .titel .mono { margin-bottom: 14px; }
  h1 { font-size: 17vw; }
  .griff { left: 22%; top: 11%; width: 56%; height: 34%; }
  .hinweis { left: 50%; bottom: auto; top: calc(11% + 34% + 4px); }
  .poster { left: 50%; top: 29%; width: 44vw; }
  .fuss { flex-direction: column; align-items: stretch; gap: 16px; }
  .text p { font-size: 14px; }
  .text a { display: none; }
  .steuer { justify-items: start; }
  .steuer-zeile { justify-content: flex-start; }
  .werte { justify-content: flex-start; }
  .mehr { grid-template-columns: 1fr; }
}
@media (max-height: 700px) and (min-width: 761px) { .text p { display: none; } }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}
</style>
</head>
<body>
<section class="buehne" data-buehne aria-labelledby="titel">
  <div class="raster" aria-hidden="true"></div>
  <header class="kopf">
    <p class="marke">Kranich<span>Drechslerei</span></p>
    <nav class="mono" aria-label="Hauptmenü"><a href="#werkstatt">Werkstatt</a><a href="#laden">Laden</a><a href="#besuch">Besuch</a></nav>
  </header>

  <div class="titel">
    <p class="mono">Kreisel Nr. 7 · Seiffen, Erzgebirge</p>
    <h1 id="titel">Aus einem Stück <em>gedreht.</em></h1>
  </div>

  <dl class="mass mono">
    <div><dt>Holz</dt><dd><b>Bergahorn</b></dd></div>
    <div><dt>Ø</dt><dd><b>52 mm</b></dd></div>
    <div><dt>Gewicht</dt><dd><b>41 g</b></dd></div>
    <div><dt>Oberfläche</dt><dd><b>Leinöl, Lack</b></dd></div>
  </dl>

  <svg class="poster" data-poster viewBox="0 0 200 240" aria-hidden="true">
    <defs>
      <linearGradient id="holz" x1="0" x2="1"><stop offset="0" stop-color="#9c7347"/><stop offset=".38" stop-color="#e6c79c"/><stop offset=".55" stop-color="#d8b282"/><stop offset="1" stop-color="#8a6239"/></linearGradient>
      <linearGradient id="lack" x1="0" x2="1"><stop offset="0" stop-color="#7d2318"/><stop offset=".4" stop-color="#d0503c"/><stop offset="1" stop-color="#6e1d14"/></linearGradient>
      <radialGradient id="schatten"><stop offset="0" stop-color="#1c1915" stop-opacity=".35"/><stop offset="1" stop-color="#1c1915" stop-opacity="0"/></radialGradient>
      <clipPath id="koerper"><path d="M100 230 113 213 139 185 168 157 188 138 190 129 186 119 168 110 140 101 114 96 111 87 111 48 114 37 113 26 108 14 100 12 92 14 87 26 86 37 89 48 89 87 86 96 60 101 32 110 14 119 10 129 12 138 32 157 61 185 87 213Z"/></clipPath>
    </defs>
    <ellipse cx="100" cy="232" rx="62" ry="7" fill="url(#schatten)"/>
    <g clip-path="url(#koerper)">
      <rect width="200" height="240" fill="url(#holz)"/>
      <rect y="116" width="200" height="25" fill="url(#lack)"/>
      <rect y="102" width="200" height="3" fill="#2f5d46"/>
      <rect y="12" width="200" height="22" fill="url(#lack)"/>
      <rect y="214" width="200" height="20" fill="#6b4a2c"/>
    </g>
  </svg>

  <canvas data-canvas aria-hidden="true"></canvas>
  <div class="griff" data-griff tabindex="0" role="img" aria-roledescription="3D-Objekt" aria-label="Kreisel Nr. 7 aus Bergahorn mit rotem Lackring. Pfeiltasten drehen ihn, Leertaste wirft ihn an." aria-describedby="hinweis"></div>
  <p class="hinweis mono" id="hinweis" data-hinweis><i></i><span data-hinweis-text>Zeiger bewegen</span></p>

  <div class="fuss">
    <div class="text">
      <p>Seit 1908 drechseln wir in Seiffen Spielzeug, das Generationen übersteht. Gut angeworfen läuft Nr. 7 länger als eine Viertelminute.</p>
      <a href="#laden">Im Laden ansehen <span aria-hidden="true">→</span></a>
    </div>
    <div class="steuer">
      <div class="steuer-zeile">
        <div class="modi mono" role="group" aria-label="Interaktion wählen" data-modi>
          <button type="button" data-modus="neigen" aria-pressed="true">Neigen</button>
          <button type="button" data-modus="ziehen" aria-pressed="false">Ziehen</button>
          <button type="button" data-modus="anwerfen" aria-pressed="false">Anwerfen</button>
        </div>
        <button type="button" class="halt mono" data-halt aria-pressed="false" aria-label="Bewegung anhalten">Anhalten</button>
      </div>
      <p class="werte mono" data-werte aria-live="polite"></p>
    </div>
  </div>
  <div class="zeiger" data-zeiger aria-hidden="true"></div>
</section>

<section class="mehr" id="werkstatt">
  <div><p class="mono">01 · Rohling</p><h2>Bergahorn</h2><p>Hart, hell und feinporig. Wir lagern das Holz drei Jahre luftig unter dem Dach, bevor es auf die Drehbank kommt.</p></div>
  <div><p class="mono">02 · Drehbank</p><h2>Ein Stück</h2><p>Körper, Hals und Griff entstehen aus einem einzigen Kantel. Nichts ist geklebt, darum läuft er so ruhig.</p></div>
  <div id="laden"><p class="mono">03 · Oberfläche</p><h2>Lackring</h2><p>Der rote Ring wird auf der laufenden Bank aufgetragen. Zum Schluss reiben wir alles mit Leinöl ein.</p></div>
</section>

<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)').matches;
const buehne = document.querySelector('[data-buehne]');
const canvas = document.querySelector('[data-canvas]');
const griff = document.querySelector('[data-griff]');
const zeiger = document.querySelector('[data-zeiger]');
const werteEl = document.querySelector('[data-werte]');
const hinweisText = document.querySelector('[data-hinweis-text]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];

/* ---------- Werte (meta.json → messwerte: Lerp 0.05–0.1, Float speed 1–2, floatIntensity 0.5–1, Neigung max. 10–20°) ---------- */
const GRAD = Math.PI / 180;
const W = {
  lerp: 0.08,                // Cursor-Neigung, je Bild bei 60 Bildern/s, auf echte Bildrate umgerechnet
  neigungMax: 15 * GRAD,     // Neigung zum Zeiger
  floatSpeed: 1.5,           // wie drei <Float speed>
  floatIntensity: 0.8,       // wie drei <Float floatIntensity>
  floatRotation: 0.5,        // wie drei <Float rotationIntensity>
  schwebeHoehe: 0.22,        // Abstand über dem Boden beim Schweben
  idle: 0.2,                 // langsames Eigendrehen in rad/s
  zugRadProPx: 0.006,        // Ziehen: Drehung je Pixel
  traegheit: 0.95,           // Ziehen: Nachlauf je Bild (60 Bilder/s)
  polarMax: 20 * GRAD,       // Ziehen: senkrechte Neigung begrenzt
  feder: { masse: 1, spannung: 170, reibung: 26 },   // Zurückfedern (drei PresentationControls, snap)
  omegaMax: 18,              // Anwerfen: Startdrehzahl in rad/s
  reibA: 0.15, reibB: 0.3,   // Anwerfen: dω/dt = −(0.15·ω + 0.3)
  praezession: 6,            // Anwerfen: Präzession in rad/s = 6 / ω
  kippMax: 17 * GRAD,        // Anwerfen: größte Schräglage beim Auslaufen
  taumel: 7 * GRAD, taumelS: 1.0, taumelPraez: 5,   // Anwerfen: Anwurf-Taumeln (7°, +5 rad/s Präzession), klingt mit e^(−t/1 s) ab
  scrollDreh: Math.PI,       // Scroll: halbe Umdrehung über eine Bühnenhöhe
};
const MODI = {
  neigen: { hinweis: beruehrung ? 'Antippen und ziehen' : 'Zeiger bewegen' },
  ziehen: { hinweis: 'Seitlich ziehen' },
  anwerfen: { hinweis: beruehrung ? 'Wischen zum Anwerfen' : 'Klicken oder wischen' },
};
let modus = 'neigen';

function zeigeWerte(laufzeit) {
  let s = '';
  if (reduziert()) s = `<span>Bewegung <b>aus</b></span><span>Drehen <b>nur per Hand</b></span><span>Float <b>aus</b></span>`;
  else if (modus === 'neigen') s = `<span>lerp <b>${W.lerp}</b></span><span>neigung <b>max 15°</b></span><span>float speed <b>${W.floatSpeed}</b></span><span>intensity <b>${W.floatIntensity}</b></span><span>idle <b>${W.idle} rad/s</b></span>`;
  else if (modus === 'ziehen') s = `<span>drag <b>${W.zugRadProPx} rad/px</b></span><span>trägheit <b>${W.traegheit}</b></span><span>polar <b>±20°</b></span><span>feder <b>170 / 26</b></span>`;
  else s = `<span>ω <b>${W.omegaMax} rad/s</b></span><span>reibung <b>${W.reibA}ω + ${W.reibB}</b></span><span>präzession <b>${W.praezession}/ω</b></span><span>taumel <b>7°</b></span><span>laufzeit <b class="laufzeit" data-laufzeit>${laufzeit || '0,0 s'}</b></span>`;
  werteEl.innerHTML = s;
}
function knopfZustand() { knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === modus))); }
function zeigeHinweis() { hinweisText.textContent = reduziert() ? 'Pfeiltasten oder ziehen' : MODI[modus].hinweis; }

/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2') || probe.getContext('webgl')) {
  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}

let neustart = () => {}, vorfuehren = () => {}, pause = () => {}, weiter = () => {};
let setzeModus = m => { modus = m; html.classList.toggle('ziehen', m === 'ziehen'); knopfZustand(); zeigeWerte(); zeigeHinweis(); };
let stoppeVorfuehrung = () => {};
knoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(k.dataset.modus); }));

if (!renderer) {
  html.classList.add('ohne-webgl');
  setzeModus('neigen');
} else {
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.0;
  renderer.setClearColor(0x000000, 0);

  const szene = new THREE.Scene();
  const kamera = new THREE.PerspectiveCamera(26, 1, 0.1, 60);

  /* Studio-Umgebung statt HDRI-Datei: heller Raum mit Softboxen, über PMREM vorgefiltert */
  const pmrem = new THREE.PMREMGenerator(renderer);
  const studio = new THREE.Scene();
  studio.add(new THREE.Mesh(new THREE.SphereGeometry(20, 24, 12), new THREE.MeshBasicMaterial({ color: 0x4a443c, side: THREE.BackSide })));
  const softbox = (w, h, farbe, staerke, pos) => {
    const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(farbe).multiplyScalar(staerke), side: THREE.DoubleSide }));
    m.position.set(...pos); m.lookAt(0, 0, 0); studio.add(m);
  };
  softbox(10, 3, '#fff6ea', 4, [0, 9, 3]);        // Deckenlicht
  softbox(3, 10, '#ffe2c2', 3.2, [-9, 2, 5]);     // warme Kante links
  softbox(2, 8, '#dfe8ff', 2.2, [9, 1, -2]);      // kühles Streiflicht rechts hinten
  softbox(12, 2, '#ebe5da', 1.2, [0, -6, 6]);     // Aufhellung vom Tisch
  szene.environment = pmrem.fromScene(studio, 0.03).texture;
  const sonne = new THREE.DirectionalLight('#fff3e2', 1.6); sonne.position.set(-3, 5, 4); szene.add(sonne);

  /* ---------- Der Kreisel: Drehkörper aus einem Profil, Holz und Lack als gemalte Textur ---------- */
  const profil = new THREE.SplineCurve([
    [0, 0], [0.03, 0.025], [0.13, 0.13], [0.36, 0.33], [0.62, 0.52], [0.79, 0.645], [0.83, 0.71], [0.8, 0.775],
    [0.64, 0.855], [0.38, 0.915], [0.15, 0.955], [0.105, 1.0], [0.1, 1.2], [0.105, 1.3], [0.135, 1.38], [0.125, 1.46], [0.075, 1.535], [0, 1.555],
  ].map(([x, y]) => new THREE.Vector2(x, y)));
  const punkte = profil.getSpacedPoints(90);
  punkte[0].set(0.0001, 0); punkte[punkte.length - 1].x = 0.0001;
  const geo = new THREE.LatheGeometry(punkte, 96);
  geo.computeVertexNormals();
  // v = Anteil der Profillänge → Lage der Lackringe in der Textur
  const vBeiHoehe = h => { let best = 0, d = 9; punkte.forEach((p, i) => { const dd = Math.abs(p.y - h); if (dd < d) { d = dd; best = i; } }); return best / (punkte.length - 1); };
  // nur auf der Oberseite suchen, wo das Profil wieder zurückläuft
  const vOben = h => { let best = 0, d = 9; punkte.forEach((p, i) => { if (i / (punkte.length - 1) < 0.35) return; const dd = Math.abs(p.y - h); if (dd < d) { d = dd; best = i; } }); return best / (punkte.length - 1); };

  const tex = document.createElement('canvas'); tex.width = 1024; tex.height = 512;
  {
    const c = tex.getContext('2d'), B = tex.width, H = tex.height;
    const zeile = v => (1 - v) * H;   // flipY: v = 1 oben
    // Grundton Bergahorn
    const g = c.createLinearGradient(0, 0, B, 0);
    g.addColorStop(0, '#dcbd90'); g.addColorStop(0.5, '#e4c8a0'); g.addColorStop(1, '#dcbd90');
    c.fillStyle = g; c.fillRect(0, 0, B, H);
    // Längsmaserung: Holzfasern laufen entlang der Drehachse
    let s = 7; const zufall = () => (s = (s * 16807) % 2147483647) / 2147483647;
    for (let i = 0; i < 150; i++) {
      const x0 = zufall() * B, breite = 0.6 + zufall() * 2.2, ton = zufall();
      c.strokeStyle = ton < 0.7 ? `rgba(150,104,58,${0.12 + zufall() * 0.22})` : `rgba(255,240,215,${0.15 + zufall() * 0.2})`;
      c.lineWidth = breite; c.beginPath();
      for (let y = 0; y <= H; y += 16) { const x = x0 + Math.sin(y * 0.013 + i) * 10 + Math.sin(y * 0.041 + i * 2) * 3; y ? c.lineTo(x, y) : c.moveTo(x, y); }
      c.stroke();
    }
    // Spitze: dunkel eingelaufen
    const vSpitze = 0.06;
    const gs = c.createLinearGradient(0, zeile(vSpitze), 0, H);
    gs.addColorStop(0, 'rgba(90,60,34,0)'); gs.addColorStop(0.35, 'rgba(90,60,34,.85)'); gs.addColorStop(1, '#3d2a1a');
    c.fillStyle = gs; c.fillRect(0, zeile(vSpitze), B, H - zeile(vSpitze));
    // Lackring am Rand, mit feinen schwarzen Kanten und einer Punktreihe (macht die Drehung sichtbar)
    const vA = vBeiHoehe(0.6), vB = vOben(0.79);
    const y1 = zeile(vB), y2 = zeile(vA);
    c.fillStyle = '#b5382a'; c.fillRect(0, y1, B, y2 - y1);
    c.fillStyle = 'rgba(0,0,0,.18)'; c.fillRect(0, y1 + (y2 - y1) * 0.62, B, (y2 - y1) * 0.38);
    c.fillStyle = '#1c1915'; c.fillRect(0, y1 - 3, B, 3); c.fillRect(0, y2, B, 3);
    c.fillStyle = '#efe4cf';
    for (let i = 0; i < 14; i++) { c.beginPath(); c.arc((i + 0.5) / 14 * B, (y1 + y2) / 2, 7, 0, Math.PI * 2); c.fill(); }
    // grüne Zierlinie auf der Haube
    const vg = vOben(0.885); c.fillStyle = '#2f5d46'; c.fillRect(0, zeile(vg) - 3, B, 6);
    // Griffknopf rot
    const vk = vOben(1.37); c.fillStyle = '#b5382a'; c.fillRect(0, 0, B, zeile(vk));
    c.fillStyle = '#1c1915'; c.fillRect(0, zeile(vk), B, 2);
  }
  const holzTextur = new THREE.CanvasTexture(tex);
  holzTextur.colorSpace = THREE.SRGBColorSpace;
  holzTextur.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
  holzTextur.wrapS = THREE.RepeatWrapping;
  const material = new THREE.MeshPhysicalMaterial({
    map: holzTextur, roughness: 0.42, metalness: 0, clearcoat: 0.7, clearcoatRoughness: 0.18, sheen: 0.2, sheenColor: new THREE.Color('#ffe7c4'), envMapIntensity: 1.1,
  });
  const kreisel = new THREE.Mesh(geo, material);

  // Hierarchie: wurzel (Ort, Schweben, Scroll) → neiger (Schräglage, Drehpunkt) → dreher (Eigendrehung) → kreisel
  const wurzel = new THREE.Group(), neiger = new THREE.Group(), dreher = new THREE.Group();
  dreher.add(kreisel); neiger.add(dreher); wurzel.add(neiger); szene.add(wurzel);

  /* Kontaktschatten: weicher Fleck am Boden, wird mit der Höhe größer und blasser (wie drei ContactShadows) */
  const sl = document.createElement('canvas'); sl.width = sl.height = 256;
  { const c = sl.getContext('2d'), g = c.createRadialGradient(128, 128, 0, 128, 128, 128);
    g.addColorStop(0, 'rgba(28,22,15,.75)'); g.addColorStop(0.35, 'rgba(28,22,15,.32)'); g.addColorStop(1, 'rgba(28,22,15,0)');
    c.fillStyle = g; c.fillRect(0, 0, 256, 256); }
  const schatten = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(sl), transparent: true, depthWrite: false, toneMapped: false }));
  schatten.rotation.x = -Math.PI / 2; szene.add(schatten);

  /* ---------- Zustand ---------- */
  const Z = {
    zielNeig: new THREE.Vector2(), neig: new THREE.Vector2(),      // x: um X-Achse, y: um Z-Achse
    dreh: 0.4, drehV: 0,                                            // Eigendrehung, Nachlauf beim Ziehen
    polar: 0, polarV: 0, zieht: false,
    omega: 0, phi: 0, kipp: 0, kippV: 0, laufStart: 0, laeuft: false,
    hoehe: W.schwebeHoehe, drehpunkt: 0.75, scroll: 0, uhr: 0,
  };
  let B = 1, H = 1, heim = new THREE.Vector3(), massstab = 1; 

  function groesse() {
    const r = buehne.getBoundingClientRect(); B = r.width; H = r.height;
    renderer.setPixelRatio(Math.min(devicePixelRatio || 1, beruehrung ? 1.5 : 2));
    renderer.setSize(B, H, false);
    kamera.aspect = B / H;
    const schmal = B < 761;
    // Kamera leicht von oben, Objekt rechts (Desktop) bzw. oben mittig (Telefon)
    kamera.position.set(0, 2.2, 9.5); kamera.lookAt(0, 0.55, 0); kamera.updateProjectionMatrix(); kamera.updateMatrixWorld();
    const sichtH = 2 * 9.5 * Math.tan(kamera.fov * GRAD / 2), sichtB = sichtH * kamera.aspect;
    // Objektmitte an eine feste Bildschirmstelle legen, Größe an die Bühne
    if (schmal) { massstab = Math.min(0.85, sichtB * 0.4); heim.copy(amBildschirm(0.5, 0.29)); }
    else { massstab = Math.min(1.6, sichtH * 0.33, sichtB * 0.16); heim.copy(amBildschirm(0.7, 0.47)); }
    heim.y -= (W.schwebeHoehe + 0.75) * massstab;
    malen();
  }

  function amBildschirm(ax, ay) {   // Punkt auf der Ebene z = 0 unter einer Bildschirmstelle
    const v = new THREE.Vector3(ax * 2 - 1, 1 - ay * 2, 0.5).unproject(kamera).sub(kamera.position).normalize();
    return kamera.position.clone().addScaledVector(v, -kamera.position.z / v.z);
  }

  /* ---------- Eingaben ---------- */
  const lokal = e => { const r = buehne.getBoundingClientRect(); return [(e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height]; };
  let zug = null;   // { x, y, t, vx }
  function objektMitte() {
    const v = new THREE.Vector3(heim.x, heim.y + (Z.hoehe + 0.75) * massstab, 0).project(kamera);
    return [(v.x + 1) / 2, (1 - v.y) / 2];
  }
  function neigeZu(ax, ay) {
    const [mx, my] = objektMitte();
    const dx = THREE.MathUtils.clamp((ax - mx) / 0.35, -1, 1), dy = THREE.MathUtils.clamp((ay - my) / 0.35, -1, 1);
    // Objekt dreht sich zum Zeiger: oben/unten kippt um X, links/rechts um Z
    Z.zielNeig.set(dy * W.neigungMax, -dx * W.neigungMax);
  }
  buehne.addEventListener('pointermove', e => {
    if (html.classList.contains('vorfuehrung')) return;
    const [ax, ay] = lokal(e);
    if (zug) { zieheWeiter(e.clientX, e.clientY, performance.now()); return; }
    if (modus === 'neigen' && e.pointerType === 'mouse' && !beruehrung && !reduziert()) { neigeZu(ax, ay); anstossen(); }   // Touch-Geräte: nur Antippen zählt
  });
  buehne.addEventListener('pointerdown', e => {
    if (e.target.closest('button, a')) return;
    stoppeVorfuehrung();
    const [ax, ay] = lokal(e);
    if (modus === 'neigen' && !reduziert() && e.pointerType !== 'mouse') { neigeZu(ax, ay); }
    zieheStart(e.clientX, e.clientY, performance.now());
    anstossen();
  });
  addEventListener('pointerup', () => zieheEnde());
  addEventListener('pointercancel', () => zieheEnde());
  buehne.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && modus === 'neigen' && !html.classList.contains('vorfuehrung')) { Z.zielNeig.set(0, 0); anstossen(); } });

  function zieheStart(x, y, t) { zug = { x, y, x0: x, t, vx: 0 }; if (modus === 'ziehen' || reduziert()) { Z.zieht = true; Z.drehV = 0; } }
  function zieheWeiter(x, y, t) {
    if (!zug) return;
    const dx = x - zug.x, dy = y - zug.y, dt = Math.max(1, t - zug.t);
    zug.vx = zug.vx * 0.6 + (dx / dt) * 0.4;   // px/ms, geglättet
    if (modus === 'ziehen' || reduziert()) {
      Z.dreh += dx * W.zugRadProPx;
      Z.drehV = dx * W.zugRadProPx / (dt / 16.67);
      if (!beruehrung && !reduziert()) Z.polar = THREE.MathUtils.clamp(Z.polar + dy * W.zugRadProPx, -W.polarMax, W.polarMax);
    } else if (modus === 'neigen' && !reduziert()) {
      const [ax, ay] = lokal({ clientX: x, clientY: y }); neigeZu(ax, ay);
    }
    zug.x = x; zug.y = y; zug.t = t; anstossen();
  }
  function zieheEnde() {
    if (!zug) return;
    const weg = Math.abs(zug.x - zug.x0);
    if (modus === 'anwerfen' && !reduziert()) anwerfen(weg < 6 ? 1 : Math.min(1, Math.abs(zug.vx) / 1.4) * Math.sign(zug.vx || 1));
    if (modus === 'neigen' && zug && beruehrung) Z.zielNeig.set(0, 0);
    Z.zieht = false; if (reduziert()) Z.drehV = 0;
    zug = null; anstossen();
  }
  function anwerfen(staerke) {
    const s = Math.abs(staerke) < 0.35 ? Math.sign(staerke || 1) * 0.35 : staerke;
    Z.omega = W.omegaMax * Math.abs(s) * (s < 0 ? -1 : 1);
    Z.laufStart = performance.now(); Z.laeuft = true;
    anstossen();
  }
  griff.addEventListener('keydown', e => {
    const schritt = 15 * GRAD;
    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { stoppeVorfuehrung(); const r = e.key === 'ArrowRight' ? 1 : -1; if (reduziert()) Z.dreh += r * schritt; else Z.drehV += r * 0.04; e.preventDefault(); }
    else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { stoppeVorfuehrung(); Z.zielNeig.x = THREE.MathUtils.clamp(Z.zielNeig.x + (e.key === 'ArrowDown' ? 1 : -1) * 5 * GRAD, -W.neigungMax, W.neigungMax); e.preventDefault(); }
    else if ((e.key === ' ' || e.key === 'Enter') && !reduziert()) { stoppeVorfuehrung(); if (modus !== 'anwerfen') setzeModus('anwerfen'); anwerfen(1); e.preventDefault(); }
    anstossen();
  });

  const altSetze = setzeModus;
  setzeModus = m => {
    altSetze(m);
    if (m !== 'anwerfen') { Z.laeuft = false; Z.omega = 0; }
    if (m !== 'neigen') Z.zielNeig.set(0, 0);
    anstossen();
  };

  /* ---------- Bildschleife ---------- */
  let laeuft = false, letzte = performance.now(), sichtbar = true, angehalten = false;
  function anstossen() { if (!laeuft && sichtbar && !angehalten) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
  const feder = (x, v, ziel, dt) => {   // gedämpfte Feder, halbimplizit
    const a = (-W.feder.spannung * (x - ziel) - W.feder.reibung * v) / W.feder.masse;
    v += a * dt; return [x + v * dt, v];
  };
  let letzteLaufzeit = '';
  function bild(t) {
    if (!sichtbar || angehalten) { laeuft = false; return; }
    const dt = Math.min(48, t - letzte) / 1000; letzte = t;
    const k = 1 - Math.pow(1 - W.lerp, dt * 60);
    const still = reduziert();
    Z.uhr += dt;
    let ruhig = true;

    // Scroll: Bühnenfortschritt 0..1
    const r = buehne.getBoundingClientRect();
    Z.scroll = THREE.MathUtils.clamp(-r.top / r.height, 0, 1);

    const zielHoehe = modus === 'anwerfen' ? 0 : W.schwebeHoehe;
    const zielPunkt = modus === 'anwerfen' ? 0 : 0.75;
    Z.hoehe += (zielHoehe - Z.hoehe) * (still ? 1 : k);
    Z.drehpunkt += (zielPunkt - Z.drehpunkt) * (still ? 1 : k);

    if (still) {
      Z.neig.set(0, 0); Z.kipp = 0; Z.polar = 0; Z.omega = 0;
    } else if (modus === 'anwerfen') {
      if (Z.laeuft) {
        const w = Math.abs(Z.omega), dw = (W.reibA * w + W.reibB) * dt;
        const neu = Math.max(0, w - dw); Z.omega = neu * Math.sign(Z.omega);
        Z.dreh += Z.omega * dt;
        // je langsamer, desto schräger und desto schneller die Präzession
        const anteil = 1 - Math.min(1, neu / W.omegaMax);
        const sek = (t - Z.laufStart) / 1000;
        const zielKipp = neu > 0.6 ? 0.03 + W.kippMax * anteil * anteil + W.taumel * Math.exp(-sek / W.taumelS) : 0;
        [Z.kipp, Z.kippV] = feder(Z.kipp, Z.kippV, zielKipp, dt);
        // Präzession: langsam, solange er schnell dreht; kurz nach dem Anwurf taumelt er schneller
        Z.phi += (W.praezession / Math.max(neu, 1.2) + W.taumelPraez * Math.exp(-sek / W.taumelS)) * dt * Math.sign(Z.omega || 1);
        const lz = sek.toFixed(1).replace('.', ',') + ' s';
        if (neu <= 0.6) { Z.laeuft = false; }
        if (lz !== letzteLaufzeit) { letzteLaufzeit = lz; const el = document.querySelector('[data-laufzeit]'); if (el) el.textContent = lz; }
        ruhig = false;
      } else {
        [Z.kipp, Z.kippV] = feder(Z.kipp, Z.kippV, 0, dt);
        if (Math.abs(Z.kipp) > 1e-4 || Math.abs(Z.kippV) > 1e-4) ruhig = false;
      }
      const ziel = new THREE.Vector2(Math.cos(Z.phi) * Z.kipp, Math.sin(Z.phi) * Z.kipp);
      if (Z.laeuft) Z.neig.copy(ziel); else { Z.neig.lerp(ziel, k); if (Z.neig.distanceTo(ziel) > 1e-4) ruhig = false; }
      if (Math.abs(Z.hoehe) > 1e-3 || Math.abs(Z.drehpunkt) > 1e-3) ruhig = false;
    } else {
      Z.neig.lerp(Z.zielNeig, k);
      // Ziehen: Nachlauf, Polar-Neigung federt zurück
      if (!Z.zieht) {
        Z.dreh += Z.drehV * dt * 60;
        Z.drehV *= Math.pow(W.traegheit, dt * 60);
        if (Math.abs(Z.drehV) < 1e-4) Z.drehV = 0;
      }
      if (!Z.zieht && Math.abs(Z.drehV) < W.idle * dt + 1e-3) Z.dreh += W.idle * dt;   // Eigendrehen, wenn nichts anderes dreht
      ruhig = false;
    }
    if (!still && !Z.zieht) { [Z.polar, Z.polarV] = feder(Z.polar, Z.polarV, 0, dt); if (Math.abs(Z.polar) > 1e-4) ruhig = false; }
    malen();
    if (ruhig && !html.classList.contains('vorfuehrung')) { laeuft = false; return; }
    requestAnimationFrame(bild);
  }

  function malen() {
    const still = reduziert();
    const s = massstab;
    // drei-Float-Formel: y = sin(t/4 · speed)/10 · floatIntensity, dazu leichtes Wiegen
    const ft = Z.uhr / 4 * W.floatSpeed;
    const schweben = still ? 0 : (modus === 'anwerfen' ? 0 : 1);
    const fy = Math.sin(ft) / 10 * W.floatIntensity * schweben;
    const fx = Math.cos(ft) / 8 * W.floatRotation * schweben, fz = Math.sin(ft) / 20 * W.floatRotation * schweben;

    wurzel.position.set(heim.x, heim.y + (Z.hoehe + fy) * s + Z.scroll * 0.9, 0);
    wurzel.scale.setScalar(s);
    neiger.position.y = Z.drehpunkt;
    dreher.position.y = -Z.drehpunkt;
    neiger.rotation.set(Z.neig.x + Z.polar + fx * 0.3, 0, Z.neig.y + fz * 0.3, 'XYZ');
    dreher.rotation.y = Z.dreh + (still ? 0 : Z.scroll * W.scrollDreh);

    const h = Z.hoehe + fy;
    schatten.position.set(heim.x + Math.sin(Z.neig.y) * -0.2 * s, heim.y + 0.002, 0);
    schatten.scale.set(s * (1.6 + h * 1.5), s * (1.0 + h * 0.9), 1);
    schatten.material.opacity = Math.max(0.25, 0.85 - h * 1.6) * (1 - Z.scroll * 0.6);
    renderer.render(szene, kamera);
  }

  new IntersectionObserver(([e]) => { sichtbar = e.isIntersecting; if (sichtbar) anstossen(); }).observe(buehne);
  addEventListener('scroll', () => anstossen(), { passive: true });
  let rz; addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(groesse, 120); });

  /* ---------- Vorführung: gedachter Zeiger, alle drei Interaktionen nacheinander ---------- */
  const TAKT = { neigen: 5200, ziehen: 5200, anwerfen: 6400 };
  const REIHE = ['neigen', 'ziehen', 'anwerfen'];
  const GESAMT = REIHE.reduce((a, m) => a + TAKT[m], 0);
  let uhr = null, vStart = 0, vZeit = 0, vPausiert = false, vSchrittZuletzt = '';
  function setzeZeiger(ax, ay, drueckt) {
    zeiger.style.transform = `translate(${ax * B}px, ${ay * H}px)`;
    zeiger.classList.toggle('drueckt', !!drueckt);
  }
  function vSchritt(t) {
    let rest = (t - vStart) % GESAMT, m = REIHE[0];
    for (const n of REIHE) { if (rest < TAKT[n]) { m = n; break; } rest -= TAKT[n]; }
    if (m !== modus) setzeModus(m);
    const [mx, my] = objektMitte();
    const s = rest / 1000;
    if (m === 'neigen') {
      // Zeiger kreist um das Objekt
      const ax = mx + Math.sin(s * 1.5) * 0.24, ay = my + Math.sin(s * 2.1 + 0.8) * 0.2;
      setzeZeiger(ax, ay, false); neigeZu(ax, ay);
    } else if (m === 'ziehen') {
      // zweimal von links nach rechts ziehen und loslassen
      const zyklus = 2600, p = (rest % zyklus) / zyklus, nr = Math.floor(rest / zyklus);
      const schritt = `z${nr}`;
      if (p < 0.15) { setzeZeiger(mx - 0.1, my + 0.04, false); }
      else if (p < 0.3) {
        const q = (p - 0.15) / 0.15, e = Math.pow(q, 1.4);   // beschleunigt, damit der Nachlauf sichtbar wird
        const x = (mx - 0.1 + e * 0.24) * B, y = (my + 0.04 - e * 0.03) * H;
        if (vSchrittZuletzt !== schritt) { vSchrittZuletzt = schritt; zieheStart(x, y, t); }
        zieheWeiter(x, y, t); setzeZeiger(x / B, y / H, true);
      } else { if (zug) zieheEnde(); setzeZeiger(mx + 0.12, my + 0.01, false); }
    } else {
      // kurz wischen, dann zusehen
      if (rest < 500) setzeZeiger(mx - 0.1, my + 0.06, false);
      else if (rest < 800) { const q = (rest - 500) / 300; setzeZeiger(mx - 0.1 + q * 0.18, my + 0.06 - q * 0.02, true); }
      else { if (vSchrittZuletzt !== 'wurf') { vSchrittZuletzt = 'wurf'; anwerfen(1); } setzeZeiger(mx + 0.16, my + 0.12, false); }
    }
    if (m !== 'anwerfen' && vSchrittZuletzt === 'wurf') vSchrittZuletzt = '';
    anstossen();
    uhr = requestAnimationFrame(vSchritt);
  }
  vorfuehren = () => {
    cancelAnimationFrame(uhr); clearTimeout(uhr); angehalten = false; vPausiert = false;
    html.classList.add('vorfuehrung');
    if (reduziert()) {
      // ohne Bewegung: die drei Ansichten still im Takt, Objekt steht
      vStart = performance.now();
      const takt = () => { setzeModus(REIHE[Math.floor((performance.now() - vStart) / 3000) % 3]); malen(); uhr = setTimeout(takt, 500); };
      takt(); return;
    }
    vStart = performance.now(); vSchrittZuletzt = '';
    uhr = requestAnimationFrame(vSchritt);
  };
  pause = () => {
    cancelAnimationFrame(uhr); clearTimeout(uhr); uhr = null; angehalten = true;
    if (html.classList.contains('vorfuehrung')) { vPausiert = true; vZeit = performance.now() - vStart; }
  };
  weiter = () => {
    angehalten = false;
    if (!vPausiert || !html.classList.contains('vorfuehrung')) { anstossen(); return; }
    vPausiert = false; vStart = performance.now() - vZeit;
    if (reduziert()) { vorfuehren(); return; }
    anstossen(); uhr = requestAnimationFrame(vSchritt);
  };
  stoppeVorfuehrung = () => {
    if (!html.classList.contains('vorfuehrung')) return;
    cancelAnimationFrame(uhr); clearTimeout(uhr); uhr = null; vPausiert = false;
    if (zug) zieheEnde();
    html.classList.remove('vorfuehrung'); zeiger.classList.remove('drueckt');
  };
  neustart = () => {
    stoppeVorfuehrung(); angehalten = false;
    Object.assign(Z, { dreh: 0.4, drehV: 0, polar: 0, polarV: 0, omega: 0, kipp: 0, kippV: 0, laeuft: false, hoehe: W.schwebeHoehe, drehpunkt: 0.75 });
    Z.neig.set(0, 0); Z.zielNeig.set(0, 0);
    setzeModus('neigen');
    malen(); anstossen();
  };

  groesse();
  neustart();
  requestAnimationFrame(() => html.classList.add('gl-bereit'));
}

// Knopf „Anhalten/Abspielen“: dieselbe pause()/weiter() wie die API
const haltKnopf = document.querySelector('[data-halt]');
function haltZeigen(an) { haltKnopf.setAttribute('aria-pressed', String(an)); haltKnopf.textContent = an ? 'Abspielen' : 'Anhalten'; haltKnopf.setAttribute('aria-label', an ? 'Bewegung abspielen' : 'Bewegung anhalten'); }
haltKnopf.addEventListener('click', () => { const an = haltKnopf.getAttribute('aria-pressed') !== 'true'; an ? window.FUNDUS_DEMO.pause() : window.FUNDUS_DEMO.weiter(); });

</script>
</body>
</html>