Ansicht poliert: ruhige Fassade, korrekte Orientierung, echte Traufschatten

Nutzer-Feedback 'Ansichten sehen noch schrecklich aus' — drei Ursachen behoben:

1. RUHIGE FLÄCHEN: Rollen-Füllung (Fassade/Decke einheitlich hell, Dach eine
   Stufe dunkler) statt Tiefen-Grau-Patchworks; Wand-/Deckenflächen ohne
   Kontur (kein Linienraster aus Pfeiler-/Sturz-Teilboxen und Geschossfugen
   mehr — der Fassadenrand liest sich über den Kontrast zum Papier), nur das
   Dach behält seine Kontur. Painter-Gleichstand: Decke vor Wand (bündige
   Decken-Stirnstreifen verschwinden unter der Fassade).

2. ENTSPIEGELT: die u-Achse der Ansicht ist jetzt das Blickrichtungs-RECHTS
   (cross(up, N)) statt der Kamera-Formel für Blick entlang −z — wer nach
   Norden schaut, hat Osten rechts. (Die Schnitt-Pipeline behält vorerst die
   Rust-Konvention; die dortige Spiegelung ist als separates Thema notiert.)

3. SCHATTEN REPARIERT: Werfer-Tiefe = nächste Kante (depthMin — bei
   Dachflächen die auskragende Traufe, dadurch wirft das Dach jetzt den
   klassischen Traufschatten) + Kappung MAX_SHADOW_DELTA 1.2 m (keine
   raumhohen Parallelogramme von fernen Innenwänden mehr).

