3D-Öffnungen im VW-Detail 'fein': verschachtelte Flügelrahmen, DIN-Symbole, Sims, Zargen-Umgriff

- Flügelrahmen sitzen in eigenem, ~12 mm nach aussen vorstehenden und flacheren
  Quer-Band (abgesetzte, plastische Schachtelung Blendrahmen -> Flügelrahmen ->
  Glas) statt flach mit dem Blendrahmen verschmolzen; jetzt bei mittel + fein.
- DIN-Öffnungssymbole (Dreh/Kipp/Dreh-Kipp) als sehr dünne, in der Öffnungsebene
  gedrehte dunkle Prismen knapp vor der Glasebene (neuer Helfer openingPlaneBox);
  nur bei fein, nur je nicht-festem Flügel, exakt wie in der 2D-Ansicht.
- Fensterbank (Sims) unter der Öffnung: Bank-Quader + dünnere Tropfkanten-Stufe,
  seitlich ueberstehend, nach aussen auskragend; Gating sillBoard (keine/innen aus,
  Default zeichnen) und DetailLevel (mittel + fein).
- Tuer-Zarge mit Umgriff: schmale Bekleidungs-Platte vor beiden Wandflaechen als
  Ring um die Oeffnung (nur frameKind 'zarge'; Blockrahmen bleibt buendig).
- Mesh-Tests je Feature: Anzahl/Farbe/BBox, sillBoard- und frameKind-Gating,
  Symbole nur fein/nicht-fest, Flügelrahmen-Vorstand.
