18443b60c3
- Alt+Click bypass im Cluster-Volume-Select-Handler raus.
- _layer_join_attempt + dJoin-Detection in smart_join.py raus.
Phase 2 automatic handling deckt die Use-Cases ab. Falls Manual-Layer-
Merge spaeter doch noetig: commit 118bc51 hat den Code als Referenz.
459 lines
19 KiB
Python
459 lines
19 KiB
Python
#! 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 "<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()
|