Files
DOSSIER-STANDALONE/src/plan/toElevation.ts
T
karim ce728703f6 Ansicht: echter Flächen-Generator (Painter) + Schlagschatten-Kern
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.
2026-07-11 00:18:46 +02:00

680 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(WP, U), v = Welt-Höhe (W.y,
// identisch zur Schnitt-v-Konvention), depth = dot(WP, 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 (SutherlandHodgman, 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 } };
}