// Leitet aus den Wänden eines Projekts die serialisierbaren WallInput-Records
// für den nativen render3d-Renderer ab. Matcht 1:1 render3d::types::WallInput
// { start:[x,y], end:[x,y], thickness, height, baseElevation, color:[r,g,b] }
// mit Modell-Metern und Y-up-Extrusion (Rust-Seite: plan liegt in XZ, +Y hoch).
//
// Alle Geschosse werden einbezogen und über wallVerticalExtent korrekt
// gestapelt (EG baseElevation 0, OG 2.6 …), sodass das 3D-Fenster das ganze
// Gebäude zeigt.
//
// ÖFFNUNGEN (Türen/Fenster): eine Wand mit Öffnungen wird NICHT als ein Quader
// emittiert, sondern in Teilquader entlang der Achse zerlegt:
//   • volle Wandhöhe links/rechts der Öffnung (Pfeiler),
//   • unter dem Fenster die Brüstung (UK..Sill),
//   • über Tür/Fenster der Sturz (OpeningTop..Wandkopf).
// So entstehen sichtbare Aussparungen ohne dass render3d etwas davon wissen muss.
//
// DECKEN (Slabs): geschossgebundene Flächenbauteile werden als extrudierte
// Polygon-Platten (SlabInput) mitgegeben — render3d trianguliert den Umriss und
// zieht ihn über die Deckendicke hoch.
//
// SCHICHTEN (Putz/Dämmung/Mauerwerk/…): der aufgelöste Schichtaufbau des
// WallType wird HIER (TS-seitig) in echte 3D-Substanz zerlegt — statt EINES
// Vollkörpers in der repräsentativen Farbe emittiert jede Achsen-Teilquader-
// Position je Materiallage eine EIGENE Box, quer zur Wanddicke entlang der
// Wand-Normalen gestapelt und in der Materialfarbe ihres Components eingefärbt
// (Option B). Schicht i belegt entlang der Normalen das Intervall
//   [−T/2 + Σ_{k<i} t_k,  −T/2 + Σ_{k≤i} t_k],  T = Σ t_k (Summe der Lagen),
// zentriert um die Wandachse. Alle Schicht-Boxen einer Wand tragen dieselbe
// `wallId` (Klick/Highlight selektieren die GANZE Wand). Jede Box ist ein
// Vollkörper (`layers: []`), den render3d in ihrer `color` zeichnet — die
// Schicht-Aufteilung passiert also komplett hier, render3d bleibt unverändert.
// Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle
// Dicke im neutralen Grundton (heutiges Fallback-Verhalten).

import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination } from "../model/types";
import {
  getComponent,
  getWallType,
  getCeilingType,
  getStairType,
  wallTypeThickness,
  openingsOfWall,
  getFloor,
  flattenCategories,
} from "../model/types";
import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
import { pointInOutline } from "../geometry/ceiling";
import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening";
import { stairGeometry, stairBBox } from "../geometry/stair";
import { extrudePolygon, extrudeCircle } from "../engine/truckSolid";
import { computeJoins } from "../model/joins";
import type { WallCuts } from "../model/joins";
import { add, dot, leftNormal, lineIntersect, normalize, scale, sub } from "../model/geometry";
import type { Line } from "../model/geometry";
import { resolveHatch } from "./generatePlan";
import type { PickOpening, PickStair } from "../viewport/raycast3d";

/**
 * Achswinkel einer GESCHNITTENEN Wand im Schnitt-Koordinatensystem (Modell-
 * Konvention, identisch zu `SECTION_WALL_AXIS_ANGLE_DEG` in toSection.ts): das
 * Wand-Band steht vertikal (Achse entlang v, atan2(1,0) = 90°). Damit drehen
 * `relativeToWall`-Muster (Dämmung, Backstein) im 3D-Schnitt genauso mit der Wand
 * wie im 2D-Schnitt (`splitWallLayers` → `resolveHatch(…, 90, …)`). Für DECKEN gibt
 * es keinen Wandachsen-Bezug — dort bleibt der Winkel absolut (`emitSlabs` ruft
 * `resolveComponentHatch` ohne Achswinkel, wie `splitSlabLayers` → `resolveHatch(…,
 * undefined, …)`), sodass die liegende Boden-Dämmung vertikal statt horizontal läuft.
 */
const SECTION_WALL_AXIS_ANGLE_DEG = 90;

export type RVec2 = [number, number];
export type RRgb = [number, number, number];
/** Weltpunkt (x, elevation, y) — nur intern für Highlight-Kantengeometrie. */
type RVec3 = [number, number, number];

/**
 * Eine aufgelöste Wandschicht: Dicke + 3D-Albedo-Farbe (aus dem Component).
 * Wird seit der TS-seitigen Schicht-Zerlegung (Option B, siehe Moduldoc) NICHT
 * mehr an render3d durchgereicht — jede Schicht ist eine eigene Voll-Box; das
 * `RWall.layers`-Feld bleibt für Serde-Kompatibilität, aber stets leer.
 */
export interface RLayer {
  thickness: number;
  color: RRgb;
}

/**
 * Schnitt-Schraffur eines Bauteil-Bandes für den 3D-Live-Schnitt — matcht 1:1
 * render3d::types::Hatch. Nur die drei Felder, die der prozedurale Cap-Shader
 * (`CAP_WGSL`) auswertet: Muster-Id, Winkel (Grad), Skalierung. MONOCHROM (Tinte
 * auf Papier, wie die 2D-Direktive `HATCH_INK`) — es wandert nur das MUSTER mit,
 * KEINE Farbe. Muster-Id: 0 = none, 1 = solid, 2 = diagonal, 3 = crosshatch,
 * 4 = insulation (Zickzack/Dämmung). Synchron zu halten mit `hatchPatternId`
 * (unten), `types::Hatch` (Rust) und `CAP_WGSL`.
 */
export interface RHatch {
  pattern: number;
  angle: number;
  scale: number;
  /**
   * Strichstärke der Schraffur-Musterlinien in mm-Papier (aus dem LineStyle des
   * Hatch, `HatchRender.lineWeight`; Default 0.13). Matcht render3d::Hatch.lineWeight
   * — der Cap-Shader setzt die Musterlinien-Breite daraus (dieselbe mm→Welt-
   * Kalibrierung wie die Trennlinien), sodass die Schraffur-Stärke im 3D pro
   * Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs. `thin` 0.13 mm).
   */
  lineWeight?: number;
}

/** Bildet ein 2D-{@link HatchPattern} auf die Muster-Id von {@link RHatch}/CAP_WGSL ab. */
function hatchPatternId(p: HatchPattern): number {
  switch (p) {
    case "solid":
      return 1;
    case "diagonal":
      return 2;
    case "crosshatch":
      return 3;
    case "insulation":
      return 4;
    case "none":
    default:
      return 0;
  }
}

/**
 * Löst die Schnitt-Schraffur eines Bauteils (per `Component.hatchId`) zu einem
 * {@link RHatch} auf — dasselbe Muster/Winkel/Skalierung wie der 2D-Schnitt
 * (`generatePlan.ts::resolveHatch`, hier bewusst NICHT aufgerufen: nur die drei
 * render3d-relevanten Felder werden direkt aus dem {@link HatchStyle} gezogen, die
 * Farbe bleibt monochrom im Shader). `undefined`, wenn das Bauteil/die Schraffur
 * nicht auflösbar ist (der Cap-Shader fällt dann auf sein Diagonalmuster zurück).
 */
function resolveComponentHatch(
  project: Project,
  componentId: string,
  axisAngleDeg?: number,
): RHatch | undefined {
  try {
    const comp = getComponent(project, componentId);
    if (!comp.hatchId) return undefined;
    // GENAU der 2D-Schnitt-Pfad (`splitWallLayers`/`splitSlabLayers` →
    // `resolveHatch`): `axisAngleDeg` = 90 für WAND-Bänder (die geschnittene Wand
    // steht vertikal, `relativeToWall`-Muster drehen mit), `undefined` für DECKEN
    // (kein Wandachsen-Bezug → absoluter Winkel). Wir übernehmen den ENDWINKEL
    // (`HatchRender.angle`, bereits relativeToWall-korrigiert) UND die Musterlinien-
    // Stärke (`lineWeight`, aus dem LineStyle) — nicht mehr den rohen Hatch-Winkel.
    const hr = resolveHatch(project, comp.hatchId, axisAngleDeg);
    return { pattern: hatchPatternId(hr.pattern), angle: hr.angle, scale: hr.scale, lineWeight: hr.lineWeight };
  } catch {
    return undefined;
  }
}

/**
 * Schnitt-Schraffur der dicksten (dominanten) Schicht eines Schichtstapels.
 * `axisAngleDeg` wie {@link resolveComponentHatch} (90 für Wand, undefined für Decke).
 */
function dominantLayerHatch(
  layers: Layer[],
  project: Project,
  axisAngleDeg?: number,
): RHatch | undefined {
  if (layers.length === 0) return undefined;
  const dominant = layers.reduce((best, l) => (l.thickness > best.thickness ? l : best));
  return resolveComponentHatch(project, dominant.componentId, axisAngleDeg);
}

/**
 * Geordnete Liste der EINDEUTIGEN Material-Farbkarten-URLs (`Component.material.
 * color`), die von Wand-/Decken-Bauteilen des Projekts benutzt werden — in
 * deterministischer First-Seen-Reihenfolge über `project.walls` (Wandtyp-Schichten)
 * dann `project.ceilings`. Die POSITION in dieser Liste (+1) ist der Textur-Array-
 * Index (`WallInput.materialIndex`, 1-basiert; Ebene 0 = Fallback-Schachbrett), den
 * sowohl {@link resolveComponentMaterialLayer} (Zuweisung je Band) als auch der
 * JS-Decoder ({@link import("../viewport/materialTextures")}) verwenden — so
 * stimmen Band-Index und hochgeladene Textur-Ebene ohne separaten Map-Transport
 * überein. Memoisiert je Projekt-REFERENZ (der Store ist immutabel — jede Änderung
 * liefert ein neues Objekt, also kein Stale-Risiko), damit die per-Band-Auflösung
 * nicht quadratisch wird.
 */
const materialMapCache = new WeakMap<Project, string[]>();
export function wallMaterialColorMaps(project: Project): string[] {
  const cached = materialMapCache.get(project);
  if (cached) return cached;
  const seen = new Set<string>();
  const list: string[] = [];
  const consider = (componentId: string): void => {
    try {
      const url = getComponent(project, componentId).material?.color;
      if (url && !seen.has(url)) {
        seen.add(url);
        list.push(url);
      }
    } catch {
      // Unauflösbares Bauteil überspringen.
    }
  };
  for (const w of project.walls) {
    try {
      for (const l of getWallType(project, w).layers) consider(l.componentId);
    } catch {
      // Kein auflösbarer Wandtyp.
    }
  }
  for (const c of project.ceilings ?? []) {
    try {
      for (const l of getCeilingType(project, c).layers) consider(l.componentId);
    } catch {
      // Kein auflösbarer Deckentyp.
    }
  }
  materialMapCache.set(project, list);
  return list;
}

/**
 * Textur-Array-Ebene (1-basiert) der Material-Farbkarte eines Bauteils, oder
 * `undefined`, wenn das Bauteil kein Material mit Farbkarte trägt. Ebene 0 ist das
 * Fallback-Schachbrett — deshalb `indexOf + 1`. Siehe {@link wallMaterialColorMaps}.
 */
function resolveComponentMaterialLayer(project: Project, componentId: string): number | undefined {
  try {
    const url = getComponent(project, componentId).material?.color;
    if (!url) return undefined;
    const idx = wallMaterialColorMaps(project).indexOf(url);
    return idx >= 0 ? idx + 1 : undefined;
  } catch {
    return undefined;
  }
}

/**
 * Ein rechteckiges LOCH (Fenster/Tür) in EINEM Wandkörper — matcht 1:1
 * render3d::types::Hole. KONVENTION: `from`/`to` sind Achsen-Meter ab
 * `RWall.start` (0..Achslänge, dieselbe Achse wie die Öffnungs-Intervalle);
 * `zBottom`/`zTop` sind ABSOLUTE Weltmeter (NICHT relativ zur Wand-UK — anders
 * als `Opening.sillHeight`). Eine Tür ist der Sonderfall
 * `zBottom == RWall.baseElevation` (Loch bis zur Wand-UK). render3d stanzt die
 * Löcher als echte Aussparungen in die beiden Langseiten (Rechteck-Gitter-
 * Zerlegung + Laibungsflächen), statt die Wand in Pfeiler/Brüstung/Sturz zu
 * zerlegen.
 */
export interface RHole {
  from: number;
  to: number;
  zBottom: number;
  zTop: number;
}

