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.
This commit is contained in:
2026-07-11 00:18:46 +02:00
parent 9133c0961d
commit ce728703f6
3 changed files with 943 additions and 0 deletions
+679
View File
@@ -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(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 } };
}