Files
DOSSIER/rhino/aliases/cmd/smart_join.py
T
karim 3e54fa46a6 smart_join: UserString-Key war FALSCH ("dossier_type" statt "dossier_element_type")
Bug: in _walls_and_curves_from_sel + safety check + diagnostic wurde
"dossier_type" als UserString-Key gelesen, aber der echte Key (definiert
in elemente.py via _KEY_TYPE) ist "dossier_element_type".

Effekt: kein einziges Objekt wurde als wand_axis/wand_volume erkannt.
ALLES landete im "elif t == '':" Branch (= generic curves).

Solid L-Join funktionierte per ZUFALL: bei Solid-Wand (axis + outline +
volume) sind nur 1 Curve open (axis); outline ist closed rectangle. Bei
2 Solid-Waenden waren also 2 offene Curves in generic → L-Join fand die
2 vermeintlich generic Curves (= eigentlich Achsen).

Bei Layered scheiterte es: 2 Achsen + 2 Centerlines = 4 offene Curves im
generic. L-Join Bedingung "len(generic) == 2" nicht erfuellt → silent
return → Fallthrough zu _Join.

Fix: alle 4 Vorkommen auf "dossier_element_type" gefixt. Jetzt erkennt
smart_join Waende richtig, dedupliziert per wall_id, und T-Join/L-Join/
Safety-Check funktionieren wie geplant.
2026-05-31 12:49:42 +02:00

386 lines
15 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 _t_join_attempt(doc, sel):
"""T-Join: 2 OFFENE Kurven wobei der EINE Endpunkt der einen Kurve
nahe (< 20cm) auf der ANDEREN Kurve mitten landet (zwischen deren
Endpunkten). Schiebt diesen Endpunkt exakt auf die andere Kurve.
Die andere Kurve bleibt unveraendert.
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) == 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 = 0.20 # 20 cm Snap-Radius fuer T-Verbindung
end_tol = 0.05 # 5cm: wenn closest-point nahe Endpunkt → eigentlich L
candidates = []
# 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: continue
cp = bc.PointAt(t)
d = cp.DistanceTo(ep)
# Skip wenn schon snapped oder zu weit
if d < 1e-6 or d > tol_snap: continue
# Skip wenn cp nahe einem Endpunkt von bc — das ist L-Join Territory
ps = bc.PointAtStart; pe = bc.PointAtEnd
if cp.DistanceTo(ps) < end_tol or cp.DistanceTo(pe) < end_tol:
continue
candidates.append((d, a_obj, ac, end, cp))
except Exception: continue
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
# 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()