/**
 * Schnitt-Verschneidungs-/Linienstil-Metadaten EINES geschnittenen Bauteil-Bandes
 * für den 3D-Live-Schnitt — matcht 1:1 render3d::types::CutBandMeta. Trägt die
 * `joinPriority` (Schnitt-Boolean-Dominanz: die stärkere Schicht schneidet die
 * schwächere, `section_boolean::subtract_dominant_bands`), die `componentId` (MERGE-
 * Regel gleichartiger Bänder) sowie Farbe/Stärke der Schnitt-Umrisslinie (P2, aus
 * dem `jointLineStyleId`-LineStyle der Schicht). Alle Felder optional — fehlt eines,
 * greift auf der Rust-Seite der jeweilige Default (keine Subtraktion / Default-
 * Schnittkante).
 */
export interface RCutMeta {
  joinPriority?: number;
  componentId?: string;
  lineColor?: RRgb;
  lineWeight?: number;
}

export interface RWall {
  start: RVec2;
  end: RVec2;
  thickness: number;
  height: number;
  baseElevation: number;
  color: RRgb;
  /**
   * Immer leer: die Schichten werden TS-seitig in eigene Voll-Boxen zerlegt
   * (Option B, siehe Moduldoc). Feld bleibt nur zur Serde-Kompatibilität mit
   * render3d::WallInput (leer ⇒ Vollkörper-Extrusion in `color`).
   */
  layers: RLayer[];
  /**
   * Id der Ursprungs-Wand (`project.walls[].id`). Alle Öffnungs-Teilquader einer
   * Wand tragen dieselbe Id. Rein für den TS-seitigen Pick-Raycast (raycast3d);
   * der render3d/Serde-Pfad ignoriert das Feld (kein `deny_unknown_fields`,
   * serde verwirft unbekannte Felder).
   */
  wallId: string;
  /**
   * Echte rechteckige Löcher (Fenster/Türen) in DIESEM Wandkörper (siehe
   * {@link RHole}). Nur im 3D-Viewer-Pfad ({@link projectToModel3d} mit
   * `layeredWalls:true`) gesetzt: dort emittiert {@link emitWall} EINEN RWall pro
   * Schicht-Band über die volle Achse mit ALLEN Öffnungen als `holes` — statt der
   * Pfeiler/Brüstung/Sturz-Segment-Boxen. So bildet EIN Körper auch versetzt
   * übereinanderliegende Fenster ab (das kann die Segment-Zerlegung nicht). Der
   * Schnitt-Pfad (`layeredWalls:false`) setzt `holes` NICHT und segmentiert
   * unverändert weiter (Owner-Kompatibilität mit `toSection.ts`, s. u.). Fehlt/
   * leer ⇒ Vollkörper ohne Aussparung (render3d: `#[serde(default)]`). `holes`
   * und die Segment-Zerlegung schliessen sich damit gegenseitig aus.
   */
  holes?: RHole[];
  /**
   * Schnitt-Schraffur dieses Wand-Bandes (siehe {@link RHatch}) — matcht
   * render3d::WallInput.hatch. Im layered-3D-Pfad trägt jede Materiallage die
   * Schraffur IHRES Bauteils, sodass der 3D-Live-Schnitt jede geschnittene
   * Schicht in ihrem Muster (Beton/Dämmung/Backstein/…) zeichnet. Fehlt sie
   * (unauflösbares Bauteil), zeichnet der Cap-Shader sein Diagonal-Fallback.
   */
  hatch?: RHatch;
  /**
   * Material-Textur-Ebene dieses Wand-Bandes für den Stil `Textured` (matcht
   * render3d::WallInput.materialIndex, 1-basiert; Ebene 0 = Fallback-Schachbrett).
   * Aus `Component.material.color` über {@link wallMaterialColorMaps} aufgelöst;
   * fehlt sie, zeigt render3d das Schachbrett. Der Schnitt/Serde-Pfad ignoriert es.
   */
  materialIndex?: number;
  /**
   * Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe
   * {@link RCutMeta}) — matcht render3d::WallInput.cut. Im layered-3D-Pfad trägt
   * jede Materiallage ihre `joinPriority`/`componentId`/Schnittkanten-Stil, sodass
   * der 3D-Live-Schnitt die Boolean-Dominanz + Fugenlinien wie der 2D-Schnitt macht.
   */
  cut?: RCutMeta;
}

/** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */
export interface RSlab {
  outline: RVec2[];
  zBottom: number;
  zTop: number;
  color: RRgb;
  /**
   * Id der Ursprungs-Decke (`project.ceilings[].id`). Nur für den TS-seitigen
   * Pick-Raycast (raycast3d); der render3d/Serde-Pfad ignoriert das Feld
   * (kein `deny_unknown_fields`).
   */
  ceilingId: string;
  /** Schnitt-Schraffur der Decke (dominante Schicht, siehe {@link RHatch}). */
  hatch?: RHatch;
  /** Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe {@link RCutMeta}). */
  cut?: RCutMeta;
}

/** Art eines rohen Kontext-Meshes (bestimmt in render3d die Default-Einfärbung). */
export type RMeshKind = "terrain" | "imported" | "extrusion";

/**
 * Ein rohes Dreiecks-Mesh aus dem Geo-Kontext (Terrain-TIN oder importiertes
 * Gebäude-/DXF-Volumen). `positions` ist das flache (x,y,z=Höhe…)-Array in
 * MODELL-Metern — genau wie `ImportedMesh`/`TerrainMesh` es hält; render3d bildet
 * es selbst nach world (x, z, y) ab (analog Wände/Decken). Matcht
 * render3d::types::MeshInput.
 */
export interface RMesh {
  positions: number[];
  indices: number[];
  kind: RMeshKind;
  color: RRgb;
}

/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */
export interface RModel3d {
  walls: RWall[];
  slabs: RSlab[];
  meshes: RMesh[];
}

/** Warmer, neutraler Wand-Grundton (wie render3d default). */
const WALL_RGB: RRgb = [0.82, 0.8, 0.76];
/** Etwas hellerer, kühlerer Ton für Deckenplatten (heben sich von Wänden ab). */
const SLAB_RGB: RRgb = [0.86, 0.86, 0.88];
/** Gedämpftes Lehm-/Geländegrün-grau (three.js mats.terrain = 0xa9b0a2). */
const TERRAIN_RGB: RRgb = [169 / 255, 176 / 255, 162 / 255];
/** Neutrales Hellgrau für importierte Meshes (three.js mats.mesh = 0x9fa6ae). */
const IMPORTED_RGB: RRgb = [159 / 255, 166 / 255, 174 / 255];
/** Warmes Orange für truck-Profil-Extrusionen (hebt sie klar von Wand/Decke/Kontext ab). */
const EXTRUSION_RGB: RRgb = [0.85, 0.55, 0.25];
/** Neutraler Beton-/Tragwerks-Grauton für Stützen (Fallback ohne Bauteil-Farbe). */
const COLUMN_RGB: RRgb = [0.7, 0.7, 0.72];
/** Helles, leicht bläuliches Fenster-Glas (Scheibe in der Öffnung). */
const GLASS_RGB: RRgb = [0.62, 0.76, 0.85];
/** Halbe Dicke der Fensterscheibe (Meter) — sehr dünn, mittig in der Wand. */
const GLASS_HALF_THICK = 0.01;
/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */
const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255];
/** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */
const DRAWING_LINE_HALF_WIDTH = 0.008;
/** Höhen-Offset über der Geschossebene gegen Z-Fighting (wie addDrawing2DLines in Viewport3D.tsx). */
const DRAWING_LINE_ELEVATION_EPS = 0.01;

/** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */
const EPS = 1e-4;

/**
 * Wandelt eine Hex-Farbe ("#rrggbb", Component-Farbe im Plan) in float-RGB
 * (0..1, render3d-Konvention) um. Ungültiges/fehlendes Format fällt auf den
 * neutralen Wand-Grundton zurück (nie ein hartes Fehlbild).
 */
function hexToRgb(hex: string, fallback: RRgb = WALL_RGB): RRgb {
  const m = /^#?([0-9a-fA-F]{6})$/.exec(hex ?? "");
  if (!m) return fallback;
  const v = parseInt(m[1], 16);
  return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255];
}

/**
 * Repräsentative Bauteilfarbe eines Schichtaufbaus: die Farbe der DICKSTEN
 * Schicht (i. d. R. die tragende/dominante Schicht, z. B. Mauerwerk/Beton) —
 * einfache Näherung, solange Wände/Decken als EIN Vollkörper emittiert werden
 * (kein Schicht-Rendering in 3D). Fällt auf `fallback` zurück, wenn keine
 * Schicht vorhanden ist oder deren Component nicht auflösbar ist.
 */
function dominantLayerColor(layers: Layer[], project: Project, fallback: RRgb): RRgb {
  if (layers.length === 0) return fallback;
  const dominant = layers.reduce((best, l) => (l.thickness > best.thickness ? l : best));
  try {
    return hexToRgb(getComponent(project, dominant.componentId).color, fallback);
  } catch {
    return fallback;
  }
}

/**
 * Eine quer zur Wanddicke gestapelte Materiallage als 3D-Band: eigene Dicke,
 * Materialfarbe und Mitten-Versatz `offset` (Meter, entlang der Wand-Normalen,
 * relativ zur Wandachse). Jede solche Lage wird pro Achsen-Teilquader zu einer
 * eigenen Voll-Box (siehe {@link emitWall}/{@link pushSegment}).
 */
interface WallBand {
  thickness: number;
  color: RRgb;
  offset: number;
  /** Schnitt-Schraffur des Band-Bauteils (siehe {@link RHatch}). */
  hatch?: RHatch;
  /** Material-Textur-Ebene des Band-Bauteils (1-basiert, siehe {@link wallMaterialColorMaps}). */
  materialIndex?: number;
  /**
   * Verschneidungs-Rang dieser Materiallage (`Component.joinPriority`) — treibt
   * die per-Schicht-Dominanz gegen Decken im layered-3D-Pfad (s.
   * {@link collectCeilingCutters}/{@link emitWall}): eine Decke mit STRIKT höherer
   * `joinPriority` schneidet nur die Bänder mit niedrigerem Rang aus ihrem
   * z-Intervall (der Kern höherer Priorität läuft durch). `undefined` (kein
   * auflösbarer WallType / synthetisches Fallback-Band) ⇒ keine Decken-
   * Subtraktion (konservativ, wie das frühere Trim-Verhalten bei unbekanntem Typ).
   */
  priority?: number;
  /**
   * Bauteil-Identität dieser Materiallage (`Component.id`) — treibt die MERGE-Regel
   * der Schnitt-Boolean-Dominanz (gleiche Komponente + Priorität verschmelzen, keine
   * innere Trennlinie). Reist als `RCutMeta.componentId` mit.
   */
  componentId?: string;
  /** Farbe der Schnitt-Umrisslinie dieser Schicht (aus `jointLineStyleId`, sonst Default). */
  cutLineColor?: RRgb;
  /** Strichstärke der Schnitt-Umrisslinie in mm-Papier (aus `jointLineStyleId`, sonst Default). */
  cutLineWeight?: number;
}

/**
 * Löst den Schnitt-UMRISSLINIEN-Stil einer Schicht aus ihrem
 * `Layer.jointLineStyleId` (LineStyle) auf: Farbe (hex→RGB 0..1) + Strichstärke
 * (mm-Papier) — dieselbe Quelle wie die 2D-Schichtfuge (`generatePlan.ts`, Zeile
 * ~1681). Fehlt der Verweis/LineStyle, `undefined` (der 3D-Cut fällt dann auf
 * seinen Default-Schnittkanten-Stil zurück, siehe `section_fill::build_cut_cap_lines`).
 * Bewusst tolerant (`find` statt `getLineStyle`), wie im 2D — ein verwaister Verweis
 * darf den 3D-Push nicht werfen.
 */
function resolveCutLineStyle(
  project: Project,
  jointLineStyleId: string | undefined,
): { color?: RRgb; weight?: number } | undefined {
  if (!jointLineStyleId) return undefined;
  const ls = project.lineStyles.find((l) => l.id === jointLineStyleId);
  if (!ls) return undefined;
  return { color: hexToRgb(ls.color, DRAWING_LINE_FALLBACK_RGB), weight: ls.weight };
}

/**
 * Baut die {@link RCutMeta}-Nutzlast eines Wand-Bandes (Priorität/Identität/
 * Schnittkanten-Stil), oder `undefined`, wenn das Band keinerlei Schnitt-Metadaten
 * trägt (z. B. das Fallback-Vollkörper-Band ohne auflösbaren WallType). Nur gesetzte
 * Felder werden übernommen, damit `#[serde(default)]` auf der Rust-Seite greift.
 */
function bandCutMeta(b: WallBand): RCutMeta | undefined {
  const meta: RCutMeta = {};
  if (b.priority !== undefined) meta.joinPriority = b.priority;
  if (b.componentId !== undefined) meta.componentId = b.componentId;
  if (b.cutLineColor !== undefined) meta.lineColor = b.cutLineColor;
  if (b.cutLineWeight !== undefined) meta.lineWeight = b.cutLineWeight;
  return Object.keys(meta).length > 0 ? meta : undefined;
}

