// Dach im Vertikalschnitt: appendRoofSections schneidet die Schnittebene // analytisch mit den Dachflächen (der Rust-Extraktor kennt nur Wände/Decken). // Geprüft werden u-Intervalle, Oberkanten-Verlauf (linear je Fläche), die // vertikale Dicke (lotrechte Dicke / cos(Neigung)) und die Schicht-Bänder. import { describe, it, expect } from "vitest"; import { appendRoofSections } from "./toSection"; import type { SectionOutput, SectionPlaneSpec } from "./toSection"; import type { Project, Roof } from "../model/types"; const RECT: Roof["outline"] = [ { x: -3, y: 0 }, { x: 3, y: 0 }, { x: 3, y: 4 }, { x: -3, y: 4 }, ]; function roof(over: Partial = {}): Roof { return { id: "R1", type: "roof", floorId: "eg", categoryCode: "35", outline: RECT, shape: "sattel", pitchDeg: 30, overhang: 0, ridgeAxis: "x", baseElevation: 5, // explizit — kein Geschoss-Lookup nötig thickness: 0.3, ...over, }; } function project(r: Roof, extra: Partial = {}): Project { return { id: "t", name: "T", lineStyles: [], hatches: [], components: [], wallTypes: [], drawingLevels: [], layers: [], walls: [], doors: [], roofs: [r], ...extra, } as unknown as Project; } /** Schnittebene: Blick entlang +x, Spur = Modell-Gerade x=0 (u wächst mit y). */ const PLANE: SectionPlaneSpec = { point: [0, 0, 0], normal: [1, 0, 0] }; const emptyOutput = (): SectionOutput => ({ cutPolygons: [], visibleEdges: [], hiddenEdges: [] }); describe("appendRoofSections", () => { it("Flachdach: EIN Rechteck-Band, Oberkante = Traufhöhe, Höhe = Dicke", () => { const out = emptyOutput(); appendRoofSections(out, project(roof({ shape: "flach", pitchDeg: 0 })), PLANE); expect(out.cutPolygons.length).toBe(1); const cp = out.cutPolygons[0]; expect(cp.component).toEqual({ kind: "roof", index: 0 }); // u-Intervall = Tiefe 0..4; Oberkante 5, Unterkante 5 − 0.3 (cosθ = 1). const us = cp.pts.map((p) => p[0]).sort((a, b) => a - b); expect(us[0]).toBeCloseTo(0, 6); expect(us[3]).toBeCloseTo(4, 6); const vs = cp.pts.map((p) => p[1]).sort((a, b) => a - b); expect(vs[3]).toBeCloseTo(5, 6); expect(vs[0]).toBeCloseTo(5 - 0.3, 6); }); it("Satteldach quer zum First: zwei Bänder (steigend/fallend) mit Neigungs-Dicke", () => { const out = emptyOutput(); appendRoofSections(out, project(roof()), PLANE); expect(out.cutPolygons.length).toBe(2); const tan30 = Math.tan((30 * Math.PI) / 180); const cos30 = Math.cos((30 * Math.PI) / 180); const ridgeZ = 5 + 2 * tan30; // First bei halber Tiefe (y=2) // Alle Oberkanten-Werte je Band: an u=0/4 Traufe (5), an u=2 First. const topAt = (cp: (typeof out.cutPolygons)[number], u: number): number => Math.max(...cp.pts.filter((p) => Math.abs(p[0] - u) < 1e-6).map((p) => p[1])); const bands = out.cutPolygons; const front = bands.find((b) => b.pts.some((p) => Math.abs(p[0]) < 1e-6)); const back = bands.find((b) => b.pts.some((p) => Math.abs(p[0] - 4) < 1e-6)); expect(front).toBeDefined(); expect(back).toBeDefined(); expect(topAt(front!, 0)).toBeCloseTo(5, 5); expect(topAt(front!, 2)).toBeCloseTo(ridgeZ, 5); expect(topAt(back!, 4)).toBeCloseTo(5, 5); expect(topAt(back!, 2)).toBeCloseTo(ridgeZ, 5); // Vertikale Banddicke = lotrechte Dicke / cos(Neigung). const vAt = (cp: (typeof out.cutPolygons)[number], u: number): number[] => cp.pts.filter((p) => Math.abs(p[0] - u) < 1e-6).map((p) => p[1]); const [hi, lo] = vAt(front!, 0).sort((a, b) => b - a); expect(hi - lo).toBeCloseTo(0.3 / cos30, 5); }); it("Schnittspur ausserhalb des Dachs: keine Bänder", () => { const out = emptyOutput(); // Spur x=0 — Dach komplett rechts davon (x 10..16). const far = roof({ outline: [ { x: 10, y: 0 }, { x: 16, y: 0 }, { x: 16, y: 4 }, { x: 10, y: 4 }, ], }); appendRoofSections(out, project(far), PLANE); expect(out.cutPolygons.length).toBe(0); }); it("mehrschichtiges Dach (roofTypeId): je Schicht ein Band pro Fläche, aussen oben", () => { const p = project(roof({ shape: "flach", pitchDeg: 0, roofTypeId: "rt1" }), { hatches: [ { id: "h1", name: "H", pattern: "diagonal", scale: 1, angle: 45, color: "#111111" }, ], components: [ { id: "tile", name: "Ziegel", color: "#a03a2a", hatchId: "h1", joinPriority: 1 }, { id: "ins", name: "Dämmung", color: "#ffffff", hatchId: "h1", joinPriority: 2 }, ], roofTypes: [ { id: "rt1", name: "Aufbau", layers: [ { componentId: "tile", thickness: 0.05 }, { componentId: "ins", thickness: 0.25 }, ], }, ], }); const out = emptyOutput(); appendRoofSections(out, p, PLANE); expect(out.cutPolygons.length).toBe(2); // 1 Fläche × 2 Schichten const tops = out.cutPolygons.map((cp) => Math.max(...cp.pts.map((q) => q[1]))); // Eindeckung oben (OK 5), Dämmung darunter (OK 5 − 0.05). expect(Math.max(...tops)).toBeCloseTo(5, 6); expect(Math.min(...tops)).toBeCloseTo(4.95, 6); // Schicht-Stile sind aufgelöst (Schraffur des Bauteils). expect(out.cutPolygons.every((cp) => cp.hatch?.pattern === "diagonal")).toBe(true); }); });