#! python 3 # -*- coding: utf-8 -*- """ panel_base.py Geteilte Infrastruktur fuer dockbare Rhino-Panels mit React-WebView. Wird von rhinopanel.py (EBENEN) und gestaltung.py (GESTALTUNG) verwendet. """ import os import re import json import Rhino import Rhino.UI as RhinoUI import Eto.Forms as forms import Eto.Drawing as drawing import System import scriptcontext as sc _HERE = os.path.dirname(os.path.abspath(__file__)) _DIST = os.path.join(_HERE, "..", "dist", "index.html") # --- Legacy-Migration: traite_* / pause_* -> dossier_* ---------------------- # # Historisch hatte das Plugin nacheinander die Praefixe "traite_" und "pause_" # bevor es zu "dossier_" wurde. doc.Strings, Layer-UserStrings und # Object-UserStrings werden einmalig pro Doc nach "dossier_*" kopiert. # Idempotent — bestehende dossier_*-Werte werden nicht ueberschrieben. _LEGACY_DOC_KEYS = ( "zeichnungsebenen", "ebenen", "active_id", "active_code", "ausschnitte", "ausschnitt_folders", "layer_presets", "user_scale", "dpi", "show_lineweights", "plotweight_orig", "hatch_scale_orig", "clipping_plane", ) _LEGACY_LAYER_USER_KEYS = ("id", "code") _LEGACY_OBJECT_USER_KEYS = ("clipping_plane", "plotweight_orig", "hatch_scale_orig") _LEGACY_PREFIXES = ("traite_", "pause_") _MIGRATE_FLAG = "dossier_migrated_v2" # neuer Flag — laeuft auch auf Docs die nur traite->pause hatten def migrate_to_dossier(doc): """Migriert einmalig pro Document alle traite_*- und pause_*-Keys zu dossier_*. No-op wenn schon migriert (per doc.Strings-Flag erkannt).""" if doc is None: return try: if doc.Strings.GetValue(_MIGRATE_FLAG): return except Exception: return moved_ds = 0 # 1) doc.Strings try: for suffix in _LEGACY_DOC_KEYS: new = "dossier_" + suffix try: if doc.Strings.GetValue(new): continue # Dossier-Variante vorhanden -> nicht ueberschreiben for prefix in _LEGACY_PREFIXES: old_v = doc.Strings.GetValue(prefix + suffix) if old_v: doc.Strings.SetString(new, old_v) moved_ds += 1 break except Exception: pass except Exception as ex: print("[DOSSIER] migrate doc.Strings:", ex) # 2) Layer-UserStrings moved_layer = 0 try: for layer in doc.Layers: if layer is None or layer.IsDeleted: continue for suffix in _LEGACY_LAYER_USER_KEYS: try: if layer.GetUserString("dossier_" + suffix): continue for prefix in _LEGACY_PREFIXES: v = layer.GetUserString(prefix + suffix) if v: layer.SetUserString("dossier_" + suffix, v) moved_layer += 1 break except Exception: pass except Exception as ex: print("[DOSSIER] migrate layers:", ex) # 3) Object-UserStrings moved_obj = 0 try: for obj in doc.Objects: if obj is None or obj.IsDeleted: continue attrs = obj.Attributes need_apply = False a2 = None for suffix in _LEGACY_OBJECT_USER_KEYS: try: if attrs.GetUserString("dossier_" + suffix): continue for prefix in _LEGACY_PREFIXES: v = attrs.GetUserString(prefix + suffix) if v: if a2 is None: a2 = attrs.Duplicate() a2.SetUserString("dossier_" + suffix, v) need_apply = True moved_obj += 1 break except Exception: pass if need_apply and a2 is not None: try: doc.Objects.ModifyAttributes(obj, a2, True) except Exception: pass except Exception as ex: print("[DOSSIER] migrate objects:", ex) # Flag setzen damit die Migration nicht erneut laeuft try: doc.Strings.SetString(_MIGRATE_FLAG, "1") except Exception: pass if moved_ds or moved_layer or moved_obj: print("[DOSSIER] Migration: doc.Strings={} Layer-UserStrings={} Object-UserStrings={}".format( moved_ds, moved_layer, moved_obj)) # --- Bridge ----------------------------------------------------------------- class BaseBridge(object): """ Basis-Bridge mit Chunk-Zusammenbau. Subklassen ueberschreiben handle(data) und optional _on_ready(). """ def __init__(self, mode): self._wv = None self._mode = mode self._chunks = {} self._chunk_total = 0 def set_webview(self, wv): self._wv = wv def handle_raw(self, raw_str): if not raw_str: return try: data = json.loads(raw_str) except Exception as ex: print("[{}] JSON-Fehler: {}".format(self._mode.upper(), ex)) return if not isinstance(data, dict): return if "_chunk" in data: c = data["_chunk"] self._chunks[int(c["i"])] = data["d"] self._chunk_total = int(c["n"]) if len(self._chunks) == self._chunk_total: full = "".join(self._chunks[k] for k in sorted(self._chunks.keys())) self._chunks = {} self._chunk_total = 0 try: self.handle(json.loads(full)) except Exception as ex: import traceback print("[{}] Chunk-Reassembly: {}".format(self._mode.upper(), ex)) print("[{}] Traceback:\n{}".format(self._mode.upper(), traceback.format_exc())) else: self.handle(data) def handle(self, data): """Override. Default behandelt nur READY.""" if not isinstance(data, dict): return if data.get("type") == "READY": self._on_ready() def _on_ready(self): """Subklasse sendet hier den initialen State.""" pass def send(self, msg_type, payload=None): if self._wv is None: return # ensure_ascii=False umgeht Rhinos buggy json/encoder.py # (s.decode('utf-8') auf .NET-Strings mit Umlauten -> CP1252-Codec-Fehler) data = json.dumps({"type": msg_type, "payload": payload or {}}, ensure_ascii=False) try: self._wv.ExecuteScript( "if(window.onRhinoMessage)window.onRhinoMessage({});".format(data) ) except Exception: pass # --- HTML laden ------------------------------------------------------------- def load_inline(wv, mode, params=None): """Laedt dist/index.html inline und injiziert window.PANEL_MODE. `params` (optional dict): wird als `window.PANEL_PARAMS` injiziert. Wird von Satelliten-Fenstern (z.B. Settings-Dialoge) verwendet um initial- State an die React-App zu uebergeben.""" if not os.path.exists(_DIST): print("[{}] dist nicht gefunden".format(mode.upper())) return dist_dir = os.path.dirname(_DIST) with open(_DIST, "rb") as f: html = f.read().decode("utf-8") parts = ['') mode_script = ''.join(parts) if "" in html: html = html.replace("", mode_script + "") else: html = mode_script + html def inline_css(m): p = os.path.join(dist_dir, m.group(1).lstrip("./").replace("/", os.sep)) with open(p, "rb") as f2: return u"" def inline_js(m): p = os.path.join(dist_dir, m.group(1).lstrip("./").replace("/", os.sep)) with open(p, "rb") as f2: content = f2.read().decode("utf-8") return (u'') html = re.sub(r']+href="(\./assets/[^"]+\.css)"[^>]*/?>', inline_css, html) html = re.sub(r']+src="(\./assets/[^"]+\.js)"[^>]*>', inline_js, html) wv.LoadHtml(html) def attach_webview(panel, bridge, mode): wv = forms.WebView() bridge.set_webview(wv) panel.Content = wv def on_title(s, e): title = e.Title or "" if not title.startswith("RHINOMSG::"): return try: bridge.handle_raw(title[10:]) except Exception as ex: print("[{}] Message-Fehler: {}".format(mode.upper(), ex)) finally: try: wv.ExecuteScript("document.title='{}';".format(mode.upper())) except Exception: pass def on_loaded(s, e): try: wv.ExecuteScript("window.RHINO_MODE=true;") except Exception: pass def on_idle(s, e): Rhino.RhinoApp.Idle -= on_idle try: load_inline(wv, mode) except Exception as ex: print("[{}] Inline-Fehler: {}".format(mode.upper(), ex)) wv.DocumentTitleChanged += on_title wv.DocumentLoaded += on_loaded Rhino.RhinoApp.Idle += on_idle # --- Satelliten-Fenster (echtes Rhino-Fenster mit eingebetteter WebView) ---- def open_satellite_window(mode, params=None, title=None, size=(420, 560), on_save=None, on_cancel=None): """Oeffnet ein echtes Rhino-Fenster (Eto.Form) mit eingebetteter WebView. Die WebView laedt die React-App mit dem gegebenen `mode` und `params`. Die React-App sendet via Bridge `SAVE`/`CANCEL`-Messages. Wir rufen dann die jeweilige Callback-Funktion auf (mit dem Save-Payload) und schliessen das Fenster. Returns die Form-Instance (User kann sie speichern um sie spaeter programmatisch zu schliessen).""" form = forms.Form() if title is None: title = mode.replace('_', ' ').title() form.Title = title try: form.ClientSize = drawing.Size(int(size[0]), int(size[1])) except Exception: pass form.Resizable = True form.Topmost = False wv = forms.WebView() # Inline-Bridge fuer Satelliten-Fenster: handle SAVE/CANCEL, schliesse # bei beiden das Fenster. class _SatelliteBridge(BaseBridge): def __init__(self): BaseBridge.__init__(self, mode) def handle(self, data): t = data.get("type", "") p = data.get("payload") or {} if t == "READY": # React liest PANEL_PARAMS direkt vom window-Object — wir # muessen also nichts mehr aktiv senden. pass elif t == "SAVE": if on_save is not None: try: on_save(p) except Exception as ex: print("[{}] on_save: {}".format(mode.upper(), ex)) try: form.Close() except Exception: pass elif t == "CANCEL": if on_cancel is not None: try: on_cancel() except Exception: pass try: form.Close() except Exception: pass bridge = _SatelliteBridge() bridge.set_webview(wv) def on_title_(s, e): title_str = e.Title or "" if not title_str.startswith("RHINOMSG::"): return try: bridge.handle_raw(title_str[10:]) except Exception as ex: print("[{}] Message-Fehler: {}".format(mode.upper(), ex)) finally: try: wv.ExecuteScript("document.title='{}';".format(mode.upper())) except Exception: pass def on_loaded(s, e): try: wv.ExecuteScript("window.RHINO_MODE=true;") except Exception: pass wv.DocumentTitleChanged += on_title_ wv.DocumentLoaded += on_loaded form.Content = wv form.Show() # HTML nach Show() laden — sonst ist die WebView eventuell noch nicht # gerendert und die JS-Bridge initialisiert sich seltsam. try: load_inline(wv, mode, params=params) except Exception as ex: print("[{}] Inline-Fehler: {}".format(mode.upper(), ex)) return form # --- Dynamic .NET Type ------------------------------------------------------ def create_dockable_type(guid_str, type_name, assembly_name): """Baut einen echten CLR-Typ mit [Guid] fuer Rhinos RegisterPanel.""" import clr import System.Reflection as SR import System.Reflection.Emit as SRE panel_base = clr.GetClrType(forms.Panel) action_t = clr.GetClrType(System.Action[forms.Panel]) asm = SRE.AssemblyBuilder.DefineDynamicAssembly( SR.AssemblyName(assembly_name), SRE.AssemblyBuilderAccess.Run ) mod = asm.DefineDynamicModule(assembly_name) tb = mod.DefineType( type_name, SR.TypeAttributes.Public | SR.TypeAttributes.Class, panel_base ) guid_attr_t = clr.GetClrType(System.Runtime.InteropServices.GuidAttribute) guid_ctor = guid_attr_t.GetConstructor( System.Array[System.Type]([clr.GetClrType(System.String)]) ) tb.SetCustomAttribute(SRE.CustomAttributeBuilder( guid_ctor, System.Array[System.Object]([guid_str]) )) cb_field = tb.DefineField( "_callback", action_t, SR.FieldAttributes.Public | SR.FieldAttributes.Static ) ctor = tb.DefineConstructor( SR.MethodAttributes.Public, SR.CallingConventions.Standard, System.Type.EmptyTypes ) il = ctor.GetILGenerator() lbl = il.DefineLabel() base_ctor = panel_base.GetConstructor(System.Type.EmptyTypes) il.Emit(SRE.OpCodes.Ldarg_0) il.Emit(SRE.OpCodes.Call, base_ctor) il.Emit(SRE.OpCodes.Ldsfld, cb_field) il.Emit(SRE.OpCodes.Brfalse_S, lbl) il.Emit(SRE.OpCodes.Ldsfld, cb_field) il.Emit(SRE.OpCodes.Ldarg_0) il.Emit(SRE.OpCodes.Callvirt, action_t.GetMethod("Invoke")) il.MarkLabel(lbl) il.Emit(SRE.OpCodes.Ret) return tb.CreateType() def _hex_rgb(h): h = (h or "888888").lstrip("#") return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) _ICON_CACHE_DIR = os.path.expanduser("~/Library/Application Support/RhinoPanel/icons") # Versand-Icons im Projekt-Repo. PRIO 1: PNG (vorgerendert, weiss-auf- # transparent). PRIO 2: SVG (Mac-Rhino kann's via NSImage manchmal nicht # rendern, daher als Fallback ueber das Font-Glyph hinaus). Beide Ordner # werden gechecked. _PANEL_ICONS_PNG_DIR = os.path.join( os.path.dirname(_HERE), "icons_export", "panel_icons", "png") _PANEL_ICONS_SVG_DIR = os.path.join( os.path.dirname(_HERE), "icons_export", "panel_icons", "svg") def _try_load_png_white(png_path, size): """PNG-Datei direkt als Bitmap laden + auf size skalieren. Geht auf allen Rhino-Versionen zuverlaessig (PNG-Support ist universal).""" try: bmp_src = drawing.Bitmap(png_path) if bmp_src is None: return None target = drawing.Bitmap(size, size, drawing.PixelFormat.Format32bppRgba) g = drawing.Graphics(target) try: try: g.AntiAlias = True except Exception: pass g.DrawImage(bmp_src, 0, 0, size, size) finally: g.Dispose() return target except Exception as ex: print("[panel_base] PNG-load failed:", ex) return None def _try_load_svg_white(svg_path, size): """Laedt eine SVG-Datei und rendert sie als 32x32-Bitmap mit weisser Fuell-Farbe. Strategie: SVG-Text einlesen, fill="white" in alle - Elemente injizieren, in den Icon-Cache als temp .svg schreiben und via Eto.Drawing.Bitmap(path) laden — auf Mac geht das via NSImage das SVGs seit macOS 10.14 unterstuetzt. Liefert Bitmap oder None.""" try: with open(svg_path, "rb") as f: txt = f.read().decode("utf-8") # Path-Elemente weiss faerben (Material-Symbols-SVGs haben default- # black fill). Naive String-Manipulation reicht — die SVGs sind # einfach gestrickt (genau ein ). if 'fill=' not in txt: txt = txt.replace(" BaseBridge Subklasse icon_spec: (letter, hex_bg) zum Generieren eines Tab-Icons, oder None. min_size: (width, height) als Tuple — Panel-MinimumSize beim Erstellen. Bei docked Panels wirkt's als Hint, bei float Panels als tatsaechliche Startgroesse. """ sticky_reg = "panel_registered_" + mode sticky_guid = "panel_guid_" + mode if not sc.sticky.get(sticky_reg): plugin = find_plugin() if plugin is None: print("[{}] Plugin nicht gefunden".format(mode.upper())) return try: type_name = "DynPanel_" + mode asm_name = "RhinoPanelDyn_" + mode dyn_type = create_dockable_type(guid_str, type_name, asm_name) def on_created(panel): # MinimumSize setzen damit Rhino dem Panel bei Auto-Dock / # Float genug Platz gibt (sonst spawned es schmal). if min_size is not None: try: panel.MinimumSize = drawing.Size(int(min_size[0]), int(min_size[1])) except Exception as ex: print("[{}] MinimumSize konnte nicht gesetzt werden: {}".format(mode.upper(), ex)) # Auf einigen Eto-Versionen gibt es zusaetzlich Size/ClientSize for attr in ("Size", "ClientSize"): try: if hasattr(panel, attr): setattr(panel, attr, drawing.Size(int(min_size[0]), int(min_size[1]))) except Exception: pass bridge = bridge_factory() attach_webview(panel, bridge, mode) dyn_type.GetField("_callback").SetValue( None, System.Action[forms.Panel](on_created) ) icon = None if icon_spec: try: icon = make_panel_icon(icon_spec[0], icon_spec[1]) except Exception as ex: print("[{}] Icon-Erstellung uebersprungen: {}".format(mode.upper(), ex)) icon = None registered = False registered_with_icon = False # Erst mit Icon versuchen, dann stillschweigend ohne (Mac Rhino-Panels # akzeptieren auf manchen Versionen nur System.Drawing.Icon, das auf # Mac nicht verfuegbar ist - die Registrierung ohne Icon ist OK). attempts = [(icon, True)] if icon is not None else [] attempts.append((None, False)) for arg, with_icon in attempts: try: RhinoUI.Panels.RegisterPanel(plugin, dyn_type, caption, arg) registered = True registered_with_icon = with_icon if with_icon: print("[{}] Panel mit Icon registriert ({})".format( mode.upper(), type(arg).__name__)) break except Exception as ex: if with_icon: print("[{}] RegisterPanel mit Icon fehlgeschlagen: {}".format( mode.upper(), ex)) else: print("[{}] RegisterPanel fehlgeschlagen: {}".format( mode.upper(), ex)) if registered and not registered_with_icon and icon is not None: print("[{}] Panel ohne Icon registriert (Fallback)".format(mode.upper())) if not registered: return sc.sticky[sticky_reg] = True sc.sticky[sticky_guid] = System.Guid(guid_str) print("[{}] Panel registriert".format(mode.upper())) except Exception as ex: print("[{}] Registrierung fehlgeschlagen: {}".format(mode.upper(), ex)) return try: guid = sc.sticky.get(sticky_guid, System.Guid(guid_str)) RhinoUI.Panels.OpenPanel(guid) print("[{}] Panel geoeffnet".format(mode.upper())) except Exception as ex: print("[{}] OpenPanel fehlgeschlagen: {}".format(mode.upper(), ex))