Snapshot 2: Move/Rotate-Performance + Hatch↔Curve bidirektional

Performance-Optimierungen für User-Transform-Commands (_Move/_Rotate/etc):
- CommandBegin/End-Listener: Mein Code schläft während Rhinos Transform läuft
  komplett (Replace-Handler early-return). Beim CommandEnd vergleicht ein
  batch-Pass den Pre-Transform-Snapshot mit dem aktuellen State und macht
  EINEN konfliktfreien Sync-Regen pro betroffener Wand. Kein "Unable to
  transform"-Konflikt mehr, deutlich snappier.
- Sub-Volumen non-destruktiv: doc.Objects.Replace statt Delete+AddBrep wenn
  die Anzahl gleich bleibt (= häufiger Fall bei Brüstung/Höhe/XY-Drag).
- Migrate-Skip bei reinem Z-Drag: spart die Pass durch alle Öffnungen wenn
  XY unverändert ist.
- Sync-Regen-Deduplizierung im Batch via _dossier_skip_sync_regen Flag.
- Display-Suppress während des gesamten CommandEnd-Batch (kein sichtbares
  „Aufbauen" von Sub-Volumen).

Plus Gestaltung-Fix:
- Hatch→Curve Reverse-Sync via Hatch.Get3dCurves(outer=True): User kann die
  Hatch alleine verschieben/rotieren/skalieren → Curve folgt mit derselben
  Transform. Vorher nur Curve→Hatch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:21:16 +02:00
parent 961b3c0396
commit 2dde46cb85
2 changed files with 250 additions and 22 deletions
+207 -15
View File
@@ -4021,14 +4021,28 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
layer_breps = new_layer_breps
# Oeffnungs-Sub-Volumina (Rahmen+Sims+Glas) erzeugen.
# Nicht-destruktiv wenn moeglich: wenn die Anzahl der Sub-Volumen gleich
# bleibt (z.B. bei Bruestung-/Hoehe-/XY-Aenderung), nutzen wir
# `doc.Objects.Replace` auf die existierenden IDs statt Delete+AddBrep.
# Damit kollidiert ein laufender `_Move`-Command nicht mehr mit dem
# Wand-Regen → kein „Unable to transform"-Fehler mehr. Bei Anzahl-
# Aenderung (z.B. Fluegel-Wechsel) Fallback auf Delete+Add.
op_layer = _ensure_layer(doc, _layer_path_volume(doc, geschoss_name))
for op_meta, pt_loc, op_uk in opening_jobs:
for o, _m in _find_objects_by_wall_id(doc, op_meta["id"],
"oeffnung_volume"):
try: doc.Objects.Delete(o.Id, True)
except Exception as ex: print("[ELEMENTE] del old oeff vol:", ex)
old_objs = list(_find_objects_by_wall_id(doc, op_meta["id"],
"oeffnung_volume"))
pieces = _make_oeffnung_pieces(geom, pt_loc, meta["dicke"],
op_meta, op_uk)
if len(old_objs) == len(pieces) and len(pieces) > 0:
for (old_obj, _old_meta), pbrep in zip(old_objs, pieces):
try: doc.Objects.Replace(old_obj.Id, pbrep)
except Exception as ex:
print("[ELEMENTE] replace oeff vol:", ex)
continue
# Fallback: Anzahl hat sich geaendert → alte loeschen + neue adden.
for o, _m in old_objs:
try: doc.Objects.Delete(o.Id, True)
except Exception as ex: print("[ELEMENTE] del old oeff vol:", ex)
for pbrep in pieces:
op_attrs = Rhino.DocObjects.ObjectAttributes()
op_attrs.LayerIndex = op_layer
@@ -6860,16 +6874,10 @@ def _apply_oeffnung_constraint(new_obj, meta, old_obj=None):
attrs.SetUserString(_KEY_OEFF_BRUEST, "{:.6f}".format(new_bruest))
doc.Objects.ModifyAttributes(current.Id, attrs, True)
if doc is not None and parent_id:
# Defer-Regel: NUR Tueren im User-`_Move` brauchen Defer (Sync kollidiert
# mit Rhinos Move-Operation → „Unable to transform"). Fenster und Grip-
# Drag laufen sync — bei diesen tritt der Konflikt nicht auf und der
# Sync-Regen gibt sofortiges Feedback ohne sichtbares Aufladen.
skip_ids = sc.sticky.get("_elemente_replace_selected_ids") or set()
is_user_move = str(new_obj.Id) in skip_ids
is_tuer = (meta.get("oeff_typ") == "tuer")
if is_user_move and is_tuer:
_queue_regen(parent_id)
else:
# Skip Sync-Regen wenn wir gerade in einer Batch-Verarbeitung sind
# (Command-End): dort macht der Caller EINEN Sync-Regen pro Wand
# am Schluss → spart Mehrfach-Regen bei mehreren Öffnungen pro Wand.
if not sc.sticky.get("_dossier_skip_sync_regen"):
try: _regenerate_element(doc, parent_id)
except Exception as ex:
print("[ELEMENTE] sync regen oeffnung:", ex)
@@ -6886,6 +6894,11 @@ def _on_object_replaced(sender, e):
die regenerierten Volumen liegen.
"""
if sc.sticky.get(_REGEN_BUSY): return
# Wenn ein User-Transform-Command (_Move/_Rotate/etc.) aktiv ist: GAR
# NICHTS hier tun. Rhinos Move soll konfliktfrei durchlaufen. Nach
# CommandEnd vergleichen wir Snapshot vs. aktuellen State + machen den
# ganzen Update in einem konfliktfreien Batch.
if sc.sticky.get(_UT_ACTIVE_KEY): return
doc = Rhino.RhinoDoc.ActiveDoc
# Snapshot der aktuell selektierten IDs — damit Migrate die Objekte
# skippen kann die Rhinos Move/Rotate gerade transformiert (sonst
@@ -7683,6 +7696,175 @@ def _on_idle_selection(sender, e):
pass
# Welche Rhino-Commands transformieren mehrere Objekte gleichzeitig — bei
# diesen lassen wir Rhinos Move/Rotate KOMPLETT durchlaufen und feuern den
# Wand-Regen erst NACH CommandEnd. So gibt's keine „Unable to transform"-
# Kollision mehr zwischen meinem Sync-Regen und Rhinos pending Transforms.
_USER_TRANSFORM_CMDS = frozenset((
"Move", "Rotate", "Rotate3D", "Mirror", "Scale", "Scale1D", "Scale2D",
"Drag", "Gumball", "Orient", "Orient3Pt", "RemapCPlane", "Transform",
))
_UT_ACTIVE_KEY = "_dossier_user_transform_active"
_UT_SNAPSHOT_KEY = "_dossier_pre_transform_snapshot"
def _snapshot_source_positions(doc):
"""Schnappschuss aller Source-Geometrien — gerade vor einem User-Transform.
Wird in _on_command_end gegen aktuelle Positionen verglichen, um Brüstung-
Mitnahme + Migration zu rechnen ohne mit Rhinos noch laufender Move-
Operation zu kollidieren."""
snap = {}
if doc is None: return snap
for obj in doc.Objects:
try:
m = _read_meta(obj)
if not m: continue
t = m.get("type")
if t not in SOURCE_TYPES: continue
geom = obj.Geometry
if hasattr(geom, "Location"):
p = geom.Location
snap[m["id"]] = {"type": t, "pos": (p.X, p.Y, p.Z)}
elif isinstance(geom, rg.Curve):
s = geom.PointAtStart; e = geom.PointAtEnd
snap[m["id"]] = {"type": t,
"start": (s.X, s.Y, s.Z),
"end": (e.X, e.Y, e.Z)}
except Exception: pass
return snap
def _on_command_begin(sender, e):
try:
name = getattr(e, "CommandEnglishName", "") or ""
except Exception: name = ""
if name not in _USER_TRANSFORM_CMDS: return
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: return
sc.sticky[_UT_SNAPSHOT_KEY] = _snapshot_source_positions(doc)
sc.sticky[_UT_ACTIVE_KEY] = name
def _on_command_end(sender, e):
name = sc.sticky.get(_UT_ACTIVE_KEY)
if not name: return
sc.sticky[_UT_ACTIVE_KEY] = None
snapshot = sc.sticky.get(_UT_SNAPSHOT_KEY) or {}
sc.sticky[_UT_SNAPSHOT_KEY] = None
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: return
# Pseudo-Object Wrapper damit _apply_oeffnung_constraint pt_old.Location
# lesen kann ohne den echten alten RhinoObject zu kennen.
class _PseudoOld(object):
def __init__(self, pt): self.Geometry = rg.Point(pt)
affected_walls = set()
_was_busy = sc.sticky.get(_REGEN_BUSY, False)
sc.sticky[_REGEN_BUSY] = True
# Skip-Flag: in der Schleife wuerde jeder Constraint einen eigenen Sync-
# Regen ausloesen → mehrere Regens pro Wand. Wir machen am Schluss EINEN
# Regen pro affected_wall — viel schneller bei mehreren Oeffnungen.
sc.sticky["_dossier_skip_sync_regen"] = True
# Display-Updates komplett suppressen waehrend der Batch — Rhino zeichnet
# sonst nach jedem Brep-Add/Replace neu, was bei mehreren Sub-Volumen
# sichtbares „Aufbauen" verursacht. Ein einziger Redraw am Ende reicht.
prev_redraw = doc.Views.RedrawEnabled
doc.Views.RedrawEnabled = False
try:
for obj in list(doc.Objects):
try:
m = _read_meta(obj)
if not m: continue
t = m.get("type")
if t not in SOURCE_TYPES: continue
old = snapshot.get(m["id"])
if old is None: continue
if t == "wand_axis":
geom = obj.Geometry
if not isinstance(geom, rg.Curve): continue
os = old.get("start"); oe = old.get("end")
# Migrate NUR wenn XY tatsaechlich geaendert. Bei reinem
# Z-Drag (XY identisch) waere Migrate ein no-op-Loop ueber
# alle Oeffnungen mit Replace-Ops je Punkt — spart die
# ganze Pass + die nachfolgenden Replace-Events.
if os and oe:
xy_changed = (
abs(geom.PointAtStart.X - os[0]) > 1e-6 or
abs(geom.PointAtStart.Y - os[1]) > 1e-6 or
abs(geom.PointAtEnd.X - oe[0]) > 1e-6 or
abs(geom.PointAtEnd.Y - oe[1]) > 1e-6
)
if xy_changed:
try:
old_line = rg.LineCurve(
rg.Point3d(os[0], os[1], os[2]),
rg.Point3d(oe[0], oe[1], oe[2]))
_migrate_openings_to_new_axis(m["id"], old_line, geom)
except Exception as ex:
print("[ELEMENTE] post-cmd migrate:", ex)
# Z-Drag detect + Brüstungs-Mitnahme (= setzt sticky-delta
# den der Idle-Pfad spaeter applied — aber wir koennen
# gleich hier syncen)
_apply_wand_z_drag_constraint(obj, m)
z_entry = sc.sticky.get("_elemente_wand_z_delta")
if isinstance(z_entry, tuple) and len(z_entry) == 2 \
and z_entry[0] == m["id"]:
z_delta = float(z_entry[1])
sc.sticky["_elemente_wand_z_delta"] = None
# Brüstungen aller Öffnungen mit anpassen
if abs(z_delta) >= 1e-6:
for op_obj, op_meta in _find_openings_for_wall(doc, m["id"]):
cur_b = op_meta.get("oeff_brueest")
try:
cur_b_val = float(cur_b) if cur_b not in (None, "") else 0.0
except (ValueError, TypeError): cur_b_val = 0.0
new_b = max(0.0, cur_b_val + z_delta)
try:
attrs = op_obj.Attributes.Duplicate()
attrs.SetUserString(_KEY_OEFF_BRUEST,
"{:.6f}".format(new_b))
doc.Objects.ModifyAttributes(op_obj.Id, attrs, True)
pt_geom = op_obj.Geometry
if hasattr(pt_geom, "Location"):
pt = pt_geom.Location
doc.Objects.Replace(op_obj.Id,
rg.Point(rg.Point3d(pt.X, pt.Y, new_b)))
except Exception as ex:
print("[ELEMENTE] post-cmd brueest:", ex)
affected_walls.add(m["id"])
elif t == "oeffnung_point":
op_pos = old.get("pos")
if op_pos is None: continue
pseudo = _PseudoOld(rg.Point3d(op_pos[0], op_pos[1], op_pos[2]))
_apply_oeffnung_constraint(obj, m, pseudo)
pid = m.get("oeff_parent")
if pid: affected_walls.add(pid)
except Exception as ex:
print("[ELEMENTE] post-cmd source:", ex)
finally:
sc.sticky[_REGEN_BUSY] = _was_busy
sc.sticky["_dossier_skip_sync_regen"] = None
# Sync-Regen aller betroffenen Wände — Move ist sauber abgeschlossen,
# kein Konflikt mehr moeglich. EIN Regen pro Wand (nicht pro Oeffnung).
# Display bleibt suppressed bis ALLE Wände durch sind → kein „Aufbauen".
try:
for wid in affected_walls:
try: _regenerate_element(doc, wid)
except Exception as ex:
print("[ELEMENTE] post-cmd regen:", ex)
finally:
doc.Views.RedrawEnabled = prev_redraw
try: doc.Views.Redraw()
except Exception: pass
b = sc.sticky.get("elemente_bridge")
if b is not None:
try: b._send_state()
except Exception: pass
def _install_listeners(bridge):
"""Listener-Registrierung mit Re-Reload-Schutz.
@@ -7722,12 +7904,20 @@ def _install_listeners(bridge):
try:
if old_refs.get("idle"): Rhino.RhinoApp.Idle -= old_refs["idle"]
except Exception: pass
try:
if old_refs.get("cmd_begin"): Rhino.Commands.Command.BeginCommand -= old_refs["cmd_begin"]
except Exception: pass
try:
if old_refs.get("cmd_end"): Rhino.Commands.Command.EndCommand -= old_refs["cmd_end"]
except Exception: pass
Rhino.RhinoDoc.ReplaceRhinoObject += _on_object_replaced
Rhino.RhinoDoc.AddRhinoObject += _on_object_added
Rhino.RhinoDoc.DeleteRhinoObject += _on_object_deleted
Rhino.RhinoDoc.SelectObjects += _on_select_objects
Rhino.RhinoDoc.DeselectObjects += _on_deselect_objects
Rhino.RhinoApp.Idle += _on_idle_selection
Rhino.Commands.Command.BeginCommand += _on_command_begin
Rhino.Commands.Command.EndCommand += _on_command_end
sc.sticky[refs_key] = {
"replace": _on_object_replaced,
"add": _on_object_added,
@@ -7735,8 +7925,10 @@ def _install_listeners(bridge):
"select": _on_select_objects,
"deselect": _on_deselect_objects,
"idle": _on_idle_selection,
"cmd_begin": _on_command_begin,
"cmd_end": _on_command_end,
}
print("[ELEMENTE] Listener aktiv (Replace + Add + Delete + Select + Idle)")
print("[ELEMENTE] Listener aktiv (Replace + Add + Delete + Select + Idle + Cmd)")
def _bridge_factory():
+37 -1
View File
@@ -1374,11 +1374,47 @@ def _install_selection_listener(bridge):
except Exception: pass
def on_replace(sender, args):
"""Hatch der zugehoerigen Curve mitziehen wenn Curve veraendert wird."""
"""Sync Curve↔Hatch bei Move/Replace:
- Curve hat _FILL_KEY (= hatch_id) → Hatch via Hatch.Create neu auf die
aktuelle Curve aufsetzen (existierender Pfad).
- Hatch hat _FILL_OWNER_KEY (= curve_id) → Curve um den gleichen
Vektor mit-translaten (User hat Hatch alleine verschoben).
"""
new_obj = args.NewRhinoObject
if new_obj is None or new_obj.Id in _processing:
return
a = new_obj.Attributes
# Reverse-Direction: Hatch verschoben/rotiert/skaliert → Curve mitnehmen.
# Wir nehmen die Outer-Boundary direkt aus der (bereits transformed)
# Hatch — funktioniert fuer Move, Rotate, Scale, beliebige Transforms.
if isinstance(new_obj.Geometry, rg.Hatch):
owner_id_str = a.GetUserString(_FILL_OWNER_KEY)
if not owner_id_str:
return
try:
owner_id = System.Guid(owner_id_str)
except Exception:
return
doc2 = Rhino.RhinoDoc.ActiveDoc
owner_obj = doc2.Objects.FindId(owner_id)
if owner_obj is None or owner_obj.IsDeleted:
return
try:
new_curves = new_obj.Geometry.Get3dCurves(True)
except Exception as ex:
print("[GESTALTUNG] hatch.Get3dCurves:", ex)
return
if not new_curves or len(new_curves) == 0:
return
new_curve = new_curves[0]
_processing.add(owner_id)
try:
doc2.Objects.Replace(owner_id, new_curve)
except Exception as ex:
print("[GESTALTUNG] hatch→curve replace:", ex)
finally:
_processing.discard(owner_id)
return
hatch_id_str = a.GetUserString(_FILL_KEY)
if not hatch_id_str:
return