#! python 3 # -*- coding: utf-8 -*- """ kamera.py Kamera-Panel: liest/setzt Viewport-Kamera (Position, Target, Projektion, FOV/Frustum, Linse). Persistiert Presets in doc.Strings unter `dossier_kamera_presets` (JSON-Liste). """ import os import sys import json import math import uuid import Rhino import Rhino.Geometry as rg 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 PANEL_GUID_STR = "9c3f8a2d-7b4e-4a6f-9c12-1d4e5f6a7b89" _PRESETS_KEY = "dossier_kamera_presets" _NORTH_KEY = "dossier_north_angle" # Grad im Uhrzeigersinn von +Y # --------------------------------------------------------------------------- # Norden-Rotation: Default +Y = Norden. Bei rotierten Projekten (Site-Plaene, # swissBUILDINGS in LV95-Orientierung) kann der User einen Winkel definieren. # Wirkt auf N/O/S/W-View-Buttons + (optional) Iso-Octanten. def get_north_angle(doc): if doc is None: return 0.0 try: v = doc.Strings.GetValue(_NORTH_KEY) return float(v) if v else 0.0 except Exception: return 0.0 def set_north_angle(doc, angle): if doc is None: return try: a = float(angle) % 360.0 doc.Strings.SetString(_NORTH_KEY, "{:.3f}".format(a)) except Exception as ex: print("[KAMERA] set north:", ex) def _scene_target_and_diag(doc): """Centroid der Szenen-BBox + Diagonal-Laenge. Fallback (0,0,0)/50m.""" target = rg.Point3d(0, 0, 0) diag = 50.0 try: scene_bb = None for obj in doc.Objects: if obj is None: continue gb = obj.Geometry.GetBoundingBox(True) if not gb.IsValid: continue if scene_bb is None: scene_bb = gb else: scene_bb.Union(gb) if scene_bb is not None and scene_bb.IsValid: target = scene_bb.Center diag = max(scene_bb.Diagonal.Length, 10.0) except Exception: pass return target, diag def set_cardinal_view(vp, cardinal): """Setzt N/O/S/W-Ansicht unter Beruecksichtigung der Norden-Rotation. cardinal: 'N' | 'O' | 'S' | 'W'.""" if vp is None: return doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return try: north_deg = get_north_angle(doc) # Norden-Einheitsvektor (Uhrzeigersinn-Rotation von +Y aus Top-View): # angle=0 → (0,1,0); angle=90 → (1,0,0); angle=180 → (0,-1,0). nrad = math.radians(north_deg) north = (math.sin(nrad), math.cos(nrad)) east = (math.cos(nrad), -math.sin(nrad)) c = cardinal.upper() if c == "N": dx, dy = north elif c == "S": dx, dy = -north[0], -north[1] elif c == "O" or c == "E": dx, dy = east elif c == "W": dx, dy = -east[0], -east[1] else: return target, diag = _scene_target_and_diag(doc) dist = diag * 1.6 # Kamera auf Hoehe des Target-Z (echte Elevation, horizontal blickend) loc = rg.Point3d(target.X + dx * dist, target.Y + dy * dist, target.Z) if not vp.IsParallelProjection: vp.ChangeToParallelProjection(True) vp.SetCameraLocations(target, loc) # Camera-Up muss +Z sein (sonst kippt die Ansicht) try: vp.CameraUp = rg.Vector3d.ZAxis except Exception: pass try: vp.ZoomExtents() except Exception: pass Rhino.RhinoDoc.ActiveDoc.Views.ActiveView.Redraw() except Exception as ex: print("[KAMERA] set cardinal:", ex) def set_top_view(vp): """Plan-Ansicht (oben), rotiert nach Norden-Setting.""" if vp is None: return doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return try: target, diag = _scene_target_and_diag(doc) loc = rg.Point3d(target.X, target.Y, target.Z + diag * 2) if not vp.IsParallelProjection: vp.ChangeToParallelProjection(True) vp.SetCameraLocations(target, loc) # Plan-Norden zeigt nach oben im Viewport → Up-Vector = north north_deg = get_north_angle(doc) nrad = math.radians(north_deg) try: vp.CameraUp = rg.Vector3d(math.sin(nrad), math.cos(nrad), 0) except Exception: pass try: vp.ZoomExtents() except Exception: pass Rhino.RhinoDoc.ActiveDoc.Views.ActiveView.Redraw() except Exception as ex: print("[KAMERA] set top:", ex) def _read_viewport(vp): """Liest den aktuellen Zustand eines Viewports als dict.""" if vp is None: return None try: loc = vp.CameraLocation tgt = vp.CameraTarget is_par = bool(vp.IsParallelProjection) # FOV in Grad (vertical) — gilt nur fuer Perspective try: fov_v = float(vp.Camera35mmLensLength) # Linsen-Brennweite mm except Exception: fov_v = 50.0 # Frustum-Breite (m bei m-Doc) — gilt nur fuer Parallel try: f = vp.GetFrustum() # GetFrustum returns (bool ok, l, r, b, t, n, f) ok = bool(f[0]) if isinstance(f, tuple) else False if ok: _l = f[1]; _r = f[2] frustum_w = abs(_r - _l) else: frustum_w = 0.0 except Exception: frustum_w = 0.0 # Distanz Kamera→Target dx = loc.X - tgt.X; dy = loc.Y - tgt.Y; dz = loc.Z - tgt.Z dist = math.sqrt(dx*dx + dy*dy + dz*dz) return { "name": vp.Name or "", "parallel": is_par, "loc": [loc.X, loc.Y, loc.Z], "target": [tgt.X, tgt.Y, tgt.Z], "lensMm": fov_v, "frustumW": frustum_w, "distance": dist, } except Exception as ex: print("[KAMERA] read viewport:", ex) return None def _active_viewport(): try: v = Rhino.RhinoDoc.ActiveDoc.Views.ActiveView return v.ActiveViewport if v else None except Exception: return None def _set_viewport(vp, data): """Schreibt Position/Target/Projektion/Linse zurueck.""" if vp is None or not isinstance(data, dict): return try: # Projektion zuerst — danach Frustum/Linse konsistent if "parallel" in data: par = bool(data.get("parallel")) if par != bool(vp.IsParallelProjection): if par: vp.ChangeToParallelProjection(True) else: vp.ChangeToPerspectiveProjection(True, 50.0) loc_cur = vp.CameraLocation tgt_cur = vp.CameraTarget loc_arr = data.get("loc") tgt_arr = data.get("target") if isinstance(loc_arr, list) and len(loc_arr) == 3: loc_cur = rg.Point3d(float(loc_arr[0]), float(loc_arr[1]), float(loc_arr[2])) if isinstance(tgt_arr, list) and len(tgt_arr) == 3: tgt_cur = rg.Point3d(float(tgt_arr[0]), float(tgt_arr[1]), float(tgt_arr[2])) vp.SetCameraLocations(tgt_cur, loc_cur) # Linse / Frustum if not vp.IsParallelProjection and "lensMm" in data: try: lens = float(data.get("lensMm") or 50.0) vp.Camera35mmLensLength = lens except Exception: pass if vp.IsParallelProjection and "frustumW" in data: try: w = float(data.get("frustumW") or 0.0) if w > 0: # Rhino hat keine direkte SetFrustumWidth — wir nutzen # SetViewProjection-Hack via SetFrustum (l, r, b, t, n, f) cur = vp.GetFrustum() if isinstance(cur, tuple) and len(cur) >= 7 and cur[0]: l_cur, r_cur, b_cur, t_cur, n_cur, f_cur = cur[1], cur[2], cur[3], cur[4], cur[5], cur[6] cur_w = abs(r_cur - l_cur) if cur_w > 1e-9: ratio = w / cur_w mid_lr = (l_cur + r_cur) / 2.0 mid_bt = (b_cur + t_cur) / 2.0 half_w = w / 2.0 half_h = abs(t_cur - b_cur) / 2.0 * ratio vp.SetFrustum(mid_lr - half_w, mid_lr + half_w, mid_bt - half_h, mid_bt + half_h, n_cur, f_cur) except Exception as ex: print("[KAMERA] frustum set:", ex) try: Rhino.RhinoDoc.ActiveDoc.Views.ActiveView.Redraw() except Exception: pass except Exception as ex: print("[KAMERA] set viewport:", ex) def _set_iso(vp, octant="NE"): """Setzt eine standard-architektonische Iso-Ansicht (35.26° vertikal, 45° horizontal). octant: 'NE' | 'NW' | 'SE' | 'SW' — die XY-Diagonale relativ zum Norden des Projekts (= rotiert mit dossier_north_angle).""" if vp is None: return doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return try: target, diag = _scene_target_and_diag(doc) # Octant-Basisvektor (vor Norden-Rotation): NE = (+1,+1) sign_e = 1 if "E" in octant.upper() else -1 sign_n = 1 if "N" in octant.upper() else -1 north_deg = get_north_angle(doc) nrad = math.radians(north_deg) # Norden-Vektor + Osten-Vektor im Welt-Koordinatensystem north_vec = (math.sin(nrad), math.cos(nrad)) east_vec = (math.cos(nrad), -math.sin(nrad)) # Iso-XY-Richtung = sign_e * east + sign_n * north dx = sign_e * east_vec[0] + sign_n * north_vec[0] dy = sign_e * east_vec[1] + sign_n * north_vec[1] dz = 1.0 L = math.sqrt(dx*dx + dy*dy + dz*dz) dist = diag * 1.6 loc = rg.Point3d(target.X + dx/L * dist, target.Y + dy/L * dist, target.Z + dz/L * dist) if not vp.IsParallelProjection: vp.ChangeToParallelProjection(True) vp.SetCameraLocations(target, loc) try: vp.ZoomExtents() except Exception: pass Rhino.RhinoDoc.ActiveDoc.Views.ActiveView.Redraw() except Exception as ex: print("[KAMERA] set iso:", ex) def _load_presets(doc): try: raw = doc.Strings.GetValue(_PRESETS_KEY) if not raw: return [] items = json.loads(raw) return items if isinstance(items, list) else [] except Exception: return [] def _save_presets(doc, items): try: doc.Strings.SetString(_PRESETS_KEY, json.dumps(items)) except Exception as ex: print("[KAMERA] save presets:", ex) def _payload(): doc = Rhino.RhinoDoc.ActiveDoc vp = _active_viewport() return { "viewport": _read_viewport(vp), "presets": _load_presets(doc), "northAngle": get_north_angle(doc), } class KameraBridge(panel_base.BaseBridge): def __init__(self): panel_base.BaseBridge.__init__(self, "kamera") def _on_ready(self): self._send_state() def _send_state(self): self.send("STATE", _payload()) 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 = {} doc = Rhino.RhinoDoc.ActiveDoc if t == "READY" or t == "REQUEST_STATE": self._on_ready() elif t == "SET_VIEWPORT": vp = _active_viewport() _set_viewport(vp, p) self._send_state() elif t == "SET_ISO": vp = _active_viewport() _set_iso(vp, p.get("octant") or "NE") self._send_state() elif t == "SET_NORTH_ANGLE": set_north_angle(Rhino.RhinoDoc.ActiveDoc, p.get("angle") or 0) self._send_state() # Topbar refreshen damit dort der neue Winkel sichtbar ist try: b = sc.sticky.get("oberleiste_bridge") if b is not None: b._send_state(force=True) except Exception: pass elif t == "SET_PROJECTION": vp = _active_viewport() if vp is not None: par = bool(p.get("parallel")) if par: vp.ChangeToParallelProjection(True) else: vp.ChangeToPerspectiveProjection(True, 50.0) try: Rhino.RhinoDoc.ActiveDoc.Views.ActiveView.Redraw() except Exception: pass self._send_state() elif t == "ZOOM_EXTENTS": vp = _active_viewport() if vp is not None: try: vp.ZoomExtents() except Exception: pass try: Rhino.RhinoDoc.ActiveDoc.Views.ActiveView.Redraw() except Exception: pass self._send_state() elif t == "SAVE_PRESET": name = (p.get("name") or "").strip() or "Preset" vp = _active_viewport() state = _read_viewport(vp) if state is None: return items = _load_presets(doc) entry = { "id": "kp_" + uuid.uuid4().hex[:8], "name": name, "parallel": state["parallel"], "loc": state["loc"], "target": state["target"], "lensMm": state["lensMm"], "frustumW": state["frustumW"], } items.append(entry) _save_presets(doc, items) self._send_state() elif t == "APPLY_PRESET": pid = p.get("id") items = _load_presets(doc) entry = next((x for x in items if x.get("id") == pid), None) if entry is None: return vp = _active_viewport() _set_viewport(vp, entry) self._send_state() elif t == "DELETE_PRESET": pid = p.get("id") items = [x for x in _load_presets(doc) if x.get("id") != pid] _save_presets(doc, items) self._send_state() def open_as_window(): """Oeffnet KAMERA als Eto-Form + WebView (analog Overrides).""" b = KameraBridge() sc.sticky["kamera_bridge"] = b panel_base.open_satellite_window( "kamera", title="Kamera", size=(420, 600), bridge=b)