/**
 * Löst den Schichtstapel EINER Wand auf: je Materiallage ein {@link WallBand}
 * mit ihrer Dicke, Component-Farbe und dem Mitten-Versatz entlang der Normalen.
 * Die Versätze zentrieren den Stapel um die Wandachse; als Gesamtdicke T dient
 * die SUMME der tatsächlichen Lagen-Dicken (nicht `wallTypeThickness`, falls es
 * abweicht) — so schließen die Bänder lückenlos aneinander an. Kein auflösbarer
 * WallType / keine Lagen ⇒ EIN Vollkörper-Band über die volle Dicke im
 * neutralen Grundton (heutiges Fallback-Verhalten).
 */
function resolveWallBands(project: Project, wall: Wall, layered = true): WallBand[] {
  try {
    const wt = getWallType(project, wall);
    if (wt.layers.length > 0) {
      // Gesamtdicke = Summe der Lagen (konsistent mit den Offsets); wenn diese
      // von wallTypeThickness abwiche, gewinnt die Lagen-Summe (keine Lücken).
      const total = wt.layers.reduce((s, l) => s + l.thickness, 0);
      // Schnitt-Eingabe (layered=false): EINE Voll-Box über die ganze Dicke statt
      // je Lage eine Box. So bleibt die Emission 1:1 Wand→Rechteck — passend zu
      // `wallSegmentOwners`/`splitWallLayers` in toSection.ts, die die Schicht-
      // Zerlegung TS-seitig aus diesem einen Rechteck machen. (Der 3D-Viewer ruft
      // mit Default layered=true auf und bekommt unverändert die Schicht-Bänder.)
      if (!layered) {
        const dominant = wt.layers.reduce((best, l) => (l.thickness > best.thickness ? l : best));
        return [
          {
            thickness: total,
            color: dominantLayerColor(wt.layers, project, WALL_RGB),
            offset: 0,
            hatch: dominantLayerHatch(wt.layers, project, SECTION_WALL_AXIS_ANGLE_DEG),
            materialIndex: resolveComponentMaterialLayer(project, dominant.componentId),
          },
        ];
      }
      const bands: WallBand[] = [];
      let prefix = 0;
      for (const l of wt.layers) {
        const comp = getComponent(project, l.componentId);
        const color = hexToRgb(comp.color);
        const lineStyle = resolveCutLineStyle(project, l.jointLineStyleId);
        bands.push({
          thickness: l.thickness,
          color,
          offset: -total / 2 + prefix + l.thickness / 2,
          priority: comp.joinPriority,
          componentId: comp.id,
          cutLineColor: lineStyle?.color,
          cutLineWeight: lineStyle?.weight,
          hatch: resolveComponentHatch(project, l.componentId, SECTION_WALL_AXIS_ANGLE_DEG),
          materialIndex: resolveComponentMaterialLayer(project, l.componentId),
        });
        prefix += l.thickness;
      }
      return bands;
    }
    // WallType ohne Schichten: EIN Vollkörper über die Typ-Gesamtdicke.
    return [{ thickness: wallTypeThickness(wt), color: WALL_RGB, offset: 0 }];
  } catch {
    // Kein auflösbarer WallType: EIN Vollkörper mit Default-Dicke/Grundton.
    return [{ thickness: 0.2, color: WALL_RGB, offset: 0 }];
  }
}

/**
 * Hängt eine Wand-Schicht-Box an: das Achsenstück [from..to] (Meter ab
 * Wand-Startpunkt) über den Höhenbereich [zBottom..zTop], quer um `normalOffset`
 * Meter entlang der Wand-Normalen versetzt (Schichtmitte relativ zur Achse) und
 * mit Schicht-`thickness`/`color`. Entartete Stücke (Länge ≤ 0 oder Höhe ≤ 0)
 * werden übersprungen — so überleben Öffnungen dicht an den Wandenden ohne
 * Nullquader.
 *
 * Optionale `holes`: echte rechteckige Aussparungen in DIESER Box (siehe
 * {@link RHole}). Ihre `from`/`to` sind Achsen-Meter ab Wand-Start (dieselbe
 * Achse wie `from`/`to` dieser Box), `zBottom`/`zTop` absolute Weltmeter — beides
 * unverändert an render3d durchgereicht (der Querversatz `normalOffset` wirkt nur
 * senkrecht zur Achse und lässt die Loch-Parametrisierung unberührt). Werden nur
 * im 3D-Pfad (voller Achsen-Quader) gesetzt.
 */
function pushSegment(
  out: RWall[],
  wall: Wall,
  from: number,
  to: number,
  zBottom: number,
  zTop: number,
  thickness: number,
  normalOffset: number,
  color: RRgb,
  wallId: string,
  hatch?: RHatch,
  materialIndex?: number,
  holes?: RHole[],
  cut?: RCutMeta,
): void {
  if (to - from <= EPS) return;
  if (zTop - zBottom <= EPS) return;
  const dx = wall.end.x - wall.start.x;
  const dy = wall.end.y - wall.start.y;
  const len = Math.hypot(dx, dy);
  if (len < 1e-9) return;
  const ux = dx / len;
  const uy = dy / len;
  // LINKE Grundriss-Normale (identisch zu `leftNormal(u)` = (−uy, ux) in
  // generatePlan.ts/clippedBand — dieselbe Konvention, die addWallPoche für
  // den Schichtstapel im 2D-Grundriss benutzt). Der Schicht-Versatz verschiebt
  // Start/Ende der Box quer zur Wand, sodass render3d die Box um ihre eigene
  // (versetzte) Achse zentriert aufbaut — mit dieser Normale liegt Schicht 0
  // (Aussenseite, kleinster `offset`) auf derselben Weltseite wie im Grundriss.
  // Bewusst NICHT identisch zur Normale, die raycast3d/selectionHighlightLines
  // für den Box-Rebuild nutzen (n = (uy, −ux), rechte Normale) — das ist
  // unschädlich, weil jene Boxen symmetrisch um die (hier bereits versetzte)
  // Achse jedes einzelnen Layer-Records aufgebaut werden und daher unabhängig
  // von der Vorzeichen-Konvention des Versatzes sind.
  const ox = -uy * normalOffset;
  const oy = ux * normalOffset;
  const p1: RVec2 = [wall.start.x + ux * from + ox, wall.start.y + uy * from + oy];
  const p2: RVec2 = [wall.start.x + ux * to + ox, wall.start.y + uy * to + oy];
  out.push({
    start: p1,
    end: p2,
    thickness,
    height: zTop - zBottom,
    baseElevation: zBottom,
    color,
    layers: [],
    wallId,
    ...(hatch ? { hatch } : {}),
    ...(materialIndex != null ? { materialIndex } : {}),
    ...(holes && holes.length > 0 ? { holes } : {}),
    ...(cut ? { cut } : {}),
  });
}

/**
 * Achsenparalleles Grundriss-Rechteck (min/max je Achse), Meter, Modell-XY.
 * Dient hier NUR der groben Footprint-Überlapp-Prüfung Wand↔Decke (s.
 * {@link trimWallTopForCeilings}) — kein exakter Polygon-Test, siehe dort.
 */
interface Bounds2 {
  minX: number;
  maxX: number;
  minY: number;
  maxY: number;
}

/** Bounding-Box der Wandachse, um `halfThickness` in beide Richtungen geweitet
 * (sichere Über-Approximation des tatsächlichen Wandkörpers, unabhängig von
 * dessen Orientierung). */
function wallFootprintBounds(wall: Wall, halfThickness: number): Bounds2 {
  return {
    minX: Math.min(wall.start.x, wall.end.x) - halfThickness,
    maxX: Math.max(wall.start.x, wall.end.x) + halfThickness,
    minY: Math.min(wall.start.y, wall.end.y) - halfThickness,
    maxY: Math.max(wall.start.y, wall.end.y) + halfThickness,
  };
}

/** Bounding-Box eines geschlossenen Umrisses (Decke). */
function outlineBounds(outline: Array<{ x: number; y: number }>): Bounds2 {
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
  for (const p of outline) {
    if (p.x < minX) minX = p.x;
    if (p.x > maxX) maxX = p.x;
    if (p.y < minY) minY = p.y;
    if (p.y > maxY) maxY = p.y;
  }
  return { minX, maxX, minY, maxY };
}

/** Überlappen sich zwei Bounding-Boxen (mit EPS-Toleranz an den Rändern)? */
function boundsOverlap(a: Bounds2, b: Bounds2): boolean {
  return (
    a.minX <= b.maxX + EPS &&
    a.maxX >= b.minX - EPS &&
    a.minY <= b.maxY + EPS &&
    a.maxY >= b.minY - EPS
  );
}

/**
 * Dicke einer Wand für die Footprint-Näherung (s. {@link wallFootprintBounds}):
 * Summe der WallType-Schichtdicken, sonst der Fallback-Grundton-Dicke aus
 * {@link resolveWallBands} (0.2 m), falls kein WallType auflösbar ist.
 */
function wallThicknessSafe(project: Project, wall: Wall): number {
  try {
    return wallTypeThickness(getWallType(project, wall));
  } catch {
    return 0.2;
  }
}

/**
 * Repräsentative `joinPriority` einer Decke (höchste ihrer Schichten) — bestimmt,
 * ob die Decke am Wand/Decke-Stoss "durchläuft" (gewinnt) und in die Wandschichten
 * niedrigerer Priorität einschneidet (s. {@link collectCeilingCutters}).
 */
function ceilingDominantPriority(project: Project, ceiling: Ceiling): number | undefined {
  try {
    const ct = getCeilingType(project, ceiling);
    let best: number | undefined;
    for (const layer of ct.layers) {
      const p = getComponent(project, layer.componentId).joinPriority;
      if (best === undefined || p > best) best = p;
    }
    return best;
  } catch {
    return undefined;
  }
}

/**
 * Ein Decken-„Cutter": vertikales z-Intervall + Dominanz-Rang + Grundriss-Umriss
 * (s. {@link collectCeilingCutters}). Der `outline` erlaubt die PER-BAND-genaue
 * Überdeckungsprüfung in {@link emitWall} (statt eines wandweiten BBox-Tests): eine
 * Decke schneidet ein Schicht-Band nur dort, wo ihre Outline die Footprint-
 * Mittellinie des Bandes im Grundriss überdeckt.
 */
interface CeilingCutter {
  zBottom: number;
  zTop: number;
  priority: number;
  outline: Vec2[];
}

/**
 * Sammelt die Decken, die eine Wand im layered-3D-Pfad PRO SCHICHT einschneiden —
 * die 3D-Entsprechung der Schnitt-Boolean-Dominanz (`toSection.ts::
 * subtractDominantBands`): statt die GANZE Wand am Deckenanschluss zu kappen
 * (früheres `trimWallTopForCeilings`), behält die Wand ihre volle Höhe und JEDES
 * Schicht-Band verliert nur das z-Intervall der Decken mit STRIKT höherer
 * `joinPriority` als das Band (der Kern höherer Priorität läuft durch die Decke).
 * Ein Band kann dadurch vertikal in zwei Boxen zerfallen (unter Deckenunterkante
 * + über Deckenoberkante, falls die Wand die Decke überragt).
 *
 * Kriterien je Decke (alle müssen zutreffen):
 *  • dasselbe Geschoss (`ceiling.floorId === wall.floorId`),
 *  • vertikale Überlappung mit der (vollen) Wand-Ausdehnung,
 *  • grober Footprint-Überlapp (Bounding-Box, s. {@link boundsOverlap}) — nur ein
 *    billiger Vor-Filter (keine Wand berührt, wenn die Wand-BBox die Decken-BBox
 *    nicht schneidet); die EXAKTE, per-Band geometrische Überdeckung entscheidet
 *    dann in {@link emitWall} (s. {@link bandCoverageIntervals}).
 *  • auflösbare Decken-`joinPriority` (`ceilingDominantPriority`).
 *
 * Das z-Intervall/die Priorität der Cutter ist PER DECKENSCHICHT (nicht die ganze
 * Decke als EIN Block der dominanten Priorität): jede Deckenschicht wird zu einem
 * eigenen Cutter mit IHREM z-Band (proportional auf [zBottom,zTop] verteilt, exakt
 * wie {@link emitSlabs}) und IHRER `Component.joinPriority`. Das ist zwingend, damit
 * der 3D-Cut EXAKT wie der 2D-Schnitt (`toSection.ts::subtractDominantBands`)
 * verschneidet: dort trägt jede Schicht ihre eigene Priorität. Mit dem früheren
 * EINEN Block (dominante Prio 100 über die volle Decke) verlor z. B. der Backstein
 * (50) das GANZE Decken-z-Band [UK,OK] — obwohl er die schwimmenden Deckenschichten
 * Estrich (30)/Dämmung (20) ÜBERRAGEN und schneiden soll; nur die Betonschicht (100)
 * darf ihn kappen. So blieb im 3D (Solid UND Schnitt) über dem Backstein eine Lücke,
 * durch die Estrich/Dämmung „rausliefen" (Nutzer-Bug). Per Schicht läuft der
 * Backstein zwischen Beton-OK und Wandkopf durch und schneidet dort Estrich/Dämmung
 * weg — identisch zum 2D-Schnitt. Der `outline` reist mit, damit {@link emitWall}
 * PRO BAND prüft, ob die Decke die Footprint-Mittellinie dieses Bandes überdeckt
 * (geometrischer Überlapp, kein wandweiter BBox-Test). Der Prioritätsvergleich pro
 * Band passiert in {@link emitWall} (`cutter.priority > band.priority`) — eine
 * Wand-Schicht mit ≥ Rang der jeweiligen Deckenschicht läuft durch deren z-Band durch.
 * Ein-Schicht-/nicht auflösbare Deckentypen fallen auf EINEN Cutter über die volle
 * Ausdehnung mit der dominanten Priorität zurück (Alt-Verhalten).
 */
