diff --git a/rhino/elemente.py b/rhino/elemente.py index ff758b4..4f99ae7 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -7947,33 +7947,70 @@ def _on_command_end(sender, e): sources_snap = snapshot.get("sources", {}) if isinstance(snapshot, dict) else {} volumes_snap = snapshot.get("volumes", {}) if isinstance(snapshot, dict) else {} - # ─── Pure-Translate-Detection ─────────────────────────────────────────── - # Wenn ALLE bewegten Sources um den exakt gleichen Vektor verschoben - # wurden, KEIN Z-Drag (= Property-Aenderung), KEIN Rotate/Scale (= End- - # Grip-Drag) — dann reicht eine reine Translation aller noch unbewegten - # Volumen + Punkte. KEIN Wand-Regen, KEIN Boolean-Diff. Geht instant. - def _source_delta(obj, old): + # ─── Pure-Transform-Detection (Translation + Z-Rotation) ──────────────── + # 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 - 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): os_pt = old.get("start"); oe_pt = old.get("end") if os_pt is None or oe_pt is None: return None ns = geom.PointAtStart; ne = geom.PointAtEnd - ds = (ns.X - os_pt[0], ns.Y - os_pt[1], ns.Z - os_pt[2]) - de = (ne.X - oe_pt[0], ne.Y - oe_pt[1], ne.Z - oe_pt[2]) - # Beide Endpunkte muessen den gleichen Vektor haben (= Translate). - # Sonst ist's Rotate/Scale/End-Grip-Drag. - if (abs(ds[0]-de[0]) > 1e-6 or abs(ds[1]-de[1]) > 1e-6 - or abs(ds[2]-de[2]) > 1e-6): + # Z-Aenderung verbietet Pure-Transform (= Z-Drag → UK_OVER muss + # geschrieben werden → Regen-Pfad). + if (abs(ns.Z - os_pt[2]) > 1e-6 or + abs(ne.Z - oe_pt[2]) > 1e-6): 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 - 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 for obj in doc.Objects: try: @@ -7982,25 +8019,63 @@ def _on_command_end(sender, e): if m.get("type") not in SOURCE_TYPES: continue old = sources_snap.get(m["id"]) if old is None: continue - d = _source_delta(obj, old) - if d is None: - # End-Grip-Drag o.ae. → kein Pure-Translate + t = _source_rigid_transform(obj, old) + if t is None: abort_pure = True break - deltas[m["id"]] = d + source_transforms[m["id"]] = t 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} + # Phase 2: moved_ids + canonical (bevorzugt Curve-Source fuer + # Rotations-Info; Points haben nur Translation) + moved_ids = {eid for eid, t in source_transforms.items() + if not _is_identity_transform(t)} + canonical = None + 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 - # 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. + # 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) @@ -8010,49 +8085,38 @@ def _on_command_end(sender, e): orphan_opening = True break - pure_delta = None - 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] - 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") + pure_transform = None + if abort_pure: + print("[ELEMENTE] no pure-transform: z-drag/scale/end-grip detected") 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: - # 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) + 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: wenn eine Wand bewegt wurde, muessen ihre - # Oeffnungen mit. Aber NICHT umgekehrt: wenn nur eine Oeffnung - # bewegt wird, bleibt die Wand stehen. + # Eltern→Kind-Cascade: nur bewegte Sources + deren Children folgen. 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 - # RedrawEnabled wurde schon in _on_command_begin auf False gesetzt try: for obj in list(doc.Objects): try: @@ -8060,16 +8124,18 @@ def _on_command_end(sender, e): if not m: continue t = m.get("type") 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: + # 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.Translate(vec) + new_geom.Transform(pure_transform) doc.Objects.Replace(obj.Id, new_geom) continue - # Volumes: vergleiche Position vs. Snapshot. Wenn unbewegt, - # translaten. Wenn bereits transformed (durch Multi-Select- - # Move), skip. + # Volumes: bb-Center gegen Snapshot vergleichen. Unbewegt + # → transformen. Bereits transformed (Rhino) → skip. if t in VOLUME_TYPES: vol_snap = volumes_snap.get(str(obj.Id)) if vol_snap is None: continue @@ -8082,13 +8148,12 @@ def _on_command_end(sender, e): dy = c_now.Y - c_old[1] dz = c_now.Z - c_old[2] 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.Translate(vec) + new_geom.Transform(pure_transform) doc.Objects.Replace(obj.Id, new_geom) except Exception: pass except Exception as ex: - print("[ELEMENTE] pure-translate:", ex) + print("[ELEMENTE] pure-transform:", ex) finally: sc.sticky[_REGEN_BUSY] = _was_busy doc.Views.RedrawEnabled = prev_redraw_enabled