Zum Inhalt
← 3D & WebGL

Spline-Szenen als Embed

Eine Lebkuchendose als Spline-artige 3D-Szene mit Zuständen: Zeigen öffnet den Deckel, Farbfelder wechseln das Blech.

Eine 3D-Szene wird im Browser-Werkzeug Spline gestaltet: Formen, Materialien, Zustände und Ereignisse wie Hover, Klick oder Scroll. Export als Web-Embed (Viewer oder Runtime) oder als React-Komponente. Eingebunden im Hero oder in einer Produktkarte; Interaktionen laufen über Spline-Ereignisse oder die Runtime-API. Die Demo baut so eine Szene mit three.js nach, weil externe Dienste hier nicht geladen werden.

Designer ohne Three.js-Kenntnisse bekommen schnell interaktives 3D. Ein Objekt, das auf Zeiger und Klick reagiert, lädt zum Ausprobieren ein.

Wann einsetzen

Prototypen, kleine Budgets, Webflow- und Framer-Projekte, einzelne interaktive Objekte. Im Shop nur als echter Konfigurator mit Farben oder Varianten.

Wann nicht

Wenn die Ladezeit kritisch ist: Runtime und Szene sind oft mehrere MB groß. Wenn eigene Shader oder Effekte gebraucht werden. Auf Award-Niveau 2026 überwiegend durch eigenes Three.js oder R3F ersetzt (Einschätzung).

Varianten

  • Hero-Objekt: Objekt neben dem Claim, Blick folgt dem Zeiger
  • Interaktive Produktkarte: Hover öffnet, Farbfelder steuern das Material
  • Spline-Export nach three.js: dieselbe Szene ohne Runtime

Umsetzung

Lazy laden, nach Interaktion oder per IntersectionObserver, vorher ein Poster-Bild zeigen. Zustände mit kurzen, ungleichen Übergängen: Öffnen mit Ausklang, Schließen schneller mit Anlauf. Farben oder Varianten von außen über die Runtime-API setzen statt mehrere Szenen zu laden. Hover nur auf dem Objekt auslösen, nicht auf der ganzen Fläche. Preis, Varianten und Kaufen bleiben HTML. Trendstatus abklingend ist eine Einschätzung, nicht belegt.

Robust umsetzen

Telefon. Ohne Hover: Tippen schaltet den Zustand, Wischen dreht. Auf schwachen Geräten statisches Bild oder Video als Ersatz.

Ohne Bewegung. Animationen in den Spline-Zuständen abschalten: Zustände ohne Übergang, keine Eigendrehung, kein Schweben.

Barrierefrei. aria-label mit Beschreibung des Objekts, Bedienung per Tastatur (Eingabe öffnet, Pfeiltasten drehen). Keine Inhalte nur im Embed.

Code

<!DOCTYPE html>
<!--
  Spline-Szenen als Embed
  Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/spline-embed/
  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>Spline-Szenen als Embed · 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: 'Young Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/young-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; 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; }

:root {
  --grund: #f5f4f1; --kachel: #ece9e2; --tinte: #212121; --leise: #6b6b6b; --linie: rgba(33, 33, 33, .14);
  --gruen: #10714e; --pink: #c93a7d;
  --auf: cubic-bezier(0, 0, 0.2, 1);     /* Öffnen, Hover, Farbe: Ausklang */
  --zu: cubic-bezier(0.4, 0, 1, 1);      /* Schließen: Anlauf, schneller */
  --rand: clamp(16px, 3.2vw, 44px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; min-height: 100svh; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.label { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
.ziffern { font-variant-numeric: tabular-nums; }

/* ---------- Kopf ---------- */
.ansage { background: var(--tinte); color: #f5f4f1; text-align: center; font-size: 13px; padding: 8px var(--rand); }
.ansage b { font-weight: 600; }
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 18px var(--rand); border-bottom: 1px solid var(--linie); }
.kopf nav { display: flex; gap: 26px; font-size: 15px; }
.kopf nav a { text-decoration: none; color: var(--tinte); opacity: .78; transition: opacity .15s var(--auf); }
.kopf nav a:hover, .kopf nav a:focus-visible { opacity: 1; }
.marke { font: 400 25px/1 'Young Serif', serif; letter-spacing: -.01em; text-align: center; }
.marke small { display: block; font: 600 9.5px/1 'Instrument Sans', sans-serif; letter-spacing: .2em; text-transform: uppercase; color: var(--leise); margin-top: 6px; }
.werkzeug { display: flex; justify-content: flex-end; gap: 18px; align-items: center; }
.werkzeug svg { width: 21px; height: 21px; display: block; }
.korb { position: relative; }
.korb .zahl { position: absolute; right: -8px; top: -7px; min-width: 18px; height: 18px; border-radius: 9px; background: var(--tinte); color: var(--grund); font-size: 11px; font-weight: 600; display: grid; place-items: center; padding: 0 4px; transition: scale .2s var(--auf); }
.korb.neu .zahl { scale: 1.25; background: var(--gruen); }

/* ---------- Produkt ---------- */
.produkt { display: grid; grid-template-columns: minmax(0, 1.42fr) minmax(0, 1fr); gap: clamp(20px, 3.4vw, 56px); padding: clamp(14px, 2vw, 26px) var(--rand) clamp(40px, 6vw, 80px); align-items: start; }
.kachel { position: relative; background: var(--kachel); border-radius: 6px; overflow: hidden; height: min(calc(100svh - 140px), 760px); min-height: 520px; isolation: isolate; touch-action: pan-y; outline: none; }
.kachel:focus-visible { box-shadow: 0 0 0 2px var(--tinte); }
.kachel canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity .4s var(--auf); }
.gl-bereit .kachel canvas { opacity: 1; }
.kachel .boden { position: absolute; inset: auto 0 0; height: 38%; background: linear-gradient(180deg, rgba(0,0,0,0), rgba(120, 104, 84, .06)); pointer-events: none; }
.chip { position: absolute; z-index: 3; top: 16px; left: 16px; display: inline-flex; gap: 8px; align-items: center; height: 30px; padding: 0 12px; border-radius: 999px; background: rgba(245, 244, 241, .78); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); font-size: 12.5px; font-weight: 500; }
.chip i { width: 7px; height: 7px; border-radius: 50%; background: var(--gruen); display: block; }
.chip.rechts { left: auto; right: 16px; background: var(--pink); color: #fff; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; font-size: 11px; }
.hinweis { position: absolute; z-index: 3; right: 18px; bottom: 20px; white-space: nowrap; font-size: 13px; color: var(--leise); display: flex; gap: 10px; align-items: center; transition: opacity .2s var(--auf); }
.hinweis svg { width: 18px; height: 18px; }
.ohne-webgl .hinweis span { display: none; }
.ohne-webgl .hinweis::after { content: '3D nicht verfügbar: Standbild'; }

/* Poster: steht da, bis die Szene ihr erstes Bild gemalt hat (Lazy-Load), bleibt ohne WebGL */
.poster { position: absolute; z-index: 1; left: 50%; top: 52%; width: min(46%, 330px); translate: -50% -50%; transition: opacity .4s var(--auf); }
.gl-bereit .poster { opacity: 0; }

/* Hero-Variante: Kachel über volle Breite, Claim links */
.heldclaim { position: absolute; z-index: 2; left: clamp(22px, 4vw, 64px); top: 50%; translate: 0 -50%; max-width: 44%; display: none; }
.heldclaim h2 { font: 400 clamp(40px, 5.4vw, 84px)/1.03 'Young Serif', serif; letter-spacing: -.01em; }
.heldclaim p { margin-top: 18px; font-size: 17px; line-height: 1.55; color: var(--leise); max-width: 34ch; }
.heldclaim a { display: inline-flex; align-items: center; height: 50px; padding: 0 26px; margin-top: 26px; background: var(--tinte); color: var(--grund); border-radius: 999px; text-decoration: none; font-weight: 600; font-size: 15px; }
.modus-held .produkt { grid-template-columns: minmax(0, 1fr) 0fr; gap: 0; }
@media (min-width: 861px) { .modus-held .kaufbox { display: none; } }
.modus-held .heldclaim { display: block; animation: rein .3s var(--auf); }
@keyframes rein { from { opacity: 0; } }
.modus-held .chip.rechts, .modus-held .hinweis { display: none; }

/* three.js-Export: Beschriftung des Szenengraphs */
.marken { position: absolute; inset: 0; z-index: 2; pointer-events: none; display: none; }
.modus-export .marken { display: block; }
.marke3d { position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: 8px; white-space: nowrap; font: 500 10.5px/1.3 'Geist Mono', monospace; letter-spacing: .06em; color: var(--tinte); }
.marke3d::before { content: ''; width: 7px; height: 7px; border-radius: 50%; border: 1.5px solid var(--tinte); background: var(--kachel); flex: none; }
.marke3d span { background: rgba(245, 244, 241, .9); padding: 4px 7px; border-radius: 3px; }
.marke3d em { font-style: normal; color: var(--leise); }
.graph { position: absolute; z-index: 3; bottom: 16px; right: 16px; width: 228px; background: rgba(245, 244, 241, .9); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); border-radius: 6px; padding: 12px 14px; display: none; font: 500 11px/1.75 'Geist Mono', monospace; }
.modus-export .graph { display: block; }
.modus-export .chip.rechts, .modus-export .hinweis { display: none; }
.graph b { font-weight: 500; color: var(--tinte); float: right; }
.graph p { color: var(--leise); }
.graph .kopfzeile { color: var(--tinte); letter-spacing: .12em; text-transform: uppercase; border-bottom: 1px solid var(--linie); padding-bottom: 6px; margin-bottom: 6px; }

/* Bedienleiste: Varianten + Werte */
.pult { position: absolute; z-index: 4; left: 14px; bottom: 14px; display: grid; gap: 8px; max-width: calc(100% - 28px); }
.varianten { display: flex; gap: 4px; padding: 4px; background: rgba(245, 244, 241, .82); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); border-radius: 999px; width: max-content; }
.varianten button { height: 28px; padding: 0 12px; border-radius: 999px; font: 500 10.5px/1 'Geist Mono', monospace; letter-spacing: .12em; text-transform: uppercase; color: var(--leise); transition: background-color .15s var(--auf), color .15s var(--auf); }
.varianten button:hover { color: var(--tinte); }
.varianten button[aria-pressed='true'] { background: var(--tinte); color: var(--grund); }
.v-gruppe { display: flex; gap: 4px; }
/* Zugang (WCAG 2.2.2): die Dose dreht sich im Leerlauf weiter, also ein Knopf zum Anhalten */
.varianten .anhalten { width: 28px; margin-right: 12px; padding: 0; display: grid; place-items: center; color: var(--tinte); border-left: 1px solid var(--linie); border-radius: 0; }   /* Abstand rechts: dort sitzt das × des Rahmens */
.varianten .anhalten[aria-pressed='true'] { background: none; color: var(--tinte); }
.anhalten svg { width: 10px; height: 10px; fill: currentColor; }
.anhalten .abspielen, .anhalten[aria-pressed='true'] .pause { display: none; }
.anhalten[aria-pressed='true'] .abspielen { display: block; }
.reduziert .anhalten { display: none; }
.werte { font: 500 10px/1.6 'Geist Mono', monospace; letter-spacing: .08em; color: var(--leise); background: rgba(245, 244, 241, .7); padding: 5px 10px; border-radius: 4px; width: max-content; max-width: min(100%, 360px); }
.werte b { color: var(--tinte); font-weight: 500; }

