Zum Inhalt
← 3D & WebGL

CSS-3D-Räume und -Objekte

Ein Sideboard aus echten HTML-Flächen zum Drehen, Öffnen und Zerlegen, dazu Tilt-Karte und Klappkarten.

Ein Container bekommt perspective (800 bis 2000 px), die Ebenen darunter transform-style: preserve-3d. So liegen alle Kinder im selben Raum. Jedes Teil wird mit rotateX, rotateY und translateZ platziert: Quader aus sechs Flächen, Türen mit Scharnier, Kartenstapel, Faltungen, Zylinder-Karussells mit rotateY(i · 360/n) translateZ(r). Gedreht wird per Zeiger (Tilt), per Ziehen, per Klick oder scroll-gekoppelt mit animation-timeline: view() bzw. scroll(). Alles bleibt echtes HTML: lesbar, markierbar, fokussierbar.

Echte räumliche Tiefe mit zugänglichem, markierbarem HTML. Leicht, ohne WebGL und ohne Modelldateien. Ein Objekt, das man drehen, öffnen und zerlegen kann, erklärt Aufbau und Maße schneller als ein Foto.

Wann einsetzen

Karten-Tilt, Klappkarten mit Vorder- und Rückseite, Produktmodelle aus einfachen Körpern (Möbel, Verpackung, Geräte), Explosionszeichnungen, 3D-Karussells, Buch- und Faltinteraktionen, Ticket- und Kreditkarten-Mockups, flache Flächen, die sich räumlich aufklappen.

Wann nicht

Bei runden oder komplexen Formen, echtem Licht, Schatten und Materialien: dann WebGL. Bei sehr vielen Flächen wird die Tiefensortierung des Browsers langsam und zeigt Fehler.

Varianten

  • Objekt im Raum: Quader aus Flächen, drehbar, mit Zuständen wie Öffnen und Explosion
  • Tilt-Karte mit Glanz und Ebenen in der Tiefe
  • Klappkarte: rotateX(180deg), Rückseite mit backface-visibility: hidden
  • Zylinder-Karussell
  • Flache Fläche klappt beim Scrollen auf (rotateX 90° → 0)
  • Voxel aus gestapelten CSS-Grids (experimentell)
  • Scroll-getriebene 3D-Galerie ohne JS

Umsetzung

perspective auf den Container, transform-style: preserve-3d auf jede Ebene dazwischen. backface-visibility: hidden gegen Flackern und für Rückseiten. Kein overflow: hidden, filter, opacity unter 1 oder clip-path auf Elementen mit preserve-3d, das flacht den Raum ab. Auf einzelnen Flächen und am Container mit perspective ist es erlaubt. Drehpunkte wie Scharniere über translate, rotate und zurück-translate oder über transform-origin. Licht gibt es nicht: Helligkeit je Fläche selbst aus der gedrehten Normalen berechnen oder fest pro Seite vergeben. Beschriftungen im Raum: unsichtbaren Anker an das Teil hängen, seine Position mit getBoundingClientRect lesen und den Text flach darüberlegen. Text mit translateZ kann unscharf werden, auf ganze Pixel achten. Scroll-Driven Animations laufen in Chromium und Safari 26. In Firefox prüfen, sonst statisch zeigen.

Robust umsetzen

Telefon. Tilt auf Touch nur, solange der Finger auf der Karte liegt (oder per Gyro), sonst aus. Drehen per waagerechtem Wischen, touch-action: pan-y lässt senkrechtes Scrollen frei. Klappkarten per Tippen. Objekte kleiner einpassen und Beschriftungen kürzen.

Ohne Bewegung. Flache oder feste Ansicht, Drehen und Kippen aus. Zustände springen ohne Animation, Rückseiten blenden kurz über statt zu drehen.

Barrierefrei. DOM-Reihenfolge = Lesereihenfolge. Verdeckte Rückseiten mit inert oder aria-hidden aus dem Fokus nehmen. Drehbare Objekte fokussierbar machen und per Pfeiltasten bedienbar, Zustände als echte Buttons mit aria-pressed. Ein Modell aus vielen Flächen bekommt role="img" und eine Beschreibung. Beschriftungen bleiben markierbarer Text.

Code

<!DOCTYPE html>
<!--
  CSS-3D-Räume und -Objekte
  Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/css-3d-transforms/
  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>CSS-3D-Räume und -Objekte · 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: 'Special Gothic'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/special-gothic:vf@latest/latin-standard-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; 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; }

