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]); expect(glass.color).toEqual([0.62, 0.76, 0.85]);
// Vertikale Ausdehnung (z = 3. Komponente jeder Ecke, MODELL-Höhe): // 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[] = []; const zs: number[] = [];
for (let i = 2; i < glass.positions.length; i += 3) zs.push(glass.positions[i]); 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.min(...zs)).toBeCloseTo(0.96, 6);
expect(Math.max(...zs)).toBeCloseTo(2.1, 6); expect(Math.max(...zs)).toBeCloseTo(2.04, 6);
}); });
it("Türen bekommen keine Scheibe; je Fenster genau eine", () => { 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)", () => { 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, // 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 // brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1
+357 -48
View File
@@ -32,7 +32,7 @@
// Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle // Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle
// Dicke im neutralen Grundton (heutiges Fallback-Verhalten). // Dicke im neutralen Grundton (heutiges Fallback-Verhalten).
import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination } from "../model/types"; import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination, Opening } from "../model/types";
import { import {
getComponent, getComponent,
getWallType, getWallType,
@@ -42,6 +42,8 @@ import {
openingsOfWall, openingsOfWall,
getFloor, getFloor,
flattenCategories, flattenCategories,
getDoorType,
getWindowType,
} from "../model/types"; } from "../model/types";
import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall"; import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column"; import { columnFootprint } from "../geometry/column";
@@ -381,6 +383,15 @@ const COLUMN_RGB: RRgb = [0.7, 0.7, 0.72];
const GLASS_RGB: RRgb = [0.62, 0.76, 0.85]; const GLASS_RGB: RRgb = [0.62, 0.76, 0.85];
/** Halbe Dicke der Fensterscheibe (Meter) — sehr dünn, mittig in der Wand. */ /** Halbe Dicke der Fensterscheibe (Meter) — sehr dünn, mittig in der Wand. */
const GLASS_HALF_THICK = 0.01; const GLASS_HALF_THICK = 0.01;
/**
* Helles, warmes Rahmen-Grau (Zarge/Blendrahmen/Kämpfer/Sprossen) — bewusst
* deutlich heller als Wand ({@link WALL_RGB}) und klar von der bläulichen
* Scheibe ({@link GLASS_RGB}) unterscheidbar, damit der Rahmen im 3D als
* eigenes Bauteil lesbar ist (s. {@link emitOpeningFrames}).
*/
const FRAME_RGB: RRgb = [0.85, 0.85, 0.83];
/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */
const DEFAULT_FRAME_WIDTH = 0.06;
/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */ /** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */
const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255]; const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255];
/** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */ /** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */
@@ -1648,17 +1659,298 @@ function emitMeshes(project: Project): RMesh[] {
return out; return out;
} }
// Faces eines Boxen-Quaders als Dreiecks-Indizes (Ecken-Index-Bits: bit0 =
// Achse from/to, bit1 = Höhe UK/OK, bit2 = Quer-Versatz nMin/nMax). Doppelseitig
// gerendert (append_context_mesh) ⇒ Winding egal; je Fläche 2 Dreiecke, 6
// Flächen = 12 Dreiecke. Gemeinsame Topologie für Glasscheiben UND Rahmen-/
// Sprossen-Riegel (s. {@link openingAxisBox}).
const OPENING_BOX_TRIS: number[] = [
0, 1, 3, 0, 3, 2, // Quer-Versatz nMin
4, 5, 7, 4, 7, 6, // Quer-Versatz nMax
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
];
/** /**
* Emittiert je Fensteröffnung eine dünne Glasscheibe als rohes Quader-Mesh — * Baut EINEN geschlossenen Achsen-Quader (8 Ecken, 12 Dreiecke) für eine
* damit Fenster im 3D als Fenster lesbar sind statt als blosse Löcher in der * Öffnung — die gemeinsame Basis von {@link emitOpeningGlass} (Scheiben) und
* Wand. Nur `kind === "window"`; Türen bleiben offen (keine Scheibe). * {@link emitOpeningFrames} (Rahmen-/Sprossen-Riegel). Achsen-Intervall
* `[from,to]` (Meter ab `wall.start`, entlang der Wandrichtung u), Höhen-
* Intervall `[zBottom,zTop]` (absolute Weltmeter) und Quer-Versatz-Intervall
* `[nMin,nMax]` (Meter entlang der Grundriss-Normalen n=(uy,ux), dieselbe
* Konvention wie die bisherige Glas-Box — `nMin`/`nMax` MÜSSEN nicht sortiert
* übergeben werden, die Box spannt zwischen beiden Werten). `positions` in
* MODELL-Metern (x, y, z=Höhe) wie {@link emitMeshes}. Liefert `null` bei
* entarteten Massen (Achse/Höhe/Dicke ≤ EPS) statt eines Nullquaders.
*/
function openingAxisBox(
wall: Wall,
from: number,
to: number,
zBottom: number,
zTop: number,
nMin: number,
nMax: number,
color: RRgb,
): RMesh | null {
if (to - from <= EPS) return null;
if (zTop - zBottom <= EPS) return null;
if (Math.abs(nMax - nMin) <= EPS) return null;
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) return null;
const ux = dx / len;
const uy = dy / len;
// Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie pushSegment.
const nx = uy;
const ny = -ux;
const positions: number[] = [];
for (let i = 0; i < 8; i++) {
const s = i & 1 ? to : from;
const z = i & 2 ? zTop : zBottom;
const t = i & 4 ? nMax : nMin;
positions.push(wall.start.x + ux * s + nx * t, wall.start.y + uy * s + ny * t, z);
}
return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color };
}
/** Klemmt `v` auf `[lo,hi]`. */
function clampRange(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
/**
* Rahmen-Tiefe quer zur Wand (Meter): `frameThickness` des Typs, sonst dessen
* `frameDepth`, sonst die volle Wanddicke (Fallback, wenn der Typ keine
* brauchbare Tiefe nennt). Beide Typ-Felder sind wörtlich Meter ≤ 0 ⇒ ungültig
* (übersprungen, nächster Fallback greift).
*/
function resolveAcrossWallDepth(
frameThickness: number | undefined,
frameDepth: number | undefined,
wallThickness: number,
): number {
if (frameThickness !== undefined && frameThickness > EPS) return frameThickness;
if (frameDepth !== undefined && frameDepth > EPS) return frameDepth;
return wallThickness;
}
/**
* Quer-Versatz-Intervall `[nMin,nMax]` (Meter entlang der Wand-Normalen, s.
* {@link openingAxisBox}) des Rahmenkörpers — der 3D-Niederschlag des
* Schichteinzugs (`insetFromFace`/`insetFace`): die Rahmen-VORDERKANTE liegt
* bündig mit der gewählten Wandfläche, zurückversetzt um `insetFromFace`
* (0/fehlt ⇒ bündig), der Rahmenkörper erstreckt sich von dort aus `depth`
* Meter zur Wandmitte hin. "aussen" liegt an der POSITIVEN Normalen-Seite
* (kleinster Schicht-Offset in {@link pushSegment}, s. dessen Kommentar zur
* Aussenseiten-Konvention via `leftNormal`), "innen" an der negativen. Das
* Ergebnis wird auf die halbe Wanddicke geklemmt — ein Schichteinzug darf den
* Rahmen nie aus der Wand hinausschieben.
*/
function resolveFrameNormalRange(
halfThickness: number,
depth: number,
insetFromFace: number,
face: "aussen" | "innen",
): { nMin: number; nMax: number } {
const sign = face === "innen" ? -1 : 1;
const front = sign * (halfThickness - insetFromFace);
const back = front - sign * depth;
const a = clampRange(front, -halfThickness, halfThickness);
const b = clampRange(back, -halfThickness, halfThickness);
return { nMin: Math.min(a, b), nMax: Math.max(a, b) };
}
/**
* Aufgelöste Rahmen-/Sprossen-Parameter EINER Öffnung (Tür oder Fenster) aus
* ihrem {@link DoorType}/{@link WindowType} (via `getDoorType`/`getWindowType`).
* `null`, wenn kein Typ auflösbar ist (`Opening.typeId` fehlt/verwaist) — DANN
* emittieren weder {@link emitOpeningFrames} noch die Rahmen-/Sprossen-Logik in
* {@link emitOpeningGlass} irgendetwas (Rückwärtskompatibilität: unveränderte
* Alt-Darstellung, s. Moduldoc-Kommentar dort).
*/
interface OpeningFrameParams {
/** Sichtbare Rahmen-/Flügelprofil-Breite in der Öffnungsebene (Meter). */
frameWidth: number;
/** Rahmen-Tiefe quer zur Wand (Meter, informativ — steckt bereits in nMin/nMax). */
acrossWallDepth: number;
/** Quer-Versatz-Intervall des Rahmenkörpers (s. {@link resolveFrameNormalRange}). */
nMin: number;
nMax: number;
/** Oberlicht-Höhe (Meter); 0 = kein Oberlicht/Kämpfer. */
transomHeight: number;
/** Schwelle/Sohlbank zeichnen: Fenster immer, Tür nur mit `threshold`. */
hasSill: boolean;
/** Flügelzahl (vertikale Mittelpfosten = wingCount1); Türen immer 1. */
wingCount: number;
/** Kämpfer-Zeilen (horizontale Teilung = mullionRows1); Türen immer 1. */
mullionRows: number;
/** Ob eine Glasscheibe gezeichnet wird (Fenster immer, Tür nur `leafStyle: "glas"`). */
glazed: boolean;
}
function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null {
const wallThickness = wallThicknessSafe(project, wall);
const halfThickness = wallThickness / 2;
if (op.kind === "door") {
const dt = getDoorType(project, op);
if (!dt) return null;
const acrossWallDepth = resolveAcrossWallDepth(dt.frameThickness, dt.frameDepth, wallThickness);
const { nMin, nMax } = resolveFrameNormalRange(
halfThickness,
acrossWallDepth,
dt.insetFromFace ?? 0,
dt.insetFace ?? "aussen",
);
return {
frameWidth: dt.frameWidth ?? DEFAULT_FRAME_WIDTH,
acrossWallDepth,
nMin,
nMax,
transomHeight: Math.max(0, dt.transomHeight ?? 0),
hasSill: dt.threshold === true,
wingCount: 1,
mullionRows: 1,
glazed: dt.leafStyle === "glas",
};
}
const wt = getWindowType(project, op);
if (!wt) return null;
const acrossWallDepth = resolveAcrossWallDepth(wt.frameThickness, wt.frameDepth, wallThickness);
const { nMin, nMax } = resolveFrameNormalRange(
halfThickness,
acrossWallDepth,
wt.insetFromFace ?? 0,
wt.insetFace ?? "aussen",
);
return {
frameWidth: wt.frameWidth ?? DEFAULT_FRAME_WIDTH,
acrossWallDepth,
nMin,
nMax,
transomHeight: Math.max(0, wt.transomHeight ?? 0),
hasSill: true,
// Inline-Override `Opening.wingCount` gewinnt vor dem Typ-Default (analog
// `windowSymbol` im 2D, das dieselbe Priorität kennt), sonst Typ, sonst 1.
wingCount: Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1)),
mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)),
glazed: true,
};
}
/**
* Rahmen-/Sprossen-Riegel EINER Öffnung: Pfosten (links/rechts, volle
* Öffnungshöhe), Sturz (oben), Schwelle (nur wenn `params.hasSill`), optional
* ein Kämpfer-Riegel (Oberlicht-Trennung, `transomHeight>0`) sowie die
* Flügel-Mittelpfosten (`wingCount1`, vertikal) und Kämpfer-Zeilen
* (`mullionRows1`, horizontal) im Flügel-Bereich zwischen Schwelle/Sturz
* bzw. Schwelle/Kämpfer. Alle Riegel in {@link FRAME_RGB}.
*/
function frameMeshesForOpening(
wall: Wall,
iv: { from: number; to: number },
vert: { zBottom: number; zTop: number },
params: OpeningFrameParams,
): RMesh[] {
const out: RMesh[] = [];
const fw = params.frameWidth;
const push = (from: number, to: number, zBottom: number, zTop: number): void => {
const mesh = openingAxisBox(wall, from, to, zBottom, zTop, params.nMin, params.nMax, FRAME_RGB);
if (mesh) out.push(mesh);
};
// Pfosten (Laibungsseiten), über die volle Öffnungshöhe durchlaufend.
push(iv.from, iv.from + fw, vert.zBottom, vert.zTop);
push(iv.to - fw, iv.to, vert.zBottom, vert.zTop);
// Sturz (oberer Riegel).
push(iv.from + fw, iv.to - fw, vert.zTop - fw, vert.zTop);
// Schwelle: Fenster immer, Tür nur mit Bodenanschlag (threshold).
if (params.hasSill) push(iv.from + fw, iv.to - fw, vert.zBottom, vert.zBottom + fw);
// Flügel-/Sash-Bereich zwischen Schwelle und Sturz, ggf. durch den Kämpfer
// (Oberlicht-Trennung) oben beschnitten.
const sashFrom = iv.from + fw;
const sashTo = iv.to - fw;
const sashBottom = vert.zBottom + (params.hasSill ? fw : 0);
let sashTop = vert.zTop - fw;
if (params.transomHeight > EPS) {
const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTop);
push(sashFrom, sashTo, splitZ - fw / 2, splitZ + fw / 2); // Kämpfer-Riegel
sashTop = splitZ - fw / 2;
}
// Flügel-Mittelpfosten (vertikal, wingCount1) im Sash-Bereich.
if (params.wingCount > 1 && sashTo - sashFrom > EPS) {
const cell = (sashTo - sashFrom) / params.wingCount;
for (let i = 1; i < params.wingCount; i++) {
const center = sashFrom + i * cell;
push(center - fw / 2, center + fw / 2, sashBottom, sashTop);
}
}
// Kämpfer-Zeilen (horizontal, mullionRows1) im Sash-Bereich.
if (params.mullionRows > 1 && sashTop - sashBottom > EPS) {
const cell = (sashTop - sashBottom) / params.mullionRows;
for (let i = 1; i < params.mullionRows; i++) {
const center = sashBottom + i * cell;
push(sashFrom, sashTo, center - fw / 2, center + fw / 2);
}
}
return out;
}
/**
* Glasscheibe(n) EINER typisierten Öffnung (Fenster immer, Tür nur bei
* `params.glazed`): eine Hauptscheibe im Flügel-Bereich (zwischen Schwelle und
* Sturz bzw. Kämpfer), plus — bei `transomHeight>0` — eine ZWEITE Scheibe im
* Oberlicht-Band oberhalb des Kämpfers. Die Sprossen/Mittelpfosten aus
* {@link frameMeshesForOpening} liegen VOR dieser einen Hauptscheibe (keine
* Pro-Feld-Aufteilung — bewusst einfach gehalten, s. Datei-weite Erläuterung).
*/
function glassPanesForOpening(
wall: Wall,
iv: { from: number; to: number },
vert: { zBottom: number; zTop: number },
params: OpeningFrameParams,
): RMesh[] {
const out: RMesh[] = [];
const fw = params.frameWidth;
const sashFrom = iv.from + fw;
const sashTo = iv.to - fw;
const sashBottom = vert.zBottom + (params.hasSill ? fw : 0);
const sashTopFull = vert.zTop - fw;
const push = (zBottom: number, zTop: number): void => {
const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, params.nMin, params.nMax, GLASS_RGB);
if (mesh) out.push(mesh);
};
if (params.transomHeight > EPS) {
const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTopFull);
push(sashBottom, splitZ - fw / 2); // Hauptscheibe/-blatt unterhalb des Kämpfers.
push(splitZ + fw / 2, sashTopFull); // Oberlicht-Scheibe oberhalb des Kämpfers.
} else {
push(sashBottom, sashTopFull);
}
return out;
}
/**
* Emittiert je Fensteröffnung eine Glasscheibe (Türen nur bei glasfülligem
* Blatt, `leafStyle: "glas"`) als rohes Quader-Mesh — damit Fenster im 3D als
* Fenster lesbar sind statt als blosse Löcher in der Wand.
* *
* Für jede Scheibe wird ein geschlossener Quader (8 Ecken, 12 Dreiecke) gebaut: * OHNE auflösbaren Typ (`getWindowType`/`getDoorType` liefert `undefined`,
* • entlang der Wandachse: das geklemmte Öffnungs-Intervall (openingInterval), * z. B. `Opening.typeId` fehlt): unverändertes ALT-Verhalten — EINE Vollscheibe
* • vertikal: die absolute Öffnungs-Ausdehnung (openingVerticalExtent, also * über die geklemmte Öffnungs-Achse ({@link openingInterval}) und -Höhe
* UK = baseElevation+sillHeight … OK = +height, an den Wandkopf geklemmt) — * ({@link openingVerticalExtent}), quer ±{@link GLASS_HALF_THICK} mittig in der
* exakt der Bereich, den emitWall als Loch freilässt, * Wand (Rückwärtskompatibilität, s. Moduldoc-Kommentar).
* • quer zur Wand: ±GLASS_HALF_THICK um die Wandachse (mittig in der Dicke). *
* MIT Typ: die Scheibe(n) sitzen im Flügel-Bereich INNERHALB des Rahmens (s.
* {@link glassPanesForOpening}), quer entlang des über den Schichteinzug
* bestimmten Rahmen-Versatzes (s. {@link resolveFrameNormalRange}) statt fix
* mittig — bei Oberlicht (`transomHeight>0`) kommt eine zweite Scheibe im
* Oberlicht-Band dazu. Die zugehörigen Rahmen-/Sprossen-Riegel emittiert
* separat {@link emitOpeningFrames} (eigene Farbe, eigene Funktion — leichter
* einzeln zu testen/filtern als eine gemeinsame Emission).
* *
* `positions` liegen — wie bei {@link emitMeshes} (Terrain/Import) — in MODELL- * `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 * Metern als flaches (x, y, z=Höhe)-Array; render3d bildet sie selbst nach world
@@ -1667,46 +1959,60 @@ function emitMeshes(project: Project): RMesh[] {
* mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB. * mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB.
*/ */
function emitOpeningGlass(project: Project): RMesh[] { 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[] = []; const out: RMesh[] = [];
for (const wall of project.walls) { 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)) { for (const op of openingsOfWall(project, wall.id)) {
if (op.kind !== "window") continue; // Türen bekommen keine Scheibe.
const iv = openingInterval(wall, op); const iv = openingInterval(wall, op);
if (!iv) continue; if (!iv) continue;
const { zBottom, zTop } = openingVerticalExtent(project, wall, op); const vert = openingVerticalExtent(project, wall, op);
if (zTop - zBottom <= EPS) continue; if (vert.zTop - vert.zBottom <= EPS) continue;
const positions: number[] = []; if (op.kind === "window") {
// 8 Ecken: Achse s ∈ {from,to} × Höhe z ∈ {UK,OK} × Dicke t ∈ {,+}. const params = resolveOpeningFrame(project, wall, op);
for (let i = 0; i < 8; i++) { if (!params) {
const s = i & 1 ? iv.to : iv.from; // Kein Typ auflösbar -> Alt-Verhalten (fixe Vollscheibe, mittig).
const z = i & 2 ? zTop : zBottom; const mesh = openingAxisBox(
const t = i & 4 ? GLASS_HALF_THICK : -GLASS_HALF_THICK; wall,
const px = wall.start.x + ux * s + nx * t; iv.from,
const py = wall.start.y + uy * s + ny * t; iv.to,
// MODELL-Koordinaten (x, y, z=Höhe) wie emitMeshes. vert.zBottom,
positions.push(px, py, z); vert.zTop,
-GLASS_HALF_THICK,
GLASS_HALF_THICK,
GLASS_RGB,
);
if (mesh) out.push(mesh);
continue;
}
out.push(...glassPanesForOpening(wall, iv, vert, params));
} else {
// Tür: nur bei glasfülligem Blatt eine Scheibe (Alt-Verhalten: offen).
const params = resolveOpeningFrame(project, wall, op);
if (params?.glazed) out.push(...glassPanesForOpening(wall, iv, vert, params));
} }
out.push({ positions, indices: [...BOX_TRIS], kind: "imported", color: GLASS_RGB }); }
}
return out;
}
/**
* Emittiert je Öffnung MIT auflösbarem Typ ({@link getDoorType}/
* {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz,
* Schwelle, Kämpfer, Mittelpfosten — s. {@link frameMeshesForOpening}) als rohe
* Quader-Meshes in {@link FRAME_RGB}. Öffnungen OHNE auflösbaren Typ (`typeId`
* fehlt/verwaist) bekommen KEINEN Rahmen — exakt das Alt-Verhalten vor der
* Typ-Vertiefung (Rückwärtskompatibilität).
*/
function emitOpeningFrames(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const wall of project.walls) {
for (const op of openingsOfWall(project, wall.id)) {
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;
out.push(...frameMeshesForOpening(wall, iv, vert, params));
} }
} }
return out; return out;
@@ -1774,13 +2080,16 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R
// Decken + Treppen (Treppentritte als extrudierte Slabs) — so wird die Treppe // Decken + Treppen (Treppentritte als extrudierte Slabs) — so wird die Treppe
// auch im WASM/wgpu-Renderer sichtbar (der three.js-Pfad rendert sie separat). // auch im WASM/wgpu-Renderer sichtbar (der three.js-Pfad rendert sie separat).
slabs: [...emitSlabs(project), ...emitStairs(project)], slabs: [...emitSlabs(project), ...emitStairs(project)],
// Kontext-Meshes (Terrain/Import) + Fenster-Glasscheiben (eigenes Mesh je // Kontext-Meshes (Terrain/Import) + Fenster-/Tür-Glasscheiben (eigenes Mesh
// Fensteröffnung) + glatte Beton-Laufplatten (gerade Betontreppen als Mesh) // je Scheibe) + Rahmen-/Sprossen-Riegel typisierter Öffnungen (Zarge/
// — pickGeometry/Highlight nutzen `meshes` bewusst NICHT (man wählt Wand/ // Blendrahmen/Kämpfer/Mittelpfosten, s. emitOpeningFrames) + glatte Beton-
// Öffnung/Treppe; Treppen-Pick läuft über die pickGeometry-Boxen). // Laufplatten (gerade Betontreppen als Mesh) — pickGeometry/Highlight nutzen
// `meshes` bewusst NICHT (man wählt Wand/Öffnung/Treppe; Treppen-Pick läuft
// über die pickGeometry-Boxen).
meshes: [ meshes: [
...emitMeshes(project), ...emitMeshes(project),
...emitOpeningGlass(project), ...emitOpeningGlass(project),
...emitOpeningFrames(project),
...emitStairMeshes(project), ...emitStairMeshes(project),
], ],
}; };