// Räume (Flächen) — Flächenberechnung und SIA-416-Klassifikation. // // Reiner Rechenkern für die Raum-Flächenbuchhaltung nach SIA 416 (Schweizer // Norm „Flächen und Volumen von Gebäuden"). Keine UI, kein Datei-IO — nur // Polygon-Geometrie, Kategorie-Hierarchie und Aggregation für die Bilanz. // // Bezeichner englisch, Kommentare/Labels deutsch (CONVENTIONS.md). Einheit: METER. import type { Vec2 } from "../model/types"; // ── Polygon-Kennzahlen ─────────────────────────────────────────────────────── /** * Vorzeichenbehaftete Polygonfläche (Gauss'sche Trapezformel / Shoelace). * >0 = gegen den Uhrzeigersinn (CCW), <0 = im Uhrzeigersinn (CW). Der letzte * Punkt darf NICHT dupliziert sein (die Schlusskante n-1 → 0 wird ergänzt). */ export function signedArea(pts: Vec2[]): number { const n = pts.length; if (n < 3) return 0; let s = 0; for (let i = 0; i < n; i++) { const a = pts[i]; const b = pts[(i + 1) % n]; s += a.x * b.y - b.x * a.y; } return s / 2; } /** Absolute (immer positive) Polygonfläche in m². */ export function polygonArea(pts: Vec2[]): number { return Math.abs(signedArea(pts)); } /** Umfang eines geschlossenen Polygons (Summe aller Kantenlängen). */ export function perimeter(pts: Vec2[]): number { const n = pts.length; if (n < 2) return 0; let p = 0; for (let i = 0; i < n; i++) { const a = pts[i]; const b = pts[(i + 1) % n]; p += Math.hypot(b.x - a.x, b.y - a.y); } return p; } /** * Flächenschwerpunkt (Centroid) eines geschlossenen Polygons — Ankerpunkt für * das Raum-Label. Nutzt die momenten-gewichtete Formel; degeneriert (Fläche ≈ 0) * auf den Durchschnitt der Vertices. */ export function centroid(pts: Vec2[]): Vec2 { const n = pts.length; if (n === 0) return { x: 0, y: 0 }; if (n < 3) { let sx = 0; let sy = 0; for (const p of pts) { sx += p.x; sy += p.y; } return { x: sx / n, y: sy / n }; } let a = 0; let cx = 0; let cy = 0; for (let i = 0; i < n; i++) { const p = pts[i]; const q = pts[(i + 1) % n]; const cr = p.x * q.y - q.x * p.y; a += cr; cx += (p.x + q.x) * cr; cy += (p.y + q.y) * cr; } a /= 2; if (Math.abs(a) < 1e-12) { let sx = 0; let sy = 0; for (const p of pts) { sx += p.x; sy += p.y; } return { x: sx / n, y: sy / n }; } return { x: cx / (6 * a), y: cy / (6 * a) }; } // ── SIA-416-Kategorie-Hierarchie ───────────────────────────────────────────── // // SIA 416 gliedert die Geschossfläche (GF) eines Gebäudes hierarchisch: // // GF Geschossfläche // ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …) // └── NGF Nettogeschossfläche // ├── NF Nutzfläche // │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck) // │ └── NNF Nebennutzfläche (unterstützende Nutzung, Lager, WC …) // ├── VF Verkehrsfläche (Flure, Treppen, Aufzüge, Rampen) // └── FF Funktionsfläche (technische Anlagen, Haustechnik) // // Die „Blatt"-Kategorien, die einem konkreten Raum zugewiesen werden, sind // HNF, NNF, VF, FF und KGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF = GF. /** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */ export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF"; /** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */ export type SiaAggregate = "NF" | "NGF" | "GF"; /** Alle in der Bilanz vorkommenden Schlüssel (Blatt + Aggregat). */ export type SiaKey = SiaCategory | SiaAggregate; /** Reihenfolge der Blatt-Kategorien für stabile Tabellen/CSV-Ausgabe. */ export const SIA_CATEGORIES: readonly SiaCategory[] = [ "HNF", "NNF", "VF", "FF", "KGF", ] as const; /** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */ export interface SiaLabel { /** Kürzel, z. B. „HNF". */ code: SiaKey; /** Ausgeschriebene deutsche Bezeichnung. */ name: string; } const SIA_LABELS: Record = { HNF: "Hauptnutzfläche", NNF: "Nebennutzfläche", VF: "Verkehrsfläche", FF: "Funktionsfläche", KGF: "Konstruktionsfläche", NF: "Nutzfläche", NGF: "Nettogeschossfläche", GF: "Geschossfläche", }; /** Deutsche Bezeichnung zu einem SIA-Schlüssel. */ export function siaLabel(key: SiaKey): string { return SIA_LABELS[key]; } /** „HNF — Hauptnutzfläche" o. ä. für Legenden/Dropdowns. */ export function siaLabelFull(key: SiaKey): string { return `${key} — ${SIA_LABELS[key]}`; } /** * Zuordnung Blatt-Kategorie → übergeordnete Aggregate. Eine HNF trägt zu * NF, NGF und GF bei; eine KGF nur zu GF; VF/FF zu NGF und GF; NNF wie HNF. * Wird für den Bilanz-Roll-up genutzt. */ const CATEGORY_ROLLUP: Record = { HNF: ["NF", "NGF", "GF"], NNF: ["NF", "NGF", "GF"], VF: ["NGF", "GF"], FF: ["NGF", "GF"], KGF: ["GF"], }; // ── Raum-Flächenergebnis ───────────────────────────────────────────────────── /** * Ergebnis der Flächenbewertung eines einzelnen Raums. * * `grossArea` ist die geometrische Polygonfläche (Bruttofläche der Raum-Kontur). * `netArea` ist die anrechenbare Netto-Nutzfläche: standardmässig gleich der * Bruttofläche (die Raum-Kontur ist bereits die lichte Innenfläche zwischen den * Wänden). Über einen optionalen Abzug (`deduction`, z. B. für nicht anrechen- * bare Bereiche mit lichter Höhe < 1.0 m nach SIA 416) kann `netArea` reduziert * werden. `category`/`label` tragen die SIA-Klassifikation. */ export interface RoomAreaResult { grossArea: number; netArea: number; perimeter: number; centroid: Vec2; category: SiaCategory; /** Deutsche Bezeichnung der Kategorie (z. B. „Hauptnutzfläche"). */ label: string; } /** Optionen für die Flächenbewertung eines Raums. */ export interface RoomAreaOptions { /** * Abzug von der Bruttofläche (m²), z. B. für Bereiche mit lichter Höhe unter * dem SIA-416-Grenzwert, die nicht zur anrechenbaren Fläche zählen. Default 0. */ deduction?: number; } /** * Bewertet ein Raum-Polygon: berechnet Brutto-/Nettofläche, Umfang, Schwerpunkt * und hängt die SIA-416-Kategorie samt deutschem Label an. */ export function evaluateRoom( polygon: Vec2[], category: SiaCategory, options: RoomAreaOptions = {}, ): RoomAreaResult { const gross = polygonArea(polygon); const deduction = Math.max(0, options.deduction ?? 0); const net = Math.max(0, gross - deduction); return { grossArea: gross, netArea: net, perimeter: perimeter(polygon), centroid: centroid(polygon), category, label: SIA_LABELS[category], }; } // ── Bilanz (Flächenaufstellung) ────────────────────────────────────────────── /** Ein Zeileneintrag der Bilanz: Schlüssel, Label, Fläche, Raumanzahl. */ export interface BalanceRow { key: SiaKey; label: string; area: number; /** Anzahl Räume, die zu dieser (Blatt-)Kategorie zählen. Für Aggregate: Summe. */ count: number; /** true, wenn es sich um eine Zwischensumme (NF/NGF/GF) handelt. */ aggregate: boolean; } /** Vollständige SIA-416-Bilanz: geordnete Zeilen + Gesamtsumme (GF). */ export interface Balance { rows: BalanceRow[]; /** Schneller Zugriff auf die Fläche je Schlüssel. */ byKey: Record; total: number; } /** * Aggregiert Räume je SIA-416-Kategorie und rollt die Zwischensummen * (NF, NGF, GF) korrekt auf. Erwartet je Raum die Blatt-Kategorie und die * anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}). * * Die zurückgegebenen `rows` sind in kanonischer Reihenfolge: * HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, GF(=Σ) * — passend für die Bilanz-Tabelle und den CSV-Export. */ export function balance( rooms: { category: SiaCategory; area: number }[], ): Balance { const leaf: Record = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 }; const leafCount: Record = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 }; const agg: Record = { NF: 0, NGF: 0, GF: 0 }; const aggCount: Record = { NF: 0, NGF: 0, GF: 0 }; for (const r of rooms) { const area = Number.isFinite(r.area) ? r.area : 0; leaf[r.category] += area; leafCount[r.category] += 1; for (const a of CATEGORY_ROLLUP[r.category]) { agg[a] += area; aggCount[a] += 1; } } const byKey: Record = { HNF: leaf.HNF, NNF: leaf.NNF, VF: leaf.VF, FF: leaf.FF, KGF: leaf.KGF, NF: agg.NF, NGF: agg.NGF, GF: agg.GF, }; const mkLeaf = (k: SiaCategory): BalanceRow => ({ key: k, label: SIA_LABELS[k], area: leaf[k], count: leafCount[k], aggregate: false, }); const mkAgg = (k: SiaAggregate): BalanceRow => ({ key: k, label: SIA_LABELS[k], area: agg[k], count: aggCount[k], aggregate: true, }); const rows: BalanceRow[] = [ mkLeaf("HNF"), mkLeaf("NNF"), mkAgg("NF"), mkLeaf("VF"), mkLeaf("FF"), mkAgg("NGF"), mkLeaf("KGF"), mkAgg("GF"), ]; return { rows, byKey, total: agg.GF }; } // ── CSV-Serialisierung ─────────────────────────────────────────────────────── /** Ein Raum, wie er in den CSV-Export einfliesst. */ export interface RoomCsvRecord { /** Raum-Nummer/-Kennung (frei), z. B. „R.01". */ number?: string; /** Raum-Name, z. B. „Wohnen". */ name?: string; /** Geschoss/Ebene, optional. */ level?: string; category: SiaCategory; /** Anzurechnende Fläche (m²) — i. d. R. netArea. */ area: number; } /** Ein Feld CSV-sicher quoten (RFC 4180: „..." bei , " \n; " → ""). */ function csvField(value: string): string { if (/[",\n\r;]/.test(value)) { return `"${value.replace(/"/g, '""')}"`; } return value; } /** Fläche auf 2 Nachkommastellen, Punkt als Dezimaltrenner (m²). */ function fmtArea(area: number): string { return (Math.round(area * 100) / 100).toFixed(2); } export interface RoomsToCsvOptions { /** Feldtrenner. Default „;" (Excel-DE-freundlich). */ delimiter?: string; /** true (Default): Bilanz-Zwischensummen unter die Raumliste anhängen. */ includeBalance?: boolean; } /** * Serialisiert eine Raumliste als CSV (reiner String, kein Datei-IO) für den * späteren Export. Kopfzeile deutsch; optional folgt die SIA-416-Bilanz als * Summenblock. Nutzt `;` als Trenner (Standard im DE-Excel-Umfeld). */ export function roomsToCsv( rooms: RoomCsvRecord[], options: RoomsToCsvOptions = {}, ): string { const d = options.delimiter ?? ";"; const includeBalance = options.includeBalance ?? true; const lines: string[] = []; const header = ["Nummer", "Name", "Geschoss", "Kategorie", "Bezeichnung", "Fläche [m²]"]; lines.push(header.map(csvField).join(d)); for (const r of rooms) { lines.push( [ csvField(r.number ?? ""), csvField(r.name ?? ""), csvField(r.level ?? ""), csvField(r.category), csvField(SIA_LABELS[r.category]), fmtArea(r.area), ].join(d), ); } if (includeBalance) { const bal = balance(rooms.map((r) => ({ category: r.category, area: r.area }))); lines.push(""); // Leerzeile trennt Raumliste von Bilanz lines.push([csvField("Bilanz (SIA 416)"), "", "", "", "", ""].join(d)); for (const row of bal.rows) { lines.push( [ "", "", "", csvField(row.key), csvField(row.label), fmtArea(row.area), ].join(d), ); } } return lines.join("\r\n"); }