Tür/Fenster 3D: Rahmen, Sprossen/Kämpfer, Oberlicht, Schichteinzug als Mesh

Emittiert die vertieften Tür-/Fensterparameter als wgpu-Geometrie:
- emitOpeningFrames: Rahmenriegel (Laibung + Sturz + Sohlbank),
  wingCount−1 senkrechte + mullionRows−1 waagrechte Sprossen, Kämpferbalken
  bei transomHeight>0. Rahmenfarbe hell-warmgrau [0.85,0.85,0.83].
- emitOpeningGlass erweitert: typisierte Öffnungen bekommen einzugs-/
  oberlicht-bewusste Scheiben (Haupt- + Oberlichtscheibe), typlose behalten
  die alte zentrierte Scheibe (Rückwärtskompatibilität).
- Schichteinzug: Rahmen/Glas entlang der Wandnormale ab der gewählten
  Fläche (aussen/innen) um insetFromFace versetzt, auf Wanddicke geklemmt.
+5 Tests. tsc sauber, vitest 614/614 grün.
This commit is contained in:
2026-07-09 01:15:59 +02:00
parent 142ba7ec20
commit 9a65900377
2 changed files with 605 additions and 51 deletions
+248 -3
View File
@@ -275,11 +275,14 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
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.
// O1 sillHeight 0.9, height 1.2, Wand-UK 0 -> Öffnung UK 0.9 .. OK 2.1.
// O1 referenziert den Typ "window-standard" (frameWidth 0.06, kein Oberlicht)
// -> die Scheibe sitzt jetzt INNERHALB des Rahmens (Schwelle/Sturz ziehen je
// frameWidth ab), s. emitOpeningFrames/glassPanesForOpening: 0.96 .. 2.04.
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);
expect(Math.min(...zs)).toBeCloseTo(0.96, 6);
expect(Math.max(...zs)).toBeCloseTo(2.04, 6);
});
it("Türen bekommen keine Scheibe; je Fenster genau eine", () => {
@@ -294,6 +297,248 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
});
});
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("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