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
+271 -53
View File
@@ -3922,6 +3922,8 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
if meta["type"] == "wand_axis":
uk, ok = _resolve_uk_ok(doc, meta["geschoss"],
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).
miter_start = None
miter_end = None
@@ -5580,7 +5582,19 @@ class ElementeBridge(panel_base.BaseBridge):
oeff_sims_in=simsi_def,
oeff_glas=glas_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:
print("[ELEMENTE] AddPoint fehlgeschlagen"); return
@@ -6736,10 +6750,21 @@ def _apply_wand_z_drag_constraint(new_obj, meta):
meta["uk_override"], meta["ok_override"])
new_uk = uk_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.SetUserString(_KEY_UK_OVER, "{:.6f}".format(new_uk))
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
# einzelnem End-Grip-Drag). Andere Curves: ueber Translation (akzeptiert
# 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_curve = None
parent_meta = None
doc = Rhino.RhinoDoc.ActiveDoc
if doc is not None and parent_id:
for obj in doc.Objects:
@@ -6809,6 +6835,7 @@ def _apply_oeffnung_constraint(new_obj, meta, old_obj=None):
cg = obj.Geometry
if isinstance(cg, rg.Curve):
parent_curve = cg
parent_meta = m
break
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
except (ValueError, TypeError):
cur_bruest_val = 0.9
# Z-Delta = Drag in Z. Der Punkt SITZT visuell auf Bruestung-Hoehe
# (siehe Geometry-Schreibung unten), daher pt_old.Z ~= alte Bruestung.
delta_z = (pt_new.Z - pt_old.Z) if pt_old is not None else (pt_new.Z - cur_bruest_val)
# Z-Delta gegen den ERWARTETEN Welt-Z des Punktes = Wand-UK + Brueest.
# Bruestung ist relativ zur Wand-UK gespeichert. Wenn die Wand
# 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
if abs(delta_z) >= 1e-6:
new_bruest = max(0.0, cur_bruest_val + delta_z)
# Punkt visuell auf Bruestungs-Hoehe (= Unterkante Oeffnung), nicht auf 0.
# So sieht der User wo die Oeffnung beginnt + Z-Drag-Delta entspricht
# direkt der Bruestungsaenderung.
target_z = new_bruest
# Punkt visuell auf der Unterkante der Oeffnung in Welt-Z platzieren =
# Wand-UK + Brueest. So sieht der User wo die Oeffnung beginnt, auch
# wenn die Wand auf einem hoeheren Geschoss steht.
target_z = wall_uk + new_bruest
geom_changed = not (
abs(target_x - pt_new.X) < 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
# 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
# NICHTS hier tun (Rhinos Move soll konfliktfrei durchlaufen). Erstes
# Event = User hat geklickt → Redraw ab jetzt suppressen, sonst Mismatch-
# Frame zwischen Rhinos Auto-Redraw und unserem Regen.
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
# Snapshot der aktuell selektierten IDs — damit Migrate die Objekte
# 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
Volumen am richtigen Ort)."""
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:
new_obj = e.TheObject
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-
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:
obj = e.TheObject
meta = _read_meta(obj)
@@ -7688,8 +7751,15 @@ _USER_TRANSFORM_CMDS = frozenset((
"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_SNAPSHOT_KEY = "_dossier_pre_transform_snapshot"
_UNDO_ACTIVE_KEY = "_dossier_undo_active"
def _snapshot_source_positions(doc):
@@ -7707,12 +7777,16 @@ def _snapshot_source_positions(doc):
t = m.get("type")
geom = obj.Geometry
if t in SOURCE_TYPES:
parent = m.get("oeff_parent") or ""
if hasattr(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):
s = geom.PointAtStart; e = geom.PointAtEnd
snap["sources"][m["id"]] = {"type": t,
"oeff_parent": parent,
"start": (s.X, s.Y, s.Z),
"end": (e.X, e.Y, e.Z)}
elif t in VOLUME_TYPES:
@@ -7728,25 +7802,96 @@ def _snapshot_source_positions(doc):
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):
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
# 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_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):
# 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)
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 {}
sc.sticky[_UT_SNAPSHOT_KEY] = None
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 {}
volumes_snap = snapshot.get("volumes", {}) if isinstance(snapshot, dict) else {}
@@ -7794,10 +7939,29 @@ def _on_command_end(sender, e):
deltas[m["id"]] = d
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
if not abort_pure:
moved = [d for d in deltas.values()
if abs(d[0]) > 1e-6 or abs(d[1]) > 1e-6 or abs(d[2]) > 1e-6]
if not abort_pure and not orphan_opening:
moved = [d for eid, d in deltas.items() if eid in moved_ids]
if moved:
# Alle bewegten Sources muessen denselben Vektor haben
first = moved[0]
@@ -7809,24 +7973,45 @@ def _on_command_end(sender, e):
has_z_drag = abs(first[2]) > 1e-6
if same and not has_z_drag:
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:
# PURE-TRANSLATE PFAD: nur Geometries translaten die nicht schon vom
# User-Move transformed wurden. Keine Brep-Regeneration, kein
# Boolean-Diff. → instant feedback.
print("[ELEMENTE] pure-translate: delta=({:.3f}, {:.3f}, {:.3f})".format(*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)
sc.sticky[_REGEN_BUSY] = True
prev_redraw = doc.Views.RedrawEnabled
doc.Views.RedrawEnabled = False
# RedrawEnabled wurde schon in _on_command_begin auf False gesetzt
try:
for obj in list(doc.Objects):
try:
m = _read_meta(obj)
if not m: continue
t = m.get("type")
# Sources die nicht in deltas waren (= unbewegt) auch translaten
if t in SOURCE_TYPES and m["id"] not in deltas:
if not _should_follow(m): continue
# 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.Translate(vec)
doc.Objects.Replace(obj.Id, new_geom)
@@ -7854,14 +8039,30 @@ def _on_command_end(sender, e):
except Exception as ex:
print("[ELEMENTE] pure-translate:", ex)
finally:
doc.Views.RedrawEnabled = prev_redraw
sc.sticky[_REGEN_BUSY] = _was_busy
doc.Views.RedrawEnabled = prev_redraw_enabled
try: doc.Views.Redraw()
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")
if b is not None:
try: b._send_state()
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
# ─── 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 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
# RedrawEnabled wurde schon in _on_command_begin auf False gesetzt —
# damit unterdruecken wir auch Rhinos automatischen Post-Move-Redraw
# (sonst kurzer Mismatch-Frame: Oeffnung an neuer Pos, Wand-Loch noch
# an alter Pos).
try:
for obj in list(doc.Objects):
try:
@@ -7925,26 +8125,30 @@ def _on_command_end(sender, e):
except (ValueError, TypeError): z_delta = 0.0
sc.sticky["_elemente_wand_z_delta"] = None
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"]):
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)
op_snap = sources_snap.get(op_meta["id"])
if not op_snap: continue
op_pos = op_snap.get("pos")
if op_pos is None: continue
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)))
if not hasattr(pt_geom, "Location"): continue
pt = pt_geom.Location
target_z = op_pos[2] + z_delta
doc.Objects.Replace(op_obj.Id,
rg.Point(rg.Point3d(pt.X, pt.Y, target_z)))
except Exception as ex:
print("[ELEMENTE] post-cmd brueest:", ex)
print("[ELEMENTE] post-cmd brueest pt-shift:", ex)
affected_walls.add(m["id"])
elif t == "oeffnung_point":
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,
# 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
for wid in affected_walls:
try: _regenerate_element(doc, wid)
except Exception as ex:
print("[ELEMENTE] post-cmd regen:", ex)
doc.Views.RedrawEnabled = prev_redraw_enabled
try: doc.Views.Redraw()
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")
if b is not None:
try: b._send_state()
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):