8184f559fc
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_<id>_2d/_3d) Backend (elemente.py): - _layer_path_symbole(geschoss_name, variant) → <geschoss>::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 <noreply@anthropic.com>
399 lines
14 KiB
Python
399 lines
14 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 _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)
|