Compare commits

...

4 Commits

Author SHA1 Message Date
karim 15185568ce Mirror/Copy: Duplikat-IDs in CommandEnd umbenennen statt verkoppelt zu lassen
Rhinos Mirror/Copy/Array kopiert selektierte Objekte mit ihren UserString-
Metadaten → Duplikat-IDs im Doc (z.B. zwei `wand_axis` mit gleicher
`id=wall_xxx`). Resultat: unser System sieht beide als „dasselbe Element",
fasst sie verkoppelt an, Pure-Transform wird konfus, Original wand_volume
wandert mit weil bb-snapshot matched.

Fix in `_on_command_end`, BEVOR Pure-Transform-Detection laeuft:

1. Snapshot speichert jetzt `obj_ids`-Set aller pre-Command Rhino-Object-Ids.
2. Pass A: alle neuen Sources (obj.Id nicht im Snapshot) deren UserString-id
   bereits in `sources_snap` existiert → identifiziert als Mirror/Copy-
   Duplikat, neue UUID generiert (gleicher Prefix wie bei Original-Erzeugung).
3. Pass B: alle neuen Volumes mit id = alter-renamed-Source → bekommen die
   neue ID + `oeff_parent` wird umgehaengt wenn ihre Eltern-Wand renamed.
4. Pass C: neue oeffnung_points kriegen `oeff_parent` auf renamed Wand
   umgehaengt.
5. Pass D: alle gesammelten Renames atomar via ModifyAttributes anwenden.

Resultat: Mirror-Kopie ist nach CommandEnd ein vollstaendig eigenstaendiges
Element mit eigenen IDs + intakter Parent-Cascade. Pure-Transform sieht
saubere Snapshot-vs-aktuell-Bilanz (Originale=Identity, Kopien außerhalb
des Snapshots → keine Action erforderlich, Rhino hat sie schon geometrisch
korrekt platziert).

Funktioniert generisch fuer Mirror, Copy, Array — alle dup-id-erzeugenden
Operationen. Im Log: `[ELEMENTE] mirror/copy-Duplikate: N Objs neu-ID'd`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 23:17:39 +02:00
karim 82bd15a074 Pure-Transform: Rotation läuft jetzt instant wie Translation
Erweitert die bisherige Pure-Translate-Optimierung auf beliebige
Rigid-2D-Transforms (Translation + Z-Rotation). Statt nur einen
Delta-Vektor zu detektieren, wird pro Source ein Rigid-Transform aus
Snapshot-vs-aktueller-Geometrie berechnet:

- Curve-Sources: aus Endpunkten Drehwinkel + Translation ableiten.
- Length-Aenderung der Curve → Scale/End-Grip → abort_pure.
- Z-Aenderung der Curve → Z-Drag → abort_pure (UK_OVER-Schreibung
  geht weiter ueber Regen-Pfad).
- Point-Sources: nur Translation aus Position.

Konsistenz-Check: alle Curve-Transforms muessen identisch sein,
Point-Positionen muessen `canonical(old_pos) == new_pos` erfuellen.
Sonst → Regen.

Bei pure_transform != None: Transform auf alle Geometries der
Cascade anwenden die nicht schon von Rhinos Move/Rotate
transformed wurden. Volumes via bb-Snapshot-Check, Sources via
identity-transform-check.

Resultat: einzelne Wand + Oeffnungen rotieren → instant statt
~100-200ms Regen.

