From 13cc6a0d6a9872d769c34c65be5d4895f6eb2cc8 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 11 Jul 2026 18:18:37 +0200 Subject: [PATCH] Fenster im Grundriss: grob/mittel/fein nach SIA 400 klar unterscheiden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grob (1:100) zeigt nur eine schematische Glaslinie, mittel (1:50) einen Blendrahmen mit Flügel-Trennlinien und Stulp-Quadrat je Flügelstoss, fein (1:20) zusätzlich verschachtelte Flügelrahmen, Glas als Doppellinie (Isolierverglasung) und zwei Stulp-Quadrate je Stoss — vorher waren mittel und fein praktisch identisch. Referenz: SIA 400 Anhang B.9.1, Fig. 36–38 (docs/research/sia400-fenster-tueren.md). --- docs/research/sia400-fenster-tueren.md | 82 ++++++++++ src/geometry/opening.ts | 100 ++++++++---- src/plan/generatePlan.ts | 172 +++++++++++---------- src/plan/generatePlan.windowFields.test.ts | 59 ++++++- 4 files changed, 300 insertions(+), 113 deletions(-) create mode 100644 docs/research/sia400-fenster-tueren.md diff --git a/docs/research/sia400-fenster-tueren.md b/docs/research/sia400-fenster-tueren.md new file mode 100644 index 0000000..08e2fff --- /dev/null +++ b/docs/research/sia400-fenster-tueren.md @@ -0,0 +1,82 @@ +# SIA 400 — Fenster-/Türdarstellung (Grundriss, Schnitt, Ansicht) + +> Quelle: SIA 400:2000 „Planbearbeitung im Hochbau", Anhang B.9 (Darstellung von +> Bauteilen), Figuren 36–42. Das PDF liegt im Projektwurzel (lokal excluded, +> NICHT committen — urheberrechtlich geschützt). Dies ist die verbindliche +> Darstellungsnorm (Schweiz) — NICHT DIN. Massstab = Detailgrad: +> **1:100 = grob, 1:50 = mittel, 1:20 = fein.** + +## B.9.1.1 Fenster im Grundriss (Figuren 36/37/38) + +Regel (Zitat): „Die Darstellung der Fensterkonstruktionen erfolgt nach denselben +Regeln, unabhängig davon ob es sich um Holz-, Holz-Metall-, Metall- oder +Kunststoff-Fenster handelt." + +### Figur 36 — Massstab 1:100 (= grob) +- Wand **voll schwarz** (Poché massiv), keine Schraffur. +- Fenster = **eine dünne Rahmen-/Glasband-Andeutung** über die Öffnung, sehr + schematisch — im Wesentlichen EIN schmales Rechteck/Band mit wenigen Marken, + KEINE Flügel-/Anschlagdetails, KEINE Laibungsmarken. + +### Figur 37 — Massstab 1:50 (= mittel) +- Wand **materialschraffiert** (Bänder: Hinterlüftung/Dämmung senkrecht + gestrichelt, Backstein/Struktur diagonal). Drei Wandaufbauten gezeigt + (Backstein+Aussendämmung hinterlüftet / Holzelementbau / Backstein verputzt). +- Fenster: **Blendrahmen als schmales Band** nahe der Aussenfläche, dünne + Kontur; **Glaslinie**; **ein kleines Quadrat mittig** (Stulp/Flügelstoss). + Laibung/Öffnungstiefe sichtbar. Einfach, aber als Fenster lesbar. + +### Figur 38 — Massstab 1:20 (= fein) +- Wand voll schraffiert (Kreuzschraffur Backstein, Diagonalschraffur Dämmung). +- Fenster: **volles Rahmenprofil** — Blendrahmen + Flügelrahmen als mehrere + parallele Linien (Profiltiefe); **Glas als Doppellinie** (Isolierverglasung + IV, zwei enge Parallelen); **zwei Stulp-Quadrate mittig** (Flügelstoss der + zwei Flügel); **Anschlag/Laibung** mit Rahmen-Wand-Detail (Falz/Rebate), + gestrichelte Anschlagmarken an den Ecken; Anschlagwinkel an den Laibungen. + +## B.9.1.2 Fenster im Schnitt (Figuren 39/40, M 1:50) +- Rahmen im Schnitt mit Brüstung/Sturz, Bemassung (z. B. Brüstung +0.90, + Sturzhöhe). Fenstertüren (Figur 40): Rahmen bis Boden, Schwelle. + +## B.9.1.3 Sinnbilder Fenster (Öffnungsart in der ANSICHT) +Liste der Öffnungsarten (SIA-Benennung): +- Fest im Rahmen verglast (kein Symbol) +- Drehflügel einflüglig mit Verschluss, **Band rechts/links** (Dreieck, Spitze zur BANDSEITE) +- Drehflügel fest mit Band und Plattenverschraubung +- Zweiflüglig mit **Öffnungsreihenfolge** (1 = erstöffnend, 2 = zweitöffnend) +- Kippflügel mit Verschluss (Dreieck) +- Kippflügel fest, für Reinigung bedienbar +- Klappflügel mit Verschluss +- Drehkippflügel, Band rechts +- Schwingflügelfenster +- Wendeflügelfenster, Achse in der Mitte +- Vertikales Schiebefenster (nach oben schiebbar, oberer Flügel fest) + +Konvention: Dreieck-/Winkelsinnbild, **Spitze zeigt zur Bandseite (Anschlag)**, +Basis zur Griffseite. (Genaues Sinnbild-Blatt S. 38 rechts.) + +## B.9.1.4 Kurzzeichen Fenster/Sonnenschutz +KL Klappladen · SL Schiebeladen · ROL Rolladen · LAM Lamellenstoren · +RAF Rafflamellenstoren · FAL Faltrolladen · K Kurbel · DV Doppelverglasung · +IV Isolierverglasung · IV3 Dreifachisolierverglasung · BFB Beton-Fensterbank · +MFB Metall-Fensterbank · FFB Faserzement-Fensterbank. + +## B.9.2 Türen im Grundriss (Figuren 41/42) + Sinnbilder (B.9.2.2) +- **Türblatt als Linie ab Band + Viertelkreisbogen** (Schwingbahn). Bandseite = + Bogenmittelpunkt. Zweiflüglig/Doppeltüre = zwei Bögen. +- Zargenarten: Futterrahmen/Zarge, Blockrahmen/Profil, Blendrahmen — je mit + Anschlag-/Schwellenmark. Bei Niveaudifferenz Schwellenstrich. +- Weitere Sinnbilder: Pendeltüre (Bogen beidseitig gestrichelt), Falttüre + (Zickzack), Faltschiebetor, Drehtüre (Kreis mit X), Kipptor, Schiebetüre + (ausserhalb/in der Wand, Pfeil), Harmonikatüre. + +## Umsetzung im Code (Soll) +- `generatePlan.ts` Fenster-Zweig + `geometry/opening.ts::windowSymbol`: + drei DEUTLICH getrennte Detailstufen statt heute (mittel≈fein): + - **grob:** 1 Rahmen-/Glasband (Rechteck), sonst nichts. + - **mittel:** Blendrahmen-Rechteck + Glaslinie + 1 Stulp-Quadrat mittig + + Flügel-Trennlinien; keine verschachtelten Flügelrahmen, kein Anschlag. + - **fein:** verschachtelte Blend-+Flügelrahmen (mehrlinig) + Glas-Doppellinie + (IV) + Stulp-Quadrate + Anschlag/Laibungsmarken. +- Öffnungssymbole (Ansicht) auf SIA umstellen (Spitze zur Bandseite), Türbögen + bleiben (schon SIA-konform). diff --git a/src/geometry/opening.ts b/src/geometry/opening.ts index 2c7b43e..5c006f3 100644 --- a/src/geometry/opening.ts +++ b/src/geometry/opening.ts @@ -251,6 +251,14 @@ export interface WindowSymbol { * ausgedrückt statt über eine Diagonale quer über die Box. */ sashOpeningLines: [Vec2, Vec2][]; + /** + * Stulp-/Flügelstoss-Marken (SIA-„kleines Quadrat mittig", Figur 37/38): je + * Flügel-Trennung ein kleines Rechteck-Polygon auf der Glasebene, das den + * Anschlag der zwei zusammenstossenden Flügelprofile darstellt. Bei „mittel" + * ein Quadrat je Stoss, bei „fein" zwei (die beiden Stulpprofile). „grob": + * leer. Der Aufrufer zeichnet sie als dünne Umriss-Rechtecke. + */ + meetingMarks: Vec2[][]; } /** @@ -345,6 +353,7 @@ export function windowSymbol( o: Opening, glassCount: number, spec?: WindowRenderSpec, + detail: "grob" | "mittel" | "fein" = "fein", ): WindowSymbol | null { const jambs = openingJambs(wall, o); if (!jambs) return null; @@ -355,10 +364,14 @@ export function windowSymbol( const inner = refOff - total / 2; const { jambStart, jambEnd } = jambs; - // ── Reicher Pfad (typisiertes Fenster mit Flügeltabelle) ───────────────── - // Blendrahmen an der realen Einbaulage (Schichteinzug), je Flügel - // Blendrahmen → Flügelrahmen (öffenbar) → Glas. Feste Flügel: Glas direkt im - // Blendrahmen (kein Flügelrahmen) — so unterscheidet sich fest von öffenbar. + // ── Reicher Pfad (typisiertes Fenster mit Flügeltabelle), SIA-Darstellung ─ + // Detailstufe = SIA-Massstab (fig. 36/37/38): + // • grob (1:100): NUR Blendrahmen-Band + eine Glaslinie über die ganze + // Öffnung — schematisch (SIA fig. 36). + // • mittel(1:50): Blendrahmen + Flügel-Trennlinien + je Flügel eine Glas- + // linie + kleines Stulp-Quadrat je Stoss (SIA fig. 37). KEINE Flügelrahmen. + // • fein (1:20): zusätzlich verschachtelte Flügelrahmen, Glas als Doppel- + // linie (Isolierverglasung) und je Stoss ZWEI Stulp-Quadrate (SIA fig. 38). if (spec && spec.sashes.length > 0) { const fw = Math.max(0.01, spec.frameWidth); const depth = Math.min(total, spec.frameDepth && spec.frameDepth > 1e-9 ? spec.frameDepth : DEFAULT_FRAME_DEPTH); @@ -375,6 +388,7 @@ export function windowSymbol( const frameOuterN = refOff + Math.max(frontRel, backRel); const frameInnerN = refOff + Math.min(frontRel, backRel); const bandDepth = frameOuterN - frameInnerN; + const gMidBase = (frameOuterN + frameInnerN) / 2; const frame = [ add(jambStart, scale(n, frameOuterN)), @@ -383,22 +397,49 @@ export function windowSymbol( add(jambStart, scale(n, frameInnerN)), ]; - const axisWidth = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y); - const segs = sashSegments(spec.sashes, axisWidth); - - // Trennlinien (Pfosten/Mittelpfosten) an den inneren Segmentgrenzen, über - // die volle Rahmentiefe. + const glassLines: [Vec2, Vec2][] = []; const mullionLines: [Vec2, Vec2][] = []; - for (let i = 0; i < segs.length - 1; i++) { - const onAxis = along(jambStart, jambEnd, segs[i].to); - mullionLines.push([add(onAxis, scale(n, frameOuterN)), add(onAxis, scale(n, frameInnerN))]); + const sashFrames: Vec2[][] = []; + const meetingMarks: Vec2[][] = []; + + // grob (1:100): nur der Rahmenkasten + EINE Glaslinie über die ganze Öffnung. + if (detail === "grob") { + glassLines.push([add(jambStart, scale(n, gMidBase)), add(jambEnd, scale(n, gMidBase))]); + return { frame, glassLines, mullionLines, sashFrames, sashOpeningLines: [], meetingMarks }; + } + + const fein = detail === "fein"; + const axisWidth = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y); + const axisDir = normalize(sub(jambEnd, jambStart)); + const segs = sashSegments(spec.sashes, axisWidth); + // Öffenbarer Flügel sitzt `reveal` tiefer im Blendrahmen (nur „fein"). + const reveal = fein ? Math.min(bandDepth * 0.3, fw * 0.7) : 0; + // Halbe Kantenlänge eines Stulp-Quadrats (SIA-Marke), an die Rahmenbreite gekoppelt. + const markHalf = Math.min(fw * 0.6, bandDepth * 0.4); + + // Flügel-Trennlinien (Mittelpfosten) an den inneren Segmentgrenzen + + // Stulp-Quadrate: mittel 1, fein 2 (die beiden zusammenstossenden Profile). + for (let i = 0; i < segs.length - 1; i++) { + const uPos = segs[i].to; + const onAxis = along(jambStart, jambEnd, uPos); + mullionLines.push([add(onAxis, scale(n, frameOuterN)), add(onAxis, scale(n, frameInnerN))]); + const center = add(onAxis, scale(n, gMidBase)); + const square = (offU: number): Vec2[] => { + const c = add(center, scale(axisDir, offU)); + return [ + add(add(c, scale(axisDir, -markHalf)), scale(n, -markHalf)), + add(add(c, scale(axisDir, markHalf)), scale(n, -markHalf)), + add(add(c, scale(axisDir, markHalf)), scale(n, markHalf)), + add(add(c, scale(axisDir, -markHalf)), scale(n, markHalf)), + ]; + }; + if (fein) { + meetingMarks.push(square(-markHalf), square(markHalf)); + } else { + meetingMarks.push(square(0)); + } } - const sashFrames: Vec2[][] = []; - const glassLines: [Vec2, Vec2][] = []; - const count = Math.max(1, glassCount); - // Öffenbarer Flügel sitzt `reveal` tiefer im Blendrahmen (n- und u-Richtung). - const reveal = Math.min(bandDepth * 0.3, fw * 0.7); for (const seg of segs) { if (seg.sash.kind === "pfosten") continue; const clearFrom = seg.from + fw; @@ -407,7 +448,9 @@ export function windowSymbol( const operable = !!seg.sash.opening && seg.sash.opening !== "fest"; const gOuterN = frameOuterN - (operable ? reveal : 0); const gInnerN = frameInnerN + (operable ? reveal : 0); - if (operable) { + // Verschachtelter Flügelrahmen NUR bei „fein" (SIA fig. 38); „mittel" bleibt + // beim reinen Blendrahmen-Band (SIA fig. 37). + if (fein && operable) { const sFrom = along(jambStart, jambEnd, clearFrom); const sTo = along(jambStart, jambEnd, clearTo); sashFrames.push([ @@ -417,22 +460,25 @@ export function windowSymbol( add(sFrom, scale(n, gInnerN)), ]); } - // Glas innerhalb des Flügels (bei öffenbar zusätzlich in u eingerückt). - const gInsetU = operable ? fw * 0.6 : 0; + const gInsetU = fein && operable ? fw * 0.6 : 0; const gFrom = clearFrom + gInsetU; const gTo = clearTo - gInsetU; if (gTo - gFrom <= 1e-6) continue; const gMid = (gOuterN + gInnerN) / 2; - const gBand = gOuterN - gInnerN; - const step = count > 1 ? Math.min(total / 10, gBand / (count + 1)) : 0; const a = along(jambStart, jambEnd, gFrom); const b = along(jambStart, jambEnd, gTo); - for (let i = 0; i < count; i++) { - const off = gMid + (count === 1 ? 0 : (i - (count - 1) / 2) * step); - glassLines.push([add(a, scale(n, off)), add(b, scale(n, off))]); + // fein: Glas als Doppellinie (Isolierverglasung, ~15 mm Abstand); mittel: + // eine Glaslinie mittig. + if (fein) { + const half = Math.min(0.0075, bandDepth * 0.18); + for (const off of [gMid - half, gMid + half]) { + glassLines.push([add(a, scale(n, off)), add(b, scale(n, off))]); + } + } else { + glassLines.push([add(a, scale(n, gMid)), add(b, scale(n, gMid))]); } } - return { frame, glassLines, mullionLines, sashFrames, sashOpeningLines: [] }; + return { frame, glassLines, mullionLines, sashFrames, sashOpeningLines: [], meetingMarks }; } // ── Alt-Pfad (kein Typ auflösbar) ──────────────────────────────────────── @@ -463,5 +509,5 @@ export function windowSymbol( mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]); } } - return { frame, glassLines, mullionLines, sashFrames: [], sashOpeningLines: [] }; + return { frame, glassLines, mullionLines, sashFrames: [], sashOpeningLines: [], meetingMarks: [] }; } diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index f5a0473..2365923 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -35,7 +35,6 @@ import { getLineStyle, getWallType, getWindowType, - glazingPanesOf, openingLabel, openingsOfWall, roofLayers, @@ -2259,13 +2258,10 @@ function addOpeningSymbol( insetFace: wt.insetFace, } : 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, spec); + // Glaslinienzahl NUR für den Alt-Pfad (kein `spec`) relevant — der typisierte + // Pfad entscheidet die Anzahl selbst nach SIA-Detailgrad (s. windowSymbol). + const glassCount = detail === "fein" ? 2 : 1; + const sym = windowSymbol(project, wall, o, glassCount, spec, detail); 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 @@ -2283,95 +2279,115 @@ function addOpeningSymbol( openingId: o.id, }); } - if (detail !== "grob") { - // Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung). + // ── SIA-Fensterdarstellung, nach Detailgrad gestaffelt ──────────────────── + // grob (1:100): weisse Öffnung + EINE dünne Glaslinie, sonst nichts. + // mittel (1:50): Blendrahmen-Rechteck + Pfosten-Trennlinien + Stulp-Mittel- + // quadrat je Flügel-Junction + Glaslinie(n). + // fein (1:20): zusätzlich Flügelrahmen, Stulp als ZWEI Rechtecke, Fensterbank + // (aussen) und Anschlag-/Laibungskerben — deutlich reicher als mittel. + const strokeColor = o.color ?? POCHE_STROKE; + const { u: wu, n: wn } = wallAxisFrame(wall); + const fpw = Math.max(0.03, wt?.frameWidth ?? 0.06); // Rahmenprofil-Breite (m) + // Kleines Rechteck (Stulp/Anschlag) um `center`, Halbmasse hu (Achse)/hn (quer). + const emitStulp = (center: Vec2, hu: number, hn: number): void => { + const c1 = add(add(center, scale(wu, -hu)), scale(wn, -hn)); + const c2 = add(add(center, scale(wu, hu)), scale(wn, -hn)); + const c3 = add(add(center, scale(wu, hu)), scale(wn, hn)); + const c4 = add(add(center, scale(wu, -hu)), scale(wn, hn)); + for (const [a, b] of [[c1, c2], [c2, c3], [c3, c4], [c4, c1]] as [Vec2, Vec2][]) { + out.push({ kind: "line", a, b, cls: "window-stulp", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id }); + } + }; + // Flügel-Junctions (Stulp-/Anschlagstelle zweier Flügel) = Mitten der Pfosten- + // Trennlinien. NUR im typisierten Pfad (`spec`) — der Alt-Pfad (reines + // `wingCount`) hat keine echte Flügeltabelle, seine Trennlinien sind nur eine + // gleichmässige Näherung ohne Stoss-Semantik (siehe windowSymbol-Kommentar). + // Bei einflügligen Fenstern leer ⇒ kein Mittelquadrat (korrekt). + const junctionCenters: Vec2[] = spec + ? sym.mullionLines.map(([a, b]) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 })) + : []; + + if (detail === "grob") { + // SIA 1:100 — nur eine dünne Glaslinie mittig über die weisse Öffnung. + const g = sym.glassLines[Math.floor(sym.glassLines.length / 2)] ?? sym.glassLines[0]; + if (g) { + out.push({ kind: "line", a: g[0], b: g[1], cls: "window-glass", weightMm: SYMBOL_HAIRLINE_MM, greyed, openingId: o.id }); + } + } else { + // Blendrahmen-Rechteck (mittel + fein). out.push({ kind: "polygon", pts: sym.frame, fill: "none", - stroke: o.color ?? POCHE_STROKE, + stroke: strokeColor, strokeWidthMm: lwMm, hatch: NO_HATCH, greyed, openingId: o.id, }); - // Flügel-/Pfosten-Trennlinien — reine Querlinien innerhalb des Rahmens - // (kein Strichmuster). Nur mittel/fein; entfällt bei grob/schema. + // Pfosten-/Flügel-Trennlinien. for (const [a, b] of sym.mullionLines) { - out.push({ - kind: "line", - a, - b, - cls: "window-mullion", - weightMm: SYMBOL_HAIRLINE_MM, - color: o.color ?? POCHE_STROKE, - greyed, - openingId: o.id, - }); + out.push({ kind: "line", a, b, cls: "window-mullion", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id }); } - // Flügelrahmen (Flügelkontur) je ÖFFENBAREM Flügel — Rechteck (4 Kanten) - // innerhalb des Blendrahmens (Schachtelung Blendrahmen → Flügelrahmen → - // Glas). Feste Flügel bekommen keinen; so liest man fest vs. öffenbar - // direkt im Plan. - for (const pts of sym.sashFrames) { - for (let i = 0; i < pts.length; i++) { - out.push({ - kind: "line", - a: pts[i], - b: pts[(i + 1) % pts.length], - cls: "window-sash", - weightMm: SYMBOL_HAIRLINE_MM, - color: o.color ?? POCHE_STROKE, - greyed, - openingId: o.id, - }); + // fein: Flügelrahmen (Flügelkontur) je öffenbarem Flügel — Schachtelung + // Blendrahmen → Flügelrahmen → Glas (Feindetail, hebt fein klar von mittel ab). + if (detail === "fein") { + for (const pts of sym.sashFrames) { + for (let i = 0; i < pts.length; i++) { + out.push({ kind: "line", a: pts[i], b: pts[(i + 1) % pts.length], cls: "window-sash", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id }); + } } } - } - for (const [a, b] of sym.glassLines) { - out.push({ - kind: "line", - a, - b, - cls: "window-glass", - weightMm: SYMBOL_HAIRLINE_MM, - greyed, - 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. - if (detail === "fein") { - const jambs = openingJambs(wall, o); - const dir = sub(wall.end, wall.start); - const len = Math.hypot(dir.x, dir.y); - if (jambs && len > 1e-9) { - const n = { x: -dir.y / len, y: dir.x / len }; - const reveal = 0.06; - for (const jamb of [jambs.jambStart, jambs.jambEnd]) { - out.push({ - kind: "line", - a: add(jamb, scale(n, reveal)), - b: add(jamb, scale(n, -reveal)), - cls: "window-frame", - weightMm: SYMBOL_HAIRLINE_MM, - color: o.color ?? POCHE_STROKE, - greyed, - openingId: o.id, - }); + // Stulp-Mittelquadrat je Flügel-Junction (SIA-Signatur): mittel = EIN Quadrat, + // fein = ZWEI anliegende Rechtecke (die beiden Stulp-Stäbe der Flügel). + for (const c of junctionCenters) { + if (detail === "fein") { + emitStulp(add(c, scale(wu, -fpw / 2)), fpw / 2, fpw / 2); + emitStulp(add(c, scale(wu, fpw / 2)), fpw / 2, fpw / 2); + } else { + emitStulp(c, fpw / 2, fpw / 2); + } + } + // Glaslinien (Anzahl je Detailgrad/Verglasung, s. glassCount). + for (const [a, b] of sym.glassLines) { + out.push({ kind: "line", a, b, cls: "window-glass", weightMm: SYMBOL_HAIRLINE_MM, greyed, openingId: o.id }); + } + // Oberlicht-Andeutung (transom, gestrichelt) — mittel/fein. + const windowFrameSpec = resolveOpeningFrameSpec(project, o); + if (windowFrameSpec) addOpeningFrameBand(out, project, wall, o, windowFrameSpec, greyed); + // fein: Fensterbank (aussen) + Anschlag-/Laibungskerben. + if (detail === "fein") { + const jb = openingJambs(wall, o); + if (jb) { + const { jambStart, jambEnd } = jb; + const total = wallTypeThickness(getWallType(project, wall)); + const refOff = wallReferenceOffset(wall, total); + const outerN = refOff + total / 2; + // Fensterbank: schmales Rechteck vor der Aussenfläche, seitlich ~3 cm + // überstehend, ~4 cm auskragend (SIA-Sims-Andeutung im Grundriss). + const ov = 0.03; + const proj = 0.04; + const s0 = along(jambStart, jambEnd, -ov); + const s1 = along(jambEnd, jambStart, -ov); + const b0 = add(s0, scale(wn, outerN)); + const b1 = add(s1, scale(wn, outerN)); + const b2 = add(s1, scale(wn, outerN + proj)); + const b3 = add(s0, scale(wn, outerN + proj)); + for (const [a, b] of [[b0, b1], [b1, b2], [b2, b3], [b3, b0]] as [Vec2, Vec2][]) { + out.push({ kind: "line", a, b, cls: "window-sill", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id }); + } + // Anschlag-Kerben an beiden Pfosten (kurz quer zur Wand). + const reveal = 0.06; + for (const jamb of [jambStart, jambEnd]) { + out.push({ kind: "line", a: add(jamb, scale(wn, reveal)), b: add(jamb, scale(wn, -reveal)), cls: "window-frame", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id }); + } } } } // Brüstungslinie (gepunktet) — zeigt die Brüstung im Grundriss auf der Wandachse. // Nur bei Fenstern mit Brüstungshöhe > 0. Entspricht der Sill-Linie in // `_make_oeffnung_preview` und dem Brüstungs-Plansymbol im Rhino-Plugin. - if (o.sillHeight > 0) { + if (o.sillHeight > 0 && detail !== "grob") { const jambs = openingJambs(wall, o); if (jambs) { out.push({ diff --git a/src/plan/generatePlan.windowFields.test.ts b/src/plan/generatePlan.windowFields.test.ts index 1b18f15..9f9bb8c 100644 --- a/src/plan/generatePlan.windowFields.test.ts +++ b/src/plan/generatePlan.windowFields.test.ts @@ -142,10 +142,12 @@ describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => { a.forEach((v, i) => expect(v).toBeCloseTo(b[i], 9)); }); - it("öffenbarer Flügel bekommt einen Flügelrahmen (window-sash, 4 Kanten); 'fest' keinen", () => { + it("öffenbarer Flügel bekommt einen Flügelrahmen (window-sash, 4 Kanten) bei 'fein'; 'fest' keinen — SIA fig. 38 (verschachtelter Flügelrahmen nur bei fein, s. sia400-fenster-tueren.md)", () => { // Ein öffenbarer + ein fester Flügel: genau EIN Flügelrahmen-Rechteck = 4 // Kanten-Linien (der feste Flügel bekommt keinen; Glas sitzt direkt im // Blendrahmen). So liest man fest vs. öffenbar direkt im Grundriss. + // Nur bei „fein" (SIA fig. 38) — „mittel" (fig. 37) bleibt beim reinen + // Blendrahmen-Band ohne verschachtelten Flügelrahmen. const wt: WindowType = { ...baseWindowType, sashes: [ @@ -154,7 +156,8 @@ describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => { ], }; const p = project({ ...baseWindow, typeId: "wt1" }, [wt]); - expect(linesOfClass(p, "window-sash").length).toBe(4); + expect(linesOfClass(p, "window-sash", "fein").length).toBe(4); + expect(linesOfClass(p, "window-sash", "mittel").length).toBe(0); // Nur feste Flügel -> gar kein Flügelrahmen. const wtFixed: WindowType = { @@ -162,12 +165,12 @@ describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => { sashes: [{ kind: "fluegel", autoWidth: true, opening: "fest" }], }; const pFixed = project({ ...baseWindow, typeId: "wt1" }, [wtFixed]); - expect(linesOfClass(pFixed, "window-sash").length).toBe(0); + expect(linesOfClass(pFixed, "window-sash", "fein").length).toBe(0); }); it("Alt-Fall (kein typeId) erzeugt KEINEN Flügelrahmen", () => { const p = project({ ...baseWindow }); - expect(linesOfClass(p, "window-sash").length).toBe(0); + expect(linesOfClass(p, "window-sash", "fein").length).toBe(0); }); }); @@ -175,14 +178,15 @@ 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", () => { + it("glazingPanes beeinflusst die 2D-Glaslinienzahl NICHT mehr — SIA-Detailgrad entscheidet (Doppellinie/IV nur bei 'fein', unabhängig von der Scheibenzahl)", () => { 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); + expect(glassLineCount(p3, "mittel")).toBe(1); + expect(glassLineCount(p1, "mittel")).toBe(1); + expect(glassLineCount(p3, "fein")).toBe(2); + expect(glassLineCount(p1, "fein")).toBe(2); }); it("ohne typeId bleibt die alte Detailgrad-Regel (grob/mittel=1, fein=2) unverändert — Regression", () => { @@ -199,6 +203,45 @@ describe("generatePlan — Verglasung glazingPanes (Item 2)", () => { }); }); +describe("generatePlan — Stulp-/Flügelstoss-Marken (SIA fig. 37/38, window-stulp)", () => { + const stulpLineCount = (p: Project, detail: "grob" | "mittel" | "fein") => + linesOfClass(p, "window-stulp", detail).length; + const twoSashType: WindowType = { + ...baseWindowType, + sashes: [ + { kind: "fluegel", autoWidth: true, opening: "dreh", hingeSide: "left" }, + { kind: "fluegel", autoWidth: true, opening: "dreh", hingeSide: "right" }, + ], + }; + + it("mittel: EIN Stulp-Quadrat (4 Linien) je Flügel-Junction", () => { + const p = project({ ...baseWindow, typeId: "wt1" }, [twoSashType]); + expect(stulpLineCount(p, "mittel")).toBe(4); + }); + + it("fein: ZWEI Stulp-Quadrate (8 Linien) je Flügel-Junction", () => { + const p = project({ ...baseWindow, typeId: "wt1" }, [twoSashType]); + expect(stulpLineCount(p, "fein")).toBe(8); + }); + + it("grob: KEINE Stulp-Marken", () => { + const p = project({ ...baseWindow, typeId: "wt1" }, [twoSashType]); + expect(stulpLineCount(p, "grob")).toBe(0); + }); + + it("Alt-Fall (kein typeId, nur wingCount) erzeugt KEINE Stulp-Marken — keine echte Flügeltabelle", () => { + const p = project({ ...baseWindow, wingCount: 3 }); + expect(stulpLineCount(p, "mittel")).toBe(0); + expect(stulpLineCount(p, "fein")).toBe(0); + }); + + it("einflügliges Fenster (keine Junction) erzeugt KEINE Stulp-Marken", () => { + const p = project({ ...baseWindow, typeId: "wt1" }, [baseWindowType]); + expect(stulpLineCount(p, "mittel")).toBe(0); + expect(stulpLineCount(p, "fein")).toBe(0); + }); +}); + describe("generatePlan — Rollladenkasten shading (Item 3)", () => { const shadingLineCount = (p: Project) => linesOfClass(p, "window-shading").length;