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:
+45
-11
@@ -24,12 +24,14 @@ describe("selectionHighlightLines", () => {
|
||||
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
|
||||
// haben Öffnungen, s. sampleProject.ts) -> ein einziger Teilquader, also
|
||||
// exakt 12 Kanten.
|
||||
// haben Öffnungen, s. sampleProject.ts) -> ein Achsen-Teilquader. Wandtyp
|
||||
// "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);
|
||||
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", () => {
|
||||
@@ -70,16 +72,48 @@ describe("selectionHighlightLines", () => {
|
||||
});
|
||||
|
||||
describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
|
||||
it("Wand -> Farbe der dicksten Schicht (aw: insulation 0.16 m, #ffffff)", () => {
|
||||
// Wandtyp "aw" (s. sampleProject): render-ext 0.02, insulation 0.16,
|
||||
// brick 0.15, render-int 0.015 -> dickste Schicht = insulation (#ffffff).
|
||||
it("Wand -> je Materiallage eine Box in ihrer Component-Farbe (Option B)", () => {
|
||||
// Wandtyp "aw" (s. sampleProject) hat 4 Schichten mit eigenen Farben:
|
||||
// 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 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) {
|
||||
expect(seg.color[0]).toBeCloseTo(1, 6);
|
||||
expect(seg.color[1]).toBeCloseTo(1, 6);
|
||||
expect(seg.color[2]).toBeCloseTo(1, 6);
|
||||
expect(seg.wallId).toBe("W2");
|
||||
expect(seg.layers.length).toBe(0);
|
||||
}
|
||||
|
||||
// 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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user