Fenster: echte Schachtelung Blendrahmen → Flügelrahmen → Glas (2D+3D)
Öffenbare vs. feste Flügel sind jetzt unterscheidbar, und die Einbaulage (Schichteinzug) wirkt auch im 2D-Grundriss. 2D (windowSymbol/generatePlan): - Der reiche Pfad (typisiertes Fenster) zeichnet den Blendrahmen an der REALEN Einbaulage (insetFace/insetFromFace, vorher ignoriert), je Flügel einen Flügelrahmen (window-sash) nur wenn ÖFFENBAR, und das Glas darin. Feste Flügel: Glas direkt im Blendrahmen. - Die alte Diagonale quer über die Box (sah aus wie durchgestrichen) entfernt — Öffenbarkeit wird jetzt über den Flügelrahmen ausgedrückt. - Alt-Pfad (kein Typ) byte-identisch → kernel2d-Parität unberührt. 3D (frameMeshesForOpening/resolveOpeningFrame): - Je öffenbarem Flügel ein Flügelrahmen-Ring (4 Riegel) im Blendrahmen (fein). - Flügeltabelle konsistent aus explizitem sashes bzw. effektivem wingCount (Instanz-Override wirkt jetzt auch auf Flügelrahmen); wingCount daraus abgeleitet. Tests angepasst/ergänzt (fest vs. öffenbar, Zählungen). 663/663 grün.
This commit is contained in:
+117
-41
@@ -180,16 +180,40 @@ export interface WindowSymbol {
|
|||||||
*/
|
*/
|
||||||
mullionLines: [Vec2, Vec2][];
|
mullionLines: [Vec2, Vec2][];
|
||||||
/**
|
/**
|
||||||
* Öffnungsandeutung je NICHT-festem Flügel (Dreh/Kipp/Drehkipp/Schiebe): eine
|
* Flügelrahmen (Flügelkontur) je ÖFFENBAREM Flügel (Dreh/Kipp/Drehkipp/
|
||||||
* einzelne Diagonale über die Flügelfläche, von der äusseren Ecke der
|
* Schiebe): ein Rechteck INNERHALB des Blendrahmens (Schachtelung
|
||||||
* Bandseite GEGENüberliegenden Seite (aussen) zur Bandseite (innen) — eine
|
* Blendrahmen → Flügelrahmen → Glas), das den öffenbaren Flügel vom festen
|
||||||
* schlichte Andeutung der Öffnungsrichtung, analog zur Schwenkbogen-Andeutung
|
* Feld unterscheidet. Feste Flügel (`opening:"fest"`) bekommen KEINEN
|
||||||
* bei Türen, aber ohne Bogen (P0-Minimalfassung). Nur befüllt, wenn `sashes`
|
* Flügelrahmen — dort sitzt das Glas direkt im Blendrahmen. Nur befüllt im
|
||||||
* übergeben wird; leer im Alt-Fall (kein `sashes`).
|
* neuen (spec-)Pfad; leer im Alt-Fall (kein `spec`).
|
||||||
|
*/
|
||||||
|
sashFrames: Vec2[][];
|
||||||
|
/**
|
||||||
|
* (Alt-Feld, für Rückwärtskompatibilität der Schnittstelle beibehalten) — im
|
||||||
|
* neuen Pfad leer; die Öffenbarkeit wird jetzt über {@link sashFrames}
|
||||||
|
* ausgedrückt statt über eine Diagonale quer über die Box.
|
||||||
*/
|
*/
|
||||||
sashOpeningLines: [Vec2, Vec2][];
|
sashOpeningLines: [Vec2, Vec2][];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render-Parameter des Fensters für die reiche 2D-Symbolik (aus dem
|
||||||
|
* referenzierten {@link WindowType}): Flügeltabelle, Profilbreite, Rahmentiefe
|
||||||
|
* und Schichteinzug. Fehlt der Spec (kein Typ auflösbar), zeichnet
|
||||||
|
* {@link windowSymbol} das schlichte Alt-Symbol (`o.wingCount`, volle Wanddicke).
|
||||||
|
*/
|
||||||
|
export interface WindowRenderSpec {
|
||||||
|
sashes: SashDef[];
|
||||||
|
/** Sichtbare Profilbreite (Meter) von Blend-/Flügelrahmen in der Öffnungsebene. */
|
||||||
|
frameWidth: number;
|
||||||
|
/** Rahmentiefe quer zur Wand (Meter); fehlt ⇒ volle Wanddicke. */
|
||||||
|
frameDepth?: number;
|
||||||
|
/** Schichteinzug: Abstand der Rahmen-Vorderkante von {@link insetFace} (Meter). */
|
||||||
|
insetFromFace?: number;
|
||||||
|
/** Bezugsfläche des Schichteinzugs; Default "aussen". */
|
||||||
|
insetFace?: "aussen" | "innen";
|
||||||
|
}
|
||||||
|
|
||||||
/** Ein Segment (Flügel/Pfosten) der Flügeleinteilung, mit aufgelöster Breite. */
|
/** Ein Segment (Flügel/Pfosten) der Flügeleinteilung, mit aufgelöster Breite. */
|
||||||
interface SashSegment {
|
interface SashSegment {
|
||||||
sash: SashDef;
|
sash: SashDef;
|
||||||
@@ -254,7 +278,7 @@ export function windowSymbol(
|
|||||||
wall: Wall,
|
wall: Wall,
|
||||||
o: Opening,
|
o: Opening,
|
||||||
glassCount: number,
|
glassCount: number,
|
||||||
sashes?: SashDef[],
|
spec?: WindowRenderSpec,
|
||||||
): WindowSymbol | null {
|
): WindowSymbol | null {
|
||||||
const jambs = openingJambs(wall, o);
|
const jambs = openingJambs(wall, o);
|
||||||
if (!jambs) return null;
|
if (!jambs) return null;
|
||||||
@@ -264,6 +288,90 @@ export function windowSymbol(
|
|||||||
const outer = refOff + total / 2;
|
const outer = refOff + total / 2;
|
||||||
const inner = refOff - total / 2;
|
const inner = refOff - total / 2;
|
||||||
const { jambStart, jambEnd } = jambs;
|
const { jambStart, jambEnd } = jambs;
|
||||||
|
|
||||||
|
// ── Reicher Pfad (typisiertes Fenster mit Flügeltabelle) ─────────────────
|
||||||
|
// Blendrahmen an der realen Einbaulage (Schichteinzug), je Flügel
|
||||||
|
// Blendrahmen → Flügelrahmen (öffenbar) → Glas. Feste Flügel: Glas direkt im
|
||||||
|
// Blendrahmen (kein Flügelrahmen) — so unterscheidet sich fest von öffenbar.
|
||||||
|
if (spec && spec.sashes.length > 0) {
|
||||||
|
const fw = Math.max(0.01, spec.frameWidth);
|
||||||
|
const depth = spec.frameDepth && spec.frameDepth > 1e-9 ? spec.frameDepth : total;
|
||||||
|
const inset = Math.max(0, spec.insetFromFace ?? 0);
|
||||||
|
const halfT = total / 2;
|
||||||
|
const sign = (spec.insetFace ?? "aussen") === "innen" ? -1 : 1;
|
||||||
|
// Rahmen-Vorderkante bündig mit der Bezugsfläche, um `inset` zurück; der
|
||||||
|
// Körper reicht `depth` zur Wandmitte. Auf die Wanddicke geklemmt (mirror
|
||||||
|
// von resolveFrameNormalRange im 3D). Alles relativ zur Referenzlinie.
|
||||||
|
let frontRel = sign * (halfT - inset);
|
||||||
|
let backRel = frontRel - sign * depth;
|
||||||
|
frontRel = Math.max(-halfT, Math.min(halfT, frontRel));
|
||||||
|
backRel = Math.max(-halfT, Math.min(halfT, backRel));
|
||||||
|
const frameOuterN = refOff + Math.max(frontRel, backRel);
|
||||||
|
const frameInnerN = refOff + Math.min(frontRel, backRel);
|
||||||
|
const bandDepth = frameOuterN - frameInnerN;
|
||||||
|
|
||||||
|
const frame = [
|
||||||
|
add(jambStart, scale(n, frameOuterN)),
|
||||||
|
add(jambEnd, scale(n, frameOuterN)),
|
||||||
|
add(jambEnd, scale(n, frameInnerN)),
|
||||||
|
add(jambStart, scale(n, frameInnerN)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const axisWidth = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
|
||||||
|
const segs = sashSegments(spec.sashes, axisWidth);
|
||||||
|
|
||||||
|
// Trennlinien (Pfosten/Mittelpfosten) an den inneren Segmentgrenzen, über
|
||||||
|
// die volle Rahmentiefe.
|
||||||
|
const mullionLines: [Vec2, Vec2][] = [];
|
||||||
|
for (let i = 0; i < segs.length - 1; i++) {
|
||||||
|
const onAxis = along(jambStart, jambEnd, segs[i].to);
|
||||||
|
mullionLines.push([add(onAxis, scale(n, frameOuterN)), add(onAxis, scale(n, frameInnerN))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sashFrames: Vec2[][] = [];
|
||||||
|
const glassLines: [Vec2, Vec2][] = [];
|
||||||
|
const count = Math.max(1, glassCount);
|
||||||
|
// Öffenbarer Flügel sitzt `reveal` tiefer im Blendrahmen (n- und u-Richtung).
|
||||||
|
const reveal = Math.min(bandDepth * 0.3, fw * 0.7);
|
||||||
|
for (const seg of segs) {
|
||||||
|
if (seg.sash.kind === "pfosten") continue;
|
||||||
|
const clearFrom = seg.from + fw;
|
||||||
|
const clearTo = seg.to - fw;
|
||||||
|
if (clearTo - clearFrom <= 1e-6) continue;
|
||||||
|
const operable = !!seg.sash.opening && seg.sash.opening !== "fest";
|
||||||
|
const gOuterN = frameOuterN - (operable ? reveal : 0);
|
||||||
|
const gInnerN = frameInnerN + (operable ? reveal : 0);
|
||||||
|
if (operable) {
|
||||||
|
const sFrom = along(jambStart, jambEnd, clearFrom);
|
||||||
|
const sTo = along(jambStart, jambEnd, clearTo);
|
||||||
|
sashFrames.push([
|
||||||
|
add(sFrom, scale(n, gOuterN)),
|
||||||
|
add(sTo, scale(n, gOuterN)),
|
||||||
|
add(sTo, scale(n, gInnerN)),
|
||||||
|
add(sFrom, scale(n, gInnerN)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// Glas innerhalb des Flügels (bei öffenbar zusätzlich in u eingerückt).
|
||||||
|
const gInsetU = operable ? fw * 0.6 : 0;
|
||||||
|
const gFrom = clearFrom + gInsetU;
|
||||||
|
const gTo = clearTo - gInsetU;
|
||||||
|
if (gTo - gFrom <= 1e-6) continue;
|
||||||
|
const gMid = (gOuterN + gInnerN) / 2;
|
||||||
|
const gBand = gOuterN - gInnerN;
|
||||||
|
const step = count > 1 ? Math.min(total / 10, gBand / (count + 1)) : 0;
|
||||||
|
const a = along(jambStart, jambEnd, gFrom);
|
||||||
|
const b = along(jambStart, jambEnd, gTo);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const off = gMid + (count === 1 ? 0 : (i - (count - 1) / 2) * step);
|
||||||
|
glassLines.push([add(a, scale(n, off)), add(b, scale(n, off))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { frame, glassLines, mullionLines, sashFrames, sashOpeningLines: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alt-Pfad (kein Typ auflösbar) ────────────────────────────────────────
|
||||||
|
// UNVERÄNDERT (Rust-Parität kernel2d): Rahmen an voller Wanddicke, Glaslinien
|
||||||
|
// mittig, (wingCount−1) gleichmäßige Mittelpfosten. Keine Flügelrahmen.
|
||||||
const frame = [
|
const frame = [
|
||||||
add(jambStart, scale(n, outer)),
|
add(jambStart, scale(n, outer)),
|
||||||
add(jambEnd, scale(n, outer)),
|
add(jambEnd, scale(n, outer)),
|
||||||
@@ -273,53 +381,21 @@ export function windowSymbol(
|
|||||||
const glassLines: [Vec2, Vec2][] = [];
|
const glassLines: [Vec2, Vec2][] = [];
|
||||||
const count = Math.max(1, glassCount);
|
const count = Math.max(1, glassCount);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
// Glaslinien symmetrisch um die Wandmitte verteilt (Abstand = Dicke/8).
|
|
||||||
const spread = total / 8;
|
const spread = total / 8;
|
||||||
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
|
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
|
||||||
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
|
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
|
||||||
}
|
}
|
||||||
const mullionLines: [Vec2, Vec2][] = [];
|
const mullionLines: [Vec2, Vec2][] = [];
|
||||||
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 = 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)));
|
const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1)));
|
||||||
if (wings > 1) {
|
if (wings > 1) {
|
||||||
const postDepth = total * 0.6;
|
const postDepth = total * 0.6;
|
||||||
const pOuter = refOff + postDepth / 2;
|
const pOuter = refOff + postDepth / 2;
|
||||||
const pInner = refOff - postDepth / 2;
|
const pInner = refOff - postDepth / 2;
|
||||||
for (let i = 1; i < wings; i++) {
|
for (let i = 1; i < wings; i++) {
|
||||||
const t = i / wings; // gleichmäßige Teilung entlang der Achse
|
const t = i / wings;
|
||||||
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
|
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
|
||||||
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
|
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return { frame, glassLines, mullionLines, sashFrames: [], sashOpeningLines: [] };
|
||||||
return { frame, glassLines, mullionLines, sashOpeningLines };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2218,14 +2218,22 @@ function addOpeningSymbol(
|
|||||||
// Flügeltabelle NUR übergeben, wenn ein Fenstertyp auflösbar ist — fehlt er
|
// Flügeltabelle NUR übergeben, wenn ein Fenstertyp auflösbar ist — fehlt er
|
||||||
// (kein `typeId`/unbekannt), bleibt `windowSymbol` beim reinen
|
// (kein `typeId`/unbekannt), bleibt `windowSymbol` beim reinen
|
||||||
// `o.wingCount`-Alt-Verhalten (siehe dortiger Kommentar, Regressionssicherheit).
|
// `o.wingCount`-Alt-Verhalten (siehe dortiger Kommentar, Regressionssicherheit).
|
||||||
const sashes = wt ? sashesOfWindowType(wt) : undefined;
|
const spec = wt
|
||||||
|
? {
|
||||||
|
sashes: sashesOfWindowType(wt),
|
||||||
|
frameWidth: wt.frameWidth ?? 0.06,
|
||||||
|
frameDepth: wt.frameDepth,
|
||||||
|
insetFromFace: wt.insetFromFace,
|
||||||
|
insetFace: wt.insetFace,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
const detailGlassCount = detail === "fein" ? 2 : 1;
|
const detailGlassCount = detail === "fein" ? 2 : 1;
|
||||||
// Verglasungs-Scheibenzahl des Typs (glazingPanesOf) hebt die Detailgrad-
|
// Verglasungs-Scheibenzahl des Typs (glazingPanesOf) hebt die Detailgrad-
|
||||||
// Vorgabe bei mittel/fein an (mind. so viele Glaslinien); "grob" bleibt bei
|
// Vorgabe bei mittel/fein an (mind. so viele Glaslinien); "grob" bleibt bei
|
||||||
// einer Glaslinie, unabhängig vom Typ (bleibt die schlichteste Stufe).
|
// einer Glaslinie, unabhängig vom Typ (bleibt die schlichteste Stufe).
|
||||||
const glassCount =
|
const glassCount =
|
||||||
detail === "grob" ? detailGlassCount : Math.max(detailGlassCount, wt ? glazingPanesOf(wt) : 0);
|
detail === "grob" ? detailGlassCount : Math.max(detailGlassCount, wt ? glazingPanesOf(wt) : 0);
|
||||||
const sym = windowSymbol(project, wall, o, glassCount, sashes);
|
const sym = windowSymbol(project, wall, o, glassCount, spec);
|
||||||
if (!sym) return;
|
if (!sym) return;
|
||||||
// Unsichtbares Pick-Polygon über der Öffnungslücke — macht das Fenster über
|
// Unsichtbares Pick-Polygon über der Öffnungslücke — macht das Fenster über
|
||||||
// die ganze Fläche selektierbar, auch bei „grob" (nur eine Glaslinie, sonst
|
// die ganze Fläche selektierbar, auch bei „grob" (nur eine Glaslinie, sonst
|
||||||
@@ -2269,15 +2277,17 @@ function addOpeningSymbol(
|
|||||||
openingId: o.id,
|
openingId: o.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Öffnungsandeutung je NICHT-festem Flügel (Dreh/Kipp/Drehkipp/Schiebe) —
|
// Flügelrahmen (Flügelkontur) je ÖFFENBAREM Flügel — Rechteck (4 Kanten)
|
||||||
// schlichte Diagonale zur Bandseite, nur wenn ein Fenstertyp mit
|
// innerhalb des Blendrahmens (Schachtelung Blendrahmen → Flügelrahmen →
|
||||||
// Flügeltabelle referenziert ist (siehe windowSymbol/sashOpeningLines).
|
// Glas). Feste Flügel bekommen keinen; so liest man fest vs. öffenbar
|
||||||
for (const [a, b] of sym.sashOpeningLines) {
|
// direkt im Plan.
|
||||||
|
for (const pts of sym.sashFrames) {
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
out.push({
|
out.push({
|
||||||
kind: "line",
|
kind: "line",
|
||||||
a,
|
a: pts[i],
|
||||||
b,
|
b: pts[(i + 1) % pts.length],
|
||||||
cls: "window-opening",
|
cls: "window-sash",
|
||||||
weightMm: SYMBOL_HAIRLINE_MM,
|
weightMm: SYMBOL_HAIRLINE_MM,
|
||||||
color: o.color ?? POCHE_STROKE,
|
color: o.color ?? POCHE_STROKE,
|
||||||
greyed,
|
greyed,
|
||||||
@@ -2285,6 +2295,7 @@ function addOpeningSymbol(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for (const [a, b] of sym.glassLines) {
|
for (const [a, b] of sym.glassLines) {
|
||||||
out.push({
|
out.push({
|
||||||
kind: "line",
|
kind: "line",
|
||||||
|
|||||||
@@ -142,7 +142,10 @@ describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => {
|
|||||||
a.forEach((v, i) => expect(v).toBeCloseTo(b[i], 9));
|
a.forEach((v, i) => expect(v).toBeCloseTo(b[i], 9));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Flügel mit opening !== 'fest' erzeugt eine Öffnungsandeutung (window-opening); 'fest' keine", () => {
|
it("öffenbarer Flügel bekommt einen Flügelrahmen (window-sash, 4 Kanten); 'fest' keinen", () => {
|
||||||
|
// Ein öffenbarer + ein fester Flügel: genau EIN Flügelrahmen-Rechteck = 4
|
||||||
|
// Kanten-Linien (der feste Flügel bekommt keinen; Glas sitzt direkt im
|
||||||
|
// Blendrahmen). So liest man fest vs. öffenbar direkt im Grundriss.
|
||||||
const wt: WindowType = {
|
const wt: WindowType = {
|
||||||
...baseWindowType,
|
...baseWindowType,
|
||||||
sashes: [
|
sashes: [
|
||||||
@@ -151,12 +154,20 @@ describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const p = project({ ...baseWindow, typeId: "wt1" }, [wt]);
|
const p = project({ ...baseWindow, typeId: "wt1" }, [wt]);
|
||||||
expect(linesOfClass(p, "window-opening").length).toBe(1);
|
expect(linesOfClass(p, "window-sash").length).toBe(4);
|
||||||
|
|
||||||
|
// Nur feste Flügel -> gar kein Flügelrahmen.
|
||||||
|
const wtFixed: WindowType = {
|
||||||
|
...baseWindowType,
|
||||||
|
sashes: [{ kind: "fluegel", autoWidth: true, opening: "fest" }],
|
||||||
|
};
|
||||||
|
const pFixed = project({ ...baseWindow, typeId: "wt1" }, [wtFixed]);
|
||||||
|
expect(linesOfClass(pFixed, "window-sash").length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Alt-Fall (kein typeId) erzeugt KEINE Öffnungsandeutung", () => {
|
it("Alt-Fall (kein typeId) erzeugt KEINEN Flügelrahmen", () => {
|
||||||
const p = project({ ...baseWindow });
|
const p = project({ ...baseWindow });
|
||||||
expect(linesOfClass(p, "window-opening").length).toBe(0);
|
expect(linesOfClass(p, "window-sash").length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -386,8 +386,9 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
|
|||||||
|
|
||||||
it("typisiertes Fenster (O1, Typ window-standard) -> Rahmen-Riegel mit endlichen Positionen innerhalb der Öffnungshöhe", () => {
|
it("typisiertes Fenster (O1, Typ window-standard) -> Rahmen-Riegel mit endlichen Positionen innerhalb der Öffnungshöhe", () => {
|
||||||
// O1: sillHeight 0.9, height 1.2 -> Öffnung UK 0.9 .. OK 2.1. Typ "window-
|
// O1: sillHeight 0.9, height 1.2 -> Öffnung UK 0.9 .. OK 2.1. Typ "window-
|
||||||
// standard" hat wingCount 1, kein mullionRows/transomHeight -> nur die 4
|
// standard" hat wingCount 1, kein mullionRows/transomHeight -> 4 Basis-Riegel
|
||||||
// Basis-Riegel (2 Pfosten + Sturz + Schwelle), keine Sprossen/Kämpfer.
|
// (2 Pfosten + Sturz + Schwelle). "drehkipp" ist ÖFFENBAR -> zusätzlich ein
|
||||||
|
// Flügelrahmen-Ring (4 Riegel) im Blendrahmen = 8 Riegel gesamt (fein).
|
||||||
const p: Project = {
|
const p: Project = {
|
||||||
...sampleProject,
|
...sampleProject,
|
||||||
openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"),
|
openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"),
|
||||||
@@ -396,7 +397,7 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
|
|||||||
};
|
};
|
||||||
const { meshes } = projectToModel3d(p);
|
const { meshes } = projectToModel3d(p);
|
||||||
const frames = meshes.filter(isFrame);
|
const frames = meshes.filter(isFrame);
|
||||||
expect(frames.length).toBe(4);
|
expect(frames.length).toBe(8);
|
||||||
for (const f of frames) {
|
for (const f of frames) {
|
||||||
expect(f.positions.every((v) => Number.isFinite(v))).toBe(true);
|
expect(f.positions.every((v) => Number.isFinite(v))).toBe(true);
|
||||||
const { min, max } = zRange(f);
|
const { min, max } = zRange(f);
|
||||||
@@ -405,8 +406,8 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen, fein=+Mittelpfosten)", () => {
|
it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen, fein=+Mittelpfosten+Flügelrahmen)", () => {
|
||||||
// O1 mit erzwungenem wingCount 2 -> im FEIN-Fall ein Flügel-Mittelpfosten extra.
|
// 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");
|
||||||
const p: Project = {
|
const p: Project = {
|
||||||
@@ -418,8 +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, KEIN Mittelpfosten
|
expect(frameCount("mittel")).toBe(4); // 2 Pfosten + Sturz + Schwelle (keine Sprossen/Flügelrahmen)
|
||||||
expect(frameCount("fein")).toBe(5); // + 1 Flügel-Mittelpfosten (wingCount 2)
|
// fein: 4 Basis + 1 Mittelpfosten (wingCount 2) + 2 öffenbare Flügel × 4
|
||||||
|
// Flügelrahmen-Riegel = 4 + 1 + 8 = 13.
|
||||||
|
expect(frameCount("fein")).toBe(13);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("transomHeight > 0 -> zusätzlicher Kämpfer-Riegel + zweite Glasscheibe im Oberlicht-Band", () => {
|
it("transomHeight > 0 -> zusätzlicher Kämpfer-Riegel + zweite Glasscheibe im Oberlicht-Band", () => {
|
||||||
@@ -479,9 +482,41 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("wingCount=2 + mullionRows=2 -> je ein zusätzlicher Mittelpfosten (vertikal) und Kämpfer (horizontal)", () => {
|
it("wingCount=2 + mullionRows=2 -> je ein zusätzlicher Mittelpfosten (vertikal) und Kämpfer (horizontal)", () => {
|
||||||
|
// Baseline: FESTES Einzelfenster (kein Flügelrahmen), damit nur die
|
||||||
|
// Mittelpfosten/Kämpfer den Unterschied ausmachen (nicht die Flügelrahmen
|
||||||
|
// öffenbarer Flügel). Beide Typen "fest".
|
||||||
const baseline: Project = {
|
const baseline: Project = {
|
||||||
...sampleProject,
|
...sampleProject,
|
||||||
openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"),
|
windowTypes: [
|
||||||
|
...(sampleProject.windowTypes ?? []),
|
||||||
|
{
|
||||||
|
id: "win-fixed-base",
|
||||||
|
name: "Test Fest 1-flg",
|
||||||
|
kind: "fest",
|
||||||
|
wingCount: 1,
|
||||||
|
mullionRows: 1,
|
||||||
|
glazing: "einfach",
|
||||||
|
frameThickness: 0.06,
|
||||||
|
frameWidth: 0.06,
|
||||||
|
defaultSillHeight: 0.9,
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
defaultHeight: 1.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
openings: [
|
||||||
|
{
|
||||||
|
id: "OB",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: "W1",
|
||||||
|
categoryCode: "21",
|
||||||
|
kind: "window",
|
||||||
|
typeId: "win-fixed-base",
|
||||||
|
position: 3.0,
|
||||||
|
width: 1.0,
|
||||||
|
height: 1.2,
|
||||||
|
sillHeight: 0.9,
|
||||||
|
},
|
||||||
|
],
|
||||||
doors: [],
|
doors: [],
|
||||||
context: [],
|
context: [],
|
||||||
};
|
};
|
||||||
@@ -526,6 +561,49 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", (
|
|||||||
expect(mullionCount).toBe(baseCount + 2);
|
expect(mullionCount).toBe(baseCount + 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("öffenbarer Flügel bekommt einen Flügelrahmen-Ring (4 Riegel) im 3D; fest keinen", () => {
|
||||||
|
// Gleiches Fenster einmal 'fest', einmal 'dreh' (öffenbar). Der öffenbare
|
||||||
|
// Flügel erzeugt zusätzlich einen Flügelrahmen-Ring (4 Riegel) INNERHALB des
|
||||||
|
// Blendrahmens (Schachtelung Blendrahmen → Flügelrahmen → Glas).
|
||||||
|
const win = (kind: "fest" | "dreh"): Project => ({
|
||||||
|
...sampleProject,
|
||||||
|
windowTypes: [
|
||||||
|
{
|
||||||
|
id: "win-sash-test",
|
||||||
|
name: `Test ${kind}`,
|
||||||
|
kind,
|
||||||
|
wingCount: 1,
|
||||||
|
glazing: "einfach",
|
||||||
|
frameThickness: 0.06,
|
||||||
|
frameWidth: 0.06,
|
||||||
|
defaultSillHeight: 0.9,
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
defaultHeight: 1.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
openings: [
|
||||||
|
{
|
||||||
|
id: "OSF",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: "W1",
|
||||||
|
categoryCode: "21",
|
||||||
|
kind: "window",
|
||||||
|
typeId: "win-sash-test",
|
||||||
|
position: 3.0,
|
||||||
|
width: 1.0,
|
||||||
|
height: 1.2,
|
||||||
|
sillHeight: 0.9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
doors: [],
|
||||||
|
context: [],
|
||||||
|
});
|
||||||
|
const fixedCount = projectToModel3d(win("fest")).meshes.filter(isFrame).length;
|
||||||
|
const operableCount = projectToModel3d(win("dreh")).meshes.filter(isFrame).length;
|
||||||
|
expect(fixedCount).toBe(4); // nur Blendrahmen (2 Pfosten + Sturz + Schwelle)
|
||||||
|
expect(operableCount).toBe(8); // + 4 Flügelrahmen-Riegel
|
||||||
|
});
|
||||||
|
|
||||||
it("insetFromFace verschiebt Rahmen-Position entlang der Wand-Normalen gegenüber bündig (kein Einzug)", () => {
|
it("insetFromFace verschiebt Rahmen-Position entlang der Wand-Normalen gegenüber bündig (kein Einzug)", () => {
|
||||||
// W1-Achse (0,0)->(5,0): u=(1,0) -> Normale n=(0,-1); der Quer-Versatz
|
// W1-Achse (0,0)->(5,0): u=(1,0) -> Normale n=(0,-1); der Quer-Versatz
|
||||||
// bildet sich rein in der Y-Koordinate ab (y = -t). insetFromFace 0.1 muss
|
// bildet sich rein in der Y-Koordinate ab (y = -t). insetFromFace 0.1 muss
|
||||||
|
|||||||
+83
-4
@@ -32,7 +32,7 @@
|
|||||||
// Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle
|
// Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle
|
||||||
// Dicke im neutralen Grundton (heutiges Fallback-Verhalten).
|
// Dicke im neutralen Grundton (heutiges Fallback-Verhalten).
|
||||||
|
|
||||||
import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination, Opening } from "../model/types";
|
import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination, Opening, SashDef } from "../model/types";
|
||||||
import {
|
import {
|
||||||
getComponent,
|
getComponent,
|
||||||
getWallType,
|
getWallType,
|
||||||
@@ -1850,6 +1850,12 @@ interface OpeningFrameParams {
|
|||||||
* {@link glassPanesForOpening}.
|
* {@link glassPanesForOpening}.
|
||||||
*/
|
*/
|
||||||
glazingPanes: 1 | 2 | 3;
|
glazingPanes: 1 | 2 | 3;
|
||||||
|
/**
|
||||||
|
* Flügeleinteilung ({@link sashesOfWindowType}) — je ÖFFENBAREM Flügel wird im
|
||||||
|
* 3D ein Flügelrahmen (Rahmen-Riegel innerhalb des Blendrahmens) erzeugt
|
||||||
|
* (Schachtelung Blendrahmen → Flügelrahmen → Glas). Türen: leer.
|
||||||
|
*/
|
||||||
|
sashes: SashDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null {
|
function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null {
|
||||||
@@ -1876,6 +1882,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
|
|||||||
mullionRows: 1,
|
mullionRows: 1,
|
||||||
glazed: dt.leafStyle === "glas",
|
glazed: dt.leafStyle === "glas",
|
||||||
glazingPanes: 1,
|
glazingPanes: 1,
|
||||||
|
sashes: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const wt = getWindowType(project, op);
|
const wt = getWindowType(project, op);
|
||||||
@@ -1887,6 +1894,22 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
|
|||||||
wt.insetFromFace ?? 0,
|
wt.insetFromFace ?? 0,
|
||||||
wt.insetFace ?? "aussen",
|
wt.insetFace ?? "aussen",
|
||||||
);
|
);
|
||||||
|
// Flügeltabelle: explizite `wt.sashes` gewinnen; sonst gleichmäßig aus dem
|
||||||
|
// effektiven Flügelzahl-Wert (Instanz-Override `op.wingCount` vor Typ-Default).
|
||||||
|
// wingCount wird DARAUS abgeleitet, damit Mittelpfosten (wingCount−1) und
|
||||||
|
// Flügelrahmen (je Flügel) konsistent bleiben.
|
||||||
|
let sashes = wt.sashes && wt.sashes.length > 0 ? wt.sashes : undefined;
|
||||||
|
if (!sashes) {
|
||||||
|
const n = Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1));
|
||||||
|
const opening = wt.kind === "fest" ? "fest" : wt.kind;
|
||||||
|
sashes = Array.from({ length: n }, (_, i) => ({
|
||||||
|
kind: "fluegel" as const,
|
||||||
|
autoWidth: true,
|
||||||
|
opening,
|
||||||
|
hingeSide: (i % 2 === 0 ? "left" : "right") as "left" | "right",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const wingCount = Math.max(1, sashes.filter((s) => s.kind === "fluegel").length);
|
||||||
return {
|
return {
|
||||||
frameWidth: wt.frameWidth ?? DEFAULT_FRAME_WIDTH,
|
frameWidth: wt.frameWidth ?? DEFAULT_FRAME_WIDTH,
|
||||||
acrossWallDepth,
|
acrossWallDepth,
|
||||||
@@ -1894,12 +1917,11 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening
|
|||||||
nMax,
|
nMax,
|
||||||
transomHeight: Math.max(0, wt.transomHeight ?? 0),
|
transomHeight: Math.max(0, wt.transomHeight ?? 0),
|
||||||
hasSill: true,
|
hasSill: true,
|
||||||
// Inline-Override `Opening.wingCount` gewinnt vor dem Typ-Default (analog
|
wingCount,
|
||||||
// `windowSymbol` im 2D, das dieselbe Priorität kennt), sonst Typ, sonst 1.
|
|
||||||
wingCount: Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1)),
|
|
||||||
mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)),
|
mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)),
|
||||||
glazed: true,
|
glazed: true,
|
||||||
glazingPanes: glazingPanesOf(wt),
|
glazingPanes: glazingPanesOf(wt),
|
||||||
|
sashes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1962,6 +1984,63 @@ 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
|
||||||
|
// (Schachtelung Blendrahmen → Flügelrahmen → Glas). Feste Flügel bekommen
|
||||||
|
// keinen — so unterscheidet sich fest von öffenbar auch im 3D.
|
||||||
|
if (params.sashes.length > 0 && sashTo - sashFrom > EPS && sashTop - sashBottom > EPS) {
|
||||||
|
const segs = resolveSashSpans(params.sashes, sashFrom, sashTo);
|
||||||
|
const reveal = fw * 0.5;
|
||||||
|
const sfw = fw * 0.7;
|
||||||
|
for (const seg of segs) {
|
||||||
|
const s = seg.sash;
|
||||||
|
if (s.kind !== "fluegel" || !s.opening || s.opening === "fest") continue;
|
||||||
|
const cf = seg.from + fw + reveal;
|
||||||
|
const ct = seg.to - fw - reveal;
|
||||||
|
const bz = sashBottom + reveal;
|
||||||
|
const tz = sashTop - reveal;
|
||||||
|
if (ct - cf <= EPS || tz - bz <= EPS) continue;
|
||||||
|
push(cf, cf + sfw, bz, tz); // linker Flügelholm
|
||||||
|
push(ct - sfw, ct, bz, tz); // rechter Flügelholm
|
||||||
|
push(cf, ct, tz - sfw, tz); // oberer Flügelriegel
|
||||||
|
push(cf, ct, bz, bz + sfw); // unterer Flügelriegel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst die Flügeleinteilung auf das konkrete Achsintervall `[from,to]` (Meter)
|
||||||
|
* auf — feste Breiten (`kind:"pfosten"`/`autoWidth:false`) exakt, der Rest
|
||||||
|
* gleichmäßig auf die Restbreite. Pendant zu `sashSegments` in `opening.ts`
|
||||||
|
* (2D), aber direkt in Öffnungs-Achskoordinaten (3D).
|
||||||
|
*/
|
||||||
|
function resolveSashSpans(
|
||||||
|
sashes: SashDef[],
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
): { sash: SashDef; from: number; to: number }[] {
|
||||||
|
const width = Math.max(0, to - from);
|
||||||
|
let fixedSum = 0;
|
||||||
|
let autoCount = 0;
|
||||||
|
for (const s of sashes) {
|
||||||
|
if (s.kind === "pfosten") fixedSum += Math.max(0, s.postWidth ?? 0.06);
|
||||||
|
else if (s.autoWidth === false) fixedSum += Math.max(0, s.width ?? 0);
|
||||||
|
else autoCount++;
|
||||||
|
}
|
||||||
|
const autoW = autoCount > 0 ? Math.max(0, (width - fixedSum) / autoCount) : 0;
|
||||||
|
const out: { sash: SashDef; from: number; to: number }[] = [];
|
||||||
|
let cursor = from;
|
||||||
|
for (const s of sashes) {
|
||||||
|
const w =
|
||||||
|
s.kind === "pfosten"
|
||||||
|
? Math.max(0, s.postWidth ?? 0.06)
|
||||||
|
: s.autoWidth === false
|
||||||
|
? Math.max(0, s.width ?? 0)
|
||||||
|
: autoW;
|
||||||
|
out.push({ sash: s, from: cursor, to: cursor + w });
|
||||||
|
cursor += w;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user