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
+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