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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user