Files
karim bcf7d557b1 Treppen 2D-Plansymbol + Pure-Transform fuer hidden Layer
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
2026-05-28 00:41:05 +02:00

360 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#! 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 56 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)