diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 2b6148c..4777888 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -406,7 +406,7 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( } }); - it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen, fein=+Mittelpfosten+Flügelrahmen)", () => { + it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen+Flügel, fein=+Mittelpfosten+Sprossen)", () => { // O1 mit erzwungenem wingCount 2 (window-standard "drehkipp", öffenbar). const o1 = (sampleProject.openings ?? []).find((o) => o.id === "O1"); if (!o1) throw new Error("O1 fehlt im sampleProject"); @@ -419,9 +419,10 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( const frameCount = (detail: "grob" | "mittel" | "fein"): number => projectToModel3d(p, { detail }).meshes.filter(isFrame).length; expect(frameCount("grob")).toBe(0); // keine Rahmen/Sprossen - expect(frameCount("mittel")).toBe(4); // 2 Pfosten + Sturz + Schwelle (keine Sprossen/Flügelrahmen) - // fein: 4 Basis + 1 Mittelpfosten (wingCount 2) + 2 öffenbare Flügel × 4 - // Flügelrahmen-Riegel = 4 + 1 + 8 = 13. + // mittel: 4 Basis (2 Pfosten + Sturz + Schwelle) + 2 öffenbare Flügel × 4 + // verschachtelte Flügelrahmen-Riegel = 4 + 8 = 12 (keine Mittelpfosten/Sprossen). + expect(frameCount("mittel")).toBe(12); + // fein: mittel + 1 Flügel-Mittelpfosten (wingCount 2) = 12 + 1 = 13. expect(frameCount("fein")).toBe(13); }); @@ -753,6 +754,171 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( }); }); +describe("VW-fein-Öffnungsdetails: Fensterbank, DIN-Symbole, verschachtelte Flügelrahmen, Zargen-Umgriff", () => { + const isFrame = (m: { color: [number, number, number] }): boolean => + m.color[0] === 0.85 && m.color[1] === 0.85 && m.color[2] === 0.83; + // SILL_RGB [0.8,0.8,0.78] und SYMBOL_RGB [0.25,0.27,0.3] sind bewusst nicht + // exportiert -> Filter per Literalwert, wie bei GLASS_RGB/FRAME_RGB. + const isSill = (m: { color: [number, number, number] }): boolean => + m.color[0] === 0.8 && m.color[1] === 0.8 && m.color[2] === 0.78; + const isSymbol = (m: { color: [number, number, number] }): boolean => + m.color[0] === 0.25 && m.color[1] === 0.27 && m.color[2] === 0.3; + + // Ein Fenster (O1-Geometrie) mit gegebenem Fenstertyp auf W1. + const winProject = ( + wt: NonNullable[number], + ): Project => ({ + ...sampleProject, + windowTypes: [...(sampleProject.windowTypes ?? []), wt], + openings: [ + { + id: "OS", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + typeId: wt.id, + position: 3.0, + width: 1.0, + height: 1.2, + sillHeight: 0.9, + }, + ], + doors: [], + context: [], + }); + + const baseWin = { + id: "win-detail-test", + name: "Test Detail", + kind: "fest" as const, + wingCount: 1, + glazing: "einfach" as const, + frameThickness: 0.06, + frameWidth: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.0, + defaultHeight: 1.2, + }; + + it("Fensterbank (Sims): 2 Bänder (Bank + Tropfkante) unter der Öffnung, Gating sillBoard + DetailLevel", () => { + // Default (kein sillBoard) -> zeichnen (wie 2D-Ansicht): 2 Sims-Bänder. + const pDefault = winProject({ ...baseWin }); + const sills = projectToModel3d(pDefault).meshes.filter(isSill); + expect(sills.length).toBe(2); + // Beide Bänder liegen UNTER der Öffnungs-UK (sillHeight 0.9). + for (const s of sills) { + for (let i = 2; i < s.positions.length; i += 3) { + expect(s.positions[i]).toBeLessThanOrEqual(0.9 + 1e-6); + } + } + // sillBoard-Gating: "keine"/"innen" -> keine Bank; "aussen"/"beide" -> Bank. + const count = (sb: "keine" | "innen" | "aussen" | "beide"): number => + projectToModel3d(winProject({ ...baseWin, id: `win-sb-${sb}`, sillBoard: sb })).meshes.filter(isSill).length; + expect(count("keine")).toBe(0); + expect(count("innen")).toBe(0); + expect(count("aussen")).toBe(2); + expect(count("beide")).toBe(2); + // DetailLevel: grob -> keine Bank; mittel/fein -> Bank. + const byDetail = (detail: "grob" | "mittel" | "fein"): number => + projectToModel3d(pDefault, { detail }).meshes.filter(isSill).length; + expect(byDetail("grob")).toBe(0); + expect(byDetail("mittel")).toBe(2); + expect(byDetail("fein")).toBe(2); + }); + + it("DIN-Öffnungssymbole: nur fein, nur je NICHT-festem Flügel; Dreh=V, Kipp=V, Dreh-Kipp=beide", () => { + // Dreh-Kipp (öffenbar): Dreh (2 Diagonalen) + Kipp (2 Diagonalen) = 4 Symbol-Boxen. + const pDrehkipp = winProject({ ...baseWin, id: "win-drehkipp", kind: "drehkipp" }); + expect(projectToModel3d(pDrehkipp).meshes.filter(isSymbol).length).toBe(4); + // Dreh -> 2, Kipp -> 2. + expect( + projectToModel3d(winProject({ ...baseWin, id: "win-dreh", kind: "dreh" })).meshes.filter(isSymbol).length, + ).toBe(2); + expect( + projectToModel3d(winProject({ ...baseWin, id: "win-kipp", kind: "kipp" })).meshes.filter(isSymbol).length, + ).toBe(2); + // Fest -> kein Symbol. + expect( + projectToModel3d(winProject({ ...baseWin, id: "win-fest2", kind: "fest" })).meshes.filter(isSymbol).length, + ).toBe(0); + // Symbole nur bei „fein" (mittel/grob: keine). + expect(projectToModel3d(pDrehkipp, { detail: "mittel" }).meshes.filter(isSymbol).length).toBe(0); + expect(projectToModel3d(pDrehkipp, { detail: "grob" }).meshes.filter(isSymbol).length).toBe(0); + // Symbol-Boxen sind sehr dünn (endliche, gültige Positionen) und liegen im + // Öffnungs-Höhenintervall (UK 0.9 .. OK 2.1). + for (const sym of projectToModel3d(pDrehkipp).meshes.filter(isSymbol)) { + expect(sym.positions.every((v) => Number.isFinite(v))).toBe(true); + for (let i = 2; i < sym.positions.length; i += 3) { + expect(sym.positions[i]).toBeGreaterThanOrEqual(0.9 - 1e-6); + expect(sym.positions[i]).toBeLessThanOrEqual(2.1 + 1e-6); + } + } + }); + + it("Verschachtelter Flügelrahmen steht ~12 mm vor dem Blendrahmen (nicht mehr flach)", () => { + // Fest vs. öffenbar, sonst identisch (flush, frameThickness gleich). Der + // Flügelrahmen des öffenbaren Fensters sitzt in einem nach aussen (nMax) + // vorstehenden Band -> seine Aussenkante ragt 0.012 m weiter hinaus als der + // Blendrahmen (bei W1 in Richtung negatives world-y, s. Inset-Test). + const minY = (p: Project): number => { + let m = Infinity; + for (const mesh of projectToModel3d(p).meshes.filter(isFrame)) { + for (let i = 1; i < mesh.positions.length; i += 3) m = Math.min(m, mesh.positions[i]); + } + return m; + }; + const fixedMinY = minY(winProject({ ...baseWin, id: "win-proud-fixed", kind: "fest", sillBoard: "keine" })); + const openMinY = minY(winProject({ ...baseWin, id: "win-proud-open", kind: "drehkipp", sillBoard: "keine" })); + expect(fixedMinY - openMinY).toBeCloseTo(0.012, 6); + }); + + it("Tür-Zarge mit Umgriff: zarge -> 6 Bekleidungs-Riegel mehr als blockrahmen", () => { + const doorProject = (frameKind: "zarge" | "blockrahmen"): Project => ({ + ...sampleProject, + doorTypes: [ + { + id: `door-${frameKind}-test`, + name: `Test ${frameKind}`, + kind: "dreh", + leafCount: 1, + leafStyle: "glatt", + frameThickness: 0.05, + frameKind, + frameWidth: 0.06, + defaultWidth: 0.9, + defaultHeight: 2.0, + }, + ], + openings: [ + { + id: "DT", + type: "opening", + hostWallId: "W1", + categoryCode: "31", + kind: "door", + typeId: `door-${frameKind}-test`, + position: 3.0, + width: 0.9, + height: 2.0, + sillHeight: 0, + }, + ], + doors: [], + context: [], + }); + const frames = (frameKind: "zarge" | "blockrahmen"): number => + projectToModel3d(doorProject(frameKind)).meshes.filter(isFrame).length; + // Zarge: Basis-Rahmen (2 Pfosten + Sturz, kein threshold) + 2 Wandseiten × 3 + // Bekleidungs-Riegel = 3 + 6 = 9. Blockrahmen: nur Basis = 3. + expect(frames("blockrahmen")).toBe(3); + expect(frames("zarge")).toBe(9); + // Umgriff nur mittel/fein, nicht grob. + expect(projectToModel3d(doorProject("zarge"), { detail: "grob" }).meshes.filter(isFrame).length).toBe(0); + expect(projectToModel3d(doorProject("zarge"), { detail: "mittel" }).meshes.filter(isFrame).length).toBe(9); + }); +}); + 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 0de7ec2..7448d65 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -401,6 +401,22 @@ const GLASS_HALF_THICK = 0.01; 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]; +/** + * Heller Stein-/Beton-Grauton der Fensterbank (Sims/Tropfkante) — bewusst etwas + * dunkler/kühler als der Rahmen ({@link FRAME_RGB}), damit die Bank im 3D als + * eigenes Bauteil (nicht als Rahmenfortsatz) liest (s. Sims-Zweig in + * {@link frameMeshesForOpening}). + */ +const SILL_RGB: RRgb = [0.8, 0.8, 0.78]; +/** + * Dunkles Grau der DIN-Öffnungssymbole (dünne Diagonalen AUF dem Glas, wie in der + * 2D-Ansicht) — feine dunkle Linien auf der Scheibe (s. Symbol-Zweig in + * {@link frameMeshesForOpening}). Als sehr dünne, in der Öffnungsebene gedrehte + * Prismen ({@link openingPlaneBox}) knapp vor der Glasebene. + */ +const SYMBOL_RGB: RRgb = [0.25, 0.27, 0.3]; +/** Halbe Breite der Symbol-Diagonalen (Meter) — ~5 mm Gesamtbreite, feine Linie. */ +const SYMBOL_HALF_WIDTH = 0.0025; /** 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. */ @@ -1872,6 +1888,62 @@ function openingAxisBox( return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color }; } +/** + * Baut EIN sehr dünnes Prisma mit FREI orientierter Längsachse IN der Öffnungs- + * ebene — die diagonale Schwester von {@link openingAxisBox}. Die Längsachse + * verläuft vom Punkt `(u0,h0)` zum Punkt `(u1,h1)`, beide in Öffnungsebenen- + * koordinaten: `u` = Meter entlang der Wandrichtung (ab `wall.start`), `h` = + * absolute Welthöhe (Meter). Der Querschnitt spannt `±halfWidth` senkrecht zur + * Längsachse INNERHALB der Ebene (gedreht mit) und `[nBack,nFront]` entlang der + * Grundriss-Normalen (quer zur Wand, Tiefe). Damit lassen sich die schrägen + * DIN-Öffnungssymbole (Dreh-/Kipp-Diagonalen) als dünne, gedrehte dunkle Riegel + * AUF dem Glas erzeugen — der Renderer kennt nur Dreiecks-Meshes, keine Linien. + * `null` bei entarteter Länge (≤ EPS). 8 Ecken, dieselbe {@link OPENING_BOX_TRIS}- + * Topologie (doppelseitig gerendert ⇒ Winding egal; die drei Bits parametrisieren + * hier Endpunkt/Quer-Versatz/Tiefe). + */ +function openingPlaneBox( + wall: Wall, + u0: number, + h0: number, + u1: number, + h1: number, + halfWidth: number, + nFront: number, + nBack: number, + color: RRgb, +): RMesh | null { + const du = u1 - u0; + const dh = h1 - h0; + const segLen = Math.hypot(du, dh); + if (segLen < EPS) return null; + const dx = wall.end.x - wall.start.x; + const dy = wall.end.y - wall.start.y; + const wlen = Math.hypot(dx, dy); + if (wlen < 1e-9) return null; + const ux = dx / wlen; + const uy = dy / wlen; + // Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie openingAxisBox. + const nx = uy; + const ny = -ux; + // In der Ebene senkrecht zur Längsachse (u entlang Wand, h = Welthöhe): + // Rotation der normierten Richtung um 90°. `pu` wirkt auf die u-Achse (Wand), + // `ph` auf die Höhe — beide in Metern, orthonormal ⇒ echte `halfWidth`-Breite. + const pu = -dh / segLen; + const ph = du / segLen; + const positions: number[] = []; + for (let i = 0; i < 8; i++) { + const ui = i & 1 ? u1 : u0; + const hi = i & 1 ? h1 : h0; + const s = i & 2 ? 1 : -1; + const t = i & 4 ? nFront : nBack; + const u = ui + s * halfWidth * pu; + const h = hi + s * halfWidth * ph; + positions.push(wall.start.x + ux * u + nx * t, wall.start.y + uy * u + ny * t, h); + } + 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)); @@ -1967,6 +2039,18 @@ interface OpeningFrameParams { * (Schachtelung Blendrahmen → Flügelrahmen → Glas). Türen: leer. */ sashes: SashDef[]; + /** Halbe Wanddicke (Meter) — Bezug der Wand-Aussenfläche (+n) für Bank/Zargen-Umgriff. */ + halfThickness: number; + /** + * Fensterbank (Sims) zeichnen: nur Fenster mit `sillBoard` ≠ "keine"/"innen" + * (fehlt ⇒ zeichnen, wie in der 2D-Ansicht). Türen: nie. + */ + drawSill: boolean; + /** + * Zargen-Umgriff (Bekleidung VOR beiden Wandflächen) zeichnen: nur Türen mit + * `frameKind === "zarge"`. Blockrahmen bleibt bündig (false). + */ + zargeCladding: boolean; } function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null { @@ -1997,6 +2081,10 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening // Teilverglasung: glazingRatio (Glasanteil oben) bei Glasblatt; sonst voll. glazedFraction: Math.max(0.05, Math.min(1, dt.glazingRatio ?? 1)), sashes: [], + halfThickness, + drawSill: false, + // Zarge umgreift die Wand (Bekleidung auf beiden Flächen); Blockrahmen bleibt bündig. + zargeCladding: (dt.frameKind ?? "zarge") === "zarge", }; } const wt = getWindowType(project, op); @@ -2037,6 +2125,10 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening glazingPanes: glazingPanesOf(wt), glazedFraction: 1, sashes, + halfThickness, + // Fensterbank zeichnen, ausser explizit "keine"/"innen" (fehlt ⇒ zeichnen). + drawSill: wt.sillBoard !== "keine" && wt.sillBoard !== "innen", + zargeCladding: false, }; } @@ -2046,21 +2138,44 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening * 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}. + * bzw. Schwelle/Kämpfer. Alle Rahmen-Riegel in {@link FRAME_RGB}. + * + * DetailLevel-Staffelung (VW-Detailgrad „fein" als Referenz): + * • „grob" — wird gar nicht aufgerufen (s. {@link emitOpeningFrames}). + * • „mittel"— Blendrahmen + Oberlicht-Kämpfer + Flügelrahmen (verschachtelt) + + * Fensterbank/Zargen-Umgriff, aber KEINE Sprossen und KEINE DIN-Symbole. + * • „fein" — zusätzlich Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen) und + * die DIN-Öffnungssymbole ({@link openingPlaneBox}) auf dem Glas. */ function frameMeshesForOpening( wall: Wall, iv: { from: number; to: number }, vert: { zBottom: number; zTop: number }, params: OpeningFrameParams, - fine = true, + detail: DetailLevel = "fein", ): RMesh[] { const out: RMesh[] = []; + const fine = detail === "fein"; + const medium = detail === "mittel"; + const sashesOn = fine || medium; // Flügelrahmen: mittel + fein + const detailBodies = fine || medium; // Bank + Zargen-Umgriff: mittel + fein 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); + // Generischer Riegel mit frei wählbarem Quer-Band [nA,nB] und Farbe. + const pushC = ( + from: number, + to: number, + zBottom: number, + zTop: number, + nA: number, + nB: number, + color: RRgb, + ): void => { + const mesh = openingAxisBox(wall, from, to, zBottom, zTop, nA, nB, color); if (mesh) out.push(mesh); }; + // Blendrahmen-Riegel (volles Rahmen-Quer-Band nMin..nMax, Rahmenfarbe). + const push = (from: number, to: number, zBottom: number, zTop: number): void => + pushC(from, to, zBottom, zTop, params.nMin, params.nMax, FRAME_RGB); // 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); @@ -2099,27 +2214,107 @@ function frameMeshesForOpening( push(sashFrom, sashTo, center - fw / 2, center + fw / 2); } } - // Flügelrahmen je ÖFFENBAREM Flügel: ein Rahmen-Riegel-Ring (links/rechts/ - // oben/unten) INNERHALB des Blendrahmen-Felds, um `reveal` eingerückt - // (Schachtelung Blendrahmen → Flügelrahmen → Glas). Feste Flügel bekommen - // keinen — so unterscheidet sich fest von öffenbar auch im 3D. - if (params.sashes.length > 0 && sashTo - sashFrom > EPS && sashTop - sashBottom > EPS) { - const segs = resolveSashSpans(params.sashes, sashFrom, sashTo); - const reveal = fw * 0.5; - const sfw = fw * 0.7; - for (const seg of segs) { - const s = seg.sash; - if (s.kind !== "fluegel" || !s.opening || s.opening === "fest") continue; - const cf = seg.from + fw + reveal; - const ct = seg.to - fw - reveal; - const bz = sashBottom + reveal; - const tz = sashTop - reveal; - if (ct - cf <= EPS || tz - bz <= EPS) continue; - push(cf, cf + sfw, bz, tz); // linker Flügelholm - push(ct - sfw, ct, bz, tz); // rechter Flügelholm - push(cf, ct, tz - sfw, tz); // oberer Flügelriegel - push(cf, ct, bz, bz + sfw); // unterer Flügelriegel + } + // Flügelrahmen je ÖFFENBAREM Flügel: ein eigener Rahmen-Riegel-Ring (links/ + // rechts/oben/unten) INNERHALB des Blendrahmen-Felds, um `reveal` eingerückt + // (Schachtelung Blendrahmen → Flügelrahmen → Glas). Anders als bisher sitzt der + // Flügelrahmen in einem EIGENEN, nach aussen VORSTEHENDEN und flacheren Quer- + // Band (`sashBackN..sashFrontN`) — dadurch liest er sich als abgesetzter, + // verschachtelter Körper VOR dem Blendrahmen (VW „fein"), nicht mehr flach mit + // ihm verschmolzen. Zusätzlich (nur „fein") die DIN-Öffnungssymbole auf dem + // Glas. Feste Flügel bekommen weder Flügelrahmen noch Symbol. + if (sashesOn && params.sashes.length > 0 && sashTo - sashFrom > EPS && sashTop - sashBottom > EPS) { + const segs = resolveSashSpans(params.sashes, sashFrom, sashTo); + const reveal = fw * 0.5; + const sfw = fw * 0.7; + // Verschachteltes Flügelrahmen-Band: ~12 mm vor der Blendrahmen-Vorderkante + // (nMax = Aussenseite) und nur ~50 mm tief — abgesetzte, plastische Stufe. + const sashFrontN = params.nMax + 0.012; + const sashBackN = Math.max(params.nMin, params.nMax - 0.05); + // Glas-Vorderkante (s. glassPanesForOpening: n1 = nMax − 0.03); die Symbole + // liegen als ~2.5 mm dünne dunkle Prismen KNAPP davor (weiter aussen). + const glassFrontN = params.nMax - 0.03; + const symBackN = glassFrontN; + const symFrontN = glassFrontN + 0.003; + const pushSash = (from: number, to: number, zB: number, zT: number): void => + pushC(from, to, zB, zT, sashBackN, sashFrontN, FRAME_RGB); + const pushSymbol = (u0: number, h0: number, u1: number, h1: number): void => { + const mesh = openingPlaneBox(wall, u0, h0, u1, h1, SYMBOL_HALF_WIDTH, symFrontN, symBackN, SYMBOL_RGB); + if (mesh) out.push(mesh); + }; + for (const seg of segs) { + const s = seg.sash; + if (s.kind !== "fluegel" || !s.opening || s.opening === "fest") continue; + const cf = seg.from + fw + reveal; + const ct = seg.to - fw - reveal; + const bz = sashBottom + reveal; + const tz = sashTop - reveal; + if (ct - cf <= EPS || tz - bz <= EPS) continue; + pushSash(cf, cf + sfw, bz, tz); // linker Flügelholm + pushSash(ct - sfw, ct, bz, tz); // rechter Flügelholm + pushSash(cf, ct, tz - sfw, tz); // oberer Flügelriegel + pushSash(cf, ct, bz, bz + sfw); // unterer Flügelriegel + // DIN-Öffnungssymbole (nur „fein") auf dem Glasfeld INNERHALB des Flügel- + // rahmens — exakt wie in der 2D-Ansicht (s. toElevation): Dreh = V mit + // Spitze am Bandseiten-Mittel des Glasfelds, Kipp = V mit Spitze oben Mitte. + if (!fine) continue; + const gA = cf + sfw; + const gB = ct - sfw; + const gbz = bz + sfw; + const gtz = tz - sfw; + if (gB - gA <= EPS || gtz - gbz <= EPS) continue; + const kind = s.opening; + if (kind === "dreh" || kind === "drehkipp") { + const hingeLeft = (s.hingeSide ?? "left") === "left"; + const apexU = hingeLeft ? gA : gB; + const apexH = (gbz + gtz) / 2; + const farU = hingeLeft ? gB : gA; + pushSymbol(farU, gbz, apexU, apexH); + pushSymbol(farU, gtz, apexU, apexH); } + if (kind === "kipp" || kind === "drehkipp") { + const apexU = (gA + gB) / 2; + pushSymbol(gA, gbz, apexU, gtz); + pushSymbol(gB, gbz, apexU, gtz); + } + } + } + + // Fensterbank (Sims, VW „fein"/„mittel"): Bank-Quader unter der Öffnung, + // seitlich ~3 cm überstehend, nach aussen (Wand-Aussenfläche +halfThickness) + // auskragend, mit einer zweiten, dünneren Tropfkanten-Stufe an der Aussenkante + // darunter. Nur Fenster mit `params.drawSill` (sillBoard ≠ "keine"/"innen"). + if (detailBodies && params.drawSill) { + const ov = 0.03; // seitlicher Überstand je Seite + const proj = 0.04; // Auskragung nach aussen (vor die Wandfläche) + const under = 0.02; // Einbindung unter den Rahmen (in den Reveal) + const bankH = 0.04; // Bankdicke + const dripH = 0.02; // Höhe der Tropfkanten-Stufe + const dripW = 0.02; // Tiefe der Tropfkante an der Aussenkante + const outerN = params.halfThickness; // Wand-Aussenfläche + const bankTop = vert.zBottom; + const bankBottom = bankTop - bankH; + // Bank + pushC(iv.from - ov, iv.to + ov, bankBottom, bankTop, outerN - under, outerN + proj, SILL_RGB); + // Tropfkante (dünnere Stufe darunter, an der Aussenkante) + pushC(iv.from - ov, iv.to + ov, bankBottom - dripH, bankBottom, outerN + proj - dripW, outerN + proj, SILL_RGB); + } + + // Tür-Zarge mit Umgriff (VW): die Zarge umgreift die Wand — eine schmale + // Bekleidungs-Platte VOR beiden Wandflächen, als Ring (Pfosten links/rechts + + // Sturz) rund um die Öffnung, ~1.3 cm vor die Fläche vorstehend. Nur + // `frameKind === "zarge"`; Blockrahmen bleibt bündig (params.zargeCladding false). + if (detailBodies && params.zargeCladding) { + const proud = 0.013; // Vorstand vor die Wandfläche + const ext = 0.02; // Überstand auf die Wandfläche (über die Laibungskante hinaus) + const casW = fw; // Bekleidungsbreite (= Rahmenprofil) + for (const sign of [1, -1] as const) { + const faceN = sign * params.halfThickness; + const nA = faceN; + const nB = faceN + sign * proud; + pushC(iv.from - ext, iv.from + casW, vert.zBottom, vert.zTop + ext, nA, nB, FRAME_RGB); // Pfosten links + pushC(iv.to - casW, iv.to + ext, vert.zBottom, vert.zTop + ext, nA, nB, FRAME_RGB); // Pfosten rechts + pushC(iv.from - ext, iv.to + ext, vert.zTop - casW, vert.zTop + ext, nA, nB, FRAME_RGB); // Sturz } } return out; @@ -2295,7 +2490,6 @@ function emitOpeningGlass(project: Project): RMesh[] { function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMesh[] { // Grob: keine Rahmen/Sprossen — die Öffnung bleibt Loch + Scheibe (schlank). if (detail === "grob") return []; - const fine = detail === "fein"; const out: RMesh[] = []; for (const wall of project.walls) { for (const op of openingsOfWall(project, wall.id)) { @@ -2305,7 +2499,7 @@ function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMes if (vert.zTop - vert.zBottom <= EPS) continue; const params = resolveOpeningFrame(project, wall, op); if (!params) continue; - out.push(...frameMeshesForOpening(wall, iv, vert, params, fine)); + out.push(...frameMeshesForOpening(wall, iv, vert, params, detail)); } } return out;