Fenster: echte Sprossen-Spalten (mullionCols) analog Kämpfer-Zeilen

Vertikale Sprossenteilung im Glasfeld (mullionCols−1 Spalten), spiegelbildlich
zur bestehenden mullionRows-Logik: 3D-Rahmen-Riegel, Ansichts-Trennlinien +
Glasscheiben als echtes rows×cols-Raster, Eingabefeld im Fenstertyp-Editor und
im ResourceManager.
This commit is contained in:
2026-07-12 17:38:21 +02:00
parent 61313777da
commit 00bff27b02
9 changed files with 212 additions and 8 deletions
+1
View File
@@ -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",
+1
View File
@@ -408,6 +408,7 @@ export const en: Record<TranslationKey, string> = {
"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",
+6
View File
@@ -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). */
+47 -1
View File
@@ -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];
+17 -1
View File
@@ -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) {
+89 -6
View File
@@ -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 (wingCount1)
// 1 Kämpfer (mullionRows1)
// → 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
// (mullionCols1) 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", () => {
+13
View File
@@ -2035,6 +2035,8 @@ interface OpeningFrameParams {
wingCount: number;
/** Kämpfer-Zeilen (horizontale Teilung = mullionRows1); Türen immer 1. */
mullionRows: number;
/** Sprossen-Spalten (vertikale Teilung = mullionCols1); 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, mullionCols1) 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
+29
View File
@@ -492,6 +492,13 @@ function FluegelPanel({
min={1}
onCommit={(n) => patchWt({ mullionRows: Math.max(1, Math.round(n)) })}
/>
<NumField
label={t("oed.f.mullionCols")}
value={wt.mullionCols ?? 1}
step={1}
min={1}
onCommit={(n) => patchWt({ mullionCols: Math.max(1, Math.round(n)) })}
/>
<table className="oed-table">
<thead>
<tr>
@@ -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) => (
<line
key={k}
x1={pad + (fieldW * (k + 1)) / cols}
y1={top}
x2={pad + (fieldW * (k + 1)) / cols}
y2={top + fieldH}
stroke={strokeMuted}
strokeWidth={0.75}
/>
));
})()}
</>
)}
</svg>
+9
View File
@@ -2336,6 +2336,15 @@ function WindowStylesTab({
max={4}
/>
</FieldRow>
<FieldRow label={t("resources.field.mullionCols")}>
<NumberField
value={win.mullionCols ?? 1}
onChange={(mullionCols) => patch({ mullionCols: Math.max(1, Math.round(mullionCols)) })}
step={1}
min={1}
max={4}
/>
</FieldRow>
<FieldRow label={t("resources.field.glazing")}>
<SelectField
value={win.glazing}