// Grundriss-Generator: erzeugt aus dem semantischen Modell den
// 2D-Grundriss als abstrakte Vektor-Primitive (später → SVG).
//
// Kern-Erkenntnis: Der Grundriss entsteht NICHT durch Zerschneiden eines
// 3D-Meshes, sondern direkt aus den Parametern (Wandachsen, Schichtaufbau,
// Öffnungen) — exakt, schnell, sauber.

import type {
  AttributeSource,
  Ceiling,
  Column,
  Component,
  Drawing2D,
  ExtrudedSolid,
  HatchPattern,
  LayerCategory,
  Opening,
  OverrideActions,
  Project,
  Room,
  Stair,
  Vec2,
  Wall,
} from "../model/types";
import {
  columnLabel,
  columnsOfFloor,
  flattenCategories,
  getCeilingType,
  getComponent,
  getHatch,
  getLayerCategory,
  getLineStyle,
  getWallType,
  openingLabel,
  openingsOfWall,
  roomsOfFloor,
  stairLabel,
  stairsOfFloor,
  wallTypeThickness,
} from "../model/types";
import { columnFootprint } from "../geometry/column";
import { effectiveOverrides, hasOverrides } from "../overrides/engine";
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
import type { RichTextDoc } from "../text/richText";
import { docFromText } from "../text/richText";
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
import type { RoomStampLine } from "../model/roomStamp";
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
import { stairGeometry, stairCut, stairOutline } from "../geometry/stair";
import type { SpiralOutlineSegments } from "../geometry/stair";
import {
  doorSymbol,
  openingGapQuad,
  openingInterval,
  openingJambs,
  wallAxisFrame,
  windowSymbol,
} from "../geometry/opening";
import {
  add,
  along,
  clippedBand,
  dot,
  leftNormal,
  lineIntersect,
  normalize,
  scale,
  sub,
  wallCorners,
} from "../model/geometry";
import type { Line } from "../model/geometry";

/**
 * Detailgrad der Plan-Darstellung (Oberleiste „grob/mittel/fein"). Steuert hier
 * konkret zwei Dinge im Grundriss:
 *  • Tür-/Fenstersymbole: grob = nur Öffnungs-Lücke + gerade Türblatt-Linie;
 *    mittel = + Schwenkbogen; fein = + Rahmen/Anschlag-Striche.
 *  • Schraffuren + Linienfeinheit: grob = vereinfachte Poché (eine Sammelfläche
 *    ohne Schraffur, dickere Umrisslinie); mittel = aktueller mehrschichtiger
 *    Aufbau mit Schraffuren; fein = volle Schraffuren + dünnere Linien.
 *
 * NOCH OFFEN (folgt, sobald die Features existieren — hier bewusst KEIN
 * Schein-Effekt): Bemaßung/Beschriftung werden mit dem Detailgrad auf-/abblenden
 * (Maßketten, Raumstempel), und die 3D-Geometrie-Auflösung (z. B. Profil-
 * Rundungen, Tritt-/Setzstufen) wird ebenfalls dem Detailgrad folgen, sobald
 * Bemaßung bzw. eine feinere 3D-Tessellierung implementiert sind.
 */
export type DetailLevel = "grob" | "mittel" | "fein";
import { computeJoins } from "../model/joins";
import type { WallCuts } from "../model/joins";
import type { SectionOutput } from "./toSection";

/** Stroke-Farbe der Schicht-Polygone (neutrales Schwarz, geteilte Tinte mit der Schraffur). */
const POCHE_STROKE = "#1a1a1a";

/** Tinte für den Schwarz-Weiss-Modus: einheitliches Dunkel für alle Striche. */
const MONO_INK = "#111111";

// ── Linienstärken in PAPIER-Millimeter (vgl. docs/design/plans-output.md §3.2) ──
// Alle Stricharten sind in mm Papier definiert; die PlanView rechnet sie über
// dpi = 96·devicePixelRatio in konstante Bildschirm-Pixel um (non-scaling-stroke),
// damit Linien beim Zoomen ihre Papiergröße behalten und der Massstab stimmt.

/** Default-Schichtfuge zwischen den Materialschichten in mm — Haarlinie (0.02),
 *  gleiche Stärke wie die Schraffur-Tinte. Pro Fuge im Wandstil überschreibbar
 *  (z. B. 0.13, wenn zwei massive Materialien aufeinandertreffen). */
const LAYER_LINE_MM = 0.02;
/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */
const SYMBOL_HAIRLINE_MM = 0.02;
/** Strichmuster (mm Papier) für ÜBERKOPF-Ansichtslinien: Bauteile über der
 *  Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach
 *  BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */
const OVERHEAD_DASH: number[] = [0.4, 0.25];
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
  grob: 1.6, // dickere Sammellinie
  mittel: 1.0, // wie die Kategorie-Strichstärke
  fein: 0.6, // feiner
};
/** Stärke der Schichtfugen je Detailgrad (mittel/fein; grob hat keine Fugen). */
const LAYER_DETAIL_FACTOR: Record<DetailLevel, number> = {
  grob: 1.0,
  mittel: 1.0,
  fein: 0.7,
};
/** Default-Umrandungs-Strichstärke (mm), falls die Kategorie keine lw trägt
   (Grundeinstellung für „normale Elemente"). */
const WALL_FALLBACK_MM = 0.18;

/**
 * Aufgelöste Schraffur eines Polygons — alle Werte stammen aus einer
 * HatchStyle-Ressource (Muster/Maßstab/Winkel/Farbe) plus dem optional
 * verknüpften LineStyle (Linienstärke/Strichmuster der Musterlinien). Die
 * PlanView ist damit rein darstellend und kennt das Projekt nicht.
 */
export interface HatchRender {
  pattern: HatchPattern;
  scale: number;
  angle: number;
  /**
   * AUFGELÖSTE Farbe der Musterlinien bzw. der Vollfüllung (pattern==="solid").
   * Ergebnis der Farb-Resolve-Kette (siehe {@link resolveHatch}): Vordergrund =
   * (Attribut-Override) ?? (Component.foreground) ?? (HatchStyle.color, Backward-
   * Compat) ?? Default. Der heutige SVG-/GL-/DXF-Renderer liest weiterhin nur
   * dieses Feld — die Kette ist zur Renderzeit bereits kollabiert.
   */
  color: string;
  /** Linienstärke der Musterlinien in mm. */
  lineWeight: number;
  /** Strichmuster der Musterlinien in mm; null = durchgezogen. */
  dash: number[] | null;
  /**
   * Schraffur-Typ (additiv). `undefined`/"vector" ⇒ Muster nach `pattern` (heutiges
   * Verhalten); "image" ⇒ Bild-Muster (`image`). Bestehende Renderer ignorieren
   * `kind` und zeichnen nach `pattern` → unbekannte kinds fallen auf heute zurück.
   */
  kind?: "vector" | "image";
  /** Vektor-Untermodus (additiv). `undefined`/"parallel" ⇒ regelmäßig; "random" ⇒ zufällig. */
  lines?: "parallel" | "random";
  /** Bild-Muster-Parameter (nur `kind==="image"`); durchgereicht aus dem HatchStyle. */
  image?: { src: string; scaleX: number; scaleY: number; rotation: number };
  /**
   * Steuer-Parameter der Random-Vektor-Streuung (nur `lines==="random"`), 1:1 aus
   * dem HatchStyle durchgereicht. Alle optional — die eigentliche Erzeugung/
   * Default-Auflösung liegt zentral in `glPlan/glPlanHatch.ts` (single render truth).
   */
  seed?: number;
  density?: number;
  lengthMin?: number;
  lengthMax?: number;
}

/**
 * Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus
 * dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus,
 * `greyed`=true zeichnet sie gedimmt. Wird kein Resolver übergeben, gilt für
 * jede sichtbare Kategorie {render:true, greyed:false} (bisheriges Verhalten).
 */
export interface CategoryDisplay {
  render: boolean;
  greyed: boolean;
}

/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */
export type CategoryDisplayResolver = (code: string) => CategoryDisplay;

/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */
const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false });

export type Primitive =
  | {
      kind: "polygon";
      pts: Vec2[];
      /** Füllfarbe (Component-Poché) bzw. "none" für reine Umrisslinien. */
      fill: string;
      stroke: string;
      /** Umriss-Strichstärke in PAPIER-Millimeter (siehe oben). */
      strokeWidthMm: number;
      hatch: HatchRender;
      /** Gedimmt zeichnen (Darstellungsmodus „andere grau"). */
      greyed?: boolean;
      /**
       * Kanten-Indizes, die NICHT umrissen (gestrichen) werden dürfen — die Füllung
       * bleibt das volle Polygon. Kante `i` läuft von `pts[i]` nach `pts[(i+1)%n]`.
       * Damit werden die inneren Gehrungs-/Stoß-Stirnflächen an einer Wandecke
       * unterdrückt: die Diagonale, an der GLEICHFARBIGE Schichten zweier Wände
       * aneinanderstoßen, würde sonst als sichtbare 45°-Naht erscheinen und der
       * Umriss würde als Barbe über den Eck-Apex hinausschießen (Gehrungs-Spitze).
       * Längskanten (Schichtfugen zwischen VERSCHIEDENEN Materialien) bleiben
       * gestrichen — nur die Stoß-Stirnkanten am Knoten entfallen.
       */
      noStrokeEdges?: number[];
      /**
       * ID der Wand, zu der dieses Schicht-/Umriss-Polygon gehört (für die
       * Links-Klick-Auswahl: die PlanView markiert ALLE Polygone derselben
       * Wand und meldet die wallId nach oben). Reine Umriss-/Symbol-Primitive
       * ohne Wandbezug lassen das Feld weg.
       */
      wallId?: string;
      /**
       * ID des 2D-Zeichenelements (Drawing2D), falls dieses gefüllte Polygon eine
       * geschlossene 2D-Form ist (für die Links-Klick-Auswahl der Fläche).
       */
      drawingId?: string;
      /**
       * ID der Decke (Ceiling), falls dieses Polygon eine Decken-Fläche/-Umriss
       * ist (für die Links-Klick-Auswahl der Decke).
       */
      ceilingId?: string;
      /** ID der Öffnung (Fenster/Tür), z. B. der Fensterrahmen (Auswahl). */
      openingId?: string;
      /** ID der Treppe (Stair), falls dieses Polygon ein Tritt/Podest ist (Auswahl). */
      stairId?: string;
      /** ID des Raums (Room), falls dieses Polygon eine Raum-Fläche ist (Auswahl). */
      roomId?: string;
      /**
       * ID des extrudierten Körpers (truck-Integration), falls dieses Polygon der
       * Grundriss-Footprint einer Extrusion ist (für eine spätere Links-Klick-
       * Auswahl — heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet).
       */
      extrudedSolidId?: string;
      /**
       * ID der Stütze (Column), falls dieses Polygon der Grundriss-Querschnitt
       * (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche).
       */
      columnId?: string;
    }
  | {
      kind: "line";
      a: Vec2;
      b: Vec2;
      cls: string;
      /** Strichstärke in mm Papier (non-scaling). */
      weightMm: number;
      /** Strichmuster in mm Papier; null/undefined = durchgezogen. */
      dash?: number[] | null;
      /**
       * Zickzack-/Wellen-Parameter (aus einem LineStyle mit `kind==="zigzag"`),
       * beide in mm Papier. Ist es gesetzt, zeichnen die Renderer die Linie als
       * Zickzack-Pfad statt als gerade (ggf. gestrichelte) Linie — ADDITIV, ohne
       * die bestehenden Strich/Dash-Linien zu verändern (Feld fehlt ⇒ heute).
       */
      zigzag?: { amplitude: number; wavelength: number };
      /**
       * Frei gezeichnetes Wiederhol-Motiv (aus einem LineStyle mit
       * `kind==="custom"`): eine offene Polylinie in einer Einheitszelle, `points`
       * in mm Papier, `length` = Wiederhollänge in mm Papier. Ist es gesetzt,
       * kacheln die Renderer das Motiv entlang der Linie (analog `zigzag`) —
       * ADDITIV; fehlt es, bleibt die Linie gerade/gestrichelt.
       */
      motif?: { points: Vec2[]; length: number };
      /** Optionale explizite Strichfarbe (überschreibt die CSS-Klasse). */
      color?: string;
      /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */
      drawingId?: string;
      /** ID der Öffnung (für Links-Klick-Auswahl von Fenster/Tür-Symbolen). */
      openingId?: string;
      /** ID der Treppe (für Links-Klick-Auswahl von Treppen-Symbollinien). */
      stairId?: string;
      greyed?: boolean;
    }
  | {
      kind: "arc";
      center: Vec2;
      from: Vec2;
      to: Vec2;
      r: number;
      cls: string;
      /** Strichstärke in mm Papier (non-scaling). */
      weightMm: number;
      /** Strichmuster in mm Papier; null/undefined = durchgezogen. */
      dash?: number[] | null;
      /** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
      openingId?: string;
      /** ID der Treppe (für Links-Klick-Auswahl von Wendel-Outline-Bögen). */
      stairId?: string;
      greyed?: boolean;
    }
  | {
      kind: "text";
      /** Ankerpunkt im Modell (Meter). */
      at: Vec2;
      /**
       * Frei editierbarer Stempel-Text (Rich-Text). Wird zuerst gezeichnet;
       * `align` je Absatz steuert die Ausrichtung.
       */
      doc: RichTextDoc;
      /**
       * LIVE-Zusatzzeilen (z. B. Fläche „24.30 m²" + SIA-Tag), UNTER dem Doc
       * gezeichnet — unabhängig vom editierten Text, stets aktuell. Jede Zeile
       * trägt ihre eigene Ausrichtung (`align`).
       */
      extraLines: RoomStampLine[];
      /** Basis-Schriftgrösse in PUNKT (für Runs ohne eigene Grösse + Zusatzzeilen). */
      basePt: number;
      /** Textfarbe (hex) als Default. */
      color: string;
      /** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
      roomId?: string;
      greyed?: boolean;
    }
  | {
      // Schlichter, modellverankerter Einzeltext (aus Drawing2D `{shape:"text"}`,
      // z. B. DXF-Import). Anders als `kind:"text"` (Raum-Stempel, RichText) trägt
      // er nur einen String mit Höhe in Metern + Drehung — rein SVG-gerendert.
      kind: "drawingText";
      /** Ankerpunkt im Modell (Meter). */
      at: Vec2;
      text: string;
      /** Schrifthöhe in Modell-Metern. */
      heightM: number;
      /** Drehung in RADIANT (CCW im Modell). */
      angle: number;
      color: string;
      /**
       * Optionale Spaltenbreite in Modell-Metern (Textspalte): ist sie gesetzt,
       * bricht der Renderer den Text wortweise auf diese Breite um. Fehlt sie,
       * bleibt es einzeiliger Text.
       */
      wrapWidth?: number;
      /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */
      drawingId?: string;
      greyed?: boolean;
    }
  | {
      // Glatter Kreis (aus Drawing2D `{shape:"circle"}`, z. B. DXF-CIRCLE) — als
      // echtes `<circle>` gerendert (rund bei jedem Zoom), nicht als Vieleck.
      kind: "drawingCircle";
      center: Vec2;
      /** Radius in Modell-Metern. */
      r: number;
      /** Vollton-Füllfarbe oder "none". */
      fill: string;
      stroke: string;
      weightMm: number;
      dash?: number[] | null;
      drawingId?: string;
      greyed?: boolean;
    }
  | {
      // Glatter Bogen (aus Drawing2D `{shape:"arc"}`, z. B. DXF-ARC) — als
      // SVG-Bogenpfad gerendert; Winkel in RADIANT (CCW im Modell).
      kind: "drawingArc";
      center: Vec2;
      r: number;
      a0: number;
      a1: number;
      stroke: string;
      weightMm: number;
      dash?: number[] | null;
      drawingId?: string;
      greyed?: boolean;
    };

