Sonnenschutz 3D: Rollladenkasten als Box über der Öffnung

emitOpeningShading zeichnet für Fenster mit shading.create eine helle 3D-Box
über der Öffnungs-OK, an der Aussenfläche ansetzend und um box.depth hinaus,
box.height hoch, box.width breit (0 ⇒ Öffnungsbreite). Nur mittel/fein.
Vervollständigt das Sonnenschutz-Feature (2D-Kontur war da, 3D-Box war in der
Studie 'später'). +3 Tests (Box-Primitive/Position, Leerfälle, grob).
3D visuell vom Nutzer in der Tauri-App zu prüfen. 662/662 grün.
This commit is contained in:
2026-07-10 01:23:39 +02:00
parent 82d5710ddb
commit d131683304
2 changed files with 117 additions and 0 deletions
+66
View File
@@ -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);
});
});
+51
View File
@@ -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),
],