#! 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 time 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") # --- Timing-Instrumentierung ------------------------------------------------ # Schaltbar via sc.sticky["dossier_timing"] = True. Wir loggen die Hot-Paths # beim Plugin-Start. Tabelle am Ende per panel_base.print_startup_summary(). _TIMINGS = [] # Liste von (phase, label, ms) _T0 = None # Zeitstempel des allerersten Aufrufs def _t_mark(phase, label, t_start): """Loggt eine Phase + Dauer. Print sofort, plus Aggregat fuers Summary.""" ms = (time.time() - t_start) * 1000.0 _TIMINGS.append((phase, label, ms)) global _T0 if _T0 is None: _T0 = t_start try: print("[STARTUP] {:>22s} {:>22s} {:7.1f} ms".format(phase, label, ms)) except Exception: pass return ms def print_startup_summary(): """Aufruf am Ende von startup.py: zeigt total + sortierte Topliste.""" if not _TIMINGS: return total_wall = (time.time() - (_T0 or time.time())) * 1000.0 total_work = sum(ms for _, _, ms in _TIMINGS) print("[STARTUP] ===== SUMMARY =====") print("[STARTUP] Wall-time (first to last mark): {:.1f} ms".format(total_wall)) print("[STARTUP] Sum of measured work: {:.1f} ms".format(total_work)) # Top-10 nach Dauer top = sorted(_TIMINGS, key=lambda x: -x[2])[:10] print("[STARTUP] --- Top-10 nach Dauer ---") for phase, label, ms in top: print("[STARTUP] {:7.1f} ms {} / {}".format(ms, phase, label)) # Aggregat nach Phase by_phase = {} for phase, _, ms in _TIMINGS: by_phase[phase] = by_phase.get(phase, 0.0) + ms print("[STARTUP] --- Aggregat nach Phase ---") for phase, ms in sorted(by_phase.items(), key=lambda x: -x[1]): print("[STARTUP] {:7.1f} ms {}".format(ms, phase)) # --- 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 ------------------------------------------------------------- # Cache der fertig zusammengebauten Inline-HTML — Disk-IO + CSS/JS-String- # Inlining laeuft nur EINMAL pro Plugin-Session statt pro Panel-Mount. Bei # 10 Panels eliminiert das 9x ~395 KB Disk-Read + 9x Regex/Concat-Pass. # Cache-Key = mtime von dist/index.html — wenn der User neu build, wird # der Cache automatisch invalidiert. _INLINE_TEMPLATE = None # (mtime_signature, head_html, body_html_template) # Sentinel-Marker im Template fuer die per-Mount unterschiedlichen Scripts. # Wir nutzen einen "Magic-String" statt format/{} damit die JS-Bundle-Inhalte # (die {} Token enthalten koennen) nicht versehentlich matchen. _MODE_SCRIPT_PLACEHOLDER = "/*__DOSSIER_MODE_SCRIPT__*/" def _build_inline_template(): """Liest dist/index.html + inline alle CSS/JS. Liefert die HTML-String mit Placeholder fuer den per-Mount Mode-Script-Block.""" t0 = time.time() if not os.path.exists(_DIST): return None, None dist_dir = os.path.dirname(_DIST) try: mtime_sig = os.path.getmtime(_DIST) except Exception: mtime_sig = 0 with open(_DIST, "rb") as f: html = f.read().decode("utf-8") placeholder_script = '' if "" in html: html = html.replace("", placeholder_script + "") else: html = placeholder_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) _t_mark("html-build", "build_inline_template", t0) return mtime_sig, html 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. Performance: das fertige Inline-HTML (mit allen CSS/JS embedded) wird modul-weit gecached. Pro Aufruf nur noch Mode-Script-Substitute + die teure WebView.LoadHtml — Disk-IO laeuft 1x pro Plugin-Session.""" t0 = time.time() global _INLINE_TEMPLATE # Cache-Refresh wenn dist/index.html sich geaendert hat (neuer Build) try: cur_mtime = os.path.getmtime(_DIST) except Exception: cur_mtime = 0 if _INLINE_TEMPLATE is None or _INLINE_TEMPLATE[0] != cur_mtime: sig, tmpl = _build_inline_template() if tmpl is None: print("[{}] dist nicht gefunden".format(mode.upper())) return _INLINE_TEMPLATE = (sig, tmpl) # Per-Mount: nur das Mode-Script-Snippet bauen parts = ['window.PANEL_MODE="{}";'.format(mode)] if params is not None: try: parts.append('window.PANEL_PARAMS=' + json.dumps(params, ensure_ascii=False) + ';') except Exception as ex: print("[{}] PANEL_PARAMS serialize: {}".format(mode.upper(), ex)) mode_script = ''.join(parts) html = _INLINE_TEMPLATE[1].replace(_MODE_SCRIPT_PLACEHOLDER, mode_script) t_loadhtml = time.time() wv.LoadHtml(html) _t_mark("load_inline", mode, t0) _t_mark("LoadHtml", mode, t_loadhtml) 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, bridge=None): """Oeffnet ein echtes Rhino-Fenster (Eto.Form) mit eingebetteter WebView. Die WebView laedt die React-App mit dem gegebenen `mode` und `params`. Zwei Bridge-Modi: - **Default (kein `bridge`-Arg):** inline SAVE/CANCEL-Bridge. React sendet SAVE/CANCEL → on_save/on_cancel-Callback → Fenster zu. Fuer einfache One-Shot-Dialoge (Settings etc.). - **`bridge` uebergeben:** eine vollwertige BaseBridge-Subklasse (z.B. OverridesBridge). Das Fenster nutzt die wie ein normales Panel, mit allen Mess-Typen die der Bridge handlet. Save/Cancel sind dort nicht standard; Fenster bleibt offen bis User es manuell schliesst. Returns die Form-Instance.""" 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() if bridge is None: # Inline-Bridge fuer einfache Settings-Dialoge: 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": 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. """ t_outer = time.time() sticky_reg = "panel_registered_" + mode sticky_guid = "panel_guid_" + mode if not sc.sticky.get(sticky_reg): t_reg = time.time() 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: t_icon = time.time() 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 _t_mark("icon", mode, t_icon) 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 _t_mark("register", mode, t_reg) try: t_open = time.time() guid = sc.sticky.get(sticky_guid, System.Guid(guid_str)) RhinoUI.Panels.OpenPanel(guid) _t_mark("OpenPanel", mode, t_open) print("[{}] Panel geoeffnet".format(mode.upper())) except Exception as ex: print("[{}] OpenPanel fehlgeschlagen: {}".format(mode.upper(), ex)) _t_mark("register_and_open", mode, t_outer)