/**
 * Löst eine HatchStyle-Ressource (per id) zu einer darstellungsfertigen
 * {@link HatchRender} auf. Der Linienstil der Musterlinien wird, falls gesetzt,
 * aus dem Line Manager nachgeschlagen; sonst gilt ein dünner Default.
 *
 * VORDERGRUND (Muster-/Schraffurlinienfarbe bzw. Vollfüllung): AKTUELL fest auf
 * {@link HATCH_INK} gezwungen (Nutzer-Vorgabe 2026-07-04) — Schnitt- WIE
 * Oberflächen-/Ansichtsschraffur zeigen dieselbe dunkle Tinte, unabhängig von
 * Material-/Bauteilfarbe, HatchStyle.color oder einem By-Layer/By-Object-
 * Vordergrund-Override. `_foreground` bleibt Teil der Signatur — alle Aufrufer
 * reichen weiterhin den über {@link resolveForeground} aufgelösten Bauteil-
 * Vordergrund durch (z. B. für eine spätere Wiederherstellung der materialtreuen
 * Schraffur) —, fließt aber bewusst nicht mehr in `color` ein.
 */
export function resolveHatch(
  project: Project,
  hatchId: string,
  wallAngleDeg?: number,
  _foreground?: string,
): HatchRender {
  const h = getHatch(project, hatchId);
  const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
  // Wandbezogener Winkel: `HatchRender.angle` wird von allen Renderern in der
  // SVG-Konvention (im Uhrzeigersinn, Bildschirm-Y nach unten) verstanden — der
  // Modell-Renderer negiert ihn in `toModelAngleRad`. Die Wandachse `wallAngleDeg`
  // ist dagegen ein MODELL-Winkel (atan2 der Richtung, gegen den Uhrzeigersinn,
  // Y nach oben). Wegen der Y-Spiegelung entspricht die Wandrichtung am Bildschirm
  // dem Winkel −wallAngleDeg; das Muster folgt der Wand also, indem der
  // Wandwinkel SUBTRAHIERT wird. Ohne Wandkontext (Decke) bleibt `angle` absolut.
  const angle =
    h.relativeToWall && wallAngleDeg != null ? h.angle - wallAngleDeg : h.angle;
  return {
    pattern: h.pattern,
    scale: h.scale,
    angle,
    // Fest auf die Monochrom-Tinte gezwungen (siehe JSDoc oben) — weder
    // `_foreground` noch `HatchStyle.color` (Material-/Bauteilfarbe) fließen ein.
    color: HATCH_INK,
    lineWeight: ls?.weight ?? 0.13,
    dash: ls?.dash ?? null,
    kind: h.kind,
    lines: h.lines,
    image: h.image,
    // Random-Streu-Parameter unverändert durchreichen (Erzeugung in glPlanHatch).
    seed: h.seed,
    density: h.density,
    lengthMin: h.lengthMin,
    lengthMax: h.lengthMax,
  };
}

/**
 * Kollabiert die Vordergrund-Farbkette (Muster-/Schraffurlinienfarbe) eines
 * Bauteils zu einem einzelnen Wert für {@link resolveHatch}. AUFLÖSUNGSREIHENFOLGE
 * (By-Layer/By-Object, siehe `docs`/Attribut-Panel):
 *   (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ??
 *   (`Component.foreground`) ?? `undefined`.
 * `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
 * `undefined` als Endergebnis heißt „Nach System" — die Kette läuft dann in
 * resolveHatch auf den HatchStyle.color-Backward-Compat-Fallback weiter
 * (Sample-Verhalten: kein Element setzt `override`/`source`, also identisch).
 */
export function resolveForeground(
  comp: Component,
  override?: string,
  category?: LayerCategory,
  source?: AttributeSource,
): string | undefined {
  if (override != null) return override;
  if (source === "layer") return category?.color ?? comp.foreground;
  return comp.foreground;
}

/**
 * Kollabiert die Hintergrund-/Füllfarbkette (Poché) eines Bauteils, analog zu
 * {@link resolveForeground}:
 *   (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ??
 *   (`Component.background`) ?? `Component.color`.
 * Immer definiert (endet spätestens bei `Component.color` = heutiges Verhalten).
 */
export function resolveBackground(
  comp: Component,
  override?: string,
  category?: LayerCategory,
  source?: AttributeSource,
): string {
  if (override != null) return override;
  if (source === "layer") return category?.color ?? comp.background ?? comp.color;
  return comp.background ?? comp.color;
}

/**
 * Kollabiert die Schraffur-Referenz (Hatch Manager) eines Elements zu einer
 * einzelnen `hatchId` für {@link resolveHatch} — By-Layer/By-Object, analog zu
 * {@link resolveForeground}:
 *   (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.hatch`) ??
 *   `fallback` (Bauteil-Schraffur, z. B. `Component.hatchId`/`viewHatchId`, oder
 *   `undefined` bei Drawing2D ohne Bauteil-Bezug).
 * `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
 * Ergebnis kann `undefined` sein — der Aufrufer fällt dann auf `NO_HATCH` zurück.
 */
export function resolveHatchId(
  override: string | undefined,
  source: AttributeSource | undefined,
  category: LayerCategory | undefined,
  fallback?: string,
): string | undefined {
  if (override != null) return override;
  if (source === "layer") return category?.hatch ?? fallback;
  return fallback;
}

/**
 * Kollabiert die Strichstärke-Kette (mm Papier) eines Elements, By-Layer/By-
 * Object, analog zu {@link resolveHatchId}:
 *   (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.lw`) ??
 *   `fallback` (bisherige Bauteil-/LineStyle-/Kategorie-Default-Kette des
 *   Aufrufers — heutiges Verhalten).
 * `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
 */
export function resolveStrokeWeight(
  override: number | undefined,
  source: AttributeSource | undefined,
  category: LayerCategory | undefined,
  fallback: number,
): number {
  if (override != null) return override;
  if (source === "layer") return category?.lw ?? fallback;
  return fallback;
}

// ── SIA-Poché-Füllung + monochrome Schraffur-Tinte ──────────────────────────
// Die 2D-Poché einer (auch mehrschichtigen) Wand trägt NICHT die rohe Bauteil-
// Albedo (3D-Materialfarbe), sondern einen neutralen Hintergrund, auf dem die
// Schraffur-Musterlinien liegen: Vollmuster ("solid") = dunkle Tinten-Poché
// (kein Linienmuster), jedes andere Muster (inkl. "none") = HELLER Hintergrund.
// Dieselben zwei Werte sind AKTUELL auch die feste Schraffur-Vordergrundfarbe
// (siehe {@link resolveHatch}) — Schnitt- wie Oberflächen-Schraffur zeigen also
// einheitlich Tinte auf hellem Papier, unabhängig von Material-/Bauteilfarbe.
export const HATCH_PAPER = "#f0f0f0";
export const HATCH_INK = "#0f0f0f";
/** Neutrale Poché-Füllfarbe für ein Muster: "solid" → Tinte, sonst Papier. */
function pocheFill(pattern: HatchPattern): string {
  return pattern === "solid" ? HATCH_INK : HATCH_PAPER;
}

/** Füllfarbe + Schraffur eines aufgelösten Bauteils, fertig für eine Cut-Poché. */
export interface SectionCutStyle {
  fill: string;
  hatch: HatchRender;
}

/**
 * Löst das tragende Bauteil (höchste `joinPriority`) einer Wand auf — dieselbe
 * Regel wie {@link backbonePocheFill} für die grobe Grundriss-Poché — und liefert
 * dessen Poché-Füllung + echte Schraffur für die Schnitt-Poché. `null`, wenn Wandtyp
 * oder Bauteil nicht auflösbar sind (z. B. verwaistes `wallTypeId`); der
 * Aufrufer fällt dann auf die generische Schnitt-Schraffur zurück.
 */
export function resolveWallSectionStyle(project: Project, wall: Wall): SectionCutStyle | null {
  try {
    const wt = getWallType(project, wall);
    let best: Component | null = null;
    for (const layer of wt.layers) {
      const comp = getComponent(project, layer.componentId);
      if (!best || comp.joinPriority > best.joinPriority) best = comp;
    }
    if (!best) return null;
    const category = getLayerCategory(project, wall.categoryCode);
    const wallAngleDeg =
      (Math.atan2(wall.end.y - wall.start.y, wall.end.x - wall.start.x) * 180) / Math.PI;
    const hatchId = resolveHatchId(wall.hatchId, wall.hatchSource, category, best.hatchId) ?? best.hatchId;
    const hatch = resolveHatch(
      project,
      hatchId,
      wallAngleDeg,
      resolveForeground(best, wall.foreground, category, wall.foregroundSource),
    );
    // Poché-Hintergrund neutral (Papier) statt Bauteil-Albedo; Vollmuster → Tinte.
    // `hatch.color` ist bereits über {@link resolveHatch} auf HATCH_INK gezwungen.
    const solid = hatch.pattern === "solid";
    return {
      fill: solid ? HATCH_INK : HATCH_PAPER,
      hatch,
    };
  } catch {
    return null;
  }
}

/**
 * Löst das erste Bauteil des Deckentyps einer Decke auf — dieselbe Regel wie
 * {@link addCeilingPoche} für die normale Deckenpoché — und liefert dessen
 * Füllfarbe + echte Schraffur für die Schnitt-Poché. `null`, wenn Deckentyp
 * oder Bauteil nicht auflösbar sind bzw. der Deckentyp keine Schicht trägt.
 */
export function resolveCeilingSectionStyle(
  project: Project,
  ceiling: Ceiling,
): SectionCutStyle | null {
  try {
    const wt = getCeilingType(project, ceiling);
    if (wt.layers.length === 0) return null;
    const comp = getComponent(project, wt.layers[0].componentId);
    const category = getLayerCategory(project, ceiling.categoryCode);
    const hatchId =
      resolveHatchId(ceiling.hatchId, ceiling.hatchSource, category, comp.hatchId) ?? comp.hatchId;
    const hatch = resolveHatch(
      project,
      hatchId,
      undefined,
      resolveForeground(comp, ceiling.foreground, category, ceiling.foregroundSource),
    );
    // Poché-Hintergrund neutral (Papier) statt Bauteil-Albedo; Vollmuster → Tinte
    // — dieselbe Neutralisierung wie {@link resolveWallSectionStyle}, damit die
    // geschnittene Decke nicht als andersfarbige Fläche gegenüber der Wand auffällt.
    const solid = hatch.pattern === "solid";
    return {
      fill: solid ? HATCH_INK : HATCH_PAPER,
      hatch,
    };
  } catch {
    return null;
  }
}

export interface Plan {
  primitives: Primitive[];
  bounds: { minX: number; minY: number; maxX: number; maxY: number };
}

/**
 * Erzeugt den Grundriss eines Geschosses, gefiltert nach sichtbaren Ebenen
 * (Kategorie-Codes).
 *
 * Enthält Wände dieses Geschosses, deren `categoryCode` ∈ `visibleCodes`. Eine
 * Türöffnung gehört zur Wandgeometrie und wird stets ausgespart; das
 * Tür-SYMBOL (Blatt + Bogen) erscheint nur, wenn der Tür-Code sichtbar ist.
 */
