diff --git a/docs/design/window-editor-vectorworks-study.md b/docs/design/window-editor-vectorworks-study.md new file mode 100644 index 0000000..50fecd9 --- /dev/null +++ b/docs/design/window-editor-vectorworks-study.md @@ -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 ``. 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-`
` +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 }; +} +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()` → `
`. 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. diff --git a/src/App.tsx b/src/App.tsx index 5078cce..ea36938 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,6 +69,7 @@ import { exportIfcSpf } from "./export/exportIfc"; import { exportObj, exportStl } from "./export/exportMesh"; import type { ScheduleKind } from "./export/exportSchedule"; import { SettingsDialog } from "./ui/SettingsDialog"; +import { OpeningEditorDialog } from "./ui/OpeningEditorDialog"; import { ExportSaveDialog } from "./ui/ExportSaveDialog"; import type { ExportSaveFormat } from "./ui/ExportSaveDialog"; import { PromptDialog } from "./ui/PromptDialog"; @@ -414,6 +415,9 @@ export default function App() { // Angeforderter Start-Tab des Ressourcen-Fensters (z. B. „doorStyles", wenn man // von einer gewählten Tür in den Typeditor springt). const [resourcesTab, setResourcesTab] = useState("components"); + // Offener Fenster-/Tür-Einstellungsdialog (⚙ der OpeningSection) — ID der + // editierten Öffnung, oder null wenn geschlossen. + const [openingEditorId, setOpeningEditorId] = useState(null); // Aktives Layout-Blatt im HAUPT-Viewport (DOSSIER A3, Phase 2b): Id des offenen // Layouts oder null (= normale Modell-Ansicht). Transienter UI-State; das @@ -2340,6 +2344,57 @@ export default function App() { windowTypes: (p.windowTypes ?? []).filter((wt) => wt.id !== id), }; }); + // „Als Stil speichern": aktuellen Fenster-/Tür-Typ der Öffnung als NEUEN + // benannten Stil ablegen und der Öffnung zuweisen (VW „Fenster speichern…"). + // Ohne Bezugstyp wird aus den Default-Feldern ein frischer Stil erzeugt. + const saveOpeningStyle = (openingId: string, name: string) => + setProject((p) => { + const o = (p.openings ?? []).find((x) => x.id === openingId); + if (!o) return p; + if (o.kind === "door") { + const base = (p.doorTypes ?? []).find((d) => d.id === o.typeId); + const nt: DoorType = base + ? { ...base, id: `dt-${Date.now()}`, name } + : { + id: `dt-${Date.now()}`, + name, + kind: "dreh", + leafCount: 1, + leafStyle: "glatt", + frameThickness: 0.05, + defaultWidth: 0.9, + defaultHeight: 2.0, + }; + return { + ...p, + doorTypes: [...(p.doorTypes ?? []), nt], + openings: (p.openings ?? []).map((x) => + x.id === openingId ? { ...x, typeId: nt.id } : x, + ), + }; + } + const base = (p.windowTypes ?? []).find((w) => w.id === o.typeId); + const nt: WindowType = base + ? { ...base, id: `ft-${Date.now()}`, name } + : { + id: `ft-${Date.now()}`, + name, + kind: "drehkipp", + wingCount: 1, + glazing: "zweifach", + frameThickness: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.2, + defaultHeight: 1.4, + }; + return { + ...p, + windowTypes: [...(p.windowTypes ?? []), nt], + openings: (p.openings ?? []).map((x) => + x.id === openingId ? { ...x, typeId: nt.id } : x, + ), + }; + }); const addStairType = () => setProject((p) => { const st: StairType = { @@ -3166,6 +3221,7 @@ export default function App() { setResourcesTab(kind === "door" ? "doorStyles" : "windowStyles"); setResourcesOpen(true); }, + onOpenOpeningEditor: (id) => setOpeningEditorId(id), // ── Dach-Attribute (nur bei selektiertem Dach) ───────────────────────── onSetRoofPatch: (patch) => { if (selection?.kind === "roof") updateRoof(selection.id, patch); @@ -4959,6 +5015,20 @@ export default function App() { /> )} + {/* Reicher Fenster-/Tür-Einstellungsdialog (⚙ der OpeningSection). */} + {openingEditorId != null && ( + updateOpening(openingEditorId, patch)} + onSetOpeningType={(typeId) => updateOpening(openingEditorId, { typeId: typeId || undefined })} + onSaveAsStyle={(name) => saveOpeningStyle(openingEditorId, name)} + onClose={() => setOpeningEditorId(null)} + /> + )} + {/* Modaler Import-Dialog (DXF/DWG) — offen, sobald eine Datei gewählt/ fallengelassen wurde. Liefert eine strukturierte Entscheidung an runImport (Ebene anlegen / Drawings anhängen / Meshes als Kontext). */} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index dec9dac..777d937 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -362,7 +362,69 @@ export const de = { // ── Öffnungs-Attribute (Object-Info-Panel) ────────────────────────────── "objinfo.opening.section": "Öffnung", "objinfo.opening.type": "Typ", - "objinfo.opening.editType": "Typ bearbeiten (Ressourcen)", + "objinfo.opening.editType": "Einstellungen …", + // Fenster-/Tür-Einstellungsdialog (OpeningEditorDialog). + "oed.title.window": "Fenster-Einstellungen", + "oed.title.door": "Tür-Einstellungen", + "oed.style": "Stil", + "oed.saveStyle": "Als Stil speichern …", + "oed.saveStyle.hint": "Aktuelle Einstellungen als neuen, wiederverwendbaren Stil ablegen", + "oed.saveStyle.prompt": "Name des neuen Stils:", + "oed.saveStyle.copySuffix": "(Kopie)", + "oed.categories": "Kategorien", + "oed.noStyle": "Kein Stil zugewiesen — über die Stil-Leiste einen Stil wählen oder speichern.", + "oed.preview": "Ansicht", + "oed.cat.basis": "Basis", + "oed.cat.groesse": "Grösse/Position", + "oed.cat.rahmen": "Rahmen", + "oed.cat.fluegel": "Flügel/Sprossen", + "oed.cat.sonnenschutz": "Sonnenschutz", + "oed.cat.tuerblatt": "Türblatt", + "oed.f.kind": "Öffnungsart", + "oed.f.glazing": "Verglasung", + "oed.f.insetFace": "Schichteinzug ab", + "oed.f.insetFromFace": "Schichteinzug (m)", + "oed.f.width": "Breite (m)", + "oed.f.height": "Höhe (m)", + "oed.f.sillHeight": "Brüstungshöhe (m)", + "oed.f.position": "Position (m)", + "oed.f.frameThickness": "Rahmenstärke quer (m)", + "oed.f.frameDepth": "Rahmentiefe (m)", + "oed.f.frameWidth": "Rahmenbreite Ansicht (m)", + "oed.kind.dreh": "Dreh", + "oed.kind.kipp": "Kipp", + "oed.kind.drehkipp": "Dreh-Kipp", + "oed.kind.fest": "Fest", + "oed.kind.schiebe": "Schiebe", + "oed.glazing.1": "Einfach", + "oed.glazing.2": "Zweifach", + "oed.glazing.3": "Dreifach", + "oed.insetFace.aussen": "Aussenfläche", + "oed.insetFace.innen": "Innenfläche", + "oed.fluegel.hint": "Flügel und Pfosten von links nach rechts. Öffnungsart und Anschlag je Flügel.", + "oed.fluegel.col.kind": "Typ", + "oed.fluegel.col.opening": "Öffnung", + "oed.fluegel.col.hinge": "Anschlag", + "oed.fluegel.type.fluegel": "Flügel", + "oed.fluegel.type.pfosten": "Pfosten", + "oed.fluegel.remove": "Entfernen", + "oed.hinge.left": "Links", + "oed.hinge.right": "Rechts", + "oed.shading.create": "Rollladen-/Sonnenschutzkasten", + "oed.shading.kind": "Art", + "oed.shading.rollladen": "Rollladen", + "oed.shading.raffstore": "Raffstore", + "oed.shading.markise": "Markise", + "oed.shading.boxHeight": "Kastenhöhe (m)", + "oed.shading.boxDepth": "Kastentiefe (m)", + "oed.door.leafCount": "Türblätter", + "oed.door.leaf1": "Einflüglig", + "oed.door.leaf2": "Zweiflüglig", + "oed.door.leafStyle": "Blattart", + "oed.door.glatt": "Glatt", + "oed.door.kassette": "Kassette", + "oed.door.glas": "Glas", + "oed.door.wandoeffnung": "Wandöffnung", "objinfo.stair.type": "Typ", "objinfo.type.none": "(kein Typ)", "objinfo.opening.kind": "Art", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 716d866..6d4de98 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -362,7 +362,69 @@ export const en: Record = { // ── Opening attributes (object info panel) ────────────────────────────── "objinfo.opening.section": "Opening", "objinfo.opening.type": "Type", - "objinfo.opening.editType": "Edit type (Resources)", + "objinfo.opening.editType": "Settings …", + // Window/door settings dialog (OpeningEditorDialog). + "oed.title.window": "Window settings", + "oed.title.door": "Door settings", + "oed.style": "Style", + "oed.saveStyle": "Save as style …", + "oed.saveStyle.hint": "Store the current settings as a new, reusable style", + "oed.saveStyle.prompt": "Name of the new style:", + "oed.saveStyle.copySuffix": "(copy)", + "oed.categories": "Categories", + "oed.noStyle": "No style assigned — choose or save one via the style bar.", + "oed.preview": "View", + "oed.cat.basis": "Basics", + "oed.cat.groesse": "Size/position", + "oed.cat.rahmen": "Frame", + "oed.cat.fluegel": "Sashes/muntins", + "oed.cat.sonnenschutz": "Shading", + "oed.cat.tuerblatt": "Leaf", + "oed.f.kind": "Opening type", + "oed.f.glazing": "Glazing", + "oed.f.insetFace": "Inset from", + "oed.f.insetFromFace": "Layer inset (m)", + "oed.f.width": "Width (m)", + "oed.f.height": "Height (m)", + "oed.f.sillHeight": "Sill height (m)", + "oed.f.position": "Position (m)", + "oed.f.frameThickness": "Frame depth across (m)", + "oed.f.frameDepth": "Frame depth (m)", + "oed.f.frameWidth": "Frame face width (m)", + "oed.kind.dreh": "Casement", + "oed.kind.kipp": "Tilt", + "oed.kind.drehkipp": "Turn-tilt", + "oed.kind.fest": "Fixed", + "oed.kind.schiebe": "Sliding", + "oed.glazing.1": "Single", + "oed.glazing.2": "Double", + "oed.glazing.3": "Triple", + "oed.insetFace.aussen": "Outer face", + "oed.insetFace.innen": "Inner face", + "oed.fluegel.hint": "Sashes and posts from left to right. Opening type and hinge per sash.", + "oed.fluegel.col.kind": "Type", + "oed.fluegel.col.opening": "Opening", + "oed.fluegel.col.hinge": "Hinge", + "oed.fluegel.type.fluegel": "Sash", + "oed.fluegel.type.pfosten": "Post", + "oed.fluegel.remove": "Remove", + "oed.hinge.left": "Left", + "oed.hinge.right": "Right", + "oed.shading.create": "Roller shutter / shading box", + "oed.shading.kind": "Kind", + "oed.shading.rollladen": "Roller shutter", + "oed.shading.raffstore": "Venetian blind", + "oed.shading.markise": "Awning", + "oed.shading.boxHeight": "Box height (m)", + "oed.shading.boxDepth": "Box depth (m)", + "oed.door.leafCount": "Door leaves", + "oed.door.leaf1": "Single leaf", + "oed.door.leaf2": "Double leaf", + "oed.door.leafStyle": "Leaf style", + "oed.door.glatt": "Flush", + "oed.door.kassette": "Panelled", + "oed.door.glas": "Glass", + "oed.door.wandoeffnung": "Wall opening", "objinfo.stair.type": "Type", "objinfo.type.none": "(no type)", "objinfo.opening.kind": "Type", diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index c5cc154..dcd5b38 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -712,7 +712,7 @@ export function OpeningSection({
{t("objinfo.opening.section")}
{/* Tür-/Fenstertyp (Bibliothek) — je nach Art aus doorTypes bzw. windowTypes. - Der ⚙-Knopf springt direkt in den Typeditor (Ressourcen-Fenster). */} + Der ⚙-Knopf öffnet den reichen Fenster-/Tür-Einstellungsdialog. */}
{t("objinfo.opening.type")} host.onEditOpeningType(isDoor ? "door" : "window")} + onClick={() => host.onOpenOpeningEditor(opening.id)} > ⚙ diff --git a/src/panels/host.ts b/src/panels/host.ts index 63f8d0c..047202f 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -299,6 +299,12 @@ export interface PanelHostValue { * (Discoverability: von der gewählten Öffnung in den Typeditor springen). */ onEditOpeningType: (kind: "door" | "window") => void; + /** + * Öffnet den reichen Fenster-/Tür-Einstellungsdialog für DIESE Öffnung + * (Kategorie-Sidebar, Live-2D-Vorschau, Flügeleinteilung, „Als Stil speichern"). + * Ist der Primärort für Öffnungs-Parameter; das ⚙ der OpeningSection ruft ihn. + */ + onOpenOpeningEditor: (openingId: string) => void; /** * Immutable Änderung von Dach-Attributen (Form/Neigung/Überstand/Firstrichtung/ * Dicke) — wirkt NUR auf das selektierte Dach. diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index bccab02..01582bd 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -195,6 +195,8 @@ export interface CeilingInfo { * genau einer Öffnung gesetzt). Anzeigefertige Werte + Wirts-Wand-Bezug. */ export interface OpeningInfo { + /** ID der Öffnung (für den ⚙-Sprung in den Fenster-/Tür-Editor). */ + id: string; kind: "window" | "door"; /** Wirts-Wand (ID + Name/Geschoss zur Anzeige). */ hostWallId: string; @@ -675,6 +677,7 @@ function openingSelection(project: Project, o: Opening): Selection { ? openingVerticalExtent(project, wall, o) : { zBottom: 0, zTop: o.height }; const openingInfo: OpeningInfo = { + id: o.id, kind: o.kind, hostWallId: o.hostWallId, hostWallName: floor ? `${floor.name}` : o.hostWallId, diff --git a/src/styles.css b/src/styles.css index 5586c4e..95f16dd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4714,6 +4714,207 @@ body { border-color: var(--ctx-danger); } +/* ── Fenster-/Tür-Einstellungsdialog (OpeningEditorDialog) ────────────────── + Drei Zonen (Sidebar · Panel · Ansicht) unter einer Stil-Leiste. Nutzt sonst + die imp-*-Klassen (Overlay/Head/Foot). */ +.oed-dialog { + width: min(880px, 96vw); + max-height: 90vh; + display: flex; + flex-direction: column; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow-3); + overflow: hidden; +} +.oed-stylebar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--line); + background: var(--bg); +} +.oed-stylebar-spacer { + flex: 1 1 auto; +} +.oed-style-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-2); +} +.oed-style-select { + min-width: 160px; +} +.oed-main { + display: grid; + grid-template-columns: 150px 1fr 250px; + min-height: 320px; + flex: 1 1 auto; + overflow: hidden; +} +.oed-sidebar { + display: flex; + flex-direction: column; + padding: 8px; + gap: 2px; + border-right: 1px solid var(--line); + overflow-y: auto; +} +.oed-cat { + text-align: left; + font-family: inherit; + font-size: 13px; + padding: 7px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--ink); + cursor: pointer; +} +.oed-cat:hover { + background: var(--panel-2); +} +.oed-cat.active { + background: var(--accent); + color: #fff; +} +.oed-panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px; + overflow-y: auto; +} +.oed-preview { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px; + border-left: 1px solid var(--line); + background: var(--bg); +} +.oed-preview-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-2); +} +.oed-elev { + flex: 1 1 auto; + min-height: 0; + max-height: 320px; +} +.oed-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 13px; +} +.oed-flabel { + color: var(--ink); + flex: 0 0 auto; +} +.oed-input, +.oed-select { + font-family: inherit; + font-size: 13px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--input); + color: var(--ink); + min-width: 120px; +} +.oed-input { + width: 110px; + text-align: right; +} +.oed-select-sm { + min-width: 0; + width: 100%; + padding: 3px 6px; + font-size: 12px; +} +.oed-hint { + font-size: 12px; + color: var(--muted); + line-height: 1.5; +} +.oed-hint-warn { + color: var(--ctx-danger); +} +.oed-check { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--ink); + cursor: pointer; +} +.oed-btn { + font-family: inherit; + font-size: 12px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--input); + color: var(--ink); + cursor: pointer; +} +.oed-btn:hover { + background: var(--panel-2); +} +.oed-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.oed-table th { + text-align: left; + font-weight: 600; + color: var(--muted); + padding: 4px 6px; + border-bottom: 1px solid var(--line); +} +.oed-table td { + padding: 3px 6px; + border-bottom: 1px solid var(--line); +} +.oed-td-num { + color: var(--muted); + width: 20px; +} +.oed-td-dash { + color: var(--muted); +} +.oed-rowbtn { + border: none; + background: transparent; + color: var(--muted); + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} +.oed-rowbtn:hover:not(:disabled) { + color: var(--ctx-danger); +} +.oed-rowbtn:disabled { + opacity: 0.35; + cursor: default; +} +.oed-rowactions { + display: flex; + gap: 8px; + margin-top: 4px; +} + /* ── Einstellungs-Fenster (SettingsDialog): Farbfelder + Akzent-Presets ──── Nutzt sonst dieselben imp-*-Klassen wie die anderen Dialoge. */ .settings-field-label { diff --git a/src/ui/OpeningEditorDialog.tsx b/src/ui/OpeningEditorDialog.tsx new file mode 100644 index 0000000..a531ec3 --- /dev/null +++ b/src/ui/OpeningEditorDialog.tsx @@ -0,0 +1,839 @@ +// Reicher Fenster-/Tür-Einstellungsdialog (VW „Fenster bearbeiten"-Muster, +// Studie docs/design/window-editor-vectorworks-study.md). Drei Zonen: +// 1. Kategorie-Sidebar links, +// 2. Parameter-Panel Mitte, +// 3. Live-2D-Ansicht (Elevation) rechts — direkt aus den aktuellen Werten +// gezeichnet (Rahmen/Flügel/Öffnungslinien/Verglasung/Rollladenkasten). +// Oben eine Stil-Leiste: Stil wählen · „Als Stil speichern…" · zurücksetzen. +// +// Der Dialog editiert TYP-Parameter (der referenzierte WindowType/DoorType = +// wiederverwendbarer Stil, wirkt auf alle Instanzen) UND INSTANZ-Parameter +// (Grösse/Position dieser Öffnung). Bezeichner englisch, UI-Text deutsch. + +import { useEffect, useMemo, useState } from "react"; +import { t } from "../i18n"; +import type { + DoorType, + Opening, + OpeningKind, + Project, + SashDef, + WindowType, +} from "../model/types"; +import { getDoorType, getWindowType, sashesOfWindowType, glazingPanesOf } from "../model/types"; + +/** P0-Kategorien der Sidebar, je nach Öffnungsart. */ +type CatId = + | "basis" + | "groesse" + | "rahmen" + | "fluegel" + | "sonnenschutz" + | "tuerblatt"; + +export interface OpeningEditorDialogProps { + openingId: string; + project: Project; + /** Typ-Feld (aktueller Stil) ändern — wirkt auf alle Instanzen des Stils. */ + onPatchWindowType: (typeId: string, patch: Partial) => void; + onPatchDoorType: (typeId: string, patch: Partial) => void; + /** Instanz-Feld dieser Öffnung ändern. */ + onPatchOpening: (patch: Partial) => void; + /** Öffnung einem anderen Stil zuweisen ("" = keiner). */ + onSetOpeningType: (typeId: string) => void; + /** Aktuellen Stil als NEUEN benannten Stil ablegen und der Öffnung zuweisen. */ + onSaveAsStyle: (name: string) => void; + onClose: () => void; +} + +export function OpeningEditorDialog({ + openingId, + project, + onPatchWindowType, + onPatchDoorType, + onPatchOpening, + onSetOpeningType, + onSaveAsStyle, + onClose, +}: OpeningEditorDialogProps) { + const opening = (project.openings ?? []).find((o) => o.id === openingId); + const isDoor = opening?.kind === "door"; + const wt = opening && !isDoor ? getWindowType(project, opening) : undefined; + const dt = opening && isDoor ? getDoorType(project, opening) : undefined; + + const cats: { id: CatId; label: string }[] = isDoor + ? [ + { id: "basis", label: t("oed.cat.basis") }, + { id: "groesse", label: t("oed.cat.groesse") }, + { id: "rahmen", label: t("oed.cat.rahmen") }, + { id: "tuerblatt", label: t("oed.cat.tuerblatt") }, + ] + : [ + { id: "basis", label: t("oed.cat.basis") }, + { id: "groesse", label: t("oed.cat.groesse") }, + { id: "rahmen", label: t("oed.cat.rahmen") }, + { id: "fluegel", label: t("oed.cat.fluegel") }, + { id: "sonnenschutz", label: t("oed.cat.sonnenschutz") }, + ]; + const [cat, setCat] = useState("basis"); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + if (!opening) { + // Öffnung verschwand (Undo/Löschen während offen) → Dialog schliesst sich. + return null; + } + + const typeList = isDoor ? project.doorTypes ?? [] : project.windowTypes ?? []; + const styleName = + (isDoor ? dt?.name : wt?.name) ?? t("objinfo.type.none"); + + const patchWt = (patch: Partial) => { + if (wt) onPatchWindowType(wt.id, patch); + }; + const patchDt = (patch: Partial) => { + if (dt) onPatchDoorType(dt.id, patch); + }; + + return ( +
+
e.stopPropagation()} + > +
+ + {t(isDoor ? "oed.title.door" : "oed.title.window")} + + +
+ + {/* Stil-Leiste: Stil wählen · als Stil speichern · zurücksetzen. */} +
+ {t("oed.style")} + +
+ +
+ +
+ {/* 1. Kategorie-Sidebar. */} + + + {/* 2. Parameter-Panel. */} +
+ {!wt && !dt && ( +
{t("oed.noStyle")}
+ )} + {cat === "basis" && ( + + )} + {cat === "groesse" && ( + + )} + {cat === "rahmen" && ( + + )} + {cat === "fluegel" && wt && } + {cat === "sonnenschutz" && wt && } + {cat === "tuerblatt" && dt && } +
+ + {/* 3. Live-Ansicht (Elevation). */} +
+
{t("oed.preview")}
+ +
+
+ +
+ +
+
+
+ ); +} + +// ── Parameter-Panels ──────────────────────────────────────────────────────── + +/** Kleines beschriftetes Zahlenfeld (Meter), lokal gepuffert. */ +function NumField({ + label, + value, + step = 0.01, + min, + onCommit, +}: { + label: string; + value: number; + step?: number; + min?: number; + onCommit: (n: number) => void; +}) { + const [text, setText] = useState(String(value)); + useEffect(() => setText(String(value)), [value]); + return ( + + ); +} + +/** Beschriftetes Dropdown. */ +function SelField({ + label, + value, + options, + onChange, +}: { + label: string; + value: V; + options: { value: V; label: string }[]; + onChange: (v: V) => void; +}) { + return ( + + ); +} + +const OPENING_KIND_OPTS = (): { value: OpeningKind; label: string }[] => [ + { value: "dreh", label: t("oed.kind.dreh") }, + { value: "kipp", label: t("oed.kind.kipp") }, + { value: "drehkipp", label: t("oed.kind.drehkipp") }, + { value: "fest", label: t("oed.kind.fest") }, + { value: "schiebe", label: t("oed.kind.schiebe") }, +]; + +type DoorKind = DoorType["kind"]; +const DOOR_KIND_OPTS = (): { value: DoorKind; label: string }[] => [ + { value: "dreh", label: t("oed.kind.dreh") }, + { value: "schiebe", label: t("oed.kind.schiebe") }, + { value: "wandoeffnung", label: t("oed.door.wandoeffnung") }, +]; + +function BasisPanel({ + isDoor, + wt, + dt, + patchWt, + patchDt, +}: { + isDoor: boolean; + wt?: WindowType; + dt?: DoorType; + patchWt: (p: Partial) => void; + patchDt: (p: Partial) => void; +}) { + if (isDoor && dt) { + return ( + patchDt({ kind: v })} + /> + ); + } + if (wt) { + return ( + <> + patchWt({ kind: v })} + /> + patchWt({ glazingPanes: Number(v) as 1 | 2 | 3 })} + /> + patchWt({ insetFace: v })} + /> + patchWt({ insetFromFace: n })} + /> + + ); + } + return null; +} + +function GroessePanel({ + opening, + isDoor, + onPatchOpening, +}: { + opening: Opening; + isDoor: boolean; + onPatchOpening: (p: Partial) => void; +}) { + return ( + <> + onPatchOpening({ width: n })} + /> + onPatchOpening({ height: n })} + /> + {!isDoor && ( + onPatchOpening({ sillHeight: n })} + /> + )} + onPatchOpening({ position: n })} + /> + + ); +} + +function RahmenPanel({ + isDoor, + wt, + dt, + patchWt, + patchDt, +}: { + isDoor: boolean; + wt?: WindowType; + dt?: DoorType; + patchWt: (p: Partial) => void; + patchDt: (p: Partial) => void; +}) { + if (isDoor && dt) { + return ( + <> + patchDt({ frameThickness: n })} + /> + patchDt({ frameWidth: n })} + /> + + ); + } + if (wt) { + return ( + <> + patchWt({ frameThickness: n })} + /> + patchWt({ frameDepth: n || undefined })} + /> + patchWt({ frameWidth: n })} + /> + + ); + } + return null; +} + +/** Flügeleinteilung: Tabelle mit Flügel/Pfosten, Öffnungsart + Anschlag je Zeile. */ +function FluegelPanel({ + wt, + patchWt, +}: { + wt: WindowType; + patchWt: (p: Partial) => void; +}) { + const sashes = sashesOfWindowType(wt); + const setSashes = (next: SashDef[]) => + patchWt({ sashes: next, wingCount: next.filter((s) => s.kind === "fluegel").length || 1 }); + const update = (i: number, patch: Partial) => + setSashes(sashes.map((s, k) => (k === i ? { ...s, ...patch } : s))); + const addSash = (kind: SashDef["kind"]) => + setSashes([ + ...sashes, + kind === "fluegel" + ? { kind, autoWidth: true, opening: "dreh", hingeSide: "left" } + : { kind, postWidth: 0.06 }, + ]); + const remove = (i: number) => setSashes(sashes.filter((_, k) => k !== i)); + + return ( + <> +
{t("oed.fluegel.hint")}
+ + + + + + + + + + + {sashes.map((s, i) => ( + + + + + + + + ))} + +
#{t("oed.fluegel.col.kind")}{t("oed.fluegel.col.opening")}{t("oed.fluegel.col.hinge")} +
{i + 1} + + + {s.kind === "fluegel" ? ( + + ) : ( + + )} + + {s.kind === "fluegel" && s.opening !== "fest" ? ( + + ) : ( + + )} + + +
+
+ + +
+ + ); +} + +function SonnenschutzPanel({ + wt, + patchWt, +}: { + wt: WindowType; + patchWt: (p: Partial) => void; +}) { + const sh = wt.shading; + const box = sh?.box ?? { width: 0, height: 0.24, depth: 0.2 }; + const set = (patch: Partial>) => + patchWt({ + shading: { + create: sh?.create ?? false, + kind: sh?.kind ?? "rollladen", + box, + ...patch, + }, + }); + return ( + <> + + {sh?.create && ( + <> + + set({ kind: v as NonNullable["kind"] }) + } + /> + set({ box: { ...box, height: n } })} + /> + set({ box: { ...box, depth: n } })} + /> + + )} + + ); +} + +function TuerblattPanel({ + dt, + patchDt, +}: { + dt: DoorType; + patchDt: (p: Partial) => void; +}) { + return ( + <> + patchDt({ leafCount: Number(v) as 1 | 2 })} + /> + patchDt({ leafStyle: v as DoorType["leafStyle"] })} + /> + + ); +} + +// ── Live-Ansicht (Elevation, SVG) ─────────────────────────────────────────── + +/** + * Frontal-Ansicht der Öffnung aus den aktuellen Werten: Rahmen, Flügel/Pfosten + * (mit Öffnungslinien je Flügel), Verglasungshinweis und Rollladenkasten. Rein + * aus den Modellwerten gezeichnet — kein Renderer-Roundtrip, deterministisch. + */ +function ElevationPreview({ + opening, + wt, + dt, +}: { + opening: Opening; + wt?: WindowType; + dt?: DoorType; +}) { + const geom = useMemo(() => { + const w = Math.max(0.1, opening.width); + const h = Math.max(0.1, opening.height); + const pad = 24; + const boxH = (!dt && wt?.shading?.create ? wt.shading.box.height : 0) || 0; + // Zeichenfläche auf ~260×300 skalieren, Seitenverhältnis erhalten. + const availW = 220; + const availH = 260; + const totalH = h + boxH; + const scale = Math.min(availW / w, availH / totalH); + const px = (m: number) => m * scale; + return { w, h, boxH, pad, scale, px, totalH }; + }, [opening.width, opening.height, wt, dt]); + + const { w, h, boxH, pad, px } = geom; + const svgW = px(w) + pad * 2; + const svgH = px(h + boxH) + pad * 2; + const frameW = px((!dt ? wt?.frameWidth : dt?.frameWidth) ?? 0.06); + const originY = pad + px(boxH); + + // Flügel-/Pfostenaufteilung: gleichmässig (autoWidth) bzw. feste Breiten. + const sashes = !dt && wt ? sashesOfWindowType(wt) : []; + const fixedTotal = sashes.reduce( + (a, s) => a + (s.kind === "pfosten" ? s.postWidth ?? 0.06 : s.autoWidth ? 0 : s.width ?? 0), + 0, + ); + const autoCount = sashes.filter((s) => s.kind === "fluegel" && (s.autoWidth ?? true)).length; + const autoW = autoCount > 0 ? Math.max(0.05, (w - fixedTotal) / autoCount) : 0; + const panes = !dt && wt ? glazingPanesOf(wt) : 1; + + // Segmentbreiten (Meter) je Sash. + const segW = sashes.map((s) => + s.kind === "pfosten" + ? s.postWidth ?? 0.06 + : s.autoWidth ?? true + ? autoW + : s.width ?? autoW, + ); + + const strokeMain = "var(--ink)"; + const strokeMuted = "var(--muted)"; + const glass = "rgba(120,180,220,0.18)"; + + let cursor = 0; + const segEls: JSX.Element[] = []; + sashes.forEach((s, i) => { + const x0 = pad + px(cursor); + const wSeg = px(segW[i]); + cursor += segW[i]; + if (s.kind === "pfosten") { + segEls.push( + , + ); + return; + } + // Flügelrahmen + Glas + Öffnungslinien. + const gx = x0 + frameW * 0.4; + const gy = originY + frameW * 0.4; + const gw = Math.max(1, wSeg - frameW * 0.8); + const gh = Math.max(1, px(h) - frameW * 0.8); + segEls.push( + + + + {/* Verglasung: zusätzliche parallele Glaslinien = Scheibenzahl − 1. */} + {Array.from({ length: Math.max(0, panes - 1) }, (_, k) => ( + + ))} + {/* Öffnungslinien (Dreieck) je nach Anschlag; nicht bei fest. */} + {s.opening !== "fest" && + (s.hingeSide === "right" ? ( + + ) : ( + + ))} + , + ); + }); + + return ( + + {/* Rollladenkasten über der Öffnung (nur Fenster mit shading.create). */} + {boxH > 0 && ( + + )} + {/* Blendrahmen (Aussenkontur). */} + + {/* Für Türen: einfaches Türblatt statt Flügelraster. */} + {dt ? ( + <> + + {dt.leafCount === 2 && ( + + )} + + ) : ( + segEls + )} + + ); +}