diff --git a/src/geometry/roof.test.ts b/src/geometry/roof.test.ts new file mode 100644 index 0000000..533f799 --- /dev/null +++ b/src/geometry/roof.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { roofGeometry, roofBBox } from "./roof"; +import type { Roof, Vec2 } from "../model/types"; + +// Rechteck 6×4 (x:0..6, y:0..4), Neigung 45° (tan=1), Traufhöhe 10, kein Überstand. +const RECT: Vec2[] = [ + { x: 0, y: 0 }, + { x: 6, y: 0 }, + { x: 6, y: 4 }, + { x: 0, y: 4 }, +]; +const E = 10; + +function roof(over: Partial): Roof { + return { + id: "R1", + type: "roof", + floorId: "eg", + categoryCode: "35", + outline: RECT, + shape: "sattel", + pitchDeg: 45, + overhang: 0, + ridgeAxis: "x", + thickness: 0.2, + ...over, + }; +} + +/** Alle 3D-Punkte einer Geometrie (Flächen + Giebel). */ +function allZ(g: ReturnType): number[] { + const zs: number[] = []; + for (const pl of g.planes) for (const p of pl.pts) zs.push(p[2]); + for (const gp of g.gables) for (const p of gp) zs.push(p[2]); + return zs; +} + +describe("roofBBox", () => { + it("liefert die Bounding-Box", () => { + expect(roofBBox(RECT)).toEqual({ x0: 0, y0: 0, x1: 6, y1: 4 }); + }); +}); + +describe("roofGeometry — Formen (6×4, 45°, First entlang X)", () => { + it("flach: eine waagrechte Fläche auf Traufhöhe, kein First", () => { + const g = roofGeometry(roof({ shape: "flach" }), E); + expect(g.planes).toHaveLength(1); + expect(g.ridges).toHaveLength(0); + expect(g.ridgeHeight).toBe(0); + expect(allZ(g).every((z) => z === E)).toBe(true); + }); + + it("sattel: Firsthöhe = halbe Tiefe · tan, First mittig, 2 Flächen + 2 Giebel", () => { + const g = roofGeometry(roof({ shape: "sattel" }), E); + expect(g.ridgeHeight).toBeCloseTo(2, 6); // halfD 2 · tan45 1 + expect(g.planes).toHaveLength(2); + expect(g.gables).toHaveLength(2); + expect(g.ridges).toHaveLength(1); + // First auf yc=2, z=E+2. + expect(g.ridges[0][0]).toEqual({ x: 0, y: 2 }); + expect(g.ridges[0][1]).toEqual({ x: 6, y: 2 }); + expect(Math.max(...allZ(g))).toBeCloseTo(E + 2, 6); + expect(Math.min(...allZ(g))).toBeCloseTo(E, 6); + }); + + it("walm: First um halbe Tiefe verkürzt, 4 Flächen, 4 Grate, keine Giebel", () => { + const g = roofGeometry(roof({ shape: "walm" }), E); + expect(g.planes).toHaveLength(4); + expect(g.hips).toHaveLength(4); + expect(g.gables).toHaveLength(0); + // ra=2, rb=4. + expect(g.ridges[0][0]).toEqual({ x: 2, y: 2 }); + expect(g.ridges[0][1]).toEqual({ x: 4, y: 2 }); + expect(g.ridgeHeight).toBeCloseTo(2, 6); + }); + + it("zelt: Spitze über der Mitte, 4 Flächen, 4 Grate zur Mitte", () => { + const g = roofGeometry(roof({ shape: "zelt" }), E); + expect(g.planes).toHaveLength(4); + expect(g.hips).toHaveLength(4); + expect(g.ridges).toHaveLength(0); + // hz = min(halfW 3, halfD 2)=2. + expect(g.ridgeHeight).toBeCloseTo(2, 6); + // Alle Grate laufen zum Mittelpunkt (3,2). + for (const hip of g.hips) expect(hip[1]).toEqual({ x: 3, y: 2 }); + }); + + it("pult: eine Fläche, hohe Kante = Breite·tan über der Traufe", () => { + const g = roofGeometry(roof({ shape: "pult" }), E); + expect(g.planes).toHaveLength(1); + expect(g.ridgeHeight).toBeCloseTo(4, 6); // Tiefe 4 · tan45 + expect(Math.max(...allZ(g))).toBeCloseTo(E + 4, 6); + }); + + it("mansarde: 4 Flächen, 2 Knicklinien, First mittig, Knick tiefer als First", () => { + const g = roofGeometry(roof({ shape: "mansarde", pitchDeg: 70, pitchUpperDeg: 30 }), E); + expect(g.planes).toHaveLength(4); + expect(g.breaks).toHaveLength(2); + expect(g.ridges).toHaveLength(1); + expect(g.gables).toHaveLength(2); // Pentagon-Giebel + // First höher als 0, Knicklinien innerhalb der Tiefe. + expect(g.ridgeHeight).toBeGreaterThan(0); + expect(g.breaks[0][0].y).toBeCloseTo(0 + 2 * 0.4, 6); // yf = y0 + halfD·0.4 + }); +}); + +describe("roofGeometry — First entlang Y (Transponierung)", () => { + it("dreht die Sattel-First-Linie auf die X-Mitte", () => { + const g = roofGeometry(roof({ shape: "sattel", ridgeAxis: "y" }), E); + // Firstrichtung Y → First liegt bei x = xc = 3, läuft in Y. + expect(g.ridges[0][0]).toEqual({ x: 3, y: 0 }); + expect(g.ridges[0][1]).toEqual({ x: 3, y: 4 }); + // Firsthöhe nun aus der halben BREITE (3) · tan = 3. + expect(g.ridgeHeight).toBeCloseTo(3, 6); + }); +}); + +describe("roofGeometry — Überstand", () => { + it("weitet die Traufe um overhang nach aussen", () => { + const g = roofGeometry(roof({ shape: "flach", overhang: 0.5 }), E); + const bb = roofBBox(g.eaves); + expect(bb).toEqual({ x0: -0.5, y0: -0.5, x1: 6.5, y1: 4.5 }); + }); +}); diff --git a/src/geometry/roof.ts b/src/geometry/roof.ts new file mode 100644 index 0000000..ca0097e --- /dev/null +++ b/src/geometry/roof.ts @@ -0,0 +1,267 @@ +// Dach-Geometrie: leitet aus einem (rechteckigen) Grundriss-Umriss + Dachform + +// Neigung die Grundriss-Linien (Traufe/First/Grat/Knick) UND die 3D-Dachflächen +// ab. Bewusst auf der BOUNDING-BOX des Umrisses gerechnet (First entlang einer +// Hauptachse) — die gängige, intuitive Vereinfachung, die alle Standardformen +// (Flach/Pult/Sattel/Walm/Mansarde/Zelt) exakt und ohne Straight-Skeleton abdeckt. +// +// Koordinaten: Grundriss (x, y) in Metern; 3D-Punkte als [x, y, z] mit z = Höhe. +// Reine Datenschicht (kein React/Store), voll unit-testbar. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { Project, Roof, Vec2 } from "../model/types"; +import { getFloor } from "../model/types"; + +/** 3D-Punkt [x, y, z=Höhe] in Modell-Metern. */ +export type Vec3 = [number, number, number]; + +/** Eine ebene Dachfläche als Polygon (3D-Eckpunkte in Umlaufreihenfolge). */ +export interface RoofPlane { + pts: Vec3[]; +} + +/** Vollständige Dach-Geometrie (Grundriss-Linien + 3D-Flächen). */ +export interface RoofGeometry { + /** Traufe-Umriss (Grundriss, inkl. Überstand) als geschlossener Ring. */ + eaves: Vec2[]; + /** Firstlinie(n) im Grundriss (leer bei Flach/Zelt). */ + ridges: [Vec2, Vec2][]; + /** Gratlinien (Walm/Zelt: Ecke → First/Spitze). */ + hips: [Vec2, Vec2][]; + /** Knicklinien (Mansarde). */ + breaks: [Vec2, Vec2][]; + /** 3D-Dachflächen. */ + planes: RoofPlane[]; + /** Giebelflächen (vertikale Polygone an Sattel-/Mansarde-Enden). */ + gables: Vec3[][]; + /** Firsthöhe über der Traufe (Meter). */ + ridgeHeight: number; +} + +const DEG = Math.PI / 180; + +/** Bounding-Box eines Umrisses. */ +export function roofBBox(outline: Vec2[]): { x0: number; y0: number; x1: number; y1: number } { + let x0 = Infinity; + let y0 = Infinity; + let x1 = -Infinity; + let y1 = -Infinity; + for (const p of outline) { + if (p.x < x0) x0 = p.x; + if (p.y < y0) y0 = p.y; + if (p.x > x1) x1 = p.x; + if (p.y > y1) y1 = p.y; + } + return { x0, y0, x1, y1 }; +} + +/** + * Traufhöhe (absolutes Z) eines Dachs: `baseElevation`, sonst die OBERKANTE des + * zugehörigen Geschosses (baseElevation + floorHeight) — das Dach sitzt also + * standardmäßig auf der Geschossdecke auf. + */ +export function roofBaseElevation(project: Project, roof: Roof): number { + if (roof.baseElevation !== undefined) return roof.baseElevation; + try { + const floor = getFloor(project, roof.floorId); + return (floor.baseElevation ?? 0) + (floor.floorHeight ?? 0); + } catch { + return 0; + } +} + +const P = (x: number, y: number): Vec2 => ({ x, y }); + +/** + * Kanonische Berechnung mit First entlang der X-Achse auf dem Rechteck + * [x0,x1]×[y0,y1] (Traufe, inkl. Überstand) auf Traufhöhe `e`. Die „y"-First- + * Richtung wird im Wrapper durch Transponieren (x↔y) erreicht. + */ +function computeCanonical( + x0: number, + y0: number, + x1: number, + y1: number, + e: number, + roof: Roof, +): RoofGeometry { + const xc = (x0 + x1) / 2; + const yc = (y0 + y1) / 2; + const halfW = (x1 - x0) / 2; + const halfD = (y1 - y0) / 2; + const a = Math.max(0, roof.pitchDeg) * DEG; + const tan = Math.tan(a); + const h = halfD * tan; // Firsthöhe (Sattel/Walm/Mansarde) + + const eaves: Vec2[] = [P(x0, y0), P(x1, y0), P(x1, y1), P(x0, y1)]; + const flat = (): RoofGeometry => ({ + eaves, + ridges: [], + hips: [], + breaks: [], + planes: [{ pts: [[x0, y0, e], [x1, y0, e], [x1, y1, e], [x0, y1, e]] }], + gables: [], + ridgeHeight: 0, + }); + + switch (roof.shape) { + case "flach": + return flat(); + + case "pult": { + // Eine Fläche: tiefe Seite y0 auf Traufe, hohe Seite y1 auf e+H. + const H = (y1 - y0) * tan; + return { + eaves, + ridges: [[P(x0, y1), P(x1, y1)]], // hohe Kante (First-Ersatz) + hips: [], + breaks: [], + planes: [{ pts: [[x0, y0, e], [x1, y0, e], [x1, y1, e + H], [x0, y1, e + H]] }], + gables: [ + [[x0, y0, e], [x0, y1, e + H], [x0, y1, e]], + [[x1, y0, e], [x1, y1, e + H], [x1, y1, e]], + ], + ridgeHeight: H, + }; + } + + case "sattel": { + const rz = e + h; + return { + eaves, + ridges: [[P(x0, yc), P(x1, yc)]], + hips: [], + breaks: [], + planes: [ + { pts: [[x0, y0, e], [x1, y0, e], [x1, yc, rz], [x0, yc, rz]] }, + { pts: [[x0, y1, e], [x1, y1, e], [x1, yc, rz], [x0, yc, rz]] }, + ], + gables: [ + [[x0, y0, e], [x0, y1, e], [x0, yc, rz]], + [[x1, y0, e], [x1, y1, e], [x1, yc, rz]], + ], + ridgeHeight: h, + }; + } + + case "walm": { + const rz = e + h; + // First um die halbe Tiefe an beiden Enden verkürzt (45°-Grate bei + // gleicher Neigung). Bei schmalem Baukörper (halfW rb) ra = rb = xc; + return { + eaves, + ridges: [[P(ra, yc), P(rb, yc)]], + hips: [ + [P(x0, y0), P(ra, yc)], + [P(x0, y1), P(ra, yc)], + [P(x1, y0), P(rb, yc)], + [P(x1, y1), P(rb, yc)], + ], + breaks: [], + planes: [ + { pts: [[x0, y0, e], [x1, y0, e], [rb, yc, rz], [ra, yc, rz]] }, + { pts: [[x0, y1, e], [x1, y1, e], [rb, yc, rz], [ra, yc, rz]] }, + { pts: [[x0, y0, e], [x0, y1, e], [ra, yc, rz]] }, + { pts: [[x1, y0, e], [x1, y1, e], [rb, yc, rz]] }, + ], + gables: [], + ridgeHeight: h, + }; + } + + case "mansarde": { + // Sattel mit Knick: steile untere Neigung (pitchDeg) über den äusseren + // Tiefenanteil, dann flache obere (pitchUpperDeg ?? halbe Hauptneigung). + const aLow = a; + const aUp = Math.max(0, roof.pitchUpperDeg ?? roof.pitchDeg / 2) * DEG; + const d1 = halfD * 0.4; // Knick-Einzug von der Traufe (äussere 40% steil) + const z1 = e + d1 * Math.tan(aLow); + const z2 = z1 + (halfD - d1) * Math.tan(aUp); + const yf = y0 + d1; // vordere Knicklinie + const yb = y1 - d1; // hintere Knicklinie + return { + eaves, + ridges: [[P(x0, yc), P(x1, yc)]], + hips: [], + breaks: [ + [P(x0, yf), P(x1, yf)], + [P(x0, yb), P(x1, yb)], + ], + planes: [ + { pts: [[x0, y0, e], [x1, y0, e], [x1, yf, z1], [x0, yf, z1]] }, // vorn unten + { pts: [[x0, yf, z1], [x1, yf, z1], [x1, yc, z2], [x0, yc, z2]] }, // vorn oben + { pts: [[x0, y1, e], [x1, y1, e], [x1, yb, z1], [x0, yb, z1]] }, // hinten unten + { pts: [[x0, yb, z1], [x1, yb, z1], [x1, yc, z2], [x0, yc, z2]] }, // hinten oben + ], + gables: [ + [[x0, y0, e], [x0, yf, z1], [x0, yc, z2], [x0, yb, z1], [x0, y1, e]], + [[x1, y0, e], [x1, yf, z1], [x1, yc, z2], [x1, yb, z1], [x1, y1, e]], + ], + ridgeHeight: z2 - e, + }; + } + + case "zelt": { + // Allseitig zur Spitze über der Mitte. + const hz = Math.min(halfW, halfD) * tan; + const az = e + hz; + return { + eaves, + ridges: [], + hips: [ + [P(x0, y0), P(xc, yc)], + [P(x1, y0), P(xc, yc)], + [P(x1, y1), P(xc, yc)], + [P(x0, y1), P(xc, yc)], + ], + breaks: [], + planes: [ + { pts: [[x0, y0, e], [x1, y0, e], [xc, yc, az]] }, + { pts: [[x1, y0, e], [x1, y1, e], [xc, yc, az]] }, + { pts: [[x1, y1, e], [x0, y1, e], [xc, yc, az]] }, + { pts: [[x0, y1, e], [x0, y0, e], [xc, yc, az]] }, + ], + gables: [], + ridgeHeight: hz, + }; + } + } +} + +const swap2 = (p: Vec2): Vec2 => ({ x: p.y, y: p.x }); +const swap3 = (p: Vec3): Vec3 => [p[1], p[0], p[2]]; +const swapLine = (l: [Vec2, Vec2]): [Vec2, Vec2] => [swap2(l[0]), swap2(l[1])]; + +/** + * Dach-Geometrie aus Form/Neigung/Überstand + Traufhöhe `eavesZ`. Rechnet auf + * der Bounding-Box des Umrisses, um `overhang` nach aussen geweitet. Für + * `ridgeAxis:"y"` wird kanonisch (First entlang X) gerechnet und danach x↔y + * transponiert. + */ +export function roofGeometry(roof: Roof, eavesZ: number): RoofGeometry { + const bb = roofBBox(roof.outline); + const o = Math.max(0, roof.overhang); + let x0 = bb.x0 - o; + let y0 = bb.y0 - o; + let x1 = bb.x1 + o; + let y1 = bb.y1 + o; + const flip = roof.ridgeAxis === "y"; + if (flip) { + [x0, y0, x1, y1] = [y0, x0, y1, x1]; + } + const g = computeCanonical(x0, y0, x1, y1, eavesZ, roof); + if (!flip) return g; + return { + eaves: g.eaves.map(swap2), + ridges: g.ridges.map(swapLine), + hips: g.hips.map(swapLine), + breaks: g.breaks.map(swapLine), + planes: g.planes.map((pl) => ({ pts: pl.pts.map(swap3) })), + gables: g.gables.map((poly) => poly.map(swap3)), + ridgeHeight: g.ridgeHeight, + }; +} diff --git a/src/model/types.ts b/src/model/types.ts index 6cd5a45..c4fe3d2 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -954,6 +954,53 @@ export interface Ceiling { bottom?: VerticalAnchor; } +// ── Dach ──────────────────────────────────────────────────────────────────── + +/** + * Dachform (analog DOSSIER-Rhino): + * • "flach" — Flachdach (waagrechte Fläche auf Traufhöhe). + * • "pult" — Pultdach (eine geneigte Fläche, First an EINER Traufseite). + * • "sattel" — Satteldach (First mittig, zwei Flächen, Giebel an den Enden). + * • "walm" — Walmdach (allseitig geneigt, First verkürzt, vier Flächen). + * • "mansarde" — Mansarddach (Sattel mit Knick: steile untere + flache obere Neigung). + * • "zelt" — Zeltdach (allseitig zur Spitze, First-Länge 0, Pyramide). + */ +export type RoofShape = "flach" | "pult" | "sattel" | "walm" | "mansarde" | "zelt"; + +/** + * Ein Dach über einem (rechteckigen) Grundriss-Umriss. Die Geometrie rechnet auf + * der Bounding-Box des Umrisses (die gängige, intuitive Vereinfachung — First + * entlang einer Hauptachse); der Umriss selbst wird beim Zeichnen als Rechteck + * angelegt. Die Traufhöhe (`baseElevation`) ist die Unterkante der Dachfläche; + * darüber steigt das Dach je nach Form/Neigung auf. + */ +export interface Roof { + id: string; + type: "roof"; + /** Zugehörige Zeichnungsebene (Geschoss). */ + floorId: string; + /** Grafik-Kategorie (Ebene), z. B. "35" für Dächer. */ + categoryCode: string; + /** Geschlossener Grundriss-Umriss (Meter); Geometrie nutzt die Bounding-Box. */ + outline: Vec2[]; + /** Dachform. */ + shape: RoofShape; + /** Hauptneigung in Grad (0 = flach). */ + pitchDeg: number; + /** Nur Mansarde: obere (flachere) Neigung in Grad; fehlt ⇒ halbe Hauptneigung. */ + pitchUpperDeg?: number; + /** Dachüberstand an der Traufe (Meter, ringsum); 0 = bündig mit dem Umriss. */ + overhang: number; + /** Firstrichtung (Sattel/Walm/Mansarde/Pult): entlang der X- oder Y-Achse. */ + ridgeAxis: "x" | "y"; + /** Traufhöhe (absolutes Z, Meter); fehlt ⇒ Oberkante des Geschosses. */ + baseElevation?: number; + /** Dachdicke (Meter, senkrecht zur Fläche gemessen). */ + thickness: number; + /** Optionale Übersteuerung der Strich-/Umrissfarbe; sonst Kategorie-Farbe. */ + color?: string; +} + /** Schwenkrichtung der Tür relativ zur Wandachse. */ export type SwingSide = "left" | "right"; @@ -1870,6 +1917,8 @@ export interface Project { * (Default: leer behandeln). */ ceilings?: Ceiling[]; + /** Dächer über (rechteckigen) Grundriss-Umrissen. Optional (Alt-Projekte). */ + roofs?: Roof[]; doors: Door[]; /** * Öffnungen (Fenster/Türen), gehostet in Wänden. Optional, damit bestehende