export function generatePlan(
  project: Project,
  floorId: string,
  visibleCodes: Set<string>,
  categoryDisplay: CategoryDisplayResolver = SHOW_ALL,
  detail: DetailLevel = "mittel",
  showReferenceLines = false,
  mono = false,
): Plan {
  const primitives: Primitive[] = [];
  // Aktives Geschoss (für die Grundriss-Schnitthöhe der Treppen).
  const floor = project.drawingLevels.find((z) => z.id === floorId);
  // Code → Kategorie-Strichstärke (mm), damit die Wand-Umrisslinie aus dem
  // Modell (Ebene) stammt statt aus einer Magie-Konstante.
  const lwByCode = categoryLwMap(project.layers);
  const colorByCode = categoryColorMap(project.layers);
  // Code → volle LayerCategory, für die By-Layer-Attribut-Auflösung (Quelle
  // "layer" von Vordergrund/Hintergrund/Strichstärke/Schraffur).
  const catByCode = categoryMap(project.layers);

  // ── Grafische Overrides (Regel-Engine, src/overrides/engine.ts) ───────────
  // Effektive Aktionen je Element als OBERSTE Schicht ÜBER der regulären
  // By-Layer/By-Object-Auflösung: die Elemente werden hier — rein zur
  // Renderzeit — mit den Regel-Ergebnissen dekoriert (flache Kopien; das
  // Projekt bleibt unverändert, jederzeit reversibel). `layer_name` matcht
  // Ebenen-Name ODER -Code, `object_name` den Element-/Typ-Namen.
  const overrideFor = (
    categoryCode: string,
    objectName?: string,
  ): OverrideActions =>
    effectiveOverrides(project.overrideRules, {
      layerName: catByCode.get(categoryCode)?.name,
      layerCode: categoryCode,
      objectName,
    });
  const wallTypeNameById = new Map(project.wallTypes.map((t) => [t.id, t.name]));
  const ceilingTypeNameById = new Map(
    (project.ceilingTypes ?? []).map((t) => [t.id, t.name]),
  );

  // Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
  // render=false blendet die Wand aus, greyed=true dimmt sie.
  const walls = project.walls
    .filter(
      (w) =>
        w.floorId === floorId &&
        visibleCodes.has(w.categoryCode) &&
        categoryDisplay(w.categoryCode).render,
    )
    .map((w) => {
      // Regel-Override gewinnt über den Per-Instanz-Wert (oberste Schicht):
      // Farbe → Umriss-/Fugenfarbe (Wall.color), Strichstärke → strokeWeight
      // (der topmost-Wert der bestehenden resolveStrokeWeight-Kette).
      const ov = overrideFor(w.categoryCode, wallTypeNameById.get(w.wallTypeId));
      if (!hasOverrides(ov)) return w;
      return {
        ...w,
        color: ov.color ?? w.color,
        strokeWeight: ov.lineweight ?? w.strokeWeight,
      };
    });

  // Freie 2D-Zeichengeometrie dieses Geschosses (wie Wände nach sichtbaren
  // Kategorien + Darstellungsmodus gefiltert). Wird über der Wand-Poché
  // gezeichnet (am Ende angehängt).
  const drawings = project.drawings2d
    .filter(
      (d) =>
        d.levelId === floorId &&
        visibleCodes.has(d.categoryCode) &&
        categoryDisplay(d.categoryCode).render,
    )
    .map((d) => {
      // `object_name` matcht bei freier 2D-Geometrie den Formnamen
      // (line/polyline/rect/circle/arc/text). Linienstil-Override nur hier —
      // Drawing2D ist das einzige Element mit LineStyle-Referenz.
      const ov = overrideFor(d.categoryCode, d.geom.shape);
      if (!hasOverrides(ov)) return d;
      return {
        ...d,
        color: ov.color ?? d.color,
        weightMm: ov.lineweight ?? d.weightMm,
        lineStyleId: ov.linetypeId ?? d.lineStyleId,
      };
    });

  // Decken dieses Geschosses (wie Wände nach sichtbaren Kategorien +
  // Darstellungsmodus gefiltert). Eine Decke liegt (als Slab) über der
  // Schnittebene → im Grundriss als gefüllter/schraffierter Umriss dargestellt,
  // UNTER der Wand-Poché (damit die Wände darüber lesbar bleiben).
  const ceilings = (project.ceilings ?? [])
    .filter(
      (c) =>
        c.floorId === floorId &&
        visibleCodes.has(c.categoryCode) &&
        categoryDisplay(c.categoryCode).render,
    )
    .map((c) => {
      // Decken: nur der Farb-Override wirkt (der Umriss ist eine feste
      // Überkopf-Haarlinie, siehe addCeilingPoche — keine Strichstärke).
      const typeName =
        (c.ceilingTypeId ? ceilingTypeNameById.get(c.ceilingTypeId) : undefined) ??
        wallTypeNameById.get(c.wallTypeId);
      const ov = overrideFor(c.categoryCode, typeName);
      return ov.color !== undefined ? { ...c, color: ov.color } : c;
    });
  // Wand-Footprints für das Verdecken der (darunterliegenden) Decken-Umrisse:
  // die Decke liegt als Slab über der Schnittebene, ihre Umrisslinie soll dort,
  // wo eine Wand darüber steht, NICHT durch die Wand-Poché schlagen. Nur nötig,
  // wenn es überhaupt Decken gibt.
  const wallFootprints = ceilings.length ? buildWallFootprints(project, walls) : [];
  for (const ceiling of ceilings) {
    const greyed = categoryDisplay(ceiling.categoryCode).greyed;
    const category = catByCode.get(ceiling.categoryCode);
    // Kein lwMm mehr: der Decken-Umriss ist eine feste Überkopf-Haarlinie
    // (gestrichelt), unabhängig von der Kategorie-Strichstärke.
    addCeilingPoche(primitives, project, ceiling, greyed, detail, category, wallFootprints);
  }

  // Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
  // (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände
  // lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
  const rooms = roomsOfFloor(project, floorId)
    .filter(
      (r) =>
        visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render,
    )
    .map((r) => {
      // Räume: `object_name` matcht den Raum-Namen; der Farb-Override färbt
      // Fläche + Stempel (Room.color) um.
      const ov = overrideFor(r.categoryCode, r.name);
      return ov.color !== undefined ? { ...r, color: ov.color } : r;
    });
  for (const room of rooms) {
    const greyed = categoryDisplay(room.categoryCode).greyed;
    addRoomArea(primitives, room, greyed, detail);
  }

  // Gehrungs-Schnittlinien einmal pro Plan berechnen (auf der gefilterten Menge).
  const joins = computeJoins(project, walls);

  for (const wall of walls) {
    const wallGreyed = categoryDisplay(wall.categoryCode).greyed;
    const wallCategory = catByCode.get(wall.categoryCode);
    const wallLwMm = resolveStrokeWeight(
      wall.strokeWeight,
      wall.strokeWeightSource,
      wallCategory,
      lwByCode.get(wall.categoryCode) ?? WALL_FALLBACK_MM,
    );
    // Türen (Legacy) + Öffnungen (Fenster/Türen) am Wandhost: BEIDE sparen die
    // Wand-Poché aus. Aus beiden wird eine gemeinsame Lücken-Intervall-Liste.
    const doors = project.doors.filter((d) => d.hostWallId === wall.id);
    const openings = openingsOfWall(project, wall.id);
    const gaps = wallGaps(wall, doors, openings);
    const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
    // Schnitt vs. Ansicht nach Schnitthöhe (BIM-Standard): erreicht die Wand die
    // Grundriss-Schnittebene nicht (Höhe < Schnitthöhe des Geschosses, z. B. eine
    // Brüstung/ein Podestrand), wird sie nur als Ansichts-Umriss gezeichnet —
    // keine Schnitt-Poché. Normale Wände (Höhe ≥ Schnitthöhe) sind unberührt.
    const wallViewOnly = wall.height < (floor?.cutHeight ?? 1.0);
    addWallPoche(
      primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory, wallViewOnly,
    );
    // Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
    // umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
    if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
    // Tür-Symbol (Legacy-Door) nur, wenn die Kategorie sichtbar ist und der Modus
    // sie zeigt.
    for (const door of doors) {
      const d = categoryDisplay(door.categoryCode);
      if (visibleCodes.has(door.categoryCode) && d.render) {
        const doorLwMm = lwByCode.get(door.categoryCode) ?? LAYER_LINE_MM;
        addDoorSymbol(primitives, wall, door, d.greyed, detail, doorLwMm);
      }
    }
    // Öffnungs-Symbole (Fenster/Tür) je Detailgrad + Auswahl-taggbar.
    for (const o of openings) {
      const d = categoryDisplay(o.categoryCode);
      if (visibleCodes.has(o.categoryCode) && d.render) {
        // Override-Regeln: `object_name` matcht das Öffnungs-Label
        // („Tür 90×210" / „Fenster 120×140").
        const ov = overrideFor(o.categoryCode, openingLabel(o));
        const oLwMm =
          ov.lineweight ?? lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM;
        const effO = ov.color !== undefined ? { ...o, color: ov.color } : o;
        addOpeningSymbol(primitives, project, wall, effO, d.greyed, detail, oLwMm);
      }
    }
  }

  // Treppen dieses Geschosses: Tritte + Lauflinie + Auf-/Abpfeil, mit dem
  // Grundriss-Schnitt (Tritte unter der Schnitthöhe durchgezogen, darüber
  // gestrichelt/verdeckt + SIA-Bruchlinie). Über der Wand-Poché gezeichnet.
  const cutHeight = floor?.cutHeight ?? 1.0;
  const stairs = stairsOfFloor(project, floorId).filter(
    (s) =>
      visibleCodes.has(s.categoryCode) && categoryDisplay(s.categoryCode).render,
  );
  for (const stair of stairs) {
    const greyed = categoryDisplay(stair.categoryCode).greyed;
    // Override-Regeln: `object_name` matcht das Treppen-Label („Gerade 16 STG").
    const ov = overrideFor(stair.categoryCode, stairLabel(stair));
    const lwMm =
      ov.lineweight ?? lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM;
    const effStair = ov.color !== undefined ? { ...stair, color: ov.color } : stair;
    addStairSymbol(primitives, project, effStair, greyed, lwMm, cutHeight);
  }

  // Stützen (Tragwerk) dieses Geschosses: Querschnitt-Poché (Rechteck/Kreis),
  // um `rotation` gedreht, auf Kategorie „50". Über der Wand-Poché gezeichnet.
  const columns = columnsOfFloor(project, floorId).filter(
    (col) =>
      visibleCodes.has(col.categoryCode) && categoryDisplay(col.categoryCode).render,
  );
  for (const column of columns) {
    const greyed = categoryDisplay(column.categoryCode).greyed;
    const category = catByCode.get(column.categoryCode);
    const ov = overrideFor(column.categoryCode, columnLabel(column));
    const lwMm =
      ov.lineweight ?? resolveStrokeWeight(undefined, undefined, category, lwByCode.get(column.categoryCode) ?? WALL_FALLBACK_MM);
    const effCol = ov.color !== undefined ? { ...column, color: ov.color } : column;
    addColumnPoche(primitives, project, effCol, greyed, lwMm, category);
  }

  // 2D-Zeichengeometrie zuletzt (über der Poché).
  for (const d of drawings) {
    const greyed = categoryDisplay(d.categoryCode).greyed;
    addDrawing2D(primitives, project, d, greyed, lwByCode, colorByCode, catByCode);
  }

  // Extrudierte Körper (truck-Integration): das Quell-Profil wurde beim
  // Extrudieren aus `drawings2d` entfernt (s. `extrude.ts` appendExtrudedSolid)
  // — im Grundriss erscheint stattdessen der Footprint-Umriss des Körpers
  // selbst (Haarlinie, kein Poché/Schraffur; Farbe wie das 3D-Orange).
  for (const solid of project.extrudedSolids ?? []) {
    if (solid.levelId === floorId) addExtrudedSolidOutline(primitives, solid);
  }

  // Kontext-Konturen (Höhenlinien) als dezente Linien zeichnen — UNTER der
  // Wand-Poché wäre ideal, aber da sie eine eigene gedämpfte Farbe tragen, ist
  // die Reihenfolge unkritisch; wir hängen sie als Referenz an.
  addContextContours(primitives, project);

  // Schwarz-Weiss (mono): nachträglich alle Farben auf reine Tinte zwingen.
  // Element-/Kategorie-/Hatch-Farben werden ignoriert, Füllungen werden weiss,
  // Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
  if (mono) toMono(primitives);

  return {
    primitives,
    bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms),
  };
}

// ── Schnitt/Ansicht (abgeleitete Projektion aus render3d::section) ──────────────
// Die Rohdaten (Cut-Polygone + sichtbare/verdeckte projizierte Kanten) liefert
// der WASM-Extraktor (plan/toSection.ts → section.rs). Hier werden sie 1:1 in
// die BESTEHENDEN Plan-Primitive übersetzt — kein neues Primitiv nötig:
//   • Cut-Polygon  → `polygon` (kräftige Poché-Umrisslinie + Schnitt-Schraffur),
//   • sichtbare Kante → `line` (durchgezogen),
//   • verdeckte Kante → `line` (gestrichelt).
// Damit rendern PlanView (SVG) UND toRenderScene (Engine/WASM-2D) den Schnitt
// ohne jede Sonderbehandlung, mit voller Papier-mm-/Parität-Infrastruktur.

/** Tinte der Schnitt-Poché-Umrisslinie + Schraffur (dunkles Grau). */
const SECTION_INK = "#1f242c";
/** Tinte der sichtbaren Ansichtskanten. */
const SECTION_VISIBLE_INK = "#2b3039";
/** Tinte der verdeckten (gestrichelten) Ansichtskanten (etwas heller). */
const SECTION_HIDDEN_INK = "#8a929c";
/** Strichstärke der Cut-Poché-Umrisslinie (mm Papier, kräftig wie eine Wand). */
const SECTION_CUT_OUTLINE_MM = 0.35;
/** Strichstärke sichtbarer Ansichtskanten (mm Papier). */
const SECTION_VISIBLE_MM = 0.18;
/** Strichstärke verdeckter Ansichtskanten (mm Papier, dünner). */
const SECTION_HIDDEN_MM = 0.13;
/** Strichmuster verdeckter Kanten (mm Papier). */
const SECTION_HIDDEN_DASH: number[] = [0.12, 0.08];

