From 2a75b1da93f15b9f9fb3e5acd7d23c8907d9799f Mon Sep 17 00:00:00 2001 From: karim Date: Mon, 18 May 2026 22:20:35 +0200 Subject: [PATCH] =?UTF-8?q?Snapshot:=20Transform-Hierarchie=20+=20Br=C3=BC?= =?UTF-8?q?stung-Konvention=20+=20Undo-Record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- rhino/elemente.py | 324 ++++++++++++++++++++++++++++++++++++-------- rhino/gestaltung.py | 57 +++++--- 2 files changed, 309 insertions(+), 72 deletions(-) diff --git a/rhino/elemente.py b/rhino/elemente.py index 4774ad3..ce5a6e8 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -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 "" + actual_ok = verify.Attributes.GetUserString(_KEY_OK_OVER) or "" + 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): diff --git a/rhino/gestaltung.py b/rhino/gestaltung.py index e9cbdef..718afb3 100644 --- a/rhino/gestaltung.py +++ b/rhino/gestaltung.py @@ -1368,6 +1368,13 @@ def _install_selection_listener(bridge): return 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") if b is not None: try: b._send_selection() @@ -1380,6 +1387,12 @@ def _install_selection_listener(bridge): - Hatch hat _FILL_OWNER_KEY (= curve_id) → Curve um den gleichen 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 if new_obj is None or new_obj.Id in _processing: return @@ -1457,11 +1470,13 @@ def _install_selection_listener(bridge): """Wenn eine Curve geloescht wird, ihre gekoppelte Hatch mitloeschen. Wenn umgekehrt eine Hatch direkt geloescht wird, den Verweis auf der 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 - 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: return doc = Rhino.RhinoDoc.ActiveDoc @@ -1470,16 +1485,24 @@ def _install_selection_listener(bridge): except Exception: 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: hatch_id_str = attrs.GetUserString(_FILL_KEY) except Exception: hatch_id_str = None - # Fallback: Mapping in sc.sticky (UserStrings koennen nach Delete leer sein) - if not hatch_id_str: + try: + 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) - if hatch_id_str: - print("[GESTALTUNG] on_delete: hatch via sticky map gefunden") + if not hatch_id_str: + return + print("[GESTALTUNG] on_delete: hatch via sticky map gefunden") + + # Pfad A: geloeschte Curve hatte eine Hatch -> Hatch mitloeschen if hatch_id_str: try: hatch_id = System.Guid(hatch_id_str) @@ -1504,10 +1527,6 @@ def _install_selection_listener(bridge): return # Curve-Fall fertig # 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: try: 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, stellen wir die Hatch mit den gemerkten Metadaten wieder her. - 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 if obj is None: return - try: - geom_kind = type(obj.Geometry).__name__ - except Exception: - geom_kind = "?" if obj.Id in _processing: return - print("[GESTALTUNG] on_add: id={} type={}".format(obj.Id, geom_kind)) doc = Rhino.RhinoDoc.ActiveDoc # 1) Drag-Recovery: Hatch-Metadaten wurden gerade in on_delete gespeichert? @@ -1566,7 +1586,6 @@ def _install_selection_listener(bridge): except Exception as ex: print("[GESTALTUNG] on_add Exception:", ex) return - print("[GESTALTUNG] on_add ok={}".format(ok)) if ok: b = sc.sticky.get("gestaltung_bridge") if b is not None: