From ce728703f61de21f186aa3a57b6e0daa64f2e92d Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 11 Jul 2026 00:18:46 +0200 Subject: [PATCH] =?UTF-8?q?Ansicht:=20echter=20Fl=C3=A4chen-Generator=20(P?= =?UTF-8?q?ainter)=20+=20Schlagschatten-Kern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateElevationPlan (toElevation.ts) baut aus projectToModel3d die Fassadenansicht als gefüllte Flächen statt blossem Kanten-Durchlauf: Wand-Aussenseiten + Decken-Stirnflächen auf die Ansichtsebene projiziert (u entlang, v = Höhe), rückseiten-gecullt, vor der Ebene verworfen und per Painter (fern->nah) emittiert. Fenster/Türen als Rahmen+Glas, Bodenlinie, tiefengestaffelte Graustufen. Schlagschatten (45°, klassisch): auskragende Slabs werfen ein geclipptes Schatten-Parallelogramm auf die Fassade dahinter. Modellfeld DrawingLevel.shadows (additiv). Tests rein geometrisch. --- src/model/types.ts | 7 + src/plan/toElevation.test.ts | 257 +++++++++++++ src/plan/toElevation.ts | 679 +++++++++++++++++++++++++++++++++++ 3 files changed, 943 insertions(+) create mode 100644 src/plan/toElevation.test.ts create mode 100644 src/plan/toElevation.ts diff --git a/src/model/types.ts b/src/model/types.ts index 68cf01c..ab66e00 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -478,6 +478,13 @@ export interface DrawingLevel { linePoints?: [Vec2, Vec2]; /** Blickrichtung relativ zur Schnittlinie (nur Schnitt/Ansicht). */ directionSign?: 1 | -1; + /** + * Schlagschatten in der Ansicht (nur kind "elevation"). Additiv; fehlt/`false` + * ⇒ keine Schatten (heutiges Verhalten). Steuert `generateElevationPlan` + * (toElevation.ts): auskragende Bauteile werfen einen 45°-Schlagschatten auf + * die dahinterliegende Fassade. + */ + shadows?: boolean; } /** diff --git a/src/plan/toElevation.test.ts b/src/plan/toElevation.test.ts new file mode 100644 index 0000000..dfd337e --- /dev/null +++ b/src/plan/toElevation.test.ts @@ -0,0 +1,257 @@ +/** + * Unit-Tests für den Ansichts-Generator (toElevation.ts): Projektion einer + * bekannten Fläche auf (u, v)+Tiefe, Rückseiten-Culling, Painter-Reihenfolge + * (nähere Fassade NACH der ferneren) und Schlagschatten (nur mit shadows=true). + * Alles rein geometrisch auf synthetischen RWall/RSlab-Fixtures bzw. einem + * Minimalprojekt — KEIN WASM. + */ + +import { describe, it, expect } from "vitest"; +import type { Project, Wall } from "../model/types"; +import type { RModel3d, RSlab, RWall } from "./toWalls3d"; +import type { SectionPlaneSpec } from "./toSection"; +import { + buildShadows, + collectElevationFaces, + elevationFrame, + generateElevationPlan, + projectFace, + projectPoint, + wallWorldFaces, +} from "./toElevation"; + +// Betrachter im Süden (world z = −2), Blick nach +Z (in die Szene). Damit liegt +// eine Wand bei z ≈ 0 HINTER der Ebene (depth > 0) und ihre Süd-Langseite zeigt +// zum Betrachter (front-facing). +const PLANE_SOUTH: SectionPlaneSpec = { point: [-1, 0, -2], normal: [0, 0, 1] }; + +function wall(over: Partial = {}): RWall { + return { + start: [0, 0], + end: [4, 0], + thickness: 0.2, + height: 3, + baseElevation: 0, + color: [0.8, 0.8, 0.8], + layers: [], + wallId: "W", + ...over, + }; +} + +describe("toElevation — Projektion + Culling", () => { + it("projiziert einen Weltpunkt auf (u, v, depth)", () => { + const frame = elevationFrame(PLANE_SOUTH); + // Punkt auf der Süd-Fassade (z ≈ −0.1), x = 4, Höhe 3. + const p = projectPoint(frame, [4, 3, -0.1]); + expect(p.v).toBeCloseTo(3, 6); // v = absolute Höhe + expect(p.depth).toBeCloseTo(1.9, 6); // −0.1 − (−2) = 1.9 hinter der Ebene + // u = dot(rel, U); U = [−1, 0, 0], P.x = −1 → u = −(4 − (−1)) = −5. + expect(p.u).toBeCloseTo(-5, 6); + }); + + it("Süd-Langseite einer Wand: erwartetes (u, v)-Rechteck + Tiefe, Rückseite gecullt", () => { + const frame = elevationFrame(PLANE_SOUTH); + const faces = wallWorldFaces(wall()); + const projected = faces.map((f) => projectFace(frame, f)); + // Genau EINE sichtbare Fläche (die Süd-Langseite); alle anderen sind + // Rückseiten oder streifend (Deckel/Boden/Stirnkappen). + const visible = projected.filter((p) => p !== null); + expect(visible.length).toBe(1); + const face = visible[0]!; + expect(face.wallId).toBe("W"); + const us = face.pts.map((p) => p[0]); + const vs = face.pts.map((p) => p[1]); + expect(Math.min(...us)).toBeCloseTo(-5, 6); // x=4 → u=−5 (P.x=−1) + expect(Math.max(...us)).toBeCloseTo(-1, 6); // x=0 → u=−1 + expect(Math.min(...vs)).toBeCloseTo(0, 6); + expect(Math.max(...vs)).toBeCloseTo(3, 6); + expect(face.depth).toBeCloseTo(1.9, 6); + }); + + it("Rückseiten-Culling: eine Fläche mit Normale VON der Ebene weg erscheint nicht", () => { + const frame = elevationFrame(PLANE_SOUTH); + const faces = wallWorldFaces(wall()); + // Die +Quer-Fläche (Nord-Langseite, Normale [0,0,−1]·N = −1 … eigentlich die + // Süd-Seite ist sichtbar) — hier prüfen wir explizit die Rückseite: + const north = faces.find((f) => f.normal[2] > 0.5); // Normale zeigt +Z (= mit N) + expect(north).toBeDefined(); + expect(projectFace(frame, north!)).toBeNull(); + }); +}); + +describe("toElevation — Painter-Reihenfolge", () => { + it("nähere Fassade wird NACH der ferneren emittiert", () => { + const frame = elevationFrame(PLANE_SOUTH); + const model: RModel3d = { + walls: [ + wall({ wallId: "NEAR", start: [0, 0], end: [4, 0] }), // z ≈ 0 → näher + wall({ wallId: "FAR", start: [0, 3], end: [4, 3] }), // z ≈ 3 → ferner + ], + slabs: [], + meshes: [], + }; + const faces = collectElevationFaces(model, frame); + const near = faces.find((f) => f.wallId === "NEAR")!; + const far = faces.find((f) => f.wallId === "FAR")!; + expect(near.depth).toBeLessThan(far.depth); + }); + + it("im Plan: nähere Wand-Fläche hat einen höheren Primitiv-Index", () => { + const project = elevationProject({ overhang: false }); + const plan = generateElevationPlan(project, project.drawingLevels[1])!; + expect(plan).not.toBeNull(); + const idxNear = plan.primitives.findIndex( + (p) => p.kind === "polygon" && p.wallId === "NEAR", + ); + const idxFar = plan.primitives.findIndex( + (p) => p.kind === "polygon" && p.wallId === "FAR", + ); + expect(idxNear).toBeGreaterThanOrEqual(0); + expect(idxFar).toBeGreaterThanOrEqual(0); + expect(idxNear).toBeGreaterThan(idxFar); // fern zuerst, nah zuletzt + }); +}); + +describe("toElevation — Schlagschatten", () => { + it("buildShadows: Überstand vor einer Fassade wirft ein Schatten-Polygon", () => { + const frame = elevationFrame(PLANE_SOUTH); + // Wandfläche (Fassade) hinter dem Überstand. + const wallFaces = collectElevationFaces( + { walls: [wall()], slabs: [], meshes: [] }, + frame, + ); + // Überstand-Slab, der VOR der Wand (näher, z < −0.1) liegt und sie u-seitig + // überdeckt. + const slab: RSlab = { + outline: [ + [0, -0.6], + [4, -0.6], + [4, 0.2], + [0, 0.2], + ], + zBottom: 2.8, + zTop: 3, + color: [0.9, 0.9, 0.9], + ceilingId: "R", + }; + const slabFaces = collectElevationFaces( + { walls: [], slabs: [slab], meshes: [] }, + frame, + ); + const shadows = buildShadows(wallFaces, slabFaces); + expect(shadows.length).toBeGreaterThan(0); + }); + + it("buildShadows: kein Überlapp → keine Schatten", () => { + const frame = elevationFrame(PLANE_SOUTH); + const wallFaces = collectElevationFaces( + { walls: [wall({ start: [0, 0], end: [4, 0] })], slabs: [], meshes: [] }, + frame, + ); + // Slab weit seitlich versetzt (u außerhalb der Wand). + const slab: RSlab = { + outline: [ + [20, -0.6], + [24, -0.6], + [24, 0.2], + [20, 0.2], + ], + zBottom: 2.8, + zTop: 3, + color: [0.9, 0.9, 0.9], + ceilingId: "R", + }; + const slabFaces = collectElevationFaces( + { walls: [], slabs: [slab], meshes: [] }, + frame, + ); + expect(buildShadows(wallFaces, slabFaces).length).toBe(0); + }); + + it("im Plan: shadows=true erzeugt zusätzliche Schatten-Primitive, false exakt keine", () => { + const project = elevationProject({ overhang: true }); + const level = project.drawingLevels[1]; + const withOff = generateElevationPlan(project, level, { shadows: false })!; + const withOn = generateElevationPlan(project, level, { shadows: true })!; + // Schatten sind randlose (stroke "none") Polygone im festen Schattengrau. + const shadowCount = (plan: typeof withOn) => + plan.primitives.filter( + (p) => p.kind === "polygon" && p.stroke === "none" && p.fill === "#8f959e", + ).length; + expect(shadowCount(withOff)).toBe(0); + expect(shadowCount(withOn)).toBeGreaterThan(0); + }); +}); + +describe("toElevation — Fallback", () => { + it("ohne linePoints liefert generateElevationPlan null", () => { + const project = elevationProject({ overhang: false }); + const bare = { ...project.drawingLevels[1], linePoints: undefined }; + expect(generateElevationPlan(project, bare)).toBeNull(); + }); +}); + +// ── Minimalprojekt-Factory ────────────────────────────────────────────────── +// Zwei parallele Wände (nah/fern) + optional eine auskragende Dach-Decke. +function elevationProject(opts: { overhang: boolean }): Project { + const w = (id: string, y: number): Wall => ({ + id, + type: "wall", + floorId: "eg", + categoryCode: "20", + start: { x: 0, y }, + end: { x: 4, y }, + wallTypeId: "aw", + height: 3, + }); + const project: Project = { + id: "t", + name: "T", + lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }], + hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }], + components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }], + wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.2 }] }], + ceilingTypes: [{ id: "dc", name: "Dach", layers: [{ componentId: "a", thickness: 0.2 }] }], + drawingLevels: [ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1, baseElevation: 0 }, + { + id: "ans", + name: "Ansicht Süd", + kind: "elevation", + visible: true, + locked: false, + linePoints: [{ x: -1, y: -2 }, { x: 5, y: -2 }], + directionSign: 1, + }, + ], + layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }], + walls: [w("NEAR", 0), w("FAR", 3)], + doors: [], + openings: [], + ceilings: opts.overhang + ? [ + { + id: "R", + type: "ceiling", + floorId: "eg", + categoryCode: "20", + wallTypeId: "aw", + ceilingTypeId: "dc", + // Umriss ragt nach Süden (y < 0) über die Süd-Wand (y=0) hinaus. + outline: [ + { x: 0, y: -0.6 }, + { x: 4, y: -0.6 }, + { x: 4, y: 0.2 }, + { x: 0, y: 0.2 }, + ], + }, + ] + : [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + }; + return project; +} diff --git a/src/plan/toElevation.ts b/src/plan/toElevation.ts new file mode 100644 index 0000000..5706422 --- /dev/null +++ b/src/plan/toElevation.ts @@ -0,0 +1,679 @@ +// Ansichts-Generator (Elevation/Fassadenansicht): erzeugt aus dem Modell eine +// echte 2D-Architektur-Ansicht mit gefüllten Flächen statt eines blossen +// Kanten-Durchlaufs durch die Schnitt-Pipeline. +// +// Ablauf (rein TS, KEIN WASM): +// 1. Aus der Ansichts-Ebene (DrawingLevel, kind "elevation") wird über +// `sectionPlaneFromLevel`/`sectionUAxisModel` (toSection.ts, lesend) eine +// Welt-Ansichtsebene abgeleitet: Punkt P, Blicknormale N (vom Betrachter ins +// Modell) und die horizontale In-Plane-u-Achse U (Y-up-Welt). +// 2. Das Projekt wird über `projectToModel3d({ layeredWalls:false })` geflacht +// (eine Voll-Box je Wand, Öffnungen als Segment-Aussparungen; plus Decken- +// Prismen). Jede relevante Fläche (Wand-Aussenseiten, Decken-Stirn-/Deck- +// flächen) wird auf (u, v) projiziert: u = dot(W−P, U), v = Welt-Höhe (W.y, +// identisch zur Schnitt-v-Konvention), depth = dot(W−P, N). +// 3. SICHTBARKEIT über den PAINTER-ALGORITHMUS (Näherung, siehe unten): nur +// Flächen, die zum Betrachter zeigen (Normale gegen N) UND hinter der Ebene +// liegen (depth > 0), werden von HINTEN nach VORNE (grosse depth zuerst) als +// gefüllte Polygone emittiert. Nähere Flächen überdecken so fernere. +// 4. Fenster/Türen: als Rahmen+Glas-Flächen auf der Aussenseite der Wirtswand. +// 5. Optionaler Schlagschatten (`level.shadows`): auskragende Decken/Balkone +// (Slabs) werfen einen 45°-Schatten auf die dahinterliegende Fassade. +// +// NÄHERUNG (bewusst, dokumentiert): Der Painter-Algorithmus sortiert GANZE Flächen +// nach ihrer mittleren Tiefe und lässt nähere Flächen fernere überdecken — er ist +// KEIN exakter Hidden-Line-/Hidden-Surface-Algorithmus. Sich gegenseitig +// durchdringende oder in der Tiefe verschränkte Flächen (selten bei orthogonalen +// Fassaden) können falsch geordnet werden. Für Architektur-Ansichten (überwiegend +// planparallele, gestaffelte Flächen) genügt das — vgl. Auftragsvorgabe. +// +// Einheiten: METER. Ausgabe wie der Schnitt: u = horizontal entlang der Ebene, +// v = absolute Höhe (Welt-Y). Die PlanView zeichnet Y nach oben → Ansicht steht +// aufrecht. + +import type { DrawingLevel, Project, Vec2, Wall } from "../model/types"; +import { + getWallType, + openingsOfWall, + wallTypeThickness, +} from "../model/types"; +import { wallVerticalExtent } from "../model/wall"; +import { openingInterval, openingVerticalExtent } from "../geometry/opening"; +import { projectToModel3d } from "./toWalls3d"; +import type { RModel3d, RSlab, RWall } from "./toWalls3d"; +import { sectionPlaneFromLevel, sectionUAxisModel } from "./toSection"; +import type { SectionPlaneSpec } from "./toSection"; +import type { HatchRender, Plan, Primitive } from "./generatePlan"; + +/** Welt-3D-Punkt (x, Höhe/y, z) — Y-up, identisch zur render3d-Weltkonvention. */ +export type V3 = [number, number, number]; + +/** Leere Schraffur (Ansichtsflächen tragen keine Musterlinien, nur Fläche+Umriss). */ +const NO_HATCH: HatchRender = { + pattern: "none", + scale: 1, + angle: 0, + color: "#1a1a1a", + lineWeight: 0.02, + dash: null, +}; + +// ── Stil (monochrome Architektur-Ansicht) ────────────────────────────────── +/** Tinte der Ansichts-Umrisskanten (Haarlinie um jede Fläche). */ +const EDGE_INK = "#2b3039"; +/** Strichstärke der Flächen-Umrisskanten (mm Papier, Haarlinie). */ +const EDGE_MM = 0.13; +/** Fenster/Tür-Rahmen: etwas kräftigere Umrisslinie. */ +const FRAME_MM = 0.18; +/** Blaugraues Fenster-Glas (dunkler als die Fassade → als Öffnung lesbar). */ +const GLASS_FILL = "#7f8b99"; +/** Glas im Mono-Modus (neutrales Grau statt Blaugrau). */ +const GLASS_FILL_MONO = "#c4c8cd"; +/** Festes Schattengrau (über der Fassade, unter den Kanten). */ +const SHADOW_FILL = "#8f959e"; +/** Bodenlinie (Terrain-/Nulllinie): kräftige Tinte über die volle Breite. */ +const GROUND_INK = "#1f242c"; +/** Strichstärke der Bodenlinie (mm Papier). */ +const GROUND_MM = 0.5; +/** Fassaden-Fläche: hellste (nächste) und dunkelste (fernste) Graustufe. */ +const FILL_NEAR_L = 0.96; +const FILL_FAR_L = 0.82; + +/** Sonnenrichtung als (u, v)-Schattenversatz je Tiefeneinheit: 45° von links + * oben ⇒ Schatten fällt nach rechts unten (+u, −v). */ +const SUN_U = 1; +const SUN_V = -1; + +/** Numerisches Rauschen (Meter) für Tiefen-/Flächenvergleiche. */ +const EPS = 1e-6; +/** Grazing-Schwelle: Flächen fast parallel zur Blickrichtung gelten als nicht + * sichtbar (Kanten-an, keine Fläche) — vermeidet Z-Rauschen an Laibungen. */ +const FACING_EPS = 1e-4; + +// ── Ebenen-Frame ──────────────────────────────────────────────────────────── + +/** Der orthonormale Ansichts-Frame in Weltkoordinaten. */ +export interface ElevationFrame { + /** Punkt auf der Ebene (Welt). */ + P: V3; + /** Blicknormale (Einheit, vom Betrachter ins Modell). */ + N: V3; + /** Horizontale In-Plane-u-Achse (Einheit). */ + U: V3; +} + +/** + * Baut den Ansichts-Frame aus einer Welt-Ebenen-Spezifikation. `N` (aus + * `sectionPlaneFromLevel`) ist bereits horizontal und Einheit; `U` folgt exakt + * `sectionUAxisModel` (Modell-2D (x, z)) in die Welt gehoben — dieselbe u-Achse + * wie der Schnitt-Extraktor, damit Ansicht und Schnitt deckungsgleich messen. + */ +export function elevationFrame(plane: SectionPlaneSpec): ElevationFrame { + const [nx, ny, nz] = plane.normal; + const nLen = Math.hypot(nx, ny, nz) || 1; + const N: V3 = [nx / nLen, ny / nLen, nz / nLen]; + const uModel = sectionUAxisModel(plane); // Modell-2D (x, z) + const ux = uModel.x; + const uz = uModel.y; + const uLen = Math.hypot(ux, uz) || 1; + const U: V3 = [ux / uLen, 0, uz / uLen]; + return { P: plane.point, N, U }; +} + +/** Projektion eines Weltpunkts in die Ansicht: u, v (Höhe) und Tiefe hinter der Ebene. */ +export function projectPoint( + frame: ElevationFrame, + w: V3, +): { u: number; v: number; depth: number } { + const rx = w[0] - frame.P[0]; + const ry = w[1] - frame.P[1]; + const rz = w[2] - frame.P[2]; + return { + u: rx * frame.U[0] + ry * frame.U[1] + rz * frame.U[2], + v: w[1], // absolute Höhe, wie die Schnitt-v-Konvention + depth: rx * frame.N[0] + ry * frame.N[1] + rz * frame.N[2], + }; +} + +// ── Welt-Flächen aus dem geflachten Modell ───────────────────────────────── + +/** Rolle einer Fläche (steuert Füllung/Stil in der Ansicht). */ +export type FaceRole = "wall" | "slab"; + +/** Eine orientierte Welt-Fläche (konvexes Polygon + Aussennormale). */ +export interface WorldFace { + poly: V3[]; + normal: V3; + role: FaceRole; + /** Ursprungs-Wand (nur role "wall") — für Auswahl/Painter-Identität. */ + wallId?: string; + /** Ursprungs-Decke (nur role "slab"). */ + ceilingId?: string; +} + +function normalize3(a: V3): V3 { + const l = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / l, a[1] / l, a[2] / l]; +} + +/** + * Die sechs Quader-Flächen EINER Wand-Box (RWall) in Weltkoordinaten mit + * Aussennormalen: zwei Langseiten (Fassaden-Aussenflächen, Normale horizontal + * quer zur Wand), zwei Stirnkappen (Normale entlang der Achse), Deckel/Boden. + */ +export function wallWorldFaces(w: RWall): WorldFace[] { + const sx = w.start[0]; + const sz = w.start[1]; + const ex = w.end[0]; + const ez = w.end[1]; + const ax = ex - sx; + const az = ez - sz; + const len = Math.hypot(ax, az); + if (len < 1e-9 || w.height <= EPS) return []; + const aHat: V3 = [ax / len, 0, az / len]; + const nHat: V3 = [az / len, 0, -ax / len]; // horizontale Quer-Normale + const ht = w.thickness / 2; + const zb = w.baseElevation; + const zt = w.baseElevation + w.height; + // Ecke bei (Achsen-Meter sAlong, Quer-Versatz tOff, Höhe y). + const corner = (sAlong: number, tOff: number, y: number): V3 => [ + sx + aHat[0] * sAlong + nHat[0] * tOff, + y, + sz + aHat[2] * sAlong + nHat[2] * tOff, + ]; + const faces: WorldFace[] = []; + // +Quer (Aussenfläche A) + faces.push({ + role: "wall", + normal: nHat, + poly: [corner(0, ht, zb), corner(len, ht, zb), corner(len, ht, zt), corner(0, ht, zt)], + }); + // −Quer (Aussenfläche B) + faces.push({ + role: "wall", + normal: [-nHat[0], -nHat[1], -nHat[2]], + poly: [corner(0, -ht, zb), corner(0, -ht, zt), corner(len, -ht, zt), corner(len, -ht, zb)], + }); + // Stirn Start (−Achse) + faces.push({ + role: "wall", + normal: [-aHat[0], -aHat[1], -aHat[2]], + poly: [corner(0, -ht, zb), corner(0, ht, zb), corner(0, ht, zt), corner(0, -ht, zt)], + }); + // Stirn Ende (+Achse) + faces.push({ + role: "wall", + normal: aHat, + poly: [corner(len, -ht, zb), corner(len, -ht, zt), corner(len, ht, zt), corner(len, ht, zb)], + }); + // Deckel (+Höhe) + faces.push({ + role: "wall", + normal: [0, 1, 0], + poly: [corner(0, -ht, zt), corner(0, ht, zt), corner(len, ht, zt), corner(len, -ht, zt)], + }); + // Boden (−Höhe) + faces.push({ + role: "wall", + normal: [0, -1, 0], + poly: [corner(0, -ht, zb), corner(len, -ht, zb), corner(len, ht, zb), corner(0, ht, zb)], + }); + for (const f of faces) f.wallId = w.wallId; + return faces; +} + +/** + * Die Flächen EINER Deckenplatte (RSlab): Deckel + Boden (Umriss bei zTop/zBottom) + * und je Umrisskante eine Seitenfläche (Stirn). Die Seitennormalen werden über + * den Grundriss-Schwerpunkt nach AUSSEN orientiert (unabhängig von der Umriss- + * Windung). Der Umriss (Modell x,y) wird als Welt (x, y=Höhe, z=y) gehoben. + */ +export function slabWorldFaces(s: RSlab): WorldFace[] { + const n = s.outline.length; + if (n < 3 || s.zTop - s.zBottom <= EPS) return []; + // Grundriss-Schwerpunkt (für die Aussen-Orientierung der Seitenflächen). + let cx = 0; + let cz = 0; + for (const p of s.outline) { + cx += p[0]; + cz += p[1]; + } + cx /= n; + cz /= n; + const bottom: V3[] = s.outline.map((p) => [p[0], s.zBottom, p[1]]); + const top: V3[] = s.outline.map((p) => [p[0], s.zTop, p[1]]); + const faces: WorldFace[] = []; + faces.push({ role: "slab", normal: [0, 1, 0], poly: top.slice() }); + faces.push({ role: "slab", normal: [0, -1, 0], poly: bottom.slice().reverse() }); + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + const ex = s.outline[j][0] - s.outline[i][0]; + const ez = s.outline[j][1] - s.outline[i][1]; + // Kandidat-Normale horizontal quer zur Kante; nach aussen orientieren. + let m: V3 = normalize3([ez, 0, -ex]); + const midX = (s.outline[i][0] + s.outline[j][0]) / 2; + const midZ = (s.outline[i][1] + s.outline[j][1]) / 2; + if (m[0] * (midX - cx) + m[2] * (midZ - cz) < 0) m = [-m[0], -m[1], -m[2]]; + faces.push({ + role: "slab", + normal: m, + poly: [bottom[i], bottom[j], top[j], top[i]], + }); + } + for (const f of faces) f.ceilingId = s.ceilingId; + return faces; +} + +// ── Projektion + Sichtbarkeit ────────────────────────────────────────────── + +/** Eine in die Ansicht projizierte, sichtbare Fläche. */ +export interface ProjectedFace { + pts: Array<[number, number]>; + /** Mittlere Tiefe hinter der Ebene (Painter-Schlüssel). */ + depth: number; + role: FaceRole; + wallId?: string; + ceilingId?: string; +} + +/** + * Projiziert eine Welt-Fläche und entscheidet die Sichtbarkeit: + * • Rückseiten-Culling: Normale muss GEGEN die Blickrichtung zeigen (N·Nf < 0). + * • Vor/Hinter: die mittlere Tiefe muss hinter der Ebene liegen (depth > 0). + * Sonst `null` (Fläche erscheint nicht). + */ +export function projectFace(frame: ElevationFrame, face: WorldFace): ProjectedFace | null { + const facing = frame.N[0] * face.normal[0] + frame.N[1] * face.normal[1] + frame.N[2] * face.normal[2]; + if (facing >= -FACING_EPS) return null; // Rückseite oder streifend → unsichtbar + const pts: Array<[number, number]> = []; + let depthSum = 0; + for (const w of face.poly) { + const p = projectPoint(frame, w); + pts.push([p.u, p.v]); + depthSum += p.depth; + } + 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 }; +} + +/** + * Sammelt alle sichtbaren, projizierten Flächen des Modells (Wände + Decken), + * bereits rückseiten-gecullt und vor der Ebene verworfen. Reihenfolge = Emissions- + * reihenfolge des Modells (NICHT tiefensortiert) — die Painter-Sortierung macht + * der Aufrufer. + */ +export function collectElevationFaces(model: RModel3d, frame: ElevationFrame): ProjectedFace[] { + const out: ProjectedFace[] = []; + for (const w of model.walls) { + for (const f of wallWorldFaces(w)) { + const pf = projectFace(frame, f); + if (pf) out.push(pf); + } + } + for (const s of model.slabs) { + for (const f of slabWorldFaces(s)) { + const pf = projectFace(frame, f); + if (pf) out.push(pf); + } + } + return out; +} + +// ── Fenster/Türen ─────────────────────────────────────────────────────────── + +/** Eine projizierte Öffnungs-Fläche (Rahmen+Glas) auf der Wand-Aussenseite. */ +interface ProjectedOpening { + pts: Array<[number, number]>; + depth: number; +} + +/** + * Projiziert die Aussenfläche EINER Öffnung (Fenster/Tür) der Wirtswand: das + * Rechteck [from..to] × [zBottom..zTop] auf der zum Betrachter zeigenden + * Wand-Langseite (±T/2). Näherung: die Öffnung wird als planare Glasfläche BÜNDIG + * in der Aussenwandebene dargestellt (keine Laibungstiefe) — genügt für die + * Fassaden-Lesbarkeit. `null`, wenn keine Seite front-/hinter-Ebene liegt. + */ +function projectOpeningRect( + frame: ElevationFrame, + wall: Wall, + from: number, + to: number, + zBottom: number, + zTop: number, + thickness: number, +): ProjectedOpening | null { + const sx = wall.start.x; + const sz = wall.start.y; + const ax = wall.end.x - wall.start.x; + const az = wall.end.y - wall.start.y; + const len = Math.hypot(ax, az); + if (len < 1e-9 || to - from <= EPS || zTop - zBottom <= EPS) return null; + const aHat: V3 = [ax / len, 0, az / len]; + const nHat: V3 = [az / len, 0, -ax / len]; + const ht = thickness / 2; + // Kandidaten-Seiten (±T/2): die front-facing + hinter der Ebene liegende nehmen. + for (const side of [ht, -ht]) { + const normal: V3 = side > 0 ? nHat : [-nHat[0], -nHat[1], -nHat[2]]; + const facing = frame.N[0] * normal[0] + frame.N[1] * normal[1] + frame.N[2] * normal[2]; + if (facing >= -FACING_EPS) continue; + const corner = (sAlong: number, y: number): V3 => [ + sx + aHat[0] * sAlong + nHat[0] * side, + y, + sz + aHat[2] * sAlong + nHat[2] * side, + ]; + const world = [corner(from, zBottom), corner(to, zBottom), corner(to, zTop), corner(from, zTop)]; + const pts: Array<[number, number]> = []; + let depthSum = 0; + for (const w of world) { + const p = projectPoint(frame, w); + pts.push([p.u, p.v]); + depthSum += p.depth; + } + const depth = depthSum / world.length; + if (depth <= EPS) continue; + return { pts, depth }; + } + return null; +} + +/** + * Alle Öffnungs-Flächen (Fenster/Türen inkl. Legacy-Türen) des Projekts, auf die + * jeweilige Wand-Aussenseite projiziert. Thickness = WallType-Dicke (Fallback + * 0.2 m). Rein aus dem Projekt (nicht aus dem geflachten Modell), damit Rahmen+Glas + * unabhängig von der Segment-Aussparung der Wand-Box gezeichnet werden können. + */ +function collectOpenings(project: Project, frame: ElevationFrame): ProjectedOpening[] { + const out: ProjectedOpening[] = []; + for (const wall of project.walls) { + let thickness = 0.2; + try { + thickness = wallTypeThickness(getWallType(project, wall)); + } catch { + thickness = 0.2; + } + const { zBottom, zTop } = wallVerticalExtent(project, wall); + const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); + for (const op of openingsOfWall(project, wall.id)) { + const iv = openingInterval(wall, op); + if (!iv) continue; + const v = openingVerticalExtent(project, wall, op); + const pr = projectOpeningRect(frame, wall, iv.from, iv.to, v.zBottom, v.zTop, thickness); + if (pr) out.push(pr); + } + for (const d of project.doors ?? []) { + if (d.hostWallId !== wall.id) continue; + const from = Math.max(0, Math.min(d.position, axisLen)); + const to = Math.max(from, Math.min(d.position + d.width, axisLen)); + const oTop = Math.min(zTop, zBottom + d.height); + const pr = projectOpeningRect(frame, wall, from, to, zBottom, oTop, thickness); + if (pr) out.push(pr); + } + } + return out; +} + +// ── Schlagschatten (45°, klassische Architektur-Konvention) ───────────────── + +/** Achsparalleles (u, v)-Rechteck. */ +interface UVRect { + uMin: number; + uMax: number; + vMin: number; + vMax: number; +} + +function bboxOf(pts: Array<[number, number]>): UVRect { + let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity; + for (const [u, v] of pts) { + if (u < uMin) uMin = u; + if (u > uMax) uMax = u; + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + return { uMin, uMax, vMin, vMax }; +} + +/** + * Clippt ein Polygon an ein achsparalleles Rechteck (Sutherland–Hodgman, vier + * Halbebenen). Liefert das (konvexe) Restpolygon oder [] bei leerem Schnitt. + */ +function clipPolygonToRect(poly: Array<[number, number]>, r: UVRect): Array<[number, number]> { + type Pt = [number, number]; + const clip = ( + input: Pt[], + inside: (p: Pt) => boolean, + intersect: (a: Pt, b: Pt) => Pt, + ): Pt[] => { + const out: Pt[] = []; + for (let i = 0; i < input.length; i++) { + const a = input[i]; + const b = input[(i + 1) % input.length]; + const ain = inside(a); + const bin = inside(b); + if (ain) out.push(a); + if (ain !== bin) out.push(intersect(a, b)); + } + return out; + }; + let p: Pt[] = poly.slice(); + const lerp = (a: Pt, b: Pt, t: number): Pt => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + // links (u >= uMin) + p = clip(p, (q) => q[0] >= r.uMin, (a, b) => lerp(a, b, (r.uMin - a[0]) / (b[0] - a[0]))); + if (p.length < 3) return []; + // rechts (u <= uMax) + p = clip(p, (q) => q[0] <= r.uMax, (a, b) => lerp(a, b, (r.uMax - a[0]) / (b[0] - a[0]))); + if (p.length < 3) return []; + // unten (v >= vMin) + p = clip(p, (q) => q[1] >= r.vMin, (a, b) => lerp(a, b, (r.vMin - a[1]) / (b[1] - a[1]))); + if (p.length < 3) return []; + // oben (v <= vMax) + p = clip(p, (q) => q[1] <= r.vMax, (a, b) => lerp(a, b, (r.vMax - a[1]) / (b[1] - a[1]))); + if (p.length < 3) return []; + return p; +} + +/** Ein Schatten-Polygon mit der Tiefe der Empfängerfläche (für die Painter-Ordnung). */ +interface ShadowPoly { + pts: Array<[number, number]>; + depth: number; +} + +/** + * Baut die Schlagschatten auskragender Decken/Balkone (Slabs) auf die dahinter + * liegenden Fassaden (Wände). KLASSISCHE ARCHITEKTUR-KONVENTION: Sonne 45° von + * links oben ⇒ die Unterkante des Überstands wirft einen Schatten, dessen + * horizontaler Versatz UND vertikaler Abfall der Auskragungstiefe entspricht + * (Δ = Tiefendifferenz Empfänger − Überstand). Der Schatten ist ein Parallelo- + * gramm unter der Überstand-Unterkante (Höhe/Breite ∝ Δ), geclippt auf die + * Empfänger-Fassade. + * + * NÄHERUNG (dokumentiert): Überstand-Silhouette = (u, v)-Bounding-Box der Slab- + * Fläche; Empfänger = Bounding-Box der Wandfläche. Nur Slab→Wand-Paare mit + * echtem Tiefen-Vorsprung (Slab näher) und u-Überlapp erzeugen Schatten. Deckt + * die geforderten Fälle (Dachüberstand/Traufe, auskragende Decke/Balkon) ab; + * schräge Auskragungen werden als achsparalleles Band angenähert. + */ +export function buildShadows(walls: ProjectedFace[], slabs: ProjectedFace[]): ShadowPoly[] { + const out: ShadowPoly[] = []; + for (const slab of slabs) { + const so = bboxOf(slab.pts); + for (const wall of walls) { + const wo = bboxOf(wall.pts); + const delta = wall.depth - slab.depth; // Wand hinter dem Überstand? + if (delta <= EPS) continue; + // u-Überlapp Überstand/Wand? + if (so.uMax <= wo.uMin + EPS || so.uMin >= wo.uMax - EPS) continue; + // Der Schatten hängt an der Slab-Unterkante (so.vMin) und fällt um Δ nach + // unten, versetzt um Δ nach rechts (Sonne von links oben). + const vTop = so.vMin; + const parallelogram: Array<[number, number]> = [ + [so.uMin, vTop], + [so.uMax, vTop], + [so.uMax + delta * SUN_U, vTop + delta * SUN_V], + [so.uMin + delta * SUN_U, vTop + delta * SUN_V], + ]; + const clipped = clipPolygonToRect(parallelogram, wo); + if (clipped.length >= 3) out.push({ pts: clipped, depth: wall.depth }); + } + } + return out; +} + +// ── Plan-Zusammenbau ──────────────────────────────────────────────────────── + +/** Optionen des Ansichts-Generators. */ +export interface ElevationOptions { + /** Schlagschatten zeichnen (Default aus `level.shadows`). */ + shadows?: boolean; + /** Schwarz-Weiss-Modus (neutralisierte Füllungen). */ + mono?: boolean; +} + +/** Graustufen-Hex aus einer Helligkeit 0..1. */ +function greyHex(l: number): string { + const c = Math.max(0, Math.min(255, Math.round(l * 255))); + const h = c.toString(16).padStart(2, "0"); + return `#${h}${h}${h}`; +} + +/** + * Erzeugt den Ansichts-Plan einer Ebene (kind "elevation"). `null`, wenn die + * Ebene (noch) keine Ansichtslinie trägt — der Aufrufer zeigt dann den bestehenden + * Hinweis. Rein synchron/TS. + */ +export function generateElevationPlan( + project: Project, + level: DrawingLevel, + opts: ElevationOptions = {}, +): Plan | null { + const plane = sectionPlaneFromLevel(level); + if (!plane) return null; + const shadows = opts.shadows ?? level.shadows ?? false; + const mono = opts.mono ?? false; + const frame = elevationFrame(plane); + + // Eine Voll-Box je Wand (Öffnungen als Segment-Aussparungen) + Decken-Prismen. + const model = projectToModel3d(project, { layeredWalls: false }); + const faces = collectElevationFaces(model, frame); + const wallFaces = faces.filter((f) => f.role === "wall"); + const slabFaces = faces.filter((f) => f.role === "slab"); + + const primitives: Primitive[] = []; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + const acc = (x: number, y: number) => { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + }; + + // Tiefenbereich für die Graustufen-Staffelung (nah hell, fern dunkel). + let dMin = Infinity, dMax = -Infinity; + for (const f of faces) { + if (f.depth < dMin) dMin = f.depth; + if (f.depth > dMax) dMax = f.depth; + } + const dSpan = dMax - dMin; + const fillFor = (depth: number): string => { + if (mono) return "#ffffff"; + const t = dSpan > EPS ? (depth - dMin) / dSpan : 0; // 0 = nah, 1 = fern + return greyHex(FILL_NEAR_L + (FILL_FAR_L - FILL_NEAR_L) * t); + }; + + // ── Painter-Sammelstrom: Flächen + Schatten + Öffnungen, fern→nah sortiert. + // Jede Fläche trägt Füllung + eigene Umrisslinie (nähere Füllung überdeckt + // fernere Kanten → korrekte Painter-Verdeckung ohne Extra-Kantenpass). + interface Item { + depth: number; + seq: number; + prim: Primitive; + } + const items: Item[] = []; + let seq = 0; + + for (const f of faces) { + const pts: Vec2[] = f.pts.map(([u, v]) => { + acc(u, v); + return { x: u, y: v }; + }); + items.push({ + depth: f.depth, + seq: seq++, + prim: { + kind: "polygon", + pts, + fill: fillFor(f.depth), + stroke: EDGE_INK, + strokeWidthMm: EDGE_MM, + hatch: NO_HATCH, + ...(f.wallId ? { wallId: f.wallId } : {}), + ...(f.ceilingId ? { ceilingId: f.ceilingId } : {}), + }, + }); + } + + // 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)) { + const pts: Vec2[] = sh.pts.map(([u, v]) => { + acc(u, v); + return { x: u, y: v }; + }); + items.push({ + depth: sh.depth - EPS * 10, + seq: seq++, + prim: { + kind: "polygon", + pts, + fill: SHADOW_FILL, + stroke: "none", + strokeWidthMm: 0, + hatch: NO_HATCH, + }, + }); + } + } + + // Fenster/Türen: Rahmen+Glas, knapp VOR der Wirtsfläche (auf der Fassade). + for (const op of collectOpenings(project, frame)) { + const pts: Vec2[] = op.pts.map(([u, v]) => { + acc(u, v); + return { x: u, y: v }; + }); + items.push({ + depth: op.depth - EPS * 100, + seq: seq++, + prim: { + kind: "polygon", + pts, + fill: mono ? GLASS_FILL_MONO : GLASS_FILL, + stroke: EDGE_INK, + strokeWidthMm: FRAME_MM, + hatch: NO_HATCH, + }, + }); + } + + // Painter: fern (grosse Tiefe) zuerst, nah zuletzt. Stabiler Tiebreak über seq. + items.sort((a, b) => (b.depth - a.depth) || (a.seq - b.seq)); + for (const it of items) primitives.push(it.prim); + + // Bodenlinie (Terrain-/Nulllinie): kräftige Linie über die volle Breite an der + // untersten sichtbaren Fassadenkante. + if (isFinite(minX) && isFinite(minY)) { + primitives.push({ + kind: "line", + a: { x: minX, y: minY }, + b: { x: maxX, y: minY }, + cls: "elevation-ground", + weightMm: GROUND_MM, + color: GROUND_INK, + }); + } + + if (!isFinite(minX)) return { primitives, bounds: { minX: 0, minY: 0, maxX: 1, maxY: 1 } }; + return { primitives, bounds: { minX, minY, maxX, maxY } }; +}