/**
 * Schnitt-Schraffur der Cut-Flächen (diagonale Poché wie ein Wandschnitt) —
 * Fallback, wenn `toSection.ts` das Quell-Bauteil einer Cut-Fläche NICHT
 * auflösen konnte (siehe `attachCutStyles`). Vordergrund fest `HATCH_INK`,
 * damit auch der Fallback monochrom bleibt statt der rohen 3D-Albedo-Farbe.
 */
const SECTION_HATCH: HatchRender = {
  pattern: "diagonal",
  scale: 1,
  angle: 45,
  color: HATCH_INK,
  lineWeight: 0.13,
  dash: null,
};

/**
 * Baut aus einem {@link SectionOutput} (render3d::section, (u, v)-Meter) den
 * Plan eines Schnitts/einer Ansicht. `u` wird zur Plan-X, `v` (absolute Höhe)
 * zur Plan-Y — die PlanView zeigt Y nach oben, also erscheint der Schnitt
 * aufrecht. Rein darstellend; kennt das Projekt nicht.
 */
export function generateSectionPlan(output: SectionOutput, mono = false): Plan {
  const primitives: Primitive[] = [];
  let minX = Infinity,
    minY = Infinity,
    maxX = -Infinity,
    maxY = -Infinity;
  const acc = (x: number, y: number) => {
    if (x < minX) minX = x;
    if (y < minY) minY = y;
    if (x > maxX) maxX = x;
    if (y > maxY) maxY = y;
  };

  // Cut-Polygone: gefüllte Poché-Fläche + Schnitt-Schraffur + kräftige
  // Umrisslinie — wie eine geschnittene Wand im Grundriss. `toSection.ts`
  // (computeSection) löst je Cut-Polygon bereits das Quell-Bauteil auf und
  // hängt dessen echte Füllfarbe/Schraffur an (`cp.fill`/`cp.hatch`); fehlt
  // diese Auflösung (Fallback, z. B. verwaistes Bauteil), greift die generische
  // Schnitt-Schraffur (`SECTION_HATCH`, HATCH_PAPER-Hintergrund) — NICHT die rohe
  // Albedo-Farbe aus dem 3D-Modell (Nutzer-Vorgabe 2026-07-04: einheitlich
  // monochrom, auch im Fallback-Fall).
  for (const cp of output.cutPolygons) {
    if (cp.pts.length < 3) continue;
    const pts: Vec2[] = cp.pts.map(([u, v]) => {
      acc(u, v);
      return { x: u, y: v };
    });
    const fill = cp.fill ?? HATCH_PAPER;
    const hatch = cp.hatch ?? SECTION_HATCH;
    // Mono: neutrale Poché — Papier statt Bauteilfarbe, Schraffur-Musterlinien →
    // Tinte. Eine dunkle SIA-Vollpoché ("solid") BLEIBT jedoch dunkel
    // (Tinte bleibt Tinte, Papier bleibt Papier).
    const solid = hatch.pattern === "solid";
    const monoFill = solid ? HATCH_INK : HATCH_PAPER;
    const monoHatch =
      solid ? hatch : hatch.pattern !== "none" ? { ...hatch, color: SECTION_INK } : hatch;
    primitives.push({
      kind: "polygon",
      pts,
      fill: mono ? monoFill : fill,
      stroke: SECTION_INK,
      strokeWidthMm: SECTION_CUT_OUTLINE_MM,
      hatch: mono ? monoHatch : hatch,
    });
  }

  // Verdeckte Kanten zuerst (liegen unter den sichtbaren), gestrichelt.
  for (const e of output.hiddenEdges) {
    acc(e.a[0], e.a[1]);
    acc(e.b[0], e.b[1]);
    primitives.push({
      kind: "line",
      a: { x: e.a[0], y: e.a[1] },
      b: { x: e.b[0], y: e.b[1] },
      cls: "section-hidden",
      weightMm: SECTION_HIDDEN_MM,
      dash: SECTION_HIDDEN_DASH,
      color: SECTION_HIDDEN_INK,
    });
  }

  // Sichtbare Ansichtskanten, durchgezogen.
  for (const e of output.visibleEdges) {
    acc(e.a[0], e.a[1]);
    acc(e.b[0], e.b[1]);
    primitives.push({
      kind: "line",
      a: { x: e.a[0], y: e.a[1] },
      b: { x: e.b[0], y: e.b[1] },
      cls: "section-visible",
      weightMm: SECTION_VISIBLE_MM,
      color: SECTION_VISIBLE_INK,
    });
  }

  if (!isFinite(minX)) return { primitives, bounds: { minX: 0, minY: 0, maxX: 1, maxY: 1 } };
  return { primitives, bounds: { minX, minY, maxX, maxY } };
}

/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */
const CONTEXT_LINE_COLOR = "#9aa3ad";
/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */
const CONTEXT_LINE_MM = 0.1;

/**
 * Zeichnet die Konturen (Höhenlinien) der Kontext-Schicht `project.context` als
 * dünne, gedämpfte Referenz-Linien in den Grundriss. Die Z-Höhe wird ignoriert
 * (reine 2D-Projektion). TerrainMeshes/ImportedMeshes erscheinen im Plan NICHT
 * (3D genügt). Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt.
 */
function addContextContours(out: Primitive[], project: Project): void {
  const context = project.context ?? [];
  for (const obj of context) {
    if (obj.type !== "contourSet") continue;
    for (const c of obj.contours) {
      const pts = c.pts;
      if (pts.length < 2) continue;
      const segEnd = c.closed ? pts.length : pts.length - 1;
      for (let i = 0; i < segEnd; i++) {
        out.push({
          kind: "line",
          a: pts[i],
          b: pts[(i + 1) % pts.length],
          cls: "context-line",
          weightMm: CONTEXT_LINE_MM,
          color: CONTEXT_LINE_COLOR,
        });
      }
    }
  }
}

/**
 * Post-Pass für den Schwarz-Weiss-Modus: ersetzt alle Farb-Informationen der
 * Plan-Primitive durch einheitliche Tinte/Weiss. Wirkt in-place.
 * - Polygone: Füllung → weiss, Umriss → Tinte, Schraffur-Musterlinien → Tinte
 *   (Vollfüllung „solid" wird zu Weiss neutralisiert, damit keine Poché-Farbe).
 * - Linien: explizite Strichfarbe → Tinte (CSS-Klassen sind bereits neutral).
 * - Bögen tragen keine Farbe (rein CSS, neutral) → unverändert.
 */
function toMono(primitives: Primitive[]): void {
  for (const p of primitives) {
    if (p.kind === "polygon") {
      // SIA-Poché in mono: Vollmuster „solid" ist eine SCHWARZE Poché und bleibt
      // schwarz (Schwarz bleibt Schwarz); jede andere Füllung wird weiß, die
      // Linien-/Kreuzschraffur bleibt sichtbar, aber in Tinte.
      if (p.hatch.pattern === "solid") {
        p.hatch = { ...p.hatch, color: HATCH_INK };
        p.fill = p.fill === "none" ? "none" : HATCH_INK;
      } else {
        if (p.hatch.pattern !== "none") p.hatch = { ...p.hatch, color: MONO_INK };
        p.fill = p.fill === "none" ? "none" : "#ffffff";
      }
      p.stroke = MONO_INK;
    } else if (p.kind === "line") {
      p.color = MONO_INK;
    } else if (p.kind === "text") {
      p.color = MONO_INK;
    }
  }
}

/** Baut eine Map Kategorie-Code → Strichstärke (mm) über den ganzen Baum. */
function categoryLwMap(layers: LayerCategory[]): Map<string, number> {
  const map = new Map<string, number>();
  for (const c of flattenCategories(layers)) map.set(c.code, c.lw);
  return map;
}

/** Baut eine Map Kategorie-Code → Farbe über den ganzen Baum. */
function categoryColorMap(layers: LayerCategory[]): Map<string, string> {
  const map = new Map<string, string>();
  for (const c of flattenCategories(layers)) map.set(c.code, c.color);
  return map;
}

/**
 * Baut eine Map Kategorie-Code → volle LayerCategory über den ganzen Baum, für
 * die By-Layer-Attribut-Auflösung (Quelle "layer" von Vordergrund/Hintergrund/
 * Strichstärke/Schraffur, siehe {@link resolveForeground} und Geschwister).
 */
function categoryMap(layers: LayerCategory[]): Map<string, LayerCategory> {
  const map = new Map<string, LayerCategory>();
  for (const c of flattenCategories(layers)) map.set(c.code, c);
  return map;
}

/** Footprint-Farbe der truck-Extrusionen im Grundriss (identisch zum 3D-Orange EXTRUSION_RGB in toWalls3d.ts). */
const EXTRUSION_STROKE = "#d98c40";

/**
 * Footprint-Umriss eines extrudierten Körpers (truck-Integration) im Grundriss:
 * reine Haarlinie ohne Füllung/Schraffur — analog zur Ansichtslinie einer Wand
 * unter der Schnitthöhe ({@link addWallPoche} `viewOnly`), da der Körper (noch)
 * keine Schnitt-Poché/Kategorie-Zuordnung hat.
 */
function addExtrudedSolidOutline(out: Primitive[], solid: ExtrudedSolid): void {
  out.push({
    kind: "polygon",
    pts: solid.points,
    fill: "none",
    stroke: EXTRUSION_STROKE,
    strokeWidthMm: SYMBOL_HAIRLINE_MM,
    hatch: NO_HATCH,
    extrudedSolidId: solid.id,
  });
}

/** Fallback-Poché-Füllung einer Stütze ohne (auflösbares) Bauteil: neutrale Tinte. */
const COLUMN_FALLBACK_FILL = "#3a3f47";

/**
 * Grundriss-Querschnitt einer Stütze (Column) als Schnitt-Poché: gefülltes
 * Polygon (Rechteck bzw. tesselliertes Kreisprofil), an `position` platziert und
 * um `rotation` gedreht. Trägt — sofern ein Bauteil (`componentId`) auflösbar ist
 * — dessen Füllung + Schraffur (dieselbe Auflösung wie Wand/Decke, {@link
 * resolveWallSectionStyle}); sonst eine neutrale Vollton-Poché. `columnId` macht
 * die Fläche links-klick-selektierbar (analog Extrusion/Raum).
 */
function addColumnPoche(
  out: Primitive[],
  project: Project,
  column: Column,
  greyed: boolean,
  lwMm: number,
  category: LayerCategory | undefined,
): void {
  const pts = columnFootprint(column);
  const stroke = column.color ?? category?.color ?? MONO_INK;
  let fill = column.color ?? category?.color ?? COLUMN_FALLBACK_FILL;
  let hatch: HatchRender = NO_HATCH;
  if (column.componentId) {
    try {
      const comp = getComponent(project, column.componentId);
      const hatchId =
        resolveHatchId(undefined, undefined, category, comp.hatchId) ?? comp.hatchId;
      const resolved = resolveHatch(
        project,
        hatchId,
        undefined,
        resolveForeground(comp, undefined, category, undefined),
      );
      hatch = resolved;
      fill = resolved.pattern === "solid" ? HATCH_INK : HATCH_PAPER;
    } catch {
      // Bauteil nicht auflösbar → neutrale Poché (Fallback bleibt bestehen).
    }
  }
  out.push({
    kind: "polygon",
    pts,
    fill,
    stroke,
    strokeWidthMm: lwMm,
    hatch,
    greyed,
    columnId: column.id,
  });
}

/**
 * Leitet ein 2D-Zeichenelement in Plan-Primitive ab (docs/design/drawing-tools.md
 * §7.2). Farbe/Strichstärke kommen aus dem optionalen LineStyle, sonst aus der
 * Kategorie. Phase 1 deckt line/polyline/rect ab (alles → line-Primitive);
 * circle/arc/text folgen mit den Primitive-Erweiterungen (Phase 3).
 */