Fusszeile 'render3d' → 'aus dem Modell' (die Ansicht ist reine TS-Projektion).
737/737 grün; headless verifiziert (Demo-Ansicht Süd).
This commit is contained in:
2026-07-11 01:59:11 +02:00
parent 242b850beb
commit b96714641a
4 changed files with 56 additions and 37 deletions
+1 -1
View File
@@ -627,7 +627,7 @@ export const de = {
"section.loading": "{kind} wird berechnet …", "section.loading": "{kind} wird berechnet …",
"section.empty": "{kind} — keine Schnittlinie im Grundriss gesetzt", "section.empty": "{kind} — keine Schnittlinie im Grundriss gesetzt",
"section.error": "{kind} — Berechnung fehlgeschlagen (render3d-WASM neu bauen?)", "section.error": "{kind} — Berechnung fehlgeschlagen (render3d-WASM neu bauen?)",
"section.legend": "{kind} · abgeleitete Projektion (render3d)", "section.legend": "{kind} · abgeleitete Projektion aus dem Modell",
"elevation.shadows": "Schatten", "elevation.shadows": "Schatten",
"elevation.shadows.hint": "Schlagschatten auskragender Bauteile (45°) in der Ansicht ein-/ausblenden", "elevation.shadows.hint": "Schlagschatten auskragender Bauteile (45°) in der Ansicht ein-/ausblenden",
"props.floorHeight": "Geschosshöhe", "props.floorHeight": "Geschosshöhe",
+1 -1
View File
@@ -622,7 +622,7 @@ export const en: Record<TranslationKey, string> = {
"section.loading": "Computing {kind} …", "section.loading": "Computing {kind} …",
"section.empty": "{kind} — no section line set in the plan", "section.empty": "{kind} — no section line set in the plan",
"section.error": "{kind} — computation failed (rebuild render3d WASM?)", "section.error": "{kind} — computation failed (rebuild render3d WASM?)",
"section.legend": "{kind} · derived projection (render3d)", "section.legend": "{kind} · projection derived from the model",
"elevation.shadows": "Shadows", "elevation.shadows": "Shadows",
"elevation.shadows.hint": "Toggle 45° cast shadows of overhanging elements in the elevation", "elevation.shadows.hint": "Toggle 45° cast shadows of overhanging elements in the elevation",
"props.floorHeight": "Floor height", "props.floorHeight": "Floor height",
+5 -4
View File
@@ -46,8 +46,9 @@ describe("toElevation — Projektion + Culling", () => {
const p = projectPoint(frame, [4, 3, -0.1]); const p = projectPoint(frame, [4, 3, -0.1]);
expect(p.v).toBeCloseTo(3, 6); // v = absolute Höhe 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 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. // u = dot(rel, U); U (entspiegelt, Blick +z ⇒ rechts = +x) = [1, 0, 0],
expect(p.u).toBeCloseTo(-5, 6); // 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", () => { it("Süd-Langseite einer Wand: erwartetes (u, v)-Rechteck + Tiefe, Rückseite gecullt", () => {
@@ -62,8 +63,8 @@ describe("toElevation — Projektion + Culling", () => {
expect(face.wallId).toBe("W"); expect(face.wallId).toBe("W");
const us = face.pts.map((p) => p[0]); const us = face.pts.map((p) => p[0]);
const vs = face.pts.map((p) => p[1]); const vs = face.pts.map((p) => p[1]);
expect(Math.min(...us)).toBeCloseTo(-5, 6); // x=4 → u=5 (P.x=1) expect(Math.min(...us)).toBeCloseTo(1, 6); // x=0 → u=1 (P.x=1)
expect(Math.max(...us)).toBeCloseTo(-1, 6); // x=0 → u=1 expect(Math.max(...us)).toBeCloseTo(5, 6); // x=4 → u=5
expect(Math.min(...vs)).toBeCloseTo(0, 6); expect(Math.min(...vs)).toBeCloseTo(0, 6);
expect(Math.max(...vs)).toBeCloseTo(3, 6); expect(Math.max(...vs)).toBeCloseTo(3, 6);
expect(face.depth).toBeCloseTo(1.9, 6); expect(face.depth).toBeCloseTo(1.9, 6);
+49 -31
View File
@@ -42,7 +42,7 @@ import { openingInterval, openingVerticalExtent } from "../geometry/opening";
import { roofBaseElevation, roofGeometry } from "../geometry/roof"; import { roofBaseElevation, roofGeometry } from "../geometry/roof";
import { projectToModel3d } from "./toWalls3d"; import { projectToModel3d } from "./toWalls3d";
import type { RModel3d, RSlab, RWall } from "./toWalls3d"; import type { RModel3d, RSlab, RWall } from "./toWalls3d";
import { sectionPlaneFromLevel, sectionUAxisModel } from "./toSection"; import { sectionPlaneFromLevel } from "./toSection";
import type { SectionPlaneSpec } from "./toSection"; import type { SectionPlaneSpec } from "./toSection";
import type { HatchRender, Plan, Primitive } from "./generatePlan"; import type { HatchRender, Plan, Primitive } from "./generatePlan";
@@ -76,9 +76,12 @@ const SHADOW_FILL = "#8f959e";
const GROUND_INK = "#1f242c"; const GROUND_INK = "#1f242c";
/** Strichstärke der Bodenlinie (mm Papier). */ /** Strichstärke der Bodenlinie (mm Papier). */
const GROUND_MM = 0.5; const GROUND_MM = 0.5;
/** Fassaden-Fläche: hellste (nächste) und dunkelste (fernste) Graustufe. */ /** Rollen-Füllungen: EINE ruhige Fassadenfläche statt Tiefen-Patchworks —
const FILL_NEAR_L = 0.96; * klassische Architektur-Ansicht (Flächen tragen, Kanten nur wo bedeutsam:
const FILL_FAR_L = 0.82; * Dachrand, Öffnungen, Bodenlinie). Wand/Decke identisch (Decken-Stirnflächen
* verschwinden optisch in der Fassade), Dach eine Stufe dunkler. */
const WALL_FILL = "#ecebe8";
const ROOF_FILL = "#d9d6d1";
/** Sonnenrichtung als (u, v)-Schattenversatz je Tiefeneinheit: 45° von links /** Sonnenrichtung als (u, v)-Schattenversatz je Tiefeneinheit: 45° von links
* oben ⇒ Schatten fällt nach rechts unten (+u, v). */ * oben ⇒ Schatten fällt nach rechts unten (+u, v). */
@@ -113,9 +116,14 @@ export function elevationFrame(plane: SectionPlaneSpec): ElevationFrame {
const [nx, ny, nz] = plane.normal; const [nx, ny, nz] = plane.normal;
const nLen = Math.hypot(nx, ny, nz) || 1; const nLen = Math.hypot(nx, ny, nz) || 1;
const N: V3 = [nx / nLen, ny / nLen, nz / nLen]; const N: V3 = [nx / nLen, ny / nLen, nz / nLen];
const uModel = sectionUAxisModel(plane); // Modell-2D (x, z) // Blickrichtungs-RECHTS statt der Schnitt-u-Achse: `sectionUAxisModel`
const ux = uModel.x; // (cross(N, up), die Kamera-Formel für Blick entlang z) SPIEGELT eine
const uz = uModel.y; // Ansicht, die entlang +N betrachtet wird — wer nach Norden schaut, hat
// Osten rechts. Die Ansicht nutzt darum u = cross(up, N) = (Nz, Nx) im
// Grundriss. (Die Schnitt-Pipeline behält vorerst ihre Rust-Konvention —
// dort ist die Spiegelung ein bekanntes, separat zu lösendes Thema.)
const ux = nz / nLen;
const uz = -nx / nLen;
const uLen = Math.hypot(ux, uz) || 1; const uLen = Math.hypot(ux, uz) || 1;
const U: V3 = [ux / uLen, 0, uz / uLen]; const U: V3 = [ux / uLen, 0, uz / uLen];
return { P: plane.point, N, U }; return { P: plane.point, N, U };
@@ -274,6 +282,8 @@ export interface ProjectedFace {
pts: Array<[number, number]>; pts: Array<[number, number]>;
/** Mittlere Tiefe hinter der Ebene (Painter-Schlüssel). */ /** Mittlere Tiefe hinter der Ebene (Painter-Schlüssel). */
depth: number; depth: number;
/** Kleinste Punkt-Tiefe (nächste Kante — z. B. die auskragende Traufe). */
depthMin: number;
role: FaceRole; role: FaceRole;
wallId?: string; wallId?: string;
ceilingId?: string; ceilingId?: string;
@@ -291,14 +301,16 @@ export function projectFace(frame: ElevationFrame, face: WorldFace): ProjectedFa
if (facing >= -FACING_EPS) return null; // Rückseite oder streifend → unsichtbar if (facing >= -FACING_EPS) return null; // Rückseite oder streifend → unsichtbar
const pts: Array<[number, number]> = []; const pts: Array<[number, number]> = [];
let depthSum = 0; let depthSum = 0;
let depthMin = Infinity;
for (const w of face.poly) { for (const w of face.poly) {
const p = projectPoint(frame, w); const p = projectPoint(frame, w);
pts.push([p.u, p.v]); pts.push([p.u, p.v]);
depthSum += p.depth; depthSum += p.depth;
depthMin = Math.min(depthMin, p.depth);
} }
const depth = depthSum / face.poly.length; const depth = depthSum / face.poly.length;
if (depth <= EPS) return null; // vor der Ebene → ignorieren if (depth <= EPS) return null; // vor der Ebene → ignorieren
return { pts, depth, role: face.role, wallId: face.wallId, ceilingId: face.ceilingId, roofId: face.roofId }; return { pts, depth, depthMin, role: face.role, wallId: face.wallId, ceilingId: face.ceilingId, roofId: face.roofId };
} }
/** /**
@@ -564,14 +576,22 @@ interface ShadowPoly {
* die geforderten Fälle (Dachüberstand/Traufe, auskragende Decke/Balkon) ab; * die geforderten Fälle (Dachüberstand/Traufe, auskragende Decke/Balkon) ab;
* schräge Auskragungen werden als achsparalleles Band angenähert. * schräge Auskragungen werden als achsparalleles Band angenähert.
*/ */
/** Maximale Schatten-Tiefendifferenz (Meter): tiefer liegende Empfänger (ferne
* Innen-/Rückwände) bekämen sonst raumhohe Schatten-Parallelogramme —
* gezeichnet wird der klassische Bauteil-Schlagschatten (Traufe, Balkon),
* nicht die globale Verschattung. */
const MAX_SHADOW_DELTA = 1.2;
export function buildShadows(walls: ProjectedFace[], slabs: ProjectedFace[]): ShadowPoly[] { export function buildShadows(walls: ProjectedFace[], slabs: ProjectedFace[]): ShadowPoly[] {
const out: ShadowPoly[] = []; const out: ShadowPoly[] = [];
for (const slab of slabs) { for (const slab of slabs) {
const so = bboxOf(slab.pts); const so = bboxOf(slab.pts);
for (const wall of walls) { for (const wall of walls) {
const wo = bboxOf(wall.pts); const wo = bboxOf(wall.pts);
const delta = wall.depth - slab.depth; // Wand hinter dem Überstand? // Werfer-Tiefe = NÄCHSTE Kante (depthMin): bei geneigten Dachflächen ist
if (delta <= EPS) continue; // das die auskragende Traufe — der Bezug für die Schattentiefe.
const delta = wall.depth - slab.depthMin; // Wand hinter dem Überstand?
if (delta <= EPS || delta > MAX_SHADOW_DELTA) continue;
// u-Überlapp Überstand/Wand? // u-Überlapp Überstand/Wand?
if (so.uMax <= wo.uMin + EPS || so.uMin >= wo.uMax - EPS) continue; 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 // Der Schatten hängt an der Slab-Unterkante (so.vMin) und fällt um Δ nach
@@ -600,13 +620,6 @@ export interface ElevationOptions {
mono?: boolean; 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 * Erzeugt den Ansichts-Plan einer Ebene (kind "elevation"). `null`, wenn die
* Ebene (noch) keine Ansichtslinie trägt — der Aufrufer zeigt dann den bestehenden * Ebene (noch) keine Ansichtslinie trägt — der Aufrufer zeigt dann den bestehenden
@@ -644,17 +657,11 @@ export function generateElevationPlan(
if (y > maxY) maxY = y; if (y > maxY) maxY = y;
}; };
// Tiefenbereich für die Graustufen-Staffelung (nah hell, fern dunkel). // Rollen-Füllung: Fassade/Decke einheitlich, Dach eine Stufe dunkler —
let dMin = Infinity, dMax = -Infinity; // KEINE Tiefenstaffelung (die machte aus der Fassade ein Grau-Patchwork).
for (const f of faces) { const fillFor = (role: FaceRole): string => {
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"; if (mono) return "#ffffff";
const t = dSpan > EPS ? (depth - dMin) / dSpan : 0; // 0 = nah, 1 = fern return role === "roof" ? ROOF_FILL : WALL_FILL;
return greyHex(FILL_NEAR_L + (FILL_FAR_L - FILL_NEAR_L) * t);
}; };
// ── Painter-Sammelstrom: Flächen + Schatten + Öffnungen, fern→nah sortiert. // ── Painter-Sammelstrom: Flächen + Schatten + Öffnungen, fern→nah sortiert.
@@ -663,6 +670,8 @@ export function generateElevationPlan(
interface Item { interface Item {
depth: number; depth: number;
seq: number; seq: number;
/** Zeichen-Rang bei Tiefen-Gleichstand (Decke < Wand < Dach < Deko). */
rank?: number;
prim: Primitive; prim: Primitive;
} }
const items: Item[] = []; const items: Item[] = [];
@@ -673,15 +682,22 @@ export function generateElevationPlan(
acc(u, v); acc(u, v);
return { x: u, y: v }; return { x: u, y: v };
}); });
const isRoof = f.role === "roof";
items.push({ items.push({
depth: f.depth, depth: f.depth,
seq: seq++, seq: seq++,
// Painter-Gleichstand (bündige Flächen, z. B. Decken-Stirn in der
// Fassadenebene): Decke vor Wand vor Dach zeichnen, damit die Wand die
// Stirnstreifen überdeckt.
rank: f.role === "slab" ? 0 : f.role === "wall" ? 1 : 2,
prim: { prim: {
kind: "polygon", kind: "polygon",
pts, pts,
fill: fillFor(f.depth), fill: fillFor(f.role),
stroke: EDGE_INK, // Nur das Dach trägt eine Kontur — die Fassade bleibt EINE ruhige
strokeWidthMm: EDGE_MM, // Fläche (ihr Rand liest sich über den Kontrast zum Papier).
stroke: isRoof ? EDGE_INK : "none",
strokeWidthMm: isRoof ? EDGE_MM : 0,
hatch: NO_HATCH, hatch: NO_HATCH,
...(f.wallId ? { wallId: f.wallId } : {}), ...(f.wallId ? { wallId: f.wallId } : {}),
...(f.ceilingId ? { ceilingId: f.ceilingId } : {}), ...(f.ceilingId ? { ceilingId: f.ceilingId } : {}),
@@ -735,7 +751,9 @@ export function generateElevationPlan(
} }
// Painter: fern (grosse Tiefe) zuerst, nah zuletzt. Stabiler Tiebreak über seq. // Painter: fern (grosse Tiefe) zuerst, nah zuletzt. Stabiler Tiebreak über seq.
items.sort((a, b) => (b.depth - a.depth) || (a.seq - b.seq)); items.sort(
(a, b) => (b.depth - a.depth) || ((a.rank ?? 3) - (b.rank ?? 3)) || (a.seq - b.seq),
);
for (const it of items) primitives.push(it.prim); for (const it of items) primitives.push(it.prim);
// Bodenlinie (Terrain-/Nulllinie): kräftige Linie über die volle Breite an der // Bodenlinie (Terrain-/Nulllinie): kräftige Linie über die volle Breite an der