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:
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user