function addDrawing2D(
  out: Primitive[],
  project: Project,
  d: Drawing2D,
  greyed: boolean,
  lwByCode: Map<string, number>,
  colorByCode: Map<string, string>,
  catByCode: Map<string, LayerCategory>,
): void {
  const category = catByCode.get(d.categoryCode);
  const ls = d.lineStyleId
    ? project.lineStyles.find((l) => l.id === d.lineStyleId)
    : undefined;
  // Plan-Tinte fix dunkel: Fallback auf MONO_INK (statt hell „#d0d0d0"), damit
  // ein kategorieloses 2D-Element auf dem hellen Papier sichtbar bleibt.
  const color = d.color ?? ls?.color ?? colorByCode.get(d.categoryCode) ?? MONO_INK;
  // Strichstärke: "layer" erzwingt die Ebenen-Strichstärke (umgeht das
  // LineStyle-Gewicht), sonst (Default "object") die heutige Kette.
  const weightMm = resolveStrokeWeight(
    d.weightMm,
    d.strokeWeightSource,
    category,
    ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM,
  );
  const dash = ls?.dash ?? null;
  // Drawing2D trägt kein Bauteil → die By-Layer-Quelle ist hier die einzige
  // Alternative zum expliziten Wert (kein Component-Fallback); Default
  // "object"/undefined bleibt exakt das heutige Verhalten.
  const foreground = d.foreground ?? (d.foregroundSource === "layer" ? category?.color : undefined);
  const background = d.background ?? (d.backgroundSource === "layer" ? category?.color : undefined);
  const hatchId = resolveHatchId(d.hatchId, d.hatchSource, category, undefined);
  // Zickzack-Parameter aus dem LineStyle durchreichen (nur kind==="zigzag").
  // Additiv: fehlt es, bleibt die Linie gerade/gestrichelt wie bisher.
  const zigzag = ls?.kind === "zigzag" ? ls.zigzag : undefined;
  // Custom-Motiv analog durchreichen (nur kind==="custom").
  const motif = ls?.kind === "custom" ? ls.motif : undefined;
  const seg = (a: Vec2, b: Vec2) =>
    out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, zigzag, motif, color, greyed, drawingId: d.id });

  /**
   * Gefüllte Fläche einer GESCHLOSSENEN Form: trägt die Vollton-Füllfarbe
   * (`d.fillColor`, sonst transparent) UND die optionale Füllschraffur
   * (`d.hatchId`, sonst NO_HATCH). Wird VOR den Strichen gepusht, damit die
   * Umrisslinien oben liegen. Trägt `drawingId`, sodass ein Klick auf die Fläche
   * das Element selektiert (analog zu den Linien).
   */
  const fillPoly = (pts: Vec2[]) =>
    out.push({
      kind: "polygon",
      pts,
      // Hintergrund-Override (Wert ?? By-Layer) hat Vorrang vor dem Alt-Feld
      // fillColor (alle leer = transparente Fläche, nur Schraffur) — Sample
      // bleibt identisch (kein Element setzt background/backgroundSource).
      fill: background ?? d.fillColor ?? "none",
      // Umriss zeichnen die separaten Linien-Primitive (mit Strichmuster); die
      // Füllfläche selbst bleibt randlos, um doppelte Striche zu vermeiden.
      stroke: "none",
      strokeWidthMm: 0,
      // Drawing2D trägt kein Bauteil → Vordergrund/Schraffur-Override direkt aus
      // d.foreground/d.hatchId bzw. der Ebene (Attribut „Nach System" ⇒
      // undefined ⇒ HatchStyle.color-Fallback bzw. NO_HATCH).
      hatch: hatchId ? resolveHatch(project, hatchId, undefined, foreground) : NO_HATCH,
      greyed,
      drawingId: d.id,
    });

  const g = d.geom;
  switch (g.shape) {
    case "line":
      seg(g.a, g.b);
      break;
    case "polyline": {
      // Geschlossene Polylinie: zuerst die gefüllte Fläche (Vollton/Schraffur).
      if (g.closed && g.pts.length > 2) fillPoly(g.pts);
      for (let i = 0; i < g.pts.length - 1; i++) seg(g.pts[i], g.pts[i + 1]);
      if (g.closed && g.pts.length > 2) seg(g.pts[g.pts.length - 1], g.pts[0]);
      break;
    }
    case "rect": {
      const c1 = g.min;
      const c2 = { x: g.max.x, y: g.min.y };
      const c3 = g.max;
      const c4 = { x: g.min.x, y: g.max.y };
      // Rechteck ist immer geschlossen → zuerst die gefüllte Fläche.
      fillPoly([c1, c2, c3, c4]);
      seg(c1, c2);
      seg(c2, c3);
      seg(c3, c4);
      seg(c4, c1);
      break;
    }
    case "circle":
      // Echtes Kreis-Primitiv → glattes `<circle>` (rund bei jedem Zoom) mit
      // optionaler Vollton-Füllung. Ersetzt die frühere 64-Ecken-Approximation.
      out.push({
        kind: "drawingCircle",
        center: g.center,
        r: g.r,
        fill: background ?? d.fillColor ?? "none",
        stroke: color,
        weightMm,
        dash,
        greyed,
        drawingId: d.id,
      });
      break;
    case "arc":
      // Echtes Bogen-Primitiv → glatter SVG-Bogenpfad (Winkel in Radiant).
      out.push({
        kind: "drawingArc",
        center: g.center,
        r: g.r,
        a0: g.a0,
        a1: g.a1,
        stroke: color,
        weightMm,
        dash,
        greyed,
        drawingId: d.id,
      });
      break;
    case "text":
      out.push({
        kind: "drawingText",
        at: g.at,
        text: g.text,
        heightM: g.height,
        angle: g.angle,
        color,
        ...(g.width && g.width > 0 ? { wrapWidth: g.width } : {}),
        greyed,
        drawingId: d.id,
      });
      break;
    default:
      break;
  }
}

/**
 * Wand als gefüllte Schnittfläche (Poché), um Öffnungen ausgespart.
 *
 * Detailgrad:
 *  • mittel/fein — jede Schicht des Wandtyps als eigenes Band (Füllfarbe +
 *    Schraffur des Bauteils), dazu eine kräftige Wand-Umrisslinie. fein zeichnet
 *    Schichtfugen und Umriss dünner als mittel.
 *  • grob — KEINE Schichten, KEINE Schraffur: eine einzige Sammelfläche über die
 *    volle Wanddicke (Füllung = Farbe des „Backbone"-Bauteils mit höchster
 *    joinPriority) mit dicker Umrisslinie. Bewusst vereinfacht (≙ DOSSIER
 *    „Einfach").
 */
/**
 * Ein Öffnungs-Intervall entlang der Wandachse (from..to in Metern). `headHeight`
 * = Sturzhöhe (OK der Öffnung über der Wand-UK) für die 3D-Aussparung; im Plan
 * ungenutzt, aber Teil des gemeinsamen Vertrags mit dem 3D-Pfad.
 */
export interface WallGap {
  from: number;
  to: number;
}

/**
 * Vereint Legacy-Türen (`Door`) und Öffnungen (`Opening`) einer Wand zu einer
 * sortierten Lücken-Intervall-Liste entlang der Wandachse. Entartete Intervalle
 * (Breite ≤ 0 oder außerhalb der Achse) werden verworfen.
 */
export function wallGaps(
  wall: Wall,
  doors: Project["doors"],
  openings: Opening[],
): WallGap[] {
  const gaps: WallGap[] = [];
  for (const d of doors) gaps.push({ from: d.position, to: d.position + d.width });
  for (const o of openings) {
    const iv = openingInterval(wall, o);
    if (iv) gaps.push(iv);
  }
  return gaps.sort((a, b) => a.from - b.from);
}

/** Toleranz für die Achsen-Intervall-Arithmetik der Durchgangswand-Aussparungen. */
const CUTOUT_EPS = 1e-9;

/**
 * Zieht die Loch-Intervalle `holes` vom Segment `[s,e]` ab und liefert die
 * verbleibenden (gezeichneten) Teil-Intervalle in Achsen-Metern. Löcher werden
 * auf `[s,e]` geklemmt, sortiert und vereinigt; leeres Ergebnis heisst „ganz
 * ausgespart". Ist `holes` leer, kommt exakt `[[s,e]]` zurück (unverändertes
 * Verhalten für Wände ohne Aussparung).
 */
function subtractIntervals(
  s: number,
  e: number,
  holes: Array<[number, number]>,
): Array<[number, number]> {
  const clipped = holes
    .map(([a, b]): [number, number] => [
      Math.max(s, Math.min(a, b)),
      Math.min(e, Math.max(a, b)),
    ])
    .filter(([a, b]) => b > a + CUTOUT_EPS)
    .sort((p, q) => p[0] - q[0]);
  const parts: Array<[number, number]> = [];
  let cursor = s;
  for (const [a, b] of clipped) {
    if (a > cursor + CUTOUT_EPS) parts.push([cursor, a]);
    if (b > cursor) cursor = b;
  }
  if (cursor < e - CUTOUT_EPS) parts.push([cursor, e]);
  return parts;
}

/**
 * Wand-Footprint als orientiertes Rechteck (OBB) für das Verdecken der
 * darunterliegenden Decke im Grundriss: Achs-Startpunkt + Einheits-Richtung `u`
 * und -Normale `m`, Halbdicke, Referenz-Querversatz (Wandachse↔Bandmitte) und
 * Achslänge. Die u-Ausdehnung wird beim Test an beiden Enden um die Halbdicke
 * verlängert, damit auch die Eck-Überlappe (Gehrungen) sicher abgedeckt sind.
 */
interface WallFootprint {
  a: Vec2;
  u: Vec2;
  m: Vec2;
  len: number;
  half: number;
  refOff: number;
}

/** Baut die Wand-Footprints (OBBs) aus den gerenderten Wänden. Nicht auflösbarer
 *  Wandtyp ⇒ Default-Dicke 0.2 m (wie der Vollkörper-Fallback im 3D-Emitter). */
function buildWallFootprints(project: Project, walls: Wall[]): WallFootprint[] {
  const out: WallFootprint[] = [];
  for (const w of walls) {
    let t = 0.2;
    try {
      t = wallTypeThickness(getWallType(project, w));
    } catch {
      // Default-Dicke behalten.
    }
    const u = normalize(sub(w.end, w.start));
    out.push({
      a: w.start,
      u,
      m: leftNormal(u),
      len: Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y),
      half: t / 2,
      refOff: wallReferenceOffset(w, t),
    });
  }
  return out;
}

/**
 * Parametrisches Intervall [t0,t1] ⊆ [0,1], über das die Strecke `pa→pb`
 * INNERHALB des Wand-Footprints liegt (also von der Wand verdeckt). `null`, wenn
 * die Strecke den Footprint nicht schneidet. OBB-Test über zwei Slabs: Projektion
 * auf die Achse `u` (Bereich [−half, len+half]) und auf die Normale `m` (Bereich
 * [refOff−half, refOff+half]); das Schnitt-Intervall beider Slabs ∩ [0,1] ist die
 * verdeckte Strecke.
 */
function segmentInsideWallBand(
  pa: Vec2,
  pb: Vec2,
  fp: WallFootprint,
): [number, number] | null {
  const d = sub(pb, pa);
  const rel = sub(pa, fp.a);
  const clipSlab = (
    f0: number,
    fd: number,
    lo: number,
    hi: number,
  ): [number, number] | null => {
    if (Math.abs(fd) < 1e-12)
      return f0 >= lo - CUTOUT_EPS && f0 <= hi + CUTOUT_EPS ? [0, 1] : null;
    let ta = (lo - f0) / fd;
    let tb = (hi - f0) / fd;
    if (ta > tb) [ta, tb] = [tb, ta];
    return [ta, tb];
  };
  const su = clipSlab(dot(rel, fp.u), dot(d, fp.u), -fp.half, fp.len + fp.half);
  if (!su) return null;
  const sm = clipSlab(
    dot(rel, fp.m),
    dot(d, fp.m),
    fp.refOff - fp.half,
    fp.refOff + fp.half,
  );
  if (!sm) return null;
  const t0 = Math.max(0, su[0], sm[0]);
  const t1 = Math.min(1, su[1], sm[1]);
  return t1 > t0 + CUTOUT_EPS ? [t0, t1] : null;
}