/* ---------- Kaufbox ---------- */
.kaufbox { position: sticky; top: 20px; padding-top: clamp(6px, 2vw, 30px); }
.pfad { font-size: 13px; color: var(--leise); }
.pfad a { text-decoration: none; }
.kaufbox h1 { font: 400 clamp(34px, 2.4rem + 1.4vw, 60px)/1.04 'Young Serif', serif; letter-spacing: -.01em; margin: 16px 0 26px; }
.zeile { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; }
.name { font-size: 18px; font-weight: 600; letter-spacing: -.01em; }
.preis { font-size: 18px; font-weight: 600; }
.grundpreis { font-size: 13px; color: var(--leise); margin-top: 4px; }
.sterne { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 13px; color: var(--leise); }
.sterne svg { width: 82px; height: 14px; }
.swatches { margin-top: 26px; border: 0; }
.swatches legend { font-size: 14px; margin-bottom: 12px; }
.swatches legend b { font-weight: 600; }
.swatch-reihe { display: flex; gap: 10px; }
.swatch { position: relative; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; }
.swatch input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
.swatch span { position: absolute; inset: 4px; border-radius: 50%; background: var(--f); box-shadow: inset 0 -4px 8px rgba(0,0,0,.18), inset 0 3px 6px rgba(255,255,255,.18); }
.swatch::after { content: ''; position: absolute; inset: 0; border-radius: 50%; border: 1.5px solid transparent; transition: border-color .15s var(--auf); }
.swatch:hover::after { border-color: rgba(33,33,33,.3); }
.swatch:has(input:checked)::after { border-color: var(--tinte); }
.swatch:has(input:focus-visible)::after { border-color: var(--tinte); box-shadow: 0 0 0 3px rgba(33,33,33,.15); }
.kaufen { display: flex; justify-content: center; align-items: center; gap: 10px; width: 100%; height: 54px; margin-top: 26px; background: var(--tinte); color: var(--grund); border-radius: 999px; font-weight: 600; font-size: 15.5px; transition: background-color .15s var(--auf), scale .15s var(--auf); }
.kaufen:hover { background: #000; }
.kaufen:active { scale: .98; }
.kaufen.ok { background: var(--gruen); }
.vertrauen { list-style: none; margin-top: 24px; border-top: 1px solid var(--linie); }
.vertrauen li { display: grid; grid-template-columns: 22px 1fr; gap: 12px; align-items: start; padding: 13px 0; border-bottom: 1px solid var(--linie); font-size: 14px; line-height: 1.45; }
.vertrauen svg { width: 20px; height: 20px; margin-top: 1px; }
.vertrauen b { font-weight: 600; }
.vertrauen span { color: var(--leise); }

/* Gedachter Zeiger in der Vorführung */
.zeiger { position: fixed; z-index: 20; left: 0; top: 0; width: 20px; height: 20px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }

/* ---------- Vorschau 960 × 720 ---------- */
.vorschau body { overflow: hidden; }
.vorschau .ansage, .vorschau .vertrauen, .vorschau .pfad, .vorschau .kopf nav { display: none; }
.vorschau .kachel { height: calc(100vh - 92px); min-height: 0; }
.vorschau .kopf { grid-template-columns: 1fr auto; }
.vorschau .marke { text-align: left; }
.vorschau .kaufbox h1 { font-size: 40px; margin: 6px 0 22px; }

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

/* ---------- Schmal ---------- */
@media (max-width: 1100px) { .kopf nav { gap: 18px; font-size: 14px; } .graph { width: 200px; } }
@media (max-width: 860px) {
  .kopf { grid-template-columns: auto 1fr; }
  .kopf nav { display: none; }
  .marke { text-align: left; }
  .produkt, .modus-held .produkt { grid-template-columns: 1fr; }
  .kachel { height: auto; aspect-ratio: 1 / 1.06; min-height: 0; }
  .kaufbox { position: static; padding-top: 4px; }
  .heldclaim { top: auto; bottom: 22px; translate: none; max-width: none; left: 20px; right: 20px; }
  .heldclaim h2 { font-size: 34px; }
  .modus-held .chip { display: none; }
  .heldclaim p, .heldclaim a { display: none; }
  .modus-held .kachel { aspect-ratio: 1 / 1.3; }
  .graph { bottom: 14px; right: 14px; width: 172px; font-size: 9.5px; padding: 9px 11px; }
  .marke3d em { display: none; }
  .hinweis { right: auto; left: 16px; bottom: 18px; font-size: 12.5px; }
  .modus-export .hinweis { display: none; }
  .ansage { font-size: 12px; }
}
@media (hover: none) {
  .hinweis span[data-maus] { display: none; }
}
@media (hover: hover) {
  .hinweis span[data-touch] { display: none; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<p class="ansage"><b>Frisch gebacken</b> in der Weißgerbergasse · Versand ab 35 € kostenfrei</p>
<header class="kopf">
  <nav aria-label="Hauptmenü"><a href="#">Lebkuchen</a><a href="#">Dosen</a><a href="#">Geschenke</a><a href="#">Backstube</a></nav>
  <p class="marke">Weißgerber<small>Lebküchnerei · Nürnberg</small></p>
  <div class="werkzeug">
    <a href="#" aria-label="Suche"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg></a>
    <a href="#" aria-label="Konto"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8.5" r="3.8"/><path d="M4.5 20.5c1.2-3.8 4-5.6 7.5-5.6s6.3 1.8 7.5 5.6"/></svg></a>
    <a href="#" class="korb" data-korb aria-label="Warenkorb, 0 Artikel"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M5 8h14l-1.2 12.2H6.2Z"/><path d="M9 8V6.5a3 3 0 0 1 6 0V8"/></svg><span class="zahl ziffern" data-zahl>0</span></a>
  </div>
</header>

<main class="produkt">
  <div class="kachel" data-kachel tabindex="0" role="group" aria-roledescription="3D-Ansicht" aria-label="Elisen-Dose No. 3 in 3D: runde Blechdose mit Prägedruck, darin drei Elisenlebkuchen. Eingabetaste öffnet den Deckel, Pfeiltasten drehen die Dose." aria-describedby="hinweis">
    <div class="boden" aria-hidden="true"></div>
    <svg class="poster" data-poster viewBox="0 0 300 240" aria-hidden="true">
      <defs>
        <linearGradient id="blech" x1="0" x2="1"><stop offset="0" stop-color="#5e1814"/><stop offset=".32" stop-color="#b13a31"/><stop offset=".5" stop-color="#c44a3f"/><stop offset="1" stop-color="#5a1612"/></linearGradient>
        <radialGradient id="schattenP"><stop offset="0" stop-color="#3a2f22" stop-opacity=".32"/><stop offset="1" stop-color="#3a2f22" stop-opacity="0"/></radialGradient>
      </defs>
      <ellipse cx="150" cy="214" rx="134" ry="18" fill="url(#schattenP)"/>
      <path d="M22 92v100c0 14 57 26 128 26s128-12 128-26V92Z" fill="url(#blech)"/>
      <path d="M22 138c0 14 57 26 128 26s128-12 128-26v22c0 14-57 26-128 26S22 174 22 160Z" fill="#efe3c8" opacity=".9"/>
      <path d="M18 74v26c0 15 59 27 132 27s132-12 132-27V74Z" fill="url(#blech)"/>
      <ellipse cx="150" cy="74" rx="132" ry="27" fill="#a8342b"/>
      <ellipse cx="150" cy="74" rx="104" ry="20" fill="none" stroke="#efe3c8" stroke-width="2"/>
      <text x="150" y="80" text-anchor="middle" font-family="Young Serif, serif" font-size="19" fill="#efe3c8">Weißgerber</text>
    </svg>
    <canvas data-canvas aria-hidden="true"></canvas>

    <div class="heldclaim" aria-hidden="true" data-heldclaim>
      <h2>Sechs Elisen, eine Dose fürs Leben.</h2>
      <p>Gebacken auf Oblaten in der Weißgerbergasse, verpackt in Blech, das man nicht wegwirft.</p>
      <a href="#" tabindex="-1">Zur Elisen-Dose</a>
    </div>

    <p class="chip"><i></i>3D-Ansicht · live</p>
    <p class="chip rechts">Neu im Herbst</p>
    <div class="marken" data-marken aria-hidden="true"></div>
    <div class="graph" data-graph aria-hidden="true"></div>
    <p class="hinweis" id="hinweis" data-hinweis><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 12c0-3 3.6-5.5 8-5.5s8 2.5 8 5.5-3.6 5.5-8 5.5"/><path d="m10 15 2.2 2.5L10 20"/></svg><span data-maus>Zeigen öffnet die Dose · ziehen dreht</span><span data-touch>Tippen öffnet · wischen dreht</span></p>

    <div class="pult">
      <div class="varianten">
        <span class="v-gruppe" role="group" aria-label="Variante wählen">
        <button type="button" data-modus="karte" aria-pressed="true">Produktkarte</button>
        <button type="button" data-modus="held" aria-pressed="false">Hero-Objekt</button>
        <button type="button" data-modus="export" aria-pressed="false">three.js-Export</button>
        </span>
        <button class="anhalten" type="button" data-anhalten aria-pressed="false" aria-label="Bewegung anhalten" title="Bewegung anhalten"><svg class="pause" viewBox="0 0 10 10" aria-hidden="true"><rect x="1.5" y="1" width="2.4" height="8" rx=".6"/><rect x="6.1" y="1" width="2.4" height="8" rx=".6"/></svg><svg class="abspielen" viewBox="0 0 10 10" aria-hidden="true"><path d="M2.5 1.2v7.6L9 5z"/></svg></button>
      </div>
      <p class="werte" data-werte aria-live="polite"></p>
    </div>
  </div>

  <section class="kaufbox" aria-labelledby="claim">
    <p class="pfad"><a href="#">Dosen</a> / <a href="#">Elisenlebkuchen</a></p>
    <h1 id="claim">Sechs Elisen und eine Dose, die bleibt.</h1>
    <div class="zeile"><p class="name">Elisen-Dose No. 3</p><p class="preis ziffern">24,90 €</p></div>
    <p class="grundpreis ziffern">6 Stück · 480 g · 51,88 €/kg · inkl. MwSt.</p>
    <p class="sterne"><svg viewBox="0 0 82 14" aria-hidden="true"><g fill="#212121"><path id="st" d="m7 0 1.9 4.5 4.8.4-3.7 3.2 1.1 4.7L7 10.3 2.9 12.8 4 8.1.3 4.9l4.8-.4Z"/><use href="#st" x="17"/><use href="#st" x="34"/><use href="#st" x="51"/><use href="#st" x="68"/></g></svg><span class="ziffern">4,8 · 312 Bewertungen</span></p>
    <fieldset class="swatches">
      <legend>Dosenfarbe: <b data-farbname>Burgrot</b></legend>
      <div class="swatch-reihe">
        <label class="swatch" style="--f:#a8342b"><input type="radio" name="farbe" value="burgrot" checked aria-label="Burgrot"><span></span></label>
        <label class="swatch" style="--f:#24503d"><input type="radio" name="farbe" value="tannengruen" aria-label="Tannengrün"><span></span></label>
        <label class="swatch" style="--f:#25335a"><input type="radio" name="farbe" value="nachtblau" aria-label="Nachtblau"><span></span></label>
        <label class="swatch" style="--f:#c9c4b8"><input type="radio" name="farbe" value="zinn" aria-label="Zinn"><span></span></label>
      </div>
    </fieldset>
    <button class="kaufen" type="button" data-kaufen>In den Warenkorb</button>
    <ul class="vertrauen">
      <li><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 7h11v9H3zM14 10h4l3 3v3h-7z"/><circle cx="7" cy="17.5" r="1.8"/><circle cx="17.5" cy="17.5" r="1.8"/></svg><p><b>Versand in 1–2 Werktagen.</b> <span>Ab 35 € kostenfrei, sonst 4,90 €.</span></p></li>
      <li><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="8.5"/><path d="m8.2 12.3 2.6 2.6 5-5.6"/></svg><p><b>Echte Elisen.</b> <span>Mindestens 25 % Mandeln und Haselnüsse, höchstens 10 % Mehl.</span></p></li>
      <li><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 9a8 8 0 0 1 14.5-3M20 15a8 8 0 0 1-14.5 3"/><path d="M18.5 2.5V6H15M5.5 21.5V18H9"/></svg><p><b>Dose zurück, 2 € gutgeschrieben.</b> <span>Wir füllen sie in der Backstube neu.</span></p></li>
    </ul>
  </section>
</main>
<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M3 2 16.5 10l-6.2 1.3-3 5.8Z" fill="#212121" stroke="#f5f4f1" stroke-width="1.3" stroke-linejoin="round"/></svg>

<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 kachel = document.querySelector('[data-kachel]');
const canvas = document.querySelector('[data-canvas]');
const werteEl = document.querySelector('[data-werte]');
const markenEl = document.querySelector('[data-marken]');
const graphEl = document.querySelector('[data-graph]');
const zeiger = document.querySelector('[data-zeiger]');
const korb = document.querySelector('[data-korb]');
const zahlEl = document.querySelector('[data-zahl]');
const kaufen = document.querySelector('[data-kaufen]');
const farbname = document.querySelector('[data-farbname]');
const radios = [...document.querySelectorAll('input[name="farbe"]')];
const knoepfe = [...document.querySelectorAll('[data-modus]')];

/* ---------- Werte: Kurven-Paar aus dem Rezept (Öffnen Ausklang, Schließen Anlauf) ---------- */
const GRAD = Math.PI / 180;
const W = {
  auf: [0, 0, 0.2, 1], aufMs: 300,       // Zustand „Offen“ (mouseHover / Tippen)
  zu: [0.4, 0, 1, 1], zuMs: 200,         // zurück zu „Zu“
  farbeMs: 400,                          // Farbwechsel wie Produktbild-Wechsel
  hopfAuf: 250, hopfZu: 200,             // „In den Warenkorb“: Dose hüpft 0,14 Einheiten
  lerp: 0.08, neigung: 12 * GRAD,        // Neigung zum Zeiger
  idle: 0.12,                            // Eigendrehung rad/s
  zug: 0.008, traegheit: 0.92,           // Ziehen: rad/px, Nachlauf je Bild
  schweben: 0.045,                       // Hero: Amplitude
};
const FARBEN = { burgrot: ['#a8342b', 'Burgrot'], tannengruen: ['#24503d', 'Tannengrün'], nachtblau: ['#25335a', 'Nachtblau'], zinn: ['#c9c4b8', 'Zinn'] };
const kurve = ([x1, y1, x2, y2]) => t => {
  if (t <= 0) return 0; if (t >= 1) return 1;
  const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
  const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
  let x = t;
  for (let i = 0; i < 8; i++) { const f = ((ax * x + bx) * x + cx) * x - t, d = (3 * ax * x + 2 * bx) * x + cx; if (Math.abs(f) < 1e-5 || Math.abs(d) < 1e-6) break; x -= f / d; }
  return ((ay * x + by) * x + cy) * x;
};
const AUF = kurve(W.auf), ZU = kurve(W.zu);

let modus = 'karte';
function zeigeWerte() {
  let s;
  if (reduziert()) s = 'Zustände <b>ohne Übergang</b> · Eigendrehung <b>aus</b> · Neigung <b>aus</b>';
  else if (modus === 'held') s = 'Blick folgt Zeiger <b>lerp 0.08</b> · Schweben <b>±0.045</b> · Klick: Offen <b>300 ms</b>';
  else if (modus === 'export') s = 'Gleiche Szene als <b>three r186</b> · ohne Spline-Runtime · Werte siehe Tabelle';
  else s = 'Offen <b>300 ms</b> · Zu <b>200 ms</b> · Farbe <b>400 ms</b> · Neigung <b>max 12°</b><br>Kurven <b>(0, 0, .2, 1)</b> auf · <b>(.4, 0, 1, 1)</b> zu';
  werteEl.innerHTML = s;
}

/* ---------- Renderer erst, wenn die Kachel sichtbar wird (Lazy-Load wie beim Embed) ---------- */
let api = { neustart() {}, vorfuehren() {}, pause() {}, weiter() {} };
let setzeModus = m => {
  modus = m;
  html.classList.toggle('modus-held', m === 'held');
  html.classList.toggle('modus-export', m === 'export');
  knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === m)));
  zeigeWerte();
};
let stoppeVorfuehrung = () => {};
knoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(k.dataset.modus); }));
setzeModus('karte');

