Fenster: echte Schachtelung Blendrahmen → Flügelrahmen → Glas (2D+3D)

Öffenbare vs. feste Flügel sind jetzt unterscheidbar, und die Einbaulage
(Schichteinzug) wirkt auch im 2D-Grundriss.

2D (windowSymbol/generatePlan):
- Der reiche Pfad (typisiertes Fenster) zeichnet den Blendrahmen an der REALEN
  Einbaulage (insetFace/insetFromFace, vorher ignoriert), je Flügel einen
  Flügelrahmen (window-sash) nur wenn ÖFFENBAR, und das Glas darin. Feste
  Flügel: Glas direkt im Blendrahmen.
- Die alte Diagonale quer über die Box (sah aus wie durchgestrichen) entfernt —
  Öffenbarkeit wird jetzt über den Flügelrahmen ausgedrückt.
- Alt-Pfad (kein Typ) byte-identisch → kernel2d-Parität unberührt.

3D (frameMeshesForOpening/resolveOpeningFrame):
- Je öffenbarem Flügel ein Flügelrahmen-Ring (4 Riegel) im Blendrahmen (fein).
- Flügeltabelle konsistent aus explizitem sashes bzw. effektivem wingCount
  (Instanz-Override wirkt jetzt auch auf Flügelrahmen); wingCount daraus abgeleitet.

