From 8184f559fc5ec3dc89b9194fa62e1a550ed59fcc Mon Sep 17 00:00:00 2001 From: karim Date: Mon, 25 May 2026 03:31:54 +0200 Subject: [PATCH] Symbol-Funktion in Elemente-Panel (Phase S1+S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema (library.py): - Item-Format erweitert: files2d + files3d (Backwards-compat zu 'files') - _build_variant_block + _place_instance + Layer-Routing pro Variante - import_item akzeptiert at_point + layer2d/layer3d - _ensure_block_definition mit variant-Suffix (dossier_lib__2d/_3d) Backend (elemente.py): - _layer_path_symbole(geschoss_name, variant) → ::40_SYMBOLE:: SYMBOLE_2D bzw. SYMBOLE_3D - Default-Ebene 40 SYMBOLE via _find_ebene_sublayer_name - LIST_LIBRARY-Handler: sendet Library-Manifest als LIBRARY_LIST - CREATE_SYMBOL-Handler: interactive GetPoint im aktiven Viewport, laedt Block-Def + platziert Instanz(en) auf den richtigen Ebenen - Pair-Items (2D+3D) werden an gleichem Punkt beidseitig platziert → Top zeigt 2D-Layer, Persp zeigt 3D-Layer wenn User entsprechend Sichtbarkeit setzt Frontend: - SymbolPicker Modal-Component: Grid mit Symbol/Object-Cards, Search, Type-Filter (Alle/Symbole/Objekte), Doppelklick = Pick - Symbol-Button in ElementeApp (PillGroup "Library") oeffnet Modal + triggert listLibrary() fuer aktuelle Items - createSymbol(id) → Backend → GetPoint → Place Co-Authored-By: Claude Opus 4.7 --- rhino/elemente.py | 85 +++++++++++++++ rhino/library.py | 156 ++++++++++++++++++---------- src/ElementeApp.jsx | 29 ++++++ src/components/SymbolPicker.jsx | 177 ++++++++++++++++++++++++++++++++ src/lib/rhinoBridge.js | 3 + 5 files changed, 394 insertions(+), 56 deletions(-) create mode 100644 src/components/SymbolPicker.jsx diff --git a/rhino/elemente.py b/rhino/elemente.py index 05ea173..9788203 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -929,6 +929,20 @@ def _layer_path_raum(doc, geschoss_name): return "{}::{}".format(geschoss_name, sub) +def _layer_path_symbole(doc, geschoss_name, variant): + """Symbol-Layer (Library-Items). variant = '2d' oder '3d'. + Pfad: ::40_SYMBOLE::SYMBOLE_2D bzw. SYMBOLE_3D. + User kann die Top-Level-Ebene 40 frei rein-/raus-schalten — die + Sub-Layer 2D/3D regeln View-spezifische Sichtbarkeit (Top zeigt 2D, + Persp zeigt 3D).""" + sub = _find_ebene_sublayer_name(doc, ["symbol"], + "40", "SYMBOLE", + default_color="#5fa896", default_lw=0.13) + if variant.lower() == "3d": + return "{}::{}::SYMBOLE_3D".format(geschoss_name, sub) + return "{}::{}::SYMBOLE_2D".format(geschoss_name, sub) + + def _ensure_layer(doc, path): """Stellt sicher, dass ein Layer-Pfad existiert. Liefert Layer-Index.""" idx = doc.Layers.FindByFullPath(path, -1) @@ -6030,6 +6044,8 @@ class ElementeBridge(panel_base.BaseBridge): elif t == "CREATE_TRAEGER": self._cmd_create_traeger(p) elif t == "CREATE_RAUM": self._cmd_create_raum(p) elif t == "EXPORT_RAEUME": self._cmd_export_raeume(p) + elif t == "LIST_LIBRARY": self._cmd_list_library(p) + elif t == "CREATE_SYMBOL": self._cmd_create_symbol(p) elif t == "OPEN_SWISSTOPO": self._cmd_open_swisstopo(p) elif t == "IMPORT_SWISSTOPO": self._cmd_import_swisstopo(p) elif t == "OPEN_SWISSTOPO_DIALOG": self._cmd_open_swisstopo_dialog(p) @@ -8071,6 +8087,75 @@ class ElementeBridge(panel_base.BaseBridge): # unter dem aktiven Geschoss. # ------------------------------------------------------------------ + # ------------------------------------------------------------------ + # Library-Symbol/Object — listet + platziert Library-Items im Doc + # ------------------------------------------------------------------ + + def _cmd_list_library(self, p): + """Liefert Library-Manifest ans Frontend (fuer den Symbol-Picker + im Elemente-Panel). Antwort: LIBRARY_LIST message.""" + try: + import library + manifest = library.load_manifest() + self.send("LIBRARY_LIST", { + "items": manifest.get("items", []), + "name": manifest.get("name", "Dossier-Library"), + }) + except Exception as ex: + print("[ELEMENTE] list library:", ex) + self.send("LIBRARY_LIST", {"items": [], "name": ""}) + + def _cmd_create_symbol(self, p): + """Platziert ein Library-Item (symbol/object) im Doc. Interactive + GetPoint im aktiven Viewport — User klickt Position. + Payload: { id: libraryId }. Layer-Routing auf 40_SYMBOLE::2D/3D + unter aktivem Geschoss.""" + lib_id = (p.get("id") or "").strip() + if not lib_id: + print("[ELEMENTE] CREATE_SYMBOL ohne id") + return + doc = Rhino.RhinoDoc.ActiveDoc + if doc is None: return + import library + item = library.find_item(lib_id) + if item is None: + print("[ELEMENTE] CREATE_SYMBOL: item not found", lib_id) + return + if item.get("type") not in ("symbol", "object"): + print("[ELEMENTE] CREATE_SYMBOL: ungueltiger Typ", item.get("type")) + return + # Interactive point pick + try: + import Rhino.Input.Custom as ric + from Rhino.Input import GetResult + except Exception as ex: + print("[ELEMENTE] Imports:", ex); return + try: + gp = ric.GetPoint() + gp.SetCommandPrompt("Symbol platzieren — Punkt waehlen") + res = gp.Get() + if res != GetResult.Point: return + pt = gp.Point() + except Exception as ex: + print("[ELEMENTE] GetPoint:", ex); return + # Layer-Routing + geschoss_id = _active_geschoss_id(doc) + g = _geschoss_by_id(doc, geschoss_id) if geschoss_id else None + geschoss_name = (g.get("name") if g else "EG") or "EG" + layer2d = _ensure_layer(doc, _layer_path_symbole(doc, geschoss_name, "2d")) + layer3d = _ensure_layer(doc, _layer_path_symbole(doc, geschoss_name, "3d")) + # Import + place + undo_serial = doc.BeginUndoRecord( + "Symbol einfuegen: " + (item.get("name") or "")) + try: + ok, msg = library.import_item(doc, lib_id, at_point=pt, + layer2d=layer2d, layer3d=layer3d) + print("[ELEMENTE] CREATE_SYMBOL '{}': ok={} ({})".format( + item.get("name"), ok, msg)) + finally: + try: doc.EndUndoRecord(undo_serial) + except Exception: pass + def _cmd_open_swisstopo(self, p): """Oeffnet map.geo.admin.ch im Default-Browser mit den swisstopo- Layern voreingestellt. Param `mode`: 'buildings' | 'terrain' | 'both'. diff --git a/rhino/library.py b/rhino/library.py index 6d8127d..0d8bbdd 100644 --- a/rhino/library.py +++ b/rhino/library.py @@ -156,6 +156,14 @@ def _empty_manifest(): def _normalize_item(it): if not isinstance(it, dict): return None if not it.get("id") or not it.get("type"): return None + # 2D/3D-Files: ein Item kann beide haben (Objekt mit Plan-Darstellung 2D + # + perspektivischem 3D-Modell) oder nur eines. Legacy 'files'-Feld wird + # als files2d interpretiert (Symbole = nur 2D historisch). + files_legacy = list(it.get("files") or []) + files2d = list(it.get("files2d") or []) + files3d = list(it.get("files3d") or []) + if not files2d and not files3d and files_legacy: + files2d = files_legacy out = { "id": str(it["id"]), "type": str(it["type"]), @@ -164,9 +172,10 @@ def _normalize_item(it): "tags": list(it.get("tags") or []), "preview": it.get("preview"), "data": it.get("data") or {}, - # files: relative Pfade (zur library_root()) auf .3dm-Fragmente. - # Symbol/Object-Import liest die ueber File3dm.Read. - "files": list(it.get("files") or []), + "files2d": files2d, + "files3d": files3d, + # legacy "files" mitgeben fuer Backwards-Kompatibilitaet + "files": files_legacy or files2d, } return out @@ -220,13 +229,14 @@ def _lib_asset_path(rel_path): return os.path.join(library_root(), rel_path) -def _block_name_for(item): +def _block_name_for(item, variant=""): """Stabiler Block-Name fuer InstanceDefinition. Format: - 'dossier_lib_' — Dedup ueber Lib-ID, nicht Item-Name (sonst - Konflikt bei Umbenennen).""" + 'dossier_lib_[_]'. variant '2d'/'3d' fuer Pair-Items.""" lid = item.get("id") or "" safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in lid) - return "dossier_lib_" + safe + name = "dossier_lib_" + safe + if variant: name += "_" + variant + return name def _read_3dm_geometry(abs_path): @@ -256,24 +266,24 @@ def _read_3dm_geometry(abs_path): return [] -def _ensure_block_definition(doc, item, geometry_attrs): +def _ensure_block_definition(doc, item, geometry_attrs, variant=""): """Erstellt InstanceDefinition fuer dieses Library-Item wenn noch nicht - da. Returns (idx, was_created). idx<0 bei Fehler.""" + da. variant unterscheidet '2d'/'3d'. Returns (idx, was_created).""" if Rhino is None: return -1, False - name = _block_name_for(item) + name = _block_name_for(item, variant=variant) try: existing = doc.InstanceDefinitions.Find(name) except Exception: existing = None if existing is not None: return existing.Index, False - # Geometry + Attributes separat sammeln geoms = [g for g, _ in geometry_attrs if g is not None] attrs = [a for _, a in geometry_attrs] if not geoms: return -1, False base_pt = Rhino.Geometry.Point3d(0, 0, 0) - desc = "Dossier-Library: " + (item.get("name") or "") + desc_suffix = " ({})".format(variant.upper()) if variant else "" + desc = "Dossier-Library: " + (item.get("name") or "") + desc_suffix try: idx = doc.InstanceDefinitions.Add(name, desc, base_pt, geoms, attrs) return idx, True @@ -282,52 +292,83 @@ def _ensure_block_definition(doc, item, geometry_attrs): return -1, False -def import_symbol(doc, item): - """Importiert ein Symbol-Item (= 2D-Block) in das Doc. Liest die - .3dm-Datei(en) aus item.files, erstellt eine InstanceDefinition mit - stabilem Namen, fuegt eine Instanz am Ursprung ein. - Returns (ok, message).""" - return _import_block_like(doc, item, kind="symbol") - - -def import_object(doc, item): - """Importiert ein Object-Item (= 3D-Block, BIM-Element) in das Doc. - Gleiche Pipeline wie import_symbol — Symbol/Object unterscheiden sich - nur in der UI-Kategorisierung.""" - return _import_block_like(doc, item, kind="object") - - -def _import_block_like(doc, item, kind): - if doc is None: return False, "Kein aktives Dokument" - files = item.get("files") or [] - if not files: - return False, "Item hat keine .3dm-Files: " + str(item.get("id")) - # Alle Files zusammen in eine Block-Definition packen. +def _build_variant_block(doc, item, variant_files, variant_label): + """Liest .3dm-Files und legt eine InstanceDefinition (variant) an. + Returns Block-Index oder -1.""" + if not variant_files: return -1 all_geom = [] - for f in files: + for f in variant_files: abs_p = _lib_asset_path(f) all_geom.extend(_read_3dm_geometry(abs_p)) if not all_geom: - return False, "Keine importierbare Geometrie in {}".format(files) - idx, created = _ensure_block_definition(doc, item, all_geom) - if idx < 0: - return False, "InstanceDefinition konnte nicht erstellt werden" - # Instanz am Ursprung einfuegen — User kann danach verschieben. + return -1 + idx, _ = _ensure_block_definition(doc, item, all_geom, variant=variant_label) + return idx + + +def _place_instance(doc, block_idx, point, layer_idx=-1): + """Platziert eine InstanceObject am gegebenen Punkt, optional auf + spezifischem Layer. Returns Guid oder None.""" + if block_idx < 0: return None try: - xform = Rhino.Geometry.Transform.Identity - inst_id = doc.Objects.AddInstanceObject(idx, xform) - if inst_id == System_Guid_Empty(): - return False, "AddInstanceObject fehlgeschlagen" - try: doc.Views.Redraw() - except Exception: pass - msg = ("Block '{}' importiert + am Ursprung eingefuegt".format( - item.get("name") or "") - if created else - "Block bereits vorhanden — neue Instanz eingefuegt") - return True, msg + xform = Rhino.Geometry.Transform.Translation( + point.X, point.Y, point.Z) + attrs = Rhino.DocObjects.ObjectAttributes() + if layer_idx >= 0: + attrs.LayerIndex = layer_idx + gid = doc.Objects.AddInstanceObject(block_idx, xform, attrs) + return gid except Exception as ex: - print("[LIBRARY] AddInstanceObject:", ex) - return False, str(ex) + print("[LIBRARY] _place_instance:", ex) + return None + + +def import_symbol(doc, item, at_point=None, layer2d=-1, layer3d=-1): + return _import_block_like(doc, item, at_point=at_point, + layer2d=layer2d, layer3d=layer3d) + + +def import_object(doc, item, at_point=None, layer2d=-1, layer3d=-1): + return _import_block_like(doc, item, at_point=at_point, + layer2d=layer2d, layer3d=layer3d) + + +def _import_block_like(doc, item, at_point=None, layer2d=-1, layer3d=-1): + """Platziert ein Library-Item im Doc. Item kann files2d und/oder files3d + haben → beide Varianten werden geladen + an gleichem Punkt platziert auf + ihren respektiven Layern. + at_point: Point3d (default = Origin). + layer2d/3d: optionale Layer-Indizes (default = aktiver Layer).""" + if doc is None: return False, "Kein aktives Dokument" + files2d = item.get("files2d") or [] + files3d = item.get("files3d") or [] + if not files2d and not files3d: + # Legacy fallback + legacy = item.get("files") or [] + if legacy: files2d = legacy + if not files2d and not files3d: + return False, "Item hat keine .3dm-Files: " + str(item.get("id")) + if at_point is None: + at_point = Rhino.Geometry.Point3d(0, 0, 0) + placed = [] + if files2d: + idx2 = _build_variant_block(doc, item, files2d, "2d") + if idx2 >= 0: + gid = _place_instance(doc, idx2, at_point, layer_idx=layer2d) + if gid is not None and gid != System_Guid_Empty(): + placed.append("2D") + if files3d: + idx3 = _build_variant_block(doc, item, files3d, "3d") + if idx3 >= 0: + gid = _place_instance(doc, idx3, at_point, layer_idx=layer3d) + if gid is not None and gid != System_Guid_Empty(): + placed.append("3D") + if not placed: + return False, "Konnte keinen Block platzieren (Files fehlen?)" + try: doc.Views.Redraw() + except Exception: pass + return True, "{} eingefuegt ({})".format( + item.get("name") or "", "+".join(placed)) def System_Guid_Empty(): @@ -339,16 +380,19 @@ def System_Guid_Empty(): return None -def import_item(doc, item_id): +def import_item(doc, item_id, at_point=None, layer2d=-1, layer3d=-1): """Type-dispatching Import. material → Project-Settings-Liste. - symbol/object → InstanceDefinition im Doc via File3dm.Read.""" + symbol/object → InstanceDefinition im Doc via File3dm.Read. + at_point + layer2d/3d nur fuer symbol/object.""" item = find_item(item_id) if item is None: return False, "Item nicht gefunden: " + str(item_id) t = item.get("type") if t == "material": return import_material(doc, item) if t == "symbol": - return import_symbol(doc, item) + return import_symbol(doc, item, at_point=at_point, + layer2d=layer2d, layer3d=layer3d) if t == "object": - return import_object(doc, item) + return import_object(doc, item, at_point=at_point, + layer2d=layer2d, layer3d=layer3d) return False, "Unbekannter Typ: '{}'".format(t) diff --git a/src/ElementeApp.jsx b/src/ElementeApp.jsx index 1b37951..bfee380 100644 --- a/src/ElementeApp.jsx +++ b/src/ElementeApp.jsx @@ -9,7 +9,9 @@ import { openSwisstopo, openSwisstopoDialog, openOsmDialog, updateElement, deleteElement, openElementeUebersicht, openElementeProperties, saveOeffStyle, deleteOeffStyle, + listLibrary, createSymbol, } from './lib/rhinoBridge' +import SymbolPicker from './components/SymbolPicker' const labelXs = { fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', @@ -327,7 +329,16 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) { const [treppeMenuOpen, setTreppeMenuOpen] = useState(false) const [stuetzeMenuOpen, setStuetzeMenuOpen] = useState(false) const [traegerMenuOpen, setTraegerMenuOpen] = useState(false) + const [symbolPickerOpen, setSymbolPickerOpen] = useState(false) + const [libraryItems, setLibraryItems] = useState([]) const treppeWrapperRef = useRef(null) + + // Library-Items kommen via LIBRARY_LIST message vom Backend nach LIST_LIBRARY. + useEffect(() => { + onMessage('LIBRARY_LIST', ({ items }) => { + if (Array.isArray(items)) setLibraryItems(items) + }) + }, []) const dis = noGeschoss const baseHint = (label) => noGeschoss ? 'Erst im Ebenen-Manager ein Geschoss aktivieren' @@ -477,6 +488,14 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) { onClick={() => createRaum({})} /> + + { listLibrary(); setSymbolPickerOpen(true) }} /> + + openSwisstopo('both')} /> + + {symbolPickerOpen && ( + { + setSymbolPickerOpen(false) + createSymbol(id) + }} + onClose={() => setSymbolPickerOpen(false)} /> + )} ) } diff --git a/src/components/SymbolPicker.jsx b/src/components/SymbolPicker.jsx new file mode 100644 index 0000000..06c1cdc --- /dev/null +++ b/src/components/SymbolPicker.jsx @@ -0,0 +1,177 @@ +import { useState, useMemo } from 'react' +import Icon from './Icon' +import { BarToggle, BarButton, BAR_H } from './BarControls' + +/* SymbolPicker — Modal-Overlay: zeigt Library-Items (symbol/object) als + Grid. Klick auf Item → onPick(id). Schliesst via onClose. */ + +function ItemPreview({ item }) { + // Vorschau: type-spezifisches Icon im Center auf bg-input. + // Spaeter: PNG-Thumbnail aus library/previews/. + const iconName = item.type === 'symbol' ? 'navigation' : 'forest' + return ( +
+ +
+ ) +} + +function ItemCard({ item, onPick }) { + return ( + + ) +} + +export default function SymbolPicker({ items, onPick, onClose }) { + const [search, setSearch] = useState('') + const [typeFilter, setTypeFilter] = useState('all') + + // Nur symbol/object — material wird hier nicht gepicked + const placable = useMemo(() => + (items || []).filter(it => + it.type === 'symbol' || it.type === 'object'), + [items]) + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase() + return placable.filter(it => { + if (typeFilter !== 'all' && it.type !== typeFilter) return false + if (!q) return true + if ((it.name || '').toLowerCase().includes(q)) return true + if ((it.tags || []).some(t => t.toLowerCase().includes(q))) return true + return false + }) + }, [placable, search, typeFilter]) + + return ( +
+
e.stopPropagation()} + style={{ + width: '90%', maxWidth: 640, maxHeight: '80vh', + background: 'var(--bg-dialog)', + border: '1px solid var(--border)', + borderRadius: 12, + display: 'flex', flexDirection: 'column', + overflow: 'hidden', + color: 'var(--text-primary)', + fontFamily: 'var(--font)', fontSize: 11, + boxShadow: '0 8px 32px rgba(0,0,0,0.4)', + }}> + {/* Header */} +
+ + + Symbol / Objekt einfuegen + + +
+ + {/* Toolbar */} +
+ setSearch(ev.target.value)} + placeholder="Suchen (Name oder Tag)…" + autoFocus + style={{ flex: 1, height: BAR_H, padding: '0 12px', + fontSize: 11 }} /> + setTypeFilter('all')} /> + setTypeFilter('symbol')} /> + setTypeFilter('object')} /> +
+ + {/* Grid */} +
+ {filtered.length === 0 ? ( +
+ {placable.length === 0 + ? 'Keine Symbole/Objekte in der Library.' + : 'Nichts gefunden.'} +
+ ) : ( +
+ {filtered.map(it => ( + + ))} +
+ )} +
+ + {/* Footer */} +
+ Klick auf Item → im Viewport Punkt waehlen zum Platzieren. + {filtered.length > 0 && ( + · {filtered.length} / {placable.length} + )} +
+
+
+ ) +} diff --git a/src/lib/rhinoBridge.js b/src/lib/rhinoBridge.js index 9ddf44c..03a2362 100644 --- a/src/lib/rhinoBridge.js +++ b/src/lib/rhinoBridge.js @@ -335,6 +335,9 @@ export function createStuetze(p) { send('CREATE_STUETZE', p || {}) } export function createTraeger(p) { send('CREATE_TRAEGER', p || {}) } export function createRaum(p) { send('CREATE_RAUM', p || {}) } export function exportRaeume() { send('EXPORT_RAEUME', {}) } +// Library-Symbol/Object — Picker im Elemente-Panel +export function listLibrary() { send('LIST_LIBRARY', {}) } +export function createSymbol(id) { send('CREATE_SYMBOL', { id }) } export function openSwisstopo(mode) { send('OPEN_SWISSTOPO', { mode: mode || 'both' }) } export function importSwisstopo(kind) { send('IMPORT_SWISSTOPO', { kind: kind || 'buildings' }) } export function openSwisstopoDialog() { send('OPEN_SWISSTOPO_DIALOG', {}) }