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:
+3
-2
@@ -8096,9 +8096,10 @@ class ElementeBridge(panel_base.BaseBridge):
|
||||
Library-Items + Handling von PICK (User waehlt Item → CREATE_SYMBOL
|
||||
im aktiven Doc) und CANCEL (Fenster schliessen)."""
|
||||
try:
|
||||
import library
|
||||
import library, rhinopanel
|
||||
manifest = library.load_manifest()
|
||||
items = manifest.get("items", [])
|
||||
items = rhinopanel._enrich_library_items_with_previews(
|
||||
manifest.get("items", []))
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] list library:", ex)
|
||||
items = []
|
||||
|
||||
@@ -210,6 +210,137 @@ def add_item(item):
|
||||
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):
|
||||
"""Konvertiert eine beliebige CAD-Datei (.dwg/.obj/.fbx/.dae/.stl/...)
|
||||
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)
|
||||
return None
|
||||
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
|
||||
for o in new_objs:
|
||||
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)
|
||||
return None
|
||||
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))
|
||||
return rel
|
||||
except Exception as ex:
|
||||
|
||||
+38
-8
@@ -147,6 +147,24 @@ def _list_hatch_patterns_full(doc):
|
||||
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):
|
||||
"""Vollstaendiges Linetype-Listing fuer die Verwaltungs-UI.
|
||||
Mac Rhino 8 GetSegment(i) returnt (length: float, isLine: bool):
|
||||
@@ -893,7 +911,8 @@ class EbenenBridge(panel_base.BaseBridge):
|
||||
try:
|
||||
import library
|
||||
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()
|
||||
except Exception:
|
||||
lib_items = []; lib_root = ""
|
||||
@@ -1183,12 +1202,15 @@ class EbenenBridge(panel_base.BaseBridge):
|
||||
# ---- Library-Item CRUD ----
|
||||
def _send_library(self):
|
||||
"""Sendet aktuelle Library-Items ans Frontend
|
||||
(LIBRARY_ITEMS-Message)."""
|
||||
(LIBRARY_ITEMS-Message). Items kommen mit
|
||||
previewDataUri (base64 PNG) wenn Vorschau vorhanden."""
|
||||
try:
|
||||
import library
|
||||
m = library.load_manifest()
|
||||
enriched = _enrich_library_items_with_previews(
|
||||
m.get("items", []))
|
||||
self.send("LIBRARY_ITEMS", {
|
||||
"items": m.get("items", []),
|
||||
"items": enriched,
|
||||
"libraryRoot": library.library_root(),
|
||||
})
|
||||
except Exception as ex:
|
||||
@@ -1247,18 +1269,21 @@ class EbenenBridge(panel_base.BaseBridge):
|
||||
self.send("LIBRARY_ERROR", {
|
||||
"msg": "Konnte Datei nicht importieren — siehe Log."})
|
||||
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:
|
||||
# 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
|
||||
if has_preview: it["preview"] = preview_rel
|
||||
break
|
||||
library.save_manifest(m)
|
||||
else:
|
||||
# Neues Item — stem aus dem Pfad oben bereits berechnet
|
||||
import uuid as _uuid
|
||||
new_id = "obj-" + _uuid.uuid4().hex[:10]
|
||||
item = {
|
||||
@@ -1269,6 +1294,7 @@ class EbenenBridge(panel_base.BaseBridge):
|
||||
"tags": [],
|
||||
"files" + variant: [rel],
|
||||
}
|
||||
if has_preview: item["preview"] = preview_rel
|
||||
library.add_item(item)
|
||||
self._send_library()
|
||||
except Exception as ex:
|
||||
@@ -1343,10 +1369,13 @@ class EbenenBridge(panel_base.BaseBridge):
|
||||
self.send("LIBRARY_ERROR", {
|
||||
"msg": "Konnte Selection nicht speichern."})
|
||||
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:
|
||||
library.update_item(target_id, {
|
||||
("files" + variant): [rel],
|
||||
})
|
||||
patch = {("files" + variant): [rel]}
|
||||
if has_preview: patch["preview"] = preview_rel
|
||||
library.update_item(target_id, patch)
|
||||
else:
|
||||
import uuid as _uuid
|
||||
new_id = "obj-" + _uuid.uuid4().hex[:10]
|
||||
@@ -1358,6 +1387,7 @@ class EbenenBridge(panel_base.BaseBridge):
|
||||
"tags": [],
|
||||
("files" + variant): [rel],
|
||||
}
|
||||
if has_preview: item["preview"] = preview_rel
|
||||
library.add_item(item)
|
||||
self._send_library()
|
||||
b = _ProjectSettingsBridge()
|
||||
|
||||
@@ -1115,8 +1115,8 @@ export default function ProjectSettingsDialog({
|
||||
onClick={() => setSelLib(it.id)}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '20px 1fr',
|
||||
alignItems: 'center', gap: 6,
|
||||
gridTemplateColumns: '32px 1fr',
|
||||
alignItems: 'center', gap: 8,
|
||||
padding: '5px 10px',
|
||||
cursor: 'pointer',
|
||||
background: isSel ? 'var(--accent-dim)' : 'transparent',
|
||||
@@ -1129,9 +1129,21 @@ export default function ProjectSettingsDialog({
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSel) e.currentTarget.style.background = 'transparent'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 4,
|
||||
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={13}
|
||||
size={14}
|
||||
style={{ color: 'var(--text-muted)' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11,
|
||||
color: 'var(--text-primary)',
|
||||
@@ -1177,10 +1189,22 @@ export default function ProjectSettingsDialog({
|
||||
return (
|
||||
<>
|
||||
<DetailSection title="Identität">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 6,
|
||||
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={28}
|
||||
size={22}
|
||||
style={{ color: 'var(--accent)' }} />
|
||||
)}
|
||||
</div>
|
||||
<input type="text" value={it.name || ''}
|
||||
onChange={(ev) => {
|
||||
const nm = ev.target.value
|
||||
|
||||
@@ -6,17 +6,29 @@ import { BarToggle, BarButton, BAR_H } from './BarControls'
|
||||
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/.
|
||||
// Thumbnail wenn vorhanden, sonst type-spezifisches Icon
|
||||
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'
|
||||
return (
|
||||
<div style={{
|
||||
height: 56,
|
||||
height: 80,
|
||||
background: 'var(--bg-input)',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Icon name={iconName} size={24}
|
||||
<Icon name={iconName} size={28}
|
||||
style={{ color: 'var(--text-muted)' }} />
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user