From de7a26b89e0d1760c5634d2b67eb96c233ee15c3 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 12:40:25 +0200 Subject: [PATCH] =?UTF-8?q?3D:=20mehrschichtige=20W=C3=A4nde=20zeigen=20ih?= =?UTF-8?q?ren=20Schichtaufbau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/plan/toWalls3d.test.ts | 56 +++++++++++--- src/plan/toWalls3d.ts | 148 ++++++++++++++++++++++++++----------- 2 files changed, 148 insertions(+), 56 deletions(-) diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 6fcf5ec..e391485 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -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); } }); diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index cf3dd8c..644eb24 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -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 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); } }