function collectCeilingCutters(
  project: Project,
  wall: Wall,
  zBottom: number,
  zTop: number,
): CeilingCutter[] {
  const halfThickness = wallThicknessSafe(project, wall) / 2;
  const wallBounds = wallFootprintBounds(wall, halfThickness);
  const cutters: CeilingCutter[] = [];
  for (const c of project.ceilings ?? []) {
    if (c.floorId !== wall.floorId) continue;
    if (!c.outline || c.outline.length < 3) continue;
    const cExtent = ceilingVerticalExtent(project, c);
    if (cExtent.zBottom >= zTop - EPS || cExtent.zTop <= zBottom + EPS) continue;
    if (!boundsOverlap(wallBounds, outlineBounds(c.outline))) continue;

    // Per-Schicht-Cutter: je Deckenschicht ein eigenes z-Band + eigene Priorität
    // (proportional auf die tatsächliche Decken-z-Spanne skaliert, oben→unten wie
    // emitSlabs). Nur so schneidet Beton (100) den Backstein (50), Backstein aber
    // Estrich (30)/Dämmung (20) — identisch zu subtractDominantBands.
    let layers: Layer[] | undefined;
    try {
      layers = getCeilingType(project, c).layers;
    } catch {
      layers = undefined;
    }
    const total = layers?.reduce((s, l) => s + l.thickness, 0) ?? 0;
    if (layers && layers.length > 0 && total > EPS) {
      const span = cExtent.zTop - cExtent.zBottom;
      let prefixTop = 0; // von OK (zTop) nach unten aufsummiert (Meter)
      for (const l of layers) {
        const t = (l.thickness / total) * span;
        const layerTop = cExtent.zTop - prefixTop;
        const layerBottom = layerTop - t;
        prefixTop += t;
        let prio: number | undefined;
        try {
          prio = getComponent(project, l.componentId).joinPriority;
        } catch {
          prio = undefined;
        }
        if (prio === undefined) continue;
        cutters.push({ zBottom: layerBottom, zTop: layerTop, priority: prio, outline: c.outline });
      }
    } else {
      const ceilingPriority = ceilingDominantPriority(project, c);
      if (ceilingPriority === undefined) continue;
      cutters.push({ zBottom: cExtent.zBottom, zTop: cExtent.zTop, priority: ceilingPriority, outline: c.outline });
    }
  }
  return cutters;
}

/**
 * Achsen-Teilintervalle (Meter ab `wall.start` entlang `u`, geklemmt auf
 * `[lo,hi]`), auf denen die MITTELLINIE eines Schicht-Bands (Querversatz
 * `bandOffset` entlang `n = leftNormal(u)`) im Decken-Umriss `outline` liegt —
 * die per-Band-Entsprechung des geometrischen Rechteck-Überlapps in
 * `toSection.ts::subtractDominantBands`. Nur diese Teilintervalle bekommen später
 * den Decken-z-Schnitt; unbedeckte Achsenabschnitte behalten die volle Höhe (so
 * laufen Dämmung/Aussenputz, deren Mittellinie AUSSERHALB des Decken-Umrisses
 * liegt, ununterbrochen durch — der Nutzer-Bug „Kranz durch die Dämmung").
 *
 * Die Bandmittellinie wird entlang der Achse gerastert (~5-cm-Schritte, min. 32)
 * und gegen {@link pointInOutline} geprüft; Innen/Aussen-Übergänge werden per
 * Bisektion auf ~EPS verfeinert, sodass die Überdeckungsgrenzen scharf sind (auch
 * eine Decke, die nur einen Teil der Wandlänge überdeckt, wird korrekt getrimmt).
 */
function bandCoverageIntervals(
  wall: Wall,
  u: Vec2,
  n: Vec2,
  bandOffset: number,
  outline: Vec2[],
  lo: number,
  hi: number,
): Array<[number, number]> {
  if (hi - lo <= EPS) return [];
  const base = add(wall.start, scale(n, bandOffset));
  const inside = (t: number): boolean => pointInOutline(add(base, scale(u, t)), outline);
  const steps = Math.max(32, Math.ceil((hi - lo) / 0.05));
  const dt = (hi - lo) / steps;
  const intervals: Array<[number, number]> = [];
  let prevInside = inside(lo);
  let start: number | null = prevInside ? lo : null;
  let prevT = lo;
  for (let i = 1; i <= steps; i++) {
    const t = i === steps ? hi : lo + i * dt;
    const now = inside(t);
    if (now !== prevInside) {
      const cross = bisectBoundary(inside, prevT, t);
      if (now) start = cross;
      else if (start !== null) {
        intervals.push([start, cross]);
        start = null;
      }
    }
    prevInside = now;
    prevT = t;
  }
  if (start !== null) intervals.push([start, hi]);
  return intervals;
}

/**
 * Bisektion des Innen/Aussen-Übergangs der Bandmittellinie zwischen `a` und `b`
 * (verschiedene {@link pointInOutline}-Seiten) auf ~EPS-Genauigkeit — liefert die
 * scharfe Achsen-Position der Überdeckungsgrenze.
 */
function bisectBoundary(inside: (t: number) => boolean, a: number, b: number): number {
  const aInside = inside(a);
  let lo = a;
  let hi = b;
  for (let i = 0; i < 40 && hi - lo > EPS * 0.01; i++) {
    const mid = (lo + hi) / 2;
    if (inside(mid) === aInside) lo = mid;
    else hi = mid;
  }
  return (lo + hi) / 2;
}

/**
 * Zieht die Cutter-Intervalle `holes` vom Intervall `[lo,hi]` ab und liefert die
 * verbleibenden Teil-Intervalle (Meter). Löcher werden auf `[lo,hi]` geklemmt,
 * sortiert und vereinigt; leeres `holes` ⇒ exakt `[[lo,hi]]` (unveränderter Fall).
 * Dient sowohl der Achsen-Zerlegung durch Durchgangswand-Span-Cutouts als auch
 * der vertikalen Decken-Subtraktion (identische 1D-Semantik wie
 * `generatePlan.ts::subtractIntervals`, hier lokal gehalten — generatePlan bleibt
 * unangetastet).
 */
function subtractSpans(lo: number, hi: number, holes: Array<[number, number]>): Array<[number, number]> {
  const clipped = holes
    .map(([a, b]): [number, number] => [Math.max(lo, Math.min(a, b)), Math.min(hi, Math.max(a, b))])
    .filter(([a, b]) => b > a + EPS)
    .sort((p, q) => p[0] - q[0]);
  const parts: Array<[number, number]> = [];
  let cursor = lo;
  for (const [a, b] of clipped) {
    if (a > cursor + EPS) parts.push([cursor, a]);
    if (b > cursor) cursor = b;
  }
  if (cursor < hi - EPS) parts.push([cursor, hi]);
  return parts;
}

/**
 * Wendet die Decken-z-Cutter `cutters` (dominante horizontale Cut-Bänder [zBottom,
 * zTop]) auf das Wandband-Intervall `[lo,hi]` an — je nach Terminierungs-Regel der
 * Wand ({@link Wall.sliceTermination}). Bewusst als EIGENER, klar benannter Schritt
 * neben der Priorität gehalten (Terminierung = Zuschnitt, KEINE Prioritätsregel):
 *
 *   • "both"/undefined ⇒ heutiges Verhalten: `subtractSpans` stanzt jedes Cutter-
 *     z-Band aus (oben+unten Reste bleiben erhalten). Bit-identisch zu vorher.
 *   • "below" ⇒ die Wand ENDET an der Decke: alles AB der Decken-UNTERKANTE wird
 *     verworfen (kein über der Decke wieder auftauchender Rest). Clip-Grenze = das
 *     KLEINSTE `zBottom` aller dominanten Cutter dieses Bandes. Da die dominanteste
 *     (tragende) Deckenschicht — z. B. Beton (Prio 100) — jedes schwächere Band
 *     kappt UND zugleich die unterste Deckenlage ist, terminieren so ALLE Bänder
 *     einheitlich an der Deckenunterkante (kein Durchstoss/Staffel, keine
 *     Interpenetration mit Beton/Dämmung darüber). Für den Backstein, den nur der
 *     Beton kappt, ist das exakt „unter dem dominanten Cut" (dessen Unterkante).
 *   • "above" ⇒ spiegelbildlich: alles BIS zur Decken-OBERKANTE wird verworfen.
 *     Clip-Grenze = das GRÖSSTE `zTop` aller dominanten Cutter dieses Bandes.
 *
 * Ohne Cutter (`cutters` leer) bleibt `[lo,hi]` in allen Modi unverändert — ein
 * Band ausserhalb des Decken-Umrisses (Aussenputz/Dämmung) läuft also weiter voll
 * durch, unabhängig von der Terminierung.
 */
function terminateOrSubtractSpans(
  lo: number,
  hi: number,
  cutters: Array<[number, number]>,
  termination: SliceTermination | undefined,
): Array<[number, number]> {
  if (cutters.length === 0) return [[lo, hi]];
  if (termination === "below") {
    // Deckenunterkante = kleinstes zBottom der Cutter; die Wand endet dort.
    let clipTop = Infinity;
    for (const [cb] of cutters) clipTop = Math.min(clipTop, cb);
    const top = Math.min(clipTop, hi);
    return top > lo + EPS ? [[lo, top]] : [];
  }
  if (termination === "above") {
    // Deckenoberkante = grösstes zTop der Cutter; die Wand beginnt dort.
    let clipBottom = -Infinity;
    for (const [, ct] of cutters) clipBottom = Math.max(clipBottom, ct);
    const bottom = Math.max(clipBottom, lo);
    return bottom < hi - EPS ? [[bottom, hi]] : [];
  }
  return subtractSpans(lo, hi, cutters);
}

/**
 * Achsen-Distanz (Meter ab `wall.start` entlang der Wandrichtung `u`), an der die
 * MITTELLINIE eines Schicht-Bands (Querversatz `bandOffset` entlang `leftNormal(u)`)
 * die Gehrungs-/Anschluss-Schnittlinie `cut` trifft — die 3D-Achsenbox-Entsprechung
 * des 2D-`clippedBand`. Da die Box achsparallel endet (keine gekippte Stirnfläche),
 * wird die Band-Länge auf den Schnittpunkt der MITTELLINIE gekürzt/verlängert; die
 * beiden 2D-Bandkanten (bei ±thickness/2) treffen die Schnittlinie an leicht
 * versetzten Achsenpunkten — der Box-Wert ist ihr Mittelwert. `null` bei
 * (nahezu) parallelem Schnitt (kollineare Achsen) ⇒ Aufrufer behält das Bandende.
 */
function bandCutDistance(wall: Wall, u: Vec2, n: Vec2, bandOffset: number, cut: Line): number | null {
  const base = add(wall.start, scale(n, bandOffset));
  const hit = lineIntersect(base, u, cut.point, cut.dir);
  if (!hit) return null;
  return dot(sub(hit, wall.start), u);
}

/**
 * Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen).
 *
 * ZWEI PFADE je `layered`:
 *  • layered=true (3D-Viewer, Default): EIN Körper pro Schicht-Band über die
 *    VOLLE Achse mit allen Öffnungen als echte `holes` (render3d stanzt sie in
 *    die Langseiten). Ermöglicht auch versetzt übereinanderliegende Fenster in
 *    EINEM Körper (keine Segment-Nähte).
 *  • layered=false (Schnitt-Pfad, `computeSection`): UNVERÄNDERTE Pfeiler/
 *    Brüstung/Sturz-Segmentierung (EIN Voll-Band je Segment), damit die
 *    `ComponentRef.index`-Reihenfolge exakt zu `toSection.ts::wallSegmentOwners`
 *    passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher
 *    werden hier NICHT emittiert.
 */
