Files
DOSSIER/rhino/dimensionen.py
T
karim 2ee4688fe3 Masse-Dropdown in Oberleiste + Satellite-Settings statt Dimensionen-Inline
User-Feedback: Mass-Style passt nicht ins Dimensionen-Panel, und der
Name "Mass-Style" gefaellt nicht. Umzug in die Oberleiste (analog Display)
+ Zahnrad oeffnet eigenes Settings-Fenster. UI-Begriff jetzt "Masse".

Frontend:
- OberleisteApp: neue Gruppe "Masse" mit Preset-Dropdown + Zahnrad-Button
  zwischen Display und Massstab
- MasseSettingsApp.jsx (neu): Satellite-Fenster mit Name/Raum-Rundung/
  Mass-Dezimalstellen/Mass-Einheit + Picker + Add/Delete
- DimensionenApp: MassStyleSection raus
- rhinoBridge: setMasseActive + openMasseSettings (Topbar);
  masseSetActive/masseSavePreset/masseDeletePreset (Settings-Fenster)

Backend:
- rhino/masse_settings.py (neu): Bridge fuer das Satellite-Fenster,
  Topics SET_ACTIVE / SAVE / DELETE, triggert regen_all_rooms + topbar refresh
- mass_style.regen_all_rooms(doc): neue cross-modul-Helper, queued
  Raum-Regen fuer alle raum_outline-Objekte
- oberleiste.py: massePresets + masseActiveId im State, SET_MASSE_ACTIVE
  + OPEN_MASSE_SETTINGS handler, Signature update
- dimensionen.py: Mass-Style-Endpoints + State raus (sind jetzt im
  OberleisteBridge bzw. MasseSettingsBridge)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:29:23 +02:00

621 lines
23 KiB
Python

