// Reicher Fenster-/Tür-Einstellungsdialog (VW „Fenster bearbeiten"-Muster, // Studie docs/design/window-editor-vectorworks-study.md). Drei Zonen: // 1. Kategorie-Sidebar links, // 2. Parameter-Panel Mitte, // 3. Live-2D-Ansicht (Elevation) rechts — direkt aus den aktuellen Werten // gezeichnet (Rahmen/Flügel/Öffnungslinien/Verglasung/Rollladenkasten). // Oben eine Stil-Leiste: Stil wählen · „Als Stil speichern…" · zurücksetzen. // // Der Dialog editiert TYP-Parameter (der referenzierte WindowType/DoorType = // wiederverwendbarer Stil, wirkt auf alle Instanzen) UND INSTANZ-Parameter // (Grösse/Position dieser Öffnung). Bezeichner englisch, UI-Text deutsch. import { useEffect, useMemo, useState } from "react"; import { t } from "../i18n"; import type { DoorType, Opening, OpeningKind, Project, SashDef, WindowType, } from "../model/types"; import { getDoorType, getWindowType, sashesOfWindowType, glazingPanesOf } from "../model/types"; /** P0-Kategorien der Sidebar, je nach Öffnungsart. */ type CatId = | "basis" | "groesse" | "rahmen" | "fluegel" | "sonnenschutz" | "tuerblatt"; export interface OpeningEditorDialogProps { openingId: string; project: Project; /** Typ-Feld (aktueller Stil) ändern — wirkt auf alle Instanzen des Stils. */ onPatchWindowType: (typeId: string, patch: Partial) => void; onPatchDoorType: (typeId: string, patch: Partial) => void; /** Instanz-Feld dieser Öffnung ändern. */ onPatchOpening: (patch: Partial) => void; /** Öffnung einem anderen Stil zuweisen ("" = keiner). */ onSetOpeningType: (typeId: string) => void; /** Aktuellen Stil als NEUEN benannten Stil ablegen und der Öffnung zuweisen. */ onSaveAsStyle: (name: string) => void; onClose: () => void; } export function OpeningEditorDialog({ openingId, project, onPatchWindowType, onPatchDoorType, onPatchOpening, onSetOpeningType, onSaveAsStyle, onClose, }: OpeningEditorDialogProps) { const opening = (project.openings ?? []).find((o) => o.id === openingId); const isDoor = opening?.kind === "door"; const wt = opening && !isDoor ? getWindowType(project, opening) : undefined; const dt = opening && isDoor ? getDoorType(project, opening) : undefined; const cats: { id: CatId; label: string }[] = isDoor ? [ { id: "basis", label: t("oed.cat.basis") }, { id: "groesse", label: t("oed.cat.groesse") }, { id: "rahmen", label: t("oed.cat.rahmen") }, { id: "tuerblatt", label: t("oed.cat.tuerblatt") }, ] : [ { id: "basis", label: t("oed.cat.basis") }, { id: "groesse", label: t("oed.cat.groesse") }, { id: "rahmen", label: t("oed.cat.rahmen") }, { id: "fluegel", label: t("oed.cat.fluegel") }, { id: "sonnenschutz", label: t("oed.cat.sonnenschutz") }, ]; const [cat, setCat] = useState("basis"); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); if (!opening) { // Öffnung verschwand (Undo/Löschen während offen) → Dialog schliesst sich. return null; } const typeList = isDoor ? project.doorTypes ?? [] : project.windowTypes ?? []; const styleName = (isDoor ? dt?.name : wt?.name) ?? t("objinfo.type.none"); const patchWt = (patch: Partial) => { if (wt) onPatchWindowType(wt.id, patch); }; const patchDt = (patch: Partial) => { if (dt) onPatchDoorType(dt.id, patch); }; return (
e.stopPropagation()} >
{t(isDoor ? "oed.title.door" : "oed.title.window")}
{/* Stil-Leiste: Stil wählen · als Stil speichern · zurücksetzen. */}
{t("oed.style")}
{/* 1. Kategorie-Sidebar. */} {/* 2. Parameter-Panel. */}
{!wt && !dt && (
{t("oed.noStyle")}
)} {cat === "basis" && ( )} {cat === "groesse" && ( )} {cat === "rahmen" && ( )} {cat === "fluegel" && wt && } {cat === "sonnenschutz" && wt && } {cat === "tuerblatt" && dt && }
{/* 3. Live-Ansicht (Elevation). */}
{t("oed.preview")}
); } // ── Parameter-Panels ──────────────────────────────────────────────────────── /** Kleines beschriftetes Zahlenfeld (Meter), lokal gepuffert. */ function NumField({ label, value, step = 0.01, min, onCommit, }: { label: string; value: number; step?: number; min?: number; onCommit: (n: number) => void; }) { const [text, setText] = useState(String(value)); useEffect(() => setText(String(value)), [value]); return ( ); } /** Beschriftetes Dropdown. */ function SelField({ label, value, options, onChange, }: { label: string; value: V; options: { value: V; label: string }[]; onChange: (v: V) => void; }) { return ( ); } const OPENING_KIND_OPTS = (): { value: OpeningKind; label: string }[] => [ { value: "dreh", label: t("oed.kind.dreh") }, { value: "kipp", label: t("oed.kind.kipp") }, { value: "drehkipp", label: t("oed.kind.drehkipp") }, { value: "fest", label: t("oed.kind.fest") }, { value: "schiebe", label: t("oed.kind.schiebe") }, ]; type DoorKind = DoorType["kind"]; const DOOR_KIND_OPTS = (): { value: DoorKind; label: string }[] => [ { value: "dreh", label: t("oed.kind.dreh") }, { value: "schiebe", label: t("oed.kind.schiebe") }, { value: "wandoeffnung", label: t("oed.door.wandoeffnung") }, ]; function BasisPanel({ isDoor, wt, dt, patchWt, patchDt, }: { isDoor: boolean; wt?: WindowType; dt?: DoorType; patchWt: (p: Partial) => void; patchDt: (p: Partial) => void; }) { if (isDoor && dt) { return ( patchDt({ kind: v })} /> ); } if (wt) { return ( <> patchWt({ kind: v })} /> patchWt({ glazingPanes: Number(v) as 1 | 2 | 3 })} /> patchWt({ insetFace: v })} /> patchWt({ insetFromFace: n })} /> patchWt({ sillLine: v === "off" ? undefined : { faces: v as "aussen" | "innen" | "beide", view: wt.sillLine?.view ?? "auf" }, }) } /> {wt.sillLine && ( patchWt({ sillLine: { faces: wt.sillLine!.faces, view: v } })} /> )} ); } return null; } function GroessePanel({ opening, isDoor, onPatchOpening, }: { opening: Opening; isDoor: boolean; onPatchOpening: (p: Partial) => void; }) { return ( <> onPatchOpening({ width: n })} /> onPatchOpening({ height: n })} /> {!isDoor && ( onPatchOpening({ sillHeight: n })} /> )} onPatchOpening({ position: n })} /> ); } function RahmenPanel({ isDoor, wt, dt, patchWt, patchDt, }: { isDoor: boolean; wt?: WindowType; dt?: DoorType; patchWt: (p: Partial) => void; patchDt: (p: Partial) => void; }) { if (isDoor && dt) { return ( <> patchDt({ frameThickness: n })} /> patchDt({ frameWidth: n })} /> ); } if (wt) { return ( <> patchWt({ frameThickness: n })} /> patchWt({ frameDepth: n || undefined })} /> patchWt({ frameWidth: n })} /> ); } return null; } /** Flügeleinteilung: Tabelle mit Flügel/Pfosten, Öffnungsart + Anschlag je Zeile. */ function FluegelPanel({ wt, patchWt, }: { wt: WindowType; patchWt: (p: Partial) => void; }) { const sashes = sashesOfWindowType(wt); const setSashes = (next: SashDef[]) => patchWt({ sashes: next, wingCount: next.filter((s) => s.kind === "fluegel").length || 1 }); const update = (i: number, patch: Partial) => setSashes(sashes.map((s, k) => (k === i ? { ...s, ...patch } : s))); const addSash = (kind: SashDef["kind"]) => setSashes([ ...sashes, kind === "fluegel" ? { kind, autoWidth: true, opening: "dreh", hingeSide: "left" } : { kind, postWidth: 0.06 }, ]); const remove = (i: number) => setSashes(sashes.filter((_, k) => k !== i)); return ( <>
{t("oed.fluegel.hint")}
{/* Oberlicht (festes Feld über einem Kämpfer) + Kämpfer-/Sprossen-Zeilen. */} patchWt({ transomHeight: n || undefined })} /> patchWt({ mullionRows: Math.max(1, Math.round(n)) })} /> patchWt({ mullionCols: Math.max(1, Math.round(n)) })} /> {sashes.map((s, i) => ( ))}
# {t("oed.fluegel.col.kind")} {t("oed.fluegel.col.opening")} {t("oed.fluegel.col.hinge")}
{i + 1} {s.kind === "fluegel" ? ( ) : ( – )} {s.kind === "fluegel" && s.opening !== "fest" ? ( ) : ( – )}
); } function SonnenschutzPanel({ wt, patchWt, }: { wt: WindowType; patchWt: (p: Partial) => void; }) { const sh = wt.shading; const box = sh?.box ?? { width: 0, height: 0.24, depth: 0.2 }; const set = (patch: Partial>) => patchWt({ shading: { create: sh?.create ?? false, kind: sh?.kind ?? "rollladen", box, ...patch, }, }); return ( <> {sh?.create && ( <> set({ kind: v as NonNullable["kind"] }) } /> set({ box: { ...box, height: n } })} /> set({ box: { ...box, depth: n } })} /> )} ); } function TuerblattPanel({ dt, patchDt, }: { dt: DoorType; patchDt: (p: Partial) => void; }) { return ( <> patchDt({ leafCount: Number(v) as 1 | 2 })} /> patchDt({ leafStyle: v as DoorType["leafStyle"] })} /> ); } // ── Live-Ansicht (Elevation, SVG) ─────────────────────────────────────────── /** * Frontal-Ansicht der Öffnung aus den aktuellen Werten: Rahmen, Flügel/Pfosten * (mit Öffnungslinien je Flügel), Verglasungshinweis und Rollladenkasten. Rein * aus den Modellwerten gezeichnet — kein Renderer-Roundtrip, deterministisch. */ function ElevationPreview({ opening, wt, dt, }: { opening: Opening; wt?: WindowType; dt?: DoorType; }) { const geom = useMemo(() => { const w = Math.max(0.1, opening.width); const h = Math.max(0.1, opening.height); const pad = 24; const boxH = (!dt && wt?.shading?.create ? wt.shading.box.height : 0) || 0; // Zeichenfläche auf ~260×300 skalieren, Seitenverhältnis erhalten. const availW = 220; const availH = 260; const totalH = h + boxH; const scale = Math.min(availW / w, availH / totalH); const px = (m: number) => m * scale; return { w, h, boxH, pad, scale, px, totalH }; }, [opening.width, opening.height, wt, dt]); const { w, h, boxH, pad, px } = geom; const svgW = px(w) + pad * 2; const svgH = px(h + boxH) + pad * 2; const frameW = px((!dt ? wt?.frameWidth : dt?.frameWidth) ?? 0.06); const originY = pad + px(boxH); // Flügel-/Pfostenaufteilung: gleichmässig (autoWidth) bzw. feste Breiten. const sashes = !dt && wt ? sashesOfWindowType(wt) : []; const fixedTotal = sashes.reduce( (a, s) => a + (s.kind === "pfosten" ? s.postWidth ?? 0.06 : s.autoWidth ? 0 : s.width ?? 0), 0, ); const autoCount = sashes.filter((s) => s.kind === "fluegel" && (s.autoWidth ?? true)).length; const autoW = autoCount > 0 ? Math.max(0.05, (w - fixedTotal) / autoCount) : 0; const panes = !dt && wt ? glazingPanesOf(wt) : 1; // Segmentbreiten (Meter) je Sash. const segW = sashes.map((s) => s.kind === "pfosten" ? s.postWidth ?? 0.06 : s.autoWidth ?? true ? autoW : s.width ?? autoW, ); const strokeMain = "var(--ink)"; const strokeMuted = "var(--muted)"; const glass = "rgba(120,180,220,0.18)"; let cursor = 0; const segEls: JSX.Element[] = []; sashes.forEach((s, i) => { const x0 = pad + px(cursor); const wSeg = px(segW[i]); cursor += segW[i]; if (s.kind === "pfosten") { segEls.push( , ); return; } // Flügelrahmen + Glas + Öffnungslinien. const gx = x0 + frameW * 0.4; const gy = originY + frameW * 0.4; const gw = Math.max(1, wSeg - frameW * 0.8); const gh = Math.max(1, px(h) - frameW * 0.8); segEls.push( {/* Verglasung: zusätzliche parallele Glaslinien = Scheibenzahl − 1. */} {Array.from({ length: Math.max(0, panes - 1) }, (_, k) => ( ))} {/* Öffnungslinien (Dreieck) je nach Anschlag; nicht bei fest. */} {s.opening !== "fest" && (s.hingeSide === "right" ? ( ) : ( ))} , ); }); return ( {/* Rollladenkasten über der Öffnung (nur Fenster mit shading.create). */} {boxH > 0 && ( )} {/* Blendrahmen (Aussenkontur). */} {/* Für Türen: einfaches Türblatt statt Flügelraster. */} {dt ? ( <> {dt.leafCount === 2 && ( )} ) : ( <> {segEls} {/* Oberlicht-Kämpfer (waagrechte Linie transomHeight unter der OK). */} {!dt && wt && (wt.transomHeight ?? 0) > 0 && ( )} {/* Kämpfer-/Sprossen-Zeilen (mullionRows − 1 waagrechte Teiler im Hauptfeld unterhalb eines etwaigen Oberlichts). */} {!dt && wt && (() => { const rows = Math.max(1, Math.round(wt.mullionRows ?? 1)); if (rows <= 1) return null; const top = originY + px(wt.transomHeight ?? 0); const fieldH = px(h) - px(wt.transomHeight ?? 0); return Array.from({ length: rows - 1 }, (_, k) => ( )); })()} {/* Sprossen-Spalten (mullionCols − 1 vertikale Teiler im Hauptfeld unterhalb eines etwaigen Oberlichts). */} {!dt && wt && (() => { const cols = Math.max(1, Math.round(wt.mullionCols ?? 1)); if (cols <= 1) return null; const top = originY + px(wt.transomHeight ?? 0); const fieldH = px(h) - px(wt.transomHeight ?? 0); const fieldW = px(w); return Array.from({ length: cols - 1 }, (_, k) => ( )); })()} )} ); }