function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cuts?: WallCuts): void {
  const bands = resolveWallBands(project, wall, layered);
  const { zBottom, zTop } = wallVerticalExtent(project, wall);
  // Anders als früher (`trimWallTopForCeilings` kappte im layered-Pfad die GANZE
  // Wand an der Deckenunterkante) behält die Wand jetzt IN BEIDEN PFADEN ihre
  // volle Höhe; die per-Schicht-Decken-Subtraktion (unten, nur layered) schneidet
  // Bänder niedrigerer Priorität im Decken-z-Band aus — die 3D-Entsprechung der
  // Schnitt-Boolean-Dominanz. Der Schnitt-Pfad (layered=false) bleibt unverändert
  // (volle Rohgeometrie; seine Dominanz macht toSection.ts::subtractDominantBands).
  const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
  if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;

  // Ein Achsen-Teilquader × jede Materiallage = eine Box (Schichtstapel quer
  // zur Wanddicke). Alle tragen die Wand-Id, sodass ein Klick auf irgendeine
  // Schicht die GANZE Wand selektiert und das Highlight alle Bänder umrandet.
  const emitSeg = (from: number, to: number, zb: number, zt: number): void => {
    for (const b of bands) {
      pushSegment(out, wall, from, to, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, undefined, bandCutMeta(b));
    }
  };

  // Aussparungen der Wand als vereinheitlichte Cutouts sammeln:
  //   • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
  //   • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz).
  // Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung.
  const cutouts: Array<{ from: number; to: number; oBottom: number; oTop: number }> = [];
  for (const op of openingsOfWall(project, wall.id)) {
    const iv = openingInterval(wall, op);
    if (!iv) continue;
    const v = openingVerticalExtent(project, wall, op);
    cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop });
  }
  for (const d of project.doors ?? []) {
    if (d.hostWallId !== wall.id) continue;
    const from = Math.max(0, Math.min(d.position, axisLen));
    const to = Math.max(from, Math.min(d.position + d.width, axisLen));
    if (to - from < EPS) continue;
    // Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe.
    const oTop = Math.min(zTop, zBottom + d.height);
    cutouts.push({ from, to, oBottom: zBottom, oTop });
  }
  cutouts.sort((a, b) => a.from - b.from);

  // ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle
  // Achse mit allen Öffnungen als echte Löcher (render3d stanzt sie), PLUS die
  // materialbewussten Verschneidungen (identisch zum 2D-Grundriss/Schnitt):
  //   1. End-Cuts/Merge an L-/T-Stössen: jede Schicht wird an ihrem Achsenende an
  //      der Gehrungs-/Anschluss-Schnittlinie individuell gekürzt/verlängert
  //      (`WallCuts.startCut`/`endCut`, bzw. pro Schicht `layerCuts.start/end` —
  //      merge: Kern läuft bis zur Rückgrat-Nahfläche durch; trim: Putz stoppt an
  //      der Wandoberfläche). Wie im 2D wird die Band-MITTELLINIE geschnitten.
  //   2. Durchgangswand-Span-Cutouts: der Nah-Putz einer Durchgangswand wird über
  //      die Achsen-Breite des durchstechenden Abzweig-Kerns ausgeschnitten — ein
  //      Band zerfällt dabei entlang der Achse in mehrere Teilstücke.
  //   3. Decken-Dominanz pro Schicht: Bänder niedrigerer `joinPriority` verlieren
  //      das z-Intervall überlappender Decken (vertikaler Split), der Kern läuft
  //      durch (s. {@link collectCeilingCutters}).
  // Die Öffnungs-Löcher (`holes`, Achsen-Meter ab Wand-Start) werden je Teilstück
  // auf dessen Achsen-Start umgerechnet (rebased), damit render3d sie relativ zum
  // jeweiligen Box-Start korrekt stanzt.
  if (layered) {
    const holes: RHole[] = cutouts.map((c) => ({
      from: c.from,
      to: c.to,
      zBottom: c.oBottom,
      zTop: c.oTop,
    }));
    const startCut = cuts?.startCut ?? null;
    const endCut = cuts?.endCut ?? null;
    const layerCuts = cuts?.layerCuts;
    const spanCutouts = cuts?.spanCutouts ?? [];
    const ux = (wall.end.x - wall.start.x) / axisLen;
    const uy = (wall.end.y - wall.start.y) / axisLen;
    const u: Vec2 = { x: ux, y: uy };
    const n = leftNormal(u); // (-uy, ux) — dieselbe Konvention wie pushSegment/2D.
    const ceilingCutters = collectCeilingCutters(project, wall, zBottom, zTop);

    for (let li = 0; li < bands.length; li++) {
      const b = bands[li];
      // (1) Per-Schicht-Cuts an den Achsenden. `layerCuts` (materialbewusster
      // T-Stoss) überschreibt — wo gesetzt — den einheitlichen start/endCut PRO
      // Schicht; `null` an einem Index heisst „keine Kürzung an diesem Ende".
      // Exakt die 2D-Wahl aus generatePlan.ts::addWallPoche (Zeile ~1523).
      const sCut = layerCuts ? layerCuts.start[li] : startCut;
      const eCut = layerCuts ? layerCuts.end[li] : endCut;
      let from0 = 0;
      let to0 = axisLen;
      if (sCut) {
        const d = bandCutDistance(wall, u, n, b.offset, sCut);
        if (d !== null) from0 = d;
      }
      if (eCut) {
        const d = bandCutDistance(wall, u, n, b.offset, eCut);
        if (d !== null) to0 = d;
      }

      // (2) Span-Cutouts, deren Quer-Offsetbereich dieses Band schneidet, brechen
      // die Achse [from0,to0] auf (Nah-Putz-Durchstoss der Durchgangswand).
      const bandLo = b.offset - b.thickness / 2;
      const bandHi = b.offset + b.thickness / 2;
      const axisHoles: Array<[number, number]> = [];
      for (const c of spanCutouts) {
        const cLo = Math.min(c.offA, c.offB);
        const cHi = Math.max(c.offA, c.offB);
        if (bandLo < cHi - EPS && bandHi > cLo + EPS) axisHoles.push([c.from, c.to]);
      }
      const axisPieces = subtractSpans(from0, to0, axisHoles);

      // (3) Decken-Subtraktion GEOMETRISCH pro Band (Replik der Schnitt-Boolean-
      // Dominanz `toSection.ts::subtractDominantBands`): eine Decke mit STRIKT
      // höherer Priorität schneidet dieses Band nur dort aus, wo ihr Grundriss-
      // Umriss die Footprint-MITTELLINIE dieses Bandes überdeckt (nicht mehr der
      // wandweite BBox-Test). Aussenschichten (Dämmung/Aussenputz), deren
      // Mittellinie ausserhalb des Decken-Umrisses liegt, laufen ununterbrochen
      // durch — nur der überdeckte Kern verliert das Decken-z-Intervall. Ohne
      // auflösbare Band-Priorität bleibt die volle Höhe (konservativ).
      const bandZCuts: Array<{ from: number; to: number; zBottom: number; zTop: number }> = [];
      if (b.priority !== undefined) {
        for (const cc of ceilingCutters) {
          if (cc.priority <= b.priority) continue;
          for (const [ca, cb] of bandCoverageIntervals(wall, u, n, b.offset, cc.outline, from0, to0)) {
            bandZCuts.push({ from: ca, to: cb, zBottom: cc.zBottom, zTop: cc.zTop });
          }
        }
      }

      // Öffnungs-Löcher, die ein z-Teilstück vertikal berühren, auf den Box-Start
      // `a` umrechnen (render3d klemmt u/z selbst auf die Box).
      const emitPiece = (a: number, bb: number, zb: number, zt: number): void => {
        const segHoles = holes
          .filter((h) => h.zTop > zb + EPS && h.zBottom < zt - EPS)
          .map((h) => ({ from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop }));
        pushSegment(out, wall, a, bb, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, segHoles, bandCutMeta(b));
      };

      for (const [a, bb] of axisPieces) {
        // Nur die Decken-z-Schnitte, die dieses Achsen-Teilstück wirklich treffen.
        const local = bandZCuts
          .map((c) => ({ from: Math.max(a, c.from), to: Math.min(bb, c.to), zBottom: c.zBottom, zTop: c.zTop }))
          .filter((c) => c.to > c.from + EPS);
        if (local.length === 0) {
          // Kein Decken-Überlapp auf diesem Achsenstück ⇒ volle Höhe (Band läuft durch).
          emitPiece(a, bb, zBottom, zTop);
          continue;
        }
        // Achse [a,bb] an allen Überdeckungs-Grenzen in elementare Teilstücke
        // zerlegen; je Teilstück nur die dort greifenden Decken-z-Intervalle
        // subtrahieren (unbedeckte Teilstücke behalten die volle Höhe).
        const bounds = new Set<number>([a, bb]);
        for (const c of local) {
          bounds.add(c.from);
          bounds.add(c.to);
        }
        const xs = [...bounds].sort((p, q) => p - q);
        for (let i = 0; i < xs.length - 1; i++) {
          const xa = xs[i];
          const xb = xs[i + 1];
          if (xb - xa <= EPS) continue;
          const mid = (xa + xb) / 2;
          const zCutters = local
            .filter((c) => c.from <= mid && c.to >= mid)
            .map((c): [number, number] => [c.zBottom, c.zTop]);
          // Terminierung (Zuschnitt) statt reiner z-Subtraktion: bei "below"/"above"
          // endet die Wand an der Decke, statt oben wieder aufzutauchen (s.
          // {@link terminateOrSubtractSpans}). "both"/undefined = heutiges Verhalten.
          for (const [zb, zt] of terminateOrSubtractSpans(zBottom, zTop, zCutters, wall.sliceTermination)) {
            emitPiece(xa, xb, zb, zt);
          }
        }
      }
    }
    return;
  }

  // ── Schnitt-Pfad (layered=false): unveränderte Pfeiler/Brüstung/Sturz-
  // Segmentierung (Owner-Kompatibilität mit toSection.ts::wallSegmentOwners).
  // Ohne Aussparungen: ein durchgehender Quader je Schicht.
  if (cutouts.length === 0) {
    emitSeg(0, axisLen, zBottom, zTop);
    return;
  }

  // Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Aussparungen,
  // Brüstung/Sturz im Öffnungsbereich. Überlappende Cutouts werden über `cursor`
  // zusammengefasst.
  let cursor = 0;
  for (const { from, to, oBottom, oTop } of cutouts) {
    const segFrom = Math.max(cursor, from);
    if (from > cursor) {
      // Voller Wandpfeiler bis zur Aussparung.
      emitSeg(cursor, from, zBottom, zTop);
    }
    if (to > segFrom) {
      // Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom).
      if (oBottom > zBottom + EPS) {
        emitSeg(segFrom, to, zBottom, oBottom);
      }
      // Sturz über der Öffnung (bis zum Wandkopf).
      if (oTop < zTop - EPS) {
        emitSeg(segFrom, to, oTop, zTop);
      }
    }
    cursor = Math.max(cursor, to);
  }
  // Restlicher Wandpfeiler bis zum Achsenende.
  if (cursor < axisLen) {
    emitSeg(cursor, axisLen, zBottom, zTop);
  }
}

/** Neutraler Beton-Grauton für Treppen (wie der Three.js-Pfad `addStairMeshes`). */
const STAIR_RGB: RRgb = hexToRgb("#9aa0a6", SLAB_RGB);

/**
 * Emittiert Treppen als RSlabs (extrudierte Grundriss-Polygone) — der WASM/wgpu-
 * Renderer kennt sonst KEINE Treppen (nur Wände/Decken/Meshes), sodass Treppen im
 * Standard-3D unsichtbar blieben. Jeder Tritt ist ein extrudiertes Polygon; das
 * ist exakt eine RSlab. Tragart über {@link getStairType}:
 *  • "massiv" / kein Typ — Vollblock je Tritt von der Treppen-UK bis zur Tritt-OK
 *    (gestuftes Massiv, Treppe auf Erdreich/Sockel).
 *  • "beton" — Ortbeton-Laufplatte: je Tritt ein Block von der SCHRÄGEN Untersicht
 *    (eine Stufe + Laufplattendicke unter der Tritt-OK, an der Treppen-UK gekappt)
 *    bis zur Tritt-OK. Die stufig gestapelten Blöcke bilden die schräge, UNTEN
 *    OFFENE Untersicht der klassischen Betontreppe.
 *  • "wange"/"aufgesattelt"/"spindel" — nur eine dünne Tritt-Platte der Dicke
 *    `treadThickness` an der Tritt-OK, darunter offen.
 * Podest (L/Wendel) als Vollblock bis zur halben Steighöhe. Setzstufen/Nase/
 * Geländer sind Verfeinerungen (Three.js-Pfad) — hier zählt zuerst die SICHTBARKEIT.
 * `ceilingId` trägt hier die Treppen-Id (nur TS-seitiges Tagging; render3d ignoriert
 * das Feld, und der Pick läuft separat über {@link pickGeometry}).
 */
const CONCRETE_WAIST = 0.18; // Laufplattendicke einer Ortbetontreppe (Meter)

