diff --git a/src/geometry/opening.ts b/src/geometry/opening.ts index 746249e..ea772ba 100644 --- a/src/geometry/opening.ts +++ b/src/geometry/opening.ts @@ -11,7 +11,7 @@ // // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). -import type { Opening, Project, Vec2, Wall } from "../model/types"; +import type { Opening, Project, SashDef, Vec2, Wall } from "../model/types"; import { getWallType, wallTypeThickness } from "../model/types"; import { add, along, leftNormal, normalize, scale, sub } from "../model/geometry"; import { wallReferenceOffset, wallVerticalExtent } from "../model/wall"; @@ -168,29 +168,93 @@ export function doorSymbol(wall: Wall, o: Opening): DoorSymbol | null { export interface WindowSymbol { /** Rahmen-Rechteck (volle Dicke) als Ecken. */ frame: Vec2[]; - /** Glaslinien-Segmente quer über die Lücke (1–2 parallele Linien). */ + /** Glaslinien-Segmente quer über die Lücke (1–3 parallele Linien). */ glassLines: [Vec2, Vec2][]; /** - * Flügel-Mittelpfosten (`wingCount − 1` Querlinien), jeweils von einer - * Laibungsseite zur anderen (parallel zu den Laibungslinien, quer zur - * Öffnungsrichtung), gleichmäßig zwischen den beiden Pfosten verteilt. Bei - * `wingCount ≤ 1` leer. Entspricht den Mittelpfosten in `_make_oeffnung_pieces` - * des Rhino-Plugins (`oeff_fluegel`). + * Flügel-/Pfosten-Trennlinien, jeweils von einer Laibungsseite zur anderen + * (parallel zu den Laibungslinien, quer zur Öffnungsrichtung). Ist `sashes` + * (dritter Parameter von {@link windowSymbol}) gesetzt, ergeben sich die + * Positionen aus den Flügel-/Pfostenbreiten (siehe `sashSegments`); sonst + * (Alt-Fall) `wingCount − 1` gleichmäßig verteilte Querlinien. Entspricht den + * Mittelpfosten in `_make_oeffnung_pieces` des Rhino-Plugins (`oeff_fluegel`). */ mullionLines: [Vec2, Vec2][]; + /** + * Öffnungsandeutung je NICHT-festem Flügel (Dreh/Kipp/Drehkipp/Schiebe): eine + * einzelne Diagonale über die Flügelfläche, von der äusseren Ecke der + * Bandseite GEGENüberliegenden Seite (aussen) zur Bandseite (innen) — eine + * schlichte Andeutung der Öffnungsrichtung, analog zur Schwenkbogen-Andeutung + * bei Türen, aber ohne Bogen (P0-Minimalfassung). Nur befüllt, wenn `sashes` + * übergeben wird; leer im Alt-Fall (kein `sashes`). + */ + sashOpeningLines: [Vec2, Vec2][]; +} + +/** Ein Segment (Flügel/Pfosten) der Flügeleinteilung, mit aufgelöster Breite. */ +interface SashSegment { + sash: SashDef; + /** Abstand vom Laibungsstart in Metern (0 = jambStart). */ + from: number; + /** Abstand vom Laibungsstart in Metern. */ + to: number; +} + +/** Default-Pfostenbreite (Meter), wenn ein `kind:"pfosten"`-Eintrag keine `postWidth` angibt. */ +const DEFAULT_POST_WIDTH = 0.06; + +/** + * Löst eine Flügeleinteilung ({@link SashDef}-Liste) zu konkreten Segment- + * Intervallen (Meter, gemessen ab Laibungsstart) über die gegebene + * Gesamtbreite auf: `kind:"pfosten"` und Flügel mit `autoWidth === false` + * erhalten ihre feste Breite (`postWidth` bzw. `width`), die restlichen + * (`autoWidth`, Default) teilen sich die verbleibende Breite gleichmäßig. + * Degeneriert bei GLEICHMÄSSIGEN Flügeln (alle `autoWidth`, keine Pfosten) zur + * bisherigen `wingCount`-Gleichverteilung (Regressionssicherheit). + */ +function sashSegments(sashes: SashDef[], totalWidth: number): SashSegment[] { + if (sashes.length === 0) return []; + let fixedSum = 0; + let autoCount = 0; + for (const s of sashes) { + if (s.kind === "pfosten") fixedSum += Math.max(0, s.postWidth ?? DEFAULT_POST_WIDTH); + else if (s.autoWidth === false) fixedSum += Math.max(0, s.width ?? 0); + else autoCount++; + } + const remaining = Math.max(0, totalWidth - fixedSum); + const autoWidth = autoCount > 0 ? remaining / autoCount : 0; + const segs: SashSegment[] = []; + let cursor = 0; + for (const s of sashes) { + const w = + s.kind === "pfosten" + ? Math.max(0, s.postWidth ?? DEFAULT_POST_WIDTH) + : s.autoWidth === false + ? Math.max(0, s.width ?? 0) + : autoWidth; + segs.push({ sash: s, from: cursor, to: cursor + w }); + cursor += w; + } + return segs; } /** * Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle - * Wanddicke) plus 1–2 Glaslinien (dünne parallele Linien in der Wandmitte, - * `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten - * (`wingCount − 1` Querlinien). + * Wanddicke) plus 1–3 Glaslinien (dünne parallele Linien in der Wandmitte, + * `glassCount` steuert die Anzahl) plus optionale Flügel-/Pfosten-Trennlinien + * und Öffnungsandeutungen je Flügel. + * + * `sashes` (optional) ist die aufgelöste Flügeltabelle (siehe + * `sashesOfWindowType`) EINES referenzierten Fenstertyps. Fehlt sie (kein Typ + * referenziert/auflösbar), bleibt die Mittelpfosten-Berechnung beim + * BISHERIGEN Verhalten — `o.wingCount` gleichmäßig verteilt, KEINE + * Öffnungsandeutungen — exakt wie vor dieser Erweiterung (Rückwärtskompatibilität). */ export function windowSymbol( project: Project, wall: Wall, o: Opening, glassCount: number, + sashes?: SashDef[], ): WindowSymbol | null { const jambs = openingJambs(wall, o); if (!jambs) return null; @@ -214,21 +278,48 @@ export function windowSymbol( const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2); glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]); } - // Flügel-Mittelpfosten: (wingCount − 1) Querlinien gleichmäßig zwischen den - // Pfosten. Jede Linie läuft quer über die Öffnung (n-Richtung), auf ~60 % der - // Mauerdicke symmetrisch um die Wandmitte (Referenz-Offset) — analog Rhino, wo - // die Pfosten x_mid = inner_l + span·(i/fluegel) über die Rahmentiefe sitzen. const mullionLines: [Vec2, Vec2][] = []; - const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1))); - if (wings > 1) { + const sashOpeningLines: [Vec2, Vec2][] = []; + if (sashes && sashes.length > 0) { + // Neuer Weg: Trennlinien + Öffnungsandeutungen aus der Flügeltabelle. + const axisWidth = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y); + const segs = sashSegments(sashes, axisWidth); const postDepth = total * 0.6; const pOuter = refOff + postDepth / 2; const pInner = refOff - postDepth / 2; - for (let i = 1; i < wings; i++) { - const t = i / wings; // gleichmäßige Teilung entlang der Achse - const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t)); + for (let i = 0; i < segs.length - 1; i++) { + const onAxis = along(jambStart, jambEnd, segs[i].to); mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]); } + for (const seg of segs) { + if (seg.sash.kind !== "fluegel") continue; + const kind = seg.sash.opening; + if (!kind || kind === "fest") continue; + // Bandseite "left" = Scharnier am Laibungsstart (jambStart-Seite), "right" + // = am Laibungsende. Die Diagonale läuft von der GEGENüberliegenden + // Aussenecke zur Bandecke (innen) — schlichte Andeutung, kein Schwenkbogen. + const hingeAtStart = (seg.sash.hingeSide ?? "left") === "left"; + const hingeAxis = along(jambStart, jambEnd, hingeAtStart ? seg.from : seg.to); + const farAxis = along(jambStart, jambEnd, hingeAtStart ? seg.to : seg.from); + sashOpeningLines.push([add(farAxis, scale(n, outer)), add(hingeAxis, scale(n, inner))]); + } + } else { + // Alt-Fall (kein sashes): (wingCount − 1) Querlinien gleichmäßig zwischen + // den Pfosten. Jede Linie läuft quer über die Öffnung (n-Richtung), auf + // ~60 % der Mauerdicke symmetrisch um die Wandmitte (Referenz-Offset) — + // analog Rhino, wo die Pfosten x_mid = inner_l + span·(i/fluegel) über die + // Rahmentiefe sitzen. UNVERÄNDERT gegenüber dem bisherigen Verhalten. + const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1))); + if (wings > 1) { + const postDepth = total * 0.6; + const pOuter = refOff + postDepth / 2; + const pInner = refOff - postDepth / 2; + for (let i = 1; i < wings; i++) { + const t = i / wings; // gleichmäßige Teilung entlang der Achse + const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t)); + mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]); + } + } } - return { frame, glassLines, mullionLines }; + return { frame, glassLines, mullionLines, sashOpeningLines }; } diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index bf16853..f449918 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -35,9 +35,11 @@ import { getLineStyle, getWallType, getWindowType, + glazingPanesOf, openingLabel, openingsOfWall, roomsOfFloor, + sashesOfWindowType, stairLabel, stairsOfFloor, wallTypeThickness, @@ -2211,9 +2213,19 @@ function addOpeningSymbol( return; } - // Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad. - const glassCount = detail === "fein" ? 2 : 1; - const sym = windowSymbol(project, wall, o, glassCount); + // Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad + Fenstertyp. + const wt = getWindowType(project, o); + // Flügeltabelle NUR übergeben, wenn ein Fenstertyp auflösbar ist — fehlt er + // (kein `typeId`/unbekannt), bleibt `windowSymbol` beim reinen + // `o.wingCount`-Alt-Verhalten (siehe dortiger Kommentar, Regressionssicherheit). + const sashes = wt ? sashesOfWindowType(wt) : undefined; + const detailGlassCount = detail === "fein" ? 2 : 1; + // Verglasungs-Scheibenzahl des Typs (glazingPanesOf) hebt die Detailgrad- + // Vorgabe bei mittel/fein an (mind. so viele Glaslinien); "grob" bleibt bei + // einer Glaslinie, unabhängig vom Typ (bleibt die schlichteste Stufe). + const glassCount = + detail === "grob" ? detailGlassCount : Math.max(detailGlassCount, wt ? glazingPanesOf(wt) : 0); + const sym = windowSymbol(project, wall, o, glassCount, sashes); if (!sym) return; // Unsichtbares Pick-Polygon über der Öffnungslücke — macht das Fenster über // die ganze Fläche selektierbar, auch bei „grob" (nur eine Glaslinie, sonst @@ -2243,8 +2255,8 @@ function addOpeningSymbol( greyed, openingId: o.id, }); - // Flügel-Mittelpfosten (wingCount − 1) — reine Querlinien innerhalb des - // Rahmens (kein Strichmuster). Nur mittel/fein; entfällt bei grob/schema. + // Flügel-/Pfosten-Trennlinien — reine Querlinien innerhalb des Rahmens + // (kein Strichmuster). Nur mittel/fein; entfällt bei grob/schema. for (const [a, b] of sym.mullionLines) { out.push({ kind: "line", @@ -2257,6 +2269,21 @@ function addOpeningSymbol( openingId: o.id, }); } + // Öffnungsandeutung je NICHT-festem Flügel (Dreh/Kipp/Drehkipp/Schiebe) — + // schlichte Diagonale zur Bandseite, nur wenn ein Fenstertyp mit + // Flügeltabelle referenziert ist (siehe windowSymbol/sashOpeningLines). + for (const [a, b] of sym.sashOpeningLines) { + out.push({ + kind: "line", + a, + b, + cls: "window-opening", + weightMm: SYMBOL_HAIRLINE_MM, + color: o.color ?? POCHE_STROKE, + greyed, + openingId: o.id, + }); + } } for (const [a, b] of sym.glassLines) { out.push({ @@ -2318,6 +2345,55 @@ function addOpeningSymbol( }); } } + // Rollladen-/Sonnenschutzkasten (P0-Minimalfassung, reine 2D-Kontur): ein + // gestricheltes Rechteck ÜBER der Öffnung, ausserhalb der Wand-Aussenfläche — + // Breite = `shading.box.width` (0/fehlt ⇒ Öffnungsbreite), die zweite + // Rechteckseite = `shading.box.height`. Die 3D-Tiefe (`box.depth`) bleibt für + // eine spätere 3D-Box reserviert (Fokus hier bewusst 2D, s. Aufgabenstellung). + // Gestrichelt wie die übrigen Überkopf-Projektionen (Sturz/Oberlicht) — der + // Kasten liegt oberhalb der Schnittebene und wird nur angedeutet. + if (wt?.shading?.create) { + const jambs = openingJambs(wall, o); + if (jambs) { + const { u, n: nrm } = wallAxisFrame(wall); + const total = wallTypeThickness(getWallType(project, wall)); + const refOff = wallReferenceOffset(wall, total); + const outerFace = refOff + total / 2; + const axisWidth = Math.hypot( + jambs.jambEnd.x - jambs.jambStart.x, + jambs.jambEnd.y - jambs.jambStart.y, + ); + const boxWidth = wt.shading.box.width > 0 ? wt.shading.box.width : axisWidth; + const boxHeight = Math.max(0, wt.shading.box.height); + const center = add(jambs.jambStart, scale(u, axisWidth / 2)); + const half = boxWidth / 2; + const c1 = add(center, scale(u, -half)); + const c2 = add(center, scale(u, half)); + const p1 = add(c1, scale(nrm, outerFace)); + const p2 = add(c2, scale(nrm, outerFace)); + const p3 = add(c2, scale(nrm, outerFace + boxHeight)); + const p4 = add(c1, scale(nrm, outerFace + boxHeight)); + const edges: [Vec2, Vec2][] = [ + [p1, p2], + [p2, p3], + [p3, p4], + [p4, p1], + ]; + for (const [a, b] of edges) { + out.push({ + kind: "line", + a, + b, + cls: "window-shading", + weightMm: SYMBOL_HAIRLINE_MM, + dash: OVERHEAD_DASH, + color: o.color ?? POCHE_STROKE, + greyed, + openingId: o.id, + }); + } + } + } } /** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */ diff --git a/src/plan/generatePlan.windowFields.test.ts b/src/plan/generatePlan.windowFields.test.ts new file mode 100644 index 0000000..4da9560 --- /dev/null +++ b/src/plan/generatePlan.windowFields.test.ts @@ -0,0 +1,221 @@ +/** + * Unit-Tests für die NEUEN Fenster-Modellfelder im Grundriss (generatePlan + + * geometry/opening.windowSymbol): + * • `WindowType.sashes` (Flügeltabelle) — ersetzt die gleichmäßige + * `wingCount`-Ableitung durch echte Flügel-/Pfostenbreiten, sobald ein + * Fenstertyp referenziert ist. Alt-Fall (keine `sashes`/kein `typeId`) + * bleibt UNVERÄNDERT (reines `Opening.wingCount`, siehe + * generatePlan.openings.test.ts). + * • `WindowType.glazingPanes` — steuert die Anzahl paralleler Glaslinien. + * • `WindowType.shading` — zeichnet eine zusätzliche, gestrichelte + * Kastenkontur über der Öffnung. + * • Öffnungsandeutung je Flügel (`SashDef.opening`/`hingeSide`). + */ + +import { describe, it, expect } from "vitest"; +import { generatePlan } from "./generatePlan"; +import type { Opening, Project, Wall, WindowType } from "../model/types"; + +/** Minimalprojekt: eine Wand (Dicke 0.3 m, Achse mittig, horizontal entlang x) + * + eine Fenster-Öffnung + optionale Fenstertyp-Bibliothek. */ +function project(opening: Opening, windowTypes?: WindowType[]): Project { + const wall: Wall = { + id: "W1", + type: "wall", + floorId: "eg", + categoryCode: "20", + start: { x: 0, y: 0 }, + end: { x: 4, y: 0 }, + wallTypeId: "aw", + height: 2.6, + }; + return { + id: "t", + name: "T", + lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }], + hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }], + components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }], + wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.3 }] }], + windowTypes, + drawingLevels: [ + { + id: "eg", + name: "EG", + kind: "floor", + visible: true, + locked: false, + floorHeight: 2.6, + cutHeight: 1.0, + baseElevation: 0, + }, + ], + layers: [ + { code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }, + { code: "21", name: "Öffnungen", color: "#0a0a0a", lw: 0.3, visible: true, locked: false }, + ], + walls: [wall], + doors: [], + openings: [opening], + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + }; +} + +const visible = new Set(["20", "21"]); + +/** Basis-Fenster (Brüstung 0, damit keine Sill-Linie mitzählt), position 1.0, width 1.2. */ +const baseWindow: Opening = { + id: "F1", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + position: 1.0, + width: 1.2, + height: 1.2, + sillHeight: 0, +}; + +/** Minimaler Fenstertyp (Grunddaten, Felder je Test überschrieben/ergänzt). */ +const baseWindowType: WindowType = { + id: "wt1", + name: "Test-Fenster", + kind: "dreh", + wingCount: 1, + glazing: "einfach", + frameThickness: 0.06, + defaultSillHeight: 0.8, + defaultWidth: 1.2, + defaultHeight: 1.2, +}; + +function linesOfClass(p: Project, cls: string, detail: "grob" | "mittel" | "fein" = "mittel") { + return generatePlan(p, "eg", visible, undefined, detail).primitives.filter( + (pr) => pr.kind === "line" && pr.cls === cls, + ); +} + +describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => { + it("Alt-Fall (kein typeId/sashes, nur wingCount=3) erzeugt genau 2 Pfostenlinien — Regression", () => { + const p = project({ ...baseWindow, wingCount: 3 }); + expect(linesOfClass(p, "window-mullion").length).toBe(2); + }); + + it("WindowType MIT sashes (3 feste Flügelbreiten 0.4 m) erzeugt Pfostenlinien exakt an den Flügelgrenzen", () => { + // Öffnung 1.2 m breit, jambStart bei x=1.0 (position 1.0) -> jambEnd x=2.2. + // 3 Flügel à 0.4 m (autoWidth:false) -> Grenzen bei x=1.4 und x=1.8. + const wt: WindowType = { + ...baseWindowType, + sashes: [ + { kind: "fluegel", autoWidth: false, width: 0.4, opening: "dreh", hingeSide: "left" }, + { kind: "fluegel", autoWidth: false, width: 0.4, opening: "kipp" }, + { kind: "fluegel", autoWidth: false, width: 0.4, opening: "dreh", hingeSide: "right" }, + ], + }; + const p = project({ ...baseWindow, typeId: "wt1" }, [wt]); + const mullions = linesOfClass(p, "window-mullion"); + expect(mullions.length).toBe(2); + const xs = mullions + .map((pr) => (pr.kind === "line" ? pr.a.x : NaN)) + .sort((a, b) => a - b); + expect(xs[0]).toBeCloseTo(1.4, 6); + expect(xs[1]).toBeCloseTo(1.8, 6); + }); + + it("WindowType MIT sashes, aber alle autoWidth (kein fixes width/pfosten) -> identische Grenzen wie die alte wingCount-Gleichverteilung", () => { + // 3 gleichmässige Flügel über sashesOfWindowType (kein `sashes` am Typ, + // nur `wingCount: 3`) müssen exakt dieselben Pfosten liefern wie der + // Alt-Fall ohne Typ (Regressionssicherheit der Ableitung). + const wt: WindowType = { ...baseWindowType, wingCount: 3 }; + const withType = project({ ...baseWindow, typeId: "wt1" }, [wt]); + const withoutType = project({ ...baseWindow, wingCount: 3 }); + const linesOf = (p: Project) => + linesOfClass(p, "window-mullion") + .map((pr) => (pr.kind === "line" ? pr.a.x : NaN)) + .sort((a, b) => a - b); + const a = linesOf(withType); + const b = linesOf(withoutType); + expect(a.length).toBe(b.length); + a.forEach((v, i) => expect(v).toBeCloseTo(b[i], 9)); + }); + + it("Flügel mit opening !== 'fest' erzeugt eine Öffnungsandeutung (window-opening); 'fest' keine", () => { + const wt: WindowType = { + ...baseWindowType, + sashes: [ + { kind: "fluegel", autoWidth: true, opening: "dreh", hingeSide: "left" }, + { kind: "fluegel", autoWidth: true, opening: "fest" }, + ], + }; + const p = project({ ...baseWindow, typeId: "wt1" }, [wt]); + expect(linesOfClass(p, "window-opening").length).toBe(1); + }); + + it("Alt-Fall (kein typeId) erzeugt KEINE Öffnungsandeutung", () => { + const p = project({ ...baseWindow }); + expect(linesOfClass(p, "window-opening").length).toBe(0); + }); +}); + +describe("generatePlan — Verglasung glazingPanes (Item 2)", () => { + const glassLineCount = (p: Project, detail: "grob" | "mittel" | "fein" = "mittel") => + linesOfClass(p, "window-glass", detail).length; + + it("glazingPanes: 3 erzeugt mehr Glaslinien als glazingPanes: 1", () => { + const wt3: WindowType = { ...baseWindowType, id: "wt3", glazingPanes: 3 }; + const wt1: WindowType = { ...baseWindowType, id: "wt1", glazingPanes: 1 }; + const p3 = project({ ...baseWindow, typeId: "wt3" }, [wt3]); + const p1 = project({ ...baseWindow, typeId: "wt1" }, [wt1]); + expect(glassLineCount(p3)).toBeGreaterThan(glassLineCount(p1)); + expect(glassLineCount(p3)).toBe(3); + expect(glassLineCount(p1)).toBe(1); + }); + + it("ohne typeId bleibt die alte Detailgrad-Regel (grob/mittel=1, fein=2) unverändert — Regression", () => { + const p = project({ ...baseWindow }); + expect(glassLineCount(p, "grob")).toBe(1); + expect(glassLineCount(p, "mittel")).toBe(1); + expect(glassLineCount(p, "fein")).toBe(2); + }); + + it("glazingPanes wirkt bei 'grob' NICHT (bleibt bei 1 Glaslinie)", () => { + const wt: WindowType = { ...baseWindowType, glazingPanes: 3 }; + const p = project({ ...baseWindow, typeId: "wt1" }, [wt]); + expect(glassLineCount(p, "grob")).toBe(1); + }); +}); + +describe("generatePlan — Rollladenkasten shading (Item 3)", () => { + const shadingLineCount = (p: Project) => linesOfClass(p, "window-shading").length; + + it("shading.create: true erzeugt eine zusätzliche Kastenkontur (4 Linien)", () => { + const wt: WindowType = { + ...baseWindowType, + shading: { create: true, kind: "rollladen", box: { width: 1.2, height: 0.2, depth: 0.15 } }, + }; + const p = project({ ...baseWindow, typeId: "wt1" }, [wt]); + expect(shadingLineCount(p)).toBe(4); + }); + + it("shading.create: false erzeugt KEINE Kastenkontur", () => { + const wt: WindowType = { + ...baseWindowType, + shading: { create: false, kind: "rollladen", box: { width: 1.2, height: 0.2, depth: 0.15 } }, + }; + const p = project({ ...baseWindow, typeId: "wt1" }, [wt]); + expect(shadingLineCount(p)).toBe(0); + }); + + it("kein shading-Feld am Typ erzeugt KEINE Kastenkontur", () => { + const p = project({ ...baseWindow, typeId: "wt1" }, [baseWindowType]); + expect(shadingLineCount(p)).toBe(0); + }); + + it("ohne typeId (Alt-Fall) erzeugt KEINE Kastenkontur", () => { + const p = project({ ...baseWindow }); + expect(shadingLineCount(p)).toBe(0); + }); +}); diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 011cb28..07b0da9 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -260,10 +260,16 @@ 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", () => { + it("1 Fenster + 1 Tür -> 2 Glas-Meshes (Typ 'window-standard' = Zweifachverglasung) 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. + // + // ANGEPASST (glazingPanes-Feature): O1 referenziert den Typ "window-standard" + // (`glazing: "zweifach"` -> `glazingPanesOf` = 2). `glassPanesForOpening` + // zeichnet jetzt render-wirksam so viele Scheiben, statt wie bisher immer + // fix EINE — bewusste, korrekte Verhaltensänderung (Aufgabe: 2D/3D-Renderer + // sollen die neuen Fenster-Verglasungsfelder konsumieren). const p: Project = { ...sampleProject, openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"), @@ -276,34 +282,91 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => { const glassMeshes = meshes.filter( (m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85, ); - // Genau EINE Scheibe: das Fenster O1; die Tür D1 bleibt offen (keine Scheibe). - expect(glassMeshes.length).toBe(1); - const glass = glassMeshes[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 -> Ö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.96, 6); - expect(Math.max(...zs)).toBeCloseTo(2.04, 6); + expect(glassMeshes.length).toBe(2); + // Vertikale Ausdehnung (z = 3. Komponente jeder Ecke, MODELL-Höhe) — BEIDE + // Scheiben sitzen INNERHALB desselben Rahmens (Schwelle/Sturz ziehen je + // frameWidth ab): O1 sillHeight 0.9, height 1.2, Wand-UK 0 -> Öffnung UK + // 0.9 .. OK 2.1, Rahmen 0.06 -> Scheiben 0.96 .. 2.04. + for (const glass of glassMeshes) { + // 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]); + const zs: number[] = []; + for (let i = 2; i < glass.positions.length; i += 3) zs.push(glass.positions[i]); + expect(Math.min(...zs)).toBeCloseTo(0.96, 6); + expect(Math.max(...zs)).toBeCloseTo(2.04, 6); + } + // Quer zur Wand (y-Achse, da W1 entlang x läuft) sitzen die beiden Scheiben + // MIT Luftraum versetzt (Isolierverglasung) — sie überlappen sich nicht. + const yRange = (m: { positions: number[] }): { min: number; max: number } => { + let min = Infinity; + let max = -Infinity; + for (let i = 1; i < m.positions.length; i += 3) { + min = Math.min(min, m.positions[i]); + max = Math.max(max, m.positions[i]); + } + return { min, max }; + }; + const [a, b] = glassMeshes.map(yRange).sort((r1, r2) => r1.min - r2.min); + expect(a.max).toBeLessThanOrEqual(b.min + 1e-9); }); - it("Türen bekommen keine Scheibe; je Fenster genau eine", () => { - // Volles Sample-Projekt: 2 Fenster (O1, O2) + 1 Tür (D1), Kontext leer. - // Neben den Glasscheiben liegt jetzt auch das Betontreppen-Mesh in `meshes` - // → nach Glasfarbe filtern: genau 2 Scheiben (O1, O2), Tür ohne Scheibe. + it("Türen bekommen keine Scheibe; je Fenster genau 2 (window-standard = zweifach verglast)", () => { + // Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" = + // Zweifachverglasung) + 1 Tür (D1), Kontext leer. Neben den Glasscheiben + // liegt jetzt auch das Betontreppen-Mesh in `meshes` → nach Glasfarbe + // filtern: 2 Fenster × 2 Scheiben = 4, Tür ohne Scheibe. const { meshes } = projectToModel3d(sampleProject); const glassMeshes = meshes.filter( (m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85, ); - expect(glassMeshes.length).toBe(2); + expect(glassMeshes.length).toBe(4); + }); + + it("glazingPanes: 3 erzeugt mehr Glas-Meshes als glazingPanes: 1 (glazingPanesOf)", () => { + const glassMeshCount = (glazingPanes: 1 | 2 | 3): number => { + const p: Project = { + ...sampleProject, + windowTypes: [ + { + id: "win-panes-test", + name: "Test Verglasung", + kind: "fest", + wingCount: 1, + glazing: "einfach", + glazingPanes, + frameThickness: 0.06, + frameWidth: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.0, + defaultHeight: 1.2, + }, + ], + openings: [ + { + id: "OG", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + typeId: "win-panes-test", + position: 3.0, + width: 1.0, + height: 1.2, + sillHeight: 0.9, + }, + ], + doors: [], + context: [], + }; + const { meshes } = projectToModel3d(p); + return meshes.filter((m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85) + .length; + }; + expect(glassMeshCount(1)).toBe(1); + expect(glassMeshCount(3)).toBe(3); + expect(glassMeshCount(3)).toBeGreaterThan(glassMeshCount(1)); }); }); diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index bc8c644..723b315 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -44,6 +44,7 @@ import { flattenCategories, getDoorType, getWindowType, + glazingPanesOf, } from "../model/types"; import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall"; import { columnFootprint } from "../geometry/column"; @@ -1840,6 +1841,13 @@ interface OpeningFrameParams { mullionRows: number; /** Ob eine Glasscheibe gezeichnet wird (Fenster immer, Tür nur `leafStyle: "glas"`). */ glazed: boolean; + /** + * Verglasungs-Scheibenzahl (1–3), render-wirksam ({@link glazingPanesOf}). + * Türen: immer 1 (keine typisierte Mehrfachverglasung). Steuert die Anzahl + * dünner, in Wandrichtung leicht versetzter Glasflächen in + * {@link glassPanesForOpening}. + */ + glazingPanes: 1 | 2 | 3; } function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null { @@ -1865,6 +1873,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening wingCount: 1, mullionRows: 1, glazed: dt.leafStyle === "glas", + glazingPanes: 1, }; } const wt = getWindowType(project, op); @@ -1888,6 +1897,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening wingCount: Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1)), mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)), glazed: true, + glazingPanes: glazingPanesOf(wt), }; } @@ -1954,13 +1964,20 @@ function frameMeshesForOpening( return out; } +/** Luftraum zwischen zwei Scheiben einer Mehrfachverglasung (Meter, informativ). */ +const GLASS_PANE_GAP = 0.01; + /** * 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). + * `params.glazed`): je Z-Band (Hauptfeld zwischen Schwelle und Sturz bzw. + * Kämpfer, plus — bei `transomHeight>0` — ein zweites Oberlicht-Band) werden + * `params.glazingPanes` dünne, in Wandrichtung (quer zur Wand, n-Achse) leicht + * versetzte Scheiben mit Luftraum dazwischen erzeugt (Isolierverglasung: + * 1 = Einfach-, 2 = Zweifach-, 3 = Dreifachverglasung). Bei `glazingPanes: 1` + * (Alt-Verhalten/Default) entsteht exakt EINE Scheibe an derselben Position wie + * bisher. Die Sprossen/Mittelpfosten aus {@link frameMeshesForOpening} liegen + * VOR diesen Scheiben (keine Pro-Feld-Aufteilung — bewusst einfach gehalten, + * s. Datei-weite Erläuterung). */ function glassPanesForOpening( wall: Wall, @@ -1974,16 +1991,24 @@ function glassPanesForOpening( const sashTo = iv.to - fw; const sashBottom = vert.zBottom + (params.hasSill ? fw : 0); const sashTopFull = vert.zTop - fw; - // Die Scheibe ist eine DÜNNE Tafel NAHE der Aussenfläche (nMax = +n-Seite, s. - // resolveFrameNormalRange) — mittig tief im Reveal wirkte sie schwach/kaum - // sichtbar. Direkt hinter der Aussenkante (3 cm zurück) liest sich das Fenster - // klar, die Laibung/Reveal-Tiefe zeigt nach innen. + // Die Scheibe(n) sitzen als DÜNNE Tafeln NAHE der Aussenfläche (nMax = +n- + // Seite, s. resolveFrameNormalRange) — mittig tief im Reveal wirkte das + // schwach/kaum sichtbar. Direkt hinter der Aussenkante (3 cm zurück) liest + // sich das Fenster klar, die Laibung/Reveal-Tiefe zeigt nach innen. Bei + // mehreren Scheiben rücken die weiteren mit `GLASS_PANE_GAP` Luftraum + // dazwischen weiter nach innen (Richtung `nMin`). const glassThick = GLASS_HALF_THICK * 2; - const gN1 = params.nMax - 0.03; - const gN0 = Math.max(params.nMin, gN1 - glassThick); + const panes = Math.max(1, Math.min(3, Math.round(params.glazingPanes))); const push = (zBottom: number, zTop: number): void => { - const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, gN0, gN1, GLASS_RGB); - if (mesh) out.push(mesh); + let n1 = params.nMax - 0.03; + for (let p = 0; p < panes; p++) { + const n0 = Math.max(params.nMin, n1 - glassThick); + if (n1 - n0 <= EPS) break; + const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, n0, n1, GLASS_RGB); + if (mesh) out.push(mesh); + n1 = n0 - GLASS_PANE_GAP; + if (n1 <= params.nMin) break; + } }; if (params.transomHeight > EPS) { const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTopFull);