Files
DOSSIER-STANDALONE/src/plan/toWalls3d.ts
T
karim fabf97a402 Nordstern-3D: Auswahl-Highlight (orange Outline, immer sichtbar)
Ein selektiertes Bauteil wird jetzt im 3D-Viewport mit einer orangen
Umrisslinie markiert, die dank No-Depth-Pipeline auch hinter Wänden
durchscheint (klare Selektions-Rückmeldung). Zusammen mit Objekt-Info
(numerisch) und Attribute-Panel ist die 3D-Auswahl damit vollständig.

- Engine: set_highlight_lines(vertices) + highlight_pipeline
  (LineList, depth_compare Always, kein Depth-Write), als letzter
  Draw-Call obenauf. Reuse der Grid-Linien-Infrastruktur.
- TS: selectionHighlightLines() baut die Quader-/Prisma-Kanten der
  Auswahl in Akzentfarbe; App berechnet sie per useMemo aus der
  Store-Auswahl und reicht sie durch.
2026-07-04 06:26:29 +02:00

408 lines
16 KiB
TypeScript

// 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/…): zusätzlich zur Gesamtdicke wird der
// aufgelöste Schichtaufbau des WallType mitgegeben (`layers`) — je Schicht
// Dicke + 3D-Farbe des zugehörigen Bauteil-Materials (Component). render3d
// extrudiert daraus je Schicht ein eigenes Band quer zur Wanddicke (siehe
// render3d::mesh); die Eck-Gehrung (Wandknoten-Verschneidung) berechnet
// render3d selbständig aus der Wandkonnektivität, braucht also keine
// zusätzlichen Felder von hier.
import type { Project, Wall } from "../model/types";
import { getComponent, getWallType, 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). */
export interface RLayer {
thickness: number;
color: RRgb;
}
export interface RWall {
start: RVec2;
end: RVec2;
thickness: number;
height: number;
baseElevation: number;
color: RRgb;
/** Aufgelöster Schichtaufbau (leer = kein WallType auflösbar, siehe emitWall). */
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];
/** 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): RRgb {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex);
if (!m) return WALL_RGB;
const v = parseInt(m[1], 16);
return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255];
}
/**
* Hängt einen Wand-Teilquader an: das Achsenstück [from..to] (Meter ab
* Wand-Startpunkt) über den Höhenbereich [zBottom..zTop]. 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,
layers: RLayer[],
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;
const p1: RVec2 = [wall.start.x + ux * from, wall.start.y + uy * from];
const p2: RVec2 = [wall.start.x + ux * to, wall.start.y + uy * to];
out.push({
start: p1,
end: p2,
thickness,
height: zTop - zBottom,
baseElevation: zBottom,
color: WALL_RGB,
layers,
wallId,
});
}
/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */
function emitWall(out: RWall[], project: Project, wall: Wall): void {
let thickness = 0.2;
// Aufgelöster Schichtaufbau (Dicke + 3D-Farbe je Schicht des WallType) —
// leer, wenn kein WallType auflösbar ist (render3d extrudiert dann wie
// bisher einen Vollkörper aus `thickness`/`color`, siehe RWall-Moduldoc).
let layers: RLayer[] = [];
try {
const wt = getWallType(project, wall);
thickness = wallTypeThickness(wt);
layers = wt.layers.map((l) => ({
thickness: l.thickness,
color: hexToRgb(getComponent(project, l.componentId).color),
}));
} catch {
thickness = 0.2;
layers = [];
}
const { zBottom, zTop } = wallVerticalExtent(project, wall);
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;
// 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 (wie bisher).
if (cutouts.length === 0) {
pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness, layers, wall.id);
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.
pushSegment(out, wall, cursor, from, zBottom, zTop, thickness, layers, wall.id);
}
if (to > segFrom) {
// Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom).
if (oBottom > zBottom + EPS) {
pushSegment(out, wall, segFrom, to, zBottom, oBottom, thickness, layers, wall.id);
}
// Sturz über der Öffnung (bis zum Wandkopf).
if (oTop < zTop - EPS) {
pushSegment(out, wall, segFrom, to, oTop, zTop, thickness, layers, wall.id);
}
}
cursor = Math.max(cursor, to);
}
// Restlicher Wandpfeiler bis zum Achsenende.
if (cursor < axisLen) {
pushSegment(out, wall, cursor, axisLen, zBottom, zTop, thickness, layers, wall.id);
}
}
/** 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;
out.push({
outline: c.outline.map((p) => [p.x, p.y] as RVec2),
zBottom,
zTop,
color: SLAB_RGB,
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;
}
/**
* Nur die Wände (Rückwärts-Kompatibilität / bestehende Aufrufer). Zerlegt Wände
* mit Öffnungen in Teilquader.
*/
export function projectToWalls3d(project: Project): RWall[] {
const out: RWall[] = [];
for (const w of project.walls) emitWall(out, project, w);
return out;
}
/** Das volle 3D-Modell: Wände (mit Öffnungen) + Deckenplatten + Kontext-Meshes. */
export function projectToModel3d(project: Project): RModel3d {
return {
walls: projectToWalls3d(project),
slabs: emitSlabs(project),
meshes: emitMeshes(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);
}