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.empty": "{kind} — keine Schnittlinie im Grundriss gesetzt",
"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.hint": "Schlagschatten auskragender Bauteile (45°) in der Ansicht ein-/ausblenden",
"props.floorHeight": "Geschosshöhe",
+1 -1
View File
@@ -622,7 +622,7 @@ export const en: Record<TranslationKey, string> = {
"section.loading": "Computing {kind} …",
"section.empty": "{kind} — no section line set in the plan",
"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.hint": "Toggle 45° cast shadows of overhanging elements in the elevation",
"props.floorHeight": "Floor height",
+5 -4
View File
@@ -46,8 +46,9 @@ describe("toElevation — Projektion + Culling", () => {
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);
// u = dot(rel, U); U (entspiegelt, Blick +z ⇒ rechts = +x) = [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", () => {
@@ -62,8 +63,8 @@ describe("toElevation — Projektion + Culling", () => {
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(...us)).toBeCloseTo(1, 6); // x=0 → u=1 (P.x=1)
expect(Math.max(...us)).toBeCloseTo(5, 6); // x=4 → u=5
expect(Math.min(...vs)).toBeCloseTo(0, 6);
expect(Math.max(...vs)).toBeCloseTo(3, 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 { projectToModel3d } 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 { HatchRender, Plan, Primitive } from "./generatePlan";
@@ -76,9 +76,12 @@ const SHADOW_FILL = "#8f959e";
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;
/** Rollen-Füllungen: EINE ruhige Fassadenfläche statt Tiefen-Patchworks —
* klassische Architektur-Ansicht (Flächen tragen, Kanten nur wo bedeutsam:
* 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
* 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 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;
// Blickrichtungs-RECHTS statt der Schnitt-u-Achse: `sectionUAxisModel`
// (cross(N, up), die Kamera-Formel für Blick entlang z) SPIEGELT eine
// 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 U: V3 = [ux / uLen, 0, uz / uLen];
return { P: plane.point, N, U };
@@ -274,6 +282,8 @@ export interface ProjectedFace {
pts: Array<[number, number]>;
/** Mittlere Tiefe hinter der Ebene (Painter-Schlüssel). */
depth: number;
/** Kleinste Punkt-Tiefe (nächste Kante — z. B. die auskragende Traufe). */
depthMin: number;
role: FaceRole;
wallId?: 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
const pts: Array<[number, number]> = [];
let depthSum = 0;
let depthMin = Infinity;
for (const w of face.poly) {
const p = projectPoint(frame, w);
pts.push([p.u, p.v]);
depthSum += p.depth;
depthMin = Math.min(depthMin, 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, 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;
* 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[] {
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;
// Werfer-Tiefe = NÄCHSTE Kante (depthMin): bei geneigten Dachflächen ist
// 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?
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
@@ -600,13 +620,6 @@ export interface ElevationOptions {
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
@@ -644,17 +657,11 @@ export function generateElevationPlan(
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 => {
// Rollen-Füllung: Fassade/Decke einheitlich, Dach eine Stufe dunkler —
// KEINE Tiefenstaffelung (die machte aus der Fassade ein Grau-Patchwork).
const fillFor = (role: FaceRole): 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);
return role === "roof" ? ROOF_FILL : WALL_FILL;
};
// ── Painter-Sammelstrom: Flächen + Schatten + Öffnungen, fern→nah sortiert.
@@ -663,6 +670,8 @@ export function generateElevationPlan(
interface Item {
depth: number;
seq: number;
/** Zeichen-Rang bei Tiefen-Gleichstand (Decke < Wand < Dach < Deko). */
rank?: number;
prim: Primitive;
}
const items: Item[] = [];
@@ -673,15 +682,22 @@ export function generateElevationPlan(
acc(u, v);
return { x: u, y: v };
});
const isRoof = f.role === "roof";
items.push({
depth: f.depth,
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: {
kind: "polygon",
pts,
fill: fillFor(f.depth),
stroke: EDGE_INK,
strokeWidthMm: EDGE_MM,
fill: fillFor(f.role),
// Nur das Dach trägt eine Kontur — die Fassade bleibt EINE ruhige
// Fläche (ihr Rand liest sich über den Kontrast zum Papier).
stroke: isRoof ? EDGE_INK : "none",
strokeWidthMm: isRoof ? EDGE_MM : 0,
hatch: NO_HATCH,
...(f.wallId ? { wallId: f.wallId } : {}),
...(f.ceilingId ? { ceilingId: f.ceilingId } : {}),
@@ -735,7 +751,9 @@ export function generateElevationPlan(
}
// 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);
// Bodenlinie (Terrain-/Nulllinie): kräftige Linie über die volle Breite an der