let korbZahl = 0;
function inDenKorb() {
  korbZahl++;
  zahlEl.textContent = korbZahl;
  korb.setAttribute('aria-label', `Warenkorb, ${korbZahl} Artikel`);
  korb.classList.add('neu'); kaufen.classList.add('ok'); kaufen.textContent = 'Hinzugefügt';
  clearTimeout(inDenKorb.t);
  inDenKorb.t = setTimeout(() => { korb.classList.remove('neu'); kaufen.classList.remove('ok'); kaufen.textContent = 'In den Warenkorb'; }, 1400);
}

function webglDa() { const p = document.createElement('canvas'); return !!(p.getContext('webgl2') || p.getContext('webgl')); }

async function starten() {
  let renderer = null;
  if (webglDa()) { try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; } }
  if (!renderer) {
    html.classList.add('ohne-webgl');
    radios.forEach(r => r.addEventListener('change', () => { farbname.textContent = FARBEN[r.value][1]; }));
    kaufen.addEventListener('click', inDenKorb);
    return;
  }
  try { await Promise.all([document.fonts.load('400 80px "Young Serif"'), document.fonts.load('600 40px "Instrument Sans"')]); } catch (e) {}

  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.05;
  renderer.setClearColor(0x000000, 0);

  const szene = new THREE.Scene();
  const kamera = new THREE.PerspectiveCamera(30, 1, 0.1, 80);

  /* Weiches Studiolicht wie in Spline: helle Umgebung + eine gerichtete Lampe */
  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: 0x8a8378, 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(12, 4, '#fff8ef', 4.2, [0, 10, 2]);
  softbox(3, 10, '#ffe9d2', 2.6, [-10, 3, 5]);
  softbox(3, 9, '#e6eeff', 2.0, [10, 2, -3]);
  softbox(14, 2, '#ece9e2', 1.4, [0, -6, 7]);
  szene.environment = pmrem.fromScene(studio, 0.04).texture;
  const lampe = new THREE.DirectionalLight('#fff4e6', 1.2); lampe.position.set(-3, 6, 4); szene.add(lampe);
  szene.add(new THREE.HemisphereLight('#ffffff', '#cfc6b4', 0.35));

  /* ---------- Texturen: Druckband rundum und Deckelbild, als Canvas gemalt ---------- */
  const creme = '#efe3c8';
  function stern(c, x, y, r, z = 8) {
    c.beginPath();
    for (let i = 0; i < z * 2; i++) { const a = i * Math.PI / z - Math.PI / 2, rr = i % 2 ? r * 0.42 : r; c.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr); }
    c.closePath(); c.fill();
  }
  const band = document.createElement('canvas'); band.width = 4096; band.height = 192;
  {
    const c = band.getContext('2d'), B = band.width, H = band.height;
    c.fillStyle = creme;
    c.fillRect(0, 14, B, 4); c.fillRect(0, 25, B, 2); c.fillRect(0, H - 18, B, 4); c.fillRect(0, H - 27, B, 2);
    c.font = '600 44px "Instrument Sans", sans-serif'; c.textBaseline = 'middle';
    const worte = ['WEISSGERBER', 'ELISENLEBKUCHEN', 'NÜRNBERG'];
    // gleichmäßig verteilen, damit die Naht nicht auffällt
    const breiten = worte.map(w => { c.letterSpacing = '12px'; return c.measureText(w).width; });
    const summe = breiten.reduce((a, b) => a + b, 0), luecke = (B - summe * 2) / 6;
    let x = luecke / 2;
    for (let r = 0; r < 2; r++) worte.forEach((w, i) => { c.letterSpacing = '12px'; c.fillText(w, x, H / 2 + 2); x += breiten[i] + luecke / 2; stern(c, x, H / 2, 13); x += luecke / 2; });
  }
  const deckel = document.createElement('canvas'); deckel.width = deckel.height = 1024;
  {
    const c = deckel.getContext('2d'), M = 512;
    c.strokeStyle = creme; c.fillStyle = creme;
    c.lineWidth = 6; c.beginPath(); c.arc(M, M, 470, 0, Math.PI * 2); c.stroke();
    c.lineWidth = 2; c.beginPath(); c.arc(M, M, 448, 0, Math.PI * 2); c.stroke();
    c.beginPath(); c.arc(M, M, 300, 0, Math.PI * 2); c.stroke();
    // Umschrift im Kreis
    const umschrift = (text, r, start, ende, groesse) => {
      c.font = `600 ${groesse}px "Instrument Sans", sans-serif`; c.textAlign = 'center'; c.textBaseline = 'middle';
      const zeichen = [...text], schritt = (ende - start) / (zeichen.length - 1);
      zeichen.forEach((z, i) => { const a = start + i * schritt; c.save(); c.translate(M + Math.cos(a) * r, M + Math.sin(a) * r); c.rotate(a + Math.PI / 2); c.fillText(z, 0, 0); c.restore(); });
    };
    umschrift('ELISENLEBKUCHEN · No. 3', 374, -Math.PI * 0.86, -Math.PI * 0.14, 46);
    // untere Umschrift: Lesrichtung umdrehen
    c.font = '600 40px "Instrument Sans", sans-serif';
    { const text = [...'NÜRNBERG · WEISSGERBERGASSE'], start = Math.PI * 0.84, ende = Math.PI * 0.16, schritt = (ende - start) / (text.length - 1);
      text.forEach((z, i) => { const a = start + i * schritt; c.save(); c.translate(M + Math.cos(a) * 382, M + Math.sin(a) * 382); c.rotate(a - Math.PI / 2); c.fillText(z, 0, 0); c.restore(); }); }
    stern(c, M - 374, M, 14); stern(c, M + 374, M, 14);
    c.font = '400 118px "Young Serif", serif'; c.textAlign = 'center'; c.textBaseline = 'alphabetic';
    c.fillText('Weiß', M, M - 8); c.fillText('gerber', M, M + 104);
    stern(c, M, M - 182, 26);
  }
  const bandTex = new THREE.CanvasTexture(band); bandTex.colorSpace = THREE.SRGBColorSpace; bandTex.wrapS = THREE.RepeatWrapping; bandTex.anisotropy = 8;
  const deckelTex = new THREE.CanvasTexture(deckel); deckelTex.colorSpace = THREE.SRGBColorSpace; deckelTex.anisotropy = 8;

  /* ---------- Materialien ---------- */
  const lack = new THREE.MeshPhysicalMaterial({ side: THREE.DoubleSide, color: FARBEN.burgrot[0], metalness: 0.35, roughness: 0.34, clearcoat: 1, clearcoatRoughness: 0.12, envMapIntensity: 1.0 });
  const blank = new THREE.MeshStandardMaterial({ color: '#d9d1c1', metalness: 0.9, roughness: 0.28, side: THREE.DoubleSide });
  const druck = new THREE.MeshStandardMaterial({ map: bandTex, transparent: true, metalness: 0.2, roughness: 0.4, depthWrite: false });
  const deckelDruck = new THREE.MeshStandardMaterial({ map: deckelTex, transparent: true, metalness: 0.2, roughness: 0.4, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2 });

  const drehen = (punkte, seg = 72) => new THREE.LatheGeometry(punkte.map(([x, y]) => new THREE.Vector2(x, y)), seg);

  /* ---------- Die Dose: Korpus, Deckel, drei Elisen auf Oblaten ---------- */
  const dose = new THREE.Group(); dose.name = 'dose';
  const korpus = new THREE.Mesh(drehen([[0, 0], [0.94, 0], [0.985, 0.012], [1, 0.045], [1, 0.47], [0.985, 0.49], [0.985, 0.6]]), lack); korpus.name = 'korpus';
  const innen = new THREE.Mesh(drehen([[0.985, 0.6], [0.96, 0.6], [0.96, 0.05], [0, 0.05]]), blank); innen.name = 'innen';
  const bandMesh = new THREE.Mesh(new THREE.CylinderGeometry(1.003, 1.003, 0.3, 96, 1, true), druck); bandMesh.position.y = 0.25; bandMesh.name = 'druck';
  dose.add(korpus, innen, bandMesh);

  const deckelGruppe = new THREE.Group(); deckelGruppe.name = 'deckel';
  const deckelMesh = new THREE.Mesh(drehen([[0, 0.665], [0.93, 0.665], [0.985, 0.66], [1.012, 0.635], [1.018, 0.6], [1.018, 0.47], [0.995, 0.465], [0.99, 0.6], [0.94, 0.648], [0, 0.65]]), lack); deckelMesh.name = 'deckel';
  deckelMesh.material = lack;
  const deckelBild = new THREE.Mesh(new THREE.CircleGeometry(0.93, 72), deckelDruck); deckelBild.rotation.x = -Math.PI / 2; deckelBild.position.y = 0.6665; deckelBild.name = 'deckelbild';
  const deckelUnten = new THREE.Mesh(new THREE.CircleGeometry(0.99, 72), blank); deckelUnten.rotation.x = Math.PI / 2; deckelUnten.position.y = 0.645;
  deckelGruppe.add(deckelMesh, deckelBild, deckelUnten);
  // Drehpunkt an der hinteren Kante, damit der Deckel nach hinten aufklappt und abhebt
  const scharnier = new THREE.Group(); scharnier.position.set(0, 0.6, -1); deckelGruppe.position.set(0, -0.6, 1);
  scharnier.add(deckelGruppe); dose.add(scharnier);

  const elisen = new THREE.Group(); elisen.name = 'elisen';
  const keksGeo = drehen([[0, 0], [0.37, 0], [0.385, 0.02], [0.37, 0.07], [0.3, 0.105], [0.16, 0.122], [0, 0.126]], 48);
  const oblateGeo = new THREE.CylinderGeometry(0.405, 0.405, 0.012, 48);
  const mandelGeo = new THREE.SphereGeometry(0.06, 16, 10);
  const glasuren = [
    new THREE.MeshPhysicalMaterial({ color: '#3a2116', roughness: 0.26, clearcoat: 0.8, clearcoatRoughness: 0.2 }),   // Schokolade
    new THREE.MeshStandardMaterial({ color: '#efe6d6', roughness: 0.55 }),                                             // Zuckerglasur
    new THREE.MeshStandardMaterial({ color: '#7a4421', roughness: 0.7 }),                                              // natur
  ];
  const oblate = new THREE.MeshStandardMaterial({ color: '#f4efe4', roughness: 0.9 });
  const mandel = new THREE.MeshStandardMaterial({ color: '#d8b48a', roughness: 0.6 });
  ['schoko', 'zucker', 'natur'].forEach((n, i) => {
    const g = new THREE.Group(); g.name = 'elise_' + n;
    const a = i * Math.PI * 2 / 3 + 0.5;
    g.position.set(Math.cos(a) * 0.5, 0.06, Math.sin(a) * 0.5);
    const o = new THREE.Mesh(oblateGeo, oblate); o.position.y = 0.006;
    const k = new THREE.Mesh(keksGeo, glasuren[i]); k.position.y = 0.012;
    g.add(o, k);
    for (let j = 0; j < 3; j++) {
      const m = new THREE.Mesh(mandelGeo, mandel); const b = j * Math.PI * 2 / 3 + a;
      m.scale.set(1, 0.38, 0.62); m.position.set(Math.cos(b) * 0.15, 0.135, Math.sin(b) * 0.15); m.rotation.y = -b; g.add(m);
    }
    elisen.add(g);
  });
  const papier = new THREE.Mesh(new THREE.CircleGeometry(0.955, 64), new THREE.MeshStandardMaterial({ color: '#efe7d6', roughness: 0.85 }));
  papier.rotation.x = -Math.PI / 2; papier.position.y = 0.055; elisen.add(papier);
  dose.add(elisen);

  /* Zutaten, die im Hero-Objekt um die Dose schweben (verspielt wie Spline-Szenen) */
  const zutaten = new THREE.Group(); zutaten.name = 'zutaten';
  const anisForm = new THREE.Shape();
  for (let i = 0; i < 16; i++) { const a = i * Math.PI / 8, r = i % 2 ? 0.07 : 0.2; i ? anisForm.lineTo(Math.cos(a) * r, Math.sin(a) * r) : anisForm.moveTo(r, 0); }
  const anis = new THREE.Mesh(new THREE.ExtrudeGeometry(anisForm, { depth: 0.05, bevelEnabled: true, bevelThickness: 0.025, bevelSize: 0.02, bevelSegments: 2 }), new THREE.MeshStandardMaterial({ color: '#6b3a22', roughness: 0.75 }));
  const zimt = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.75, 20, 1, false), new THREE.MeshStandardMaterial({ color: '#9a5a2e', roughness: 0.8 }));
  const m1 = new THREE.Mesh(mandelGeo, mandel), m2 = new THREE.Mesh(mandelGeo, mandel);
  m1.scale.set(2.2, 0.9, 1.4); m2.scale.set(2.2, 0.9, 1.4);
  const plaetze = [[anis, 0.15, 2.05, -1.1], [zimt, 1.55, 1.05, -0.2], [m1, 1.35, -0.05, 0.9], [m2, -1.25, 1.3, -0.9]];
  plaetze.forEach(([o, x, y, z]) => { o.userData.heim = new THREE.Vector3(x, y, z); zutaten.add(o); });
  zimt.rotation.set(0.4, 0, 0.9);

  const podest = new THREE.Mesh(drehen([[0, -0.32], [1.3, -0.32], [1.36, -0.3], [1.38, -0.26], [1.38, -0.035], [1.36, -0.006], [1.32, 0], [0, 0]], 96), new THREE.MeshStandardMaterial({ color: '#e6e1d6', roughness: 0.82, metalness: 0 }));
  podest.name = 'podest';
  const wurzel = new THREE.Group(); wurzel.add(podest); wurzel.add(dose); wurzel.add(zutaten); szene.add(wurzel);

  /* Kontaktschatten */
  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(58,47,34,.55)'); g.addColorStop(0.45, 'rgba(58,47,34,.22)'); g.addColorStop(1, 'rgba(58,47,34,0)');
    c.fillStyle = g; c.fillRect(0, 0, 256, 256); }
  const schatten = new THREE.Mesh(new THREE.PlaneGeometry(2.7, 2.7), new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(sl), transparent: true, depthWrite: false, toneMapped: false }));
  schatten.rotation.x = -Math.PI / 2; schatten.position.y = 0.002; schatten.name = 'schatten'; wurzel.add(schatten);

  /* ---------- Zustände wie im Spline-Editor: „Zu“ und „Offen“, dazu „Bestätigt“ ---------- */
  const Z = {
    offen: 0, offenVon: 0, offenZiel: 0, offenStart: 0, offenDauer: 1, offenKurve: AUF,
    farbeVon: new THREE.Color(FARBEN.burgrot[0]), farbeZiel: new THREE.Color(FARBEN.burgrot[0]), farbeStart: -1e9,
    hopf: 0, hopfStart: -1e9,
    neig: new THREE.Vector2(), zielNeig: new THREE.Vector2(),
    dreh: -0.35, drehV: 0, uhr: 0, zieht: false,
  };
  function setzeOffen(ziel, sofort) {
    if (ziel === Z.offenZiel && !sofort) return;
    const jetzt = performance.now();
    Z.offenVon = Z.offen; Z.offenZiel = ziel; Z.offenStart = jetzt;
    Z.offenDauer = (ziel ? W.aufMs : W.zuMs) * Math.abs(ziel - Z.offen);
    Z.offenKurve = ziel ? AUF : ZU;
    if (sofort || reduziert()) { Z.offen = ziel; Z.offenDauer = 0; }
    anstossen();
  }
  function setzeFarbe(wert) {
    Z.farbeVon.copy(lack.color); Z.farbeZiel.set(FARBEN[wert][0]); Z.farbeStart = performance.now();
    farbname.textContent = FARBEN[wert][1];
    radios.forEach(r => { r.checked = r.value === wert; });
    if (reduziert()) lack.color.copy(Z.farbeZiel);
    anstossen();
  }
  radios.forEach(r => r.addEventListener('change', () => { stoppeVorfuehrung(); setzeFarbe(r.value); }));
  kaufen.addEventListener('click', e => { if (e.isTrusted) stoppeVorfuehrung(); inDenKorb(); if (!reduziert()) { Z.hopfStart = performance.now(); anstossen(); } });

  /* ---------- Größe, Kamera ---------- */
  let B = 1, H = 1, heimX = 0, abstand = 7;
  function groesse() {
    const r = kachel.getBoundingClientRect(); B = r.width; H = r.height;
    renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
    renderer.setSize(B, H, false);
    kamera.aspect = B / H;
    const halbH = Math.tan(15 * GRAD);
    abstand = Math.max(6.7, 1.7 / (halbH * kamera.aspect));
    if (modus === 'held' && B > 860) { abstand = 6.6; heimX = halbH * abstand * kamera.aspect * 0.4; }
    else if (modus === 'held') { abstand = Math.max(7.4, 1.9 / (halbH * kamera.aspect)); heimX = 0; }
    else heimX = 0;
    kamera.position.set(0, 2.7 * abstand / 6.2, abstand); kamera.lookAt(0, modus === 'held' && B <= 860 ? -0.75 : 0.5, 0);
    kamera.updateProjectionMatrix(); kamera.updateMatrixWorld();
    malen();
  }
  new ResizeObserver(() => groesse()).observe(kachel);
  const altSetze = setzeModus;
  setzeModus = m => { altSetze(m); groesse(); if (m !== 'held') { Z.zielNeig.set(0, 0); } anstossen(); };

  /* ---------- Eingaben: Hover-Ereignis per Raycast auf die Dose (wie Spline „Mouse Hover“) ---------- */
  const strahl = new THREE.Raycaster(), maus = new THREE.Vector2();
  function lokal(x, y) { const r = kachel.getBoundingClientRect(); return [(x - r.left) / r.width, (y - r.top) / r.height]; }
  function trifft(ax, ay) { maus.set(ax * 2 - 1, 1 - ay * 2); strahl.setFromCamera(maus, kamera); return strahl.intersectObject(dose, true).length > 0; }
  function zeigeAuf(ax, ay) {
    if (reduziert()) return;
    const v = new THREE.Vector3(heimX, 0.35, 0).project(kamera), mx = (v.x + 1) / 2, my = (1 - v.y) / 2;
    const dx = THREE.MathUtils.clamp((ax - mx) / 0.4, -1, 1), dy = THREE.MathUtils.clamp((ay - my) / 0.4, -1, 1);
    Z.zielNeig.set(dy * W.neigung, dx * W.neigung * (modus === 'held' ? 2.2 : 1));
  }
  let zug = null;
  kachel.addEventListener('pointermove', e => {
    if (html.classList.contains('vorfuehrung')) return;
    const [ax, ay] = lokal(e.clientX, e.clientY);
    if (zug) { const dx = e.clientX - zug.x; Z.dreh += dx * W.zug; Z.drehV = dx * W.zug; zug.weg += Math.abs(dx); zug.x = e.clientX; anstossen(); return; }
    if (e.pointerType !== 'mouse') return;
    zeigeAuf(ax, ay);
    if (modus !== 'held') setzeOffen(trifft(ax, ay) ? 1 : 0);
    kachel.style.cursor = trifft(ax, ay) ? 'grab' : '';
    anstossen();
  });
  kachel.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung')) { Z.zielNeig.set(0, 0); if (modus !== 'held') setzeOffen(0); anstossen(); } });
  kachel.addEventListener('pointerdown', e => {
    if (e.target.closest('button, a')) return;
    stoppeVorfuehrung();
    zug = { x: e.clientX, weg: 0, typ: e.pointerType }; Z.zieht = true; Z.drehV = 0;
  });
  addEventListener('pointerup', e => {
    if (!zug) return;
    const [ax, ay] = lokal(e.clientX, e.clientY);
    // Tippen (Touch) bzw. Klick im Hero-Objekt: Zustand umschalten
    if (zug.weg < 6 && trifft(ax, ay) && (zug.typ !== 'mouse' || modus === 'held')) setzeOffen(Z.offenZiel ? 0 : 1);
    zug = null; Z.zieht = false; anstossen();
  });
  addEventListener('pointercancel', () => { zug = null; Z.zieht = false; });
  kachel.addEventListener('keydown', e => {
    if (e.key === 'Enter' || e.key === ' ') { stoppeVorfuehrung(); setzeOffen(Z.offenZiel ? 0 : 1); e.preventDefault(); }
    else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { stoppeVorfuehrung(); Z.dreh += (e.key === 'ArrowRight' ? 1 : -1) * 15 * GRAD; anstossen(); e.preventDefault(); }
  });

  /* ---------- three.js-Export: Szenengraph sichtbar machen (echte Zahlen aus der Szene) ---------- */
  const anker = [
    [deckelGruppe, new THREE.Vector3(0.35, 0.67, 0.35), 'deckel', 'Group · Zu/Offen'],
    [dose, new THREE.Vector3(0.12, 0.06, 0.99), 'korpus', 'MeshPhysicalMaterial'],
    [dose, new THREE.Vector3(-0.97, 0.3, 0.25), 'druck', 'CanvasTexture 4096×192'],
    [dose, new THREE.Vector3(0.42, 0.5, 0.3), 'elisen', '3 × Group'],
    [wurzel, new THREE.Vector3(-0.95, -0.16, 1.0), 'podest', 'LatheGeometry'],
  ].map(([obj, p, name, art]) => { const el = document.createElement('p'); el.className = 'marke3d'; el.innerHTML = `<span>${name} <em>${art}</em></span>`; markenEl.appendChild(el); return { obj, p, el }; });
  function graphZaehlen() {
    let meshes = 0; const mats = new Set();
    szene.traverse(o => { if (o.isMesh && o.visible) { meshes++; mats.add(o.material); } });
    const info = renderer.info.render;
    graphEl.innerHTML = `<p class="kopfzeile">Szene · three r${THREE.REVISION}</p>
      <p>Meshes <b>${meshes}</b></p><p>Materialien <b>${mats.size}</b></p><p>Dreiecke <b>${info.triangles.toLocaleString('de-DE')}</b></p><p>Draw Calls <b>${info.calls}</b></p>
      <p>Zustände <b>Zu · Offen</b></p><p>Runtime <b>keine</b></p>`;
  }

  /* ---------- Bildschleife ---------- */
  let laeuft = false, letzte = performance.now(), sichtbar = true, angehalten = false;
  function anstossen() { if (!laeuft && sichtbar && !angehalten) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
  let graphUhr = 0;
  function bild(t) {
    if (!sichtbar || angehalten) { laeuft = false; return; }
    const dt = Math.min(48, t - letzte) / 1000; letzte = t;
    const still = reduziert();
    const k = 1 - Math.pow(1 - W.lerp, dt * 60);
    Z.uhr += dt;
    // Zustand Offen
    if (Z.offenDauer > 0) { const p = Math.min(1, (t - Z.offenStart) / Z.offenDauer); Z.offen = Z.offenVon + (Z.offenZiel - Z.offenVon) * Z.offenKurve(p); }
    else Z.offen = Z.offenZiel;
    // Farbe
    const pf = Math.min(1, (t - Z.farbeStart) / W.farbeMs);
    if (!still) lack.color.copy(Z.farbeVon).lerp(Z.farbeZiel, AUF(pf));
    // Hopsen
    const ph = t - Z.hopfStart;
    Z.hopf = ph < W.hopfAuf ? AUF(ph / W.hopfAuf) : ph < W.hopfAuf + W.hopfZu ? 1 - ZU((ph - W.hopfAuf) / W.hopfZu) : 0;
    // Neigung, Drehung
    if (still) { Z.neig.set(0, 0); }
    else {
      Z.neig.lerp(Z.zielNeig, k);
      if (!Z.zieht) { Z.dreh += Z.drehV; 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) < 1e-3 && Z.offenZiel === 0) Z.dreh += W.idle * dt;
    }
    malen();
    if (modus === 'export' && t - graphUhr > 500) { graphUhr = t; graphZaehlen(); }
    const ruhig = still && Z.offen === Z.offenZiel && pf >= 1 && Z.hopf === 0 && !html.classList.contains('vorfuehrung');
    if (ruhig) { laeuft = false; return; }
    requestAnimationFrame(bild);
  }

  function malen() {
    const still = reduziert(), held = modus === 'held';
    const s = Z.offen;
    const fy = held && !still ? Math.sin(Z.uhr * 1.3) * W.schweben : 0;
    wurzel.position.set(heimX, 0, 0);
    dose.position.y = fy + Z.hopf * 0.14;
    dose.rotation.set(Z.neig.x * 0.6, Z.dreh + Z.neig.y, -Z.neig.y * 0.15);
    // Deckel hebt 0,22 ab und kippt an der hinteren Kante um 32° nach hinten
    scharnier.rotation.x = -s * 32 * GRAD;
    scharnier.position.y = 0.6 + s * 0.22;
    elisen.position.y = 0.33 + s * 0.03;
    // Zutaten nur im Hero-Objekt
    const zs = held ? 1 : 0;
    zutaten.visible = held;
    zutaten.children.forEach((o, i) => {
      const h = o.userData.heim;
      o.position.set(h.x, h.y + (still ? 0 : Math.sin(Z.uhr * 1.1 + i * 1.7) * 0.07), h.z);
      if (!still) { o.rotation.y += 0.004; o.rotation.x = 0.4 + Math.sin(Z.uhr * 0.7 + i) * 0.2; }
    });
    const hoehe = dose.position.y;
    schatten.scale.setScalar(1 + hoehe * 0.8); schatten.material.opacity = 1 - hoehe * 2.2;
    renderer.render(szene, kamera);
    if (modus === 'export') {
      anker.forEach(({ obj, p, el }) => {
        const w = p.clone().applyMatrix4(obj.matrixWorld).project(kamera);
        el.style.transform = `translate(${((w.x + 1) / 2 * B - 4).toFixed(1)}px, ${((1 - w.y) / 2 * H - 8).toFixed(1)}px)`;
      });
    }
  }

  new IntersectionObserver(([e]) => { sichtbar = e.isIntersecting; if (sichtbar) anstossen(); }).observe(kachel);

  /* ---------- Vorführung: gedachter Zeiger, Zustände, Farben, Warenkorb ---------- */
  // Je Farbe 3,6 s: zur Dose (Deckel öffnet beim Hover), dort verweilen, zum nächsten Farbfeld, klicken
  const TAKT = 3600, REIHE = [1, 2, 3, 0];
  const DAUER = TAKT * REIHE.length + 1600;   // am Ende einmal „In den Warenkorb“
  let uhr = null, vStart = 0, vZeit = 0, vPausiert = false, zuletzt = -1;
  const zielPunkt = el => { const r = el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; };
  let zp = [innerWidth * 0.5, innerHeight * 0.8], zz = zp.slice();
  function vSchritt(t) {
    const rest = (t - vStart) % DAUER;
    const kr = kachel.getBoundingClientRect();
    const v = new THREE.Vector3(heimX, 0.35, 0).project(kamera);
    const mitte = [kr.left + (v.x + 1) / 2 * kr.width, kr.top + (1 - v.y) / 2 * kr.height];
    const s = (t - vStart) / 1000;
    let nr, aktion;
    if (rest >= TAKT * REIHE.length) { nr = 'k'; aktion = 'kaufen'; }
    else {
      const i = Math.floor(rest / TAKT), p = rest % TAKT;
      nr = i * 2 + (p < 2500 ? 0 : 1); aktion = p < 2500 ? 'dose' : 'farbe' + REIHE[i];
    }
    if (aktion === 'dose') zz = [mitte[0] + Math.sin(s * 1.7) * kr.width * 0.12, mitte[1] + Math.cos(s * 1.3) * kr.height * 0.05];
    else if (aktion === 'kaufen') zz = zielPunkt(kaufen);
    else zz = zielPunkt(radios[+aktion.slice(5)].parentElement);
    if (innerWidth < 861 && aktion !== 'dose') zz = [kr.left + kr.width * 0.5, kr.top + kr.height * 0.94];   // Telefon: Farbfelder liegen unter der Kachel
    // Zeiger gleitet (expo-artig) zum Ziel
    const dtv = Math.min(100, t - (vSchritt.t || t)) / 16.667; vSchritt.t = t;
    const kz = 1 - Math.pow(0.86, dtv);
    zp[0] += (zz[0] - zp[0]) * kz; zp[1] += (zz[1] - zp[1]) * kz;
    zeiger.style.transform = `translate(${zp[0] - 3}px, ${zp[1] - 2}px)`;
    const [ax, ay] = lokal(zp[0], zp[1]);
    if (ax >= 0 && ax <= 1 && ay >= 0 && ay <= 1) { zeigeAuf(ax, ay); if (modus !== 'held') setzeOffen(trifft(ax, ay) ? 1 : 0); }
    else { Z.zielNeig.set(0, 0); setzeOffen(0); }
    if (nr !== zuletzt) {
      zuletzt = nr;
      if (aktion.startsWith('farbe')) { const w = +aktion.slice(5); setTimeout(() => { if (!html.classList.contains('vorfuehrung')) return; radios[w].checked = true; setzeFarbe(radios[w].value); }, 600); }
      if (aktion === 'kaufen') setTimeout(() => { if (html.classList.contains('vorfuehrung')) kaufen.click(); }, 700);
    }
    anstossen();
    uhr = requestAnimationFrame(vSchritt);
  }
  api.vorfuehren = () => {
    cancelAnimationFrame(uhr); angehalten = false; vPausiert = false;
    if (modus === 'held') setzeModus('karte');
    html.classList.add('vorfuehrung');
    vStart = performance.now(); zuletzt = -1;
    uhr = requestAnimationFrame(vSchritt);
  };
  api.pause = () => {
    cancelAnimationFrame(uhr); uhr = null; angehalten = true;
    if (html.classList.contains('vorfuehrung')) { vPausiert = true; vZeit = performance.now() - vStart; }
  };
  api.weiter = () => {
    angehalten = false;
    if (!vPausiert || !html.classList.contains('vorfuehrung')) { anstossen(); return; }
    vPausiert = false; vStart = performance.now() - vZeit; anstossen(); uhr = requestAnimationFrame(vSchritt);
  };
  stoppeVorfuehrung = () => {
    if (!html.classList.contains('vorfuehrung')) return;
    cancelAnimationFrame(uhr); uhr = null; vPausiert = false;
    html.classList.remove('vorfuehrung');
  };
  api.neustart = () => {
    stoppeVorfuehrung(); angehalten = false;
    Object.assign(Z, { dreh: -0.35, drehV: 0, hopfStart: -1e9 });
    Z.neig.set(0, 0); Z.zielNeig.set(0, 0);
    setzeOffen(0, true);
    radios[0].checked = true; setzeFarbe('burgrot'); lack.color.set(FARBEN.burgrot[0]);
    setzeModus('karte');
    anstossen();
  };
  /* Knopf „Bewegung anhalten“: dieselbe Pause wie FUNDUS_DEMO.pause() */
  const anhaltenKnopf = document.querySelector('[data-anhalten]');
  const anhaltenZeigen = () => anhaltenKnopf.setAttribute('aria-pressed', angehalten ? 'true' : 'false');
  for (const n of ['pause', 'weiter', 'vorfuehren', 'neustart']) { const f = api[n]; api[n] = () => { f(); anhaltenZeigen(); }; }
  anhaltenKnopf.addEventListener('click', () => { if (angehalten) api.weiter(); else api.pause(); });

  groesse();
  setzeOffen(0, true);
  anstossen();
  requestAnimationFrame(() => requestAnimationFrame(() => html.classList.add('gl-bereit')));
}

// Lazy: Szene erst bauen, wenn die Kachel in Sicht kommt
let gestartet = null;
const wartend = [];
new IntersectionObserver(([e], io) => {
  if (!e.isIntersecting || gestartet) return;
  io.disconnect();
  gestartet = starten().then(() => wartend.splice(0).forEach(f => f()));
}).observe(kachel);
const spaeter = name => () => { if (gestartet) gestartet.then(() => api[name]()); else wartend.push(() => api[name]()); };

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