function emitStairs(project: Project): RSlab[] {
  const out: RSlab[] = [];
  for (const stair of project.stairs ?? []) {
    const { zBottom, zTop } = stairVerticalExtent(project, stair);
    const totalRise = zTop - zBottom;
    if (totalRise <= EPS) continue;
    const geo = stairGeometry(stair, totalRise);
    const st = getStairType(project, stair);
    const structure = st?.structure ?? "massiv";
    // Gerade Betontreppe: die GLATTE Laufplatte kommt als echtes Mesh
    // (emitStairMeshes) — hier keine Slab-Näherung emittieren (sonst doppelt).
    if (structure === "beton" && stair.shape === "straight") continue;
    const treadThickness = st?.treadThickness ?? 0.04;
    const riser = geo.riserHeight;
    for (const tr of geo.treads) {
      if (tr.pts.length < 3) continue;
      const top = zBottom + tr.topRise;
      const bottom =
        structure === "massiv"
          ? zBottom // Vollblock bis zur Treppen-UK
          : structure === "beton"
            ? Math.max(zBottom, top - riser - CONCRETE_WAIST) // schräge Laufplatte
            : top - treadThickness; // offene Tritte (Wange/aufgesattelt/Spindel)
      if (top - bottom <= EPS) continue;
      out.push({
        outline: tr.pts.map((p) => [p.x, p.y] as RVec2),
        zBottom: bottom,
        zTop: top,
        color: STAIR_RGB,
        ceilingId: stair.id,
      });
    }
    if (geo.landing && geo.landing.length >= 3) {
      out.push({
        outline: geo.landing.map((p) => [p.x, p.y] as RVec2),
        zBottom,
        zTop: zBottom + totalRise * 0.5,
        color: STAIR_RGB,
        ceilingId: stair.id,
      });
    }
  }
  return out;
}

/**
 * Glatte Ortbeton-Laufplatte (Tragart "beton") als echtes Dreiecks-Mesh — nur für
 * GERADE Läufe. Anders als die stufige Slab-Näherung ({@link emitStairs}) entsteht
 * eine SPIEGELGLATTE schräge Untersicht: je Tritt ein konvexes Prisma mit
 * HORIZONTALER Oberkante (= Trittfläche) und SCHRÄGER Unterkante auf EINER
 * gemeinsamen Soffit-Geraden. Weil alle Prismen-Unterkanten exakt auf dieser
 * Geraden liegen, bildet die Vereinigung eine durchgehende glatte Schräge; die
 * Höhensprünge der Oberkanten bleiben als sichtbare Setzstufen. Prismen sind
 * konvex → triviale, robuste Triangulierung (kein Ear-Clipping). Positions in
 * MODELL-Metern (x, y, z=Höhe); render3d bildet nach world (x, z, y) ab.
 */
function emitStairMeshes(project: Project): RMesh[] {
  const out: RMesh[] = [];
  for (const stair of project.stairs ?? []) {
    const st = getStairType(project, stair);
    if (!st || st.structure !== "beton") continue;
    if (stair.shape !== "straight") continue; // glattes Mesh nur für gerade Läufe
    if (stair.runLength <= EPS) continue;
    const { zBottom, zTop } = stairVerticalExtent(project, stair);
    const totalRise = zTop - zBottom;
    if (totalRise <= EPS) continue;
    const geo = stairGeometry(stair, totalRise);
    const u = normalize(stair.dir); // Laufrichtung (Grundriss)
    const w = leftNormal(u); // Querrichtung (Breite)
    const runTotal = stair.runLength;
    // Laufplattendicke (vertikal): an die Steigung gekoppelt, geklemmt.
    const waistV = Math.min(0.24, Math.max(0.14, geo.riserHeight * 1.2));
    // Soffit-Höhe entlang des Laufs: eine Gerade PARALLEL zur Trittkantenlinie
    // (Steigung totalRise/runTotal), um `waistV` nach unten versetzt → KONSTANTE
    // Plattendicke über den ganzen Lauf (sonst würde die Platte nach oben dicker).
    // An der Treppen-UK gekappt, damit die unterste Stufe auf dem Boden aufsitzt
    // statt darunter zu schweben.
    const soffitH = (runS: number) => {
      const s = Math.min(Math.max(runS, 0), runTotal);
      return Math.max(zBottom, zBottom + (s / runTotal) * totalRise - waistV);
    };
    const runOf = (p: Vec2) => (p.x - stair.start.x) * u.x + (p.y - stair.start.y) * u.y;
    const sideOf = (p: Vec2) => (p.x - stair.start.x) * w.x + (p.y - stair.start.y) * w.y;
    const positions: number[] = [];
    const indices: number[] = [];
    for (const tr of geo.treads) {
      if (tr.pts.length < 4) continue;
      // Vier Eckpunkte nach Lauf-Parameter trennen (2 kleinste = Startkante, 2
      // grösste = Endkante), je Kante nach Quer-Parameter sortieren (0 = links).
      const byRun = [...tr.pts].sort((a, b) => runOf(a) - runOf(b));
      const startEdge = [byRun[0], byRun[1]].sort((a, b) => sideOf(a) - sideOf(b));
      const endEdge = [byRun[2], byRun[3]].sort((a, b) => sideOf(a) - sideOf(b));
      const runS = Math.min(runOf(byRun[0]), runOf(byRun[1]));
      const runE = Math.max(runOf(byRun[2]), runOf(byRun[3]));
      const topH = zBottom + tr.topRise;
      const bS = soffitH(runS);
      const bE = soffitH(runE);
      if (topH - Math.max(bS, bE) <= EPS) continue;
      const base = positions.length / 3;
      const push = (p: Vec2, z: number) => positions.push(p.x, p.y, z);
      // 0/1 Startkante unten (soffit), 2/3 Endkante unten, 4/5 Start oben, 6/7 End oben.
      push(startEdge[0], bS); push(startEdge[1], bS);
      push(endEdge[0], bE);   push(endEdge[1], bE);
      push(startEdge[0], topH); push(startEdge[1], topH);
      push(endEdge[0], topH);   push(endEdge[1], topH);
      const quad = (a: number, b: number, c: number, d: number) =>
        indices.push(base + a, base + b, base + c, base + a, base + c, base + d);
      quad(0, 1, 3, 2); // Untersicht (schräge Soffit-Fläche)
      quad(4, 6, 7, 5); // Oberseite (Trittfläche)
      quad(0, 4, 5, 1); // Startkante (Setzstufen-Front der Vorstufe sichtbar)
      quad(2, 3, 7, 6); // Endkante
      quad(0, 2, 6, 4); // linke Wange
      quad(1, 5, 7, 3); // rechte Wange
    }
    if (indices.length === 0) continue;
    out.push({ positions, indices, kind: "extrusion", color: STAIR_RGB });
  }
  return out;
}

/** Emittiert die Deckenplatten eines Projekts (falls vorhanden). */
function emitSlabs(project: Project): RSlab[] {
  const out: RSlab[] = [];
  for (const c of project.ceilings ?? []) {
    if (!c.outline || c.outline.length < 3) continue;
    // zBottom/zTop (und damit die Deckendicke) berechnet ceilingVerticalExtent
    // bereits über ceilingThickness (respektiert Typ-Dicke + Übersteuerung).
    const { zBottom, zTop } = ceilingVerticalExtent(project, c);
    if (zTop - zBottom <= EPS) continue;
    const outline = c.outline.map((p) => [p.x, p.y] as RVec2);

    // Geschichteter Bodenaufbau: je Schicht des Deckentyps EIN eigener Slab,
    // von oben (OK, erste Lage) nach unten (UK, letzte Lage) gestapelt und
    // proportional auf die tatsächliche z-Spanne [zBottom, zTop] verteilt (wie
    // die Lagen-Summe bei den Wänden). Jede Lage trägt ihre Bauteil-Schraffur +
    // -Farbe — so zeigt der 3D-Schnitt den Bodenaufbau GESCHICHTET (Estrich/
    // Dämmung/Beton …), identisch zum 2D-Schnitt, statt eines Einkörpers in der
    // dominanten Schicht. Kein auflösbarer Deckentyp / keine Lagen ⇒ EIN
    // Vollkörper-Slab in SLAB_RGB (bisheriges Fallback-Verhalten).
    let layers: Layer[] | undefined;
    try {
      layers = getCeilingType(project, c).layers;
    } catch {
      layers = undefined;
    }
    const total = layers?.reduce((s, l) => s + l.thickness, 0) ?? 0;
    if (layers && layers.length > 0 && total > EPS) {
      const span = zTop - zBottom;
      let prefixTop = 0; // von OK (zTop) nach unten aufsummiert (Meter)
      for (const l of layers) {
        const t = (l.thickness / total) * span;
        const layerTop = zTop - prefixTop;
        const layerBottom = layerTop - t;
        prefixTop += t;
        let color: RRgb = SLAB_RGB;
        let cut: RCutMeta | undefined;
        try {
          const comp = getComponent(project, l.componentId);
          color = hexToRgb(comp.color);
          // Schnitt-Metadaten der Schicht: joinPriority + Identität (für die
          // Boolean-Dominanz/MERGE) + Schnittkanten-Stil aus jointLineStyleId.
          const lineStyle = resolveCutLineStyle(project, l.jointLineStyleId);
          cut = {
            joinPriority: comp.joinPriority,
            componentId: comp.id,
            ...(lineStyle?.color ? { lineColor: lineStyle.color } : {}),
            ...(lineStyle?.weight != null ? { lineWeight: lineStyle.weight } : {}),
          };
        } catch {
          color = SLAB_RGB;
          cut = undefined;
        }
        const hatch = resolveComponentHatch(project, l.componentId);
        out.push({
          outline,
          zBottom: layerBottom,
          zTop: layerTop,
          color,
          ceilingId: c.id,
          ...(hatch ? { hatch } : {}),
          ...(cut ? { cut } : {}),
        });
      }
    } else {
      out.push({ outline, zBottom, zTop, color: SLAB_RGB, ceilingId: c.id });
    }
  }
  return out;
}

/** Event-Name für den "Extrusion(en) geladen"-Stoss (s. Wasm3DViewport.tsx/nativeSync.ts). */
export const TRUCK_MESH_READY_EVENT = "dossier:truck-mesh-ready";

/**
 * Extrudiert `project.extrudedSolids` (truck-Integration, `docs/design/
 * truck-plan.md`) zu Kontext-Meshes. `extrudePolygon` lädt WASM asynchron;
 * `emitMeshes`/`emitExtrudedSolids` selbst bleiben synchron (Aufrufer wie
 * `updateModel`/`pushNativeWalls` erwarten das) — daher ein modulweiter Cache
 * je Solid-Id, der bei fehlendem/veraltetem Eintrag die Extrusion im
 * Hintergrund anstösst und `TRUCK_MESH_READY_EVENT` feuert, sobald sie fertig
 * ist (Wasm3DViewport/nativeSync hören darauf und re-pushen das Modell — ohne
 * das Signal käme eine fertige Extrusion trotz Erfolgs NIE ins Bild, weil
 * `updateModel` nur bei PROJEKT-Änderungen erneut läuft). Nur im Browser (kein
 * `window` unter vitest/node) und mit stillem Fallback bei Fehlern.
 */
interface TruckCacheEntry {
  /** Signatur (Punkte+Höhe) des Solids beim Laden — invalidiert den Eintrag bei Änderung. */
  signature: string;
  /** null, solange die Extrusion noch lädt. */
  mesh: RMesh | null;
}
const truckMeshCache = new Map<string, TruckCacheEntry>();

function extrudedSolidSignature(solid: ExtrudedSolid): string {
  return `${solid.height}|${solid.taper ?? 0}|${solid.points.map((p) => `${p.x},${p.y}`).join(";")}`;
}

function emitExtrudedSolids(project: Project): RMesh[] {
  if (typeof window === "undefined") return [];
  const out: RMesh[] = [];
  // Cache-Einträge gelöschter/nicht mehr existierender Körper verwerfen (sonst
  // wächst der Cache unbegrenzt über eine Session, z. B. nach Extrudieren →
  // Kopieren → Löschen in Serie).
  const liveIds = new Set((project.extrudedSolids ?? []).map((s) => s.id));
  for (const id of truckMeshCache.keys()) {
    if (!liveIds.has(id)) truckMeshCache.delete(id);
  }
  for (const solid of project.extrudedSolids ?? []) {
    if (solid.points.length < 3 || solid.height <= 0) continue;
    const signature = extrudedSolidSignature(solid);
    let entry = truckMeshCache.get(solid.id);
    if (!entry || entry.signature !== signature) {
      // Die bisherige Mesh (falls vorhanden) WEITER anzeigen, bis die neue
      // Extrusion fertig geladen ist — sonst verschwindet der Körper kurz,
      // während der Nutzer Höhe/Verjüngung im Attribute-Panel tippt (jeder
      // Tastendruck feuert onChange → neue Signatur).
      const staleMesh = entry?.mesh ?? null;
      entry = { signature, mesh: staleMesh };
      truckMeshCache.set(solid.id, entry);
      let baseZ = 0;
      try {
        baseZ = getFloor(project, solid.levelId).baseElevation ?? 0;
      } catch {
        // Geschoss nicht (mehr) auflösbar: bei UK 0 extrudieren (Fallback).
      }
      // Kreis-Profil: echte runde truck-Extrusion (extrudeCircle) statt der
      // 48-Eck-Tessellierung in `points` — glattes Zylinder-Mesh.
      const taper = solid.taper ?? 0;
      const extrusion = solid.circle
        ? extrudeCircle(solid.circle.center.x, solid.circle.center.y, solid.circle.r, solid.height, taper)
        : extrudePolygon(solid.points.flatMap((p) => [p.x, p.y]), solid.height, taper);
      extrusion
        .then((mesh) => {
          // truck extrudiert immer ab z=0 — auf die Geschoss-UK anheben.
          const positions =
            baseZ === 0
              ? mesh.positions
              : mesh.positions.map((v, i) => (i % 3 === 2 ? v + baseZ : v));
          const cur = truckMeshCache.get(solid.id);
          if (!cur || cur.signature !== signature) return; // inzwischen veraltet
          cur.mesh = { positions, indices: mesh.indices, kind: "extrusion", color: EXTRUSION_RGB };
          window.dispatchEvent(new Event(TRUCK_MESH_READY_EVENT));
        })
        .catch((e) => console.warn("truck-Extrusion fehlgeschlagen:", e));
    }
    if (entry.mesh) out.push(entry.mesh);
  }
  return out;
}

