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:
2026-07-10 01:13:15 +02:00
parent 1bbe814ab1
commit c734802555
9 changed files with 1696 additions and 4 deletions
@@ -0,0 +1,449 @@
# Fenster-/Tür-Editor — Studie & Designdokument (Referenz: Vectorworks „Fenster bearbeiten")
Status: Studie/Entwurf (KEIN Code). Ziel: den heute als „mega mager" empfundenen
Fenster-/Tür-Editor zu einem eigenständigen, reichen **Einstellungs-Dialog** mit
Kategorie-Sidebar, Live-Vorschau (2D + 3D) und **„Als Stil speichern"** ausbauen.
Diese Datei ordnet die Vectorworks-Referenz dem bestehenden DOSSIER-Modell zu und
schlägt einen realistischen, phasierten Plan vor.
Konvention: Bezeichner englisch, UI-Text/Kommentare deutsch. Meter als Grundmaß.
---
## 1. Executive Summary
**Was ein guter DOSSIER-Fenster-/Tür-Editor sein sollte.** Ein eigener modaler
Dialog „Fenster-/Tür-Einstellungen" — nicht die heutige, in den Ressourcen-Manager
eingebettete Formularspalte (`WindowStylesTab`/`DoorStylesTab` in
`src/ui/ResourceManager.tsx`), die pro Feld nur eine `FieldRow` zeigt und keinerlei
Vorschau bietet. Der neue Dialog hat drei Zonen (wie Vectorworks):
1. **Kategorie-Sidebar** links (Basis, Größe/Position, Rahmen, Flügel/Sprossen,
Oberlicht/Unterlicht, Laibung/Bank, Sonnenschutz/Rollladen, Attribute/Darstellung,
Detaillierung).
2. **Parameter-Panel** in der Mitte (die Controls der gewählten Kategorie).
3. **Live-Vorschau** rechts: eine 2D-Plan-Vorschau (aus `generatePlan()`) und eine
3D-Ansicht/Elevation (aus `projectToModel3d()`), beide sofort aktualisiert.
**Die eine wichtigste strukturelle Änderung.** Der Editier-Primärort wandert vom
Ressourcen-Tab in einen **dedizierten `OpeningEditorDialog`**, der TYP-Parameter
(wiederverwendbarer Stil = `WindowType`/`DoorType`) und INSTANZ-Parameter (dieses
`Opening`) im selben Fenster editiert und oben eine Stil-Leiste trägt:
`Stil: [Dropdown]` · `Fenster speichern…` (aktuelle Konfiguration als neuen
benannten `WindowType`/`DoorType` ablegen) · `Einstellungen zurücksetzen…`
(auf den Stil zurückfallen). Das ⚙ in `ObjectInfoPanel.OpeningSection`
(`src/panels/ObjectInfoPanel.tsx:731`) öffnet künftig DIESEN Dialog statt den
Ressourcen-Manager-Tab.
**Begriffsklärung.** Der Nutzer-Ausdruck „als **Wandstil** speichern" ist ein
Versprecher — gemeint ist „als **Fensterstil/Türstil** (Bauteilstil) speichern",
also ein neuer Eintrag in `project.windowTypes` bzw. `project.doorTypes`, analog
`WallType`/`CeilingType`/`StairType`. Es entsteht KEIN neuer Wandtyp.
**Warum das der Hebel ist.** Alle geplante Tiefe (mehrflügelig, Sprossenraster,
Rollladen, Bank/Nische, Ober-/Unterlicht, Verglasungsanzahl) braucht (a) mehr
Felder auf `WindowType`/`DoorType` und (b) eine UI, die sie ohne Formularwust
zeigt und deren Wirkung sofort sichtbar macht. Ohne Live-Vorschau bleibt ein
reicher Parametersatz unbenutzbar. Erst der Dialog macht die Tiefe zugänglich; die
Modellfelder allein (Abschnitt 4) reichen nicht.
---
## 2. Vollständige Mapping-Tabelle (VW-Referenz → DOSSIER)
Legende — Status: **✓** vorhanden · **~** teilweise (Feld existiert, Renderer liest
es nicht ODER nur grob) · **✗** fehlt. Ebene: **T** = Typ (Stil, wiederverwendbar,
auf `WindowType`/`DoorType`) · **I** = Instanz (auf `Opening`). Priorität P0
(erste reiche Scheibe) … P3 (VW-Ballast). Aufwand grob in Personentagen (PT).
Wichtiger Ist-Befund aus dem Code (Renderer-Konsum-Lücken):
- `WindowType.glazing` (einfach/zweifach/dreifach) ist editierbar, wird aber von
KEINEM Renderer gelesen. 3D zeichnet stets EINE Scheibe (`glassPanesForOpening`
in `src/plan/toWalls3d.ts:1965` ignoriert `glazing`); 2D leitet die Glaslinien-
Anzahl allein aus `DetailLevel` ab (`addOpeningSymbol` in
`src/plan/generatePlan.ts:2214`, `glassCount = detail==="fein" ? 2 : 1`).
- `WindowType.kind`/`DoorType.kind` (dreh/kipp/drehkipp/fest/schiebe …) schlagen
sich NICHT in 2D/3D-Geometrie nieder.
- `DoorType.leafCount` (1/2), `leafStyle`/`glazingRatio` (außer `leafStyle==="glas"`
→ 3D-Verglasung an) sind ungenutzt.
- `WindowType.sillBoard` ungenutzt; `DoorType.threshold``hasSill` genutzt.
- 3D-Mittelpfosten/Kämpfer und Sprossen werden nur bei `detail==="fein"` emittiert
(`frameMeshesForOpening` in `src/plan/toWalls3d.ts:1902`, ab :1938).
### 2.1 Basiseinstellungen
| VW-Control | Status | DOSSIER-Feld (Vorschlag) | 2D-Wirkung | 3D-Wirkung | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Fensterart („Für normale Wand") | ✗ | `WindowType.wallKind?: "normal"\|"eck"` (später) | — | — | T | P3 | 0.5 |
| Öffnungsart (Dreh/Kipp/…) | ~ | `WindowType.kind` existiert, kein Render | Öffnungssymbol/Öffnungslinien | Öffnungslinien 3D | T | P1 | 2 |
| Einfügepunkt längs (Mitte/…) | ✓ | `Opening.position` + Bezugspunkt in `ObjectInfoPanel` | Position im Plan | Position | I | — | — |
| Einfügepunkt quer (Fensterseite außen/…) | ~ | `insetFromFace`/`insetFace` (T) | Band-Lage | Rahmen-Normalenlage (`resolveFrameNormalRange` :1803) | T | P1 | 0.5 |
| Versatz im Fassadenmodul | ✗ | — (Fassadensystem fehlt in DOSSIER) | — | — | — | P3 | — |
| Laibung (wählen) | ~ | `insetFromFace` deckt Teil ab | Laibungsstriche (fein) | Laibungstiefe | T | P2 | 1 |
| Klasse | ~ | `Opening.categoryCode` (LayerCategory) | Farbe/Strich | — | I | — | — |
| Darstellung höchste Detaillierung | ✓ | `Opening.detailLevel` + Ansichts-DetailLevel | Symbolstufe | Meshstufe | I | — | — |
| Eigenes Symbol verwenden | ✗ | `WindowType.symbolId?` (Drawing2D-Ref) | Symbol-Override | — | T | P3 | 3 |
### 2.2 Fenstergröße & Bemaßung
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Fensterbreite (Wert) | ✓ | `Opening.width` (+ `defaultWidth` T) | ✓ | ✓ | I/T | — | — |
| Fensterhöhe (Wert) | ✓ | `Opening.height` (+ `defaultHeight` T) | ✓ | ✓ | I/T | — | — |
| Bezug B1..B5 / H1..H7 (Roh-/Fertigmaß) | ✗ | `WindowType.dimRef?: {...}` | Bemaßungsschema | — | T | P3 | 3+ |
| „Bemaßung automatisch" (außen/innen) | ✗ | — (DOSSIER hat noch keine parametrische Öffnungs-Bemaßung) | Maßketten | — | T | P3 | 5+ |
Der ganze VW-Maßketten-/Bezugsapparat (B1..B5, H1..H7, Roh-/Fertigmaß-Umschaltung)
ist **P3/„nicht bauen"** — siehe Abschnitt 5. DOSSIER trägt lichte Maße
(`width`/`height`), das genügt.
### 2.3 Höhe Brüstung/Sturz
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Position definieren durch (Brüstungshöhe/…) | ✓ | `Opening.sillHeight` (+ `defaultSillHeight` T) | — | vertikale Lage (`openingVerticalExtent`) | I | — | — |
| Abstand Brüstungshöhe (Wert) | ✓ | `Opening.sillHeight` | — | ✓ | I | — | — |
| „bezieht sich auf" (Wandaußenseite/…) | ✗ | — (immer Wand-UK-relativ) | — | — | — | P3 | — |
| „auf Ebenenbasishöhe" | ~ | Wand-UK ergibt sich aus Geschoss (`baseElevation`) | — | ✓ | — | — | — |
### 2.4 Rahmenwerte
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Rahmenbreiten (4 Kanten, asymmetrisch) | ~ | heute nur `frameWidth` (eine Zahl) → `WindowType.frameWidths?: {left,right,top,bottom}` | Rahmenkontur (`windowSymbol`/`addOpeningFrameBand`) | Rahmen-Boxen (`frameMeshesForOpening`) | T | P2 | 2 |
| Symmetrisch-Toggle | ✗ | `WindowType.frameSymmetric?: boolean` | — | — | T | P2 | 0.5 |
| Flügel mittig / Versatz | ✗ | `WindowType.sashOffset?: number` | Aufschlag-Linie | Flügel-Box-Lage | T | P2 | 1 |
| Rahmenstärke quer zur Wand | ✓ | `frameThickness` / `frameDepth` (→ `resolveAcrossWallDepth` :1780) | — | Rahmentiefe | T | — | — |
| Schnitt-/Außenansicht-Rahmenbreiten | ~ | von `frameWidth` mitgezeichnet | Bandbreite | — | T | P2 | 1 |
### 2.5 Flügeleinteilung (KERN-Tiefe — höchster Wert)
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Flügeltabelle (Nr/Typ/Breite/Anschlag/…) | ~ | `wingCount` (Zahl) → **neu** `WindowType.sashes: SashDef[]` (siehe §4) | Pfostenlinien (`mullionLines`) heute nur gleichmäßig | Pfosten-Boxen nur gleichmäßig | T | **P0** | 4 |
| Typ Flügel \| Pfosten | ✗ | `SashDef.kind: "fluegel"\|"pfosten"` | Pfostenlage frei | Pfosten frei | T | P0 | (inkl.) |
| Aut. Breite / Flügelbreite | ~ | `SashDef.autoWidth`/`width` (heute nur gleichmäßig) | Teilungslage | Teilungslage | T | P1 | 1 |
| Pfostenbreite / „alle gleich" | ~ | `SashDef.postWidth`, `WindowType.uniformPosts` | Pfostendicke | Pfostenbox-Breite | T | P1 | 1 |
| Anschlag (Drehbar links/Drehkipp rechts) | ✗ | `SashDef.opening: OpeningKind`, `SashDef.hingeSide: "left"\|"right"` | Öffnungslinien/Pfeil je Flügel | Öffnungslinien 3D | T | **P0** | 2 |
| Aufschlag (Abstand zu Rahmen) | ✗ | `SashDef.rebate?: number` | Aufschlag-Linie | — | T | P2 | 0.5 |
| Winkel / 3D-Öffnung | ✗ | `SashDef.openAngle?: number` | — | Flügel gekippt/offen | T | P2 | 1.5 |
| Griffart | ✗ | `SashDef.handle?: HandleKind` | — | Griff-Mesh (§2.7) | T | P2 | 1 |
| Kämpfer-Zeilen (horizontale Teilung) | ~ | `mullionRows` existiert | Querlinien | Kämpfer-Boxen (fein) | T | P1 | — |
Dies ist die **wertvollste Lücke**: VW modelliert je Flügel Typ, Breite,
Öffnungsrichtung, Anschlag, Griff. DOSSIER hat nur eine gleichmäßige `wingCount`.
Die `SashDef[]`-Tabelle (§4, Phase P0/P1) ist der zentrale Ausbau.
### 2.6 Laibungsverkleidung · Form · Ober-/Unterlicht · Nische/Bank
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Laibungsverkleidung erstellen | ✗ | `WindowType.reveal?: {create, depth, thickness}` | Laibungsband | Laibungs-Boxen | T | P2 | 1.5 |
| Form (Eckig/Schräg/Spitz/Rund) | ✗ | `WindowType.headShape?: HeadShape` + Eckmaße | Öffnungsumriss-Form | Bogen-/Schräg-Mesh | T | P2 | 4 |
| Oberlicht erstellen + Höhe/Rahmen/Sprossen | ~ | `transomHeight` existiert (feste Scheibe) → `WindowType.transom?: {height, frame, grid}` | Kämpferlinie + Feld | zweite Scheibe (`glassPanesForOpening` :1988) | T | P1 | 2 |
| Unterlicht (unteres festes Feld) | ✗ | `WindowType.underlight?: {height, frame, grid}` | Feld | Scheibe unten | T | P2 | 1.5 |
| Sprossen (horiz./vert. im Feld) | ~ | `mullionRows` grob → `WindowType.muntins?: {rows, cols}` je Feld | Sprossengitter | Sprossen-Boxen (fein) | T | P1 | 2 |
| Nische aussparen (außen/innen) | ✗ | `WindowType.niche?: {aussen, innen, ...}` | Nischenkontur | Nischen-Aussparung | T | P2 | 2 |
| Fensterbank erstellen (außen/innen) | ~ | `sillBoard` existiert, ungenutzt → `WindowType.sill?: {aussen, innen, typ, masse}` | Bankkontur | Bank-Box | T | P1 | 2 |
| Banktyp/Winkel/ΔZ/Endverkröpfung | ✗ | Unterfelder von `sill` | Detail | Detail-Mesh | T | P3 | 2 |
### 2.7 Sonnenschutz · Beschlag · Geländer · Heizkörper · Eckfenster
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Sonnenschutz/Rollladen erstellen + Typ | ✗ | `WindowType.shading?: {create, kind: "raffstore"\|"rollladen"\|…, box, projection}` | Kasten-/Kastenlinien im Grundriss | Kastenbox + Auskragung | T | **P0/P1** | 2.5 |
| Rollladenkasten-Maße | ✗ | `shading.box: {w,h,d}` | Rechteck | Box-Mesh | T | P0 | (inkl.) |
| „Nische erzeugen"/„Kasten symm."/Lamellenwinkel | ✗ | `shading`-Unterfelder | — | Detail | T | P2 | 1 |
| Beschlag (Griff/Knauf) Geometrie/Maße | ✗ | `SashDef.handle` + `WindowType.handleGeom?` | — | Griff-/Knauf-Mesh | T | P2 | 2 |
| Geländer (Position/Höhe/Bauteile) | ✗ | `WindowType.railing?: {...}` | Geländerlinien | Geländer-Mesh | T | P3 | 3 |
| Heizkörper | ✗ | — (eigenes Bauteil, nicht Fenster) | — | — | — | P3 | — |
| Eckfenster | ✗ | eigener Sonderfall (zwei Wände) | — | — | T | P3 | 5+ |
### 2.8 Attribute · Schnitte/Ansichten · Detaillierung · IFC/Energos
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Attribut-Tabelle je Bestandteil (Klasse/Material/Stift/Linie/…) | ~ | DOSSIER hat By-Layer/By-Object-Attribute (`foreground`/`background`/`hatchId`/`strokeWeight` an Wand/Decke, NICHT an `Opening`) → `Opening.foreground?` etc. + evtl. je Bestandteil | Farbe/Strich der Bestandteile | Material | I/T | P2 | 3 |
| „Klassenattribute zuweisen/entfernen" | ~ | `AttributeSource` („layer"/„object") existiert für Wand/Decke | — | — | I | P2 | 1 |
| Automatische 2D-Darstellungen (Horizontal/Ansicht/Querschnitt) | ~ | `generatePlan` erzeugt Grundriss; Schnitt/Ansicht sind Platzhalter (`DrawingLevelKind`) | — | — | — | P3 | — |
| Sichtbarkeitsmatrix 3D-Objekte × Ansichten (Oben/Unten/…/Querschnitt) | ✗ | **nicht bauen** | — | — | — | P3 | — |
| Detaillierung: Option × Kategorie × Detailstufe (Augen-Tabelle) | ~ | `DetailLevel` (grob/mittel/fein) fließt in 2D + 3D | Sichtbarkeit je Stufe | Meshstufe | I | P2 | 2 |
| „Für 3D die Tiefe/Mittlere/Hohe Detaillierung verwenden" | ✓ | `Model3dOptions.detail` (`toWalls3d.ts:2145`) | — | Meshstufe | — | — | — |
| Beschriftung | ✗ | eigenes Text/Tag-System | Label | — | — | P3 | — |
| Infos/IFC-Daten/Infopalette/Energos | ✗ | **nicht bauen** | — | — | — | P3 | — |
| „Mehrere ändern" | ~ | Multi-Selektion patcht bereits gemeinsame Felder | — | — | I | P2 | 1 |
---
## 3. Der dedizierte Dialog
### 3.1 Komponente & Einbettung
Neue Komponente **`src/ui/OpeningEditorDialog.tsx`** (Muster: die bestehenden
`*Dialog.tsx` in `src/ui/`, z. B. `SettingsDialog.tsx`, `TextEditorDialog.tsx`,
und das Overlay-Muster `res-overlay` + `role="dialog"` aus
`ResourceManager.tsx:371`). Props (über `usePanelHost`/App-State geliefert):
```
open: boolean
openingId: string // die editierte Instanz
kind: "window" | "door" // steuert Sidebar-Sätze + welche Type-Liste
project: Project // read (Typ-Listen, Bauteile, LayerCategories)
onPatchOpening(patch) // Instanz-Felder
onPatchType(typeId, patch) // Typ-Felder (aktueller Stil)
onSaveAsStyle(name, snapshot)// „Fenster/Tür speichern…" → neuer WindowType/DoorType
onResetToStyle() // „zurücksetzen…"
onClose()
```
**Öffnen aus dem ⚙.** `ObjectInfoPanel.OpeningSection`
(`src/panels/ObjectInfoPanel.tsx:731`, `onClick → host.onEditOpeningType(...)`)
ruft künftig einen neuen Host-Handler `host.onOpenOpeningEditor(openingId)` statt
`setResourcesTab(...) + setResourcesOpen(true)` (heutige Verdrahtung in
`src/App.tsx:3130`). App hält `openingEditorId: string | null` als State und
rendert `<OpeningEditorDialog open={openingEditorId!=null} .../>`. Der bisherige
Ressourcen-Tab bleibt als Bibliotheks-/Massenpflege bestehen, ist aber nicht mehr
der Primärort — das ⚙ landet direkt im reichen Dialog.
### 3.2 Kategorie-Sidebar (realistischer Teilmenge)
Reihenfolge und Sichtbarkeit je `kind`:
1. **Basis** — Öffnungsart (`kind`), Einfügepunkt quer (`insetFace`/`insetFromFace`),
Klasse (`categoryCode`), Detaillierung (`detailLevel`).
2. **Größe/Position**`width`, `height`, `sillHeight`, `position` (I),
`defaultWidth/Height/SillHeight` (T).
3. **Rahmen**`frameThickness`, `frameDepth`, `frameWidth`/`frameWidths`,
`frameKind` (Tür), `sashOffset`.
4. **Flügel/Sprossen** — die `SashDef[]`-Tabelle (Flügel/Pfosten einfügen,
Öffnungsrichtung/Anschlag je Flügel), Kämpfer-Zeilen, Sprossengitter.
5. **Oberlicht/Unterlicht**`transom`, `underlight` (je Feld: Höhe, Rahmen, Gitter).
6. **Laibung/Bank**`reveal`, `sill` (außen/innen), `niche`.
7. **Sonnenschutz/Rollladen**`shading` (Typ, Kastenmaße, Auskragung).
8. **Attribute/Darstellung** — Bestandteil-Attribute (Farbe/Strich/Material).
9. **Detaillierung** — welche Bestandteile bei grob/mittel/fein sichtbar sind.
Sidebar-Sätze: bei `kind==="door"` entfallen Oberlicht/Unterlicht-Gitter-Details,
Sonnenschutz und Bank; dafür Türblatt (`leafCount`, `leafStyle`, `threshold`,
`swing`/`hinge`/`openingDir`/`swingAngle`).
### 3.3 Live-Vorschau
**2D (der billige, sofort machbare Teil).** DOSSIER hat den kompletten
Plan→SVG-Pfad bereits: `generatePlan()` (`src/plan/generatePlan.ts:632`) →
`toRenderScene.ts` (RScene) → `sceneToPrintSvg()` (`src/export/sceneToPrintSvg.ts:109`)
bzw. `planToPrintSvg()` (`src/export/planToPrintSvg.ts:155`). Für die Vorschau ein
**Mini-Projekt** bauen (eine kurze Wand + das editierte `Opening` mit den aktuellen
Dialog-Werten), durch `generatePlan()` schicken und als SVG in ein Vorschau-`<div>`
rendern — Grundriss oben, optional eine Elevations-Skizze. Reagiert auf jeden
Patch reaktiv (React-State → neu generieren; günstig, da nur ein Element).
**3D/Elevation.** Zwei Optionen, in aufsteigendem Aufwand:
- **(A) günstig, empfohlen für den ersten Wurf:** eine reine 2D-**Ansicht/Elevation**
aus denselben Plan-Primitiven — VW's „Vorschau, schattiert" ist für uns nicht
nötig; eine saubere Frontalansicht (Rahmen/Flügel/Sprossen/Glas als SVG) zeigt
die Flügeleinteilung am aussagekräftigsten und nutzt exakt die neuen Felder.
- **(B) echte 3D-Vorschau:** `projectToModel3d()` (`src/plan/toWalls3d.ts:2149`)
auf das Mini-Projekt anwenden und im wgpu-Viewport (`Wasm3DViewport.tsx`)
darstellen. Teurer (eigener Render-Kontext im Modal, WASM-Instanz) und laut
Projekt-Memo NICHT per Browser/Puppeteer verifizierbar — der Nutzer testet 3D
selbst in der Tauri-Dev-App. Deshalb 3D-Vorschau erst NACH der 2D-Vorschau, als
eigene Phase.
Empfehlung: erste Scheibe nur **2D-Grundriss + 2D-Elevation**; echte wgpu-Vorschau
später.
### 3.4 „Als Stil speichern"-Flow
Drei Aktionen in der Stil-Leiste:
- **Fenster/Tür speichern… (`onSaveAsStyle`)** — nimmt einen Schnappschuss der
aktuellen (Typ-relevanten) Dialogwerte, fragt einen Namen ab (`PromptDialog.tsx`)
und legt einen NEUEN `WindowType`/`DoorType` in `project.windowTypes`/`doorTypes`
an (CRUD-Factories existieren: `addWindowType`/`addDoorType` in `src/App.tsx`
ab :2273/:2304). Das editierte `Opening.typeId` zeigt danach auf den neuen Stil.
- **Update dieses Stils** — `patchWindowType`/`patchDoorType` (App :2319/:2287) auf
den aktuell referenzierten `typeId` anwenden (wirkt auf alle Instanzen des Stils).
- **Einstellungen zurücksetzen… (`onResetToStyle`)** — die instanzseitigen
Overrides verwerfen und wieder die Typ-Defaults ziehen (Instanz-Felder auf
`undefined` setzen, sodass die Resolver `getWindowType`/`getDoorType`
(`src/model/types.ts:2060`/:2064) greifen).
TYP-vs-INSTANZ-Regel im Dialog sichtbar machen: Typ-Felder tragen eine dezente
„Stil"-Markierung; ein instanzseitig übersteuertes Feld zeigt einen „abweichend
vom Stil"-Indikator + „zurücksetzen". Das ist genau VW's Stil-/Override-Logik.
---
## 4. Modell-Erweiterungsplan (phasiert)
Alle Felder additiv/optional (Alt-Projekte laden unverändert — die bestehende
Konvention der Datei, vgl. `wingCount?`, `sliceTermination` Default). Neue Felder
auf `WindowType`/`DoorType` in `src/model/types.ts`.
### P0 — die erste reiche Scheibe (Kern-Tiefe)
```ts
// Ein Flügel- oder Pfosten-Eintrag der Flügeleinteilung.
type OpeningKind = "dreh" | "kipp" | "drehkipp" | "fest" | "schiebe";
interface SashDef {
kind: "fluegel" | "pfosten";
autoWidth?: boolean; // gleichmäßig aufteilen (Default true)
width?: number; // feste Flügelbreite (m), wenn !autoWidth
postWidth?: number; // Pfostenbreite (m), nur kind="pfosten"
opening?: OpeningKind; // Öffnungsart DIESES Flügels
hingeSide?: "left" | "right"; // Anschlag
}
interface WindowType {
// … bestehend …
sashes?: SashDef[]; // ersetzt/erweitert wingCount; fehlt ⇒ aus wingCount abgeleitet
shading?: { // Rollladen/Sonnenschutz-Kasten (P0-Minimalfassung)
create: boolean;
kind: "rollladen" | "raffstore" | "markise";
box: { width: number; height: number; depth: number };
};
glazingPanes?: 1 | 2 | 3; // Verglasung endlich RENDER-wirksam (heute: glazing unbenutzt)
}
```
Rationale/Render-Freischaltung:
- `sashes` — schaltet echte, ungleichmäßige Flügel/Pfosten + Öffnungsrichtung je
Flügel frei; treibt neue Pfostenlinien in `windowSymbol`/`mullionLines`
(`src/geometry/opening.ts`) und Pfosten-Boxen in `frameMeshesForOpening`
(`src/plan/toWalls3d.ts:1938`). Höchster sichtbarer Mehrwert.
- `shading` — der Rollladenkasten ist ein einzelnes Kästchen: eine 2D-Rechteckkontur
über der Öffnung (neuer Zeichenzweig in `addOpeningSymbol`) + eine Box in
`emitOpeningFrames`/eigener Emitter. Wenig Code, große „Reichhaltigkeit".
- `glazingPanes``glassPanesForOpening` (:1965) liest die Scheibenzahl statt
konstant EINE Scheibe zu zeichnen; 2D-Glaslinien-Anzahl analog. Schließt die
auffälligste Konsum-Lücke.
### P1 — Tiefe der Flügel/Felder
```ts
interface SashDef { /* + */ rebate?: number; openAngle?: number; handle?: "drueck"|"knauf"|"none"; }
interface WindowType {
frameWidths?: { left: number; right: number; top: number; bottom: number };
frameSymmetric?: boolean;
uniformPosts?: boolean;
transom?: { height: number; frame?: number; muntins?: { rows: number; cols: number } };
sill?: { aussen?: SillDef; innen?: SillDef };
muntins?: { rows: number; cols: number }; // Sprossen im Hauptfeld
}
```
Rationale: asymmetrische Rahmenbreiten, richtiges Oberlicht (statt nur feste Scheibe
via `transomHeight`), Fensterbank (heute `sillBoard` ungenutzt), Sprossengitter.
Alles hängt an vorhandenen Zeichen-/Mesh-Funktionen; nur Parameterausbau.
### P2 — Detailbestandteile
```ts
interface WindowType {
reveal?: { create: boolean; depth: number; thickness: number }; // Laibungsverkleidung
underlight?: { height: number; frame?: number }; // Unterlicht
niche?: { aussen?: boolean; innen?: boolean; depth: number }; // Nische
headShape?: "eckig" | "schraeg_links" | "schraeg_rechts" | "spitz" | "rund";
handleGeom?: { kind: "quader" | "rund"; masse: Record<string, number> };
}
interface Opening { foreground?: string; background?: string; strokeWeight?: number; } // Bestandteil-Attribute
```
Rationale: Form (Rundbogen/Schräge → neue Öffnungsumriss-Geometrie), Laibung/Nische,
Beschlag-Geometrie, per-Instanz-Attribute (analog Wand/Decke, die es schon haben).
### P3 — VW-Ballast (nur wenn je nachgefragt)
Bemaßungs-Bezugsschemata (B1..B5/H1..H7), Sichtbarkeitsmatrix 3D×Ansichten,
IFC/Energos, Geländer, Heizkörper, Eckfenster, eigenes Symbol, Fassadenmodul-Versatz.
Siehe Abschnitt 5.
**Höchster-Wert-Reihenfolge (verdichtet):** (1) `sashes` inkl. Öffnungsrichtung je
Flügel, (2) mehrscheibige Verglasung, (3) Rollladenkasten, (4) Bank/Nische,
(5) Sprossengitter, (6) Form (Rund/Spitz/Schräg), (7) Ober-/Unterlicht als eigene
Felder.
---
## 5. Was NICHT bauen / Risiken
**Nicht bauen (VW-Komplexität ohne DOSSIER-Nutzen):**
- **Sichtbarkeitsmatrix „3D-Objekte × 9 Ansichten"** (Oben/Unten/Horizontalschnitt/
Vorne/Hinten/Frontalschnitt/Links/Rechts/Querschnitt). DOSSIER rendert heute
primär Grundriss; Schnitt/Ansicht sind `DrawingLevelKind`-Platzhalter. Eine
Matrix mit ~13×9 An/Aus-Zellen ist Pflegehölle ohne Zielrenderer. `DetailLevel`
(grob/mittel/fein) genügt als Sichtbarkeitsachse.
- **Bemaßungs-Bezugssystem (Roh-/Fertigmaß, B1..B5/H1..H7).** DOSSIER trägt lichte
Maße; eine parametrische Öffnungs-Maßkette existiert nicht. Sehr teuer, geringer
Nutzen für ein CAAD-Werkzeug dieser Reife.
- **IFC-Datenmapping-Tiefe, Energos, Infopalette.** Kein IFC-Export-Pfad vorhanden;
bewusst weglassen (kein Schein-Feature, vgl. die Modell-Doku-Konvention).
- **Heizkörper, Geländer, Eckfenster** als Fenster-Unterobjekte — das sind eigene
Bauteile bzw. topologische Sonderfälle (zwei Wände). Nicht ins Fenster packen.
- **Eigenes Symbol verwenden** (Drawing2D-Override je Stil) — erst wenn ein
Symbol-/Blocksystem existiert.
**Risiken:**
- **3D-Vorschau nicht als „korrekt" verkaufen.** Laut Projekt-Memo werden
`render3d`/`Wasm3DViewport`-Änderungen NICHT per Puppeteer/Browser verifiziert;
der Nutzer testet 3D selbst in der Tauri-Dev-App. Der Dialog darf 3D anzeigen,
aber diese Studie/Implementierung behauptet keine visuelle Korrektheit der
3D-Vorschau — nur der 2D-Pfad (`generatePlan`→SVG) ist headless prüfbar.
- **Typ-vs-Instanz-Verwirrung.** Ohne klaren „vom Stil abweichend"-Indikator wird
unklar, ob eine Änderung den Stil (alle Instanzen) oder nur dieses Fenster trifft.
Muss im UI explizit sein (§3.4).
- **`sashes` vs. `wingCount` Migration.** `wingCount` bleibt als Fallback; `sashes`
hat Vorrang, wenn gesetzt. Resolver in `resolveOpeningFrame`
(`toWalls3d.ts:1845`) und `windowSymbol` müssen beide Wege beherrschen, sonst
brechen Alt-Projekte oder die `ObjectInfoPanel`-Flügelanzahl-Eingabe.
- **Modal-Render-Kosten.** Live-Vorschau bei jedem Tastendruck neu generieren ist
ok für ein Mini-Projekt, aber Patches sollten (leicht) entprellt werden.
---
## 6. Empfohlene erste Scheibe (P0, konkret)
Kleinstes End-to-End-Inkrement, das sich schon „reich" anfühlt:
**Umfang:** Dialog-Shell + Live-2D-Vorschau + Flügeleinteilung (n Flügel mit
Öffnungsrichtung je Flügel) + mehrscheibige Verglasung + Rollladenkasten +
„Als Stil speichern".
**Build-Reihenfolge:**
1. **Modell (P0-Felder).** `SashDef`, `WindowType.sashes?`, `WindowType.shading?`,
`WindowType.glazingPanes?` in `src/model/types.ts` ergänzen (additiv). Resolver
`getWindowType` bleibt; `wingCount``sashes`-Ableitung als Helper.
2. **Renderer-Konsum (2D zuerst, headless prüfbar).**
- `windowSymbol`/`mullionLines` (`src/geometry/opening.ts`) auf `sashes` umstellen
(ungleichmäßige Pfostenlagen + Öffnungsrichtung je Flügel als Öffnungslinien).
- `glassPanesForOpening`/2D-Glaslinien auf `glazingPanes` (statt konstant 1/detail).
- neuer Zeichenzweig in `addOpeningSymbol` (`src/plan/generatePlan.ts:2214`) für
die Rollladenkasten-Kontur; 3D-Box später.
- Unit-Tests gegen `generatePlan()`-Output (Primitive zählen) — das ist der
verifizierbare Beweis, nicht nur „grüne Typecheck".
3. **Dialog-Shell.** `src/ui/OpeningEditorDialog.tsx` (Overlay `res-overlay` +
`role="dialog"`), Sidebar mit den Kategorien Basis / Größe/Position / Rahmen /
Flügel-Sprossen / Sonnenschutz. Nur diese fünf für P0.
4. **Live-2D-Vorschau.** Mini-Projekt (kurze Wand + editiertes `Opening`) →
`generatePlan()``sceneToPrintSvg()`/`planToPrintSvg()``<div>`. Grundriss +
Elevations-SVG. Reaktiv auf Patches (leicht entprellt).
5. **Flügel-Tabelle.** UI-Tabelle mit „Flügel einfügen / Pfosten einfügen / Löschen"
und je Zeile Öffnungsart + Anschlag (`SashDef`). Schreibt `WindowType.sashes`.
6. **„Als Stil speichern".** Stil-Leiste + `onSaveAsStyle``addWindowType`
(`src/App.tsx:2304`) mit `PromptDialog`-Namensabfrage; „zurücksetzen" verwirft
Instanz-Overrides.
7. **⚙-Verdrahtung.** `ObjectInfoPanel.OpeningSection` (:731) → neuer Host-Handler
`onOpenOpeningEditor(openingId)`; App-State `openingEditorId`. Ressourcen-Tab
bleibt als Bibliothek erhalten.
**Definition of Done (P0):** Aus dem ⚙ öffnet sich der Dialog; man setzt 3 Flügel,
davon einer Dreh-links / einer Dreh-rechts / einer fest, wählt Dreifachverglasung
und einen Rollladenkasten; die 2D-Vorschau zeigt die Pfosten/Öffnungslinien/Kasten
sofort; „Speichern…" legt einen benannten Fensterstil an, der im Typ-Dropdown der
`OpeningSection` erscheint. Verifikation über `generatePlan()`-Primitiv-Tests
(2D) — 3D-Box erst danach, vom Nutzer in der Tauri-App geprüft.
+70
View File
@@ -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<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
// 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 && (
<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/
fallengelassen wurde. Liefert eine strukturierte Entscheidung an
runImport (Ebene anlegen / Drawings anhängen / Meshes als Kontext). */}
+63 -1
View File
@@ -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",
+63 -1
View File
@@ -362,7 +362,69 @@ export const en: Record<TranslationKey, string> = {
// ── 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",
+2 -2
View File
@@ -712,7 +712,7 @@ export function OpeningSection({
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
{/* 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">
<span className="objinfo-flabel">{t("objinfo.opening.type")}</span>
<Dropdown
@@ -733,7 +733,7 @@ export function OpeningSection({
className="objinfo-typebtn"
title={t("objinfo.opening.editType")}
aria-label={t("objinfo.opening.editType")}
onClick={() => host.onEditOpeningType(isDoor ? "door" : "window")}
onClick={() => host.onOpenOpeningEditor(opening.id)}
>
</button>
+6
View File
@@ -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.
+3
View File
@@ -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,
+201
View File
@@ -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 {
+839
View File
@@ -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>
);
}