Mirror-Limitation: Einzelne Wand-Spiegelung wird als 180°-Rotation
interpretiert (matched die Endpunkte). Bei symmetrischen Volumen
unsichtbar; bei asymmetrischen Fenstern visuell anders als ein
echter Mirror. Mehrere Walls gleichzeitig spiegeln triggert
all_consistent=False → Regen-Fallback (korrekt). Bekannte
Einschraenkung, separater Fix nötig.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 23:02:20 +02:00
karim 0978d9fc2e Rotation: Snapshot-basierte Migrate für korrekte Bogenlängen-Position
Bug: Bei Rotation um einen externen Punkt liegt der Öffnungs-Punkt
nach Rhinos Transform NICHT mehr auf der alten Achse → migrate's
ClosestPoint(current_pos) snappte zum nächsten Endpunkt der alten
Achse → relative=1 → alle Öffnungen landeten am gleichen Ende der
neuen Achse (= „bei Referenzpunkt der Drehung").

Fix: Migrate nutzt jetzt die PRE-TRANSFORM Position aus dem Snapshot
(via `old_positions` Parameter). Aufrufer im CommandEnd-Regen-Pfad
sammelt die alten Positionen aus `sources_snap` und gibt sie weiter.

Migrate setzt opening_point.Z jetzt auch konsistent auf
`wall_uk + brüstung` statt nur `brüstung` — vermeidet Brüstung-Drop
beim nachfolgenden _apply_oeffnung_constraint.

Constraint überspringt XY-Projektion wenn Wand gerade migriert
wurde (`_dossier_migrated_walls` sticky-Set) — sonst würde
ClosestPoint(pt_old) auf neuer rotierter Achse die Position wieder
verschieben.

Debug-Logs in _apply_wand_z_drag_constraint + Wand-Regen bleiben
drin — haben bei der Eingrenzung des UK_OVER-Bugs geholfen, kosten
nichts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:47:09 +02:00
karim 2a75b1da93 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>
2026-05-18 22:20:35 +02:00
2 changed files with 613 additions and 134 deletions
+575 -115
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,10 +6835,20 @@ 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
if parent_curve is not None: # Wenn die Wand gerade migrate'd wurde (Rotation/Reshape/XY-Move) →
# XY-Projektion HIER UEBERSPRINGEN. Migrate hat den Punkt schon per
# Bogenlaengen-Mapping auf die neue Achse gesetzt. Eine zweite XY-
# Projektion mit ClosestPoint(pt_old) auf der NEUEN Achse wuerde die
# Position wieder verschieben (Rotation: pt_old liegt nicht mehr auf
# der neuen Achse → ClosestPoint+Tangent stimmen nicht zusammen).
migrated_walls = sc.sticky.get("_dossier_migrated_walls")
skip_xy_projection = (isinstance(migrated_walls, set)
and parent_id in migrated_walls)
if parent_curve is not None and not skip_xy_projection:
if pt_old is not None: if pt_old is not None:
try: try:
rc, t_old = parent_curve.ClosestPoint( rc, t_old = parent_curve.ClosestPoint(
@@ -6850,17 +6886,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 +6953,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
@@ -7044,12 +7101,20 @@ def _on_object_replaced_body(sender, e):
print("[ELEMENTE] on_object_replaced:", ex) print("[ELEMENTE] on_object_replaced:", ex)
def _migrate_openings_to_new_axis(wall_id, old_geom, new_geom): def _migrate_openings_to_new_axis(wall_id, old_geom, new_geom, old_positions=None):
"""Verschiebt alle Oeffnungs-Points einer Wand mit, wenn deren Achse """Verschiebt alle Oeffnungs-Points einer Wand mit, wenn deren Achse
veraendert wird. Mapping ueber relative Bogenlaenge: ein Oeffnungs- veraendert wird. Mapping ueber relative Bogenlaenge: ein Oeffnungs-
Punkt bei 30 % der alten Kurve sitzt nachher bei 30 % der neuen. Punkt bei 30 % der alten Kurve sitzt nachher bei 30 % der neuen.
So bleiben die Oeffnungen 'sticky' an der Wand bei Verschieben, So bleiben die Oeffnungen 'sticky' an der Wand bei Verschieben,
Drehen, Skalieren oder Reshape der Achse.""" Drehen, Skalieren oder Reshape der Achse.
`old_positions` (optional): {opening_id: (x, y, z)} Pre-Transform
Snapshot der Oeffnungs-Punkte. WICHTIG bei Rotation/Move: nach Rhinos
Transform liegen die Punkte schon NICHT MEHR auf der alten Axis
`ClosestPoint(current_pos)` an old_geom snappt zum naechsten Endpunkt
statt zur echten Bogenlaengen-Position alle Oeffnungen landen am
selben Ende. Bei Reshape-Operationen ohne Snapshot: Fallback auf
aktuelle Geometrie (Punkt liegt dort noch auf alter Axis)."""
if not isinstance(old_geom, rg.Curve) or not isinstance(new_geom, rg.Curve): if not isinstance(old_geom, rg.Curve) or not isinstance(new_geom, rg.Curve):
return return
doc = Rhino.RhinoDoc.ActiveDoc doc = Rhino.RhinoDoc.ActiveDoc
@@ -7059,6 +7124,29 @@ def _migrate_openings_to_new_axis(wall_id, old_geom, new_geom):
new_len = new_geom.GetLength() new_len = new_geom.GetLength()
except Exception: return except Exception: return
if old_len < 1e-9 or new_len < 1e-9: return if old_len < 1e-9 or new_len < 1e-9: return
# Wand-UK aufloesen damit Oeffnungs-Punkte auf UK+Brueestung gesetzt
# werden (= visuell auf Unterkante Oeffnung). Sonst landen sie auf
# reiner Brueest-Hoehe und der nachfolgende Constraint interpretiert
# die Diskrepanz als User-Z-Drag → Brueest dropt.
wall_uk = 0.0
src = _find_axis(doc, wall_id)
if src is not None:
wm = _read_meta(src)
if wm:
try:
wall_uk, _ = _resolve_uk_ok(doc, wm["geschoss"],
wm["uk_override"],
wm["ok_override"])
except Exception:
wall_uk = 0.0
# Migrierten Wand registrieren — der Constraint soll fuer Oeffnungen
# dieser Wand die XY-Projektion ueberspringen (migrate hat XY bereits
# via Bogenlaengen-Mapping korrekt gesetzt).
migrated = sc.sticky.get("_dossier_migrated_walls")
if not isinstance(migrated, set):
migrated = set()
migrated.add(wall_id)
sc.sticky["_dossier_migrated_walls"] = migrated
# Selected-Snapshot vom Replace-Handler — nicht live IsSelected, weil # Selected-Snapshot vom Replace-Handler — nicht live IsSelected, weil
# op_obj im laufenden Move-Event evtl. schon stale ist. # op_obj im laufenden Move-Event evtl. schon stale ist.
@@ -7079,18 +7167,27 @@ def _migrate_openings_to_new_axis(wall_id, old_geom, new_geom):
# transform" + ganzer Regen-Undo-Record wird rollbacked. # transform" + ganzer Regen-Undo-Record wird rollbacked.
if str(op_obj.Id) in skip_ids: if str(op_obj.Id) in skip_ids:
continue continue
pt_geom = op_obj.Geometry # Pre-Transform Position bevorzugen — die liegt garantiert
if hasattr(pt_geom, 'Location'): # auf der alten Axis. Aktuelle (post-transform) Position kann
cur_pos = pt_geom.Location # bei Rotation weit weg liegen → ClosestPoint snappt zum
elif isinstance(pt_geom, rg.Point3d): # falschen Endpunkt.
cur_pos = pt_geom src_pos = None
else: if old_positions is not None:
continue src_pos = old_positions.get(op_meta["id"])
if src_pos is None:
pt_geom = op_obj.Geometry
if hasattr(pt_geom, 'Location'):
loc = pt_geom.Location
src_pos = (loc.X, loc.Y, loc.Z)
elif isinstance(pt_geom, rg.Point3d):
src_pos = (pt_geom.X, pt_geom.Y, pt_geom.Z)
else:
continue
# XY-only ClosestPoint — sonst zieht eine non-zero Z-Komponente # XY-only ClosestPoint — sonst zieht eine non-zero Z-Komponente
# (Bruestungs-Hoehe) den Parameter bei kurvigen Wand-Achsen # (Bruestungs-Hoehe) den Parameter bei kurvigen Wand-Achsen
# leicht weg von der „echten" Position. # leicht weg von der „echten" Position.
ok_old, t_old = old_geom.ClosestPoint( ok_old, t_old = old_geom.ClosestPoint(
rg.Point3d(cur_pos.X, cur_pos.Y, 0.0)) rg.Point3d(src_pos[0], src_pos[1], 0.0))
if not ok_old: continue if not ok_old: continue
# Bogenlaenge auf alter Kurve bis t_old → relative Position # Bogenlaenge auf alter Kurve bis t_old → relative Position
sub = rg.Interval(old_geom.Domain.Min, t_old) sub = rg.Interval(old_geom.Domain.Min, t_old)
@@ -7126,7 +7223,9 @@ def _migrate_openings_to_new_axis(wall_id, old_geom, new_geom):
bruest_z = float(bruest) if bruest not in (None, "") else 0.0 bruest_z = float(bruest) if bruest not in (None, "") else 0.0
except (ValueError, TypeError): except (ValueError, TypeError):
bruest_z = 0.0 bruest_z = 0.0
new_pos = rg.Point3d(new_pos.X, new_pos.Y, bruest_z) # Welt-Z = Wand-UK + Brueestung (Konvention: Punkt sitzt
# visuell auf Unterkante Oeffnung).
new_pos = rg.Point3d(new_pos.X, new_pos.Y, wall_uk + bruest_z)
doc.Objects.Replace(op_obj.Id, rg.Point(new_pos)) doc.Objects.Replace(op_obj.Id, rg.Point(new_pos))
except Exception as ex: except Exception as ex:
print("[ELEMENTE] migrate one opening:", ex) print("[ELEMENTE] migrate one opening:", ex)
@@ -7159,6 +7258,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 +7361,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 +7802,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):
@@ -7697,22 +7818,30 @@ def _snapshot_source_positions(doc):
BBox-Centers. Source-Map (key=element_id) füttert Constraint+Migrate. BBox-Centers. Source-Map (key=element_id) füttert Constraint+Migrate.
Volume-Map (key=obj.Id-string) erlaubt im CommandEnd die Pure-Translate- Volume-Map (key=obj.Id-string) erlaubt im CommandEnd die Pure-Translate-
Detection wir checken pro Volume ob es schon vom Rhinos Move Detection wir checken pro Volume ob es schon vom Rhinos Move
transformed wurde, oder noch ge-translaten werden muss.""" transformed wurde, oder noch ge-translaten werden muss.
snap = {"sources": {}, "volumes": {}} obj_ids-Set: alle pre-Command Rhino-Object-IDs. Wird in CommandEnd
benutzt um Mirror/Copy-Duplikate zu erkennen (= neue Objs mit IDs die
nicht im Snapshot waren)."""
snap = {"sources": {}, "volumes": {}, "obj_ids": set()}
if doc is None: return snap if doc is None: return snap
for obj in doc.Objects: for obj in doc.Objects:
try: try:
snap["obj_ids"].add(str(obj.Id))
m = _read_meta(obj) m = _read_meta(obj)
if not m: continue if not m: continue
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,56 +7857,271 @@ 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 {}
old_obj_ids = snapshot.get("obj_ids", set()) if isinstance(snapshot, dict) else set()
# ─── Pure-Translate-Detection ─────────────────────────────────────────── # ─── Mirror/Copy-Duplikat-Detection ─────────────────────────────────────
# Wenn ALLE bewegten Sources um den exakt gleichen Vektor verschoben # Rhinos Mirror/Copy/Array erzeugt KOPIEN selektierter Objekte mit ihren
# wurden, KEIN Z-Drag (= Property-Aenderung), KEIN Rotate/Scale (= End- # UserStrings (= Metadata). Resultat: Duplikat-IDs im Doc — z.B. zwei
# Grip-Drag) — dann reicht eine reine Translation aller noch unbewegten # `wand_axis` mit `id=wall_xxx`. Unser System haelt die fuer „dasselbe
# Volumen + Punkte. KEIN Wand-Regen, KEIN Boolean-Diff. Geht instant. # Element", was zu „verkoppelten" Elementen fuehrt und zu kaputten
def _source_delta(obj, old): # Pure-Transform-Detections.
#
# Fix: alle NEUEN Objs (obj.Id nicht im Snapshot) deren UserString-id
# bereits im Snapshot existiert → neue UUID. Sub-Volumen und
# Oeffnungs-Parent-Refs werden konsistent umgehaengt.
_type_to_prefix = {
"wand_axis": "wall_",
"decke_outline": "decke_",
"dach_outline": "dach_",
"treppe_axis": "treppe_",
"stuetze_point": "trag_",
"traeger_axis": "trag_",
"raum_outline": "raum_",
"decke_aussparung_outline": "aussp_",
}
# Pass A: identifiziere neue Sources mit dup-IDs, sammle (obj, alte_id, neue_id)
dup_source_renames = [] # list of (obj, old_id, new_id, type)
for obj in doc.Objects:
try:
if str(obj.Id) in old_obj_ids: continue # original existed pre-command
m = _read_meta(obj)
if not m: continue
t = m.get("type")
if t not in SOURCE_TYPES: continue
old_id = m["id"]
if old_id not in sources_snap: continue # echtes neues Element
if t == "oeffnung_point":
prefix = "fenster_" if m.get("oeff_typ") == "fenster" else "tuer_"
else:
prefix = _type_to_prefix.get(t, "elem_")
new_id = prefix + uuid.uuid4().hex[:10]
dup_source_renames.append((obj, old_id, new_id, t))
except Exception as ex:
print("[ELEMENTE] dup detection:", ex)
# Pass B: neue Volumes mit dup-IDs identifizieren (alte UserString-id ist
# eine umbenannte Source). Mapping alte_id → neue_id zum Lookup.
elem_id_map = {old_id: new_id for (_, old_id, new_id, _) in dup_source_renames}
dup_volume_renames = [] # list of (obj, new_id, oeff_parent_old, oeff_parent_new)
for obj in doc.Objects:
try:
if str(obj.Id) in old_obj_ids: continue
m = _read_meta(obj)
if not m: continue
t = m.get("type")
if t not in VOLUME_TYPES: continue
old_vol_id = m["id"]
new_vol_id = elem_id_map.get(old_vol_id)
if not new_vol_id: continue # Volume gehoert nicht zu einem renamed Source
# oeff_parent rewire bei oeffnung_volume
old_parent = m.get("oeff_parent") or ""
new_parent = elem_id_map.get(old_parent, old_parent)
dup_volume_renames.append((obj, new_vol_id, old_parent, new_parent))
except Exception as ex:
print("[ELEMENTE] dup volume detection:", ex)
# Pass C: oeffnung_point's oeff_parent rewire (nicht-Volume, also Sources)
# Wenn eine Wand umbenannt wurde, alle (umbenannten) Oeffnungen die zu ihr
# gehoeren auch auf neue Wand-id umhaengen.
if elem_id_map:
# In dup_source_renames Liste: fuer oeffnung_point-Renames pruefen, ob
# ihr oeff_parent in elem_id_map ist → updaten.
for i, (obj, old_id, new_id, t) in enumerate(dup_source_renames):
if t != "oeffnung_point": continue
try:
m = _read_meta(obj)
if not m: continue
old_parent = m.get("oeff_parent") or ""
new_parent = elem_id_map.get(old_parent, old_parent)
# Tuple aktualisieren (alte vs neue parent-ID, fuer apply unten)
dup_source_renames[i] = (obj, old_id, new_id, t, new_parent)
except Exception: pass
# Pass D: alle gesammelten Renames anwenden
n_renamed = 0
for entry in dup_source_renames:
try:
if len(entry) == 5:
obj, old_id, new_id, t, new_parent = entry
else:
obj, old_id, new_id, t = entry
new_parent = None
attrs = obj.Attributes.Duplicate()
attrs.SetUserString(_KEY_ID, new_id)
if new_parent is not None:
attrs.SetUserString(_KEY_OEFF_PARENT, new_parent)
doc.Objects.ModifyAttributes(obj.Id, attrs, True)
n_renamed += 1
except Exception as ex:
print("[ELEMENTE] apply source rename:", ex)
for obj, new_vol_id, old_parent, new_parent in dup_volume_renames:
try:
attrs = obj.Attributes.Duplicate()
attrs.SetUserString(_KEY_ID, new_vol_id)
if old_parent and new_parent and new_parent != old_parent:
attrs.SetUserString(_KEY_OEFF_PARENT, new_parent)
doc.Objects.ModifyAttributes(obj.Id, attrs, True)
n_renamed += 1
except Exception as ex:
print("[ELEMENTE] apply volume rename:", ex)
if n_renamed > 0:
print("[ELEMENTE] mirror/copy-Duplikate: {} Objs neu-ID'd".format(n_renamed))
# Wenn ALLE bewegten Sources sich mit dem gleichen Rigid-2D-Transform
# abbilden lassen (Translation und/oder Rotation um Z-Achse, KEIN Scale,
# KEIN Z-Drag, KEIN End-Grip-Drag, KEIN Mirror), reicht eine Transform-
# Anwendung auf alle noch unbewegten Volumen + Punkte. KEIN Wand-Regen,
# KEIN Boolean-Diff. Geht instant.
import math as _math
def _source_rigid_transform(obj, old):
"""Berechnet den Rigid-2D-Transform (Translation + Z-Rotation) der
alte Source-Geometrie auf die aktuelle abbildet. Returns None wenn
Z-Drag/Scale/End-Grip/Mirror erkannt."""
geom = obj.Geometry geom = obj.Geometry
if hasattr(geom, "Location"):
op = old.get("pos")
if op is None: return None
p = geom.Location
return (p.X - op[0], p.Y - op[1], p.Z - op[2])
if isinstance(geom, rg.Curve): if isinstance(geom, rg.Curve):
os_pt = old.get("start"); oe_pt = old.get("end") os_pt = old.get("start"); oe_pt = old.get("end")
if os_pt is None or oe_pt is None: return None if os_pt is None or oe_pt is None: return None
ns = geom.PointAtStart; ne = geom.PointAtEnd ns = geom.PointAtStart; ne = geom.PointAtEnd
ds = (ns.X - os_pt[0], ns.Y - os_pt[1], ns.Z - os_pt[2]) # Z-Aenderung verbietet Pure-Transform (= Z-Drag → UK_OVER muss
de = (ne.X - oe_pt[0], ne.Y - oe_pt[1], ne.Z - oe_pt[2]) # geschrieben werden → Regen-Pfad).
# Beide Endpunkte muessen den gleichen Vektor haben (= Translate). if (abs(ns.Z - os_pt[2]) > 1e-6 or
# Sonst ist's Rotate/Scale/End-Grip-Drag. abs(ne.Z - oe_pt[2]) > 1e-6):
if (abs(ds[0]-de[0]) > 1e-6 or abs(ds[1]-de[1]) > 1e-6
or abs(ds[2]-de[2]) > 1e-6):
return None return None
return ds old_dx = oe_pt[0] - os_pt[0]; old_dy = oe_pt[1] - os_pt[1]
new_dx = ne.X - ns.X; new_dy = ne.Y - ns.Y
old_len = _math.hypot(old_dx, old_dy)
new_len = _math.hypot(new_dx, new_dy)
if old_len < 1e-9: return None
# Laengenaenderung → Scale (oder einzelner Endpunkt-Drag)
if abs(old_len - new_len) > 1e-6: return None
# Drehwinkel um Z aus Richtungsvektoren
old_angle = _math.atan2(old_dy, old_dx)
new_angle = _math.atan2(new_dy, new_dx)
angle = new_angle - old_angle
# Transform: erst um old_start zentrieren, dann rotieren, dann
# zu new_start translaten. So mappen sowohl old_start→new_start
# als auch old_end→new_end korrekt.
to_origin = rg.Transform.Translation(-os_pt[0], -os_pt[1], -os_pt[2])
rotate = rg.Transform.Rotation(angle, rg.Vector3d.ZAxis, rg.Point3d.Origin)
to_new = rg.Transform.Translation(ns.X, ns.Y, ns.Z)
return to_new * rotate * to_origin
if hasattr(geom, "Location"):
op = old.get("pos")
if op is None: return None
p = geom.Location
# Punkt: keine Orientierungs-Info → nur Translation ableitbar.
# Konsistenz mit Curve-Transform wird in Phase 2 geprueft.
return rg.Transform.Translation(p.X - op[0], p.Y - op[1], p.Z - op[2])
return None return None
deltas = {} def _is_identity_transform(t, tol=1e-6):
for i in range(4):
for j in range(4):
ref = 1.0 if i == j else 0.0
if abs(t[i, j] - ref) > tol: return False
return True
def _transforms_equal(t1, t2, tol=1e-6):
for i in range(4):
for j in range(4):
if abs(t1[i, j] - t2[i, j]) > tol: return False
return True
# Phase 1: Transform pro Source berechnen, abort bei non-rigid
source_transforms = {}
abort_pure = False abort_pure = False
for obj in doc.Objects: for obj in doc.Objects:
try: try:
@@ -7786,54 +8130,123 @@ def _on_command_end(sender, e):
if m.get("type") not in SOURCE_TYPES: continue if m.get("type") not in SOURCE_TYPES: continue
old = sources_snap.get(m["id"]) old = sources_snap.get(m["id"])
if old is None: continue if old is None: continue
d = _source_delta(obj, old) t = _source_rigid_transform(obj, old)
if d is None: if t is None:
# End-Grip-Drag o.ae. → kein Pure-Translate
abort_pure = True abort_pure = True
break break
deltas[m["id"]] = d source_transforms[m["id"]] = t
except Exception: pass except Exception: pass
pure_delta = None # Phase 2: moved_ids + canonical (bevorzugt Curve-Source fuer
if not abort_pure: # Rotations-Info; Points haben nur Translation)
moved = [d for d in deltas.values() moved_ids = {eid for eid, t in source_transforms.items()
if abs(d[0]) > 1e-6 or abs(d[1]) > 1e-6 or abs(d[2]) > 1e-6] if not _is_identity_transform(t)}
if moved: canonical = None
# Alle bewegten Sources muessen denselben Vektor haben for eid in moved_ids:
first = moved[0] old = sources_snap.get(eid)
same = all(abs(d[0]-first[0]) < 1e-6 and if old and "start" in old:
abs(d[1]-first[1]) < 1e-6 and canonical = source_transforms[eid]
abs(d[2]-first[2]) < 1e-6 for d in moved) break
# Z-Drag erkennen: wenn Wand-Achse Z aenderung hat → Brüstungs- if canonical is None and moved_ids:
# Mitnahme noetig → kein Pure-Translate. # Keine Curve bewegt → nimm irgendeinen Point-Transform
has_z_drag = abs(first[2]) > 1e-6 for eid in moved_ids:
if same and not has_z_drag: canonical = source_transforms[eid]
pure_delta = first break
# Phase 3: alle bewegten Sources MUESSEN canonical erfuellen
all_consistent = True
if canonical is not None and not abort_pure:
for eid in moved_ids:
old = sources_snap.get(eid)
if old is None: continue
if "start" in old:
# Curve: Transform muss canonical sein
if not _transforms_equal(source_transforms[eid], canonical):
all_consistent = False
break
else:
# Point: canonical applied to old_pos muss aktuelle Position sein
op = old.get("pos")
if op is None: continue
expected = rg.Point3d(op[0], op[1], op[2])
expected.Transform(canonical)
actual = None
for obj in doc.Objects:
mm = _read_meta(obj)
if mm and mm.get("id") == eid and mm.get("type") == old.get("type"):
gg = obj.Geometry
if hasattr(gg, "Location"):
actual = gg.Location
break
if actual is None: continue
if (abs(actual.X - expected.X) > 1e-6 or
abs(actual.Y - expected.Y) > 1e-6 or
abs(actual.Z - expected.Z) > 1e-6):
all_consistent = False
break
# Orphan-Oeffnung erkennen: bewegte Oeffnung deren Eltern-Wand NICHT
# mitbewegt wurde. Cutout muss regen.
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_transform = None
if abort_pure:
print("[ELEMENTE] no pure-transform: z-drag/scale/end-grip detected")
elif orphan_opening:
print("[ELEMENTE] no pure-transform: opening moved without parent wall (cutout muss regen)")
elif not all_consistent:
print("[ELEMENTE] no pure-transform: sources moved with different transforms")
elif canonical is not None:
pure_transform = canonical
if pure_transform is not None:
# PURE-TRANSFORM PFAD: Transform auf alle Geometries anwenden die
# nicht schon vom User-Move transformed wurden. Funktioniert fuer
# Translation UND Rotation. → instant feedback.
tx = pure_transform[0, 3]
ty = pure_transform[1, 3]
tz = pure_transform[2, 3]
# Rotations-Anteil aus m00/m01 (Z-Rotation der 2x2 oberen Submatrix)
rot_deg = _math.degrees(_math.atan2(pure_transform[1, 0], pure_transform[0, 0]))
print("[ELEMENTE] pure-transform: tx={:.3f} ty={:.3f} tz={:.3f} rot={:.1f}°".format(
tx, ty, tz, rot_deg))
# Eltern→Kind-Cascade: nur bewegte Sources + deren Children folgen.
def _should_follow(m):
eid = m.get("id")
if eid in moved_ids: return True
parent = m.get("oeff_parent")
if parent and parent in moved_ids: return True
return False
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.
vec = rg.Vector3d(*pure_delta)
_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
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 (= identity transform)
# transformen — nur via _should_follow erlaubt (Cascade).
if t in SOURCE_TYPES:
src_t = source_transforms.get(m["id"])
if src_t is not None and not _is_identity_transform(src_t):
continue # Rhino hat bereits transformed
new_geom = obj.Geometry.Duplicate() new_geom = obj.Geometry.Duplicate()
new_geom.Translate(vec) new_geom.Transform(pure_transform)
doc.Objects.Replace(obj.Id, new_geom) doc.Objects.Replace(obj.Id, new_geom)
continue continue
# Volumes: vergleiche Position vs. Snapshot. Wenn unbewegt, # Volumes: bb-Center gegen Snapshot vergleichen. Unbewegt
# translaten. Wenn bereits transformed (durch Multi-Select- # transformen. Bereits transformed (Rhino) → skip.
# Move), skip.
if t in VOLUME_TYPES: if t in VOLUME_TYPES:
vol_snap = volumes_snap.get(str(obj.Id)) vol_snap = volumes_snap.get(str(obj.Id))
if vol_snap is None: continue if vol_snap is None: continue
@@ -7846,22 +8259,38 @@ def _on_command_end(sender, e):
dy = c_now.Y - c_old[1] dy = c_now.Y - c_old[1]
dz = c_now.Z - c_old[2] dz = c_now.Z - c_old[2]
if (abs(dx) < 1e-6 and abs(dy) < 1e-6 and abs(dz) < 1e-6): if (abs(dx) < 1e-6 and abs(dy) < 1e-6 and abs(dz) < 1e-6):
# noch nicht transformed → translaten
new_geom = obj.Geometry.Duplicate() new_geom = obj.Geometry.Duplicate()
new_geom.Translate(vec) new_geom.Transform(pure_transform)
doc.Objects.Replace(obj.Id, new_geom) doc.Objects.Replace(obj.Id, new_geom)
except Exception: pass except Exception: pass
except Exception as ex: except Exception as ex:
print("[ELEMENTE] pure-translate:", ex) print("[ELEMENTE] pure-transform:", 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
sc.sticky["_dossier_migrated_walls"] = 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 +8306,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:
@@ -7911,7 +8339,20 @@ def _on_command_end(sender, e):
old_line = rg.LineCurve( old_line = rg.LineCurve(
rg.Point3d(os[0], os[1], os[2]), rg.Point3d(os[0], os[1], os[2]),
rg.Point3d(oe[0], oe[1], oe[2])) rg.Point3d(oe[0], oe[1], oe[2]))
_migrate_openings_to_new_axis(m["id"], old_line, geom) # Pre-Transform Oeffnungs-Positionen aus dem
# Snapshot ziehen — Migrate braucht sie um die
# Bogenlaengen-Position auf der ALTEN Axis zu
# finden (sonst bei Rotation falscher Snap).
old_op_positions = {}
for snap_id, snap_data in sources_snap.items():
if snap_data.get("type") != "oeffnung_point":
continue
if snap_data.get("oeff_parent") != m["id"]:
continue
pos = snap_data.get("pos")
if pos: old_op_positions[snap_id] = pos
_migrate_openings_to_new_axis(
m["id"], old_line, geom, old_op_positions)
except Exception as ex: except Exception as ex:
print("[ELEMENTE] post-cmd migrate:", ex) print("[ELEMENTE] post-cmd migrate:", ex)
# Z-Drag detect + Brüstungs-Mitnahme. Constraint setzt # Z-Drag detect + Brüstungs-Mitnahme. Constraint setzt
@@ -7925,26 +8366,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
doc.Objects.Replace(op_obj.Id, target_z = op_pos[2] + z_delta
rg.Point(rg.Point3d(pt.X, pt.Y, new_b))) doc.Objects.Replace(op_obj.Id,
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 +8407,34 @@ 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) doc.Views.RedrawEnabled = prev_redraw_enabled
finally:
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
sc.sticky["_dossier_migrated_walls"] = 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):
+38 -19
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:
print("[GESTALTUNG] on_delete: hatch via sticky map gefunden") return
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: