From b69dd8e27934c876385ba4d9a60108fb330dc8f7 Mon Sep 17 00:00:00 2001 From: karim Date: Wed, 20 May 2026 20:41:50 +0200 Subject: [PATCH] Kamera-Panel + Iso-Button in der Oberleiste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oberleiste: - View-Gruppe: Iso-Button neu zwischen Right und Persp - matchView: Iso = parallel ohne orthogonalen Standard-Namen, Perspektive = !parallel — beide via Projektions-Flag unterschieden (Rhino-Viewport-Name ist oft "Perspective" fuer beide) - Camera-Knopf (Icon: videocam) oeffnet das neue Kamera-Panel - SET_VIEW Backend: 'Iso' faelltt auf kamera._set_iso(vp, "NE") - OPEN_KAMERA_PANEL Handler Kamera-Panel (neu — rhino/kamera.py + src/KameraApp.jsx): - Viewport-Name + Projektions-Toggle (Persp/Parallel) - 4 Iso-Quick-Buttons (NW/NE/SE/SW) — true-iso 35°/45°, Kamera-Distanz auto aus Szenen-BBox - Vec3-Felder fuer Kamera-Position + Blick-Ziel (numerisch editierbar, m) - Distanz read-only - Brennweite (mm) bei Persp, Frustum-Breite (m) bei Parallel - Zoom-Extents-Button - Presets: speichern + anwenden + loeschen, persistiert in doc.Strings["dossier_kamera_presets"] (JSON) - Eto-Form-Satelliten-Fenster (420x600) via panel_base.open_satellite_window Co-Authored-By: Claude Opus 4.7 --- rhino/kamera.py | 290 +++++++++++++++++++++++++++++++++++++++++ rhino/oberleiste.py | 19 +++ src/KameraApp.jsx | 284 ++++++++++++++++++++++++++++++++++++++++ src/OberleisteApp.jsx | 30 ++++- src/lib/rhinoBridge.js | 10 ++ src/main.jsx | 2 + 6 files changed, 630 insertions(+), 5 deletions(-) create mode 100644 rhino/kamera.py create mode 100644 src/KameraApp.jsx diff --git a/rhino/kamera.py b/rhino/kamera.py new file mode 100644 index 0000000..6fda821 --- /dev/null +++ b/rhino/kamera.py @@ -0,0 +1,290 @@ +#! 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" + + +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. + octant: 'NE' | 'NW' | 'SE' | 'SW' — die XY-Diagonale aus der die Kamera blickt. + Winkel: 35.264° (true iso) vertikal, 45° horizontal.""" + if vp is None: return + try: + # Aktuelle Szenen-BBox als Target-Mitte + doc = Rhino.RhinoDoc.ActiveDoc + bb = doc.Objects.GetSelectedObjects(False, False) + # Falls keine Selektion: ganze Szene + try: + bbox = doc.Objects.BoundingBoxCorners + except Exception: + bbox = None + 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 + + sign_x = 1 if "E" in octant.upper() else -1 + sign_y = 1 if "N" in octant.upper() else -1 + + # True iso direction: (sign_x, sign_y, 1) normalized * distance + dx = sign_x; dy = sign_y; 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), + } + + +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_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) diff --git a/rhino/oberleiste.py b/rhino/oberleiste.py index 317ad41..643f5fd 100644 --- a/rhino/oberleiste.py +++ b/rhino/oberleiste.py @@ -878,6 +878,25 @@ class OberleisteBridge(panel_base.BaseBridge): if v in ("Top", "Front", "Right", "Perspective", "Left", "Back", "Bottom"): _run("_-{} _Enter".format(v)) self._send_state(force=True) + elif v == "Iso": + # Standard-Architektur-Isometrie aus NE, true-iso 35°/45°. + # Implementiert in kamera.py — Logik dort, damit Kamera- + # Panel und Topbar dieselbe Berechnung teilen. + try: + import kamera + vp = kamera._active_viewport() + kamera._set_iso(vp, "NE") + except Exception as ex: + print("[OBERLEISTE] iso:", ex) + self._send_state(force=True) + + # --- Kamera-Panel oeffnen --------------------------------------- + elif t == "OPEN_KAMERA_PANEL": + try: + import kamera + kamera.open_as_window() + except Exception as ex: + print("[OBERLEISTE] open kamera:", ex) # --- Display-Mode ----------------------------------------------- elif t == "SET_DISPLAY_MODE": diff --git a/src/KameraApp.jsx b/src/KameraApp.jsx new file mode 100644 index 0000000..f3966f9 --- /dev/null +++ b/src/KameraApp.jsx @@ -0,0 +1,284 @@ +import { useState, useEffect } from 'react' +import Icon from './components/Icon' +import { + onMessage, notifyReady, + setKameraViewport, setKameraProjection, setKameraIso, + kameraZoomExtents, saveKameraPreset, applyKameraPreset, deleteKameraPreset, +} from './lib/rhinoBridge' + +const labelXs = { + fontSize: 9, color: 'var(--text-muted)', + textTransform: 'uppercase', letterSpacing: '0.06em', + fontWeight: 600, +} + +function NumberField({ label, value, onCommit, suffix, step = 0.1 }) { + const [draft, setDraft] = useState(value != null ? value.toFixed(3) : '') + useEffect(() => { + setDraft(value != null ? value.toFixed(3) : '') + }, [value]) + const commit = () => { + const n = parseFloat(draft) + if (!isNaN(n)) onCommit(n) + else setDraft(value != null ? value.toFixed(3) : '') + } + return ( +
+ {label} +
+ setDraft(ev.target.value)} + onBlur={commit} + onKeyDown={(ev) => { + if (ev.key === 'Enter') commit() + if (ev.key === 'Escape') setDraft(value != null ? value.toFixed(3) : '') + }} + style={{ flex: 1, fontSize: 11, padding: '4px 8px', + fontFamily: 'var(--font-mono)' }} + /> + {suffix && ( + + {suffix} + + )} +
+
+ ) +} + +function Vec3Field({ label, value, onCommit }) { + const v = value || [0, 0, 0] + return ( +
+ {label} +
+ {['X', 'Y', 'Z'].map((axis, i) => ( + { + const next = [v[0], v[1], v[2]] + next[i] = n + onCommit(next) + }} + /> + ))} +
+
+ ) +} + +export default function KameraApp() { + const [vp, setVp] = useState(null) + const [presets, setPresets] = useState([]) + const [presetName, setPresetName] = useState('') + + useEffect(() => { + onMessage('STATE', (s) => { + setVp(s.viewport || null) + setPresets(s.presets || []) + }) + notifyReady() + }, []) + + if (!vp) { + return ( +
+ Kein aktiver Viewport. +
+ ) + } + + const isPar = !!vp.parallel + const updateLoc = (loc) => setKameraViewport({ loc }) + const updateTgt = (target) => setKameraViewport({ target }) + const updateLens = (lensMm) => setKameraViewport({ lensMm }) + const updateFW = (frustumW) => setKameraViewport({ frustumW }) + + const saveCurrent = () => { + const n = (presetName || '').trim() + if (!n) return + saveKameraPreset(n) + setPresetName('') + } + + return ( +
+ {/* Header: Viewport-Name + Projektion-Toggle */} +
+ Viewport +
+ {vp.name || 'Unnamed'} +
+
+ + +
+
+
+ + {/* Iso-Quick-Picker */} +
+ Isometrie (Standard, true-iso 35°/45°) +
+ {[ + { v: 'NW', label: 'NW' }, + { v: 'NE', label: 'NE' }, + { v: 'SE', label: 'SE' }, + { v: 'SW', label: 'SW' }, + ].map(o => ( + + ))} +
+
+ + {/* Kamera-Position + Target */} + + + + {/* Distance read-only */} +
+ Distanz + + {vp.distance != null ? vp.distance.toFixed(2) + ' m' : '—'} + +
+ + {/* Linse / Frustum je nach Projektion */} + {isPar ? ( + + ) : ( + + )} + + + + {/* Presets */} +
+ Presets +
+ setPresetName(ev.target.value)} + onKeyDown={(ev) => { if (ev.key === 'Enter') saveCurrent() }} + style={{ flex: 1, fontSize: 11, padding: '4px 8px' }} + /> + +
+ {presets.length === 0 ? ( + + Keine Presets gespeichert. + + ) : ( +
+ {presets.map(p => ( +
+ + {p.name} + + {p.parallel ? 'Par' : 'Persp'} + + +
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/OberleisteApp.jsx b/src/OberleisteApp.jsx index 386a0bf..377ae1b 100644 --- a/src/OberleisteApp.jsx +++ b/src/OberleisteApp.jsx @@ -9,7 +9,7 @@ import { toggleOverrides, setOverridesPreset, openOverridesPanel, pickLayerCombination, saveLayerCombination, deleteLayerCombination, openLayerCombinationsDialog, - openDossierSettings, + openDossierSettings, openKameraPanel, } from './lib/rhinoBridge' const PRESETS = [ @@ -24,7 +24,8 @@ const VIEWS = [ { value: 'Top', icon: 'view_quilt', label: 'Top' }, { value: 'Front', icon: 'north', label: 'Front' }, { value: 'Right', icon: 'east', label: 'Right' }, - { value: 'Perspective', icon: 'view_in_ar', label: 'Persp' }, + { value: 'Iso', icon: 'view_in_ar', label: 'Iso' }, + { value: 'Perspective', icon: '3d_rotation', label: 'Persp' }, ] function fmtScale(s) { @@ -186,10 +187,20 @@ export default function OberleisteApp() { if (appliedScale && appliedScale > 0) setMassstab(appliedScale) } - // Aktuelles View-Match (manche User haben "Top" / "Right" als view name) + // Aktuelles View-Match. Orthogonale Standard-Views matchen via viewName. + // 'Iso' und 'Perspective' werden via parallel-Flag unterschieden — die + // Rhino-Viewport-Namen sind beide oft "Perspective". const matchView = (v) => { - if (!state.viewName) return false - return state.viewName === v || state.viewName.toLowerCase() === v.toLowerCase() + const name = (state.viewName || '').toLowerCase() + const ortho = ['top', 'front', 'right', 'bottom', 'left', 'back'] + if (v === 'Iso') { + // Parallel-Projektion + kein orthogonaler Standardname → Iso + return state.parallel === true && !ortho.includes(name) + } + if (v === 'Perspective') { + return state.parallel === false + } + return name === v.toLowerCase() } // (Command-Bar wurde entfernt — Rhinos eigene Command-Line wird benutzt.) @@ -264,6 +275,15 @@ export default function OberleisteApp() { title={`Ansicht ${v.label}`} /> ))} +
diff --git a/src/lib/rhinoBridge.js b/src/lib/rhinoBridge.js index 16927f9..7feffa5 100644 --- a/src/lib/rhinoBridge.js +++ b/src/lib/rhinoBridge.js @@ -169,6 +169,16 @@ export function toggleOverrides(on) { send('TOGGLE_OVERRIDES', { enabled: ! export function setOverridesPreset(name) { send('SET_OVERRIDES_PRESET', { name: name || null }) } export function saveOverridesPreset(name) { send('SAVE_OVERRIDES_PRESET', { name }) } export function openOverridesPanel() { send('OPEN_OVERRIDES_PANEL', {}) } +export function openKameraPanel() { send('OPEN_KAMERA_PANEL', {}) } + +// --- Kamera-Panel --- +export function setKameraViewport(state) { send('SET_VIEWPORT', { ...state }) } +export function setKameraProjection(parallel) { send('SET_PROJECTION', { parallel: !!parallel }) } +export function setKameraIso(octant) { send('SET_ISO', { octant: octant || 'NE' }) } +export function kameraZoomExtents() { send('ZOOM_EXTENTS', {}) } +export function saveKameraPreset(name) { send('SAVE_PRESET', { name }) } +export function applyKameraPreset(id) { send('APPLY_PRESET', { id }) } +export function deleteKameraPreset(id) { send('DELETE_PRESET', { id }) } // Ebenenkombinationen (gehosted in Oberleiste, gleicher Store wie EBENEN) export function pickLayerCombination(name) { send('PICK_LAYER_COMBINATION', { name: name || null }) } export function saveLayerCombination(name) { send('SAVE_LAYER_COMBINATION', { name }) } diff --git a/src/main.jsx b/src/main.jsx index 99e1196..6d8f185 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -11,6 +11,7 @@ import AusschnittSettingsApp from './AusschnittSettingsApp.jsx' import LayoutDialogApp from './LayoutDialogApp.jsx' import SwisstopoApp from './SwisstopoApp.jsx' import OsmApp from './OsmApp.jsx' +import KameraApp from './KameraApp.jsx' import GestaltungApp from './GestaltungApp.jsx' import AusschnitteApp from './AusschnitteApp.jsx' import MassstabApp from './MassstabApp.jsx' @@ -40,6 +41,7 @@ const RootApp = mode === 'gestaltung' ? GestaltungApp : mode === 'layout_dialog' ? LayoutDialogApp : mode === 'swisstopo' ? SwisstopoApp : mode === 'osm' ? OsmApp + : mode === 'kamera' ? KameraApp : App window.onerror = function (msg, src, line, col, err) {