222b00c113
UI: - GeschossManager: Master-Eye + Master-Lock im Header (analog EbenenManager). Scheren-Button pro Geschoss togglet hasClipping. Auge ganz links wie bei Ebenen. Eye-Logik klar 4-Wege: aktive Z immer hell+on, in 'active'/'all_force' fuer non-active gedimmt, sonst spiegelt Flag direkt. Schrift wird NIE gegrayt. Neuer Mode 'all_force' = "Alle anzeigen" (ignoriert Eye), 'all' jetzt mit Label "Ausgewaehlte" (respektiert Eye). Klick aufs Auge in 'active'/'all_force' wechselt automatisch in "Ausgewaehlte" damit Aktion sofort wirkt. - layer_builder.apply_visibility: neuer z_mode 'all_force' vor visible-Check — zeigt jede Z auch wenn Eye=false war. - elemente._cmd_create_oeffnung: gruene Live-Preview (vertikales Oeffnungs- Rechteck + Breiten-Marker + Diagonale) waehrend Fenster/Tuer-Platzierung entlang der Wand-Achse. Brueest-Offset aus Geschoss-UK korrekt im Z. Performance Cold-Start: - panel_base: Inlined-HTML als Modul-Cache (1x build, n-mal mount). Pro Panel-Mount nur noch str.replace + LoadHtml. Spart bei 10 Panels 9x ~395 KB Disk-Read + Regex-Pass. Cache-Key = mtime von dist/index.html. - Timing-Instrumentierung: _t_mark + print_startup_summary. Hook in startup.py feuert 3s nach Plugin-Load + listet Wall-time, Top-10, Aggregat pro Phase. - OberleisteBridge: Command-Enumeration (~1000 Commands) jetzt lazy via Rhino.RhinoApp.Idle statt synchron im __init__. Cold-Start nicht blockiert, Autocomplete kommt ~1 Frame spaeter. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
904 lines
35 KiB
Python
904 lines
35 KiB
Python
#! 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 = '<script>' + _MODE_SCRIPT_PLACEHOLDER + '</script>'
|
|
if "</head>" in html:
|
|
html = html.replace("</head>", placeholder_script + "</head>")
|
|
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"<style>" + f2.read().decode("utf-8") + u"</style>"
|
|
|
|
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'<script>document.addEventListener("DOMContentLoaded",function(){'
|
|
+ content + u'});</script>')
|
|
|
|
html = re.sub(r'<link[^>]+href="(\./assets/[^"]+\.css)"[^>]*/?>', inline_css, html)
|
|
html = re.sub(r'<script[^>]+src="(\./assets/[^"]+\.js)"[^>]*></script>', 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 <path>-
|
|
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 <path>).
|
|
if 'fill=' not in txt:
|
|
txt = txt.replace("<path ", '<path fill="#ffffff" ')
|
|
if not os.path.isdir(_ICON_CACHE_DIR):
|
|
os.makedirs(_ICON_CACHE_DIR)
|
|
safe = re.sub(r"[^A-Za-z0-9]", "_",
|
|
os.path.splitext(os.path.basename(svg_path))[0])
|
|
tmp_path = os.path.join(_ICON_CACHE_DIR, "_svg_" + safe + ".svg")
|
|
with open(tmp_path, "wb") as f:
|
|
f.write(txt.encode("utf-8"))
|
|
# Eto.Drawing.Bitmap aus File-Pfad: nutzt auf Mac NSImage (kann SVG).
|
|
try:
|
|
bmp_src = drawing.Bitmap(tmp_path)
|
|
except Exception:
|
|
bmp_src = None
|
|
if bmp_src is None: return None
|
|
# Auf size x size skalieren — die meisten Material-Symbols haben
|
|
# einen 24-Einheiten-Viewbox, wir wollen 32px Output.
|
|
target = drawing.Bitmap(size, size,
|
|
drawing.PixelFormat.Format32bppRgba)
|
|
g = drawing.Graphics(target)
|
|
try:
|
|
try: g.AntiAlias = True
|
|
except Exception: pass
|
|
# Transparenter Hintergrund — der Caller composited spaeter
|
|
# ueber den farbigen Panel-Hintergrund.
|
|
g.DrawImage(bmp_src, 0, 0, size, size)
|
|
finally:
|
|
g.Dispose()
|
|
return target
|
|
except Exception as ex:
|
|
print("[panel_base] SVG-load failed:", ex)
|
|
return None
|
|
|
|
|
|
# Material Symbols Outlined Codepoints fuer die Panel-Icons.
|
|
# Quelle: https://fonts.google.com/icons (Codepoint-Tab pro Icon)
|
|
# Wenn die Font "Material Symbols Outlined" installiert ist (Mac:
|
|
# ~/Library/Fonts/MaterialSymbolsOutlined-Regular.ttf), werden diese
|
|
# Glyphen gerendert. Sonst Fallback auf den ersten Buchstaben.
|
|
_MATERIAL_CODEPOINTS = {
|
|
"foundation": 0xf200,
|
|
"view_in_ar": 0xe9fe,
|
|
"palette": 0xe40a,
|
|
"settings": 0xe8b8,
|
|
"straighten": 0xe41c,
|
|
"crop": 0xe3be,
|
|
"view_quilt": 0xe8f9,
|
|
"tune": 0xe429,
|
|
"filter_alt": 0xef4f,
|
|
"build": 0xe869,
|
|
"construction": 0xea3c,
|
|
"aspect_ratio": 0xe85b,
|
|
"rule": 0xf1c2,
|
|
"layers": 0xe53b,
|
|
"menu": 0xe5d2,
|
|
"design_services": 0xf10a,
|
|
"square_foot": 0xea49,
|
|
"dashboard": 0xe871,
|
|
"category": 0xe574,
|
|
}
|
|
|
|
_MATERIAL_FONT_NAMES = (
|
|
"Material Symbols Outlined",
|
|
"Material Symbols Rounded",
|
|
"Material Icons", # alter Web-Font
|
|
)
|
|
|
|
|
|
def _try_material_font():
|
|
"""Probiert die Material-Schrift-Namen durch und liefert den ersten der
|
|
sich als FontFamily laden laesst — None wenn keiner installiert."""
|
|
for fam in _MATERIAL_FONT_NAMES:
|
|
try:
|
|
ff = drawing.FontFamily(fam)
|
|
if ff is not None: return fam
|
|
except Exception: continue
|
|
return None
|
|
|
|
|
|
def _draw_glyph(g, size, font, glyph, fg):
|
|
"""Zeichnet Text mittig auf eine Graphics-Surface."""
|
|
try:
|
|
ts = g.MeasureString(font, glyph)
|
|
tx = (size - ts.Width) / 2
|
|
ty = (size - ts.Height) / 2
|
|
except Exception:
|
|
tx, ty = size * 0.18, size * 0.12
|
|
g.DrawText(font, fg, float(tx), float(ty), glyph)
|
|
|
|
|
|
def make_panel_icon(name_or_letter, bg_hex):
|
|
"""Erzeugt ein 32x32 Panel-Icon. `name_or_letter` kann ein Material-
|
|
Icon-Name (z.B. 'foundation', 'palette') ODER ein einzelner Buchstabe
|
|
sein. Bei Material-Namen wird die Material-Schrift verwendet; Fallback
|
|
auf den ersten Buchstaben wenn die Schrift nicht installiert ist."""
|
|
try:
|
|
size = 32
|
|
bmp = drawing.Bitmap(size, size, drawing.PixelFormat.Format32bppRgba)
|
|
g = drawing.Graphics(bmp)
|
|
used_material = False
|
|
try:
|
|
try: g.AntiAlias = True
|
|
except Exception: pass
|
|
r, gg, bl = _hex_rgb(bg_hex)
|
|
bg = drawing.Color.FromArgb(r, gg, bl, 255)
|
|
g.FillRectangle(bg, 0, 0, size, size)
|
|
|
|
# 0) Versand-Icons aus dem Repo bevorzugen. Zuerst PNG (geht
|
|
# auf allen Rhino-Versionen sicher), sonst SVG-Fallback (NSImage
|
|
# auf Mac, klappt nur manchmal).
|
|
used_svg = False
|
|
icon_bmp = None
|
|
chosen_path = ""
|
|
try:
|
|
png_path = os.path.join(_PANEL_ICONS_PNG_DIR,
|
|
name_or_letter + ".png")
|
|
if os.path.isfile(png_path):
|
|
icon_bmp = _try_load_png_white(png_path, size - 8)
|
|
if icon_bmp is not None: chosen_path = png_path
|
|
else: print("[panel_base] PNG geladen aber Bitmap None:",
|
|
png_path)
|
|
else:
|
|
print("[panel_base] PNG nicht gefunden:", png_path)
|
|
if icon_bmp is None:
|
|
svg_path = os.path.join(_PANEL_ICONS_SVG_DIR,
|
|
name_or_letter + ".svg")
|
|
if os.path.isfile(svg_path):
|
|
icon_bmp = _try_load_svg_white(svg_path, size - 8)
|
|
if icon_bmp is not None: chosen_path = svg_path
|
|
if icon_bmp is not None:
|
|
pad = 4
|
|
try:
|
|
g.DrawImage(icon_bmp, pad, pad,
|
|
size - 2*pad, size - 2*pad)
|
|
used_svg = True
|
|
used_material = True # → kein Letter-Fallback
|
|
print("[panel_base] Icon-Pfad: {} ← {}".format(
|
|
name_or_letter, chosen_path))
|
|
except Exception as ex:
|
|
print("[panel_base] Icon-Composite Fehler:", ex)
|
|
except Exception as ex:
|
|
print("[panel_base] Icon-Pfad-Check:", ex)
|
|
|
|
# 1) Material-Icon-Font (wenn keine SVG vorhanden)
|
|
mat_cp = _MATERIAL_CODEPOINTS.get(name_or_letter)
|
|
if not used_svg and mat_cp is not None:
|
|
font_family_name = _try_material_font()
|
|
if font_family_name:
|
|
try:
|
|
ff = drawing.FontFamily(font_family_name)
|
|
# FontStyle.None: in Python3 nicht direkt zugreifbar
|
|
# (None ist Keyword) → getattr-Workaround, sonst 0
|
|
try: fs = getattr(drawing.FontStyle, "None")
|
|
except Exception: fs = 0
|
|
font = drawing.Font(ff, 20, fs)
|
|
glyph = chr(mat_cp)
|
|
_draw_glyph(g, size, font, glyph,
|
|
drawing.Colors.White)
|
|
used_material = True
|
|
except Exception as ex:
|
|
print("[panel_base] Material-Render Fehler:", ex)
|
|
used_material = False
|
|
|
|
# 2) Fallback: Buchstabe (erstes Zeichen bzw. eingegebener Buchstabe)
|
|
if not used_material:
|
|
letter = (name_or_letter[:1].upper()
|
|
if name_or_letter else "?")
|
|
try:
|
|
font = drawing.Font(drawing.FontFamilies.Sans, 18,
|
|
drawing.FontStyle.Bold)
|
|
except Exception:
|
|
font = drawing.Font("Helvetica", 18,
|
|
drawing.FontStyle.Bold)
|
|
_draw_glyph(g, size, font, letter, drawing.Colors.White)
|
|
finally:
|
|
g.Dispose()
|
|
# PNG auf Disk schreiben — zuverlaessig fuer Mac Eto.Drawing.Icon
|
|
try:
|
|
if not os.path.isdir(_ICON_CACHE_DIR):
|
|
os.makedirs(_ICON_CACHE_DIR)
|
|
if used_svg: tag = "svg"
|
|
elif used_material: tag = "mat"
|
|
else: tag = "ltr"
|
|
safe = re.sub(r"[^A-Za-z0-9]", "_", name_or_letter)
|
|
path = os.path.join(_ICON_CACHE_DIR, "icon_{}_{}_{}.png".format(
|
|
tag, safe, bg_hex.lstrip("#")))
|
|
bmp.Save(path, drawing.ImageFormat.Png)
|
|
except Exception as ex:
|
|
print("[panel_base] Icon-Save:", ex)
|
|
path = None
|
|
# WICHTIG: Mac Rhinos RegisterPanel meldet "expected Icon, got Icon"
|
|
# wenn wir Eto.Drawing.Icon uebergeben — die API erwartet
|
|
# System.Drawing.Icon. Daher zuerst System.Drawing probieren,
|
|
# dann Eto als Fallback.
|
|
if path and os.path.isfile(path):
|
|
try:
|
|
import System.Drawing as _sd
|
|
ic = _sd.Icon(path)
|
|
print("[panel_base] Icon erzeugt via System.Drawing.Icon(path) [{}]".format(tag))
|
|
return ic
|
|
except Exception as ex:
|
|
print("[panel_base] System.Drawing.Icon(path) fehlgeschlagen:", ex)
|
|
# System.Drawing.Bitmap als Fallback (manche RegisterPanel-Overloads akzeptieren Bitmap)
|
|
try:
|
|
import System.Drawing as _sd
|
|
bmp_sd = _sd.Bitmap(path)
|
|
print("[panel_base] Icon erzeugt via System.Drawing.Bitmap(path) [{}]".format(tag))
|
|
return bmp_sd
|
|
except Exception as ex:
|
|
print("[panel_base] System.Drawing.Bitmap(path) fehlgeschlagen:", ex)
|
|
# Eto.Drawing.Icon als letzter Versuch — falls Rhino-Version anders ist
|
|
try:
|
|
ic = drawing.Icon(path)
|
|
print("[panel_base] Icon erzeugt via Eto.Drawing.Icon(path) [{}]".format(tag))
|
|
return ic
|
|
except Exception as ex:
|
|
print("[panel_base] Eto.Drawing.Icon(path) fehlgeschlagen:", ex)
|
|
# Bitmap-Fallback (in-memory) — wenn alles vorherige fehlschlaegt
|
|
try:
|
|
ic = drawing.Icon(1.0, bmp)
|
|
print("[panel_base] Icon erzeugt via Eto.Drawing.Icon(scale, bmp) [{}]".format(tag))
|
|
return ic
|
|
except Exception: pass
|
|
print("[panel_base] Icon Fallback: Eto.Bitmap zurueck ({})".format(tag))
|
|
return bmp
|
|
except Exception as ex:
|
|
print("[panel_base] Icon-Erstellung fehlgeschlagen:", ex)
|
|
return None
|
|
|
|
|
|
def find_plugin():
|
|
try:
|
|
installed = Rhino.PlugIns.PlugIn.GetInstalledPlugIns()
|
|
for guid in installed.Keys:
|
|
name = str(installed[guid])
|
|
if any(k in name for k in ["RhinoCode", "Scripting", "Python", "Script"]):
|
|
p = Rhino.PlugIns.PlugIn.Find(guid)
|
|
if p is not None:
|
|
return p
|
|
except Exception as ex:
|
|
print("[panel_base] Plugin-Suche:", ex)
|
|
return None
|
|
|
|
|
|
def register_and_open(mode, caption, guid_str, bridge_factory, icon_spec=None, min_size=None):
|
|
"""
|
|
Registriert (falls noetig) und oeffnet ein Panel.
|
|
bridge_factory: callable() -> 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)
|