Dächer: Platzier-Werkzeug + 2D-Plan + 3D-Flächen + Demo-Dach

Dach-Feature end-to-end nutzbar:
- Befehl „Dach" (Alias dach/rf, BIM-Ribbon, Satteldach-Icon): Grundriss als
  Rechteck aufziehen; die Dachform (Sattel/Walm/Pult/Mansarde/Zelt/Flach)
  wählt man als Inline-Option der Befehlszeile vor/während des Aufziehens.
  First entlang der längeren Seite (Standard).
- 2D-Grundriss (generatePlan): Traufe/First/Grat (Walm/Zelt) + gestrichelte
  Knicklinien (Mansarde), Farbe aus roof.color/Kategorie „31 Dächer".
- 3D (toWalls3d emitRoofs): Dachflächen + Giebel als terrakottafarbene
  Meshes (Fan-Triangulierung, Mansarde-Fünfeck sauber).
- Seed: Demo-Satteldach RF1 über dem Baukörper (OG, 5×4, Überstand 0.4).
- i18n de/en für Befehl + Formen.

+10 Tests (2D 7, 3D 3). tsc + vitest 640 grün. Auswahl/Attribut-Editieren
placierter Dächer folgt separat.
This commit is contained in:
2026-07-09 21:13:11 +02:00
parent 1195d2a054
commit 31f2d63362
11 changed files with 583 additions and 5 deletions
+147
View File
@@ -0,0 +1,147 @@
/**
* Unit-Tests für die Dach-Darstellung im Grundriss (generatePlan → addRoof):
* `project.roofs` wird nach Geschoss + sichtbarer Kategorie gefiltert (analog
* Decken) und pro Dach in Grundriss-Linien übersetzt (Traufe/First/Grat/Knick,
* aus `roofGeometry`). Reine Linien-Darstellung ohne Poché — die Form (flach/
* sattel/walm/mansarde) entscheidet, welche der vier Linienarten auftauchen.
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { Project, Roof } from "../model/types";
/** Minimalprojekt ohne Wände: nur das Geschoss + die Dach-Kategorie „35". */
function baseProject(roofs?: Roof[]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
],
layers: [{ code: "35", name: "Dächer", color: "#8a4b32", lw: 0.25, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
...(roofs !== undefined ? { roofs } : {}),
};
}
const rectOutline: Roof["outline"] = [
{ x: 0, y: 0 },
{ x: 6, y: 0 },
{ x: 6, y: 4 },
{ x: 0, y: 4 },
];
const roof = (id: string, shape: Roof["shape"], extra: Partial<Roof> = {}): Roof => ({
id,
type: "roof",
floorId: "eg",
categoryCode: "35",
outline: rectOutline,
shape,
pitchDeg: 30,
overhang: 0.5,
ridgeAxis: "x",
thickness: 0.3,
...extra,
});
const visible = new Set(["35"]);
/** Filtert die Grundriss-Primitive nach `kind:"line"` + gegebener Klasse. */
function linesOf(primitives: ReturnType<typeof generatePlan>["primitives"], cls: string) {
return primitives.filter(
(p): p is Extract<typeof p, { kind: "line" }> => p.kind === "line" && p.cls === cls,
);
}
describe("generatePlan — Dächer (Dachaufsicht)", () => {
it("Satteldach: Traufe + First, aber kein Grat/Knick", () => {
const plan = generatePlan(baseProject([roof("R1", "sattel")]), "eg", visible);
expect(linesOf(plan.primitives, "roof-eaves").length).toBeGreaterThan(0);
expect(linesOf(plan.primitives, "roof-ridge").length).toBeGreaterThan(0);
expect(linesOf(plan.primitives, "roof-hip").length).toBe(0);
expect(linesOf(plan.primitives, "roof-break").length).toBe(0);
});
it("Walmdach: Traufe + First + Grat", () => {
const plan = generatePlan(baseProject([roof("R2", "walm")]), "eg", visible);
expect(linesOf(plan.primitives, "roof-eaves").length).toBeGreaterThan(0);
expect(linesOf(plan.primitives, "roof-ridge").length).toBeGreaterThan(0);
expect(linesOf(plan.primitives, "roof-hip").length).toBeGreaterThan(0);
});
it("Mansarddach: Knicklinien gestrichelt", () => {
const plan = generatePlan(baseProject([roof("R3", "mansarde")]), "eg", visible);
const breaks = linesOf(plan.primitives, "roof-break");
expect(breaks.length).toBeGreaterThan(0);
for (const b of breaks) {
expect(b.dash).toBeTruthy();
expect(b.dash?.length).toBeGreaterThan(0);
}
});
it("Flachdach: nur Traufe, weder First noch Grat noch Knick", () => {
const plan = generatePlan(baseProject([roof("R4", "flach")]), "eg", visible);
expect(linesOf(plan.primitives, "roof-eaves").length).toBeGreaterThan(0);
expect(linesOf(plan.primitives, "roof-ridge").length).toBe(0);
expect(linesOf(plan.primitives, "roof-hip").length).toBe(0);
expect(linesOf(plan.primitives, "roof-break").length).toBe(0);
});
it("First-Linie ist kräftiger als die Traufe (First > Traufe > Knick)", () => {
const plan = generatePlan(baseProject([roof("R5", "mansarde")]), "eg", visible);
const eaves = linesOf(plan.primitives, "roof-eaves")[0];
const ridge = linesOf(plan.primitives, "roof-ridge")[0];
const brk = linesOf(plan.primitives, "roof-break")[0];
expect(ridge.weightMm).toBeGreaterThan(eaves.weightMm);
expect(eaves.weightMm).toBeGreaterThan(brk.weightMm);
});
it("löst die Strichfarbe über roof.color, sonst die Kategorie-Farbe auf", () => {
const withColor = generatePlan(
baseProject([roof("R6", "sattel", { color: "#ff0000" })]),
"eg",
visible,
);
expect(linesOf(withColor.primitives, "roof-eaves")[0].color).toBe("#ff0000");
const withoutColor = generatePlan(baseProject([roof("R7", "sattel")]), "eg", visible);
expect(linesOf(withoutColor.primitives, "roof-eaves")[0].color).toBe("#8a4b32");
});
it("Rückwärtskompatibilität: ohne Dächer (bzw. Dach auf anderem Geschoss) bleiben die Primitive unverändert", () => {
const planNoField = generatePlan(baseProject(undefined), "eg", visible);
const planEmpty = generatePlan(baseProject([]), "eg", visible);
const planOtherFloor = generatePlan(
baseProject([roof("R8", "sattel", { floorId: "og" })]),
"eg",
visible,
);
expect(planEmpty.primitives).toEqual(planNoField.primitives);
expect(planOtherFloor.primitives).toEqual(planNoField.primitives);
expect(planNoField.primitives.some((p) => p.kind === "line" && p.cls.startsWith("roof-"))).toBe(
false,
);
});
});
+82 -1
View File
@@ -17,6 +17,7 @@ import type {
Opening,
OverrideActions,
Project,
Roof,
Room,
Stair,
Vec2,
@@ -42,6 +43,7 @@ import {
wallTypeThickness,
} from "../model/types";
import { columnFootprint } from "../geometry/column";
import { roofGeometry } from "../geometry/roof";
import { effectiveOverrides, hasOverrides } from "../overrides/engine";
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
import type { Marks, RichTextDoc } from "../text/richText";
@@ -114,6 +116,15 @@ const SYMBOL_HAIRLINE_MM = 0.02;
* Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach
* BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */
const OVERHEAD_DASH: number[] = [0.4, 0.25];
/** Strichstärken der Dach-Grundrisslinien (mm Papier): Traufe = mittlere
* Haarlinie, First etwas kräftiger als die Traufe, Grat dazwischen, Knick dünn
* + gestrichelt (vgl. addRoofs). */
const ROOF_EAVES_MM = 0.13;
const ROOF_RIDGE_MM = 0.25;
const ROOF_HIP_MM = 0.18;
const ROOF_BREAK_MM = 0.09;
/** Strichmuster (mm Papier) der Dach-Knicklinie (Mansarde). */
const ROOF_BREAK_DASH: number[] = [0.12, 0.08];
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
grob: 1.6, // dickere Sammellinie
@@ -735,6 +746,20 @@ export function generatePlan(
addCeilingPoche(primitives, project, ceiling, greyed, detail, category, wallFootprints);
}
// Dächer dieses Geschosses (Dachaufsicht): reine Grundriss-Linien (Traufe/
// First/Grat/Knick), kein Poché. Über der Wand-Poché gezeichnet.
const roofs = (project.roofs ?? []).filter(
(r) =>
r.floorId === floorId &&
visibleCodes.has(r.categoryCode) &&
categoryDisplay(r.categoryCode).render,
);
for (const roof of roofs) {
const greyed = categoryDisplay(roof.categoryCode).greyed;
const category = catByCode.get(roof.categoryCode);
addRoof(primitives, roof, greyed, category);
}
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
// (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände
// lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
@@ -867,7 +892,7 @@ export function generatePlan(
return {
primitives,
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms),
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms, roofs),
};
}
@@ -2414,6 +2439,58 @@ function addCeilingPoche(
}
}
/**
* Dach im Grundriss (Dachaufsicht): reine Linien-Darstellung ohne Poché — die
* 2D-Geometrie aus `roofGeometry` (First/Grat/Knick hängen NICHT von der
* Traufhöhe ab, daher `eavesZ=0`):
* • Traufe (`eaves`): geschlossener Umriss-Ring als mittlere Haarlinie.
* • First (`ridges`): kräftige Volllinie, etwas stärker als die Traufe.
* • Grat (`hips`, Walm/Zelt): Volllinie zwischen Traufe- und First-Stärke.
* • Knick (`breaks`, Mansarde): dünne gestrichelte Linie.
* Farbe: `roof.color` übersteuert sonst die Kategorie-Farbe (analog Stützen-
* Poché), Default die neutrale Poché-Tinte.
*/
function addRoof(
out: Primitive[],
roof: Roof,
greyed: boolean,
category: LayerCategory | undefined,
): void {
const stroke = roof.color ?? category?.color ?? POCHE_STROKE;
const geo = roofGeometry(roof, 0);
const n = geo.eaves.length;
for (let i = 0; i < n; i++) {
out.push({
kind: "line",
a: geo.eaves[i],
b: geo.eaves[(i + 1) % n],
cls: "roof-eaves",
weightMm: ROOF_EAVES_MM,
color: stroke,
greyed,
});
}
for (const [a, b] of geo.ridges) {
out.push({ kind: "line", a, b, cls: "roof-ridge", weightMm: ROOF_RIDGE_MM, color: stroke, greyed });
}
for (const [a, b] of geo.hips) {
out.push({ kind: "line", a, b, cls: "roof-hip", weightMm: ROOF_HIP_MM, color: stroke, greyed });
}
for (const [a, b] of geo.breaks) {
out.push({
kind: "line",
a,
b,
cls: "roof-break",
weightMm: ROOF_BREAK_MM,
dash: ROOF_BREAK_DASH,
color: stroke,
greyed,
});
}
}
/**
* Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente
* Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren
@@ -2671,6 +2748,7 @@ function computeBounds(
ceilings: Ceiling[] = [],
stairs: Stair[] = [],
rooms: Room[] = [],
roofs: Roof[] = [],
) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const acc = (p: Vec2) => {
@@ -2683,6 +2761,9 @@ function computeBounds(
}
for (const c of ceilings) for (const p of c.outline) acc(p);
for (const r of rooms) for (const p of r.boundary) acc(p);
// Traufe (inkl. Überstand) statt reinem Umriss, damit ein weit auskragendes
// Dach das „Einpassen" korrekt mit einrahmt.
for (const roof of roofs) for (const p of roofGeometry(roof, 0).eaves) acc(p);
for (const s of stairs) {
const { zBottom, zTop } = stairVerticalExtent(project, s);
const geo = stairGeometry(s, zTop - zBottom);
+77 -1
View File
@@ -6,10 +6,11 @@
*/
import { describe, it, expect } from "vitest";
import type { Project } from "../model/types";
import type { Project, Roof } from "../model/types";
import { sampleProject } from "../model/sampleProject";
import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d";
import { resolveHatch } from "./generatePlan";
import { roofGeometry, roofBaseElevation } from "../geometry/roof";
const RGB: [number, number, number] = [1, 0.5, 0.1];
const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b]
@@ -1047,3 +1048,78 @@ describe("emitStairMeshes (Betontreppe: glatte Laufplatte als Mesh)", () => {
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
});
});
describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
// Dieselbe Terrakotta-Farbe wie ROOF_RGB in toWalls3d.ts — dient hier nur der
// Isolation der Dach-Meshes von Kontext-/Öffnungs-/Treppen-Meshes im Test.
const ROOF_RGB: [number, number, number] = [0.72, 0.45, 0.36];
const isRoofMesh = (m: { color: readonly number[] }) =>
m.color[0] === ROOF_RGB[0] && m.color[1] === ROOF_RGB[1] && m.color[2] === ROOF_RGB[2];
const baseRoof: Roof = {
id: "R1",
type: "roof",
floorId: "eg",
categoryCode: "35",
outline: [
{ x: 0, y: 0 },
{ x: 6, y: 0 },
{ x: 6, y: 4 },
{ x: 0, y: 4 },
],
shape: "sattel",
pitchDeg: 30,
overhang: 0,
ridgeAxis: "x",
baseElevation: 3, // fix, unabhängig von Geschoss-Stapelung
thickness: 0.3,
};
it("Satteldach: Flächen + Giebel als EIN Roof-Mesh, First-Höhe passt zur Traufhöhe + ridgeHeight", () => {
const project: Project = { ...sampleProject, roofs: [baseRoof] };
const { meshes } = projectToModel3d(project);
const roofMeshes = meshes.filter(isRoofMesh);
// Zwei Dachflächen (je ein Quad -> 2 Dreiecke) + zwei Giebel (je ein Dreieck)
// landen in EINEM kombinierten Mesh (Flächen und Giebel bilden zusammen den
// geschlossenen Dachkörper).
expect(roofMeshes.length).toBe(1);
const m = roofMeshes[0];
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
expect(m.indices.length % 3).toBe(0);
expect(m.indices.length).toBeGreaterThan(0);
const vcount = m.positions.length / 3;
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
// Firsthöhe: eavesZ (baseElevation) + ridgeHeight aus roofGeometry.
const g = roofGeometry(baseRoof, roofBaseElevation(project, baseRoof));
const expectedRidgeZ = roofBaseElevation(project, baseRoof) + g.ridgeHeight;
const heights = m.positions.filter((_, i) => i % 3 === 2);
expect(Math.max(...heights)).toBeCloseTo(expectedRidgeZ, 6);
});
it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => {
const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" };
const project: Project = { ...sampleProject, roofs: [walmRoof] };
const { meshes } = projectToModel3d(project);
const roofMeshes = meshes.filter(isRoofMesh);
expect(roofMeshes.length).toBe(1);
const m = roofMeshes[0];
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
// Walm hat KEINE Giebel (gables: []); 2 Quader (je 2 Dreiecke) + 2 Dreiecke
// (je 1 Dreieck) = 6 Dreiecke = 18 Indizes.
expect(m.indices.length).toBe(18);
const vcount = m.positions.length / 3;
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
});
it("Projekt ohne Dächer emittiert keine Roof-Meshes (Rückwärtskompatibilität)", () => {
// Explizit leeres roofs-Feld -> keine Roof-Meshes.
const project: Project = { ...sampleProject, roofs: [] };
expect(projectToModel3d(project).meshes.filter(isRoofMesh).length).toBe(0);
// Fehlt das Feld ganz (undefined), ebenfalls keine.
const noField: Project = { ...sampleProject };
delete (noField as { roofs?: unknown }).roofs;
expect(projectToModel3d(noField).meshes.filter(isRoofMesh).length).toBe(0);
// sampleProject enthält jetzt ein Demo-Satteldach (RF1) -> genau ein Roof-Mesh.
expect(projectToModel3d(sampleProject).meshes.filter(isRoofMesh).length).toBe(1);
});
});
+51 -3
View File
@@ -51,6 +51,8 @@ import { pointInOutline } from "../geometry/ceiling";
import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening";
import type { DetailLevel } from "../ui/TopBar";
import { stairGeometry, stairBBox } from "../geometry/stair";
import { roofGeometry, roofBaseElevation } from "../geometry/roof";
import type { Vec3 as RoofVec3 } from "../geometry/roof";
import { extrudePolygon, extrudeCircle } from "../engine/truckSolid";
import { computeJoins } from "../model/joins";
import type { WallCuts } from "../model/joins";
@@ -393,6 +395,8 @@ const GLASS_HALF_THICK = 0.01;
const FRAME_RGB: RRgb = [0.85, 0.85, 0.83];
/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */
const DEFAULT_FRAME_WIDTH = 0.06;
/** Warmer Terrakotta-Grauton für Dachflächen/-giebel (s. {@link emitRoofs}) — hebt das Dach klar von Wand/Decke/Kontext ab. */
const ROOF_RGB: RRgb = [0.72, 0.45, 0.36];
/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */
const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255];
/** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */
@@ -1633,6 +1637,49 @@ function emitColumns(project: Project): RMesh[] {
return out;
}
/**
* Trianguliert ein KONVEXES 3D-Polygon per Dreiecks-Fächer ab Vertex 0 und hängt
* die Dreiecke an `positions`/`indices` an. `roofGeometry` (Vertrag in
* `geometry/roof.ts`) liefert für `planes`/`gables` ausschliesslich konvexe
* Polygone (Dreieck/Quader bei den meisten Formen, ein Fünfeck-Giebel bei
* Mansarde) — ein Fächer trianguliert diese korrekt, ohne Ear-Clipping.
*/
function pushFanTriangles(positions: number[], indices: number[], pts: RoofVec3[]): void {
if (pts.length < 3) return;
const base = positions.length / 3;
for (const p of pts) positions.push(p[0], p[1], p[2]);
for (let i = 1; i < pts.length - 1; i++) indices.push(base, base + i, base + i + 1);
}
/**
* Emittiert Dächer (`project.roofs`) als geschlossene Dreiecks-Meshes: sowohl die
* geneigten Dachflächen (`RoofGeometry.planes`) als auch die Giebelflächen
* (`RoofGeometry.gables`) — erst zusammen bilden sie einen von aussen
* geschlossenen Körper (die Giebel schliessen die offenen Enden bei Sattel/
* Mansarde, die First-/Grat-/Knicklinien sind reine Grundriss-Hilfslinien und
* werden hier nicht gebraucht). Traufhöhe über {@link roofBaseElevation}
* (Override oder Geschoss-Oberkante), Flächen über {@link roofGeometry}. Farbe:
* `roof.color` (falls gültiges Hex), sonst {@link ROOF_RGB}. `positions` in
* MODELL-Metern (x, y, z=Höhe) wie {@link emitOpeningGlass}/{@link emitMeshes};
* render3d rendert Kontext-Meshes doppelseitig, die Winding-Reihenfolge ist also
* unerheblich.
*/
function emitRoofs(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const roof of project.roofs ?? []) {
const eavesZ = roofBaseElevation(project, roof);
const g = roofGeometry(roof, eavesZ);
const color = roof.color ? hexToRgb(roof.color, ROOF_RGB) : ROOF_RGB;
const positions: number[] = [];
const indices: number[] = [];
for (const plane of g.planes) pushFanTriangles(positions, indices, plane.pts);
for (const gable of g.gables) pushFanTriangles(positions, indices, gable);
if (indices.length === 0) continue;
out.push({ positions, indices, kind: "extrusion", color });
}
return out;
}
/**
* Emittiert die rohen Kontext-Meshes eines Projekts: Gelände-TINs (`terrainMesh`)
* und importierte Volumen (`importedMesh`) aus `project.context`. `contourSet`
@@ -2110,14 +2157,15 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R
// Kontext-Meshes (Terrain/Import) + Fenster-/Tür-Glasscheiben (eigenes Mesh
// je Scheibe) + Rahmen-/Sprossen-Riegel typisierter Öffnungen (Zarge/
// Blendrahmen/Kämpfer/Mittelpfosten, s. emitOpeningFrames) + glatte Beton-
// Laufplatten (gerade Betontreppen als Mesh) — pickGeometry/Highlight nutzen
// `meshes` bewusst NICHT (man wählt Wand/Öffnung/Treppe; Treppen-Pick läuft
// über die pickGeometry-Boxen).
// Laufplatten (gerade Betontreppen als Mesh) + Dachflächen/-giebel
// (s. emitRoofs) — pickGeometry/Highlight nutzen `meshes` bewusst NICHT (man
// wählt Wand/Öffnung/Treppe; Treppen-Pick läuft über die pickGeometry-Boxen).
meshes: [
...emitMeshes(project),
...emitOpeningGlass(project),
...emitOpeningFrames(project, detail),
...emitStairMeshes(project),
...emitRoofs(project),
],
};
}