42d9c1e27b
OVERRIDES war als gedocktes Panel zu schmal. Jetzt: kein Panel mehr, sondern ein echtes Rhino-Fenster (Eto.Form + WebView, frei verschieb- und resizable), das vom Oberleiste-Gear-Button geoeffnet wird. panel_base.open_satellite_window: - Akzeptiert jetzt optional einen `bridge`-Parameter. Wenn gegeben, wird die Custom-Bridge (z.B. OverridesBridge) statt der einfachen inline SAVE/CANCEL-Bridge benutzt. So koennen vollwertige Panels (mit bidirektionalem Mess-Verkehr) als Satellite-Fenster laufen. overrides_panel.py: - register_and_open entfaellt — Overrides wird nicht mehr als Panel registriert. - Neue Funktion open_as_window(): erstellt OverridesBridge, registriert sie in sticky["overrides_bridge"] und oeffnet als Satellite-Window. Listener werden lazy beim ersten Aufruf installiert (_ensure_listeners_once). oberleiste.py: - OPEN_OVERRIDES_PANEL ruft jetzt overrides_panel.open_as_window() statt RhinoUI.Panels.OpenPanel(). OberleisteApp.jsx: - Settings-Gear (ToolButton mit icon="settings") nach dem Preset- Dropdown im Overrides-Bereich. Click ruft openOverridesPanel() → oeffnet das Satelliten-Fenster. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
810 lines
31 KiB
Python
810 lines
31 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 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 = ['<script>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))
|
|
parts.append('</script>')
|
|
mode_script = ''.join(parts)
|
|
if "</head>" in html:
|
|
html = html.replace("</head>", mode_script + "</head>")
|
|
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"<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)
|
|
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, 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.
|
|
"""
|
|
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))
|