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
+45 -11
View File
@@ -24,12 +24,14 @@ describe("selectionHighlightLines", () => {
expect(lines.length).toBe(0); expect(lines.length).toBe(0);
}); });
it("eine Wand ohne Öffnungen -> genau 12 Kanten (ein Quader)", () => { it("eine Wand ohne Öffnungen -> je Materiallage ein Quader (4·12 Kanten)", () => {
// W2 (EG, Ost) trägt keine Tür/kein Fenster im Sample-Projekt (nur W1/W3 // W2 (EG, Ost) trägt keine Tür/kein Fenster im Sample-Projekt (nur W1/W3
// haben Öffnungen, s. sampleProject.ts) -> ein einziger Teilquader, also // haben Öffnungen, s. sampleProject.ts) -> ein Achsen-Teilquader. Wandtyp
// exakt 12 Kanten. // "aw" hat 4 Schichten (render-ext/insulation/brick/render-int), die
// TS-seitig je als eigene Box gestapelt werden (Option B) -> 4 Quader =
// 4·12 Kanten. (Früher: 1 Vollkörper = 12 Kanten.)
const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB); const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB);
expect(lines.length).toBe(12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); expect(lines.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
}); });
it("eine Wand mit Farbwerten in jedem Vertex", () => { it("eine Wand mit Farbwerten in jedem Vertex", () => {
@@ -70,16 +72,48 @@ describe("selectionHighlightLines", () => {
}); });
describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => { describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
it("Wand -> Farbe der dicksten Schicht (aw: insulation 0.16 m, #ffffff)", () => { it("Wand -> je Materiallage eine Box in ihrer Component-Farbe (Option B)", () => {
// Wandtyp "aw" (s. sampleProject): render-ext 0.02, insulation 0.16, // Wandtyp "aw" (s. sampleProject) hat 4 Schichten mit eigenen Farben:
// brick 0.15, render-int 0.015 -> dickste Schicht = insulation (#ffffff). // render-ext 0.02 #d8d2c7, insulation 0.16 #ffffff,
// brick 0.15 #8c5544, render-int 0.015 #efece6 (Σ = 0.345 m).
// W2 ist öffnungslos -> genau ein Achsen-Teilquader, also 4 Schicht-Boxen.
const walls = projectToWalls3d(sampleProject); const walls = projectToWalls3d(sampleProject);
const w2 = walls.filter((w) => w.wallId === "W2"); const w2 = walls.filter((w) => w.wallId === "W2");
expect(w2.length).toBeGreaterThan(0); expect(w2.length).toBe(4);
// Alle Schicht-Boxen tragen die gemeinsame wallId (Klick selektiert ganze Wand)
// und sind Vollkörper (layers leer -> render3d zeichnet in `color`).
for (const seg of w2) { for (const seg of w2) {
expect(seg.color[0]).toBeCloseTo(1, 6); expect(seg.wallId).toBe("W2");
expect(seg.color[1]).toBeCloseTo(1, 6); expect(seg.layers.length).toBe(0);
expect(seg.color[2]).toBeCloseTo(1, 6); }
// Die vier Materialfarben kommen genau einmal vor.
const hex = (c: [number, number, number]) =>
c.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
const colors = w2.map((s) => hex(s.color)).sort();
expect(colors).toEqual(["8c5544", "d8d2c7", "efece6", "ffffff"]);
// Die Schichtdicken summieren zur Gesamt-Wanddicke (keine Lücken/Overlaps).
const totalT = w2.reduce((s, seg) => s + seg.thickness, 0);
expect(totalT).toBeCloseTo(0.345, 6);
});
it("Schicht-Offsets kacheln die Wanddicke lückenlos um die Achse", () => {
// W2-Achse (5,0)->(5,4): u=(0,1), Normale n=(1,0) -> der Schicht-Versatz
// wirkt rein auf x (Box-Start.x = 5 + offset). Sortiert man die Boxen nach
// Versatz, müssen sich benachbarte Flächen exakt berühren und der Stapel
// symmetrisch um die Achse liegen: min = -T/2, max = +T/2.
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
const bands = w2
.map((s) => ({ offset: s.start[0] - 5, t: s.thickness }))
.sort((a, b) => a.offset - b.offset);
const T = bands.reduce((s, b) => s + b.t, 0);
expect(bands[0].offset - bands[0].t / 2).toBeCloseTo(-T / 2, 6);
expect(bands[bands.length - 1].offset + bands[bands.length - 1].t / 2).toBeCloseTo(T / 2, 6);
for (let i = 1; i < bands.length; i++) {
// Oberkante der Vorlage == Unterkante der nächsten (keine Lücke/Overlap).
expect(bands[i - 1].offset + bands[i - 1].t / 2).toBeCloseTo(bands[i].offset - bands[i].t / 2, 6);
} }
}); });
+103 -45
View File
@@ -18,13 +18,19 @@
// Polygon-Platten (SlabInput) mitgegeben — render3d trianguliert den Umriss und // Polygon-Platten (SlabInput) mitgegeben — render3d trianguliert den Umriss und
// zieht ihn über die Deckendicke hoch. // zieht ihn über die Deckendicke hoch.
// //
// SCHICHTEN (Putz/Dämmung/Mauerwerk/…): zusätzlich zur Gesamtdicke wird der // SCHICHTEN (Putz/Dämmung/Mauerwerk/…): der aufgelöste Schichtaufbau des
// aufgelöste Schichtaufbau des WallType mitgegeben (`layers`) — je Schicht // WallType wird HIER (TS-seitig) in echte 3D-Substanz zerlegt — statt EINES
// Dicke + 3D-Farbe des zugehörigen Bauteil-Materials (Component). render3d // Vollkörpers in der repräsentativen Farbe emittiert jede Achsen-Teilquader-
// extrudiert daraus je Schicht ein eigenes Band quer zur Wanddicke (siehe // Position je Materiallage eine EIGENE Box, quer zur Wanddicke entlang der
// render3d::mesh); die Eck-Gehrung (Wandknoten-Verschneidung) berechnet // Wand-Normalen gestapelt und in der Materialfarbe ihres Components eingefärbt
// render3d selbständig aus der Wandkonnektivität, braucht also keine // (Option B). Schicht i belegt entlang der Normalen das Intervall
// zusätzlichen Felder von hier. // [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 type { Project, Wall, Layer } from "../model/types";
import { import {
@@ -42,7 +48,12 @@ export type RRgb = [number, number, number];
/** Weltpunkt (x, elevation, y) — nur intern für Highlight-Kantengeometrie. */ /** Weltpunkt (x, elevation, y) — nur intern für Highlight-Kantengeometrie. */
type RVec3 = [number, number, number]; 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 { export interface RLayer {
thickness: number; thickness: number;
color: RRgb; color: RRgb;
@@ -55,7 +66,11 @@ export interface RWall {
height: number; height: number;
baseElevation: number; baseElevation: number;
color: RRgb; 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[]; layers: RLayer[];
/** /**
* Id der Ursprungs-Wand (`project.walls[].id`). Alle Öffnungs-Teilquader einer * 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 * Eine quer zur Wanddicke gestapelte Materiallage als 3D-Band: eigene Dicke,
* Wand-Startpunkt) über den Höhenbereich [zBottom..zTop]. Entartete Stücke * Materialfarbe und Mitten-Versatz `offset` (Meter, entlang der Wand-Normalen,
* (Länge ≤ 0 oder Höhe ≤ 0) werden übersprungen — so überleben Öffnungen dicht * relativ zur Wandachse). Jede solche Lage wird pro Achsen-Teilquader zu einer
* an den Wandenden ohne Nullquader. * 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( function pushSegment(
out: RWall[], out: RWall[],
@@ -159,9 +221,9 @@ function pushSegment(
zBottom: number, zBottom: number,
zTop: number, zTop: number,
thickness: number, thickness: number,
layers: RLayer[], normalOffset: number,
wallId: string,
color: RRgb, color: RRgb,
wallId: string,
): void { ): void {
if (to - from <= EPS) return; if (to - from <= EPS) return;
if (zTop - zBottom <= EPS) return; if (zTop - zBottom <= EPS) return;
@@ -171,8 +233,14 @@ function pushSegment(
if (len < 1e-9) return; if (len < 1e-9) return;
const ux = dx / len; const ux = dx / len;
const uy = dy / len; const uy = dy / len;
const p1: RVec2 = [wall.start.x + ux * from, wall.start.y + uy * from]; // Grundriss-Normale (identisch zur Dicken-Achse in raycast3d /
const p2: RVec2 = [wall.start.x + ux * to, wall.start.y + uy * to]; // 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({ out.push({
start: p1, start: p1,
end: p2, end: p2,
@@ -180,38 +248,27 @@ function pushSegment(
height: zTop - zBottom, height: zTop - zBottom,
baseElevation: zBottom, baseElevation: zBottom,
color, color,
layers, layers: [],
wallId, wallId,
}); });
} }
/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */ /** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */
function emitWall(out: RWall[], project: Project, wall: Wall): void { function emitWall(out: RWall[], project: Project, wall: Wall): void {
let thickness = 0.2; const bands = resolveWallBands(project, wall);
// 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 { zBottom, zTop } = wallVerticalExtent(project, wall); const { zBottom, zTop } = wallVerticalExtent(project, wall);
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return; 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: // Aussparungen der Wand als vereinheitlichte Cutouts sammeln:
// • Öffnungen (project.openings, Fenster mit Brüstung/Sturz), // • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
// • Legacy-Türen (project.doors, sitzen am Boden, nur 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); 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) { if (cutouts.length === 0) {
pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness, layers, wall.id, color); emitSeg(0, axisLen, zBottom, zTop);
return; return;
} }
@@ -248,23 +306,23 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void {
const segFrom = Math.max(cursor, from); const segFrom = Math.max(cursor, from);
if (from > cursor) { if (from > cursor) {
// Voller Wandpfeiler bis zur Aussparung. // 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) { if (to > segFrom) {
// Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom). // Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom).
if (oBottom > zBottom + EPS) { 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). // Sturz über der Öffnung (bis zum Wandkopf).
if (oTop < zTop - EPS) { 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); cursor = Math.max(cursor, to);
} }
// Restlicher Wandpfeiler bis zum Achsenende. // Restlicher Wandpfeiler bis zum Achsenende.
if (cursor < axisLen) { if (cursor < axisLen) {
pushSegment(out, wall, cursor, axisLen, zBottom, zTop, thickness, layers, wall.id, color); emitSeg(cursor, axisLen, zBottom, zTop);
} }
} }