Files
DOSSIER/rhino/aliases/cmd/smart_join.py
T
karim 118bc51cc5 T-Junction Phase 2 + Layer-Smart-Join: Backbone-Drill bis Stahl-Layer-Far-Face
Phase-2-Fixes:
- Backbone-Ext berechnet bis MATCHING-Layer (Stahl) far-face, nicht bis
  Body-far-face. Beton drillt durch Stahl-Band, nicht durch Daemm/Putz.
- Case A/B detection via dot(out_dir, RhinoPerp). Rhino Curve.Offset
  benutzt (tan × +z) = (b_tan.Y, -b_tan.X), nicht (-b_tan.Y, b_tan.X).
- Backbone-axis-ext separat von _my_axis_ext: nur Backbone-Column
  extended, non-backbone columns stoppen am Snap.
- Through-only Mats (z.B. Daemm wo T-stem keinen hat) werden auch
  durchlaufen damit hoehere-prio backbone diese carven kann.
- Post-Carve Union: gleiche-Material-Pieces mergen (Backbone-Beton-Col
  + Through-Stahl-Band → T-Shape).
- BBox-overlap strict-Filter vor BoolDiff: touching coplanar faces
  ueberspringen, vermeidet Rhino BoolDiff "punch-through" Artefakte.
- _has_my_cols guard: KeyError beim consume von T-stem-Layern fuer
  through-only mats verhindert.

Layer-Smart-Join (smart_join.py):
- Neue _layer_join_attempt: 2 selektierte wand_volume Breps gleicher
  Material via BoolUnion mergen. Manueller Override fuer Edge-Cases
  wo auto Phase 2 nicht reicht.

Cluster-Volume Select-Handler (elemente.py):
- Alt-Click bypassed Cluster-Swap → User kann einzelne Layer-Breps
  direkt anwaehlen (= fuer Layer-Smart-Join).
2026-06-01 21:54:02 +02:00

527 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#! 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 _layer_join_attempt(doc, wand_volumes):
"""Layer-Level Join: Versuch BoolUnion zwischen 2 wand_volume Breps.
Wenn beide gleiches Material haben + sich beruehren oder ueberlappen
→ mergen zu einem Brep. Behaelt UserStrings (= wand_id, material,
layer_idx) vom ersten Brep."""
if len(wand_volumes) != 2: return False
br0 = wand_volumes[0].Geometry
br1 = wand_volumes[1].Geometry
if not isinstance(br0, rg.Brep) or not isinstance(br1, rg.Brep):
return False
# Material-Check via UserStrings (= dossier_element_material)
mat0 = wand_volumes[0].Attributes.GetUserString(
"dossier_layer_material") or ""
mat1 = wand_volumes[1].Attributes.GetUserString(
"dossier_layer_material") or ""
if mat0 and mat1 and mat0 != mat1:
print("[SMART-JOIN] Layer-Join: Materialien unterschiedlich "
"({} vs {}), skip.".format(mat0, mat1))
return False
try:
union = rg.Brep.CreateBooleanUnion([br0, br1], 0.01)
except Exception as ex:
print("[SMART-JOIN] Layer-Join BoolUnion exc:", ex)
return False
if not union or len(union) == 0:
print("[SMART-JOIN] Layer-Join: Breps overlappen nicht "
"(BoolUnion returned None/empty)")
return False
if len(union) > 1:
print("[SMART-JOIN] Layer-Join: Breps overlappen nicht (BoolUnion"
" returned {} pieces, brauche 1)".format(len(union)))
return False
# 1 merged Brep
merged = union[0]
if not merged.IsValid:
print("[SMART-JOIN] Layer-Join: merged Brep invalid")
return False
try: merged.MergeCoplanarFaces(0.01)
except Exception: pass
ur = doc.BeginUndoRecord("DOSSIER Layer-Join")
try:
ok = doc.Objects.Replace(wand_volumes[0].Id, merged)
if ok:
doc.Objects.Delete(wand_volumes[1].Id, True)
return True
return False
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
# Layer-Level Join: wenn GENAU 2 wand_volume Breps selektiert + keine
# wand_axis → versuche Layer-Join (= BoolUnion der zwei Layer-Breps).
_wand_vols = [o for o in sel
if (o.Attributes.GetUserString("dossier_element_type")
or "") == "wand_volume"]
_wand_axes = [o for o in sel
if (o.Attributes.GetUserString("dossier_element_type")
or "") == "wand_axis"]
if (len(_wand_vols) == 2 and len(_wand_axes) == 0
and len(sel) == 2):
print("[SMART-JOIN] Layer-Join attempt: 2 wand_volume Breps")
try:
if _layer_join_attempt(doc, _wand_vols):
doc.Views.Redraw()
print("[SMART-JOIN] Layer-Join: Breps zu einem gemerged")
return
except Exception as ex:
print("[SMART-JOIN] Layer-Join exc:", ex)
# 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 "<none>"
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()