#! python 3
# -*- coding: utf-8 -*-
"""
dimensionen.py
DIMENSIONEN-Panel: Object Info Palette nach Vectorworks-Vorbild.
Zeigt Position + Abmessungen der Selektion an und erlaubt direktes Eintippen
mit 9-Punkt-Referenz, World/CPlane-Modus und Shape-spezifischen Feldern
(Kreis, Linie, Rechteck).
"""
import os
import sys
import math
import Rhino
import Rhino.Geometry as rg
import System
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 = "9e3c8c5d-6d4a-4f3e-b3c5-d4e5f6071a2c"
# Idle-Polling fuer geometrische Aenderungen (Gumball-Move feuert keine
# SelectObjects-Events). Tick alle N Idle-Calls — N hoeher = weniger CPU.
_IDLE_GEOM_POLL = 8
# --- Geometrie-Helpers ------------------------------------------------------
def _get_selected_objects(doc):
"""Liste aller aktuell selektierten RhinoObjects."""
if doc is None: return []
try:
return list(doc.Objects.GetSelectedObjects(False, False))
except Exception:
return []
def _get_cplane(doc):
"""Aktive Construction Plane oder Plane.WorldXY als Fallback."""
try:
v = doc.Views.ActiveView
if v is not None:
cp = v.ActiveViewport.ConstructionPlane()
if cp is not None and cp.Plane.IsValid:
return cp.Plane
except Exception:
pass
return rg.Plane.WorldXY
def _active_plane(doc, mode):
"""Plane fuer die aktuelle Koordinatenangabe — World oder CPlane."""
if mode == "cplane":
return _get_cplane(doc)
return rg.Plane.WorldXY
def _bbox_in_plane(objs, plane):
"""BBox aller selektierten Objekte im Koordinatensystem der gegebenen
Plane (achsen-aligned zur Plane). Liefert (BoundingBox, plane) oder None."""
if not objs:
return None
# World -> Plane Transform anwenden -> BBox in plane-Koordinaten
xform = rg.Transform.PlaneToPlane(plane, rg.Plane.WorldXY)
bbox = rg.BoundingBox.Empty
for obj in objs:
try:
geom = obj.Geometry
if geom is None: continue
bb = geom.GetBoundingBox(xform)
if bb.IsValid:
bbox.Union(bb)
except Exception:
pass
return bbox if bbox.IsValid else None
def _ref_point_local(bbox, ref):
"""Referenzpunkt in plane-lokalen Koordinaten anhand ref-Dict
{x: 'min'|'mid'|'max', y: ..., z: ...}."""
def axis(amin, amax, code):
if code == "min": return amin
if code == "max": return amax
return (amin + amax) * 0.5
return rg.Point3d(
axis(bbox.Min.X, bbox.Max.X, ref.get("x", "min")),
axis(bbox.Min.Y, bbox.Max.Y, ref.get("y", "min")),
axis(bbox.Min.Z, bbox.Max.Z, ref.get("z", "mid")),
)
def _ref_point_world(bbox_local, ref, plane):
"""Referenzpunkt in Welt-Koordinaten: lokal -> plane.PointAt."""
p_local = _ref_point_local(bbox_local, ref)
return plane.PointAt(p_local.X, p_local.Y, p_local.Z)
def _round(v, digits=4):
try:
return round(float(v), digits)
except Exception:
return v
# --- Shape-Detection --------------------------------------------------------
def _detect_shape(objs):
"""Erkennt spezifische Formen: Kreis, Linie, Rechteck (geschlossene
planare Polyline mit 4 perpendikularen Segmenten). Liefert dict oder None.
Nur bei genau einem selektierten Curve-Objekt."""
if len(objs) != 1:
return None
obj = objs[0]
geom = obj.Geometry
if not isinstance(geom, rg.Curve):
return None
# Kreis?
try:
ok, circle = geom.TryGetCircle(0.001)
if ok and circle.IsValid:
return {
"type": "circle",
"radius": _round(circle.Radius, 4),
"center": [_round(circle.Center.X), _round(circle.Center.Y), _round(circle.Center.Z)],
}
except Exception:
pass
# Linie?
try:
if isinstance(geom, rg.LineCurve):
line = geom.Line
length = line.Length
angle_deg = math.degrees(math.atan2(line.Direction.Y, line.Direction.X))
return {
"type": "line",
"length": _round(length, 4),
"angle": _round(angle_deg, 3),
"start": [_round(line.From.X), _round(line.From.Y), _round(line.From.Z)],
"end": [_round(line.To.X), _round(line.To.Y), _round(line.To.Z)],
}
except Exception:
pass
# Rechteck als geschlossene Polyline mit 4 perpendikularen Segmenten?
try:
ok, poly = geom.TryGetPolyline()
if ok and poly is not None and poly.Count == 5 and poly[0].DistanceTo(poly[-1]) < 1e-6:
pts = [poly[i] for i in range(4)]
v0 = pts[1] - pts[0]
v1 = pts[2] - pts[1]
v2 = pts[3] - pts[2]
v3 = pts[0] - pts[3]
def _dot(a, b): return a.X * b.X + a.Y * b.Y + a.Z * b.Z
# Adjacente Kanten perpendikular?
if (abs(_dot(v0, v1)) < 1e-4 and
abs(_dot(v1, v2)) < 1e-4 and
abs(_dot(v2, v3)) < 1e-4):
w = v0.Length
h = v1.Length
return {
"type": "rectangle",
"width": _round(w, 4),
"height": _round(h, 4),
}
except Exception:
pass
return None
# --- Transform-Operationen --------------------------------------------------
def _apply_xform(doc, objs, xform):
"""Transform auf alle Objekte anwenden (in-place via ID)."""
if not xform.IsValid: return 0
n = 0
for obj in objs:
try:
if doc.Objects.Transform(obj.Id, xform, True):
n += 1
except Exception as ex:
print("[DIMENSIONEN] Transform-Fehler:", ex)
return n
# --- Undo-Wrapper -----------------------------------------------------------
# Ohne BeginUndoRecord/EndUndoRecord wird ein Multi-Objekt-Transform nicht
# zuverlaessig als ein einziger Undo-Schritt registriert — Ctrl+Z ueberspringt
# dann unsere Aenderung. Wir packen jede User-Aktion in einen benannten Record.
class _UndoRecord(object):
def __init__(self, doc, label):
self.doc = doc
self.label = label
self.serial = 0
def __enter__(self):
try:
self.serial = self.doc.BeginUndoRecord(self.label)
except Exception as ex:
print("[DIMENSIONEN] BeginUndoRecord:", ex)
self.serial = 0
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.serial:
try: self.doc.EndUndoRecord(self.serial)
except Exception as ex:
print("[DIMENSIONEN] EndUndoRecord:", ex)
return False # exceptions propagieren
def _translate_in_plane(doc, objs, plane, dx, dy, dz):
"""Verschiebt um (dx, dy, dz) in plane-lokalen Achsen."""
if dx == 0 and dy == 0 and dz == 0: return
delta_world = plane.XAxis * dx + plane.YAxis * dy + plane.ZAxis * dz
xform = rg.Transform.Translation(delta_world)
with _UndoRecord(doc, "Dossier: Position aendern"):
_apply_xform(doc, objs, xform)
doc.Views.Redraw()
def _scale_around_point(doc, objs, plane, ref_world, sx, sy, sz):
"""Skalierung mit eigenen Faktoren pro Achse, zentriert am Referenzpunkt,
ausgerichtet an plane."""
if sx == 1 and sy == 1 and sz == 1: return
if sx <= 0 or sy <= 0 or sz <= 0:
print("[DIMENSIONEN] Ungueltige Skalierungsfaktoren:", sx, sy, sz)
return
p = rg.Plane(plane)
p.Origin = ref_world
xform = rg.Transform.Scale(p, sx, sy, sz)
with _UndoRecord(doc, "Dossier: Abmessung aendern"):
_apply_xform(doc, objs, xform)
doc.Views.Redraw()
def _rotate_around_axis(doc, objs, ref_world, axis_dir, angle_deg):
"""Rotation um axis_dir durch ref_world."""
if angle_deg == 0: return
xform = rg.Transform.Rotation(math.radians(angle_deg), axis_dir, ref_world)
with _UndoRecord(doc, "Dossier: Drehen"):
_apply_xform(doc, objs, xform)
doc.Views.Redraw()
# --- Shape-Edit-Operationen -------------------------------------------------
def _set_circle_radius(doc, obj, new_radius, plane, ref_world):
"""Skaliert ein Kreis-Curve so, dass es genau new_radius hat — Referenz
bleibt fix. Wird ueber globale Scale realisiert, damit das Objekt
konsistent mit dem Rest selektierter Objekte transformiert wird."""
geom = obj.Geometry
ok, circle = geom.TryGetCircle(0.001)
if not ok or circle.Radius <= 0:
return False
factor = float(new_radius) / circle.Radius
if factor <= 0: return False
p = rg.Plane(plane)
p.Origin = ref_world
xform = rg.Transform.Scale(p, factor, factor, factor)
return bool(doc.Objects.Transform(obj.Id, xform, True))
def _set_line_length(doc, obj, new_length, ref_world):
"""Linie so verlaengern/verkuerzen, dass sie new_length hat. Skaliert
Linie entlang ihrer Richtung um den Referenzpunkt."""
geom = obj.Geometry
if not isinstance(geom, rg.LineCurve): return False
line = geom.Line
cur = line.Length
if cur <= 0: return False
factor = float(new_length) / cur
if factor <= 0: return False
# Linie skaliert sich nur entlang ihrer Direction. Scale-1D ueber eine
# Plane mit der Linien-Direction als X-Achse waere ideal — vereinfacht:
# uniformer Scale, falls Linie achsen-parallel zur lokalen X-Plane
# ist das aequivalent zu Length-Scaling.
xaxis = rg.Vector3d(line.Direction)
xaxis.Unitize()
yaxis = rg.Vector3d.CrossProduct(rg.Vector3d.ZAxis, xaxis)
if yaxis.Length < 1e-6:
yaxis = rg.Vector3d.YAxis
yaxis.Unitize()
plane = rg.Plane(ref_world, xaxis, yaxis)
xform = rg.Transform.Scale(plane, factor, 1.0, 1.0)
return bool(doc.Objects.Transform(obj.Id, xform, True))
def _set_rectangle_dims(doc, obj, new_w, new_h, plane, ref_world):
"""Skaliert Rechteck-Curve auf (new_w, new_h). Annahme: width = Laenge
erster Seite (v0), height = zweiter Seite (v1) in der Polyline-
Reihenfolge — entspricht der Reihenfolge aus _detect_shape."""
geom = obj.Geometry
if not isinstance(geom, rg.Curve): return False
ok, poly = geom.TryGetPolyline()
if not ok or poly is None or poly.Count != 5: return False
pts = [poly[i] for i in range(4)]
v0 = pts[1] - pts[0]
v1 = pts[2] - pts[1]
w_cur = v0.Length
h_cur = v1.Length
if w_cur <= 0 or h_cur <= 0: return False
sw = float(new_w) / w_cur
sh = float(new_h) / h_cur
if sw <= 0 or sh <= 0: return False
# Achsen des Rechtecks als Plane fuer den Scale
xaxis = rg.Vector3d(v0); xaxis.Unitize()
yaxis = rg.Vector3d(v1); yaxis.Unitize()
rect_plane = rg.Plane(ref_world, xaxis, yaxis)
xform = rg.Transform.Scale(rect_plane, sw, sh, 1.0)
return bool(doc.Objects.Transform(obj.Id, xform, True))
# --- Bridge -----------------------------------------------------------------
class DimensionenBridge(panel_base.BaseBridge):
def __init__(self):
panel_base.BaseBridge.__init__(self, "dimensionen")
self._ref = {"x": "min", "y": "min", "z": "mid"}
self._coord_sys = "world" # "world" | "cplane"
self._last_sig = None
self._last_ids = ()
self._idle_cnt = 0
def _on_ready(self):
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 = {}
if t == "READY": self._on_ready()
elif t == "REQUEST_STATE": self._send_state(force=True)
elif t == "SET_REF_POINT": self._set_ref_point(p)
elif t == "SET_COORD_SYSTEM": self._set_coord_system(p)
elif t == "SET_POSITION": self._set_position(p)
elif t == "SET_DIMENSION": self._set_dimension(p)
elif t == "SET_ROTATION_Z": self._set_rotation_z(p)
elif t == "SET_CIRCLE_RADIUS":self._set_circle_radius(p)
elif t == "SET_LINE_LENGTH": self._set_line_length(p)
elif t == "SET_RECTANGLE": self._set_rectangle(p)
# --- State-Snapshot -----------------------------------------------------
def _compute_state(self):
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None:
return {"selection": {"count": 0, "type": "none", "shape": None},
"refPoint": self._ref, "coordSystem": self._coord_sys}
objs = _get_selected_objects(doc)
plane = _active_plane(doc, self._coord_sys)
bbox_local = _bbox_in_plane(objs, plane)
# Typ der Selektion
typ = "none"
if len(objs) == 0:
typ = "none"
elif len(objs) == 1:
g = objs[0].Geometry
if isinstance(g, rg.Curve): typ = "curve"
elif isinstance(g, rg.Brep): typ = "brep"
elif isinstance(g, rg.Mesh): typ = "mesh"
elif isinstance(g, rg.Extrusion): typ = "extrusion"
elif isinstance(g, rg.InstanceReferenceGeometry): typ = "block"
elif isinstance(g, rg.Point): typ = "point"
elif isinstance(g, rg.TextEntity): typ = "text"
else: typ = "other"
else:
typ = "mixed"
out = {
"selection": {"count": len(objs), "type": typ},
"refPoint": self._ref,
"coordSystem": self._coord_sys,
"planeName": "CPlane" if self._coord_sys == "cplane" else "Welt",
}
shape = _detect_shape(objs)
out["shape"] = shape
if bbox_local is None:
out["position"] = None
out["dimensions"] = None
return out
# Position des Referenzpunkts (in plane-lokalen Koordinaten — das ist
# das, was der User typischerweise sehen will: World ist Plane=WorldXY,
# CPlane ist Plane=ActiveCPlane).
ref_local = _ref_point_local(bbox_local, self._ref)
out["position"] = {
"x": _round(ref_local.X, 4),
"y": _round(ref_local.Y, 4),
"z": _round(ref_local.Z, 4),
}
# Abmessungen (Plane-aligned BBox-Spannweite)
out["dimensions"] = {
"width": _round(bbox_local.Max.X - bbox_local.Min.X, 4),
"depth": _round(bbox_local.Max.Y - bbox_local.Min.Y, 4),
"height": _round(bbox_local.Max.Z - bbox_local.Min.Z, 4),
}
return out
def _send_state(self, force=False):
state = self._compute_state()
# Signature fuer Diff — komplette JSON-Repr ist ok bei kleinen Dicts
sig = (
state.get("selection", {}).get("count"),
state.get("selection", {}).get("type"),
(state.get("shape") or {}).get("type"),
state.get("coordSystem"),
tuple(sorted((state.get("refPoint") or {}).items())),
tuple(sorted((state.get("position") or {}).items())),
tuple(sorted((state.get("dimensions") or {}).items())),
tuple(sorted((state.get("shape") or {}).items())) if state.get("shape") else None,
)
if not force and sig == self._last_sig:
return
self._last_sig = sig
self.send("STATE", state)
def tick_idle(self):
# 1) Schnelle Selektions-Erkennung: ID-Liste vergleichen
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: return
try:
objs = _get_selected_objects(doc)
ids = tuple(sorted(str(o.Id) for o in objs))
if ids != self._last_ids:
self._last_ids = ids
self._send_state(force=True)
self._idle_cnt = 0
return
except Exception:
pass
# 2) Geometrie kann sich aendern ohne Selektions-Change (Gumball-Move).
# Niedriger-frequenter Geom-Poll.
self._idle_cnt += 1
if self._idle_cnt >= _IDLE_GEOM_POLL:
self._idle_cnt = 0
self._send_state(force=False)
# --- Handler ------------------------------------------------------------
def _set_ref_point(self, p):
ref = self._ref
for k in ("x", "y", "z"):
v = p.get(k)
if v in ("min", "mid", "max"):
ref[k] = v
self._send_state(force=True)
def _set_coord_system(self, p):
mode = p.get("mode")
if mode in ("world", "cplane"):
self._coord_sys = mode
self._send_state(force=True)
def _set_position(self, p):
"""Verschiebt Selektion so, dass der Referenzpunkt auf den neuen
Wert kommt. Nur die angegebene Achse wird geaendert."""
doc = Rhino.RhinoDoc.ActiveDoc
objs = _get_selected_objects(doc)
if not objs: return
axis = p.get("axis")
try: value = float(p.get("value"))
except Exception: return
if axis not in ("x", "y", "z"): return
plane = _active_plane(doc, self._coord_sys)
bbox_local = _bbox_in_plane(objs, plane)
if bbox_local is None: return
ref_local = _ref_point_local(bbox_local, self._ref)
dx = dy = dz = 0.0
if axis == "x": dx = value - ref_local.X
if axis == "y": dy = value - ref_local.Y
if axis == "z": dz = value - ref_local.Z
_translate_in_plane(doc, objs, plane, dx, dy, dz)
self._send_state(force=True)
def _set_dimension(self, p):
"""Skaliert Selektion in der angegebenen Achse, sodass der angegebene
Wert die neue Plane-aligned BBox-Spannweite ist. Referenzpunkt bleibt
fix."""
doc = Rhino.RhinoDoc.ActiveDoc
objs = _get_selected_objects(doc)
if not objs: return
axis = p.get("axis") # 'width' | 'depth' | 'height'
try: value = float(p.get("value"))
except Exception: return
if axis not in ("width", "depth", "height"): return
if value <= 0: return
plane = _active_plane(doc, self._coord_sys)
bbox_local = _bbox_in_plane(objs, plane)
if bbox_local is None: return
ref_world = _ref_point_world(bbox_local, self._ref, plane)
cur_w = bbox_local.Max.X - bbox_local.Min.X
cur_d = bbox_local.Max.Y - bbox_local.Min.Y
cur_h = bbox_local.Max.Z - bbox_local.Min.Z
sx = sy = sz = 1.0
if axis == "width" and cur_w > 0: sx = value / cur_w
if axis == "depth" and cur_d > 0: sy = value / cur_d
if axis == "height" and cur_h > 0: sz = value / cur_h
_scale_around_point(doc, objs, plane, ref_world, sx, sy, sz)
self._send_state(force=True)
def _set_rotation_z(self, p):
"""Rotiert Selektion um die Z-Achse der aktiven Plane durch den
Referenzpunkt um angle Grad."""
doc = Rhino.RhinoDoc.ActiveDoc
objs = _get_selected_objects(doc)
if not objs: return
try: angle = float(p.get("angle"))
except Exception: return
plane = _active_plane(doc, self._coord_sys)
bbox_local = _bbox_in_plane(objs, plane)
if bbox_local is None: return
ref_world = _ref_point_world(bbox_local, self._ref, plane)
_rotate_around_axis(doc, objs, ref_world, plane.ZAxis, angle)
self._send_state(force=True)
def _set_circle_radius(self, p):
doc = Rhino.RhinoDoc.ActiveDoc
objs = _get_selected_objects(doc)
if len(objs) != 1: return
try: radius = float(p.get("value"))
except Exception: return
if radius <= 0: return
plane = _active_plane(doc, self._coord_sys)
bbox_local = _bbox_in_plane(objs, plane)
if bbox_local is None: return
ref_world = _ref_point_world(bbox_local, self._ref, plane)
with _UndoRecord(doc, "Dossier: Radius aendern"):
_set_circle_radius(doc, objs[0], radius, plane, ref_world)
doc.Views.Redraw()
self._send_state(force=True)
def _set_line_length(self, p):
doc = Rhino.RhinoDoc.ActiveDoc
objs = _get_selected_objects(doc)
if len(objs) != 1: return
try: length = float(p.get("value"))
except Exception: return
if length <= 0: return
plane = _active_plane(doc, self._coord_sys)
bbox_local = _bbox_in_plane(objs, plane)
if bbox_local is None: return
ref_world = _ref_point_world(bbox_local, self._ref, plane)
with _UndoRecord(doc, "Dossier: Linienlaenge aendern"):
_set_line_length(doc, objs[0], length, ref_world)
doc.Views.Redraw()
self._send_state(force=True)
def _set_rectangle(self, p):
doc = Rhino.RhinoDoc.ActiveDoc
objs = _get_selected_objects(doc)
if len(objs) != 1: return
try:
w = float(p.get("width"))
h = float(p.get("height"))
except Exception:
return
if w <= 0 or h <= 0: return
plane = _active_plane(doc, self._coord_sys)
bbox_local = _bbox_in_plane(objs, plane)
if bbox_local is None: return
ref_world = _ref_point_world(bbox_local, self._ref, plane)
with _UndoRecord(doc, "Dossier: Rechteck-Abmessung"):
_set_rectangle_dims(doc, objs[0], w, h, plane, ref_world)
doc.Views.Redraw()
self._send_state(force=True)
# --- Listener-Installation --------------------------------------------------
def _install_listeners(bridge):
flag = "dimensionen_listeners"
sc.sticky["dimensionen_bridge"] = bridge
if sc.sticky.get(flag):
return
def on_idle(s, e):
# Waehrend Bulk-Ops (z.B. _Delete bei 6000 Objekten): nicht pollen.
# tick_idle iteriert alle Doc-Objekte, das ist Overhead bei jedem
# Tick zwischen den einzelnen Deletes. CommandEnd refresht.
if sc.sticky.get("_dossier_bulk_op_active"): return
b = sc.sticky.get("dimensionen_bridge")
if b is not None:
try: b.tick_idle()
except Exception as ex: print("[DIMENSIONEN] idle:", ex)
def on_select(s, e):
# Swisstopo-Import feuert tausende Selection-Events → bail.
if sc.sticky.get("dossier_swisstopo_busy"): return
if sc.sticky.get("_dossier_bulk_op_active"): return
b = sc.sticky.get("dimensionen_bridge")
if b is not None:
try: b._send_state(force=True)
except Exception: pass
Rhino.RhinoApp.Idle += on_idle
try:
Rhino.RhinoDoc.SelectObjects += on_select
Rhino.RhinoDoc.DeselectObjects += on_select
Rhino.RhinoDoc.DeselectAllObjects += on_select
except Exception as ex:
print("[DIMENSIONEN] select-events:", ex)
sc.sticky[flag] = True
print("[DIMENSIONEN] Listener aktiv (Idle + SelectObjects)")
def _bridge_factory():
b = DimensionenBridge()
_install_listeners(b)
return b
panel_base.register_and_open("dimensionen", "Dimensionen", PANEL_GUID_STR,
_bridge_factory,
icon_spec=("aspect_ratio", "#9e7050"))