3D: Glasscheiben in Fenstern (AUDIT A5-Teilschritt)

Jede Fensteröffnung bekommt im 3D eine dünne, leicht bläuliche
Glasscheibe (RMesh, mittig in der Wanddicke, UK=baseElevation+sillHeight
bis OK), damit Fenster als Fenster lesbar sind statt als Löcher. Türen
bleiben offen. Nur meshes-Ebene → pickGeometry/Highlight unberührt.
This commit is contained in:
2026-07-04 12:53:01 +02:00
parent fde27f6838
commit c54a2f916c
2 changed files with 111 additions and 1 deletions
+39
View File
@@ -6,6 +6,7 @@
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import type { Project } from "../model/types";
import { sampleProject } from "../model/sampleProject"; import { sampleProject } from "../model/sampleProject";
import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d"; import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d";
@@ -117,6 +118,44 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
} }
}); });
});
describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
it("1 Fenster + 1 Tür -> genau 1 Glas-Mesh 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.
const p: Project = {
...sampleProject,
openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"),
doors: sampleProject.doors,
context: [],
};
const { meshes } = projectToModel3d(p);
// Genau EINE Scheibe: das Fenster O1; die Tür D1 bleibt offen (keine Scheibe).
expect(meshes.length).toBe(1);
const glass = meshes[0];
// 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]);
// Vertikale Ausdehnung (z = 3. Komponente jeder Ecke, MODELL-Höhe):
// O1 sillHeight 0.9, height 1.2, Wand-UK 0 -> UK 0.9 .. OK 2.1.
const zs: number[] = [];
for (let i = 2; i < glass.positions.length; i += 3) zs.push(glass.positions[i]);
expect(Math.min(...zs)).toBeCloseTo(0.9, 6);
expect(Math.max(...zs)).toBeCloseTo(2.1, 6);
});
it("Türen bekommen keine Scheibe; je Fenster genau eine", () => {
// Volles Sample-Projekt: 2 Fenster (O1, O2) + 1 Tür (D1), Kontext leer.
const { meshes } = projectToModel3d(sampleProject);
expect(meshes.length).toBe(2);
});
});
describe("Bauteil-/Materialfarbe Decke", () => {
it("Decke -> Farbe der dicksten Schicht (dg-massiv: concrete 0.2 m, #9aa0a6)", () => { it("Decke -> Farbe der dicksten Schicht (dg-massiv: concrete 0.2 m, #9aa0a6)", () => {
// Deckentyp "dg-massiv": screed 0.06, insulation 0.04, concrete 0.2 -> // Deckentyp "dg-massiv": screed 0.06, insulation 0.04, concrete 0.2 ->
// dickste Schicht = concrete (#9aa0a6). // dickste Schicht = concrete (#9aa0a6).
+72 -1
View File
@@ -127,6 +127,10 @@ const SLAB_RGB: RRgb = [0.86, 0.86, 0.88];
const TERRAIN_RGB: RRgb = [169 / 255, 176 / 255, 162 / 255]; const TERRAIN_RGB: RRgb = [169 / 255, 176 / 255, 162 / 255];
/** Neutrales Hellgrau für importierte Meshes (three.js mats.mesh = 0x9fa6ae). */ /** Neutrales Hellgrau für importierte Meshes (three.js mats.mesh = 0x9fa6ae). */
const IMPORTED_RGB: RRgb = [159 / 255, 166 / 255, 174 / 255]; const IMPORTED_RGB: RRgb = [159 / 255, 166 / 255, 174 / 255];
/** Helles, leicht bläuliches Fenster-Glas (Scheibe in der Öffnung). */
const GLASS_RGB: RRgb = [0.62, 0.76, 0.85];
/** Halbe Dicke der Fensterscheibe (Meter) — sehr dünn, mittig in der Wand. */
const GLASS_HALF_THICK = 0.01;
/** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */ /** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */
const EPS = 1e-4; const EPS = 1e-4;
@@ -378,6 +382,70 @@ function emitMeshes(project: Project): RMesh[] {
return out; return out;
} }
/**
* Emittiert je Fensteröffnung eine dünne Glasscheibe als rohes Quader-Mesh —
* damit Fenster im 3D als Fenster lesbar sind statt als blosse Löcher in der
* Wand. Nur `kind === "window"`; Türen bleiben offen (keine Scheibe).
*
* Für jede Scheibe wird ein geschlossener Quader (8 Ecken, 12 Dreiecke) gebaut:
* • entlang der Wandachse: das geklemmte Öffnungs-Intervall (openingInterval),
* • vertikal: die absolute Öffnungs-Ausdehnung (openingVerticalExtent, also
* UK = baseElevation+sillHeight … OK = +height, an den Wandkopf geklemmt) —
* exakt der Bereich, den emitWall als Loch freilässt,
* • quer zur Wand: ±GLASS_HALF_THICK um die Wandachse (mittig in der Dicke).
*
* `positions` liegen — wie bei {@link emitMeshes} (Terrain/Import) — in MODELL-
* Metern als flaches (x, y, z=Höhe)-Array; render3d bildet sie selbst nach world
* (x, Höhe, y) ab und rendert die Meshes doppelseitig (append_context_mesh),
* daher ist die Winding-Reihenfolge der Dreiecke unerheblich. `kind: "imported"`
* mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB.
*/
function emitOpeningGlass(project: Project): RMesh[] {
// Faces eines Boxen-Quaders als Dreiecks-Indizes (Ecken-Index-Bits:
// bit0 = Achse from/to, bit1 = Höhe UK/OK, bit2 = Dicke /+). Doppelseitig
// gerendert ⇒ Winding egal; je Fläche 2 Dreiecke, 6 Flächen = 12 Dreiecke.
const BOX_TRIS: number[] = [
0, 1, 3, 0, 3, 2, // Dicke
4, 5, 7, 4, 7, 6, // Dicke +
0, 1, 5, 0, 5, 4, // UK
2, 3, 7, 2, 7, 6, // OK
0, 2, 6, 0, 6, 4, // from
1, 3, 7, 1, 7, 5, // to
];
const out: RMesh[] = [];
for (const wall of project.walls) {
const dx = wall.end.x - wall.start.x;
const dy = wall.end.y - wall.start.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) continue;
const ux = dx / len;
const uy = dy / len;
// Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie pushSegment.
const nx = uy;
const ny = -ux;
for (const op of openingsOfWall(project, wall.id)) {
if (op.kind !== "window") continue; // Türen bekommen keine Scheibe.
const iv = openingInterval(wall, op);
if (!iv) continue;
const { zBottom, zTop } = openingVerticalExtent(project, wall, op);
if (zTop - zBottom <= EPS) continue;
const positions: number[] = [];
// 8 Ecken: Achse s ∈ {from,to} × Höhe z ∈ {UK,OK} × Dicke t ∈ {,+}.
for (let i = 0; i < 8; i++) {
const s = i & 1 ? iv.to : iv.from;
const z = i & 2 ? zTop : zBottom;
const t = i & 4 ? GLASS_HALF_THICK : -GLASS_HALF_THICK;
const px = wall.start.x + ux * s + nx * t;
const py = wall.start.y + uy * s + ny * t;
// MODELL-Koordinaten (x, y, z=Höhe) wie emitMeshes.
positions.push(px, py, z);
}
out.push({ positions, indices: [...BOX_TRIS], kind: "imported", color: GLASS_RGB });
}
}
return out;
}
/** /**
* Nur die Wände (Rückwärts-Kompatibilität / bestehende Aufrufer). Zerlegt Wände * Nur die Wände (Rückwärts-Kompatibilität / bestehende Aufrufer). Zerlegt Wände
* mit Öffnungen in Teilquader. * mit Öffnungen in Teilquader.
@@ -393,7 +461,10 @@ export function projectToModel3d(project: Project): RModel3d {
return { return {
walls: projectToWalls3d(project), walls: projectToWalls3d(project),
slabs: emitSlabs(project), slabs: emitSlabs(project),
meshes: emitMeshes(project), // Kontext-Meshes (Terrain/Import) + Fenster-Glasscheiben (eigenes Mesh je
// Fensteröffnung) — pickGeometry/Highlight nutzen `meshes` bewusst NICHT,
// daher sind die Scheiben nicht selektierbar (man wählt Wand/Öffnung).
meshes: [...emitMeshes(project), ...emitOpeningGlass(project)],
}; };
} }