Viewport3D.tsx aufgeteilt: Kamera-Helfer + Mesh-Builder nach src/viewport/viewport3d/.

Gleiches Muster wie beim PlanView.tsx-Split: die Komponente (ThreeViewport3D)
bleibt, die anschliessenden ~1000 Zeilen reiner three.js-Erzeugung wandern in
zwei fokussierte Module. Reine Verschiebung, keine Verhaltensänderung.

- viewport3d/camera.ts: visibleFloorKey, updateOrthoFrustum, applyView3d
  (Ortho-Frustum-Bemessung + die kanonischen Blickwinkel-Presets).
- viewport3d/meshBuilders.ts: MatCaches/BuildOpts/ContextMats, buildContext,
  buildBuilding (+ private buildFloor/addWallMeshes/addOpeningMeshes/
  addCeilingMesh/addStairMeshes/addLayerPrism/addDrawing2DLines/layerMaterial).

Viewport3D.tsx: 2661 → 1588 Zeilen.

tsc -b sauber, vitest 891/891 grün, vite build ok.
This commit is contained in:
2026-08-19 23:05:25 +02:00
parent 528f213f1f
commit d6fae13326
3 changed files with 1102 additions and 1083 deletions
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
// Kamera-Helfer der three.js-3D-Ansicht: Ortho-Frustum-Bemessung + die
// kanonischen Blickwinkel-Presets (Vectorworks/DOSSIER-Konvention).
import type { MutableRefObject } from "react";
import * as THREE from "three";
import type { OrbitControls } from "three/addons/controls/OrbitControls.js";
import type { Project } from "../../model/types";
import type { View3d } from "../../ui/TopBar";
/** Stabiler Schlüssel der sichtbaren Geschosse (IDs in Dokumentreihenfolge). */
export function visibleFloorKey(project: Project): string {
return project.drawingLevels
.filter((l) => l.kind === "floor" && l.visible)
.map((l) => l.id)
.join(",");
}
/**
* Setzt das Frustum einer OrthographicCamera so, dass die Modell-Bounds bei
* gegebenem Canvas-Seitenverhältnis formatfüllend (mit etwas Rand) abgebildet
* werden. Die parallele Projektion zeigt keine Fluchtpunkt-Verzerrung — die
* Größe im Bild bestimmt allein das Frustum (nicht die Kamera-Distanz).
*
* Bemessung: Halb-Höhe/Halb-Breite aus dem Bounds-Radius (Diagonale/2, damit das
* Modell aus jeder Richtung — auch iso — komplett passt), dann ans aspect
* angepasst (an der jeweils engeren Achse). near/far bleiben fix (großzügig).
*/
export function updateOrthoFrustum(
camera: THREE.OrthographicCamera,
bounds: THREE.Box3,
aspect: number,
): void {
const size = bounds.getSize(new THREE.Vector3());
const radius = Math.max(0.5 * Math.hypot(size.x, size.y, size.z), 0.001);
const margin = 1.1; // etwas Rand
// Halb-Ausdehnungen so, dass ein 2·radius großes Quadrat ins Bild passt.
let halfW: number;
let halfH: number;
if (aspect >= 1) {
halfH = radius * margin;
halfW = halfH * aspect;
} else {
halfW = radius * margin;
halfH = halfW / aspect;
}
camera.left = -halfW;
camera.right = halfW;
camera.top = halfH;
camera.bottom = -halfH;
camera.updateProjectionMatrix();
}
/**
* Wendet einen kanonischen Blickwinkel-Preset (Vectorworks/DOSSIER) an und
* bestimmt die aktive Kamera:
* perspective → PerspectiveCamera (Fluchtpunkt-Projektion, FOV wirkt).
* front/back/side/left/top/iso/isoFrontLeft/isoBackRight/isoBackLeft →
* OrthographicCamera (echte parallele Projektion; „Isometrie" zeigt
* parallele Kanten parallel, ohne Konvergenz).
*
* Für die aktive Kamera: Ziel = Modell-Zentrum, Position = Zentrum + Richtung ×
* Distanz, up=(0,1,0) (für top eine stabile up-Achse über winzigen Z-Anteil).
* Bei der Perspektive macht die Distanz das Modell formatfüllend (Radius/FOV);
* bei Ortho ist die Distanz nur fürs Clipping relevant (Größe macht das Frustum,
* siehe updateOrthoFrustum) — sie wird so gewählt, dass das Modell sicher
* zwischen near/far liegt.
*
* OrbitControls wird auf die aktive Kamera umgehängt (`.object`) und sein target
* aufs Zentrum gesetzt; danach kann frei orbitiert/gezoomt/geschwenkt werden
* (Preset = „dorthin springen", kein Lock).
*/
export function applyView3d(
camera: THREE.PerspectiveCamera,
orthoCamera: THREE.OrthographicCamera,
activeCameraRef: MutableRefObject<THREE.Camera | null>,
controls: OrbitControls,
bounds: THREE.Box3,
view3d: View3d,
aspect: number,
): void {
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const radius = Math.max(0.5 * Math.hypot(size.x, size.y, size.z), 0.001);
// Blickrichtung je Preset (vom Zentrum zur Kamera, normalisiert). Kardinal-
// Paare sind exakte Gegenrichtungen (back = -front, left = -side); die vier
// Iso-Richtungen sind die oberen Oktanten (untere bleiben im Hochbau
// ungenutzt) — dieselbe Achs-Konvention wie `preset_camera` in render3d/
// src/math.rs (WASM-Pfad), s. View3d in TopBar.tsx.
let dir: THREE.Vector3;
switch (view3d) {
case "front":
dir = new THREE.Vector3(0, 0, 1);
break;
case "back":
dir = new THREE.Vector3(0, 0, -1);
break;
case "side":
dir = new THREE.Vector3(1, 0, 0);
break;
case "left":
dir = new THREE.Vector3(-1, 0, 0);
break;
case "top":
dir = new THREE.Vector3(0, 1, 0.0001); // winziger Z-Anteil → stabiles Up
break;
case "iso":
dir = new THREE.Vector3(1, 1, 1).normalize();
break;
case "isoFrontLeft":
dir = new THREE.Vector3(-1, 1, 1).normalize();
break;
case "isoBackRight":
dir = new THREE.Vector3(1, 1, -1).normalize();
break;
case "isoBackLeft":
dir = new THREE.Vector3(-1, 1, -1).normalize();
break;
case "perspective":
default:
// Heutiger 3/4-Blick (vorne-oben-rechts), etwas flacher als die Isometrie.
dir = new THREE.Vector3(0.62, 0.5, 0.7).normalize();
break;
}
const perspective = view3d === "perspective";
const active: THREE.PerspectiveCamera | THREE.OrthographicCamera = perspective
? camera
: orthoCamera;
if (perspective) {
// Distanz so, dass die Bounds im FOV liegen (+ Reserve).
const half = Math.max(THREE.MathUtils.degToRad(camera.fov) * 0.5, 0.1);
const dist = (radius / Math.sin(half)) * 1.25 + radius;
camera.aspect = aspect;
camera.position.copy(center).addScaledVector(dir, dist);
camera.up.set(0, 1, 0);
camera.lookAt(center);
camera.updateProjectionMatrix();
} else {
// Frustum (Größe) ans aspect anpassen; Distanz nur fürs Clipping relevant,
// weit genug, damit das Modell sicher zwischen near (0.1) und far liegt.
updateOrthoFrustum(orthoCamera, bounds, aspect);
const dist = radius * 4 + 10;
orthoCamera.position.copy(center).addScaledVector(dir, dist);
orthoCamera.up.set(0, 1, 0);
orthoCamera.lookAt(center);
orthoCamera.updateProjectionMatrix();
}
// OrbitControls an die aktive Kamera binden. `.object` lässt sich live tauschen
// (robuster als Neu-Erstellen: Maus-Schema/Listener bleiben erhalten); danach
// update() für die korrekte sphärische Ausrichtung relativ zum Target.
if (controls.object !== active) controls.object = active;
controls.target.copy(center);
controls.update();
activeCameraRef.current = active;
}
+934
View File
@@ -0,0 +1,934 @@
// Baut die three.js-Szene aus dem semantischen Modell: Kontext (Terrain/Import/
// Konturen), Gebäude (Wände inkl. Öffnungen, Decken, Treppen, freie 2D-Linien).
// Reine THREE.js-Erzeugung — kein Komponenten-/Interaktions-Zugriff; bekommt
// Materialcaches + Render-Modus über `BuildOpts` gereicht.
import * as THREE from "three";
import {
flattenCategories,
getCeilingType,
getComponent,
getStairType,
getWallType,
stairsOfFloor,
wallTypeThickness,
type Ceiling,
type Contour,
type ContextObject,
type Drawing2D,
type DrawingLevel,
type Opening,
type Project,
type Stair,
type Vec2,
type Wall,
} from "../../model/types";
import { along, clippedBand } from "../../model/geometry";
import { stairGeometry } from "../../geometry/stair";
import {
doorSymbol,
openingInterval,
openingVerticalExtent,
wallAxisFrame,
} from "../../geometry/opening";
import { computeJoins, type WallCuts } from "../../model/joins";
import {
ceilingVerticalExtent,
stairVerticalExtent,
wallReferenceOffset,
wallVerticalExtent,
} from "../../model/wall";
import { MaterialRuntime } from "../../materials/runtime";
import type { RenderMode } from "../../ui/TopBar";
import type { DisplayResolver } from "../Viewport3D";
/** Getrennte Material-Caches: normal und gedimmt (je Component-ID). */
export interface MatCaches {
normal: Map<string, THREE.MeshStandardMaterial>;
greyed: Map<string, THREE.MeshStandardMaterial>;
/** Linien-Materialien der 2D-Zeichengeometrie (Schlüssel: `farbe|greyed`). */
lines: Map<string, THREE.LineBasicMaterial>;
}
/** Bündel der Bau-Optionen, das durch die Bau-Helfer gereicht wird. */
export interface BuildOpts {
mats: MatCaches;
renderMode: RenderMode;
/** Geteiltes flaches Face-Material für den Hidden-Line-Modus. */
hiddenFace: THREE.MeshBasicMaterial;
/** Geteiltes Kanten-Material für den Hidden-Line-Modus. */
edgeMat: THREE.LineBasicMaterial;
/** Geteiltes mattes helles Material für den „Weiss"-Modus (Clay-Look). */
whiteFace: THREE.MeshStandardMaterial;
/** Laufzeit für PBR-Texturen (Render-Modus „textured"); sonst ungenutzt. */
matRuntime: MaterialRuntime;
}
/** Geteilte Materialien der Kontext-Schicht (Gelände/Mesh/Kontur). */
export interface ContextMats {
terrain: THREE.MeshStandardMaterial;
mesh: THREE.MeshStandardMaterial;
contour: THREE.LineBasicMaterial;
}
/**
* Baut die Kontext-Gruppe aus `Project.context`:
* • TerrainMesh / ImportedMesh → BufferGeometry aus positions/indices.
* • ContourSet → LineSegments je Kontur auf ihrer Z-Höhe.
*
* Achs-Konvention: das Modell speichert (x, y, z) mit z = Höhe; Three nutzt y als
* Höhe. Wir bilden Modell (x,y,z) → Three (x, z, y) ab (wie die übrige Szene, in
* der Plan-Y auf die Three-Z-Achse fällt und die Höhe auf Three-Y).
*/
export function buildContext(
context: ContextObject[],
mats: ContextMats,
): THREE.Group {
const group = new THREE.Group();
for (const obj of context) {
if (obj.type === "terrainMesh" || obj.type === "importedMesh") {
const mesh = buildContextMesh(
obj.positions,
obj.indices,
obj.type === "terrainMesh" ? mats.terrain : mats.mesh,
);
if (mesh) group.add(mesh);
} else if (obj.type === "contourSet") {
const lines = buildContourLines(obj.contours, mats.contour);
if (lines) group.add(lines);
}
}
return group;
}
/** BufferGeometry-Mesh aus rohen (x,y,z)-positions/indices; Modell→Three-Achsen. */
function buildContextMesh(
positions: number[],
indices: number[],
mat: THREE.Material,
): THREE.Mesh | null {
if (positions.length < 9 || indices.length < 3) return null;
// Modell (x,y,z) → Three (x, z, y).
const verts = new Float32Array(positions.length);
for (let i = 0; i < positions.length; i += 3) {
verts[i] = positions[i]; // x
verts[i + 1] = positions[i + 2]; // y(Three) = z(Modell) = Höhe
verts[i + 2] = positions[i + 1]; // z(Three) = y(Modell)
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(verts, 3));
geo.setIndex(indices);
geo.computeVertexNormals();
geo.computeBoundingSphere();
return new THREE.Mesh(geo, mat);
}
/** LineSegments aller Konturen (jede Kontur als zusammenhängender Linienzug). */
function buildContourLines(
contours: Contour[],
mat: THREE.LineBasicMaterial,
): THREE.LineSegments | null {
const pos: number[] = [];
for (const c of contours) {
const n = c.pts.length;
if (n < 2) continue;
const segCount = c.closed ? n : n - 1;
for (let i = 0; i < segCount; i++) {
const a = c.pts[i];
const b = c.pts[(i + 1) % n];
// Modell (x,y,z=c.z) → Three (x, z, y).
pos.push(a.x, c.z, a.y, b.x, c.z, b.y);
}
}
if (pos.length === 0) return null;
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(pos), 3));
geo.computeBoundingSphere();
const seg = new THREE.LineSegments(geo, mat);
seg.frustumCulled = false;
return seg;
}
export function buildBuilding(
project: Project,
visibleCodes: Set<string>,
floorDisplay: DisplayResolver,
categoryDisplay: DisplayResolver,
opts: BuildOpts,
): THREE.Group {
const group = new THREE.Group();
// Geschosse gemäß Darstellungsmodus auswählen; jedes vertikal um seine
// baseElevation versetzt stapeln. Ein gedimmtes Geschoss färbt all seine
// Wände gedämpft ein.
const floors = project.drawingLevels.filter((l) => l.kind === "floor");
for (const floor of floors) {
const d = floorDisplay(floor.id);
if (!d.render) continue;
buildFloor(group, project, floor, visibleCodes, categoryDisplay, d.greyed, opts);
}
return group;
}
/**
* Baut ein einzelnes Geschoss in die Gruppe. Es werden nur die Wände dieses
* Geschosses mit sichtbarer Kategorie verwendet; die Gehrung wird auf genau
* dieser Wandmenge berechnet. Jede vertikale Koordinate wird um die
* baseElevation des Geschosses verschoben, sodass die Geschosse stapeln.
*/
function buildFloor(
group: THREE.Group,
project: Project,
floor: DrawingLevel,
visibleCodes: Set<string>,
categoryDisplay: DisplayResolver,
floorGreyed: boolean,
opts: BuildOpts,
): void {
const baseElevation = floor.baseElevation ?? 0;
// Sichtbarkeit wie bisher; der Kategorie-Darstellungsmodus verfeinert sie.
const walls = project.walls.filter(
(w) =>
w.floorId === floor.id &&
visibleCodes.has(w.categoryCode) &&
categoryDisplay(w.categoryCode).render,
);
// Gehrungs-Schnittlinien auf der gefilterten Wandmenge berechnen, damit 3D
// mit dem gehrten Grundriss übereinstimmt.
const joins = computeJoins(project, walls);
for (const wall of walls) {
// Gedimmt, wenn das Geschoss ODER die Kategorie der Wand gedimmt ist.
const greyed = floorGreyed || categoryDisplay(wall.categoryCode).greyed;
const doors = project.doors.filter((d) => d.hostWallId === wall.id);
const wallOpenings = (project.openings ?? []).filter(
(o) => o.hostWallId === wall.id,
);
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
addWallMeshes(group, project, wall, doors, wallOpenings, cuts, baseElevation, greyed, opts);
// Rahmen/Glas/Blatt der Öffnungen NUR, wenn die Öffnungs-Kategorie sichtbar ist.
for (const o of wallOpenings) {
const oGreyed = floorGreyed || categoryDisplay(o.categoryCode).greyed;
if (visibleCodes.has(o.categoryCode) && categoryDisplay(o.categoryCode).render) {
addOpeningMeshes(group, project, wall, o, oGreyed, opts);
}
}
}
// Decken dieses Geschosses als extrudierte Slabs (Umriss × Dicke) an der
// korrekten Z-Lage (OK = Geschoss-Oberkante, wächst nach unten).
const ceilings = (project.ceilings ?? []).filter(
(c) =>
c.floorId === floor.id &&
visibleCodes.has(c.categoryCode) &&
categoryDisplay(c.categoryCode).render,
);
for (const ceiling of ceilings) {
const greyed = floorGreyed || categoryDisplay(ceiling.categoryCode).greyed;
addCeilingMesh(group, project, ceiling, greyed, opts);
}
// Treppen dieses Geschosses als gestufte Boxen (Tritte + Setzstufen), von der
// Geschoss-UK bis zur nächsten Geschoss-OK aufsteigend.
const stairs = stairsOfFloor(project, floor.id).filter(
(s) =>
visibleCodes.has(s.categoryCode) && categoryDisplay(s.categoryCode).render,
);
for (const stair of stairs) {
const greyed = floorGreyed || categoryDisplay(stair.categoryCode).greyed;
addStairMeshes(group, project, stair, greyed, opts);
}
// Freie 2D-Zeichengeometrie dieses Geschosses flach auf der Geschossebene
// (Z = baseElevation) — z. B. ein Linien-Raster liegt auf dem Boden des Geschosses.
const drawings = project.drawings2d.filter(
(d) =>
d.levelId === floor.id &&
visibleCodes.has(d.categoryCode) &&
categoryDisplay(d.categoryCode).render,
);
for (const d of drawings) {
const greyed = floorGreyed || categoryDisplay(d.categoryCode).greyed;
addDrawing2DLines(group, project, d, baseElevation, greyed, opts);
}
}
/** Modellfarbe eines 2D-Zeichenelements (color → LineStyle → Kategorie). */
function drawing2DColor(project: Project, d: Drawing2D): string {
if (d.color) return d.color;
const ls = d.lineStyleId
? project.lineStyles.find((l) => l.id === d.lineStyleId)
: undefined;
if (ls?.color) return ls.color;
const cat = flattenCategories(project.layers).find((c) => c.code === d.categoryCode);
return cat?.color ?? "#888888";
}
/** Strecken eines 2D-Zeichenelements (line/polyline/rect); andere → leer. */
function drawing2DSegments(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;
}
/**
* Zeichnet ein 2D-Element als flache Linien auf der Geschossebene. Plan (x,y) →
* Three (x, baseElevation, y); ein winziger Höhen-Offset verhindert Z-Fighting mit
* dem Boden/Raster. Material je Farbe (+ gedimmt) gecached.
*/
function addDrawing2DLines(
group: THREE.Group,
project: Project,
d: Drawing2D,
baseElevation: number,
greyed: boolean,
opts: BuildOpts,
): void {
const segs = drawing2DSegments(d);
if (segs.length === 0) return;
const color = drawing2DColor(project, d);
const key = `${color}|${greyed}`;
let mat = opts.mats.lines.get(key);
if (!mat) {
mat = new THREE.LineBasicMaterial({
color: new THREE.Color(color),
transparent: greyed,
opacity: greyed ? 0.35 : 1,
});
opts.mats.lines.set(key, mat);
}
const y = baseElevation + 0.01; // knapp über der Geschossebene (kein Z-Fighting)
const positions = new Float32Array(segs.length * 6);
segs.forEach(([a, b], i) => {
positions.set([a.x, y, a.y, b.x, y, b.y], i * 6);
});
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geo.computeBoundingSphere();
const seg = new THREE.LineSegments(geo, mat);
// Dünne Liniengeometrie nicht vorzeitig wegcullen; Tiefentest bleibt aktiv,
// damit Wände die bodennahen Linien korrekt verdecken (natürliche Verdeckung).
seg.frustumCulled = false;
group.add(seg);
}
/**
* Wand als extrudierte Schicht-Bänder — pro Schicht ein eigenes Prisma, um
* Türen ausgespart, mit Sturz darüber. Es wird DASSELBE gehrte Band-Polygon
* wie im Grundriss extrudiert, sodass Ecken auch in 3D sauber stoßen.
* `baseElevation` hebt das gesamte Geschoss vertikal an.
*/
function addWallMeshes(
group: THREE.Group,
project: Project,
wall: Wall,
doors: Project["doors"],
wallOpenings: Opening[],
cuts: WallCuts,
// baseElevation wird jetzt über wallVerticalExtent (UK/OK-Auflösung) bezogen;
// der Parameter bleibt für die Aufrufstelle erhalten, wird hier aber nicht
// mehr direkt genutzt.
_baseElevation: number,
greyed: boolean,
opts: BuildOpts,
): void {
const wt = getWallType(project, wall);
const total = wallTypeThickness(wt);
const length = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
// Vertikale Ausdehnung (absolute Z): UK/OK aus den Wand-Attributen, sonst
// baseElevation des Geschosses … +height (= heutiges Verhalten). Wir geben
// die absoluten Z-Werte direkt an addLayerPrism (baseElevation dort = 0).
const { zBottom, zTop } = wallVerticalExtent(project, wall);
// Tür-/Sturzhöhen sind relativ zur Wand-UK definiert (Türen sitzen am Boden).
const wallTopRel = zTop - zBottom;
// Öffnungen (Legacy-Türen + neue Öffnungen) mit sill/head RELATIV zur Wand-UK:
// • Tür — sillRel 0 … headRel = height (Segment darunter entfällt).
// • Fenster— sillRel = Brüstung … headRel = sillRel + height (Brüstungsmauer
// UNTER dem Fenster bleibt stehen, Sturz DARÜBER ebenfalls).
const openings = [
...doors.map((d) => ({
from: d.position,
to: d.position + d.width,
sillRel: 0,
headRel: Math.min(d.height, wallTopRel),
})),
...wallOpenings
.map((o) => {
const iv = openingInterval(wall, o);
if (!iv) return null;
const sillRel = Math.max(0, Math.min(o.sillHeight, wallTopRel));
const headRel = Math.min(sillRel + o.height, wallTopRel);
return { from: iv.from, to: iv.to, sillRel, headRel };
})
.filter((x): x is { from: number; to: number; sillRel: number; headRel: number } => x != null),
].sort((a, b) => a.from - b.from);
// Referenzlinien-Versatz über die Dicke (center=0 → unverändert).
const refOff = wallReferenceOffset(wall, total);
// Schichten von außen (-T/2) nach innen (+T/2) stapeln, um den Referenzversatz
// verschoben (damit die Achse bei „außen"/„innen" auf der Wandfläche liegt).
let off = refOff - total / 2;
for (const layer of wt.layers) {
// Material je Modus: Hidden-Line nutzt die geteilten flachen weißen Faces,
// „Weiss" das geteilte matte Clay-Material, „Texturiert" ein PBR-Material je
// Fläche (s. u., daher hier null), shaded/wireframe das (ggf. gedimmte)
// Component-Material.
const textured = opts.renderMode === "textured";
// Im Texturiert-Modus das (gecachte) PBR-Material des Bauteils (oder null →
// matte Default-Farbe). Da die Seitenwand-UVs in Welt-Metern liegen und das
// Material `repeat = 1/sizeM` trägt, gilt EIN Material für alle Flächen.
const textureMat = textured
? opts.matRuntime.get(getComponent(project, layer.componentId).material)
: null;
// Material je Modus: Hidden-Line die geteilten flachen weißen Faces, „Weiss"
// das matte Clay-Material, „Texturiert" das PBR-Material (sonst Fallback auf
// das matte Component-Material — Wände ohne Textur bleiben sichtbar),
// shaded/wireframe das (ggf. gedimmte) Component-Material.
const mat =
opts.renderMode === "hidden"
? opts.hiddenFace
: opts.renderMode === "white"
? opts.whiteFace
: textured && textureMat
? textureMat
: layerMaterial(project, layer.componentId, greyed, opts);
const offA = off;
const offB = off + layer.thickness;
// Vollhohe Segmente zwischen den Öffnungen. Z-Werte sind ABSOLUT (zBottom…
// zTop); baseElevation wurde bereits in die Extent-Auflösung eingerechnet,
// daher hier 0. Tür-/Sturzhöhen sind relativ zur Wand-UK → zBottom + rel.
let cursor = 0;
for (const op of openings) {
if (op.from > cursor)
addLayerPrism(group, wall, cursor, op.from, zBottom, zTop, offA, offB, cuts, 0, mat, opts);
// Brüstungsmauer UNTER der Öffnung (Fenster: sillRel > 0; Tür: sillRel 0 → nichts).
if (op.sillRel > 1e-4)
addLayerPrism(group, wall, op.from, op.to, zBottom, zBottom + op.sillRel, offA, offB, cuts, 0, mat, opts);
// Sturz ÜBER der Öffnung (türnahe Enden bleiben rechtwinklig).
if (op.headRel < wallTopRel)
addLayerPrism(group, wall, op.from, op.to, zBottom + op.headRel, zTop, offA, offB, cuts, 0, mat, opts);
cursor = Math.max(cursor, op.to);
}
if (cursor < length)
addLayerPrism(group, wall, cursor, length, zBottom, zTop, offA, offB, cuts, 0, mat, opts);
off += layer.thickness;
}
}
/**
* 3D-Geometrie einer Öffnung: Rahmen (dünne Box um die lichte Öffnung) + Glas
* (Fenster, halbtransparente Scheibe) bzw. Türblatt (Fenster: entfällt; Tür:
* flaches Panel in geöffneter Stellung, Winkel aus swingAngle). Alle Meshes
* tragen `userData.openingId` für die Raycast-Auswahl. Plan (x,y) → Three (x,z=y),
* Höhe entlang Three-Y. Materialien werden je Aufruf erzeugt (leichtgewichtig).
*/
function addOpeningMeshes(
group: THREE.Group,
project: Project,
wall: Wall,
o: Opening,
greyed: boolean,
opts: BuildOpts,
): void {
const iv = openingInterval(wall, o);
if (!iv) return;
const { u, n } = wallAxisFrame(wall);
const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total);
const { zBottom, zTop } = openingVerticalExtent(project, wall, o);
const h = zTop - zBottom;
if (h <= 1e-4) return;
const p0 = along(wall.start, wall.end, iv.from);
const width = iv.to - iv.from;
const frameDepth = Math.min(o.frameThickness ?? total, total);
const opacity = greyed ? 0.3 : 1;
// Ein Balken (Box) entlang (u, n, y): Länge len entlang `dir` in der XZ-Ebene,
// Querschnitt breadth×heightBox. `centerAlong` = Abstand vom p0 entlang `along`,
// `offN` = Versatz quer (n), `y0..y1` vertikale Lage. Rotiert um Y auf die
// Achsrichtung.
const angle = Math.atan2(u.y, u.x); // Achswinkel in der XZ-Ebene (Plan)
const addBar = (
mat: THREE.Material,
aFrom: number,
aTo: number,
offN: number,
breadthN: number,
y0: number,
y1: number,
) => {
const lenA = aTo - aFrom;
const hy = y1 - y0;
if (lenA <= 1e-5 || hy <= 1e-5 || breadthN <= 1e-5) return;
const geo = new THREE.BoxGeometry(lenA, hy, breadthN);
const mesh = new THREE.Mesh(geo, mat);
// Mittelpunkt: entlang der Achse (aFrom+aTo)/2, quer offN, Höhe (y0+y1)/2.
const midA = (aFrom + aTo) / 2;
const cx = p0.x + u.x * midA + n.x * offN;
const cz = p0.y + u.y * midA + n.y * offN;
mesh.position.set(cx, (y0 + y1) / 2, cz);
// Box-Länge liegt auf lokaler X → um Y auf die Plan-Achsrichtung drehen.
// Plan-Y bildet auf Three-Z ab, daher −angle als Drehung um die Three-Y-Achse.
mesh.rotation.y = -angle;
mesh.userData.openingId = o.id;
if (opts.renderMode === "hidden") {
const edges = new THREE.EdgesGeometry(geo, 1);
mesh.add(new THREE.LineSegments(edges, opts.edgeMat));
}
group.add(mesh);
};
// Rahmen-Material (dunkelgrau) + Glas-Material (bläulich, transparent).
const frameMat = new THREE.MeshStandardMaterial({
color: greyed ? 0x9aa0a6 : 0x55606c,
roughness: 0.55,
metalness: 0.15,
envMapIntensity: 0.7,
transparent: greyed,
opacity,
});
const jambW = 0.05; // 5 cm Rahmenprofil
// Vertikale Rahmenprofile an beiden Pfosten + oben/unten (nur so breit wie die
// lichte Öffnung; sie sitzen INNERHALB der Lücke).
addBar(frameMat, 0, jambW, refOff, frameDepth, zBottom, zTop);
addBar(frameMat, width - jambW, width, refOff, frameDepth, zBottom, zTop);
addBar(frameMat, jambW, width - jambW, refOff, frameDepth, zTop - jambW, zTop);
addBar(frameMat, jambW, width - jambW, refOff, frameDepth, zBottom, zBottom + jambW);
if (o.kind === "window") {
// Glasscheibe in der Wandmitte, zwischen den Rahmenprofilen.
const glassMat = new THREE.MeshStandardMaterial({
color: 0x9fc4e8,
roughness: 0.08,
metalness: 0,
envMapIntensity: 1,
transparent: true,
opacity: greyed ? 0.18 : 0.35,
depthWrite: false,
});
addBar(
glassMat,
jambW,
width - jambW,
refOff,
Math.min(0.02, frameDepth * 0.4),
zBottom + jambW,
zTop - jambW,
);
return;
}
// Tür: flaches Blatt in geöffneter Stellung. Anschlag am Scharnier-Pfosten,
// gedreht um swingAngle in die Aufschlagrichtung. Das Blatt liegt VERTIKAL vom
// Boden (zBottom) bis zTop; sein Grundriss-Verlauf folgt der doorSymbol-Achse.
const sym = doorSymbol(wall, o);
if (!sym) return;
const leafMat = new THREE.MeshStandardMaterial({
color: greyed ? 0xb0b4b8 : 0x8a6f57,
roughness: 0.75,
metalness: 0,
envMapIntensity: 0.6,
transparent: greyed,
opacity,
});
// Blatt als dünne Box zwischen hinge und openEnd (im Grundriss), vertikal
// extrudiert. Länge = Türbreite, Dicke ~4 cm.
const dx = sym.openEnd.x - sym.hinge.x;
const dz = sym.openEnd.y - sym.hinge.y;
const leafLen = Math.hypot(dx, dz);
if (leafLen <= 1e-4) return;
const leafAngle = Math.atan2(dz, dx);
const leafGeo = new THREE.BoxGeometry(leafLen, zTop - zBottom, 0.04);
const leaf = new THREE.Mesh(leafGeo, leafMat);
leaf.position.set(
sym.hinge.x + dx / 2,
(zBottom + zTop) / 2,
sym.hinge.y + dz / 2,
);
leaf.rotation.y = -leafAngle;
leaf.userData.openingId = o.id;
if (opts.renderMode === "hidden") {
const edges = new THREE.EdgesGeometry(leafGeo, 1);
leaf.add(new THREE.LineSegments(edges, opts.edgeMat));
}
group.add(leaf);
}
/**
* MeshStandardMaterial pro Component-ID gecached (3D-Farbe = component.color).
* Rauigkeit hoch (mattes Putz-/Wandbild), keine Metallizität — passend für
* Wände/Decken. Gedimmte Variante: Bauteilfarbe Richtung Grau gemischt +
* transparent, damit gedimmte Geschosse/Kategorien als „andere" sichtbar,
* aber zurückgenommen sind.
*/
function layerMaterial(
project: Project,
componentId: string,
greyed: boolean,
opts: BuildOpts,
): THREE.MeshStandardMaterial {
const { mats } = opts;
// Drahtgitter: Component-Material mit wireframe=true. Wird mitgecacht; da der
// Render-Modus den gesamten Szenen-Neuaufbau auslöst, ist der Cache pro Modus
// konsistent (die Caches leben innerhalb des Aufbau-Effekts).
const wireframe = opts.renderMode === "wireframe";
const cache = greyed ? mats.greyed : mats.normal;
let mat = cache.get(componentId);
if (!mat) {
const color = new THREE.Color(getComponent(project, componentId).color);
if (greyed) {
// Zu mittlerem Grau hin mischen (entsättigen) und transparent machen.
color.lerp(new THREE.Color(0x9aa0a6), 0.7);
mat = new THREE.MeshStandardMaterial({
color,
wireframe,
roughness: 0.9,
metalness: 0,
envMapIntensity: 0.6,
transparent: true,
opacity: 0.35,
depthWrite: false,
});
} else {
mat = new THREE.MeshStandardMaterial({
color,
wireframe,
roughness: 0.9,
metalness: 0,
envMapIntensity: 0.6,
});
}
cache.set(componentId, mat);
}
return mat;
}
/**
* Ein Schicht-Prisma: das (ggf. gehrte) Band-Polygon von Achs-Abstand s..e
* zwischen den Offsets offA..offB wird vertikal von baseY bis topY extrudiert.
* Es wird exakt dasselbe Polygon wie im Grundriss verwendet.
* Gehrung greift nur an den echten Wandenden (s==0 bzw. e==length).
* `baseElevation` verschiebt das Prisma vertikal (Geschoss-Stapelung).
*/
function addLayerPrism(
group: THREE.Group,
wall: Wall,
s: number,
e: number,
baseY: number,
topY: number,
offA: number,
offB: number,
cuts: WallCuts,
baseElevation: number,
mat: THREE.Material,
opts: BuildOpts,
): void {
if (e - s <= 1e-6) return;
const length = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
const p1 = along(wall.start, wall.end, s);
const p2 = along(wall.start, wall.end, e);
const startCut = s <= 1e-6 ? cuts.startCut : null;
const endCut = e >= length - 1e-6 ? cuts.endCut : null;
const quad = clippedBand(p1, p2, offA, offB, startCut, endCut);
// Plan (x,y) → Three (x, y). Y-Spiegelung, damit die Form im Three-XY-
// System dieselbe Wicklung behält wie im Grundriss; nach der Extrusion
// wird das Prisma so rotiert, dass Plan-Y auf die Three-Z-Achse fällt.
const shape = new THREE.Shape();
const pt = (v: Vec2, i: number) => (i === 0 ? shape.moveTo(v.x, v.y) : shape.lineTo(v.x, v.y));
quad.forEach(pt);
shape.closePath();
const depth = topY - baseY;
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
// Texturiert-Modus mit aoMap: die Ambient-Occlusion-Karte liest aus `uv2`.
// ExtrudeGeometry erzeugt nur `uv`; wir kopieren sie nach `uv2` (gleiche UV-
// Koordinaten in Welt-Metern → identische Kachelung wie die übrigen Karten).
const std = mat as THREE.MeshStandardMaterial;
if (opts.renderMode === "textured" && std.isMeshStandardMaterial && std.aoMap) {
const uv = geo.getAttribute("uv");
if (uv && !geo.getAttribute("uv2")) {
geo.setAttribute(
"uv2",
new THREE.BufferAttribute((uv as THREE.BufferAttribute).array, 2),
);
}
}
const mesh = new THREE.Mesh(geo, mat);
// Wand-ID für die Raycast-Auswahl mitführen (Links-Klick im Viewport).
mesh.userData.wallId = wall.id;
// ExtrudeGeometry liegt in der XY-Ebene und wächst entlang +Z. Drehung +90°
// um X bildet Plan (x,y) → Three (x, z=y) ab (wie zuvor bei den Boxen); die
// Extrusion zeigt dadurch nach −Y, deshalb sitzt die Form auf topY und
// wächst hinunter bis baseY. Die baseElevation hebt das gesamte Geschoss an.
mesh.rotation.x = Math.PI / 2;
mesh.position.y = topY + baseElevation;
// Verdeckte Kanten: schwarze EdgesGeometry-Linien als Kind des Mesh (erben
// dessen Transform). Zusammen mit den (tiefen-getesteten) weißen Faces +
// polygonOffset ergibt das den Hidden-Line-Look (Kanten dahinter verdeckt).
if (opts.renderMode === "hidden") {
const edges = new THREE.EdgesGeometry(geo, 1);
mesh.add(new THREE.LineSegments(edges, opts.edgeMat));
}
group.add(mesh);
}
/**
* Eine Decke als extrudierter Slab: das geschlossene Umriss-Polygon wird um die
* Deckendicke extrudiert und an die OK (zTop) der Decke gesetzt (wächst nach
* unten bis zBottom) — dieselbe Plan→Three-Abbildung und Extrusions-Konvention
* wie beim Wand-Schicht-Prisma. Das Material folgt dem Render-Modus (analog Wand)
* und wird aus dem BAUTEIL der ersten Schicht des Aufbau-Typs aufgelöst.
* `userData.ceilingId` trägt die Auswahl-ID für den Raycast.
*/
function addCeilingMesh(
group: THREE.Group,
project: Project,
ceiling: Ceiling,
greyed: boolean,
opts: BuildOpts,
): void {
const outline = ceiling.outline;
if (outline.length < 3) return;
const wt = getCeilingType(project, ceiling);
if (wt.layers.length === 0) return;
const componentId = wt.layers[0].componentId;
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
const depth = zTop - zBottom;
if (depth <= 1e-6) return;
// Material je Render-Modus (wie addWallMeshes).
const textured = opts.renderMode === "textured";
const textureMat = textured
? opts.matRuntime.get(getComponent(project, componentId).material)
: null;
const mat =
opts.renderMode === "hidden"
? opts.hiddenFace
: opts.renderMode === "white"
? opts.whiteFace
: textured && textureMat
? textureMat
: layerMaterial(project, componentId, greyed, opts);
const shape = new THREE.Shape();
outline.forEach((v, i) => (i === 0 ? shape.moveTo(v.x, v.y) : shape.lineTo(v.x, v.y)));
shape.closePath();
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
// aoMap-UV2-Kopie wie beim Wand-Prisma (Texturiert-Modus).
const std = mat as THREE.MeshStandardMaterial;
if (opts.renderMode === "textured" && std.isMeshStandardMaterial && std.aoMap) {
const uv = geo.getAttribute("uv");
if (uv && !geo.getAttribute("uv2")) {
geo.setAttribute(
"uv2",
new THREE.BufferAttribute((uv as THREE.BufferAttribute).array, 2),
);
}
}
const mesh = new THREE.Mesh(geo, mat);
mesh.userData.ceilingId = ceiling.id;
// Plan (x,y) → Three (x, z=y); Extrusion zeigt nach −Y, daher an OK setzen.
mesh.rotation.x = Math.PI / 2;
mesh.position.y = zTop;
if (opts.renderMode === "hidden") {
const edges = new THREE.EdgesGeometry(geo, 1);
mesh.add(new THREE.LineSegments(edges, opts.edgeMat));
}
group.add(mesh);
}
/**
* Eine Treppe als gestufte Boxen: jeder Tritt wird als Vollblock von der
* Treppen-UK (zBottom) bis zur Oberkante SEINER Setzstufe (zBottom + topRise)
* extrudiert — so entsteht das treppenartige Stufenprofil (Tritt + Setzstufe in
* einem). Das Zwischenpodest (L/Wendel) wird bis zu seiner Höhe extrudiert. Das
* Material folgt dem Render-Modus; ein neutraler Beton-Grauton dient als Default.
* `userData.stairId` trägt die Auswahl-ID für den Raycast.
*
* Ist ein Treppentyp (`StairType`) aufgelöst und dessen Tragart ungleich
* "massiv", wird die Treppe stattdessen UNTEN OFFEN dargestellt: jeder Tritt
* wird nur als dünne Platte (Dicke `treadThickness`) an seiner Oberkante
* modelliert, optional mit Trittkanten-Überstand (Nase) und/oder geschlossenen
* Setzstufen-Platten — je nach `StairType`-Feldern.
*/
function addStairMeshes(
group: THREE.Group,
project: Project,
stair: Stair,
greyed: boolean,
opts: BuildOpts,
): void {
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const totalRise = zTop - zBottom;
if (totalRise <= 1e-6) return;
const geo = stairGeometry(stair, totalRise);
const st = getStairType(project, stair);
// Material je Render-Modus. Ohne Component-Referenz nutzen wir einen neutralen
// Beton-Grauton (gedimmt heller/transparent).
const baseMat =
opts.renderMode === "hidden"
? opts.hiddenFace
: opts.renderMode === "white"
? opts.whiteFace
: new THREE.MeshStandardMaterial({
color: greyed ? 0xb8bcc2 : 0x9aa0a6,
roughness: 0.85,
metalness: 0,
envMapIntensity: 0.6,
transparent: greyed,
opacity: greyed ? 0.35 : 1,
});
// Extrudiert ein geschlossenes Grundriss-Polygon zu einer Platte zwischen
// `bottom` und `top` (beide relativ zur Treppen-UK `zBottom`). Analog zum
// Vollblock, aber mit frei wählbarer Unterkante statt immer 0.
const extrudeSlab = (pts: Vec2[], bottom: number, top: number) => {
const depth = top - bottom;
if (pts.length < 3 || depth <= 1e-6) return;
const shape = new THREE.Shape();
pts.forEach((v, i) => (i === 0 ? shape.moveTo(v.x, v.y) : shape.lineTo(v.x, v.y)));
shape.closePath();
const g = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
const mesh = new THREE.Mesh(g, baseMat);
mesh.userData.stairId = stair.id;
// Plan (x,y) → Three (x, z=y); Extrusion nach −Y, daher an die Platten-OK setzen.
mesh.rotation.x = Math.PI / 2;
mesh.position.y = zBottom + top;
if (opts.renderMode === "hidden") {
const edges = new THREE.EdgesGeometry(g, 1);
mesh.add(new THREE.LineSegments(edges, opts.edgeMat));
}
group.add(mesh);
};
// Vollblock (Default/"massiv"): von der Treppen-UK bis zur Tritt-Oberkante.
const extrudeBlock = (pts: Vec2[], top: number) => extrudeSlab(pts, 0, top);
const centroidOf = (pts: Vec2[]): Vec2 => {
let x = 0, y = 0;
for (const p of pts) { x += p.x; y += p.y; }
return { x: x / pts.length, y: y / pts.length };
};
// Bestimmt die "vordere" Kante eines Tritt-Vierecks (die Kante in Richtung
// des nächsten Tritts). `target` ist der Schwerpunkt eines Nachbartritts;
// `preferNear` wählt die dem Ziel NÄCHSTE Kante (Nachbar = nächster Tritt),
// sonst die FERNSTE (Nachbar = vorheriger Tritt, Vorderkante liegt entgegengesetzt).
// Rückgabe: Indizes der beiden Eckpunkte der Vorderkante + deren Richtung
// (vom Trittschwerpunkt zur Kantenmitte, normiert).
const frontEdgeOf = (
pts: Vec2[],
target: Vec2,
preferNear: boolean,
): { a: number; b: number; dir: Vec2 } => {
const c = centroidOf(pts);
let best = 0;
let bestDist = preferNear ? Infinity : -Infinity;
for (let e = 0; e < pts.length; e++) {
const p0 = pts[e];
const p1 = pts[(e + 1) % pts.length];
const mid = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
const d = Math.hypot(mid.x - target.x, mid.y - target.y);
if (preferNear ? d < bestDist : d > bestDist) {
bestDist = d;
best = e;
}
}
const p0 = pts[best];
const p1 = pts[(best + 1) % pts.length];
const mid = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
const dx = mid.x - c.x;
const dy = mid.y - c.y;
const len = Math.hypot(dx, dy) || 1e-9;
return { a: best, b: (best + 1) % pts.length, dir: { x: dx / len, y: dy / len } };
};
if (!st || st.structure === "massiv") {
// Default/rückwärtskompatibel: durchgehend massive Vollblöcke.
for (const tr of geo.treads) extrudeBlock(tr.pts, tr.topRise);
} else {
// Offene Treppe (Wangen-, aufgesattelte oder Spindeltreppe): dünne
// Tritt-Platten statt Vollblöcken, darunter bleibt die Treppe offen.
const treads = geo.treads;
for (let k = 0; k < treads.length; k++) {
const tr = treads[k];
let pts = tr.pts;
// Nase (Nosing): die Vorderkante des Tritts (Richtung nächster Tritt)
// um den Überstand nach vorne verlängern. Die Vorderkante wird über den
// Nachbartritt (Schwerpunkt) bestimmt — für den letzten Tritt (kein
// Nachfolger) wird stattdessen die dem Vorgänger abgewandte Kante genutzt.
const nosing = st.nosing ?? 0;
if (nosing > 1e-6) {
const hasNext = k + 1 < treads.length;
const hasPrev = k > 0;
const ref = hasNext ? treads[k + 1] : hasPrev ? treads[k - 1] : null;
if (ref) {
const { a, b, dir } = frontEdgeOf(pts, centroidOf(ref.pts), hasNext);
const nosed = pts.slice();
nosed[a] = { x: pts[a].x + dir.x * nosing, y: pts[a].y + dir.y * nosing };
nosed[b] = { x: pts[b].x + dir.x * nosing, y: pts[b].y + dir.y * nosing };
pts = nosed;
}
}
// Tritt-Platte: dünne Scheibe, Oberkante an der Setzstufen-Oberkante.
extrudeSlab(pts, tr.topRise - st.treadThickness, tr.topRise);
// Geschlossene Setzstufe: vertikale Platte an der RÜCKKante dieses Tritts
// (= Vorderkante des Vorgängers), von dessen UK (`baseRise`) bis zu
// dieser Oberkante (`topRise`). Offene Treppe (closedRisers=false) lässt
// diese Fläche bewusst weg.
if (st.closedRisers) {
const hasNext = k + 1 < treads.length;
const ref = hasNext ? treads[k + 1] : k > 0 ? treads[k - 1] : null;
if (ref) {
// Rückkante = Gegenkante der Vorderkante desselben Tritts.
const front = frontEdgeOf(tr.pts, centroidOf(ref.pts), hasNext);
const n = tr.pts.length;
const backA = (front.a + Math.floor(n / 2)) % n;
const backB = (front.b + Math.floor(n / 2)) % n;
const riserPts = [tr.pts[backA], tr.pts[backB]];
// Dünne Platte: Rückkante zu einer flachen Wange in Setzrichtung
// aufgeweitet (minimaler Versatz nach vorn), damit ein extrudierbares
// Polygon entsteht.
const thin = Math.min(st.treadThickness, 0.02) || 0.01;
const offA = { x: tr.pts[backA].x - front.dir.x * thin, y: tr.pts[backA].y - front.dir.y * thin };
const offB = { x: tr.pts[backB].x - front.dir.x * thin, y: tr.pts[backB].y - front.dir.y * thin };
const riserPoly = [riserPts[0], riserPts[1], offB, offA];
extrudeSlab(riserPoly, tr.baseRise, tr.topRise);
}
}
// TODO Geländer (railing) noch nicht umgesetzt.
}
}
// Podest (L/Wendel-Auge): bis zur mittleren Höhe extrudieren, damit es sichtbar
// als Absatz erscheint (halber Gesamt-Rise als sinnvoller Default). Bleibt
// unabhängig von der Tragart immer als Vollblock stehen.
if (geo.landing && geo.landing.length >= 3) {
// Höhe des Podests = Höhe des höchsten Tritts unter/an der Podestposition;
// wir nehmen die halbe Gesamt-Steighöhe als robusten Näherungswert.
extrudeBlock(geo.landing, totalRise * 0.5);
}
}