Library-Thumbnails: Auto-Capture + Base64-Preview in der UI

Beim Hinzufuegen oder Importieren eines Library-Items wird automatisch
ein PNG-Thumbnail vom Item generiert (Top-View, 128x128) und in
library/previews/<id>.png abgelegt. Frontend rendert die Previews als
Base64-Data-URIs (sicher gegen WebKit-file://-Restriktionen).

library.py:
- _previews_dir(): legt previews/-Folder an
- _preview_rel_for(asset_rel): predictable PNG-Pfad pro Item
- _capture_thumbnail_of_objects(): hided andere Objekte temporaer,
  switcht auf Top-Parallel, ZoomBoundingBox, CaptureToBitmap → PNG,
  restored Viewport + Hidden-State
- read_preview_data_uri(): liest PNG + encoded als data:image/png;base64
- Hook in convert_to_3dm_via_import (vor Cleanup) + save_selection_to_asset

rhinopanel.py:
- _enrich_library_items_with_previews(): haengt previewDataUri an
  jedes Item das ein preview-Feld hat
- Initial-Params + _send_library + ElementeBridge._cmd_list_library
  liefern angereicherte Items
- _add_library_file + _save_selection_as_library setzen preview-Pfad
  im Item wenn Thumbnail-Datei existiert

Frontend:
- SymbolPicker.ItemPreview: rendert <div backgroundImage> mit Base64-URI
  wenn vorhanden, sonst Icon-Fallback
- ProjectSettingsDialog Symbole-Tab: List-Row + Detail-Identity zeigen
  Thumbnail (32px in Liste, 56px im Detail), Icon nur als Fallback

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 19:07:33 +02:00
parent 827bd8d4d7
commit e1b63aa4e6
5 changed files with 236 additions and 23 deletions
+3 -2
View File
@@ -8096,9 +8096,10 @@ class ElementeBridge(panel_base.BaseBridge):
Library-Items + Handling von PICK (User waehlt Item CREATE_SYMBOL Library-Items + Handling von PICK (User waehlt Item CREATE_SYMBOL
im aktiven Doc) und CANCEL (Fenster schliessen).""" im aktiven Doc) und CANCEL (Fenster schliessen)."""
try: try:
import library import library, rhinopanel
manifest = library.load_manifest() manifest = library.load_manifest()
items = manifest.get("items", []) items = rhinopanel._enrich_library_items_with_previews(
manifest.get("items", []))
except Exception as ex: except Exception as ex:
print("[ELEMENTE] list library:", ex) print("[ELEMENTE] list library:", ex)
items = [] items = []
+146
View File
@@ -210,6 +210,137 @@ def add_item(item):
return ok, load_manifest() return ok, load_manifest()
def _previews_dir():
"""Pfad zum previews/-Subfolder. Wird angelegt falls fehlt."""
p = os.path.join(library_root(), "previews")
if not os.path.isdir(p):
try: os.makedirs(p)
except Exception: pass
return p
def _preview_rel_for(asset_rel_or_id):
"""Erzeugt einen relativen Preview-Pfad fuer ein Asset (oder Item-ID).
Liefert z.B. 'previews/<name>.png'. Wir benutzen den Stamm des
.3dm-Files damit Asset + Preview gleichen Namen haben (debuggbar)."""
stem = asset_rel_or_id
if "/" in stem: stem = stem.split("/")[-1]
if "\\" in stem: stem = stem.split("\\")[-1]
if stem.lower().endswith(".3dm"): stem = stem[:-4]
safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in stem)
return "previews/" + safe + ".png"
def _capture_thumbnail_of_objects(target_objects, png_abs_path, size=128):
"""Captured einen Top-View der gegebenen Objekte als PNG. Hided
temporaer alle anderen Objekte damit der Background sauber ist.
Returns True/False."""
if Rhino is None: return False
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None or not target_objects: return False
try:
from System.Drawing import Size
import scriptcontext as sc
except Exception:
return False
# IDs der Ziel-Objekte
target_ids = set()
for o in target_objects:
try:
if not o.IsDeleted: target_ids.add(str(o.Id))
except Exception: pass
if not target_ids: return False
# Andere Objekte temporaer ausblenden
hidden_by_us = []
try:
for o in list(doc.Objects):
try:
if o.IsDeleted: continue
if str(o.Id) in target_ids: continue
if o.IsHidden: continue
if not o.IsNormal: continue # bereits hidden/locked → skip
doc.Objects.Hide(o.Id, True)
hidden_by_us.append(o.Id)
except Exception: pass
except Exception: pass
capture_ok = False
try:
view = doc.Views.ActiveView
if view is None:
return False
vp = view.ActiveViewport
# Viewport-State sichern damit User nichts verliert
saved_target = vp.CameraTarget
saved_loc = vp.CameraLocation
saved_proj = vp.IsParallelProjection
try:
# Auf Top-Parallel wechseln + Zoom auf Ziel
vp.SetProjection(Rhino.Display.DefinedViewportProjection.Top, "Top", True)
try:
bbox = Rhino.Geometry.BoundingBox.Empty
for o in target_objects:
g = o.Geometry
if g is None: continue
try:
bb = g.GetBoundingBox(True)
if bb.IsValid: bbox.Union(bb)
except Exception: pass
if bbox.IsValid:
# Etwas Padding
bbox.Inflate(bbox.Diagonal.Length * 0.1)
vp.ZoomBoundingBox(bbox)
except Exception as ex:
print("[LIBRARY] thumbnail zoom:", ex)
view.Redraw()
# Capture
try:
bmp = view.CaptureToBitmap(Size(int(size), int(size)))
if bmp is not None:
# Sicherstellen dass Verzeichnis da ist
try:
d = os.path.dirname(png_abs_path)
if d and not os.path.isdir(d): os.makedirs(d)
except Exception: pass
bmp.Save(png_abs_path)
capture_ok = True
except Exception as ex:
print("[LIBRARY] thumbnail capture:", ex)
finally:
# Viewport wiederherstellen
try:
vp.SetCameraLocation(saved_loc, False)
vp.SetCameraTarget(saved_target, True)
if saved_proj:
vp.IsParallelProjection = True
else:
vp.IsParallelProjection = False
except Exception: pass
finally:
# Hidden Objekte wieder einblenden
for gid in hidden_by_us:
try: doc.Objects.Show(gid, True)
except Exception: pass
try: doc.Views.Redraw()
except Exception: pass
return capture_ok
def read_preview_data_uri(rel_path):
"""Liest die PNG-Vorschau als data:image/png;base64-URI fuer
direktes Einsetzen in <img src='...'>. Liefert None wenn Datei fehlt."""
if not rel_path: return None
abs_p = os.path.join(library_root(), rel_path)
if not os.path.isfile(abs_p): return None
try:
import base64
with open(abs_p, "rb") as f:
data = f.read()
return "data:image/png;base64," + base64.b64encode(data).decode("ascii")
except Exception as ex:
print("[LIBRARY] read_preview:", ex)
return None
def convert_to_3dm_via_import(src_path, target_name): def convert_to_3dm_via_import(src_path, target_name):
"""Konvertiert eine beliebige CAD-Datei (.dwg/.obj/.fbx/.dae/.stl/...) """Konvertiert eine beliebige CAD-Datei (.dwg/.obj/.fbx/.dae/.stl/...)
nach .3dm. Strategie: Rhinos _-Import in den aktiven Doc, dann die nach .3dm. Strategie: Rhinos _-Import in den aktiven Doc, dann die
@@ -321,6 +452,14 @@ def convert_to_3dm_via_import(src_path, target_name):
print("[LIBRARY] convert_to_3dm File3dm:", ex) print("[LIBRARY] convert_to_3dm File3dm:", ex)
return None return None
finally: finally:
# Thumbnail-Capture BEVOR die Objekte geloescht werden.
try:
rel_for_preview = os.path.relpath(target, library_root())
preview_rel = _preview_rel_for(rel_for_preview)
preview_abs = os.path.join(library_root(), preview_rel)
_capture_thumbnail_of_objects(new_objs, preview_abs)
except Exception as ex:
print("[LIBRARY] convert thumbnail:", ex)
# Cleanup: importierte Objekte wieder loeschen # Cleanup: importierte Objekte wieder loeschen
for o in new_objs: for o in new_objs:
try: doc.Objects.Delete(o.Id, True) try: doc.Objects.Delete(o.Id, True)
@@ -419,6 +558,13 @@ def save_selection_to_asset(doc, target_name):
print("[LIBRARY] save_selection write:", ex) print("[LIBRARY] save_selection write:", ex)
return None return None
rel = os.path.relpath(target, library_root()) rel = os.path.relpath(target, library_root())
# Thumbnail aus den (noch selektierten) Objekten capturen
try:
preview_rel = _preview_rel_for(rel)
preview_abs = os.path.join(library_root(), preview_rel)
_capture_thumbnail_of_objects(sel, preview_abs)
except Exception as ex:
print("[LIBRARY] save_selection thumbnail:", ex)
print("[LIBRARY] save_selection: {} objs → {}".format(len(geoms), rel)) print("[LIBRARY] save_selection: {} objs → {}".format(len(geoms), rel))
return rel return rel
except Exception as ex: except Exception as ex:
+38 -8
View File
@@ -147,6 +147,24 @@ def _list_hatch_patterns_full(doc):
return out return out
def _enrich_library_items_with_previews(items):
"""Liefert Liste mit zusaetzlichem 'previewDataUri'-Feld pro Item
(base64-PNG falls preview-Datei existiert). Items werden NICHT
persistiert — nur fuer den Transport ans Frontend angereichert."""
import library
out = []
for it in (items or []):
if not isinstance(it, dict):
out.append(it); continue
enriched = dict(it)
prev = it.get("preview")
if prev:
uri = library.read_preview_data_uri(prev)
if uri: enriched["previewDataUri"] = uri
out.append(enriched)
return out
def _list_linetypes_full(doc): def _list_linetypes_full(doc):
"""Vollstaendiges Linetype-Listing fuer die Verwaltungs-UI. """Vollstaendiges Linetype-Listing fuer die Verwaltungs-UI.
Mac Rhino 8 GetSegment(i) returnt (length: float, isLine: bool): Mac Rhino 8 GetSegment(i) returnt (length: float, isLine: bool):
@@ -893,7 +911,8 @@ class EbenenBridge(panel_base.BaseBridge):
try: try:
import library import library
lib_manifest = library.load_manifest() lib_manifest = library.load_manifest()
lib_items = lib_manifest.get("items", []) lib_items = _enrich_library_items_with_previews(
lib_manifest.get("items", []))
lib_root = library.library_root() lib_root = library.library_root()
except Exception: except Exception:
lib_items = []; lib_root = "" lib_items = []; lib_root = ""
@@ -1183,12 +1202,15 @@ class EbenenBridge(panel_base.BaseBridge):
# ---- Library-Item CRUD ---- # ---- Library-Item CRUD ----
def _send_library(self): def _send_library(self):
"""Sendet aktuelle Library-Items ans Frontend """Sendet aktuelle Library-Items ans Frontend
(LIBRARY_ITEMS-Message).""" (LIBRARY_ITEMS-Message). Items kommen mit
previewDataUri (base64 PNG) wenn Vorschau vorhanden."""
try: try:
import library import library
m = library.load_manifest() m = library.load_manifest()
enriched = _enrich_library_items_with_previews(
m.get("items", []))
self.send("LIBRARY_ITEMS", { self.send("LIBRARY_ITEMS", {
"items": m.get("items", []), "items": enriched,
"libraryRoot": library.library_root(), "libraryRoot": library.library_root(),
}) })
except Exception as ex: except Exception as ex:
@@ -1247,18 +1269,21 @@ class EbenenBridge(panel_base.BaseBridge):
self.send("LIBRARY_ERROR", { self.send("LIBRARY_ERROR", {
"msg": "Konnte Datei nicht importieren — siehe Log."}) "msg": "Konnte Datei nicht importieren — siehe Log."})
return return
# Preview-Pfad falls Thumbnail erzeugt wurde
preview_rel = library._preview_rel_for(rel)
preview_abs = os.path.join(library.library_root(), preview_rel)
has_preview = os.path.isfile(preview_abs)
if target_id: if target_id:
# Bestehendes Item updaten
m = library.load_manifest() m = library.load_manifest()
for it in m.get("items", []): for it in m.get("items", []):
if it.get("id") == target_id: if it.get("id") == target_id:
key = "files" + variant key = "files" + variant
it[key] = [rel] it[key] = [rel]
if not it.get("type"): it["type"] = item_type if not it.get("type"): it["type"] = item_type
if has_preview: it["preview"] = preview_rel
break break
library.save_manifest(m) library.save_manifest(m)
else: else:
# Neues Item — stem aus dem Pfad oben bereits berechnet
import uuid as _uuid import uuid as _uuid
new_id = "obj-" + _uuid.uuid4().hex[:10] new_id = "obj-" + _uuid.uuid4().hex[:10]
item = { item = {
@@ -1269,6 +1294,7 @@ class EbenenBridge(panel_base.BaseBridge):
"tags": [], "tags": [],
"files" + variant: [rel], "files" + variant: [rel],
} }
if has_preview: item["preview"] = preview_rel
library.add_item(item) library.add_item(item)
self._send_library() self._send_library()
except Exception as ex: except Exception as ex:
@@ -1343,10 +1369,13 @@ class EbenenBridge(panel_base.BaseBridge):
self.send("LIBRARY_ERROR", { self.send("LIBRARY_ERROR", {
"msg": "Konnte Selection nicht speichern."}) "msg": "Konnte Selection nicht speichern."})
return return
preview_rel = library._preview_rel_for(rel)
preview_abs = os.path.join(library.library_root(), preview_rel)
has_preview = os.path.isfile(preview_abs)
if target_id: if target_id:
library.update_item(target_id, { patch = {("files" + variant): [rel]}
("files" + variant): [rel], if has_preview: patch["preview"] = preview_rel
}) library.update_item(target_id, patch)
else: else:
import uuid as _uuid import uuid as _uuid
new_id = "obj-" + _uuid.uuid4().hex[:10] new_id = "obj-" + _uuid.uuid4().hex[:10]
@@ -1358,6 +1387,7 @@ class EbenenBridge(panel_base.BaseBridge):
"tags": [], "tags": [],
("files" + variant): [rel], ("files" + variant): [rel],
} }
if has_preview: item["preview"] = preview_rel
library.add_item(item) library.add_item(item)
self._send_library() self._send_library()
b = _ProjectSettingsBridge() b = _ProjectSettingsBridge()
+33 -9
View File
@@ -1115,8 +1115,8 @@ export default function ProjectSettingsDialog({
onClick={() => setSelLib(it.id)} onClick={() => setSelLib(it.id)}
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '20px 1fr', gridTemplateColumns: '32px 1fr',
alignItems: 'center', gap: 6, alignItems: 'center', gap: 8,
padding: '5px 10px', padding: '5px 10px',
cursor: 'pointer', cursor: 'pointer',
background: isSel ? 'var(--accent-dim)' : 'transparent', background: isSel ? 'var(--accent-dim)' : 'transparent',
@@ -1129,9 +1129,21 @@ export default function ProjectSettingsDialog({
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (!isSel) e.currentTarget.style.background = 'transparent' if (!isSel) e.currentTarget.style.background = 'transparent'
}}> }}>
<Icon name={it.type === 'symbol' ? 'navigation' : 'forest'} <div style={{
size={13} width: 32, height: 32, borderRadius: 4,
style={{ color: 'var(--text-muted)' }} /> background: it.previewDataUri
? `url("${it.previewDataUri}") center/contain no-repeat, var(--bg-input)`
: 'var(--bg-input)',
border: '1px solid var(--border-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{!it.previewDataUri && (
<Icon name={it.type === 'symbol' ? 'navigation' : 'forest'}
size={14}
style={{ color: 'var(--text-muted)' }} />
)}
</div>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ fontSize: 11, <div style={{ fontSize: 11,
color: 'var(--text-primary)', color: 'var(--text-primary)',
@@ -1177,10 +1189,22 @@ export default function ProjectSettingsDialog({
return ( return (
<> <>
<DetailSection title="Identität"> <DetailSection title="Identität">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Icon name={it.type === 'symbol' ? 'navigation' : 'forest'} <div style={{
size={28} width: 56, height: 56, borderRadius: 6,
style={{ color: 'var(--accent)' }} /> background: it.previewDataUri
? `url("${it.previewDataUri}") center/contain no-repeat, var(--bg-input)`
: 'var(--bg-input)',
border: '1px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{!it.previewDataUri && (
<Icon name={it.type === 'symbol' ? 'navigation' : 'forest'}
size={22}
style={{ color: 'var(--accent)' }} />
)}
</div>
<input type="text" value={it.name || ''} <input type="text" value={it.name || ''}
onChange={(ev) => { onChange={(ev) => {
const nm = ev.target.value const nm = ev.target.value
+16 -4
View File
@@ -6,17 +6,29 @@ import { BarToggle, BarButton, BAR_H } from './BarControls'
Grid. Klick auf Item → onPick(id). Schliesst via onClose. */ Grid. Klick auf Item → onPick(id). Schliesst via onClose. */
function ItemPreview({ item }) { function ItemPreview({ item }) {
// Vorschau: type-spezifisches Icon im Center auf bg-input. // Thumbnail wenn vorhanden, sonst type-spezifisches Icon
// Spaeter: PNG-Thumbnail aus library/previews/. if (item.previewDataUri) {
return (
<div style={{
height: 80,
background: 'var(--bg-input)',
borderBottom: '1px solid var(--border-light)',
backgroundImage: `url("${item.previewDataUri}")`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}} />
)
}
const iconName = item.type === 'symbol' ? 'navigation' : 'forest' const iconName = item.type === 'symbol' ? 'navigation' : 'forest'
return ( return (
<div style={{ <div style={{
height: 56, height: 80,
background: 'var(--bg-input)', background: 'var(--bg-input)',
borderBottom: '1px solid var(--border-light)', borderBottom: '1px solid var(--border-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
<Icon name={iconName} size={24} <Icon name={iconName} size={28}
style={{ color: 'var(--text-muted)' }} /> style={{ color: 'var(--text-muted)' }} />
</div> </div>
) )