Snapshot: Transform-Hierarchie + Brüstung-Konvention + Undo-Record

Funktionierender Stand der Move/Rotate-Pipeline mit Eltern-Kind-Cascade
und sauberer Brüstung-Semantik:

- Pure-Translate hierarchisch: nur Sources mit echtem Delta + ihre Kinder
  (Öffnungen → Wand) folgen mit. Wand folgt NICHT der Öffnung.
- Orphan-Detection: Öffnung ohne mitbewegter Eltern-Wand → Regen-Fallback
  (sonst bleibt Cutout am alten Ort im Wand-Brep).
- Brüstung = relativ zur Wand-UK (Archicad/Revit-Konvention). Bei Wand-
  Z-Drag wird UK_OVER angepasst, Brüstung bleibt; Öffnungs-Punkt wandert
  via Snapshot+Delta mit. Keine Doppel-Addition mehr.
- Opening-Punkt wird beim Erzeugen direkt auf UK+brüstung platziert
  (sonst Brüstung-Drop beim ersten Move).
- Undo-Record umschliesst Rhinos Move + unseren Regen in einem Cmd+Z-
  Schritt → keine doppelten Elemente nach Undo.
- RedrawEnabled-Suppression event-getriggert (erst beim ersten Replace-
  Event nach User-Klick) → Rubber-Band + Drag-Vorschau bleiben sichtbar.
- _Undo/_Redo: Event-Handler komplett aussetzen → kein Regen-Storm.
- Gestaltung-Listener während User-Transform + Regen stumm, danach
  einmaliger Selection-Refresh.