function addWallPoche(
  out: Primitive[],
  project: Project,
  wall: Wall,
  gaps: WallGap[],
  cuts: WallCuts,
  greyed: boolean,
  detail: DetailLevel,
  wallLwMm: number,
  category: LayerCategory | undefined,
  // Wand liegt GANZ unter der Grundriss-Schnittebene (z. B. 30-cm-Brüstung bei
  // 1 m Schnitthöhe) → „von oben gesehen": nur Ansichts-Umriss (Haarlinie), KEINE
  // Schnitt-Poché/Schraffur/Schichten. Der reguläre Schnittfall bleibt `false`.
  viewOnly: boolean,
): void {
  const wt = getWallType(project, wall);
  const total = wallTypeThickness(wt);
  // Versatz der Schichtstapelung über die Dicke gemäß Referenzlinie (center=0,
  // left=+T/2, right=−T/2). Alle Band-Offsets unten werden hierum verschoben,
  // sodass die Achse bei „außen"/„innen" auf der jeweiligen Wandfläche liegt.
  const refOff = wallReferenceOffset(wall, total);
  // Jede Schicht-/Umrissfläche trägt die Wand-ID, damit ein Links-Klick die
  // GANZE Wand (alle Bänder) markiert und die PlanView die wallId nach oben
  // melden kann.
  const wallId = wall.id;
  // Achswinkel der Wand (Modell, Grad) — treibt wandbezogene Schraffuren
  // ({@link resolveHatch}), damit z. B. eine Diagonale oder die Dämmungsstriche
  // mit der Wandorientierung mitdrehen.
  const wallAngleDeg =
    (Math.atan2(wall.end.y - wall.start.y, wall.end.x - wall.start.x) * 180) / Math.PI;
  // Strich-/Umrandungsfarbe der Wand: optionale Übersteuerung (wall.color),
  // sonst der Default-Poché-Strich. Betrifft NUR die Linienfarbe (Umriss +
  // Schichtfugen), nicht die Schicht-Füllfarben/Schraffuren.
  const stroke = wall.color ?? POCHE_STROKE;

  // Öffnungs-Intervalle entlang der Wandachse, sortiert (bereits vereint).
  const openings = gaps;

  // Wand in Segmente zwischen den Öffnungen zerlegen.
  let cursor = 0;
  const segments: Array<[number, number]> = [];
  for (const op of openings) {
    if (op.from > cursor) segments.push([cursor, op.from]);
    cursor = Math.max(cursor, op.to);
  }
  const axisLen = length(wall);
  if (cursor < axisLen) segments.push([cursor, axisLen]);

  const outlineMm = wallLwMm * OUTLINE_DETAIL_FACTOR[detail];
  const layerLineMm = LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail];
  // Vereinfachte Füllung (grob): neutrale SIA-Poché des tragenden Bauteils
  // (höchste Priorität) — weiß, bzw. schwarz falls dessen Muster "solid" ist.
  const simpleFill = backbonePocheFill(project, wt);

  // Durchgangswand-Aussparungen (Phase 1c): über diese Achsen-Intervalle × Quer-
  // Offsetbereiche wird die Nah-Putz-Zone der Durchgangswand echt ausgeschnitten
  // (der Abzweig-Kern sticht durch). `offA` ist stets die Nahfläche, `offB` die
  // Rückgrat-Nahfläche (siehe SpanCutout in model/joins.ts). Leer ⇒ heutiges
  // Verhalten (Wand ohne T-Durchstoss).
  const spanCutouts = cuts.spanCutouts ?? [];

  // Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten).
  // clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end)
  // ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche.
  // Diese werden NICHT umrissen: sonst 45°-Naht zwischen gleichfarbigen Schichten
  // + überschießende Gehrungs-Barbe am Eck-Apex. Freie Enden (kein Cut) bleiben
  // rechtwinklig gestrichen. Die Längskanten (Schichtfugen) bleiben stets sichtbar.
  const joinEdges = (startCut: Line | null, endCut: Line | null): number[] => {
    const e: number[] = [];
    if (endCut) e.push(1);
    if (startCut) e.push(3);
    return e;
  };

  for (const [s, e] of segments) {
    const p1 = along(wall.start, wall.end, s);
    const p2 = along(wall.start, wall.end, e);
    // Nur die echten Wandenden bekommen die Gehrung. Türnahe Segmentenden
    // bleiben rechtwinklig (kein Schnitt).
    const startCut = s <= 1e-6 ? cuts.startCut : null;
    const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null;

    if (viewOnly) {
      // Ansichts-Wand (unter der Schnittebene): ein Umriss über die volle Dicke
      // als Haarlinie, ohne Füllung/Schraffur. Segment-/Gehrungs-/Öffnungs-Logik
      // bleibt dieselbe wie im Schnittfall (nur eben ohne Poché).
      out.push({
        kind: "polygon",
        pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
        fill: "none",
        stroke,
        strokeWidthMm: layerLineMm,
        hatch: NO_HATCH,
        greyed,
        noStrokeEdges: joinEdges(startCut, endCut),
        wallId,
      });
      continue;
    }

    if (detail === "grob") {
      // Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss.
      out.push({
        kind: "polygon",
        pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
        fill: simpleFill,
        stroke,
        strokeWidthMm: outlineMm,
        hatch: NO_HATCH,
        greyed,
        noStrokeEdges: joinEdges(startCut, endCut),
        wallId,
      });
      continue;
    }

    // mittel/fein: Schichten von außen (-T/2) nach innen (+T/2) stapeln. Jede
    // Schicht löst Bauteil (Füllfarbe) + Schraffur (Muster/Maßstab/Winkel/Farbe)
    // auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede
    // innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet.
    const bandJoinEdges = joinEdges(startCut, endCut);
    // Nur die Aussparungen, die dieses Segment [s,e] entlang der Achse berühren.
    const segCutouts = spanCutouts.filter(
      (c) =>
        Math.min(c.from, c.to) < e - CUTOUT_EPS &&
        Math.max(c.from, c.to) > s + CUTOUT_EPS,
    );
    // Punkt entlang der Wandachse bei Achsen-Meter `d` (p1/p2 an den Rändern).
    const axisPt = (d: number): Vec2 =>
      d === s ? p1 : d === e ? p2 : along(wall.start, wall.end, d);
    // Materialbewusster T-Stoss (siehe resolveJoinPriority in model/joins.ts):
    // liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den
    // einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe
    // Maskierung wie oben für startCut/endCut, s <= 1e-6 / e >= axisLen-1e-6).
    // Fehlt sie, bleibt alles wie zuvor (startCut/endCut für jede Schicht).
    const layerCuts = cuts.layerCuts;
    let off = refOff - total / 2;
    for (let li = 0; li < wt.layers.length; li++) {
      const layer = wt.layers[li];
      const comp = getComponent(project, layer.componentId);
      const layerHatchId =
        resolveHatchId(wall.hatchId, wall.hatchSource, category, comp.hatchId) ?? comp.hatchId;
      const layerHatch = resolveHatch(
        project,
        layerHatchId,
        wallAngleDeg,
        resolveForeground(comp, wall.foreground, category, wall.foregroundSource),
      );
      // SIA-Poché: neutraler Hintergrund (Papier) statt Bauteil-Albedo, die
      // Schichtschraffur (fest in HATCH_INK, siehe resolveHatch) liegt darüber;
      // Vollmuster ("solid") = Tinten-Poché.
      const layerSolid = layerHatch.pattern === "solid";
      const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null;
      const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : null;
      // Durchgangswand-Aussparung: liegt der Quer-Offsetbereich dieses Bandes
      // [off, off+thickness] im Nah-Putz-Bereich einer Aussparung, wird das Band
      // entlang der Achse in Teil-Bänder VOR/NACH dem Intervall zerlegt (das
      // Durchstoss-Stück entfällt). Ohne Überlappung bleibt genau ein Band
      // [s,e] (unverändertes Verhalten).
      const bandLo = off;
      const bandHi = off + layer.thickness;
      const bandHoles: Array<[number, number]> = [];
      for (const c of segCutouts) {
        const cLo = Math.min(c.offA, c.offB);
        const cHi = Math.max(c.offA, c.offB);
        if (bandLo < cHi - CUTOUT_EPS && bandHi > cLo + CUTOUT_EPS)
          bandHoles.push([c.from, c.to]);
      }
      for (const [a, b] of subtractIntervals(s, e, bandHoles)) {
        const sc = a <= s + CUTOUT_EPS ? layerStartCut : null;
        const ec = b >= e - CUTOUT_EPS ? layerEndCut : null;
        out.push({
          kind: "polygon",
          pts: clippedBand(axisPt(a), axisPt(b), off, off + layer.thickness, sc, ec),
          fill: layerSolid ? HATCH_INK : HATCH_PAPER,
          // Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
          // am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
          stroke: "none",
          strokeWidthMm: 0,
          hatch: layerHatch,
          greyed,
          // Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos).
          noStrokeEdges: bandJoinEdges,
          wallId,
        });
      }
      // L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine
      // verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt
      // zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie
      // begrenzt die Schichtbreite dort, wo der Kern sie unterbricht (L-Form).
      const sideStart = s <= 1e-6 ? layerCuts?.startSide[li] : null;
      const sideEnd = e >= axisLen - 1e-6 ? layerCuts?.endSide[li] : null;
      if (sideStart || sideEnd) {
        const uSeg = normalize(sub(p2, p1));
        const nSeg = leftNormal(uSeg);
        const sidePoint = (base: Vec2, layerOff: number, cut: Line): Vec2 => {
          const pt = add(base, scale(nSeg, layerOff));
          return lineIntersect(pt, uSeg, cut.point, cut.dir) ?? pt;
        };
        if (sideStart) {
          out.push({
            kind: "line",
            a: sidePoint(p1, off, sideStart),
            b: sidePoint(p1, off + layer.thickness, sideStart),
            cls: "layer-joint-l",
            weightMm: layerLineMm,
            color: stroke,
            greyed,
          });
        }
        if (sideEnd) {
          out.push({
            kind: "line",
            a: sidePoint(p2, off, sideEnd),
            b: sidePoint(p2, off + layer.thickness, sideEnd),
            cls: "layer-joint-l",
            weightMm: layerLineMm,
            color: stroke,
            greyed,
          });
        }
      }
      off += layer.thickness;
      // Innere Schichtfuge (zwischen dieser und der nächst-inneren Schicht) als
      // eigene Linie am kumulierten Offset `off`, mit dem Cut der Bänder geclippt.
      // Stil aus layer.jointLineStyleId (Gewicht/Farbe/Strich); Rückfall = Haar-
      // linie (LAYER_LINE_MM) in Wandfarbe. Das Gewicht folgt demselben Detail-
      // Faktor wie die frühere Schichtfuge (`layerLineMm`), damit die Detailgrade
      // sich unverändert verhalten. Die innerste Schicht hat keine innere Fuge.
      // Nutzt bewusst weiterhin den einheitlichen startCut/endCut (nicht die
      // Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB
      // dieser Wand und bleibt daher an der bisherigen Nahfläche.
      if (li < wt.layers.length - 1) {
        const jls = layer.jointLineStyleId
          ? project.lineStyles.find((l) => l.id === layer.jointLineStyleId)
          : undefined;
        // Durchgangswand-Aussparung: liegt die Fuge im (geschlossenen) Nah-Putz-
        // Bereich einer Aussparung — inkl. der Rückgrat-Nahfläche (Merge-Kontakt)
        // —, wird sie über das Durchstoss-Intervall unterbrochen. So bleibt an der
        // Verschmelzungsfläche keine Trennlinie stehen.
        const fugeHoles: Array<[number, number]> = [];
        for (const c of segCutouts) {
          const cLo = Math.min(c.offA, c.offB);
          const cHi = Math.max(c.offA, c.offB);
          if (off >= cLo - CUTOUT_EPS && off <= cHi + CUTOUT_EPS)
            fugeHoles.push([c.from, c.to]);
        }
        for (const [a, b] of subtractIntervals(s, e, fugeHoles)) {
          const sc = a <= s + CUTOUT_EPS ? startCut : null;
          const ec = b >= e - CUTOUT_EPS ? endCut : null;
          // clippedBand(pa, pb, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
          // edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
          // (Ende), beide am Cut abgeschnitten wie die Bandlängskante.
          const jq = clippedBand(axisPt(a), axisPt(b), off, off, sc, ec);
          out.push({
            kind: "line",
            a: jq[0],
            b: jq[1],
            cls: "layer-joint",
            weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
            color: jls ? jls.color : stroke,
            dash: jls ? jls.dash : null,
            // Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
            zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
            // Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen.
            motif: jls?.kind === "custom" ? jls.motif : undefined,
            greyed,
          });
        }
      }
    }
    // Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung),
    // damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs-
    // Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe.
    // Bei einer Durchgangswand-Aussparung wird zusätzlich die NAHFLÄCHEN-Kante
    // (die Längskante auf der Durchstoss-Seite) über [from,to] strokelos gemacht
    // und als separate Segmente vor/nach dem Durchstoss gezeichnet — sonst liefe
    // die Umrisslinie über die Durchstoss-Breite.
    const offLo = refOff - total / 2;
    const offHi = refOff + total / 2;
    const holesHi: Array<[number, number]> = [];
    const holesLo: Array<[number, number]> = [];
    for (const c of segCutouts) {
      // `offA` ist stets die Nahfläche (Wandoberfläche); ordne die Aussparung der
      // passenden Längskante (offHi bzw. offLo) zu.
      if (Math.abs(c.offA - offHi) < 1e-6) holesHi.push([c.from, c.to]);
      else if (Math.abs(c.offA - offLo) < 1e-6) holesLo.push([c.from, c.to]);
    }
    const outlineNoStroke = [...bandJoinEdges];
    // clippedBand-Kantenreihenfolge [A.start(offLo), A.end(offLo), B.end(offHi),
    // B.start(offHi)]: Kante 0 ist die offLo-Längskante, Kante 2 die offHi-Längskante.
    if (holesHi.length) outlineNoStroke.push(2);
    if (holesLo.length) outlineNoStroke.push(0);
    out.push({
      kind: "polygon",
      pts: clippedBand(p1, p2, offLo, offHi, startCut, endCut),
      fill: "none",
      stroke,
      strokeWidthMm: outlineMm,
      hatch: NO_HATCH,
      greyed,
      noStrokeEdges: outlineNoStroke,
      wallId,
    });
    const drawNearEdge = (edgeOff: number, holes: Array<[number, number]>): void => {
      for (const [a, b] of subtractIntervals(s, e, holes)) {
        const sc = a <= s + CUTOUT_EPS ? startCut : null;
        const ec = b >= e - CUTOUT_EPS ? endCut : null;
        const jq = clippedBand(axisPt(a), axisPt(b), edgeOff, edgeOff, sc, ec);
        out.push({
          kind: "line",
          a: jq[0],
          b: jq[1],
          cls: "wall-outline",
          weightMm: outlineMm,
          color: stroke,
          greyed,
        });
      }
    };
    if (holesHi.length) drawNearEdge(offHi, holesHi);
    if (holesLo.length) drawNearEdge(offLo, holesLo);
  }
}

/**
 * Neutrale SIA-Poché-Füllung des tragenden Bauteils (höchste joinPriority) für
 * die grobe Sammelfläche: Papier, bzw. Tinte falls dessen Schraffur "solid" ist.
 * (Ersetzt die frühere rohe Bauteil-Albedo — im Plan zählt die Poché, nicht die
 * 3D-Materialfarbe.)
 */
function backbonePocheFill(project: Project, wt: Project["wallTypes"][number]): string {
  let best: { pattern: HatchPattern; prio: number } | null = null;
  for (const layer of wt.layers) {
    const comp = getComponent(project, layer.componentId);
    if (!best || comp.joinPriority > best.prio)
      best = { pattern: getHatch(project, comp.hatchId).pattern, prio: comp.joinPriority };
  }
  return best ? pocheFill(best.pattern) : HATCH_PAPER;
}

/**
 * Tür-Symbol je Detailgrad:
 *  • grob   — nur die gerade Türblatt-Linie (die Öffnungs-Lücke kommt aus der
 *             ausgesparten Poché).
 *  • mittel — + Schwenkbogen (geschlossen → offen).
 *  • fein   — + Rahmen/Anschlag-Striche an beiden Pfosten (kurze Striche quer
 *             zur Wand, die den Türrahmen/-anschlag andeuten).
 */
