3D: mehrschichtige Wände zeigen ihren Schichtaufbau

Statt eines Vollkörpers in der repräsentativen Farbe wird jede
Materiallage der Wand als eigener extrudierter Teilquader in ihrer
Component-Farbe emittiert (quer zur Wanddicke gestapelt, zentriert um
die Achse). Gilt auch für die Öffnungs-Teilquader (Pfeiler/Brüstung/
Sturz). Jede Schicht-Box trägt die wallId der Ursprungswand → Klick-
Auswahl + Highlight umfassen weiter die ganze Wand.
This commit is contained in:
2026-07-04 12:40:25 +02:00
parent 018fef56bf
commit 00c90857ad
2 changed files with 148 additions and 56 deletions
+103 -45
View File
@@ -18,13 +18,19 @@
// 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.
// 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, Layer } from "../model/types";
import {
@@ -42,7 +48,12 @@ 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). */
/**
* 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;
@@ -55,7 +66,11 @@ export interface RWall {
height: number;
baseElevation: number;
color: RRgb;
/** Aufgelöster Schichtaufbau (leer = kein WallType auflösbar, siehe emitWall). */
/**
* 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
@@ -146,10 +161,57 @@ function dominantLayerColor(layers: Layer[], project: Project, fallback: RRgb):
}
/**
* 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.
* 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): 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);
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[],
@@ -159,9 +221,9 @@ function pushSegment(
zBottom: number,
zTop: number,
thickness: number,
layers: RLayer[],
wallId: string,
normalOffset: number,
color: RRgb,
wallId: string,
): void {
if (to - from <= EPS) return;
if (zTop - zBottom <= EPS) return;
@@ -171,8 +233,14 @@ function pushSegment(
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];
// Grundriss-Normale (identisch zur Dicken-Achse in raycast3d /
// selectionHighlightLines: n = (uy, ux)). Der Schicht-Versatz verschiebt
// Start/Ende der Box quer zur Wand, sodass render3d/raycast die Box um ihre
// eigene (versetzte) Achse zentriert aufbaut.
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,
@@ -180,38 +248,27 @@ function pushSegment(
height: zTop - zBottom,
baseElevation: zBottom,
color,
layers,
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[] = [];
// Repräsentative Vollkörper-Farbe = Farbe der dicksten Schicht (Option A,
// siehe Moduldoc); Fallback WALL_RGB, wenn kein WallType auflösbar ist.
let color: RRgb = WALL_RGB;
try {
const wt = getWallType(project, wall);
thickness = wallTypeThickness(wt);
layers = wt.layers.map((l) => ({
thickness: l.thickness,
color: hexToRgb(getComponent(project, l.componentId).color),
}));
color = dominantLayerColor(wt.layers, project, WALL_RGB);
} catch {
thickness = 0.2;
layers = [];
color = WALL_RGB;
}
const bands = resolveWallBands(project, wall);
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;
// 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).
@@ -234,9 +291,10 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void {
}
cutouts.sort((a, b) => a.from - b.from);
// Ohne Aussparungen: ein durchgehender Quader (wie bisher).
// Ohne Aussparungen: ein durchgehender Quader je Schicht (wie bisher, aber
// in Lagen aufgeteilt).
if (cutouts.length === 0) {
pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness, layers, wall.id, color);
emitSeg(0, axisLen, zBottom, zTop);
return;
}
@@ -248,23 +306,23 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void {
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, color);
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) {
pushSegment(out, wall, segFrom, to, zBottom, oBottom, thickness, layers, wall.id, color);
emitSeg(segFrom, to, zBottom, oBottom);
}
// Sturz über der Öffnung (bis zum Wandkopf).
if (oTop < zTop - EPS) {
pushSegment(out, wall, segFrom, to, oTop, zTop, thickness, layers, wall.id, color);
emitSeg(segFrom, to, oTop, zTop);
}
}
cursor = Math.max(cursor, to);
}
// Restlicher Wandpfeiler bis zum Achsenende.
if (cursor < axisLen) {
pushSegment(out, wall, cursor, axisLen, zBottom, zTop, thickness, layers, wall.id, color);
emitSeg(cursor, axisLen, zBottom, zTop);
}
}