Files
DOSSIER-STANDALONE/src/geometry/roomArea.ts
T
karim 3d2d4d6321 2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
2026-07-02 00:12:39 +02:00

390 lines
12 KiB
TypeScript

// 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<SiaKey, string> = {
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<SiaCategory, SiaAggregate[]> = {
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<SiaKey, number>;
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<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
const aggCount: Record<SiaAggregate, number> = { 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<SiaKey, number> = {
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");
}