/**
 * Strecken eines 2D-Zeichenelements für die Boden-Spur im 3D — exakt dieselbe
 * Teilmenge wie `drawing2DSegments` im drei.js-Fallback (`Viewport3D.tsx`):
 * line/polyline/rect. Kreis/Bogen/Text bleiben aussen vor (keine neue
 * Fähigkeit gegenüber dem Fallback, nur Parität).
 */
function drawingSegments3d(d: Drawing2D): [Vec2, Vec2][] {
  const g = d.geom;
  const out: [Vec2, Vec2][] = [];
  if (g.shape === "line") out.push([g.a, g.b]);
  else if (g.shape === "polyline") {
    for (let i = 0; i < g.pts.length - 1; i++) out.push([g.pts[i], g.pts[i + 1]]);
    if (g.closed && g.pts.length > 2) out.push([g.pts[g.pts.length - 1], g.pts[0]]);
  } else if (g.shape === "rect") {
    const c1 = g.min, c3 = g.max;
    const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y };
    out.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]);
  }
  return out;
}

/** Modellfarbe einer 2D-Zeichnung (color → LineStyle → Kategorie), analog `drawing2DColor`. */
function drawingColor3d(project: Project, d: Drawing2D): RRgb {
  if (d.color) return hexToRgb(d.color, DRAWING_LINE_FALLBACK_RGB);
  const ls = d.lineStyleId ? project.lineStyles.find((l) => l.id === d.lineStyleId) : undefined;
  if (ls?.color) return hexToRgb(ls.color, DRAWING_LINE_FALLBACK_RGB);
  const cat = flattenCategories(project.layers).find((c) => c.code === d.categoryCode);
  return cat?.color ? hexToRgb(cat.color, DRAWING_LINE_FALLBACK_RGB) : DRAWING_LINE_FALLBACK_RGB;
}

/**
 * Emittiert je 2D-Zeichnung ein dünnes Ribbon-Mesh knapp über ihrer
 * Geschossebene — die "2D-Referenz im 3D" (z. B. ein Boden-Raster), die der
 * drei.js-Fallback (`addDrawing2DLines`) zeigt, bisher aber im nativen
 * render3d/wgpu-Pfad fehlte (`RModel3d` kannte nur walls/slabs/meshes).
 * `positions` in MODELL-Metern (x, y, z=Höhe) wie `emitOpeningGlass`; render3d
 * rendert Kontext-Meshes ohnehin doppelseitig (`append_context_mesh`), daher
 * genügt ein flaches Ribbon (kein Quader) pro Strecke.
 */
function emitDrawingLines(project: Project): RMesh[] {
  const out: RMesh[] = [];
  for (const d of project.drawings2d) {
    const segs = drawingSegments3d(d);
    if (segs.length === 0) continue;
    let baseZ = 0;
    try {
      baseZ = getFloor(project, d.levelId).baseElevation ?? 0;
    } catch {
      // Geschoss nicht (mehr) auflösbar: bei UK 0 zeichnen (Fallback).
    }
    const elev = baseZ + DRAWING_LINE_ELEVATION_EPS;
    const positions: number[] = [];
    const indices: number[] = [];
    for (const [a, b] of segs) {
      const dx = b.x - a.x;
      const dy = b.y - a.y;
      const len = Math.hypot(dx, dy);
      if (len < 1e-9) continue;
      const nx = (-dy / len) * DRAWING_LINE_HALF_WIDTH;
      const ny = (dx / len) * DRAWING_LINE_HALF_WIDTH;
      const base = positions.length / 3;
      positions.push(
        a.x + nx, a.y + ny, elev,
        a.x - nx, a.y - ny, elev,
        b.x - nx, b.y - ny, elev,
        b.x + nx, b.y + ny, elev,
      );
      indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
    }
    if (positions.length === 0) continue;
    out.push({ positions, indices, kind: "imported", color: drawingColor3d(project, d) });
  }
  return out;
}

/**
 * 3D-Farbe einer Stütze: die Bauteil-Albedo (`Component.color`), falls ein
 * `componentId` auflösbar ist, sonst der neutrale Tragwerks-Grauton {@link
 * COLUMN_RGB}. Eine explizite `column.color` (Grundriss-Umrissfarbe) wirkt
 * bewusst NICHT auf die 3D-Fläche (nur der 2D-Umriss), damit die 3D-Darstellung
 * material-/neutral bleibt — analog Wand (Umrissfarbe ≠ 3D-Albedo).
 */
function columnColor3d(project: Project, col: Column): RRgb {
  if (col.componentId) {
    try {
      return hexToRgb(getComponent(project, col.componentId).color, COLUMN_RGB);
    } catch {
      return COLUMN_RGB;
    }
  }
  return COLUMN_RGB;
}

/**
 * Baut ein geschlossenes Prisma-Mesh aus einem konvexen Grundriss-Polygon
 * `foot` (≥3 Punkte, CCW) zwischen `zBottom` und `zTop`. `positions` in MODELL-
 * Metern (x, y, z=Höhe) wie {@link emitMeshes}; render3d rendert Kontext-Meshes
 * doppelseitig, daher ist die Winding-Reihenfolge unerheblich. Die Deckel werden
 * als Dreiecks-Fächer ab dem ersten Vertex tesselliert (konvex → korrekt).
 */
function prismMesh(foot: Vec2[], zBottom: number, zTop: number, color: RRgb): RMesh {
  const n = foot.length;
  const positions: number[] = [];
  const indices: number[] = [];
  // 0..n-1 = untere Ring-Vertices, n..2n-1 = obere Ring-Vertices.
  for (const p of foot) positions.push(p.x, p.y, zBottom);
  for (const p of foot) positions.push(p.x, p.y, zTop);
  // Mantelflächen: je Kante ein Quad (2 Dreiecke).
  for (let i = 0; i < n; i++) {
    const j = (i + 1) % n;
    const b0 = i, b1 = j, t0 = i + n, t1 = j + n;
    indices.push(b0, b1, t1, b0, t1, t0);
  }
  // Deckel (Fächer ab Vertex 0): unten + oben.
  for (let i = 1; i < n - 1; i++) {
    indices.push(0, i, i + 1); // unten
    indices.push(n, n + i, n + i + 1); // oben
  }
  return { positions, indices, kind: "extrusion", color };
}

/**
 * Extrudiert `project.columns` (Tragwerk) zu parametrischen Prisma-Meshes —
 * Rechteck-Box bzw. tesselliertes Kreis-Prisma, je Profil. SYNCHRON (anders als
 * die truck-Extrusion der `ExtrudedSolid`s, {@link emitExtrudedSolids}): die
 * Kontur kommt aus {@link columnFootprint}, die vertikale Lage aus {@link
 * columnVerticalExtent} (UK/OK-Anker bzw. Geschoss-UK + Höhe). Rust/render3d
 * bleibt unverändert (Kontext-Mesh-Pfad).
 */
function emitColumns(project: Project): RMesh[] {
  const out: RMesh[] = [];
  for (const col of project.columns ?? []) {
    if (col.height <= 0) continue;
    const foot = columnFootprint(col);
    if (foot.length < 3) continue;
    const { zBottom, zTop } = columnVerticalExtent(project, col);
    if (zTop - zBottom <= EPS) continue;
    out.push(prismMesh(foot, zBottom, zTop, columnColor3d(project, col)));
  }
  return out;
}

/**
 * Emittiert die rohen Kontext-Meshes eines Projekts: Gelände-TINs (`terrainMesh`)
 * und importierte Volumen (`importedMesh`) aus `project.context`. `contourSet`
 * bleibt außen vor — das sind nur 2D-Linien (Höhenlinien/Footprints), keine
 * Dreiecksmeshes. Die `positions`/`indices` werden 1:1 durchgereicht (Modell-
 * Koordinaten, x,y,z=Höhe); render3d bildet sie selbst nach world ab.
 */
function emitMeshes(project: Project): RMesh[] {
  const out: RMesh[] = [];
  for (const obj of project.context ?? []) {
    if (obj.type === "terrainMesh" || obj.type === "importedMesh") {
      // Leere/entartete Meshes überspringen (render3d würde sie ohnehin verwerfen).
      if (obj.positions.length < 9 || obj.indices.length < 3) continue;
      out.push({
        positions: obj.positions,
        indices: obj.indices,
        kind: obj.type === "terrainMesh" ? "terrain" : "imported",
        color: obj.type === "terrainMesh" ? TERRAIN_RGB : IMPORTED_RGB,
      });
    }
  }
  out.push(...emitExtrudedSolids(project));
  out.push(...emitColumns(project));
  out.push(...emitDrawingLines(project));
  return out;
}

/**
 * Emittiert je Fensteröffnung eine dünne Glasscheibe als rohes Quader-Mesh —
 * damit Fenster im 3D als Fenster lesbar sind statt als blosse Löcher in der
 * Wand. Nur `kind === "window"`; Türen bleiben offen (keine Scheibe).
 *
 * Für jede Scheibe wird ein geschlossener Quader (8 Ecken, 12 Dreiecke) gebaut:
 *   • entlang der Wandachse: das geklemmte Öffnungs-Intervall (openingInterval),
 *   • vertikal: die absolute Öffnungs-Ausdehnung (openingVerticalExtent, also
 *     UK = baseElevation+sillHeight … OK = +height, an den Wandkopf geklemmt) —
 *     exakt der Bereich, den emitWall als Loch freilässt,
 *   • quer zur Wand: ±GLASS_HALF_THICK um die Wandachse (mittig in der Dicke).
 *
 * `positions` liegen — wie bei {@link emitMeshes} (Terrain/Import) — in MODELL-
 * Metern als flaches (x, y, z=Höhe)-Array; render3d bildet sie selbst nach world
 * (x, Höhe, y) ab und rendert die Meshes doppelseitig (append_context_mesh),
 * daher ist die Winding-Reihenfolge der Dreiecke unerheblich. `kind: "imported"`
 * mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB.
 */
function emitOpeningGlass(project: Project): RMesh[] {
  // Faces eines Boxen-Quaders als Dreiecks-Indizes (Ecken-Index-Bits:
  // bit0 = Achse from/to, bit1 = Höhe UK/OK, bit2 = Dicke −/+). Doppelseitig
  // gerendert ⇒ Winding egal; je Fläche 2 Dreiecke, 6 Flächen = 12 Dreiecke.
  const BOX_TRIS: number[] = [
    0, 1, 3, 0, 3, 2, // Dicke −
    4, 5, 7, 4, 7, 6, // Dicke +
    0, 1, 5, 0, 5, 4, // UK
    2, 3, 7, 2, 7, 6, // OK
    0, 2, 6, 0, 6, 4, // from
    1, 3, 7, 1, 7, 5, // to
  ];
  const out: RMesh[] = [];
  for (const wall of project.walls) {
    const dx = wall.end.x - wall.start.x;
    const dy = wall.end.y - wall.start.y;
    const len = Math.hypot(dx, dy);
    if (len < 1e-9) continue;
    const ux = dx / len;
    const uy = dy / len;
    // Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie pushSegment.
    const nx = uy;
    const ny = -ux;
    for (const op of openingsOfWall(project, wall.id)) {
      if (op.kind !== "window") continue; // Türen bekommen keine Scheibe.
      const iv = openingInterval(wall, op);
      if (!iv) continue;
      const { zBottom, zTop } = openingVerticalExtent(project, wall, op);
      if (zTop - zBottom <= EPS) continue;
      const positions: number[] = [];
      // 8 Ecken: Achse s ∈ {from,to} × Höhe z ∈ {UK,OK} × Dicke t ∈ {−,+}.
      for (let i = 0; i < 8; i++) {
        const s = i & 1 ? iv.to : iv.from;
        const z = i & 2 ? zTop : zBottom;
        const t = i & 4 ? GLASS_HALF_THICK : -GLASS_HALF_THICK;
        const px = wall.start.x + ux * s + nx * t;
        const py = wall.start.y + uy * s + ny * t;
        // MODELL-Koordinaten (x, y, z=Höhe) wie emitMeshes.
        positions.push(px, py, z);
      }
      out.push({ positions, indices: [...BOX_TRIS], kind: "imported", color: GLASS_RGB });
    }
  }
  return out;
}

