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>
This commit is contained in:
2026-05-18 23:02:20 +02:00
parent 0978d9fc2e
commit 82bd15a074
+139 -74
View File
@@ -7947,33 +7947,70 @@ def _on_command_end(sender, e):
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 {}
# ─── Pure-Translate-Detection ─────────────────────────────────────────── # ─── Pure-Transform-Detection (Translation + Z-Rotation) ────────────────
# Wenn ALLE bewegten Sources um den exakt gleichen Vektor verschoben # Wenn ALLE bewegten Sources sich mit dem gleichen Rigid-2D-Transform
# wurden, KEIN Z-Drag (= Property-Aenderung), KEIN Rotate/Scale (= End- # abbilden lassen (Translation und/oder Rotation um Z-Achse, KEIN Scale,
# Grip-Drag) — dann reicht eine reine Translation aller noch unbewegten # KEIN Z-Drag, KEIN End-Grip-Drag, KEIN Mirror), reicht eine Transform-
# Volumen + Punkte. KEIN Wand-Regen, KEIN Boolean-Diff. Geht instant. # Anwendung auf alle noch unbewegten Volumen + Punkte. KEIN Wand-Regen,
def _source_delta(obj, old): # 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:
@@ -7982,25 +8019,63 @@ 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
# IDs der tatsaechlich bewegten Sources (Delta != 0). `deltas` enthaelt # Phase 2: moved_ids + canonical (bevorzugt Curve-Source fuer
# ALLE Sources der Szene mit ihrem Snapshot-Delta — unbewegte haben # Rotations-Info; Points haben nur Translation)
# Delta=(0,0,0). Wir brauchen aber die *bewegten*, um Hierarchie- moved_ids = {eid for eid, t in source_transforms.items()
# Entscheidungen zu treffen (Eltern→Kind-Cascade). if not _is_identity_transform(t)}
moved_ids = {eid for eid, d in deltas.items() canonical = None
if abs(d[0]) > 1e-6 or abs(d[1]) > 1e-6 or abs(d[2]) > 1e-6} for eid in moved_ids:
old = sources_snap.get(eid)
if old and "start" in old:
canonical = source_transforms[eid]
break
if canonical is None and moved_ids:
# Keine Curve bewegt → nimm irgendeinen Point-Transform
for eid in moved_ids:
canonical = source_transforms[eid]
break
# Orphan-Oeffnung erkennen: bewegte Oeffnung, deren Eltern-Wand NICHT # Phase 3: alle bewegten Sources MUESSEN canonical erfuellen
# mitbewegt wurde. In diesem Fall ist Pure-Translate verboten — die all_consistent = True
# Wand-Aussparung (Boolean-Difference-Loch im Wand-Brep) bliebe am alten if canonical is not None and not abort_pure:
# Ort. Nur ein voller Wand-Regen rebuildet das Loch an der neuen Pos. 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 orphan_opening = False
for eid in moved_ids: for eid in moved_ids:
old = sources_snap.get(eid) old = sources_snap.get(eid)
@@ -8010,49 +8085,38 @@ def _on_command_end(sender, e):
orphan_opening = True orphan_opening = True
break break
pure_delta = None pure_transform = None
if not abort_pure and not orphan_opening: if abort_pure:
moved = [d for eid, d in deltas.items() if eid in moved_ids] print("[ELEMENTE] no pure-transform: z-drag/scale/end-grip detected")
if moved:
# Alle bewegten Sources muessen denselben Vektor haben
first = moved[0]
same = all(abs(d[0]-first[0]) < 1e-6 and
abs(d[1]-first[1]) < 1e-6 and
abs(d[2]-first[2]) < 1e-6 for d in moved)
# Z-Drag erkennen: wenn Wand-Achse Z aenderung hat → Brüstungs-
# Mitnahme noetig → kein Pure-Translate.
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: elif orphan_opening:
print("[ELEMENTE] no pure-translate: opening moved without parent wall (cutout muss regen)") 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_delta is not None: if pure_transform is not None:
# PURE-TRANSLATE PFAD: nur Geometries translaten die nicht schon vom # PURE-TRANSFORM PFAD: Transform auf alle Geometries anwenden die
# User-Move transformed wurden. Keine Brep-Regeneration, kein # nicht schon vom User-Move transformed wurden. Funktioniert fuer
# Boolean-Diff. → instant feedback. # Translation UND Rotation. → instant feedback.
print("[ELEMENTE] pure-translate: delta=({:.3f}, {:.3f}, {:.3f})".format(*pure_delta)) tx = pure_transform[0, 3]
vec = rg.Vector3d(*pure_delta) 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: wenn eine Wand bewegt wurde, muessen ihre # Eltern→Kind-Cascade: nur bewegte Sources + deren Children folgen.
# Oeffnungen mit. Aber NICHT umgekehrt: wenn nur eine Oeffnung
# bewegt wird, bleibt die Wand stehen.
def _should_follow(m): def _should_follow(m):
eid = m.get("id") eid = m.get("id")
if eid in moved_ids: return True if eid in moved_ids: return True
# Oeffnung ihrer Eltern-Wand folgen lassen
parent = m.get("oeff_parent") parent = m.get("oeff_parent")
if parent and parent in moved_ids: return True if parent and parent in moved_ids: return True
return False return False
_was_busy = sc.sticky.get(_REGEN_BUSY, False) _was_busy = sc.sticky.get(_REGEN_BUSY, False)
sc.sticky[_REGEN_BUSY] = True sc.sticky[_REGEN_BUSY] = True
# RedrawEnabled wurde schon in _on_command_begin auf False gesetzt
try: try:
for obj in list(doc.Objects): for obj in list(doc.Objects):
try: try:
@@ -8060,16 +8124,18 @@ def _on_command_end(sender, e):
if not m: continue if not m: continue
t = m.get("type") t = m.get("type")
if not _should_follow(m): continue if not _should_follow(m): continue
# Sources die nicht bewegt wurden (= Delta=0) translaten # Sources die nicht bewegt wurden (= identity transform)
# — nur Oeffnungs-Sources via _should_follow erlaubt. # transformen — nur via _should_follow erlaubt (Cascade).
if t in SOURCE_TYPES and m["id"] not in moved_ids: 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
@@ -8082,13 +8148,12 @@ 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:
sc.sticky[_REGEN_BUSY] = _was_busy sc.sticky[_REGEN_BUSY] = _was_busy
doc.Views.RedrawEnabled = prev_redraw_enabled doc.Views.RedrawEnabled = prev_redraw_enabled