/* Rezept „Handwerk: Werkstatt und Klartext“: Materialfarben als Flächen, ein Signalton nur für Anfrage/Anruf */
:root {
  --grund: #efedea; --zement: #d3cdc2; --holz: #c3b4a5; --salbei: #bdc1a9; --blau: #97a2bc;
  --text: #1a1a1a; --leise: rgba(26, 26, 26, .62); --linie: rgba(26, 26, 26, .14);
  --signal: #fb6100; --signal-tief: #e35700;
  --kurve: cubic-bezier(0.2, 0.8, 0.2, 1);   /* Muster-Kurve: Tilt-Rückkehr, Klappen, Möbel-Zustände */
  --ui: cubic-bezier(0.4, 0, 0.2, 1);        /* Rezept: UI-Zustände 150 ms */
  --rand: clamp(16px, 3.4vw, 48px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font: 400 17px/1.55 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; overflow-x: clip; }
a { color: inherit; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
.gewerk { font: 600 13px/1.1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .04em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.unsichtbar { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ---------- Kopf der Website ---------- */
.kopf { display: flex; align-items: center; gap: 24px; padding: 14px var(--rand); border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: center; gap: 12px; text-decoration: none; margin-right: auto; }
.marke svg { width: 26px; height: 26px; flex: none; }
.marke-name { font: 700 22px/1 'Special Gothic', sans-serif; letter-spacing: -.01em; text-transform: uppercase; }
.marke-zusatz { display: block; color: var(--leise); margin-top: 3px; font-size: 11px; }
.nav { display: flex; gap: 22px; }
.nav a { font-size: 15px; text-decoration: none; color: var(--leise); transition: color .15s var(--ui); }
.nav a:hover, .nav a[aria-current] { color: var(--text); }
.kontakt { display: flex; align-items: center; gap: 10px; }
.tel { display: inline-flex; align-items: center; gap: 8px; height: 42px; padding: 0 14px; border: 1px solid rgba(26, 26, 26, .28); border-radius: 2px; text-decoration: none; font-size: 15px; font-variant-numeric: tabular-nums; transition: border-color .15s var(--ui), background-color .15s var(--ui); }
.tel:hover { border-color: var(--text); background: rgba(26, 26, 26, .04); }
.tel svg { width: 16px; height: 16px; }
.knopf { display: inline-flex; align-items: center; gap: 10px; height: 42px; padding: 0 18px; border-radius: 2px; text-decoration: none; font: 700 15px/1 'Atkinson Hyperlegible Next', sans-serif; transition: background-color .15s var(--ui), transform .15s var(--ui); }
.knopf.signal { background: var(--signal); color: var(--text); }
.knopf.signal:hover { background: var(--signal-tief); }
.knopf:active, .tel:active { transform: translateY(1px); }
.knopf.gross, .tel.gross { height: 50px; font-size: 16px; }

/* ---------- Bühne und Szenen ---------- */
.buehne { display: grid; padding: clamp(24px, 4vh, 48px) var(--rand); min-height: 0; }
.szene { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-rows: 1fr auto; gap: 18px clamp(28px, 5vw, 80px); align-items: center; animation: szene-ein .4s var(--kurve); }
.szene[hidden] { display: none; }
@keyframes szene-ein { from { opacity: 0; } }
.text { grid-row: 1 / 3; display: grid; gap: 22px; align-content: center; max-width: 34rem; }
.text h1 { font: 700 clamp(2.25rem, 1.5rem + 3vw, 3.5rem)/1.05 'Special Gothic', sans-serif; letter-spacing: -.01em; text-wrap: balance; }
.text p { max-width: 33em; text-wrap: pretty; color: rgba(26, 26, 26, .78); }
.fakten { display: grid; grid-template-columns: auto 1fr; border-top: 1px solid var(--linie); font-size: 15px; font-variant-numeric: tabular-nums; }
.fakten dt, .fakten dd { padding: 9px 0; border-bottom: 1px solid var(--linie); }
.fakten dt { padding-right: 28px; color: var(--leise); }
.fakten dd strong { font-weight: 700; }
.aktionen { display: flex; flex-wrap: wrap; gap: 10px; }

/* ---------- Variante 1: Möbel im Raum ---------- */
.raum { position: relative; align-self: stretch; min-height: 420px; perspective: 1400px; perspective-origin: 50% 42%; overflow: hidden; cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; outline-offset: -2px; }
.raum.zieht { cursor: grabbing; }
.raum-titel { position: absolute; left: 0; top: 0; display: flex; gap: 14px; color: var(--leise); z-index: 3; pointer-events: none; }
.objekt { position: absolute; left: 50%; top: 50%; width: 0; height: 0; transform-style: preserve-3d; }
.teil, .gruppe { position: absolute; left: 0; top: 0; transform-style: preserve-3d; }
.gruppe { width: 0; height: 0; }
.flaeche { position: absolute; backface-visibility: hidden; -webkit-backface-visibility: hidden; box-shadow: inset 0 0 0 1px rgba(26, 26, 26, .16); }
.griff { position: absolute; background: rgba(40, 30, 20, .55); border-radius: 1px; box-shadow: inset 0 2px 2px rgba(0, 0, 0, .35); }
.boden-flaeche { position: absolute; left: 0; top: 0; transform-style: preserve-3d; }
.raster { position: absolute; inset: 0; backface-visibility: hidden;
  background-image: linear-gradient(rgba(26, 26, 26, .09) 1px, transparent 1px), linear-gradient(90deg, rgba(26, 26, 26, .09) 1px, transparent 1px);
  background-size: 30px 30px; background-position: center;
  -webkit-mask-image: radial-gradient(closest-side, #000 30%, transparent 100%); mask-image: radial-gradient(closest-side, #000 30%, transparent 100%); }
.schatten { position: absolute; border-radius: 40%; background: rgba(26, 26, 26, .32); filter: blur(14px); backface-visibility: hidden; }
.mass { position: absolute; color: rgba(26, 26, 26, .7); font-size: 22px; backface-visibility: hidden; }
.mass::before { content: ''; position: absolute; background: currentColor; }
.mass-x { height: 0; border-top: 2px solid; }
.mass-x::before { left: 0; right: 0; top: -9px; height: 16px; background: none; border-left: 2px solid; border-right: 2px solid; }
.mass-x span { position: absolute; left: 50%; bottom: 8px; translate: -50% 0; }
.mass-y { width: 0; border-left: 2px solid; }
.mass-y::before { top: 0; bottom: 0; left: -9px; width: 16px; background: none; border-top: 2px solid; border-bottom: 2px solid; }
.mass-y span { position: absolute; top: 50%; left: 10px; translate: 0 -50%; writing-mode: vertical-rl; rotate: 180deg; }
.anker { position: absolute; width: 0; height: 0; }
.beschriftung { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
.zettel { position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: 8px; white-space: nowrap; opacity: 0; will-change: transform, opacity; }
.zettel::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--text); box-shadow: 0 0 0 3px var(--grund); flex: none; }
.zettel i { width: 34px; height: 1px; background: var(--text); flex: none; }
.zettel span { display: grid; gap: 1px; font-size: 13px; line-height: 1.25; background: rgba(239, 237, 234, .95); padding: 3px 6px; }
.zettel em { font-style: normal; }
.zettel b { font: 600 12px/1.1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .04em; text-transform: uppercase; }
.zettel.links { flex-direction: row-reverse; translate: -100% 0; }
.zettel.links::before { margin-left: -3.5px; }
.zettel:not(.links)::before { margin-left: -3.5px; }
.zettel { margin-top: -3.5px; }
.steuer { grid-column: 2; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.segment { display: inline-flex; border: 1px solid rgba(26, 26, 26, .28); border-radius: 2px; padding: 3px; gap: 3px; }
.segment button { height: 36px; padding: 0 14px; border-radius: 1px; transition: background-color .15s var(--ui), color .15s var(--ui); }
.segment button:hover { background: rgba(26, 26, 26, .06); }
.segment button[aria-pressed='true'] { background: var(--text); color: var(--grund); }
.hinweis { display: inline-flex; align-items: center; gap: 8px; color: var(--leise); font-size: 14px; }
.hinweis svg { width: 22px; height: 22px; }

/* ---------- Variante 2: Musterkarte mit Tilt und Glanz ---------- */
.tilt-raum { position: relative; align-self: stretch; display: grid; place-items: center; perspective: 1000px; min-height: 420px; }
.karte { --rx: 0deg; --ry: 0deg; --gx: 50%; --gy: 40%; --glanz: 0;
  position: relative; width: min(560px, 100%); aspect-ratio: 1.42; transform-style: preserve-3d;
  display: grid; grid-template-rows: auto 1fr auto; gap: 5%; padding: 6.5% 7% 6%;
  transform: rotateX(var(--rx)) rotateY(var(--ry)); transition: transform .5s var(--kurve); touch-action: none; cursor: default; }
.karte.folgt { transition: transform .12s linear; }
.karte-grund { position: absolute; inset: 0; background: #ddd7cc; border-radius: 6px; box-shadow: inset 0 0 0 1px rgba(26, 26, 26, .1); }
.ebene { transform: translateZ(var(--z)); }
.karte-kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.karte-kopf .marke-name { font-size: clamp(15px, 2.2vw, 22px); }
.karte-kopf .gewerk { color: var(--leise); font-size: clamp(10px, 1.3vw, 13px); }
.muster { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px 3.5%; list-style: none; align-content: center; }
.muster li { display: grid; gap: 5px; font-size: clamp(10px, 1.25vw, 13px); line-height: 1.2; }
.probe { aspect-ratio: 1.7; border-radius: 2px; background: var(--c); box-shadow: 0 1px 0 rgba(255, 255, 255, .35) inset, 0 6px 12px -6px rgba(26, 26, 26, .55); }
.muster b { font-weight: 700; }
.muster small { color: var(--leise); font: 600 clamp(9px, 1.1vw, 11px)/1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .04em; }
.karte-fuss { display: flex; justify-content: space-between; gap: 12px; color: var(--leise); font-size: clamp(10px, 1.2vw, 13px); border-top: 1px solid var(--linie); padding-top: 3%; }
.etikett { position: absolute; right: 5%; top: -4%; background: var(--text); color: var(--grund); padding: 8px 12px; border-radius: 2px; }
.glanz { position: absolute; inset: 0; border-radius: 6px; pointer-events: none; opacity: var(--glanz); transition: opacity .5s var(--kurve);
  background: radial-gradient(circle at var(--gx) var(--gy), rgba(255, 255, 255, .55), rgba(255, 255, 255, .12) 32%, transparent 62%); transform: translateZ(1px); }
.karte-schatten { position: absolute; left: 50%; top: 50%; width: min(520px, 92%); aspect-ratio: 1.6; translate: calc(-50% + var(--sx, 0px)) calc(-42% + var(--sy, 0px)); border-radius: 50%; background: rgba(26, 26, 26, .2); filter: blur(30px); z-index: -1; transition: translate .5s var(--kurve); }

/* ---------- Variante 3: Klappkarten ---------- */
.klapp-raster { align-self: center; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; list-style: none; }
.klapp { perspective: 1000px; aspect-ratio: 1.18; }
.klapp-innen { position: relative; height: 100%; transform-style: preserve-3d; transition: transform .6s var(--kurve); }
.klapp.an .klapp-innen { transform: rotateX(180deg); }
.seite { position: absolute; inset: 0; backface-visibility: hidden; -webkit-backface-visibility: hidden; border-radius: 3px; padding: clamp(14px, 2vw, 24px); display: grid; grid-template-rows: auto 1fr auto; text-align: left; width: 100%; }
.seite.vorn { background: var(--c); }
.seite.hinten { background: var(--text); color: var(--grund); transform: rotateX(180deg); }
.seite h2 { font: 700 clamp(18px, 2.1vw, 28px)/1.05 'Special Gothic', sans-serif; letter-spacing: -.01em; align-self: end; }
.seite .nr { display: flex; justify-content: space-between; color: rgba(26, 26, 26, .6); }
.seite svg { width: clamp(64px, 8.5vw, 124px); height: auto; justify-self: end; align-self: center; }
.seite .tipp { color: rgba(26, 26, 26, .6); font-size: 13px; margin-top: 8px; }
.hinten .gewerk { color: rgba(239, 237, 234, .62); }
.preis { font: 700 clamp(26px, 3.2vw, 44px)/1 'Special Gothic', sans-serif; font-variant-numeric: tabular-nums; align-self: center; }
.preis small { display: grid; gap: 2px; font: 400 14px/1.4 'Atkinson Hyperlegible Next', sans-serif; color: rgba(239, 237, 234, .72); margin-top: 8px; }
.hinten a { justify-self: start; font-weight: 700; text-underline-offset: 4px; }

/* ---------- Fundus-Leiste: Variante und verwendete Werte ---------- */
.fundus-leiste { display: flex; align-items: center; justify-content: space-between; gap: 10px 24px; flex-wrap: wrap; padding: 12px var(--rand) 14px; border-top: 1px solid var(--linie); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 18px; }
.werte b { color: var(--text); font-weight: 500; }
.modi { display: flex; gap: 4px; align-items: center; }
.modi span { margin-right: 8px; }
.modi button { white-space: nowrap; font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 12px; transition: color .15s var(--ui), background-color .15s var(--ui), border-color .15s var(--ui); }
.modi button:hover { color: var(--text); border-color: rgba(26, 26, 26, .35); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.mobilleiste { display: none; }

/* ---------- Vorschau in der Galerie: nur die Bühne ---------- */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .fundus-leiste, .vorschau .nav, .vorschau .text p, .vorschau .hinweis { display: none; }

/* ---------- Mittlere Breiten ---------- */
@media (max-width: 1180px) { .nav { display: none; } }
@media (max-width: 900px) {
  .szene { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto; gap: 22px; }
  .text { display: contents; }
  .text > .gewerk { order: 1; } .text h1 { order: 2; } .raum, .tilt-raum, .klapp-raster { order: 3; } .steuer { order: 4; }
  .text p:not(.gewerk) { order: 5; } .fakten { order: 6; } .aktionen { order: 7; }
  .szene { gap: 18px; }
  .steuer { grid-column: 1; }
  .raum { min-height: 0; height: clamp(320px, 92vw, 520px); }
  .tilt-raum { min-height: 0; padding: 28px 0; }
}
@media (max-width: 900px) and (min-height: 600px) { .vorschau .text { gap: 12px; } }
/* Galerie-Vorschau (960 × 720) bleibt zweispaltig */
.vorschau .szene { grid-template-columns: minmax(0, 9fr) minmax(0, 11fr); grid-template-rows: 1fr auto; gap: 18px 36px; }
.vorschau .text h1 { font-size: 46px; }
.vorschau .text { grid-row: 1 / 3; }
.vorschau .steuer { grid-column: 2; }
.vorschau .raum { height: auto; min-height: 0; }

/* ---------- Telefon ---------- */
@media (max-width: 560px) {
  html { font-size: 16px; }
  .kopf { gap: 10px; padding-block: 10px; }
  .marke-name { font-size: 19px; }
  .marke-zusatz { font-size: 10px; }
  .kontakt .tel span, .kontakt .knopf { display: none; }
  .kontakt .tel { width: 42px; padding: 0; justify-content: center; }
  .text { gap: 16px; }
  .fakten { font-size: 14px; }
  .aktionen { display: none; }
  .segment button { padding: 0 10px; font-size: 12px; }
  .hinweis { font-size: 13px; }
  .zettel i { width: 14px; }
  .zettel span { font-size: 11px; padding: 2px 4px; }
  .zettel b { font-size: 10px; }
  .modi { flex-wrap: wrap; row-gap: 6px; }
  .klapp-raster { gap: 10px; }
  .klapp { aspect-ratio: .9; }
  .seite .tipp { display: none; }
  .preis small { font-size: 12px; }
  .etikett { top: -7%; padding: 6px 9px; }
  .karte-fuss span:last-child { display: none; }
  .fundus-leiste { padding-bottom: 84px; }
  .modi span { display: none; }
  .modi button { padding: 0 10px; }
  .zettel em { display: none; }
  .karte { aspect-ratio: auto; gap: 18px; padding: 22px 18px 18px; }
  .muster { gap: 12px 8px; }
  .mobilleiste { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; position: fixed; left: 0; right: 0; bottom: 0; z-index: 9; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); background: rgba(239, 237, 234, .94); border-top: 1px solid var(--linie); backdrop-filter: blur(8px); }
  .mobilleiste a { justify-content: center; height: 48px; }
}

/* ---------- Touch: kein Hover ---------- */
@media (hover: none) {
  .raum { cursor: default; }
  .hinweis .maus { display: none; }
}
@media (hover: hover) { .hinweis .finger { display: none; } }

/* ---------- Reduzierte Bewegung: feste Ansicht, nichts dreht oder kippt ---------- */
.reduziert .szene { animation: none; }
.reduziert .raum { cursor: default; touch-action: auto; }
.reduziert .hinweis { display: none; }
.reduziert .karte, .reduziert .karte.folgt { transform: none; transition: none; }
.reduziert .glanz { opacity: 0 !important; }
.reduziert .karte-schatten { translate: -50% -42%; transition: none; }
.reduziert .klapp-innen, .reduziert .klapp.an .klapp-innen { transform: none; transition: none; }
.reduziert .seite.hinten { transform: none; opacity: 0; visibility: hidden; transition: opacity .15s linear, visibility .15s; }
.reduziert .klapp.an .seite.hinten { opacity: 1; visibility: visible; }
.reduziert * { scroll-behavior: auto !important; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
  <a class="marke" href="#start" aria-label="Schreinerei Gramlich, Startseite">
    <svg viewBox="0 0 26 26" aria-hidden="true"><path d="M3 3h6v14h14v6H3z" fill="#1a1a1a"/><path d="M12 3h11v11H12z" fill="#c3b4a5"/></svg>
    <span><span class="marke-name">Gramlich</span><span class="marke-zusatz gewerk">Schreinerei · Würzburg-Heidingsfeld</span></span>
  </a>
  <nav class="nav" aria-label="Hauptnavigation">
    <a href="#moebel" aria-current="page">Möbel nach Maß</a><a href="#kuechen">Küchen</a><a href="#innenausbau">Innenausbau</a><a href="#referenzen">Referenzen</a><a href="#werkstatt">Werkstatt</a>
  </nav>
  <div class="kontakt">
    <a class="tel" href="tel:+49931664127" aria-label="Anrufen: 0931 66 41 27"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.2 1.5 6.3 4 5 5.6a8.5 8.5 0 0 0 5.4 5.4L12 9.7l2.5 2.1-1.2 2.4c-6.3.2-11.8-5.3-11.6-11.6z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg><span>0931 66 41 27</span></a>
    <a class="knopf signal" href="#anfrage">Anfrage</a>
  </div>
</header>

<main class="buehne" id="start">
  <!-- Variante 1: ein Möbel aus HTML-Flächen im Raum -->
  <section class="szene" data-szene="moebel" aria-labelledby="t-moebel">
    <div class="text">
      <p class="gewerk">Möbel nach Maß · Sideboard Mainkai</p>
      <h1 id="t-moebel">Von allen Seiten geprüft, bevor wir sägen.</h1>
      <p>Zu jedem Auftrag bauen wir zuerst ein Modell. Drehen Sie das Sideboard, öffnen Sie Türen und Schubkästen oder sehen Sie, aus welchen Teilen es besteht.</p>
      <dl class="fakten">
        <dt>Holz</dt><dd>Eiche massiv, geölt</dd>
        <dt>Maße</dt><dd>180 × 76 × 45 cm, frei anpassbar</dd>
        <dt>Preis</dt><dd><strong>ab 3.480 €</strong> inkl. Aufbau in Würzburg</dd>
        <dt>Lieferzeit</dt><dd>8 bis 10 Wochen</dd>
      </dl>
      <div class="aktionen"><a class="knopf signal gross" href="#anfrage">Aufmaß anfragen</a><a class="tel gross" href="tel:+49931664127">0931 66 41 27</a></div>
    </div>
    <div class="raum" data-raum tabindex="0" role="img" aria-roledescription="3D-Modell" aria-label="Sideboard Mainkai aus Eiche, drehbar. Ziehen oder Pfeiltasten links und rechts drehen das Modell.">
      <p class="raum-titel gewerk" aria-hidden="true"><span>Modell M 1:10</span><span data-ansicht>Ansicht −34°</span></p>
      <div class="objekt" data-objekt></div>
      <div class="beschriftung" data-beschriftung aria-hidden="true"></div>
    </div>
    <div class="steuer">
      <div class="segment gewerk" role="group" aria-label="Zustand des Modells">
        <button type="button" data-zustand="zu" aria-pressed="true">Geschlossen</button>
        <button type="button" data-zustand="offen" aria-pressed="false">Geöffnet</button>
        <button type="button" data-zustand="teile" aria-pressed="false">Explosion</button>
      </div>
      <p class="hinweis"><svg viewBox="0 0 22 22" aria-hidden="true"><path d="M4 11h14M4 11l3-3M4 11l3 3M18 11l-3-3M18 11l-3 3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg><span class="maus">Ziehen zum Drehen</span><span class="finger">Wischen zum Drehen</span></p>
    </div>
  </section>

  <!-- Variante 2: Musterkarte, kippt zum Zeiger, Ebenen in der Tiefe, Glanz -->
  <section class="szene" data-szene="karte" aria-labelledby="t-karte" hidden>
    <div class="text">
      <p class="gewerk">Fronten und Oberflächen</p>
      <h1 id="t-karte">Die Musterkarte kommt per Post. Kostenlos.</h1>
      <p>Acht Oberflächen aus unserer Werkstatt auf einer Karte: geölte Eiche, Nussbaum, schwarze Esche, drei Lacktöne und Linoleum. Anfassen geht nur in echt, ansehen schon hier.</p>
      <dl class="fakten">
        <dt>Format</dt><dd>DIN A5, Muster je 4 × 3 cm</dd>
        <dt>Versand</dt><dd>innerhalb von 3 Werktagen</dd>
      </dl>
      <div class="aktionen"><a class="knopf signal gross" href="#anfrage">Musterkarte bestellen</a></div>
    </div>
    <div class="tilt-raum" data-tilt-raum>
      <div class="karte-schatten" data-schatten aria-hidden="true"></div>
      <div class="karte" data-karte>
        <div class="karte-grund"></div>
        <div class="karte-kopf ebene" style="--z: 26px"><span class="marke-name">Gramlich</span><span class="gewerk">Musterkarte Fronten · 2026</span></div>
        <ul class="muster ebene" style="--z: 14px" aria-label="Oberflächen">
          <li><span class="probe" style="--c: #c9a97f"></span><b>Eiche geölt</b><small>EI-01</small></li>
          <li><span class="probe" style="--c: #e1d4bf"></span><b>Eiche weiß</b><small>EI-02</small></li>
          <li><span class="probe" style="--c: #6e4f3a"></span><b>Nussbaum</b><small>NU-01</small></li>
          <li><span class="probe" style="--c: #2a2623"></span><b>Esche schwarz</b><small>ES-09</small></li>
          <li><span class="probe" style="--c: #bdc1a9"></span><b>Lack Salbei</b><small>LA-31</small></li>
          <li><span class="probe" style="--c: #b5ada1"></span><b>Lack Zement</b><small>LA-02</small></li>
          <li><span class="probe" style="--c: #8e9ab4"></span><b>Lack Mainblau</b><small>LA-47</small></li>
          <li><span class="probe" style="--c: #a9775a"></span><b>Linoleum Lehm</b><small>LI-12</small></li>
        </ul>
        <div class="karte-fuss ebene" style="--z: 8px"><span>Originaloberflächen aus der Werkstatt</span><span>Schreinerei Gramlich, Würzburg</span></div>
        <div class="etikett ebene gewerk" style="--z: 40px">Kostenlos</div>
        <div class="glanz" aria-hidden="true"></div>
      </div>
    </div>
  </section>

  <!-- Variante 3: Klappkarten, Rückseite mit Ab-Preis -->
  <section class="szene" data-szene="klapp" aria-labelledby="t-klapp" hidden>
    <div class="text">
      <p class="gewerk">Leistungen und Ab-Preise</p>
      <h1 id="t-klapp">Was es kostet, steht hinten drauf.</h1>
      <p>Vier Gewerke, vier Richtpreise. Die Rückseite nennt Ab-Preis und Lieferzeit. Das genaue Angebot bekommen Sie nach dem Aufmaß bei Ihnen vor Ort.</p>
      <div class="aktionen"><a class="knopf signal gross" href="#anfrage">Aufmaß anfragen</a><a class="tel gross" href="tel:+49931664127">0931 66 41 27</a></div>
    </div>
    <ul class="klapp-raster" data-klapp-raster>
      <li class="klapp"><div class="klapp-innen">
        <button type="button" class="seite vorn" style="--c: var(--zement)" aria-expanded="false"><span class="nr gewerk"><span>01</span><span>Möbel</span></span>
          <svg viewBox="0 0 96 64" aria-hidden="true" fill="none" stroke="#1a1a1a" stroke-width="2"><rect x="6" y="10" width="84" height="38"/><path d="M34 10v38M62 10v38M34 29h28M18 26v8M78 26v8M44 19h8M44 38h8M12 48v8M84 48v8"/></svg>
          <span><h2>Möbel nach Maß</h2><span class="tipp">Preis ansehen</span></span></button>
        <div class="seite hinten" inert><span class="gewerk">Möbel nach Maß</span><p class="preis">ab 2.900 €<small><span>Sideboards, Schränke, Regale aus Massivholz</span><span>8 bis 10 Wochen</span></small></p><a href="#anfrage">Anfragen</a></div>
      </div></li>
      <li class="klapp"><div class="klapp-innen">
        <button type="button" class="seite vorn" style="--c: var(--holz)" aria-expanded="false"><span class="nr gewerk"><span>02</span><span>Küche</span></span>
          <svg viewBox="0 0 96 64" aria-hidden="true" fill="none" stroke="#1a1a1a" stroke-width="2"><path d="M4 26h88M8 26v32h80V26M36 26v32M64 26v32M18 14h12M66 8v18M60 8h12"/><circle cx="22" cy="20" r="0"/><path d="M48 36h4M76 36h4M20 36h4"/></svg>
          <span><h2>Einbauküchen</h2><span class="tipp">Preis ansehen</span></span></button>
        <div class="seite hinten" inert><span class="gewerk">Einbauküchen</span><p class="preis">ab 14.500 €<small><span>mit Arbeitsplatte und Einbau, ohne Geräte</span><span>10 bis 12 Wochen</span></small></p><a href="#anfrage">Anfragen</a></div>
      </div></li>
      <li class="klapp"><div class="klapp-innen">
        <button type="button" class="seite vorn" style="--c: var(--salbei)" aria-expanded="false"><span class="nr gewerk"><span>03</span><span>Türen</span></span>
          <svg viewBox="0 0 96 64" aria-hidden="true" fill="none" stroke="#1a1a1a" stroke-width="2"><path d="M30 60V4h36v56M24 60h48M36 10h24v44H36z"/><path d="M55 32h4"/></svg>
          <span><h2>Innentüren</h2><span class="tipp">Preis ansehen</span></span></button>
        <div class="seite hinten" inert><span class="gewerk">Innentüren</span><p class="preis">ab 890 €<small><span>je Tür mit Zarge, Drücker und Einbau</span><span>6 bis 8 Wochen</span></small></p><a href="#anfrage">Anfragen</a></div>
      </div></li>
      <li class="klapp"><div class="klapp-innen">
        <button type="button" class="seite vorn" style="--c: var(--blau)" aria-expanded="false"><span class="nr gewerk"><span>04</span><span>Treppen</span></span>
          <svg viewBox="0 0 96 64" aria-hidden="true" fill="none" stroke="#1a1a1a" stroke-width="2"><path d="M6 58h16V46h16V34h16V22h16V10h20M22 58V46M38 46V34M54 34V22M70 22V10"/></svg>
          <span><h2>Holztreppen</h2><span class="tipp">Preis ansehen</span></span></button>
        <div class="seite hinten" inert><span class="gewerk">Holztreppen</span><p class="preis">ab 9.800 €<small><span>Massivholz, gerade oder viertelgewendelt</span><span>12 bis 14 Wochen</span></small></p><a href="#anfrage">Anfragen</a></div>
      </div></li>
    </ul>
  </section>
</main>

<div class="fundus-leiste">
  <p class="werte mono" data-werte aria-live="polite"></p>
  <div class="modi mono" role="group" aria-label="Variante">
    <span>Variante</span>
    <button type="button" data-variante="moebel" aria-pressed="true">3D-Objekt</button>
    <button type="button" data-variante="karte" aria-pressed="false">Tilt-Karte</button>
    <button type="button" data-variante="klapp" aria-pressed="false">Klappkarten</button>
  </div>
</div>
<nav class="mobilleiste" aria-label="Schnellkontakt"><a class="tel" href="tel:+49931664127">Anrufen</a><a class="knopf signal" href="#anfrage">Anfrage</a></nav>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];

  // Eine Kurve für alles Räumliche: cubic-bezier(0.2, 0.8, 0.2, 1), aufgelöst nach x
  function bezier(x1, y1, x2, y2) {
    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;
    const sx = t => ((ax * t + bx) * t + cx) * t, sy = t => ((ay * t + by) * t + cy) * t, dx = t => (3 * ax * t + 2 * bx) * t + cx;
    return x => {
      if (x <= 0) return 0; if (x >= 1) return 1;
      let t = x;
      for (let i = 0; i < 8; i++) { const e = sx(t) - x; if (Math.abs(e) < 1e-5) break; const d = dx(t); if (Math.abs(d) < 1e-6) break; t -= e / d; }
      return sy(Math.min(1, Math.max(0, t)));
    };
  }
  const kurve = bezier(0.2, 0.8, 0.2, 1);
  const klemm = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
  const rad = d => d * Math.PI / 180;

  /* =====================================================================
     Variante 1: Sideboard aus HTML-Flächen (Quader aus 6 Divs je Teil)
     Maße in cm, 1 cm = 3 px; das ganze Objekt wird per scale3d eingepasst.
     ===================================================================== */
  const U = 3, B = 180, H = 62, T = 45, S = 2.6;      // Breite, Korpushöhe, Tiefe, Plattenstärke
  const raum = $('[data-raum]'), objekt = $('[data-objekt]'), beschriftung = $('[data-beschriftung]');
  const EICHE = [203, 174, 134], KANTE = [186, 156, 116], RUECK = [176, 150, 112], SOCKEL = [52, 47, 42];
  const L = (() => { const v = [-0.4, -0.75, 0.85], l = Math.hypot(...v); return v.map(x => x / l); })();
  const flaechen = [], teile = [];

  function quader(def) {
    const { masse: [w, h, d], pos, farbe = EICHE, kante = KANTE, in: eltern = objekt } = def;
    const el = document.createElement('div'); el.className = 'teil';
    const pw = w * U, ph = h * U, pd = d * U;
    el.style.cssText = `width:${pw}px;height:${ph}px;margin:${-ph / 2}px 0 0 ${-pw / 2}px`;
    const seiten = [
      ['v', pw, ph, 0, 0, `translateZ(${pd / 2}px)`, [0, 0, 1], w * h],
      ['h', pw, ph, 0, 0, `rotateY(180deg) translateZ(${pd / 2}px)`, [0, 0, -1], w * h],
      ['r', pd, ph, (pw - pd) / 2, 0, `rotateY(90deg) translateZ(${pw / 2}px)`, [1, 0, 0], d * h],
      ['l', pd, ph, (pw - pd) / 2, 0, `rotateY(-90deg) translateZ(${pw / 2}px)`, [-1, 0, 0], d * h],
      ['o', pw, pd, 0, (ph - pd) / 2, `rotateX(90deg) translateZ(${ph / 2}px)`, [0, -1, 0], w * d],
      ['u', pw, pd, 0, (ph - pd) / 2, `rotateX(-90deg) translateZ(${ph / 2}px)`, [0, 1, 0], w * d],
    ];
    const groesste = Math.max(w * h, d * h, w * d);
    const t = { ...def, el, winkel: 0, flaechen: {} };
    for (const [k, fw, fh, l, tp, tr, n, a] of seiten) {
      if (def.ohne && def.ohne.includes(k)) continue;
      const f = document.createElement('div'); f.className = 'flaeche';
      f.style.cssText = `width:${fw}px;height:${fh}px;left:${l}px;top:${tp}px;transform:${tr}`;
      const o = { el: f, n, rgb: a >= groesste * 0.6 ? farbe : kante, teil: t, alt: '' };
      flaechen.push(o); t.flaechen[k] = f; el.appendChild(f);
    }
    eltern.appendChild(el); teile.push(t);
    return t;
  }
  function gruppe(def) {
    const el = document.createElement('div'); el.className = 'gruppe'; objekt.appendChild(el);
    const g = { ...def, el, winkel: 0, gruppe: true }; teile.push(g); return g;
  }
  function griff(f, css) { const g = document.createElement('i'); g.className = 'griff'; g.style.cssText = css; f.appendChild(g); }
  function anker(t, x, y, z) { const a = document.createElement('i'); a.className = 'anker'; a.style.transform = `translate3d(${x * U}px,${y * U}px,${z * U}px)`; a.style.left = '50%'; a.style.top = '50%'; t.el.appendChild(a); return a; }

  // Explosion: der Korpus hebt sich um 14 cm vom Sockel, dazu je Teil ein eigener Weg. i = Reihenfolge (Versatz 40 ms)
  const HUB = -14;
  const yo = -H / 2 + S / 2, yu = H / 2 - S / 2, zv = T / 2 + 1;
  const deckel = quader({ masse: [B, S, T], pos: [0, yo, 0], weg: [0, HUB - 22, 0], i: 0 });
  const rueck = quader({ masse: [B - 2 * S, H - 2 * S, 0.8], pos: [0, 0, -T / 2 + 0.4], weg: [0, HUB, -34], i: 1, farbe: RUECK, kante: RUECK });
  const seiteL = quader({ masse: [S, H - S, T], pos: [-B / 2 + S / 2, S / 2, 0], weg: [-26, HUB, 0], i: 2 });
  const seiteR = quader({ masse: [S, H - S, T], pos: [B / 2 - S / 2, S / 2, 0], weg: [26, HUB, 0], i: 2 });
  quader({ masse: [B - 2 * S, S, T], pos: [0, yu, 0], weg: [0, HUB + 8, 0], i: 3 });
  quader({ masse: [1.9, H - 2 * S, 43], pos: [-30, 0, -0.2], weg: [-8, HUB, -10], i: 4 });
  quader({ masse: [1.9, H - 2 * S, 43], pos: [30, 0, -0.2], weg: [8, HUB, -10], i: 4 });
  quader({ masse: [56, 1.9, 40], pos: [-59.2, 2, -1.5], weg: [-14, HUB - 4, 0], i: 5 });
  quader({ masse: [56, 1.9, 40], pos: [59.2, 2, -1.5], weg: [14, HUB - 4, 0], i: 5 });
  const tuerL = quader({ masse: [59.6, 59, 2], pos: [-60.2, 1.3, zv], weg: [-8, HUB, 30], i: 6, scharnier: -59.6 / 2, auf: -100 });
  const tuerR = quader({ masse: [59.6, 59, 2], pos: [60.2, 1.3, zv], weg: [8, HUB, 30], i: 6, scharnier: 59.6 / 2, auf: 100 });
  griff(tuerL.flaechen.v, `right:${1.4 * U}px;top:${18 * U}px;width:${1.2 * U}px;height:${16 * U}px`);
  griff(tuerR.flaechen.v, `left:${1.4 * U}px;top:${18 * U}px;width:${1.2 * U}px;height:${16 * U}px`);
  // Schubkästen: Front + Zarge als Gruppe, die nach vorn fährt
  function schubkasten(yFront, auszug, verzug, i) {
    const g = gruppe({ pos: [0, 0, 0], weg: [0, HUB, 48], i, auszug, verzug });
    const front = quader({ in: g.el, masse: [58.4, 29.1, 2], pos: [0, yFront, zv] });
    griff(front.flaechen.v, `left:50%;margin-left:${-8 * U}px;top:${2.2 * U}px;width:${16 * U}px;height:${1.2 * U}px`);
    const yz = yFront + 2, zz = T / 2 - 20;
    quader({ in: g.el, masse: [1.4, 17, 40], pos: [-26.6, yz, zz] });
    quader({ in: g.el, masse: [1.4, 17, 40], pos: [26.6, yz, zz] });
    quader({ in: g.el, masse: [51.8, 1, 40], pos: [0, yz + 8, zz], farbe: RUECK });
    quader({ in: g.el, masse: [51.8, 17, 1.4], pos: [0, yz, T / 2 - 40] });
    g.front = front; return g;
  }
  const schubO = schubkasten(-13.65, 32, 0, 7);
  const schubU = schubkasten(15.85, 24, 120, 8);
  const sockel = quader({ masse: [168, 14, 37], pos: [0, H / 2 + 7, -2.5], weg: [0, 0, 0], i: 9, farbe: SOCKEL, kante: SOCKEL });

  // Boden mit Raster, Schatten und Bemaßung (liegt auf y = 45 cm, Unterkante Sockel)
  const BODEN_Y = H / 2 + 14;
  const boden = document.createElement('div'); boden.className = 'boden-flaeche';
  const bw = 320 * U, bt = 220 * U;
  boden.style.cssText = `width:${bw}px;height:${bt}px;margin:${-bt / 2}px 0 0 ${-bw / 2}px;transform:translate3d(0,${BODEN_Y * U}px,0) rotateX(90deg)`;
  boden.innerHTML = `<div class="raster"></div>
    <div class="schatten" data-schatten-boden style="width:${172 * U}px;height:${40 * U}px;left:${bw / 2 - 86 * U}px;top:${bt / 2 - 22.5 * U}px"></div>
    <div class="mass mass-x gewerk" data-mass style="width:${B * U}px;left:${bw / 2 - B / 2 * U}px;top:${bt / 2 + (T / 2 + 16) * U}px"><span>1800</span></div>
    <div class="mass mass-y gewerk" data-mass style="height:${T * U}px;left:${bw / 2 + (B / 2 + 14) * U}px;top:${bt / 2 - T / 2 * U}px"><span>450</span></div>`;
  objekt.appendChild(boden);
  const hoehe = document.createElement('div'); hoehe.className = 'mass mass-y gewerk'; hoehe.dataset.mass = '';
  hoehe.style.cssText = `height:${76 * U}px;left:${(B / 2 + 14) * U}px;top:${-H / 2 * U}px;transform:translateZ(${T / 2 * U}px)`;
  hoehe.innerHTML = '<span>760</span>';
  objekt.appendChild(hoehe);
  const masse = $$('[data-mass]', objekt), schattenBoden = $('[data-schatten-boden]', objekt);

  // Beschriftungen der Explosionszeichnung: Anker im Raum, Zettel flach darüber (bleiben lesbar und markierbar)
  const ZETTEL = [
    { a: anker(deckel, -62, -1.3, 8), seite: 'links', titel: 'Deckplatte', text: 'Eiche massiv, 26 mm' },
    { a: anker(rueck, 70, -14, -0.4), seite: 'rechts', titel: 'Rückwand', text: 'in Nut, 8 mm' },
    { a: anker(tuerR, 16, -4, 1), seite: 'rechts', titel: 'Tür', text: 'Topfband mit Dämpfung' },
    { a: anker(schubO.front, 22, -10, 1), seite: 'rechts', titel: 'Schubkasten', text: 'Vollauszug, gezinkt' },
    { a: anker(sockel, 70, 0, 18.5), seite: 'rechts', titel: 'Sockel', text: 'schwarz gebeizt' },
  ];
  for (const z of ZETTEL) {
    const el = document.createElement('div'); el.className = 'zettel ' + (z.seite === 'links' ? 'links' : '');
    el.innerHTML = `<i></i><span><b>${z.titel}</b><em>${z.text}</em></span>`;
    beschriftung.appendChild(el); z.el = el;
  }

  // Zustände und ihre Werte
  const ZUSTAND = { zu: { tuer: 0, schub: 0, teile: 0 }, offen: { tuer: 1, schub: 1, teile: 0 }, teile: { tuer: 0, schub: 0, teile: 1 } };
  const DAUER = { tuer: 900, schub: 900, teile: 1200, versatz: 40 };
  const ANSICHT = { rx: -20, ry: -34 };
  const m = { rx: ANSICHT.rx, ry: ANSICHT.ry, vry: 0, k: 1, tuer: 0, schub: 0, teile: 0, zustand: 'zu' };
  let anim = null, schmutzig = true;

  function zustand(name, sofort) {
    m.zustand = name;
    $$('[data-zustand]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.zustand === name)));
    const von = { tuer: m.tuer, schub: m.schub, teile: m.teile }, nach = ZUSTAND[name];
    if (sofort || reduziert()) { anim = null; Object.assign(m, nach); schmutzig = true; return; }
    anim = { von, nach, start: performance.now() };
  }
  function schritt(now) {
    if (!anim) return;
    const e = now - anim.start, { von, nach } = anim;
    m.tuer = von.tuer + (nach.tuer - von.tuer) * kurve(e / DAUER.tuer);
    m.schub = von.schub + (nach.schub - von.schub) * kurve(e / DAUER.schub);
    m.teile = von.teile + (nach.teile - von.teile) * kurve(e / DAUER.teile);
    anim.e = e;
    if (e > DAUER.teile + 10 * DAUER.versatz + 50) anim = null;
    schmutzig = true;
  }
  // Explosion je Teil mit Versatz: eigener Fortschritt aus der laufenden Animation
  function teilFortschritt(i) {
    if (!anim) return m.teile;
    const { von, nach } = anim, e = anim.e || 0;
    return von.teile + (nach.teile - von.teile) * kurve((e - i * DAUER.versatz) / DAUER.teile);
  }

  function einpassen() {
    const r = raum.getBoundingClientRect();
    m.k = Math.min(r.width / (B * U * (r.width < 560 ? 1.32 : 1.62)), r.height / (136 * U));
    m.w = r.width; m.h = r.height; schmutzig = true;
  }

  function zeichnen() {
    const ty = (6 - 15 * m.teile) * U * m.k;    // Kamera folgt dem sich hebenden Korpus
    objekt.style.transform = `translate3d(0,${ty}px,0) rotateX(${m.rx}deg) rotateY(${m.ry}deg) scale3d(${m.k},${m.k},${m.k})`;
    for (const t of teile) {
      if (!t.pos) continue;
      let [x, y, z] = t.pos;
      if (t.weg) { const p = teilFortschritt(t.i || 0); x += t.weg[0] * p; y += t.weg[1] * p; z += t.weg[2] * p; }
      if (t.gruppe) {
        const e = anim ? anim.e || 0 : 0;
        let s = m.schub;
        if (anim && t.verzug) { const { von, nach } = anim; s = von.schub + (nach.schub - von.schub) * kurve((e - t.verzug) / DAUER.schub); }
        z += t.auszug * s;
      }
      let tr = `translate3d(${x * U}px,${y * U}px,${z * U}px)`;
      if (t.auf) {
        t.winkel = t.auf * m.tuer;
        const h = t.scharnier * U;
        tr += ` translateX(${h}px) rotateY(${t.winkel}deg) translateX(${-h}px)`;
      }
      t.el.style.transform = tr;
    }
    schattieren();
    const ma = klemm(1 - m.teile * 2.5);
    masse.forEach(el => el.style.opacity = ma);
    schattenBoden.style.opacity = 1 - m.teile * 0.6;
    zettel();
  }

  // Licht von links oben vorn: Helligkeit je Fläche aus ihrer Normalen nach allen Drehungen
  function schattieren() {
    const cx = Math.cos(rad(m.rx)), sx = Math.sin(rad(m.rx)), cy = Math.cos(rad(m.ry)), sy = Math.sin(rad(m.ry));
    for (const f of flaechen) {
      let [x, y, z] = f.n;
      const w = f.teil.winkel;
      if (w) { const c = Math.cos(rad(w)), s = Math.sin(rad(w)); [x, z] = [c * x + s * z, -s * x + c * z]; }
      [x, z] = [cy * x + sy * z, -sy * x + cy * z];
      [y, z] = [cx * y - sx * z, sx * y + cx * z];
      const d = x * L[0] + y * L[1] + z * L[2];
      const b = 0.64 + 0.44 * Math.max(0, d);
      const c = `rgb(${Math.min(255, f.rgb[0] * b) | 0},${Math.min(255, f.rgb[1] * b) | 0},${Math.min(255, f.rgb[2] * b) | 0})`;
      if (c !== f.alt) { f.el.style.backgroundColor = c; f.alt = c; }
    }
  }

  function zettel() {
    const o = klemm((m.teile - 0.55) / 0.45);
    if (o <= 0) { ZETTEL.forEach(z => z.el.style.opacity = 0); return; }
    const r = raum.getBoundingClientRect(), belegt = [];
    for (const z of ZETTEL) {
      const a = z.a.getBoundingClientRect(), x = a.left - r.left, y = a.top - r.top;
      // Zettel auf die Seite legen, auf der er Platz hat und keinen anderen verdeckt
      const w = z.el.offsetWidth, h = z.el.offsetHeight;
      const passt = l => (l ? x - w >= 4 : x + w <= r.width - 4);
      const frei = l => { const x0 = l ? x - w : x; return !belegt.some(q => x0 < q[2] && x0 + w > q[0] && y - h / 2 < q[3] && y + h / 2 > q[1]); };
      let links = z.seite === 'links';
      if (!passt(links) || (!frei(links) && passt(!links) && frei(!links))) links = !links;
      belegt.push([links ? x - w : x, y - h / 2, links ? x : x + w, y + h / 2]);
      if (links !== z.el.classList.contains('links')) z.el.classList.toggle('links', links);
      z.el.style.transform = `translate3d(${x}px,${y}px,0)`;
      z.el.style.opacity = o;
    }
  }

  // Werte-Anzeige
  const werte = $('[data-werte]'), ansicht = $('[data-ansicht]');
  let variante = 'moebel', letzteRy = null;
  function zeigeWerte() {
    if (variante === 'moebel') {
      werte.innerHTML = reduziert()
        ? '<span>Perspektive <b>1400 px</b></span><span>Ansicht <b>rotateX(−20°) rotateY(−34°), fest</b></span><span>Reduzierte Bewegung: <b>Zustände ohne Animation</b></span>'
        : `<span>Perspektive <b>1400 px</b></span><span>Ansicht <b>rotateX(${Math.round(m.rx)}°) rotateY(${Math.round(m.ry)}°)</b></span><span>Türen <b>0 → 100°, 900 ms</b></span><span>Schubkästen <b>32 / 24 cm, 900 ms, +120 ms</b></span><span>Explosion <b>1200 ms, Versatz 40 ms</b></span><span>Kurve <b>cubic-bezier(0.2, 0.8, 0.2, 1)</b></span>`;
    } else if (variante === 'karte') {
      werte.innerHTML = reduziert()
        ? '<span>Reduzierte Bewegung: <b>Karte flach, kein Glanz</b></span>'
        : '<span>Perspektive <b>1000 px</b></span><span>Tilt <b>max. 10°</b></span><span>Ebenen <b>translateZ 8 / 14 / 26 / 40 px</b></span><span>Folgen <b>120 ms linear</b></span><span>Rückkehr <b>500 ms cubic-bezier(0.2, 0.8, 0.2, 1)</b></span>';
    } else {
      werte.innerHTML = reduziert()
        ? '<span>Reduzierte Bewegung: <b>Rückseite blendet in 150 ms über</b></span>'
        : '<span>Perspektive <b>1000 px</b></span><span>Klappen <b>rotateX 0 → 180°</b></span><span>Dauer <b>600 ms</b></span><span>Kurve <b>cubic-bezier(0.2, 0.8, 0.2, 1)</b></span><span>Rückseite <b>backface-visibility: hidden, inert</b></span>';
    }
  }
  function ansichtText() {
    const r = Math.round(m.ry);
    if (r === letzteRy) return;
    letzteRy = r;
    ansicht.textContent = `Ansicht ${r < 0 ? '−' : ''}${Math.abs(r)}°`;
    if (variante === 'moebel' && !reduziert()) zeigeWerte();
  }

  // Ziehen dreht, beim Loslassen läuft der Schwung aus
  let zug = null;
  raum.addEventListener('pointerdown', e => {
    if (reduziert() || e.button > 0) return;
    stopVorfuehrung();
    zug = { x: e.clientX, y: e.clientY, t: performance.now(), id: e.pointerId, maus: e.pointerType === 'mouse' };
    m.vry = 0;
    if (zug.maus) { raum.setPointerCapture(e.pointerId); raum.classList.add('zieht'); }
  });
  raum.addEventListener('pointermove', e => {
    if (!zug || e.pointerId !== zug.id) return;
    const dx = e.clientX - zug.x, dy = e.clientY - zug.y, now = performance.now();
    m.ry += dx * 0.35;
    if (zug.maus) m.rx = klemm(m.rx - dy * 0.2, -38, -6);
    m.vry = dx * 0.35 / Math.max(8, now - zug.t) * 16.7;
    zug.x = e.clientX; zug.y = e.clientY; zug.t = now; schmutzig = true;
  });
  const loslassen = () => { if (zug) { zug = null; raum.classList.remove('zieht'); } };
  raum.addEventListener('pointerup', loslassen);
  raum.addEventListener('pointercancel', loslassen);
  raum.addEventListener('keydown', e => {
    if (reduziert()) return;
    const d = { ArrowLeft: -15, ArrowRight: 15 }[e.key];
    if (!d) return;
    e.preventDefault(); stopVorfuehrung(); dreheZu(m.ry + d, 450);
  });
  let drehung = null;
  function dreheZu(ziel, dauer) { drehung = { von: m.ry, nach: ziel, rxVon: m.rx, start: performance.now(), dauer }; }
  $$('[data-zustand]').forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); zustand(b.dataset.zustand); }));

  // Ein Bildtakt für alles; gezeichnet wird nur, wenn sich etwas geändert hat
  let vorfuehrung = null, takt = 0;
  function loop(now) {
    takt = requestAnimationFrame(loop);
    if (variante !== 'moebel') return;
    if (vorfuehrung) vorfuehrSchritt(now);
    if (drehung) {
      const p = kurve((now - drehung.start) / drehung.dauer);
      m.ry = drehung.von + (drehung.nach - drehung.von) * p;
      m.rx = drehung.rxVon + (ANSICHT.rx - drehung.rxVon) * p;
      if (p >= 1) drehung = null;
      schmutzig = true;
    }
    if (!zug && Math.abs(m.vry) > 0.02) { m.ry += m.vry; m.vry *= 0.92; schmutzig = true; }
    schritt(now);
    if (schmutzig) { schmutzig = false; zeichnen(); ansichtText(); }
  }

  /* =====================================================================
     Variante 2: Musterkarte kippt zum Zeiger (max. 10°), Glanz folgt
     ===================================================================== */
  const karte = $('[data-karte]'), schatten = $('[data-schatten]');
  const TILT = 10;
  function kippe(u, v) {          // u, v in −1 … 1 (Zeiger relativ zur Kartenmitte)
    if (reduziert()) return;
    karte.style.setProperty('--rx', (-v * TILT).toFixed(2) + 'deg');
    karte.style.setProperty('--ry', (u * TILT).toFixed(2) + 'deg');
    karte.style.setProperty('--gx', (50 + u * 50).toFixed(1) + '%');
    karte.style.setProperty('--gy', (50 + v * 50).toFixed(1) + '%');
    karte.style.setProperty('--glanz', 1);
    schatten.style.setProperty('--sx', (-u * 18).toFixed(1) + 'px');
    schatten.style.setProperty('--sy', (-v * 14 + 8).toFixed(1) + 'px');
  }
  function zurueck() {
    karte.classList.remove('folgt');
    karte.style.setProperty('--rx', '0deg'); karte.style.setProperty('--ry', '0deg'); karte.style.setProperty('--glanz', 0);
    schatten.style.setProperty('--sx', '0px'); schatten.style.setProperty('--sy', '0px');
  }
  function kippeAus(e) {
    const r = karte.getBoundingClientRect();
    kippe(klemm((e.clientX - r.left) / r.width * 2 - 1, -1, 1), klemm((e.clientY - r.top) / r.height * 2 - 1, -1, 1));
  }
  let kartenZug = false;
  karte.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') { stopVorfuehrung(); karte.classList.add('folgt'); } });
  karte.addEventListener('pointermove', e => { if (e.pointerType === 'mouse' || kartenZug) kippeAus(e); });
  karte.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') zurueck(); });
  // Touch: Finger auf der Karte kippt sie, Loslassen federt zurück
  karte.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') { stopVorfuehrung(); kartenZug = true; karte.classList.add('folgt'); karte.setPointerCapture(e.pointerId); kippeAus(e); } });
  const kartenEnde = () => { if (kartenZug) { kartenZug = false; zurueck(); } };
  karte.addEventListener('pointerup', kartenEnde);
  karte.addEventListener('pointercancel', kartenEnde);

  /* =====================================================================
     Variante 3: Klappkarten (rotateX 180°, Rückseite inert, solange verdeckt)
     ===================================================================== */
  const klappen = $$('.klapp');
  function klappe(k, an) {
    k.classList.toggle('an', an);
    k.querySelector('.vorn').setAttribute('aria-expanded', String(an));
    k.querySelector('.hinten').inert = !an;
  }
  const hoverGeraet = matchMedia('(hover: hover)');
  klappen.forEach(k => {
    k.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse' && hoverGeraet.matches) { stopVorfuehrung(); klappe(k, true); } });
    k.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && hoverGeraet.matches) klappe(k, false); });
    k.querySelector('.vorn').addEventListener('click', () => {
      stopVorfuehrung();
      const an = !k.classList.contains('an');
      klappen.forEach(o => { if (o !== k) klappe(o, false); });
      klappe(k, an);
      if (an) k.querySelector('.hinten a').focus({ preventScroll: true });
    });
    k.querySelector('.hinten').addEventListener('focusout', e => { if (!k.contains(e.relatedTarget)) klappe(k, false); });
  });

  /* =====================================================================
     Variante wählen, Vorführung, API
     ===================================================================== */
  function zeigeVariante(v) {
    variante = v;
    $$('[data-szene]').forEach(s => s.hidden = s.dataset.szene !== v);
    $$('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
    if (v === 'moebel') { einpassen(); schmutzig = true; }
    zeigeWerte();
  }
  $$('[data-variante]').forEach(b => b.addEventListener('click', () => {
    const lief = !!vorfuehrung; stopVorfuehrung(); grundzustand(); zeigeVariante(b.dataset.variante);
    if (lief) vorfuehren();
  }));

  function grundzustand() {
    drehung = null; m.vry = 0; m.rx = ANSICHT.rx; m.ry = ANSICHT.ry; zustand('zu', true);
    zurueck(); klappen.forEach(k => klappe(k, false));
    html.classList.remove('vorfuehrung');
  }

  // Möbel: 12 s pro Runde. Das Modell pendelt um die Grundansicht, dazu geöffnet, Explosion, geschlossen
  const PLAN = [[0, 'zu'], [1500, 'offen'], [4800, 'teile'], [8800, 'zu']], RUNDE = 12000;
  function vorfuehrSchritt(now) {
    const v = vorfuehrung, e = (now - v.start) % RUNDE;
    if (variante === 'moebel') {
      if (!reduziert()) {
        m.ry = ANSICHT.ry + 20 * Math.sin(e / RUNDE * Math.PI * 2);
        m.rx = ANSICHT.rx - 6 * Math.sin(e / RUNDE * Math.PI * 4);
        schmutzig = true;
      }
      let soll = 'zu';
      for (const [ab, z] of PLAN) if (e >= ab) soll = z;
      if (soll !== m.zustand) zustand(soll);
    }
  }
  let kartenUhr = 0, klappUhr = 0;
  function karteVorfuehren() {
    const start = performance.now();
    const f = now => {
      const e = (now - start) % 5200;
      if (e < 3800) {
        if (!karte.classList.contains('folgt')) karte.classList.add('folgt');
        const p = e / 3800 * Math.PI * 2;
        kippe(Math.sin(p) * 0.9, Math.sin(p * 2) * 0.7 - 0.1);
      } else if (karte.classList.contains('folgt')) zurueck();
      kartenUhr = requestAnimationFrame(f);
    };
    kartenUhr = requestAnimationFrame(f);
  }
  function klappVorfuehren() {
    let i = 0;
    const naechste = () => {
      klappe(klappen[i % 4], true);
      const k = klappen[i % 4];
      klappUhr = setTimeout(() => { klappe(k, false); i++; klappUhr = setTimeout(naechste, 450); }, 1700);
    };
    naechste();
  }
  function stopVorfuehrung() {
    vorfuehrung = null; cancelAnimationFrame(kartenUhr); clearTimeout(klappUhr);
    html.classList.remove('vorfuehrung');
  }
  function vorfuehren() {
    stopVorfuehrung(); grundzustand();
    html.classList.add('vorfuehrung');
    vorfuehrung = { start: performance.now() };
    if (variante === 'karte') karteVorfuehren();
    if (variante === 'klapp') klappVorfuehren();
  }
  let pausiert = false;
  function pause() {
    if (!vorfuehrung && !html.classList.contains('vorfuehrung')) return;
    pausiert = true; cancelAnimationFrame(kartenUhr); clearTimeout(klappUhr);
    if (vorfuehrung) vorfuehrung.pauseAb = performance.now();
    cancelAnimationFrame(takt); takt = 0;
  }
  function weiter() {
    if (!pausiert) return;
    pausiert = false;
    if (vorfuehrung && vorfuehrung.pauseAb) { vorfuehrung.start += performance.now() - vorfuehrung.pauseAb; vorfuehrung.pauseAb = 0; }
    if (!takt) takt = requestAnimationFrame(loop);
    if (variante === 'karte') karteVorfuehren();
    if (variante === 'klapp') klappVorfuehren();
  }
  function neustart() { stopVorfuehrung(); pausiert = false; grundzustand(); if (!takt) takt = requestAnimationFrame(loop); schmutzig = true; zeigeWerte(); }

  new ResizeObserver(einpassen).observe(raum);
  einpassen(); zeigeWerte();
  takt = requestAnimationFrame(loop);

})();
</script>
</body>
</html>