function addDoorSymbol(
  out: Primitive[],
  wall: Wall,
  door: Project["doors"][number],
  greyed: boolean,
  detail: DetailLevel,
  _doorLwMm: number,
): void {
  const u = normalize(sub(wall.end, wall.start));
  const n = leftNormal(u); // linke Seite der Wandachse
  const swingDir = door.swing === "left" ? n : scale(n, -1);

  const jambStart = along(wall.start, wall.end, door.position);
  const jambEnd = along(wall.start, wall.end, door.position + door.width);
  const hinge = door.hinge === "start" ? jambStart : jambEnd;
  const closedDir = door.hinge === "start" ? u : scale(u, -1);

  const openEnd = add(hinge, scale(swingDir, door.width));
  const closedEnd = add(hinge, scale(closedDir, door.width));

  // Türblatt in geöffneter Stellung (alle Stufen).
  out.push({
    kind: "line",
    a: hinge,
    b: openEnd,
    cls: "door-leaf",
    weightMm: SYMBOL_HAIRLINE_MM,
    greyed,
  });

  // mittel + fein: Schwenkbogen von geschlossen → offen.
  if (detail !== "grob") {
    out.push({
      kind: "arc",
      center: hinge,
      from: closedEnd,
      to: openEnd,
      r: door.width,
      cls: "door-swing",
      weightMm: SYMBOL_HAIRLINE_MM,
      greyed,
    });
  }

  // fein: Rahmen/Anschlag-Striche an beiden Pfosten (kurz quer zur Wand,
  // symmetrisch um die Achse über eine halbe Wanddicke-Andeutung).
  if (detail === "fein") {
    const reveal = 0.06; // 6 cm Anschlag-Andeutung je Seite
    for (const jamb of [jambStart, jambEnd]) {
      out.push({
        kind: "line",
        a: add(jamb, scale(n, reveal)),
        b: add(jamb, scale(n, -reveal)),
        cls: "door-frame",
        weightMm: SYMBOL_HAIRLINE_MM,
        greyed,
      });
    }
  }
}

/**
 * Öffnungs-Symbol (Fenster/Tür) je Detailgrad. Anders als das Legacy-`addDoorSymbol`
 * arbeitet diese Funktion über die reinen Geometrie-Helfer (opening.ts) und taggt
 * ihre Primitive mit `openingId`, sodass die Öffnung im Plan selektierbar ist.
 *
 * Tür:
 *  • grob   — gerade Türblatt-Linie (die Lücke kommt aus der ausgesparten Poché).
 *  • mittel — + Schwenkbogen (Öffnungswinkel).
 *  • fein   — + Anschlag-Striche an beiden Pfosten.
 * Fenster:
 *  • grob   — eine einzelne Glaslinie über die Lücke.
 *  • mittel — Rahmen-Rechteck (Umriss) + eine Glaslinie.
 *  • fein   — Rahmen + zwei Glaslinien (Doppelverglasung angedeutet).
 */
function addOpeningSymbol(
  out: Primitive[],
  project: Project,
  wall: Wall,
  o: Opening,
  greyed: boolean,
  detail: DetailLevel,
  lwMm: number,
): void {
  if (o.kind === "door") {
    const sym = doorSymbol(wall, o);
    if (!sym) return;
    // Unsichtbares Pick-Polygon über der Öffnungslücke (Laibung × Wanddicke) —
    // damit die Tür wie ein Fenster per Punkt-in-Polygon über die ganze
    // Öffnungsfläche selektierbar ist (Türsymbol besteht sonst nur aus dünnem
    // Blatt + Bogen, die man exakt treffen müsste). fill/stroke "none" ⇒ zeichnet
    // nichts; `pickOpening` (PlanView) testet nur die `pts`.
    const gap = openingGapQuad(project, wall, o);
    if (gap) {
      out.push({
        kind: "polygon",
        pts: gap,
        fill: "none",
        stroke: "none",
        strokeWidthMm: 0,
        hatch: NO_HATCH,
        greyed,
        openingId: o.id,
      });
    }
    // Tür-Typ „wandoeffnung": reiner Wanddurchbruch — KEIN Türblatt, KEIN
    // Schwenkbogen. Nur die Öffnung/Laibung bleibt (die Wandlücke kommt aus der
    // ausgesparten Poché; Sturz-/Anschlaglinien werden weiter unten gezeichnet).
    // Entspricht `oeff_tuer_typ == "wandoeffnung"` im Rhino-Plugin.
    const isWandoeffnung = (o.doorType ?? "normal") === "wandoeffnung";
    if (!isWandoeffnung) {
      // Türblatt (alle Stufen).
      out.push({
        kind: "line",
        a: sym.hinge,
        b: sym.openEnd,
        cls: "door-leaf",
        weightMm: SYMBOL_HAIRLINE_MM,
        greyed,
        openingId: o.id,
      });
      // mittel + fein: Schwenkbogen von geschlossen → offen.
      if (detail !== "grob") {
        out.push({
          kind: "arc",
          center: sym.hinge,
          from: sym.closedEnd,
          to: sym.openEnd,
          r: sym.radius,
          cls: "door-swing",
          weightMm: SYMBOL_HAIRLINE_MM,
          greyed,
          openingId: o.id,
        });
      }
    }
    // fein: Anschlag-Striche an beiden Pfosten (quer zur Wand).
    if (detail === "fein") {
      const reveal = 0.06;
      for (const jamb of [sym.jambStart, sym.jambEnd]) {
        out.push({
          kind: "line",
          a: add(jamb, scale(sym.normal, reveal)),
          b: add(jamb, scale(sym.normal, -reveal)),
          cls: "door-frame",
          weightMm: SYMBOL_HAIRLINE_MM,
          greyed,
          openingId: o.id,
        });
      }
    }
    // Sturzlinien (SIA, gestrichelt) — Überkopf-Projektion des Sturzes quer über
    // die Öffnung an der Wand-Innen-/Aussenkante. Entspricht `_make_tuer_sturz_curves`
    // im Rhino-Plugin. Default „beide" wenn nicht explizit gesetzt.
    const lintelMode = o.lintelLines ?? "beide";
    if (lintelMode !== "keine") {
      const jambs = openingJambs(wall, o);
      if (jambs) {
        const { n } = wallAxisFrame(wall);
        const total = wallTypeThickness(getWallType(project, wall));
        const refOff = wallReferenceOffset(wall, total);
        const halfD = total / 2;
        const outerOff = refOff + halfD;
        const innerOff = refOff - halfD;
        // Strichmuster: gestrichelt (Überkopf-Projektion, SIA-Konvention).
        const dash: number[] = [0.18, 0.09];
        const strokeColor = o.color ?? POCHE_STROKE;
        /** Linie quer über die Öffnung am gegebenen Perp-Offset. */
        const lintelLine = (perpOff: number) => ({
          a: add(jambs.jambStart, scale(n, perpOff)),
          b: add(jambs.jambEnd,   scale(n, perpOff)),
        });
        if (lintelMode === "aussen" || lintelMode === "beide") {
          const seg = lintelLine(outerOff);
          out.push({
            kind: "line" as const,
            a: seg.a,
            b: seg.b,
            cls: "door-lintel",
            weightMm: SYMBOL_HAIRLINE_MM,
            dash,
            color: strokeColor,
            greyed,
            openingId: o.id,
          });
        }
        if (lintelMode === "innen" || lintelMode === "beide") {
          const seg = lintelLine(innerOff);
          out.push({
            kind: "line" as const,
            a: seg.a,
            b: seg.b,
            cls: "door-lintel",
            weightMm: SYMBOL_HAIRLINE_MM,
            dash,
            color: strokeColor,
            greyed,
            openingId: o.id,
          });
        }
      }
    }
    return;
  }

  // Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad.
  const glassCount = detail === "fein" ? 2 : 1;
  const sym = windowSymbol(project, wall, o, glassCount);
  if (!sym) return;
  // Unsichtbares Pick-Polygon über der Öffnungslücke — macht das Fenster über
  // die ganze Fläche selektierbar, auch bei „grob" (nur eine Glaslinie, sonst
  // schwer zu treffen). Siehe Tür-Zweig oben.
  const wgap = openingGapQuad(project, wall, o);
  if (wgap) {
    out.push({
      kind: "polygon",
      pts: wgap,
      fill: "none",
      stroke: "none",
      strokeWidthMm: 0,
      hatch: NO_HATCH,
      greyed,
      openingId: o.id,
    });
  }
  if (detail !== "grob") {
    // Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung).
    out.push({
      kind: "polygon",
      pts: sym.frame,
      fill: "none",
      stroke: o.color ?? POCHE_STROKE,
      strokeWidthMm: lwMm,
      hatch: NO_HATCH,
      greyed,
      openingId: o.id,
    });
    // Flügel-Mittelpfosten (wingCount − 1) — reine Querlinien innerhalb des
    // Rahmens (kein Strichmuster). Nur mittel/fein; entfällt bei grob/schema.
    for (const [a, b] of sym.mullionLines) {
      out.push({
        kind: "line",
        a,
        b,
        cls: "window-mullion",
        weightMm: SYMBOL_HAIRLINE_MM,
        color: o.color ?? POCHE_STROKE,
        greyed,
        openingId: o.id,
      });
    }
  }
  for (const [a, b] of sym.glassLines) {
    out.push({
      kind: "line",
      a,
      b,
      cls: "window-glass",
      weightMm: SYMBOL_HAIRLINE_MM,
      greyed,
      openingId: o.id,
    });
  }
  // fein: Anschlag-/Laibungsstriche an beiden Pfosten (kurz quer zur Wand) —
  // analog zur Tür, damit Fenster im Detailplan gleich „fertig" wirken (bisher
  // hatten nur Türen sie). Haarlinie, 6 cm Andeutung je Seite.
  if (detail === "fein") {
    const jambs = openingJambs(wall, o);
    const dir = sub(wall.end, wall.start);
    const len = Math.hypot(dir.x, dir.y);
    if (jambs && len > 1e-9) {
      const n = { x: -dir.y / len, y: dir.x / len };
      const reveal = 0.06;
      for (const jamb of [jambs.jambStart, jambs.jambEnd]) {
        out.push({
          kind: "line",
          a: add(jamb, scale(n, reveal)),
          b: add(jamb, scale(n, -reveal)),
          cls: "window-frame",
          weightMm: SYMBOL_HAIRLINE_MM,
          color: o.color ?? POCHE_STROKE,
          greyed,
          openingId: o.id,
        });
      }
    }
  }
  // Brüstungslinie (gepunktet) — zeigt die Brüstung im Grundriss auf der Wandachse.
  // Nur bei Fenstern mit Brüstungshöhe > 0. Entspricht der Sill-Linie in
  // `_make_oeffnung_preview` und dem Brüstungs-Plansymbol im Rhino-Plugin.
  if (o.sillHeight > 0) {
    const jambs = openingJambs(wall, o);
    if (jambs) {
      out.push({
        kind: "line",
        a: jambs.jambStart,
        b: jambs.jambEnd,
        cls: "window-sill",
        weightMm: SYMBOL_HAIRLINE_MM,
        // Gepunktet (dot-dash) wie in _make_oeffnung_preview DrawDottedLine.
        dash: [0.04, 0.08],
        color: o.color ?? POCHE_STROKE,
        greyed,
        openingId: o.id,
      });
    }
  }
}

/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
const NO_HATCH: HatchRender = {
  pattern: "none",
  scale: 1,
  angle: 0,
  color: POCHE_STROKE,
  lineWeight: LAYER_LINE_MM,
  dash: null,
};

/**
 * Referenzlinie einer Wand: dünne, gestrichelte Achslinie entlang der Wandachse
 * (start → end). Von der Oberleiste umschaltbar; rein darstellend.
 */
function addReferenceLine(out: Primitive[], wall: Wall, greyed: boolean): void {
  out.push({
    kind: "line",
    a: wall.start,
    b: wall.end,
    cls: "wall-axis",
    weightMm: 0.13,
    dash: [0.18, 0.06, 0.04, 0.06], // Strich-Punkt-Achse in mm Papier
    greyed,
  });
}

/**
 * Decken-Poché im Grundriss: die Decke liegt als Slab ÜBER der Schnittebene und
 * wird daher als geschlossener Umriss ihres Grundriss-Polygons gezeichnet — eine
 * gefüllte + schraffierte Fläche plus eine kräftige Umrisslinie. Fill/Schraffur
 * stammen aus dem BAUTEIL der ersten (obersten) Schicht des Aufbau-Typs (analog
 * zur Wand-Schichtauflösung); die Umrissfarbe aus der optionalen Decken-Farbe
 * bzw. dem Poché-Strich. Jedes Polygon trägt die `ceilingId` für die Auswahl.
 *
 * BEWUSST FLÄCHIG, auch bei mehrschichtigem Deckentyp: anders als die Wand
 * (deren Schichten im Grundriss als Querschnitt sichtbar sind) liegt der
 * Schichtaufbau einer Decke in Z (oben → unten) — von oben unsichtbar. Die
 * Schichtbänderung erscheint daher NUR im Schnitt (siehe `toSection.ts`), nicht
 * hier. Im Detailgrad „grob" entfällt die Schraffur (nur Fläche + Umriss);
 * „mittel"/„fein" zeigen die Ansichts-Schraffur des Bauteils (`viewHatchId`,
 * Default weiss) — die frontal gesehene Aufsicht ist kein Schnitt. `lwMm` ist
 * die Kategorie-Strichstärke.
 */
