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;
|
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";
|
} from "../model/types";
|
||||||
import { wallVerticalExtent } from "../model/wall";
|
import { wallVerticalExtent } from "../model/wall";
|
||||||
import { openingInterval, openingVerticalExtent } from "../geometry/opening";
|
import { openingInterval, openingVerticalExtent } from "../geometry/opening";
|
||||||
|
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, sectionUAxisModel } from "./toSection";
|
||||||
@@ -138,7 +139,7 @@ export function projectPoint(
|
|||||||
// ── Welt-Flächen aus dem geflachten Modell ─────────────────────────────────
|
// ── Welt-Flächen aus dem geflachten Modell ─────────────────────────────────
|
||||||
|
|
||||||
/** Rolle einer Fläche (steuert Füllung/Stil in der Ansicht). */
|
/** 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). */
|
/** Eine orientierte Welt-Fläche (konvexes Polygon + Aussennormale). */
|
||||||
export interface WorldFace {
|
export interface WorldFace {
|
||||||
@@ -149,6 +150,8 @@ export interface WorldFace {
|
|||||||
wallId?: string;
|
wallId?: string;
|
||||||
/** Ursprungs-Decke (nur role "slab"). */
|
/** Ursprungs-Decke (nur role "slab"). */
|
||||||
ceilingId?: string;
|
ceilingId?: string;
|
||||||
|
/** Ursprungs-Dach (nur role "roof"). */
|
||||||
|
roofId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize3(a: V3): V3 {
|
function normalize3(a: V3): V3 {
|
||||||
@@ -274,6 +277,7 @@ export interface ProjectedFace {
|
|||||||
role: FaceRole;
|
role: FaceRole;
|
||||||
wallId?: string;
|
wallId?: string;
|
||||||
ceilingId?: string;
|
ceilingId?: string;
|
||||||
|
roofId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -294,7 +298,7 @@ export function projectFace(frame: ElevationFrame, face: WorldFace): ProjectedFa
|
|||||||
}
|
}
|
||||||
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 };
|
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;
|
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 ───────────────────────────────────────────────────────────
|
// ── Fenster/Türen ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Eine projizierte Öffnungs-Fläche (Rahmen+Glas) auf der Wand-Aussenseite. */
|
/** 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.
|
// Eine Voll-Box je Wand (Öffnungen als Segment-Aussparungen) + Decken-Prismen.
|
||||||
const model = projectToModel3d(project, { layeredWalls: false });
|
const model = projectToModel3d(project, { layeredWalls: false });
|
||||||
const faces = collectElevationFaces(model, frame);
|
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 wallFaces = faces.filter((f) => f.role === "wall");
|
||||||
const slabFaces = faces.filter((f) => f.role === "slab");
|
const slabFaces = faces.filter((f) => f.role === "slab");
|
||||||
|
const roofFaces = faces.filter((f) => f.role === "roof");
|
||||||
|
|
||||||
const primitives: Primitive[] = [];
|
const primitives: Primitive[] = [];
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
@@ -610,6 +685,7 @@ export function generateElevationPlan(
|
|||||||
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 } : {}),
|
||||||
|
...(f.roofId ? { roofId: f.roofId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -617,7 +693,8 @@ export function generateElevationPlan(
|
|||||||
// Schatten (leicht VOR der Empfängertiefe, damit sie direkt nach der Fassade,
|
// Schatten (leicht VOR der Empfängertiefe, damit sie direkt nach der Fassade,
|
||||||
// aber unter den Öffnungen/Kanten näherer Flächen liegen).
|
// aber unter den Öffnungen/Kanten näherer Flächen liegen).
|
||||||
if (shadows) {
|
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]) => {
|
const pts: Vec2[] = sh.pts.map(([u, v]) => {
|
||||||
acc(u, v);
|
acc(u, v);
|
||||||
return { x: u, y: v };
|
return { x: u, y: v };
|
||||||
|
|||||||
Reference in New Issue
Block a user