Files
DOSSIER/rhino/aliases/cmd/smart_join.py
T
karim 250853d7d0 Waende: Cluster-Boolean-Union + Click-UX + Outline/Centerline + Smart-L-Join
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)
2026-05-30 16:12:48 +02:00

291 lines
11 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_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
sel = list(doc.Objects.GetSelectedObjects(False, False))
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
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()