#! python3 # -*- coding: utf-8 -*- """ wand_grips.py Custom Endpoint-Grips fuer Waende — Display-Conduit + MouseCallback Overlay. Problem das geloest wird: - wand_axis liegt auf dem Referenzlinien-Sublayer (Code 19). Wenn der User in einem Visibility-Mode ist der diesen Layer ausblendet, sind die Achsen + ihre nativen Rhino-Grips unsichtbar. - Native Grips sind 5–6 Pixel klein, schwer zu treffen. - Klick neben den Grip greift das Wand-Volumen → ganze Wand wird statt nur des Endpunkts verschoben. Loesung: - Display-Conduit zeichnet bei jeder selektierten Wand zwei dicke, farbige Kreise an den Achs-Endpunkten — unabhaengig von der Layer- Visibility (Conduit-Overlay laeuft ueber dem normalen Rendering). - MouseCallback erkennt Mouse-Down nahe eines Markers, triggert eine Rhino-GetPoint-Interaktion (mit Snap-Engine, OrthoMode, Tracking- Linie zum fixen Endpunkt) und ersetzt nach Confirm den wand_axis. - Der existierende _on_object_replaced-Handler regiert das Volumen automatisch neu — keine manuelle Regen-Logik noetig. Funktioniert sowohl wenn das wand_axis-Objekt eine Line ist als auch Polyline (Multi-Segment-Wand). Bei Polyline: nur erster + letzter Vertex sind als Endpoint-Grips ausgewiesen. Module-Singleton — registriert sich einmal pro Rhino-Session via sticky-Flag, Re-Loads ueber _reset_panels raeumen den alten Handler sauber weg. """ import Rhino import Rhino.Display as rd import Rhino.Geometry as rg import scriptcontext as sc import System import System.Drawing as SD # --- Konstanten ------------------------------------------------------------ # Hit-Radius in Pixeln fuer Marker-Klick-Detection. Bewusst grosszuegig # (~ 14px) damit der User nicht zielen muss. _HIT_RADIUS_PX = 14 # Marker-Radius in Pixeln fuer das Drawing. 8px ist gut sichtbar ohne zu # stoeren. Bei Hover etwas groesser (10px). _MARKER_RADIUS_PX = 7 _MARKER_RADIUS_HOVER_PX = 10 # Farben — accent-gruen analog zum Dossier-Theme. _MARKER_FILL = SD.Color.FromArgb(220, 95, 168, 150) _MARKER_BORDER = SD.Color.FromArgb(255, 47, 93, 84) _MARKER_HOVER = SD.Color.FromArgb(255, 255, 140, 60) # --- Helpers -------------------------------------------------------------- def _read_axis_type(obj): """Schnelle Pruefung ob obj eine wand_axis ist. Importiert elemente lazy um Circular-Import beim Modul-Load zu vermeiden.""" if obj is None or obj.IsDeleted: return False try: return obj.Attributes.GetUserString("dossier_element_type") == "wand_axis" except Exception: return False def _find_axis_for_obj(doc, obj): """Gibt die wand_axis zurueck zu der dieses Objekt gehoert. - Wenn obj selber eine wand_axis ist: return obj - Wenn obj ein wand_volume ist: suche Source via element_id Liefert None bei Mismatch oder fehlenden Tags.""" if obj is None or obj.IsDeleted: return None attrs = obj.Attributes try: t = attrs.GetUserString("dossier_element_type") eid = attrs.GetUserString("dossier_element_id") if not t or not eid: return None if t == "wand_axis": return obj if t != "wand_volume": return None # Source suchen — iteriere doc, finde wand_axis mit gleicher id for o in doc.Objects: if o is None or o.IsDeleted: continue a2 = o.Attributes try: if a2.GetUserString("dossier_element_id") == eid and \ a2.GetUserString("dossier_element_type") == "wand_axis": return o except Exception: continue except Exception: pass return None def _curve_endpoints(curve): """Liefert (start_pt, end_pt) fuer eine wand_axis. Funktioniert fuer LineCurve, PolylineCurve, NurbsCurve etc — alle Curve-Typen haben PointAtStart/PointAtEnd. Bei degenerierten Curves None.""" if curve is None: return None, None try: return curve.PointAtStart, curve.PointAtEnd except Exception: return None, None def _replace_axis_endpoint(doc, axis_obj, kind, new_pt): """Tauscht den Start- (kind='start') oder Endpunkt (kind='end') der wand_axis-Curve gegen new_pt. Geht intelligent um mit: - LineCurve: erzeuge neue Line vom fixen Punkt zum neuen Punkt - PolylineCurve: ersetze ersten/letzten Vertex, Rest bleibt - andere Curve-Typen: aktuell nur Line-Fallback (Erst/Letzt-Vertex rekonstruieren) Setzt die neue Geometrie via Objects.Replace — das feuert ReplaceRhinoObject-Event, was den existierenden Wand-Regen anwirft.""" if axis_obj is None or axis_obj.IsDeleted: return False geom = axis_obj.Geometry if geom is None: return False try: # PolylineCurve mit > 2 Vertices: ersten/letzten Vertex ersetzen if isinstance(geom, rg.PolylineCurve): poly = geom.ToPolyline() if poly is None or poly.Count < 2: return False pts = list(poly) if kind == "start": pts[0] = new_pt else: pts[-1] = new_pt new_poly = rg.Polyline(pts) new_curve = rg.PolylineCurve(new_poly) else: # LineCurve oder unbekannter Typ → reduziere auf Line zwischen # neuem + altem fixen Punkt. p_start, p_end = _curve_endpoints(geom) if p_start is None or p_end is None: return False if kind == "start": new_curve = rg.LineCurve(new_pt, p_end) else: new_curve = rg.LineCurve(p_start, new_pt) return doc.Objects.Replace(axis_obj.Id, new_curve) except Exception as ex: print("[WAND_GRIPS] replace endpoint:", ex) return False # --- Display-Conduit ------------------------------------------------------- class _EndpointConduit(rd.DisplayConduit): """Zeichnet bei jeder selektierten Wand zwei dicke Marker an den Achs-Endpunkten. hot_key (axis_guid_str, 'start'|'end') hebt einen Marker als Hover-Highlight hervor.""" def __init__(self): rd.DisplayConduit.__init__(self) self.hot_key = None # (axis_id_str, kind) — fuer Hover self.drag_key = None # (axis_id_str, kind) — waehrend aktivem Drag self.drag_preview = None # rg.Line — Live-Vorschau waehrend GetPoint def _collect_endpoints(self, doc): """Liefert Liste von (axis_obj, kind, world_pt) fuer alle selektier- ten Waende. Iteriert die Selektion + dedupliziert Achsen (jede Wand erscheint nur einmal, auch wenn mehrere Volumen mit-selek- tiert sind).""" out = [] seen_axis = set() try: sel = list(doc.Objects.GetSelectedObjects(False, False)) except Exception: return out for obj in sel: axis = _find_axis_for_obj(doc, obj) if axis is None: continue aid = str(axis.Id) if aid in seen_axis: continue seen_axis.add(aid) p_start, p_end = _curve_endpoints(axis.Geometry) if p_start is not None: out.append((axis, "start", p_start)) if p_end is not None: out.append((axis, "end", p_end)) return out def DrawForeground(self, e): try: doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return for axis, kind, pt in self._collect_endpoints(doc): aid = str(axis.Id) # Skip den gerade gezogenen Marker — der wird via # drag_preview separat dargestellt. if self.drag_key and self.drag_key == (aid, kind): continue is_hot = self.hot_key and self.hot_key == (aid, kind) r = _MARKER_RADIUS_HOVER_PX if is_hot else _MARKER_RADIUS_PX fill = _MARKER_HOVER if is_hot else _MARKER_FILL # DrawPoint mit RoundControlPoint = gefuellter Kreis + # Border. Sieht aus wie ein dicker Grip-Punkt. try: e.Display.DrawPoint( pt, rd.PointStyle.RoundControlPoint, r, fill) except Exception: # Fallback fuer aeltere Rhino-Versionen: einfacher # DrawDot mit Label "●" e.Display.DrawDot(pt, "●", fill, _MARKER_BORDER) # Drag-Preview-Linie waehrend GetPoint aktiv ist if self.drag_preview is not None: try: e.Display.DrawLine(self.drag_preview, _MARKER_HOVER, 2) except Exception: pass except Exception as ex: print("[WAND_GRIPS] DrawForeground:", ex) # --- Mouse-Handler -------------------------------------------------------- class _EndpointMouseHandler(Rhino.UI.MouseCallback): """Erkennt Mouse-Down nahe eines Endpoint-Markers + triggert Rhino- GetPoint fuer den neuen Endpunkt. Hover-Update via OnMouseMove fuer visuelles Highlight.""" def __init__(self, conduit): Rhino.UI.MouseCallback.__init__(self) self.conduit = conduit self._busy = False # Re-Entry-Schutz waehrend Drag-Get-Point def _hit_test(self, view, screen_pt): """Liefert (axis, kind, world_pt) wenn screen_pt nahe eines Endpoint- Markers liegt, sonst None. Iteriert die aktuelle Conduit-Liste.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return None try: vp = view.ActiveViewport except Exception: return None thresh2 = _HIT_RADIUS_PX * _HIT_RADIUS_PX for axis, kind, world_pt in self.conduit._collect_endpoints(doc): try: s = vp.WorldToClient(world_pt) dx = s.X - screen_pt.X dy = s.Y - screen_pt.Y if (dx * dx + dy * dy) <= thresh2: return axis, kind, world_pt except Exception: continue return None def OnMouseMove(self, e): if self._busy: return try: view = e.View if view is None: return hit = self._hit_test(view, e.ViewportPoint) new_key = (str(hit[0].Id), hit[1]) if hit else None if new_key != self.conduit.hot_key: self.conduit.hot_key = new_key try: view.Redraw() except Exception: pass except Exception: pass def OnMouseDown(self, e): if self._busy: return try: # Nur linke Maustaste try: btn = e.MouseButton btn_str = str(btn) if "Left" not in btn_str: return except Exception: pass view = e.View if view is None: return hit = self._hit_test(view, e.ViewportPoint) if hit is None: return # Default-Klick (Selection) abwuergen — wir uebernehmen try: e.Cancel = True except Exception: pass axis, kind, world_pt = hit self._start_drag(view.Document, axis, kind, world_pt) except Exception as ex: print("[WAND_GRIPS] OnMouseDown:", ex) def _start_drag(self, doc, axis, kind, anchor_pt): """Startet eine Rhino-GetPoint-Interaktion um den neuen Endpunkt zu picken. Der ANDERE Endpunkt (Fix-Punkt) wird als BasePoint gesetzt — damit kriegt der User Tracking-Linie, Ortho-Mode etc. wie bei _Move.""" if doc is None: return geom = axis.Geometry if geom is None: return p_start, p_end = _curve_endpoints(geom) if p_start is None or p_end is None: return fixed_pt = p_end if kind == "start" else p_start # Conduit-State: drag-Marker hervorheben + Preview-Linie self.conduit.drag_key = (str(axis.Id), kind) self.conduit.drag_preview = rg.Line(fixed_pt, anchor_pt) self._busy = True try: gp = Rhino.Input.Custom.GetPoint() gp.SetCommandPrompt("Wand-Endpunkt: neuer Punkt (Esc=Abbruch)") gp.SetBasePoint(fixed_pt, True) gp.DrawLineFromPoint(fixed_pt, True) # Live-Preview ueber Conduit (zusaetzlich zu Rhinos eigener # Tracking-Linie) — sieht ueblich, hilft beim Verstehen welcher # Endpunkt sich bewegt. def _on_mouse_move(sender, args): try: self.conduit.drag_preview = rg.Line(fixed_pt, args.Point) except Exception: pass try: gp.MouseMove += _on_mouse_move except Exception: pass res = gp.Get() if res == Rhino.Input.GetResult.Point: new_pt = gp.Point() _replace_axis_endpoint(doc, axis, kind, new_pt) except Exception as ex: print("[WAND_GRIPS] _start_drag:", ex) finally: self.conduit.drag_key = None self.conduit.drag_preview = None self._busy = False try: doc.Views.Redraw() except Exception: pass # --- Install / Teardown --------------------------------------------------- _STICKY_CONDUIT = "_dossier_wand_grips_conduit" _STICKY_HANDLER = "_dossier_wand_grips_handler" def install_handlers(): """Idempotente Registrierung. Bei Modul-Reload wird der alte Conduit + Mouse-Handler zuerst disabled, dann neu erstellt + enabled. Sticky haelt die Referenzen am Leben (sonst Garbage-Collection).""" try: old_conduit = sc.sticky.get(_STICKY_CONDUIT) if old_conduit is not None: try: old_conduit.Enabled = False except Exception: pass old_handler = sc.sticky.get(_STICKY_HANDLER) if old_handler is not None: try: old_handler.Enabled = False except Exception: pass conduit = _EndpointConduit() conduit.Enabled = True handler = _EndpointMouseHandler(conduit) handler.Enabled = True sc.sticky[_STICKY_CONDUIT] = conduit sc.sticky[_STICKY_HANDLER] = handler print("[WAND_GRIPS] Endpoint-Conduit + Mouse-Handler aktiv") except Exception as ex: print("[WAND_GRIPS] install:", ex)