Enthält Debug-Logs in _apply_wand_z_drag_constraint + Wand-Regen
für offenen Bug: bei gemeinsamer Z-Verschiebung (Wand+Fenster+Tür)
landen Öffnungen manchmal über der Wand — UK_OVER scheint nicht
durchzukommen. Logs sollen das eingrenzen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 22:20:35 +02:00
parent d3984ba501
commit 2a75b1da93
2 changed files with 309 additions and 72 deletions
+265 -47
View File
@@ -3922,6 +3922,8 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
if meta["type"] == "wand_axis": if meta["type"] == "wand_axis":
uk, ok = _resolve_uk_ok(doc, meta["geschoss"], uk, ok = _resolve_uk_ok(doc, meta["geschoss"],
meta["uk_override"], meta["ok_override"]) meta["uk_override"], meta["ok_override"])
print("[ELEMENTE] regen wand {}: uk={:.3f} ok={:.3f} (uk_over='{}' ok_over='{}')".format(
element_id, uk, ok, meta.get("uk_override", ""), meta.get("ok_override", "")))
# Wand-Verbindungen: Miter-Linien aus Nachbarwand-Joints (Corner + T). # Wand-Verbindungen: Miter-Linien aus Nachbarwand-Joints (Corner + T).
miter_start = None miter_start = None
miter_end = None miter_end = None
@@ -5580,7 +5582,19 @@ class ElementeBridge(panel_base.BaseBridge):
oeff_sims_in=simsi_def, oeff_sims_in=simsi_def,
oeff_glas=glas_def, oeff_glas=glas_def,
oeff_referenz=referenz_def) oeff_referenz=referenz_def)
new_id = doc.Objects.AddPoint(on_axis, attrs) # Oeffnungs-Punkt auf UK+Brueestung-Hoehe platzieren (= visuell auf
# Unterkante Oeffnung). Constraint vergleicht spaeter pt.Z mit
# UK+brueest — wenn der Punkt am axis.Z=0 saesse, wuerde der erste
# Move die Brueest auf 0 droppen. Hier UK auflösen (Geschoss-OKFF +
# ggf. Override) und Punkt direkt auf richtige Welt-Z setzen.
try:
wall_uk, _ = _resolve_uk_ok(doc, geschoss,
wall_meta.get("uk_override", ""),
wall_meta.get("ok_override", ""))
except Exception:
wall_uk = 0.0
pt_at_brueest = rg.Point3d(on_axis.X, on_axis.Y, wall_uk + float(brueest))
new_id = doc.Objects.AddPoint(pt_at_brueest, attrs)
if new_id == System.Guid.Empty: if new_id == System.Guid.Empty:
print("[ELEMENTE] AddPoint fehlgeschlagen"); return print("[ELEMENTE] AddPoint fehlgeschlagen"); return
@@ -6736,10 +6750,21 @@ def _apply_wand_z_drag_constraint(new_obj, meta):
meta["uk_override"], meta["ok_override"]) meta["uk_override"], meta["ok_override"])
new_uk = uk_cur + delta new_uk = uk_cur + delta
new_ok = ok_cur + delta new_ok = ok_cur + delta
print("[ELEMENTE] wand z-drag: uk_cur={:.3f} ok_cur={:.3f} new_uk={:.3f} new_ok={:.3f} (meta uk_over='{}' ok_over='{}')".format(
uk_cur, ok_cur, new_uk, new_ok, meta.get("uk_override", ""), meta.get("ok_override", "")))
attrs = new_obj.Attributes.Duplicate() attrs = new_obj.Attributes.Duplicate()
attrs.SetUserString(_KEY_UK_OVER, "{:.6f}".format(new_uk)) attrs.SetUserString(_KEY_UK_OVER, "{:.6f}".format(new_uk))
attrs.SetUserString(_KEY_OK_OVER, "{:.6f}".format(new_ok)) attrs.SetUserString(_KEY_OK_OVER, "{:.6f}".format(new_ok))
doc.Objects.ModifyAttributes(new_obj.Id, attrs, True) mod_ok = doc.Objects.ModifyAttributes(new_obj.Id, attrs, True)
# Verifikation: UK_OVER wirklich in Doc geschrieben?
verify = doc.Objects.FindId(new_obj.Id)
if verify is not None:
actual_uk = verify.Attributes.GetUserString(_KEY_UK_OVER) or "<empty>"
actual_ok = verify.Attributes.GetUserString(_KEY_OK_OVER) or "<empty>"
print("[ELEMENTE] wand z-drag ModifyAttributes returned={} → stored uk_over='{}' ok_over='{}'".format(
mod_ok, actual_uk, actual_ok))
else:
print("[ELEMENTE] wand z-drag verify: FindId returned None!")
# Curve auf Z=0 fixen. LineCurve: explizit beide Endpunkte (auch bei # Curve auf Z=0 fixen. LineCurve: explizit beide Endpunkte (auch bei
# einzelnem End-Grip-Drag). Andere Curves: ueber Translation (akzeptiert # einzelnem End-Grip-Drag). Andere Curves: ueber Translation (akzeptiert
# leichten Schraeg bei End-Grip-Drag, gleicht sich beim naechsten # leichten Schraeg bei End-Grip-Drag, gleicht sich beim naechsten
@@ -6801,6 +6826,7 @@ def _apply_oeffnung_constraint(new_obj, meta, old_obj=None):
parent_id = meta.get("oeff_parent") parent_id = meta.get("oeff_parent")
parent_curve = None parent_curve = None
parent_meta = None
doc = Rhino.RhinoDoc.ActiveDoc doc = Rhino.RhinoDoc.ActiveDoc
if doc is not None and parent_id: if doc is not None and parent_id:
for obj in doc.Objects: for obj in doc.Objects:
@@ -6809,6 +6835,7 @@ def _apply_oeffnung_constraint(new_obj, meta, old_obj=None):
cg = obj.Geometry cg = obj.Geometry
if isinstance(cg, rg.Curve): if isinstance(cg, rg.Curve):
parent_curve = cg parent_curve = cg
parent_meta = m
break break
target_x, target_y = pt_new.X, pt_new.Y target_x, target_y = pt_new.X, pt_new.Y
@@ -6850,17 +6877,33 @@ def _apply_oeffnung_constraint(new_obj, meta, old_obj=None):
cur_bruest_val = float(cur_bruest) if cur_bruest not in (None, "") else 0.9 cur_bruest_val = float(cur_bruest) if cur_bruest not in (None, "") else 0.9
except (ValueError, TypeError): except (ValueError, TypeError):
cur_bruest_val = 0.9 cur_bruest_val = 0.9
# Z-Delta = Drag in Z. Der Punkt SITZT visuell auf Bruestung-Hoehe # Z-Delta gegen den ERWARTETEN Welt-Z des Punktes = Wand-UK + Brueest.
# (siehe Geometry-Schreibung unten), daher pt_old.Z ~= alte Bruestung. # Bruestung ist relativ zur Wand-UK gespeichert. Wenn die Wand
delta_z = (pt_new.Z - pt_old.Z) if pt_old is not None else (pt_new.Z - cur_bruest_val) # hochgezogen wurde (UK_OVER += z_delta) und der Wand-Loop den
# Oeffnungs-Punkt um z_delta translatet hat, sitzt der Punkt jetzt auf
# `new_UK + cur_brueest` = `expected_pt_z`. delta_z = 0 → kein
# Bruestungs-Update (gut so, sonst doppelt). Wenn der User nur den
# Punkt allein vertikal gezogen hat (Brueestung-Drag), divergiert
# pt_new.Z vom expected_pt_z → delta_z entspricht der echten User-
# Eingabe → Bruestung wird angepasst.
wall_uk = 0.0
if parent_meta is not None:
try:
wall_uk, _ = _resolve_uk_ok(doc, parent_meta["geschoss"],
parent_meta["uk_override"],
parent_meta["ok_override"])
except Exception:
wall_uk = 0.0
expected_pt_z = wall_uk + cur_bruest_val
delta_z = pt_new.Z - expected_pt_z
new_bruest = cur_bruest_val new_bruest = cur_bruest_val
if abs(delta_z) >= 1e-6: if abs(delta_z) >= 1e-6:
new_bruest = max(0.0, cur_bruest_val + delta_z) new_bruest = max(0.0, cur_bruest_val + delta_z)
# Punkt visuell auf Bruestungs-Hoehe (= Unterkante Oeffnung), nicht auf 0. # Punkt visuell auf der Unterkante der Oeffnung in Welt-Z platzieren =
# So sieht der User wo die Oeffnung beginnt + Z-Drag-Delta entspricht # Wand-UK + Brueest. So sieht der User wo die Oeffnung beginnt, auch
# direkt der Bruestungsaenderung. # wenn die Wand auf einem hoeheren Geschoss steht.
target_z = new_bruest target_z = wall_uk + new_bruest
geom_changed = not ( geom_changed = not (
abs(target_x - pt_new.X) < 1e-9 abs(target_x - pt_new.X) < 1e-9
and abs(target_y - pt_new.Y) < 1e-9 and abs(target_y - pt_new.Y) < 1e-9
@@ -6901,10 +6944,15 @@ def _on_object_replaced(sender, e):
""" """
if sc.sticky.get(_REGEN_BUSY): return if sc.sticky.get(_REGEN_BUSY): return
# Wenn ein User-Transform-Command (_Move/_Rotate/etc.) aktiv ist: GAR # Wenn ein User-Transform-Command (_Move/_Rotate/etc.) aktiv ist: GAR
# NICHTS hier tun. Rhinos Move soll konfliktfrei durchlaufen. Nach # NICHTS hier tun (Rhinos Move soll konfliktfrei durchlaufen). Erstes
# CommandEnd vergleichen wir Snapshot vs. aktuellen State + machen den # Event = User hat geklickt → Redraw ab jetzt suppressen, sonst Mismatch-
# ganzen Update in einem konfliktfreien Batch. # Frame zwischen Rhinos Auto-Redraw und unserem Regen.
if sc.sticky.get(_UT_ACTIVE_KEY): return if sc.sticky.get(_UT_ACTIVE_KEY):
_suppress_redraw_until_cmd_end()
return
# Undo/Redo: Rhino restored den Zustand → wir machen NICHTS, sonst
# Regen-Storm fuer jedes restored Object.
if sc.sticky.get(_UNDO_ACTIVE_KEY): return
doc = Rhino.RhinoDoc.ActiveDoc doc = Rhino.RhinoDoc.ActiveDoc
# Snapshot der aktuell selektierten IDs — damit Migrate die Objekte # Snapshot der aktuell selektierten IDs — damit Migrate die Objekte
# skippen kann die Rhinos Move/Rotate gerade transformiert (sonst # skippen kann die Rhinos Move/Rotate gerade transformiert (sonst
@@ -7159,6 +7207,14 @@ def _on_object_added(sender, e):
neue UUID, Volume-Duplikate werden geloescht (Regen baut das neue neue UUID, Volume-Duplikate werden geloescht (Regen baut das neue
Volumen am richtigen Ort).""" Volumen am richtigen Ort)."""
if sc.sticky.get(_REGEN_BUSY): return if sc.sticky.get(_REGEN_BUSY): return
# Waehrend Move/Rotate/Mirror/Scale: Rhino feuert intern Delete+Add fuer
# jedes transformierte Objekt. CommandEnd uebernimmt die Re-Sync —
# diese Events ignorieren, sonst laeuft die Regen-Pipeline trotz
# Pure-Translate-Skip.
if sc.sticky.get(_UT_ACTIVE_KEY):
_suppress_redraw_until_cmd_end()
return
if sc.sticky.get(_UNDO_ACTIVE_KEY): return
try: try:
new_obj = e.TheObject new_obj = e.TheObject
meta = _read_meta(new_obj) meta = _read_meta(new_obj)
@@ -7254,6 +7310,13 @@ def _on_object_deleted(sender, e):
wenn die Source mit gleicher ID zurueckkommt (= Transform, kein User- wenn die Source mit gleicher ID zurueckkommt (= Transform, kein User-
Delete). Delete).
""" """
# Waehrend Move/Rotate/Mirror/Scale: CommandEnd-Pfad uebernimmt das
# Re-Sync. Sonst queued der Delete-Event ueberfluessige Regen-Calls die
# den Pure-Translate-Skip wieder zunichtemachen.
if sc.sticky.get(_UT_ACTIVE_KEY):
_suppress_redraw_until_cmd_end()
return
if sc.sticky.get(_UNDO_ACTIVE_KEY): return
try: try:
obj = e.TheObject obj = e.TheObject
meta = _read_meta(obj) meta = _read_meta(obj)
@@ -7688,8 +7751,15 @@ _USER_TRANSFORM_CMDS = frozenset((
"Drag", "Gumball", "Orient", "Orient3Pt", "RemapCPlane", "Transform", "Drag", "Gumball", "Orient", "Orient3Pt", "RemapCPlane", "Transform",
)) ))
# Undo/Redo: Rhino restored Objekte aus dem Undo-Stack → feuert Add/Delete-
# Events fuer ALLE betroffenen Objekte. Unsere Handler wuerden fuer jedes
# einen Regen queuen → Storm. Wir suppressen die Handler komplett; Undo hat
# den Zustand schon konsistent wiederhergestellt, kein Regen noetig.
_USER_UNDO_CMDS = frozenset(("Undo", "Redo"))
_UT_ACTIVE_KEY = "_dossier_user_transform_active" _UT_ACTIVE_KEY = "_dossier_user_transform_active"
_UT_SNAPSHOT_KEY = "_dossier_pre_transform_snapshot" _UT_SNAPSHOT_KEY = "_dossier_pre_transform_snapshot"
_UNDO_ACTIVE_KEY = "_dossier_undo_active"
def _snapshot_source_positions(doc): def _snapshot_source_positions(doc):
@@ -7707,12 +7777,16 @@ def _snapshot_source_positions(doc):
t = m.get("type") t = m.get("type")
geom = obj.Geometry geom = obj.Geometry
if t in SOURCE_TYPES: if t in SOURCE_TYPES:
parent = m.get("oeff_parent") or ""
if hasattr(geom, "Location"): if hasattr(geom, "Location"):
p = geom.Location p = geom.Location
snap["sources"][m["id"]] = {"type": t, "pos": (p.X, p.Y, p.Z)} snap["sources"][m["id"]] = {"type": t,
"oeff_parent": parent,
"pos": (p.X, p.Y, p.Z)}
elif isinstance(geom, rg.Curve): elif isinstance(geom, rg.Curve):
s = geom.PointAtStart; e = geom.PointAtEnd s = geom.PointAtStart; e = geom.PointAtEnd
snap["sources"][m["id"]] = {"type": t, snap["sources"][m["id"]] = {"type": t,
"oeff_parent": parent,
"start": (s.X, s.Y, s.Z), "start": (s.X, s.Y, s.Z),
"end": (e.X, e.Y, e.Z)} "end": (e.X, e.Y, e.Z)}
elif t in VOLUME_TYPES: elif t in VOLUME_TYPES:
@@ -7728,25 +7802,96 @@ def _snapshot_source_positions(doc):
return snap return snap
def _suppress_redraw_until_cmd_end():
"""Schaltet RedrawEnabled erst auf False sobald das ERSTE Object-Event
waehrend eines User-Transform-Commands feuert. Damit bleiben Rubber-
Band-Linie und Drag-Vorschau waehrend des Pickings sichtbar (Picking
feuert keine Object-Events), aber Rhinos automatischer Post-Move-
Redraw (kommt nach dem Klick, direkt nach den Replace-Events) wird
unterdrueckt. Wird im selben Command nur einmal aktiv."""
if sc.sticky.get("_dossier_cmd_redraw_suppressed"): return
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: return
try:
sc.sticky["_dossier_cmd_redraw_prev"] = bool(doc.Views.RedrawEnabled)
doc.Views.RedrawEnabled = False
sc.sticky["_dossier_cmd_redraw_suppressed"] = True
except Exception as ex:
print("[ELEMENTE] suppress redraw:", ex)
def _on_command_begin(sender, e): def _on_command_begin(sender, e):
try: try:
name = getattr(e, "CommandEnglishName", "") or "" name = getattr(e, "CommandEnglishName", "") or ""
except Exception: name = "" except Exception: name = ""
if name not in _USER_TRANSFORM_CMDS: return
doc = Rhino.RhinoDoc.ActiveDoc doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: return if doc is None: return
# Undo/Redo: nur Flag setzen, KEIN Snapshot, KEIN Redraw-Suppress —
# Rhinos Undo verwaltet RedrawEnabled selbst. Event-Handler ignorieren
# waehrend dieser Phase alle Add/Delete/Replace-Events → kein Regen-
# Storm.
if name in _USER_UNDO_CMDS:
sc.sticky[_UNDO_ACTIVE_KEY] = name
return
if name not in _USER_TRANSFORM_CMDS: return
sc.sticky[_UT_SNAPSHOT_KEY] = _snapshot_source_positions(doc) sc.sticky[_UT_SNAPSHOT_KEY] = _snapshot_source_positions(doc)
sc.sticky[_UT_ACTIVE_KEY] = name sc.sticky[_UT_ACTIVE_KEY] = name
# RedrawEnabled bleibt HIER auf True. Wird erst beim ersten Object-Event
# (= nach dem Klick) via `_suppress_redraw_until_cmd_end` ausgeschaltet.
# Rubber-Band-Linie + Drag-Vorschau bleiben dadurch wahrend Picking
# sichtbar.
# Undo-Record umschliesst Rhinos Move + unseren Regen in EINEM Undo-
# Schritt. Sonst macht jedes Delete/AddBrep eine eigene Undo-Entry und
# Cmd+Z bringt nur halbe Wand zurueck → Duplikate.
try:
serial = doc.BeginUndoRecord("Element-Transform")
sc.sticky["_dossier_undo_serial"] = serial
except Exception as ex:
print("[ELEMENTE] cmd-begin undo record:", ex)
sc.sticky["_dossier_undo_serial"] = None
def _on_command_end(sender, e): def _on_command_end(sender, e):
# Undo/Redo abschliessen: nur Flag clearen, kein Regen + ein Selection-
# Refresh fuers Gestaltung-Panel (Listener waren waehrend Undo aus).
if sc.sticky.get(_UNDO_ACTIVE_KEY):
sc.sticky[_UNDO_ACTIVE_KEY] = None
gb = sc.sticky.get("gestaltung_bridge")
if gb is not None:
try: gb._send_selection()
except Exception: pass
b = sc.sticky.get("elemente_bridge")
if b is not None:
try: b._send_state()
except Exception: pass
return
name = sc.sticky.get(_UT_ACTIVE_KEY) name = sc.sticky.get(_UT_ACTIVE_KEY)
if not name: return if not name: return
sc.sticky[_UT_ACTIVE_KEY] = None # _UT_ACTIVE_KEY bleibt gesetzt bis am Ende der Funktion — sonst feuern
# gestaltungs Listener auf die Replace-Events die wir hier selber
# erzeugen (Pure-Translate translates Volumen via Replace; Regen-Pfad
# ersetzt Sub-Volumen). Cleanup im finally-Block am Ende.
snapshot = sc.sticky.get(_UT_SNAPSHOT_KEY) or {} snapshot = sc.sticky.get(_UT_SNAPSHOT_KEY) or {}
sc.sticky[_UT_SNAPSHOT_KEY] = None sc.sticky[_UT_SNAPSHOT_KEY] = None
doc = Rhino.RhinoDoc.ActiveDoc doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: return if doc is None:
sc.sticky[_UT_ACTIVE_KEY] = None
sc.sticky["_dossier_cmd_redraw_suppressed"] = None
sc.sticky["_dossier_cmd_redraw_prev"] = None
sc.sticky["_dossier_undo_serial"] = None
return
# RedrawEnabled wurde idR schon beim ersten Object-Event nach dem
# User-Klick auf False gesetzt (`_suppress_redraw_until_cmd_end`). Den
# gemerkten prev-Wert lesen. Falls kein Event gefeuert hat (z.B. Move
# ohne tatsaechliche Aenderung), suppressen wir jetzt selber.
if sc.sticky.get("_dossier_cmd_redraw_suppressed"):
prev_redraw_enabled = sc.sticky.get("_dossier_cmd_redraw_prev", True)
sc.sticky["_dossier_cmd_redraw_suppressed"] = None
sc.sticky["_dossier_cmd_redraw_prev"] = None
else:
prev_redraw_enabled = doc.Views.RedrawEnabled
doc.Views.RedrawEnabled = False
sources_snap = snapshot.get("sources", {}) if isinstance(snapshot, dict) else {} sources_snap = snapshot.get("sources", {}) if isinstance(snapshot, dict) else {}
volumes_snap = snapshot.get("volumes", {}) if isinstance(snapshot, dict) else {} volumes_snap = snapshot.get("volumes", {}) if isinstance(snapshot, dict) else {}
@@ -7794,10 +7939,29 @@ def _on_command_end(sender, e):
deltas[m["id"]] = d deltas[m["id"]] = d
except Exception: pass except Exception: pass
# IDs der tatsaechlich bewegten Sources (Delta != 0). `deltas` enthaelt
# ALLE Sources der Szene mit ihrem Snapshot-Delta — unbewegte haben
# Delta=(0,0,0). Wir brauchen aber die *bewegten*, um Hierarchie-
# Entscheidungen zu treffen (Eltern→Kind-Cascade).
moved_ids = {eid for eid, d in deltas.items()
if abs(d[0]) > 1e-6 or abs(d[1]) > 1e-6 or abs(d[2]) > 1e-6}
# Orphan-Oeffnung erkennen: bewegte Oeffnung, deren Eltern-Wand NICHT
# mitbewegt wurde. In diesem Fall ist Pure-Translate verboten — die
# Wand-Aussparung (Boolean-Difference-Loch im Wand-Brep) bliebe am alten
# Ort. Nur ein voller Wand-Regen rebuildet das Loch an der neuen Pos.
orphan_opening = False
for eid in moved_ids:
old = sources_snap.get(eid)
if old and old.get("type") == "oeffnung_point":
parent = old.get("oeff_parent")
if parent and parent not in moved_ids:
orphan_opening = True
break
pure_delta = None pure_delta = None
if not abort_pure: if not abort_pure and not orphan_opening:
moved = [d for d in deltas.values() moved = [d for eid, d in deltas.items() if eid in moved_ids]
if abs(d[0]) > 1e-6 or abs(d[1]) > 1e-6 or abs(d[2]) > 1e-6]
if moved: if moved:
# Alle bewegten Sources muessen denselben Vektor haben # Alle bewegten Sources muessen denselben Vektor haben
first = moved[0] first = moved[0]
@@ -7809,24 +7973,45 @@ def _on_command_end(sender, e):
has_z_drag = abs(first[2]) > 1e-6 has_z_drag = abs(first[2]) > 1e-6
if same and not has_z_drag: if same and not has_z_drag:
pure_delta = first pure_delta = first
else:
print("[ELEMENTE] no pure-translate: same={} has_z={} n_moved={}".format(
same, has_z_drag, len(moved)))
elif abort_pure:
print("[ELEMENTE] no pure-translate: end-grip/rotate/scale detected")
elif orphan_opening:
print("[ELEMENTE] no pure-translate: opening moved without parent wall (cutout muss regen)")
if pure_delta is not None: if pure_delta is not None:
# PURE-TRANSLATE PFAD: nur Geometries translaten die nicht schon vom # PURE-TRANSLATE PFAD: nur Geometries translaten die nicht schon vom
# User-Move transformed wurden. Keine Brep-Regeneration, kein # User-Move transformed wurden. Keine Brep-Regeneration, kein
# Boolean-Diff. → instant feedback. # Boolean-Diff. → instant feedback.
print("[ELEMENTE] pure-translate: delta=({:.3f}, {:.3f}, {:.3f})".format(*pure_delta))
vec = rg.Vector3d(*pure_delta) vec = rg.Vector3d(*pure_delta)
# Eltern→Kind-Cascade: wenn eine Wand bewegt wurde, muessen ihre
# Oeffnungen mit. Aber NICHT umgekehrt: wenn nur eine Oeffnung
# bewegt wird, bleibt die Wand stehen.
def _should_follow(m):
eid = m.get("id")
if eid in moved_ids: return True
# Oeffnung ihrer Eltern-Wand folgen lassen
parent = m.get("oeff_parent")
if parent and parent in moved_ids: return True
return False
_was_busy = sc.sticky.get(_REGEN_BUSY, False) _was_busy = sc.sticky.get(_REGEN_BUSY, False)
sc.sticky[_REGEN_BUSY] = True sc.sticky[_REGEN_BUSY] = True
prev_redraw = doc.Views.RedrawEnabled # RedrawEnabled wurde schon in _on_command_begin auf False gesetzt
doc.Views.RedrawEnabled = False
try: try:
for obj in list(doc.Objects): for obj in list(doc.Objects):
try: try:
m = _read_meta(obj) m = _read_meta(obj)
if not m: continue if not m: continue
t = m.get("type") t = m.get("type")
# Sources die nicht in deltas waren (= unbewegt) auch translaten if not _should_follow(m): continue
if t in SOURCE_TYPES and m["id"] not in deltas: # Sources die nicht bewegt wurden (= Delta=0) translaten
# — nur Oeffnungs-Sources via _should_follow erlaubt.
if t in SOURCE_TYPES and m["id"] not in moved_ids:
new_geom = obj.Geometry.Duplicate() new_geom = obj.Geometry.Duplicate()
new_geom.Translate(vec) new_geom.Translate(vec)
doc.Objects.Replace(obj.Id, new_geom) doc.Objects.Replace(obj.Id, new_geom)
@@ -7854,14 +8039,30 @@ def _on_command_end(sender, e):
except Exception as ex: except Exception as ex:
print("[ELEMENTE] pure-translate:", ex) print("[ELEMENTE] pure-translate:", ex)
finally: finally:
doc.Views.RedrawEnabled = prev_redraw
sc.sticky[_REGEN_BUSY] = _was_busy sc.sticky[_REGEN_BUSY] = _was_busy
doc.Views.RedrawEnabled = prev_redraw_enabled
try: doc.Views.Redraw() try: doc.Views.Redraw()
except Exception: pass except Exception: pass
# Flag erst HIER cleren, nachdem alle Replace-Events durch sind —
# sonst feuert gestaltung.on_replace pro Volume.
sc.sticky[_UT_ACTIVE_KEY] = None
# Undo-Record schliessen — alles seit BeginUndoRecord landet in
# einem einzelnen Cmd+Z-Schritt.
undo_serial = sc.sticky.get("_dossier_undo_serial")
if undo_serial:
try: doc.EndUndoRecord(undo_serial)
except Exception: pass
sc.sticky["_dossier_undo_serial"] = None
b = sc.sticky.get("elemente_bridge") b = sc.sticky.get("elemente_bridge")
if b is not None: if b is not None:
try: b._send_state() try: b._send_state()
except Exception: pass except Exception: pass
# Gestaltung-Panel einmalig nachziehen — Listener waren waehrend
# des User-Transform-Commands suspendiert.
gb = sc.sticky.get("gestaltung_bridge")
if gb is not None:
try: gb._send_selection()
except Exception: pass
return return
# ─── Regulärer Pfad: Constraints + Migrate + Regen (existing flow) ────── # ─── Regulärer Pfad: Constraints + Migrate + Regen (existing flow) ──────
@@ -7877,11 +8078,10 @@ def _on_command_end(sender, e):
# Regen ausloesen → mehrere Regens pro Wand. Wir machen am Schluss EINEN # Regen ausloesen → mehrere Regens pro Wand. Wir machen am Schluss EINEN
# Regen pro affected_wall — viel schneller bei mehreren Oeffnungen. # Regen pro affected_wall — viel schneller bei mehreren Oeffnungen.
sc.sticky["_dossier_skip_sync_regen"] = True sc.sticky["_dossier_skip_sync_regen"] = True
# Display-Updates komplett suppressen waehrend der Batch — Rhino zeichnet # RedrawEnabled wurde schon in _on_command_begin auf False gesetzt —
# sonst nach jedem Brep-Add/Replace neu, was bei mehreren Sub-Volumen # damit unterdruecken wir auch Rhinos automatischen Post-Move-Redraw
# sichtbares „Aufbauen" verursacht. Ein einziger Redraw am Ende reicht. # (sonst kurzer Mismatch-Frame: Oeffnung an neuer Pos, Wand-Loch noch
prev_redraw = doc.Views.RedrawEnabled # an alter Pos).
doc.Views.RedrawEnabled = False
try: try:
for obj in list(doc.Objects): for obj in list(doc.Objects):
try: try:
@@ -7925,26 +8125,30 @@ def _on_command_end(sender, e):
except (ValueError, TypeError): z_delta = 0.0 except (ValueError, TypeError): z_delta = 0.0
sc.sticky["_elemente_wand_z_delta"] = None sc.sticky["_elemente_wand_z_delta"] = None
if abs(z_delta) >= 1e-6: if abs(z_delta) >= 1e-6:
# Brüstungen aller Öffnungen der Wand um delta mitnehmen # OPTION A: Brueest ist RELATIV zur Wand-UK. Da UK
# in `_apply_wand_z_drag_constraint` schon um z_delta
# geaendert wurde, folgt die Oeffnung automatisch via
# Regen (cutout = new_UK + brueest = old_world_Z +
# z_delta). Wir muessen NICHT die brueest-UserString
# aktualisieren — sonst gaebe es Doppel-Addition.
# Den Oeffnungs-Punkt setzen wir auf Snapshot-Z +
# z_delta. So funktioniert es egal ob Rhino die
# Oeffnung schon mit-bewegt hat (User-Multi-Select)
# oder nicht — das End-Z ist immer das richtige.
for op_obj, op_meta in _find_openings_for_wall(doc, m["id"]): for op_obj, op_meta in _find_openings_for_wall(doc, m["id"]):
cur_b = op_meta.get("oeff_brueest")
try: try:
cur_b_val = float(cur_b) if cur_b not in (None, "") else 0.0 op_snap = sources_snap.get(op_meta["id"])
except (ValueError, TypeError): if not op_snap: continue
cur_b_val = 0.0 op_pos = op_snap.get("pos")
new_b = max(0.0, cur_b_val + z_delta) if op_pos is None: continue
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 pt_geom = op_obj.Geometry
if hasattr(pt_geom, "Location"): if not hasattr(pt_geom, "Location"): continue
pt = pt_geom.Location pt = pt_geom.Location
target_z = op_pos[2] + z_delta
doc.Objects.Replace(op_obj.Id, doc.Objects.Replace(op_obj.Id,
rg.Point(rg.Point3d(pt.X, pt.Y, new_b))) rg.Point(rg.Point3d(pt.X, pt.Y, target_z)))
except Exception as ex: except Exception as ex:
print("[ELEMENTE] post-cmd brueest:", ex) print("[ELEMENTE] post-cmd brueest pt-shift:", ex)
affected_walls.add(m["id"]) affected_walls.add(m["id"])
elif t == "oeffnung_point": elif t == "oeffnung_point":
op_pos = old.get("pos") op_pos = old.get("pos")
@@ -7962,19 +8166,33 @@ def _on_command_end(sender, e):
# Sync-Regen aller betroffenen Wände — Move ist sauber abgeschlossen, # Sync-Regen aller betroffenen Wände — Move ist sauber abgeschlossen,
# kein Konflikt mehr moeglich. EIN Regen pro Wand (nicht pro Oeffnung). # kein Konflikt mehr moeglich. EIN Regen pro Wand (nicht pro Oeffnung).
# Display bleibt suppressed bis ALLE Wände durch sind → kein „Aufbauen". # Display bleibt suppressed bis ALLE Wände durch sind → kein „Aufbauen".
try:
for wid in affected_walls: for wid in affected_walls:
try: _regenerate_element(doc, wid) try: _regenerate_element(doc, wid)
except Exception as ex: except Exception as ex:
print("[ELEMENTE] post-cmd regen:", ex) print("[ELEMENTE] post-cmd regen:", ex)
finally: doc.Views.RedrawEnabled = prev_redraw_enabled
doc.Views.RedrawEnabled = prev_redraw
try: doc.Views.Redraw() try: doc.Views.Redraw()
except Exception: pass except Exception: pass
# Flag erst HIER cleren — nach dem Regen-Pfad, der via _regenerate_element
# viele Replace-Events erzeugt die wir auch suppressen wollen.
sc.sticky[_UT_ACTIVE_KEY] = None
# Undo-Record schliessen — alles seit BeginUndoRecord landet in
# einem einzelnen Cmd+Z-Schritt.
undo_serial = sc.sticky.get("_dossier_undo_serial")
if undo_serial:
try: doc.EndUndoRecord(undo_serial)
except Exception: pass
sc.sticky["_dossier_undo_serial"] = None
b = sc.sticky.get("elemente_bridge") b = sc.sticky.get("elemente_bridge")
if b is not None: if b is not None:
try: b._send_state() try: b._send_state()
except Exception: pass except Exception: pass
# Gestaltung-Panel einmalig nachziehen — Listener waren waehrend
# des Commands + des Regens suspendiert.
gb = sc.sticky.get("gestaltung_bridge")
if gb is not None:
try: gb._send_selection()
except Exception: pass
def _install_listeners(bridge): def _install_listeners(bridge):
+37 -18
View File
@@ -1368,6 +1368,13 @@ def _install_selection_listener(bridge):
return return
def refresh(*args): def refresh(*args):
# Waehrend Move/Rotate/Mirror/Scale schweigen — Rhino oszilliert die
# Selection pro transformiertem Object mehrfach (deselect→delete→add→
# reselect). Bei 7 Objekten sind das ~100 IPC-Sends in den WebView,
# was sich als „Regen" anfuehlt. elemente._on_command_end refresht
# nach dem Command einmalig.
if sc.sticky.get("_dossier_user_transform_active"): return
if sc.sticky.get("_dossier_undo_active"): return
b = sc.sticky.get("gestaltung_bridge") b = sc.sticky.get("gestaltung_bridge")
if b is not None: if b is not None:
try: b._send_selection() try: b._send_selection()
@@ -1380,6 +1387,12 @@ def _install_selection_listener(bridge):
- Hatch hat _FILL_OWNER_KEY (= curve_id) → Curve um den gleichen - Hatch hat _FILL_OWNER_KEY (= curve_id) → Curve um den gleichen
Vektor mit-translaten (User hat Hatch alleine verschoben). Vektor mit-translaten (User hat Hatch alleine verschoben).
""" """
# Waehrend User-Transform-Command: elemente uebernimmt die Geometrie-
# Synchronisation. Hatch-Re-Create laeuft hier sowieso ins Leere weil
# Rhino bei Move Delete+Add statt Replace feuert.
if sc.sticky.get("_dossier_user_transform_active"): return
if sc.sticky.get("_dossier_undo_active"): return
if sc.sticky.get("_elemente_regen_busy"): return
new_obj = args.NewRhinoObject new_obj = args.NewRhinoObject
if new_obj is None or new_obj.Id in _processing: if new_obj is None or new_obj.Id in _processing:
return return
@@ -1457,11 +1470,13 @@ def _install_selection_listener(bridge):
"""Wenn eine Curve geloescht wird, ihre gekoppelte Hatch mitloeschen. """Wenn eine Curve geloescht wird, ihre gekoppelte Hatch mitloeschen.
Wenn umgekehrt eine Hatch direkt geloescht wird, den Verweis auf der Wenn umgekehrt eine Hatch direkt geloescht wird, den Verweis auf der
Curve aufraeumen damit beim naechsten Toggle keine Geister-Referenz steht.""" Curve aufraeumen damit beim naechsten Toggle keine Geister-Referenz steht."""
# Waehrend User-Transform-Command: Rhino feuert Delete+Add fuer
# transformierte Objekte. Curve→Hatch-Cascade hier wuerde die Hatch
# killen obwohl sie gleich wieder benoetigt wird.
if sc.sticky.get("_dossier_user_transform_active"): return
if sc.sticky.get("_dossier_undo_active"): return
if sc.sticky.get("_elemente_regen_busy"): return
obj = args.TheObject obj = args.TheObject
try:
print("[GESTALTUNG] on_delete fired id={}".format(obj.Id if obj else None))
except Exception:
pass
if obj is None or obj.Id in _processing: if obj is None or obj.Id in _processing:
return return
doc = Rhino.RhinoDoc.ActiveDoc doc = Rhino.RhinoDoc.ActiveDoc
@@ -1470,16 +1485,24 @@ def _install_selection_listener(bridge):
except Exception: except Exception:
return return
# Pfad A: geloeschte Curve hatte eine Hatch -> Hatch mitloeschen # Schneller Bail-out: ohne Hatch-UserString interessiert uns das
# Event nicht. Vermeidet Print-Spam fuer Wand-Sub-Volumen etc.
try: try:
hatch_id_str = attrs.GetUserString(_FILL_KEY) hatch_id_str = attrs.GetUserString(_FILL_KEY)
except Exception: except Exception:
hatch_id_str = None hatch_id_str = None
# Fallback: Mapping in sc.sticky (UserStrings koennen nach Delete leer sein) try:
if not hatch_id_str: owner_id_str = attrs.GetUserString(_FILL_OWNER_KEY)
except Exception:
owner_id_str = None
if not hatch_id_str and not owner_id_str:
# UserStrings koennen nach Delete leer sein → Sticky-Fallback.
hatch_id_str = _lookup_hatch_for_curve(obj.Id) hatch_id_str = _lookup_hatch_for_curve(obj.Id)
if hatch_id_str: if not hatch_id_str:
return
print("[GESTALTUNG] on_delete: hatch via sticky map gefunden") print("[GESTALTUNG] on_delete: hatch via sticky map gefunden")
# Pfad A: geloeschte Curve hatte eine Hatch -> Hatch mitloeschen
if hatch_id_str: if hatch_id_str:
try: try:
hatch_id = System.Guid(hatch_id_str) hatch_id = System.Guid(hatch_id_str)
@@ -1504,10 +1527,6 @@ def _install_selection_listener(bridge):
return # Curve-Fall fertig return # Curve-Fall fertig
# Pfad B: geloeschte Hatch hatte einen Owner-Verweis -> Curve aufraeumen # Pfad B: geloeschte Hatch hatte einen Owner-Verweis -> Curve aufraeumen
try:
owner_id_str = attrs.GetUserString(_FILL_OWNER_KEY)
except Exception:
owner_id_str = None
if owner_id_str: if owner_id_str:
try: try:
owner_id = System.Guid(owner_id_str) owner_id = System.Guid(owner_id_str)
@@ -1532,16 +1551,17 @@ def _install_selection_listener(bridge):
- Wenn das Objekt eben gerade als Teil eines Drag/Move geloescht wurde, - Wenn das Objekt eben gerade als Teil eines Drag/Move geloescht wurde,
stellen wir die Hatch mit den gemerkten Metadaten wieder her. stellen wir die Hatch mit den gemerkten Metadaten wieder her.
- Sonst pruefen wir ob die Ebene ein Auto-Fill konfiguriert hat.""" - Sonst pruefen wir ob die Ebene ein Auto-Fill konfiguriert hat."""
# Waehrend User-Transform-Command: elemente uebernimmt Geometrie-Sync.
# Auto-Fill hier wuerde unnoetige Hatches erzeugen weil das Objekt
# bereits eine geerbte Fill-UserString hat (vom Delete+Add im Move).
if sc.sticky.get("_dossier_user_transform_active"): return
if sc.sticky.get("_dossier_undo_active"): return
if sc.sticky.get("_elemente_regen_busy"): return
obj = args.TheObject obj = args.TheObject
if obj is None: if obj is None:
return return
try:
geom_kind = type(obj.Geometry).__name__
except Exception:
geom_kind = "?"
if obj.Id in _processing: if obj.Id in _processing:
return return
print("[GESTALTUNG] on_add: id={} type={}".format(obj.Id, geom_kind))
doc = Rhino.RhinoDoc.ActiveDoc doc = Rhino.RhinoDoc.ActiveDoc
# 1) Drag-Recovery: Hatch-Metadaten wurden gerade in on_delete gespeichert? # 1) Drag-Recovery: Hatch-Metadaten wurden gerade in on_delete gespeichert?
@@ -1566,7 +1586,6 @@ def _install_selection_listener(bridge):
except Exception as ex: except Exception as ex:
print("[GESTALTUNG] on_add Exception:", ex) print("[GESTALTUNG] on_add Exception:", ex)
return return
print("[GESTALTUNG] on_add ok={}".format(ok))
if ok: if ok:
b = sc.sticky.get("gestaltung_bridge") b = sc.sticky.get("gestaltung_bridge")
if b is not None: if b is not None: