diff --git a/rhino/elemente.py b/rhino/elemente.py index 7403945..3e10b94 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -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".