205c626a5a
User-Vorschlag: Architektur-konformes View-Layout — 3D-Views oben, 4 Gebaeudeansichten unten. Plus Norden-Rotation als Doc-Setting damit bei rotierten Projekten (swissBUILDINGS, Sonnenberechnungen) die richtigen Wandansichten gepickt werden. Backend (rhino/kamera.py): - get_north_angle/set_north_angle — doc.Strings["dossier_north_angle"] (Grad im Uhrzeigersinn von +Y, default 0°) - _scene_target_and_diag(doc) — gemeinsamer Helper fuer Szenen-Center + Diagonal-Distanz - set_cardinal_view(vp, 'N'|'O'|'S'|'W'): rotiert Kamera-Position via Norden-Vektor. Parallel-Projektion, Camera-Z = Target-Z (echte Elevation), Up-Vektor +Z. - set_top_view(vp): Plan-Ansicht mit Norden = Up-Vektor (Plan rotiert visuell wenn Norden != +Y) - _set_iso(vp, octant): Octant-Richtung jetzt aus north+east-Vektoren konstruiert → ISO rotiert mit Norden mit - Bridge-Handler SET_NORTH_ANGLE + state.northAngle, notify Oberleiste Backend (oberleiste.py): - SET_VIEW erweitert: Top → kamera.set_top_view, N/O/S/W → kamera.set_cardinal_view, Iso → kamera._set_iso. Front/Right/etc bleibt als Legacy direkt-Rhino-Call. - State liefert northAngle Frontend (OberleisteApp): - VIEWS_ROW1: TOP/ISO/PERSP + Kamera-Settings-Button (Icons only) - VIEWS_ROW2: N/O/S/W als DM-Mono-Buchstaben - 2x4-Grid, VIEW_W=140 (konsistent mit Massstab-Pills), CELL_W=35 - matchView nur fuer Top/Iso/Perspective; Cardinals haben keinen Active-State (Viewport-Name ist nicht zuverlaessig erkennbar) Frontend (KameraApp): - Plan-Norden Section mit Number-Input (Grad, 0.5°-Step) + Reset-Button - Hinweis-Text dass Wirkung auf TOP/ISO/N/O/S/W geht Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
393 lines
14 KiB
Python
393 lines
14 KiB
Python
#! 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)
|