Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+46
View File
@@ -0,0 +1,46 @@
// Stützen-Geometrie: leitet aus einer Stütze (Column) ihren Grundriss-Querschnitt
// (Poché-/Footprint-Polygon) ab. Reine Funktion, keine Modell-Mutation — sowohl
// der 2D-Grundriss (generatePlan) als auch die 3D-Extrusion (toWalls3d) und die
// Auswahl-/Transform-Vorschau (transform.ts) nutzen dieselbe Kontur.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Column, Vec2 } from "../model/types";
/** Tessellierungs-Segmentzahl eines runden Stützenprofils (Grundriss + 3D-Prisma). */
export const COLUMN_ROUND_SEGMENTS = 32;
/**
* Grundriss-Querschnitt einer Stütze als geschlossenes Polygon (Modell-Meter,
* CCW), um `position` platziert und (bei Rechteck) um `rotation` gedreht. Ein
* rundes Profil wird als regelmäßiges {@link COLUMN_ROUND_SEGMENTS}-Eck
* tesselliert; der Schlusspunkt wird NICHT dupliziert.
*/
export function columnFootprint(col: Column): Vec2[] {
const { x: cx, y: cy } = col.position;
if (col.profile.kind === "round") {
const r = col.profile.radius;
const pts: Vec2[] = [];
for (let i = 0; i < COLUMN_ROUND_SEGMENTS; i++) {
const t = (i / COLUMN_ROUND_SEGMENTS) * Math.PI * 2;
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
}
return pts;
}
// Rechteck: lokale Ecken ±w/2 (X) × ±d/2 (Y), um `rotation` gedreht.
const hw = col.profile.width / 2;
const hd = col.profile.depth / 2;
const rot = col.rotation ?? 0;
const cos = Math.cos(rot);
const sin = Math.sin(rot);
const local: Vec2[] = [
{ x: -hw, y: -hd },
{ x: +hw, y: -hd },
{ x: +hw, y: +hd },
{ x: -hw, y: +hd },
];
return local.map((p) => ({
x: cx + p.x * cos - p.y * sin,
y: cy + p.x * sin + p.y * cos,
}));
}
+325 -1
View File
@@ -60,7 +60,7 @@ import {
stairGeometry,
type StairGeometry as TSStairGeometry,
} from "./stair";
import type { Stair } from "../model/types";
import { wallTypeThickness, type Opening, type Project, type Stair, type Wall, type WallType } from "../model/types";
import {
detectRooms,
pointInPolygon as rbPointInPolygon,
@@ -69,6 +69,19 @@ import {
type WallSegment,
type WallFace,
} from "./roomBoundary";
import { wallReferenceOffset } from "../model/wall";
import {
doorSymbol,
openingCenter,
openingGapQuad,
openingInterval,
openingJambs,
wallAxisFrame,
wallAxisLength,
windowSymbol,
type DoorSymbol,
type WindowSymbol,
} from "./opening";
type Poly = { pts: Vec2[]; closed: boolean };
@@ -912,6 +925,317 @@ describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 3: roomBou
});
});
// ── Slice 4: opening ──────────────────────────────────────────────────────────
// `opening.ts` ist model-gekoppelt (Wall/Opening/Project) — die geometrisch
// reinen Kerne wurden mit abgeflachter Signatur (Vec2/number) portiert
// (PORT_PLAN §1). `thickness`/`refOffset` werden hier — wie beim Rust-Port —
// bereits aufgeloest uebergeben (`wallTypeThickness`/`wallReferenceOffset`,
// nicht ueber `getWallType`/ein volles `Project` im WASM-Aufruf).
/** Zufaellige, NICHT entartete Wand (Laenge 0.5..15.5 m). */
function genWall(rng: () => number): Wall {
const start = v(rng);
const angle = rng() * Math.PI * 2;
const length = 0.5 + rng() * 15;
const end = { x: start.x + Math.cos(angle) * length, y: start.y + Math.sin(angle) * length };
const refs = ["left", "center", "right"] as const;
return {
id: "w1",
type: "wall",
floorId: "f1",
categoryCode: "20",
start,
end,
wallTypeId: "wt1",
height: 2.4,
referenceLine: refs[Math.floor(rng() * 3)],
};
}
/** Einschichtiger WallType mit zufaelliger Dicke (0.15..0.5 m). */
function genWallType(rng: () => number): WallType {
return { id: "wt1", name: "wt", layers: [{ componentId: "c1", thickness: 0.15 + rng() * 0.35 }] };
}
/** Minimales Project — `getWallType` liest nur `wallTypes`. */
function makeProject(wallType: WallType): Project {
return { wallTypes: [wallType] } as unknown as Project;
}
/** Zufaellige Oeffnung auf `wall`: position/width teils ausserhalb der Achse
* oder negativ (Strukturabdeckung fuer den null-Zweig von `openingInterval`). */
function genOpening(rng: () => number, wall: Wall, kind: "window" | "door"): Opening {
const axis = wallAxisLength(wall);
const position = (rng() * 1.6 - 0.3) * axis;
const width = rng() < 0.1 ? -0.5 + rng() * 0.5 : 0.3 + rng() * Math.max(0.3, axis);
const swings = ["left", "right"] as const;
const hinges = ["start", "end"] as const;
const dirs = ["in", "out"] as const;
return {
id: "o1",
type: "opening",
hostWallId: wall.id,
categoryCode: "21",
kind,
position,
width,
height: 1.2,
sillHeight: kind === "door" ? 0 : 0.8,
wingCount: rng() < 0.5 ? undefined : 1 + Math.floor(rng() * 4),
hinge: rng() < 0.3 ? undefined : hinges[Math.floor(rng() * 2)],
swing: rng() < 0.3 ? undefined : swings[Math.floor(rng() * 2)],
swingAngle: rng() < 0.3 ? undefined : 30 + rng() * 120,
openingDir: rng() < 0.3 ? undefined : dirs[Math.floor(rng() * 2)],
};
}
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 4: opening", () => {
it("wallAxisLength (Zufallswaende, rel 1e-9)", () => {
const rng = mulberry32(50);
const walls = Array.from({ length: N }, () => genWall(rng));
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
const w = JSON.parse(K.wall_axis_length_batch_json(JSON.stringify(qs))) as number[];
walls.forEach((wl, i) => {
expect(closeNum(w[i], wallAxisLength(wl)), `axisLen#${i}`).toBe(true);
});
});
it("openingInterval: Struktur exakt (null-Zweig inklusive) + Werte", () => {
const rng = mulberry32(51);
const cases = Array.from({ length: N }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "window") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
}));
const w = JSON.parse(K.opening_interval_batch_json(JSON.stringify(qs))) as (
| { from: number; to: number }
| null
)[];
cases.forEach(({ wall, o }, i) => {
const t = openingInterval(wall, o);
expect(w[i] === null, `iv-null#${i}`).toBe(t === null);
if (t && w[i]) {
expect(closeNum(w[i]!.from, t.from), `iv-from#${i}`).toBe(true);
expect(closeNum(w[i]!.to, t.to), `iv-to#${i}`).toBe(true);
}
});
});
it("wallAxisFrame (u/n, rel 1e-9)", () => {
const rng = mulberry32(52);
const walls = Array.from({ length: N }, () => genWall(rng));
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
const w = JSON.parse(K.wall_axis_frame_batch_json(JSON.stringify(qs))) as { u: Vec2; n: Vec2 }[];
walls.forEach((wl, i) => {
const t = wallAxisFrame(wl);
expect(closeVec(w[i].u, t.u), `frame-u#${i}`).toBe(true);
expect(closeVec(w[i].n, t.n), `frame-n#${i}`).toBe(true);
});
});
it("openingJambs / openingCenter: Struktur exakt (null-Zweig) + Werte", () => {
const rng = mulberry32(53);
const cases = Array.from({ length: N }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "window") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
}));
const wJ = JSON.parse(K.opening_jambs_batch_json(JSON.stringify(qs))) as (
| { jambStart: Vec2; jambEnd: Vec2 }
| null
)[];
const wC = JSON.parse(K.opening_center_batch_json(JSON.stringify(qs))) as (Vec2 | null)[];
cases.forEach(({ wall, o }, i) => {
const tJ = openingJambs(wall, o);
expect(wJ[i] === null, `jambs-null#${i}`).toBe(tJ === null);
if (tJ && wJ[i]) {
expect(closeVec(wJ[i]!.jambStart, tJ.jambStart), `jambs-start#${i}`).toBe(true);
expect(closeVec(wJ[i]!.jambEnd, tJ.jambEnd), `jambs-end#${i}`).toBe(true);
}
const tC = openingCenter(wall, o);
expect(wC[i] === null, `center-null#${i}`).toBe(tC === null);
if (tC && wC[i]) expect(closeVec(wC[i]!, tC), `center#${i}`).toBe(true);
});
});
it("openingGapQuad / windowSymbol: Struktur exakt + Werte (rel 1e-9)", () => {
const rng = mulberry32(54);
const cases = Array.from({ length: 150 }, () => {
const wall = genWall(rng);
const wallType = genWallType(rng);
wall.wallTypeId = wallType.id;
const project = makeProject(wallType);
const o = genOpening(rng, wall, "window");
const total = wallTypeThickness(wallType);
const refOffset = wallReferenceOffset(wall, total);
const glassCount = 1 + Math.floor(rng() * 3);
return { wall, o, project, total, refOffset, glassCount };
});
const qsGap = cases.map((c) => ({
start: c.wall.start,
end: c.wall.end,
position: c.o.position,
width: c.o.width,
thickness: c.total,
refOffset: c.refOffset,
}));
const wGap = JSON.parse(K.opening_gap_quad_batch_json(JSON.stringify(qsGap))) as (Vec2[] | null)[];
cases.forEach((c, i) => {
const t = openingGapQuad(c.project, c.wall, c.o);
expect(wGap[i] === null, `gap-null#${i}`).toBe(t === null);
if (t && wGap[i]) expect(eqPts(wGap[i]!, t), `gap-pts#${i}`).toBe(true);
});
const qsWin = cases.map((c) => ({
start: c.wall.start,
end: c.wall.end,
position: c.o.position,
width: c.o.width,
thickness: c.total,
refOffset: c.refOffset,
glassCount: c.glassCount,
wingCount: c.o.wingCount,
}));
const wWin = JSON.parse(K.window_symbol_batch_json(JSON.stringify(qsWin))) as (WindowSymbol | null)[];
cases.forEach((c, i) => {
const t = windowSymbol(c.project, c.wall, c.o, c.glassCount);
expect(wWin[i] === null, `win-null#${i}`).toBe(t === null);
if (t && wWin[i]) {
expect(eqPts(wWin[i]!.frame, t.frame), `win-frame#${i}`).toBe(true);
expect(eqPairs(wWin[i]!.glassLines, t.glassLines), `win-glass#${i}`).toBe(true);
expect(eqPairs(wWin[i]!.mullionLines, t.mullionLines), `win-mullion#${i}`).toBe(true);
}
});
});
it("doorSymbol: Struktur exakt + Werte (Default-Parameter inklusive)", () => {
const rng = mulberry32(55);
const cases = Array.from({ length: 150 }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "door") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
swing: o.swing,
openingDir: o.openingDir,
hinge: o.hinge,
swingAngle: o.swingAngle,
}));
const w = JSON.parse(K.door_symbol_batch_json(JSON.stringify(qs))) as (DoorSymbol | null)[];
cases.forEach(({ wall, o }, i) => {
const t = doorSymbol(wall, o);
expect(w[i] === null, `door-null#${i}`).toBe(t === null);
if (t && w[i]) {
expect(closeVec(w[i]!.hinge, t.hinge), `door-hinge#${i}`).toBe(true);
expect(closeVec(w[i]!.openEnd, t.openEnd), `door-open#${i}`).toBe(true);
expect(closeVec(w[i]!.closedEnd, t.closedEnd), `door-closed#${i}`).toBe(true);
expect(closeNum(w[i]!.radius, t.radius), `door-radius#${i}`).toBe(true);
expect(closeVec(w[i]!.jambStart, t.jambStart), `door-jambStart#${i}`).toBe(true);
expect(closeVec(w[i]!.jambEnd, t.jambEnd), `door-jambEnd#${i}`).toBe(true);
expect(closeVec(w[i]!.normal, t.normal), `door-normal#${i}`).toBe(true);
}
});
});
it("Golden: entartetes Intervall (Breite 0 / ausserhalb der Achse) → null; wingCount-Clamp >4 → 3 Pfosten", () => {
const wall: Wall = {
id: "w",
type: "wall",
floorId: "f",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "wt",
height: 2.4,
referenceLine: "center",
};
const wallType: WallType = { id: "wt", name: "wt", layers: [{ componentId: "c", thickness: 0.24 }] };
const project = makeProject(wallType);
// Breite 0 → entartet.
const oZero: Opening = {
id: "o",
type: "opening",
hostWallId: "w",
categoryCode: "21",
kind: "window",
position: 2,
width: 0,
height: 1.2,
sillHeight: 0.8,
};
const ivZero = JSON.parse(
K.opening_interval_batch_json(
JSON.stringify([{ start: wall.start, end: wall.end, position: oZero.position, width: oZero.width }]),
),
) as ({ from: number; to: number } | null)[];
expect(ivZero[0] === null).toBe(true);
expect(ivZero[0] === null).toBe(openingInterval(wall, oZero) === null);
// Vollstaendig ausserhalb der Achse.
const oOut: Opening = { ...oZero, position: 10, width: 1 };
const ivOut = JSON.parse(
K.opening_interval_batch_json(
JSON.stringify([{ start: wall.start, end: wall.end, position: oOut.position, width: oOut.width }]),
),
) as ({ from: number; to: number } | null)[];
expect(ivOut[0] === null).toBe(true);
expect(ivOut[0] === null).toBe(openingInterval(wall, oOut) === null);
// wingCount > 4 wird auf 4 geklemmt → 3 Mittelpfosten.
const total = wallTypeThickness(wallType);
const refOffset = wallReferenceOffset(wall, total);
const oWings: Opening = {
id: "o",
type: "opening",
hostWallId: "w",
categoryCode: "21",
kind: "window",
position: 1,
width: 2,
height: 1.2,
sillHeight: 0.8,
wingCount: 9,
};
const wWin = JSON.parse(
K.window_symbol_batch_json(
JSON.stringify([
{
start: wall.start,
end: wall.end,
position: oWings.position,
width: oWings.width,
thickness: total,
refOffset,
glassCount: 1,
wingCount: oWings.wingCount,
},
]),
),
) as (WindowSymbol | null)[];
const tWin = windowSymbol(project, wall, oWings, 1);
expect(tWin).not.toBeNull();
expect(wWin[0]).not.toBeNull();
expect(wWin[0]!.mullionLines.length).toBe(tWin!.mullionLines.length);
expect(wWin[0]!.mullionLines.length).toBe(3);
expect(eqPairs(wWin[0]!.mullionLines, tWin!.mullionLines)).toBe(true);
});
});
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => {
it("parallele/kollineare Strecken → null (beide)", () => {
const qs = [
+102
View File
@@ -0,0 +1,102 @@
// Tests für die SIA-416-Flächenbilanz (roomArea.ts): Roll-up-Hierarchie,
// insbesondere die Einhängung der Kategorie AGF (Aussengeschossfläche).
import { describe, expect, it } from "vitest";
import { balance, roomsToCsv, SIA_CATEGORIES, siaLabel } from "./roomArea";
describe("SIA_CATEGORIES", () => {
it("enthält AGF als Blatt-Kategorie", () => {
expect(SIA_CATEGORIES).toContain("AGF");
});
it("liefert das deutsche Label für AGF", () => {
expect(siaLabel("AGF")).toBe("Aussengeschossfläche");
});
});
describe("balance() — Roll-up-Hierarchie", () => {
it("rollt HNF/NNF/VF/FF/KGF wie bisher auf (Referenzfall ohne AGF)", () => {
const bal = balance([
{ category: "HNF", area: 20 },
{ category: "NNF", area: 5 },
{ category: "VF", area: 8 },
{ category: "FF", area: 2 },
{ category: "KGF", area: 10 },
]);
expect(bal.byKey.NF).toBeCloseTo(25);
expect(bal.byKey.NGF).toBeCloseTo(35);
expect(bal.byKey.GF).toBeCloseTo(45);
expect(bal.byKey.AGF).toBe(0);
});
it("zählt AGF zur GF, aber NICHT zu NF/NGF", () => {
const bal = balance([
{ category: "HNF", area: 20 },
{ category: "AGF", area: 12 },
]);
// NGF bleibt unverändert (nur HNF trägt bei) — AGF fliesst hier nicht ein.
expect(bal.byKey.NF).toBeCloseTo(20);
expect(bal.byKey.NGF).toBeCloseTo(20);
// GF = NGF + KGF + AGF = 20 + 0 + 12
expect(bal.byKey.AGF).toBeCloseTo(12);
expect(bal.byKey.GF).toBeCloseTo(32);
expect(bal.total).toBeCloseTo(32);
});
it("GF = NGF + KGF + AGF bei gemischter Belegung", () => {
const bal = balance([
{ category: "HNF", area: 40 },
{ category: "NNF", area: 6 },
{ category: "VF", area: 9 },
{ category: "FF", area: 3 },
{ category: "KGF", area: 15 },
{ category: "AGF", area: 7.5 },
]);
expect(bal.byKey.NF).toBeCloseTo(46);
expect(bal.byKey.NGF).toBeCloseTo(58);
expect(bal.byKey.KGF).toBeCloseTo(15);
expect(bal.byKey.AGF).toBeCloseTo(7.5);
expect(bal.byKey.GF).toBeCloseTo(bal.byKey.NGF + bal.byKey.KGF + bal.byKey.AGF);
expect(bal.byKey.GF).toBeCloseTo(80.5);
expect(bal.total).toBeCloseTo(80.5);
});
it("führt eine eigene Bilanzzeile für AGF, positioniert nach KGF und vor GF", () => {
const bal = balance([{ category: "AGF", area: 4 }]);
const keys = bal.rows.map((r) => r.key);
expect(keys).toEqual(["HNF", "NNF", "NF", "VF", "FF", "NGF", "KGF", "AGF", "GF"]);
const agfRow = bal.rows.find((r) => r.key === "AGF");
expect(agfRow).toBeDefined();
expect(agfRow?.aggregate).toBe(false);
expect(agfRow?.area).toBeCloseTo(4);
expect(agfRow?.count).toBe(1);
});
});
describe("roomsToCsv() — Bilanzblock mit AGF", () => {
it("weist einen Raum mit Kategorie AGF in Raumliste und Bilanz korrekt aus", () => {
const csv = roomsToCsv([
{ number: "R.01", name: "Wohnen", category: "HNF", area: 20 },
{ number: "R.02", name: "Balkon", category: "AGF", area: 6 },
]);
const lines = csv.split("\r\n");
// Raumliste enthält den AGF-Raum mit Kategorie + Label.
const roomLine = lines.find((l) => l.startsWith("R.02"));
expect(roomLine).toBeDefined();
expect(roomLine).toContain("AGF");
expect(roomLine).toContain("Aussengeschossfläche");
expect(roomLine).toContain("6.00");
// Bilanzblock enthält eine AGF-Zeile mit der Fläche des Raums.
const balanceAgfLine = lines.find((l) => l.includes(";AGF;"));
expect(balanceAgfLine).toBeDefined();
expect(balanceAgfLine).toContain("Aussengeschossfläche");
expect(balanceAgfLine).toContain("6.00");
// GF-Summe berücksichtigt AGF (20 HNF + 6 AGF = 26).
const gfLine = lines.find((l) => l.includes(";GF;"));
expect(gfLine).toBeDefined();
expect(gfLine).toContain("26.00");
});
});
+15 -6
View File
@@ -92,6 +92,7 @@ export function centroid(pts: Vec2[]): Vec2 {
//
// GF Geschossfläche
// ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …)
// ├── AGF Aussengeschossfläche (Balkone, Terrassen, Laubengänge …)
// └── NGF Nettogeschossfläche
// ├── NF Nutzfläche
// │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck)
@@ -100,10 +101,12 @@ export function centroid(pts: Vec2[]): Vec2 {
// └── 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.
// HNF, NNF, VF, FF, KGF und AGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF+AGF = GF.
// AGF zählt zur Geschossfläche, ist aber ausserhalb der Gebäudehülle (Aussen-
// bereich) und fliesst daher NICHT in NGF (bzw. NF) ein.
/** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF";
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF" | "AGF";
/** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */
export type SiaAggregate = "NF" | "NGF" | "GF";
@@ -118,6 +121,7 @@ export const SIA_CATEGORIES: readonly SiaCategory[] = [
"VF",
"FF",
"KGF",
"AGF",
] as const;
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
@@ -134,6 +138,7 @@ const SIA_LABELS: Record<SiaKey, string> = {
VF: "Verkehrsfläche",
FF: "Funktionsfläche",
KGF: "Konstruktionsfläche",
AGF: "Aussengeschossfläche",
NF: "Nutzfläche",
NGF: "Nettogeschossfläche",
GF: "Geschossfläche",
@@ -152,7 +157,8 @@ export function siaLabelFull(key: SiaKey): string {
/**
* 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.
* AGF (Aussengeschossfläche) trägt nur zu GF bei — sie liegt ausserhalb der
* Gebäudehülle und zählt NICHT zu NF/NGF. Wird für den Bilanz-Roll-up genutzt.
*/
const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
HNF: ["NF", "NGF", "GF"],
@@ -160,6 +166,7 @@ const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
VF: ["NGF", "GF"],
FF: ["NGF", "GF"],
KGF: ["GF"],
AGF: ["GF"],
};
// ── Raum-Flächenergebnis ─────────────────────────────────────────────────────
@@ -242,14 +249,14 @@ export interface Balance {
* 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(=Σ)
* HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, AGF, 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 leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 };
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 };
const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
const aggCount: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
@@ -269,6 +276,7 @@ export function balance(
VF: leaf.VF,
FF: leaf.FF,
KGF: leaf.KGF,
AGF: leaf.AGF,
NF: agg.NF,
NGF: agg.NGF,
GF: agg.GF,
@@ -297,6 +305,7 @@ export function balance(
mkLeaf("FF"),
mkAgg("NGF"),
mkLeaf("KGF"),
mkLeaf("AGF"),
mkAgg("GF"),
];