Files
DOSSIER-STANDALONE/src/plan/toWalls3d.ts
T
karim 4beae72eea Fenster/Tür 3D: tiefer, Detailgrad im 3D, kein Fenster→Tür-Umschalten
Nutzer-Feedback:
- Fenster wirkten im 3D flach: die Rahmen-Einbautiefe wurde fälschlich aus
  frameThickness (Profil-Breite, ~6 cm) statt aus der Wanddicke bestimmt.
  Jetzt füllt der Rahmen die VOLLE Wanddicke (tiefe Laibung), die Scheibe
  ist eine dünne Tafel mittig darin → plastische Fenster mit sichtbarer
  Laibung. frameDepth übersteuert weiterhin.
- Detailgrad grob/mittel/fein wirkt jetzt auch im 3D: grob = nur Loch +
  Scheibe, mittel = Rahmen (+Oberlicht-Kämpfer), fein = zusätzlich
  Flügel-Mittelpfosten/Kämpfer-Sprossen. Durchgereicht App → Viewport3D →
  Wasm3DViewport → useWasm3dRenderer → projectToModel3d({detail}).
- Fenster↔Tür-Umschalter im Objekt-Info entfernt (nur noch Anzeige): die
  Gattung wird beim Platzieren festgelegt, nicht nachträglich getauscht.

+1 Test (Detailgrad-Gating grob/mittel/fein). tsc + vitest grün.
2026-07-09 20:59:21 +02:00

