#! python3 # -*- coding: utf-8 -*- # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 Karim Gabriele Varano """ 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 _axis_vertices(geom): """Liefert die Vertices der wand_axis-Curve als Liste. - PolylineCurve: alle Vertices - LineCurve / sonstige Curve: [Start, End] (zwei-Vertex-Faelle) Returnt [] bei degeneriertem Input.""" if geom is None: return [] try: if isinstance(geom, rg.PolylineCurve): poly = geom.ToPolyline() if poly is None or poly.Count < 2: return [] return list(poly) p_start = geom.PointAtStart p_end = geom.PointAtEnd return [p_start, p_end] except Exception: return [] def _replace_axis_vertex(doc, axis_obj, vertex_idx, new_pt): """Tauscht den Vertex an Index `vertex_idx` der wand_axis-Curve gegen new_pt. Funktioniert fuer Linien (idx 0/1) und Polylinien (alle idx). Setzt die neue Geometrie via Objects.Replace — 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: pts = _axis_vertices(geom) if not pts: return False if vertex_idx < 0 or vertex_idx >= len(pts): return False pts[vertex_idx] = new_pt if len(pts) == 2: new_curve = rg.LineCurve(pts[0], pts[1]) else: new_curve = rg.PolylineCurve(rg.Polyline(pts)) return doc.Objects.Replace(axis_obj.Id, new_curve) except Exception as ex: print("[WAND_GRIPS] replace vertex:", 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, vidx) — fuer Hover self.drag_key = None # (axis_id_str, vidx) — waehrend aktivem Drag self.drag_preview = None # Liste von rg.Line — Live-Vorschau (Linien # zu Nachbar-Vertices waehrend GetPoint) def _collect_grip_points(self, doc): """Liefert Liste von (axis_obj, vertex_idx, world_pt) fuer ALLE Vertices aller selektierten Waende — fuer Polyline-Waende ist jeder Knick ein eigener Grip. Iteriert die Selektion + dedupli- ziert Achsen (jede Wand erscheint nur einmal, auch wenn mehrere Volumen mit-selektiert 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) for i, pt in enumerate(_axis_vertices(axis.Geometry)): out.append((axis, i, pt)) return out def DrawForeground(self, e): try: doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return for axis, vidx, pt in self._collect_grip_points(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, vidx): continue is_hot = self.hot_key and self.hot_key == (aid, vidx) 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-Linien waehrend GetPoint aktiv ist if self.drag_preview: for line in self.drag_preview: try: e.Display.DrawLine(line, _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, vertex_idx, world_pt) wenn screen_pt nahe eines Vertex-Markers liegt, sonst None.""" 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, vidx, world_pt in self.conduit._collect_grip_points(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, vidx, 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, vidx, world_pt = hit self._start_drag(view.Document, axis, vidx, world_pt) except Exception as ex: print("[WAND_GRIPS] OnMouseDown:", ex) def _start_drag(self, doc, axis, vertex_idx, anchor_pt): """Startet eine Rhino-GetPoint-Interaktion um den Vertex zu verschieben. BasePoint-Strategie: - End-Vertex (idx 0 oder letzter): gegenueberliegender End-Vertex → User bekommt Tracking-Linie + Wand-Laenge wie bei _Move - Mittel-Vertex (Polyline-Knick): Vertex selbst, plus Live-Preview zu beiden Nachbar-Vertices damit beide Segmente sichtbar mit- schwingen.""" if doc is None: return geom = axis.Geometry if geom is None: return pts = _axis_vertices(geom) if not pts or vertex_idx < 0 or vertex_idx >= len(pts): return is_first = vertex_idx == 0 is_last = vertex_idx == len(pts) - 1 prev_pt = pts[vertex_idx - 1] if not is_first else None next_pt = pts[vertex_idx + 1] if not is_last else None if is_first: base_pt = next_pt elif is_last: base_pt = prev_pt else: base_pt = anchor_pt # Conduit-State: drag-Marker hervorheben + Preview-Linien self.conduit.drag_key = (str(axis.Id), vertex_idx) self.conduit.drag_preview = [] if prev_pt is not None: self.conduit.drag_preview.append(rg.Line(prev_pt, anchor_pt)) if next_pt is not None: self.conduit.drag_preview.append(rg.Line(next_pt, anchor_pt)) self._busy = True try: gp = Rhino.Input.Custom.GetPoint() gp.SetCommandPrompt("Wand-Vertex: neuer Punkt (Esc=Abbruch)") gp.SetBasePoint(base_pt, True) gp.DrawLineFromPoint(base_pt, True) def _on_mouse_move(sender, args): try: preview = [] if prev_pt is not None: preview.append(rg.Line(prev_pt, args.Point)) if next_pt is not None: preview.append(rg.Line(next_pt, args.Point)) self.conduit.drag_preview = preview 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_vertex(doc, axis, vertex_idx, 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)