From 9a38636bf24e38b78283f44cea67a9f99bb7377e Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 10 Jul 2026 01:47:27 +0200 Subject: [PATCH] =?UTF-8?q?Fenster:=20echte=20Schachtelung=20Blendrahmen?= =?UTF-8?q?=20=E2=86=92=20Fl=C3=BCgelrahmen=20=E2=86=92=20Glas=20(2D+3D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ö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. --- src/geometry/opening.ts | 166 +++++++++++++++------ src/plan/generatePlan.ts | 43 ++++-- src/plan/generatePlan.windowFields.test.ts | 19 ++- src/plan/toWalls3d.test.ts | 94 +++++++++++- src/plan/toWalls3d.ts | 87 ++++++++++- 5 files changed, 332 insertions(+), 77 deletions(-) diff --git a/src/geometry/opening.ts b/src/geometry/opening.ts index ea772ba..b4e21f5 100644 --- a/src/geometry/opening.ts +++ b/src/geometry/opening.ts @@ -180,16 +180,40 @@ export interface WindowSymbol { */ 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`). + * Flügelrahmen (Flügelkontur) je ÖFFENBAREM Flügel (Dreh/Kipp/Drehkipp/ + * Schiebe): ein Rechteck INNERHALB des Blendrahmens (Schachtelung + * Blendrahmen → Flügelrahmen → Glas), das den öffenbaren Flügel vom festen + * Feld unterscheidet. Feste Flügel (`opening:"fest"`) bekommen KEINEN + * Flügelrahmen — dort sitzt das Glas direkt im Blendrahmen. Nur befüllt im + * 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][]; } +/** + * 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. */ interface SashSegment { sash: SashDef; @@ -254,7 +278,7 @@ export function windowSymbol( wall: Wall, o: Opening, glassCount: number, - sashes?: SashDef[], + spec?: WindowRenderSpec, ): WindowSymbol | null { const jambs = openingJambs(wall, o); if (!jambs) return null; @@ -264,6 +288,90 @@ export function windowSymbol( const outer = refOff + total / 2; const inner = refOff - total / 2; 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 = [ add(jambStart, scale(n, outer)), add(jambEnd, scale(n, outer)), @@ -273,53 +381,21 @@ export function windowSymbol( const glassLines: [Vec2, Vec2][] = []; const count = Math.max(1, glassCount); for (let i = 0; i < count; i++) { - // Glaslinien symmetrisch um die Wandmitte verteilt (Abstand = Dicke/8). const spread = total / 8; const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2); glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]); } 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 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 = 0; i < segs.length - 1; i++) { - const onAxis = along(jambStart, jambEnd, segs[i].to); + for (let i = 1; i < wings; i++) { + const t = i / wings; + const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t)); 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, sashOpeningLines }; + return { frame, glassLines, mullionLines, sashFrames: [], sashOpeningLines: [] }; } diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index f449918..5839c0c 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -2218,14 +2218,22 @@ function addOpeningSymbol( // 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 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; // 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); + const sym = windowSymbol(project, wall, o, glassCount, spec); 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 @@ -2269,20 +2277,23 @@ 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, - }); + // Flügelrahmen (Flügelkontur) je ÖFFENBAREM Flügel — Rechteck (4 Kanten) + // innerhalb des Blendrahmens (Schachtelung Blendrahmen → Flügelrahmen → + // Glas). Feste Flügel bekommen keinen; so liest man fest vs. öffenbar + // direkt im Plan. + for (const pts of sym.sashFrames) { + for (let i = 0; i < pts.length; i++) { + out.push({ + kind: "line", + a: pts[i], + b: pts[(i + 1) % pts.length], + cls: "window-sash", + weightMm: SYMBOL_HAIRLINE_MM, + color: o.color ?? POCHE_STROKE, + greyed, + openingId: o.id, + }); + } } } for (const [a, b] of sym.glassLines) { diff --git a/src/plan/generatePlan.windowFields.test.ts b/src/plan/generatePlan.windowFields.test.ts index 4da9560..1b18f15 100644 --- a/src/plan/generatePlan.windowFields.test.ts +++ b/src/plan/generatePlan.windowFields.test.ts @@ -142,7 +142,10 @@ describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => { 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 = { ...baseWindowType, sashes: [ @@ -151,12 +154,20 @@ describe("generatePlan — Flügeleinteilung aus sashes (Item 1)", () => { ], }; 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 }); - expect(linesOfClass(p, "window-opening").length).toBe(0); + expect(linesOfClass(p, "window-sash").length).toBe(0); }); }); diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 9c5cae9..4041cf5 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -386,8 +386,9 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( it("typisiertes Fenster (O1, Typ window-standard) -> Rahmen-Riegel mit endlichen Positionen innerhalb der Öffnungshöhe", () => { // O1: sillHeight 0.9, height 1.2 -> Öffnung UK 0.9 .. OK 2.1. Typ "window- - // standard" hat wingCount 1, kein mullionRows/transomHeight -> nur die 4 - // Basis-Riegel (2 Pfosten + Sturz + Schwelle), keine Sprossen/Kämpfer. + // standard" hat wingCount 1, kein mullionRows/transomHeight -> 4 Basis-Riegel + // (2 Pfosten + Sturz + Schwelle). "drehkipp" ist ÖFFENBAR -> zusätzlich ein + // Flügelrahmen-Ring (4 Riegel) im Blendrahmen = 8 Riegel gesamt (fein). const p: Project = { ...sampleProject, openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"), @@ -396,7 +397,7 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( }; const { meshes } = projectToModel3d(p); const frames = meshes.filter(isFrame); - expect(frames.length).toBe(4); + expect(frames.length).toBe(8); for (const f of frames) { expect(f.positions.every((v) => Number.isFinite(v))).toBe(true); const { min, max } = zRange(f); @@ -405,8 +406,8 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( } }); - it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen, fein=+Mittelpfosten)", () => { - // O1 mit erzwungenem wingCount 2 -> im FEIN-Fall ein Flügel-Mittelpfosten extra. + it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen, fein=+Mittelpfosten+Flügelrahmen)", () => { + // O1 mit erzwungenem wingCount 2 (window-standard "drehkipp", öffenbar). const o1 = (sampleProject.openings ?? []).find((o) => o.id === "O1"); if (!o1) throw new Error("O1 fehlt im sampleProject"); const p: Project = { @@ -418,8 +419,10 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( const frameCount = (detail: "grob" | "mittel" | "fein"): number => projectToModel3d(p, { detail }).meshes.filter(isFrame).length; expect(frameCount("grob")).toBe(0); // keine Rahmen/Sprossen - expect(frameCount("mittel")).toBe(4); // 2 Pfosten + Sturz + Schwelle, KEIN Mittelpfosten - expect(frameCount("fein")).toBe(5); // + 1 Flügel-Mittelpfosten (wingCount 2) + expect(frameCount("mittel")).toBe(4); // 2 Pfosten + Sturz + Schwelle (keine Sprossen/Flügelrahmen) + // fein: 4 Basis + 1 Mittelpfosten (wingCount 2) + 2 öffenbare Flügel × 4 + // Flügelrahmen-Riegel = 4 + 1 + 8 = 13. + expect(frameCount("fein")).toBe(13); }); it("transomHeight > 0 -> zusätzlicher Kämpfer-Riegel + zweite Glasscheibe im Oberlicht-Band", () => { @@ -479,9 +482,41 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( }); it("wingCount=2 + mullionRows=2 -> je ein zusätzlicher Mittelpfosten (vertikal) und Kämpfer (horizontal)", () => { + // Baseline: FESTES Einzelfenster (kein Flügelrahmen), damit nur die + // Mittelpfosten/Kämpfer den Unterschied ausmachen (nicht die Flügelrahmen + // öffenbarer Flügel). Beide Typen "fest". const baseline: Project = { ...sampleProject, - openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"), + windowTypes: [ + ...(sampleProject.windowTypes ?? []), + { + id: "win-fixed-base", + name: "Test Fest 1-flg", + kind: "fest", + wingCount: 1, + mullionRows: 1, + glazing: "einfach", + frameThickness: 0.06, + frameWidth: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.0, + defaultHeight: 1.2, + }, + ], + openings: [ + { + id: "OB", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + typeId: "win-fixed-base", + position: 3.0, + width: 1.0, + height: 1.2, + sillHeight: 0.9, + }, + ], doors: [], context: [], }; @@ -526,6 +561,49 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( expect(mullionCount).toBe(baseCount + 2); }); + it("öffenbarer Flügel bekommt einen Flügelrahmen-Ring (4 Riegel) im 3D; fest keinen", () => { + // Gleiches Fenster einmal 'fest', einmal 'dreh' (öffenbar). Der öffenbare + // Flügel erzeugt zusätzlich einen Flügelrahmen-Ring (4 Riegel) INNERHALB des + // Blendrahmens (Schachtelung Blendrahmen → Flügelrahmen → Glas). + const win = (kind: "fest" | "dreh"): Project => ({ + ...sampleProject, + windowTypes: [ + { + id: "win-sash-test", + name: `Test ${kind}`, + kind, + wingCount: 1, + glazing: "einfach", + frameThickness: 0.06, + frameWidth: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.0, + defaultHeight: 1.2, + }, + ], + openings: [ + { + id: "OSF", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + typeId: "win-sash-test", + position: 3.0, + width: 1.0, + height: 1.2, + sillHeight: 0.9, + }, + ], + doors: [], + context: [], + }); + const fixedCount = projectToModel3d(win("fest")).meshes.filter(isFrame).length; + const operableCount = projectToModel3d(win("dreh")).meshes.filter(isFrame).length; + expect(fixedCount).toBe(4); // nur Blendrahmen (2 Pfosten + Sturz + Schwelle) + expect(operableCount).toBe(8); // + 4 Flügelrahmen-Riegel + }); + it("insetFromFace verschiebt Rahmen-Position entlang der Wand-Normalen gegenüber bündig (kein Einzug)", () => { // W1-Achse (0,0)->(5,0): u=(1,0) -> Normale n=(0,-1); der Quer-Versatz // bildet sich rein in der Y-Koordinate ab (y = -t). insetFromFace 0.1 muss diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 8605a08..ef1ce49 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -32,7 +32,7 @@ // Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle // 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 { getComponent, getWallType, @@ -1850,6 +1850,12 @@ interface OpeningFrameParams { * {@link glassPanesForOpening}. */ 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 { @@ -1876,6 +1882,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening mullionRows: 1, glazed: dt.leafStyle === "glas", glazingPanes: 1, + sashes: [], }; } const wt = getWindowType(project, op); @@ -1887,6 +1894,22 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening wt.insetFromFace ?? 0, 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 { frameWidth: wt.frameWidth ?? DEFAULT_FRAME_WIDTH, acrossWallDepth, @@ -1894,12 +1917,11 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening nMax, transomHeight: Math.max(0, wt.transomHeight ?? 0), hasSill: true, - // Inline-Override `Opening.wingCount` gewinnt vor dem Typ-Default (analog - // `windowSymbol` im 2D, das dieselbe Priorität kennt), sonst Typ, sonst 1. - wingCount: Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1)), + wingCount, mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)), glazed: true, glazingPanes: glazingPanesOf(wt), + sashes, }; } @@ -1962,6 +1984,63 @@ function frameMeshesForOpening( 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; }