Polyline-Wand-Refactor (Chain-Anchor) + Cmd+Z-Fixes

Chain-Modell: kompatible benachbarte Waende (gleiche dicke/referenz/layers/
material) teilen ein gemeinsames wand_volume via Polyline-Extrusion. Anchor
(= alphabetisch kleinste ID) baut + besitzt das Volume; Non-Anchors haben
keins. wand_chain_members UserString trackt Mitglieder.

- _find_wall_chain/_build_chain_polyline mit korrekter Erst-Seg-Orientierung
- Anchor-Branch im Regen + Non-Anchor-covered-Check (Loop-Schutz)
- Pre-Test des Chain-Breps vor Cleanup → Fallback per-wand bei Brep-Build-Fail
  (z.B. Offset self-intersect auf Innen-Ecke)
- Pre-Check in _on_command_end liest existing chain_members statt Live-Detect
  → erkennt teil-bewegte Chains + aborted pure-transform
- affected_chain_members in regular regen-Pfad → Survivor regenes mit
- _update_wall regen't alte Chain-Members nach Property-Change
- _on_object_replaced No-Op-Check fuer wand_axis (Grips-Toggle skippt regen)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 13:11:16 +02:00
parent a308ba62d2
commit c050b9aeb6
+396 -18
View File
@@ -36,6 +36,7 @@ _KEY_REFERENZ = "dossier_referenz" # "mid" | "left" | "right"
_KEY_WAND_LAYERED = "dossier_wand_layered" # "1" = mehrschichtig, sonst solid
_KEY_WAND_LAYERS = "dossier_wand_layers" # JSON-Liste [{name, dicke, color}]
_KEY_WAND_LAYER_IDX = "dossier_wand_layer_idx" # Layer-Index am Volume-Brep
_KEY_WAND_CHAIN_MEMBERS = "dossier_wand_chain_members" # JSON-Liste wand_ids einer Polyline-Chain (nur auf wand_volume)
_KEY_DACH_NEIGUNG = "dossier_dach_neigung" # Grad als string ("30")
_KEY_DACH_EAVE = "dossier_dach_eave" # Index der Traufkante (string)
_KEY_DACH_TYP = "dossier_dach_typ" # "pult"|"sattel"|"walm"|"mansarde"
@@ -1808,16 +1809,20 @@ def _miter_dir(out_a, out_b):
def _detect_t_junction(doc, geschoss_id, wall_id, endpoint,
pos_tol=0.01, end_tol=0.05):
pos_tol=0.01, end_tol=0.05, exclude_ids=None):
"""Sucht ob `endpoint` auf der INNEREN Achse einer anderen Wand liegt
(T-Stoss). Endpunkte der anderen Wand (Eckverbindung) werden bewusst
ausgeschlossen die werden bereits durch die Corner-Logik abgedeckt.
`exclude_ids` (optional): zusaetzliche wall_ids die ignoriert werden
sollen (Chain-Members), sonst nur wall_id.
Liefert (other_wall_id, b_tangent_vec3, b_dicke) oder None."""
skip = set(exclude_ids or ())
skip.add(wall_id)
for obj in doc.Objects:
meta = _read_meta(obj)
if not meta or meta["type"] != "wand_axis": continue
if meta["geschoss"] != geschoss_id: continue
if meta["id"] == wall_id: continue
if meta["id"] in skip: continue
geom = obj.Geometry
if not isinstance(geom, rg.Curve): continue
try:
@@ -1863,6 +1868,169 @@ def _t_junction_miter(endpoint, out_dir, b_tan, b_dicke):
return (mpt, mdir)
# --- Wand-Chain: Polyline-vereinte Volumes -----------------------------------
# Wenn N kompatible Waende sequentiell zusammenliegen (gleiche Geometrie/
# Material/Hoehe, jeweils 2-Wall-Joints) verbinden wir ihre Achsen zu einer
# Polyline und extrudieren ein einziges Brep statt N. Damit verschwinden die
# internen Stoss-Linien in der Section-View (gleichfarbige Hatch ueber die
# ganze Polyline).
#
# Anchor-Modell: das gemeinsame wand_volume gehoert zur "Anchor"-Wand
# (alphabetisch kleinste wall_id der Chain). Die anderen Chain-Members haben
# kein eigenes wand_volume — `_find_volume` schaut deswegen zusaetzlich nach
# wand_chain_members-UserStrings.
def _wand_chain_compat(meta_a, meta_b):
"""Sind zwei Waende kompatibel fuer einen gemeinsamen Polyline-Chain?
Wenn irgendein geometrie-relevanter Parameter abweicht: nein. Sonst
waere das gemeinsame Volume nicht sauber baubar."""
if not meta_a or not meta_b: return False
if meta_a.get("geschoss") != meta_b.get("geschoss"): return False
if abs(float(meta_a.get("dicke", 0)) - float(meta_b.get("dicke", 0))) > 1e-6:
return False
if meta_a.get("referenz", "mid") != meta_b.get("referenz", "mid"):
return False
if (meta_a.get("uk_override") or "") != (meta_b.get("uk_override") or ""):
return False
if (meta_a.get("ok_override") or "") != (meta_b.get("ok_override") or ""):
return False
if bool(meta_a.get("wand_layered")) != bool(meta_b.get("wand_layered")):
return False
if meta_a.get("wand_layered"):
la = meta_a.get("wand_layers") or []
lb = meta_b.get("wand_layers") or []
if len(la) != len(lb): return False
for x, y in zip(la, lb):
if abs(float(x.get("dicke", 0)) - float(y.get("dicke", 0))) > 1e-6:
return False
# Material muss identisch sein — die Layer-Reihenfolge + Materialien
# bestimmen die Sub-Layer-Zuordnung der Schicht-Breps.
if (x.get("material") or "") != (y.get("material") or ""):
return False
return True
def _find_wall_chain(doc, wall_id):
"""Liefert ORDERED Liste der wall_ids im Polyline-Chain von wall_id.
Reihenfolge: vom Chain-Start bis zum Chain-Ende (so dass die Achsen
aneinander anschliessen). wall_id selbst ist immer dabei.
Bei nicht-Wand oder Wand nicht gefunden: leere Liste.
Stop-Bedingungen: Verzweigung (>=2 Nachbarn am Joint), T-Stoss,
inkompatibler Nachbar, oder kein Nachbar."""
src, meta = _find_source(doc, wall_id)
if src is None or meta is None or meta.get("type") != "wand_axis":
return []
geschoss = meta["geschoss"]
joints = _collect_wall_joints(doc, geschoss)
meta_by_id = {wall_id: meta}
geom_by_id = {wall_id: src.Geometry}
for obj in doc.Objects:
m = _read_meta(obj)
if not m or m["type"] != "wand_axis": continue
if m["geschoss"] != geschoss: continue
if m["id"] in meta_by_id: continue
meta_by_id[m["id"]] = m
geom_by_id[m["id"]] = obj.Geometry
def _chain_neighbor(cur_id, cur_pt):
"""Am gemeinsamen Punkt: liefert (neighbor_id, neighbor_end) wenn
es genau einen kompatiblen Nachbarn gibt, sonst None. neighbor_end
ist "start" oder "end" welcher Endpunkt von neighbor an cur_pt
sitzt."""
key = _pt_key(cur_pt)
partners = [(p_wid, p_end)
for (p_wid, p_end, _od) in joints.get(key, [])
if p_wid != cur_id]
if len(partners) != 1: return None
p_wid, p_end = partners[0]
if not _wand_chain_compat(meta_by_id.get(cur_id),
meta_by_id.get(p_wid)):
return None
return (p_wid, p_end)
chain = [wall_id]
visited = {wall_id}
# Vorwaerts: ans "end" der aktuellen Wand entlang
cur_id = wall_id
cur_pt = geom_by_id[cur_id].PointAtEnd
while True:
nb = _chain_neighbor(cur_id, cur_pt)
if nb is None: break
p_wid, p_end = nb
if p_wid in visited: break
chain.append(p_wid); visited.add(p_wid)
cur_id = p_wid
# Anderer Endpunkt des Nachbarn ist der naechste Walk-Point
cur_pt = (geom_by_id[p_wid].PointAtEnd if p_end == "start"
else geom_by_id[p_wid].PointAtStart)
# Rueckwaerts: ans "start" der aktuellen Wand entlang
cur_id = wall_id
cur_pt = geom_by_id[cur_id].PointAtStart
while True:
nb = _chain_neighbor(cur_id, cur_pt)
if nb is None: break
p_wid, p_end = nb
if p_wid in visited: break
chain.insert(0, p_wid); visited.add(p_wid)
cur_id = p_wid
cur_pt = (geom_by_id[p_wid].PointAtEnd if p_end == "start"
else geom_by_id[p_wid].PointAtStart)
return chain
def _chain_anchor(chain_ids):
"""Eindeutiger Anchor (= alphabetisch kleinste ID). Anchor besitzt das
gemeinsame wand_volume; die anderen Chain-Members haben keins."""
if not chain_ids: return None
return sorted(chain_ids)[0]
def _build_chain_polyline(doc, ordered_chain_ids):
"""Joint die wand_axis-Curves zu einer PolylineCurve. Orientiert die
erste Curve so dass ihr End-Punkt am Joint zur zweiten Curve sitzt,
danach Auto-Flip pro Folge-Curve. Returns Curve oder None.
Bei single-element chain: Duplicate der Original-Curve."""
if not ordered_chain_ids: return None
if len(ordered_chain_ids) == 1:
obj = _find_axis(doc, ordered_chain_ids[0])
return obj.Geometry.DuplicateCurve() if obj else None
# Alle Segs (Point3d-Listen) sammeln
segs = []
for wid in ordered_chain_ids:
obj = _find_axis(doc, wid)
if obj is None: return None
geom = obj.Geometry
if not isinstance(geom, rg.Curve): return None
rc, pl = geom.TryGetPolyline()
if not rc:
if isinstance(geom, rg.LineCurve):
pl = rg.Polyline([geom.PointAtStart, geom.PointAtEnd])
else:
return None
segs.append([pl[i] for i in range(pl.Count)])
# Erste Seg orientieren: ihr letzter Punkt MUSS am Joint mit Seg[1]
# sitzen. Wenn nicht (Joint ist an seg0[0]) → seg0 flippen. Sonst
# springt die Polyline spaeter quer durch den Raum.
s0, s1 = segs[0], segs[1]
tol = 1e-3
if (s0[-1].DistanceTo(s1[0]) > tol
and s0[-1].DistanceTo(s1[-1]) > tol):
segs[0] = s0[::-1]
# Folge-Segs flippen wenn ihr Start nicht am prev_end sitzt.
pts = list(segs[0])
for seg in segs[1:]:
prev_end = pts[-1]
d_start = prev_end.DistanceTo(seg[0])
d_end = prev_end.DistanceTo(seg[-1])
if d_end < d_start:
seg = seg[::-1]
# erstes pt deckt sich mit prev_end → ueberspringen (sonst Duplikat
# im Knick → Offset bricht oder erzeugt komische Mitten-Diskontinuitaet)
pts.extend(seg[1:])
if len(pts) < 2: return None
return rg.PolylineCurve(rg.Polyline(pts))
def _find_dependent_walls(doc, geschoss_id, moving_wall_id, old_curve, new_curve,
pos_tol=0.01):
"""Findet alle Waende deren Geometrie sich aendert wenn moving_wall sich
@@ -2212,6 +2380,7 @@ def _attach_meta(obj_attrs, wall_id, type_, geschoss, dicke, uk_over, ok_over,
raum_rundung=None, raum_txt_h=None,
raum_align=None, raum_sia=None, raum_fuellung=None,
wand_layered=None, wand_layers=None, wand_layer_idx=None,
wand_chain_members=None,
aussp_parent=None):
"""User-Strings auf die Object-Attributes setzen."""
obj_attrs.SetUserString(_KEY_ID, wall_id)
@@ -2393,6 +2562,17 @@ def _attach_meta(obj_attrs, wall_id, type_, geschoss, dicke, uk_over, ok_over,
try: obj_attrs.SetUserString(_KEY_WAND_LAYER_IDX,
"{}".format(int(wand_layer_idx)))
except Exception: pass
if wand_chain_members is not None:
try:
import json as _json
if isinstance(wand_chain_members, str):
obj_attrs.SetUserString(_KEY_WAND_CHAIN_MEMBERS,
wand_chain_members)
else:
obj_attrs.SetUserString(_KEY_WAND_CHAIN_MEMBERS,
_json.dumps(list(wand_chain_members),
ensure_ascii=False))
except Exception: pass
# Decken-Aussparung
if aussp_parent is not None:
obj_attrs.SetUserString(_KEY_AUSSP_PARENT, str(aussp_parent))
@@ -2562,6 +2742,15 @@ def _read_meta(obj):
except Exception: pass
try: w_layer_idx = int(a.GetUserString(_KEY_WAND_LAYER_IDX) or "-1")
except Exception: w_layer_idx = -1
w_chain_raw = a.GetUserString(_KEY_WAND_CHAIN_MEMBERS) or ""
w_chain_members = []
if w_chain_raw:
try:
import json as _json
parsed = _json.loads(w_chain_raw)
if isinstance(parsed, list):
w_chain_members = [str(x) for x in parsed if x]
except Exception: pass
aussp_parent_raw = a.GetUserString(_KEY_AUSSP_PARENT) or ""
return {
"id": a.GetUserString(_KEY_ID) or "",
@@ -2628,6 +2817,7 @@ def _read_meta(obj):
"wand_layered": w_layered,
"wand_layers": w_layers,
"wand_layer_idx": w_layer_idx,
"wand_chain_members": w_chain_members,
"aussp_parent": aussp_parent_raw,
}
except Exception:
@@ -2652,8 +2842,17 @@ def _find_axis(doc, wall_id):
def _find_volume(doc, wall_id):
# Direkter Hit: Volume gehoert genau dieser Wand
for obj, meta in _find_objects_by_wall_id(doc, wall_id, "wand_volume"):
return obj
# Indirekter Hit: Wand ist Non-Anchor in einem Chain — Volume haengt am
# Anchor, fuehrt aber wand_chain_members das diese Wand enthaelt
for obj in doc.Objects:
meta = _read_meta(obj)
if meta and meta.get("type") == "wand_volume":
members = meta.get("wand_chain_members") or []
if wall_id in members:
return obj
return None
@@ -4885,13 +5084,94 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
"""Eigentliche Implementierung des Regen — der aeussere Wrapper
`_regenerate_element` setzt _REGEN_BUSY und dispatcht oeffnung_point."""
if meta["type"] == "wand_axis":
# Chain-Detection: wenn diese Wand mit Nachbarn (gleiche Geometrie/
# Material/Hoehe, 2-Wall-Joints) zu einem Polyline-Chain gehoert,
# baut nur die Anchor-Wand (alphabetisch kleinste ID) das gemeinsame
# Volume. Andere Chain-Members loeschen ggf. ihr eigenes Volume und
# triggern Anchor-Regen.
chain_ids = []
try:
chain_ids = _find_wall_chain(doc, element_id)
except Exception as ex:
print("[ELEMENTE] chain detect:", ex)
if len(chain_ids) > 1:
anchor = _chain_anchor(chain_ids)
if anchor != element_id:
# Non-Anchor: eigenes altes Volume entfernen, Anchor regenen
for o, _m in _find_objects_by_wall_id(doc, element_id,
"wand_volume"):
try: doc.Objects.Delete(o.Id, True)
except Exception: pass
try: _regenerate_element(doc, anchor)
except Exception as ex:
print("[ELEMENTE] anchor regen:", ex)
# Pruefen: hat Anchor ein Chain-Volume gebaut das uns deckt?
# Wenn ja → fertig. Wenn nein (z.B. Polyline-Build failed) →
# weiterlaufen + uns als Solo-Wand bauen.
covered = False
for obj in doc.Objects:
m = _read_meta(obj)
if m and m.get("type") == "wand_volume":
members = m.get("wand_chain_members") or []
if element_id in members:
covered = True; break
if covered:
return True
# Anchor hat keine Chain — wir bauen Solo. chain_ids leer
# damit chain_set + Miter-Logik wie Solo wirkt.
chain_ids = []
# Anchor: Polyline aus den Chain-Achsen bauen + als geom verwenden.
# Vor-Verifikation: Brep-Build mit Polyline probieren. Wenn das
# fehlschlaegt (z.B. Innenseite-Offset self-intersect) → Chain-
# Modus dropen + per-Wand regenen. Sonst gibt's invisible walls.
chain_curve = None
try:
chain_curve = _build_chain_polyline(doc, chain_ids)
except Exception as ex:
print("[ELEMENTE] chain polyline:", ex)
if chain_curve is not None:
_test_uk, _test_ok = _resolve_uk_ok(doc, meta["geschoss"],
meta["uk_override"],
meta["ok_override"])
_test_brep = _make_volume_geometry(
chain_curve, meta["dicke"], _test_uk, _test_ok,
meta.get("referenz", "mid"))
if _test_brep is None:
print("[ELEMENTE] chain {} brep build FAILED — fallback "
"per-wand (Offset evtl. mehrteilig)".format(chain_ids))
chain_curve = None
if chain_curve is None:
# Chain-Modus dropen: Anchor verhaelt sich wie Solo-Wand.
# chain_ids leerziehen damit chain_set spaeter auch nur den
# element_id enthaelt (Miter-Logik wirkt wie Solo).
# Andere Chain-Members werden NICHT requeued — sonst Loop
# (sie wuerden chain detecten + Anchor anrufen + wieder
# failure). Stattdessen: ihre eigenen Regen-Calls
# (Move-Listener → deps) fallen via covered-Check durch
# auf Solo. Bei Erstellung wird _make_wall_from_axis ohnehin
# _find_dependent_walls auf alle benachbarten Waende anwenden.
chain_ids = []
else:
# Stale wand_volumes der anderen Chain-Members raeumen
for wid in chain_ids:
if wid == element_id: continue
for o, _m in _find_objects_by_wall_id(doc, wid,
"wand_volume"):
try: doc.Objects.Delete(o.Id, True)
except Exception: pass
geom = chain_curve
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", "")))
print("[ELEMENTE] regen wand {}: uk={:.3f} ok={:.3f} chain={} (uk_over='{}' ok_over='{}')".format(
element_id, uk, ok, len(chain_ids) if chain_ids else 1,
meta.get("uk_override", ""), meta.get("ok_override", "")))
# Wand-Verbindungen: Miter-Linien aus Nachbarwand-Joints (Corner + T).
# Bei Chain: alle Chain-Members ausschliessen, weil sie an Polyline-
# Endpunkten dort nicht als "externe Nachbarn" zaehlen sollen — die
# liegen ja schon in der gejointen Polyline drin.
miter_start = None
miter_end = None
chain_set = set(chain_ids) if (chain_ids and len(chain_ids) > 1) else {element_id}
try:
joints = _collect_wall_joints(doc, meta["geschoss"])
out_s, out_e = _wall_out_dirs(geom)
@@ -4901,7 +5181,7 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
key_s = _pt_key(p_s)
partners_s = [(wid, end, od)
for (wid, end, od) in joints.get(key_s, [])
if wid != element_id]
if wid not in chain_set]
if len(partners_s) == 1:
_wid, _end, other_out = partners_s[0]
mdir = _miter_dir(out_s, other_out)
@@ -4909,7 +5189,8 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
miter_start = (p_s, mdir)
elif len(partners_s) == 0:
tj = _detect_t_junction(doc, meta["geschoss"],
element_id, p_s)
element_id, p_s,
exclude_ids=chain_set)
if tj is not None:
_oid, b_tan, b_dicke = tj
tm = _t_junction_miter(p_s, out_s, b_tan, b_dicke)
@@ -4918,7 +5199,7 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
key_e = _pt_key(p_e)
partners_e = [(wid, end, od)
for (wid, end, od) in joints.get(key_e, [])
if wid != element_id]
if wid not in chain_set]
if len(partners_e) == 1:
_wid, _end, other_out = partners_e[0]
mdir = _miter_dir(out_e, other_out)
@@ -4926,7 +5207,8 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
miter_end = (p_e, mdir)
elif len(partners_e) == 0:
tj = _detect_t_junction(doc, meta["geschoss"],
element_id, p_e)
element_id, p_e,
exclude_ids=chain_set)
if tj is not None:
_oid, b_tan, b_dicke = tj
tm = _t_junction_miter(p_e, out_e, b_tan, b_dicke)
@@ -4948,12 +5230,29 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
geom, meta["dicke"], uk, ok,
meta.get("referenz", "mid"),
miter_start=miter_start, miter_end=miter_end)
if single_brep is None:
# Bei Chain: Polyline ist Curve, Single-Wall-Logik koennte
# an mehrteiliger Curve scheitern. Pro-Segment-Fallback.
if (chain_ids and len(chain_ids) > 1
and isinstance(geom, rg.PolylineCurve)):
print("[ELEMENTE] chain volume geom FAILED, fallback "
"per-segment (chain={})".format(chain_ids))
else:
print("[ELEMENTE] wand volume geom FAILED for {}".format(
element_id))
layer_breps = [(single_brep, "", "")] if single_brep else []
# Oeffnungen einsammeln + Cutouts pro Schicht anwenden.
# Chain: alle Member-Walls liefern Oeffnungen — der Anchor baut ein
# gemeinsames Brep, alle Cutouts werden dort eingearbeitet.
opening_jobs = []
cutouts = []
for op_obj, op_meta in _find_openings_for_wall(doc, element_id):
opening_walls = (chain_ids if (chain_ids and len(chain_ids) > 1)
else [element_id])
all_openings = []
for _wid in opening_walls:
all_openings.extend(_find_openings_for_wall(doc, _wid))
for op_obj, op_meta in all_openings:
pt_geom = op_obj.Geometry
pt_loc = None
if hasattr(pt_geom, 'Location'):
@@ -5181,7 +5480,9 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
meta.get("referenz", "mid"),
wand_layered=is_layered,
wand_layers=layers_json,
wand_layer_idx=idx)
wand_layer_idx=idx,
wand_chain_members=(chain_ids
if (chain_ids and len(chain_ids) > 1) else None))
try: doc.Objects.AddBrep(lbrep, attrs)
except Exception as ex: print("[ELEMENTE] AddBrep wand layer:", ex)
return True
@@ -9431,6 +9732,14 @@ class ElementeBridge(panel_base.BaseBridge):
try:
dicke = sum(float(l.get("dicke", 0)) for l in wand_layers)
except Exception: pass
# ALTE Chain merken: Property-Aenderung kann Chain brechen
# (z.B. dicke geaendert -> nicht mehr kompatibel). Nach dem Regen
# muessen ehemalige Chain-Members eigenes Volume erhalten.
old_chain_members = []
if old_meta["type"] == "wand_axis":
try:
old_chain_members = _find_wall_chain(doc, wall_id)
except Exception: pass
_attach_meta(attrs, wall_id, old_meta["type"], geschoss, dicke,
uk_over, ok_over, referenz,
neigung=neigung, eave_idx=eave_idx, dach_typ=dach_typ,
@@ -9442,6 +9751,13 @@ class ElementeBridge(panel_base.BaseBridge):
axis_obj.CommitChanges()
# Volumen regenerieren (Layer ggf. anpassen)
_regenerate_volume(doc, wall_id)
# Ehemalige Chain-Members re-regen damit sie ihr eigenes Volume
# bekommen (oder einen neuen Anchor bilden)
for wid in old_chain_members:
if wid == wall_id: continue
try: _regenerate_element(doc, wid)
except Exception as ex:
print("[ELEMENTE] old-chain regen:", ex)
doc.Views.Redraw()
self._send_state()
@@ -9973,19 +10289,41 @@ def _on_object_replaced_body(sender, e):
# Oeffnungen entlang der neuen Achse migrieren + Regen einreihen.
if meta.get("type") == "wand_axis":
old_geom = e.OldRhinoObject.Geometry if e.OldRhinoObject else None
new_geom = e.NewRhinoObject.Geometry if e.NewRhinoObject else None
# No-Op-Check: GripsOn/CommitChanges feuert Replace ohne Geometrie-
# Aenderung. Triggert sonst eine ganze Regen-Kette die in eigenem
# Undo-Record laeuft (Cmd+Z muesste mehrfach gedrueckt werden).
# Endpunkt-Vergleich reicht fuer LineCurves; Polylines/Splines
# checken zusaetzlich Mid-Punkt + Laenge.
unchanged = False
try:
if (isinstance(old_geom, rg.Curve)
and isinstance(new_geom, rg.Curve)):
p_o_s, p_o_e = old_geom.PointAtStart, old_geom.PointAtEnd
p_n_s, p_n_e = new_geom.PointAtStart, new_geom.PointAtEnd
tol = 1e-6
if (p_o_s.DistanceTo(p_n_s) < tol
and p_o_e.DistanceTo(p_n_e) < tol):
# Endpunkte gleich — Polyline/Spline-Mitten checken
try:
l_o = old_geom.GetLength()
l_n = new_geom.GetLength()
if abs(l_o - l_n) < tol * 100:
unchanged = True
except Exception:
unchanged = True
except Exception: unchanged = False
if unchanged:
# Nur Grips-Toggle / Attribut-Aenderung — kein Regen noetig.
return
# Joint-Cache invalidieren — Wand hat sich geaendert
_invalidate_joints_cache(meta.get("geschoss"))
try:
old_geom = e.OldRhinoObject.Geometry if e.OldRhinoObject else None
new_geom = e.NewRhinoObject.Geometry if e.NewRhinoObject else None
_migrate_openings_to_new_axis(meta["id"], old_geom, new_geom)
except Exception as ex:
print("[ELEMENTE] migrate openings:", ex)
# Wand-Verbindungen: alle ABHAENGIGEN Waende mit re-regenerieren.
# Das umfasst sowohl Corner-Partner (Endpunkte teilen) als auch
# T-Stoss-Wande (Endpunkt liegt auf der bewegten Achse). Wir
# checken gegen ALTE und NEUE Geometrie damit auch sich-loesende
# Verbindungen erkannt werden.
try:
doc2 = Rhino.RhinoDoc.ActiveDoc
if doc2 is not None:
@@ -11420,6 +11758,37 @@ def _on_command_end(sender, e):
elif canonical is not None:
pure_transform = canonical
# Chain-Volumes-Pre-Check (basierend auf EXISTIERENDEN wand_chain_members,
# nicht auf Live-Detection — sonst sieht Pre-Check schon die NEUE
# Topologie und merkt zerbrochene Chains nicht):
# Wenn ein chain-volume einen unserer moved walls referenziert UND nicht
# ALLE chain_members mit-bewegt wurden → pure-transform ist unsicher
# (Chain-Volume kann nicht einfach mit-translatiert werden, Chain muss
# neu vermessen + rebuilded werden). Wenn alle members im Move → Volume
# darf mit pure-transform folgen.
chain_volume_ids_to_follow = set()
affected_chain_members = set() # alle wall_ids deren Chain teil-bewegt wurde
if pure_transform is not None:
moved_axis_ids = set()
for moved_id in moved_ids:
old = sources_snap.get(moved_id) or {}
if old.get("type") == "wand_axis":
moved_axis_ids.add(moved_id)
if moved_axis_ids:
for obj in doc.Objects:
m = _read_meta(obj)
if not m or m.get("type") != "wand_volume": continue
members = m.get("wand_chain_members") or []
if not members: continue
if not any(wid in moved_axis_ids for wid in members):
continue
if not all(wid in moved_axis_ids for wid in members):
print("[ELEMENTE] chain partial-move → abort pure-transform")
affected_chain_members.update(members)
pure_transform = None
else:
chain_volume_ids_to_follow.add(str(obj.Id))
if pure_transform is not None:
# PURE-TRANSFORM PFAD: Transform auf alle Geometries anwenden die
# nicht schon vom User-Move transformed wurden. Funktioniert fuer
@@ -11433,11 +11802,14 @@ def _on_command_end(sender, e):
tx, ty, tz, rot_deg))
# Eltern→Kind-Cascade: nur bewegte Sources + deren Children folgen.
def _should_follow(m):
# Chain-Volumes folgen wenn alle Chain-Members im Move waren.
def _should_follow(m, obj_id_str=""):
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
if obj_id_str and obj_id_str in chain_volume_ids_to_follow:
return True
return False
_was_busy = sc.sticky.get(_REGEN_BUSY, False)
@@ -11448,7 +11820,7 @@ def _on_command_end(sender, e):
m = _read_meta(obj)
if not m: continue
t = m.get("type")
if not _should_follow(m): continue
if not _should_follow(m, str(obj.Id)): continue
# Sources die nicht bewegt wurden (= identity transform)
# transformen — nur via _should_follow erlaubt (Cascade).
if t in SOURCE_TYPES:
@@ -11618,6 +11990,12 @@ def _on_command_end(sender, e):
sc.sticky[_REGEN_BUSY] = _was_busy
sc.sticky["_dossier_skip_sync_regen"] = None
# Chain-Survivor: alle Members einer teil-bewegten Chain regenen, auch
# wenn der einzelne Member nicht in affected_walls steckt. Sonst bleibt
# ein non-anchor-survivor ohne Volume zurueck (sein altes Volume gehoerte
# der inzwischen geloeschten/verschobenen Anchor).
for wid in affected_chain_members:
affected_walls.add(wid)
# 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".