Files
DOSSIER-STANDALONE/src/plan/toWalls3d.ts
T
karim 20cf870c4c Schnitt: materialspezifische SIA-Schraffuren wiederhergestellt
de7a26b lieferte projectToModel3d pro SCHICHT eine eigene Voll-Box; der
Schnitt bekam damit segments*layers Cut-Polygone, waehrend
wallSegmentOwners weiter nur oeffnungsbasiert segmentiert -> Index-
Versatz, falscher Owner, splitWallLayers zerteilte bereits einschichtige
Baender erneut -> einheitliches Diagonal-Muster statt SIA-Muster.

Fix: Model3dOptions { layeredWalls } an projectToModel3d (Default true,
3D-Viewer unveraendert); computeSection ruft mit layeredWalls:false ->
eine Voll-Box pro Wand, Owner-Mapping wieder 1:1, splitWallLayers
erzeugt die per-Material-Baender (Daemmung-Haarlinien, Backstein-
Diagonale, Beton-Kreuz) wie im Grundriss. 216 Tests gruen.
2026-07-04 14:44:36 +02:00

755 lines
31 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, Layer } from "../model/types";
import {
getComponent,
getWallType,
getCeilingType,
wallTypeThickness,
openingsOfWall,
} from "../model/types";
import { wallVerticalExtent, ceilingVerticalExtent } from "../model/wall";
import { openingInterval, openingVerticalExtent } from "../geometry/opening";
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;
}
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;
}
/** 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;
}
/** Art eines rohen Kontext-Meshes (bestimmt in render3d die Default-Einfärbung). */
export type RMeshKind = "terrain" | "imported";
/**
* 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];
/** 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;
/** 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;
}
/**
* 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) {
return [{ thickness: total, color: dominantLayerColor(wt.layers, project, WALL_RGB), offset: 0 }];
}
const bands: WallBand[] = [];
let prefix = 0;
for (const l of wt.layers) {
const color = hexToRgb(getComponent(project, l.componentId).color);
bands.push({ thickness: l.thickness, color, offset: -total / 2 + prefix + l.thickness / 2 });
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.
*/
function pushSegment(
out: RWall[],
wall: Wall,
from: number,
to: number,
zBottom: number,
zTop: number,
thickness: number,
normalOffset: number,
color: RRgb,
wallId: string,
): 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,
});
}
/**
* 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 Wand (höchste ihrer Schichten) — analog
* `wallRepresentativePriority` in `toSection.ts` (2D-Schnitt-Dominanz), hier
* fürs 3D-Trim gegen überlappende Decken (s. {@link trimWallTopForCeilings}).
* `undefined`, wenn kein WallType/keine Schicht auflösbar ist (dann findet nie
* ein Trim statt — konservativ, kein Verhaltensbruch für unbekannte Typen).
*/
function wallDominantPriority(project: Project, wall: Wall): number | undefined {
try {
const wt = getWallType(project, wall);
let best: number | undefined;
for (const layer of wt.layers) {
const p = getComponent(project, layer.componentId).joinPriority;
if (best === undefined || p > best) best = p;
}
return best;
} catch {
return undefined;
}
}
/**
* Repräsentative `joinPriority` einer Decke (höchste ihrer Schichten, analog
* {@link wallDominantPriority}) — bestimmt, ob die Decke am Wand/Decke-Stoss
* "durchläuft" (gewinnt) und die Wand kappt.
*/
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;
}
}
/**
* Kappt `zTop` einer Wand an der Unterkante jeder überlappenden Decke mit
* HÖHERER `joinPriority` (Prioritäts-Join, Phase 3 — behebt das Wand/Decke-
* Z-Fighting aus `wallVerticalExtent`/`ceilingVerticalExtent`, s. Moduldoc):
* wächst `wall.height` bis zur Deckenoberkante (Standardfall, häufig bündig
* mit dem Geschoss-Wandkopf) und die Decke wächst vom selben Z nach unten, so
* überlappen sich beide Volumen exakt über die Deckendicke im Wand-Footprint.
* Gewinnt die Decke (höhere Priorität), endet die Wand VOR der Deckenunterkante
* statt sie zu durchdringen.
*
* Kriterien je Decke (alle müssen zutreffen, sonst kein Effekt):
* • dasselbe Geschoss (`ceiling.floorId === wall.floorId`),
* • vertikale Überlappung mit der (ungekappten) Wand-Ausdehnung,
* • grober Footprint-Überlapp (Bounding-Box, s. {@link boundsOverlap}) —
* KEIN exakter Polygon-Test; eine Decke, deren Umriss die Wand nur knapp
* berührt/verfehlt, kann so fälschlich als überlappend gelten. Das ist eine
* bekannte Restlücke (siehe Moduldoc-Kommentar in dieser Datei): für den
* Regelfall (Decken-Umriss = Gebäude-/Raumgrundriss inkl. Wand-Footprints)
* ist die Bounding-Box exakt genug, um das Haupt-Z-Fighting zu beheben.
* • Decke hat eine HÖHERE (strikt) repräsentative `joinPriority` als die Wand.
*
* Bei mehreren qualifizierenden Decken gewinnt die NIEDRIGSTE Deckenunterkante
* (kappt am weitesten). Ohne auflösbare Wand-Priorität (unbekannter WallType)
* bleibt `zTop` unverändert (konservativ, wie heute).
*/
function trimWallTopForCeilings(
project: Project,
wall: Wall,
zBottom: number,
zTop: number,
): number {
const wallPriority = wallDominantPriority(project, wall);
if (wallPriority === undefined) return zTop;
const halfThickness = wallThicknessSafe(project, wall) / 2;
const wallBounds = wallFootprintBounds(wall, halfThickness);
let top = zTop;
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);
// Keine vertikale Überlappung mit der (ungekappten) Wand-Ausdehnung -> Decke
// liegt komplett ausserhalb des Wandkörpers, betrifft ihn nicht.
if (cExtent.zBottom >= zTop - EPS || cExtent.zTop <= zBottom + EPS) continue;
const ceilingPriority = ceilingDominantPriority(project, c);
if (ceilingPriority === undefined || ceilingPriority <= wallPriority) continue;
if (!boundsOverlap(wallBounds, outlineBounds(c.outline))) continue;
if (cExtent.zBottom < top) top = cExtent.zBottom;
}
return Math.max(zBottom, top);
}
/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */
function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): void {
const bands = resolveWallBands(project, wall, layered);
const { zBottom, zTop: rawZTop } = wallVerticalExtent(project, wall);
const zTop = trimWallTopForCeilings(project, wall, zBottom, rawZTop);
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);
}
};
// 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);
// Ohne Aussparungen: ein durchgehender Quader je Schicht (wie bisher, aber
// in Lagen aufgeteilt).
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);
}
}
/** 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;
// Repräsentative Farbe = dickste Schicht des Deckentyps (analog Wände);
// kein auflösbarer Deckentyp ⇒ SLAB_RGB-Fallback.
let color: RRgb = SLAB_RGB;
try {
color = dominantLayerColor(getCeilingType(project, c).layers, project, SLAB_RGB);
} catch {
color = SLAB_RGB;
}
out.push({
outline: c.outline.map((p) => [p.x, p.y] as RVec2),
zBottom,
zTop,
color,
ceilingId: c.id,
});
}
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,
});
}
}
return out;
}
/**
* Emittiert je Fensteröffnung eine dünne Glasscheibe als rohes Quader-Mesh —
* damit Fenster im 3D als Fenster lesbar sind statt als blosse Löcher in der
* Wand. Nur `kind === "window"`; Türen bleiben offen (keine Scheibe).
*
* Für jede Scheibe wird ein geschlossener Quader (8 Ecken, 12 Dreiecke) gebaut:
* • entlang der Wandachse: das geklemmte Öffnungs-Intervall (openingInterval),
* • vertikal: die absolute Öffnungs-Ausdehnung (openingVerticalExtent, also
* UK = baseElevation+sillHeight … OK = +height, an den Wandkopf geklemmt) —
* exakt der Bereich, den emitWall als Loch freilässt,
* • quer zur Wand: ±GLASS_HALF_THICK um die Wandachse (mittig in der Dicke).
*
* `positions` liegen — wie bei {@link emitMeshes} (Terrain/Import) — in MODELL-
* Metern als flaches (x, y, z=Höhe)-Array; render3d bildet sie selbst nach world
* (x, Höhe, y) ab und rendert die Meshes doppelseitig (append_context_mesh),
* daher ist die Winding-Reihenfolge der Dreiecke unerheblich. `kind: "imported"`
* mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB.
*/
function emitOpeningGlass(project: Project): RMesh[] {
// Faces eines Boxen-Quaders als Dreiecks-Indizes (Ecken-Index-Bits:
// bit0 = Achse from/to, bit1 = Höhe UK/OK, bit2 = Dicke /+). Doppelseitig
// gerendert ⇒ Winding egal; je Fläche 2 Dreiecke, 6 Flächen = 12 Dreiecke.
const BOX_TRIS: number[] = [
0, 1, 3, 0, 3, 2, // Dicke
4, 5, 7, 4, 7, 6, // Dicke +
0, 1, 5, 0, 5, 4, // UK
2, 3, 7, 2, 7, 6, // OK
0, 2, 6, 0, 6, 4, // from
1, 3, 7, 1, 7, 5, // to
];
const out: RMesh[] = [];
for (const wall of project.walls) {
const dx = wall.end.x - wall.start.x;
const dy = wall.end.y - wall.start.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) continue;
const ux = dx / len;
const uy = dy / len;
// Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie pushSegment.
const nx = uy;
const ny = -ux;
for (const op of openingsOfWall(project, wall.id)) {
if (op.kind !== "window") continue; // Türen bekommen keine Scheibe.
const iv = openingInterval(wall, op);
if (!iv) continue;
const { zBottom, zTop } = openingVerticalExtent(project, wall, op);
if (zTop - zBottom <= EPS) continue;
const positions: number[] = [];
// 8 Ecken: Achse s ∈ {from,to} × Höhe z ∈ {UK,OK} × Dicke t ∈ {,+}.
for (let i = 0; i < 8; i++) {
const s = i & 1 ? iv.to : iv.from;
const z = i & 2 ? zTop : zBottom;
const t = i & 4 ? GLASS_HALF_THICK : -GLASS_HALF_THICK;
const px = wall.start.x + ux * s + nx * t;
const py = wall.start.y + uy * s + ny * t;
// MODELL-Koordinaten (x, y, z=Höhe) wie emitMeshes.
positions.push(px, py, z);
}
out.push({ positions, indices: [...BOX_TRIS], kind: "imported", color: GLASS_RGB });
}
}
return out;
}
/**
* Nur die Wände (Rückwärts-Kompatibilität / bestehende Aufrufer). Zerlegt Wände
* mit Öffnungen in Teilquader.
*/
export function projectToWalls3d(project: Project, layered = true): RWall[] {
const out: RWall[] = [];
for (const w of project.walls) emitWall(out, project, w, layered);
return out;
}
/**
* Optionen für {@link projectToModel3d}. `layeredWalls` steuert, ob eine
* mehrschichtige Wand als eigene Box je Materiallage (Default, 3D-Viewer) oder
* als EINE Voll-Box über die ganze Dicke emittiert wird. Der Schnitt-Pfad
* (`computeSection`) setzt `false`, damit die Cut-Polygone 1:1 zur Wand liegen
* (die Schicht-Zerlegung macht dort `splitWallLayers` TS-seitig) — sonst
* verschöben die per-Lage vervielfachten Boxen die `ComponentRef.index`
* gegenüber `wallSegmentOwners` und die materialspezifische Schnitt-Schraffur
* ginge verloren.
*/
export interface Model3dOptions {
layeredWalls?: boolean;
}
/** Das volle 3D-Modell: Wände (mit Öffnungen) + Deckenplatten + Kontext-Meshes. */
export function projectToModel3d(project: Project, opts: Model3dOptions = {}): RModel3d {
const layered = opts.layeredWalls ?? true;
return {
walls: projectToWalls3d(project, layered),
slabs: emitSlabs(project),
// Kontext-Meshes (Terrain/Import) + Fenster-Glasscheiben (eigenes Mesh je
// Fensteröffnung) — pickGeometry/Highlight nutzen `meshes` bewusst NICHT,
// daher sind die Scheiben nicht selektierbar (man wählt Wand/Öffnung).
meshes: [...emitMeshes(project), ...emitOpeningGlass(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). 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[] } {
return { walls: projectToWalls3d(project), slabs: emitSlabs(project) };
}
/**
* 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,
): Float32Array {
if (wallIds.length === 0 && ceilingIds.length === 0) return new Float32Array(0);
const { walls, slabs } = pickGeometry(project);
const wallSet = new Set(wallIds);
const ceilingSet = new Set(ceilingIds);
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],
];
for (const w of walls) {
if (!wallSet.has(w.wallId)) continue;
const ax = w.end[0] - w.start[0];
const az = w.end[1] - w.start[1];
const len = Math.hypot(ax, az);
if (len < EPS || w.height <= EPS || w.thickness <= EPS) continue;
const axis: RVec3 = [ax / len, 0, az / len];
const normal: RVec3 = [az / len, 0, -ax / len];
const halfLen = len / 2;
const halfHeight = w.height / 2;
const halfThick = w.thickness / 2;
const center: RVec3 = [
(w.start[0] + w.end[0]) / 2,
w.baseElevation + w.height / 2,
(w.start[1] + w.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]);
}
for (const s of slabs) {
if (!ceilingSet.has(s.ceilingId)) continue;
const n = s.outline.length;
if (n < 3 || s.zTop - s.zBottom <= EPS) continue;
const bottom: RVec3[] = s.outline.map((p) => [p[0], s.zBottom, p[1]]);
const top: RVec3[] = s.outline.map((p) => [p[0], s.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]);
}
return new Float32Array(out);
}