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":
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 "<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
# 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,10 +6835,20 @@ 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
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:
try:
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
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 +6953,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
@@ -7044,12 +7101,20 @@ def _on_object_replaced_body(sender, e):
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
veraendert wird. Mapping ueber relative Bogenlaenge: ein Oeffnungs-
Punkt bei 30 % der alten Kurve sitzt nachher bei 30 % der neuen.
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):
return
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()
except Exception: 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
# 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.
if str(op_obj.Id) in skip_ids:
continue
pt_geom = op_obj.Geometry
if hasattr(pt_geom, 'Location'):
cur_pos = pt_geom.Location
elif isinstance(pt_geom, rg.Point3d):
cur_pos = pt_geom
else:
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
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
# (Bruestungs-Hoehe) den Parameter bei kurvigen Wand-Achsen
# leicht weg von der „echten" Position.
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
# Bogenlaenge auf alter Kurve bis t_old → relative Position
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
except (ValueError, TypeError):
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))
except Exception as 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
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 +7361,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 +7802,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):
@@ -7697,22 +7818,30 @@ def _snapshot_source_positions(doc):
BBox-Centers. Source-Map (key=element_id) füttert Constraint+Migrate.
Volume-Map (key=obj.Id-string) erlaubt im CommandEnd die Pure-Translate-
Detection wir checken pro Volume ob es schon vom Rhinos Move
transformed wurde, oder noch ge-translaten werden muss."""
snap = {"sources": {}, "volumes": {}}
transformed wurde, oder noch ge-translaten werden muss.
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
for obj in doc.Objects:
try:
snap["obj_ids"].add(str(obj.Id))
m = _read_meta(obj)
if not m: continue
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,56 +7857,271 @@ 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 {}
old_obj_ids = snapshot.get("obj_ids", set()) if isinstance(snapshot, dict) else set()
# ─── 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):
# ─── Mirror/Copy-Duplikat-Detection ─────────────────────────────────────
# Rhinos Mirror/Copy/Array erzeugt KOPIEN selektierter Objekte mit ihren
# UserStrings (= Metadata). Resultat: Duplikat-IDs im Doc — z.B. zwei
# `wand_axis` mit `id=wall_xxx`. Unser System haelt die fuer „dasselbe
# Element", was zu „verkoppelten" Elementen fuehrt und zu kaputten
# 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
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:
@@ -7786,54 +8130,123 @@ 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
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 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
# 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
# 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)
sc.sticky[_REGEN_BUSY] = True
prev_redraw = doc.Views.RedrawEnabled
doc.Views.RedrawEnabled = False
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 (= 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
@@ -7846,22 +8259,38 @@ 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:
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
sc.sticky["_dossier_migrated_walls"] = 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 +8306,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:
@@ -7911,7 +8339,20 @@ def _on_command_end(sender, e):
old_line = rg.LineCurve(
rg.Point3d(os[0], os[1], os[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:
print("[ELEMENTE] post-cmd migrate:", ex)
# 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
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 +8407,34 @@ 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
sc.sticky["_dossier_migrated_walls"] = 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):
+38 -19
View File
@@ -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: