bcf7d557b1
Frontend: - 2D-Plansymbol pro Treppe (Tritte/Lauflinie/Aussenlinie/Bruchsymbol) mit per-Treppe-Toggles in Properties-Panel - 'Obere Stufen gestrichelt'-Toggle splittet Tritte/Aussenlinie an Schnittebene; Lauflinie hat zwei Pfeile bei Bruch - Wand-Polyline-Grips fuer alle Vertices (nicht nur Enden) - PopupMenu unterstuetzt Divider + Checkbox-Items Backend: - Eigener Layer 41_Treppen_2D fuer Plansymbol, Layer-Default schwarz - Aussenlinie-Polygone folgen der Bruch-Diagonale (kein Versatz mehr) - Linetype-Fallback laedt Dashed bei Bedarf nach - Tritten-immer-an (Toggle entfernt), Z auf Geschoss-OKFF - TREPPEN/RAEUME Layer-Migration auf Capital-Case (Treppen/Raeume) - Selection-Partnership: treppe_2d_symbol pairs in axis + volume Pure-Transform fuer Treppen-Move: - treppe_2d_symbol + treppe_volume in VOLUME_TYPES → cascade-Support - Phase 1.5 Volume-only-Detection: wenn Source unbewegt aber Volumes uniform translated → synthetisiere canonical aus Avg-Delta der bewegten Volumes (unbewegte rausgefiltert sonst Verzerrung) - Hidden-inclusive ObjectEnumerator in Snapshot + Apply-Loop damit hidden treppe_axis auf 40_Treppen mit-transformiert wird - Properties-Fallback im _send_state findet hidden Sources via expliziter Iteration → Panel zeigt Treppe auch bei 3D-Layer aus - Dimensionen-Panel skipt on_select/idle waehrend UT_ACTIVE oder Partnership-Cascade → keine Flicker beim Drag mehr
360 lines
14 KiB
Python
360 lines
14 KiB
Python
#! 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)
|