Files
karim e1b63aa4e6 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>
2026-05-25 19:07:33 +02:00

852 lines
32 KiB
Python

#! python3
# -*- coding: utf-8 -*-
"""
library.py — Dossier-Library (Phase A: lokal, read-only)
Library = wiederverwendbare Material-/Symbol-/Object-Templates die ein User
oder ein Team teilt. Phase A: lokaler Ordner mit library.json + Assets.
Spaeter (Phase B/C): Cloud-Sync via GitHub-Releases.
Library-Root: ~/Library/Application Support/Dossier/library/
Struktur:
library/
library.json # Manifest (Schema-Version + items[])
previews/ # PNG-Thumbnails
assets/ # .3dm-Fragmente fuer symbol/object
Manifest-Item:
{
"id": "mat-beton-roh-v1", # global eindeutig (UUID/Slug)
"type": "material", # material | symbol | object
"name": "Beton roh",
"version": 1, # Schema-Version des Items
"tags": ["beton", "tragwerk"],
"preview": "previews/mat-beton-roh.png",
"data": { ... } # Typ-spezifische Felder
}
Material-data: { color, hatch, scale }
Symbol/Object-data: { files: ["assets/sym-foo.3dm"] }
"""
import os
import json
# Lazy Import von Rhino — library.py soll auch in einem Test-Skript ohne
# Rhino-Kontext importierbar sein (z.B. fuer Seed-Logik).
try:
import Rhino # noqa: F401
except Exception:
Rhino = None
_LIB_DIRNAME = "library"
_MANIFEST_FN = "library.json"
_SCHEMA_VERSION = 1
def library_root():
"""Mac-Pfad zur lokalen Library. Wird bei Bedarf angelegt."""
base = os.path.expanduser(
"~/Library/Application Support/Dossier")
return os.path.join(base, _LIB_DIRNAME)
def ensure_library():
"""Legt Library-Folder + Sub-Folders + Seed-Manifest an wenn leer."""
root = library_root()
if not os.path.isdir(root):
try: os.makedirs(root)
except Exception as ex:
print("[LIBRARY] mkdir:", ex); return root
for sub in ("previews", "assets"):
p = os.path.join(root, sub)
if not os.path.isdir(p):
try: os.makedirs(p)
except Exception: pass
manifest_path = os.path.join(root, _MANIFEST_FN)
if not os.path.isfile(manifest_path):
_write_seed_manifest(manifest_path)
return root
def _write_seed_manifest(path):
"""Bootstrappt 4 Materialien + 2 Symbol/Object-Beispiel-Eintraege als
Start. Die Symbol/Object-Files muss der User selber ablegen unter
library/assets/ — sonst schlaegt der Import fehl (graceful)."""
seed = {
"schemaVersion": _SCHEMA_VERSION,
"name": "Dossier-Library (lokal)",
"items": [
{
"id": "mat-beton-sichtbeton-v1",
"type": "material", "version": 1,
"name": "Beton — Sichtbeton",
"tags": ["beton", "tragwerk", "roh"],
"data": {"color": "#a8a39b"},
},
{
"id": "mat-mauerwerk-backstein-v1",
"type": "material", "version": 1,
"name": "Mauerwerk — Backstein",
"tags": ["mauerwerk", "stein"],
"data": {"color": "#a45a3c"},
},
{
"id": "mat-daemmung-mineralwolle-v1",
"type": "material", "version": 1,
"name": "Daemmung — Mineralwolle",
"tags": ["daemmung", "weich"],
"data": {"color": "#e8d36b"},
},
{
"id": "mat-holz-fichte-v1",
"type": "material", "version": 1,
"name": "Holz — Fichte",
"tags": ["holz", "ausbau"],
"data": {"color": "#c8a06a"},
},
{
"id": "sym-nordpfeil-01",
"type": "symbol", "version": 1,
"name": "Nordpfeil",
"tags": ["plan", "nordpfeil"],
"files": ["assets/sym-nordpfeil-01.3dm"],
},
{
"id": "obj-baum-laubbaum-01",
"type": "object", "version": 1,
"name": "Baum — Laubbaum",
"tags": ["aussen", "vegetation"],
"files": ["assets/obj-baum-laubbaum-01.3dm"],
},
],
}
try:
with open(path, "w") as f:
json.dump(seed, f, indent=2, ensure_ascii=False)
print("[LIBRARY] Seed geschrieben:", path)
except Exception as ex:
print("[LIBRARY] seed write:", ex)
def load_manifest():
"""Liest library.json — legt Library bei Bedarf an. Returns dict mit
schemaVersion + name + items[]. Bei Fehler leeres Manifest."""
ensure_library()
path = os.path.join(library_root(), _MANIFEST_FN)
try:
with open(path, "r") as f:
data = json.load(f)
if not isinstance(data, dict): return _empty_manifest()
items = data.get("items")
if not isinstance(items, list): data["items"] = []
else: data["items"] = [_normalize_item(x) for x in items
if _normalize_item(x) is not None]
return data
except Exception as ex:
print("[LIBRARY] load_manifest:", ex)
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 _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
NEU hinzugekommenen Objekte als File3dm in library/assets/<name>.3dm
speichern + aus dem Doc wieder loeschen. Returns relativer Pfad oder
None.
WARNUNG: User-Doc wird kurz veraendert (Objects in/out) — wir
delete'n alles wieder am Ende. Setzt einen sticky-Flag damit unsere
eigenen Listener nichts cascaden."""
if not src_path or not os.path.isfile(src_path): return None
if Rhino is None: return None
import scriptcontext as sc
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: 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
safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in target_name)
if not safe.endswith(".3dm"): safe += ".3dm"
target = os.path.join(assets_dir, safe)
if os.path.isfile(target):
stem, ext = os.path.splitext(safe)
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))
# Listener stilllegen: unsere Add-/Delete-Cascade soll bei diesem
# temporaeren Import nicht greifen (Objekte haben keine DOSSIER-
# UserStrings, kommen aber trotzdem durch unsere Schnellfilter).
sc.sticky["dossier_library_import_busy"] = True
sc.sticky["dossier_swisstopo_busy"] = True # blockt schon viele Listener
# Snapshot der existierenden Object-IDs
before_ids = set()
try:
for o in doc.Objects:
try:
if not o.IsDeleted: before_ids.add(str(o.Id))
except Exception: pass
except Exception: pass
# User-Selection sichern damit wir sie am Ende restoren
sel_before_ids = []
try:
for o in doc.Objects.GetSelectedObjects(False, False):
sel_before_ids.append(o.Id)
except Exception: pass
new_objs = []
try:
try: doc.Objects.UnselectAll()
except Exception: pass
# _-Import dash-prefix = scripted, kein UI-Dialog. Pfad in Quotes
# damit Spaces nicht splitten. _Enter beendet die Optionen.
cmd = '_-Import "' + src_path + '" _Enter _Enter'
try:
Rhino.RhinoApp.RunScript(cmd, False)
except Exception as ex:
print("[LIBRARY] convert_to_3dm RunScript:", ex)
# Sammle die NEU hinzugekommenen Objekte
try:
for o in doc.Objects:
try:
if o.IsDeleted: continue
if str(o.Id) not in before_ids:
new_objs.append(o)
except Exception: pass
except Exception: pass
if not new_objs:
print("[LIBRARY] convert_to_3dm: Import lieferte keine Objekte")
return None
# In File3dm packen
try:
from Rhino.FileIO import File3dm
import Rhino.Geometry as rg
bbox = rg.BoundingBox.Empty
geoms_attrs = []
for o in new_objs:
g = o.Geometry
if g is None: continue
try:
bb = g.GetBoundingBox(True)
if bb.IsValid: bbox.Union(bb)
except Exception: pass
geoms_attrs.append((g, o.Attributes))
if bbox.IsValid:
offset = rg.Transform.Translation(
-bbox.Min.X, -bbox.Min.Y, -bbox.Min.Z)
else:
offset = rg.Transform.Identity
f3 = File3dm()
for g, a in geoms_attrs:
try:
g2 = g.Duplicate()
try: g2.Transform(offset)
except Exception: pass
try: f3.Objects.Add(g2, a)
except Exception:
if isinstance(g2, rg.Brep): f3.Objects.AddBrep(g2)
elif isinstance(g2, rg.Curve): f3.Objects.AddCurve(g2)
elif isinstance(g2, rg.Mesh): f3.Objects.AddMesh(g2)
except Exception as ex:
print("[LIBRARY] convert add geom:", ex)
try: f3.Write(target, 8)
except Exception as ex:
print("[LIBRARY] convert_to_3dm Write:", ex)
return None
except Exception as ex:
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)
except Exception: pass
# Restore Selection
try:
for gid in sel_before_ids:
try: doc.Objects.Select(gid, True)
except Exception: pass
except Exception: pass
rel = os.path.relpath(target, library_root())
print("[LIBRARY] convert_to_3dm OK: {}{}".format(src_path, rel))
return rel
finally:
sc.sticky["dossier_library_import_busy"] = False
sc.sticky["dossier_swisstopo_busy"] = False
try: doc.Views.Redraw()
except Exception: pass
def save_selection_to_asset(doc, target_name):
"""Speichert die aktuelle Selection aus dem Doc als eigene .3dm-Datei
in library/assets/<target_name>.3dm. Returns relativer Pfad oder None.
Geometry wird relativ zum BoundingBox-Min platziert damit der Block-
Origin am Ursprung sitzt — sinnvoll fuer Symbol-Insert."""
if doc is None or not target_name: return None
try:
sel = list(doc.Objects.GetSelectedObjects(False, False))
except Exception:
sel = []
if not sel:
print("[LIBRARY] save_selection: keine Auswahl")
return None
if Rhino is None: 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
# Safe filename
safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in target_name)
if not safe.endswith(".3dm"): safe += ".3dm"
target = os.path.join(assets_dir, safe)
if os.path.isfile(target):
stem, ext = os.path.splitext(safe)
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))
# File3dm aufbauen + Selection rein
try:
from Rhino.FileIO import File3dm, File3dmWriteOptions
import Rhino.Geometry as rg
# BoundingBox sammeln um geometrie an Ursprung zu verschieben
bbox = rg.BoundingBox.Empty
geoms = []
for o in sel:
g = o.Geometry
if g is None: continue
try:
bb = g.GetBoundingBox(True)
if bb.IsValid: bbox.Union(bb)
except Exception: pass
geoms.append((g, o.Attributes))
if not geoms:
return None
if not bbox.IsValid:
origin = rg.Point3d(0, 0, 0)
else:
origin = bbox.Min
offset = rg.Transform.Translation(-origin.X, -origin.Y, -origin.Z)
f3 = File3dm()
for g, a in geoms:
try:
g2 = g.Duplicate()
try: g2.Transform(offset)
except Exception: pass
# Generischer Add fuer alle GeometryBase
try: f3.Objects.Add(g2, a)
except Exception:
# Fallback: typ-spezifisch
if isinstance(g2, rg.Brep): f3.Objects.AddBrep(g2)
elif isinstance(g2, rg.Curve): f3.Objects.AddCurve(g2)
elif isinstance(g2, rg.Mesh): f3.Objects.AddMesh(g2)
elif isinstance(g2, rg.Point): f3.Objects.AddPoint(g2.Location)
except Exception as ex:
print("[LIBRARY] save_selection add:", ex)
# Write
try:
opts = File3dmWriteOptions()
opts.Version = 8
f3.Write(target, opts)
except Exception:
try: f3.Write(target, 8)
except Exception as ex:
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:
print("[LIBRARY] save_selection:", ex)
return None
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": []}
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"]),
"name": str(it.get("name") or "Unbenannt"),
"version": int(it.get("version") or 1),
"tags": list(it.get("tags") or []),
"preview": it.get("preview"),
"data": it.get("data") or {},
"files2d": files2d,
"files3d": files3d,
# legacy "files" mitgeben fuer Backwards-Kompatibilitaet
"files": files_legacy or files2d,
}
return out
def find_item(item_id):
"""Sucht ein Item per id im Manifest. Returns None wenn nicht da."""
if not item_id: return None
for it in load_manifest().get("items", []):
if it.get("id") == item_id: return it
return None
# --- Import-Logik -----------------------------------------------------------
def import_material(doc, item):
"""Importiert ein Material-Item in die Projekt-Settings des Doc. Dedupe
per libraryId — wenn schon importiert, kein Doppel-Eintrag.
Returns (ok, message)."""
if doc is None: return False, "Kein aktives Dokument"
if not isinstance(item, dict) or item.get("type") != "material":
return False, "Item ist kein Material"
data = item.get("data") or {}
new_mat = {
"name": item.get("name") or "Unbenannt",
"color": data.get("color", "#888888"),
"source": "library",
"libraryId": item.get("id"),
}
# PBR + Textur-Felder, falls Library-Item welche hat
for k in ("roughness", "reflection", "transparency", "iorN",
"uvScaleM", "textures"):
if k in data: new_mat[k] = data[k]
# Lazy-Import um Zyklen zu vermeiden
import rhinopanel
settings = rhinopanel.load_project_settings(doc)
mats = list(settings.get("materials", []))
for m in mats:
if m.get("libraryId") == new_mat["libraryId"]:
return False, "Material bereits importiert"
mats.append(new_mat)
settings["materials"] = mats
rhinopanel.save_project_settings(doc, settings)
return True, "Material importiert"
# --- Symbol/Object-Import via File3dm + InstanceDefinitions ----------------
def _lib_asset_path(rel_path):
"""Absoluter Pfad zu einer Library-Asset-Datei (Eintrag aus item.files)."""
if not rel_path: return None
return os.path.join(library_root(), rel_path)
def _block_name_for(item, variant=""):
"""Stabiler Block-Name fuer InstanceDefinition. Format:
'dossier_lib_<libraryId>[_<variant>]'. 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)
name = "dossier_lib_" + safe
if variant: name += "_" + variant
return name
def _read_3dm_geometry(abs_path):
"""Liest alle Top-Level-Objekte aus einer .3dm-Datei. Returns Liste von
(GeometryBase, ObjectAttributes). Bei Fehler leere Liste."""
if not abs_path or not os.path.isfile(abs_path):
print("[LIBRARY] _read_3dm: Datei nicht gefunden:", abs_path)
return []
try:
from Rhino.FileIO import File3dm
f3 = File3dm.Read(abs_path)
if f3 is None:
print("[LIBRARY] _read_3dm: File3dm.Read returned None:", abs_path)
return []
out = []
for obj in f3.Objects:
try:
g = obj.Geometry
a = obj.Attributes.Duplicate() if obj.Attributes else None
if g is not None:
out.append((g.Duplicate(), a))
except Exception as ex:
print("[LIBRARY] _read_3dm obj:", ex)
return out
except Exception as ex:
print("[LIBRARY] _read_3dm:", ex)
return []
def _ensure_block_definition(doc, item, geometry_attrs, variant=""):
"""Erstellt InstanceDefinition fuer dieses Library-Item wenn noch nicht
da. variant unterscheidet '2d'/'3d'. Returns (idx, was_created)."""
if Rhino is None: return -1, False
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
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_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
except Exception as ex:
print("[LIBRARY] InstanceDef Add:", ex)
return -1, False
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 variant_files:
abs_p = _lib_asset_path(f)
all_geom.extend(_read_3dm_geometry(abs_p))
if not all_geom:
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.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] _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():
"""Helper — System.Guid.Empty Vergleichswert."""
try:
import System
return System.Guid.Empty
except Exception:
return None
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.
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, at_point=at_point,
layer2d=layer2d, layer3d=layer3d)
if t == "object":
return import_object(doc, item, at_point=at_point,
layer2d=layer2d, layer3d=layer3d)
return False, "Unbekannter Typ: '{}'".format(t)