/**
 * Nur die Wände (Rückwärts-Kompatibilität / bestehende Aufrufer). Zerlegt Wände
 * mit Öffnungen in Teilquader.
 */
export function projectToWalls3d(project: Project, layered = true): RWall[] {
  const out: RWall[] = [];
  // Verschneidungs-Schnittlinien PRO GESCHOSS berechnen (wie generatePlan.ts sie
  // je Grundriss auf der gefilterten Wandmenge bildet) — nur für den layered-3D-
  // Pfad. Nach Geschoss gruppieren, damit deckungsgleiche Wände verschiedener
  // Geschosse (z. B. EG/OG an identischen XY-Knoten) NICHT fälschlich verschneiden.
  // Der Schnitt-Pfad (layered=false) bekommt KEINE Cuts (unverändert, seine
  // Dominanz macht toSection.ts). Anders als im 2D wird hier NICHT nach
  // Sichtbarkeit gefiltert — 3D zeigt alle Wände, alle nehmen an den Joins teil.
  const joins = layered ? computeJoinsByFloor(project) : undefined;
  for (const w of project.walls) emitWall(out, project, w, layered, joins?.get(w.id));
  return out;
}

/**
 * Berechnet {@link computeJoins} getrennt je Geschoss (`wall.floorId`) und führt
 * die Ergebnis-Maps zu einer flachen `wallId → WallCuts` zusammen. So bilden nur
 * Wände desselben Geschosses gemeinsame Ecken/T-Stösse (identisch zur 2D-Semantik,
 * wo `generatePlan` je Grundriss aufruft).
 */
function computeJoinsByFloor(project: Project): Map<string, WallCuts> {
  const byFloor = new Map<string, Wall[]>();
  for (const w of project.walls) {
    const key = w.floorId ?? "";
    const list = byFloor.get(key);
    if (list) list.push(w);
    else byFloor.set(key, [w]);
  }
  const merged = new Map<string, WallCuts>();
  for (const walls of byFloor.values()) {
    const cuts = computeJoins(project, walls);
    for (const [id, wc] of cuts) merged.set(id, wc);
  }
  return merged;
}

/**
 * Optionen für {@link projectToModel3d}. `layeredWalls` steuert, ob eine
 * mehrschichtige Wand als eigene Box je Materiallage (Default, 3D-Viewer) oder
 * als EINE Voll-Box über die ganze Dicke emittiert wird. Der Schnitt-Pfad
 * (`computeSection`) setzt `false`, damit die Cut-Polygone 1:1 zur Wand liegen
 * (die Schicht-Zerlegung macht dort `splitWallLayers` TS-seitig) — sonst
 * verschöben die per-Lage vervielfachten Boxen die `ComponentRef.index`
 * gegenüber `wallSegmentOwners` und die materialspezifische Schnitt-Schraffur
 * ginge verloren.
 */
export interface Model3dOptions {
  layeredWalls?: boolean;
}

/** Das volle 3D-Modell: Wände (mit Öffnungen) + Deckenplatten + Kontext-Meshes. */
export function projectToModel3d(project: Project, opts: Model3dOptions = {}): RModel3d {
  const layered = opts.layeredWalls ?? true;
  return {
    walls: projectToWalls3d(project, layered),
    // Decken + Treppen (Treppentritte als extrudierte Slabs) — so wird die Treppe
    // auch im WASM/wgpu-Renderer sichtbar (der three.js-Pfad rendert sie separat).
    slabs: [...emitSlabs(project), ...emitStairs(project)],
    // Kontext-Meshes (Terrain/Import) + Fenster-Glasscheiben (eigenes Mesh je
    // Fensteröffnung) + glatte Beton-Laufplatten (gerade Betontreppen als Mesh)
    // — pickGeometry/Highlight nutzen `meshes` bewusst NICHT (man wählt Wand/
    // Öffnung/Treppe; Treppen-Pick läuft über die pickGeometry-Boxen).
    meshes: [
      ...emitMeshes(project),
      ...emitOpeningGlass(project),
      ...emitStairMeshes(project),
    ],
  };
}

/**
 * Pick-Geometrie für den TS-seitigen 3D-Raycast (siehe viewport/raycast3d): die
 * Wand-Teilquader (mit `wallId`) und Decken-Prismen (mit `ceilingId`) desselben
 * geflachten Modells wie {@link projectToModel3d}, aber ohne die Kontext-Meshes
 * (die sind für die Bauteil-Auswahl irrelevant), PLUS die Öffnungs- (Fenster/
 * Türen) und Treppen-Boxen fürs Raycast-Picking. Reine TS-Nutzung — geht NICHT
 * an render3d; die Records tragen dieselben Geometriefelder wie die Serde-
 * Payload, plus die Ids fürs Picking.
 */
export function pickGeometry(
  project: Project,
): { walls: RWall[]; slabs: RSlab[]; openings: PickOpening[]; stairs: PickStair[] } {
  const openings: PickOpening[] = [];
  for (const o of project.openings ?? []) {
    const wall = project.walls.find((w) => w.id === o.hostWallId);
    if (!wall) continue;
    const jambs = openingJambs(wall, o);
    if (!jambs) continue;
    const thickness = wallTypeThickness(getWallType(project, wall));
    const { zBottom, zTop } = openingVerticalExtent(project, wall, o);
    if (zTop - zBottom <= 0) continue;
    openings.push({
      start: [jambs.jambStart.x, jambs.jambStart.y],
      end: [jambs.jambEnd.x, jambs.jambEnd.y],
      thickness,
      height: zTop - zBottom,
      baseElevation: zBottom,
      openingId: o.id,
    });
  }
  const stairs: PickStair[] = [];
  for (const st of project.stairs ?? []) {
    const { zBottom, zTop } = stairVerticalExtent(project, st);
    const totalRise = zTop - zBottom;
    if (totalRise <= 0) continue;
    const bbox = stairBBox(stairGeometry(st, totalRise));
    stairs.push({
      outline: [
        [bbox.minX, bbox.minY],
        [bbox.maxX, bbox.minY],
        [bbox.maxX, bbox.maxY],
        [bbox.minX, bbox.maxY],
      ],
      zBottom,
      zTop,
      stairId: st.id,
    });
  }
  return { walls: projectToWalls3d(project), slabs: emitSlabs(project), openings, stairs };
}

/**
 * Baut die Highlight-Linien der aktuellen 3D-Auswahl (Wand-/Decken-Ids) als
 * flaches LineList-Vertex-Array `[px,py,pz,r,g,b, ...]` (world-Meter, je 2
 * Vertices = 1 Kante) — genau das Format, das `set_highlight_lines` erwartet
 * (immer obenauf gezeichnete Akzent-Umrisslinie, s. useWasm3dRenderer). Leere
 * Auswahl → leeres Array (Highlight löschen).
 *
 * GEOMETRIE (dieselbe Welt-Konvention wie pickGeometry/raycast3d: model (x,y)
 * → world (x, elevation, y), Höhe entlang +Y):
 *  • Wand: derselbe orientierte Quader wie `rayWallBox` (raycast3d.ts) — Achse
 *    entlang der Wandlänge, Normale (Dicke) senkrecht dazu in der XZ-Ebene,
 *    Y = Höhe. Aus den 8 Ecken werden die 12 Kanten des Quaders emittiert.
 *    Wände MIT Öffnungen bestehen aus mehreren Teilquadern (Pfeiler/Brüstung/
 *    Sturz, s. emitWall) — alle tragen dieselbe `wallId` und werden hier ALLE
 *    umrandet, sodass Öffnungsränder mit hervorgehoben werden.
 *  • Decke: der Umriss oben (zTop) und unten (zBottom) je als geschlossener
 *    Kantenzug, plus eine vertikale Kante an jeder Umriss-Ecke.
 *
 * Kanten werden EINFACH (nicht versetzt/doppelt) ausgegeben — eine zweite,
 * leicht verschobene Kopie je Linie (für optisch "breitere" Linien bei
 * WebGPU-Linienbreite 1) würde die Vertexzahl verdoppeln und die Achse/Betrag
 * des Versatzes müsste in Bildschirm- statt Weltraum berechnet werden (hängt
 * von Kamera-Distanz/-Winkel ab) — außerhalb des schmalen TS-Rahmens dieser
 * Funktion. Der Renderer kann das bei Bedarf selbst tun (Rust-seitige
 * Linienbreite/Shader), ohne dass sich dieses Vertexformat ändert.
 */
export function selectionHighlightLines(
  project: Project,
  wallIds: string[],
  ceilingIds: string[],
  rgb: RRgb,
  openingIds: string[] = [],
  stairIds: string[] = [],
): Float32Array {
  if (
    wallIds.length === 0 &&
    ceilingIds.length === 0 &&
    openingIds.length === 0 &&
    stairIds.length === 0
  ) {
    return new Float32Array(0);
  }
  const { walls, slabs, openings, stairs } = pickGeometry(project);
  const wallSet = new Set(wallIds);
  const ceilingSet = new Set(ceilingIds);
  const openingSet = new Set(openingIds);
  const stairSet = new Set(stairIds);
  const out: number[] = [];
  const pushEdge = (a: RVec3, b: RVec3) => {
    out.push(a[0], a[1], a[2], rgb[0], rgb[1], rgb[2]);
    out.push(b[0], b[1], b[2], rgb[0], rgb[1], rgb[2]);
  };
  // 12 Kanten eines Quaders aus 8 Ecken (Ecken-Index-Bits: bit0=Achse,
  // bit1=Höhe, bit2=Dicke — je zwei Ecken, die sich in genau einem Bit
  // unterscheiden, bilden eine Kante).
  const BOX_EDGES: Array<[number, number]> = [
    [0, 1], [0, 2], [0, 4], [1, 3], [1, 5], [2, 3],
    [2, 6], [3, 7], [4, 5], [4, 6], [5, 7], [6, 7],
  ];
  // Orientierter Quader (Wand ODER Öffnung: gleiche Felder start/end/thickness/
  // height/baseElevation) → seine 12 Kanten.
  const boxEdges = (b: {
    start: readonly [number, number];
    end: readonly [number, number];
    thickness: number;
    height: number;
    baseElevation: number;
  }) => {
    const ax = b.end[0] - b.start[0];
    const az = b.end[1] - b.start[1];
    const len = Math.hypot(ax, az);
    if (len < EPS || b.height <= EPS || b.thickness <= EPS) return;
    const axis: RVec3 = [ax / len, 0, az / len];
    const normal: RVec3 = [az / len, 0, -ax / len];
    const halfLen = len / 2;
    const halfHeight = b.height / 2;
    const halfThick = b.thickness / 2;
    const center: RVec3 = [
      (b.start[0] + b.end[0]) / 2,
      b.baseElevation + b.height / 2,
      (b.start[1] + b.end[1]) / 2,
    ];
    const corners: RVec3[] = [];
    for (let i = 0; i < 8; i++) {
      const sL = i & 1 ? 1 : -1;
      const sH = i & 2 ? 1 : -1;
      const sT = i & 4 ? 1 : -1;
      corners.push([
        center[0] + sL * halfLen * axis[0] + sT * halfThick * normal[0],
        center[1] + sH * halfHeight,
        center[2] + sL * halfLen * axis[2] + sT * halfThick * normal[2],
      ]);
    }
    for (const [i, j] of BOX_EDGES) pushEdge(corners[i], corners[j]);
  };
  // Vertikales Prisma (Decke ODER Treppen-Fussabdruck) → Umriss oben/unten + Ecken.
  const prismEdges = (outline: ReadonlyArray<readonly [number, number]>, zBottom: number, zTop: number) => {
    const n = outline.length;
    if (n < 3 || zTop - zBottom <= EPS) return;
    const bottom: RVec3[] = outline.map((p) => [p[0], zBottom, p[1]]);
    const top: RVec3[] = outline.map((p) => [p[0], zTop, p[1]]);
    for (let i = 0, j = n - 1; i < n; j = i++) {
      pushEdge(bottom[j], bottom[i]);
      pushEdge(top[j], top[i]);
    }
    for (let i = 0; i < n; i++) pushEdge(bottom[i], top[i]);
  };
  for (const w of walls) if (wallSet.has(w.wallId)) boxEdges(w);
  for (const o of openings) if (openingSet.has(o.openingId)) boxEdges(o);
  for (const s of slabs) if (ceilingSet.has(s.ceilingId)) prismEdges(s.outline, s.zBottom, s.zTop);
  for (const st of stairs) if (stairSet.has(st.stairId)) prismEdges(st.outline, st.zBottom, st.zTop);
  return new Float32Array(out);
}
