diff --git a/src/App.tsx b/src/App.tsx index 5078cce..ea36938 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,6 +69,7 @@ import { exportIfcSpf } from "./export/exportIfc"; import { exportObj, exportStl } from "./export/exportMesh"; import type { ScheduleKind } from "./export/exportSchedule"; import { SettingsDialog } from "./ui/SettingsDialog"; +import { OpeningEditorDialog } from "./ui/OpeningEditorDialog"; import { ExportSaveDialog } from "./ui/ExportSaveDialog"; import type { ExportSaveFormat } from "./ui/ExportSaveDialog"; import { PromptDialog } from "./ui/PromptDialog"; @@ -414,6 +415,9 @@ export default function App() { // Angeforderter Start-Tab des Ressourcen-Fensters (z. B. „doorStyles", wenn man // von einer gewählten Tür in den Typeditor springt). const [resourcesTab, setResourcesTab] = useState("components"); + // Offener Fenster-/Tür-Einstellungsdialog (⚙ der OpeningSection) — ID der + // editierten Öffnung, oder null wenn geschlossen. + const [openingEditorId, setOpeningEditorId] = useState(null); // Aktives Layout-Blatt im HAUPT-Viewport (DOSSIER A3, Phase 2b): Id des offenen // Layouts oder null (= normale Modell-Ansicht). Transienter UI-State; das @@ -2340,6 +2344,57 @@ export default function App() { windowTypes: (p.windowTypes ?? []).filter((wt) => wt.id !== id), }; }); + // „Als Stil speichern": aktuellen Fenster-/Tür-Typ der Öffnung als NEUEN + // benannten Stil ablegen und der Öffnung zuweisen (VW „Fenster speichern…"). + // Ohne Bezugstyp wird aus den Default-Feldern ein frischer Stil erzeugt. + const saveOpeningStyle = (openingId: string, name: string) => + setProject((p) => { + const o = (p.openings ?? []).find((x) => x.id === openingId); + if (!o) return p; + if (o.kind === "door") { + const base = (p.doorTypes ?? []).find((d) => d.id === o.typeId); + const nt: DoorType = base + ? { ...base, id: `dt-${Date.now()}`, name } + : { + id: `dt-${Date.now()}`, + name, + kind: "dreh", + leafCount: 1, + leafStyle: "glatt", + frameThickness: 0.05, + defaultWidth: 0.9, + defaultHeight: 2.0, + }; + return { + ...p, + doorTypes: [...(p.doorTypes ?? []), nt], + openings: (p.openings ?? []).map((x) => + x.id === openingId ? { ...x, typeId: nt.id } : x, + ), + }; + } + const base = (p.windowTypes ?? []).find((w) => w.id === o.typeId); + const nt: WindowType = base + ? { ...base, id: `ft-${Date.now()}`, name } + : { + id: `ft-${Date.now()}`, + name, + kind: "drehkipp", + wingCount: 1, + glazing: "zweifach", + frameThickness: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.2, + defaultHeight: 1.4, + }; + return { + ...p, + windowTypes: [...(p.windowTypes ?? []), nt], + openings: (p.openings ?? []).map((x) => + x.id === openingId ? { ...x, typeId: nt.id } : x, + ), + }; + }); const addStairType = () => setProject((p) => { const st: StairType = { @@ -3166,6 +3221,7 @@ export default function App() { setResourcesTab(kind === "door" ? "doorStyles" : "windowStyles"); setResourcesOpen(true); }, + onOpenOpeningEditor: (id) => setOpeningEditorId(id), // ── Dach-Attribute (nur bei selektiertem Dach) ───────────────────────── onSetRoofPatch: (patch) => { if (selection?.kind === "roof") updateRoof(selection.id, patch); @@ -4959,6 +5015,20 @@ export default function App() { /> )} + {/* Reicher Fenster-/Tür-Einstellungsdialog (⚙ der OpeningSection). */} + {openingEditorId != null && ( + updateOpening(openingEditorId, patch)} + onSetOpeningType={(typeId) => updateOpening(openingEditorId, { typeId: typeId || undefined })} + onSaveAsStyle={(name) => saveOpeningStyle(openingEditorId, name)} + onClose={() => setOpeningEditorId(null)} + /> + )} + {/* Modaler Import-Dialog (DXF/DWG) — offen, sobald eine Datei gewählt/ fallengelassen wurde. Liefert eine strukturierte Entscheidung an runImport (Ebene anlegen / Drawings anhängen / Meshes als Kontext). */} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index dec9dac..777d937 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -362,7 +362,69 @@ export const de = { // ── Öffnungs-Attribute (Object-Info-Panel) ────────────────────────────── "objinfo.opening.section": "Öffnung", "objinfo.opening.type": "Typ", - "objinfo.opening.editType": "Typ bearbeiten (Ressourcen)", + "objinfo.opening.editType": "Einstellungen …", + // Fenster-/Tür-Einstellungsdialog (OpeningEditorDialog). + "oed.title.window": "Fenster-Einstellungen", + "oed.title.door": "Tür-Einstellungen", + "oed.style": "Stil", + "oed.saveStyle": "Als Stil speichern …", + "oed.saveStyle.hint": "Aktuelle Einstellungen als neuen, wiederverwendbaren Stil ablegen", + "oed.saveStyle.prompt": "Name des neuen Stils:", + "oed.saveStyle.copySuffix": "(Kopie)", + "oed.categories": "Kategorien", + "oed.noStyle": "Kein Stil zugewiesen — über die Stil-Leiste einen Stil wählen oder speichern.", + "oed.preview": "Ansicht", + "oed.cat.basis": "Basis", + "oed.cat.groesse": "Grösse/Position", + "oed.cat.rahmen": "Rahmen", + "oed.cat.fluegel": "Flügel/Sprossen", + "oed.cat.sonnenschutz": "Sonnenschutz", + "oed.cat.tuerblatt": "Türblatt", + "oed.f.kind": "Öffnungsart", + "oed.f.glazing": "Verglasung", + "oed.f.insetFace": "Schichteinzug ab", + "oed.f.insetFromFace": "Schichteinzug (m)", + "oed.f.width": "Breite (m)", + "oed.f.height": "Höhe (m)", + "oed.f.sillHeight": "Brüstungshöhe (m)", + "oed.f.position": "Position (m)", + "oed.f.frameThickness": "Rahmenstärke quer (m)", + "oed.f.frameDepth": "Rahmentiefe (m)", + "oed.f.frameWidth": "Rahmenbreite Ansicht (m)", + "oed.kind.dreh": "Dreh", + "oed.kind.kipp": "Kipp", + "oed.kind.drehkipp": "Dreh-Kipp", + "oed.kind.fest": "Fest", + "oed.kind.schiebe": "Schiebe", + "oed.glazing.1": "Einfach", + "oed.glazing.2": "Zweifach", + "oed.glazing.3": "Dreifach", + "oed.insetFace.aussen": "Aussenfläche", + "oed.insetFace.innen": "Innenfläche", + "oed.fluegel.hint": "Flügel und Pfosten von links nach rechts. Öffnungsart und Anschlag je Flügel.", + "oed.fluegel.col.kind": "Typ", + "oed.fluegel.col.opening": "Öffnung", + "oed.fluegel.col.hinge": "Anschlag", + "oed.fluegel.type.fluegel": "Flügel", + "oed.fluegel.type.pfosten": "Pfosten", + "oed.fluegel.remove": "Entfernen", + "oed.hinge.left": "Links", + "oed.hinge.right": "Rechts", + "oed.shading.create": "Rollladen-/Sonnenschutzkasten", + "oed.shading.kind": "Art", + "oed.shading.rollladen": "Rollladen", + "oed.shading.raffstore": "Raffstore", + "oed.shading.markise": "Markise", + "oed.shading.boxHeight": "Kastenhöhe (m)", + "oed.shading.boxDepth": "Kastentiefe (m)", + "oed.door.leafCount": "Türblätter", + "oed.door.leaf1": "Einflüglig", + "oed.door.leaf2": "Zweiflüglig", + "oed.door.leafStyle": "Blattart", + "oed.door.glatt": "Glatt", + "oed.door.kassette": "Kassette", + "oed.door.glas": "Glas", + "oed.door.wandoeffnung": "Wandöffnung", "objinfo.stair.type": "Typ", "objinfo.type.none": "(kein Typ)", "objinfo.opening.kind": "Art", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 716d866..6d4de98 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -362,7 +362,69 @@ export const en: Record = { // ── Opening attributes (object info panel) ────────────────────────────── "objinfo.opening.section": "Opening", "objinfo.opening.type": "Type", - "objinfo.opening.editType": "Edit type (Resources)", + "objinfo.opening.editType": "Settings …", + // Window/door settings dialog (OpeningEditorDialog). + "oed.title.window": "Window settings", + "oed.title.door": "Door settings", + "oed.style": "Style", + "oed.saveStyle": "Save as style …", + "oed.saveStyle.hint": "Store the current settings as a new, reusable style", + "oed.saveStyle.prompt": "Name of the new style:", + "oed.saveStyle.copySuffix": "(copy)", + "oed.categories": "Categories", + "oed.noStyle": "No style assigned — choose or save one via the style bar.", + "oed.preview": "View", + "oed.cat.basis": "Basics", + "oed.cat.groesse": "Size/position", + "oed.cat.rahmen": "Frame", + "oed.cat.fluegel": "Sashes/muntins", + "oed.cat.sonnenschutz": "Shading", + "oed.cat.tuerblatt": "Leaf", + "oed.f.kind": "Opening type", + "oed.f.glazing": "Glazing", + "oed.f.insetFace": "Inset from", + "oed.f.insetFromFace": "Layer inset (m)", + "oed.f.width": "Width (m)", + "oed.f.height": "Height (m)", + "oed.f.sillHeight": "Sill height (m)", + "oed.f.position": "Position (m)", + "oed.f.frameThickness": "Frame depth across (m)", + "oed.f.frameDepth": "Frame depth (m)", + "oed.f.frameWidth": "Frame face width (m)", + "oed.kind.dreh": "Casement", + "oed.kind.kipp": "Tilt", + "oed.kind.drehkipp": "Turn-tilt", + "oed.kind.fest": "Fixed", + "oed.kind.schiebe": "Sliding", + "oed.glazing.1": "Single", + "oed.glazing.2": "Double", + "oed.glazing.3": "Triple", + "oed.insetFace.aussen": "Outer face", + "oed.insetFace.innen": "Inner face", + "oed.fluegel.hint": "Sashes and posts from left to right. Opening type and hinge per sash.", + "oed.fluegel.col.kind": "Type", + "oed.fluegel.col.opening": "Opening", + "oed.fluegel.col.hinge": "Hinge", + "oed.fluegel.type.fluegel": "Sash", + "oed.fluegel.type.pfosten": "Post", + "oed.fluegel.remove": "Remove", + "oed.hinge.left": "Left", + "oed.hinge.right": "Right", + "oed.shading.create": "Roller shutter / shading box", + "oed.shading.kind": "Kind", + "oed.shading.rollladen": "Roller shutter", + "oed.shading.raffstore": "Venetian blind", + "oed.shading.markise": "Awning", + "oed.shading.boxHeight": "Box height (m)", + "oed.shading.boxDepth": "Box depth (m)", + "oed.door.leafCount": "Door leaves", + "oed.door.leaf1": "Single leaf", + "oed.door.leaf2": "Double leaf", + "oed.door.leafStyle": "Leaf style", + "oed.door.glatt": "Flush", + "oed.door.kassette": "Panelled", + "oed.door.glas": "Glass", + "oed.door.wandoeffnung": "Wall opening", "objinfo.stair.type": "Type", "objinfo.type.none": "(no type)", "objinfo.opening.kind": "Type", diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index c5cc154..dcd5b38 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -712,7 +712,7 @@ export function OpeningSection({
{t("objinfo.opening.section")}
{/* Tür-/Fenstertyp (Bibliothek) — je nach Art aus doorTypes bzw. windowTypes. - Der ⚙-Knopf springt direkt in den Typeditor (Ressourcen-Fenster). */} + Der ⚙-Knopf öffnet den reichen Fenster-/Tür-Einstellungsdialog. */}
{t("objinfo.opening.type")} host.onEditOpeningType(isDoor ? "door" : "window")} + onClick={() => host.onOpenOpeningEditor(opening.id)} > ⚙ diff --git a/src/panels/host.ts b/src/panels/host.ts index 63f8d0c..047202f 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -299,6 +299,12 @@ export interface PanelHostValue { * (Discoverability: von der gewählten Öffnung in den Typeditor springen). */ onEditOpeningType: (kind: "door" | "window") => void; + /** + * Öffnet den reichen Fenster-/Tür-Einstellungsdialog für DIESE Öffnung + * (Kategorie-Sidebar, Live-2D-Vorschau, Flügeleinteilung, „Als Stil speichern"). + * Ist der Primärort für Öffnungs-Parameter; das ⚙ der OpeningSection ruft ihn. + */ + onOpenOpeningEditor: (openingId: string) => void; /** * Immutable Änderung von Dach-Attributen (Form/Neigung/Überstand/Firstrichtung/ * Dicke) — wirkt NUR auf das selektierte Dach. diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index bccab02..01582bd 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -195,6 +195,8 @@ export interface CeilingInfo { * genau einer Öffnung gesetzt). Anzeigefertige Werte + Wirts-Wand-Bezug. */ export interface OpeningInfo { + /** ID der Öffnung (für den ⚙-Sprung in den Fenster-/Tür-Editor). */ + id: string; kind: "window" | "door"; /** Wirts-Wand (ID + Name/Geschoss zur Anzeige). */ hostWallId: string; @@ -675,6 +677,7 @@ function openingSelection(project: Project, o: Opening): Selection { ? openingVerticalExtent(project, wall, o) : { zBottom: 0, zTop: o.height }; const openingInfo: OpeningInfo = { + id: o.id, kind: o.kind, hostWallId: o.hostWallId, hostWallName: floor ? `${floor.name}` : o.hostWallId, diff --git a/src/styles.css b/src/styles.css index 5586c4e..95f16dd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4714,6 +4714,207 @@ body { border-color: var(--ctx-danger); } +/* ── Fenster-/Tür-Einstellungsdialog (OpeningEditorDialog) ────────────────── + Drei Zonen (Sidebar · Panel · Ansicht) unter einer Stil-Leiste. Nutzt sonst + die imp-*-Klassen (Overlay/Head/Foot). */ +.oed-dialog { + width: min(880px, 96vw); + max-height: 90vh; + display: flex; + flex-direction: column; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow-3); + overflow: hidden; +} +.oed-stylebar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--line); + background: var(--bg); +} +.oed-stylebar-spacer { + flex: 1 1 auto; +} +.oed-style-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-2); +} +.oed-style-select { + min-width: 160px; +} +.oed-main { + display: grid; + grid-template-columns: 150px 1fr 250px; + min-height: 320px; + flex: 1 1 auto; + overflow: hidden; +} +.oed-sidebar { + display: flex; + flex-direction: column; + padding: 8px; + gap: 2px; + border-right: 1px solid var(--line); + overflow-y: auto; +} +.oed-cat { + text-align: left; + font-family: inherit; + font-size: 13px; + padding: 7px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--ink); + cursor: pointer; +} +.oed-cat:hover { + background: var(--panel-2); +} +.oed-cat.active { + background: var(--accent); + color: #fff; +} +.oed-panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px; + overflow-y: auto; +} +.oed-preview { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px; + border-left: 1px solid var(--line); + background: var(--bg); +} +.oed-preview-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-2); +} +.oed-elev { + flex: 1 1 auto; + min-height: 0; + max-height: 320px; +} +.oed-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 13px; +} +.oed-flabel { + color: var(--ink); + flex: 0 0 auto; +} +.oed-input, +.oed-select { + font-family: inherit; + font-size: 13px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--input); + color: var(--ink); + min-width: 120px; +} +.oed-input { + width: 110px; + text-align: right; +} +.oed-select-sm { + min-width: 0; + width: 100%; + padding: 3px 6px; + font-size: 12px; +} +.oed-hint { + font-size: 12px; + color: var(--muted); + line-height: 1.5; +} +.oed-hint-warn { + color: var(--ctx-danger); +} +.oed-check { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--ink); + cursor: pointer; +} +.oed-btn { + font-family: inherit; + font-size: 12px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--input); + color: var(--ink); + cursor: pointer; +} +.oed-btn:hover { + background: var(--panel-2); +} +.oed-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.oed-table th { + text-align: left; + font-weight: 600; + color: var(--muted); + padding: 4px 6px; + border-bottom: 1px solid var(--line); +} +.oed-table td { + padding: 3px 6px; + border-bottom: 1px solid var(--line); +} +.oed-td-num { + color: var(--muted); + width: 20px; +} +.oed-td-dash { + color: var(--muted); +} +.oed-rowbtn { + border: none; + background: transparent; + color: var(--muted); + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} +.oed-rowbtn:hover:not(:disabled) { + color: var(--ctx-danger); +} +.oed-rowbtn:disabled { + opacity: 0.35; + cursor: default; +} +.oed-rowactions { + display: flex; + gap: 8px; + margin-top: 4px; +} + /* ── Einstellungs-Fenster (SettingsDialog): Farbfelder + Akzent-Presets ──── Nutzt sonst dieselben imp-*-Klassen wie die anderen Dialoge. */ .settings-field-label { diff --git a/src/ui/OpeningEditorDialog.tsx b/src/ui/OpeningEditorDialog.tsx new file mode 100644 index 0000000..a531ec3 --- /dev/null +++ b/src/ui/OpeningEditorDialog.tsx @@ -0,0 +1,839 @@ +// 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 })} + /> + + ); + } + 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")}
+ + + + + + + + + + + {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 + )} + + ); +}