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:
+111
-20
@@ -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 (1–2 parallele Linien). */
|
||||
/** Glaslinien-Segmente quer über die Lücke (1–3 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 1–2 Glaslinien (dünne parallele Linien in der Wandmitte,
|
||||
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
|
||||
* (`wingCount − 1` Querlinien).
|
||||
* Wanddicke) plus 1–3 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 };
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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
@@ -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 (1–3), 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);
|
||||
|
||||
Reference in New Issue
Block a user