Initial commit — Dossier Rhino 8 Plugin
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>
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
# ! 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))
|
||||
Reference in New Issue
Block a user