From c993935b17a816ded6617bd122f182bc78f6cb0e Mon Sep 17 00:00:00 2001 From: karim Date: Mon, 25 May 2026 03:58:41 +0200 Subject: [PATCH] Symbole-Tab in Project-Settings (Library-Item-Management) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project-Settings hat jetzt 5 Tabs. Neuer 'Symbole'-Tab managt die Dossier-Library: List/Detail wie Materialien, mit 2D + 3D Slot pro Item. Backend (library.py): - save_manifest, update_item, delete_item, add_item — full CRUD aufs library.json - copy_to_assets: kopiert User-Dateien in library/assets/ mit Konflikt-Resolution (auto-suffix) Backend (rhinopanel.py / ProjectSettingsBridge): - _send_library: aktuelle Items + libraryRoot an Frontend - _add_library_file: File-Picker (.3dm direkt; .dwg/.obj/etc. zeigt Hinweis fuer kuenftige Konvertierung), kopiert + appended ans Item (variant 2d/3d) oder erstellt neues Item - _update_library_item: patch by id - _delete_library_item: entfernt Eintrag aus Manifest - LIBRARY_ITEMS + LIBRARY_ERROR Messages ans Frontend Frontend: - Neuer 'Symbole'-Tab mit List/Detail - Liste: Name, Type-Icon, '2D'/'3D' Status-Badge - Detail rechts: Name-Edit (live persist on blur), Type-Toggle (Symbol/Objekt), 2D/3D-File-Slots mit Datei-Picker, Tags-Editor - 'Neues Objekt' Button im Listen-Footer Co-Authored-By: Claude Opus 4.7 --- rhino/library.py | 91 ++++++++++ rhino/rhinopanel.py | 131 +++++++++++++++ src/components/ProjectSettingsDialog.jsx | 201 +++++++++++++++++++++++ src/lib/rhinoBridge.js | 13 ++ 4 files changed, 436 insertions(+) diff --git a/rhino/library.py b/rhino/library.py index 0d8bbdd..71a3dd6 100644 --- a/rhino/library.py +++ b/rhino/library.py @@ -148,6 +148,97 @@ def load_manifest(): return _empty_manifest() +def save_manifest(manifest): + """Schreibt das Manifest zurueck zur library.json. Items werden + normalisiert. Returns True/False.""" + ensure_library() + path = os.path.join(library_root(), _MANIFEST_FN) + try: + if not isinstance(manifest, dict): manifest = _empty_manifest() + manifest.setdefault("schemaVersion", _SCHEMA_VERSION) + manifest.setdefault("name", "Dossier-Library") + items = manifest.get("items") or [] + manifest["items"] = [_normalize_item(x) for x in items + if _normalize_item(x) is not None] + with open(path, "w") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + return True + except Exception as ex: + print("[LIBRARY] save_manifest:", ex) + return False + + +def update_item(item_id, patch): + """Patcht ein Item im Manifest. Returns (ok, new_manifest).""" + m = load_manifest() + items = m.get("items", []) + found = False + for it in items: + if it.get("id") == item_id: + for k, v in (patch or {}).items(): + it[k] = v + found = True + break + if not found: return False, m + ok = save_manifest(m) + return ok, load_manifest() + + +def delete_item(item_id): + """Loescht ein Item aus dem Manifest. Asset-Files bleiben auf Disk + (User koennte sie noch wollen).""" + m = load_manifest() + items = m.get("items", []) + new_items = [it for it in items if it.get("id") != item_id] + if len(new_items) == len(items): return False, m + m["items"] = new_items + ok = save_manifest(m) + return ok, load_manifest() + + +def add_item(item): + """Fuegt ein neues Item zum Manifest hinzu. Returns (ok, new_manifest).""" + norm = _normalize_item(item) + if norm is None: return False, load_manifest() + m = load_manifest() + items = m.get("items", []) + # Dedupe per id + items = [it for it in items if it.get("id") != norm["id"]] + items.append(norm) + m["items"] = items + ok = save_manifest(m) + return ok, load_manifest() + + +def copy_to_assets(src_path, target_name=None): + """Kopiert eine Datei in library/assets/. target_name optional (sonst + Original-Name). Returns relativer Pfad ('assets/foo.3dm') oder None.""" + if not src_path or not os.path.isfile(src_path): + return None + ensure_library() + assets_dir = os.path.join(library_root(), "assets") + if not os.path.isdir(assets_dir): + try: os.makedirs(assets_dir) + except Exception: pass + base = target_name or os.path.basename(src_path) + target = os.path.join(assets_dir, base) + # Konflikt-Resolution: bei doppeltem Namen Nummer dran + if os.path.isfile(target): + stem, ext = os.path.splitext(base) + n = 2 + while os.path.isfile(os.path.join(assets_dir, "{}_{}{}".format(stem, n, ext))): + n += 1 + target = os.path.join(assets_dir, "{}_{}{}".format(stem, n, ext)) + try: + import shutil + shutil.copy2(src_path, target) + rel = os.path.relpath(target, library_root()) + return rel + except Exception as ex: + print("[LIBRARY] copy_to_assets:", ex) + return None + + def _empty_manifest(): return {"schemaVersion": _SCHEMA_VERSION, "name": "Dossier-Library", "items": []} diff --git a/rhino/rhinopanel.py b/rhino/rhinopanel.py index 9bd98b7..2d575fc 100644 --- a/rhino/rhinopanel.py +++ b/rhino/rhinopanel.py @@ -888,13 +888,25 @@ class EbenenBridge(panel_base.BaseBridge): }) except Exception: built_in = [] + # Library-Items initial laden (Symbole/Objekte fuer den + # Symbole-Tab; Materialien sind separat). + try: + import library + lib_manifest = library.load_manifest() + lib_items = lib_manifest.get("items", []) + lib_root = library.library_root() + except Exception: + lib_items = []; lib_root = "" params = { "defaults": current.get("defaults", {}), + "project": current.get("project", {}), "materials": current.get("materials", []), "builtinMaterials": built_in, "hatchPatterns": _hatch_pattern_names(doc), "hatchPatternsFull": _list_hatch_patterns_full(doc), "linetypes": _list_linetypes_full(doc), + "libraryItems": lib_items, + "libraryRoot": lib_root, } def on_save(updated): doc2 = Rhino.RhinoDoc.ActiveDoc @@ -992,6 +1004,14 @@ class EbenenBridge(panel_base.BaseBridge): self._delete_hatch(p) elif t == "IMPORT_HATCH_FILE": self._import_hatch_file() + elif t == "LIST_LIBRARY_ITEMS": + self._send_library() + elif t == "ADD_LIBRARY_FILE": + self._add_library_file(p) + elif t == "UPDATE_LIBRARY_ITEM": + self._update_library_item(p) + elif t == "DELETE_LIBRARY_ITEM": + self._delete_library_item(p) def _pick_texture(self, payload): slot = payload.get("slot") or "diffuse" try: @@ -1157,6 +1177,117 @@ class EbenenBridge(panel_base.BaseBridge): "linetypes": _list_linetypes_full(d), "hatchPatternsFull": _list_hatch_patterns_full(d), }) + + # ---- Library-Item CRUD ---- + def _send_library(self): + """Sendet aktuelle Library-Items ans Frontend + (LIBRARY_ITEMS-Message).""" + try: + import library + m = library.load_manifest() + self.send("LIBRARY_ITEMS", { + "items": m.get("items", []), + "libraryRoot": library.library_root(), + }) + except Exception as ex: + print("[PROJECT-SETTINGS] _send_library:", ex) + + def _add_library_file(self, payload): + """Datei-Picker → kopieren in library/assets/ → Item zum + Manifest hinzufuegen. Payload: {variant: '2d'|'3d', + targetId: ?, itemType: 'symbol'|'object'}. + Wenn targetId gesetzt: bestehendes Item updaten (Datei + wird seinem files2d/3d zugewiesen). Sonst: neues Item.""" + try: + import library + import Eto.Forms as forms + variant = (payload.get("variant") or "2d").lower() + if variant not in ("2d", "3d"): variant = "2d" + target_id = (payload.get("targetId") or "").strip() or None + item_type = payload.get("itemType") or "object" + if item_type not in ("symbol", "object"): + item_type = "object" + dlg = forms.OpenFileDialog() + dlg.Title = ("Datei waehlen ({})".format(variant.upper())) + dlg.MultiSelect = False + dlg.Filters.Add(forms.FileFilter( + "Rhino 3DM", ".3dm")) + dlg.Filters.Add(forms.FileFilter( + "Andere CAD-Formate", ".dwg", ".obj", ".fbx", ".dae", ".stl")) + dlg.Filters.Add(forms.FileFilter("Alle", ".*")) + parent = bridge_holder.get("form") + res = dlg.ShowDialog(parent) if parent else dlg.ShowDialog(None) + if str(res) != "Ok": + return + path = dlg.FileName or "" + if not path: return + ext = (path.split(".")[-1] if "." in path else "").lower() + if ext != "3dm": + # TODO: konvertieren via temporaerer RhinoDoc-Import + # Phase 1: nur .3dm direkt unterstuetzt + print("[PROJECT-SETTINGS] Format '.{}' wird in dieser " + "Version noch nicht konvertiert — bitte in Rhino " + "oeffnen + als .3dm speichern".format(ext)) + self.send("LIBRARY_ERROR", { + "msg": "Format .{} noch nicht unterstuetzt. " + "Konvertiere in Rhino zu .3dm.".format(ext), + }) + return + rel = library.copy_to_assets(path) + if not rel: + print("[PROJECT-SETTINGS] copy_to_assets failed") + return + if target_id: + # Bestehendes Item updaten + m = library.load_manifest() + for it in m.get("items", []): + if it.get("id") == target_id: + key = "files" + variant + it[key] = [rel] + if not it.get("type"): it["type"] = item_type + break + library.save_manifest(m) + else: + # Neues Item + import os + base = os.path.basename(path) + stem = os.path.splitext(base)[0] + import uuid as _uuid + new_id = "obj-" + _uuid.uuid4().hex[:10] + item = { + "id": new_id, + "type": item_type, + "name": stem, + "version": 1, + "tags": [], + "files" + variant: [rel], + } + library.add_item(item) + self._send_library() + except Exception as ex: + print("[PROJECT-SETTINGS] _add_library_file:", ex) + + def _update_library_item(self, payload): + """Patcht ein Item per id. payload: {id, patch: {...}}""" + try: + import library + item_id = (payload.get("id") or "").strip() + patch = payload.get("patch") or {} + if not item_id or not isinstance(patch, dict): return + library.update_item(item_id, patch) + self._send_library() + except Exception as ex: + print("[PROJECT-SETTINGS] _update_library_item:", ex) + + def _delete_library_item(self, payload): + try: + import library + item_id = (payload.get("id") or "").strip() + if not item_id: return + library.delete_item(item_id) + self._send_library() + except Exception as ex: + print("[PROJECT-SETTINGS] _delete_library_item:", ex) b = _ProjectSettingsBridge() bridge_holder["form"] = panel_base.open_satellite_window( "project_settings", diff --git a/src/components/ProjectSettingsDialog.jsx b/src/components/ProjectSettingsDialog.jsx index 58d7bfc..023c611 100644 --- a/src/components/ProjectSettingsDialog.jsx +++ b/src/components/ProjectSettingsDialog.jsx @@ -5,6 +5,7 @@ import { openLibrary, pickTextureFile, onMessage, renameLinetype, deleteLinetype, loadLinetypeDefaults, importLinetypeFile, renameHatch, deleteHatch, importHatchFile, + listLibraryItems, addLibraryFile, updateLibraryItem, deleteLibraryItem, } from '../lib/rhinoBridge' /* Field — Stack-Layout fuer komplexe Inputs (mehrere Felder nebeneinander) */ @@ -638,6 +639,9 @@ export default function ProjectSettingsDialog({ const [hatches, setHatches] = useState(initial.hatchPatternsFull || []) const [selLt, setSelLt] = useState(null) const [selHt, setSelHt] = useState(null) + const [libItems, setLibItems] = useState(initial.libraryItems || []) + const [libRoot, setLibRoot] = useState(initial.libraryRoot || '') + const [selLib, setSelLib] = useState(null) const builtin = initial.builtinMaterials || [] // Aktuell ausgewaehltes Material aus Selection ableiten @@ -668,6 +672,13 @@ export default function ProjectSettingsDialog({ if (lts) setLinetypes(lts) if (hps) setHatches(hps) }) + onMessage('LIBRARY_ITEMS', ({ items, libraryRoot }) => { + if (Array.isArray(items)) setLibItems(items) + if (libraryRoot) setLibRoot(libraryRoot) + }) + onMessage('LIBRARY_ERROR', ({ msg }) => { + if (msg) alert(msg) + }) }, []) // Backend-File-Picker-Antwort: aktualisiert das Slot im aktuell @@ -741,6 +752,7 @@ export default function ProjectSettingsDialog({ { key: 'materials', label: 'Materialien' }, { key: 'linetypes', label: 'Linientypen' }, { key: 'hatches', label: 'Schraffuren' }, + { key: 'symbols', label: 'Symbole' }, ]} active={tab} onChange={setTab} /> {/* Body */} @@ -1074,6 +1086,195 @@ export default function ProjectSettingsDialog({ )} + + {tab === 'symbols' && ( +
+ {/* Links: Items-Liste */} +
+
+ {libItems.length === 0 && ( +
+ Noch keine Symbole/Objekte. +
+ Erstes Item via ⬇ unten hinzufügen. +
+
+ )} + {libItems.map((it) => { + const isSel = selLib === it.id + return ( +
setSelLib(it.id)} + style={{ + display: 'grid', + gridTemplateColumns: '20px 1fr', + alignItems: 'center', gap: 6, + padding: '5px 10px', + cursor: 'pointer', + background: isSel ? 'var(--accent-dim)' : 'transparent', + borderLeft: '2px solid ' + (isSel ? 'var(--accent)' : 'transparent'), + transition: 'background 0.12s', + }} + onMouseEnter={(e) => { + if (!isSel) e.currentTarget.style.background = 'var(--bg-item-hover)' + }} + onMouseLeave={(e) => { + if (!isSel) e.currentTarget.style.background = 'transparent' + }}> + +
+
+ {it.name || 'Unbenannt'} +
+
+ {(it.files2d || []).length > 0 ? '2D ' : ''} + {(it.files3d || []).length > 0 ? '3D' : ''} + {!(it.files2d || []).length && !(it.files3d || []).length && '— leer'} +
+
+
+ ) + })} +
+
+ addLibraryFile('2d', null, 'object')} + title="Datei waehlen, kopiert in Library + neues Item" /> +
+
+ + {/* Rechts: Item-Detail */} +
+ {(() => { + const it = libItems.find(x => x.id === selLib) + if (!it) return ( +
+ Item links auswählen oder neues anlegen. +
+ ) + return ( + <> + +
+ + { + const nm = ev.target.value + setLibItems(arr => arr.map(x => + x.id === it.id ? { ...x, name: nm } : x)) + }} + onBlur={(ev) => updateLibraryItem(it.id, + { name: ev.target.value })} + style={{ flex: 1, height: BAR_H, padding: '0 12px', + fontSize: 12, fontWeight: 500 }} /> + { + deleteLibraryItem(it.id) + setSelLib(null) + }} + title="Item löschen" /> +
+
+ updateLibraryItem(it.id, + { type: 'symbol' })} /> + updateLibraryItem(it.id, + { type: 'object' })} /> +
+
+ + + {(it.files2d || []).length === 0 ? ( +
+ Keine 2D-Datei zugewiesen. +
+ ) : ( +
+ {(it.files2d || []).join(', ')} +
+ )} + addLibraryFile('2d', it.id, it.type)} /> +
+ + + {(it.files3d || []).length === 0 ? ( +
+ Keine 3D-Datei zugewiesen. +
+ ) : ( +
+ {(it.files3d || []).join(', ')} +
+ )} + addLibraryFile('3d', it.id, it.type)} /> +
+ + + { + const tg = ev.target.value + setLibItems(arr => arr.map(x => + x.id === it.id ? { + ...x, tags: tg.split(',').map(s => s.trim()).filter(Boolean) + } : x)) + }} + onBlur={(ev) => updateLibraryItem(it.id, { + tags: ev.target.value.split(',') + .map(s => s.trim()).filter(Boolean) + })} + style={{ width: '100%', height: BAR_H, + padding: '0 12px', fontSize: 11, + boxSizing: 'border-box' }} /> + + +
+ ID: {it.id}
+ Library: {libRoot} +
+ + ) + })()} +
+
+ )} {/* Footer — Pill-Buttons */} diff --git a/src/lib/rhinoBridge.js b/src/lib/rhinoBridge.js index 03a2362..482a764 100644 --- a/src/lib/rhinoBridge.js +++ b/src/lib/rhinoBridge.js @@ -204,6 +204,19 @@ export function importLinetypeFile() { send('IMPORT_LINETYPE_FILE', {}) export function renameHatch(index, name) { send('RENAME_HATCH', { index, name }) } export function deleteHatch(index) { send('DELETE_HATCH', { index }) } export function importHatchFile() { send('IMPORT_HATCH_FILE', {}) } +// Library-Item-CRUD (Project-Settings → Symbole-Tab) +export function listLibraryItems() { send('LIST_LIBRARY_ITEMS', {}) } +export function addLibraryFile(variant, targetId, itemType) { + send('ADD_LIBRARY_FILE', { + variant: variant || '2d', + targetId: targetId || null, + itemType: itemType || 'object', + }) +} +export function updateLibraryItem(id, patch) { + send('UPDATE_LIBRARY_ITEM', { id, patch }) +} +export function deleteLibraryItem(id) { send('DELETE_LIBRARY_ITEM', { id }) } // Schnitt/Ansicht — interaktiver 2-Punkt-Pick im Rhino-Viewport. Erzeugt // eine neue Zeichnungsebene type=schnitt + 2D-Plan-Symbol + aktiviert sie. // opts: { cutAtLine: bool, depthBack: m, heightMin: m, heightMax: m, namePrefix }