diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 67aab4b..1348ee4 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -408,6 +408,7 @@ export const de = { "oed.f.frameWidth": "Rahmenbreite Ansicht (m)", "oed.f.transomHeight": "Oberlichthöhe (m)", "oed.f.mullionRows": "Kämpfer-/Sprossenzeilen", + "oed.f.mullionCols": "Sprossen-Spalten", "oed.kind.dreh": "Dreh", "oed.kind.kipp": "Kipp", "oed.kind.drehkipp": "Dreh-Kipp", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 06be379..6cbf05c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -408,6 +408,7 @@ export const en: Record = { "oed.f.frameWidth": "Frame face width (m)", "oed.f.transomHeight": "Transom height (m)", "oed.f.mullionRows": "Transom/muntin rows", + "oed.f.mullionCols": "Muntin columns", "oed.kind.dreh": "Casement", "oed.kind.kipp": "Tilt", "oed.kind.drehkipp": "Turn-tilt", diff --git a/src/model/types.ts b/src/model/types.ts index 2b92396..fdf6b27 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -427,6 +427,12 @@ export interface WindowType { * Sprossen-/Flügelraster. Fehlt es, gilt 1. */ mullionRows?: number; + /** + * Anzahl vertikaler Felder (Sprossen-Spalten): 1 = keine Querteilung, 2 = ein + * Sprossen-Mittelpunkt (Spalte), usw. Zusammen mit {@link wingCount} (Spalten) + * ergibt sich das Sprossen-/Flügelraster. Fehlt es, gilt 1. + */ + mullionCols?: number; /** Verglasung: Einfach/Zweifach/Dreifach (3D-Scheibenzahl). */ glazing: "einfach" | "zweifach" | "dreifach"; /** Rahmenstärke quer zur Wand (Meter). */ diff --git a/src/plan/toElevation.test.ts b/src/plan/toElevation.test.ts index 972b850..aaeb376 100644 --- a/src/plan/toElevation.test.ts +++ b/src/plan/toElevation.test.ts @@ -400,7 +400,7 @@ describe("toElevation — Öffnungen: Verdeckung + Reveal-Schatten", () => { it("Kämpfer-Zeilen (mullionRows): teilen das Glasfeld horizontal (Glas + Trennlinien)", () => { const base = elevationProject({ overhang: false }); - const mk = (mullionRows: number): Project => + const mk = (mullionRows: number): Project => ({ ...base, windowTypes: [ @@ -444,6 +444,52 @@ describe("toElevation — Öffnungen: Verdeckung + Reveal-Schatten", () => { expect(glassCount(3)).toBe(3); // in 3 Zeilen geteilt }); + it("Sprossen-Spalten (mullionCols): teilen das Glasfeld vertikal (Glas + Trennlinien)", () => { + const base = elevationProject({ overhang: false }); + const mk = (mullionCols: number): Project => + ({ + ...base, + windowTypes: [ + { + id: "wt", + name: "F", + kind: "fest", + wingCount: 1, + glazing: "einfach", + frameWidth: 0.06, + mullionCols, + defaultSillHeight: 0.9, + defaultWidth: 1, + defaultHeight: 1.2, + }, + ], + openings: [ + { + id: "op", + type: "opening", + hostWallId: "NEAR", + categoryCode: "21", + kind: "window", + typeId: "wt", + position: 1.5, + width: 1, + height: 1.2, + sillHeight: 0.9, + }, + ], + }) as unknown as Project; + const glassCount = (cols: number): number => { + const p = mk(cols); + const plan = generateElevationPlan(p, p.drawingLevels[1])!; + // Glasflächen = weiss/blau gefüllte Polygone mit Kontur, keine Öffnung/Rahmen. + return plan.primitives.filter( + (pr) => pr.kind === "polygon" && pr.fill === "#9fc2d9", + ).length; + }; + expect(glassCount(1)).toBe(1); // fest, 1 Feld + expect(glassCount(3)).toBe(3); // in 3 Spalten geteilt + }); + it("Reveal-Schatten: shadows=true erzeugt Öffnungs-Schattenbänder, false keine", () => { const project = openingProject(); const level = project.drawingLevels[1]; diff --git a/src/plan/toElevation.ts b/src/plan/toElevation.ts index 9c24674..074fac2 100644 --- a/src/plan/toElevation.ts +++ b/src/plan/toElevation.ts @@ -776,16 +776,32 @@ function collectOpenings(project: Project, frame: ElevationFrame): ProjectedOpen // horizontale Scheiben geteilt (analog zum 3D, s. toWalls3d), mit einer // Trennlinie je Zeilengrenze über die Glasbreite. 1 = ungeteilt (bisher). const rows = Math.max(1, Math.round(wt?.mullionRows ?? 1)); + // Sprossen-Spalten (mullionCols): das Glasfeld wird in `cols` vertikale + // Scheiben geteilt (analog zum 3D, s. toWalls3d), mit einer + // Trennlinie je Spaltengrenze über die Glasscheibenhöhe. + const cols = Math.max(1, Math.round(wt?.mullionCols ?? 1)); if (gB - gA > 1e-6 && gz1 - gz0 > 1e-6) { + // Glasfelder: rows × cols Gitter aus Einzelscheiben. for (let r = 0; r < rows; r++) { const rz0 = gz0 + ((gz1 - gz0) * r) / rows + (r > 0 ? gV / 2 : 0); const rz1 = gz0 + ((gz1 - gz0) * (r + 1)) / rows - (r < rows - 1 ? gV / 2 : 0); - if (rz1 - rz0 > 1e-6) pr.glass.push(rect(pr.pts, gA, gB, rz0, rz1)); + if (rz1 - rz0 <= 1e-6) continue; + for (let c = 0; c < cols; c++) { + const ca0 = gA + ((gB - gA) * c) / cols + (c > 0 ? gU / 2 : 0); + const ca1 = gA + ((gB - gA) * (c + 1)) / cols - (c < cols - 1 ? gU / 2 : 0); + if (ca1 - ca0 > 1e-6) pr.glass.push(rect(pr.pts, ca0, ca1, rz0, rz1)); + } } + // Horizontale Trennlinien (Kämpfer) for (let r = 1; r < rows; r++) { const rz = gz0 + ((gz1 - gz0) * r) / rows; pr.divisions.push([quadAt(pr.pts, gA, rz), quadAt(pr.pts, gB, rz)]); } + // Vertikale Trennlinien (Sprossen-Spalten) + for (let c = 1; c < cols; c++) { + const ca = gA + ((gB - gA) * c) / cols; + pr.divisions.push([quadAt(pr.pts, ca, gz0), quadAt(pr.pts, ca, gz1)]); + } } // Trennlinie zum nächsten Feld (ausser am Rand). if (fa1 < 1 - fa - 1e-6) { diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index bc177db..122b2d4 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -527,7 +527,7 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( ...(sampleProject.windowTypes ?? []), { id: "win-mullion-test", - name: "Test Sprossen", + name: "Test Fenster 2-flg", kind: "fest", wingCount: 2, mullionRows: 2, @@ -541,7 +541,7 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( ], openings: [ { - id: "OM", + id: "OB", type: "opening", hostWallId: "W1", categoryCode: "21", @@ -556,10 +556,93 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( doors: [], context: [], }; - const baseCount = projectToModel3d(baseline).meshes.filter(isFrame).length; - const mullionCount = projectToModel3d(withMullions).meshes.filter(isFrame).length; - // +1 Mittelpfosten (wingCount-1) +1 Kämpfer-Zeile (mullionRows-1). - expect(mullionCount).toBe(baseCount + 2); + const baselineFrames = projectToModel3d(baseline).meshes.filter(isFrame); + const withMullionsFrames = projectToModel3d(withMullions).meshes.filter(isFrame); + // Mit den Mittelpfosten (wingCount-1) und Kämpfern (mullionRows-1): + // 1 Flügelpfosten (wingCount−1) + // 1 Kämpfer (mullionRows−1) + // → zusammen 2 Riegel mehr als Basis (1×1 =1). + expect(withMullionsFrames.length).toBe(baselineFrames.length + 2); + }); + + it("wingCount=2 + mullionCols=2 -> je ein zusätzlicher Sprossen-Spalte", () => { + // Test um mullionCols zu prüfen (analog obigem mullionRows-Test). + const baseline: Project = { + ...sampleProject, + windowTypes: [ + ...(sampleProject.windowTypes ?? []), + { + id: "win-fixed-base", + name: "Test Fest 1-flg", + kind: "fest", + wingCount: 1, + mullionCols: 1, + glazing: "einfach", + frameThickness: 0.06, + frameWidth: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.0, + defaultHeight: 1.2, + }, + ], + openings: [ + { + id: "OB", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + typeId: "win-fixed-base", + position: 3.0, + width: 1.0, + height: 1.2, + sillHeight: 0.9, + }, + ], + doors: [], + context: [], + }; + const withMullions: Project = { + ...sampleProject, + windowTypes: [ + ...(sampleProject.windowTypes ?? []), + { + id: "win-mullion-test", + name: "Test Fest 1-flg mit Sprossen-Spalte", + kind: "fest", + wingCount: 1, + mullionCols: 2, + glazing: "einfach", + frameThickness: 0.06, + frameWidth: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.0, + defaultHeight: 1.2, + }, + ], + openings: [ + { + id: "OB", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + typeId: "win-mullion-test", + position: 3.0, + width: 1.0, + height: 1.2, + sillHeight: 0.9, + }, + ], + doors: [], + context: [], + }; + const baselineFrames = projectToModel3d(baseline).meshes.filter(isFrame); + const withMullionsFrames = projectToModel3d(withMullions).meshes.filter(isFrame); + // wingCount bleibt in beiden Fällen 1 (isoliert den mullionCols-Effekt von + // wingCount, s. separater Mittelpfosten-Test oben): nur die Sprossen-Spalte + // (mullionCols−1) macht den Unterschied -> 1 Riegel mehr als Basis. + expect(withMullionsFrames.length).toBe(baselineFrames.length + 1); }); it("öffenbarer Flügel bekommt einen Flügelrahmen-Ring (4 Riegel) im 3D; fest keinen", () => { diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 7c74a12..fdd80e4 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -2035,6 +2035,8 @@ interface OpeningFrameParams { wingCount: number; /** Kämpfer-Zeilen (horizontale Teilung = mullionRows−1); Türen immer 1. */ mullionRows: number; + /** Sprossen-Spalten (vertikale Teilung = mullionCols−1); Türen immer 1. */ + mullionCols: number; /** Ob eine Glasscheibe gezeichnet wird (Fenster immer, Tür nur `leafStyle: "glas"`). */ glazed: boolean; /** @@ -2094,6 +2096,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening // Zweiflüglige Tür ⇒ 2 Flügel = ein mittiger Anschlagpfosten (Stulp) im 3D. wingCount: Math.max(1, Math.round(dt.leafCount ?? 1)), mullionRows: 1, + mullionCols: 1, glazed: dt.leafStyle === "glas", glazingPanes: 1, // Teilverglasung: glazingRatio (Glasanteil oben) bei Glasblatt; sonst voll. @@ -2139,6 +2142,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening hasSill: true, wingCount, mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)), + mullionCols: Math.max(1, Math.round(wt.mullionCols ?? 1)), glazed: true, glazingPanes: glazingPanesOf(wt), glazedFraction: 1, @@ -2232,6 +2236,15 @@ function frameMeshesForOpening( push(sashFrom, sashTo, center - fw / 2, center + fw / 2); } } + + // Sprossen-Spalten (vertikal, mullionCols−1) im Sash-Bereich. + if (params.mullionCols > 1 && sashTo - sashFrom > EPS) { + const cell = (sashTo - sashFrom) / params.mullionCols; + for (let i = 1; i < params.mullionCols; i++) { + const center = sashFrom + i * cell; + push(center - fw / 2, center + fw / 2, sashBottom, sashTop); + } + } } // Flügelrahmen je ÖFFENBAREM Flügel: ein eigener Rahmen-Riegel-Ring (links/ // rechts/oben/unten), der DIREKT am Blendrahmen ansetzt (kein Spalt — sonst diff --git a/src/ui/OpeningEditorDialog.tsx b/src/ui/OpeningEditorDialog.tsx index bdeebb5..21a9876 100644 --- a/src/ui/OpeningEditorDialog.tsx +++ b/src/ui/OpeningEditorDialog.tsx @@ -492,6 +492,13 @@ function FluegelPanel({ min={1} onCommit={(n) => patchWt({ mullionRows: Math.max(1, Math.round(n)) })} /> + patchWt({ mullionCols: Math.max(1, Math.round(n)) })} + /> @@ -879,6 +886,28 @@ function ElevationPreview({ /> )); })()} + {/* Sprossen-Spalten (mullionCols − 1 vertikale Teiler im + Hauptfeld unterhalb eines etwaigen Oberlichts). */} + {!dt && + wt && + (() => { + const cols = Math.max(1, Math.round(wt.mullionCols ?? 1)); + if (cols <= 1) return null; + const top = originY + px(wt.transomHeight ?? 0); + const fieldH = px(h) - px(wt.transomHeight ?? 0); + const fieldW = px(w); + return Array.from({ length: cols - 1 }, (_, k) => ( + + )); + })()} )} diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx index 312463c..899794c 100644 --- a/src/ui/ResourceManager.tsx +++ b/src/ui/ResourceManager.tsx @@ -2336,6 +2336,15 @@ function WindowStylesTab({ max={4} /> + + patch({ mullionCols: Math.max(1, Math.round(mullionCols)) })} + step={1} + min={1} + max={4} + /> +