From a6db682e9d94eec0a00dbfdc765a58f5bed30601 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 11 Jul 2026 00:30:16 +0200 Subject: [PATCH] =?UTF-8?q?Ansicht:=20D=C3=A4cher=20als=20Fl=C3=A4chen=20+?= =?UTF-8?q?=20Giebel=20+=20Traufschatten=20nachger=C3=BCstet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Ansichts-Generator entstand auf einem Basisstand ohne TS-Dächer — roofWorldFacesAll liefert jetzt die Dachflächen (Newell-Normale aufwärts) und Giebel (nach aussen orientiert) aus roofGeometry, projiziert in den Painter- Strom (rückseiten-gecullt, roofId für spätere Auswahl); Dachflächen wirken zusätzlich als Schattenwerfer (Traufüberstand → 45°-Schlagschatten auf die Fassade). +1 Test (Sattel-Süd-Schräge bis Firsthöhe). 722/722 grün. --- src/plan/toElevation.test.ts | 45 +++++++++++++++++++ src/plan/toElevation.ts | 83 ++++++++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/plan/toElevation.test.ts b/src/plan/toElevation.test.ts index dfd337e..f25f076 100644 --- a/src/plan/toElevation.test.ts +++ b/src/plan/toElevation.test.ts @@ -255,3 +255,48 @@ function elevationProject(opts: { overhang: boolean }): Project { }; return project; } + +describe("Dächer in der Ansicht (roofWorldFacesAll)", () => { + it("Dachflächen erscheinen als roofId-Polygone bis zur Firsthöhe", () => { + // Sattel 4×3 über dem Fixture-Haus, First entlang X, Traufe 3 m — die + // Süd-Schräge zeigt zum Betrachter (Ansicht Süd des Fixtures), die + // Nord-Schräge ist rückseiten-gecullt. + const base = elevationProject({ overhang: false }); + const p = { + ...base, + roofs: [ + { + id: "R1", + type: "roof", + floorId: "eg", + categoryCode: "35", + outline: [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, + ], + shape: "sattel", + pitchDeg: 30, + overhang: 0, + ridgeAxis: "x", + baseElevation: 3, + thickness: 0.2, + }, + ], + } as unknown as Project; + const level = p.drawingLevels.find((z) => z.kind === "elevation")!; + const plan = generateElevationPlan(p, level); + expect(plan).not.toBeNull(); + const roofPolys = plan!.primitives.filter( + (pr) => pr.kind === "polygon" && (pr as { roofId?: string }).roofId === "R1", + ); + expect(roofPolys.length).toBeGreaterThanOrEqual(1); + // Die sichtbare Dachfläche reicht bis zur Firsthöhe 3 + 1.5·tan30. + const ridge = 3 + 1.5 * Math.tan((30 * Math.PI) / 180); + const maxV = Math.max( + ...roofPolys.flatMap((pr) => (pr as { pts: { y: number }[] }).pts.map((q) => q.y)), + ); + expect(maxV).toBeCloseTo(ridge, 5); + }); +}); diff --git a/src/plan/toElevation.ts b/src/plan/toElevation.ts index 5706422..baad8e7 100644 --- a/src/plan/toElevation.ts +++ b/src/plan/toElevation.ts @@ -39,6 +39,7 @@ import { } from "../model/types"; import { wallVerticalExtent } from "../model/wall"; import { openingInterval, openingVerticalExtent } from "../geometry/opening"; +import { roofBaseElevation, roofGeometry } from "../geometry/roof"; import { projectToModel3d } from "./toWalls3d"; import type { RModel3d, RSlab, RWall } from "./toWalls3d"; import { sectionPlaneFromLevel, sectionUAxisModel } from "./toSection"; @@ -138,7 +139,7 @@ export function projectPoint( // ── Welt-Flächen aus dem geflachten Modell ───────────────────────────────── /** Rolle einer Fläche (steuert Füllung/Stil in der Ansicht). */ -export type FaceRole = "wall" | "slab"; +export type FaceRole = "wall" | "slab" | "roof"; /** Eine orientierte Welt-Fläche (konvexes Polygon + Aussennormale). */ export interface WorldFace { @@ -149,6 +150,8 @@ export interface WorldFace { wallId?: string; /** Ursprungs-Decke (nur role "slab"). */ ceilingId?: string; + /** Ursprungs-Dach (nur role "roof"). */ + roofId?: string; } function normalize3(a: V3): V3 { @@ -274,6 +277,7 @@ export interface ProjectedFace { role: FaceRole; wallId?: string; ceilingId?: string; + roofId?: string; } /** @@ -294,7 +298,7 @@ export function projectFace(frame: ElevationFrame, face: WorldFace): ProjectedFa } const depth = depthSum / face.poly.length; if (depth <= EPS) return null; // vor der Ebene → ignorieren - return { pts, depth, role: face.role, wallId: face.wallId, ceilingId: face.ceilingId }; + return { pts, depth, role: face.role, wallId: face.wallId, ceilingId: face.ceilingId, roofId: face.roofId }; } /** @@ -320,6 +324,71 @@ export function collectElevationFaces(model: RModel3d, frame: ElevationFrame): P return out; } +// ── Dächer ────────────────────────────────────────────────────────────────── + +/** + * Die Welt-Flächen ALLER Dächer des Projekts: Dachflächen (Aufsicht-Polygone der + * roofGeometry, Normale aufwärts orientiert) + Giebel (vertikale Endflächen, + * Normale vom Dach-Schwerpunkt weg nach aussen). Modell [x,y,z=Höhe] → Welt + * (x, y=Höhe, z=y), dieselbe Konvention wie emitRoofs/pickGeometry. Die + * Materialdicke bleibt für die Ansicht unberücksichtigt (Silhouette genügt — + * die Untersicht ist rückseiten-gecullt ohnehin unsichtbar). + */ +export function roofWorldFacesAll(project: Project): WorldFace[] { + const out: WorldFace[] = []; + for (const roof of project.roofs ?? []) { + const eavesZ = roofBaseElevation(project, roof); + const g = roofGeometry(roof, eavesZ); + // Dach-Schwerpunkt (Welt) für die Aussen-Orientierung der Giebel. + let cx = 0; + let cy = 0; + let cz = 0; + let cn = 0; + for (const pl of g.planes) { + for (const p of pl.pts) { + cx += p[0]; + cy += p[2]; + cz += p[1]; + cn++; + } + } + if (cn === 0) continue; + cx /= cn; + cy /= cn; + cz /= cn; + const newell = (poly: V3[]): V3 => { + let nx = 0; + let ny = 0; + let nz = 0; + for (let i = 0; i < poly.length; i++) { + const a = poly[i]; + const b = poly[(i + 1) % poly.length]; + nx += (a[1] - b[1]) * (a[2] + b[2]); + ny += (a[2] - b[2]) * (a[0] + b[0]); + nz += (a[0] - b[0]) * (a[1] + b[1]); + } + return normalize3([nx, ny, nz]); + }; + for (const pl of g.planes) { + const poly: V3[] = pl.pts.map((p) => [p[0], p[2], p[1]]); + let n = newell(poly); + // Dachflächen zeigen aufwärts (Welt-Y). + if (n[1] < 0) n = [-n[0], -n[1], -n[2]]; + out.push({ role: "roof", normal: n, poly, roofId: roof.id }); + } + for (const gable of g.gables) { + const poly: V3[] = gable.map((p) => [p[0], p[2], p[1]]); + if (poly.length < 3) continue; + let n = newell(poly); + // Giebel zeigen vom Dach-Schwerpunkt weg nach aussen. + const toC: V3 = [cx - poly[0][0], cy - poly[0][1], cz - poly[0][2]]; + if (n[0] * toC[0] + n[1] * toC[1] + n[2] * toC[2] > 0) n = [-n[0], -n[1], -n[2]]; + out.push({ role: "roof", normal: n, poly, roofId: roof.id }); + } + } + return out; +} + // ── Fenster/Türen ─────────────────────────────────────────────────────────── /** Eine projizierte Öffnungs-Fläche (Rahmen+Glas) auf der Wand-Aussenseite. */ @@ -557,8 +626,14 @@ export function generateElevationPlan( // Eine Voll-Box je Wand (Öffnungen als Segment-Aussparungen) + Decken-Prismen. const model = projectToModel3d(project, { layeredWalls: false }); const faces = collectElevationFaces(model, frame); + // Dächer analytisch (roofGeometry) — nicht Teil von RModel3d walls/slabs. + for (const rf of roofWorldFacesAll(project)) { + const pf = projectFace(frame, rf); + if (pf) faces.push(pf); + } const wallFaces = faces.filter((f) => f.role === "wall"); const slabFaces = faces.filter((f) => f.role === "slab"); + const roofFaces = faces.filter((f) => f.role === "roof"); const primitives: Primitive[] = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; @@ -610,6 +685,7 @@ export function generateElevationPlan( hatch: NO_HATCH, ...(f.wallId ? { wallId: f.wallId } : {}), ...(f.ceilingId ? { ceilingId: f.ceilingId } : {}), + ...(f.roofId ? { roofId: f.roofId } : {}), }, }); } @@ -617,7 +693,8 @@ export function generateElevationPlan( // Schatten (leicht VOR der Empfängertiefe, damit sie direkt nach der Fassade, // aber unter den Öffnungen/Kanten näherer Flächen liegen). if (shadows) { - for (const sh of buildShadows(wallFaces, slabFaces)) { + // Schattenwerfer: auskragende Decken UND Dachflächen (Traufüberstand). + for (const sh of buildShadows(wallFaces, [...slabFaces, ...roofFaces])) { const pts: Vec2[] = sh.pts.map(([u, v]) => { acc(u, v); return { x: u, y: v };