Ansicht: Dächer als Flächen + Giebel + Traufschatten nachgerüstet
Der Ansichts-Generator entstand auf einem Basisstand ohne TS-Dächer — roofWorldFacesAll liefert jetzt die Dachflächen (Newell-Normale aufwärts) und Giebel (nach aussen orientiert) aus roofGeometry, projiziert in den Painter- Strom (rückseiten-gecullt, roofId für spätere Auswahl); Dachflächen wirken zusätzlich als Schattenwerfer (Traufüberstand → 45°-Schlagschatten auf die Fassade). +1 Test (Sattel-Süd-Schräge bis Firsthöhe). 722/722 grün.
This commit is contained in:
@@ -255,3 +255,48 @@ function elevationProject(opts: { overhang: boolean }): Project {
|
||||
};
|
||||
return project;
|
||||
}
|
||||
|
||||
describe("Dächer in der Ansicht (roofWorldFacesAll)", () => {
|
||||
it("Dachflächen erscheinen als roofId-Polygone bis zur Firsthöhe", () => {
|
||||
// Sattel 4×3 über dem Fixture-Haus, First entlang X, Traufe 3 m — die
|
||||
// Süd-Schräge zeigt zum Betrachter (Ansicht Süd des Fixtures), die
|
||||
// Nord-Schräge ist rückseiten-gecullt.
|
||||
const base = elevationProject({ overhang: false });
|
||||
const p = {
|
||||
...base,
|
||||
roofs: [
|
||||
{
|
||||
id: "R1",
|
||||
type: "roof",
|
||||
floorId: "eg",
|
||||
categoryCode: "35",
|
||||
outline: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 4, y: 0 },
|
||||
{ x: 4, y: 3 },
|
||||
{ x: 0, y: 3 },
|
||||
],
|
||||
shape: "sattel",
|
||||
pitchDeg: 30,
|
||||
overhang: 0,
|
||||
ridgeAxis: "x",
|
||||
baseElevation: 3,
|
||||
thickness: 0.2,
|
||||
},
|
||||
],
|
||||
} as unknown as Project;
|
||||
const level = p.drawingLevels.find((z) => z.kind === "elevation")!;
|
||||
const plan = generateElevationPlan(p, level);
|
||||
expect(plan).not.toBeNull();
|
||||
const roofPolys = plan!.primitives.filter(
|
||||
(pr) => pr.kind === "polygon" && (pr as { roofId?: string }).roofId === "R1",
|
||||
);
|
||||
expect(roofPolys.length).toBeGreaterThanOrEqual(1);
|
||||
// Die sichtbare Dachfläche reicht bis zur Firsthöhe 3 + 1.5·tan30.
|
||||
const ridge = 3 + 1.5 * Math.tan((30 * Math.PI) / 180);
|
||||
const maxV = Math.max(
|
||||
...roofPolys.flatMap((pr) => (pr as { pts: { y: number }[] }).pts.map((q) => q.y)),
|
||||
);
|
||||
expect(maxV).toBeCloseTo(ridge, 5);
|
||||
});
|
||||
});
|
||||
|
||||
+80
-3
@@ -39,6 +39,7 @@ import {
|
||||
} from "../model/types";
|
||||
import { wallVerticalExtent } from "../model/wall";
|
||||
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";
|
||||
@@ -138,7 +139,7 @@ export function projectPoint(
|
||||
// ── Welt-Flächen aus dem geflachten Modell ─────────────────────────────────
|
||||
|
||||
/** Rolle einer Fläche (steuert Füllung/Stil in der Ansicht). */
|
||||
export type FaceRole = "wall" | "slab";
|
||||
export type FaceRole = "wall" | "slab" | "roof";
|
||||
|
||||
/** Eine orientierte Welt-Fläche (konvexes Polygon + Aussennormale). */
|
||||
export interface WorldFace {
|
||||
@@ -149,6 +150,8 @@ export interface WorldFace {
|
||||
wallId?: string;
|
||||
/** Ursprungs-Decke (nur role "slab"). */
|
||||
ceilingId?: string;
|
||||
/** Ursprungs-Dach (nur role "roof"). */
|
||||
roofId?: string;
|
||||
}
|
||||
|
||||
function normalize3(a: V3): V3 {
|
||||
@@ -274,6 +277,7 @@ export interface ProjectedFace {
|
||||
role: FaceRole;
|
||||
wallId?: string;
|
||||
ceilingId?: string;
|
||||
roofId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,7 +298,7 @@ export function projectFace(frame: ElevationFrame, face: WorldFace): ProjectedFa
|
||||
}
|
||||
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 };
|
||||
return { pts, depth, role: face.role, wallId: face.wallId, ceilingId: face.ceilingId, roofId: face.roofId };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,6 +324,71 @@ export function collectElevationFaces(model: RModel3d, frame: ElevationFrame): P
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Dächer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Die Welt-Flächen ALLER Dächer des Projekts: Dachflächen (Aufsicht-Polygone der
|
||||
* roofGeometry, Normale aufwärts orientiert) + Giebel (vertikale Endflächen,
|
||||
* Normale vom Dach-Schwerpunkt weg nach aussen). Modell [x,y,z=Höhe] → Welt
|
||||
* (x, y=Höhe, z=y), dieselbe Konvention wie emitRoofs/pickGeometry. Die
|
||||
* Materialdicke bleibt für die Ansicht unberücksichtigt (Silhouette genügt —
|
||||
* die Untersicht ist rückseiten-gecullt ohnehin unsichtbar).
|
||||
*/
|
||||
export function roofWorldFacesAll(project: Project): WorldFace[] {
|
||||
const out: WorldFace[] = [];
|
||||
for (const roof of project.roofs ?? []) {
|
||||
const eavesZ = roofBaseElevation(project, roof);
|
||||
const g = roofGeometry(roof, eavesZ);
|
||||
// Dach-Schwerpunkt (Welt) für die Aussen-Orientierung der Giebel.
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
let cz = 0;
|
||||
let cn = 0;
|
||||
for (const pl of g.planes) {
|
||||
for (const p of pl.pts) {
|
||||
cx += p[0];
|
||||
cy += p[2];
|
||||
cz += p[1];
|
||||
cn++;
|
||||
}
|
||||
}
|
||||
if (cn === 0) continue;
|
||||
cx /= cn;
|
||||
cy /= cn;
|
||||
cz /= cn;
|
||||
const newell = (poly: V3[]): V3 => {
|
||||
let nx = 0;
|
||||
let ny = 0;
|
||||
let nz = 0;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[(i + 1) % poly.length];
|
||||
nx += (a[1] - b[1]) * (a[2] + b[2]);
|
||||
ny += (a[2] - b[2]) * (a[0] + b[0]);
|
||||
nz += (a[0] - b[0]) * (a[1] + b[1]);
|
||||
}
|
||||
return normalize3([nx, ny, nz]);
|
||||
};
|
||||
for (const pl of g.planes) {
|
||||
const poly: V3[] = pl.pts.map((p) => [p[0], p[2], p[1]]);
|
||||
let n = newell(poly);
|
||||
// Dachflächen zeigen aufwärts (Welt-Y).
|
||||
if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
|
||||
out.push({ role: "roof", normal: n, poly, roofId: roof.id });
|
||||
}
|
||||
for (const gable of g.gables) {
|
||||
const poly: V3[] = gable.map((p) => [p[0], p[2], p[1]]);
|
||||
if (poly.length < 3) continue;
|
||||
let n = newell(poly);
|
||||
// Giebel zeigen vom Dach-Schwerpunkt weg nach aussen.
|
||||
const toC: V3 = [cx - poly[0][0], cy - poly[0][1], cz - poly[0][2]];
|
||||
if (n[0] * toC[0] + n[1] * toC[1] + n[2] * toC[2] > 0) n = [-n[0], -n[1], -n[2]];
|
||||
out.push({ role: "roof", normal: n, poly, roofId: roof.id });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Fenster/Türen ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Eine projizierte Öffnungs-Fläche (Rahmen+Glas) auf der Wand-Aussenseite. */
|
||||
@@ -557,8 +626,14 @@ export function generateElevationPlan(
|
||||
// Eine Voll-Box je Wand (Öffnungen als Segment-Aussparungen) + Decken-Prismen.
|
||||
const model = projectToModel3d(project, { layeredWalls: false });
|
||||
const faces = collectElevationFaces(model, frame);
|
||||
// Dächer analytisch (roofGeometry) — nicht Teil von RModel3d walls/slabs.
|
||||
for (const rf of roofWorldFacesAll(project)) {
|
||||
const pf = projectFace(frame, rf);
|
||||
if (pf) faces.push(pf);
|
||||
}
|
||||
const wallFaces = faces.filter((f) => f.role === "wall");
|
||||
const slabFaces = faces.filter((f) => f.role === "slab");
|
||||
const roofFaces = faces.filter((f) => f.role === "roof");
|
||||
|
||||
const primitives: Primitive[] = [];
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
@@ -610,6 +685,7 @@ export function generateElevationPlan(
|
||||
hatch: NO_HATCH,
|
||||
...(f.wallId ? { wallId: f.wallId } : {}),
|
||||
...(f.ceilingId ? { ceilingId: f.ceilingId } : {}),
|
||||
...(f.roofId ? { roofId: f.roofId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -617,7 +693,8 @@ export function generateElevationPlan(
|
||||
// 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)) {
|
||||
// Schattenwerfer: auskragende Decken UND Dachflächen (Traufüberstand).
|
||||
for (const sh of buildShadows(wallFaces, [...slabFaces, ...roofFaces])) {
|
||||
const pts: Vec2[] = sh.pts.map(([u, v]) => {
|
||||
acc(u, v);
|
||||
return { x: u, y: v };
|
||||
|
||||
Reference in New Issue
Block a user