This commit is contained in:
2026-07-11 02:58:50 +02:00
parent ee9aeda01e
commit 06803206e5
2 changed files with 390 additions and 30 deletions
+170 -4
View File
@@ -406,7 +406,7 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
} }
}); });
it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen, fein=+Mittelpfosten+Flügelrahmen)", () => { it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen+Flügel, fein=+Mittelpfosten+Sprossen)", () => {
// O1 mit erzwungenem wingCount 2 (window-standard "drehkipp", öffenbar). // O1 mit erzwungenem wingCount 2 (window-standard "drehkipp", öffenbar).
const o1 = (sampleProject.openings ?? []).find((o) => o.id === "O1"); const o1 = (sampleProject.openings ?? []).find((o) => o.id === "O1");
if (!o1) throw new Error("O1 fehlt im sampleProject"); if (!o1) throw new Error("O1 fehlt im sampleProject");
@@ -419,9 +419,10 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
const frameCount = (detail: "grob" | "mittel" | "fein"): number => const frameCount = (detail: "grob" | "mittel" | "fein"): number =>
projectToModel3d(p, { detail }).meshes.filter(isFrame).length; projectToModel3d(p, { detail }).meshes.filter(isFrame).length;
expect(frameCount("grob")).toBe(0); // keine Rahmen/Sprossen expect(frameCount("grob")).toBe(0); // keine Rahmen/Sprossen
expect(frameCount("mittel")).toBe(4); // 2 Pfosten + Sturz + Schwelle (keine Sprossen/Flügelrahmen) // mittel: 4 Basis (2 Pfosten + Sturz + Schwelle) + 2 öffenbare Flügel × 4
// fein: 4 Basis + 1 Mittelpfosten (wingCount 2) + 2 öffenbare Flügel × 4 // verschachtelte Flügelrahmen-Riegel = 4 + 8 = 12 (keine Mittelpfosten/Sprossen).
// Flügelrahmen-Riegel = 4 + 1 + 8 = 13. expect(frameCount("mittel")).toBe(12);
// fein: mittel + 1 Flügel-Mittelpfosten (wingCount 2) = 12 + 1 = 13.
expect(frameCount("fein")).toBe(13); expect(frameCount("fein")).toBe(13);
}); });
@@ -753,6 +754,171 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
}); });
}); });
describe("VW-fein-Öffnungsdetails: Fensterbank, DIN-Symbole, verschachtelte Flügelrahmen, Zargen-Umgriff", () => {
const isFrame = (m: { color: [number, number, number] }): boolean =>
m.color[0] === 0.85 && m.color[1] === 0.85 && m.color[2] === 0.83;
// SILL_RGB [0.8,0.8,0.78] und SYMBOL_RGB [0.25,0.27,0.3] sind bewusst nicht
// exportiert -> Filter per Literalwert, wie bei GLASS_RGB/FRAME_RGB.
const isSill = (m: { color: [number, number, number] }): boolean =>
m.color[0] === 0.8 && m.color[1] === 0.8 && m.color[2] === 0.78;
const isSymbol = (m: { color: [number, number, number] }): boolean =>
m.color[0] === 0.25 && m.color[1] === 0.27 && m.color[2] === 0.3;
// Ein Fenster (O1-Geometrie) mit gegebenem Fenstertyp auf W1.
const winProject = (
wt: NonNullable<Project["windowTypes"]>[number],
): Project => ({
...sampleProject,
windowTypes: [...(sampleProject.windowTypes ?? []), wt],
openings: [
{
id: "OS",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
typeId: wt.id,
position: 3.0,
width: 1.0,
height: 1.2,
sillHeight: 0.9,
},
],
doors: [],
context: [],
});
const baseWin = {
id: "win-detail-test",
name: "Test Detail",
kind: "fest" as const,
wingCount: 1,
glazing: "einfach" as const,
frameThickness: 0.06,
frameWidth: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1.0,
defaultHeight: 1.2,
};
it("Fensterbank (Sims): 2 Bänder (Bank + Tropfkante) unter der Öffnung, Gating sillBoard + DetailLevel", () => {
// Default (kein sillBoard) -> zeichnen (wie 2D-Ansicht): 2 Sims-Bänder.
const pDefault = winProject({ ...baseWin });
const sills = projectToModel3d(pDefault).meshes.filter(isSill);
expect(sills.length).toBe(2);
// Beide Bänder liegen UNTER der Öffnungs-UK (sillHeight 0.9).
for (const s of sills) {
for (let i = 2; i < s.positions.length; i += 3) {
expect(s.positions[i]).toBeLessThanOrEqual(0.9 + 1e-6);
}
}
// sillBoard-Gating: "keine"/"innen" -> keine Bank; "aussen"/"beide" -> Bank.
const count = (sb: "keine" | "innen" | "aussen" | "beide"): number =>
projectToModel3d(winProject({ ...baseWin, id: `win-sb-${sb}`, sillBoard: sb })).meshes.filter(isSill).length;
expect(count("keine")).toBe(0);
expect(count("innen")).toBe(0);
expect(count("aussen")).toBe(2);
expect(count("beide")).toBe(2);
// DetailLevel: grob -> keine Bank; mittel/fein -> Bank.
const byDetail = (detail: "grob" | "mittel" | "fein"): number =>
projectToModel3d(pDefault, { detail }).meshes.filter(isSill).length;
expect(byDetail("grob")).toBe(0);
expect(byDetail("mittel")).toBe(2);
expect(byDetail("fein")).toBe(2);
});
it("DIN-Öffnungssymbole: nur fein, nur je NICHT-festem Flügel; Dreh=V, Kipp=V, Dreh-Kipp=beide", () => {
// Dreh-Kipp (öffenbar): Dreh (2 Diagonalen) + Kipp (2 Diagonalen) = 4 Symbol-Boxen.
const pDrehkipp = winProject({ ...baseWin, id: "win-drehkipp", kind: "drehkipp" });
expect(projectToModel3d(pDrehkipp).meshes.filter(isSymbol).length).toBe(4);
// Dreh -> 2, Kipp -> 2.
expect(
projectToModel3d(winProject({ ...baseWin, id: "win-dreh", kind: "dreh" })).meshes.filter(isSymbol).length,
).toBe(2);
expect(
projectToModel3d(winProject({ ...baseWin, id: "win-kipp", kind: "kipp" })).meshes.filter(isSymbol).length,
).toBe(2);
// Fest -> kein Symbol.
expect(
projectToModel3d(winProject({ ...baseWin, id: "win-fest2", kind: "fest" })).meshes.filter(isSymbol).length,
).toBe(0);
// Symbole nur bei „fein" (mittel/grob: keine).
expect(projectToModel3d(pDrehkipp, { detail: "mittel" }).meshes.filter(isSymbol).length).toBe(0);
expect(projectToModel3d(pDrehkipp, { detail: "grob" }).meshes.filter(isSymbol).length).toBe(0);
// Symbol-Boxen sind sehr dünn (endliche, gültige Positionen) und liegen im
// Öffnungs-Höhenintervall (UK 0.9 .. OK 2.1).
for (const sym of projectToModel3d(pDrehkipp).meshes.filter(isSymbol)) {
expect(sym.positions.every((v) => Number.isFinite(v))).toBe(true);
for (let i = 2; i < sym.positions.length; i += 3) {
expect(sym.positions[i]).toBeGreaterThanOrEqual(0.9 - 1e-6);
expect(sym.positions[i]).toBeLessThanOrEqual(2.1 + 1e-6);
}
}
});
it("Verschachtelter Flügelrahmen steht ~12 mm vor dem Blendrahmen (nicht mehr flach)", () => {
// Fest vs. öffenbar, sonst identisch (flush, frameThickness gleich). Der
// Flügelrahmen des öffenbaren Fensters sitzt in einem nach aussen (nMax)
// vorstehenden Band -> seine Aussenkante ragt 0.012 m weiter hinaus als der
// Blendrahmen (bei W1 in Richtung negatives world-y, s. Inset-Test).
const minY = (p: Project): number => {
let m = Infinity;
for (const mesh of projectToModel3d(p).meshes.filter(isFrame)) {
for (let i = 1; i < mesh.positions.length; i += 3) m = Math.min(m, mesh.positions[i]);
}
return m;
};
const fixedMinY = minY(winProject({ ...baseWin, id: "win-proud-fixed", kind: "fest", sillBoard: "keine" }));
const openMinY = minY(winProject({ ...baseWin, id: "win-proud-open", kind: "drehkipp", sillBoard: "keine" }));
expect(fixedMinY - openMinY).toBeCloseTo(0.012, 6);
});
it("Tür-Zarge mit Umgriff: zarge -> 6 Bekleidungs-Riegel mehr als blockrahmen", () => {
const doorProject = (frameKind: "zarge" | "blockrahmen"): Project => ({
...sampleProject,
doorTypes: [
{
id: `door-${frameKind}-test`,
name: `Test ${frameKind}`,
kind: "dreh",
leafCount: 1,
leafStyle: "glatt",
frameThickness: 0.05,
frameKind,
frameWidth: 0.06,
defaultWidth: 0.9,
defaultHeight: 2.0,
},
],
openings: [
{
id: "DT",
type: "opening",
hostWallId: "W1",
categoryCode: "31",
kind: "door",
typeId: `door-${frameKind}-test`,
position: 3.0,
width: 0.9,
height: 2.0,
sillHeight: 0,
},
],
doors: [],
context: [],
});
const frames = (frameKind: "zarge" | "blockrahmen"): number =>
projectToModel3d(doorProject(frameKind)).meshes.filter(isFrame).length;
// Zarge: Basis-Rahmen (2 Pfosten + Sturz, kein threshold) + 2 Wandseiten × 3
// Bekleidungs-Riegel = 3 + 6 = 9. Blockrahmen: nur Basis = 3.
expect(frames("blockrahmen")).toBe(3);
expect(frames("zarge")).toBe(9);
// Umgriff nur mittel/fein, nicht grob.
expect(projectToModel3d(doorProject("zarge"), { detail: "grob" }).meshes.filter(isFrame).length).toBe(0);
expect(projectToModel3d(doorProject("zarge"), { detail: "mittel" }).meshes.filter(isFrame).length).toBe(9);
});
});
describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Boolean-Dominanz)", () => { describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Boolean-Dominanz)", () => {
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw": render-ext 10, insulation 20, // Sample-Projekt: W1-W4 (EG, Wandtyp "aw": render-ext 10, insulation 20,
// brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1 // brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1
+220 -26
View File
@@ -401,6 +401,22 @@ const GLASS_HALF_THICK = 0.01;
const FRAME_RGB: RRgb = [0.85, 0.85, 0.83]; const FRAME_RGB: RRgb = [0.85, 0.85, 0.83];
/** Heller Grauton für den Rollladen-/Sonnenschutzkasten (s. {@link emitOpeningShading}). */ /** Heller Grauton für den Rollladen-/Sonnenschutzkasten (s. {@link emitOpeningShading}). */
const SHADING_RGB: RRgb = [0.88, 0.88, 0.9]; const SHADING_RGB: RRgb = [0.88, 0.88, 0.9];
/**
* Heller Stein-/Beton-Grauton der Fensterbank (Sims/Tropfkante) — bewusst etwas
* dunkler/kühler als der Rahmen ({@link FRAME_RGB}), damit die Bank im 3D als
* eigenes Bauteil (nicht als Rahmenfortsatz) liest (s. Sims-Zweig in
* {@link frameMeshesForOpening}).
*/
const SILL_RGB: RRgb = [0.8, 0.8, 0.78];
/**
* Dunkles Grau der DIN-Öffnungssymbole (dünne Diagonalen AUF dem Glas, wie in der
* 2D-Ansicht) — feine dunkle Linien auf der Scheibe (s. Symbol-Zweig in
* {@link frameMeshesForOpening}). Als sehr dünne, in der Öffnungsebene gedrehte
* Prismen ({@link openingPlaneBox}) knapp vor der Glasebene.
*/
const SYMBOL_RGB: RRgb = [0.25, 0.27, 0.3];
/** Halbe Breite der Symbol-Diagonalen (Meter) — ~5 mm Gesamtbreite, feine Linie. */
const SYMBOL_HALF_WIDTH = 0.0025;
/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */ /** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */
const DEFAULT_FRAME_WIDTH = 0.06; const DEFAULT_FRAME_WIDTH = 0.06;
/** Warmer Terrakotta-Grauton für Dachflächen/-giebel (s. {@link emitRoofs}) — hebt das Dach klar von Wand/Decke/Kontext ab. */ /** Warmer Terrakotta-Grauton für Dachflächen/-giebel (s. {@link emitRoofs}) — hebt das Dach klar von Wand/Decke/Kontext ab. */
@@ -1872,6 +1888,62 @@ function openingAxisBox(
return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color }; return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color };
} }
/**
* Baut EIN sehr dünnes Prisma mit FREI orientierter Längsachse IN der Öffnungs-
* ebene — die diagonale Schwester von {@link openingAxisBox}. Die Längsachse
* verläuft vom Punkt `(u0,h0)` zum Punkt `(u1,h1)`, beide in Öffnungsebenen-
* koordinaten: `u` = Meter entlang der Wandrichtung (ab `wall.start`), `h` =
* absolute Welthöhe (Meter). Der Querschnitt spannt `±halfWidth` senkrecht zur
* Längsachse INNERHALB der Ebene (gedreht mit) und `[nBack,nFront]` entlang der
* Grundriss-Normalen (quer zur Wand, Tiefe). Damit lassen sich die schrägen
* DIN-Öffnungssymbole (Dreh-/Kipp-Diagonalen) als dünne, gedrehte dunkle Riegel
* AUF dem Glas erzeugen — der Renderer kennt nur Dreiecks-Meshes, keine Linien.
* `null` bei entarteter Länge (≤ EPS). 8 Ecken, dieselbe {@link OPENING_BOX_TRIS}-
* Topologie (doppelseitig gerendert ⇒ Winding egal; die drei Bits parametrisieren
* hier Endpunkt/Quer-Versatz/Tiefe).
*/
function openingPlaneBox(
wall: Wall,
u0: number,
h0: number,
u1: number,
h1: number,
halfWidth: number,
nFront: number,
nBack: number,
color: RRgb,
): RMesh | null {
const du = u1 - u0;
const dh = h1 - h0;
const segLen = Math.hypot(du, dh);
if (segLen < EPS) return null;
const dx = wall.end.x - wall.start.x;
const dy = wall.end.y - wall.start.y;
const wlen = Math.hypot(dx, dy);
if (wlen < 1e-9) return null;
const ux = dx / wlen;
const uy = dy / wlen;
// Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie openingAxisBox.
const nx = uy;
const ny = -ux;
// In der Ebene senkrecht zur Längsachse (u entlang Wand, h = Welthöhe):
// Rotation der normierten Richtung um 90°. `pu` wirkt auf die u-Achse (Wand),
// `ph` auf die Höhe — beide in Metern, orthonormal ⇒ echte `halfWidth`-Breite.
const pu = -dh / segLen;
const ph = du / segLen;
const positions: number[] = [];
for (let i = 0; i < 8; i++) {
const ui = i & 1 ? u1 : u0;
const hi = i & 1 ? h1 : h0;
const s = i & 2 ? 1 : -1;
const t = i & 4 ? nFront : nBack;
const u = ui + s * halfWidth * pu;
const h = hi + s * halfWidth * ph;
positions.push(wall.start.x + ux * u + nx * t, wall.start.y + uy * u + ny * t, h);
}
return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color };
}
/** Klemmt `v` auf `[lo,hi]`. */ /** Klemmt `v` auf `[lo,hi]`. */
function clampRange(v: number, lo: number, hi: number): number { function clampRange(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v)); return Math.max(lo, Math.min(hi, v));
@@ -1967,6 +2039,18 @@ interface OpeningFrameParams {
* (Schachtelung Blendrahmen → Flügelrahmen → Glas). Türen: leer. * (Schachtelung Blendrahmen → Flügelrahmen → Glas). Türen: leer.
*/ */
sashes: SashDef[]; sashes: SashDef[];
/** Halbe Wanddicke (Meter) — Bezug der Wand-Aussenfläche (+n) für Bank/Zargen-Umgriff. */
halfThickness: number;
/**
* Fensterbank (Sims) zeichnen: nur Fenster mit `sillBoard` ≠ "keine"/"innen"
* (fehlt ⇒ zeichnen, wie in der 2D-Ansicht). Türen: nie.
*/
drawSill: boolean;
/**
* Zargen-Umgriff (Bekleidung VOR beiden Wandflächen) zeichnen: nur Türen mit
* `frameKind === "zarge"`. Blockrahmen bleibt bündig (false).
*/
zargeCladding: boolean;
} }
function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null { function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null {
@@ -1997,6 +2081,10 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
// Teilverglasung: glazingRatio (Glasanteil oben) bei Glasblatt; sonst voll. // Teilverglasung: glazingRatio (Glasanteil oben) bei Glasblatt; sonst voll.
glazedFraction: Math.max(0.05, Math.min(1, dt.glazingRatio ?? 1)), glazedFraction: Math.max(0.05, Math.min(1, dt.glazingRatio ?? 1)),
sashes: [], sashes: [],
halfThickness,
drawSill: false,
// Zarge umgreift die Wand (Bekleidung auf beiden Flächen); Blockrahmen bleibt bündig.
zargeCladding: (dt.frameKind ?? "zarge") === "zarge",
}; };
} }
const wt = getWindowType(project, op); const wt = getWindowType(project, op);
@@ -2037,6 +2125,10 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
glazingPanes: glazingPanesOf(wt), glazingPanes: glazingPanesOf(wt),
glazedFraction: 1, glazedFraction: 1,
sashes, sashes,
halfThickness,
// Fensterbank zeichnen, ausser explizit "keine"/"innen" (fehlt ⇒ zeichnen).
drawSill: wt.sillBoard !== "keine" && wt.sillBoard !== "innen",
zargeCladding: false,
}; };
} }
@@ -2046,21 +2138,44 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
* ein Kämpfer-Riegel (Oberlicht-Trennung, `transomHeight>0`) sowie die * ein Kämpfer-Riegel (Oberlicht-Trennung, `transomHeight>0`) sowie die
* Flügel-Mittelpfosten (`wingCount1`, vertikal) und Kämpfer-Zeilen * Flügel-Mittelpfosten (`wingCount1`, vertikal) und Kämpfer-Zeilen
* (`mullionRows1`, horizontal) im Flügel-Bereich zwischen Schwelle/Sturz * (`mullionRows1`, horizontal) im Flügel-Bereich zwischen Schwelle/Sturz
* bzw. Schwelle/Kämpfer. Alle Riegel in {@link FRAME_RGB}. * bzw. Schwelle/Kämpfer. Alle Rahmen-Riegel in {@link FRAME_RGB}.
*
* DetailLevel-Staffelung (VW-Detailgrad „fein" als Referenz):
* • „grob" — wird gar nicht aufgerufen (s. {@link emitOpeningFrames}).
* • „mittel"— Blendrahmen + Oberlicht-Kämpfer + Flügelrahmen (verschachtelt) +
* Fensterbank/Zargen-Umgriff, aber KEINE Sprossen und KEINE DIN-Symbole.
* • „fein" — zusätzlich Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen) und
* die DIN-Öffnungssymbole ({@link openingPlaneBox}) auf dem Glas.
*/ */
function frameMeshesForOpening( function frameMeshesForOpening(
wall: Wall, wall: Wall,
iv: { from: number; to: number }, iv: { from: number; to: number },
vert: { zBottom: number; zTop: number }, vert: { zBottom: number; zTop: number },
params: OpeningFrameParams, params: OpeningFrameParams,
fine = true, detail: DetailLevel = "fein",
): RMesh[] { ): RMesh[] {
const out: RMesh[] = []; const out: RMesh[] = [];
const fine = detail === "fein";
const medium = detail === "mittel";
const sashesOn = fine || medium; // Flügelrahmen: mittel + fein
const detailBodies = fine || medium; // Bank + Zargen-Umgriff: mittel + fein
const fw = params.frameWidth; const fw = params.frameWidth;
const push = (from: number, to: number, zBottom: number, zTop: number): void => { // Generischer Riegel mit frei wählbarem Quer-Band [nA,nB] und Farbe.
const mesh = openingAxisBox(wall, from, to, zBottom, zTop, params.nMin, params.nMax, FRAME_RGB); const pushC = (
from: number,
to: number,
zBottom: number,
zTop: number,
nA: number,
nB: number,
color: RRgb,
): void => {
const mesh = openingAxisBox(wall, from, to, zBottom, zTop, nA, nB, color);
if (mesh) out.push(mesh); if (mesh) out.push(mesh);
}; };
// Blendrahmen-Riegel (volles Rahmen-Quer-Band nMin..nMax, Rahmenfarbe).
const push = (from: number, to: number, zBottom: number, zTop: number): void =>
pushC(from, to, zBottom, zTop, params.nMin, params.nMax, FRAME_RGB);
// Pfosten (Laibungsseiten), über die volle Öffnungshöhe durchlaufend. // Pfosten (Laibungsseiten), über die volle Öffnungshöhe durchlaufend.
push(iv.from, iv.from + fw, vert.zBottom, vert.zTop); push(iv.from, iv.from + fw, vert.zBottom, vert.zTop);
push(iv.to - fw, iv.to, vert.zBottom, vert.zTop); push(iv.to - fw, iv.to, vert.zBottom, vert.zTop);
@@ -2099,27 +2214,107 @@ function frameMeshesForOpening(
push(sashFrom, sashTo, center - fw / 2, center + fw / 2); 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 // Flügelrahmen je ÖFFENBAREM Flügel: ein eigener Rahmen-Riegel-Ring (links/
// (Schachtelung Blendrahmen → Flügelrahmen → Glas). Feste Flügel bekommen // rechts/oben/unten) INNERHALB des Blendrahmen-Felds, um `reveal` eingerückt
// keinen — so unterscheidet sich fest von öffenbar auch im 3D. // (Schachtelung Blendrahmen → Flügelrahmen → Glas). Anders als bisher sitzt der
if (params.sashes.length > 0 && sashTo - sashFrom > EPS && sashTop - sashBottom > EPS) { // Flügelrahmen in einem EIGENEN, nach aussen VORSTEHENDEN und flacheren Quer-
const segs = resolveSashSpans(params.sashes, sashFrom, sashTo); // Band (`sashBackN..sashFrontN`) — dadurch liest er sich als abgesetzter,
const reveal = fw * 0.5; // verschachtelter Körper VOR dem Blendrahmen (VW „fein"), nicht mehr flach mit
const sfw = fw * 0.7; // ihm verschmolzen. Zusätzlich (nur „fein") die DIN-Öffnungssymbole auf dem
for (const seg of segs) { // Glas. Feste Flügel bekommen weder Flügelrahmen noch Symbol.
const s = seg.sash; if (sashesOn && params.sashes.length > 0 && sashTo - sashFrom > EPS && sashTop - sashBottom > EPS) {
if (s.kind !== "fluegel" || !s.opening || s.opening === "fest") continue; const segs = resolveSashSpans(params.sashes, sashFrom, sashTo);
const cf = seg.from + fw + reveal; const reveal = fw * 0.5;
const ct = seg.to - fw - reveal; const sfw = fw * 0.7;
const bz = sashBottom + reveal; // Verschachteltes Flügelrahmen-Band: ~12 mm vor der Blendrahmen-Vorderkante
const tz = sashTop - reveal; // (nMax = Aussenseite) und nur ~50 mm tief — abgesetzte, plastische Stufe.
if (ct - cf <= EPS || tz - bz <= EPS) continue; const sashFrontN = params.nMax + 0.012;
push(cf, cf + sfw, bz, tz); // linker Flügelholm const sashBackN = Math.max(params.nMin, params.nMax - 0.05);
push(ct - sfw, ct, bz, tz); // rechter Flügelholm // Glas-Vorderkante (s. glassPanesForOpening: n1 = nMax 0.03); die Symbole
push(cf, ct, tz - sfw, tz); // oberer Flügelriegel // liegen als ~2.5 mm dünne dunkle Prismen KNAPP davor (weiter aussen).
push(cf, ct, bz, bz + sfw); // unterer Flügelriegel const glassFrontN = params.nMax - 0.03;
const symBackN = glassFrontN;
const symFrontN = glassFrontN + 0.003;
const pushSash = (from: number, to: number, zB: number, zT: number): void =>
pushC(from, to, zB, zT, sashBackN, sashFrontN, FRAME_RGB);
const pushSymbol = (u0: number, h0: number, u1: number, h1: number): void => {
const mesh = openingPlaneBox(wall, u0, h0, u1, h1, SYMBOL_HALF_WIDTH, symFrontN, symBackN, SYMBOL_RGB);
if (mesh) out.push(mesh);
};
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;
pushSash(cf, cf + sfw, bz, tz); // linker Flügelholm
pushSash(ct - sfw, ct, bz, tz); // rechter Flügelholm
pushSash(cf, ct, tz - sfw, tz); // oberer Flügelriegel
pushSash(cf, ct, bz, bz + sfw); // unterer Flügelriegel
// DIN-Öffnungssymbole (nur „fein") auf dem Glasfeld INNERHALB des Flügel-
// rahmens — exakt wie in der 2D-Ansicht (s. toElevation): Dreh = V mit
// Spitze am Bandseiten-Mittel des Glasfelds, Kipp = V mit Spitze oben Mitte.
if (!fine) continue;
const gA = cf + sfw;
const gB = ct - sfw;
const gbz = bz + sfw;
const gtz = tz - sfw;
if (gB - gA <= EPS || gtz - gbz <= EPS) continue;
const kind = s.opening;
if (kind === "dreh" || kind === "drehkipp") {
const hingeLeft = (s.hingeSide ?? "left") === "left";
const apexU = hingeLeft ? gA : gB;
const apexH = (gbz + gtz) / 2;
const farU = hingeLeft ? gB : gA;
pushSymbol(farU, gbz, apexU, apexH);
pushSymbol(farU, gtz, apexU, apexH);
} }
if (kind === "kipp" || kind === "drehkipp") {
const apexU = (gA + gB) / 2;
pushSymbol(gA, gbz, apexU, gtz);
pushSymbol(gB, gbz, apexU, gtz);
}
}
}
// Fensterbank (Sims, VW „fein"/„mittel"): Bank-Quader unter der Öffnung,
// seitlich ~3 cm überstehend, nach aussen (Wand-Aussenfläche +halfThickness)
// auskragend, mit einer zweiten, dünneren Tropfkanten-Stufe an der Aussenkante
// darunter. Nur Fenster mit `params.drawSill` (sillBoard ≠ "keine"/"innen").
if (detailBodies && params.drawSill) {
const ov = 0.03; // seitlicher Überstand je Seite
const proj = 0.04; // Auskragung nach aussen (vor die Wandfläche)
const under = 0.02; // Einbindung unter den Rahmen (in den Reveal)
const bankH = 0.04; // Bankdicke
const dripH = 0.02; // Höhe der Tropfkanten-Stufe
const dripW = 0.02; // Tiefe der Tropfkante an der Aussenkante
const outerN = params.halfThickness; // Wand-Aussenfläche
const bankTop = vert.zBottom;
const bankBottom = bankTop - bankH;
// Bank
pushC(iv.from - ov, iv.to + ov, bankBottom, bankTop, outerN - under, outerN + proj, SILL_RGB);
// Tropfkante (dünnere Stufe darunter, an der Aussenkante)
pushC(iv.from - ov, iv.to + ov, bankBottom - dripH, bankBottom, outerN + proj - dripW, outerN + proj, SILL_RGB);
}
// Tür-Zarge mit Umgriff (VW): die Zarge umgreift die Wand — eine schmale
// Bekleidungs-Platte VOR beiden Wandflächen, als Ring (Pfosten links/rechts +
// Sturz) rund um die Öffnung, ~1.3 cm vor die Fläche vorstehend. Nur
// `frameKind === "zarge"`; Blockrahmen bleibt bündig (params.zargeCladding false).
if (detailBodies && params.zargeCladding) {
const proud = 0.013; // Vorstand vor die Wandfläche
const ext = 0.02; // Überstand auf die Wandfläche (über die Laibungskante hinaus)
const casW = fw; // Bekleidungsbreite (= Rahmenprofil)
for (const sign of [1, -1] as const) {
const faceN = sign * params.halfThickness;
const nA = faceN;
const nB = faceN + sign * proud;
pushC(iv.from - ext, iv.from + casW, vert.zBottom, vert.zTop + ext, nA, nB, FRAME_RGB); // Pfosten links
pushC(iv.to - casW, iv.to + ext, vert.zBottom, vert.zTop + ext, nA, nB, FRAME_RGB); // Pfosten rechts
pushC(iv.from - ext, iv.to + ext, vert.zTop - casW, vert.zTop + ext, nA, nB, FRAME_RGB); // Sturz
} }
} }
return out; return out;
@@ -2295,7 +2490,6 @@ function emitOpeningGlass(project: Project): RMesh[] {
function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMesh[] { function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMesh[] {
// Grob: keine Rahmen/Sprossen — die Öffnung bleibt Loch + Scheibe (schlank). // Grob: keine Rahmen/Sprossen — die Öffnung bleibt Loch + Scheibe (schlank).
if (detail === "grob") return []; if (detail === "grob") return [];
const fine = detail === "fein";
const out: RMesh[] = []; const out: RMesh[] = [];
for (const wall of project.walls) { for (const wall of project.walls) {
for (const op of openingsOfWall(project, wall.id)) { for (const op of openingsOfWall(project, wall.id)) {
@@ -2305,7 +2499,7 @@ function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMes
if (vert.zTop - vert.zBottom <= EPS) continue; if (vert.zTop - vert.zBottom <= EPS) continue;
const params = resolveOpeningFrame(project, wall, op); const params = resolveOpeningFrame(project, wall, op);
if (!params) continue; if (!params) continue;
out.push(...frameMeshesForOpening(wall, iv, vert, params, fine)); out.push(...frameMeshesForOpening(wall, iv, vert, params, detail));
} }
} }
return out; return out;