From 250853d7d0acdcca5cfabbe60d821aab58d018ef Mon Sep 17 00:00:00 2001 From: karim Date: Sat, 30 May 2026 16:12:48 +0200 Subject: [PATCH] Waende: Cluster-Boolean-Union + Click-UX + Outline/Centerline + Smart-L-Join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geometrie - _find_wall_cluster: BFS ueber alle same-material verbundenen Waende inkl. T-Junctions (Stem auf Through-Achse + Through-Wand-Mitte erkannt) - _build_cluster_union_brep: per-Wand-Rect-Extrude + Boolean-Union zu einem einheitlichen Brep. Walls ueberlappen am Joint via Extension um nachbar_dicke/2 (Far-Face-Reach ohne Stummel) - _regen_cluster_anchor: Anchor-Pattern wie Chain — anchor haelt cluster_brep + alle openings als BoolDiff cutouts pro Member-Wand - _is_linear_chain: nur lineare 2-Wall-Endpoint-Sequenzen → existing Polyline-Extrude. Komplexe Cluster (verzweigt / mit T-Junction) → Union Auto-T-Snap - _t_snap_to_wand_axis mit zwei Pfaden: - Volume-Hit: IsPointInside (strict=False) auf wand_volume Brep → snap zur naechsten Cluster-Achse, unabhaengig von Wand-Dicke - Axis-Near: dynamische Toleranz max(15cm, dicke/2+10cm) → dicke Waende kriegen groessere Snap-Zone - Endpunkt-Bias 10cm → naher Endpunkt gewinnt fuer saubere Corner - Aufruf in _collect_wall_polyline + first-pt der Wand-Erstellung Click-Verhalten - _ClusterVolumeSelectHandler (MouseCallback): in Plan-View - Klick INNEN im Volume → naechste Achse selektieren - Klick auf Vertex (12 px) → Volume selektieren (Standard) - Klick auf Edge (8 px) → Volume selektieren (Standard) - Klick direkt auf Achse (5 px) → Rhino-Standard, Achse selektiert - wand_axis aus _PAIRED_SOURCE_TYPES raus → Klick auf Linie selektiert NUR die Linie (kein Mit-Selektieren des Volumens) - wand_volume bleibt in _PAIRED_VOLUME_TYPES + _collect_partners erweitert: Volume-Klick sammelt alle Cluster-Member-Achsen + Centerlines + Outlines → alle Referenzlinien leuchten bei Volume-Klick mit auf - Auto-Group fuer alle Waende entfernt + Startup-Migration _migrate_strip_wall_auto_groups_once raeumt alte Memberships Outline + Centerline - _make_wall_centerline: parallele Achse-Offset bei ref != mid → Centerline - _make_wall_outline: geschlossenes Viereck (linker + rechter Offset + perpendikulare Caps) - _regen_wall_lines: LOCKED Curves auf Referenzen-Sublayer - Centerline (dashed): nur bei ref=left/right - Outline (solid): nur Solo-Waende (Cluster-Member ueber merged Brep) - Beide mit dossier_type-Tag fuer Cleanup beim naechsten Regen Smart-L-Join (dJoin) - _l_join_attempt: 2 OFFENE Curves mit nicht-parallelen Tangenten → unendliche-Linien-Schnitt + Endpunkte beider Curves auf Schnittpunkt ersetzen (extend / shorten zu L) - _walls_and_curves_from_sel: dedupliziert Selection via wall_id, akzeptiert axis+volume Auto-Group als 1 Wand - Fallback zu Standard _Join wenn nicht passend Performance - Joint-Cache per-batch invalidieren statt per-regen (sticky _dossier_regen_batch_active) --- rhino/aliases/cmd/smart_join.py | 133 ++++ rhino/elemente.py | 1182 +++++++++++++++++++++++++++++-- 2 files changed, 1250 insertions(+), 65 deletions(-) diff --git a/rhino/aliases/cmd/smart_join.py b/rhino/aliases/cmd/smart_join.py index 8659d64..ff5e1b2 100644 --- a/rhino/aliases/cmd/smart_join.py +++ b/rhino/aliases/cmd/smart_join.py @@ -58,6 +58,128 @@ def _attr_key(obj): return (layer_idx, col_key, lt_key, pw_key, fill_key) +def _replace_curve_endpoint(curve, which_end, new_pt): + """Ersetze Start- (which_end=0) oder End-Punkt (which_end=1). Liefert + eine neue Curve oder None bei nicht-unterstuetztem Typ.""" + if isinstance(curve, rg.LineCurve): + if which_end == 0: + return rg.LineCurve(new_pt, curve.PointAtEnd) + return rg.LineCurve(curve.PointAtStart, new_pt) + if isinstance(curve, rg.PolylineCurve): + n = curve.PointCount + pts = [curve.Point(i) for i in range(n)] + if which_end == 0: pts[0] = new_pt + else: pts[-1] = new_pt + return rg.PolylineCurve(pts) + # Fallback: generische Curve via Extend + cu = curve.DuplicateCurve() + if cu is None: return None + end_enum = rg.CurveEnd.Start if which_end == 0 else rg.CurveEnd.End + try: + return cu.Extend(end_enum, + rg.CurveExtensionStyle.Line, + [rg.Point3d(new_pt)]) + except Exception: + return None + + +def _walls_and_curves_from_sel(doc, sel): + """Liefert (axes, generic_curves). Axes = dedup Wand-Achsen (per wall_id), + generic_curves = offene Kurven die KEINE Wand sind. wand_volumes werden + auf ihre Achse via wall_id resolved (auto-group bringt axis+volume + automatisch beide in sel).""" + seen_walls = set() + axes = [] + generic = [] + # Pre-Index wand_axis by wall_id fuer schnelles Lookup + axis_by_id = {} + for o in doc.Objects: + if o.Attributes.GetUserString("dossier_type") == "wand_axis": + wid = o.Attributes.GetUserString("dossier_element_id") or "" + if wid: axis_by_id[wid] = o + for obj in sel: + t = obj.Attributes.GetUserString("dossier_type") or "" + wid = obj.Attributes.GetUserString("dossier_element_id") or "" + if t == "wand_axis" and wid and wid not in seen_walls: + axes.append(obj); seen_walls.add(wid) + elif t == "wand_volume" and wid: + wall_ids = {wid} + members_raw = obj.Attributes.GetUserString( + "dossier_wand_chain_members") or "" + if members_raw: + try: + import json as _j + for c in _j.loads(members_raw): + if c: wall_ids.add(c) + except Exception: pass + for w in wall_ids: + if w in seen_walls: continue + ax = axis_by_id.get(w) + if ax is not None: + axes.append(ax); seen_walls.add(w) + elif t == "": + g = obj.Geometry + if isinstance(g, rg.Curve) and not g.IsClosed: + generic.append(obj) + return axes, generic + + +def _l_join_attempt(doc, sel): + """Wenn genau 2 OFFENE Kurven (Wand-Achsen oder generische Lines) + selektiert sind, deren End-Tangenten sich in einem Punkt schneiden → + beide Kurven extend/shorten zu diesem Punkt (= L-Form). True wenn + ausgefuehrt.""" + axes, generic = _walls_and_curves_from_sel(doc, sel) + # Erlaubte Konfigs: 2 Wand-Achsen ODER 2 generische Kurven (keine mix) + if len(axes) == 2 and len(generic) == 0: + o1, o2 = axes[0], axes[1] + elif len(axes) == 0 and len(generic) == 2: + o1, o2 = generic[0], generic[1] + else: + return False + c1 = o1.Geometry; c2 = o2.Geometry + if not (isinstance(c1, rg.Curve) and isinstance(c2, rg.Curve)): + return False + if c1.IsClosed or c2.IsClosed: return False + tol = max(doc.ModelAbsoluteTolerance, 1e-6) + # Closest endpoint pair (a_end, b_end ∈ {0=start, 1=end}) + pairs = [ + (c1.PointAtStart, c2.PointAtStart, 0, 0), + (c1.PointAtStart, c2.PointAtEnd, 0, 1), + (c1.PointAtEnd, c2.PointAtStart, 1, 0), + (c1.PointAtEnd, c2.PointAtEnd, 1, 1), + ] + pairs.sort(key=lambda p: p[0].DistanceTo(p[1])) + p1, p2, e1, e2 = pairs[0] + if p1.DistanceTo(p2) < tol: + return False # bereits verbunden + def _out_dir(c, end): + return -c.TangentAtStart if end == 0 else c.TangentAtEnd + d1 = _out_dir(c1, e1) + d2 = _out_dir(c2, e2) + # Parallel-Check (Cross-Produkt-Laenge in XY) + cross_z = d1.X * d2.Y - d1.Y * d2.X + if abs(cross_z) < 1e-9: return False # parallel + # Unendliche Linien-Intersection + line1 = rg.Line(p1, p1 + d1) + line2 = rg.Line(p2, p2 + d2) + rc, t_a, t_b = rg.Intersect.Intersection.LineLine(line1, line2, tol, False) + if not rc: return False + ipt = line1.PointAt(t_a) + if line2.PointAt(t_b).DistanceTo(ipt) > 0.01: + return False # Schiefe Linien in 3D + nc1 = _replace_curve_endpoint(c1, e1, ipt) + nc2 = _replace_curve_endpoint(c2, e2, ipt) + if nc1 is None or nc2 is None: return False + ur = doc.BeginUndoRecord("DOSSIER L-Join") + try: + ok1 = doc.Objects.Replace(o1.Id, nc1) + ok2 = doc.Objects.Replace(o2.Id, nc2) + return bool(ok1 and ok2) + finally: + doc.EndUndoRecord(ur) + + def _run(): doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return @@ -65,6 +187,17 @@ def _run(): if not sel: Rhino.RhinoApp.RunScript("_Join", False); return + # L-Join: genau 2 offene Kurven die sich (verlaengert) treffen wuerden. + # Walls werden via ihrer Achse automatisch regenert (Replace-Listener). + if len(sel) == 2: + try: + if _l_join_attempt(doc, sel): + doc.Views.Redraw() + print("[SMART-JOIN] L-Join: 2 Curves zu L verbunden") + return + except Exception as ex: + print("[SMART-JOIN] L-Join error:", ex) + # Curves nach Closed/Open trennen closed_objs = [] has_non_closed = False diff --git a/rhino/elemente.py b/rhino/elemente.py index a4182bb..35787b7 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -1878,6 +1878,114 @@ def _make_traeger_preview(first_pt, profil, B, H, D, t, angle): return handler +def _t_snap_to_wand_axis(doc, pt, tol=0.15): + """Snap pt auf die naechste wand_axis. Zwei Pfade: + + 1) VOLUME-HIT: pt liegt INNERHALB irgendeines wand_volume Breps (Plan-View) → + snappe auf naechste Achse dieses Volumens (oder seiner Cluster-Members). + Funktioniert unabhaengig von Wand-Dicke — auch 40cm-Waende werden korrekt + erfasst wenn man irgendwo aufs Volumen klickt. + + 2) AXIS-NEAR: pt liegt nahe ( bb.Max.X + boundary_tol or + pt.Y < bb.Min.Y - boundary_tol or pt.Y > bb.Max.Y + boundary_tol): + continue + mid_z = (bb.Min.Z + bb.Max.Z) * 0.5 + test_pt = rg.Point3d(pt.X, pt.Y, mid_z) + try: + inside = brep.IsPointInside(test_pt, 0.01, False) + except Exception: inside = False + if not inside: continue + # Naechste Achse innerhalb dieser Member-Gruppe finden + best_cp = None; best_d = float('inf') + for wid in members: + g = axis_by_wid.get(wid) + if g is None: continue + try: + rc, t_par = g.ClosestPoint(pt) + if not rc: continue + cp = g.PointAt(t_par) + d = ((cp.X - pt.X) ** 2 + (cp.Y - pt.Y) ** 2) ** 0.5 + if d < best_d: + best_d = d; best_cp = (_snap_endpoint_bias(g, cp)) + except Exception: continue + if best_cp is not None: + return rg.Point3d(best_cp.X, best_cp.Y, pt.Z) + + # Pfad 2: Achse innerhalb tol (dynamisch: bei dicken Waenden mehr Reichweite, + # damit auch Klicks knapp neben der Aussenseite die Achse erwischen). + best_pt = None; best_d = tol + # Per-Achse dynamische Toleranz: max(default_tol, dicke/2 + 10cm) + for obj in doc.Objects: + try: + if obj.Attributes.GetUserString("dossier_type") != "wand_axis": + continue + except Exception: continue + g = obj.Geometry + if not isinstance(g, rg.Curve): continue + try: + wd = float(obj.Attributes.GetUserString("dossier_dicke") or 0) + except Exception: wd = 0 + dyn_tol = max(tol, wd * 0.5 + 0.10) + try: + rc, t_par = g.ClosestPoint(pt) + if not rc: continue + cp = g.PointAt(t_par) + d = ((cp.X - pt.X) ** 2 + (cp.Y - pt.Y) ** 2) ** 0.5 + if d < dyn_tol and d < best_d: + cp = _snap_endpoint_bias(g, cp) + best_d = d + best_pt = rg.Point3d(cp.X, cp.Y, pt.Z) + except Exception: continue + return best_pt + + def _make_preview_handler(committed_points, dicke, referenz): """Preview fuer Polylinie-Wand: gesetzte Punkte + Rubberband + Wand-Kanten.""" import System.Drawing as SD @@ -2083,15 +2191,21 @@ def _detect_t_junction(doc, geschoss_id, wall_id, endpoint, def _resolve_corner_miter(doc, meta, p_pt, out_dir, - partner_wid, partner_end, partner_out): + partner_wid, partner_end, partner_out, + my_end="end"): """Liefert (miter_pt, miter_dir) fuer einen Corner-Joint mit Style-Prio- Dominanz. None = kein Miter (= flat cap an dieser Seite). Regel: - Gleiche Prio (oder beide ohne Style): klassischer Winkelhalbierender-Miter - - Ich habe hoehere Prio: ich gewinne die Ecke → kein Miter (eigener Cap) - - Ich habe niedrigere Prio: ich fuege mich → T-Stoss-Miter an Partner's - nahe Aussenflaeche + - Ich habe hoehere Prio: ich extendiere um partner_dicke/2 ueber meinen + Endpunkt → fuelle die Aussen-Ecke vollstaendig aus + - Ich habe niedrigere Prio: ich retract um partner_dicke/2 in mein Body + → mein Brep endet an Partner's naher Aussenflaeche, kein Konflikt + + Beide Faelle: Miter-Linie perpendicular zu MEINER Achse (anders als der + klassische Bisector-Miter). Effekt: bei 90°-Ecke gewinnt der staerkere + Wand-Brep komplett, der schwaechere klemmt sauber dran. """ p_meta = _wand_meta_by_id(doc, partner_wid) my_prio = _wand_meta_prio(doc, meta) @@ -2099,19 +2213,65 @@ def _resolve_corner_miter(doc, meta, p_pt, out_dir, if my_prio == other_prio: mdir = _miter_dir(out_dir, partner_out) return (p_pt, mdir) if mdir is not None else None - if my_prio > other_prio: - # Ich dominiere → kein Miter, eigener Endpunkt wird flat gecappt - return None - # Ich verliere → T-Stoss gegen Partner's Achse - partner_axis = _find_axis(doc, partner_wid) - if partner_axis is None: return None - p_geom = partner_axis.Geometry - if not isinstance(p_geom, rg.Curve): return None - tan = (p_geom.TangentAtStart if partner_end == "start" - else p_geom.TangentAtEnd) - b_tan = rg.Vector3d(tan.X, tan.Y, 0) + # Prios unterschiedlich → Dominanz-Miter. Eigene Tangente am Joint + # bestimmen (richtung body-weg). + my_axis = _find_axis(doc, meta.get("id", "")) + if my_axis is None: return None + my_geom = my_axis.Geometry + if not isinstance(my_geom, rg.Curve): return None + my_tan_raw = (my_geom.TangentAtStart if my_end == "start" + else my_geom.TangentAtEnd) + # OUTWARD = weg vom body: bei "end" = +TangentAtEnd, bei "start" = -TangentAtStart + if my_end == "start": + my_out_tan = rg.Vector3d(-my_tan_raw.X, -my_tan_raw.Y, 0) + else: + my_out_tan = rg.Vector3d(my_tan_raw.X, my_tan_raw.Y, 0) + try: my_out_tan.Unitize() + except Exception: return None b_dicke = float((p_meta or {}).get("dicke", 0.25)) - return _t_junction_miter(p_pt, out_dir, b_tan, b_dicke) + # Winner extendiert (+), Verlierer retract (-) um b_dicke/2 + sign = +1.0 if (my_prio > other_prio) else -1.0 + off = b_dicke * 0.5 * sign + mpt = rg.Point3d(p_pt.X + my_out_tan.X * off, + p_pt.Y + my_out_tan.Y * off, 0) + # Miter-Linie perpendicular zu meiner Achse + perp_my = rg.Vector3d(-my_out_tan.Y, my_out_tan.X, 0) + try: perp_my.Unitize() + except Exception: return None + return (mpt, perp_my) + + +def _detect_through_wall_at(doc, partners_at_joint, exclude_self_tan): + """Liefert (through_tan, through_dicke) wenn unter den Partnern an + einem 3+-Joint zwei collineare Wand-Endpunkte zu finden sind, die + NICHT collinear mit exclude_self_tan (= meine Tangente) sind. + Diese zwei collineare Partner formen die 'Durchgangswand' an die wir + T-mitern. None wenn keine solche Konfiguration existiert.""" + # Sammle (wid, tangent) fuer jeden Partner + cands = [] + for p_wid, p_end, _od in partners_at_joint: + ax = _find_axis(doc, p_wid) + if ax is None: continue + g = ax.Geometry + if not isinstance(g, rg.Curve): continue + tan = g.TangentAtStart if p_end == "start" else g.TangentAtEnd + cands.append((p_wid, tan)) + # Finde Gruppe collinearer Partner die NICHT collinear mit mir sind + for i in range(len(cands)): + for j in range(i + 1, len(cands)): + wi, ti = cands[i] + wj, tj = cands[j] + # collinear zueinander? + crossij = ti.X * tj.Y - ti.Y * tj.X + if abs(crossij) >= 1e-4: continue + # NICHT collinear mit mir? + cross_im = ti.X * exclude_self_tan.Y - ti.Y * exclude_self_tan.X + if abs(cross_im) < 1e-4: continue + # Gefunden: ti/tj formen die Through-Wand + mi = _wand_meta_by_id(doc, wi) + b_dicke = float((mi or {}).get("dicke", 0.25)) + return (rg.Vector3d(ti.X, ti.Y, 0), b_dicke) + return None def _t_junction_miter(endpoint, out_dir, b_tan, b_dicke): @@ -2154,27 +2314,35 @@ def _t_junction_miter(endpoint, out_dir, b_tan, b_dicke): # 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. +def _wand_solid_material(doc, meta): + """Liefert das Material-Name einer SOLID-Wand (= aus dem zugehoerigen + Style). Leerstring wenn nicht aufloesbar. Fuer layered Walls ohne + Bedeutung (Material kommt pro Layer).""" + if not meta: return "" + if meta.get("wand_layered"): return "" + sid = (meta.get("wand_style_id") or "").strip() + if not sid: return "" + s = _find_wand_style(doc, sid) + if not s: return "" + return str(s.get("material") or "") - Style-Shortcut: wenn beide Walls den GLEICHEN style_id haben → kompatibel - (Style-Definition garantiert dass alle abgeleiteten Parameter identisch sind). - Wenn unterschiedliche style_ids → NICHT kompatibel (Phase 3: dann gilt - Prio-Dominanz mit T-Mitering, kein Chain-Merge).""" + +def _wand_chain_compat(meta_a, meta_b, doc=None): + """Sind zwei Waende kompatibel fuer einen gemeinsamen Polyline-Chain? + Wenn irgendein geometrie-relevanter Parameter abweicht: nein. + + Phase-3-Regel: chain-bar wenn gleiche GEOMETRIE (dicke + layered-Struktur) + UND gleiches MATERIAL (= dieselbe Section-Hatch). Style-Prio und Style- + Name duerfen unterschiedlich sein — solange Material identisch ist + mergen wir, damit der Schnitt eine durchgehende Hatch zeigt. + + doc: optional, fuer Material-Lookup bei solid-Walls via Style. Wenn None + uebergeben, wird Material NICHT geprueft (Legacy-Behavior). + + Wenn Materialien unterschiedlich → Solo-Build mit Prio-Dominanz an der + Ecke (siehe _resolve_corner_miter).""" if not meta_a or not meta_b: return False if meta_a.get("geschoss") != meta_b.get("geschoss"): return False - # Style-Shortcut: identische style_ids → direkt kompatibel - sa = (meta_a.get("wand_style_id") or "").strip() - sb = (meta_b.get("wand_style_id") or "").strip() - if sa and sb: - return sa == sb - # Mixed-Mode (einer hat style_id, anderer nicht): nicht kompatibel — - # falls Style gesetzt aber andere Wand legacy ist, wollen wir Solo-Build. - if (sa and not sb) or (sb and not sa): - return False - # Beide ohne style_id → Legacy-Verhalten: alle Parameter vergleichen. 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"): @@ -2196,20 +2364,619 @@ def _wand_chain_compat(meta_a, meta_b): # bestimmen die Sub-Layer-Zuordnung der Schicht-Breps. if (x.get("material") or "") != (y.get("material") or ""): return False + else: + # SOLID-Walls: Material kommt aus Style. Nur chain-bar wenn beide + # auf das gleiche Material referenzieren (Phase 3). + if doc is not None: + mat_a = _wand_solid_material(doc, meta_a) + mat_b = _wand_solid_material(doc, meta_b) + if mat_a != mat_b: return False + return True + + +def _find_wall_cluster(doc, wall_id): + """BFS ueber alle verbundenen same-material Waende. Liefert SET aller + wall_ids im Cluster (egal ob linear, verzweigt oder T-gestossen). + + Folgt sowohl Endpoint-Sharing (joints) als auch T-Junctions: + - Mein Endpunkt liegt MITTEN auf einer anderen Achse (= ich bin T-Stem) + - Ein anderer Endpunkt liegt MITTEN auf MEINER Achse (= ich bin Durchgang) + + Verwendet fuer Boolean-Union-Build bei komplexen Clustern. + """ + src, meta = _find_source(doc, wall_id) + if src is None or meta is None or meta.get("type") != "wand_axis": + return set() + geschoss = meta["geschoss"] + joints = _collect_wall_joints(doc, geschoss) + # Precompute alle wand_axis-Objekte des Geschosses (perf: einmaliger Scan) + all_walls = {} + for obj in doc.Objects: + m = _read_meta(obj) + if m and m.get("type") == "wand_axis" and m.get("geschoss") == geschoss: + all_walls[m["id"]] = (obj, m) + + tol = 0.001 + visited = {wall_id} + queue = [wall_id] + while queue: + wid = queue.pop(0) + entry = all_walls.get(wid) + if not entry: continue + wax, wm = entry + wg = wax.Geometry + if not isinstance(wg, rg.Curve): continue + my_eps = (wg.PointAtStart, wg.PointAtEnd) + # 1) Endpoint-Sharing via joints-Index + for ep in my_eps: + for (p_wid, _end, _od) in joints.get(_pt_key(ep), []): + if p_wid == wid or p_wid in visited: continue + p_entry = all_walls.get(p_wid) + if not p_entry: continue + if not _wand_chain_compat(wm, p_entry[1], doc=doc): continue + visited.add(p_wid); queue.append(p_wid) + # 2) T-Junctions in beide Richtungen + for oid, (oax, om) in all_walls.items(): + if oid == wid or oid in visited: continue + if not _wand_chain_compat(wm, om, doc=doc): continue + og = oax.Geometry + if not isinstance(og, rg.Curve): continue + other_eps = (og.PointAtStart, og.PointAtEnd) + is_t = False + # 2a) Mein Endpunkt mitten auf ihrer Achse + for ep in my_eps: + rc, t = og.ClosestPoint(ep) + if not rc: continue + if og.PointAt(t).DistanceTo(ep) > tol: continue + # Nicht an ihrem Endpunkt (das ist Endpoint-Sharing, schon abgedeckt) + if (other_eps[0].DistanceTo(ep) <= tol or + other_eps[1].DistanceTo(ep) <= tol): + continue + is_t = True; break + # 2b) Ihr Endpunkt mitten auf meiner Achse + if not is_t: + for o_ep in other_eps: + rc, t = wg.ClosestPoint(o_ep) + if not rc: continue + if wg.PointAt(t).DistanceTo(o_ep) > tol: continue + if (my_eps[0].DistanceTo(o_ep) <= tol or + my_eps[1].DistanceTo(o_ep) <= tol): + continue + is_t = True; break + if is_t: + visited.add(oid); queue.append(oid) + return visited + + +def _is_linear_chain(doc, cluster_ids): + """Linear = (a) keine Endpunkt-Verzweigung (jeder Member hat ≤1 Cluster- + Nachbar pro Endpunkt) UND (b) Cluster ist via Endpoint-Sharing voll + verbunden (keine T-Junctions). Sonst Boolean-Union noetig.""" + if len(cluster_ids) <= 1: return True + cluster_set = set(cluster_ids) + # BFS nur ueber Endpoint-Sharing — wenn am Ende nicht alle Cluster-Member + # erreicht sind, gibt's T-Junctions (= complex) + start = next(iter(cluster_ids)) + visited = {start} + queue = [start] + while queue: + wid = queue.pop(0) + ax = _find_axis(doc, wid) + if ax is None: return False + m = _read_meta(ax) + if m is None: return False + joints = _collect_wall_joints(doc, m.get("geschoss")) + g = ax.Geometry + if not isinstance(g, rg.Curve): return False + for ep in (g.PointAtStart, g.PointAtEnd): + partners_in_cluster = [ + p_wid for (p_wid, _e, _o) in joints.get(_pt_key(ep), []) + if p_wid != wid and p_wid in cluster_set + ] + if len(partners_in_cluster) > 1: return False # Endpoint-Branch + for p in partners_in_cluster: + if p not in visited: + visited.add(p); queue.append(p) + return visited == cluster_set + + +def _extend_axis_curve(g, ext_start, ext_end): + """Verlaengert die Curve an Start/End in Tangenten-Richtung. Unterstuetzt + LineCurve + PolylineCurve (Standard fuer Wand-Achsen).""" + if ext_start <= 0 and ext_end <= 0: return g + if isinstance(g, rg.LineCurve): + p1 = g.PointAtStart; p2 = g.PointAtEnd + d = p2 - p1 + if d.Length < 1e-6: return g + d.Unitize() + new_p1 = (p1 - d * ext_start) if ext_start > 0 else p1 + new_p2 = (p2 + d * ext_end) if ext_end > 0 else p2 + return rg.LineCurve(new_p1, new_p2) + if isinstance(g, rg.PolylineCurve): + n = g.PointCount + if n < 2: return g + pts = [g.Point(i) for i in range(n)] + if ext_start > 0: + d = pts[0] - pts[1] + if d.Length > 1e-6: + d.Unitize() + pts[0] = pts[0] + d * ext_start + if ext_end > 0: + d = pts[-1] - pts[-2] + if d.Length > 1e-6: + d.Unitize() + pts[-1] = pts[-1] + d * ext_end + return rg.PolylineCurve(pts) + return g + + +def _build_cluster_union_brep(doc, cluster_ids, uk, ok): + """Per-Wand Rect-Extrude (mit Endpunkt-Verlaengerung an Cluster-Joints) + + Boolean-Union + MergeCoplanarFaces → sauberes Brep ohne sichtbare Nahtlinien. + + Verlaengerungs-Strategie: an jedem Endpunkt der Achse, der einen Cluster- + Nachbarn hat (Endpunkt-shared ODER T-Junction), wird die Achse um + `nachbar_dicke` verlaengert. Damit ueberlappen sich die Breps am Joint + substantiell und BooleanUnion liefert eine clean unified Topology.""" + if not cluster_ids: return None + # Pre-collect alle Member-Geometrien + Metas + walls = {} # wid → (ax, meta, geom) + for wid in cluster_ids: + ax = _find_axis(doc, wid) + if ax is None: continue + wm = _read_meta(ax) + if wm is None: continue + g = ax.Geometry + if not isinstance(g, rg.Curve): continue + walls[wid] = (ax, wm, g) + if not walls: return None + + eps = 0.001 + breps = [] + for wid, (ax, wm, g) in walls.items(): + ext_start = 0.0; ext_end = 0.0 + my_start = g.PointAtStart; my_end = g.PointAtEnd + for nid, (n_ax, n_meta, n_g) in walls.items(): + if nid == wid: continue + # Extension um NACHBAR-dicke/2 (= bis zur Mitte bzw. Far-Face der + # Nachbar-Wand). Bei voller dicke wuerde der Stem auf der anderen + # Seite als kleiner Stummel rausragen. + n_half = float(n_meta.get("dicke", 0.0)) * 0.5 + n_start = n_g.PointAtStart; n_end = n_g.PointAtEnd + # Endpunkt-Share: mein Endpunkt = ihr Endpunkt + if (my_start.DistanceTo(n_start) < eps or + my_start.DistanceTo(n_end) < eps): + ext_start = max(ext_start, n_half) + if (my_end.DistanceTo(n_start) < eps or + my_end.DistanceTo(n_end) < eps): + ext_end = max(ext_end, n_half) + # T-Junction: mein Endpunkt mitten auf ihrer Achse + for my_ep, is_start in ((my_start, True), (my_end, False)): + rc, t = n_g.ClosestPoint(my_ep) + if not rc: continue + if n_g.PointAt(t).DistanceTo(my_ep) > eps: continue + # Nicht an ihrem Endpunkt (schon oben behandelt) + if (n_start.DistanceTo(my_ep) < eps or + n_end.DistanceTo(my_ep) < eps): + continue + # Verlaengere mein Brep bis zur Far-Face der Nachbar-Wand + if is_start: + ext_start = max(ext_start, n_half) + else: + ext_end = max(ext_end, n_half) + ext_g = _extend_axis_curve(g, ext_start, ext_end) + b = _make_volume_geometry(ext_g, wm["dicke"], uk, ok, + wm.get("referenz", "mid")) + if b is not None: + breps.append(b) + if not breps: return None + if len(breps) == 1: + try: breps[0].MergeCoplanarFaces(doc.ModelAbsoluteTolerance) + except Exception: pass + return breps[0] + tol = doc.ModelAbsoluteTolerance + try: + unioned = rg.Brep.CreateBooleanUnion(breps, tol) + except Exception as ex: + print("[ELEMENTE] cluster boolean-union exc:", ex) + return None + if not unioned or len(unioned) == 0: + print("[ELEMENTE] cluster boolean-union empty (cluster={})".format( + cluster_ids)) + return None + if len(unioned) == 1: + result = unioned[0] + else: + try: + joined = rg.Brep.JoinBreps(list(unioned), tol) + if joined and len(joined) > 0: + result = max(joined, key=lambda b: b.GetVolume() if b else 0) + else: + result = max(unioned, key=lambda b: b.GetVolume() if b else 0) + except Exception: + result = max(unioned, key=lambda b: b.GetVolume() if b else 0) + # MergeCoplanarFaces entfernt interne Nahtlinien zwischen koplanaren Faces + try: result.MergeCoplanarFaces(tol) + except Exception as ex: print("[ELEMENTE] MergeCoplanarFaces:", ex) + return result + + +class _ClusterVolumeSelectHandler(Rhino.UI.MouseCallback): + """Mouse-Down auf Wand-Volume: + - Klick INNEN (nicht am Rand/Eck) → naechste Achse selektieren + - Klick auf RAND (Edge, ~8 px) → Standard, Volume wird selektiert + - Klick auf ECK (Vertex, ~12 px) → Standard, Volume wird selektiert + + Gilt fuer solo + cluster Volumes. Nur Parallel-Projektion.""" + + def __init__(self): + Rhino.UI.MouseCallback.__init__(self) + self._busy = False + + def OnMouseDown(self, e): + if self._busy: return + try: + try: + if "Left" not in str(e.MouseButton): return + except Exception: pass + view = e.View + if view is None: return + vp = view.ActiveViewport + if vp is None: return + try: + if not vp.IsParallelProjection: return + except Exception: return + doc = Rhino.RhinoDoc.ActiveDoc + if doc is None: return + scr_pt = e.ViewportPoint + # GetFrustumLine returns Tuple(bool, Line) in CPython + try: + res = vp.GetFrustumLine(scr_pt.X, scr_pt.Y) + if isinstance(res, tuple): + ok_fl, line = res + if not ok_fl: return + else: + line = res + except Exception: return + plane = rg.Plane(rg.Point3d.Origin, rg.Vector3d.ZAxis) + res2 = rg.Intersect.Intersection.LinePlane(line, plane) + if isinstance(res2, tuple): + rc, t = res2 + else: + rc, t = res2, 0.0 + if not rc: return + world_pt = line.PointAt(t) + # Skip wenn Klick direkt auf einer Wand-Achse landet (5 px) + near_px2 = 5 * 5 + for axis_obj in doc.Objects: + am = _read_meta(axis_obj) + if not am or am.get("type") != "wand_axis": continue + ag = axis_obj.Geometry + if not isinstance(ag, rg.Curve): continue + rc2, t2 = ag.ClosestPoint(world_pt) + if not rc2: continue + cp = ag.PointAt(t2) + try: + s = vp.WorldToClient(cp) + dx = s.X - scr_pt.X + dy = s.Y - scr_pt.Y + if (dx * dx + dy * dy) < near_px2: + return + except Exception: continue + # Wand-Volume unter dem Klick finden (solo ODER cluster) + hit_volume = None; hit_members = None + for obj in doc.Objects: + m = _read_meta(obj) + if not m or m.get("type") != "wand_volume": continue + wid = m.get("id") or "" + if not wid: continue + geom = obj.Geometry + if not isinstance(geom, rg.Brep): continue + bb = geom.GetBoundingBox(True) + if not bb.IsValid: continue + if (world_pt.X < bb.Min.X - 0.001 or + world_pt.X > bb.Max.X + 0.001 or + world_pt.Y < bb.Min.Y - 0.001 or + world_pt.Y > bb.Max.Y + 0.001): + continue + mid_z = (bb.Min.Z + bb.Max.Z) * 0.5 + test_pt = rg.Point3d(world_pt.X, world_pt.Y, mid_z) + try: + if geom.IsPointInside(test_pt, 0.001, True): + hit_volume = obj + members = [wid] + for c in (m.get("wand_chain_members") or []): + if c and c not in members: members.append(c) + hit_members = members + break + except Exception: continue + if hit_volume is None: return + # Edge/Corner-Test in Screen-Space → Volume selektieren lassen + brep = hit_volume.Geometry + vert_px2 = 12 * 12 + edge_px2 = 8 * 8 + try: + for vtx in brep.Vertices: + p = vtx.Location + s = vp.WorldToClient(p) + dx = s.X - scr_pt.X + dy = s.Y - scr_pt.Y + if (dx * dx + dy * dy) < vert_px2: + return # Eck-Klick → Standard + except Exception: pass + try: + for edge in brep.Edges: + crv = edge.EdgeCurve + if crv is None: continue + rc3, t3 = crv.ClosestPoint(world_pt) + if not rc3: continue + cp = crv.PointAt(t3) + s = vp.WorldToClient(cp) + dx = s.X - scr_pt.X + dy = s.Y - scr_pt.Y + if (dx * dx + dy * dy) < edge_px2: + return # Rand-Klick → Standard + except Exception: pass + # Interior-Klick → naechste Achse selektieren + best_axis = None; best_d2 = float('inf') + for c_wid in hit_members: + c_ax = _find_axis(doc, c_wid) + if c_ax is None: continue + c_g = c_ax.Geometry + if not isinstance(c_g, rg.Curve): continue + try: + rc4, t4 = c_g.ClosestPoint(world_pt) + if not rc4: continue + cp4 = c_g.PointAt(t4) + d2 = ((cp4.X - world_pt.X) ** 2 + + (cp4.Y - world_pt.Y) ** 2) + if d2 < best_d2: + best_d2 = d2; best_axis = c_ax + except Exception: continue + if best_axis is None: return + try: e.Cancel = True + except Exception: pass + self._busy = True + try: + doc.Objects.UnselectAll() + doc.Objects.Select(best_axis.Id, True) + try: view.Redraw() + except Exception: pass + finally: + self._busy = False + except Exception as ex: + print("[ELEMENTE] cluster-select OnMouseDown:", ex) + + +_STICKY_CLUSTER_SELECT = "_dossier_cluster_select_handler" + + +def install_cluster_select_handler(): + """Idempotent: alten Handler disable, neuen installieren.""" + try: + old = sc.sticky.get(_STICKY_CLUSTER_SELECT) + if old is not None: + try: old.Enabled = False + except Exception: pass + h = _ClusterVolumeSelectHandler() + h.Enabled = True + sc.sticky[_STICKY_CLUSTER_SELECT] = h + print("[ELEMENTE] Cluster-Volume Select-Handler aktiv") + except Exception as ex: + print("[ELEMENTE] cluster-select install:", ex) + + +def _regen_cluster_anchor(doc, anchor_id, cluster_ids, anchor_meta): + """Anchor-Build fuer komplexe Multi-Wand-Cluster (same material, verzweigt). + Baut BooleanUnion-Brep aller Cluster-Member-Volumina, subtrahiert + Oeffnungs-Cutouts. Opening-Sub-Pieces (Rahmen/Sims/Glas/Schwung) bleiben + pro-Wand zustaendig — die werden von ihrer eigenen Wand-Regen verwaltet. + + Return True bei Erfolg, False bei Fallback-Bedarf (caller faellt dann auf + chain/solo zurueck).""" + cluster_list = sorted(cluster_ids) + uk, ok = _resolve_uk_ok(doc, anchor_meta["geschoss"], + anchor_meta["uk_override"], + anchor_meta["ok_override"]) + cluster_brep = _build_cluster_union_brep(doc, cluster_list, uk, ok) + if cluster_brep is None: + return False + + # Openings: cutouts pro Member-Wand sammeln + abziehen + tol = 0.001 + cutouts = [] + for c_wid in cluster_list: + c_ax = _find_axis(doc, c_wid) + if c_ax is None: continue + c_meta = _read_meta(c_ax) + if c_meta is None: continue + c_geom = c_ax.Geometry + if not isinstance(c_geom, rg.Curve): continue + for op_obj, op_meta in _find_openings_for_wall(doc, c_wid): + pt_g = op_obj.Geometry + pt_loc = pt_g.Location if hasattr(pt_g, 'Location') else pt_g + if not isinstance(pt_loc, rg.Point3d): continue + try: + eff_pt = _oeff_effective_axis_point( + c_geom, pt_loc, op_meta["oeff_breite"], + op_meta.get("oeff_referenz", "mid")) + co = _make_oeffnung_cutout( + c_geom, eff_pt, c_meta["dicke"], + op_meta["oeff_breite"], op_meta["oeff_hoehe"], + op_meta["oeff_brueest"], uk) + if co: cutouts.append(co) + except Exception as ex: + print("[ELEMENTE] cluster cutout:", ex) + if cutouts: + try: + diff = rg.Brep.CreateBooleanDifference( + [cluster_brep], cutouts, tol) + if diff and len(diff) > 0: + cluster_brep = diff[0] + except Exception as ex: + print("[ELEMENTE] cluster bool-diff openings:", ex) + + # Alle stale wand_volume-Objekte der Cluster-Members raeumen + for c_wid in cluster_list: + for o, _m in _find_objects_by_wall_id(doc, c_wid, "wand_volume"): + try: doc.Objects.Delete(o.Id, True) + except Exception: pass + # Auch alte Anchor-Volumes mit chain_members die unsere Member enthalten + cluster_set = set(cluster_list) + for _vobj in list(doc.Objects): + _vm = _read_meta(_vobj) + if not _vm or _vm.get("type") != "wand_volume": continue + _members = _vm.get("wand_chain_members") or [] + if any(c in cluster_set for c in _members): + try: doc.Objects.Delete(_vobj.Id, True) + except Exception: pass + + # Layer + Material aus Anchor's Style + g = _geschoss_by_id(doc, anchor_meta["geschoss"]) + geschoss_name = g.get("name", "EG") if g else "EG" + layer_idx = _ensure_layer(doc, _layer_path_volume(doc, geschoss_name)) + mat_color = "#9a9a9a" + try: + sm = _wand_solid_material(doc, anchor_meta) + if sm: + all_mats = _get_all_materials(doc) + if sm in all_mats: + mat_color = all_mats[sm].get("color", mat_color) + ms = _ensure_material_sublayer(doc, geschoss_name, sm) + if ms >= 0: layer_idx = ms + except Exception as ex: + print("[ELEMENTE] cluster material lookup:", ex) + + attrs = Rhino.DocObjects.ObjectAttributes() + attrs.LayerIndex = layer_idx + try: + import System.Drawing as SD + attrs.ColorSource = ( + Rhino.DocObjects.ObjectColorSource.ColorFromObject) + attrs.ObjectColor = SD.Color.FromArgb(255, 0, 0, 0) + except Exception: pass + mat_idx = _ensure_material(doc, mat_color) + if mat_idx >= 0: + attrs.MaterialIndex = mat_idx + attrs.MaterialSource = ( + Rhino.DocObjects.ObjectMaterialSource.MaterialFromObject) + _attach_meta(attrs, anchor_id, "wand_volume", + anchor_meta["geschoss"], anchor_meta["dicke"], + anchor_meta["uk_override"], anchor_meta["ok_override"], + anchor_meta.get("referenz", "mid"), + wand_chain_members=cluster_list) + # Cluster-Volume bewusst NICHT in Anchor-Group: sonst wuerde Click auf + # Anchor-Achse das ganze gemerged Brep mit-selektieren. Stattdessen: + # - Click auf Achse → nur die Achse + # - Click auf Volume → Select-Handler swappt auf naechste Cluster-Achse + try: + doc.Objects.AddBrep(cluster_brep, attrs) + except Exception as ex: + print("[ELEMENTE] AddBrep cluster:", ex) + return False + # Stale Auto-Groups + Centerlines fuer alle Cluster-Member regen + try: + for _wid in cluster_list: + _strip_wall_auto_group(doc, _wid) + _regen_wall_lines(doc, _wid, in_cluster=True) + except Exception as ex: + print("[ELEMENTE] strip/lines (cluster):", ex) + print("[ELEMENTE] cluster-union anchor={} members={} cutouts={}".format( + anchor_id, len(cluster_list), len(cutouts))) return True def _find_wall_chain(doc, wall_id): - """Chain-Logik DEAKTIVIERT (Plan 3B Phase 1): jede Wand baut ihr eigenes - Volume. So sind Volumes pro Achsen-Segment einzeln anwaehlbar/loeschbar. - Sauberer Joint-Visual kommt ueber per-Wand Miter (siehe miter_start/end - in _regenerate_element_body) — adjacent compat-Walls mitern in dieselbe - Plane, treffen perfekt aufeinander, Naht ist nur eine 1px-Edge. - Section-Hatch-Verbinden wird in Phase 3 ueber Hatch-Boundary-Union geloest.""" + """Liefert ORDERED Liste der wall_ids im Polyline-Chain von wall_id. + Chain = LINEARE Sequenz benachbarter Waende mit gleichem Material. + Anchor (= lex-kleinste id) baut ein gemeinsames Polyline-Volume. + + Stop-Bedingungen: Verzweigung (>=2 Nachbarn am Joint), inkompatibler + Nachbar (anderes Material/Geometrie), oder kein Nachbar. + + Fuer komplexe verzweigte Cluster: siehe _find_wall_cluster + BooleanUnion. + """ src, meta = _find_source(doc, wall_id) if src is None or meta is None or meta.get("type") != "wand_axis": return [] - return [wall_id] + 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): + """Sucht den naechsten Chain-Partner an cur_pt. + - 1 Partner: klassische Eck-/Linien-Fortsetzung + - 2+ Partner (3-way+ Joint, z.B. T-Joint via Smart-Split): suchen + den EINEN collinearen Partner = durchgehende Wand. So mergen + gleich-material Wand-Hälften zu einer Chain-Polyline; der + perpendicular T-Stem wird im Regen separat T-mitered.""" + 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) == 0: return None + # Meine Tangente am Joint + my_geom = geom_by_id.get(cur_id) + if my_geom is None: return None + # cur_pt ist mein Start oder Ende — Tangente entsprechend + ms = my_geom.PointAtStart + at_start = (abs(ms.X - cur_pt.X) < 1e-4 and abs(ms.Y - cur_pt.Y) < 1e-4) + my_tan_raw = my_geom.TangentAtStart if at_start else my_geom.TangentAtEnd + if len(partners) == 1: + p_wid, p_end = partners[0] + else: + # 3+ Joint: filter auf collineare Fortsetzungen + collinear = [] + for p_wid, p_end in partners: + pg = geom_by_id.get(p_wid) + if pg is None: continue + pt = pg.TangentAtStart if p_end == "start" else pg.TangentAtEnd + # Kreuzprodukt (z-Komponente) ~ 0 → parallel oder anti-parallel + crossz = my_tan_raw.X * pt.Y - my_tan_raw.Y * pt.X + if abs(crossz) < 1e-4: + collinear.append((p_wid, p_end)) + if len(collinear) != 1: return None + p_wid, p_end = collinear[0] + if not _wand_chain_compat(meta_by_id.get(cur_id), + meta_by_id.get(p_wid), doc=doc): + 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 + 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): @@ -2403,6 +3170,116 @@ def _wall_offsets_from_referenz(dicke, referenz): return (+half, dicke) # mid +def _make_wall_centerline(axis_curve, dicke, referenz): + """Liefert die Centerline-Curve (= Mitte des Volumens) wenn die Achse + NICHT die Mitte ist. None bei referenz='mid' (Achse ist Centerline).""" + if referenz == "mid": return None + start_off, d_total = _wall_offsets_from_referenz(dicke, referenz) + d_left = start_off + d_right = start_off - d_total + center_off = (d_left + d_right) / 2.0 + if abs(center_off) < 1e-9: return None + plane = rg.Plane.WorldXY + res = _offset_curve(axis_curve, plane, center_off, 0.001) + if not res or len(res) == 0: return None + return res[0] + + +def _make_wall_outline(axis_curve, dicke, referenz): + """Liefert die geschlossene Outline-Curve (Viereck-Polylinie) des Wand- + Volumens in der XY-Ebene. Perpendikulare Caps an Start/End. None wenn + Offset fehlschlaegt.""" + start_off, d_total = _wall_offsets_from_referenz(dicke, referenz) + d_left = start_off + d_right = start_off - d_total + plane = rg.Plane.WorldXY + tol = 0.001 + left = _offset_curve(axis_curve, plane, float(d_left), tol) + right = _offset_curve(axis_curve, plane, float(d_right), tol) + if not left or not right: return None + L = left[0] + R = right[0].DuplicateCurve() + try: + R.Reverse() + cap_start = rg.LineCurve(L.PointAtEnd, R.PointAtStart) + cap_end = rg.LineCurve(R.PointAtEnd, L.PointAtStart) + joined = rg.Curve.JoinCurves([L, cap_start, R, cap_end], tol) + except Exception: + return None + if not joined or len(joined) == 0: return None + return joined[0] + + +def _regen_wall_lines(doc, wall_id, in_cluster=False): + """Stale wand_centerline + wand_outline-Objekte loeschen + neue addieren. + Beide als LOCKED Curves auf der Referenzen-Sublayer. + + - Centerline (dashed): nur wenn ref != 'mid' (Achse = Centerline sonst) + - Outline (solid): nur fuer SOLO Waende — Cluster/Chain-Member werden + ueber den merged Brep dargestellt; eigene Outlines wuerden visuell + ueberlappen und verwirren.""" + for obj in list(doc.Objects): + try: + t = obj.Attributes.GetUserString(_KEY_TYPE) or "" + wid = obj.Attributes.GetUserString(_KEY_ID) or "" + if t in ("wand_centerline", "wand_outline") and wid == wall_id: + doc.Objects.Delete(obj.Id, True) + except Exception: pass + src, meta = _find_source(doc, wall_id) + if src is None or meta is None or meta.get("type") != "wand_axis": + return + axis_curve = src.Geometry + if not isinstance(axis_curve, rg.Curve): return + try: dicke = float(meta.get("dicke", 0)) + except Exception: dicke = 0 + if dicke <= 0: return + referenz = meta.get("referenz", "mid") + g = _geschoss_by_id(doc, meta["geschoss"]) + geschoss_name = g.get("name", "EG") if g else "EG" + layer_idx = _ensure_layer(doc, _layer_path_referenz(doc, geschoss_name)) + + def _build_attrs(type_str, dashed=False): + attrs = Rhino.DocObjects.ObjectAttributes() + attrs.LayerIndex = layer_idx + attrs.Mode = Rhino.DocObjects.ObjectMode.Locked + if dashed: + try: + lt_idx = doc.Linetypes.Find("Dashed", True) + if isinstance(lt_idx, tuple): + lt_idx = lt_idx[0] if lt_idx else -1 + if lt_idx is not None and lt_idx >= 0: + attrs.LinetypeIndex = lt_idx + attrs.LinetypeSource = ( + Rhino.DocObjects.ObjectLinetypeSource.LinetypeFromObject) + except Exception: pass + _attach_meta(attrs, wall_id, type_str, + meta["geschoss"], dicke, + meta.get("uk_override", ""), meta.get("ok_override", ""), + referenz) + return attrs + + if referenz != "mid": + cl = _make_wall_centerline(axis_curve, dicke, referenz) + if cl is not None: + try: doc.Objects.AddCurve(cl, _build_attrs("wand_centerline", + dashed=True)) + except Exception as ex: + print("[ELEMENTE] AddCurve centerline:", ex) + + if not in_cluster: + outline = _make_wall_outline(axis_curve, dicke, referenz) + if outline is not None: + try: doc.Objects.AddCurve(outline, _build_attrs("wand_outline", + dashed=False)) + except Exception as ex: + print("[ELEMENTE] AddCurve outline:", ex) + + +# Alias fuer Backwards-Compat — alte Callsites benutzen den alten Namen. +def _regen_wall_centerline(doc, wall_id): + _regen_wall_lines(doc, wall_id, in_cluster=False) + + def _make_volume_geometry(axis_curve, dicke, uk, ok, referenz="mid", miter_start=None, miter_end=None): """Solide Wand — duenner Wrapper um _make_wall_layer_brep mit Offsets @@ -3575,6 +4452,44 @@ def _wall_group_index(doc, wall_id, create_if_missing=True): return grp_idx +def _strip_wall_auto_group(doc, wall_id): + """Entfernt die Achse einer Wand aus ihrer Auto-Group (alte + `_add_to_wall_group`-Memberships). User-gemachte Gruppen mit anderen + Objekten bleiben erhalten. Wird beim Regen aufgerufen damit stale Auto- + Groups (= Axis+Volume zusammen) verschwinden und Click-auf-Linie wirklich + nur die Linie selektiert.""" + axis_obj = _find_axis(doc, wall_id) + if axis_obj is None: return + try: + grp_list = axis_obj.Attributes.GetGroupList() + except Exception: return + if grp_list is None or len(grp_list) == 0: return + auto_groups = [] + for gi in grp_list: + members = doc.Groups.GroupMembers(int(gi)) + if members is None: continue + # Auto-Group erkennen: enthaelt nur DOSSIER-eigene Wand-Objekte + # (wand_axis + ggf. wand_volume), nichts Fremdes. + is_auto = True + for mo in members: + try: + t = mo.Attributes.GetUserString("dossier_type") or "" + except Exception: t = "" + if t not in ("wand_axis", "wand_volume"): + is_auto = False; break + if is_auto: + auto_groups.append(int(gi)) + if not auto_groups: return + try: + new_attrs = axis_obj.Attributes.Duplicate() + for gi in auto_groups: + try: new_attrs.RemoveFromGroup(gi) + except Exception: pass + doc.Objects.ModifyAttributes(axis_obj, new_attrs, True) + except Exception as ex: + print("[ELEMENTE] strip auto-group:", ex) + + def _add_to_wall_group(doc, obj_id, wall_id): """Fuegt obj_id (z.B. neu erzeugtes wand_volume) in die Group der Wand (axis+volumes). UX: damit selektiert ein Klick beide → kein ChooseOne- @@ -7068,11 +7983,12 @@ def _regenerate_element(doc, element_id): return _regenerate_element(doc, parent_id) return False geom = src_obj.Geometry - # Joint-Cache vor jedem Wand-Regen invalidieren — sonst kann eine alte - # Cache-Variante (z.B. von vor einem Replace) noch Stale-T-Junctions zeigen. - # Performance: nur O(N) walls beim naechsten _collect_wall_joints-Call, - # cache-hit innerhalb des einzelnen Regens bleibt erhalten. - if meta.get("type") == "wand_axis": + # Joint-Cache nur dann pro-Regen invalidieren wenn NICHT in einem Batch + # (Batch invalidiert einmal am Anfang). Vermeidet O(N²) Cache-Rebuilds + # bei Idle-Batches mit vielen Walls — sonst triggert Rhino's Technical- + # Drawing-Analyse den Wireframe-Fallback. + if (meta.get("type") == "wand_axis" + and not sc.sticky.get("_dossier_regen_batch_active")): _invalidate_joints_cache(meta.get("geschoss")) # Stuetze hat Point-Geometrie, alle anderen Source-Typen sind Curves if meta["type"] == "stuetze_point": @@ -7113,11 +8029,45 @@ 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. + # Cluster-Detection ZUERST: BFS ueber alle same-material verbundenen + # Waende. Wenn der Cluster verzweigt ist (3+ Waende am Joint), nutze + # BooleanUnion statt linearem Polyline-Chain. Sonst (linear oder solo) + # faellt es durch zur Chain-Logik unten. + cluster_ids = set() + try: + cluster_ids = _find_wall_cluster(doc, element_id) + except Exception as ex: + print("[ELEMENTE] cluster detect:", ex) + is_layered_meta = bool(meta.get("wand_layered", False)) + if (len(cluster_ids) > 1 and not is_layered_meta + and not _is_linear_chain(doc, cluster_ids)): + anchor = sorted(cluster_ids)[0] + if anchor != element_id: + # Non-Anchor: stale 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] cluster anchor regen:", ex) + # Covered-Check: hat Anchor uns ins gemeinsame Brep aufgenommen? + 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: + return True + # Nicht covered → fallthrough zu chain/solo + else: + # ANCHOR: Cluster-Union bauen + if _regen_cluster_anchor(doc, element_id, cluster_ids, meta): + return True + # Fallback zu chain/solo wenn Union fehlschlaegt + + # Chain-Detection (linear): wenn diese Wand mit Nachbarn (gleiche + # Geometrie/Material/Hoehe, 2-Wall-Joints) zu einem Polyline-Chain + # gehoert, baut nur die Anchor-Wand das gemeinsame Polyline-Volume. chain_ids = [] try: chain_ids = _find_wall_chain(doc, element_id) @@ -7214,7 +8164,8 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name if len(partners_s) == 1: p_wid, p_end, other_out = partners_s[0] miter_start = _resolve_corner_miter( - doc, meta, p_s, out_s, p_wid, p_end, other_out) + doc, meta, p_s, out_s, p_wid, p_end, other_out, + my_end="start") elif len(partners_s) == 0: tj = _detect_t_junction(doc, meta["geschoss"], element_id, p_s, @@ -7223,6 +8174,18 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name _oid, b_tan, b_dicke = tj tm = _t_junction_miter(p_s, out_s, b_tan, b_dicke) if tm is not None: miter_start = tm + else: + # 3+ Joint: ich bin T-Stem wenn meine Tangente NICHT + # collinear mit zwei collinearen Partner-Tangenten ist. + # Eigene Tangente (gerichtet aus Body raus) am Start = -TangentAtStart + _my_t = geom.TangentAtStart + _my_out_tan = rg.Vector3d(-_my_t.X, -_my_t.Y, 0) + through = _detect_through_wall_at( + doc, joints.get(key_s, []), _my_out_tan) + if through is not None: + b_tan, b_dicke = through + tm = _t_junction_miter(p_s, out_s, b_tan, b_dicke) + if tm is not None: miter_start = tm if out_e is not None: key_e = _pt_key(p_e) partners_e = [(wid, end, od) @@ -7231,7 +8194,8 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name if len(partners_e) == 1: p_wid, p_end, other_out = partners_e[0] miter_end = _resolve_corner_miter( - doc, meta, p_e, out_e, p_wid, p_end, other_out) + doc, meta, p_e, out_e, p_wid, p_end, other_out, + my_end="end") elif len(partners_e) == 0: tj = _detect_t_junction(doc, meta["geschoss"], element_id, p_e, @@ -7240,6 +8204,16 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name _oid, b_tan, b_dicke = tj tm = _t_junction_miter(p_e, out_e, b_tan, b_dicke) if tm is not None: miter_end = tm + else: + # 3+ Joint: T-Stem-Erkennung (analog start) + _my_t = geom.TangentAtEnd + _my_out_tan = rg.Vector3d(_my_t.X, _my_t.Y, 0) + through = _detect_through_wall_at( + doc, joints.get(key_e, []), _my_out_tan) + if through is not None: + b_tan, b_dicke = through + tm = _t_junction_miter(p_e, out_e, b_tan, b_dicke) + if tm is not None: miter_end = tm except Exception as ex: print("[ELEMENTE] wall joints:", ex) @@ -7529,12 +8503,22 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name wand_chain_members=(chain_ids if (chain_ids and len(chain_ids) > 1) else None)) try: - _vol_id = doc.Objects.AddBrep(lbrep, attrs) - # Auto-Group: axis + alle volume-breps dieser Wand → 1 Rhino-Group. - # Click selektiert beide, Delete loescht beide (axis-Delete kaskaden - # eh die Volumes mit). Kein ChooseOne-Dialog bei ueberlappenden Clicks. - _add_to_wall_group(doc, _vol_id, element_id) + doc.Objects.AddBrep(lbrep, attrs) + # KEIN Auto-Group: konsistent mit Cluster — Click auf Achse + # selektiert nur die Achse, Click ins Volumen swappt via Mouse- + # Handler auf naechste Achse, Click auf Rand/Eck selektiert + # Volumen. Delete-Kaskade Volumen-nach-Axis-Delete laeuft via + # _elemente_pending_source_cascade. except Exception as ex: print("[ELEMENTE] AddBrep wand layer:", ex) + # Stale Auto-Groups aus frueheren Versionen aufraeumen — Achse aus + # auto-erstellten Wand-Groups loesen. + try: + _is_chain = bool(chain_ids and len(chain_ids) > 1) + for _wid in (chain_ids if _is_chain else [element_id]): + _strip_wall_auto_group(doc, _wid) + _regen_wall_lines(doc, _wid, in_cluster=_is_chain) + except Exception as ex: + print("[ELEMENTE] strip/lines (chain):", ex) return True elif meta["type"] == "decke_outline": uk, ok = _resolve_decke_z(doc, meta["geschoss"], meta["dicke"], @@ -8768,6 +9752,9 @@ class ElementeBridge(panel_base.BaseBridge): continue if res != GetResult.Point: return first_pt = gp.Point() + # Auto-T-Snap: wenn paar cm neben bestehender Wand-Achse → snappen + _snap = _t_snap_to_wand_axis(doc, first_pt) + if _snap is not None: first_pt = _snap break except Exception as ex: print("[ELEMENTE] wand first-point:", ex); return @@ -8851,6 +9838,9 @@ class ElementeBridge(panel_base.BaseBridge): if res == GetResult.Nothing: break if res != GetResult.Point: break pt = gp.Point() + # Auto-T-Snap auf bestehende Wand-Achsen + _snap = _t_snap_to_wand_axis(doc, pt) + if _snap is not None: pt = _snap if pt.DistanceTo(points[-1]) < tol and ends_after != 1: break points.append(pt) @@ -14318,22 +15308,21 @@ _SELECT_BUSY = "_elemente_select_busy" # mit (Rahmen+Sims+Fluegel bei Oeffnungen, Stufen bei Treppen, Schichten bei # Wand/Decke). Source-Achse/Punkt kriegt zusaetzlich Grips zum Editieren. _PAIRED_VOLUME_TYPES = ( - "wand_volume", "decke_volume", "dach_volume", + "wand_volume", # → Achse(n) + Centerline + Outline mit-selektieren + "decke_volume", "dach_volume", "oeffnung_volume", "treppe_volume", "treppe_2d_symbol", "stuetze_volume", "traeger_volume", # Raum-Fuellung haengt an der Outline und soll mitwandern → pairing aktiv. - # raum_stamp ABSICHTLICH NICHT hier: Klick auf den Stempel-Text soll nur - # den Text selektieren, damit der User ihn unabhaengig verschieben kann - # (Position bleibt via dx,dy-UserString persistent, siehe - # _sync_raum_stamps_to_source). Klick auf die Outline pairt weiter alles - # drei via _PAIRED_SOURCE_TYPES + _find_all_volumes. "raum_fill", ) _PAIRED_SOURCE_TYPES = ( - "wand_axis", "decke_outline", "dach_outline", + "decke_outline", "dach_outline", "oeffnung_point", "treppe_axis", "stuetze_point", "traeger_axis", "raum_outline", + # wand_axis ABSICHTLICH NICHT hier: Klick auf Wand-Linie soll NUR die + # Linie selektieren (nicht das ganze Volume). Umgekehrt aber schon: Klick + # auf wand_volume sammelt Achsen+Centerlines+Outlines (siehe _collect_partners). ) @@ -14386,6 +15375,25 @@ def _collect_partners(doc, rhino_objects): for v in _find_all_volumes(doc, meta["id"]): if str(v.Id) != str(obj.Id): _add_partner(v) + # Wand-spezifisch: Cluster-Members + alle wand_centerline + + # wand_outline Curves mit-selektieren (= "alle Referenzlinien"). + if t == "wand_volume": + wall_ids = [meta["id"]] + for c in (meta.get("wand_chain_members") or []): + if c and c not in wall_ids: wall_ids.append(c) + for wid in wall_ids: + if wid != meta["id"]: + s, _ = _find_source(doc, wid) + if s is not None: + _add_partner(s); _add_source(s) + for lo in doc.Objects: + try: + lt = lo.Attributes.GetUserString(_KEY_TYPE) or "" + lwid = lo.Attributes.GetUserString(_KEY_ID) or "" + if (lt in ("wand_centerline", "wand_outline") + and lwid == wid): + _add_partner(lo) + except Exception: pass elif t in _PAIRED_SOURCE_TYPES: # Klick auf Source → ALLE Volumen (alle Schichten) mitsammeln. for v in _find_all_volumes(doc, meta["id"]): @@ -14587,6 +15595,33 @@ def _migrate_plangrafik_60_to_80_once(doc): except Exception: pass +def _migrate_strip_wall_auto_groups_once(doc): + """One-shot pro Dokument: alle wand_axis-Achsen aus ihren Auto-Groups + raus (= alte Memberships von _add_to_wall_group). Group-Inhalt-Check: + enthaelt NUR DOSSIER wand_axis+wand_volume → Auto-Group → stripten.""" + if doc is None: return + try: key = "_dossier_wall_groups_strip_v1_" + str(doc.RuntimeSerialNumber) + except Exception: key = "_dossier_wall_groups_strip_v1_default" + if sc.sticky.get(key): return + sc.sticky[key] = True + n = 0 + try: + for obj in list(doc.Objects): + try: + t = obj.Attributes.GetUserString(_KEY_TYPE) or "" + except Exception: t = "" + if t != "wand_axis": continue + wid = obj.Attributes.GetUserString(_KEY_ID) or "" + if not wid: continue + _strip_wall_auto_group(doc, wid) + n += 1 + except Exception as ex: + print("[ELEMENTE] strip-wall-groups migration:", ex) + if n > 0: + print("[ELEMENTE] Strip-Wall-Auto-Groups Migration: {} Wand-Achsen " + "geprueft".format(n)) + + def _migrate_layer_caps_once(doc): """One-shot pro Doc: legacy CAPS-LOCK Layernamen ('RAEUME', 'TREPPEN') auf Capital-Case ('Räume', 'Treppen') umstellen — restlichen DOSSIER- @@ -14816,6 +15851,9 @@ def _on_idle_selection(sender, e): _migrate_plangrafik_60_to_80_once(doc) # One-shot: CAPS-LOCK Ebenennamen (RAEUME, TREPPEN) → Capital-Case _migrate_layer_caps_once(doc) + # One-shot: alte Wand-Auto-Groups stripten (Axis war frueher mit Volume + # gegruppt — jetzt sollen Click auf Linie nur die Linie selektieren) + _migrate_strip_wall_auto_groups_once(doc) # Oeffnungen-Tree in dossier_ebenen anlegen falls noch nicht vorhanden # (sonst erscheinen die neuen Sublayer nicht im Ebenen-Panel). _ensure_oeff_ebenen_once(doc) @@ -14837,6 +15875,13 @@ def _on_idle_selection(sender, e): ids = list(pending) pending.clear() sc.sticky[_REGEN_BUSY] = True + # Joint-Cache EINMAL fuer das ganze Batch invalidieren (statt pro + # Wand-Regen). Verhindert O(N²) Rebuilds wenn viele Wand-Regens + # gequeued sind — vermeidet Technical-Drawing-Timeout/Wireframe- + # Fallback. Innerhalb des Batches rebuilds _collect_wall_joints + # genau einmal und alle nachfolgenden Regens nutzen denselben Cache. + sc.sticky["_dossier_regen_batch_active"] = True + _invalidate_joints_cache(None) # Bulk-Performance: ein einziger Undo-Record fuer alle queued # Regens + Redraw nur am Ende (statt einem pro AddBrep/Delete). undo_serial = doc.BeginUndoRecord( @@ -14854,6 +15899,7 @@ def _on_idle_selection(sender, e): try: doc.EndUndoRecord(undo_serial) except Exception: pass sc.sticky[_REGEN_BUSY] = False + sc.sticky["_dossier_regen_batch_active"] = False try: doc.Views.Redraw() except Exception: pass try: b._send_state() @@ -16288,6 +17334,12 @@ def _install_listeners(bridge): schnitt_grips.install_handlers() except Exception as ex: print("[ELEMENTE] schnitt_grips install:", ex) + # Cluster-Volume Selection-Swap: Klick auf gemerged Wand-Brep selektiert + # die naechste Cluster-Member-Achse (UX fuer Multi-Wand-Cluster). + try: + install_cluster_select_handler() + except Exception as ex: + print("[ELEMENTE] cluster-select install:", ex) def _bridge_factory():