9dc191be4f
OpenStudio-Suite Architektur-Plugin fuer Rhino 8 (Mac): - Smart-Elemente: Wand, Decke, Dach (Pult/Sattel/Walm/Mansarde), Oeffnungen (Fenster/Tueren mit Rahmen + Sims + Glas + Fluegel), Treppen (gerade · L · Wendel mit Schrittmass-Validierung) - Live-Previews mit Step-Lines + Soll-Range-Clamping - Bidirektionale Selection-Sync zwischen Source-Linie und Volume - Geschoss-/Ebenen-Verwaltung mit OKFF-Persistenz - Layouts mit PDF-Export - Ausschnitte / Massstab / Override-Regeln - Petrol-Gruen Theme (Rapport-konform) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
500 lines
18 KiB
Python
500 lines
18 KiB
Python
# ! python3
|
|
# -*- 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):
|
|
"""Laedt dist/index.html inline und injiziert window.PANEL_MODE."""
|
|
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")
|
|
|
|
mode_script = '<script>window.PANEL_MODE="{}";</script>'.format(mode)
|
|
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
|
|
|
|
|
|
# --- 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")
|
|
|
|
|
|
def make_panel_icon(letter, bg_hex):
|
|
"""Erzeugt ein Icon (32x32) mit farbigem Quadrat + Buchstabe.
|
|
Schreibt es als PNG-Datei auf Disk und laedt es via Eto.Drawing.Icon(path)
|
|
— das ist der zuverlaessigste Weg auf Mac Rhino.
|
|
"""
|
|
try:
|
|
size = 32 # 32x32 fuer Retina (wird auf 16pt skaliert dargestellt)
|
|
bmp = drawing.Bitmap(size, size, drawing.PixelFormat.Format32bppRgba)
|
|
g = drawing.Graphics(bmp)
|
|
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)
|
|
try:
|
|
font = drawing.Font(drawing.FontFamilies.Sans, 18, drawing.FontStyle.Bold)
|
|
except Exception:
|
|
font = drawing.Font("Helvetica", 18, drawing.FontStyle.Bold)
|
|
try:
|
|
text_size = g.MeasureString(font, letter)
|
|
tx = (size - text_size.Width) / 2
|
|
ty = (size - text_size.Height) / 2
|
|
except Exception:
|
|
tx, ty = size * 0.18, size * 0.12
|
|
g.DrawText(font, drawing.Colors.White, float(tx), float(ty), letter)
|
|
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)
|
|
safe = re.sub(r"[^A-Za-z0-9]", "_", letter)
|
|
path = os.path.join(_ICON_CACHE_DIR, "icon_{}_{}.png".format(
|
|
safe, bg_hex.lstrip("#")))
|
|
bmp.Save(path, drawing.ImageFormat.Png)
|
|
except Exception as ex:
|
|
print("[panel_base] Icon-Save:", ex)
|
|
path = None
|
|
# 1. Versuch: Icon aus Datei-Pfad
|
|
if path and os.path.isfile(path):
|
|
try:
|
|
return drawing.Icon(path)
|
|
except Exception as ex:
|
|
print("[panel_base] Icon(path) fehlgeschlagen:", ex)
|
|
# 2. Versuch: Icon(scale, bitmap)
|
|
try:
|
|
return drawing.Icon(1.0, bmp)
|
|
except Exception: pass
|
|
# 3. Versuch: Icon(bitmap)
|
|
try:
|
|
return drawing.Icon(bmp)
|
|
except Exception: pass
|
|
# 4. Fallback: einfach das Bitmap zurueck (Rhino akzeptiert ggf. das auch)
|
|
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))
|