diff --git a/src/plan/generatePlan.doorwindow.test.ts b/src/plan/generatePlan.doorwindow.test.ts new file mode 100644 index 0000000..0ec2b15 --- /dev/null +++ b/src/plan/generatePlan.doorwindow.test.ts @@ -0,0 +1,212 @@ +/** + * Unit-Tests für die Tür-/Fenstertyp-Rahmenparameter im Grundriss + * (generatePlan): Rahmenband (Zarge/Blockrahmen), Schichteinzug (insetFromFace/ + * insetFace), Oberlicht (transomHeight). `mullionRows` bleibt bewusst ungetestet + * — Kämpfer-Zeilen sind im Grundriss-Schnitt unsichtbar (3D-Sache). + */ + +import { describe, it, expect } from "vitest"; +import { generatePlan } from "./generatePlan"; +import type { DoorType, Opening, Project, Wall, WindowType } from "../model/types"; + +/** Minimalprojekt: eine Wand (Dicke 0.3 m, Achse mittig) + Öffnungen und + * optionale Tür-/Fenstertyp-Bibliotheken, konfigurierbar je Test. */ +function project( + openings: Opening[], + opts: { doorTypes?: DoorType[]; 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 }] }], + doorTypes: opts.doorTypes, + windowTypes: opts.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, + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + }; +} + +const visible = new Set(["20", "21"]); + +/** Basis-Fenster, jenseits der Tür auf derselben Wandachse. */ +const baseWindow: Opening = { + id: "F1", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + position: 2.0, + width: 1.2, + height: 1.2, + sillHeight: 0, +}; + +/** Basis-Tür. */ +const baseDoor: Opening = { + id: "T1", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "door", + position: 0.2, + width: 0.9, + height: 2.0, + sillHeight: 0, + swing: "left", + hinge: "start", +}; + +/** Fenstertyp mit Rahmen, Schichteinzug (aussen) und Oberlicht. */ +const windowType: WindowType = { + id: "wt1", + name: "Fenstertyp Test", + kind: "dreh", + wingCount: 1, + glazing: "zweifach", + frameThickness: 0.06, + frameWidth: 0.05, + insetFromFace: 0.05, + insetFace: "aussen", + transomHeight: 0.3, + defaultSillHeight: 0.8, + defaultWidth: 1.2, + defaultHeight: 1.2, +}; + +/** Türtyp mit Blockrahmen, Schichteinzug (innen) und Oberlicht. */ +const doorType: DoorType = { + id: "dt1", + name: "Türtyp Test", + kind: "dreh", + leafCount: 1, + leafStyle: "glatt", + frameThickness: 0.06, + frameKind: "blockrahmen", + frameWidth: 0.06, + insetFromFace: 0.04, + insetFace: "innen", + transomHeight: 0.4, + defaultWidth: 0.9, + defaultHeight: 2.0, +}; + +type LinePrim = Extract["primitives"][number], { kind: "line" }>; + +function linesOfClass(p: Project, cls: string, openingId?: string): LinePrim[] { + return generatePlan(p, "eg", visible, undefined, "mittel").primitives.filter( + (pr): pr is LinePrim => + pr.kind === "line" && pr.cls === cls && (!openingId || pr.openingId === openingId), + ); +} + +describe("generatePlan — Tür-/Fenstertyp-Rahmenband (opening-frame)", () => { + it("erzeugt Rahmenband-Primitive für Tür UND Fenster mit referenziertem Typ", () => { + const p = project( + [ + { ...baseWindow, typeId: "wt1" }, + { ...baseDoor, typeId: "dt1" }, + ], + { doorTypes: [doorType], windowTypes: [windowType] }, + ); + expect(linesOfClass(p, "opening-frame").length).toBeGreaterThan(0); + // Je Öffnung ein geschlossenes Rechteck aus 4 Kanten. + expect(linesOfClass(p, "opening-frame", "F1").length).toBe(4); + expect(linesOfClass(p, "opening-frame", "T1").length).toBe(4); + }); + + it("Blockrahmen (Tür) zeichnet eine kräftigere Linie als die Zargen-Optik (Fenster)", () => { + const p = project( + [ + { ...baseWindow, typeId: "wt1" }, + { ...baseDoor, typeId: "dt1" }, + ], + { doorTypes: [doorType], windowTypes: [windowType] }, + ); + const doorWeight = linesOfClass(p, "opening-frame", "T1")[0].weightMm; + const windowWeight = linesOfClass(p, "opening-frame", "F1")[0].weightMm; + expect(doorWeight).toBeGreaterThan(windowWeight); + }); +}); + +describe("generatePlan — Oberlicht (opening-transom)", () => { + it("erscheint genau dann, wenn transomHeight > 0", () => { + const withTransom = project([{ ...baseWindow, typeId: "wt1" }], { windowTypes: [windowType] }); + const transomLines = linesOfClass(withTransom, "opening-transom", "F1"); + expect(transomLines.length).toBe(1); + expect(Array.isArray(transomLines[0].dash) && transomLines[0].dash!.length).toBeGreaterThan(0); + + const noTransomType: WindowType = { ...windowType, id: "wt2", transomHeight: 0 }; + const withoutTransom = project([{ ...baseWindow, typeId: "wt2" }], { windowTypes: [noTransomType] }); + expect(linesOfClass(withoutTransom, "opening-transom", "F1").length).toBe(0); + }); +}); + +describe("generatePlan — Schichteinzug (insetFromFace/insetFace)", () => { + it("verschiebt die Rahmen-Nahfläche um den Einzugsbetrag, die Gegenfläche bleibt unverändert", () => { + const flushType: WindowType = { ...windowType, id: "wt-flush", insetFromFace: 0 }; + const inset = project([{ ...baseWindow, typeId: "wt1" }], { windowTypes: [windowType] }); + const flush = project([{ ...baseWindow, typeId: "wt-flush" }], { windowTypes: [flushType] }); + + // Die Wandachse ist horizontal (u=(1,0)) ⇒ Wandnormale n=(0,1); die + // y-Koordinate der Rahmenband-Punkte entspricht direkt dem Offset quer zur + // Wand. Die "aussen"-Fläche liegt bei +n — ihr Rahmenrand muss um genau + // `insetFromFace` (0.05 m) nach innen (kleineres y) rücken; die + // gegenüberliegende ("innen"-)Fläche bleibt unverändert. + const insetYs = linesOfClass(inset, "opening-frame", "F1").flatMap((l) => [l.a.y, l.b.y]); + const flushYs = linesOfClass(flush, "opening-frame", "F1").flatMap((l) => [l.a.y, l.b.y]); + + expect(Math.max(...flushYs) - Math.max(...insetYs)).toBeCloseTo(0.05, 9); + expect(Math.min(...insetYs)).toBeCloseTo(Math.min(...flushYs), 9); + }); +}); + +describe("generatePlan — Rückwärtskompatibilität (Öffnung ohne typeId)", () => { + it("liefert exakt dieselben Primitive, ob Typ-Bibliotheken im Projekt existieren oder nicht", () => { + const withResources = project([{ ...baseWindow }, { ...baseDoor }], { + doorTypes: [doorType], + windowTypes: [windowType], + }); + const withoutResources = project([{ ...baseWindow }, { ...baseDoor }]); + + const a = generatePlan(withResources, "eg", visible, undefined, "mittel").primitives; + const b = generatePlan(withoutResources, "eg", visible, undefined, "mittel").primitives; + expect(a).toEqual(b); + expect(a.some((pr) => pr.kind === "line" && pr.cls === "opening-frame")).toBe(false); + expect(a.some((pr) => pr.kind === "line" && pr.cls === "opening-transom")).toBe(false); + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 8752c80..deb4d23 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -28,10 +28,12 @@ import { flattenCategories, getCeilingType, getComponent, + getDoorType, getHatch, getLayerCategory, getLineStyle, getWallType, + getWindowType, openingLabel, openingsOfWall, roomsOfFloor, @@ -1863,6 +1865,166 @@ function addDoorSymbol( } } +/** + * Aufgelöste Rahmen-Parameter (Typ-Bibliothek) einer Tür-/Fenster-Öffnung — + * nur vorhanden, wenn die Öffnung einen Bauteil-Typ referenziert (`typeId`). + * Fehlt der Typ, bleibt das heutige Inline-Verhalten unverändert (kein + * Rahmenband, kein Oberlicht) — siehe `resolveOpeningFrameSpec`. + */ +interface OpeningFrameSpec { + frameWidth: number; + /** Nur bei Türen relevant; Fenster zeichnen stets die schmale Zargen-Optik. */ + frameKind: "zarge" | "blockrahmen"; + insetFromFace: number; + insetFace: "aussen" | "innen"; + transomHeight: number; +} + +/** + * Löst die Rahmen-Parameter aus dem referenzierten Tür-/Fenstertyp auf (samt + * Defaults); `undefined`, wenn kein Typ referenziert/auflösbar ist — die + * Öffnung bleibt dann beim heutigen Inline-Verhalten (rückwärtskompatibel). + */ +function resolveOpeningFrameSpec(project: Project, o: Opening): OpeningFrameSpec | undefined { + if (o.kind === "door") { + const t = getDoorType(project, o); + if (!t) return undefined; + return { + frameWidth: t.frameWidth ?? 0.06, + frameKind: t.frameKind ?? "zarge", + insetFromFace: t.insetFromFace ?? 0, + insetFace: t.insetFace ?? "aussen", + transomHeight: t.transomHeight ?? 0, + }; + } + const t = getWindowType(project, o); + if (!t) return undefined; + return { + frameWidth: t.frameWidth ?? 0.06, + frameKind: "zarge", + insetFromFace: t.insetFromFace ?? 0, + insetFace: t.insetFace ?? "aussen", + transomHeight: t.transomHeight ?? 0, + }; +} + +/** + * Rahmenband + Oberlicht-Andeutung einer Tür-/Fenstertyp-Öffnung im + * Grundriss-Schnitt. Nur aufgerufen, wenn `resolveOpeningFrameSpec` einen Typ + * liefert — sonst bleibt die Darstellung exakt wie zuvor (kein neues Primitiv). + * + * • Zarge — schmales Umlauf-Rahmenband, ringsum um `frameWidth` + * von der vollen Öffnung (Pfosten × Wanddicke) nach innen eingezogen. + * • Blockrahmen (Tür) — kräftiges Rechteckprofil, sitzt PROUD an der + * gewählten Fläche (kein Einzug an den Pfosten, nur eine Tiefenscheibe von + * `frameWidth` ab der Nah-Fläche), kräftigere Linie. + * • Schichteinzug — verschiebt die Nah-Fläche des Rahmens um + * `insetFromFace` von der gewählten Wandfläche nach innen; die Gegenfläche + * bleibt unverändert (der Rahmen reicht weiterhin bis dorthin). + * • Oberlicht — Überkopf-Andeutung (gestrichelte Linie quer über die + * Öffnung), nur wenn `transomHeight > 0`. + */ +function addOpeningFrameBand( + out: Primitive[], + project: Project, + wall: Wall, + o: Opening, + spec: OpeningFrameSpec, + greyed: boolean, +): void { + const jambs = openingJambs(wall, o); + if (!jambs) return; + const { n } = wallAxisFrame(wall); + const total = wallTypeThickness(getWallType(project, wall)); + const refOff = wallReferenceOffset(wall, total); + const outerFace = refOff + total / 2; // "aussen"-Fläche (+n) + const innerFace = refOff - total / 2; // "innen"-Fläche (−n) + + // Schichteinzug: die GEWÄHLTE Fläche rückt um `insetFromFace` nach innen; die + // gegenüberliegende Fläche bleibt unverändert (der Rahmen reicht weiterhin + // bis dorthin) — vgl. DoorType.insetFromFace/WindowType.insetFromFace. + const clampedInset = Math.max(0, Math.min(spec.insetFromFace, total)); + const nearIsAussen = spec.insetFace !== "innen"; + const faceNear = nearIsAussen ? outerFace - clampedInset : innerFace + clampedInset; + const faceFar = nearIsAussen ? innerFace : outerFace; + const lo = Math.min(faceNear, faceFar); + const hi = Math.max(faceNear, faceFar); + + const { jambStart, jambEnd } = jambs; + const frameWidth = Math.max(0, spec.frameWidth); + const isBlock = o.kind === "door" && spec.frameKind === "blockrahmen"; + const color = o.color ?? POCHE_STROKE; + + let bandLo: number; + let bandHi: number; + let jStart: Vec2; + let jEnd: Vec2; + let weightMm: number; + if (isBlock) { + // Blockrahmen: volle Öffnungsbreite (kein Pfosten-Einzug), nur eine + // Tiefenscheibe von frameWidth ab der Nah-Fläche — sitzt proud VOR/auf der + // Laibung, statt schmal darin zu verschwinden (Zargen-Optik). + const depth = Math.min(frameWidth, hi - lo); + bandLo = nearIsAussen ? hi - depth : lo; + bandHi = nearIsAussen ? hi : lo + depth; + jStart = jambStart; + jEnd = jambEnd; + weightMm = SYMBOL_HAIRLINE_MM * 2.2; + } else { + // Zarge: ringsum um frameWidth von der vollen Öffnung eingezogenes, + // schmales Rahmenband (Laibungs-Anschlaglinie). + const jambSpan = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y); + const jambInset = Math.min(frameWidth, jambSpan / 2); + jStart = along(jambStart, jambEnd, jambInset); + jEnd = along(jambEnd, jambStart, jambInset); + const depthInset = Math.min(frameWidth, (hi - lo) / 2); + bandLo = lo + depthInset; + bandHi = hi - depthInset; + weightMm = SYMBOL_HAIRLINE_MM; + } + + // Geschlossenes Rechteck aus 4 Kanten (Primitive "line", nicht "polygon" — + // so bleibt das Band per `cls` selektierbar/testbar wie die übrigen Symbole). + const p1 = add(jStart, scale(n, bandLo)); + const p2 = add(jEnd, scale(n, bandLo)); + const p3 = add(jEnd, scale(n, bandHi)); + const p4 = add(jStart, scale(n, bandHi)); + 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: "opening-frame", + weightMm, + color, + greyed, + openingId: o.id, + }); + } + + // Oberlicht (Überkopf-Andeutung): eine gestrichelte Linie quer über die + // Öffnung auf der Wandachse — nur wenn der Typ ein Oberlicht definiert. + if (spec.transomHeight > 0) { + out.push({ + kind: "line", + a: jambStart, + b: jambEnd, + cls: "opening-transom", + weightMm: SYMBOL_HAIRLINE_MM, + dash: OVERHEAD_DASH, + color, + greyed, + openingId: o.id, + }); + } +} + /** * Öffnungs-Symbol (Fenster/Tür) je Detailgrad. Anders als das Legacy-`addDoorSymbol` * arbeitet diese Funktion über die reinen Geometrie-Helfer (opening.ts) und taggt @@ -1953,6 +2115,10 @@ function addOpeningSymbol( }); } } + // Rahmenband (Zarge/Blockrahmen) + Oberlicht-Andeutung — nur wenn die + // Öffnung einen Türtyp referenziert (siehe `resolveOpeningFrameSpec`). + const doorFrameSpec = resolveOpeningFrameSpec(project, o); + if (doorFrameSpec) addOpeningFrameBand(out, project, wall, o, doorFrameSpec, greyed); // Sturzlinien (SIA, gestrichelt) — Überkopf-Projektion des Sturzes quer über // die Öffnung an der Wand-Innen-/Aussenkante. Entspricht `_make_tuer_sturz_curves` // im Rhino-Plugin. Default „beide" wenn nicht explizit gesetzt. @@ -2065,6 +2231,11 @@ function addOpeningSymbol( openingId: o.id, }); } + // Rahmenband (Zarge) + Oberlicht-Andeutung — nur wenn die Öffnung einen + // Fenstertyp referenziert (siehe `resolveOpeningFrameSpec`). Kämpfer-Zeilen + // (`mullionRows`) bleiben im Grundriss-Schnitt unsichtbar (3D-Sache). + const windowFrameSpec = resolveOpeningFrameSpec(project, o); + if (windowFrameSpec) addOpeningFrameBand(out, project, wall, o, windowFrameSpec, greyed); // fein: Anschlag-/Laibungsstriche an beiden Pfosten (kurz quer zur Wand) — // analog zur Tür, damit Fenster im Detailplan gleich „fertig" wirken (bisher // hatten nur Türen sie). Haarlinie, 6 cm Andeutung je Seite.