/** * Unit-Tests für das Stützen-Bauteil (Column) — DOSSIER-Audit A4 Tragwerk: * • 2D-Grundriss (generatePlan): eine Stütze erzeugt ein Poché-Polygon mit * columnId an der richtigen Position; die Rotation dreht den Rechteck- * Querschnitt. * • 3D (projectToModel3d/emitColumns): eine Stütze wird zu einem Prisma-Mesh * (Rechteck-Box bzw. Kreis-Prisma) mit der erwarteten vertikalen Ausdehnung. * • Vertikale Lage (columnVerticalExtent): Geschoss-Default sowie UK/OK-Anker. */ import { describe, it, expect } from "vitest"; import { generatePlan } from "./generatePlan"; import { projectToModel3d } from "./toWalls3d"; import { columnVerticalExtent } from "../model/wall"; import { columnFootprint } from "../geometry/column"; import type { Column, Project } from "../model/types"; /** Minimalprojekt (zwei Geschosse gestapelt) mit einer konfigurierbaren Stütze. */ function projectWithColumn(column: Column): Project { return { id: "t", name: "T", lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }], hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }], components: [{ id: "a", name: "A", color: "#c8c8cc", hatchId: "none", joinPriority: 10 }], wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.3 }] }], drawingLevels: [ { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1.0, baseElevation: 0 }, { id: "og", name: "OG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1.0, baseElevation: 3 }, ], layers: [ { code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }, { code: "50", name: "Tragwerk", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }, ], walls: [], doors: [], openings: [], ceilings: [], stairs: [], rooms: [], columns: [column], drawings2d: [], context: [], }; } const visible = new Set(["20", "50"]); /** Basis-Stütze: Rechteck 0.4×0.2 an (2,3), Höhe 3, keine Drehung. */ const rectColumn: Column = { id: "C1", type: "column", floorId: "eg", categoryCode: "50", position: { x: 2, y: 3 }, profile: { kind: "rect", width: 0.4, depth: 0.2 }, rotation: 0, height: 3, }; describe("Stütze — 2D-Grundriss (generatePlan)", () => { it("erzeugt ein Poché-Polygon mit columnId am Rechteck-Querschnitt", () => { const plan = generatePlan(projectWithColumn(rectColumn), "eg", visible); const polys = plan.primitives.filter( (p) => p.kind === "polygon" && p.columnId === "C1", ); expect(polys.length).toBe(1); const poly = polys[0]; if (poly.kind !== "polygon") throw new Error("kein Polygon"); // Rechteck 0.4×0.2 um (2,3): Ecken bei x∈{1.8,2.2}, y∈{2.9,3.1}. const xs = poly.pts.map((p) => p.x).sort((a, b) => a - b); const ys = poly.pts.map((p) => p.y).sort((a, b) => a - b); expect(poly.pts.length).toBe(4); expect(xs[0]).toBeCloseTo(1.8, 6); expect(xs[3]).toBeCloseTo(2.2, 6); expect(ys[0]).toBeCloseTo(2.9, 6); expect(ys[3]).toBeCloseTo(3.1, 6); }); it("dreht den Querschnitt um 90° (Breite/Tiefe vertauscht)", () => { const rotated: Column = { ...rectColumn, rotation: Math.PI / 2 }; const plan = generatePlan(projectWithColumn(rotated), "eg", visible); const poly = plan.primitives.find( (p) => p.kind === "polygon" && p.columnId === "C1", ); if (!poly || poly.kind !== "polygon") throw new Error("kein Polygon"); // Nach 90°-Drehung: X-Ausdehnung = ±depth/2 = ±0.1, Y = ±width/2 = ±0.2. const xs = poly.pts.map((p) => p.x).sort((a, b) => a - b); const ys = poly.pts.map((p) => p.y).sort((a, b) => a - b); expect(xs[0]).toBeCloseTo(1.9, 6); expect(xs[3]).toBeCloseTo(2.1, 6); expect(ys[0]).toBeCloseTo(2.8, 6); expect(ys[3]).toBeCloseTo(3.2, 6); }); it("blendet die Stütze aus, wenn Kategorie 50 unsichtbar ist", () => { const plan = generatePlan(projectWithColumn(rectColumn), "eg", new Set(["20"])); expect(plan.primitives.some((p) => p.kind === "polygon" && p.columnId)).toBe(false); }); }); describe("Stütze — 3D (projectToModel3d / emitColumns)", () => { it("erzeugt eine Rechteck-Box (8 Ecken) über die volle Höhe", () => { const model = projectToModel3d(projectWithColumn(rectColumn)); const meshes = model.meshes.filter((m) => m.kind === "extrusion"); expect(meshes.length).toBe(1); const mesh = meshes[0]; // 4 Footprint-Punkte × (unten + oben) = 8 Vertices. expect(mesh.positions.length).toBe(8 * 3); // z-Werte (jedes 3. Element) liegen bei 0 (UK) und 3 (OK). const zs = mesh.positions.filter((_, i) => i % 3 === 2); expect(Math.min(...zs)).toBeCloseTo(0, 6); expect(Math.max(...zs)).toBeCloseTo(3, 6); }); it("erzeugt ein tesselliertes Kreis-Prisma (64 Ecken)", () => { const round: Column = { ...rectColumn, profile: { kind: "round", radius: 0.15 }, }; const model = projectToModel3d(projectWithColumn(round)); const mesh = model.meshes.find((m) => m.kind === "extrusion"); if (!mesh) throw new Error("kein Prisma-Mesh"); // 32-Eck × (unten + oben) = 64 Vertices. expect(mesh.positions.length).toBe(64 * 3); }); }); describe("Stütze — vertikale Lage (columnVerticalExtent)", () => { it("nutzt Geschoss-UK + Höhe als Default", () => { const { zBottom, zTop } = columnVerticalExtent( projectWithColumn(rectColumn), rectColumn, ); expect(zBottom).toBeCloseTo(0, 6); expect(zTop).toBeCloseTo(3, 6); }); it("bindet UK/OK an Anker (custom + floor)", () => { const anchored: Column = { ...rectColumn, bottom: { mode: "custom", z: 0.5 }, top: { mode: "floor", floorId: "og" }, // baseElevation OG = 3 }; const { zBottom, zTop } = columnVerticalExtent(projectWithColumn(anchored), anchored); expect(zBottom).toBeCloseTo(0.5, 6); expect(zTop).toBeCloseTo(3, 6); }); }); describe("Stütze — Footprint-Helfer (columnFootprint)", () => { it("tesselliert ein rundes Profil als 32-Eck um die Position", () => { const round: Column = { ...rectColumn, profile: { kind: "round", radius: 0.15 } }; const pts = columnFootprint(round); expect(pts.length).toBe(32); // Alle Punkte liegen im Radius 0.15 um (2,3). for (const p of pts) { const d = Math.hypot(p.x - 2, p.y - 3); expect(d).toBeCloseTo(0.15, 6); } }); });