#! python3 # -*- coding: utf-8 -*- # Smart-Join: bei geschlossenen Curves → BooleanUnion (innere Linien weg), # bei offenen Curves → normales _Join (Endpunkt-Verbindung). # Sicherheits-Filter: # A) Group by Layer + Object-Overrides (Color/Linetype/PlotWeight) + Fill — # nur Curves mit IDENTISCHEN visuellen Attributen werden gemerged. # C) Pre-Check Overlap — BooleanUnion liefert genauso viele Outputs wie # Inputs wenn nichts overlapt → dann KEINE Aktion, Curves bleiben. # Kombinierter Effekt: nur visuell zusammengehoerige UND tatsaechlich # ueberlappende Curves werden zu einer Outline vereint. import scriptcontext as sc import Rhino import Rhino.Geometry as rg import Rhino.DocObjects as rdoc def _attr_key(obj): """Tuple das definiert ob 2 Curves visuell identisch sind. Layer + Per-Object-Overrides (alles was ByObject nicht ByLayer ist) + Fill- State (Hatch-ID + No-Fill-Flag).""" a = obj.Attributes layer_idx = a.LayerIndex # Color: nur Object-Override unterscheidend, ByLayer ist gleich. col_key = ("layer",) try: if a.ColorSource == rdoc.ObjectColorSource.ColorFromObject: col_key = ("obj", a.ObjectColor.ToArgb()) except Exception: pass # Linetype lt_key = ("layer",) try: if a.LinetypeSource == rdoc.ObjectLinetypeSource.LinetypeFromObject: lt_key = ("obj", a.LinetypeIndex) except Exception: pass # PlotWeight pw_key = ("layer",) try: if a.PlotWeightSource == rdoc.ObjectPlotWeightSource.PlotWeightFromObject: pw_key = ("obj", float(a.PlotWeight)) except Exception: pass # Fill / Hatch via gestaltung-UserStrings fill_hatch = "" fill_source = "" no_fill = "" try: fill_hatch = a.GetUserString("ebenen_fill_hatch_id") or "" fill_source = a.GetUserString("ebenen_fill_source") or "" no_fill = a.GetUserString("ebenen_no_fill") or "" except Exception: pass # Fuer Gruppierung zaehlt: "hatte Fill ja/nein" + Quelle + No-Fill-Flag. fill_key = (bool(fill_hatch), fill_source, no_fill) 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_element_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_element_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 _find_nearest_other_wand_axis(doc, my_axis_obj, my_id, tol=1.0): """Findet die naechste andere wand_axis im Doc (innerhalb tol). Return das axis Object oder None.""" if my_axis_obj is None: return None g = my_axis_obj.Geometry if not isinstance(g, rg.Curve): return None bb = g.GetBoundingBox(True) if not bb.IsValid: return None best = None; best_d = tol for obj in doc.Objects: try: if obj.Attributes.GetUserString("dossier_element_type") != "wand_axis": continue wid = obj.Attributes.GetUserString("dossier_element_id") or "" if wid == my_id or not wid: continue except Exception: continue og = obj.Geometry if not isinstance(og, rg.Curve): continue try: # Mindest-Distanz: Endpunkte gegeneinander UND ClosestPoint d_min = float('inf') for ep in (g.PointAtStart, g.PointAtEnd): rc, t = og.ClosestPoint(ep) if rc: d = og.PointAt(t).DistanceTo(ep) if d < d_min: d_min = d for ep in (og.PointAtStart, og.PointAtEnd): rc, t = g.ClosestPoint(ep) if rc: d = g.PointAt(t).DistanceTo(ep) if d < d_min: d_min = d if d_min < best_d: best_d = d_min; best = obj except Exception: continue return best def _t_join_attempt(doc, sel): """T-Join: 2 OFFENE Kurven wobei der EINE Endpunkt der einen Kurve nahe (< 1m) auf der ANDEREN Kurve mitten landet (zwischen deren Endpunkten). Schiebt diesen Endpunkt exakt auf die andere Kurve. Die andere Kurve bleibt unveraendert. Auch 1-Wand-Modus: wenn nur 1 wand_axis selektiert, sucht automatisch die naechste andere Wand und snappt diese eine. Liefert True wenn ausgefuehrt.""" axes, generic = _walls_and_curves_from_sel(doc, sel) if len(axes) == 2 and len(generic) == 0: o1, o2 = axes[0], axes[1] elif len(axes) == 1 and len(generic) == 0: # 1-Wand-Modus: finde naechste andere wand_axis im Doc my_id = axes[0].Attributes.GetUserString("dossier_element_id") or "" other = _find_nearest_other_wand_axis(doc, axes[0], my_id, tol=1.0) if other is None: print("[SMART-JOIN] 1-Wand T-Join: keine Nachbar-Wand " "innerhalb 1m gefunden") return False o1 = axes[0]; o2 = other print("[SMART-JOIN] 1-Wand T-Join: snappe an Nachbar-Wand") 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_snap = 1.00 # 1 m Snap-Radius fuer T-Verbindung — generous damit # auch grosse Drift (z.B. wenn User auf Outline statt # Axis gesnappt hat = 30cm Wand-dicke) gefangen wird end_tol = 0.05 # 5cm: wenn closest-point nahe Endpunkt → eigentlich L candidates = [] debug_rows = [] # Pro Endpunkt der einen Kurve: ClosestPoint auf der ANDEREN Kurve for (a_obj, ac, b_obj, bc) in ((o1, c1, o2, c2), (o2, c2, o1, c1)): for end in (0, 1): ep = ac.PointAtStart if end == 0 else ac.PointAtEnd try: rc, t = bc.ClosestPoint(ep) if not rc: debug_rows.append(("axis_end={}".format(end), "ClosestPoint failed")) continue cp = bc.PointAt(t) d = cp.DistanceTo(ep) ps = bc.PointAtStart; pe = bc.PointAtEnd d_to_ps = cp.DistanceTo(ps) d_to_pe = cp.DistanceTo(pe) reason = None if d < 1e-6: reason = "schon gesnappt" elif d > tol_snap: reason = "zu weit ({:.3f} > {:.2f})".format(d, tol_snap) elif d_to_ps < end_tol: reason = "cp nahe Endpunkt-Start ({:.3f}<{:.2f}) → L-Join Sache".format(d_to_ps, end_tol) elif d_to_pe < end_tol: reason = "cp nahe Endpunkt-End ({:.3f}<{:.2f}) → L-Join Sache".format(d_to_pe, end_tol) debug_rows.append(("axis_end={} d={:.3f}".format(end, d), reason or "candidate")) if reason is None: candidates.append((d, a_obj, ac, end, cp)) except Exception as ex: debug_rows.append(("axis_end={}".format(end), "exc: {}".format(ex))) # Diagnostic alles ausgeben for tag, msg in debug_rows: print("[SMART-JOIN] T-Join check {}: {}".format(tag, msg)) if not candidates: return False # Naechster Endpunkt → der wird gesnappt candidates.sort(key=lambda x: x[0]) _d, a_obj, ac, end, cp = candidates[0] new_c = _replace_curve_endpoint(ac, end, cp) if new_c is None: return False ur = doc.BeginUndoRecord("DOSSIER T-Join") try: ok = doc.Objects.Replace(a_obj.Id, new_c) return bool(ok) finally: doc.EndUndoRecord(ur) 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 sel = list(doc.Objects.GetSelectedObjects(False, False)) if not sel: Rhino.RhinoApp.RunScript("_Join", False); return # Info-Hint (T-Join unterstuetzt 1-Wand-Modus, L-Join braucht 2) n_wand_axes = sum(1 for o in sel if (o.Attributes.GetUserString("dossier_element_type") or "") == "wand_axis") if n_wand_axes == 0 and any( (o.Attributes.GetUserString("dossier_element_type") or "") .startswith("wand_") for o in sel): print("[SMART-JOIN] keine Wand-Achse in Selection — selektiere die " "Wand-Linie oder das Wand-Volumen.") # T-Join: Endpunkt der einen Curve trifft mitten auf die andere → snap. # L-Join: beide Endpunkte werden zum Schnittpunkt der verlaengerten Linien # gezogen. T zuerst probieren (= spezifischer), dann L als Fallback. if len(sel) >= 2: # Diagnostic: was sieht smart_join in der Selection? axes_dbg, generic_dbg = _walls_and_curves_from_sel(doc, sel) type_counts = {} for o in sel: try: t = o.Attributes.GetUserString("dossier_element_type") or "" wid_raw = o.Attributes.GetUserString("dossier_element_id") or "" geom_kind = type(o.Geometry).__name__ key = "{}|{}|wid={}".format(t, geom_kind, "yes" if wid_raw else "no") type_counts[key] = type_counts.get(key, 0) + 1 except Exception: pass print("[SMART-JOIN] sel-detect: {} Wand-Achsen, {} generische Curves " "(sel total: {})".format(len(axes_dbg), len(generic_dbg), len(sel))) for k, n in type_counts.items(): print("[SMART-JOIN] {} × {}".format(n, k)) try: if _t_join_attempt(doc, sel): doc.Views.Redraw() print("[SMART-JOIN] T-Join: Endpunkt auf Achse gesnappt") return else: print("[SMART-JOIN] T-Join: kein passender Kandidat (zu weit " "weg oder am Endpunkt → L-Join Territory)") except Exception as ex: print("[SMART-JOIN] T-Join error:", ex) try: if _l_join_attempt(doc, sel): doc.Views.Redraw() print("[SMART-JOIN] L-Join: 2 Curves zu L verbunden") return else: print("[SMART-JOIN] L-Join: konnte nicht ausfuehren (parallel, " "schon verbunden, oder Geometrie ungueltig)") except Exception as ex: print("[SMART-JOIN] L-Join error:", ex) # Safety: wenn Wand-Achsen selektiert sind, NIE auf Standard-_Join fallen # — das wuerde mehrere Achsen zu einer Curve zusammenkleben und die Wand- # Verknuepfung zerstoeren (Source-Duplikat-Listener kapert die alte ID). has_wand_axis = any( obj.Attributes.GetUserString("dossier_element_type") == "wand_axis" for obj in sel) if has_wand_axis: print("[SMART-JOIN] Wand-Achsen selektiert: T-Join/L-Join hat nicht " "gegriffen (zu viele Selektionen oder zu weit weg). Bitte " "GENAU 2 Waende selektieren die sich verbinden sollen, dann " "erneut Cmd+J.") return # Curves nach Closed/Open trennen closed_objs = [] has_non_closed = False for obj in sel: g = obj.Geometry if isinstance(g, rg.Curve) and g.IsClosed: closed_objs.append(obj) else: has_non_closed = True # Wenn nicht ALLE closed sind → einfach Standard-Join if has_non_closed or len(closed_objs) < 2: Rhino.RhinoApp.RunScript("_Join", False); return # Gruppieren nach (Layer + Attrs + Fill) groups = {} # key → [obj, obj, ...] for obj in closed_objs: try: k = _attr_key(obj) except Exception: k = ("ungroup", id(obj)) groups.setdefault(k, []).append(obj) # gestaltung fuer Fill-Re-Apply _g = None try: import gestaltung as _gmod; _g = _gmod except Exception as iex: print("[SMART-JOIN] gestaltung import:", iex) tol = doc.ModelAbsoluteTolerance ur = doc.BeginUndoRecord("DOSSIER Smart-Join (gruppiert)") n_merged_total = 0 n_groups_ops = 0 try: for key, objs in groups.items(): if len(objs) < 2: continue # einzelne Curve → nichts zu mergen try: curves = [o.Geometry for o in objs] result = rg.Curve.CreateBooleanUnion(curves, tol) except Exception as ex: print("[SMART-JOIN] BooleanUnion in Gruppe fehlgeschlagen:", ex) continue if not result: continue # C) Pre-Check Overlap: wenn result-Anzahl gleich input-Anzahl # ist, gab's keinen tatsaechlichen Overlap → Gruppe nicht # anfassen. if len(result) >= len(objs): continue # Tatsaechlich gemerged → replace attrs_template = objs[0].Attributes.Duplicate() # Fill-Key clearen damit _apply_ebene_fill nicht "schon gefuellt" # zurueckgibt try: attrs_template.SetUserString("ebenen_fill_hatch_id", "") except Exception: pass any_had_fill = bool(key[4][0]) # fill_key[0] = had-fill bool new_ids = [] for crv in result: nid = doc.Objects.AddCurve(crv, attrs_template) if nid: new_ids.append(nid) for o in objs: try: doc.Objects.Delete(o.Id, True) except Exception: pass # Fill nachziehen wenn Inputs welche hatten if any_had_fill and _g is not None: for nid in new_ids: try: nobj = doc.Objects.FindId(nid) if nobj is not None: _g._apply_ebene_fill(doc, nobj) except Exception as fex: print("[SMART-JOIN] fill-apply:", fex) n_merged_total += (len(objs) - len(result)) n_groups_ops += 1 finally: doc.EndUndoRecord(ur) if n_groups_ops == 0: print("[SMART-JOIN] Nichts zu mergen — keine Curves overlappen " "(oder verschiedene Attribute/Layer)") else: doc.Views.Redraw() print("[SMART-JOIN] {} Gruppe(n) bearbeitet, {} Curve(s) zu Union vereint" .format(n_groups_ops, n_merged_total)) _run()