2289 lines
101 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Leitet aus den Wänden eines Projekts die serialisierbaren WallInput-Records
// für den nativen render3d-Renderer ab. Matcht 1:1 render3d::types::WallInput
// { start:[x,y], end:[x,y], thickness, height, baseElevation, color:[r,g,b] }
// mit Modell-Metern und Y-up-Extrusion (Rust-Seite: plan liegt in XZ, +Y hoch).
//
// Alle Geschosse werden einbezogen und über wallVerticalExtent korrekt
// gestapelt (EG baseElevation 0, OG 2.6 …), sodass das 3D-Fenster das ganze
// Gebäude zeigt.
//
// ÖFFNUNGEN (Türen/Fenster): eine Wand mit Öffnungen wird NICHT als ein Quader
// emittiert, sondern in Teilquader entlang der Achse zerlegt:
// • volle Wandhöhe links/rechts der Öffnung (Pfeiler),
// • unter dem Fenster die Brüstung (UK..Sill),
// • über Tür/Fenster der Sturz (OpeningTop..Wandkopf).
// So entstehen sichtbare Aussparungen ohne dass render3d etwas davon wissen muss.
//
// DECKEN (Slabs): geschossgebundene Flächenbauteile werden als extrudierte
// Polygon-Platten (SlabInput) mitgegeben — render3d trianguliert den Umriss und
// zieht ihn über die Deckendicke hoch.
//
// SCHICHTEN (Putz/Dämmung/Mauerwerk/…): der aufgelöste Schichtaufbau des
// WallType wird HIER (TS-seitig) in echte 3D-Substanz zerlegt — statt EINES
// Vollkörpers in der repräsentativen Farbe emittiert jede Achsen-Teilquader-
// Position je Materiallage eine EIGENE Box, quer zur Wanddicke entlang der
// Wand-Normalen gestapelt und in der Materialfarbe ihres Components eingefärbt
// (Option B). Schicht i belegt entlang der Normalen das Intervall
// [T/2 + Σ_{k<i} t_k, T/2 + Σ_{k≤i} t_k], T = Σ t_k (Summe der Lagen),
// zentriert um die Wandachse. Alle Schicht-Boxen einer Wand tragen dieselbe
// `wallId` (Klick/Highlight selektieren die GANZE Wand). Jede Box ist ein
// Vollkörper (`layers: []`), den render3d in ihrer `color` zeichnet — die
// Schicht-Aufteilung passiert also komplett hier, render3d bleibt unverändert.
// Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle
// Dicke im neutralen Grundton (heutiges Fallback-Verhalten).
import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination, Opening } from "../model/types";
import {
getComponent,
getWallType,
getCeilingType,
getStairType,
wallTypeThickness,
openingsOfWall,
getFloor,
flattenCategories,
getDoorType,
getWindowType,
} from "../model/types";
import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
import { pointInOutline } from "../geometry/ceiling";
import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening";
import type { DetailLevel } from "../ui/TopBar";
import { stairGeometry, stairBBox } from "../geometry/stair";
import { extrudePolygon, extrudeCircle } from "../engine/truckSolid";
import { computeJoins } from "../model/joins";
import type { WallCuts } from "../model/joins";
import { add, dot, leftNormal, lineIntersect, normalize, scale, sub } from "../model/geometry";
import type { Line } from "../model/geometry";
import { resolveHatch } from "./generatePlan";
import type { PickOpening, PickStair } from "../viewport/raycast3d";
/**
* Achswinkel einer GESCHNITTENEN Wand im Schnitt-Koordinatensystem (Modell-
* Konvention, identisch zu `SECTION_WALL_AXIS_ANGLE_DEG` in toSection.ts): das
* Wand-Band steht vertikal (Achse entlang v, atan2(1,0) = 90°). Damit drehen
* `relativeToWall`-Muster (Dämmung, Backstein) im 3D-Schnitt genauso mit der Wand
* wie im 2D-Schnitt (`splitWallLayers` → `resolveHatch(…, 90, …)`). Für DECKEN gibt
* es keinen Wandachsen-Bezug — dort bleibt der Winkel absolut (`emitSlabs` ruft
* `resolveComponentHatch` ohne Achswinkel, wie `splitSlabLayers` → `resolveHatch(…,
* undefined, …)`), sodass die liegende Boden-Dämmung vertikal statt horizontal läuft.
*/
const SECTION_WALL_AXIS_ANGLE_DEG = 90;
export type RVec2 = [number, number];
export type RRgb = [number, number, number];
/** Weltpunkt (x, elevation, y) — nur intern für Highlight-Kantengeometrie. */
type RVec3 = [number, number, number];
/**
* Eine aufgelöste Wandschicht: Dicke + 3D-Albedo-Farbe (aus dem Component).
* Wird seit der TS-seitigen Schicht-Zerlegung (Option B, siehe Moduldoc) NICHT
* mehr an render3d durchgereicht — jede Schicht ist eine eigene Voll-Box; das
* `RWall.layers`-Feld bleibt für Serde-Kompatibilität, aber stets leer.
*/
export interface RLayer {
thickness: number;
color: RRgb;
}
/**
* Schnitt-Schraffur eines Bauteil-Bandes für den 3D-Live-Schnitt — matcht 1:1
* render3d::types::Hatch. Nur die drei Felder, die der prozedurale Cap-Shader
* (`CAP_WGSL`) auswertet: Muster-Id, Winkel (Grad), Skalierung. MONOCHROM (Tinte
* auf Papier, wie die 2D-Direktive `HATCH_INK`) — es wandert nur das MUSTER mit,
* KEINE Farbe. Muster-Id: 0 = none, 1 = solid, 2 = diagonal, 3 = crosshatch,
* 4 = insulation (Zickzack/Dämmung). Synchron zu halten mit `hatchPatternId`
* (unten), `types::Hatch` (Rust) und `CAP_WGSL`.
*/
export interface RHatch {
pattern: number;
angle: number;
scale: number;
/**
* Strichstärke der Schraffur-Musterlinien in mm-Papier (aus dem LineStyle des
* Hatch, `HatchRender.lineWeight`; Default 0.13). Matcht render3d::Hatch.lineWeight
* — der Cap-Shader setzt die Musterlinien-Breite daraus (dieselbe mm→Welt-
* Kalibrierung wie die Trennlinien), sodass die Schraffur-Stärke im 3D pro
* Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs. `thin` 0.13 mm).
*/
lineWeight?: number;
}
/** Bildet ein 2D-{@link HatchPattern} auf die Muster-Id von {@link RHatch}/CAP_WGSL ab. */
function hatchPatternId(p: HatchPattern): number {
switch (p) {
case "solid":
return 1;
case "diagonal":
return 2;
case "crosshatch":
return 3;
case "insulation":
return 4;
case "none":
default:
return 0;
}
}
/**
* Löst die Schnitt-Schraffur eines Bauteils (per `Component.hatchId`) zu einem
* {@link RHatch} auf — dasselbe Muster/Winkel/Skalierung wie der 2D-Schnitt
* (`generatePlan.ts::resolveHatch`, hier bewusst NICHT aufgerufen: nur die drei
* render3d-relevanten Felder werden direkt aus dem {@link HatchStyle} gezogen, die
* Farbe bleibt monochrom im Shader). `undefined`, wenn das Bauteil/die Schraffur
* nicht auflösbar ist (der Cap-Shader fällt dann auf sein Diagonalmuster zurück).
*/
function resolveComponentHatch(
project: Project,
componentId: string,
axisAngleDeg?: number,
): RHatch | undefined {
try {
const comp = getComponent(project, componentId);
if (!comp.hatchId) return undefined;
// GENAU der 2D-Schnitt-Pfad (`splitWallLayers`/`splitSlabLayers` →
// `resolveHatch`): `axisAngleDeg` = 90 für WAND-Bänder (die geschnittene Wand
// steht vertikal, `relativeToWall`-Muster drehen mit), `undefined` für DECKEN
// (kein Wandachsen-Bezug → absoluter Winkel). Wir übernehmen den ENDWINKEL
// (`HatchRender.angle`, bereits relativeToWall-korrigiert) UND die Musterlinien-
// Stärke (`lineWeight`, aus dem LineStyle) — nicht mehr den rohen Hatch-Winkel.
const hr = resolveHatch(project, comp.hatchId, axisAngleDeg);
return { pattern: hatchPatternId(hr.pattern), angle: hr.angle, scale: hr.scale, lineWeight: hr.lineWeight };
} catch {
return undefined;
}
}
/**
* Schnitt-Schraffur der dicksten (dominanten) Schicht eines Schichtstapels.
* `axisAngleDeg` wie {@link resolveComponentHatch} (90 für Wand, undefined für Decke).
*/
function dominantLayerHatch(
layers: Layer[],
project: Project,
axisAngleDeg?: number,
): RHatch | undefined {
if (layers.length === 0) return undefined;
const dominant = layers.reduce((best, l) => (l.thickness > best.thickness ? l : best));
return resolveComponentHatch(project, dominant.componentId, axisAngleDeg);
}
/**
* Geordnete Liste der EINDEUTIGEN Material-Farbkarten-URLs (`Component.material.
* color`), die von Wand-/Decken-Bauteilen des Projekts benutzt werden — in
* deterministischer First-Seen-Reihenfolge über `project.walls` (Wandtyp-Schichten)
* dann `project.ceilings`. Die POSITION in dieser Liste (+1) ist der Textur-Array-
* Index (`WallInput.materialIndex`, 1-basiert; Ebene 0 = Fallback-Schachbrett), den
* sowohl {@link resolveComponentMaterialLayer} (Zuweisung je Band) als auch der
* JS-Decoder ({@link import("../viewport/materialTextures")}) verwenden — so
* stimmen Band-Index und hochgeladene Textur-Ebene ohne separaten Map-Transport
* überein. Memoisiert je Projekt-REFERENZ (der Store ist immutabel — jede Änderung
* liefert ein neues Objekt, also kein Stale-Risiko), damit die per-Band-Auflösung
* nicht quadratisch wird.
*/
const materialMapCache = new WeakMap<Project, string[]>();
export function wallMaterialColorMaps(project: Project): string[] {
const cached = materialMapCache.get(project);
if (cached) return cached;
const seen = new Set<string>();
const list: string[] = [];
const consider = (componentId: string): void => {
try {
const url = getComponent(project, componentId).material?.color;
if (url && !seen.has(url)) {
seen.add(url);
list.push(url);
}
} catch {
// Unauflösbares Bauteil überspringen.
}
};
for (const w of project.walls) {
try {
for (const l of getWallType(project, w).layers) consider(l.componentId);
} catch {
// Kein auflösbarer Wandtyp.
}
}
for (const c of project.ceilings ?? []) {
try {
for (const l of getCeilingType(project, c).layers) consider(l.componentId);
} catch {
// Kein auflösbarer Deckentyp.
}
}
materialMapCache.set(project, list);
return list;
}
/**
* Textur-Array-Ebene (1-basiert) der Material-Farbkarte eines Bauteils, oder
* `undefined`, wenn das Bauteil kein Material mit Farbkarte trägt. Ebene 0 ist das
* Fallback-Schachbrett — deshalb `indexOf + 1`. Siehe {@link wallMaterialColorMaps}.
*/
function resolveComponentMaterialLayer(project: Project, componentId: string): number | undefined {
try {
const url = getComponent(project, componentId).material?.color;
if (!url) return undefined;
const idx = wallMaterialColorMaps(project).indexOf(url);
return idx >= 0 ? idx + 1 : undefined;
} catch {
return undefined;
}
}
/**
* Ein rechteckiges LOCH (Fenster/Tür) in EINEM Wandkörper — matcht 1:1
* render3d::types::Hole. KONVENTION: `from`/`to` sind Achsen-Meter ab
* `RWall.start` (0..Achslänge, dieselbe Achse wie die Öffnungs-Intervalle);
* `zBottom`/`zTop` sind ABSOLUTE Weltmeter (NICHT relativ zur Wand-UK — anders
* als `Opening.sillHeight`). Eine Tür ist der Sonderfall
* `zBottom == RWall.baseElevation` (Loch bis zur Wand-UK). render3d stanzt die
* Löcher als echte Aussparungen in die beiden Langseiten (Rechteck-Gitter-
* Zerlegung + Laibungsflächen), statt die Wand in Pfeiler/Brüstung/Sturz zu
* zerlegen.
*/
export interface RHole {
from: number;
to: number;
zBottom: number;
zTop: number;
}
/**
* Schnitt-Verschneidungs-/Linienstil-Metadaten EINES geschnittenen Bauteil-Bandes
* für den 3D-Live-Schnitt — matcht 1:1 render3d::types::CutBandMeta. Trägt die
* `joinPriority` (Schnitt-Boolean-Dominanz: die stärkere Schicht schneidet die
* schwächere, `section_boolean::subtract_dominant_bands`), die `componentId` (MERGE-
* Regel gleichartiger Bänder) sowie Farbe/Stärke der Schnitt-Umrisslinie (P2, aus
* dem `jointLineStyleId`-LineStyle der Schicht). Alle Felder optional — fehlt eines,
* greift auf der Rust-Seite der jeweilige Default (keine Subtraktion / Default-
* Schnittkante).
*/
export interface RCutMeta {
joinPriority?: number;
componentId?: string;
lineColor?: RRgb;
lineWeight?: number;
}
export interface RWall {
start: RVec2;
end: RVec2;
thickness: number;
height: number;
baseElevation: number;
color: RRgb;
/**
* Immer leer: die Schichten werden TS-seitig in eigene Voll-Boxen zerlegt
* (Option B, siehe Moduldoc). Feld bleibt nur zur Serde-Kompatibilität mit
* render3d::WallInput (leer ⇒ Vollkörper-Extrusion in `color`).
*/
layers: RLayer[];
/**
* Id der Ursprungs-Wand (`project.walls[].id`). Alle Öffnungs-Teilquader einer
* Wand tragen dieselbe Id. Rein für den TS-seitigen Pick-Raycast (raycast3d);
* der render3d/Serde-Pfad ignoriert das Feld (kein `deny_unknown_fields`,
* serde verwirft unbekannte Felder).
*/
wallId: string;
/**
* Echte rechteckige Löcher (Fenster/Türen) in DIESEM Wandkörper (siehe
* {@link RHole}). Nur im 3D-Viewer-Pfad ({@link projectToModel3d} mit
* `layeredWalls:true`) gesetzt: dort emittiert {@link emitWall} EINEN RWall pro
* Schicht-Band über die volle Achse mit ALLEN Öffnungen als `holes` — statt der
* Pfeiler/Brüstung/Sturz-Segment-Boxen. So bildet EIN Körper auch versetzt
* übereinanderliegende Fenster ab (das kann die Segment-Zerlegung nicht). Der
* Schnitt-Pfad (`layeredWalls:false`) setzt `holes` NICHT und segmentiert
* unverändert weiter (Owner-Kompatibilität mit `toSection.ts`, s. u.). Fehlt/
* leer ⇒ Vollkörper ohne Aussparung (render3d: `#[serde(default)]`). `holes`
* und die Segment-Zerlegung schliessen sich damit gegenseitig aus.
*/
holes?: RHole[];
/**
* Schnitt-Schraffur dieses Wand-Bandes (siehe {@link RHatch}) — matcht
* render3d::WallInput.hatch. Im layered-3D-Pfad trägt jede Materiallage die
* Schraffur IHRES Bauteils, sodass der 3D-Live-Schnitt jede geschnittene
* Schicht in ihrem Muster (Beton/Dämmung/Backstein/…) zeichnet. Fehlt sie
* (unauflösbares Bauteil), zeichnet der Cap-Shader sein Diagonal-Fallback.
*/
hatch?: RHatch;
/**
* Material-Textur-Ebene dieses Wand-Bandes für den Stil `Textured` (matcht
* render3d::WallInput.materialIndex, 1-basiert; Ebene 0 = Fallback-Schachbrett).
* Aus `Component.material.color` über {@link wallMaterialColorMaps} aufgelöst;
* fehlt sie, zeigt render3d das Schachbrett. Der Schnitt/Serde-Pfad ignoriert es.
*/
materialIndex?: number;
/**
* Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe
* {@link RCutMeta}) — matcht render3d::WallInput.cut. Im layered-3D-Pfad trägt
* jede Materiallage ihre `joinPriority`/`componentId`/Schnittkanten-Stil, sodass
* der 3D-Live-Schnitt die Boolean-Dominanz + Fugenlinien wie der 2D-Schnitt macht.
*/
cut?: RCutMeta;
}
/** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */
export interface RSlab {
outline: RVec2[];
zBottom: number;
zTop: number;
color: RRgb;
/**
* Id der Ursprungs-Decke (`project.ceilings[].id`). Nur für den TS-seitigen
* Pick-Raycast (raycast3d); der render3d/Serde-Pfad ignoriert das Feld
* (kein `deny_unknown_fields`).
*/
ceilingId: string;
/** Schnitt-Schraffur der Decke (dominante Schicht, siehe {@link RHatch}). */
hatch?: RHatch;
/** Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe {@link RCutMeta}). */
cut?: RCutMeta;
}
/** Art eines rohen Kontext-Meshes (bestimmt in render3d die Default-Einfärbung). */
export type RMeshKind = "terrain" | "imported" | "extrusion";
/**
* Ein rohes Dreiecks-Mesh aus dem Geo-Kontext (Terrain-TIN oder importiertes
* Gebäude-/DXF-Volumen). `positions` ist das flache (x,y,z=Höhe…)-Array in
* MODELL-Metern — genau wie `ImportedMesh`/`TerrainMesh` es hält; render3d bildet
* es selbst nach world (x, z, y) ab (analog Wände/Decken). Matcht
* render3d::types::MeshInput.
*/
export interface RMesh {
positions: number[];
indices: number[];
kind: RMeshKind;
color: RRgb;
}
/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */
export interface RModel3d {
walls: RWall[];
slabs: RSlab[];
meshes: RMesh[];
}
/** Warmer, neutraler Wand-Grundton (wie render3d default). */
const WALL_RGB: RRgb = [0.82, 0.8, 0.76];
/** Etwas hellerer, kühlerer Ton für Deckenplatten (heben sich von Wänden ab). */
const SLAB_RGB: RRgb = [0.86, 0.86, 0.88];
/** Gedämpftes Lehm-/Geländegrün-grau (three.js mats.terrain = 0xa9b0a2). */
const TERRAIN_RGB: RRgb = [169 / 255, 176 / 255, 162 / 255];
/** Neutrales Hellgrau für importierte Meshes (three.js mats.mesh = 0x9fa6ae). */
const IMPORTED_RGB: RRgb = [159 / 255, 166 / 255, 174 / 255];
/** Warmes Orange für truck-Profil-Extrusionen (hebt sie klar von Wand/Decke/Kontext ab). */
const EXTRUSION_RGB: RRgb = [0.85, 0.55, 0.25];
/** Neutraler Beton-/Tragwerks-Grauton für Stützen (Fallback ohne Bauteil-Farbe). */
const COLUMN_RGB: RRgb = [0.7, 0.7, 0.72];
/** Helles, leicht bläuliches Fenster-Glas (Scheibe in der Öffnung). */
const GLASS_RGB: RRgb = [0.62, 0.76, 0.85];
/** Halbe Dicke der Fensterscheibe (Meter) — sehr dünn, mittig in der Wand. */
const GLASS_HALF_THICK = 0.01;
/**
* Helles, warmes Rahmen-Grau (Zarge/Blendrahmen/Kämpfer/Sprossen) — bewusst
* deutlich heller als Wand ({@link WALL_RGB}) und klar von der bläulichen
* Scheibe ({@link GLASS_RGB}) unterscheidbar, damit der Rahmen im 3D als
* eigenes Bauteil lesbar ist (s. {@link emitOpeningFrames}).
*/
const FRAME_RGB: RRgb = [0.85, 0.85, 0.83];
/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */
const DEFAULT_FRAME_WIDTH = 0.06;
/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */
const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255];
/** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */
const DRAWING_LINE_HALF_WIDTH = 0.008;
/** Höhen-Offset über der Geschossebene gegen Z-Fighting (wie addDrawing2DLines in Viewport3D.tsx). */
const DRAWING_LINE_ELEVATION_EPS = 0.01;
/** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */
const EPS = 1e-4;
/**
* Wandelt eine Hex-Farbe ("#rrggbb", Component-Farbe im Plan) in float-RGB
* (0..1, render3d-Konvention) um. Ungültiges/fehlendes Format fällt auf den
* neutralen Wand-Grundton zurück (nie ein hartes Fehlbild).
*/
function hexToRgb(hex: string, fallback: RRgb = WALL_RGB): RRgb {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex ?? "");
if (!m) return fallback;
const v = parseInt(m[1], 16);
return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255];
}
/**
* Repräsentative Bauteilfarbe eines Schichtaufbaus: die Farbe der DICKSTEN
* Schicht (i. d. R. die tragende/dominante Schicht, z. B. Mauerwerk/Beton) —
* einfache Näherung, solange Wände/Decken als EIN Vollkörper emittiert werden
* (kein Schicht-Rendering in 3D). Fällt auf `fallback` zurück, wenn keine
* Schicht vorhanden ist oder deren Component nicht auflösbar ist.
*/
function dominantLayerColor(layers: Layer[], project: Project, fallback: RRgb): RRgb {
if (layers.length === 0) return fallback;
const dominant = layers.reduce((best, l) => (l.thickness > best.thickness ? l : best));
try {
return hexToRgb(getComponent(project, dominant.componentId).color, fallback);
} catch {
return fallback;
}
}
/**
* Eine quer zur Wanddicke gestapelte Materiallage als 3D-Band: eigene Dicke,
* Materialfarbe und Mitten-Versatz `offset` (Meter, entlang der Wand-Normalen,
* relativ zur Wandachse). Jede solche Lage wird pro Achsen-Teilquader zu einer
* eigenen Voll-Box (siehe {@link emitWall}/{@link pushSegment}).
*/
interface WallBand {
thickness: number;
color: RRgb;
offset: number;
/** Schnitt-Schraffur des Band-Bauteils (siehe {@link RHatch}). */
hatch?: RHatch;
/** Material-Textur-Ebene des Band-Bauteils (1-basiert, siehe {@link wallMaterialColorMaps}). */
materialIndex?: number;
/**
* Verschneidungs-Rang dieser Materiallage (`Component.joinPriority`) — treibt
* die per-Schicht-Dominanz gegen Decken im layered-3D-Pfad (s.
* {@link collectCeilingCutters}/{@link emitWall}): eine Decke mit STRIKT höherer
* `joinPriority` schneidet nur die Bänder mit niedrigerem Rang aus ihrem
* z-Intervall (der Kern höherer Priorität läuft durch). `undefined` (kein
* auflösbarer WallType / synthetisches Fallback-Band) ⇒ keine Decken-
* Subtraktion (konservativ, wie das frühere Trim-Verhalten bei unbekanntem Typ).
*/
priority?: number;
/**
* Bauteil-Identität dieser Materiallage (`Component.id`) — treibt die MERGE-Regel
* der Schnitt-Boolean-Dominanz (gleiche Komponente + Priorität verschmelzen, keine
* innere Trennlinie). Reist als `RCutMeta.componentId` mit.
*/
componentId?: string;
/** Farbe der Schnitt-Umrisslinie dieser Schicht (aus `jointLineStyleId`, sonst Default). */
cutLineColor?: RRgb;
/** Strichstärke der Schnitt-Umrisslinie in mm-Papier (aus `jointLineStyleId`, sonst Default). */
cutLineWeight?: number;
}
/**
* Löst den Schnitt-UMRISSLINIEN-Stil einer Schicht aus ihrem
* `Layer.jointLineStyleId` (LineStyle) auf: Farbe (hex→RGB 0..1) + Strichstärke
* (mm-Papier) — dieselbe Quelle wie die 2D-Schichtfuge (`generatePlan.ts`, Zeile
* ~1681). Fehlt der Verweis/LineStyle, `undefined` (der 3D-Cut fällt dann auf
* seinen Default-Schnittkanten-Stil zurück, siehe `section_fill::build_cut_cap_lines`).
* Bewusst tolerant (`find` statt `getLineStyle`), wie im 2D — ein verwaister Verweis
* darf den 3D-Push nicht werfen.
*/
function resolveCutLineStyle(
project: Project,
jointLineStyleId: string | undefined,
): { color?: RRgb; weight?: number } | undefined {
if (!jointLineStyleId) return undefined;
const ls = project.lineStyles.find((l) => l.id === jointLineStyleId);
if (!ls) return undefined;
return { color: hexToRgb(ls.color, DRAWING_LINE_FALLBACK_RGB), weight: ls.weight };
}
/**
* Baut die {@link RCutMeta}-Nutzlast eines Wand-Bandes (Priorität/Identität/
* Schnittkanten-Stil), oder `undefined`, wenn das Band keinerlei Schnitt-Metadaten
* trägt (z. B. das Fallback-Vollkörper-Band ohne auflösbaren WallType). Nur gesetzte
* Felder werden übernommen, damit `#[serde(default)]` auf der Rust-Seite greift.
*/
function bandCutMeta(b: WallBand): RCutMeta | undefined {
const meta: RCutMeta = {};
if (b.priority !== undefined) meta.joinPriority = b.priority;
if (b.componentId !== undefined) meta.componentId = b.componentId;
if (b.cutLineColor !== undefined) meta.lineColor = b.cutLineColor;
if (b.cutLineWeight !== undefined) meta.lineWeight = b.cutLineWeight;
return Object.keys(meta).length > 0 ? meta : undefined;
}
/**
* Löst den Schichtstapel EINER Wand auf: je Materiallage ein {@link WallBand}
* mit ihrer Dicke, Component-Farbe und dem Mitten-Versatz entlang der Normalen.
* Die Versätze zentrieren den Stapel um die Wandachse; als Gesamtdicke T dient
* die SUMME der tatsächlichen Lagen-Dicken (nicht `wallTypeThickness`, falls es
* abweicht) — so schließen die Bänder lückenlos aneinander an. Kein auflösbarer
* WallType / keine Lagen ⇒ EIN Vollkörper-Band über die volle Dicke im
* neutralen Grundton (heutiges Fallback-Verhalten).
*/
function resolveWallBands(project: Project, wall: Wall, layered = true): WallBand[] {
try {
const wt = getWallType(project, wall);
if (wt.layers.length > 0) {
// Gesamtdicke = Summe der Lagen (konsistent mit den Offsets); wenn diese
// von wallTypeThickness abwiche, gewinnt die Lagen-Summe (keine Lücken).
const total = wt.layers.reduce((s, l) => s + l.thickness, 0);
// Schnitt-Eingabe (layered=false): EINE Voll-Box über die ganze Dicke statt
// je Lage eine Box. So bleibt die Emission 1:1 Wand→Rechteck — passend zu
// `wallSegmentOwners`/`splitWallLayers` in toSection.ts, die die Schicht-
// Zerlegung TS-seitig aus diesem einen Rechteck machen. (Der 3D-Viewer ruft
// mit Default layered=true auf und bekommt unverändert die Schicht-Bänder.)
if (!layered) {
const dominant = wt.layers.reduce((best, l) => (l.thickness > best.thickness ? l : best));
return [
{
thickness: total,
color: dominantLayerColor(wt.layers, project, WALL_RGB),
offset: 0,
hatch: dominantLayerHatch(wt.layers, project, SECTION_WALL_AXIS_ANGLE_DEG),
materialIndex: resolveComponentMaterialLayer(project, dominant.componentId),
},
];
}
const bands: WallBand[] = [];
let prefix = 0;
for (const l of wt.layers) {
const comp = getComponent(project, l.componentId);
const color = hexToRgb(comp.color);
const lineStyle = resolveCutLineStyle(project, l.jointLineStyleId);
bands.push({
thickness: l.thickness,
color,
offset: -total / 2 + prefix + l.thickness / 2,
priority: comp.joinPriority,
componentId: comp.id,
cutLineColor: lineStyle?.color,
cutLineWeight: lineStyle?.weight,
hatch: resolveComponentHatch(project, l.componentId, SECTION_WALL_AXIS_ANGLE_DEG),
materialIndex: resolveComponentMaterialLayer(project, l.componentId),
});
prefix += l.thickness;
}
return bands;
}
// WallType ohne Schichten: EIN Vollkörper über die Typ-Gesamtdicke.
return [{ thickness: wallTypeThickness(wt), color: WALL_RGB, offset: 0 }];
} catch {
// Kein auflösbarer WallType: EIN Vollkörper mit Default-Dicke/Grundton.
return [{ thickness: 0.2, color: WALL_RGB, offset: 0 }];
}
}
/**
* Hängt eine Wand-Schicht-Box an: das Achsenstück [from..to] (Meter ab
* Wand-Startpunkt) über den Höhenbereich [zBottom..zTop], quer um `normalOffset`
* Meter entlang der Wand-Normalen versetzt (Schichtmitte relativ zur Achse) und
* mit Schicht-`thickness`/`color`. Entartete Stücke (Länge ≤ 0 oder Höhe ≤ 0)
* werden übersprungen — so überleben Öffnungen dicht an den Wandenden ohne
* Nullquader.
*
* Optionale `holes`: echte rechteckige Aussparungen in DIESER Box (siehe
* {@link RHole}). Ihre `from`/`to` sind Achsen-Meter ab Wand-Start (dieselbe
* Achse wie `from`/`to` dieser Box), `zBottom`/`zTop` absolute Weltmeter — beides
* unverändert an render3d durchgereicht (der Querversatz `normalOffset` wirkt nur
* senkrecht zur Achse und lässt die Loch-Parametrisierung unberührt). Werden nur
* im 3D-Pfad (voller Achsen-Quader) gesetzt.
*/
function pushSegment(
out: RWall[],
wall: Wall,
from: number,
to: number,
zBottom: number,
zTop: number,
thickness: number,
normalOffset: number,
color: RRgb,
wallId: string,
hatch?: RHatch,
materialIndex?: number,
holes?: RHole[],
cut?: RCutMeta,
): void {
if (to - from <= EPS) return;
if (zTop - zBottom <= EPS) return;
const dx = wall.end.x - wall.start.x;
const dy = wall.end.y - wall.start.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) return;
const ux = dx / len;
const uy = dy / len;
// LINKE Grundriss-Normale (identisch zu `leftNormal(u)` = (uy, ux) in
// generatePlan.ts/clippedBand — dieselbe Konvention, die addWallPoche für
// den Schichtstapel im 2D-Grundriss benutzt). Der Schicht-Versatz verschiebt
// Start/Ende der Box quer zur Wand, sodass render3d die Box um ihre eigene
// (versetzte) Achse zentriert aufbaut — mit dieser Normale liegt Schicht 0
// (Aussenseite, kleinster `offset`) auf derselben Weltseite wie im Grundriss.
// Bewusst NICHT identisch zur Normale, die raycast3d/selectionHighlightLines
// für den Box-Rebuild nutzen (n = (uy, ux), rechte Normale) — das ist
// unschädlich, weil jene Boxen symmetrisch um die (hier bereits versetzte)
// Achse jedes einzelnen Layer-Records aufgebaut werden und daher unabhängig
// von der Vorzeichen-Konvention des Versatzes sind.
const ox = -uy * normalOffset;
const oy = ux * normalOffset;
const p1: RVec2 = [wall.start.x + ux * from + ox, wall.start.y + uy * from + oy];
const p2: RVec2 = [wall.start.x + ux * to + ox, wall.start.y + uy * to + oy];
out.push({
start: p1,
end: p2,
thickness,
height: zTop - zBottom,
baseElevation: zBottom,
color,
layers: [],
wallId,
...(hatch ? { hatch } : {}),
...(materialIndex != null ? { materialIndex } : {}),
...(holes && holes.length > 0 ? { holes } : {}),
...(cut ? { cut } : {}),
});
}
/**
* Achsenparalleles Grundriss-Rechteck (min/max je Achse), Meter, Modell-XY.
* Dient hier NUR der groben Footprint-Überlapp-Prüfung Wand↔Decke (s.
* {@link trimWallTopForCeilings}) — kein exakter Polygon-Test, siehe dort.
*/
interface Bounds2 {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
/** Bounding-Box der Wandachse, um `halfThickness` in beide Richtungen geweitet
* (sichere Über-Approximation des tatsächlichen Wandkörpers, unabhängig von
* dessen Orientierung). */
function wallFootprintBounds(wall: Wall, halfThickness: number): Bounds2 {
return {
minX: Math.min(wall.start.x, wall.end.x) - halfThickness,
maxX: Math.max(wall.start.x, wall.end.x) + halfThickness,
minY: Math.min(wall.start.y, wall.end.y) - halfThickness,
maxY: Math.max(wall.start.y, wall.end.y) + halfThickness,
};
}
/** Bounding-Box eines geschlossenen Umrisses (Decke). */
function outlineBounds(outline: Array<{ x: number; y: number }>): Bounds2 {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const p of outline) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
return { minX, maxX, minY, maxY };
}
/** Überlappen sich zwei Bounding-Boxen (mit EPS-Toleranz an den Rändern)? */
function boundsOverlap(a: Bounds2, b: Bounds2): boolean {
return (
a.minX <= b.maxX + EPS &&
a.maxX >= b.minX - EPS &&
a.minY <= b.maxY + EPS &&
a.maxY >= b.minY - EPS
);
}
/**
* Dicke einer Wand für die Footprint-Näherung (s. {@link wallFootprintBounds}):
* Summe der WallType-Schichtdicken, sonst der Fallback-Grundton-Dicke aus
* {@link resolveWallBands} (0.2 m), falls kein WallType auflösbar ist.
*/
function wallThicknessSafe(project: Project, wall: Wall): number {
try {
return wallTypeThickness(getWallType(project, wall));
} catch {
return 0.2;
}
}
/**
* Repräsentative `joinPriority` einer Decke (höchste ihrer Schichten) — bestimmt,
* ob die Decke am Wand/Decke-Stoss "durchläuft" (gewinnt) und in die Wandschichten
* niedrigerer Priorität einschneidet (s. {@link collectCeilingCutters}).
*/
function ceilingDominantPriority(project: Project, ceiling: Ceiling): number | undefined {
try {
const ct = getCeilingType(project, ceiling);
let best: number | undefined;
for (const layer of ct.layers) {
const p = getComponent(project, layer.componentId).joinPriority;
if (best === undefined || p > best) best = p;
}
return best;
} catch {
return undefined;
}
}
/**
* Ein Decken-„Cutter": vertikales z-Intervall + Dominanz-Rang + Grundriss-Umriss
* (s. {@link collectCeilingCutters}). Der `outline` erlaubt die PER-BAND-genaue
* Überdeckungsprüfung in {@link emitWall} (statt eines wandweiten BBox-Tests): eine
* Decke schneidet ein Schicht-Band nur dort, wo ihre Outline die Footprint-
* Mittellinie des Bandes im Grundriss überdeckt.
*/
interface CeilingCutter {
zBottom: number;
zTop: number;
priority: number;
outline: Vec2[];
}
/**
* Sammelt die Decken, die eine Wand im layered-3D-Pfad PRO SCHICHT einschneiden —
* die 3D-Entsprechung der Schnitt-Boolean-Dominanz (`toSection.ts::
* subtractDominantBands`): statt die GANZE Wand am Deckenanschluss zu kappen
* (früheres `trimWallTopForCeilings`), behält die Wand ihre volle Höhe und JEDES
* Schicht-Band verliert nur das z-Intervall der Decken mit STRIKT höherer
* `joinPriority` als das Band (der Kern höherer Priorität läuft durch die Decke).
* Ein Band kann dadurch vertikal in zwei Boxen zerfallen (unter Deckenunterkante
* + über Deckenoberkante, falls die Wand die Decke überragt).
*
* Kriterien je Decke (alle müssen zutreffen):
* • dasselbe Geschoss (`ceiling.floorId === wall.floorId`),
* • vertikale Überlappung mit der (vollen) Wand-Ausdehnung,
* • grober Footprint-Überlapp (Bounding-Box, s. {@link boundsOverlap}) — nur ein
* billiger Vor-Filter (keine Wand berührt, wenn die Wand-BBox die Decken-BBox
* nicht schneidet); die EXAKTE, per-Band geometrische Überdeckung entscheidet
* dann in {@link emitWall} (s. {@link bandCoverageIntervals}).
* • auflösbare Decken-`joinPriority` (`ceilingDominantPriority`).
*
* Das z-Intervall/die Priorität der Cutter ist PER DECKENSCHICHT (nicht die ganze
* Decke als EIN Block der dominanten Priorität): jede Deckenschicht wird zu einem
* eigenen Cutter mit IHREM z-Band (proportional auf [zBottom,zTop] verteilt, exakt
* wie {@link emitSlabs}) und IHRER `Component.joinPriority`. Das ist zwingend, damit
* der 3D-Cut EXAKT wie der 2D-Schnitt (`toSection.ts::subtractDominantBands`)
* verschneidet: dort trägt jede Schicht ihre eigene Priorität. Mit dem früheren
* EINEN Block (dominante Prio 100 über die volle Decke) verlor z. B. der Backstein
* (50) das GANZE Decken-z-Band [UK,OK] — obwohl er die schwimmenden Deckenschichten
* Estrich (30)/Dämmung (20) ÜBERRAGEN und schneiden soll; nur die Betonschicht (100)
* darf ihn kappen. So blieb im 3D (Solid UND Schnitt) über dem Backstein eine Lücke,
* durch die Estrich/Dämmung „rausliefen" (Nutzer-Bug). Per Schicht läuft der
* Backstein zwischen Beton-OK und Wandkopf durch und schneidet dort Estrich/Dämmung
* weg — identisch zum 2D-Schnitt. Der `outline` reist mit, damit {@link emitWall}
* PRO BAND prüft, ob die Decke die Footprint-Mittellinie dieses Bandes überdeckt
* (geometrischer Überlapp, kein wandweiter BBox-Test). Der Prioritätsvergleich pro
* Band passiert in {@link emitWall} (`cutter.priority > band.priority`) — eine
* Wand-Schicht mit ≥ Rang der jeweiligen Deckenschicht läuft durch deren z-Band durch.
* Ein-Schicht-/nicht auflösbare Deckentypen fallen auf EINEN Cutter über die volle
* Ausdehnung mit der dominanten Priorität zurück (Alt-Verhalten).
*/
function collectCeilingCutters(
project: Project,
wall: Wall,
zBottom: number,
zTop: number,
): CeilingCutter[] {
const halfThickness = wallThicknessSafe(project, wall) / 2;
const wallBounds = wallFootprintBounds(wall, halfThickness);
const cutters: CeilingCutter[] = [];
for (const c of project.ceilings ?? []) {
if (c.floorId !== wall.floorId) continue;
if (!c.outline || c.outline.length < 3) continue;
const cExtent = ceilingVerticalExtent(project, c);
if (cExtent.zBottom >= zTop - EPS || cExtent.zTop <= zBottom + EPS) continue;
if (!boundsOverlap(wallBounds, outlineBounds(c.outline))) continue;
// Per-Schicht-Cutter: je Deckenschicht ein eigenes z-Band + eigene Priorität
// (proportional auf die tatsächliche Decken-z-Spanne skaliert, oben→unten wie
// emitSlabs). Nur so schneidet Beton (100) den Backstein (50), Backstein aber
// Estrich (30)/Dämmung (20) — identisch zu subtractDominantBands.
let layers: Layer[] | undefined;
try {
layers = getCeilingType(project, c).layers;
} catch {
layers = undefined;
}
const total = layers?.reduce((s, l) => s + l.thickness, 0) ?? 0;
if (layers && layers.length > 0 && total > EPS) {
const span = cExtent.zTop - cExtent.zBottom;
let prefixTop = 0; // von OK (zTop) nach unten aufsummiert (Meter)
for (const l of layers) {
const t = (l.thickness / total) * span;
const layerTop = cExtent.zTop - prefixTop;
const layerBottom = layerTop - t;
prefixTop += t;
let prio: number | undefined;
try {
prio = getComponent(project, l.componentId).joinPriority;
} catch {
prio = undefined;
}
if (prio === undefined) continue;
cutters.push({ zBottom: layerBottom, zTop: layerTop, priority: prio, outline: c.outline });
}
} else {
const ceilingPriority = ceilingDominantPriority(project, c);
if (ceilingPriority === undefined) continue;
cutters.push({ zBottom: cExtent.zBottom, zTop: cExtent.zTop, priority: ceilingPriority, outline: c.outline });
}
}
return cutters;
}
/**
* Achsen-Teilintervalle (Meter ab `wall.start` entlang `u`, geklemmt auf
* `[lo,hi]`), auf denen die MITTELLINIE eines Schicht-Bands (Querversatz
* `bandOffset` entlang `n = leftNormal(u)`) im Decken-Umriss `outline` liegt —
* die per-Band-Entsprechung des geometrischen Rechteck-Überlapps in
* `toSection.ts::subtractDominantBands`. Nur diese Teilintervalle bekommen später
* den Decken-z-Schnitt; unbedeckte Achsenabschnitte behalten die volle Höhe (so
* laufen Dämmung/Aussenputz, deren Mittellinie AUSSERHALB des Decken-Umrisses
* liegt, ununterbrochen durch — der Nutzer-Bug „Kranz durch die Dämmung").
*
* Die Bandmittellinie wird entlang der Achse gerastert (~5-cm-Schritte, min. 32)
* und gegen {@link pointInOutline} geprüft; Innen/Aussen-Übergänge werden per
* Bisektion auf ~EPS verfeinert, sodass die Überdeckungsgrenzen scharf sind (auch
* eine Decke, die nur einen Teil der Wandlänge überdeckt, wird korrekt getrimmt).
*/
function bandCoverageIntervals(
wall: Wall,
u: Vec2,
n: Vec2,
bandOffset: number,
outline: Vec2[],
lo: number,
hi: number,
): Array<[number, number]> {
if (hi - lo <= EPS) return [];
const base = add(wall.start, scale(n, bandOffset));
const inside = (t: number): boolean => pointInOutline(add(base, scale(u, t)), outline);
const steps = Math.max(32, Math.ceil((hi - lo) / 0.05));
const dt = (hi - lo) / steps;
const intervals: Array<[number, number]> = [];
let prevInside = inside(lo);
let start: number | null = prevInside ? lo : null;
let prevT = lo;
for (let i = 1; i <= steps; i++) {
const t = i === steps ? hi : lo + i * dt;
const now = inside(t);
if (now !== prevInside) {
const cross = bisectBoundary(inside, prevT, t);
if (now) start = cross;
else if (start !== null) {
intervals.push([start, cross]);
start = null;
}
}
prevInside = now;
prevT = t;
}
if (start !== null) intervals.push([start, hi]);
return intervals;
}
/**
* Bisektion des Innen/Aussen-Übergangs der Bandmittellinie zwischen `a` und `b`
* (verschiedene {@link pointInOutline}-Seiten) auf ~EPS-Genauigkeit — liefert die
* scharfe Achsen-Position der Überdeckungsgrenze.
*/
function bisectBoundary(inside: (t: number) => boolean, a: number, b: number): number {
const aInside = inside(a);
let lo = a;
let hi = b;
for (let i = 0; i < 40 && hi - lo > EPS * 0.01; i++) {
const mid = (lo + hi) / 2;
if (inside(mid) === aInside) lo = mid;
else hi = mid;
}
return (lo + hi) / 2;
}
/**
* Zieht die Cutter-Intervalle `holes` vom Intervall `[lo,hi]` ab und liefert die
* verbleibenden Teil-Intervalle (Meter). Löcher werden auf `[lo,hi]` geklemmt,
* sortiert und vereinigt; leeres `holes` ⇒ exakt `[[lo,hi]]` (unveränderter Fall).
* Dient sowohl der Achsen-Zerlegung durch Durchgangswand-Span-Cutouts als auch
* der vertikalen Decken-Subtraktion (identische 1D-Semantik wie
* `generatePlan.ts::subtractIntervals`, hier lokal gehalten — generatePlan bleibt
* unangetastet).
*/
function subtractSpans(lo: number, hi: number, holes: Array<[number, number]>): Array<[number, number]> {
const clipped = holes
.map(([a, b]): [number, number] => [Math.max(lo, Math.min(a, b)), Math.min(hi, Math.max(a, b))])
.filter(([a, b]) => b > a + EPS)
.sort((p, q) => p[0] - q[0]);
const parts: Array<[number, number]> = [];
let cursor = lo;
for (const [a, b] of clipped) {
if (a > cursor + EPS) parts.push([cursor, a]);
if (b > cursor) cursor = b;
}
if (cursor < hi - EPS) parts.push([cursor, hi]);
return parts;
}
/**
* Wendet die Decken-z-Cutter `cutters` (dominante horizontale Cut-Bänder [zBottom,
* zTop]) auf das Wandband-Intervall `[lo,hi]` an — je nach Terminierungs-Regel der
* Wand ({@link Wall.sliceTermination}). Bewusst als EIGENER, klar benannter Schritt
* neben der Priorität gehalten (Terminierung = Zuschnitt, KEINE Prioritätsregel):
*
* • "both"/undefined ⇒ heutiges Verhalten: `subtractSpans` stanzt jedes Cutter-
* z-Band aus (oben+unten Reste bleiben erhalten). Bit-identisch zu vorher.
* • "below" ⇒ die Wand ENDET an der Decke: alles AB der Decken-UNTERKANTE wird
* verworfen (kein über der Decke wieder auftauchender Rest). Clip-Grenze = das
* KLEINSTE `zBottom` aller dominanten Cutter dieses Bandes. Da die dominanteste
* (tragende) Deckenschicht — z. B. Beton (Prio 100) — jedes schwächere Band
* kappt UND zugleich die unterste Deckenlage ist, terminieren so ALLE Bänder
* einheitlich an der Deckenunterkante (kein Durchstoss/Staffel, keine
* Interpenetration mit Beton/Dämmung darüber). Für den Backstein, den nur der
* Beton kappt, ist das exakt „unter dem dominanten Cut" (dessen Unterkante).
* • "above" ⇒ spiegelbildlich: alles BIS zur Decken-OBERKANTE wird verworfen.
* Clip-Grenze = das GRÖSSTE `zTop` aller dominanten Cutter dieses Bandes.
*
* Ohne Cutter (`cutters` leer) bleibt `[lo,hi]` in allen Modi unverändert — ein
* Band ausserhalb des Decken-Umrisses (Aussenputz/Dämmung) läuft also weiter voll
* durch, unabhängig von der Terminierung.
*/
function terminateOrSubtractSpans(
lo: number,
hi: number,
cutters: Array<[number, number]>,
termination: SliceTermination | undefined,
): Array<[number, number]> {
if (cutters.length === 0) return [[lo, hi]];
if (termination === "below") {
// Deckenunterkante = kleinstes zBottom der Cutter; die Wand endet dort.
let clipTop = Infinity;
for (const [cb] of cutters) clipTop = Math.min(clipTop, cb);
const top = Math.min(clipTop, hi);
return top > lo + EPS ? [[lo, top]] : [];
}
if (termination === "above") {
// Deckenoberkante = grösstes zTop der Cutter; die Wand beginnt dort.
let clipBottom = -Infinity;
for (const [, ct] of cutters) clipBottom = Math.max(clipBottom, ct);
const bottom = Math.max(clipBottom, lo);
return bottom < hi - EPS ? [[bottom, hi]] : [];
}
return subtractSpans(lo, hi, cutters);
}
/**
* Achsen-Distanz (Meter ab `wall.start` entlang der Wandrichtung `u`), an der die
* MITTELLINIE eines Schicht-Bands (Querversatz `bandOffset` entlang `leftNormal(u)`)
* die Gehrungs-/Anschluss-Schnittlinie `cut` trifft — die 3D-Achsenbox-Entsprechung
* des 2D-`clippedBand`. Da die Box achsparallel endet (keine gekippte Stirnfläche),
* wird die Band-Länge auf den Schnittpunkt der MITTELLINIE gekürzt/verlängert; die
* beiden 2D-Bandkanten (bei ±thickness/2) treffen die Schnittlinie an leicht
* versetzten Achsenpunkten — der Box-Wert ist ihr Mittelwert. `null` bei
* (nahezu) parallelem Schnitt (kollineare Achsen) ⇒ Aufrufer behält das Bandende.
*/
function bandCutDistance(wall: Wall, u: Vec2, n: Vec2, bandOffset: number, cut: Line): number | null {
const base = add(wall.start, scale(n, bandOffset));
const hit = lineIntersect(base, u, cut.point, cut.dir);
if (!hit) return null;
return dot(sub(hit, wall.start), u);
}
/**
* Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen).
*
* ZWEI PFADE je `layered`:
* • layered=true (3D-Viewer, Default): EIN Körper pro Schicht-Band über die
* VOLLE Achse mit allen Öffnungen als echte `holes` (render3d stanzt sie in
* die Langseiten). Ermöglicht auch versetzt übereinanderliegende Fenster in
* EINEM Körper (keine Segment-Nähte).
* • layered=false (Schnitt-Pfad, `computeSection`): UNVERÄNDERTE Pfeiler/
* Brüstung/Sturz-Segmentierung (EIN Voll-Band je Segment), damit die
* `ComponentRef.index`-Reihenfolge exakt zu `toSection.ts::wallSegmentOwners`
* passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher
* werden hier NICHT emittiert.
*/
function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cuts?: WallCuts): void {
const bands = resolveWallBands(project, wall, layered);
const { zBottom, zTop } = wallVerticalExtent(project, wall);
// Anders als früher (`trimWallTopForCeilings` kappte im layered-Pfad die GANZE
// Wand an der Deckenunterkante) behält die Wand jetzt IN BEIDEN PFADEN ihre
// volle Höhe; die per-Schicht-Decken-Subtraktion (unten, nur layered) schneidet
// Bänder niedrigerer Priorität im Decken-z-Band aus — die 3D-Entsprechung der
// Schnitt-Boolean-Dominanz. Der Schnitt-Pfad (layered=false) bleibt unverändert
// (volle Rohgeometrie; seine Dominanz macht toSection.ts::subtractDominantBands).
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;
// Ein Achsen-Teilquader × jede Materiallage = eine Box (Schichtstapel quer
// zur Wanddicke). Alle tragen die Wand-Id, sodass ein Klick auf irgendeine
// Schicht die GANZE Wand selektiert und das Highlight alle Bänder umrandet.
const emitSeg = (from: number, to: number, zb: number, zt: number): void => {
for (const b of bands) {
pushSegment(out, wall, from, to, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, undefined, bandCutMeta(b));
}
};
// Aussparungen der Wand als vereinheitlichte Cutouts sammeln:
// • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
// • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz).
// Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung.
const cutouts: Array<{ from: number; to: number; oBottom: number; oTop: number }> = [];
for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op);
if (!iv) continue;
const v = openingVerticalExtent(project, wall, op);
cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop });
}
for (const d of project.doors ?? []) {
if (d.hostWallId !== wall.id) continue;
const from = Math.max(0, Math.min(d.position, axisLen));
const to = Math.max(from, Math.min(d.position + d.width, axisLen));
if (to - from < EPS) continue;
// Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe.
const oTop = Math.min(zTop, zBottom + d.height);
cutouts.push({ from, to, oBottom: zBottom, oTop });
}
cutouts.sort((a, b) => a.from - b.from);
// ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle
// Achse mit allen Öffnungen als echte Löcher (render3d stanzt sie), PLUS die
// materialbewussten Verschneidungen (identisch zum 2D-Grundriss/Schnitt):
// 1. End-Cuts/Merge an L-/T-Stössen: jede Schicht wird an ihrem Achsenende an
// der Gehrungs-/Anschluss-Schnittlinie individuell gekürzt/verlängert
// (`WallCuts.startCut`/`endCut`, bzw. pro Schicht `layerCuts.start/end` —
// merge: Kern läuft bis zur Rückgrat-Nahfläche durch; trim: Putz stoppt an
// der Wandoberfläche). Wie im 2D wird die Band-MITTELLINIE geschnitten.
// 2. Durchgangswand-Span-Cutouts: der Nah-Putz einer Durchgangswand wird über
// die Achsen-Breite des durchstechenden Abzweig-Kerns ausgeschnitten — ein
// Band zerfällt dabei entlang der Achse in mehrere Teilstücke.
// 3. Decken-Dominanz pro Schicht: Bänder niedrigerer `joinPriority` verlieren
// das z-Intervall überlappender Decken (vertikaler Split), der Kern läuft
// durch (s. {@link collectCeilingCutters}).
// Die Öffnungs-Löcher (`holes`, Achsen-Meter ab Wand-Start) werden je Teilstück
// auf dessen Achsen-Start umgerechnet (rebased), damit render3d sie relativ zum
// jeweiligen Box-Start korrekt stanzt.
if (layered) {
const holes: RHole[] = cutouts.map((c) => ({
from: c.from,
to: c.to,
zBottom: c.oBottom,
zTop: c.oTop,
}));
const startCut = cuts?.startCut ?? null;
const endCut = cuts?.endCut ?? null;
const layerCuts = cuts?.layerCuts;
const spanCutouts = cuts?.spanCutouts ?? [];
const ux = (wall.end.x - wall.start.x) / axisLen;
const uy = (wall.end.y - wall.start.y) / axisLen;
const u: Vec2 = { x: ux, y: uy };
const n = leftNormal(u); // (-uy, ux) — dieselbe Konvention wie pushSegment/2D.
const ceilingCutters = collectCeilingCutters(project, wall, zBottom, zTop);
for (let li = 0; li < bands.length; li++) {
const b = bands[li];
// (1) Per-Schicht-Cuts an den Achsenden. `layerCuts` (materialbewusster
// T-Stoss) überschreibt — wo gesetzt — den einheitlichen start/endCut PRO
// Schicht; `null` an einem Index heisst „keine Kürzung an diesem Ende".
// Exakt die 2D-Wahl aus generatePlan.ts::addWallPoche (Zeile ~1523).
const sCut = layerCuts ? layerCuts.start[li] : startCut;
const eCut = layerCuts ? layerCuts.end[li] : endCut;
let from0 = 0;
let to0 = axisLen;
if (sCut) {
const d = bandCutDistance(wall, u, n, b.offset, sCut);
if (d !== null) from0 = d;
}
if (eCut) {
const d = bandCutDistance(wall, u, n, b.offset, eCut);
if (d !== null) to0 = d;
}
// (2) Span-Cutouts, deren Quer-Offsetbereich dieses Band schneidet, brechen
// die Achse [from0,to0] auf (Nah-Putz-Durchstoss der Durchgangswand).
const bandLo = b.offset - b.thickness / 2;
const bandHi = b.offset + b.thickness / 2;
const axisHoles: Array<[number, number]> = [];
for (const c of spanCutouts) {
const cLo = Math.min(c.offA, c.offB);
const cHi = Math.max(c.offA, c.offB);
if (bandLo < cHi - EPS && bandHi > cLo + EPS) axisHoles.push([c.from, c.to]);
}
const axisPieces = subtractSpans(from0, to0, axisHoles);
// (3) Decken-Subtraktion GEOMETRISCH pro Band (Replik der Schnitt-Boolean-
// Dominanz `toSection.ts::subtractDominantBands`): eine Decke mit STRIKT
// höherer Priorität schneidet dieses Band nur dort aus, wo ihr Grundriss-
// Umriss die Footprint-MITTELLINIE dieses Bandes überdeckt (nicht mehr der
// wandweite BBox-Test). Aussenschichten (Dämmung/Aussenputz), deren
// Mittellinie ausserhalb des Decken-Umrisses liegt, laufen ununterbrochen
// durch — nur der überdeckte Kern verliert das Decken-z-Intervall. Ohne
// auflösbare Band-Priorität bleibt die volle Höhe (konservativ).
const bandZCuts: Array<{ from: number; to: number; zBottom: number; zTop: number }> = [];
if (b.priority !== undefined) {
for (const cc of ceilingCutters) {
if (cc.priority <= b.priority) continue;
for (const [ca, cb] of bandCoverageIntervals(wall, u, n, b.offset, cc.outline, from0, to0)) {
bandZCuts.push({ from: ca, to: cb, zBottom: cc.zBottom, zTop: cc.zTop });
}
}
}
// Öffnungs-Löcher, die ein z-Teilstück vertikal berühren, auf den Box-Start
// `a` umrechnen (render3d klemmt u/z selbst auf die Box).
const emitPiece = (a: number, bb: number, zb: number, zt: number): void => {
const segHoles = holes
.filter((h) => h.zTop > zb + EPS && h.zBottom < zt - EPS)
.map((h) => ({ from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop }));
pushSegment(out, wall, a, bb, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, segHoles, bandCutMeta(b));
};
for (const [a, bb] of axisPieces) {
// Nur die Decken-z-Schnitte, die dieses Achsen-Teilstück wirklich treffen.
const local = bandZCuts
.map((c) => ({ from: Math.max(a, c.from), to: Math.min(bb, c.to), zBottom: c.zBottom, zTop: c.zTop }))
.filter((c) => c.to > c.from + EPS);
if (local.length === 0) {
// Kein Decken-Überlapp auf diesem Achsenstück ⇒ volle Höhe (Band läuft durch).
emitPiece(a, bb, zBottom, zTop);
continue;
}
// Achse [a,bb] an allen Überdeckungs-Grenzen in elementare Teilstücke
// zerlegen; je Teilstück nur die dort greifenden Decken-z-Intervalle
// subtrahieren (unbedeckte Teilstücke behalten die volle Höhe).
const bounds = new Set<number>([a, bb]);
for (const c of local) {
bounds.add(c.from);
bounds.add(c.to);
}
const xs = [...bounds].sort((p, q) => p - q);
for (let i = 0; i < xs.length - 1; i++) {
const xa = xs[i];
const xb = xs[i + 1];
if (xb - xa <= EPS) continue;
const mid = (xa + xb) / 2;
const zCutters = local
.filter((c) => c.from <= mid && c.to >= mid)
.map((c): [number, number] => [c.zBottom, c.zTop]);
// Terminierung (Zuschnitt) statt reiner z-Subtraktion: bei "below"/"above"
// endet die Wand an der Decke, statt oben wieder aufzutauchen (s.
// {@link terminateOrSubtractSpans}). "both"/undefined = heutiges Verhalten.
for (const [zb, zt] of terminateOrSubtractSpans(zBottom, zTop, zCutters, wall.sliceTermination)) {
emitPiece(xa, xb, zb, zt);
}
}
}
}
return;
}
// ── Schnitt-Pfad (layered=false): unveränderte Pfeiler/Brüstung/Sturz-
// Segmentierung (Owner-Kompatibilität mit toSection.ts::wallSegmentOwners).
// Ohne Aussparungen: ein durchgehender Quader je Schicht.
if (cutouts.length === 0) {
emitSeg(0, axisLen, zBottom, zTop);
return;
}
// Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Aussparungen,
// Brüstung/Sturz im Öffnungsbereich. Überlappende Cutouts werden über `cursor`
// zusammengefasst.
let cursor = 0;
for (const { from, to, oBottom, oTop } of cutouts) {
const segFrom = Math.max(cursor, from);
if (from > cursor) {
// Voller Wandpfeiler bis zur Aussparung.
emitSeg(cursor, from, zBottom, zTop);
}
if (to > segFrom) {
// Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom).
if (oBottom > zBottom + EPS) {
emitSeg(segFrom, to, zBottom, oBottom);
}
// Sturz über der Öffnung (bis zum Wandkopf).
if (oTop < zTop - EPS) {
emitSeg(segFrom, to, oTop, zTop);
}
}
cursor = Math.max(cursor, to);
}
// Restlicher Wandpfeiler bis zum Achsenende.
if (cursor < axisLen) {
emitSeg(cursor, axisLen, zBottom, zTop);
}
}
/** Neutraler Beton-Grauton für Treppen (wie der Three.js-Pfad `addStairMeshes`). */
const STAIR_RGB: RRgb = hexToRgb("#9aa0a6", SLAB_RGB);
/**
* Emittiert Treppen als RSlabs (extrudierte Grundriss-Polygone) — der WASM/wgpu-
* Renderer kennt sonst KEINE Treppen (nur Wände/Decken/Meshes), sodass Treppen im
* Standard-3D unsichtbar blieben. Jeder Tritt ist ein extrudiertes Polygon; das
* ist exakt eine RSlab. Tragart über {@link getStairType}:
* • "massiv" / kein Typ — Vollblock je Tritt von der Treppen-UK bis zur Tritt-OK
* (gestuftes Massiv, Treppe auf Erdreich/Sockel).
* • "beton" — Ortbeton-Laufplatte: je Tritt ein Block von der SCHRÄGEN Untersicht
* (eine Stufe + Laufplattendicke unter der Tritt-OK, an der Treppen-UK gekappt)
* bis zur Tritt-OK. Die stufig gestapelten Blöcke bilden die schräge, UNTEN
* OFFENE Untersicht der klassischen Betontreppe.
* • "wange"/"aufgesattelt"/"spindel" — nur eine dünne Tritt-Platte der Dicke
* `treadThickness` an der Tritt-OK, darunter offen.
* Podest (L/Wendel) als Vollblock bis zur halben Steighöhe. Setzstufen/Nase/
* Geländer sind Verfeinerungen (Three.js-Pfad) — hier zählt zuerst die SICHTBARKEIT.
* `ceilingId` trägt hier die Treppen-Id (nur TS-seitiges Tagging; render3d ignoriert
* das Feld, und der Pick läuft separat über {@link pickGeometry}).
*/
const CONCRETE_WAIST = 0.18; // Laufplattendicke einer Ortbetontreppe (Meter)
function emitStairs(project: Project): RSlab[] {
const out: RSlab[] = [];
for (const stair of project.stairs ?? []) {
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const totalRise = zTop - zBottom;
if (totalRise <= EPS) continue;
const geo = stairGeometry(stair, totalRise);
const st = getStairType(project, stair);
const structure = st?.structure ?? "massiv";
// Gerade Betontreppe: die GLATTE Laufplatte kommt als echtes Mesh
// (emitStairMeshes) — hier keine Slab-Näherung emittieren (sonst doppelt).
if (structure === "beton" && stair.shape === "straight") continue;
const treadThickness = st?.treadThickness ?? 0.04;
const riser = geo.riserHeight;
for (const tr of geo.treads) {
if (tr.pts.length < 3) continue;
const top = zBottom + tr.topRise;
const bottom =
structure === "massiv"
? zBottom // Vollblock bis zur Treppen-UK
: structure === "beton"
? Math.max(zBottom, top - riser - CONCRETE_WAIST) // schräge Laufplatte
: top - treadThickness; // offene Tritte (Wange/aufgesattelt/Spindel)
if (top - bottom <= EPS) continue;
out.push({
outline: tr.pts.map((p) => [p.x, p.y] as RVec2),
zBottom: bottom,
zTop: top,
color: STAIR_RGB,
ceilingId: stair.id,
});
}
if (geo.landing && geo.landing.length >= 3) {
out.push({
outline: geo.landing.map((p) => [p.x, p.y] as RVec2),
zBottom,
zTop: zBottom + totalRise * 0.5,
color: STAIR_RGB,
ceilingId: stair.id,
});
}
}
return out;
}
/**
* Glatte Ortbeton-Laufplatte (Tragart "beton") als echtes Dreiecks-Mesh — nur für
* GERADE Läufe. Anders als die stufige Slab-Näherung ({@link emitStairs}) entsteht
* eine SPIEGELGLATTE schräge Untersicht: je Tritt ein konvexes Prisma mit
* HORIZONTALER Oberkante (= Trittfläche) und SCHRÄGER Unterkante auf EINER
* gemeinsamen Soffit-Geraden. Weil alle Prismen-Unterkanten exakt auf dieser
* Geraden liegen, bildet die Vereinigung eine durchgehende glatte Schräge; die
* Höhensprünge der Oberkanten bleiben als sichtbare Setzstufen. Prismen sind
* konvex → triviale, robuste Triangulierung (kein Ear-Clipping). Positions in
* MODELL-Metern (x, y, z=Höhe); render3d bildet nach world (x, z, y) ab.
*/
function emitStairMeshes(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const stair of project.stairs ?? []) {
const st = getStairType(project, stair);
if (!st || st.structure !== "beton") continue;
if (stair.shape !== "straight") continue; // glattes Mesh nur für gerade Läufe
if (stair.runLength <= EPS) continue;
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const totalRise = zTop - zBottom;
if (totalRise <= EPS) continue;
const geo = stairGeometry(stair, totalRise);
const u = normalize(stair.dir); // Laufrichtung (Grundriss)
const w = leftNormal(u); // Querrichtung (Breite)
const runTotal = stair.runLength;
// Laufplattendicke (vertikal): an die Steigung gekoppelt, geklemmt.
const waistV = Math.min(0.24, Math.max(0.14, geo.riserHeight * 1.2));
// Soffit-Höhe entlang des Laufs: eine Gerade PARALLEL zur Trittkantenlinie
// (Steigung totalRise/runTotal), um `waistV` nach unten versetzt → KONSTANTE
// Plattendicke über den ganzen Lauf (sonst würde die Platte nach oben dicker).
// An der Treppen-UK gekappt, damit die unterste Stufe auf dem Boden aufsitzt
// statt darunter zu schweben.
const soffitH = (runS: number) => {
const s = Math.min(Math.max(runS, 0), runTotal);
return Math.max(zBottom, zBottom + (s / runTotal) * totalRise - waistV);
};
const runOf = (p: Vec2) => (p.x - stair.start.x) * u.x + (p.y - stair.start.y) * u.y;
const sideOf = (p: Vec2) => (p.x - stair.start.x) * w.x + (p.y - stair.start.y) * w.y;
const positions: number[] = [];
const indices: number[] = [];
for (const tr of geo.treads) {
if (tr.pts.length < 4) continue;
// Vier Eckpunkte nach Lauf-Parameter trennen (2 kleinste = Startkante, 2
// grösste = Endkante), je Kante nach Quer-Parameter sortieren (0 = links).
const byRun = [...tr.pts].sort((a, b) => runOf(a) - runOf(b));
const startEdge = [byRun[0], byRun[1]].sort((a, b) => sideOf(a) - sideOf(b));
const endEdge = [byRun[2], byRun[3]].sort((a, b) => sideOf(a) - sideOf(b));
const runS = Math.min(runOf(byRun[0]), runOf(byRun[1]));
const runE = Math.max(runOf(byRun[2]), runOf(byRun[3]));
const topH = zBottom + tr.topRise;
const bS = soffitH(runS);
const bE = soffitH(runE);
if (topH - Math.max(bS, bE) <= EPS) continue;
const base = positions.length / 3;
const push = (p: Vec2, z: number) => positions.push(p.x, p.y, z);
// 0/1 Startkante unten (soffit), 2/3 Endkante unten, 4/5 Start oben, 6/7 End oben.
push(startEdge[0], bS); push(startEdge[1], bS);
push(endEdge[0], bE); push(endEdge[1], bE);
push(startEdge[0], topH); push(startEdge[1], topH);
push(endEdge[0], topH); push(endEdge[1], topH);
const quad = (a: number, b: number, c: number, d: number) =>
indices.push(base + a, base + b, base + c, base + a, base + c, base + d);
quad(0, 1, 3, 2); // Untersicht (schräge Soffit-Fläche)
quad(4, 6, 7, 5); // Oberseite (Trittfläche)
quad(0, 4, 5, 1); // Startkante (Setzstufen-Front der Vorstufe sichtbar)
quad(2, 3, 7, 6); // Endkante
quad(0, 2, 6, 4); // linke Wange
quad(1, 5, 7, 3); // rechte Wange
}
if (indices.length === 0) continue;
out.push({ positions, indices, kind: "extrusion", color: STAIR_RGB });
}
return out;
}
/** Emittiert die Deckenplatten eines Projekts (falls vorhanden). */
function emitSlabs(project: Project): RSlab[] {
const out: RSlab[] = [];
for (const c of project.ceilings ?? []) {
if (!c.outline || c.outline.length < 3) continue;
// zBottom/zTop (und damit die Deckendicke) berechnet ceilingVerticalExtent
// bereits über ceilingThickness (respektiert Typ-Dicke + Übersteuerung).
const { zBottom, zTop } = ceilingVerticalExtent(project, c);
if (zTop - zBottom <= EPS) continue;
const outline = c.outline.map((p) => [p.x, p.y] as RVec2);
// Geschichteter Bodenaufbau: je Schicht des Deckentyps EIN eigener Slab,
// von oben (OK, erste Lage) nach unten (UK, letzte Lage) gestapelt und
// proportional auf die tatsächliche z-Spanne [zBottom, zTop] verteilt (wie
// die Lagen-Summe bei den Wänden). Jede Lage trägt ihre Bauteil-Schraffur +
// -Farbe — so zeigt der 3D-Schnitt den Bodenaufbau GESCHICHTET (Estrich/
// Dämmung/Beton …), identisch zum 2D-Schnitt, statt eines Einkörpers in der
// dominanten Schicht. Kein auflösbarer Deckentyp / keine Lagen ⇒ EIN
// Vollkörper-Slab in SLAB_RGB (bisheriges Fallback-Verhalten).
let layers: Layer[] | undefined;
try {
layers = getCeilingType(project, c).layers;
} catch {
layers = undefined;
}
const total = layers?.reduce((s, l) => s + l.thickness, 0) ?? 0;
if (layers && layers.length > 0 && total > EPS) {
const span = zTop - zBottom;
let prefixTop = 0; // von OK (zTop) nach unten aufsummiert (Meter)
for (const l of layers) {
const t = (l.thickness / total) * span;
const layerTop = zTop - prefixTop;
const layerBottom = layerTop - t;
prefixTop += t;
let color: RRgb = SLAB_RGB;
let cut: RCutMeta | undefined;
try {
const comp = getComponent(project, l.componentId);
color = hexToRgb(comp.color);
// Schnitt-Metadaten der Schicht: joinPriority + Identität (für die
// Boolean-Dominanz/MERGE) + Schnittkanten-Stil aus jointLineStyleId.
const lineStyle = resolveCutLineStyle(project, l.jointLineStyleId);
cut = {
joinPriority: comp.joinPriority,
componentId: comp.id,
...(lineStyle?.color ? { lineColor: lineStyle.color } : {}),
...(lineStyle?.weight != null ? { lineWeight: lineStyle.weight } : {}),
};
} catch {
color = SLAB_RGB;
cut = undefined;
}
const hatch = resolveComponentHatch(project, l.componentId);
out.push({
outline,
zBottom: layerBottom,
zTop: layerTop,
color,
ceilingId: c.id,
...(hatch ? { hatch } : {}),
...(cut ? { cut } : {}),
});
}
} else {
out.push({ outline, zBottom, zTop, color: SLAB_RGB, ceilingId: c.id });
}
}
return out;
}
/** Event-Name für den "Extrusion(en) geladen"-Stoss (s. Wasm3DViewport.tsx/nativeSync.ts). */
export const TRUCK_MESH_READY_EVENT = "dossier:truck-mesh-ready";
/**
* Extrudiert `project.extrudedSolids` (truck-Integration, `docs/design/
* truck-plan.md`) zu Kontext-Meshes. `extrudePolygon` lädt WASM asynchron;
* `emitMeshes`/`emitExtrudedSolids` selbst bleiben synchron (Aufrufer wie
* `updateModel`/`pushNativeWalls` erwarten das) — daher ein modulweiter Cache
* je Solid-Id, der bei fehlendem/veraltetem Eintrag die Extrusion im
* Hintergrund anstösst und `TRUCK_MESH_READY_EVENT` feuert, sobald sie fertig
* ist (Wasm3DViewport/nativeSync hören darauf und re-pushen das Modell — ohne
* das Signal käme eine fertige Extrusion trotz Erfolgs NIE ins Bild, weil
* `updateModel` nur bei PROJEKT-Änderungen erneut läuft). Nur im Browser (kein
* `window` unter vitest/node) und mit stillem Fallback bei Fehlern.
*/
interface TruckCacheEntry {
/** Signatur (Punkte+Höhe) des Solids beim Laden — invalidiert den Eintrag bei Änderung. */
signature: string;
/** null, solange die Extrusion noch lädt. */
mesh: RMesh | null;
}
const truckMeshCache = new Map<string, TruckCacheEntry>();
function extrudedSolidSignature(solid: ExtrudedSolid): string {
return `${solid.height}|${solid.taper ?? 0}|${solid.points.map((p) => `${p.x},${p.y}`).join(";")}`;
}
function emitExtrudedSolids(project: Project): RMesh[] {
if (typeof window === "undefined") return [];
const out: RMesh[] = [];
// Cache-Einträge gelöschter/nicht mehr existierender Körper verwerfen (sonst
// wächst der Cache unbegrenzt über eine Session, z. B. nach Extrudieren →
// Kopieren → Löschen in Serie).
const liveIds = new Set((project.extrudedSolids ?? []).map((s) => s.id));
for (const id of truckMeshCache.keys()) {
if (!liveIds.has(id)) truckMeshCache.delete(id);
}
for (const solid of project.extrudedSolids ?? []) {
if (solid.points.length < 3 || solid.height <= 0) continue;
const signature = extrudedSolidSignature(solid);
let entry = truckMeshCache.get(solid.id);
if (!entry || entry.signature !== signature) {
// Die bisherige Mesh (falls vorhanden) WEITER anzeigen, bis die neue
// Extrusion fertig geladen ist — sonst verschwindet der Körper kurz,
// während der Nutzer Höhe/Verjüngung im Attribute-Panel tippt (jeder
// Tastendruck feuert onChange → neue Signatur).
const staleMesh = entry?.mesh ?? null;
entry = { signature, mesh: staleMesh };
truckMeshCache.set(solid.id, entry);
let baseZ = 0;
try {
baseZ = getFloor(project, solid.levelId).baseElevation ?? 0;
} catch {
// Geschoss nicht (mehr) auflösbar: bei UK 0 extrudieren (Fallback).
}
// Kreis-Profil: echte runde truck-Extrusion (extrudeCircle) statt der
// 48-Eck-Tessellierung in `points` — glattes Zylinder-Mesh.
const taper = solid.taper ?? 0;
const extrusion = solid.circle
? extrudeCircle(solid.circle.center.x, solid.circle.center.y, solid.circle.r, solid.height, taper)
: extrudePolygon(solid.points.flatMap((p) => [p.x, p.y]), solid.height, taper);
extrusion
.then((mesh) => {
// truck extrudiert immer ab z=0 — auf die Geschoss-UK anheben.
const positions =
baseZ === 0
? mesh.positions
: mesh.positions.map((v, i) => (i % 3 === 2 ? v + baseZ : v));
const cur = truckMeshCache.get(solid.id);
if (!cur || cur.signature !== signature) return; // inzwischen veraltet
cur.mesh = { positions, indices: mesh.indices, kind: "extrusion", color: EXTRUSION_RGB };
window.dispatchEvent(new Event(TRUCK_MESH_READY_EVENT));
})
.catch((e) => console.warn("truck-Extrusion fehlgeschlagen:", e));
}
if (entry.mesh) out.push(entry.mesh);
}
return out;
}
/**
* Strecken eines 2D-Zeichenelements für die Boden-Spur im 3D — exakt dieselbe
* Teilmenge wie `drawing2DSegments` im drei.js-Fallback (`Viewport3D.tsx`):
* line/polyline/rect. Kreis/Bogen/Text bleiben aussen vor (keine neue
* Fähigkeit gegenüber dem Fallback, nur Parität).
*/
function drawingSegments3d(d: Drawing2D): [Vec2, Vec2][] {
const g = d.geom;
const out: [Vec2, Vec2][] = [];
if (g.shape === "line") out.push([g.a, g.b]);
else if (g.shape === "polyline") {
for (let i = 0; i < g.pts.length - 1; i++) out.push([g.pts[i], g.pts[i + 1]]);
if (g.closed && g.pts.length > 2) out.push([g.pts[g.pts.length - 1], g.pts[0]]);
} else if (g.shape === "rect") {
const c1 = g.min, c3 = g.max;
const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y };
out.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]);
}
return out;
}
/** Modellfarbe einer 2D-Zeichnung (color → LineStyle → Kategorie), analog `drawing2DColor`. */
function drawingColor3d(project: Project, d: Drawing2D): RRgb {
if (d.color) return hexToRgb(d.color, DRAWING_LINE_FALLBACK_RGB);
const ls = d.lineStyleId ? project.lineStyles.find((l) => l.id === d.lineStyleId) : undefined;
if (ls?.color) return hexToRgb(ls.color, DRAWING_LINE_FALLBACK_RGB);
const cat = flattenCategories(project.layers).find((c) => c.code === d.categoryCode);
return cat?.color ? hexToRgb(cat.color, DRAWING_LINE_FALLBACK_RGB) : DRAWING_LINE_FALLBACK_RGB;
}
/**
* Emittiert je 2D-Zeichnung ein dünnes Ribbon-Mesh knapp über ihrer
* Geschossebene — die "2D-Referenz im 3D" (z. B. ein Boden-Raster), die der
* drei.js-Fallback (`addDrawing2DLines`) zeigt, bisher aber im nativen
* render3d/wgpu-Pfad fehlte (`RModel3d` kannte nur walls/slabs/meshes).
* `positions` in MODELL-Metern (x, y, z=Höhe) wie `emitOpeningGlass`; render3d
* rendert Kontext-Meshes ohnehin doppelseitig (`append_context_mesh`), daher
* genügt ein flaches Ribbon (kein Quader) pro Strecke.
*/
function emitDrawingLines(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const d of project.drawings2d) {
const segs = drawingSegments3d(d);
if (segs.length === 0) continue;
let baseZ = 0;
try {
baseZ = getFloor(project, d.levelId).baseElevation ?? 0;
} catch {
// Geschoss nicht (mehr) auflösbar: bei UK 0 zeichnen (Fallback).
}
const elev = baseZ + DRAWING_LINE_ELEVATION_EPS;
const positions: number[] = [];
const indices: number[] = [];
for (const [a, b] of segs) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) continue;
const nx = (-dy / len) * DRAWING_LINE_HALF_WIDTH;
const ny = (dx / len) * DRAWING_LINE_HALF_WIDTH;
const base = positions.length / 3;
positions.push(
a.x + nx, a.y + ny, elev,
a.x - nx, a.y - ny, elev,
b.x - nx, b.y - ny, elev,
b.x + nx, b.y + ny, elev,
);
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
}
if (positions.length === 0) continue;
out.push({ positions, indices, kind: "imported", color: drawingColor3d(project, d) });
}
return out;
}
/**
* 3D-Farbe einer Stütze: die Bauteil-Albedo (`Component.color`), falls ein
* `componentId` auflösbar ist, sonst der neutrale Tragwerks-Grauton {@link
* COLUMN_RGB}. Eine explizite `column.color` (Grundriss-Umrissfarbe) wirkt
* bewusst NICHT auf die 3D-Fläche (nur der 2D-Umriss), damit die 3D-Darstellung
* material-/neutral bleibt — analog Wand (Umrissfarbe ≠ 3D-Albedo).
*/
function columnColor3d(project: Project, col: Column): RRgb {
if (col.componentId) {
try {
return hexToRgb(getComponent(project, col.componentId).color, COLUMN_RGB);
} catch {
return COLUMN_RGB;
}
}
return COLUMN_RGB;
}
/**
* Baut ein geschlossenes Prisma-Mesh aus einem konvexen Grundriss-Polygon
* `foot` (≥3 Punkte, CCW) zwischen `zBottom` und `zTop`. `positions` in MODELL-
* Metern (x, y, z=Höhe) wie {@link emitMeshes}; render3d rendert Kontext-Meshes
* doppelseitig, daher ist die Winding-Reihenfolge unerheblich. Die Deckel werden
* als Dreiecks-Fächer ab dem ersten Vertex tesselliert (konvex → korrekt).
*/
function prismMesh(foot: Vec2[], zBottom: number, zTop: number, color: RRgb): RMesh {
const n = foot.length;
const positions: number[] = [];
const indices: number[] = [];
// 0..n-1 = untere Ring-Vertices, n..2n-1 = obere Ring-Vertices.
for (const p of foot) positions.push(p.x, p.y, zBottom);
for (const p of foot) positions.push(p.x, p.y, zTop);
// Mantelflächen: je Kante ein Quad (2 Dreiecke).
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const b0 = i, b1 = j, t0 = i + n, t1 = j + n;
indices.push(b0, b1, t1, b0, t1, t0);
}
// Deckel (Fächer ab Vertex 0): unten + oben.
for (let i = 1; i < n - 1; i++) {
indices.push(0, i, i + 1); // unten
indices.push(n, n + i, n + i + 1); // oben
}
return { positions, indices, kind: "extrusion", color };
}
/**
* Extrudiert `project.columns` (Tragwerk) zu parametrischen Prisma-Meshes —
* Rechteck-Box bzw. tesselliertes Kreis-Prisma, je Profil. SYNCHRON (anders als
* die truck-Extrusion der `ExtrudedSolid`s, {@link emitExtrudedSolids}): die
* Kontur kommt aus {@link columnFootprint}, die vertikale Lage aus {@link
* columnVerticalExtent} (UK/OK-Anker bzw. Geschoss-UK + Höhe). Rust/render3d
* bleibt unverändert (Kontext-Mesh-Pfad).
*/
function emitColumns(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const col of project.columns ?? []) {
if (col.height <= 0) continue;
const foot = columnFootprint(col);
if (foot.length < 3) continue;
const { zBottom, zTop } = columnVerticalExtent(project, col);
if (zTop - zBottom <= EPS) continue;
out.push(prismMesh(foot, zBottom, zTop, columnColor3d(project, col)));
}
return out;
}
/**
* Emittiert die rohen Kontext-Meshes eines Projekts: Gelände-TINs (`terrainMesh`)
* und importierte Volumen (`importedMesh`) aus `project.context`. `contourSet`
* bleibt außen vor — das sind nur 2D-Linien (Höhenlinien/Footprints), keine
* Dreiecksmeshes. Die `positions`/`indices` werden 1:1 durchgereicht (Modell-
* Koordinaten, x,y,z=Höhe); render3d bildet sie selbst nach world ab.
*/
function emitMeshes(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const obj of project.context ?? []) {
if (obj.type === "terrainMesh" || obj.type === "importedMesh") {
// Leere/entartete Meshes überspringen (render3d würde sie ohnehin verwerfen).
if (obj.positions.length < 9 || obj.indices.length < 3) continue;
out.push({
positions: obj.positions,
indices: obj.indices,
kind: obj.type === "terrainMesh" ? "terrain" : "imported",
color: obj.type === "terrainMesh" ? TERRAIN_RGB : IMPORTED_RGB,
});
}
}
out.push(...emitExtrudedSolids(project));
out.push(...emitColumns(project));
out.push(...emitDrawingLines(project));
return out;
}
// Faces eines Boxen-Quaders als Dreiecks-Indizes (Ecken-Index-Bits: bit0 =
// Achse from/to, bit1 = Höhe UK/OK, bit2 = Quer-Versatz nMin/nMax). Doppelseitig
// gerendert (append_context_mesh) ⇒ Winding egal; je Fläche 2 Dreiecke, 6
// Flächen = 12 Dreiecke. Gemeinsame Topologie für Glasscheiben UND Rahmen-/
// Sprossen-Riegel (s. {@link openingAxisBox}).
const OPENING_BOX_TRIS: number[] = [
0, 1, 3, 0, 3, 2, // Quer-Versatz nMin
4, 5, 7, 4, 7, 6, // Quer-Versatz nMax
0, 1, 5, 0, 5, 4, // UK
2, 3, 7, 2, 7, 6, // OK
0, 2, 6, 0, 6, 4, // from
1, 3, 7, 1, 7, 5, // to
];
/**
* Baut EINEN geschlossenen Achsen-Quader (8 Ecken, 12 Dreiecke) für eine
* Öffnung — die gemeinsame Basis von {@link emitOpeningGlass} (Scheiben) und
* {@link emitOpeningFrames} (Rahmen-/Sprossen-Riegel). Achsen-Intervall
* `[from,to]` (Meter ab `wall.start`, entlang der Wandrichtung u), Höhen-
* Intervall `[zBottom,zTop]` (absolute Weltmeter) und Quer-Versatz-Intervall
* `[nMin,nMax]` (Meter entlang der Grundriss-Normalen n=(uy,ux), dieselbe
* Konvention wie die bisherige Glas-Box — `nMin`/`nMax` MÜSSEN nicht sortiert
* übergeben werden, die Box spannt zwischen beiden Werten). `positions` in
* MODELL-Metern (x, y, z=Höhe) wie {@link emitMeshes}. Liefert `null` bei
* entarteten Massen (Achse/Höhe/Dicke ≤ EPS) statt eines Nullquaders.
*/
function openingAxisBox(
wall: Wall,
from: number,
to: number,
zBottom: number,
zTop: number,
nMin: number,
nMax: number,
color: RRgb,
): RMesh | null {
if (to - from <= EPS) return null;
if (zTop - zBottom <= EPS) return null;
if (Math.abs(nMax - nMin) <= EPS) return null;
const dx = wall.end.x - wall.start.x;
const dy = wall.end.y - wall.start.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) return null;
const ux = dx / len;
const uy = dy / len;
// Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie pushSegment.
const nx = uy;
const ny = -ux;
const positions: number[] = [];
for (let i = 0; i < 8; i++) {
const s = i & 1 ? to : from;
const z = i & 2 ? zTop : zBottom;
const t = i & 4 ? nMax : nMin;
positions.push(wall.start.x + ux * s + nx * t, wall.start.y + uy * s + ny * t, z);
}
return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color };
}
/** Klemmt `v` auf `[lo,hi]`. */
function clampRange(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
/**
* Rahmen-Tiefe quer zur Wand (Meter): `frameThickness` des Typs, sonst dessen
* `frameDepth`, sonst die volle Wanddicke (Fallback, wenn der Typ keine
* brauchbare Tiefe nennt). Beide Typ-Felder sind wörtlich Meter ≤ 0 ⇒ ungültig
* (übersprungen, nächster Fallback greift).
*/
function resolveAcrossWallDepth(
frameDepth: number | undefined,
wallThickness: number,
): number {
// Rahmen-Tiefe quer zur Wand: explizite `frameDepth` gewinnt, sonst füllt der
// Rahmen die VOLLE Wanddicke (tiefe Laibung/Reveal). Das `frameThickness` ist
// die Profil-Breite des Rahmenstabs (2D/Ansicht), NICHT die Einbautiefe — es
// hier zu verwenden liess Fenster im 3D flach wirken (nur wenige cm tief).
if (frameDepth !== undefined && frameDepth > EPS) return frameDepth;
return wallThickness;
}
/**
* Quer-Versatz-Intervall `[nMin,nMax]` (Meter entlang der Wand-Normalen, s.
* {@link openingAxisBox}) des Rahmenkörpers — der 3D-Niederschlag des
* Schichteinzugs (`insetFromFace`/`insetFace`): die Rahmen-VORDERKANTE liegt
* bündig mit der gewählten Wandfläche, zurückversetzt um `insetFromFace`
* (0/fehlt ⇒ bündig), der Rahmenkörper erstreckt sich von dort aus `depth`
* Meter zur Wandmitte hin. "aussen" liegt an der POSITIVEN Normalen-Seite
* (kleinster Schicht-Offset in {@link pushSegment}, s. dessen Kommentar zur
* Aussenseiten-Konvention via `leftNormal`), "innen" an der negativen. Das
* Ergebnis wird auf die halbe Wanddicke geklemmt — ein Schichteinzug darf den
* Rahmen nie aus der Wand hinausschieben.
*/
function resolveFrameNormalRange(
halfThickness: number,
depth: number,
insetFromFace: number,
face: "aussen" | "innen",
): { nMin: number; nMax: number } {
const sign = face === "innen" ? -1 : 1;
const front = sign * (halfThickness - insetFromFace);
const back = front - sign * depth;
const a = clampRange(front, -halfThickness, halfThickness);
const b = clampRange(back, -halfThickness, halfThickness);
return { nMin: Math.min(a, b), nMax: Math.max(a, b) };
}
/**
* Aufgelöste Rahmen-/Sprossen-Parameter EINER Öffnung (Tür oder Fenster) aus
* ihrem {@link DoorType}/{@link WindowType} (via `getDoorType`/`getWindowType`).
* `null`, wenn kein Typ auflösbar ist (`Opening.typeId` fehlt/verwaist) — DANN
* emittieren weder {@link emitOpeningFrames} noch die Rahmen-/Sprossen-Logik in
* {@link emitOpeningGlass} irgendetwas (Rückwärtskompatibilität: unveränderte
* Alt-Darstellung, s. Moduldoc-Kommentar dort).
*/
interface OpeningFrameParams {
/** Sichtbare Rahmen-/Flügelprofil-Breite in der Öffnungsebene (Meter). */
frameWidth: number;
/** Rahmen-Tiefe quer zur Wand (Meter, informativ — steckt bereits in nMin/nMax). */
acrossWallDepth: number;
/** Quer-Versatz-Intervall des Rahmenkörpers (s. {@link resolveFrameNormalRange}). */
nMin: number;
nMax: number;
/** Oberlicht-Höhe (Meter); 0 = kein Oberlicht/Kämpfer. */
transomHeight: number;
/** Schwelle/Sohlbank zeichnen: Fenster immer, Tür nur mit `threshold`. */
hasSill: boolean;
/** Flügelzahl (vertikale Mittelpfosten = wingCount1); Türen immer 1. */
wingCount: number;
/** Kämpfer-Zeilen (horizontale Teilung = mullionRows1); Türen immer 1. */
mullionRows: number;
/** Ob eine Glasscheibe gezeichnet wird (Fenster immer, Tür nur `leafStyle: "glas"`). */
glazed: boolean;
}
function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null {
const wallThickness = wallThicknessSafe(project, wall);
const halfThickness = wallThickness / 2;
if (op.kind === "door") {
const dt = getDoorType(project, op);
if (!dt) return null;
const acrossWallDepth = resolveAcrossWallDepth(dt.frameDepth, wallThickness);
const { nMin, nMax } = resolveFrameNormalRange(
halfThickness,
acrossWallDepth,
dt.insetFromFace ?? 0,
dt.insetFace ?? "aussen",
);
return {
frameWidth: dt.frameWidth ?? DEFAULT_FRAME_WIDTH,
acrossWallDepth,
nMin,
nMax,
transomHeight: Math.max(0, dt.transomHeight ?? 0),
hasSill: dt.threshold === true,
wingCount: 1,
mullionRows: 1,
glazed: dt.leafStyle === "glas",
};
}
const wt = getWindowType(project, op);
if (!wt) return null;
const acrossWallDepth = resolveAcrossWallDepth(wt.frameDepth, wallThickness);
const { nMin, nMax } = resolveFrameNormalRange(
halfThickness,
acrossWallDepth,
wt.insetFromFace ?? 0,
wt.insetFace ?? "aussen",
);
return {
frameWidth: wt.frameWidth ?? DEFAULT_FRAME_WIDTH,
acrossWallDepth,
nMin,
nMax,
transomHeight: Math.max(0, wt.transomHeight ?? 0),
hasSill: true,
// Inline-Override `Opening.wingCount` gewinnt vor dem Typ-Default (analog
// `windowSymbol` im 2D, das dieselbe Priorität kennt), sonst Typ, sonst 1.
wingCount: Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1)),
mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)),
glazed: true,
};
}
/**
* Rahmen-/Sprossen-Riegel EINER Öffnung: Pfosten (links/rechts, volle
* Öffnungshöhe), Sturz (oben), Schwelle (nur wenn `params.hasSill`), optional
* ein Kämpfer-Riegel (Oberlicht-Trennung, `transomHeight>0`) sowie die
* Flügel-Mittelpfosten (`wingCount1`, vertikal) und Kämpfer-Zeilen
* (`mullionRows1`, horizontal) im Flügel-Bereich zwischen Schwelle/Sturz
* bzw. Schwelle/Kämpfer. Alle Riegel in {@link FRAME_RGB}.
*/
function frameMeshesForOpening(
wall: Wall,
iv: { from: number; to: number },
vert: { zBottom: number; zTop: number },
params: OpeningFrameParams,
fine = true,
): RMesh[] {
const out: RMesh[] = [];
const fw = params.frameWidth;
const push = (from: number, to: number, zBottom: number, zTop: number): void => {
const mesh = openingAxisBox(wall, from, to, zBottom, zTop, params.nMin, params.nMax, FRAME_RGB);
if (mesh) out.push(mesh);
};
// Pfosten (Laibungsseiten), über die volle Öffnungshöhe durchlaufend.
push(iv.from, iv.from + fw, vert.zBottom, vert.zTop);
push(iv.to - fw, iv.to, vert.zBottom, vert.zTop);
// Sturz (oberer Riegel).
push(iv.from + fw, iv.to - fw, vert.zTop - fw, vert.zTop);
// Schwelle: Fenster immer, Tür nur mit Bodenanschlag (threshold).
if (params.hasSill) push(iv.from + fw, iv.to - fw, vert.zBottom, vert.zBottom + fw);
// Flügel-/Sash-Bereich zwischen Schwelle und Sturz, ggf. durch den Kämpfer
// (Oberlicht-Trennung) oben beschnitten.
const sashFrom = iv.from + fw;
const sashTo = iv.to - fw;
const sashBottom = vert.zBottom + (params.hasSill ? fw : 0);
let sashTop = vert.zTop - fw;
if (params.transomHeight > EPS) {
const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTop);
push(sashFrom, sashTo, splitZ - fw / 2, splitZ + fw / 2); // Kämpfer-Riegel
sashTop = splitZ - fw / 2;
}
// Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen) nur bei FEINEM Detailgrad —
// bei „mittel" bleibt es beim reinen Rahmen (+ Oberlicht-Kämpfer oben).
if (fine) {
// Flügel-Mittelpfosten (vertikal, wingCount1) im Sash-Bereich.
if (params.wingCount > 1 && sashTo - sashFrom > EPS) {
const cell = (sashTo - sashFrom) / params.wingCount;
for (let i = 1; i < params.wingCount; i++) {
const center = sashFrom + i * cell;
push(center - fw / 2, center + fw / 2, sashBottom, sashTop);
}
}
// Kämpfer-Zeilen (horizontal, mullionRows1) im Sash-Bereich.
if (params.mullionRows > 1 && sashTop - sashBottom > EPS) {
const cell = (sashTop - sashBottom) / params.mullionRows;
for (let i = 1; i < params.mullionRows; i++) {
const center = sashBottom + i * cell;
push(sashFrom, sashTo, center - fw / 2, center + fw / 2);
}
}
}
return out;
}
/**
* Glasscheibe(n) EINER typisierten Öffnung (Fenster immer, Tür nur bei
* `params.glazed`): eine Hauptscheibe im Flügel-Bereich (zwischen Schwelle und
* Sturz bzw. Kämpfer), plus — bei `transomHeight>0` — eine ZWEITE Scheibe im
* Oberlicht-Band oberhalb des Kämpfers. Die Sprossen/Mittelpfosten aus
* {@link frameMeshesForOpening} liegen VOR dieser einen Hauptscheibe (keine
* Pro-Feld-Aufteilung — bewusst einfach gehalten, s. Datei-weite Erläuterung).
*/
function glassPanesForOpening(
wall: Wall,
iv: { from: number; to: number },
vert: { zBottom: number; zTop: number },
params: OpeningFrameParams,
): RMesh[] {
const out: RMesh[] = [];
const fw = params.frameWidth;
const sashFrom = iv.from + fw;
const sashTo = iv.to - fw;
const sashBottom = vert.zBottom + (params.hasSill ? fw : 0);
const sashTopFull = vert.zTop - fw;
// Die Scheibe ist eine DÜNNE Tafel mittig in der (nun tiefen) Rahmen-Laibung —
// nicht der volle Rahmen-Querschnitt, sonst wäre das „Glas" ein dicker Block
// und die Laibung/Reveal-Tiefe unsichtbar. So liest sich das Fenster plastisch:
// Rahmen zieht in die Wand, die Scheibe sitzt zurückgesetzt in der Mitte.
const glassMid = (params.nMin + params.nMax) / 2;
const gN0 = glassMid - GLASS_HALF_THICK;
const gN1 = glassMid + GLASS_HALF_THICK;
const push = (zBottom: number, zTop: number): void => {
const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, gN0, gN1, GLASS_RGB);
if (mesh) out.push(mesh);
};
if (params.transomHeight > EPS) {
const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTopFull);
push(sashBottom, splitZ - fw / 2); // Hauptscheibe/-blatt unterhalb des Kämpfers.
push(splitZ + fw / 2, sashTopFull); // Oberlicht-Scheibe oberhalb des Kämpfers.
} else {
push(sashBottom, sashTopFull);
}
return out;
}
/**
* Emittiert je Fensteröffnung eine Glasscheibe (Türen nur bei glasfülligem
* Blatt, `leafStyle: "glas"`) als rohes Quader-Mesh — damit Fenster im 3D als
* Fenster lesbar sind statt als blosse Löcher in der Wand.
*
* OHNE auflösbaren Typ (`getWindowType`/`getDoorType` liefert `undefined`,
* z. B. `Opening.typeId` fehlt): unverändertes ALT-Verhalten — EINE Vollscheibe
* über die geklemmte Öffnungs-Achse ({@link openingInterval}) und -Höhe
* ({@link openingVerticalExtent}), quer ±{@link GLASS_HALF_THICK} mittig in der
* Wand (Rückwärtskompatibilität, s. Moduldoc-Kommentar).
*
* MIT Typ: die Scheibe(n) sitzen im Flügel-Bereich INNERHALB des Rahmens (s.
* {@link glassPanesForOpening}), quer entlang des über den Schichteinzug
* bestimmten Rahmen-Versatzes (s. {@link resolveFrameNormalRange}) statt fix
* mittig — bei Oberlicht (`transomHeight>0`) kommt eine zweite Scheibe im
* Oberlicht-Band dazu. Die zugehörigen Rahmen-/Sprossen-Riegel emittiert
* separat {@link emitOpeningFrames} (eigene Farbe, eigene Funktion — leichter
* einzeln zu testen/filtern als eine gemeinsame Emission).
*
* `positions` liegen — wie bei {@link emitMeshes} (Terrain/Import) — in MODELL-
* Metern als flaches (x, y, z=Höhe)-Array; render3d bildet sie selbst nach world
* (x, Höhe, y) ab und rendert die Meshes doppelseitig (append_context_mesh),
* daher ist die Winding-Reihenfolge der Dreiecke unerheblich. `kind: "imported"`
* mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB.
*/
function emitOpeningGlass(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const wall of project.walls) {
for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op);
if (!iv) continue;
const vert = openingVerticalExtent(project, wall, op);
if (vert.zTop - vert.zBottom <= EPS) continue;
if (op.kind === "window") {
const params = resolveOpeningFrame(project, wall, op);
if (!params) {
// Kein Typ auflösbar -> Alt-Verhalten (fixe Vollscheibe, mittig).
const mesh = openingAxisBox(
wall,
iv.from,
iv.to,
vert.zBottom,
vert.zTop,
-GLASS_HALF_THICK,
GLASS_HALF_THICK,
GLASS_RGB,
);
if (mesh) out.push(mesh);
continue;
}
out.push(...glassPanesForOpening(wall, iv, vert, params));
} else {
// Tür: nur bei glasfülligem Blatt eine Scheibe (Alt-Verhalten: offen).
const params = resolveOpeningFrame(project, wall, op);
if (params?.glazed) out.push(...glassPanesForOpening(wall, iv, vert, params));
}
}
}
return out;
}
/**
* Emittiert je Öffnung MIT auflösbarem Typ ({@link getDoorType}/
* {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz,
* Schwelle, Kämpfer, Mittelpfosten — s. {@link frameMeshesForOpening}) als rohe
* Quader-Meshes in {@link FRAME_RGB}. Öffnungen OHNE auflösbaren Typ (`typeId`
* fehlt/verwaist) bekommen KEINEN Rahmen — exakt das Alt-Verhalten vor der
* Typ-Vertiefung (Rückwärtskompatibilität).
*/
function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMesh[] {
// Grob: keine Rahmen/Sprossen — die Öffnung bleibt Loch + Scheibe (schlank).
if (detail === "grob") return [];
const fine = detail === "fein";
const out: RMesh[] = [];
for (const wall of project.walls) {
for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op);
if (!iv) continue;
const vert = openingVerticalExtent(project, wall, op);
if (vert.zTop - vert.zBottom <= EPS) continue;
const params = resolveOpeningFrame(project, wall, op);
if (!params) continue;
out.push(...frameMeshesForOpening(wall, iv, vert, params, fine));
}
}
return out;
}
/**
* Nur die Wände (Rückwärts-Kompatibilität / bestehende Aufrufer). Zerlegt Wände
* mit Öffnungen in Teilquader.
*/
export function projectToWalls3d(project: Project, layered = true): RWall[] {
const out: RWall[] = [];
// Verschneidungs-Schnittlinien PRO GESCHOSS berechnen (wie generatePlan.ts sie
// je Grundriss auf der gefilterten Wandmenge bildet) — nur für den layered-3D-
// Pfad. Nach Geschoss gruppieren, damit deckungsgleiche Wände verschiedener
// Geschosse (z. B. EG/OG an identischen XY-Knoten) NICHT fälschlich verschneiden.
// Der Schnitt-Pfad (layered=false) bekommt KEINE Cuts (unverändert, seine
// Dominanz macht toSection.ts). Anders als im 2D wird hier NICHT nach
// Sichtbarkeit gefiltert — 3D zeigt alle Wände, alle nehmen an den Joins teil.
const joins = layered ? computeJoinsByFloor(project) : undefined;
for (const w of project.walls) emitWall(out, project, w, layered, joins?.get(w.id));
return out;
}
/**
* Berechnet {@link computeJoins} getrennt je Geschoss (`wall.floorId`) und führt
* die Ergebnis-Maps zu einer flachen `wallId → WallCuts` zusammen. So bilden nur
* Wände desselben Geschosses gemeinsame Ecken/T-Stösse (identisch zur 2D-Semantik,
* wo `generatePlan` je Grundriss aufruft).
*/
function computeJoinsByFloor(project: Project): Map<string, WallCuts> {
const byFloor = new Map<string, Wall[]>();
for (const w of project.walls) {
const key = w.floorId ?? "";
const list = byFloor.get(key);
if (list) list.push(w);
else byFloor.set(key, [w]);
}
const merged = new Map<string, WallCuts>();
for (const walls of byFloor.values()) {
const cuts = computeJoins(project, walls);
for (const [id, wc] of cuts) merged.set(id, wc);
}
return merged;
}
/**
* Optionen für {@link projectToModel3d}. `layeredWalls` steuert, ob eine
* mehrschichtige Wand als eigene Box je Materiallage (Default, 3D-Viewer) oder
* als EINE Voll-Box über die ganze Dicke emittiert wird. Der Schnitt-Pfad
* (`computeSection`) setzt `false`, damit die Cut-Polygone 1:1 zur Wand liegen
* (die Schicht-Zerlegung macht dort `splitWallLayers` TS-seitig) — sonst
* verschöben die per-Lage vervielfachten Boxen die `ComponentRef.index`
* gegenüber `wallSegmentOwners` und die materialspezifische Schnitt-Schraffur
* ginge verloren.
*/
export interface Model3dOptions {
layeredWalls?: boolean;
/**
* Detailgrad der Öffnungs-Darstellung im 3D (wie im 2D-Grundriss):
* • "grob" — keine Rahmen/Sprossen (nur Loch + Scheibe).
* • "mittel"— Rahmen (Pfosten/Sturz/Schwelle) + ggf. Kämpfer, keine Sprossen.
* • "fein" — zusätzlich Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen).
* Fehlt es, gilt "fein" (= bisheriges Verhalten, volle Details).
*/
detail?: DetailLevel;
}
/** Das volle 3D-Modell: Wände (mit Öffnungen) + Deckenplatten + Kontext-Meshes. */
export function projectToModel3d(project: Project, opts: Model3dOptions = {}): RModel3d {
const layered = opts.layeredWalls ?? true;
const detail = opts.detail ?? "fein";
return {
walls: projectToWalls3d(project, layered),
// Decken + Treppen (Treppentritte als extrudierte Slabs) — so wird die Treppe
// auch im WASM/wgpu-Renderer sichtbar (der three.js-Pfad rendert sie separat).
slabs: [...emitSlabs(project), ...emitStairs(project)],
// Kontext-Meshes (Terrain/Import) + Fenster-/Tür-Glasscheiben (eigenes Mesh
// je Scheibe) + Rahmen-/Sprossen-Riegel typisierter Öffnungen (Zarge/
// Blendrahmen/Kämpfer/Mittelpfosten, s. emitOpeningFrames) + glatte Beton-
// Laufplatten (gerade Betontreppen als Mesh) — pickGeometry/Highlight nutzen
// `meshes` bewusst NICHT (man wählt Wand/Öffnung/Treppe; Treppen-Pick läuft
// über die pickGeometry-Boxen).
meshes: [
...emitMeshes(project),
...emitOpeningGlass(project),
...emitOpeningFrames(project, detail),
...emitStairMeshes(project),
],
};
}
/**
* Pick-Geometrie für den TS-seitigen 3D-Raycast (siehe viewport/raycast3d): die
* Wand-Teilquader (mit `wallId`) und Decken-Prismen (mit `ceilingId`) desselben
* geflachten Modells wie {@link projectToModel3d}, aber ohne die Kontext-Meshes
* (die sind für die Bauteil-Auswahl irrelevant), PLUS die Öffnungs- (Fenster/
* Türen) und Treppen-Boxen fürs Raycast-Picking. Reine TS-Nutzung — geht NICHT
* an render3d; die Records tragen dieselben Geometriefelder wie die Serde-
* Payload, plus die Ids fürs Picking.
*/
export function pickGeometry(
project: Project,
): { walls: RWall[]; slabs: RSlab[]; openings: PickOpening[]; stairs: PickStair[] } {
const openings: PickOpening[] = [];
for (const o of project.openings ?? []) {
const wall = project.walls.find((w) => w.id === o.hostWallId);
if (!wall) continue;
const jambs = openingJambs(wall, o);
if (!jambs) continue;
const thickness = wallTypeThickness(getWallType(project, wall));
const { zBottom, zTop } = openingVerticalExtent(project, wall, o);
if (zTop - zBottom <= 0) continue;
openings.push({
start: [jambs.jambStart.x, jambs.jambStart.y],
end: [jambs.jambEnd.x, jambs.jambEnd.y],
thickness,
height: zTop - zBottom,
baseElevation: zBottom,
openingId: o.id,
});
}
const stairs: PickStair[] = [];
for (const st of project.stairs ?? []) {
const { zBottom, zTop } = stairVerticalExtent(project, st);
const totalRise = zTop - zBottom;
if (totalRise <= 0) continue;
const bbox = stairBBox(stairGeometry(st, totalRise));
stairs.push({
outline: [
[bbox.minX, bbox.minY],
[bbox.maxX, bbox.minY],
[bbox.maxX, bbox.maxY],
[bbox.minX, bbox.maxY],
],
zBottom,
zTop,
stairId: st.id,
});
}
return { walls: projectToWalls3d(project), slabs: emitSlabs(project), openings, stairs };
}
/**
* Baut die Highlight-Linien der aktuellen 3D-Auswahl (Wand-/Decken-Ids) als
* flaches LineList-Vertex-Array `[px,py,pz,r,g,b, ...]` (world-Meter, je 2
* Vertices = 1 Kante) — genau das Format, das `set_highlight_lines` erwartet
* (immer obenauf gezeichnete Akzent-Umrisslinie, s. useWasm3dRenderer). Leere
* Auswahl → leeres Array (Highlight löschen).
*
* GEOMETRIE (dieselbe Welt-Konvention wie pickGeometry/raycast3d: model (x,y)
* → world (x, elevation, y), Höhe entlang +Y):
* • Wand: derselbe orientierte Quader wie `rayWallBox` (raycast3d.ts) — Achse
* entlang der Wandlänge, Normale (Dicke) senkrecht dazu in der XZ-Ebene,
* Y = Höhe. Aus den 8 Ecken werden die 12 Kanten des Quaders emittiert.
* Wände MIT Öffnungen bestehen aus mehreren Teilquadern (Pfeiler/Brüstung/
* Sturz, s. emitWall) — alle tragen dieselbe `wallId` und werden hier ALLE
* umrandet, sodass Öffnungsränder mit hervorgehoben werden.
* • Decke: der Umriss oben (zTop) und unten (zBottom) je als geschlossener
* Kantenzug, plus eine vertikale Kante an jeder Umriss-Ecke.
*
* Kanten werden EINFACH (nicht versetzt/doppelt) ausgegeben — eine zweite,
* leicht verschobene Kopie je Linie (für optisch "breitere" Linien bei
* WebGPU-Linienbreite 1) würde die Vertexzahl verdoppeln und die Achse/Betrag
* des Versatzes müsste in Bildschirm- statt Weltraum berechnet werden (hängt
* von Kamera-Distanz/-Winkel ab) — außerhalb des schmalen TS-Rahmens dieser
* Funktion. Der Renderer kann das bei Bedarf selbst tun (Rust-seitige
* Linienbreite/Shader), ohne dass sich dieses Vertexformat ändert.
*/
export function selectionHighlightLines(
project: Project,
wallIds: string[],
ceilingIds: string[],
rgb: RRgb,
openingIds: string[] = [],
stairIds: string[] = [],
): Float32Array {
if (
wallIds.length === 0 &&
ceilingIds.length === 0 &&
openingIds.length === 0 &&
stairIds.length === 0
) {
return new Float32Array(0);
}
const { walls, slabs, openings, stairs } = pickGeometry(project);
const wallSet = new Set(wallIds);
const ceilingSet = new Set(ceilingIds);
const openingSet = new Set(openingIds);
const stairSet = new Set(stairIds);
const out: number[] = [];
const pushEdge = (a: RVec3, b: RVec3) => {
out.push(a[0], a[1], a[2], rgb[0], rgb[1], rgb[2]);
out.push(b[0], b[1], b[2], rgb[0], rgb[1], rgb[2]);
};
// 12 Kanten eines Quaders aus 8 Ecken (Ecken-Index-Bits: bit0=Achse,
// bit1=Höhe, bit2=Dicke — je zwei Ecken, die sich in genau einem Bit
// unterscheiden, bilden eine Kante).
const BOX_EDGES: Array<[number, number]> = [
[0, 1], [0, 2], [0, 4], [1, 3], [1, 5], [2, 3],
[2, 6], [3, 7], [4, 5], [4, 6], [5, 7], [6, 7],
];
// Orientierter Quader (Wand ODER Öffnung: gleiche Felder start/end/thickness/
// height/baseElevation) → seine 12 Kanten.
const boxEdges = (b: {
start: readonly [number, number];
end: readonly [number, number];
thickness: number;
height: number;
baseElevation: number;
}) => {
const ax = b.end[0] - b.start[0];
const az = b.end[1] - b.start[1];
const len = Math.hypot(ax, az);
if (len < EPS || b.height <= EPS || b.thickness <= EPS) return;
const axis: RVec3 = [ax / len, 0, az / len];
const normal: RVec3 = [az / len, 0, -ax / len];
const halfLen = len / 2;
const halfHeight = b.height / 2;
const halfThick = b.thickness / 2;
const center: RVec3 = [
(b.start[0] + b.end[0]) / 2,
b.baseElevation + b.height / 2,
(b.start[1] + b.end[1]) / 2,
];
const corners: RVec3[] = [];
for (let i = 0; i < 8; i++) {
const sL = i & 1 ? 1 : -1;
const sH = i & 2 ? 1 : -1;
const sT = i & 4 ? 1 : -1;
corners.push([
center[0] + sL * halfLen * axis[0] + sT * halfThick * normal[0],
center[1] + sH * halfHeight,
center[2] + sL * halfLen * axis[2] + sT * halfThick * normal[2],
]);
}
for (const [i, j] of BOX_EDGES) pushEdge(corners[i], corners[j]);
};
// Vertikales Prisma (Decke ODER Treppen-Fussabdruck) → Umriss oben/unten + Ecken.
const prismEdges = (outline: ReadonlyArray<readonly [number, number]>, zBottom: number, zTop: number) => {
const n = outline.length;
if (n < 3 || zTop - zBottom <= EPS) return;
const bottom: RVec3[] = outline.map((p) => [p[0], zBottom, p[1]]);
const top: RVec3[] = outline.map((p) => [p[0], zTop, p[1]]);
for (let i = 0, j = n - 1; i < n; j = i++) {
pushEdge(bottom[j], bottom[i]);
pushEdge(top[j], top[i]);
}
for (let i = 0; i < n; i++) pushEdge(bottom[i], top[i]);
};
for (const w of walls) if (wallSet.has(w.wallId)) boxEdges(w);
for (const o of openings) if (openingSet.has(o.openingId)) boxEdges(o);
for (const s of slabs) if (ceilingSet.has(s.ceilingId)) prismEdges(s.outline, s.zBottom, s.zTop);
for (const st of stairs) if (stairSet.has(st.stairId)) prismEdges(st.outline, st.zBottom, st.zTop);
return new Float32Array(out);
}