diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 108b893..7f6f409 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -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 diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 41f7e56..39f43c9 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -32,7 +32,7 @@ // Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle // 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 { getComponent, getWallType, @@ -42,6 +42,8 @@ import { openingsOfWall, getFloor, flattenCategories, + getDoorType, + getWindowType, } from "../model/types"; import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall"; 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]; /** Halbe Dicke der Fensterscheibe (Meter) — sehr dünn, mittig in der Wand. */ 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"). */ 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. */ @@ -1648,17 +1659,298 @@ function emitMeshes(project: Project): RMesh[] { 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 — - * 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). + * Baut EINEN geschlossenen Achsen-Quader (8 Ecken, 12 Dreiecke) für eine + * Öffnung — die gemeinsame Basis von {@link emitOpeningGlass} (Scheiben) und + * {@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 = wingCount−1); Türen immer 1. */ + wingCount: number; + /** Kämpfer-Zeilen (horizontale Teilung = mullionRows−1); 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 (`wingCount−1`, vertikal) und Kämpfer-Zeilen + * (`mullionRows−1`, 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, wingCount−1) 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, mullionRows−1) 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: - * • 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). + * OHNE auflösbaren Typ (`getWindowType`/`getDoorType` liefert `undefined`, + * z. B. `Opening.typeId` fehlt): unverändertes ALT-Verhalten — EINE Vollscheibe + * über die geklemmte Öffnungs-Achse ({@link openingInterval}) und -Höhe + * ({@link openingVerticalExtent}), quer ±{@link GLASS_HALF_THICK} mittig in der + * Wand (Rückwärtskompatibilität, s. Moduldoc-Kommentar). + * + * 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- * 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. */ 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); + const vert = openingVerticalExtent(project, wall, op); + if (vert.zTop - vert.zBottom <= EPS) continue; + if (op.kind === "window") { + const params = resolveOpeningFrame(project, wall, op); + if (!params) { + // Kein Typ auflösbar -> Alt-Verhalten (fixe Vollscheibe, mittig). + const mesh = openingAxisBox( + wall, + iv.from, + iv.to, + vert.zBottom, + 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; @@ -1774,13 +2080,16 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R // Decken + Treppen (Treppentritte als extrudierte Slabs) — so wird die Treppe // auch im WASM/wgpu-Renderer sichtbar (der three.js-Pfad rendert sie separat). slabs: [...emitSlabs(project), ...emitStairs(project)], - // Kontext-Meshes (Terrain/Import) + Fenster-Glasscheiben (eigenes Mesh je - // Fensteröffnung) + glatte Beton-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). + // Kontext-Meshes (Terrain/Import) + Fenster-/Tür-Glasscheiben (eigenes Mesh + // je Scheibe) + Rahmen-/Sprossen-Riegel typisierter Öffnungen (Zarge/ + // Blendrahmen/Kämpfer/Mittelpfosten, s. emitOpeningFrames) + glatte Beton- + // 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: [ ...emitMeshes(project), ...emitOpeningGlass(project), + ...emitOpeningFrames(project), ...emitStairMeshes(project), ], };