Fenster-/Tür-Einstellungsdialog: reicher Editor mit Live-Ansicht + Stil speichern
Das ⚙ der Öffnungs-Sektion öffnet neu einen dedizierten Dialog (VW-Studie §3) statt in den Ressourcen-Tab zu springen. Drei Zonen: Kategorie-Sidebar, Parameter-Panel, Live-2D-Frontalansicht (aus den aktuellen Werten gezeichnet: Rahmen/Flügel/Öffnungslinien/Verglasung/Rollladenkasten). - OpeningEditorDialog.tsx: Kategorien Basis/Grösse/Rahmen/Flügel/Sonnenschutz (Fenster) bzw. Türblatt (Tür); Flügeltabelle (Flügel/Pfosten + Öffnungsart + Anschlag je Zeile); Stil-Leiste mit Stilwahl + 'Als Stil speichern …' - App: openingEditorId-State, Dialog-Render, saveOpeningStyle (klont aktuellen Typ als neuen benannten WindowType/DoorType und weist ihn zu) - host.onOpenOpeningEditor + OpeningInfo.id für den ⚙-Sprung - Studie docs/design/window-editor-vectorworks-study.md - Dialog-CSS (.oed-*) + i18n (de/en) Renderer-Konsum der neuen Felder (sashes/glazingPanes/shading in 2D/3D) folgt separat.
This commit is contained in:
+70
@@ -69,6 +69,7 @@ import { exportIfcSpf } from "./export/exportIfc";
|
|||||||
import { exportObj, exportStl } from "./export/exportMesh";
|
import { exportObj, exportStl } from "./export/exportMesh";
|
||||||
import type { ScheduleKind } from "./export/exportSchedule";
|
import type { ScheduleKind } from "./export/exportSchedule";
|
||||||
import { SettingsDialog } from "./ui/SettingsDialog";
|
import { SettingsDialog } from "./ui/SettingsDialog";
|
||||||
|
import { OpeningEditorDialog } from "./ui/OpeningEditorDialog";
|
||||||
import { ExportSaveDialog } from "./ui/ExportSaveDialog";
|
import { ExportSaveDialog } from "./ui/ExportSaveDialog";
|
||||||
import type { ExportSaveFormat } from "./ui/ExportSaveDialog";
|
import type { ExportSaveFormat } from "./ui/ExportSaveDialog";
|
||||||
import { PromptDialog } from "./ui/PromptDialog";
|
import { PromptDialog } from "./ui/PromptDialog";
|
||||||
@@ -414,6 +415,9 @@ export default function App() {
|
|||||||
// Angeforderter Start-Tab des Ressourcen-Fensters (z. B. „doorStyles", wenn man
|
// Angeforderter Start-Tab des Ressourcen-Fensters (z. B. „doorStyles", wenn man
|
||||||
// von einer gewählten Tür in den Typeditor springt).
|
// von einer gewählten Tür in den Typeditor springt).
|
||||||
const [resourcesTab, setResourcesTab] = useState<ResourceTabId>("components");
|
const [resourcesTab, setResourcesTab] = useState<ResourceTabId>("components");
|
||||||
|
// Offener Fenster-/Tür-Einstellungsdialog (⚙ der OpeningSection) — ID der
|
||||||
|
// editierten Öffnung, oder null wenn geschlossen.
|
||||||
|
const [openingEditorId, setOpeningEditorId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Aktives Layout-Blatt im HAUPT-Viewport (DOSSIER A3, Phase 2b): Id des offenen
|
// Aktives Layout-Blatt im HAUPT-Viewport (DOSSIER A3, Phase 2b): Id des offenen
|
||||||
// Layouts oder null (= normale Modell-Ansicht). Transienter UI-State; das
|
// 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),
|
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 = () =>
|
const addStairType = () =>
|
||||||
setProject((p) => {
|
setProject((p) => {
|
||||||
const st: StairType = {
|
const st: StairType = {
|
||||||
@@ -3166,6 +3221,7 @@ export default function App() {
|
|||||||
setResourcesTab(kind === "door" ? "doorStyles" : "windowStyles");
|
setResourcesTab(kind === "door" ? "doorStyles" : "windowStyles");
|
||||||
setResourcesOpen(true);
|
setResourcesOpen(true);
|
||||||
},
|
},
|
||||||
|
onOpenOpeningEditor: (id) => setOpeningEditorId(id),
|
||||||
// ── Dach-Attribute (nur bei selektiertem Dach) ─────────────────────────
|
// ── Dach-Attribute (nur bei selektiertem Dach) ─────────────────────────
|
||||||
onSetRoofPatch: (patch) => {
|
onSetRoofPatch: (patch) => {
|
||||||
if (selection?.kind === "roof") updateRoof(selection.id, 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 && (
|
||||||
|
<OpeningEditorDialog
|
||||||
|
openingId={openingEditorId}
|
||||||
|
project={project}
|
||||||
|
onPatchWindowType={patchWindowType}
|
||||||
|
onPatchDoorType={patchDoorType}
|
||||||
|
onPatchOpening={(patch) => 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/
|
{/* Modaler Import-Dialog (DXF/DWG) — offen, sobald eine Datei gewählt/
|
||||||
fallengelassen wurde. Liefert eine strukturierte Entscheidung an
|
fallengelassen wurde. Liefert eine strukturierte Entscheidung an
|
||||||
runImport (Ebene anlegen / Drawings anhängen / Meshes als Kontext). */}
|
runImport (Ebene anlegen / Drawings anhängen / Meshes als Kontext). */}
|
||||||
|
|||||||
+63
-1
@@ -362,7 +362,69 @@ export const de = {
|
|||||||
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
|
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
|
||||||
"objinfo.opening.section": "Öffnung",
|
"objinfo.opening.section": "Öffnung",
|
||||||
"objinfo.opening.type": "Typ",
|
"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.stair.type": "Typ",
|
||||||
"objinfo.type.none": "(kein Typ)",
|
"objinfo.type.none": "(kein Typ)",
|
||||||
"objinfo.opening.kind": "Art",
|
"objinfo.opening.kind": "Art",
|
||||||
|
|||||||
+63
-1
@@ -362,7 +362,69 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
// ── Opening attributes (object info panel) ──────────────────────────────
|
// ── Opening attributes (object info panel) ──────────────────────────────
|
||||||
"objinfo.opening.section": "Opening",
|
"objinfo.opening.section": "Opening",
|
||||||
"objinfo.opening.type": "Type",
|
"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.stair.type": "Type",
|
||||||
"objinfo.type.none": "(no type)",
|
"objinfo.type.none": "(no type)",
|
||||||
"objinfo.opening.kind": "Type",
|
"objinfo.opening.kind": "Type",
|
||||||
|
|||||||
@@ -712,7 +712,7 @@ export function OpeningSection({
|
|||||||
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
|
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
|
||||||
|
|
||||||
{/* Tür-/Fenstertyp (Bibliothek) — je nach Art aus doorTypes bzw. windowTypes.
|
{/* 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. */}
|
||||||
<div className="objinfo-field">
|
<div className="objinfo-field">
|
||||||
<span className="objinfo-flabel">{t("objinfo.opening.type")}</span>
|
<span className="objinfo-flabel">{t("objinfo.opening.type")}</span>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -733,7 +733,7 @@ export function OpeningSection({
|
|||||||
className="objinfo-typebtn"
|
className="objinfo-typebtn"
|
||||||
title={t("objinfo.opening.editType")}
|
title={t("objinfo.opening.editType")}
|
||||||
aria-label={t("objinfo.opening.editType")}
|
aria-label={t("objinfo.opening.editType")}
|
||||||
onClick={() => host.onEditOpeningType(isDoor ? "door" : "window")}
|
onClick={() => host.onOpenOpeningEditor(opening.id)}
|
||||||
>
|
>
|
||||||
⚙
|
⚙
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -299,6 +299,12 @@ export interface PanelHostValue {
|
|||||||
* (Discoverability: von der gewählten Öffnung in den Typeditor springen).
|
* (Discoverability: von der gewählten Öffnung in den Typeditor springen).
|
||||||
*/
|
*/
|
||||||
onEditOpeningType: (kind: "door" | "window") => void;
|
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/
|
* Immutable Änderung von Dach-Attributen (Form/Neigung/Überstand/Firstrichtung/
|
||||||
* Dicke) — wirkt NUR auf das selektierte Dach.
|
* Dicke) — wirkt NUR auf das selektierte Dach.
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ export interface CeilingInfo {
|
|||||||
* genau einer Öffnung gesetzt). Anzeigefertige Werte + Wirts-Wand-Bezug.
|
* genau einer Öffnung gesetzt). Anzeigefertige Werte + Wirts-Wand-Bezug.
|
||||||
*/
|
*/
|
||||||
export interface OpeningInfo {
|
export interface OpeningInfo {
|
||||||
|
/** ID der Öffnung (für den ⚙-Sprung in den Fenster-/Tür-Editor). */
|
||||||
|
id: string;
|
||||||
kind: "window" | "door";
|
kind: "window" | "door";
|
||||||
/** Wirts-Wand (ID + Name/Geschoss zur Anzeige). */
|
/** Wirts-Wand (ID + Name/Geschoss zur Anzeige). */
|
||||||
hostWallId: string;
|
hostWallId: string;
|
||||||
@@ -675,6 +677,7 @@ function openingSelection(project: Project, o: Opening): Selection {
|
|||||||
? openingVerticalExtent(project, wall, o)
|
? openingVerticalExtent(project, wall, o)
|
||||||
: { zBottom: 0, zTop: o.height };
|
: { zBottom: 0, zTop: o.height };
|
||||||
const openingInfo: OpeningInfo = {
|
const openingInfo: OpeningInfo = {
|
||||||
|
id: o.id,
|
||||||
kind: o.kind,
|
kind: o.kind,
|
||||||
hostWallId: o.hostWallId,
|
hostWallId: o.hostWallId,
|
||||||
hostWallName: floor ? `${floor.name}` : o.hostWallId,
|
hostWallName: floor ? `${floor.name}` : o.hostWallId,
|
||||||
|
|||||||
+201
@@ -4714,6 +4714,207 @@ body {
|
|||||||
border-color: var(--ctx-danger);
|
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 ────
|
/* ── Einstellungs-Fenster (SettingsDialog): Farbfelder + Akzent-Presets ────
|
||||||
Nutzt sonst dieselben imp-*-Klassen wie die anderen Dialoge. */
|
Nutzt sonst dieselben imp-*-Klassen wie die anderen Dialoge. */
|
||||||
.settings-field-label {
|
.settings-field-label {
|
||||||
|
|||||||
@@ -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<WindowType>) => void;
|
||||||
|
onPatchDoorType: (typeId: string, patch: Partial<DoorType>) => void;
|
||||||
|
/** Instanz-Feld dieser Öffnung ändern. */
|
||||||
|
onPatchOpening: (patch: Partial<Opening>) => 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<CatId>("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<WindowType>) => {
|
||||||
|
if (wt) onPatchWindowType(wt.id, patch);
|
||||||
|
};
|
||||||
|
const patchDt = (patch: Partial<DoorType>) => {
|
||||||
|
if (dt) onPatchDoorType(dt.id, patch);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="imp-overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="oed-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t(isDoor ? "oed.title.door" : "oed.title.window")}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="imp-head">
|
||||||
|
<span className="imp-head-title">
|
||||||
|
{t(isDoor ? "oed.title.door" : "oed.title.window")}
|
||||||
|
</span>
|
||||||
|
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Stil-Leiste: Stil wählen · als Stil speichern · zurücksetzen. */}
|
||||||
|
<div className="oed-stylebar">
|
||||||
|
<span className="oed-style-label">{t("oed.style")}</span>
|
||||||
|
<select
|
||||||
|
className="oed-select oed-style-select"
|
||||||
|
value={opening.typeId ?? ""}
|
||||||
|
onChange={(e) => onSetOpeningType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t("objinfo.type.none")}</option>
|
||||||
|
{typeList.map((ty) => (
|
||||||
|
<option key={ty.id} value={ty.id}>
|
||||||
|
{ty.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="oed-stylebar-spacer" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="oed-btn"
|
||||||
|
title={t("oed.saveStyle.hint")}
|
||||||
|
onClick={() => {
|
||||||
|
const name = window.prompt(
|
||||||
|
t("oed.saveStyle.prompt"),
|
||||||
|
`${styleName} ${t("oed.saveStyle.copySuffix")}`,
|
||||||
|
);
|
||||||
|
if (name && name.trim()) onSaveAsStyle(name.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("oed.saveStyle")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="oed-main">
|
||||||
|
{/* 1. Kategorie-Sidebar. */}
|
||||||
|
<nav className="oed-sidebar" aria-label={t("oed.categories")}>
|
||||||
|
{cats.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
className={`oed-cat${cat === c.id ? " active" : ""}`}
|
||||||
|
onClick={() => setCat(c.id)}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 2. Parameter-Panel. */}
|
||||||
|
<div className="oed-panel">
|
||||||
|
{!wt && !dt && (
|
||||||
|
<div className="oed-hint oed-hint-warn">{t("oed.noStyle")}</div>
|
||||||
|
)}
|
||||||
|
{cat === "basis" && (
|
||||||
|
<BasisPanel
|
||||||
|
isDoor={isDoor}
|
||||||
|
wt={wt}
|
||||||
|
dt={dt}
|
||||||
|
patchWt={patchWt}
|
||||||
|
patchDt={patchDt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cat === "groesse" && (
|
||||||
|
<GroessePanel opening={opening} isDoor={isDoor} onPatchOpening={onPatchOpening} />
|
||||||
|
)}
|
||||||
|
{cat === "rahmen" && (
|
||||||
|
<RahmenPanel isDoor={isDoor} wt={wt} dt={dt} patchWt={patchWt} patchDt={patchDt} />
|
||||||
|
)}
|
||||||
|
{cat === "fluegel" && wt && <FluegelPanel wt={wt} patchWt={patchWt} />}
|
||||||
|
{cat === "sonnenschutz" && wt && <SonnenschutzPanel wt={wt} patchWt={patchWt} />}
|
||||||
|
{cat === "tuerblatt" && dt && <TuerblattPanel dt={dt} patchDt={patchDt} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Live-Ansicht (Elevation). */}
|
||||||
|
<div className="oed-preview">
|
||||||
|
<div className="oed-preview-title">{t("oed.preview")}</div>
|
||||||
|
<ElevationPreview opening={opening} wt={wt} dt={dt} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="imp-foot">
|
||||||
|
<button className="imp-btn primary" onClick={onClose}>
|
||||||
|
{t("export.close")}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<label className="oed-field">
|
||||||
|
<span className="oed-flabel">{label}</span>
|
||||||
|
<input
|
||||||
|
className="oed-input"
|
||||||
|
type="number"
|
||||||
|
step={step}
|
||||||
|
min={min}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
setText(e.target.value);
|
||||||
|
const n = Number(e.target.value);
|
||||||
|
if (Number.isFinite(n)) onCommit(n);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beschriftetes Dropdown. */
|
||||||
|
function SelField<V extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: V;
|
||||||
|
options: { value: V; label: string }[];
|
||||||
|
onChange: (v: V) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="oed-field">
|
||||||
|
<span className="oed-flabel">{label}</span>
|
||||||
|
<select
|
||||||
|
className="oed-select"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value as V)}
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WindowType>) => void;
|
||||||
|
patchDt: (p: Partial<DoorType>) => void;
|
||||||
|
}) {
|
||||||
|
if (isDoor && dt) {
|
||||||
|
return (
|
||||||
|
<SelField
|
||||||
|
label={t("oed.f.kind")}
|
||||||
|
value={dt.kind}
|
||||||
|
options={DOOR_KIND_OPTS()}
|
||||||
|
onChange={(v) => patchDt({ kind: v })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (wt) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SelField
|
||||||
|
label={t("oed.f.kind")}
|
||||||
|
value={wt.kind}
|
||||||
|
options={OPENING_KIND_OPTS()}
|
||||||
|
onChange={(v) => patchWt({ kind: v })}
|
||||||
|
/>
|
||||||
|
<SelField
|
||||||
|
label={t("oed.f.glazing")}
|
||||||
|
value={String(glazingPanesOf(wt)) as "1" | "2" | "3"}
|
||||||
|
options={[
|
||||||
|
{ value: "1", label: t("oed.glazing.1") },
|
||||||
|
{ value: "2", label: t("oed.glazing.2") },
|
||||||
|
{ value: "3", label: t("oed.glazing.3") },
|
||||||
|
]}
|
||||||
|
onChange={(v) => patchWt({ glazingPanes: Number(v) as 1 | 2 | 3 })}
|
||||||
|
/>
|
||||||
|
<SelField
|
||||||
|
label={t("oed.f.insetFace")}
|
||||||
|
value={wt.insetFace ?? "aussen"}
|
||||||
|
options={[
|
||||||
|
{ value: "aussen", label: t("oed.insetFace.aussen") },
|
||||||
|
{ value: "innen", label: t("oed.insetFace.innen") },
|
||||||
|
]}
|
||||||
|
onChange={(v) => patchWt({ insetFace: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t("oed.f.insetFromFace")}
|
||||||
|
value={wt.insetFromFace ?? 0}
|
||||||
|
min={0}
|
||||||
|
onCommit={(n) => patchWt({ insetFromFace: n })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroessePanel({
|
||||||
|
opening,
|
||||||
|
isDoor,
|
||||||
|
onPatchOpening,
|
||||||
|
}: {
|
||||||
|
opening: Opening;
|
||||||
|
isDoor: boolean;
|
||||||
|
onPatchOpening: (p: Partial<Opening>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NumField
|
||||||
|
label={t("oed.f.width")}
|
||||||
|
value={opening.width}
|
||||||
|
min={0.1}
|
||||||
|
onCommit={(n) => onPatchOpening({ width: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t("oed.f.height")}
|
||||||
|
value={opening.height}
|
||||||
|
min={0.1}
|
||||||
|
onCommit={(n) => onPatchOpening({ height: n })}
|
||||||
|
/>
|
||||||
|
{!isDoor && (
|
||||||
|
<NumField
|
||||||
|
label={t("oed.f.sillHeight")}
|
||||||
|
value={opening.sillHeight}
|
||||||
|
min={0}
|
||||||
|
onCommit={(n) => onPatchOpening({ sillHeight: n })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<NumField
|
||||||
|
label={t("oed.f.position")}
|
||||||
|
value={opening.position}
|
||||||
|
min={0}
|
||||||
|
onCommit={(n) => onPatchOpening({ position: n })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RahmenPanel({
|
||||||
|
isDoor,
|
||||||
|
wt,
|
||||||
|
dt,
|
||||||
|
patchWt,
|
||||||
|
patchDt,
|
||||||
|
}: {
|
||||||
|
isDoor: boolean;
|
||||||
|
wt?: WindowType;
|
||||||
|
dt?: DoorType;
|
||||||
|
patchWt: (p: Partial<WindowType>) => void;
|
||||||
|
patchDt: (p: Partial<DoorType>) => void;
|
||||||
|
}) {
|
||||||
|
if (isDoor && dt) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NumField
|
||||||
|
label={t("oed.f.frameThickness")}
|
||||||
|
value={dt.frameThickness}
|
||||||
|
min={0}
|
||||||
|
onCommit={(n) => patchDt({ frameThickness: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t("oed.f.frameWidth")}
|
||||||
|
value={dt.frameWidth ?? 0.06}
|
||||||
|
min={0}
|
||||||
|
onCommit={(n) => patchDt({ frameWidth: n })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (wt) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NumField
|
||||||
|
label={t("oed.f.frameThickness")}
|
||||||
|
value={wt.frameThickness}
|
||||||
|
min={0}
|
||||||
|
onCommit={(n) => patchWt({ frameThickness: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t("oed.f.frameDepth")}
|
||||||
|
value={wt.frameDepth ?? 0}
|
||||||
|
min={0}
|
||||||
|
onCommit={(n) => patchWt({ frameDepth: n || undefined })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t("oed.f.frameWidth")}
|
||||||
|
value={wt.frameWidth ?? 0.06}
|
||||||
|
min={0}
|
||||||
|
onCommit={(n) => 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<WindowType>) => 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<SashDef>) =>
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="oed-hint">{t("oed.fluegel.hint")}</div>
|
||||||
|
<table className="oed-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{t("oed.fluegel.col.kind")}</th>
|
||||||
|
<th>{t("oed.fluegel.col.opening")}</th>
|
||||||
|
<th>{t("oed.fluegel.col.hinge")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sashes.map((s, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="oed-td-num">{i + 1}</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
className="oed-select oed-select-sm"
|
||||||
|
value={s.kind}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(i, { kind: e.target.value as SashDef["kind"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="fluegel">{t("oed.fluegel.type.fluegel")}</option>
|
||||||
|
<option value="pfosten">{t("oed.fluegel.type.pfosten")}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{s.kind === "fluegel" ? (
|
||||||
|
<select
|
||||||
|
className="oed-select oed-select-sm"
|
||||||
|
value={s.opening ?? "dreh"}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(i, { opening: e.target.value as OpeningKind })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{OPENING_KIND_OPTS().map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="oed-td-dash">–</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{s.kind === "fluegel" && s.opening !== "fest" ? (
|
||||||
|
<select
|
||||||
|
className="oed-select oed-select-sm"
|
||||||
|
value={s.hingeSide ?? "left"}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(i, { hingeSide: e.target.value as "left" | "right" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="left">{t("oed.hinge.left")}</option>
|
||||||
|
<option value="right">{t("oed.hinge.right")}</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="oed-td-dash">–</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="oed-rowbtn"
|
||||||
|
title={t("oed.fluegel.remove")}
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
disabled={sashes.length <= 1}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="oed-rowactions">
|
||||||
|
<button type="button" className="oed-btn" onClick={() => addSash("fluegel")}>
|
||||||
|
+ {t("oed.fluegel.type.fluegel")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="oed-btn" onClick={() => addSash("pfosten")}>
|
||||||
|
+ {t("oed.fluegel.type.pfosten")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SonnenschutzPanel({
|
||||||
|
wt,
|
||||||
|
patchWt,
|
||||||
|
}: {
|
||||||
|
wt: WindowType;
|
||||||
|
patchWt: (p: Partial<WindowType>) => void;
|
||||||
|
}) {
|
||||||
|
const sh = wt.shading;
|
||||||
|
const box = sh?.box ?? { width: 0, height: 0.24, depth: 0.2 };
|
||||||
|
const set = (patch: Partial<NonNullable<WindowType["shading"]>>) =>
|
||||||
|
patchWt({
|
||||||
|
shading: {
|
||||||
|
create: sh?.create ?? false,
|
||||||
|
kind: sh?.kind ?? "rollladen",
|
||||||
|
box,
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="oed-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sh?.create ?? false}
|
||||||
|
onChange={(e) => set({ create: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>{t("oed.shading.create")}</span>
|
||||||
|
</label>
|
||||||
|
{sh?.create && (
|
||||||
|
<>
|
||||||
|
<SelField
|
||||||
|
label={t("oed.shading.kind")}
|
||||||
|
value={sh.kind}
|
||||||
|
options={[
|
||||||
|
{ value: "rollladen", label: t("oed.shading.rollladen") },
|
||||||
|
{ value: "raffstore", label: t("oed.shading.raffstore") },
|
||||||
|
{ value: "markise", label: t("oed.shading.markise") },
|
||||||
|
]}
|
||||||
|
onChange={(v) =>
|
||||||
|
set({ kind: v as NonNullable<WindowType["shading"]>["kind"] })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t("oed.shading.boxHeight")}
|
||||||
|
value={box.height}
|
||||||
|
min={0}
|
||||||
|
onCommit={(n) => set({ box: { ...box, height: n } })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t("oed.shading.boxDepth")}
|
||||||
|
value={box.depth}
|
||||||
|
min={0}
|
||||||
|
onCommit={(n) => set({ box: { ...box, depth: n } })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TuerblattPanel({
|
||||||
|
dt,
|
||||||
|
patchDt,
|
||||||
|
}: {
|
||||||
|
dt: DoorType;
|
||||||
|
patchDt: (p: Partial<DoorType>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SelField
|
||||||
|
label={t("oed.door.leafCount")}
|
||||||
|
value={String(dt.leafCount) as "1" | "2"}
|
||||||
|
options={[
|
||||||
|
{ value: "1", label: t("oed.door.leaf1") },
|
||||||
|
{ value: "2", label: t("oed.door.leaf2") },
|
||||||
|
]}
|
||||||
|
onChange={(v) => patchDt({ leafCount: Number(v) as 1 | 2 })}
|
||||||
|
/>
|
||||||
|
<SelField
|
||||||
|
label={t("oed.door.leafStyle")}
|
||||||
|
value={dt.leafStyle}
|
||||||
|
options={[
|
||||||
|
{ value: "glatt", label: t("oed.door.glatt") },
|
||||||
|
{ value: "kassette", label: t("oed.door.kassette") },
|
||||||
|
{ value: "glas", label: t("oed.door.glas") },
|
||||||
|
]}
|
||||||
|
onChange={(v) => 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(
|
||||||
|
<rect
|
||||||
|
key={`p${i}`}
|
||||||
|
x={x0}
|
||||||
|
y={originY}
|
||||||
|
width={wSeg}
|
||||||
|
height={px(h)}
|
||||||
|
fill={strokeMuted}
|
||||||
|
opacity={0.5}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<g key={`f${i}`}>
|
||||||
|
<rect x={x0} y={originY} width={wSeg} height={px(h)} fill="none" stroke={strokeMain} />
|
||||||
|
<rect x={gx} y={gy} width={gw} height={gh} fill={glass} stroke={strokeMuted} />
|
||||||
|
{/* Verglasung: zusätzliche parallele Glaslinien = Scheibenzahl − 1. */}
|
||||||
|
{Array.from({ length: Math.max(0, panes - 1) }, (_, k) => (
|
||||||
|
<line
|
||||||
|
key={k}
|
||||||
|
x1={gx + (gw * (k + 1)) / panes}
|
||||||
|
y1={gy}
|
||||||
|
x2={gx + (gw * (k + 1)) / panes}
|
||||||
|
y2={gy + gh}
|
||||||
|
stroke={strokeMuted}
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Öffnungslinien (Dreieck) je nach Anschlag; nicht bei fest. */}
|
||||||
|
{s.opening !== "fest" &&
|
||||||
|
(s.hingeSide === "right" ? (
|
||||||
|
<polyline
|
||||||
|
points={`${gx + gw},${gy} ${gx},${gy + gh / 2} ${gx + gw},${gy + gh}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeMuted}
|
||||||
|
strokeWidth={0.75}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<polyline
|
||||||
|
points={`${gx},${gy} ${gx + gw},${gy + gh / 2} ${gx},${gy + gh}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeMuted}
|
||||||
|
strokeWidth={0.75}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="oed-elev"
|
||||||
|
viewBox={`0 0 ${svgW} ${svgH}`}
|
||||||
|
width="100%"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
role="img"
|
||||||
|
aria-label={t("oed.preview")}
|
||||||
|
>
|
||||||
|
{/* Rollladenkasten über der Öffnung (nur Fenster mit shading.create). */}
|
||||||
|
{boxH > 0 && (
|
||||||
|
<rect
|
||||||
|
x={pad}
|
||||||
|
y={pad}
|
||||||
|
width={px(w)}
|
||||||
|
height={px(boxH)}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeMuted}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Blendrahmen (Aussenkontur). */}
|
||||||
|
<rect
|
||||||
|
x={pad}
|
||||||
|
y={originY}
|
||||||
|
width={px(w)}
|
||||||
|
height={px(h)}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeMain}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
{/* Für Türen: einfaches Türblatt statt Flügelraster. */}
|
||||||
|
{dt ? (
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
x={pad + frameW}
|
||||||
|
y={originY + frameW}
|
||||||
|
width={Math.max(1, px(w) - frameW * 2)}
|
||||||
|
height={Math.max(1, px(h) - frameW)}
|
||||||
|
fill={dt.leafStyle === "glas" ? glass : "none"}
|
||||||
|
stroke={strokeMuted}
|
||||||
|
/>
|
||||||
|
{dt.leafCount === 2 && (
|
||||||
|
<line
|
||||||
|
x1={pad + px(w) / 2}
|
||||||
|
y1={originY + frameW}
|
||||||
|
x2={pad + px(w) / 2}
|
||||||
|
y2={originY + px(h)}
|
||||||
|
stroke={strokeMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
segEls
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user