Tests angepasst/ergänzt (fest vs. öffenbar, Zählungen). 663/663 grün.
This commit is contained in:
2026-07-10 01:47:27 +02:00
parent d131683304
commit 9a38636bf2
5 changed files with 332 additions and 77 deletions
+27 -16
View File
@@ -2218,14 +2218,22 @@ function addOpeningSymbol(
// Flügeltabelle NUR übergeben, wenn ein Fenstertyp auflösbar ist — fehlt er
// (kein `typeId`/unbekannt), bleibt `windowSymbol` beim reinen
// `o.wingCount`-Alt-Verhalten (siehe dortiger Kommentar, Regressionssicherheit).
const sashes = wt ? sashesOfWindowType(wt) : undefined;
const spec = wt
? {
sashes: sashesOfWindowType(wt),
frameWidth: wt.frameWidth ?? 0.06,
frameDepth: wt.frameDepth,
insetFromFace: wt.insetFromFace,
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, sashes);
const sym = windowSymbol(project, wall, o, glassCount, spec);
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
@@ -2269,20 +2277,23 @@ function addOpeningSymbol(
openingId: o.id,
});
}
// Öffnungsandeutung je NICHT-festem Flügel (Dreh/Kipp/Drehkipp/Schiebe) —
// schlichte Diagonale zur Bandseite, nur wenn ein Fenstertyp mit
// Flügeltabelle referenziert ist (siehe windowSymbol/sashOpeningLines).
for (const [a, b] of sym.sashOpeningLines) {
out.push({
kind: "line",
a,
b,
cls: "window-opening",
weightMm: SYMBOL_HAIRLINE_MM,
color: o.color ?? POCHE_STROKE,
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,
});
}
}
}
for (const [a, b] of sym.glassLines) {
+15 -4
View File
@@ -142,7 +142,10 @@ describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => {
a.forEach((v, i) => expect(v).toBeCloseTo(b[i], 9));
});
it("Flügel mit opening !== 'fest' erzeugt eine Öffnungsandeutung (window-opening); 'fest' keine", () => {
it("öffenbarer Flügel bekommt einen Flügelrahmen (window-sash, 4 Kanten); 'fest' keinen", () => {
// 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.
const wt: WindowType = {
...baseWindowType,
sashes: [
@@ -151,12 +154,20 @@ describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => {
],
};
const p = project({ ...baseWindow, typeId: "wt1" }, [wt]);
expect(linesOfClass(p, "window-opening").length).toBe(1);
expect(linesOfClass(p, "window-sash").length).toBe(4);
// Nur feste Flügel -> gar kein Flügelrahmen.
const wtFixed: WindowType = {
...baseWindowType,
sashes: [{ kind: "fluegel", autoWidth: true, opening: "fest" }],
};
const pFixed = project({ ...baseWindow, typeId: "wt1" }, [wtFixed]);
expect(linesOfClass(pFixed, "window-sash").length).toBe(0);
});
it("Alt-Fall (kein typeId) erzeugt KEINE Öffnungsandeutung", () => {
it("Alt-Fall (kein typeId) erzeugt KEINEN Flügelrahmen", () => {
const p = project({ ...baseWindow });
expect(linesOfClass(p, "window-opening").length).toBe(0);
expect(linesOfClass(p, "window-sash").length).toBe(0);
});
});
+86 -8
View File
@@ -386,8 +386,9 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
it("typisiertes Fenster (O1, Typ window-standard) -> Rahmen-Riegel mit endlichen Positionen innerhalb der Öffnungshöhe", () => {
// O1: sillHeight 0.9, height 1.2 -> Öffnung UK 0.9 .. OK 2.1. Typ "window-
// standard" hat wingCount 1, kein mullionRows/transomHeight -> nur die 4
// Basis-Riegel (2 Pfosten + Sturz + Schwelle), keine Sprossen/Kämpfer.
// standard" hat wingCount 1, kein mullionRows/transomHeight -> 4 Basis-Riegel
// (2 Pfosten + Sturz + Schwelle). "drehkipp" ist ÖFFENBAR -> zusätzlich ein
// Flügelrahmen-Ring (4 Riegel) im Blendrahmen = 8 Riegel gesamt (fein).
const p: Project = {
...sampleProject,
openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"),
@@ -396,7 +397,7 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
};
const { meshes } = projectToModel3d(p);
const frames = meshes.filter(isFrame);
expect(frames.length).toBe(4);
expect(frames.length).toBe(8);
for (const f of frames) {
expect(f.positions.every((v) => Number.isFinite(v))).toBe(true);
const { min, max } = zRange(f);
@@ -405,8 +406,8 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
}
});
it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen, fein=+Mittelpfosten)", () => {
// O1 mit erzwungenem wingCount 2 -> im FEIN-Fall ein Flügel-Mittelpfosten extra.
it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen, fein=+Mittelpfosten+Flügelrahmen)", () => {
// O1 mit erzwungenem wingCount 2 (window-standard "drehkipp", öffenbar).
const o1 = (sampleProject.openings ?? []).find((o) => o.id === "O1");
if (!o1) throw new Error("O1 fehlt im sampleProject");
const p: Project = {
@@ -418,8 +419,10 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
const frameCount = (detail: "grob" | "mittel" | "fein"): number =>
projectToModel3d(p, { detail }).meshes.filter(isFrame).length;
expect(frameCount("grob")).toBe(0); // keine Rahmen/Sprossen
expect(frameCount("mittel")).toBe(4); // 2 Pfosten + Sturz + Schwelle, KEIN Mittelpfosten
expect(frameCount("fein")).toBe(5); // + 1 Flügel-Mittelpfosten (wingCount 2)
expect(frameCount("mittel")).toBe(4); // 2 Pfosten + Sturz + Schwelle (keine Sprossen/Flügelrahmen)
// fein: 4 Basis + 1 Mittelpfosten (wingCount 2) + 2 öffenbare Flügel × 4
// Flügelrahmen-Riegel = 4 + 1 + 8 = 13.
expect(frameCount("fein")).toBe(13);
});
it("transomHeight > 0 -> zusätzlicher Kämpfer-Riegel + zweite Glasscheibe im Oberlicht-Band", () => {
@@ -479,9 +482,41 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
});
it("wingCount=2 + mullionRows=2 -> je ein zusätzlicher Mittelpfosten (vertikal) und Kämpfer (horizontal)", () => {
// Baseline: FESTES Einzelfenster (kein Flügelrahmen), damit nur die
// Mittelpfosten/Kämpfer den Unterschied ausmachen (nicht die Flügelrahmen
// öffenbarer Flügel). Beide Typen "fest".
const baseline: Project = {
...sampleProject,
openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"),
windowTypes: [
...(sampleProject.windowTypes ?? []),
{
id: "win-fixed-base",
name: "Test Fest 1-flg",
kind: "fest",
wingCount: 1,
mullionRows: 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: [],
};
@@ -526,6 +561,49 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
expect(mullionCount).toBe(baseCount + 2);
});
it("öffenbarer Flügel bekommt einen Flügelrahmen-Ring (4 Riegel) im 3D; fest keinen", () => {
// Gleiches Fenster einmal 'fest', einmal 'dreh' (öffenbar). Der öffenbare
// Flügel erzeugt zusätzlich einen Flügelrahmen-Ring (4 Riegel) INNERHALB des
// Blendrahmens (Schachtelung Blendrahmen → Flügelrahmen → Glas).
const win = (kind: "fest" | "dreh"): Project => ({
...sampleProject,
windowTypes: [
{
id: "win-sash-test",
name: `Test ${kind}`,
kind,
wingCount: 1,
glazing: "einfach",
frameThickness: 0.06,
frameWidth: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1.0,
defaultHeight: 1.2,
},
],
openings: [
{
id: "OSF",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
typeId: "win-sash-test",
position: 3.0,
width: 1.0,
height: 1.2,
sillHeight: 0.9,
},
],
doors: [],
context: [],
});
const fixedCount = projectToModel3d(win("fest")).meshes.filter(isFrame).length;
const operableCount = projectToModel3d(win("dreh")).meshes.filter(isFrame).length;
expect(fixedCount).toBe(4); // nur Blendrahmen (2 Pfosten + Sturz + Schwelle)
expect(operableCount).toBe(8); // + 4 Flügelrahmen-Riegel
});
it("insetFromFace verschiebt Rahmen-Position entlang der Wand-Normalen gegenüber bündig (kein Einzug)", () => {
// W1-Achse (0,0)->(5,0): u=(1,0) -> Normale n=(0,-1); der Quer-Versatz
// bildet sich rein in der Y-Koordinate ab (y = -t). insetFromFace 0.1 muss
+83 -4
View File
@@ -32,7 +32,7 @@
// Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle
// Dicke im neutralen Grundton (heutiges Fallback-Verhalten).
import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination, Opening } from "../model/types";
import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination, Opening, SashDef } from "../model/types";
import {
getComponent,
getWallType,
@@ -1850,6 +1850,12 @@ interface OpeningFrameParams {
* {@link glassPanesForOpening}.
*/
glazingPanes: 1 | 2 | 3;
/**
* Flügeleinteilung ({@link sashesOfWindowType}) — je ÖFFENBAREM Flügel wird im
* 3D ein Flügelrahmen (Rahmen-Riegel innerhalb des Blendrahmens) erzeugt
* (Schachtelung Blendrahmen → Flügelrahmen → Glas). Türen: leer.
*/
sashes: SashDef[];
}
function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null {
@@ -1876,6 +1882,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
mullionRows: 1,
glazed: dt.leafStyle === "glas",
glazingPanes: 1,
sashes: [],
};
}
const wt = getWindowType(project, op);
@@ -1887,6 +1894,22 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
wt.insetFromFace ?? 0,
wt.insetFace ?? "aussen",
);
// Flügeltabelle: explizite `wt.sashes` gewinnen; sonst gleichmäßig aus dem
// effektiven Flügelzahl-Wert (Instanz-Override `op.wingCount` vor Typ-Default).
// wingCount wird DARAUS abgeleitet, damit Mittelpfosten (wingCount1) und
// Flügelrahmen (je Flügel) konsistent bleiben.
let sashes = wt.sashes && wt.sashes.length > 0 ? wt.sashes : undefined;
if (!sashes) {
const n = Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1));
const opening = wt.kind === "fest" ? "fest" : wt.kind;
sashes = Array.from({ length: n }, (_, i) => ({
kind: "fluegel" as const,
autoWidth: true,
opening,
hingeSide: (i % 2 === 0 ? "left" : "right") as "left" | "right",
}));
}
const wingCount = Math.max(1, sashes.filter((s) => s.kind === "fluegel").length);
return {
frameWidth: wt.frameWidth ?? DEFAULT_FRAME_WIDTH,
acrossWallDepth,
@@ -1894,12 +1917,11 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
nMax,
transomHeight: Math.max(0, wt.transomHeight ?? 0),
hasSill: true,
// Inline-Override `Opening.wingCount` gewinnt vor dem Typ-Default (analog
// `windowSymbol` im 2D, das dieselbe Priorität kennt), sonst Typ, sonst 1.
wingCount: Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1)),
wingCount,
mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)),
glazed: true,
glazingPanes: glazingPanesOf(wt),
sashes,
};
}
@@ -1962,6 +1984,63 @@ function frameMeshesForOpening(
push(sashFrom, sashTo, center - fw / 2, center + fw / 2);
}
}
// Flügelrahmen je ÖFFENBAREM Flügel: ein Rahmen-Riegel-Ring (links/rechts/
// oben/unten) INNERHALB des Blendrahmen-Felds, um `reveal` eingerückt
// (Schachtelung Blendrahmen → Flügelrahmen → Glas). Feste Flügel bekommen
// keinen — so unterscheidet sich fest von öffenbar auch im 3D.
if (params.sashes.length > 0 && sashTo - sashFrom > EPS && sashTop - sashBottom > EPS) {
const segs = resolveSashSpans(params.sashes, sashFrom, sashTo);
const reveal = fw * 0.5;
const sfw = fw * 0.7;
for (const seg of segs) {
const s = seg.sash;
if (s.kind !== "fluegel" || !s.opening || s.opening === "fest") continue;
const cf = seg.from + fw + reveal;
const ct = seg.to - fw - reveal;
const bz = sashBottom + reveal;
const tz = sashTop - reveal;
if (ct - cf <= EPS || tz - bz <= EPS) continue;
push(cf, cf + sfw, bz, tz); // linker Flügelholm
push(ct - sfw, ct, bz, tz); // rechter Flügelholm
push(cf, ct, tz - sfw, tz); // oberer Flügelriegel
push(cf, ct, bz, bz + sfw); // unterer Flügelriegel
}
}
}
return out;
}
/**
* Löst die Flügeleinteilung auf das konkrete Achsintervall `[from,to]` (Meter)
* auf — feste Breiten (`kind:"pfosten"`/`autoWidth:false`) exakt, der Rest
* gleichmäßig auf die Restbreite. Pendant zu `sashSegments` in `opening.ts`
* (2D), aber direkt in Öffnungs-Achskoordinaten (3D).
*/
function resolveSashSpans(
sashes: SashDef[],
from: number,
to: number,
): { sash: SashDef; from: number; to: number }[] {
const width = Math.max(0, to - from);
let fixedSum = 0;
let autoCount = 0;
for (const s of sashes) {
if (s.kind === "pfosten") fixedSum += Math.max(0, s.postWidth ?? 0.06);
else if (s.autoWidth === false) fixedSum += Math.max(0, s.width ?? 0);
else autoCount++;
}
const autoW = autoCount > 0 ? Math.max(0, (width - fixedSum) / autoCount) : 0;
const out: { sash: SashDef; from: number; to: number }[] = [];
let cursor = from;
for (const s of sashes) {
const w =
s.kind === "pfosten"
? Math.max(0, s.postWidth ?? 0.06)
: s.autoWidth === false
? Math.max(0, s.width ?? 0)
: autoW;
out.push({ sash: s, from: cursor, to: cursor + w });
cursor += w;
}
return out;
}