From 26fbd11b73f32ba91a8fcd94114cc29e16f9a723 Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 02:33:48 +0200 Subject: [PATCH] Bauteile Gruppe A (4-6): Treppe-Referenz, Fenster-Fluegel, Tuer-wandoeffnung - Treppe referenz links/mitte/rechts (Stair.referenz, Default mitte): Perp-Versatz referenzMidShift auf start/center, Lauflinie/Pfeil bleiben visuell zentriert. - Fenster Fluegel-Mittelpfosten (Opening.wingCount 1-4, Default 1): wingCount-1 Querlinien im Rahmen (window-mullion), nur mittel/fein. - Tuer-Typ wandoeffnung (Opening.doorType, Default normal): ueberspringt Tuerblatt + Schwenkbogen, Laibung/Sturz bleiben. - Alle Modellfelder optional/rueckwaertskompatibel. 17 neue Tests. vitest 292/292, tsc sauber. --- src/geometry/opening.ts | 29 ++++- src/geometry/stair.ts | 63 +++++++++-- src/geometry/stairOutline.test.ts | 65 ++++++++++- src/model/types.ts | 29 +++++ src/plan/generatePlan.openings.test.ts | 149 +++++++++++++++++++++++++ src/plan/generatePlan.ts | 57 +++++++--- 6 files changed, 361 insertions(+), 31 deletions(-) create mode 100644 src/plan/generatePlan.openings.test.ts diff --git a/src/geometry/opening.ts b/src/geometry/opening.ts index ccc4aae..746249e 100644 --- a/src/geometry/opening.ts +++ b/src/geometry/opening.ts @@ -170,12 +170,21 @@ export interface WindowSymbol { frame: Vec2[]; /** Glaslinien-Segmente quer über die Lücke (1–2 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`). + */ + mullionLines: [Vec2, Vec2][]; } /** * 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). + * `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten + * (`wingCount − 1` Querlinien). */ export function windowSymbol( project: Project, @@ -205,5 +214,21 @@ 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))]); } - return { frame, glassLines }; + // 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 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 }; } diff --git a/src/geometry/stair.ts b/src/geometry/stair.ts index 79e6b06..61c3b98 100644 --- a/src/geometry/stair.ts +++ b/src/geometry/stair.ts @@ -27,6 +27,32 @@ const norm = (a: Vec2): Vec2 => { /** Linke Normale (n = (−u.y, u.x)) — quer zur Laufrichtung. */ const leftN = (u: Vec2): Vec2 => ({ x: -u.y, y: u.x }); +/** + * Perp-Versatz von der Referenzkante zur visuellen Treppen-MITTE, entlang der + * linken Normale `n = leftN(u)`. Entspricht `mid_off` aus `_treppe_2d_side_offsets` + * im Rhino-Plugin (Konvention: `perp` = CCW-Normale = linke Normale): + * • referenz "links" → Achse ist die LINKE Kante; die Mitte liegt eine + * halbe Breite in −n-Richtung (rechts) → mid_off = −width/2. + * • referenz "rechts" → Achse ist die RECHTE Kante; die Mitte liegt eine + * halbe Breite in +n-Richtung (links) → mid_off = +width/2. + * • referenz "mitte"/undefined → Achse ist bereits die Mitte → 0. + * + * ANNAHME (selbstkritisch): „links" heisst „linke Kante in Laufrichtung", und die + * linke Kante liegt in +n (n = leftN(u), 90° links der Laufrichtung). Von der + * linken Kante zur Mitte geht es also nach −n, daher mid_off = −width/2. Das + * deckt sich mit `_treppe_2d_side_offsets` (referenz="links" → off_a=0, off_b=−b, + * Mitte = −b/2·perp). Wird die Richtung im UI als seitenverkehrt empfunden, ist + * hier das Vorzeichen zu drehen — die Geometrie bleibt in sich konsistent. + */ +function referenzMidShift(stair: Stair, u: Vec2): Vec2 { + const ref = stair.referenz ?? "mitte"; + if (ref === "mitte") return { x: 0, y: 0 }; + const n = leftN(u); + const halfW = Math.max(0.05, stair.width / 2); + const midOff = ref === "links" ? -halfW : halfW; + return scale(n, midOff); +} + /** SIA-nahe Schrittregel: gute Steigungshöhe ≈ 0.17 m, Auftritt ≈ 0.29 m. */ export const IDEAL_RISER = 0.17; export const IDEAL_TREAD = 0.29; @@ -105,13 +131,22 @@ export function stairGeometry(stair: Stair, totalRise: number): StairGeometry { const u = norm(stair.dir); const n = leftN(u); - if (stair.shape === "spiral") { - return spiralGeometry(stair, totalRise, steps, riserHeight, halfW); + // Referenz (links/mitte/rechts): die gespeicherte Achse wird auf die visuelle + // Treppen-MITTE versetzt, danach greift die bestehende, um die Achse zentrierte + // Tritt-/Lauflinien-Logik unverändert. So bleibt die Lauflinie samt Pfeil stets + // auf der Mitte (nicht auf der Referenzkante). "mitte"/undefined ⇒ Nullversatz. + const c = referenzMidShift(stair, u); + const s = stair.referenz && stair.referenz !== "mitte" + ? { ...stair, start: add(stair.start, c), center: stair.center ? add(stair.center, c) : stair.center } + : stair; + + if (s.shape === "spiral") { + return spiralGeometry(s, totalRise, steps, riserHeight, halfW); } - if (stair.shape === "L") { - return lGeometry(stair, totalRise, steps, riserHeight, halfW, u, n); + if (s.shape === "L") { + return lGeometry(s, totalRise, steps, riserHeight, halfW, u, n); } - return straightGeometry(stair, totalRise, steps, riserHeight, halfW, u, n); + return straightGeometry(s, totalRise, steps, riserHeight, halfW, u, n); } /** Gerade Treppe: gleich breite Tritte über die Lauflänge. */ @@ -530,11 +565,19 @@ export function stairOutline( ): { polygon: Vec2[] | null; spiral: SpiralOutlineSegments | null } { void totalRise; // Signatur-Konsistenz mit stairGeometry; derzeit ungenutzt const halfW = Math.max(0.05, stair.width / 2); - if (stair.shape === "spiral") { - return { polygon: null, spiral: spiralOutline(stair, halfW) }; + // Referenz (links/mitte/rechts): die gespeicherte Achse zuerst auf die + // visuelle Treppen-Mitte versetzen, danach die um die Mitte zentrierte + // Aussenlinien-Logik unverändert anwenden (identisch zu `stairGeometry`). + const u = norm(stair.dir); + const c = referenzMidShift(stair, u); + const s = stair.referenz && stair.referenz !== "mitte" + ? { ...stair, start: add(stair.start, c), center: stair.center ? add(stair.center, c) : stair.center } + : stair; + if (s.shape === "spiral") { + return { polygon: null, spiral: spiralOutline(s, halfW) }; } - if (stair.shape === "L") { - return { polygon: lOutlinePoints(stair, halfW), spiral: null }; + if (s.shape === "L") { + return { polygon: lOutlinePoints(s, halfW), spiral: null }; } - return { polygon: straightOutlinePoints(stair, halfW), spiral: null }; + return { polygon: straightOutlinePoints(s, halfW), spiral: null }; } diff --git a/src/geometry/stairOutline.test.ts b/src/geometry/stairOutline.test.ts index fe61d40..fc239c6 100644 --- a/src/geometry/stairOutline.test.ts +++ b/src/geometry/stairOutline.test.ts @@ -11,7 +11,7 @@ */ import { describe, it, expect } from "vitest"; -import { stairOutline } from "./stair"; +import { stairOutline, stairGeometry } from "./stair"; import type { Stair, Vec2 } from "../model/types"; /** Einfache Distanz-Funktion. */ @@ -146,3 +146,66 @@ describe("stairOutline — Wendeltreppe", () => { expect(d).toBeCloseTo(arcIn.r, 4); }); }); + +describe("stairOutline — Referenz links/mitte/rechts (Item 4)", () => { + const totalRise = 3.0; + // Gerade Treppe entlang +X, Breite 1.2 m. Perp (linke Normale) = (0, +1). + const base: Stair = { ...geradeStair, referenz: "mitte" }; + const mitte = stairOutline(base, totalRise).polygon!; + const links = stairOutline({ ...base, referenz: "links" }, totalRise).polygon!; + const rechts = stairOutline({ ...base, referenz: "rechts" }, totalRise).polygon!; + + /** Mittlere Y-Höhe eines Rechteck-Polygons. */ + const midY = (pts: Vec2[]) => pts.reduce((s, p) => s + p.y, 0) / pts.length; + + it("Breite bleibt bei allen Referenzen gleich", () => { + for (const poly of [mitte, links, rechts]) { + expect(dist(poly[0], poly[1])).toBeCloseTo(geradeStair.width, 5); + } + }); + + it("referenz=links: Treppe liegt RECHTS der Achse (−n) → Mitte um width/2 nach −Y", () => { + // Achse (start/dir) = linke Kante; die Fläche versetzt sich in −n = (0,−1). + expect(midY(links) - midY(mitte)).toBeCloseTo(-geradeStair.width / 2, 5); + }); + + it("referenz=rechts: Treppe liegt LINKS der Achse (+n) → Mitte um width/2 nach +Y", () => { + expect(midY(rechts) - midY(mitte)).toBeCloseTo(+geradeStair.width / 2, 5); + }); + + it("bei referenz=links liegt die Achse (y=0) auf der linken Kante der Treppe", () => { + // Linke Kante = max-Y-Kante der Fläche (n = +Y). Sie soll bei y=0 liegen. + const maxY = Math.max(...links.map((p) => p.y)); + expect(maxY).toBeCloseTo(0, 5); + }); + + it("mitte entspricht dem Default (kein referenz-Feld gesetzt)", () => { + const noRef = stairOutline({ ...geradeStair }, totalRise).polygon!; + for (let i = 0; i < noRef.length; i++) { + expect(noRef[i].x).toBeCloseTo(mitte[i].x, 6); + expect(noRef[i].y).toBeCloseTo(mitte[i].y, 6); + } + }); +}); + +describe("stairGeometry — Lauflinie bleibt visuell zentriert (Item 4)", () => { + const totalRise = 3.0; + const links = stairGeometry({ ...geradeStair, referenz: "links" }, totalRise); + const mitte = stairGeometry({ ...geradeStair, referenz: "mitte" }, totalRise); + + it("Lauflinie liegt auf der Treppen-Mitte, nicht auf der Referenzkante (y=0)", () => { + // Bei referenz=links liegt die Mitte bei y = −width/2 = −0.6. + for (const p of links.runLine) { + expect(p.y).toBeCloseTo(-geradeStair.width / 2, 5); + } + // Bei mitte liegt die Lauflinie auf y=0 (Achse = Mitte). + for (const p of mitte.runLine) { + expect(p.y).toBeCloseTo(0, 5); + } + }); + + it("Tritte sind bei referenz=links um −width/2 versetzt", () => { + const dy = links.treads[0].pts[0].y - mitte.treads[0].pts[0].y; + expect(dy).toBeCloseTo(-geradeStair.width / 2, 5); + }); +}); diff --git a/src/model/types.ts b/src/model/types.ts index 2fa296e..d00849c 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -731,6 +731,22 @@ export interface Opening { height: number; /** Brüstungshöhe (Unterkante der Öffnung) über der Wand-UK; 0 bei Türen. */ sillHeight: number; + /** + * Nur Fenster: Anzahl der Flügel (1–4). Bei > 1 werden im Plan `wingCount − 1` + * Mittelpfosten (Querlinien quer zur Öffnungsrichtung) gleichmäßig innerhalb des + * Rahmens gezeichnet — analog den Mittelpfosten in `_make_oeffnung_pieces` des + * Rhino-Plugins (`oeff_fluegel`). Fehlt es, gilt 1 (= heutiges Verhalten: keine + * Pfosten). + */ + wingCount?: number; + /** + * Nur Tür: Tür-Typ (analog `oeff_tuer_typ` im Rhino-Plugin). + * • "normal" — Türblatt + Schwenkbogen (Default = heutiges Verhalten). + * • "wandoeffnung" — reiner Wanddurchbruch: KEIN Türblatt, KEIN Schwenkbogen; + * nur die Öffnung/Laibung (Wandlücke + ggf. Sturz-/Anschlaglinien) bleibt. + * Fehlt es, gilt "normal". + */ + doorType?: "normal" | "wandoeffnung"; /** Nur Tür: an welchem Pfosten das Scharnier sitzt. */ hinge?: "start" | "end"; /** Nur Tür: auf welche Seite der Wandachse das Blatt aufschlägt. */ @@ -814,6 +830,19 @@ export interface Stair { sweep?: number; /** Laufbreite in Metern (quer zur Laufrichtung). */ width: number; + /** + * Lage der gespeicherten Achse (`start`/`dir` bzw. Wendel-Bogen) über die + * Laufbreite — analog `treppe_referenz` im Rhino-Plugin. Bestimmt, ob die Achse + * die LINKE, MITTLERE oder RECHTE Kante der Treppe repräsentiert (in + * Laufrichtung gesehen). Fehlt sie, gilt "mitte" (= heutiges Verhalten: Achse = + * Treppen-Mitte). + * • "links" — Achse = linke Kante; die Treppe liegt rechts der Achse. + * • "mitte" — Achse = Mitte (Default). + * • "rechts" — Achse = rechte Kante; die Treppe liegt links der Achse. + * Die Lauflinie samt Pfeil bleibt IMMER auf der visuellen Treppen-Mitte + * (nicht auf der Referenzkante) — siehe `stairGeometry`. + */ + referenz?: "links" | "mitte" | "rechts"; /** * Gesamt-Steighöhe in Metern (OKFF → OKFF nächstes Geschoss). Fehlt sie, gilt * beim Auflösen die Geschosshöhe des zugehörigen Geschosses. diff --git a/src/plan/generatePlan.openings.test.ts b/src/plan/generatePlan.openings.test.ts new file mode 100644 index 0000000..0536fa8 --- /dev/null +++ b/src/plan/generatePlan.openings.test.ts @@ -0,0 +1,149 @@ +/** + * Unit-Tests für die Öffnungs-Symbole im Grundriss (generatePlan): + * • Item 5 — Fenster Flügel-Mittelpfosten: bei wingCount=3 werden genau 2 + * Pfosten-Linien (Querlinien) erzeugt; wingCount=1/undefined ⇒ keine. + * • Item 6 — Tür-Typ „wandoeffnung": kein Türblatt, kein Schwenkbogen (arc); + * „normal" (Default) zeichnet Blatt + Bogen wie bisher. + */ + +import { describe, it, expect } from "vitest"; +import { generatePlan } from "./generatePlan"; +import type { Opening, Project, Wall } from "../model/types"; + +/** Minimalprojekt mit einer Wand + einer Öffnung, konfigurierbar. */ +function projectWithOpening(opening: Opening): 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 }] }], + 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). */ +const baseWindow: Opening = { + id: "F1", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + position: 1.0, + width: 1.6, + height: 1.2, + sillHeight: 0, +}; + +/** Basis-Tür. */ +const baseDoor: Opening = { + id: "T1", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "door", + position: 1.0, + width: 0.9, + height: 2.0, + sillHeight: 0, + swing: "left", + hinge: "start", +}; + +/** Zählt Mittelpfosten-Linien (cls "window-mullion") im Plan. */ +const mullionCount = (p: Project) => + generatePlan(p, "eg", visible, undefined, "mittel").primitives.filter( + (pr) => pr.kind === "line" && pr.cls === "window-mullion", + ).length; + +describe("generatePlan — Fenster Flügel-Mittelpfosten (Item 5)", () => { + it("wingCount=3 erzeugt genau 2 Pfosten-Linien", () => { + expect(mullionCount(projectWithOpening({ ...baseWindow, wingCount: 3 }))).toBe(2); + }); + + it("wingCount=1 erzeugt keine Pfosten", () => { + expect(mullionCount(projectWithOpening({ ...baseWindow, wingCount: 1 }))).toBe(0); + }); + + it("ohne wingCount (Default) keine Pfosten — rückwärtskompatibel", () => { + expect(mullionCount(projectWithOpening({ ...baseWindow }))).toBe(0); + }); + + it("wingCount=4 erzeugt 3 Pfosten", () => { + expect(mullionCount(projectWithOpening({ ...baseWindow, wingCount: 4 }))).toBe(3); + }); + + it("Pfosten sind reine Linien ohne Strichmuster (kein dash)", () => { + const plan = generatePlan(projectWithOpening({ ...baseWindow, wingCount: 3 }), "eg", visible, undefined, "mittel"); + const mullions = plan.primitives.filter( + (pr) => pr.kind === "line" && pr.cls === "window-mullion", + ); + for (const m of mullions) { + expect((m as { dash?: number[] | null }).dash ?? null).toBeNull(); + } + }); + + it("bei grob keine Pfosten (nur mittel/fein)", () => { + const plan = generatePlan(projectWithOpening({ ...baseWindow, wingCount: 3 }), "eg", visible, undefined, "grob"); + const n = plan.primitives.filter((pr) => pr.kind === "line" && pr.cls === "window-mullion").length; + expect(n).toBe(0); + }); +}); + +describe("generatePlan — Tür-Typ wandoeffnung (Item 6)", () => { + /** Zählt Bögen (arc-Primitive) im Plan. */ + const arcCount = (p: Project, detail: "grob" | "mittel" | "fein" = "mittel") => + generatePlan(p, "eg", visible, undefined, detail).primitives.filter((pr) => pr.kind === "arc").length; + /** Zählt Türblatt-Linien (cls "door-leaf"). */ + const leafCount = (p: Project) => + generatePlan(p, "eg", visible, undefined, "mittel").primitives.filter( + (pr) => pr.kind === "line" && pr.cls === "door-leaf", + ).length; + + it("wandoeffnung erzeugt KEINE Bögen (arc)", () => { + expect(arcCount(projectWithOpening({ ...baseDoor, doorType: "wandoeffnung" }))).toBe(0); + }); + + it("wandoeffnung erzeugt KEIN Türblatt", () => { + expect(leafCount(projectWithOpening({ ...baseDoor, doorType: "wandoeffnung" }))).toBe(0); + }); + + it("normal (Default) erzeugt Türblatt + Schwenkbogen", () => { + const normal = projectWithOpening({ ...baseDoor, doorType: "normal" }); + expect(leafCount(normal)).toBe(1); + expect(arcCount(normal)).toBe(1); + }); + + it("ohne doorType (Default) verhält sich wie normal — rückwärtskompatibel", () => { + const def = projectWithOpening({ ...baseDoor }); + expect(leafCount(def)).toBe(1); + expect(arcCount(def)).toBe(1); + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 2908307..eb3907a 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -1588,29 +1588,36 @@ function addOpeningSymbol( if (o.kind === "door") { const sym = doorSymbol(wall, o); if (!sym) return; - // Türblatt (alle Stufen). - out.push({ - kind: "line", - a: sym.hinge, - b: sym.openEnd, - cls: "door-leaf", - weightMm: SYMBOL_HAIRLINE_MM, - greyed, - openingId: o.id, - }); - // mittel + fein: Schwenkbogen von geschlossen → offen. - if (detail !== "grob") { + // Tür-Typ „wandoeffnung": reiner Wanddurchbruch — KEIN Türblatt, KEIN + // Schwenkbogen. Nur die Öffnung/Laibung bleibt (die Wandlücke kommt aus der + // ausgesparten Poché; Sturz-/Anschlaglinien werden weiter unten gezeichnet). + // Entspricht `oeff_tuer_typ == "wandoeffnung"` im Rhino-Plugin. + const isWandoeffnung = (o.doorType ?? "normal") === "wandoeffnung"; + if (!isWandoeffnung) { + // Türblatt (alle Stufen). out.push({ - kind: "arc", - center: sym.hinge, - from: sym.closedEnd, - to: sym.openEnd, - r: sym.radius, - cls: "door-swing", + kind: "line", + a: sym.hinge, + b: sym.openEnd, + cls: "door-leaf", weightMm: SYMBOL_HAIRLINE_MM, greyed, openingId: o.id, }); + // mittel + fein: Schwenkbogen von geschlossen → offen. + if (detail !== "grob") { + out.push({ + kind: "arc", + center: sym.hinge, + from: sym.closedEnd, + to: sym.openEnd, + r: sym.radius, + cls: "door-swing", + weightMm: SYMBOL_HAIRLINE_MM, + greyed, + openingId: o.id, + }); + } } // fein: Anschlag-Striche an beiden Pfosten (quer zur Wand). if (detail === "fein") { @@ -1697,6 +1704,20 @@ 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. + 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, + }); + } } for (const [a, b] of sym.glassLines) { out.push({