/** * Unit-Tests für {@link selectionHighlightLines} (Umriss-Linien der 3D- * Auswahl, s. Datei-Kommentar in toWalls3d.ts): korrekte Vertexanzahl je * Bauteilart (Wand-Quader = 12 Kanten, Slab-Prisma = 3·n Kanten) sowie die * Leerfälle (keine Auswahl / unbekannte Id). */ import { describe, it, expect } from "vitest"; import type { Project, Roof } from "../model/types"; import { sampleProject } from "../model/sampleProject"; import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d"; import { resolveHatch } from "./generatePlan"; import { roofGeometry, roofBaseElevation } from "../geometry/roof"; const RGB: [number, number, number] = [1, 0.5, 0.1]; const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b] const VERTICES_PER_EDGE = 2; describe("selectionHighlightLines", () => { it("leere Auswahl -> leeres Array", () => { const lines = selectionHighlightLines(sampleProject, [], [], RGB); expect(lines.length).toBe(0); }); it("unbekannte Ids -> leeres Array (keine Übereinstimmung in der Geometrie)", () => { const lines = selectionHighlightLines(sampleProject, ["does-not-exist"], [], RGB); expect(lines.length).toBe(0); }); it("ein Dach -> Draht-Kanten der Dachflächen/Giebel (nicht leer, mit Farbe)", () => { // sampleProject enthält das Demo-Satteldach RF1. const lines = selectionHighlightLines(sampleProject, [], [], RGB, [], [], ["RF1"]); expect(lines.length).toBeGreaterThan(0); // Farbe in jedem Vertex (erstes Vertex: [px,py,pz,r,g,b]). expect(lines[3]).toBeCloseTo(RGB[0], 6); expect(lines[5]).toBeCloseTo(RGB[2], 6); }); it("eine Wand ohne Öffnungen -> je Materiallage ein Quader, Kern-Band unter der Decke gespalten (5·12 Kanten)", () => { // W2 (EG, Ost) trägt keine Tür/kein Fenster im Sample-Projekt (nur W1/W3 // haben Öffnungen, s. sampleProject.ts) -> ein Achsen-Teilquader. Wandtyp // "aw" hat 4 Schichten (render-ext/insulation/brick/render-int), die // TS-seitig je als eigene Box gestapelt werden (Option B). // NEU (per-Schicht-Decken-Dominanz): W2 steht unter der Geschossdecke C1 // (dg-massiv: screed 30 / insulation 20 / concrete 100). Der Backstein (50) // wird NUR von der Beton-Schicht (100) im z-Band [2.3,2.5] gekappt und läuft // darüber (bis Wandkopf 2.6) durch -> ZWEI Backstein-Boxen [0,2.3] + [2.5,2.6]. // Die anderen Bänder bleiben je 1 Box (Aussenputz/Dämmung aussen: ungeschnitten; // Innenputz 10 < alle Deckenschichten: bis 2.3). Also 5 Quader = 5·12 Kanten. const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB); expect(lines.length).toBe(5 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); }); it("eine Wand mit Farbwerten in jedem Vertex", () => { const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB); // Erstes Vertex: Position (3) + Farbe (3) = [px,py,pz,r,g,b]. expect(lines[3]).toBeCloseTo(RGB[0], 6); expect(lines[4]).toBeCloseTo(RGB[1], 6); expect(lines[5]).toBeCloseTo(RGB[2], 6); }); it("eine Decke (Slab) -> je Schicht 3·n Kanten (geschichteter Bodenaufbau)", () => { // C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten JE SCHICHT. // Deckentyp "dg-massiv" hat 3 Schichten (Estrich/Dämmung/Beton) -> je ein // eigener Slab, damit der 3D-Schnitt den Aufbau geschichtet zeigt (wie 2D). const lines = selectionHighlightLines(sampleProject, [], ["C1"], RGB); const n = 4; const layers = 3; // dg-massiv: screed + insulation + concrete expect(lines.length).toBe(layers * 3 * n * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); }); it("Mehrfachauswahl (Wand + Decke) -> Summe der Einzelgeometrien", () => { const wallOnly = selectionHighlightLines(sampleProject, ["W2"], [], RGB); const ceilingOnly = selectionHighlightLines(sampleProject, [], ["C1"], RGB); const both = selectionHighlightLines(sampleProject, ["W2"], ["C1"], RGB); expect(both.length).toBe(wallOnly.length + ceilingOnly.length); }); it("mehrere gewählte Wände -> Kantenzahl skaliert linear", () => { const one = selectionHighlightLines(sampleProject, ["W2"], [], RGB); const two = selectionHighlightLines(sampleProject, ["W2", "W4"], [], RGB); expect(two.length).toBe(one.length * 2); }); it("Öffnungen allein multiplizieren die Bänder NICHT (ein Körper je Schicht-Band mit Löchern)", () => { // 3D-Pfad (layered=true): eine Wand mit Öffnungen wird NICHT in Pfeiler/ // Brüstung/Sturz zerlegt, sondern als EIN Körper pro Schicht-Band mit echten // Löchern emittiert — die Löcher fügen im Highlight keine Kanten hinzu. // Isoliert von Verschneidungen getestet: OHNE die Querwand W9 (deren T-Stoss // sonst die innerste Putzschicht von W1 span-cutout-splittet, s. eigener // Test unten) hat W1 (Tür D1 + Fenster O1) dieselbe Bänder-Zahl wie die // öffnungslose Wand W2 desselben Typs "aw" — die Löcher fügen keine Körper // hinzu. Beide stehen unter der Geschossdecke C1, deren Beton-Schicht (100) // den Backstein (50) im z-Band [2.3,2.5] spaltet -> 5 Körper (per-Schicht- // Decken-Dominanz, s. Backstein-Boxen [0,2.3] + [2.5,2.6]). const noW9: Project = { ...sampleProject, walls: sampleProject.walls.filter((w) => w.id !== "W9") }; const w1 = selectionHighlightLines(noW9, ["W1"], [], RGB); const w2 = selectionHighlightLines(noW9, ["W2"], [], RGB); expect(w1.length).toBe(5 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); expect(w1.length).toBe(w2.length); }); }); describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:true)", () => { // Wand W1 (EG, Wandtyp "aw" = 4 Schichten) mit ZWEI Fenstern, deren Achsen- // Intervalle sich ÜBERLAPPEN, aber auf UNTERSCHIEDLICHER Höhe liegen: // Fenster A: u=[1.0,2.0] (position 1.0, width 1.0), z=[0.9,2.1] (sill 0.9, h 1.2) // Fenster B: u=[1.5,2.5] (position 1.5, width 1.0), z=[0.3,0.7] (sill 0.3, h 0.4) // Die alte Segment-Zerlegung kann diesen versetzt-überlappenden Fall nicht // abbilden; als Löcher in EINEM Körper müssen beide sauber als `holes` // erscheinen. W1: start (0,0)->(5,0), floor "eg" (baseElevation 0). const twoOffsetWindows: Project = { ...sampleProject, doors: [], openings: [ { id: "WA", type: "opening", hostWallId: "W1", categoryCode: "21", kind: "window", position: 1.0, width: 1.0, height: 1.2, sillHeight: 0.9, }, { id: "WB", type: "opening", hostWallId: "W1", categoryCode: "21", kind: "window", position: 1.5, width: 1.0, height: 0.4, sillHeight: 0.3, }, ], }; it("layered=true: BEIDE Fenster als holes je Band, Loch-Positionen bleiben nach Join-Rebase korrekt", () => { const w1 = projectToWalls3d(twoOffsetWindows, true).filter((w) => w.wallId === "W1"); // W1 ist Durchgangswand für die Querwand W9 (T-Stoss): die innerste Putz- // schicht wird durch den Span-Cutout entlang der Achse in ZWEI Teilstücke // gebrochen. NEU (per-Schicht-Decken-Dominanz): W1 steht unter der Decke C1, // deren Beton (100) den Backstein (50) im z-Band [2.3,2.5] spaltet -> der // Backstein kommt als ZWEI Boxen [0,2.3] + [2.5,2.6]. Die obere Backstein-Box // liegt ÜBER beiden Fenstern (z ≤ 2.1) und trägt daher KEINE Löcher. // Körper gesamt: render-ext(1) + insulation(1) + brick[0,2.3](1) + // brick[2.5,2.6](1, ohne Loch) + render-int-Span(2) = 6. expect(w1.length).toBe(6); // Nur die Körper, die die Fenster-z-Bänder schneiden, tragen Löcher (5 Stück); // die obere Backstein-Box (über den Fenstern) hat keine. const withHoles = w1.filter((b) => b.holes && b.holes.length > 0); expect(withHoles.length).toBe(5); // W1-Achse: (0,0)->(5,0), u=(1,0) -> Box-Achsenstart = box.start[0]. Die Loch- // Achsenkoordinaten sind je Körper auf DESSEN Box-Start rebased; die ABSOLUTE // Position (Box-Start + from) muss wieder exakt auf den Öffnungs-Intervallen liegen. for (const body of withHoles) { expect(body.holes).toBeDefined(); expect(body.holes!.length).toBe(2); const axis0 = body.start[0]; const byFrom = [...body.holes!].sort((a, b) => a.from - b.from); // Fenster A: u=[1,2], z=[0.9,2.1] (absolut rekonstruiert). expect(axis0 + byFrom[0].from).toBeCloseTo(1.0, 6); expect(axis0 + byFrom[0].to).toBeCloseTo(2.0, 6); expect(byFrom[0].zBottom).toBeCloseTo(0.9, 6); expect(byFrom[0].zTop).toBeCloseTo(2.1, 6); // Fenster B: u=[1.5,2.5], z=[0.3,0.7] — überlappt A in u, liegt tiefer. expect(axis0 + byFrom[1].from).toBeCloseTo(1.5, 6); expect(axis0 + byFrom[1].to).toBeCloseTo(2.5, 6); expect(byFrom[1].zBottom).toBeCloseTo(0.3, 6); expect(byFrom[1].zTop).toBeCloseTo(0.7, 6); } }); it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => { const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1"); // Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr // Boxen als die 4 Schicht-Bänder des 3D-Pfads) und setzt NIE `holes`. expect(w1.length).toBeGreaterThan(4); for (const seg of w1) expect(seg.holes).toBeUndefined(); }); it("Tür = Loch bis zur Wand-UK (zBottom == baseElevation)", () => { // Eine Tür (D1 auf W1) muss im 3D-Pfad als Loch bis zum Boden erscheinen: // zBottom des Lochs == Wand-UK (0), NICHT ab einer Brüstung. const p: Project = { ...sampleProject, openings: [] }; const w1 = projectToWalls3d(p, true).filter((w) => w.wallId === "W1"); // W1 ist Durchgangswand für W9 (T-Stoss) -> innerste Putzschicht span-cutout- // gebrochen. Zusätzlich spaltet die Decke C1 (Beton 100) den Backstein (50) im // z-Band [2.3,2.5] -> obere Backstein-Box [2.5,2.6] ohne Loch. Körper: render-ext // + insulation + brick[0,2.3] + brick[2.5,2.6] + render-int-Span(2) = 6. expect(w1.length).toBe(6); // Der erste Körper (Aussenputz-Band, volle Achse) trägt die Tür als Loch bis UK. const door = w1[0].holes?.find((h) => h.zBottom < 1e-6); expect(door).toBeDefined(); expect(door!.zBottom).toBeCloseTo(0, 6); // Loch bis zur Wand-UK. }); }); describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => { it("Wand -> je Materiallage eine Box in ihrer Component-Farbe (Option B)", () => { // Wandtyp "aw" (s. sampleProject) hat 4 Schichten mit eigenen Farben: // render-ext 0.02 #d8d2c7, insulation 0.16 #ffffff, // brick 0.15 #8c5544, render-int 0.015 #efece6 (Σ = 0.345 m). // W2 ist öffnungslos, steht aber unter der Geschossdecke C1: die Beton-Schicht // (100) spaltet den Backstein (50) im z-Band [2.3,2.5] -> der Backstein kommt // als ZWEI Boxen [0,2.3] + [2.5,2.6] (per-Schicht-Decken-Dominanz), die drei // übrigen Bänder je 1 Box = 5 Boxen. Die Farbzuordnung je Schicht bleibt. const walls = projectToWalls3d(sampleProject); const w2 = walls.filter((w) => w.wallId === "W2"); expect(w2.length).toBe(5); // Alle Schicht-Boxen tragen die gemeinsame wallId (Klick selektiert ganze Wand) // und sind Vollkörper (layers leer -> render3d zeichnet in `color`). for (const seg of w2) { expect(seg.wallId).toBe("W2"); expect(seg.layers.length).toBe(0); } // Die vier Materialfarben kommen vor; der Backstein (#8c5544) zweimal (gespalten). const hex = (c: [number, number, number]) => c.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join(""); const colors = w2.map((s) => hex(s.color)).sort(); expect(colors).toEqual(["8c5544", "8c5544", "d8d2c7", "efece6", "ffffff"]); expect([...new Set(colors)].sort()).toEqual(["8c5544", "d8d2c7", "efece6", "ffffff"]); // Die Schichtdicken (je EINDEUTIGEM Querversatz, damit die gespaltene Backstein- // Box nur EINMAL zählt) summieren zur Gesamt-Wanddicke (keine Lücken/Overlaps). const byOffset = new Map(); for (const seg of w2) byOffset.set(Math.round((seg.start[0] - 5) * 1e6) / 1e6, seg.thickness); const totalT = [...byOffset.values()].reduce((s, t) => s + t, 0); expect(totalT).toBeCloseTo(0.345, 6); }); it("Schicht-Offsets kacheln die Wanddicke lückenlos um die Achse", () => { // W2-Achse (5,0)->(5,4): u=(0,1), Normale n=(1,0) -> der Schicht-Versatz // wirkt rein auf x (Box-Start.x = 5 + offset). Sortiert man die Boxen nach // Versatz, müssen sich benachbarte Flächen exakt berühren und der Stapel // symmetrisch um die Achse liegen: min = -T/2, max = +T/2. // Nach EINDEUTIGEM Querversatz zusammenfassen: der unter der Decke gespaltene // Backstein ([0,2.3] + [2.5,2.6]) liegt zweimal am SELBEN Versatz vor und darf // die Dicken-Bilanz nicht doppelt zählen (per-Schicht-Decken-Dominanz). const w2raw = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2"); const byOffset = new Map(); for (const s of w2raw) byOffset.set(Math.round((s.start[0] - 5) * 1e6) / 1e6, s.thickness); const bands = [...byOffset.entries()] .map(([offset, t]) => ({ offset, t })) .sort((a, b) => a.offset - b.offset); const T = bands.reduce((s, b) => s + b.t, 0); expect(bands[0].offset - bands[0].t / 2).toBeCloseTo(-T / 2, 6); expect(bands[bands.length - 1].offset + bands[bands.length - 1].t / 2).toBeCloseTo(T / 2, 6); for (let i = 1; i < bands.length; i++) { // Oberkante der Vorlage == Unterkante der nächsten (keine Lücke/Overlap). expect(bands[i - 1].offset + bands[i - 1].t / 2).toBeCloseTo(bands[i].offset - bands[i].t / 2, 6); } }); }); describe("Fenster-Glasscheiben (emitOpeningGlass)", () => { it("1 Fenster + 1 Tür -> 2 Glas-Meshes (Typ 'window-standard' = Zweifachverglasung) mit korrekter vertikaler Ausdehnung", () => { // Sample-Projekt auf EINE Öffnung (Fenster O1) + EINE Tür (D1) reduzieren, // beide auf W1 (EG, baseElevation 0). Kontext ist leer -> `meshes` enthält // ausschliesslich die Glasscheiben. // // ANGEPASST (glazingPanes-Feature): O1 referenziert den Typ "window-standard" // (`glazing: "zweifach"` -> `glazingPanesOf` = 2). `glassPanesForOpening` // zeichnet jetzt render-wirksam so viele Scheiben, statt wie bisher immer // fix EINE — bewusste, korrekte Verhaltensänderung (Aufgabe: 2D/3D-Renderer // sollen die neuen Fenster-Verglasungsfelder konsumieren). const p: Project = { ...sampleProject, openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"), doors: sampleProject.doors, context: [], }; const { meshes } = projectToModel3d(p); // `meshes` enthält jetzt auch die glatte Betontreppen-Laufplatte (ST1, Mesh); // die Glasscheiben tragen die bläuliche Glasfarbe → danach filtern. const glassMeshes = meshes.filter( (m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85, ); expect(glassMeshes.length).toBe(2); // Vertikale Ausdehnung (z = 3. Komponente jeder Ecke, MODELL-Höhe) — BEIDE // Scheiben sitzen INNERHALB desselben Rahmens (Schwelle/Sturz ziehen je // frameWidth ab): O1 sillHeight 0.9, height 1.2, Wand-UK 0 -> Öffnung UK // 0.9 .. OK 2.1, Rahmen 0.06 -> Scheiben 0.96 .. 2.04. for (const glass of glassMeshes) { // Quader: 8 Ecken (24 Floats) + 12 Dreiecke (36 Indizes), leicht bläuliches Glas. expect(glass.positions.length).toBe(24); expect(glass.indices.length).toBe(36); expect(glass.color).toEqual([0.62, 0.76, 0.85]); const zs: number[] = []; for (let i = 2; i < glass.positions.length; i += 3) zs.push(glass.positions[i]); expect(Math.min(...zs)).toBeCloseTo(0.96, 6); expect(Math.max(...zs)).toBeCloseTo(2.04, 6); } // Quer zur Wand (y-Achse, da W1 entlang x läuft) sitzen die beiden Scheiben // MIT Luftraum versetzt (Isolierverglasung) — sie überlappen sich nicht. const yRange = (m: { positions: number[] }): { min: number; max: number } => { let min = Infinity; let max = -Infinity; for (let i = 1; i < m.positions.length; i += 3) { min = Math.min(min, m.positions[i]); max = Math.max(max, m.positions[i]); } return { min, max }; }; const [a, b] = glassMeshes.map(yRange).sort((r1, r2) => r1.min - r2.min); expect(a.max).toBeLessThanOrEqual(b.min + 1e-9); }); it("Türen bekommen keine Scheibe; je Fenster genau 2 (window-standard = zweifach verglast)", () => { // Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" = // Zweifachverglasung) + 1 Tür (D1), Kontext leer. Neben den Glasscheiben // liegt jetzt auch das Betontreppen-Mesh in `meshes` → nach Glasfarbe // filtern: 2 Fenster × 2 Scheiben = 4, Tür ohne Scheibe. const { meshes } = projectToModel3d(sampleProject); const glassMeshes = meshes.filter( (m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85, ); expect(glassMeshes.length).toBe(4); }); it("glazingPanes: 3 erzeugt mehr Glas-Meshes als glazingPanes: 1 (glazingPanesOf)", () => { const glassMeshCount = (glazingPanes: 1 | 2 | 3): number => { const p: Project = { ...sampleProject, windowTypes: [ { id: "win-panes-test", name: "Test Verglasung", kind: "fest", wingCount: 1, glazing: "einfach", glazingPanes, frameThickness: 0.06, frameWidth: 0.06, defaultSillHeight: 0.9, defaultWidth: 1.0, defaultHeight: 1.2, }, ], openings: [ { id: "OG", type: "opening", hostWallId: "W1", categoryCode: "21", kind: "window", typeId: "win-panes-test", position: 3.0, width: 1.0, height: 1.2, sillHeight: 0.9, }, ], doors: [], context: [], }; const { meshes } = projectToModel3d(p); return meshes.filter((m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85) .length; }; expect(glassMeshCount(1)).toBe(1); expect(glassMeshCount(3)).toBe(3); expect(glassMeshCount(3)).toBeGreaterThan(glassMeshCount(1)); }); }); describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", () => { // Farbfilter analog GLASS_RGB oben: FRAME_RGB (toWalls3d.ts) ist bewusst NICHT // exportiert -> Tests filtern per Literalwert, wie es der bestehende Glas-Test // schon für GLASS_RGB tut. const isFrame = (m: { color: [number, number, number] }): boolean => m.color[0] === 0.85 && m.color[1] === 0.85 && m.color[2] === 0.83; const isGlass = (m: { color: [number, number, number] }): boolean => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85; const zRange = (m: { positions: number[] }): { min: number; max: number } => { const zs: number[] = []; for (let i = 2; i < m.positions.length; i += 3) zs.push(m.positions[i]); return { min: Math.min(...zs), max: Math.max(...zs) }; }; it("typisiertes Fenster (O1, Typ window-standard) -> Rahmen-Riegel mit endlichen Positionen innerhalb der Öffnungshöhe", () => { // O1: sillHeight 0.9, height 1.2 -> Öffnung UK 0.9 .. OK 2.1. Typ "window- // standard" hat wingCount 1, kein mullionRows/transomHeight -> nur die 4 // Basis-Riegel (2 Pfosten + Sturz + Schwelle), keine Sprossen/Kämpfer. const p: Project = { ...sampleProject, openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"), doors: [], context: [], }; const { meshes } = projectToModel3d(p); const frames = meshes.filter(isFrame); expect(frames.length).toBe(4); for (const f of frames) { expect(f.positions.every((v) => Number.isFinite(v))).toBe(true); const { min, max } = zRange(f); expect(min).toBeGreaterThanOrEqual(0.9 - 1e-6); expect(max).toBeLessThanOrEqual(2.1 + 1e-6); } }); it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen, fein=+Mittelpfosten)", () => { // O1 mit erzwungenem wingCount 2 -> im FEIN-Fall ein Flügel-Mittelpfosten extra. const o1 = (sampleProject.openings ?? []).find((o) => o.id === "O1"); if (!o1) throw new Error("O1 fehlt im sampleProject"); const p: Project = { ...sampleProject, openings: [{ ...o1, wingCount: 2 }], doors: [], context: [], }; const frameCount = (detail: "grob" | "mittel" | "fein"): number => projectToModel3d(p, { detail }).meshes.filter(isFrame).length; expect(frameCount("grob")).toBe(0); // keine Rahmen/Sprossen expect(frameCount("mittel")).toBe(4); // 2 Pfosten + Sturz + Schwelle, KEIN Mittelpfosten expect(frameCount("fein")).toBe(5); // + 1 Flügel-Mittelpfosten (wingCount 2) }); it("transomHeight > 0 -> zusätzlicher Kämpfer-Riegel + zweite Glasscheibe im Oberlicht-Band", () => { const p: Project = { ...sampleProject, windowTypes: [ ...(sampleProject.windowTypes ?? []), { id: "win-transom-test", name: "Test Oberlicht", kind: "fest", wingCount: 1, glazing: "einfach", frameThickness: 0.06, frameWidth: 0.06, transomHeight: 0.4, defaultSillHeight: 0.9, defaultWidth: 1.0, defaultHeight: 1.2, }, ], openings: [ { id: "OT", type: "opening", hostWallId: "W1", categoryCode: "21", kind: "window", typeId: "win-transom-test", position: 3.0, width: 1.0, height: 1.2, sillHeight: 0.9, }, ], doors: [], context: [], }; const { meshes } = projectToModel3d(p); const frames = meshes.filter(isFrame); const glass = meshes.filter(isGlass); // 2 Pfosten + Sturz + Schwelle + Kämpfer = 5 Riegel (gegenüber 4 ohne Oberlicht). expect(frames.length).toBe(5); // Hauptscheibe (unterhalb des Kämpfers) + Oberlicht-Scheibe (oberhalb) = 2. expect(glass.length).toBe(2); // Kämpfer sitzt bei zTop - transomHeight = 2.1 - 0.4 = 1.7 und muss diese // Höhe im eigenen z-Intervall einschliessen. const splitZ = 1.7; const kaempfer = frames.find((f) => { const { min, max } = zRange(f); return min < splitZ && max > splitZ; }); expect(kaempfer).toBeDefined(); // Eine der beiden Scheiben liegt VOLLSTÄNDIG oberhalb des Kämpfers (Oberlicht). const hasUpperPane = glass.some((g) => zRange(g).min > splitZ); expect(hasUpperPane).toBe(true); }); it("wingCount=2 + mullionRows=2 -> je ein zusätzlicher Mittelpfosten (vertikal) und Kämpfer (horizontal)", () => { const baseline: Project = { ...sampleProject, openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"), doors: [], context: [], }; const withMullions: Project = { ...sampleProject, windowTypes: [ ...(sampleProject.windowTypes ?? []), { id: "win-mullion-test", name: "Test Sprossen", kind: "fest", wingCount: 2, mullionRows: 2, glazing: "einfach", frameThickness: 0.06, frameWidth: 0.06, defaultSillHeight: 0.9, defaultWidth: 1.0, defaultHeight: 1.2, }, ], openings: [ { id: "OM", type: "opening", hostWallId: "W1", categoryCode: "21", kind: "window", typeId: "win-mullion-test", position: 3.0, width: 1.0, height: 1.2, sillHeight: 0.9, }, ], doors: [], context: [], }; const baseCount = projectToModel3d(baseline).meshes.filter(isFrame).length; const mullionCount = projectToModel3d(withMullions).meshes.filter(isFrame).length; // +1 Mittelpfosten (wingCount-1) +1 Kämpfer-Zeile (mullionRows-1). expect(mullionCount).toBe(baseCount + 2); }); it("insetFromFace verschiebt Rahmen-Position entlang der Wand-Normalen gegenüber bündig (kein Einzug)", () => { // W1-Achse (0,0)->(5,0): u=(1,0) -> Normale n=(0,-1); der Quer-Versatz // bildet sich rein in der Y-Koordinate ab (y = -t). insetFromFace 0.1 muss // die Rahmen-Aussenkante (min y, s. resolveFrameNormalRange) um ~0.1 m // gegenüber bündig (insetFromFace 0) verschieben. const flushWindow = (project: Project): Project => ({ ...project, windowTypes: [ ...(project.windowTypes ?? []), { id: "win-flush-test", name: "Test bündig", kind: "fest", wingCount: 1, glazing: "einfach", frameThickness: 0.06, frameWidth: 0.06, insetFromFace: 0, insetFace: "aussen", defaultSillHeight: 0.9, defaultWidth: 1.0, defaultHeight: 1.2, }, ], }); const insetWindow = (project: Project): Project => ({ ...project, windowTypes: [ ...(project.windowTypes ?? []), { id: "win-inset-test", name: "Test Schichteinzug", kind: "fest", wingCount: 1, glazing: "einfach", frameThickness: 0.06, frameWidth: 0.06, insetFromFace: 0.1, insetFace: "aussen", defaultSillHeight: 0.9, defaultWidth: 1.0, defaultHeight: 1.2, }, ], }); const openingWithType = (typeId: string): Project["openings"] => [ { id: "OI", type: "opening", hostWallId: "W1", categoryCode: "21", kind: "window", typeId, position: 3.0, width: 1.0, height: 1.2, sillHeight: 0.9, }, ]; const pFlush: Project = { ...flushWindow(sampleProject), openings: openingWithType("win-flush-test"), doors: [], context: [] }; const pInset: Project = { ...insetWindow(sampleProject), openings: openingWithType("win-inset-test"), doors: [], context: [] }; const minY = (meshes: { positions: number[]; color: [number, number, number] }[]): number => { let m = Infinity; for (const mesh of meshes.filter(isFrame)) { for (let i = 1; i < mesh.positions.length; i += 3) m = Math.min(m, mesh.positions[i]); } return m; }; const flushMinY = minY(projectToModel3d(pFlush).meshes); const insetMinY = minY(projectToModel3d(pInset).meshes); // Bündig sitzt die Rahmen-Aussenkante weiter aussen (negativeres y); mit // Einzug rückt sie um insetFromFace (0.1 m) Richtung Wandmitte (grösseres y). expect(insetMinY - flushMinY).toBeCloseTo(0.1, 6); }); it("Öffnung ohne auflösbaren Typ -> KEIN Rahmen-Mesh (Rückwärtskompatibilität)", () => { const p: Project = { ...sampleProject, openings: [ { id: "OX", type: "opening", hostWallId: "W1", categoryCode: "21", kind: "window", // Kein typeId -> kein auflösbarer WindowType. position: 3.0, width: 1.0, height: 1.2, sillHeight: 0.9, }, ], doors: [], context: [], }; const { meshes } = projectToModel3d(p); expect(meshes.some(isFrame)).toBe(false); // Alt-Verhalten bleibt unverändert: weiterhin genau EINE Vollscheibe. expect(meshes.filter(isGlass).length).toBe(1); }); }); describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Boolean-Dominanz)", () => { // Sample-Projekt: W1-W4 (EG, Wandtyp "aw": render-ext 10, insulation 20, // brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1 // (EG, Deckentyp "dg-massiv", dominante Schicht "concrete" joinPriority 100) // hat OK bündig mit dem Wandkopf (zTop 2.6), Gesamtdicke 0.3 m -> zBottom 2.3. // // ENTSCHEIDEND (Nutzer-Bug „Kranz durch die Dämmung"): C1.outline ist der // Raumgrundriss auf den WAND-ACHSEN ((0,0)-(5,0)-(5,4)-(0,4)) — die Decke liegt // also nur auf dem INNEREN Halbquerschnitt jeder Aussenwand. Für W2 (Achse x=5) // liegen die Bandmittellinien bei x = 5 − offset: // render-ext x=5.1625 (AUSSEN, x>5) -> NICHT überdeckt -> läuft durch // insulation x=5.0725 (AUSSEN) -> NICHT überdeckt -> läuft durch // brick x=4.9175 (INNEN, x<5) -> überdeckt -> Decken-z-Schnitt // render-int x=4.8350 (INNEN) -> überdeckt -> Decken-z-Schnitt // Die Decke schneidet ein Band also nur dort, wo ihre Outline die Footprint- // Mittellinie DIESES Bandes überdeckt (geometrischer Überlapp wie // toSection.ts::subtractDominantBands) — NICHT mehr per wandweitem BBox-Test. // (Frühere Tests erwarteten hier „ALLE Schichten geschnitten"; das kodierte das // falsche BBox-Verhalten und ist die Ursache des 3D-Kranzes. Korrigiert.) it("Decke schneidet nur die von ihrer Outline überdeckten (inneren) Bänder; Dämmung/Aussenputz laufen durch; Beton spaltet den Backstein PER SCHICHT", () => { const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2"); // PER-SCHICHT-Decken-Dominanz (identisch zu toSection.ts::subtractDominantBands): // jede Deckenschicht (screed 30 / insulation 20 / concrete 100) schneidet nur // Wandbänder mit STRIKT niedrigerer Priorität in IHREM z-Band. Der Backstein (50) // wird also NUR von der Beton-Schicht (100, z-Band [2.3,2.5]) gekappt und läuft // darüber bis zum Wandkopf (2.6) durch -> ZWEI Boxen. So schneidet er im Schnitt // die schwimmenden Deckenschichten Estrich/Dämmung über sich weg (Nutzer-Bug). const zBoxes = (t: number) => w2.filter((s) => Math.abs(s.thickness - t) < 1e-6) .map((s) => [s.baseElevation, s.baseElevation + s.height] as const) .sort((a, b) => a[0] - b[0]); // brick: [0,2.3] (unter Beton) + [2.5,2.6] (über Beton, bis Wandkopf). const brick = zBoxes(0.15); expect(brick.length).toBe(2); expect(brick[0][1]).toBeCloseTo(2.3, 6); // endet an Beton-UK expect(brick[1][0]).toBeCloseTo(2.5, 6); // beginnt an Beton-OK expect(brick[1][1]).toBeCloseTo(2.6, 6); // läuft bis Wandkopf durch // render-int (10 < allen Deckenschichten): über [2.3,2.6] komplett gekappt -> [0,2.3]. const renderInt = zBoxes(0.015); expect(renderInt.length).toBe(1); expect(renderInt[0][1]).toBeCloseTo(2.3, 6); // AUSSEN liegende Schichten (Mittellinie ausserhalb der Decken-Outline): KEIN // Schnitt -> volle Höhe (behobener „Kranz"-Bug, wie im 2D-Schnitt). expect(zBoxes(0.16)).toEqual([[0, 2.6]]); // insulation läuft durch expect(zBoxes(0.02)).toEqual([[0, 2.6]]); // render-ext (Aussenputz) läuft durch expect(w2.length).toBe(5); // render-ext + insulation + brick(2) + render-int }); it("PER-SCHICHT: nur überdeckte Bänder mit < Decken-Priorität werden geschnitten (Kern >= Prio läuft durch)", () => { // Deckentyp-Priorität (concrete) auf 40 senken. Von den ÜBERDECKTEN (inneren) // Bändern läuft brick (50 > 40) jetzt bis zum Wandkopf (2.6) durch; render-int // (10 < 40) wird im Decken-z-Band [2.3,2.6] geschnitten -> endet bei 2.3. Die // AUSSEN liegenden Bänder (insulation, render-ext) sind ohnehin nicht überdeckt // und laufen unabhängig von der Priorität durch (2.6). Priorität ALLEIN genügt // also nicht — es braucht zusätzlich den geometrischen Überlapp (wie die // „gestaffelte Kette" in toSection.boolean.test.ts, wo Beton die Dämmung trotz // höherer Prio NICHT schneidet, weil sie sich nicht überlappen). const p: Project = { ...sampleProject, components: sampleProject.components.map((c) => c.id === "concrete" ? { ...c, joinPriority: 40 } : c, ), }; const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2"); expect(w2.length).toBe(4); // je 1 Box pro Band (Wand ragt nicht über die Decke) const top = (t: number) => { const b = w2.find((s) => Math.abs(s.thickness - t) < 1e-6)!; return b.baseElevation + b.height; }; expect(top(0.15)).toBeCloseTo(2.6, 6); // brick (überdeckt, 50 > 40) läuft durch expect(top(0.015)).toBeCloseTo(2.3, 6); // render-int (überdeckt, 10 < 40) geschnitten expect(top(0.16)).toBeCloseTo(2.6, 6); // insulation (nicht überdeckt) läuft durch expect(top(0.02)).toBeCloseTo(2.6, 6); // render-ext (nicht überdeckt) läuft durch }); it("Wand ragt über die Decke: überdecktes dominiertes Band spaltet vertikal, Aussenschichten bleiben ungeteilt", () => { // W2 auf 3.0 m erhöhen (über Decken-OK 2.6) und concrete-Priorität auf 25 // senken. Von den ÜBERDECKTEN Bändern spaltet render-int (10 < 25) vertikal in // ZWEI Boxen [0,2.3] + [2.6,3.0] (Decken-z-Band [2.3,2.6] ausgespart); brick // (50 > 25) läuft als EINE Box [0,3.0] durch. Die NICHT überdeckten Aussen- // schichten (insulation, render-ext) bleiben je EINE volle Box [0,3.0] — sie // werden NICHT vertikal aufgespalten (kein Kranz). const p: Project = { ...sampleProject, walls: sampleProject.walls.map((w) => (w.id === "W2" ? { ...w, height: 3.0 } : w)), components: sampleProject.components.map((c) => c.id === "concrete" ? { ...c, joinPriority: 25 } : c, ), }; const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2"); // render-int 2 Boxen + brick/insulation/render-ext je 1 Box = 5 Boxen. expect(w2.length).toBe(5); const zRanges = (t: number) => w2 .filter((s) => Math.abs(s.thickness - t) < 1e-6) .map((s) => [s.baseElevation, s.baseElevation + s.height] as const) .sort((a, b) => a[0] - b[0]); // brick (überdeckt, 50 > 25): EINE durchgehende Box. const brick = zRanges(0.15); expect(brick.length).toBe(1); expect(brick[0][0]).toBeCloseTo(0, 6); expect(brick[0][1]).toBeCloseTo(3.0, 6); // render-int (überdeckt, 10 < 25): ZWEI Boxen unter/über der Decke. const renderInt = zRanges(0.015); expect(renderInt.length).toBe(2); expect(renderInt[0][0]).toBeCloseTo(0, 6); expect(renderInt[0][1]).toBeCloseTo(2.3, 6); expect(renderInt[1][0]).toBeCloseTo(2.6, 6); expect(renderInt[1][1]).toBeCloseTo(3.0, 6); // insulation (nicht überdeckt): EINE volle Box, KEIN vertikaler Split. const insul = zRanges(0.16); expect(insul.length).toBe(1); expect(insul[0][0]).toBeCloseTo(0, 6); expect(insul[0][1]).toBeCloseTo(3.0, 6); // render-ext (nicht überdeckt): ebenfalls EINE volle Box. const renderExt = zRanges(0.02); expect(renderExt.length).toBe(1); expect(renderExt[0][0]).toBeCloseTo(0, 6); expect(renderExt[0][1]).toBeCloseTo(3.0, 6); }); it("Decke überdeckt die VOLLE Wanddicke -> jede Schicht PER SCHICHT gegen die Deckenschichten verschnitten", () => { // Gegenprobe: verbreitert man C1 so, dass die Outline die ganze W2 überdeckt // (Aussenkante x=5.1725 < 5.5), liegen ALLE Bandmittellinien innerhalb der Decke. // PER-SCHICHT (statt pauschal): Deckenschichten screed(30, z[2.54,2.6]), // insulation(20, z[2.5,2.54]), concrete(100, z[2.3,2.5]). // • render-ext/render-int (Prio 10 < allen): über [2.3,2.6] komplett gekappt -> [0,2.3]. // • brick (50): nur von concrete(100) gekappt -> [0,2.3] + [2.5,2.6]. // • insulation-Wand (20): von concrete(100) und screed(30) gekappt, aber NICHT // von der gleichrangigen Decken-insulation(20) -> [0,2.3] + [2.5,2.54] (Sliver, // wo gleichrangiges Material koexistiert — exakt wie „gleiche Prio koexistiert"). const p: Project = { ...sampleProject, ceilings: (sampleProject.ceilings ?? []).map((c) => c.id === "C1" ? { ...c, outline: [ { x: 0, y: -0.5 }, { x: 5.5, y: -0.5 }, { x: 5.5, y: 4.5 }, { x: 0, y: 4.5 }, ], } : c, ), }; const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2"); expect(w2.length).toBe(6); const zBoxes = (t: number) => w2.filter((s) => Math.abs(s.thickness - t) < 1e-6) .map((s) => [s.baseElevation, s.baseElevation + s.height] as const) .sort((a, b) => a[0] - b[0]); expect(zBoxes(0.02)).toEqual([[0, 2.3]]); // render-ext (10) voll gekappt expect(zBoxes(0.015)).toEqual([[0, 2.3]]); // render-int (10) voll gekappt const brick = zBoxes(0.15); // brick (50): nur Beton kappt expect(brick.length).toBe(2); expect(brick[0][1]).toBeCloseTo(2.3, 6); expect(brick[1][0]).toBeCloseTo(2.5, 6); expect(brick[1][1]).toBeCloseTo(2.6, 6); const insul = zBoxes(0.16); // insulation-Wand (20): Sliver [2.5,2.54] bleibt expect(insul.length).toBe(2); expect(insul[0][1]).toBeCloseTo(2.3, 6); expect(insul[1][0]).toBeCloseTo(2.5, 6); expect(insul[1][1]).toBeCloseTo(2.54, 6); }); it("Decke überdeckt nur EINEN TEIL der Wandlänge -> überdecktes Kern-Band spaltet ENTLANG DER ACHSE", () => { // C1.outline auf die halbe Länge kürzen (y in [0,2] statt [0,4]): die Decke // überdeckt die inneren W2-Bänder nur auf dem Achsenstück y in [0,2]. Das // überdeckte Kern-Band (brick) wird dort z-geschnitten und läuft im Rest der // Wandlänge (y > 2) über die volle Höhe durch — die Überdeckungsgrenze (y=2) // wird aus der Bandmittellinien-Abtastung per Bisektion scharf ermittelt. Die // Aussenschichten (nicht überdeckt) bleiben ganz. So wird auch eine Decke // korrekt behandelt, die nur einen Teil der Wandlänge trägt. const p: Project = { ...sampleProject, ceilings: (sampleProject.ceilings ?? []).map((c) => c.id === "C1" ? { ...c, outline: [{ x: 0, y: 0 }, { x: 5, y: 0 }, { x: 5, y: 2 }, { x: 0, y: 2 }] } : c, ), }; const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2"); // Per-Schicht-Decken-Dominanz + Achsen-Überdeckung: der Backstein (50) wird nur // von der Beton-Schicht (100) UND nur auf dem überdeckten Achsenstück y∈[~0.08,2] // gekappt. Dort entstehen ZWEI z-Boxen [0,2.3] + [2.5,2.6]; das unbedeckte Stück // y∈[2,~3.92] läuft als EINE volle Box [0,2.6] durch -> 3 Backstein-Boxen. // render-int (10 < Beton) spaltet in [0,2.3] (überdeckt) + [0,2.6] (unbedeckt) = 2. // Aussenschichten (insulation, render-ext) nicht überdeckt -> je 1 volle Box. expect(w2.length).toBe(7); const brick = w2 .filter((s) => Math.abs(s.thickness - 0.15) < 1e-6) .map((s) => ({ sy: s.start[1], zb: s.baseElevation, zt: s.baseElevation + s.height })) .sort((a, b) => a.zb - b.zb || a.sy - b.sy); expect(brick.length).toBe(3); // Überdecktes Stück y∈[~0.08,2]: untere Box [0,2.3] + obere Box [2.5,2.6]. const covered = brick.filter((b) => b.sy < 1.0); expect(covered.length).toBe(2); expect(covered[0].zt).toBeCloseTo(2.3, 6); // unter Beton expect(covered[1].zb).toBeCloseTo(2.5, 6); // über Beton expect(covered[1].zt).toBeCloseTo(2.6, 6); // Unbedecktes Stück y∈[2,~3.92]: EINE volle Box [0,2.6]. const uncovered = brick.filter((b) => b.sy > 1.5); expect(uncovered.length).toBe(1); expect(uncovered[0].zt).toBeCloseTo(2.6, 6); // insulation (aussen) bleibt EINE ungeteilte volle Box. const insul = w2.filter((s) => Math.abs(s.thickness - 0.16) < 1e-6); expect(insul.length).toBe(1); expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6); }); it("keine Subtraktion, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => { // W6 (OG) hat keine überlappende Decke im Sample-Projekt (C1 ist EG) -> // unverändertes Verhalten (volle wall.height). const w6 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W6"); expect(w6.length).toBeGreaterThan(0); for (const seg of w6) { expect(seg.height).toBeCloseTo(2.6, 6); } }); }); describe("Geschichteter Bodenaufbau Decke", () => { it("Decke -> je Schicht ein Slab, oben->unten gestapelt mit Bauteilfarbe/-schraffur", () => { // Deckentyp "dg-massiv": screed 0.06 (#c9c2b3), insulation 0.04 (#ffffff), // concrete 0.2 (#9aa0a6) — Reihenfolge OK->UK. C1: zTop 2.6, Gesamt 0.3 -> // zBottom 2.3. Proportional: Estrich 2.54..2.6, Dämmung 2.50..2.54, // Beton 2.30..2.50. Der 3D-Schnitt zeigt so den Aufbau geschichtet (wie 2D). const { slabs } = projectToModel3d(sampleProject); const c1 = slabs .filter((s) => s.ceilingId === "C1") .sort((a, b) => b.zTop - a.zTop); // oben zuerst expect(c1.length).toBe(3); // Oberste Schicht = Estrich (#c9c2b3), bündig mit OK der Decke (2.6). expect(c1[0].zTop).toBeCloseTo(2.6, 6); expect(c1[0].color[0]).toBeCloseTo(0xc9 / 255, 6); expect(c1[0].color[1]).toBeCloseTo(0xc2 / 255, 6); expect(c1[0].color[2]).toBeCloseTo(0xb3 / 255, 6); // Mittlere Schicht = Dämmung (#ffffff). expect(c1[1].color[0]).toBeCloseTo(1, 6); expect(c1[1].color[1]).toBeCloseTo(1, 6); expect(c1[1].color[2]).toBeCloseTo(1, 6); // Unterste Schicht = Betondecke (#9aa0a6), bündig mit UK der Decke (2.3). expect(c1[2].zBottom).toBeCloseTo(2.3, 6); expect(c1[2].color[0]).toBeCloseTo(0x9a / 255, 6); expect(c1[2].color[1]).toBeCloseTo(0xa0 / 255, 6); expect(c1[2].color[2]).toBeCloseTo(0xa6 / 255, 6); // Schichten schließen lückenlos aneinander (UK Schicht k == OK Schicht k+1). expect(c1[0].zBottom).toBeCloseTo(c1[1].zTop, 6); expect(c1[1].zBottom).toBeCloseTo(c1[2].zTop, 6); }); }); describe("sliceTermination: Wand endet an der Decke (Zuschnitt, nicht Priorität)", () => { // W2 (EG, aw) steht unter der Decke C1 (dg-massiv). Ohne Terminierung ("both") // spaltet der Beton (100) den Backstein (50) in [0,2.3] + [2.5,2.6] (heutiges, // prioritäts-korrektes Verhalten). "below" clippt die Wand an der Decken-UK → // nur die untere Backstein-Box [0,2.3] bleibt; "above" spiegelbildlich [2.5,2.6]. const withTermination = (mode: "both" | "below" | "above") => ({ ...sampleProject, walls: sampleProject.walls.map((w) => w.id === "W2" ? { ...w, sliceTermination: mode === "both" ? undefined : mode } : w, ), }); const brickBoxes = (mode: "both" | "below" | "above") => projectToWalls3d(withTermination(mode)) .filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.15) < 1e-6) .map((w) => [w.baseElevation, w.baseElevation + w.height] as const) .sort((a, b) => a[0] - b[0]); it('Default/"both": Backstein bleibt als ZWEI Boxen [0,2.3] + [2.5,2.6] (heutiges Verhalten)', () => { const brick = brickBoxes("both"); expect(brick.length).toBe(2); expect(brick[0][0]).toBeCloseTo(0, 6); expect(brick[0][1]).toBeCloseTo(2.3, 6); expect(brick[1][0]).toBeCloseTo(2.5, 6); expect(brick[1][1]).toBeCloseTo(2.6, 6); }); it('"below": NUR die untere Backstein-Box [0,2.3] (Wand endet an der Decke, kein Rest oben)', () => { const brick = brickBoxes("below"); expect(brick.length).toBe(1); expect(brick[0][0]).toBeCloseTo(0, 6); expect(brick[0][1]).toBeCloseTo(2.3, 6); // Auch der Innenputz (render-int, überdeckt) endet einheitlich an der Decken-UK 2.3. const renderInt = projectToWalls3d(withTermination("below")) .filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.015) < 1e-6); expect(renderInt.length).toBe(1); expect(renderInt[0].baseElevation + renderInt[0].height).toBeCloseTo(2.3, 6); // Aussenschichten (nicht überdeckt) laufen weiter voll durch (2.6). const insul = projectToWalls3d(withTermination("below")) .filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.16) < 1e-6); expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6); }); it('"above": NUR die obere Backstein-Box [2.5,2.6] (Brüstung/Attika von oben)', () => { const brick = brickBoxes("above"); expect(brick.length).toBe(1); expect(brick[0][0]).toBeCloseTo(2.5, 6); expect(brick[0][1]).toBeCloseTo(2.6, 6); }); }); describe("Schnitt-Schraffur-Orientierung: relativeToWall dreht bei Wand, nicht bei Decke (2D-Parität)", () => { // 2D-Referenz (verbindlich): splitWallLayers ruft resolveHatch(…, 90, …) (Wand // steht vertikal, relativeToWall-Muster drehen mit), splitSlabLayers ruft // resolveHatch(…, undefined, …) (Decke liegt, absoluter Winkel). Der 3D-Pfad muss // EXAKT diesen Endwinkel je Kontext liefern (statt des rohen Hatch-Winkels) — // sonst läuft z. B. die Boden-Dämmung im 3D-Schnitt horizontal statt vertikal. const wallInsul = () => projectToModel3d(sampleProject).walls.find( (w) => w.cut?.componentId === "insulation" && Math.abs(w.thickness - 0.16) < 1e-6, )!; const slabInsul = () => projectToModel3d(sampleProject).slabs.find((s) => s.cut?.componentId === "insulation")!; it("WAND-Dämmung: 3D-Hatch-Winkel == resolveHatch(sia-insulation, 90)", () => { const expected = resolveHatch(sampleProject, "sia-insulation", 90).angle; expect(wallInsul().hatch?.angle).toBeCloseTo(expected, 6); }); it("DECKEN-Dämmung: 3D-Hatch-Winkel == resolveHatch(sia-insulation, undefined) (absolut)", () => { const expected = resolveHatch(sampleProject, "sia-insulation", undefined).angle; expect(slabInsul().hatch?.angle).toBeCloseTo(expected, 6); }); it("Wand- und Decken-Dämmung unterscheiden sich um den Wandachsenwinkel (relativeToWall dreht 90°)", () => { // sia-insulation ist relativeToWall -> Wand-Winkel = roh − 90, Decke = roh. // Die Differenz ist exakt 90° (der Boden-Aufbau läuft dadurch quer zur Wand). const diff = (slabInsul().hatch!.angle - wallInsul().hatch!.angle + 360) % 360; expect(diff).toBeCloseTo(90, 6); }); it("WAND-Backstein (relativeToWall): 3D-Hatch-Winkel == resolveHatch(sia-brick, 90)", () => { const brick = projectToModel3d(sampleProject).walls.find( (w) => w.cut?.componentId === "brick" && Math.abs(w.thickness - 0.15) < 1e-6, )!; expect(brick.hatch?.angle).toBeCloseTo(resolveHatch(sampleProject, "sia-brick", 90).angle, 6); }); it("Musterlinien-Stärke kommt aus dem LineStyle des Hatch durch (2D-Parität)", () => { // sia-insulation nutzt lineStyleId "hatch-hair" (0.02 mm). Der 3D-Hatch muss die // resolveHatch-lineWeight (aus dem LineStyle) tragen — für Wand UND Decke gleich. const expected = resolveHatch(sampleProject, "sia-insulation", 90).lineWeight; expect(wallInsul().hatch?.lineWeight).toBeCloseTo(expected, 6); expect(slabInsul().hatch?.lineWeight).toBeCloseTo(expected, 6); expect(expected).toBeCloseTo(0.02, 6); // hatch-hair }); }); describe("Wand-Joins im 3D (L-/T-Stösse, identisch zum 2D-Grundriss/Schnitt)", () => { // Die per-Schicht-Cut-Distanzen aus computeJoins (model/joins.ts) werden im // layered-3D-Pfad auf die Band-Achsenlängen angewandt — dieselben Zahlen, die // generatePlan.ts über clippedBand auf die 2D-Polygone anwendet (Band-Mittellinie // an der Gehrungs-/Anschluss-Linie geschnitten). Alle Erwartungswerte unten sind // aus der Join-Geometrie des Sample-Projekts von Hand hergeleitet. it("(a) L-Ecke gleicher Wandtyp: jede Schicht individuell gehrt (aussen länger, Kern kürzer)", () => { // W2 (5,0)->(5,4), Typ "aw", KEINE Öffnung/Querwand -> genau 4 Schicht-Bänder. // Beide Enden sind rechtwinklige L-Ecken (mit W1 bei (5,0), W3 bei (5,4)): // 45°-Gehrung x+y=5 (Start) bzw. y=x-1 (Ende). Die Mittellinie einer Schicht // bei Querversatz `offset` trifft die Gehrung bei Achsenmeter `offset` (Start) // bzw. `4-offset` (Ende). Aussenschichten (negativer offset) laufen ÜBER die // Ecke hinaus (wrappen), Innenschichten (positiver offset) enden kürzer. // Achse u=(0,1) -> Box-Achsenanfang/-ende = start[1]/end[1]. // W2 steht unter der Decke C1: die Beton-Schicht (100) spaltet den Backstein (50) // z-mässig in [0,2.3] + [2.5,2.6] (per-Schicht-Decken-Dominanz) -> 5 Boxen. Die // Gehrung ist z-unabhängig, beide Backstein-Boxen tragen denselben Achsenschnitt. const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2"); expect(w2.length).toBe(5); const band = (t: number) => w2.find((s) => Math.abs(s.thickness - t) < 1e-9)!; // offset je Schicht (aw, center): -0.1625 / -0.0725 / 0.0825 / 0.165. const cases: Array<[number, number]> = [ [0.02, -0.1625], // render-ext (aussen) -> from=-0.1625, to=4.1625 [0.16, -0.0725], // insulation [0.15, 0.0825], // brick (Kern) [0.015, 0.165], // render-int (innen) -> from=0.165, to=3.835 (am kürzesten) ]; for (const [t, offset] of cases) { const b = band(t); expect(b.start[1]).toBeCloseTo(offset, 6); // Achsenanfang = offset expect(b.end[1]).toBeCloseTo(4 - offset, 6); // Achsenende = 4-offset } // Der Kern (brick) ist kürzer als die Aussenschicht (render-ext): die // per-Schicht-Gehrung ist also wirklich individuell (keine einheitliche Länge). const len = (t: number) => band(t).end[1] - band(t).start[1]; expect(len(0.15)).toBeLessThan(len(0.02)); expect(len(0.15)).toBeCloseTo(3.835, 6); // 3.9175 - 0.0825 expect(len(0.02)).toBeCloseTo(4.325, 6); // 4.1625 - (-0.1625) }); it("(b) T-Stoss: Abzweig-Kern läuft bis zur Rückgrat-Nahfläche durch, Putz-Band stoppt an der Wandfläche", () => { // Querwand W9 (2.4,0)->(2.4,4), Typ "iw" (render-int 0.015 / brick 0.12 / // render-int 0.015), stösst mittig auf W1 (unten) und W3 (oben) -> zwei // Mittelspannen-T-Stösse. W9-brick == W1/W3-Rückgrat (Komponente "brick", // Prio 50) -> MERGE: der brick-Kern sticht durch den 0.015-Nah-Putz der // Durchgangswand bis zu deren Rückgrat-Nahfläche, die render-int-Putzschichten // stoppen an der Wandoberfläche. Achse u=(0,1) -> Achsenmeter = start[1]/end[1]. // W9 (iw) steht ebenfalls unter der Decke C1: die Beton-Schicht (100) spaltet den // Kern-Backstein (50) z-mässig in [0,2.3] + [2.5,2.6] -> 2 Kern-Boxen + 2 Putz = 4. // Die Gehrung/T-Stoss-Achse ist z-unabhängig, beide Kern-Boxen identisch. const w9 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W9"); expect(w9.length).toBe(4); const brick = w9.find((s) => Math.abs(s.thickness - 0.12) < 1e-9)!; const renders = w9.filter((s) => Math.abs(s.thickness - 0.015) < 1e-9); expect(renders.length).toBe(2); // Putz stoppt an W1-Oberfläche (y=0.1725) bzw. W3-Oberfläche (y=3.8275). for (const r of renders) { expect(r.start[1]).toBeCloseTo(0.1725, 6); expect(r.end[1]).toBeCloseTo(3.8275, 6); } // Kern läuft 0.015 tiefer bis zur Rückgrat-Nahfläche (y=0.1575 bzw. 3.8425). expect(brick.start[1]).toBeCloseTo(0.1575, 6); expect(brick.end[1]).toBeCloseTo(3.8425, 6); expect(brick.start[1]).toBeLessThan(renders[0].start[1]); // Kern durchgesteckt }); it("(b) T-Stoss Durchgangswand: Nah-Putz wird über die Kernbreite aufgebrochen (Span-Cutout splittet das Band)", () => { // W1 (Durchgangswand) verliert den Nah-Putz (innerstes render-int-Band) über // die Achsen-Breite des durchstechenden W9-Kerns: das Band zerfällt entlang // der Achse in zwei Teilstücke [0.165..2.34] und [2.46..4.835] (Lücke = die // 0.12 m Kernbreite um x=2.4). W1-Achse u=(1,0) -> Achsenmeter = start[0]/end[0]. const w1 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W1"); const innerPlaster = w1 .filter((s) => Math.abs(s.thickness - 0.015) < 1e-9) .map((s) => [s.start[0], s.end[0]] as const) .sort((a, b) => a[0] - b[0]); expect(innerPlaster.length).toBe(2); // in zwei Teilstücke gebrochen // Lücke = Kernbreite-Projektion [2.34, 2.46] um die Querwand-Achse x=2.4. expect(innerPlaster[0][1]).toBeCloseTo(2.34, 6); // Ende Teilstück 1 expect(innerPlaster[1][0]).toBeCloseTo(2.46, 6); // Anfang Teilstück 2 }); it("(c) Join-verschobenes Band: Öffnungs-Löcher bleiben absolut korrekt positioniert (rebased)", () => { // W1 trägt Fenster O1 (Achsenintervall [3.0,4.0]). Das Aussenputz-Band ist an // den L-Ecken über die Achse hinaus verlängert (Achsenanfang < 0) — die Loch- // `from`/`to` sind auf DIESEN Box-Start umgerechnet. Rekonstruiert man die // absolute Achsenposition (Box-Achsenanfang + `from`), liegt das Fenster wieder // exakt auf [3.0,4.0], unabhängig von der Join-Verschiebung. W1-Achse u=(1,0). const w1 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W1"); const outer = w1.find((s) => Math.abs(s.thickness - 0.02) < 1e-9)!; expect(outer.start[0]).toBeLessThan(0); // Band über die Ecke hinaus verlängert const window = (outer.holes ?? []) .map((h) => [outer.start[0] + h.from, outer.start[0] + h.to] as const) .sort((a, b) => a[0] - b[0]) .find(([a]) => a > 2); // das Fenster O1 (das andere Loch ist die Tür D1) expect(window).toBeDefined(); expect(window![0]).toBeCloseTo(3.0, 6); expect(window![1]).toBeCloseTo(4.0, 6); }); it("Joins wirken NUR im layered-3D-Pfad, nicht im Schnitt-Pfad (layered=false unverändert)", () => { // Der Schnitt-Pfad (computeSection, layeredWalls:false) darf KEINE Join-Cuts // bekommen: W2 bleibt eine Voll-Box über die ganze Dicke, ungehrt (Achse // 0..4), damit toSection.ts::subtractDominantBands seine eigene Dominanz auf // Rohgeometrie anwendet. const w2 = projectToWalls3d(sampleProject, false).filter((w) => w.wallId === "W2"); expect(w2.length).toBe(1); // Voll-Box, keine Schicht-Bänder expect(w2[0].start[1]).toBeCloseTo(0, 6); // ungehrt: Achse 0..4 expect(w2[0].end[1]).toBeCloseTo(4, 6); }); }); describe("emitStairMeshes (Betontreppe: glatte Laufplatte als Mesh)", () => { it("gerade Betontreppe -> Mesh mit 8 Ecken je Tritt, glatte schräge Untersicht", () => { // Sample-Treppe ST1: Typ "stair-standard" (Tragart "beton"), gerade, // stepCount 15 -> ein konvexes Prisma je Tritt = 8 Ecken. Das Mesh landet in // model.meshes (kind "extrusion"). const model = projectToModel3d(sampleProject); const stairMesh = model.meshes.find((m) => m.positions.length / 3 === 8 * 15); expect(stairMesh).toBeTruthy(); const m = stairMesh!; // Indizes bilden Dreiecke (6 Quads * 2 Tris * 3 = 36 Indizes je Tritt). expect(m.indices.length).toBe(15 * 36); expect(m.indices.length % 3).toBe(0); // Keine NaN/Inf in der Geometrie. expect(m.positions.every((v) => Number.isFinite(v))).toBe(true); // Höhen (jede 3. Komponente) liegen zwischen Treppen-UK (~0) und -OK (~2.6). const heights = m.positions.filter((_, i) => i % 3 === 2); expect(Math.min(...heights)).toBeGreaterThanOrEqual(-1e-6); expect(Math.max(...heights)).toBeLessThanOrEqual(2.6 + 1e-6); // Alle Index-Werte referenzieren existierende Vertices. const vcount = m.positions.length / 3; expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true); }); }); describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => { // Dieselbe Terrakotta-Farbe wie ROOF_RGB in toWalls3d.ts — dient hier nur der // Isolation der Dach-Meshes von Kontext-/Öffnungs-/Treppen-Meshes im Test. const ROOF_RGB: [number, number, number] = [0.72, 0.45, 0.36]; const isRoofMesh = (m: { color: readonly number[] }) => m.color[0] === ROOF_RGB[0] && m.color[1] === ROOF_RGB[1] && m.color[2] === ROOF_RGB[2]; const baseRoof: Roof = { id: "R1", type: "roof", floorId: "eg", categoryCode: "35", outline: [ { x: 0, y: 0 }, { x: 6, y: 0 }, { x: 6, y: 4 }, { x: 0, y: 4 }, ], shape: "sattel", pitchDeg: 30, overhang: 0, ridgeAxis: "x", baseElevation: 3, // fix, unabhängig von Geschoss-Stapelung thickness: 0.3, }; it("Satteldach: Flächen + Giebel als EIN Roof-Mesh, First-Höhe passt zur Traufhöhe + ridgeHeight", () => { const project: Project = { ...sampleProject, roofs: [baseRoof] }; const { meshes } = projectToModel3d(project); const roofMeshes = meshes.filter(isRoofMesh); // Zwei Dachflächen (je ein Quad -> 2 Dreiecke) + zwei Giebel (je ein Dreieck) // landen in EINEM kombinierten Mesh (Flächen und Giebel bilden zusammen den // geschlossenen Dachkörper). expect(roofMeshes.length).toBe(1); const m = roofMeshes[0]; expect(m.positions.every((v) => Number.isFinite(v))).toBe(true); expect(m.indices.length % 3).toBe(0); expect(m.indices.length).toBeGreaterThan(0); const vcount = m.positions.length / 3; expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true); // Firsthöhe: eavesZ (baseElevation) + ridgeHeight aus roofGeometry. const g = roofGeometry(baseRoof, roofBaseElevation(project, baseRoof)); const expectedRidgeZ = roofBaseElevation(project, baseRoof) + g.ridgeHeight; const heights = m.positions.filter((_, i) => i % 3 === 2); expect(Math.max(...heights)).toBeCloseTo(expectedRidgeZ, 6); }); it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => { const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" }; const project: Project = { ...sampleProject, roofs: [walmRoof] }; const { meshes } = projectToModel3d(project); const roofMeshes = meshes.filter(isRoofMesh); expect(roofMeshes.length).toBe(1); const m = roofMeshes[0]; expect(m.positions.every((v) => Number.isFinite(v))).toBe(true); // Walm hat KEINE Giebel (gables: []); 2 Quader (je 2 Dreiecke) + 2 Dreiecke // (je 1 Dreieck) = 6 Dreiecke = 18 Indizes. expect(m.indices.length).toBe(18); const vcount = m.positions.length / 3; expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true); }); it("Projekt ohne Dächer emittiert keine Roof-Meshes (Rückwärtskompatibilität)", () => { // Explizit leeres roofs-Feld -> keine Roof-Meshes. const project: Project = { ...sampleProject, roofs: [] }; expect(projectToModel3d(project).meshes.filter(isRoofMesh).length).toBe(0); // Fehlt das Feld ganz (undefined), ebenfalls keine. const noField: Project = { ...sampleProject }; delete (noField as { roofs?: unknown }).roofs; expect(projectToModel3d(noField).meshes.filter(isRoofMesh).length).toBe(0); // sampleProject enthält jetzt ein Demo-Satteldach (RF1) -> genau ein Roof-Mesh. expect(projectToModel3d(sampleProject).meshes.filter(isRoofMesh).length).toBe(1); }); });