function addCeilingPoche(
  out: Primitive[],
  project: Project,
  ceiling: Ceiling,
  greyed: boolean,
  detail: DetailLevel,
  category: LayerCategory | undefined,
  wallFootprints: WallFootprint[],
): void {
  const pts = ceiling.outline;
  if (pts.length < 3) return;
  const wt = getCeilingType(project, ceiling);
  const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
  const stroke = ceiling.color ?? POCHE_STROKE;
  const ceilingId = ceiling.id;

  // Gefüllte Fläche mit der Ansichts-Schraffur: die Decke wird im Grundriss
  // frontal von unten gesehen, nicht aufgeschnitten — daher greift hier
  // NICHT die Schnitt-Schraffur (die gehört auf echte Schnittflächen, siehe
  // resolveCeilingSectionStyle/splitSlabLayers), sondern `viewHatchId`. Fehlt
  // sie (Default), bleibt die Aufsicht weiss. Im Detailgrad „grob" entfällt
  // die Schraffur ganz (nur Fläche + Umriss). Das Attribut-Override
  // `ceiling.hatchId` (+ `hatchSource`) überschreibt diese Ansichts-Schraffur
  // genauso wie die Schnitt-Schraffur in {@link resolveCeilingSectionStyle}.
  const effectiveViewHatchId = resolveHatchId(
    ceiling.hatchId,
    ceiling.hatchSource,
    category,
    comp?.viewHatchId,
  );
  const hatch =
    detail !== "grob" && comp && effectiveViewHatchId
      ? resolveHatch(
          project,
          effectiveViewHatchId,
          undefined,
          resolveForeground(comp, ceiling.foreground, category, ceiling.foregroundSource),
        )
      : NO_HATCH;
  // Gefüllte Fläche OHNE eigenen Umriss (stroke:"none"): die Decke liegt unter
  // der Wand-Poché; ihre Kante wird separat als verdeckungs-geclippte Linie
  // gezeichnet (s. u.), damit sie nicht durch die darüberstehenden Wände schlägt.
  // Trägt weiterhin die `ceilingId` für die Links-Klick-Auswahl der Fläche.
  out.push({
    kind: "polygon",
    pts,
    fill: comp ? pocheFill(hatch.pattern) : "none",
    stroke: "none",
    strokeWidthMm: 0,
    hatch,
    greyed,
    ceilingId,
  });
  // Kräftige Umrisslinie NUR dort, wo keine Wand die Decke verdeckt: jede
  // Umriss-Kante gegen die Wand-Footprints clippen und die freien Teilstücke als
  // Linien ausgeben. Liegt die Decke deckungsgleich unter den Wänden (Regelfall:
  // Deckenumriss = Gebäude-/Wand-Mittellinien), entfällt der Umriss ganz; nur
  // Überstände (Vordach/Balkon jenseits der Wand) bleiben sichtbar.
  const n = pts.length;
  for (let i = 0; i < n; i++) {
    const a = pts[i];
    const b = pts[(i + 1) % n];
    const holes: Array<[number, number]> = [];
    for (const fp of wallFootprints) {
      const iv = segmentInsideWallBand(a, b, fp);
      if (iv) holes.push(iv);
    }
    const d = sub(b, a);
    for (const [t0, t1] of subtractIntervals(0, 1, holes)) {
      out.push({
        kind: "line",
        a: add(a, scale(d, t0)),
        b: add(a, scale(d, t1)),
        cls: "ceiling-outline",
        // Überkopf-Ansichtslinie (Decke liegt als Slab über der Schnittebene) →
        // gestrichelte Haarlinie statt kräftiger Volllinie (BIM-Konvention).
        weightMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
        dash: OVERHEAD_DASH,
        color: stroke,
        greyed,
      });
    }
  }
}

/**
 * Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente
 * Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren
 * Schema-Ebenen vorbehalten. Alpha 0 = unsichtbar; das Polygon bleibt als
 * `roomId`-Primitiv erhalten (Auswahl per Punkt-in-Polygon in PlanView, NICHT
 * über die native Pointer-Trefferfläche der Füllung — die volle Fläche bleibt
 * damit trotz Alpha 0 klickbar).
 */
const ROOM_FILL_ALPHA = "00";
/** Basis-Schriftgrösse des Raum-Stempels in Punkt (pt). */
const ROOM_STAMP_PT = 10;

/** Farbe mit Alpha-Suffix versehen (nur, wenn `color` ein 6-stelliges Hex ist). */
function withAlpha(color: string, alphaHex: string): string {
  return /^#[0-9a-fA-F]{6}$/.test(color) ? `${color}${alphaHex}` : color;
}

/**
 * Raum (SIA-416-Fläche) im Grundriss: eine (unsichtbare, nur klickbare) Füll-
 * fläche über dem lichten Umriss + eine dünne neutrale Umrisslinie (beide
 * `roomId`-getaggt für die Auswahl) + ein Raum-Stempel (Text) am Anker
 * (Centroid oder `stampAnchor`) mit Name, Fläche „24.30 m²" und SIA-Kürzel.
 * Keine Farbwäsche — Plandarstellung schwarz/grau, Farbe bleibt späteren
 * Schema-Ebenen vorbehalten. Der Stempel entfällt beim Detailgrad „grob";
 * Fläche/Umfang/Schwerpunkt werden aus `boundary` abgeleitet (nie gespeichert).
 * Wird UNTER der Wand-Poché gezeichnet.
 */
function addRoomArea(
  out: Primitive[],
  room: Room,
  greyed: boolean,
  detail: DetailLevel,
): void {
  const pts = room.boundary;
  if (pts.length < 3) return;
  const res = evaluateRoom(pts, room.siaCategory);
  const stroke = room.color;

  // Unsichtbare Füllfläche (Alpha 0): keine Farbwäsche im Plan, das Polygon
  // bleibt aber als `roomId`-Primitiv für die Punkt-in-Polygon-Auswahl erhalten.
  out.push({
    kind: "polygon",
    pts,
    fill: withAlpha(room.color, ROOM_FILL_ALPHA),
    stroke,
    strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
    hatch: NO_HATCH,
    greyed,
    roomId: room.id,
  });

  // Raum-Stempel: frei editierbarer Rich-Text (Name/Notizen) + LIVE-Flächenzeile
  // + SIA-Kürzel. Anker fix (stampAnchor, sonst Centroid). Nur ab Detailgrad
  // „mittel". Die Flächenzeile aktualisiert sich unabhängig vom editierten Text.
  if (detail !== "grob") {
    const at = room.stampAnchor ?? res.centroid;
    // Strukturierter Stempel (Feldmodell) hat Vorrang; sonst Alt-Pfad (Rich-Text
    // bzw. Raum-Name als einfacher Text).
    const doc = room.stamp
      ? roomStampToDoc(room.stamp)
      : room.stampDoc ?? docFromText(room.name);
    const extraLines = room.stamp
      ? roomStampExtraLines(room.stamp, {
          netArea: res.netArea,
          siaCategory: room.siaCategory,
          siaLabel: siaLabel(room.siaCategory),
        })
      : [
          { text: `${res.netArea.toFixed(2)} m²`, align: "center" as const },
          {
            text: `${room.siaCategory} · ${siaLabel(room.siaCategory)}`,
            align: "center" as const,
          },
        ];
    out.push({
      kind: "text",
      at,
      doc,
      extraLines,
      basePt: ROOM_STAMP_PT,
      color: stroke,
      roomId: room.id,
      greyed,
    });
  }
}

/**
 * Zeichnet die Wendel-Aussenlinie: Innen-/Aussenbogen als Arc-Primitive plus zwei
 * radiale Schliesslinien. Wird von `addStairSymbol` aufgerufen wenn `shape === "spiral"`.
 */
function _addSpiralOutline(
  out: Primitive[],
  seg: SpiralOutlineSegments,
  stroke: string,
  lwMm: number,
  greyed: boolean,
  stairId: string,
): void {
  // Radiale Schliesslinien (Startwinkel + Endwinkel).
  for (const [a, b] of seg.lines) {
    out.push({ kind: "line", a, b, cls: "stair-outline", weightMm: lwMm, color: stroke, greyed, stairId });
  }
  // Innen- und Aussenbogen.
  for (const arc of seg.arcs) {
    const from: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a0), y: arc.cy + arc.r * Math.sin(arc.a0) };
    const to: Vec2   = { x: arc.cx + arc.r * Math.cos(arc.a1), y: arc.cy + arc.r * Math.sin(arc.a1) };
    out.push({
      kind: "arc",
      center: { x: arc.cx, y: arc.cy },
      from,
      to,
      r: arc.r,
      cls: "stair-outline",
      weightMm: lwMm,
      greyed,
      stairId,
    });
  }
}

/**
 * Treppen-Symbol im Grundriss (DOSSIER/SIA):
 *  • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der
 *    Schnittebene DURCHGEZOGEN (sichtbar), darüber GESTRICHELT (verdeckt).
 *  • Der erste Tritt oberhalb der Schnittebene trägt die diagonale SIA-Bruchlinie
 *    (Doppelstrich quer über den Lauf).
 *  • Zwischenpodest (L/Wendel) als Umriss-Polygon.
 *  • Lauflinie (Lauflinie) mit Auf-/Abpfeil (Auf-/Abpfeil) und Stufenanzahl-Label.
 * Alle Primitive tragen `stairId` für die Auswahl.
 */
function addStairSymbol(
  out: Primitive[],
  project: Project,
  stair: Stair,
  greyed: boolean,
  lwMm: number,
  cutHeight: number,
): void {
  const { zBottom, zTop } = stairVerticalExtent(project, stair);
  const totalRise = zTop - zBottom;
  const geo = stairGeometry(stair, totalRise);
  const cut = stairCut(geo, cutHeight);
  const below = new Set(cut.belowIndices);
  const stroke = stair.color ?? POCHE_STROKE;
  const stairId = stair.id;

  // Umriss + Tritt-Trennlinien je Stufe. Der letzte Punkt jedes Tritt-Rechtecks
  // liefert die vordere Kante (Auftritt); wir zeichnen jede Tritt-Kante.
  for (const tr of geo.treads) {
    const isBelow = below.has(tr.index);
    // Tritt-Rechteck als reine Umrisslinie (keine Füllung), durchgezogen wenn
    // unter der Schnittebene, sonst gestrichelt (verdeckt).
    out.push({
      kind: "polygon",
      pts: tr.pts,
      fill: "none",
      stroke,
      strokeWidthMm: lwMm * 0.7,
      hatch: NO_HATCH,
      greyed,
      stairId,
    });
    // Zusätzliche Auftrittskante (vordere Kante der Stufe) etwas kräftiger.
    const front: [Vec2, Vec2] = [tr.pts[1], tr.pts[2]];
    out.push({
      kind: "line",
      a: front[0],
      b: front[1],
      cls: "stair-tread",
      weightMm: lwMm * (isBelow ? 1.0 : 0.6),
      dash: isBelow ? null : [0.12, 0.08],
      color: stroke,
      greyed,
      stairId,
    });
  }

  // Zwischenpodest (L/Wendel-Auge): Umriss-Polygon.
  if (geo.landing && geo.landing.length >= 3) {
    out.push({
      kind: "polygon",
      pts: geo.landing,
      fill: "none",
      stroke,
      strokeWidthMm: lwMm * 0.7,
      hatch: NO_HATCH,
      greyed,
      stairId,
    });
  }

  // SIA-Bruchlinie (Doppelstrich diagonal über den Schnitt-Tritt).
  if (cut.breakLine) {
    for (const [a, b] of cut.breakLine) {
      out.push({
        kind: "line",
        a,
        b,
        cls: "stair-break",
        weightMm: lwMm,
        color: stroke,
        greyed,
        stairId,
      });
    }
  }

  // Lauflinie (Lauflinie) — kräftig, durchgezogen.
  for (let i = 0; i < geo.runLine.length - 1; i++) {
    out.push({
      kind: "line",
      a: geo.runLine[i],
      b: geo.runLine[i + 1],
      cls: "stair-run",
      weightMm: lwMm,
      color: stroke,
      greyed,
      stairId,
    });
  }
  // Auf-/Abpfeil an der Spitze der Lauflinie.
  const [h1, tip, h2] = geo.arrow.head;
  out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
  out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });

  // Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L)
  // bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel).
  // Entspricht `_aussen_gerade` / `_aussen_l_polygon` / `_aussen_wendel` im
  // Rhino-Plugin. Stets durchgezogen, volle Strichstärke.
  const outline = stairOutline(stair, totalRise);
  if (outline.polygon && outline.polygon.length >= 3) {
    out.push({
      kind: "polygon",
      pts: outline.polygon,
      fill: "none",
      stroke,
      strokeWidthMm: lwMm,
      hatch: NO_HATCH,
      greyed,
      stairId,
    });
  }
  if (outline.spiral) {
    _addSpiralOutline(out, outline.spiral, stroke, lwMm, greyed, stairId);
  }
}

const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);

function computeBounds(
  project: Project,
  walls: Wall[],
  drawings: Drawing2D[] = [],
  ceilings: Ceiling[] = [],
  stairs: Stair[] = [],
  rooms: Room[] = [],
) {
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  const acc = (p: Vec2) => {
    minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
    maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
  };
  for (const w of walls) {
    const t = wallTypeThickness(getWallType(project, w));
    for (const c of wallCorners(w.start, w.end, t)) acc(c);
  }
  for (const c of ceilings) for (const p of c.outline) acc(p);
  for (const r of rooms) for (const p of r.boundary) acc(p);
  for (const s of stairs) {
    const { zBottom, zTop } = stairVerticalExtent(project, s);
    const geo = stairGeometry(s, zTop - zBottom);
    for (const tr of geo.treads) for (const p of tr.pts) acc(p);
    if (geo.landing) for (const p of geo.landing) acc(p);
  }
  for (const d of drawings) {
    const g = d.geom;
    if (g.shape === "line") { acc(g.a); acc(g.b); }
    else if (g.shape === "polyline") g.pts.forEach(acc);
    else if (g.shape === "rect") { acc(g.min); acc(g.max); }
    else if (g.shape === "circle" || g.shape === "arc") {
      acc({ x: g.center.x - g.r, y: g.center.y - g.r });
      acc({ x: g.center.x + g.r, y: g.center.y + g.r });
    } else if (g.shape === "text") acc(g.at);
  }
  // Kontext-Konturen mit einrahmen, damit „Einpassen" sie ebenfalls zeigt.
  for (const obj of project.context ?? []) {
    if (obj.type !== "contourSet") continue;
    for (const c of obj.contours) for (const p of c.pts) acc(p);
  }
  if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
  return { minX, minY, maxX, maxY };
}
