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>
This commit is contained in:
2026-05-18 22:47:09 +02:00
parent 2a75b1da93
commit 0978d9fc2e
+74 -8
View File
@@ -6839,7 +6839,16 @@ def _apply_oeffnung_constraint(new_obj, meta, old_obj=None):
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(
@@ -7092,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
@@ -7107,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.
@@ -7127,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
# Pre-Transform Position bevorzugen — die liegt garantiert
# auf der alten Axis. Aktuelle (post-transform) Position kann
# bei Rotation weit weg liegen → ClosestPoint snappt zum
# falschen Endpunkt.
src_pos = None
if old_positions is not None:
src_pos = old_positions.get(op_meta["id"])
if src_pos is None:
pt_geom = op_obj.Geometry pt_geom = op_obj.Geometry
if hasattr(pt_geom, 'Location'): if hasattr(pt_geom, 'Location'):
cur_pos = pt_geom.Location loc = pt_geom.Location
src_pos = (loc.X, loc.Y, loc.Z)
elif isinstance(pt_geom, rg.Point3d): elif isinstance(pt_geom, rg.Point3d):
cur_pos = pt_geom src_pos = (pt_geom.X, pt_geom.Y, pt_geom.Z)
else: else:
continue 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)
@@ -7174,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)
@@ -8053,6 +8104,7 @@ def _on_command_end(sender, e):
try: doc.EndUndoRecord(undo_serial) try: doc.EndUndoRecord(undo_serial)
except Exception: pass except Exception: pass
sc.sticky["_dossier_undo_serial"] = None 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()
@@ -8111,7 +8163,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
@@ -8183,6 +8248,7 @@ def _on_command_end(sender, e):
try: doc.EndUndoRecord(undo_serial) try: doc.EndUndoRecord(undo_serial)
except Exception: pass except Exception: pass
sc.sticky["_dossier_undo_serial"] = None 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()