# ! python3 # -*- coding: utf-8 -*- """ oberleiste.py OBERLEISTE-Panel: horizontale Top-Bar mit Architektur-Kontext-Controls. Vereint View-Switcher, Display-Mode, Massstab, Print-View und Snap-Toggles. Re-used massstab-Modul fuer Skala/PlotWeight-Logik — die Bridge proxiet alle Massstab-bezogenen Nachrichten dorthin. """ import os import sys import Rhino import scriptcontext as sc _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) import panel_base import massstab import overrides PANEL_GUID_STR = "7e1f6a5b-8e2f-4f3c-d5e6-f70819203b51" OVERRIDES_PANEL_GUID_STR = "8f2a7b6c-9f3a-4f4d-e6f7-08192a3c4d62" def _run(cmd): """Hilfsfunktion: Rhino-Befehl ausfuehren, mit Logging.""" try: Rhino.RhinoApp.RunScript(cmd, False) except Exception as ex: print("[OBERLEISTE] RunScript-Fehler ({}): {}".format(cmd, ex)) def _get_active_viewport_name(): try: v = Rhino.RhinoDoc.ActiveDoc.Views.ActiveView return v.ActiveViewport.Name if v else None except Exception: return None def _list_all_command_names(): """Enumeriert alle registrierten Rhino-Commands (englische Namen). Wird einmalig beim Bridge-Start aufgerufen und gecached.""" names = set() # Variante 1: statische API Rhino.Commands.Command.GetCommandNames try: all_names = Rhino.Commands.Command.GetCommandNames(True, True) for n in all_names: if n and isinstance(n, str): names.add(n) except Exception: pass # Variante 2: ueber alle PlugIns iterieren (Fallback) if not names: try: for guid in Rhino.Plugins.PlugIn.GetInstalledPlugIns().Keys: try: pi = Rhino.Plugins.PlugIn.Find(guid) if pi is None: continue cmds = pi.GetCommands() if hasattr(pi, "GetCommands") else [] for cmd_guid in cmds: try: n = Rhino.Commands.Command.GetCommandName(cmd_guid) if n: names.add(n) except Exception: pass except Exception: pass except Exception: pass # Variante 3: minimaler Fallback fuer den Fall dass keine API greift if not names: for n in ("Line","Polyline","Rectangle","Circle","Arc","Curve","Text","Hatch", "Move","Copy","Rotate","Scale","Mirror","Offset","Trim","Extend", "Join","Explode","Fillet","Array","Box","ExtrudeCrv","BooleanUnion", "BooleanDifference","BooleanIntersection","Cap","Section","Loft", "Zoom","Pan","Top","Front","Right","Perspective","Undo","Redo", "Group","Ungroup","Hide","Show","Delete","SelAll","SelNone", "Properties","Layer","Snap","Ortho","Planar","Save","SaveAs"): names.add(n) out = sorted(names) print("[OBERLEISTE] {} Rhino-Commands fuer Autocomplete enumeriert".format(len(out))) return out def _get_command_prompt(): """Liefert den aktuellen Rhino-Command-Prompt oder leeren String. Wird gepollt damit OBERLEISTE den Prompt + Optionen anzeigen kann.""" try: p = Rhino.RhinoApp.CommandPrompt return p if p is not None else "" except Exception: return "" def _parse_command_options(prompt): """Extrahiert Option-Tokens aus einem Rhino-Prompt. Beispiele: "Line: First point ( BothSides Bisector Length Vertical Angle )" "Polyline: Next point of polyline ( Close Helix Mode=Persistent Undo )" Liefert Liste von dicts: [{name, value (optional), token}]. """ import re if not prompt: return [] # Inhalt der letzten Klammer m = re.search(r"\(([^()]+)\)\s*$", prompt) if not m: return [] body = m.group(1).strip() options = [] for tok in body.split(): tok = tok.strip().rstrip(",;:") if not tok: continue if "=" in tok: name, val = tok.split("=", 1) options.append({"name": name, "value": val, "token": tok}) else: options.append({"name": tok, "value": None, "token": tok}) return options def _get_active_display_mode_name(): try: v = Rhino.RhinoDoc.ActiveDoc.Views.ActiveView if v is None: return None dm = v.ActiveViewport.DisplayMode return dm.LocalName if dm else None except Exception: return None _display_modes_cache = None # gecacht — Liste aendert sich pro Rhino-Session selten def _list_display_modes(): """Alle verfuegbaren Display-Modes (LocalName + Id-String). Gecacht — Liste aendert sich nur wenn User Display-Modes ergaenzt/loescht. Bei Bedarf kann _display_modes_cache von aussen auf None gesetzt werden.""" global _display_modes_cache if _display_modes_cache is not None: return _display_modes_cache out = [] try: for dm in Rhino.Display.DisplayModeDescription.GetDisplayModes(): try: out.append({"name": dm.LocalName, "id": str(dm.Id)}) except Exception: continue except Exception as ex: print("[OBERLEISTE] _list_display_modes:", ex) _display_modes_cache = out return out def _set_display_mode(name): """Setzt Display-Mode des aktiven Viewports per Name.""" try: v = Rhino.RhinoDoc.ActiveDoc.Views.ActiveView if v is None: return False for dm in Rhino.Display.DisplayModeDescription.GetDisplayModes(): if dm.LocalName == name or dm.EnglishName == name: v.ActiveViewport.DisplayMode = dm v.Redraw() print("[OBERLEISTE] Display-Mode: {}".format(name)) return True except Exception as ex: print("[OBERLEISTE] _set_display_mode:", ex) return False # --- Snap / Ortho via ModelAidSettings -------------------------------------- def _get_snap_state(): try: s = Rhino.ApplicationSettings.ModelAidSettings return { "ortho": bool(s.Ortho), "gridSnap": bool(s.GridSnap), "osnap": bool(s.UseHorizontalDialog) if False else bool(getattr(s, "Osnap", False)) or False, "planar": bool(getattr(s, "ProjectOsnapsToCPlane", False)), } except Exception: return {"ortho": False, "gridSnap": False, "osnap": False, "planar": False} def _set_ortho(v): try: Rhino.ApplicationSettings.ModelAidSettings.Ortho = bool(v) except Exception as ex: print("[OBERLEISTE] _set_ortho:", ex) def _set_grid_snap(v): try: Rhino.ApplicationSettings.ModelAidSettings.GridSnap = bool(v) except Exception as ex: print("[OBERLEISTE] _set_grid_snap:", ex) def _set_osnap_master(v): """Master-Toggle fuer Object-Snap (alle aktiven Snaps).""" try: s = Rhino.ApplicationSettings.ModelAidSettings if hasattr(s, "Osnap"): s.Osnap = bool(v) elif hasattr(s, "UsePoints"): # Fallback: einzelne Modi durch pass except Exception as ex: print("[OBERLEISTE] _set_osnap_master:", ex) # --- Bridge ----------------------------------------------------------------- class OberleisteBridge(panel_base.BaseBridge): def __init__(self): panel_base.BaseBridge.__init__(self, "oberleiste") self._idle_counter = 0 self._last_prompt = "" self._last_state_sig = None # Fingerprint des letzten Push — dedupe self._cached_overrides = None # (enabled, count) — invalidiert bei Toggle/Update # Command-Liste einmalig laden (kann teuer sein -> cachen) try: self._all_commands = _list_all_command_names() except Exception as ex: print("[OBERLEISTE] command-enum:", ex) self._all_commands = [] def _on_ready(self): # Bootstrap DPI (gemeinsam mit massstab.py) try: massstab._bootstrap_dpi() except Exception: pass # WebView wurde (neu) gemountet — Frontend-State ist leer, also one-shot # Listen (displayModes, allCommands) neu mitsenden. Sonst zeigt das # Display-Dropdown nach einem Re-Mount (z.B. Andocken, Layout-Wechsel) # nur die "—"-Option und wirkt wie ein toter Button. self._dm_sent = False self._commands_sent = False self._send_state(force=True) def handle(self, data): if not isinstance(data, dict): return t = data.get("type", "") p = data.get("payload") or {} if not isinstance(p, dict): p = {} # --- Lifecycle -------------------------------------------------- if t == "READY": self._on_ready() elif t == "REQUEST_STATE": self._send_state(force=True) # --- Massstab (delegiert an massstab-Modul) --------------------- elif t == "SET_SCALE": doc, vp = massstab._active_vp() try: ratio = float(p.get("ratio")) except Exception: return if ratio > 0 and massstab._apply_scale(doc, vp, ratio): self._send_state(force=True) elif t == "ZOOM_EXTENTS": doc, vp = massstab._active_vp() massstab._zoom_extents(doc, vp, selected_only=False) self._send_state(force=True) elif t == "ZOOM_SELECTION": doc, vp = massstab._active_vp() massstab._zoom_extents(doc, vp, selected_only=True) self._send_state(force=True) elif t == "SET_LINEWEIGHTS": doc, _ = massstab._active_vp() massstab._set_lineweights_enabled(doc, bool(p.get("enabled"))) self._send_state(force=True) elif t == "SET_DPI": doc, _ = massstab._active_vp() massstab._set_dpi(doc, p.get("dpi"), source="manual") self._send_state(force=True) elif t == "DETECT_DPI": massstab._force_redetect_dpi() self._send_state(force=True) # --- View-Switcher ---------------------------------------------- elif t == "SET_VIEW": v = p.get("view") if v in ("Top", "Front", "Right", "Perspective", "Left", "Back", "Bottom"): _run("_-{} _Enter".format(v)) self._send_state(force=True) # --- Display-Mode ----------------------------------------------- elif t == "SET_DISPLAY_MODE": n = p.get("name") if n: _set_display_mode(n) self._send_state(force=True) # --- Snap-Toggles ----------------------------------------------- elif t == "TOGGLE_ORTHO": _set_ortho(bool(p.get("enabled"))) self._send_state(force=True) elif t == "TOGGLE_GRID_SNAP": _set_grid_snap(bool(p.get("enabled"))) self._send_state(force=True) elif t == "TOGGLE_OSNAP": _set_osnap_master(bool(p.get("enabled"))) self._send_state(force=True) # --- Graphical Overrides ---------------------------------------- elif t == "TOGGLE_OVERRIDES": doc = Rhino.RhinoDoc.ActiveDoc overrides.set_enabled(doc, bool(p.get("enabled"))) self._cached_overrides = None # Cache invalidieren self._send_state(force=True) elif t == "SET_OVERRIDES_PRESET": doc = Rhino.RhinoDoc.ActiveDoc name = p.get("name") or None overrides.set_active_preset(doc, name) self._cached_overrides = None # Cache invalidieren self._send_state(force=True) # OVERRIDES-Panel mit-informieren: dort haben sich die Rules # geaendert (Preset wurde reingeladen). try: b = sc.sticky.get("overrides_bridge") if b is not None: b._send_state() except Exception as ex: print("[OBERLEISTE] notify overrides:", ex) elif t == "SAVE_OVERRIDES_PRESET": # Quick-Save direkt aus der Topbar: aktuelle Doc-Rules unter # gegebenem Namen ablegen und sofort als activePreset markieren. # Spart dem User den Umweg ueber den grossen OVERRIDES-Editor. doc = Rhino.RhinoDoc.ActiveDoc name = (p.get("name") or "").strip() if not name: pass else: cfg = overrides.load_config(doc) rules = cfg.get("rules") or [] if overrides.save_preset(name, rules): overrides.set_active_preset(doc, name) self._cached_overrides = None self._send_state(force=True) try: b = sc.sticky.get("overrides_bridge") if b is not None: b._send_state() except Exception as ex: print("[OBERLEISTE] notify overrides:", ex) elif t == "OPEN_OVERRIDES_PANEL": try: import System import Rhino.UI as RhinoUI RhinoUI.Panels.OpenPanel(System.Guid(OVERRIDES_PANEL_GUID_STR)) except Exception as ex: print("[OBERLEISTE] OpenPanel Overrides:", ex) # --- Command-Line Integration ----------------------------------- elif t == "RUN_COMMAND": cmd = (p.get("cmd") or "").strip() if cmd: # Auto-Praefix mit "_" falls nicht vorhanden, damit auch # lokalisierte Rhino-Installationen die EN-Namen verstehen. if not (cmd.startswith("_") or cmd.startswith("'")): cmd = "_" + cmd try: # WICHTIG: Mac Rhinos Command-Bar sammelt parallel # User-Keystrokes (globaler Keyhook). Wenn unsere React- # Eingabe tippt landet die da auch. ESC clearen sonst # haben wir doppelten Text und braucht 2x Enter. Rhino.RhinoApp.SendKeystrokes("\x1b", False) Rhino.RhinoApp.RunScript(cmd, False) except Exception as ex: print("[OBERLEISTE] RunScript-Fehler:", ex) elif t == "SEND_KEYS": text = p.get("text") or "" append_enter = bool(p.get("enter", True)) try: # Ebenfalls Buffer zuerst leeren wenn User parallel mitgetippt hat if text: Rhino.RhinoApp.SendKeystrokes("\x1b", False) Rhino.RhinoApp.SendKeystrokes(text, append_enter) except Exception as ex: print("[OBERLEISTE] SendKeystrokes-Fehler:", ex) elif t == "CANCEL_COMMAND": try: # Doppel-ESC: einmal um Eingabe-Buffer zu clearen, einmal um # aktiven Befehl abzubrechen Rhino.RhinoApp.SendKeystrokes("\x1b", False) Rhino.RhinoApp.SendKeystrokes("\x1b", False) except Exception: pass elif t == "TOGGLE_RHINO_CMD_LINE": # Versucht Rhinos eigene Befehlszeile/History zu togglen. # Mehrere Wege probieren — je nach Version greift einer. for c in ( "_-CommandPrompt _Hide _Enter", "_CommandHistory _Toggle _Enter", "_-Toolbar _Hide _Commands _Enter", ): try: Rhino.RhinoApp.RunScript(c, False) except Exception: pass def _send_state(self, force=False): doc, vp = massstab._active_vp() info = massstab._compute_scale(doc, vp) # Massstab-State (Scale, Print-Toggle, DPI) info["viewMode"] = _get_active_viewport_name() info["displayMode"] = _get_active_display_mode_name() # displayModes-Liste nur einmal initial mitsenden — aendert sich kaum if not getattr(self, "_dm_sent", False): info["displayModes"] = _list_display_modes() self._dm_sent = True # Snap-State info.update(_get_snap_state()) # Overrides-State — cached, invalidiert bei TOGGLE_OVERRIDES und # SET_OVERRIDES_PRESET. Bei manuellen Aenderungen via OVERRIDES-Panel # bleibt der Cache stale bis zum naechsten Toggle — pragmatischer # Trade-off, weil die beiden Bridges nicht direkt voneinander wissen. if self._cached_overrides is None: try: cfg = overrides.load_config(doc) presets = [item.get("name") for item in overrides.list_presets() if item.get("name")] self._cached_overrides = ( bool(cfg.get("enabled")), len(cfg.get("rules") or []), cfg.get("activePreset"), tuple(presets), ) except Exception: self._cached_overrides = (False, 0, None, ()) (info["overridesEnabled"], info["overridesCount"], info["overridesActivePreset"], _presets_tuple) = self._cached_overrides info["overridesPresets"] = list(_presets_tuple) # Command-Line State prompt = _get_command_prompt() info["cmdPrompt"] = prompt info["cmdOptions"] = _parse_command_options(prompt) # Command-Autocomplete-Liste — nur einmal initial schicken (gross) if not getattr(self, "_commands_sent", False): info["allCommands"] = self._all_commands self._commands_sent = True force = True # Erste Push immer feuern # Diff-Check: wenn weder Daten noch force, gar nichts schicken # (dedupe Idle-Ticks ohne Aenderung — spart WebView-ExecuteScript Roundtrip) sig = ( info.get("scale"), info.get("appliedScale"), info.get("parallel"), info.get("viewMode"), info.get("displayMode"), info.get("ortho"), info.get("gridSnap"), info.get("osnap"), info.get("showLineweights"), info["overridesEnabled"], info["overridesCount"], info.get("overridesActivePreset"), tuple(info.get("overridesPresets") or ()), prompt, ) if not force and sig == self._last_state_sig: return self._last_state_sig = sig self.send("STATE", info) def tick_idle(self): # Command-Prompt aendert sich oft schnell -> separater Pfad: wenn sich # der Prompt seit letztem Tick geaendert hat, sofort pushen (ungedrosselt). cur_prompt = _get_command_prompt() if cur_prompt != self._last_prompt: self._last_prompt = cur_prompt self._send_state(force=True) self._idle_counter = 0 return # Sonst: normaler throttle fuer den restlichen State self._idle_counter += 1 if self._idle_counter < massstab._IDLE_THROTTLE: return self._idle_counter = 0 self._send_state(force=False) # --- Listener-Hookup -------------------------------------------------------- def _install_listeners(bridge): flag = "oberleiste_listeners" sc.sticky["oberleiste_bridge"] = bridge if sc.sticky.get(flag): return def on_idle(s, e): b = sc.sticky.get("oberleiste_bridge") if b is not None: try: b.tick_idle() except Exception: pass def on_view_change(*args): b = sc.sticky.get("oberleiste_bridge") if b is not None: try: b._send_state(force=True) except Exception: pass Rhino.RhinoApp.Idle += on_idle Rhino.RhinoDoc.ActiveDocumentChanged += on_view_change sc.sticky[flag] = True print("[OBERLEISTE] Listener aktiv") def _bridge_factory(): b = OberleisteBridge() _install_listeners(b) return b panel_base.register_and_open("oberleiste", "OBERLEISTE", PANEL_GUID_STR, _bridge_factory, icon_spec=("O", "#2f5d54"))