# ! python3 # -*- 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): 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): 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=("D", "#9e7050"))