Fenster-Renderer: Flügeleinteilung, Öffnungslinien, Verglasung, Rollladenkasten

Der 2D/3D-Renderer konsumiert die neuen Fenster-Modellfelder (VW-Studie §6):

- opening.ts: windowSymbol nimmt die Flügeltabelle (sashes); Trennlinien aus
  Flügel-/Pfostenbreiten statt gleichmässiger wingCount-Teilung + Öffnungs-
  andeutung (Diagonale zur Bandseite) je nicht-festem Flügel. Ohne sashes
  exakt das bisherige Verhalten (Regressionssicherheit).
- generatePlan: glazingPanesOf steuert die Glaslinien-Anzahl (mittel/fein),
  grob bleibt bei einer; gestrichelte Rollladenkasten-Kontur über der Öffnung.
- toWalls3d: glassPanesForOpening zeichnet glazingPanes dünne, in Wandrichtung
  versetzte Scheiben mit Luftraum (Ein-/Zwei-/Dreifachverglasung); panes=1 =
  exakt die alte Einzelscheibe.

Verhaltensänderung: window-standard (zweifach) zeigt 3D nun 2 Scheiben statt
einer — 2 Bestandstests entsprechend angepasst (+ Nicht-Überlappungs-Check).
+13 Primitiv-Tests (generatePlan.windowFields, toWalls3d). 659/659 grün.
This commit is contained in:
2026-07-10 01:17:35 +02:00
parent c734802555
commit 2e13ec3097
5 changed files with 537 additions and 61 deletions
+111 -20
View File
@@ -11,7 +11,7 @@
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Opening, Project, Vec2, Wall } from "../model/types";
import type { Opening, Project, SashDef, Vec2, Wall } from "../model/types";
import { getWallType, wallTypeThickness } from "../model/types";
import { add, along, leftNormal, normalize, scale, sub } from "../model/geometry";
import { wallReferenceOffset, wallVerticalExtent } from "../model/wall";
@@ -168,29 +168,93 @@ export function doorSymbol(wall: Wall, o: Opening): DoorSymbol | null {
export interface WindowSymbol {
/** Rahmen-Rechteck (volle Dicke) als Ecken. */
frame: Vec2[];
/** Glaslinien-Segmente quer über die Lücke (12 parallele Linien). */
/** Glaslinien-Segmente quer über die Lücke (13 parallele Linien). */
glassLines: [Vec2, Vec2][];
/**
* Flügel-Mittelpfosten (`wingCount 1` Querlinien), jeweils von einer
* Laibungsseite zur anderen (parallel zu den Laibungslinien, quer zur
* Öffnungsrichtung), gleichmäßig zwischen den beiden Pfosten verteilt. Bei
* `wingCount ≤ 1` leer. Entspricht den Mittelpfosten in `_make_oeffnung_pieces`
* des Rhino-Plugins (`oeff_fluegel`).
* Flügel-/Pfosten-Trennlinien, jeweils von einer Laibungsseite zur anderen
* (parallel zu den Laibungslinien, quer zur Öffnungsrichtung). Ist `sashes`
* (dritter Parameter von {@link windowSymbol}) gesetzt, ergeben sich die
* Positionen aus den Flügel-/Pfostenbreiten (siehe `sashSegments`); sonst
* (Alt-Fall) `wingCount 1` gleichmäßig verteilte Querlinien. Entspricht den
* Mittelpfosten in `_make_oeffnung_pieces` des Rhino-Plugins (`oeff_fluegel`).
*/
mullionLines: [Vec2, Vec2][];
/**
* Öffnungsandeutung je NICHT-festem Flügel (Dreh/Kipp/Drehkipp/Schiebe): eine
* einzelne Diagonale über die Flügelfläche, von der äusseren Ecke der
* Bandseite GEGENüberliegenden Seite (aussen) zur Bandseite (innen) — eine
* schlichte Andeutung der Öffnungsrichtung, analog zur Schwenkbogen-Andeutung
* bei Türen, aber ohne Bogen (P0-Minimalfassung). Nur befüllt, wenn `sashes`
* übergeben wird; leer im Alt-Fall (kein `sashes`).
*/
sashOpeningLines: [Vec2, Vec2][];
}
/** Ein Segment (Flügel/Pfosten) der Flügeleinteilung, mit aufgelöster Breite. */
interface SashSegment {
sash: SashDef;
/** Abstand vom Laibungsstart in Metern (0 = jambStart). */
from: number;
/** Abstand vom Laibungsstart in Metern. */
to: number;
}
/** Default-Pfostenbreite (Meter), wenn ein `kind:"pfosten"`-Eintrag keine `postWidth` angibt. */
const DEFAULT_POST_WIDTH = 0.06;
/**
* Löst eine Flügeleinteilung ({@link SashDef}-Liste) zu konkreten Segment-
* Intervallen (Meter, gemessen ab Laibungsstart) über die gegebene
* Gesamtbreite auf: `kind:"pfosten"` und Flügel mit `autoWidth === false`
* erhalten ihre feste Breite (`postWidth` bzw. `width`), die restlichen
* (`autoWidth`, Default) teilen sich die verbleibende Breite gleichmäßig.
* Degeneriert bei GLEICHMÄSSIGEN Flügeln (alle `autoWidth`, keine Pfosten) zur
* bisherigen `wingCount`-Gleichverteilung (Regressionssicherheit).
*/
function sashSegments(sashes: SashDef[], totalWidth: number): SashSegment[] {
if (sashes.length === 0) return [];
let fixedSum = 0;
let autoCount = 0;
for (const s of sashes) {
if (s.kind === "pfosten") fixedSum += Math.max(0, s.postWidth ?? DEFAULT_POST_WIDTH);
else if (s.autoWidth === false) fixedSum += Math.max(0, s.width ?? 0);
else autoCount++;
}
const remaining = Math.max(0, totalWidth - fixedSum);
const autoWidth = autoCount > 0 ? remaining / autoCount : 0;
const segs: SashSegment[] = [];
let cursor = 0;
for (const s of sashes) {
const w =
s.kind === "pfosten"
? Math.max(0, s.postWidth ?? DEFAULT_POST_WIDTH)
: s.autoWidth === false
? Math.max(0, s.width ?? 0)
: autoWidth;
segs.push({ sash: s, from: cursor, to: cursor + w });
cursor += w;
}
return segs;
}
/**
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
* Wanddicke) plus 12 Glaslinien (dünne parallele Linien in der Wandmitte,
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
* (`wingCount 1` Querlinien).
* Wanddicke) plus 13 Glaslinien (dünne parallele Linien in der Wandmitte,
* `glassCount` steuert die Anzahl) plus optionale Flügel-/Pfosten-Trennlinien
* und Öffnungsandeutungen je Flügel.
*
* `sashes` (optional) ist die aufgelöste Flügeltabelle (siehe
* `sashesOfWindowType`) EINES referenzierten Fenstertyps. Fehlt sie (kein Typ
* referenziert/auflösbar), bleibt die Mittelpfosten-Berechnung beim
* BISHERIGEN Verhalten — `o.wingCount` gleichmäßig verteilt, KEINE
* Öffnungsandeutungen — exakt wie vor dieser Erweiterung (Rückwärtskompatibilität).
*/
export function windowSymbol(
project: Project,
wall: Wall,
o: Opening,
glassCount: number,
sashes?: SashDef[],
): WindowSymbol | null {
const jambs = openingJambs(wall, o);
if (!jambs) return null;
@@ -214,21 +278,48 @@ export function windowSymbol(
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
}
// Flügel-Mittelpfosten: (wingCount 1) Querlinien gleichmäßig zwischen den
// Pfosten. Jede Linie läuft quer über die Öffnung (n-Richtung), auf ~60 % der
// Mauerdicke symmetrisch um die Wandmitte (Referenz-Offset) — analog Rhino, wo
// die Pfosten x_mid = inner_l + span·(i/fluegel) über die Rahmentiefe sitzen.
const mullionLines: [Vec2, Vec2][] = [];
const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1)));
if (wings > 1) {
const sashOpeningLines: [Vec2, Vec2][] = [];
if (sashes && sashes.length > 0) {
// Neuer Weg: Trennlinien + Öffnungsandeutungen aus der Flügeltabelle.
const axisWidth = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
const segs = sashSegments(sashes, axisWidth);
const postDepth = total * 0.6;
const pOuter = refOff + postDepth / 2;
const pInner = refOff - postDepth / 2;
for (let i = 1; i < wings; i++) {
const t = i / wings; // gleichmäßige Teilung entlang der Achse
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
for (let i = 0; i < segs.length - 1; i++) {
const onAxis = along(jambStart, jambEnd, segs[i].to);
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
}
for (const seg of segs) {
if (seg.sash.kind !== "fluegel") continue;
const kind = seg.sash.opening;
if (!kind || kind === "fest") continue;
// Bandseite "left" = Scharnier am Laibungsstart (jambStart-Seite), "right"
// = am Laibungsende. Die Diagonale läuft von der GEGENüberliegenden
// Aussenecke zur Bandecke (innen) — schlichte Andeutung, kein Schwenkbogen.
const hingeAtStart = (seg.sash.hingeSide ?? "left") === "left";
const hingeAxis = along(jambStart, jambEnd, hingeAtStart ? seg.from : seg.to);
const farAxis = along(jambStart, jambEnd, hingeAtStart ? seg.to : seg.from);
sashOpeningLines.push([add(farAxis, scale(n, outer)), add(hingeAxis, scale(n, inner))]);
}
} else {
// Alt-Fall (kein sashes): (wingCount 1) Querlinien gleichmäßig zwischen
// den Pfosten. Jede Linie läuft quer über die Öffnung (n-Richtung), auf
// ~60 % der Mauerdicke symmetrisch um die Wandmitte (Referenz-Offset) —
// analog Rhino, wo die Pfosten x_mid = inner_l + span·(i/fluegel) über die
// Rahmentiefe sitzen. UNVERÄNDERT gegenüber dem bisherigen Verhalten.
const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1)));
if (wings > 1) {
const postDepth = total * 0.6;
const pOuter = refOff + postDepth / 2;
const pInner = refOff - postDepth / 2;
for (let i = 1; i < wings; i++) {
const t = i / wings; // gleichmäßige Teilung entlang der Achse
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
}
}
}
return { frame, glassLines, mullionLines };
return { frame, glassLines, mullionLines, sashOpeningLines };
}
+81 -5
View File
@@ -35,9 +35,11 @@ import {
getLineStyle,
getWallType,
getWindowType,
glazingPanesOf,
openingLabel,
openingsOfWall,
roomsOfFloor,
sashesOfWindowType,
stairLabel,
stairsOfFloor,
wallTypeThickness,
@@ -2211,9 +2213,19 @@ function addOpeningSymbol(
return;
}
// Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad.
const glassCount = detail === "fein" ? 2 : 1;
const sym = windowSymbol(project, wall, o, glassCount);
// Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad + Fenstertyp.
const wt = getWindowType(project, o);
// 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 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);
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
@@ -2243,8 +2255,8 @@ function addOpeningSymbol(
greyed,
openingId: o.id,
});
// Flügel-Mittelpfosten (wingCount 1) — reine Querlinien innerhalb des
// Rahmens (kein Strichmuster). Nur mittel/fein; entfällt bei grob/schema.
// Flügel-/Pfosten-Trennlinien — reine Querlinien innerhalb des Rahmens
// (kein Strichmuster). Nur mittel/fein; entfällt bei grob/schema.
for (const [a, b] of sym.mullionLines) {
out.push({
kind: "line",
@@ -2257,6 +2269,21 @@ 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,
});
}
}
for (const [a, b] of sym.glassLines) {
out.push({
@@ -2318,6 +2345,55 @@ function addOpeningSymbol(
});
}
}
// Rollladen-/Sonnenschutzkasten (P0-Minimalfassung, reine 2D-Kontur): ein
// gestricheltes Rechteck ÜBER der Öffnung, ausserhalb der Wand-Aussenfläche —
// Breite = `shading.box.width` (0/fehlt ⇒ Öffnungsbreite), die zweite
// Rechteckseite = `shading.box.height`. Die 3D-Tiefe (`box.depth`) bleibt für
// eine spätere 3D-Box reserviert (Fokus hier bewusst 2D, s. Aufgabenstellung).
// Gestrichelt wie die übrigen Überkopf-Projektionen (Sturz/Oberlicht) — der
// Kasten liegt oberhalb der Schnittebene und wird nur angedeutet.
if (wt?.shading?.create) {
const jambs = openingJambs(wall, o);
if (jambs) {
const { u, n: nrm } = wallAxisFrame(wall);
const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total);
const outerFace = refOff + total / 2;
const axisWidth = Math.hypot(
jambs.jambEnd.x - jambs.jambStart.x,
jambs.jambEnd.y - jambs.jambStart.y,
);
const boxWidth = wt.shading.box.width > 0 ? wt.shading.box.width : axisWidth;
const boxHeight = Math.max(0, wt.shading.box.height);
const center = add(jambs.jambStart, scale(u, axisWidth / 2));
const half = boxWidth / 2;
const c1 = add(center, scale(u, -half));
const c2 = add(center, scale(u, half));
const p1 = add(c1, scale(nrm, outerFace));
const p2 = add(c2, scale(nrm, outerFace));
const p3 = add(c2, scale(nrm, outerFace + boxHeight));
const p4 = add(c1, scale(nrm, outerFace + boxHeight));
const edges: [Vec2, Vec2][] = [
[p1, p2],
[p2, p3],
[p3, p4],
[p4, p1],
];
for (const [a, b] of edges) {
out.push({
kind: "line",
a,
b,
cls: "window-shading",
weightMm: SYMBOL_HAIRLINE_MM,
dash: OVERHEAD_DASH,
color: o.color ?? POCHE_STROKE,
greyed,
openingId: o.id,
});
}
}
}
}
/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
+221
View File
@@ -0,0 +1,221 @@
/**
* Unit-Tests für die NEUEN Fenster-Modellfelder im Grundriss (generatePlan +
* geometry/opening.windowSymbol):
* `WindowType.sashes` (Flügeltabelle) ersetzt die gleichmäßige
* `wingCount`-Ableitung durch echte Flügel-/Pfostenbreiten, sobald ein
* Fenstertyp referenziert ist. Alt-Fall (keine `sashes`/kein `typeId`)
* bleibt UNVERÄNDERT (reines `Opening.wingCount`, siehe
* generatePlan.openings.test.ts).
* `WindowType.glazingPanes` steuert die Anzahl paralleler Glaslinien.
* `WindowType.shading` zeichnet eine zusätzliche, gestrichelte
* Kastenkontur über der Öffnung.
* Öffnungsandeutung je Flügel (`SashDef.opening`/`hingeSide`).
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { Opening, Project, Wall, WindowType } from "../model/types";
/** Minimalprojekt: eine Wand (Dicke 0.3 m, Achse mittig, horizontal entlang x)
* + eine Fenster-Öffnung + optionale Fenstertyp-Bibliothek. */
function project(opening: Opening, windowTypes?: WindowType[]): Project {
const wall: Wall = {
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 4, y: 0 },
wallTypeId: "aw",
height: 2.6,
};
return {
id: "t",
name: "T",
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.3 }] }],
windowTypes,
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
],
layers: [
{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
{ code: "21", name: "Öffnungen", color: "#0a0a0a", lw: 0.3, visible: true, locked: false },
],
walls: [wall],
doors: [],
openings: [opening],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
const visible = new Set(["20", "21"]);
/** Basis-Fenster (Brüstung 0, damit keine Sill-Linie mitzählt), position 1.0, width 1.2. */
const baseWindow: Opening = {
id: "F1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
position: 1.0,
width: 1.2,
height: 1.2,
sillHeight: 0,
};
/** Minimaler Fenstertyp (Grunddaten, Felder je Test überschrieben/ergänzt). */
const baseWindowType: WindowType = {
id: "wt1",
name: "Test-Fenster",
kind: "dreh",
wingCount: 1,
glazing: "einfach",
frameThickness: 0.06,
defaultSillHeight: 0.8,
defaultWidth: 1.2,
defaultHeight: 1.2,
};
function linesOfClass(p: Project, cls: string, detail: "grob" | "mittel" | "fein" = "mittel") {
return generatePlan(p, "eg", visible, undefined, detail).primitives.filter(
(pr) => pr.kind === "line" && pr.cls === cls,
);
}
describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => {
it("Alt-Fall (kein typeId/sashes, nur wingCount=3) erzeugt genau 2 Pfostenlinien — Regression", () => {
const p = project({ ...baseWindow, wingCount: 3 });
expect(linesOfClass(p, "window-mullion").length).toBe(2);
});
it("WindowType MIT sashes (3 feste Flügelbreiten 0.4 m) erzeugt Pfostenlinien exakt an den Flügelgrenzen", () => {
// Öffnung 1.2 m breit, jambStart bei x=1.0 (position 1.0) -> jambEnd x=2.2.
// 3 Flügel à 0.4 m (autoWidth:false) -> Grenzen bei x=1.4 und x=1.8.
const wt: WindowType = {
...baseWindowType,
sashes: [
{ kind: "fluegel", autoWidth: false, width: 0.4, opening: "dreh", hingeSide: "left" },
{ kind: "fluegel", autoWidth: false, width: 0.4, opening: "kipp" },
{ kind: "fluegel", autoWidth: false, width: 0.4, opening: "dreh", hingeSide: "right" },
],
};
const p = project({ ...baseWindow, typeId: "wt1" }, [wt]);
const mullions = linesOfClass(p, "window-mullion");
expect(mullions.length).toBe(2);
const xs = mullions
.map((pr) => (pr.kind === "line" ? pr.a.x : NaN))
.sort((a, b) => a - b);
expect(xs[0]).toBeCloseTo(1.4, 6);
expect(xs[1]).toBeCloseTo(1.8, 6);
});
it("WindowType MIT sashes, aber alle autoWidth (kein fixes width/pfosten) -> identische Grenzen wie die alte wingCount-Gleichverteilung", () => {
// 3 gleichmässige Flügel über sashesOfWindowType (kein `sashes` am Typ,
// nur `wingCount: 3`) müssen exakt dieselben Pfosten liefern wie der
// Alt-Fall ohne Typ (Regressionssicherheit der Ableitung).
const wt: WindowType = { ...baseWindowType, wingCount: 3 };
const withType = project({ ...baseWindow, typeId: "wt1" }, [wt]);
const withoutType = project({ ...baseWindow, wingCount: 3 });
const linesOf = (p: Project) =>
linesOfClass(p, "window-mullion")
.map((pr) => (pr.kind === "line" ? pr.a.x : NaN))
.sort((a, b) => a - b);
const a = linesOf(withType);
const b = linesOf(withoutType);
expect(a.length).toBe(b.length);
a.forEach((v, i) => expect(v).toBeCloseTo(b[i], 9));
});
it("Flügel mit opening !== 'fest' erzeugt eine Öffnungsandeutung (window-opening); 'fest' keine", () => {
const wt: WindowType = {
...baseWindowType,
sashes: [
{ kind: "fluegel", autoWidth: true, opening: "dreh", hingeSide: "left" },
{ kind: "fluegel", autoWidth: true, opening: "fest" },
],
};
const p = project({ ...baseWindow, typeId: "wt1" }, [wt]);
expect(linesOfClass(p, "window-opening").length).toBe(1);
});
it("Alt-Fall (kein typeId) erzeugt KEINE Öffnungsandeutung", () => {
const p = project({ ...baseWindow });
expect(linesOfClass(p, "window-opening").length).toBe(0);
});
});
describe("generatePlan — Verglasung glazingPanes (Item 2)", () => {
const glassLineCount = (p: Project, detail: "grob" | "mittel" | "fein" = "mittel") =>
linesOfClass(p, "window-glass", detail).length;
it("glazingPanes: 3 erzeugt mehr Glaslinien als glazingPanes: 1", () => {
const wt3: WindowType = { ...baseWindowType, id: "wt3", glazingPanes: 3 };
const wt1: WindowType = { ...baseWindowType, id: "wt1", glazingPanes: 1 };
const p3 = project({ ...baseWindow, typeId: "wt3" }, [wt3]);
const p1 = project({ ...baseWindow, typeId: "wt1" }, [wt1]);
expect(glassLineCount(p3)).toBeGreaterThan(glassLineCount(p1));
expect(glassLineCount(p3)).toBe(3);
expect(glassLineCount(p1)).toBe(1);
});
it("ohne typeId bleibt die alte Detailgrad-Regel (grob/mittel=1, fein=2) unverändert — Regression", () => {
const p = project({ ...baseWindow });
expect(glassLineCount(p, "grob")).toBe(1);
expect(glassLineCount(p, "mittel")).toBe(1);
expect(glassLineCount(p, "fein")).toBe(2);
});
it("glazingPanes wirkt bei 'grob' NICHT (bleibt bei 1 Glaslinie)", () => {
const wt: WindowType = { ...baseWindowType, glazingPanes: 3 };
const p = project({ ...baseWindow, typeId: "wt1" }, [wt]);
expect(glassLineCount(p, "grob")).toBe(1);
});
});
describe("generatePlan — Rollladenkasten shading (Item 3)", () => {
const shadingLineCount = (p: Project) => linesOfClass(p, "window-shading").length;
it("shading.create: true erzeugt eine zusätzliche Kastenkontur (4 Linien)", () => {
const wt: WindowType = {
...baseWindowType,
shading: { create: true, kind: "rollladen", box: { width: 1.2, height: 0.2, depth: 0.15 } },
};
const p = project({ ...baseWindow, typeId: "wt1" }, [wt]);
expect(shadingLineCount(p)).toBe(4);
});
it("shading.create: false erzeugt KEINE Kastenkontur", () => {
const wt: WindowType = {
...baseWindowType,
shading: { create: false, kind: "rollladen", box: { width: 1.2, height: 0.2, depth: 0.15 } },
};
const p = project({ ...baseWindow, typeId: "wt1" }, [wt]);
expect(shadingLineCount(p)).toBe(0);
});
it("kein shading-Feld am Typ erzeugt KEINE Kastenkontur", () => {
const p = project({ ...baseWindow, typeId: "wt1" }, [baseWindowType]);
expect(shadingLineCount(p)).toBe(0);
});
it("ohne typeId (Alt-Fall) erzeugt KEINE Kastenkontur", () => {
const p = project({ ...baseWindow });
expect(shadingLineCount(p)).toBe(0);
});
});
+86 -23
View File
@@ -260,10 +260,16 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
});
describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
it("1 Fenster + 1 Tür -> genau 1 Glas-Mesh mit korrekter vertikaler Ausdehnung", () => {
it("1 Fenster + 1 Tür -> 2 Glas-Meshes (Typ 'window-standard' = Zweifachverglasung) mit korrekter vertikaler Ausdehnung", () => {
// Sample-Projekt auf EINE Öffnung (Fenster O1) + EINE Tür (D1) reduzieren,
// beide auf W1 (EG, baseElevation 0). Kontext ist leer -> `meshes` enthält
// ausschliesslich die Glasscheiben.
//
// ANGEPASST (glazingPanes-Feature): O1 referenziert den Typ "window-standard"
// (`glazing: "zweifach"` -> `glazingPanesOf` = 2). `glassPanesForOpening`
// zeichnet jetzt render-wirksam so viele Scheiben, statt wie bisher immer
// fix EINE — bewusste, korrekte Verhaltensänderung (Aufgabe: 2D/3D-Renderer
// sollen die neuen Fenster-Verglasungsfelder konsumieren).
const p: Project = {
...sampleProject,
openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"),
@@ -276,34 +282,91 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
const glassMeshes = meshes.filter(
(m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85,
);
// Genau EINE Scheibe: das Fenster O1; die Tür D1 bleibt offen (keine Scheibe).
expect(glassMeshes.length).toBe(1);
const glass = glassMeshes[0];
// Quader: 8 Ecken (24 Floats) + 12 Dreiecke (36 Indizes), leicht bläuliches Glas.
expect(glass.positions.length).toBe(24);
expect(glass.indices.length).toBe(36);
expect(glass.color).toEqual([0.62, 0.76, 0.85]);
// Vertikale Ausdehnung (z = 3. Komponente jeder Ecke, MODELL-Höhe):
// O1 sillHeight 0.9, height 1.2, Wand-UK 0 -> Öffnung UK 0.9 .. OK 2.1.
// O1 referenziert den Typ "window-standard" (frameWidth 0.06, kein Oberlicht)
// -> die Scheibe sitzt jetzt INNERHALB des Rahmens (Schwelle/Sturz ziehen je
// frameWidth ab), s. emitOpeningFrames/glassPanesForOpening: 0.96 .. 2.04.
const zs: number[] = [];
for (let i = 2; i < glass.positions.length; i += 3) zs.push(glass.positions[i]);
expect(Math.min(...zs)).toBeCloseTo(0.96, 6);
expect(Math.max(...zs)).toBeCloseTo(2.04, 6);
expect(glassMeshes.length).toBe(2);
// Vertikale Ausdehnung (z = 3. Komponente jeder Ecke, MODELL-Höhe) — BEIDE
// Scheiben sitzen INNERHALB desselben Rahmens (Schwelle/Sturz ziehen je
// frameWidth ab): O1 sillHeight 0.9, height 1.2, Wand-UK 0 -> Öffnung UK
// 0.9 .. OK 2.1, Rahmen 0.06 -> Scheiben 0.96 .. 2.04.
for (const glass of glassMeshes) {
// Quader: 8 Ecken (24 Floats) + 12 Dreiecke (36 Indizes), leicht bläuliches Glas.
expect(glass.positions.length).toBe(24);
expect(glass.indices.length).toBe(36);
expect(glass.color).toEqual([0.62, 0.76, 0.85]);
const zs: number[] = [];
for (let i = 2; i < glass.positions.length; i += 3) zs.push(glass.positions[i]);
expect(Math.min(...zs)).toBeCloseTo(0.96, 6);
expect(Math.max(...zs)).toBeCloseTo(2.04, 6);
}
// Quer zur Wand (y-Achse, da W1 entlang x läuft) sitzen die beiden Scheiben
// MIT Luftraum versetzt (Isolierverglasung) — sie überlappen sich nicht.
const yRange = (m: { positions: number[] }): { min: number; max: number } => {
let min = Infinity;
let max = -Infinity;
for (let i = 1; i < m.positions.length; i += 3) {
min = Math.min(min, m.positions[i]);
max = Math.max(max, m.positions[i]);
}
return { min, max };
};
const [a, b] = glassMeshes.map(yRange).sort((r1, r2) => r1.min - r2.min);
expect(a.max).toBeLessThanOrEqual(b.min + 1e-9);
});
it("Türen bekommen keine Scheibe; je Fenster genau eine", () => {
// Volles Sample-Projekt: 2 Fenster (O1, O2) + 1 Tür (D1), Kontext leer.
// Neben den Glasscheiben liegt jetzt auch das Betontreppen-Mesh in `meshes`
// → nach Glasfarbe filtern: genau 2 Scheiben (O1, O2), Tür ohne Scheibe.
it("Türen bekommen keine Scheibe; je Fenster genau 2 (window-standard = zweifach verglast)", () => {
// Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" =
// Zweifachverglasung) + 1 Tür (D1), Kontext leer. Neben den Glasscheiben
// liegt jetzt auch das Betontreppen-Mesh in `meshes` → nach Glasfarbe
// filtern: 2 Fenster × 2 Scheiben = 4, Tür ohne Scheibe.
const { meshes } = projectToModel3d(sampleProject);
const glassMeshes = meshes.filter(
(m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85,
);
expect(glassMeshes.length).toBe(2);
expect(glassMeshes.length).toBe(4);
});
it("glazingPanes: 3 erzeugt mehr Glas-Meshes als glazingPanes: 1 (glazingPanesOf)", () => {
const glassMeshCount = (glazingPanes: 1 | 2 | 3): number => {
const p: Project = {
...sampleProject,
windowTypes: [
{
id: "win-panes-test",
name: "Test Verglasung",
kind: "fest",
wingCount: 1,
glazing: "einfach",
glazingPanes,
frameThickness: 0.06,
frameWidth: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1.0,
defaultHeight: 1.2,
},
],
openings: [
{
id: "OG",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
typeId: "win-panes-test",
position: 3.0,
width: 1.0,
height: 1.2,
sillHeight: 0.9,
},
],
doors: [],
context: [],
};
const { meshes } = projectToModel3d(p);
return meshes.filter((m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85)
.length;
};
expect(glassMeshCount(1)).toBe(1);
expect(glassMeshCount(3)).toBe(3);
expect(glassMeshCount(3)).toBeGreaterThan(glassMeshCount(1));
});
});
+38 -13
View File
@@ -44,6 +44,7 @@ import {
flattenCategories,
getDoorType,
getWindowType,
glazingPanesOf,
} from "../model/types";
import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
@@ -1840,6 +1841,13 @@ interface OpeningFrameParams {
mullionRows: number;
/** Ob eine Glasscheibe gezeichnet wird (Fenster immer, Tür nur `leafStyle: "glas"`). */
glazed: boolean;
/**
* Verglasungs-Scheibenzahl (13), render-wirksam ({@link glazingPanesOf}).
* Türen: immer 1 (keine typisierte Mehrfachverglasung). Steuert die Anzahl
* dünner, in Wandrichtung leicht versetzter Glasflächen in
* {@link glassPanesForOpening}.
*/
glazingPanes: 1 | 2 | 3;
}
function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null {
@@ -1865,6 +1873,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
wingCount: 1,
mullionRows: 1,
glazed: dt.leafStyle === "glas",
glazingPanes: 1,
};
}
const wt = getWindowType(project, op);
@@ -1888,6 +1897,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
wingCount: Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1)),
mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)),
glazed: true,
glazingPanes: glazingPanesOf(wt),
};
}
@@ -1954,13 +1964,20 @@ function frameMeshesForOpening(
return out;
}
/** Luftraum zwischen zwei Scheiben einer Mehrfachverglasung (Meter, informativ). */
const GLASS_PANE_GAP = 0.01;
/**
* Glasscheibe(n) EINER typisierten Öffnung (Fenster immer, Tür nur bei
* `params.glazed`): eine Hauptscheibe im Flügel-Bereich (zwischen Schwelle und
* Sturz bzw. Kämpfer), plus bei `transomHeight>0` eine ZWEITE Scheibe im
* Oberlicht-Band oberhalb des Kämpfers. Die Sprossen/Mittelpfosten aus
* {@link frameMeshesForOpening} liegen VOR dieser einen Hauptscheibe (keine
* Pro-Feld-Aufteilung bewusst einfach gehalten, s. Datei-weite Erläuterung).
* `params.glazed`): je Z-Band (Hauptfeld zwischen Schwelle und Sturz bzw.
* Kämpfer, plus bei `transomHeight>0` ein zweites Oberlicht-Band) werden
* `params.glazingPanes` dünne, in Wandrichtung (quer zur Wand, n-Achse) leicht
* versetzte Scheiben mit Luftraum dazwischen erzeugt (Isolierverglasung:
* 1 = Einfach-, 2 = Zweifach-, 3 = Dreifachverglasung). Bei `glazingPanes: 1`
* (Alt-Verhalten/Default) entsteht exakt EINE Scheibe an derselben Position wie
* bisher. Die Sprossen/Mittelpfosten aus {@link frameMeshesForOpening} liegen
* VOR diesen Scheiben (keine Pro-Feld-Aufteilung bewusst einfach gehalten,
* s. Datei-weite Erläuterung).
*/
function glassPanesForOpening(
wall: Wall,
@@ -1974,16 +1991,24 @@ function glassPanesForOpening(
const sashTo = iv.to - fw;
const sashBottom = vert.zBottom + (params.hasSill ? fw : 0);
const sashTopFull = vert.zTop - fw;
// Die Scheibe ist eine DÜNNE Tafel NAHE der Aussenfläche (nMax = +n-Seite, s.
// resolveFrameNormalRange) — mittig tief im Reveal wirkte sie schwach/kaum
// sichtbar. Direkt hinter der Aussenkante (3 cm zurück) liest sich das Fenster
// klar, die Laibung/Reveal-Tiefe zeigt nach innen.
// Die Scheibe(n) sitzen als DÜNNE Tafeln NAHE der Aussenfläche (nMax = +n-
// Seite, s. resolveFrameNormalRange) — mittig tief im Reveal wirkte das
// schwach/kaum sichtbar. Direkt hinter der Aussenkante (3 cm zurück) liest
// sich das Fenster klar, die Laibung/Reveal-Tiefe zeigt nach innen. Bei
// mehreren Scheiben rücken die weiteren mit `GLASS_PANE_GAP` Luftraum
// dazwischen weiter nach innen (Richtung `nMin`).
const glassThick = GLASS_HALF_THICK * 2;
const gN1 = params.nMax - 0.03;
const gN0 = Math.max(params.nMin, gN1 - glassThick);
const panes = Math.max(1, Math.min(3, Math.round(params.glazingPanes)));
const push = (zBottom: number, zTop: number): void => {
const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, gN0, gN1, GLASS_RGB);
if (mesh) out.push(mesh);
let n1 = params.nMax - 0.03;
for (let p = 0; p < panes; p++) {
const n0 = Math.max(params.nMin, n1 - glassThick);
if (n1 - n0 <= EPS) break;
const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, n0, n1, GLASS_RGB);
if (mesh) out.push(mesh);
n1 = n0 - GLASS_PANE_GAP;
if (n1 <= params.nMin) break;
}
};
if (params.transomHeight > EPS) {
const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTopFull);