diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 07b0da9..9c5cae9 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -1195,3 +1195,69 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => { expect(projectToModel3d(sampleProject).meshes.filter(isRoofMesh).length).toBe(1); }); }); + +describe("emitOpeningShading (Rollladen-/Sonnenschutzkasten als 3D-Box)", () => { + // SHADING_RGB in toWalls3d.ts — nur zur Isolation der Kasten-Meshes. + const isShadingMesh = (m: { color: readonly number[] }) => + m.color[0] === 0.88 && m.color[1] === 0.88 && m.color[2] === 0.9; + + const withShading = (create: boolean): Project => ({ + ...sampleProject, + windowTypes: [ + { + id: "win-shade", + name: "Test Rollladen", + kind: "fest", + wingCount: 1, + glazing: "einfach", + frameThickness: 0.06, + frameWidth: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.0, + defaultHeight: 1.2, + shading: create + ? { create: true, kind: "rollladen", box: { width: 0, height: 0.24, depth: 0.2 } } + : undefined, + }, + ], + openings: [ + { + id: "OS", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + typeId: "win-shade", + position: 3.0, + width: 1.0, + height: 1.2, + sillHeight: 0.9, + }, + ], + doors: [], + context: [], + }); + + it("shading.create -> genau eine Kasten-Box (Quader: 24 Floats, 36 Indizes)", () => { + const meshes = projectToModel3d(withShading(true)).meshes.filter(isShadingMesh); + expect(meshes.length).toBe(1); + expect(meshes[0].positions.length).toBe(24); + expect(meshes[0].indices.length).toBe(36); + // Der Kasten sitzt ÜBER der Öffnungs-OK (UK 0.9 + Höhe 1.2 = 2.1): alle + // z-Werte >= 2.1 (nur nach oben), OK bei 2.1 + 0.24 = 2.34. + const zs: number[] = []; + for (let i = 2; i < meshes[0].positions.length; i += 3) zs.push(meshes[0].positions[i]); + expect(Math.min(...zs)).toBeCloseTo(2.1, 6); + expect(Math.max(...zs)).toBeCloseTo(2.34, 6); + }); + + it("kein shading / create:false -> keine Kasten-Box", () => { + expect(projectToModel3d(withShading(false)).meshes.filter(isShadingMesh).length).toBe(0); + }); + + it("grob -> keine Kasten-Box (nur mittel/fein)", () => { + expect( + projectToModel3d(withShading(true), { detail: "grob" }).meshes.filter(isShadingMesh).length, + ).toBe(0); + }); +}); diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 723b315..8605a08 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -394,6 +394,8 @@ const GLASS_HALF_THICK = 0.01; * eigenes Bauteil lesbar ist (s. {@link emitOpeningFrames}). */ const FRAME_RGB: RRgb = [0.85, 0.85, 0.83]; +/** Heller Grauton für den Rollladen-/Sonnenschutzkasten (s. {@link emitOpeningShading}). */ +const SHADING_RGB: RRgb = [0.88, 0.88, 0.9]; /** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */ const DEFAULT_FRAME_WIDTH = 0.06; /** Warmer Terrakotta-Grauton für Dachflächen/-giebel (s. {@link emitRoofs}) — hebt das Dach klar von Wand/Decke/Kontext ab. */ @@ -2108,6 +2110,54 @@ function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMes return out; } +/** + * Rollladen-/Sonnenschutzkasten als 3D-Box über der Öffnung (nur Fenster mit + * `WindowType.shading.create`, nur mittel/fein). Ein heller Quader, aussen an + * der Aussenfläche (params.nMax) ansetzend und um `box.depth` nach aussen + * ragend, von der Öffnungs-OK um `box.height` nach oben. Breite = `box.width` + * (0/fehlt ⇒ Öffnungsbreite), mittig über der Öffnung. Pendant zur 2D-Kontur + * (`window-shading` in generatePlan). 3D vom Nutzer in der Tauri-App zu prüfen. + */ +function emitOpeningShading(project: Project, detail: DetailLevel = "fein"): RMesh[] { + if (detail === "grob") return []; + const out: RMesh[] = []; + for (const wall of project.walls) { + for (const op of openingsOfWall(project, wall.id)) { + if (op.kind !== "window") continue; + const wt = getWindowType(project, op); + if (!wt?.shading?.create) continue; + const iv = openingInterval(wall, op); + if (!iv) continue; + const vert = openingVerticalExtent(project, wall, op); + if (vert.zTop - vert.zBottom <= EPS) continue; + const params = resolveOpeningFrame(project, wall, op); + if (!params) continue; + const box = wt.shading.box; + const h = Math.max(0, box.height); + const d = Math.max(0, box.depth); + if (h <= EPS || d <= EPS) continue; + const span = box.width > 0 ? box.width : iv.to - iv.from; + const center = (iv.from + iv.to) / 2; + const from = center - span / 2; + const to = center + span / 2; + // Aussen an der Aussenfläche (nMax) ansetzen und um die Kastentiefe hinaus. + const nOuter = params.nMax; + const mesh = openingAxisBox( + wall, + from, + to, + vert.zTop, + vert.zTop + h, + nOuter, + nOuter + d, + SHADING_RGB, + ); + if (mesh) out.push(mesh); + } + } + return out; +} + /** * Nur die Wände (Rückwärts-Kompatibilität / bestehende Aufrufer). Zerlegt Wände * mit Öffnungen in Teilquader. @@ -2189,6 +2239,7 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R ...emitMeshes(project), ...emitOpeningGlass(project), ...emitOpeningFrames(project, detail), + ...emitOpeningShading(project, detail), ...emitStairMeshes(project), ...emitRoofs(project), ],