+ {tab === 'setup' &&
}
{tab === 'rhino' &&
}
{tab === 'view' &&
}
{tab === 'ebenen' &&
}
@@ -774,6 +783,148 @@ function SettingsDialog({ initialTab = 'rhino', onClose }) {
)
}
+function SetupSettings() {
+ const [running, setRunning] = useState(false)
+ const [result, setResult] = useState(null) // { steps, overall_ok }
+ const [error, setError] = useState(null)
+ const [rhinoBusy, setRhinoBusy] = useState(false)
+ const [status, setStatus] = useState(null) // { plugin_installed, startup_cmd_set, layout_installed, initialized }
+ const [rhinoApp, setRhinoApp] = useState('')
+ const [startupPath, setStartupPath] = useState('')
+
+ // Live-Check: ob Rhino laeuft (Init kann nicht laufen wenn ja)
+ useEffect(() => {
+ let cancelled = false
+ const tick = () => {
+ invoke('is_rhino_running')
+ .then(v => { if (!cancelled) setRhinoBusy(!!v) })
+ .catch(() => {})
+ }
+ tick()
+ const id = setInterval(tick, 2000)
+ return () => { cancelled = true; clearInterval(id) }
+ }, [])
+
+ // Initialer State-Check + erkannte Rhino-Konfig
+ const refreshStatus = useCallback(() => {
+ invoke('check_dossier_initialized').then(setStatus).catch(() => {})
+ }, [])
+ useEffect(() => {
+ refreshStatus()
+ invoke('read_settings').then(s => setRhinoApp(s?.rhinoApp || 'Rhinoceros 8')).catch(() => {})
+ invoke('get_default_plugin_startup_path').then(setStartupPath).catch(() => {})
+ }, [refreshStatus])
+
+ const runInit = async () => {
+ setRunning(true); setError(null); setResult(null)
+ try {
+ const r = await invoke('dossier_init')
+ setResult(r)
+ refreshStatus()
+ } catch (e) {
+ setError(typeof e === 'string' ? e : (e?.message || String(e)))
+ } finally {
+ setRunning(false)
+ }
+ }
+
+ const dot = (ok) => (
+
+ )
+
+ return (
+
+
DOSSIER einrichten
+
+ Setzt DOSSIER auf einem frischen Mac komplett auf: installiert das C#-Plugin in Rhino via Yak,
+ traegt den Python-Bootstrap als Startup-Command ein, und kopiert das DOSSIER-Window-Layout in Rhinos
+ Workspaces-Folder. Idempotent — kann mehrfach ausgefuehrt werden.
+
+
+ {/* Erkannte Konfiguration */}
+
+
Erkannte Konfiguration:
+
+ Rhino-App:
+ {rhinoApp || '(nicht gesetzt)'}
+ startup.py:
+ {startupPath || '(nicht gefunden)'}
+
+
+ (Aendern unter Settings → Rhino)
+
+
+
+ {/* Aktueller Install-Status (live) */}
+ {status && (
+
+
Status:
+
{dot(status.plugin_installed)}DOSSIER-Plugin (.rhp) installiert
+
{dot(status.startup_cmd_set)}Python-Bootstrap in Rhino-StartupCommands
+
{dot(status.layout_installed)}Window-Layout in Rhino-Workspaces
+
+ )}
+
+
+ Hinweis: Rhino muss waehrend des Setups geschlossen sein.
+
+
+
+
+ {running ? 'Setup laeuft…' : 'Setup starten'}
+
+ {rhinoBusy && (
+
+ Rhino laeuft — bitte beenden.
+
+ )}
+
+
+ {result && (
+
+ {result.steps.map(s => (
+
+
+ {s.status === 'ok' ? '✓' : '✗'}
+
+
+
{s.label}
+
+ {s.detail}
+
+
+
+ ))}
+
+ {result.overall_ok
+ ? '✓ Alle Schritte erfolgreich. Rhino oeffnen — Plugin laedt bei erstem dWall/dDoor/...-Aufruf, startup.py bootstrappt automatisch.'
+ : '✗ Mindestens ein Schritt ist fehlgeschlagen. Details oben.'}
+
+
+ )}
+
+ {error && (
+
{error}
+ )}
+
+ )
+}
+
function TabBtn({ active, onClick, children }) {
return (
= 2:
+ if pl[0].DistanceTo(pl[pl.Count - 1]) > 1e-9:
+ pl.Add(pl[0])
+ return rg2.PolylineCurve(pl)
+ return None
+ # Generic: erst MakeClosed (closed wenn Endpunkte innerhalb tol)
+ try:
+ if crv.MakeClosed(1e-6): return crv
+ except Exception: pass
+ # Fallback: Lueckensegment einfuegen + joinen
+ line = rg2.LineCurve(crv.PointAtEnd, crv.PointAtStart)
+ joined = rg2.Curve.JoinCurves([crv, line], 1e-6)
+ if joined and len(joined) > 0 and joined[0].IsClosed:
+ return joined[0]
+ except Exception as ex:
+ print("[PIPETTE] force-close:", ex)
+ return None
+
+
+def _apply_pending(doc, new_obj, pending):
+ """Wendet pending state auf das neu erzeugte Objekt an."""
+ import Rhino.Geometry as rg2
+ import System
+ # Close-Erzwingen wenn Source geschlossen war — Polyline-Command erzeugt
+ # standardmaessig offene Curves; Pipette soll den Closed-State erhalten.
+ if pending.get("src_closed"):
+ try:
+ crv = new_obj.Geometry
+ if isinstance(crv, rg2.Curve) and not crv.IsClosed:
+ closed = _force_close_curve(crv)
+ if closed is not None:
+ if doc.Objects.Replace(new_obj.Id, closed):
+ ref = doc.Objects.FindId(new_obj.Id)
+ if ref is not None: new_obj = ref
+ print("[PIPETTE] Polyline auto-geschlossen (Source war closed)")
+ except Exception as ex:
+ print("[PIPETTE] close-replace:", ex)
+ # Linetype + PlotWeight overrides
+ try:
+ na = new_obj.Attributes.Duplicate()
+ if pending["linetype_source"] == int(rdoc.ObjectLinetypeSource.LinetypeFromObject):
+ na.LinetypeSource = rdoc.ObjectLinetypeSource.LinetypeFromObject
+ na.LinetypeIndex = pending["linetype_idx"]
+ if pending["plot_weight_source"] == int(rdoc.ObjectPlotWeightSource.PlotWeightFromObject):
+ na.PlotWeightSource = rdoc.ObjectPlotWeightSource.PlotWeightFromObject
+ na.PlotWeight = pending["plot_weight"]
+ # UserStrings 1:1 kopieren
+ for k, v in pending["user_strings"].items():
+ try: na.SetUserString(k, v)
+ except Exception: pass
+ doc.Objects.ModifyAttributes(new_obj, na, True)
+ except Exception as ex:
+ print("[PIPETTE] apply-attrs:", ex)
+
+ # Per-Object Custom-Hatch: nachbauen wenn Source einen hatte UND
+ # der neue Curve closed ist
+ hp = pending.get("hatch_props")
+ if hp is None: return
+ try:
+ crv = new_obj.Geometry
+ if not isinstance(crv, rg2.Curve) or not crv.IsClosed: return
+ tol = doc.ModelAbsoluteTolerance
+ hatches = rg2.Hatch.Create(crv, hp["pattern_idx"],
+ hp["rotation"], hp["scale"], tol)
+ if not hatches or len(hatches) == 0: return
+ ha = rdoc.ObjectAttributes()
+ ha.LayerIndex = hp["layer_idx"]
+ ha.ColorSource = rdoc.ObjectColorSource(hp["color_source"])
+ ha.ObjectColor = System.Drawing.Color.FromArgb(hp["color_argb"])
+ try:
+ ha.PlotColorSource = rdoc.ObjectPlotColorSource(hp["plot_color_source"])
+ ha.PlotColor = System.Drawing.Color.FromArgb(hp["plot_color_argb"])
+ except Exception: pass
+ if hp["linetype_source"] == int(rdoc.ObjectLinetypeSource.LinetypeFromObject):
+ ha.LinetypeSource = rdoc.ObjectLinetypeSource.LinetypeFromObject
+ ha.LinetypeIndex = hp["linetype_idx"]
+ ha.SetUserString("ebenen_fill_source", "object")
+ ha.SetUserString("ebenen_fill_owner", str(new_obj.Id))
+ new_hid = doc.Objects.AddHatch(hatches[0], ha)
+ if new_hid and new_hid != System.Guid.Empty:
+ # Cross-Link: Curve speichert Hatch-ID
+ ca = new_obj.Attributes.Duplicate()
+ ca.SetUserString("ebenen_fill_hatch_id", str(new_hid))
+ ca.SetUserString("ebenen_fill_source", "object")
+ doc.Objects.ModifyAttributes(new_obj, ca, True)
+ print("[PIPETTE] Per-Object Hatch uebernommen (Pattern={}, Scale={})"
+ .format(hp["pattern_idx"], hp["scale"]))
+ except Exception as ex:
+ print("[PIPETTE] hatch-replicate:", ex)
+
+
+def _auto_chain(doc, src):
+ """Startet das passende Draw-Command basierend auf Source-Typ."""
+ sa = src.Attributes
+ dtype = sa.GetUserString("dossier_element_type") or ""
+ geom = src.Geometry
+ geom_type = type(geom).__name__
+
+ # DOSSIER-BIM: triggere den Dispatcher
+ _DOSSIER_DRAW = {
+ "wand_axis": "wand",
+ "treppe_axis": "treppe",
+ "decke_outline": "decke",
+ "dach_outline": "dach",
+ "stuetze_point": "stuetze",
+ "traeger_axis": "traeger",
+ "oeffnung_point": None, # braucht parent-Wand-Kontext → skip auto-chain
+ "raum_outline": "raum",
+ }
+ if dtype in _DOSSIER_DRAW:
+ action = _DOSSIER_DRAW[dtype]
+ if action:
+ import os
+ _here = os.path.dirname(os.path.abspath(__file__))
+ wrapper = os.path.join(_here, action + ".py")
+ if os.path.exists(wrapper):
+ Rhino.RhinoApp.RunScript(
+ '_-RunPythonScript "{}"'.format(wrapper), False)
+ print("[PIPETTE] → starte DOSSIER {}".format(action))
+ return
+
+ # Standard-Rhino-Curves: detect Typ → entsprechendes Draw-Cmd
+ cmd = None
+ if geom_type == "LineCurve":
+ cmd = "_Line"
+ elif geom_type == "ArcCurve":
+ # ArcCurve mit voller Sweep = Kreis
+ try:
+ if geom.IsClosed: cmd = "_Circle"
+ else: cmd = "_Arc"
+ except Exception:
+ cmd = "_Arc"
+ elif geom_type == "PolylineCurve":
+ try:
+ ok, pl = geom.TryGetPolyline()
+ if ok and pl is not None and pl.IsClosed and pl.Count == 5:
+ # Geschlossen + 4 Segmente → vermutlich Rectangle
+ cmd = "_Rectangle"
+ else:
+ cmd = "_Polyline"
+ except Exception:
+ cmd = "_Polyline"
+ elif geom_type == "NurbsCurve":
+ cmd = "_Curve"
+ elif geom_type == "TextEntity":
+ cmd = "_Text"
+
+ if cmd:
+ Rhino.RhinoApp.RunScript(cmd, False)
+ print("[PIPETTE] → starte {}".format(cmd))
+
+
+_run()
diff --git a/rhino/aliases/cmd/raum.py b/rhino/aliases/cmd/raum.py
new file mode 100644
index 0000000..99c5822
--- /dev/null
+++ b/rhino/aliases/cmd/raum.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer Alias 'raum'. Importiert dossier_dispatch + ruft Action.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_dispatch
+dossier_dispatch.dispatch("raum")
diff --git a/rhino/aliases/cmd/section.py b/rhino/aliases/cmd/section.py
new file mode 100644
index 0000000..ea594bb
--- /dev/null
+++ b/rhino/aliases/cmd/section.py
@@ -0,0 +1,57 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Wrapper fuer dSection: interaktiver Schnitt-Pick (2 Punkte + Blickrichtung).
+# Defaults kommen aus Project-Settings.defaults; nach erfolgreicher
+# Erstellung wird der neue Schnitt als aktive Zeichnungs-Ebene gesetzt.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+import Rhino
+import scriptcontext as sc
+
+doc = Rhino.RhinoDoc.ActiveDoc
+if doc is None:
+ print("[SECTION] kein aktives Dokument")
+else:
+ try:
+ import schnitte
+ # Defaults aus Project-Settings; Fallback auf hartkodierte Werte.
+ defaults = {
+ "depthBack": 8.0, "heightMin": -1.0, "heightMax": 12.0,
+ "cutAtLine": True, "namePrefix": "S",
+ }
+ try:
+ import rhinopanel
+ ps = rhinopanel.load_project_settings(doc)
+ d = (ps or {}).get("defaults", {})
+ defaults["depthBack"] = float(d.get("schnittDepthBack", 8.0))
+ defaults["heightMin"] = float(d.get("schnittHeightMin", -1.0))
+ defaults["heightMax"] = float(d.get("schnittHeightMax", 12.0))
+ except Exception as ex:
+ print("[SECTION] defaults from project-settings:", ex)
+
+ sid = schnitte.pick_schnitt_interactive(doc, defaults=defaults)
+ if not sid:
+ print("[SECTION] abgebrochen")
+ else:
+ # Broadcast neue Zeichnungs-Ebene an Panels + auto-aktivieren
+ try:
+ eb = sc.sticky.get("ebenen_bridge")
+ if eb is not None:
+ eb._send_state()
+ except Exception as ex:
+ print("[SECTION] broadcast:", ex)
+ try:
+ import json
+ zraw = doc.Strings.GetValue("dossier_zeichnungsebenen") or "[]"
+ z_list = json.loads(zraw)
+ new_z = next((x for x in z_list
+ if isinstance(x, dict) and x.get("id") == sid), None)
+ if new_z is not None:
+ eb = sc.sticky.get("ebenen_bridge")
+ if eb is not None:
+ eb._set_active_zeichnungsebene(new_z)
+ print("[SECTION] erstellt: {}".format(sid))
+ except Exception as ex:
+ print("[SECTION] auto-activate:", ex)
+ except Exception as ex:
+ print("[SECTION] error:", ex)
diff --git a/rhino/aliases/cmd/smart_join.py b/rhino/aliases/cmd/smart_join.py
new file mode 100644
index 0000000..8659d64
--- /dev/null
+++ b/rhino/aliases/cmd/smart_join.py
@@ -0,0 +1,157 @@
+#! 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 _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
+
+ # 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()
diff --git a/rhino/aliases/cmd/smart_split.py b/rhino/aliases/cmd/smart_split.py
new file mode 100644
index 0000000..851cbb9
--- /dev/null
+++ b/rhino/aliases/cmd/smart_split.py
@@ -0,0 +1,267 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Smart-Split: User zeichnet eine Splitlinie/Polylinie waehrend des Befehls
+# (mehrere Klicks, Enter beendet die Eingabe). Alle Curves die die Linie
+# schneidet werden gesplittet.
+# - Offene Curves: bei den Schnittpunkten in offene Segmente.
+# - GESCHLOSSENE Curves: in mehrere CLOSED Sub-Regionen via
+# Curve.CreateBooleanRegions (funktioniert auch bei multi-segment
+# Polylinien-Cuttern). Per-Object-Hatch wird auf alle Regionen repliziert.
+# DOSSIER-Source-Typen (Wand-Achse etc.) bleiben geschuetzt.
+import scriptcontext as sc
+import Rhino
+import Rhino.Input.Custom as ric
+import Rhino.Geometry as rg
+import Rhino.DocObjects as rdoc
+from Rhino.Input import GetResult
+
+
+# Was Smart-Split NIE anfasst:
+# - oeffnung_point / stuetze_point: Punkte, nicht teilbar
+# - schnitt_axis: Schnitt-Linien sollen bleiben, sonst kaputte Schnitte
+# - treppe_axis: Treppen-State (Lauflinie, Schrittmass-Lock, Wendel-Sweep)
+# waere bei einem Split inkonsistent
+# Alles andere (wand/traeger/decke/dach/raum/aussparung) DARF gesplittet werden:
+# der Add-Listener in elemente.py erkennt die Duplikat-IDs der neuen Stuecke
+# und vergibt jedem Stueck ein frisches Element-ID + Regen → BIM-Volumen
+# baut sich pro neuem Stueck neu auf.
+_PROTECTED_TYPES = {
+ "treppe_axis",
+ "oeffnung_point", "stuetze_point", "schnitt_axis",
+}
+
+
+def _capture_hatch_props(doc, src_obj):
+ try:
+ sa = src_obj.Attributes
+ fill_hid = sa.GetUserString("ebenen_fill_hatch_id") or ""
+ if not fill_hid: return None
+ import System
+ hid = System.Guid(fill_hid)
+ hobj = doc.Objects.FindId(hid)
+ if hobj is None or hobj.IsDeleted: return None
+ hg = hobj.Geometry
+ ha = hobj.Attributes
+ if not hasattr(hg, "PatternIndex"): return None
+ return {
+ "pattern_idx": int(hg.PatternIndex),
+ "scale": float(hg.PatternScale),
+ "rotation": float(hg.PatternRotation),
+ "layer_idx": int(ha.LayerIndex),
+ "color_source": int(ha.ColorSource),
+ "color_argb": int(ha.ObjectColor.ToArgb()),
+ "plot_color_source": int(ha.PlotColorSource),
+ "plot_color_argb": int(ha.PlotColor.ToArgb()),
+ "linetype_source": int(ha.LinetypeSource),
+ "linetype_idx": int(ha.LinetypeIndex),
+ "fill_source": sa.GetUserString("ebenen_fill_source") or "object",
+ }
+ except Exception as ex:
+ print("[SMART-SPLIT] capture-hatch:", ex)
+ return None
+
+
+def _replicate_hatch(doc, new_obj, hp):
+ if hp is None: return
+ import System
+ try:
+ crv = new_obj.Geometry
+ if not isinstance(crv, rg.Curve) or not crv.IsClosed: return
+ tol = doc.ModelAbsoluteTolerance
+ hatches = rg.Hatch.Create(crv, hp["pattern_idx"], hp["rotation"],
+ hp["scale"], tol)
+ if not hatches or len(hatches) == 0: return
+ ha = rdoc.ObjectAttributes()
+ ha.LayerIndex = hp["layer_idx"]
+ ha.ColorSource = rdoc.ObjectColorSource(hp["color_source"])
+ ha.ObjectColor = System.Drawing.Color.FromArgb(hp["color_argb"])
+ try:
+ ha.PlotColorSource = rdoc.ObjectPlotColorSource(hp["plot_color_source"])
+ ha.PlotColor = System.Drawing.Color.FromArgb(hp["plot_color_argb"])
+ except Exception: pass
+ if hp["linetype_source"] == int(rdoc.ObjectLinetypeSource.LinetypeFromObject):
+ ha.LinetypeSource = rdoc.ObjectLinetypeSource.LinetypeFromObject
+ ha.LinetypeIndex = hp["linetype_idx"]
+ ha.SetUserString("ebenen_fill_source", hp.get("fill_source", "object"))
+ ha.SetUserString("ebenen_fill_owner", str(new_obj.Id))
+ new_hid = doc.Objects.AddHatch(hatches[0], ha)
+ if new_hid and new_hid != System.Guid.Empty:
+ ca = new_obj.Attributes.Duplicate()
+ ca.SetUserString("ebenen_fill_hatch_id", str(new_hid))
+ ca.SetUserString("ebenen_fill_source", hp.get("fill_source", "object"))
+ doc.Objects.ModifyAttributes(new_obj, ca, True)
+ except Exception as ex:
+ print("[SMART-SPLIT] hatch-replicate:", ex)
+
+
+def _collect_polyline_cutter(prompt_first, prompt_more):
+ """Sammelt n Punkte fuer den Cutter. Enter beendet (min. 2 Punkte).
+ ESC bricht ab. Returnt Polyline oder None."""
+ pts = []
+ while True:
+ gp = ric.GetPoint()
+ if not pts:
+ gp.SetCommandPrompt(prompt_first)
+ else:
+ gp.SetCommandPrompt(prompt_more + " (Enter zum Splitten, ESC = abbrechen)")
+ gp.SetBasePoint(pts[-1], True)
+ gp.DrawLineFromPoint(pts[-1], True)
+ gp.AcceptNothing(True)
+ res = gp.Get()
+ if res == GetResult.Nothing:
+ # Enter gedrueckt
+ if len(pts) >= 2: return rg.Polyline(pts)
+ print("[SMART-SPLIT] Mindestens 2 Punkte noetig"); return None
+ if res != GetResult.Point: return None
+ pts.append(gp.Point())
+
+
+def _split_closed_with_cutter(closed_crv, cutter_crv, doc):
+ """Splittet closed curve mit beliebigem cutter (Linie oder Polylinie) in
+ closed Sub-Regionen via Curve.CreateBooleanRegions."""
+ tol = doc.ModelAbsoluteTolerance
+ try:
+ # WorldXY-Plane als Default (DOSSIER ist 2D Plan-Workflow)
+ plane = rg.Plane.WorldXY
+ regions = rg.Curve.CreateBooleanRegions(
+ [closed_crv, cutter_crv], plane, False, tol)
+ if regions is None or regions.RegionCount == 0:
+ return None
+ out = []
+ for i in range(regions.RegionCount):
+ rcurves = list(regions.RegionCurves(i))
+ if not rcurves: continue
+ if len(rcurves) == 1:
+ if rcurves[0].IsClosed:
+ out.append(rcurves[0])
+ else:
+ # einzelne offene curve — sollte nicht passieren bei
+ # Boolean-Regions, aber defensiv
+ joined = rg.Curve.JoinCurves([rcurves[0]], tol)
+ if joined and len(joined) > 0 and joined[0].IsClosed:
+ out.append(joined[0])
+ else:
+ joined = rg.Curve.JoinCurves(rcurves, tol)
+ if joined:
+ for j in joined:
+ if j.IsClosed: out.append(j)
+ return out if out else None
+ except Exception as ex:
+ print("[SMART-SPLIT] closed-split:", ex)
+ return None
+
+
+def _run():
+ doc = Rhino.RhinoDoc.ActiveDoc
+ if doc is None: return
+
+ # Polylinie als Cutter sammeln
+ poly = _collect_polyline_cutter(
+ "Splitlinie Startpunkt",
+ "Naechster Punkt")
+ if poly is None or poly.Count < 2:
+ return
+ cutter = rg.PolylineCurve(poly)
+ tol = doc.ModelAbsoluteTolerance
+
+ pre_sel = [o for o in doc.Objects.GetSelectedObjects(False, False)
+ if o is not None and not o.IsDeleted]
+ if pre_sel:
+ source = pre_sel
+ mode_label = "selektierte ({})".format(len(pre_sel))
+ else:
+ s = rdoc.ObjectEnumeratorSettings()
+ s.HiddenObjects = False; s.LockedObjects = False
+ source = list(doc.Objects.GetObjectList(s))
+ mode_label = "alle sichtbaren"
+
+ candidates_open = []
+ candidates_closed = []
+ for obj in source:
+ if obj is None or obj.IsDeleted: continue
+ try:
+ t = obj.Attributes.GetUserString("dossier_element_type") or ""
+ if t in _PROTECTED_TYPES: continue
+ except Exception: pass
+ g = obj.Geometry
+ if not isinstance(g, rg.Curve): continue
+ try:
+ ints = rg.Intersect.Intersection.CurveCurve(cutter, g, tol, tol)
+ except Exception:
+ continue
+ if not ints or ints.Count == 0: continue
+
+ if g.IsClosed:
+ candidates_closed.append((obj, g))
+ else:
+ params = []
+ for i in range(ints.Count):
+ ev = ints[i]
+ if ev.IsPoint:
+ params.append(ev.ParameterB)
+ else:
+ params.append(ev.ParameterB); params.append(ev.ParameterB2)
+ if params:
+ params = sorted(set(round(p, 6) for p in params))
+ candidates_open.append((obj, g, params))
+
+ if not candidates_open and not candidates_closed:
+ print("[SMART-SPLIT] Cutter schneidet nichts ({})".format(mode_label))
+ return
+
+ ur = doc.BeginUndoRecord("DOSSIER Smart-Split")
+ n_open = 0; n_closed = 0
+ try:
+ # Closed: Boolean-Regions → CLOSED Sub-Regionen + Fill replicate
+ for obj, crv in candidates_closed:
+ try:
+ regions = _split_closed_with_cutter(crv, cutter, doc)
+ if not regions or len(regions) <= 1: continue
+ hatch_props = _capture_hatch_props(doc, obj)
+ attrs = obj.Attributes.Duplicate()
+ try: attrs.SetUserString("ebenen_fill_hatch_id", "")
+ except Exception: pass
+ new_ids = []
+ for r in regions:
+ nid = doc.Objects.AddCurve(r, attrs)
+ if nid: new_ids.append(nid)
+ doc.Objects.Delete(obj.Id, True)
+ if hatch_props is not None:
+ for nid in new_ids:
+ nobj = doc.Objects.FindId(nid)
+ if nobj is not None:
+ _replicate_hatch(doc, nobj, hatch_props)
+ else:
+ try:
+ import gestaltung as _gmod
+ for nid in new_ids:
+ nobj = doc.Objects.FindId(nid)
+ if nobj is not None:
+ _gmod._apply_ebene_fill(doc, nobj)
+ except Exception: pass
+ n_closed += 1
+ except Exception as ex:
+ print("[SMART-SPLIT] closed-fail:", ex)
+
+ # Open: split bei Params
+ for obj, crv, params in candidates_open:
+ try:
+ pieces = crv.Split(params)
+ if not pieces or len(pieces) <= 1: continue
+ attrs = obj.Attributes.Duplicate()
+ for p in pieces:
+ doc.Objects.AddCurve(p, attrs)
+ doc.Objects.Delete(obj.Id, True)
+ n_open += 1
+ except Exception as ex:
+ print("[SMART-SPLIT] open-fail:", ex)
+ finally:
+ doc.EndUndoRecord(ur)
+
+ doc.Views.Redraw()
+ print("[SMART-SPLIT] {} closed-Regionen + {} offene Curves gesplittet "
+ "({} Cutter-Punkte, {})"
+ .format(n_closed, n_open, poly.Count, mode_label))
+
+
+_run()
diff --git a/rhino/aliases/cmd/stempel.py b/rhino/aliases/cmd/stempel.py
new file mode 100644
index 0000000..9c08831
--- /dev/null
+++ b/rhino/aliases/cmd/stempel.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer Alias 'stempel'. Importiert dossier_dispatch + ruft Action.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_dispatch
+dossier_dispatch.dispatch("stempel")
diff --git a/rhino/aliases/cmd/stuetze.py b/rhino/aliases/cmd/stuetze.py
new file mode 100644
index 0000000..43aa181
--- /dev/null
+++ b/rhino/aliases/cmd/stuetze.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer Alias 'stuetze'. Importiert dossier_dispatch + ruft Action.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_dispatch
+dossier_dispatch.dispatch("stuetze")
diff --git a/rhino/aliases/cmd/symbol.py b/rhino/aliases/cmd/symbol.py
new file mode 100644
index 0000000..6ef92fc
--- /dev/null
+++ b/rhino/aliases/cmd/symbol.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer Alias 'symbol'. Importiert dossier_dispatch + ruft Action.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_dispatch
+dossier_dispatch.dispatch("symbol")
diff --git a/rhino/aliases/cmd/traeger.py b/rhino/aliases/cmd/traeger.py
new file mode 100644
index 0000000..21287bf
--- /dev/null
+++ b/rhino/aliases/cmd/traeger.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer Alias 'traeger'. Importiert dossier_dispatch + ruft Action.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_dispatch
+dossier_dispatch.dispatch("traeger")
diff --git a/rhino/aliases/cmd/treppe.py b/rhino/aliases/cmd/treppe.py
new file mode 100644
index 0000000..a6d9e7b
--- /dev/null
+++ b/rhino/aliases/cmd/treppe.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer Alias 'treppe'. Importiert dossier_dispatch + ruft Action.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_dispatch
+dossier_dispatch.dispatch("treppe")
diff --git a/rhino/aliases/cmd/tuer.py b/rhino/aliases/cmd/tuer.py
new file mode 100644
index 0000000..d5e831e
--- /dev/null
+++ b/rhino/aliases/cmd/tuer.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer Alias 'tuer'. Importiert dossier_dispatch + ruft Action.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_dispatch
+dossier_dispatch.dispatch("tuer")
diff --git a/rhino/aliases/cmd/wand.py b/rhino/aliases/cmd/wand.py
new file mode 100644
index 0000000..06e66e7
--- /dev/null
+++ b/rhino/aliases/cmd/wand.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer Alias 'wand'. Importiert dossier_dispatch + ruft Action.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_dispatch
+dossier_dispatch.dispatch("wand")
diff --git a/rhino/aliases/dossier_dispatch.py b/rhino/aliases/dossier_dispatch.py
new file mode 100644
index 0000000..506511b
--- /dev/null
+++ b/rhino/aliases/dossier_dispatch.py
@@ -0,0 +1,97 @@
+#! python3
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# Copyright (C) 2026 Karim Gabriele Varano
+"""
+dossier_dispatch.py
+Universal-Wrapper fuer DOSSIER-Bridge-Commands via Rhino-Alias.
+
+Aufruf vom Alias:
+ _-RunPythonScript "/.../dossier_dispatch.py"
+oder via Rhino.Input.RhinoGet — wir lesen den letzten String-Parameter
+aus der Command-Line.
+
+Aktionen mappen auf ElementeBridge._cmd_create_* via einer kleinen
+Dispatch-Tabelle. Bridge-Referenz wird in sc.sticky vom panel_factory
+abgelegt (siehe elemente.py _bridge_factory).
+"""
+import sys
+import scriptcontext as sc
+
+
+_ACTIONS = {
+ "wand": ("_cmd_create_wall", ()),
+ "tuer": ("_cmd_create_oeffnung", ("tuer",)),
+ "fenster": ("_cmd_create_oeffnung", ("fenster",)),
+ "decke": ("_cmd_create_decke", ()),
+ "aussparung":("_cmd_create_aussparung",()),
+ "dach": ("_cmd_create_dach", ()),
+ "treppe": ("_cmd_create_treppe", ()),
+ "stuetze": ("_cmd_create_stuetze", ()),
+ "traeger": ("_cmd_create_traeger", ()),
+ "raum": ("_cmd_create_raum", ()),
+ "stempel": ("_cmd_create_stempel", ()),
+ "symbol": ("_cmd_create_symbol", ()),
+}
+
+
+_PRETTY = {
+ "wand": "DOSSIER Wand",
+ "tuer": "DOSSIER Tuer",
+ "fenster": "DOSSIER Fenster",
+ "decke": "DOSSIER Decke",
+ "aussparung": "DOSSIER Aussparung",
+ "dach": "DOSSIER Dach",
+ "treppe": "DOSSIER Treppe",
+ "stuetze": "DOSSIER Stuetze",
+ "traeger": "DOSSIER Traeger",
+ "raum": "DOSSIER Raum",
+ "stempel": "DOSSIER Stempel",
+ "symbol": "DOSSIER Symbol",
+}
+
+
+def dispatch(action):
+ """Public entry — von per-action Wrapper-Scripts aufgerufen."""
+ try:
+ import Rhino
+ Rhino.RhinoApp.SetCommandPrompt(_PRETTY.get(action, "DOSSIER " + action.capitalize()))
+ except Exception: pass
+ bridge = sc.sticky.get("dossier_bridge_elemente")
+ if bridge is None:
+ print("[DOSSIER-ALIAS] Elemente-Bridge nicht aktiv (Panel oeffnen)")
+ return
+ spec = _ACTIONS.get(action)
+ if spec is None:
+ print("[DOSSIER-ALIAS] Unbekannte Aktion:", action)
+ return
+ method_name, args = spec
+ method = getattr(bridge, method_name, None)
+ if method is None:
+ print("[DOSSIER-ALIAS] Bridge-Method fehlt:", method_name)
+ return
+ try:
+ method({}, *args)
+ except Exception as ex:
+ print("[DOSSIER-ALIAS]", action, "->", method_name, ":", ex)
+
+
+# Backwards-Compat (alter Name).
+_dispatch = dispatch
+
+
+def _read_action_from_argv():
+ # sys.argv enthaelt bei _-RunPythonScript "path" arg1 arg2 ... die
+ # Args nach dem Skript-Pfad. argv[0] = Skript-Pfad.
+ if len(sys.argv) >= 2:
+ return str(sys.argv[1]).strip().lower()
+ return None
+
+
+if __name__ == "__main__":
+ a = _read_action_from_argv()
+ if a:
+ _dispatch(a)
+ else:
+ print("[DOSSIER-ALIAS] Keine Aktion uebergeben. Erwartet:",
+ ", ".join(sorted(_ACTIONS.keys())))
diff --git a/rhino/aliases/dossier_view_mode.py b/rhino/aliases/dossier_view_mode.py
new file mode 100644
index 0000000..8b23517
--- /dev/null
+++ b/rhino/aliases/dossier_view_mode.py
@@ -0,0 +1,72 @@
+#! python3
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# Copyright (C) 2026 Karim Gabriele Varano
+"""
+dossier_view_mode.py
+Setzt Display-Mode (+ optional Standard-Ansicht) im aktiven Viewport.
+
+Aufruf:
+ _-RunPythonScript "/.../dossier_view_mode.py"
+mode: plan | persp3d | material | raytracing
+"""
+import sys
+import Rhino
+
+
+_MODES = {
+ "plan": {"display": "Dossier Plan", "view": "Top", "label": "DOSSIER Plan-Mode"},
+ "persp3d": {"display": "Dossier 3D", "view": "Perspective","label": "DOSSIER 3D-Mode"},
+ "material": {"display": "Dossier Material", "view": None, "label": "DOSSIER Material-Mode"},
+ "raytracing": {"display": "Dossier Raytracing", "view": None, "label": "DOSSIER Raytracing"},
+}
+
+
+def _apply(mode_name):
+ spec = _MODES.get(mode_name)
+ if spec is None:
+ print("[VIEW-MODE] Unbekannt:", mode_name)
+ return
+ try: Rhino.RhinoApp.SetCommandPrompt(spec.get("label", "DOSSIER View"))
+ except Exception: pass
+ doc = Rhino.RhinoDoc.ActiveDoc
+ if doc is None:
+ print("[VIEW-MODE] Kein aktives Doc")
+ return
+ view = doc.Views.ActiveView
+ if view is None:
+ print("[VIEW-MODE] Kein aktiver Viewport")
+ return
+ # Standard-View setzen (Top / Perspective) falls definiert
+ vw_name = spec["view"]
+ if vw_name:
+ try:
+ view.ActiveViewport.SetProjection(
+ Rhino.Display.DefinedViewportProjection.Top
+ if vw_name == "Top"
+ else Rhino.Display.DefinedViewportProjection.Perspective,
+ vw_name, True)
+ except Exception as ex:
+ print("[VIEW-MODE] view-set:", ex)
+ # Display-Mode setzen via Description-Lookup
+ dm_name = spec["display"]
+ try:
+ all_dm = Rhino.Display.DisplayModeDescription.GetDisplayModes()
+ target = None
+ for d in all_dm:
+ if d.EnglishName == dm_name or d.LocalName == dm_name:
+ target = d; break
+ if target is None:
+ print("[VIEW-MODE] Display-Mode nicht gefunden:", dm_name)
+ return
+ view.ActiveViewport.DisplayMode = target
+ view.Redraw()
+ except Exception as ex:
+ print("[VIEW-MODE] display-mode:", ex)
+
+
+if __name__ == "__main__":
+ if len(sys.argv) >= 2:
+ _apply(str(sys.argv[1]).strip().lower())
+ else:
+ print("[VIEW-MODE] Erwartet Mode-Name:", ", ".join(_MODES.keys()))
diff --git a/rhino/aliases/loader.py b/rhino/aliases/loader.py
new file mode 100644
index 0000000..103e68f
--- /dev/null
+++ b/rhino/aliases/loader.py
@@ -0,0 +1,469 @@
+#! python3
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# Copyright (C) 2026 Karim Gabriele Varano
+"""
+aliases/loader.py
+Liest shortcuts_default.json + User-Overrides aus dossier_settings.json,
+merged und wendet via Rhino.ApplicationSettings.CommandAliasList /
+ShortcutKeySettings an. Wird einmal beim Rhino-Start aus startup.py
+aufgerufen (idempotent — SetMacro ueberschreibt).
+
+User-Override-Format in dossier_settings.json:
+ "shortcuts_user": {
+ "": "" // leer = Default
+ }
+"""
+import os
+import json
+import Rhino
+
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_quit_xml_pairs = [] # gefuellt in apply_all(), genutzt vom Closing-Hook
+_DEFAULTS_PATH = os.path.join(_HERE, "shortcuts_default.json")
+_SETTINGS_PATHS = [
+ os.path.expanduser("~/Library/Application Support/ch.gabrielevarano.Dossier/dossier_settings.json"),
+ os.path.expanduser("~/Library/Application Support/RhinoPanel/dossier_settings.json"), # legacy
+]
+
+
+def _read_defaults():
+ try:
+ with open(_DEFAULTS_PATH, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ out = {}
+ for k, v in data.items():
+ if k.startswith("_"): continue
+ if not isinstance(v, dict): continue
+ out[k] = v
+ return out
+ except Exception as ex:
+ print("[ALIAS-LOADER] Defaults lesen:", ex)
+ return {}
+
+
+def _read_user_overrides():
+ """Liest 'shortcuts_user' aus dossier_settings.json. Format:
+ { action_id: trigger_string }. Leerer String / None = Default."""
+ for path in _SETTINGS_PATHS:
+ if not os.path.exists(path): continue
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ so = data.get("shortcuts_user")
+ if isinstance(so, dict): return so
+ except Exception as ex:
+ print("[ALIAS-LOADER] Settings lesen:", ex)
+ return {}
+
+
+def _expand_macro(macro):
+ """Platzhalter {ALIASDIR} → absoluter Pfad zum aliases/-Ordner."""
+ return macro.replace("{ALIASDIR}", _HERE)
+
+
+# Sonderzeichen → Rhino-Enum-Namen (Mac XML + ShortcutKey-API)
+_SPECIAL_KEY_NAMES = {
+ "-": "Minus", "+": "Plus", "=": "Equals",
+ "/": "Slash", "\\": "Backslash",
+ ".": "Period", ",": "Comma",
+ ";": "Semicolon", "'": "Quote", "`": "Backquote",
+ "[": "OpenBracket", "]": "CloseBracket",
+}
+
+
+def _normalize_key_part(key_part):
+ """Mapped Sonderzeichen wie '-' auf Enum-Namen ('Minus'). Buchstaben/F-Keys
+ bleiben unveraendert (Case-preserved)."""
+ if key_part in _SPECIAL_KEY_NAMES:
+ return _SPECIAL_KEY_NAMES[key_part]
+ return key_part
+
+
+def _xml_key_from_trigger(trigger):
+ """'Cmd+Shift+F3' → 'CommandShiftF3' (Mac Rhino XML-Schema).
+ Cmd/Ctrl → 'Command', Shift → 'Shift', Alt/Option → 'Option'.
+ Sonderzeichen ('-', '/', etc.) werden auf Enum-Namen gemapped."""
+ t = trigger.replace(" ", "")
+ parts = t.split("+") if "+" in t[1:] else [t]
+ # Edge-Case: trigger endet auf literal '+' oder '-' → letztes Element ist Key
+ # 'Cmd+-' → ['Cmd', '', '-'] via split. Fix: re-split last token wenn leer
+ parts = [p for p in parts if p != ""]
+ # Sonderfall trigger == 'Cmd+-' → split('+') = ['Cmd', '-'], OK
+ # Sonderfall trigger == 'Cmd++' → split('+') = ['Cmd', '', ''] → key = '+'
+ if "Cmd++" in trigger or "Ctrl++" in trigger or "Shift++" in trigger:
+ parts = trigger.replace(" ", "").rstrip("+").split("+") + ["+"]
+ if not parts: return None
+ key_part = _normalize_key_part(parts[-1])
+ mods = set(p.lower() for p in parts[:-1])
+ has_cmd = ("cmd" in mods) or ("ctrl" in mods) or ("command" in mods)
+ has_shift = "shift" in mods
+ has_alt = ("alt" in mods) or ("option" in mods) or ("opt" in mods)
+ prefix = ""
+ if has_cmd: prefix += "Command"
+ if has_shift: prefix += "Shift"
+ if has_alt: prefix += "Option"
+ return prefix + key_part
+
+
+def _entry_in_xml(xml_key, expected_macro):
+ """True wenn expected_macro bereits im
+ Mac Rhino settings-XML existiert."""
+ import os
+ import re
+ paths = [
+ os.path.expanduser("~/Library/Application Support/McNeel/Rhinoceros/8.0/settings/settings-Scheme__Default.xml"),
+ ]
+ _esc = lambda s: s.replace("&", "&").replace("<", "<").replace(">", ">")
+ pat = re.compile(
+ r'([^<]*) ')
+ for path in paths:
+ if not os.path.exists(path): continue
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+ m = pat.search(content)
+ if m and m.group(1) == _esc(expected_macro):
+ return True
+ except Exception: pass
+ return False
+
+
+def _xml_persist_shortcut(xml_key, macro, verbose=False):
+ """Schreibt direkt in Mac Rhino's
+ settings-Scheme__Default.xml unter . String-
+ basiert damit die Original-Formatierung 1:1 erhalten bleibt."""
+ import os
+ import re
+ paths = [
+ os.path.expanduser("~/Library/Application Support/McNeel/Rhinoceros/8.0/settings/settings-Scheme__Default.xml"),
+ ]
+ n_written = 0
+ _esc = lambda s: s.replace("&", "&").replace("<", "<").replace(">", ">")
+
+ for path in paths:
+ if not os.path.exists(path): continue
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+ new_entry = '{} '.format(xml_key, _esc(macro))
+
+ # Existing entry? Loeschen (mit umgebendem Whitespace+Newline)
+ # und neu hinzufuegen mit sauberem Format. Vermeidet
+ # kaputt-formatierte Entries.
+ pat = re.compile(
+ r' |>[^<]*)')
+ m = pat.search(content)
+ if m:
+ # Check Line-Kontext: nur diese Entry auf Zeile + unveraendert?
+ line_start = content.rfind("\n", 0, m.start()) + 1
+ line_end = content.find("\n", m.end())
+ if line_end < 0: line_end = len(content)
+ line_trim = content[line_start:line_end].strip()
+ if line_trim == new_entry:
+ if verbose: print("[ALIAS-LOADER] XML '{}' unchanged".format(xml_key))
+ continue
+ # Sonst: loeschen inkl. preceding-newline+whitespace damit
+ # keine orphan-line uebrig bleibt
+ del_start = m.start()
+ while del_start > 0 and content[del_start-1] in " \t":
+ del_start -= 1
+ if del_start > 0 and content[del_start-1] == "\n":
+ del_start -= 1
+ content = content[:del_start] + content[m.end():]
+ if True:
+ # ShortcutKeys-Section finden
+ sec_start = content.find('')
+ if sec_start < 0:
+ if verbose: print("[ALIAS-LOADER] ShortcutKeys-section fehlt")
+ continue
+ sec_end = content.find(' ', sec_start)
+ if sec_end < 0:
+ if verbose: print("[ALIAS-LOADER] ShortcutKeys-close fehlt")
+ continue
+ # Indent vom letzten in der Section uebernehmen
+ section = content[sec_start:sec_end]
+ ms = list(re.finditer(r'\n([ \t]*) (typisch 6 spaces)
+ close_match = re.search(r'\n([ \t]*)$', content[:sec_end])
+ close_indent = close_match.group(1) if close_match else " "
+ # Section neu zusammensetzen: alles vor bereinigt
+ # + sauberer Insert
+ before = content[:sec_end].rstrip(" \t") + "\n"
+ content = (before + entry_indent + new_entry + "\n"
+ + close_indent + content[sec_end:])
+ action = "added"
+
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+ n_written += 1
+ if verbose: print("[ALIAS-LOADER] XML {} '{}'".format(action, xml_key))
+ except Exception as ex:
+ print("[ALIAS-LOADER] XML-Write {}: {}".format(path, ex))
+ return n_written
+
+
+def _install_quit_xml_save(pairs):
+ """Rhino's Closing-Event fired auf Mac NICHT zuverlaessig. Wir
+ installieren MEHRERE Hooks parallel:
+ 1. Rhino.RhinoApp.Closing (Mac: meist No-op, Windows: ok)
+ 2. Python atexit (laeuft wenn Interpreter terminiert)
+ 3. AppDomain.ProcessExit (.NET-Level Hook)
+ 4. Idle-Watcher: schreibt XML alle 30s wenn Aenderung erkannt
+ (Fallback fuer Rhino's runtime-flush)
+ Marker-Logging zur Verifikation welcher Hook wirklich feuert."""
+ import os as _os
+ import datetime as _dt
+ _marker = _os.path.expanduser("~/Library/Logs/dossier_quit_hook.log")
+ try:
+ _os.makedirs(_os.path.dirname(_marker), exist_ok=True)
+ except Exception: pass
+
+ def _log(msg):
+ try:
+ with open(_marker, "a") as f:
+ f.write("[{}] {}\n".format(_dt.datetime.now().isoformat(), msg))
+ except Exception: pass
+
+ def _write_all(source):
+ n_ok = 0
+ for xml_key, macro in pairs:
+ if _xml_persist_shortcut(xml_key, macro, verbose=False) > 0:
+ n_ok += 1
+ _log("{} FIRED — {}/{} ok".format(source, n_ok, len(pairs)))
+ return n_ok
+
+ n_hooks = 0
+ try:
+ import Rhino
+ def _on_closing(*_):
+ try: _write_all("RhinoClosing")
+ except Exception as ex: _log("RhinoClosing ERROR: {}".format(ex))
+ Rhino.RhinoApp.Closing += _on_closing
+ n_hooks += 1
+ except Exception as ex:
+ _log("RhinoClosing install err: {}".format(ex))
+
+ try:
+ import atexit
+ def _on_atexit():
+ try: _write_all("atexit")
+ except Exception as ex: _log("atexit ERROR: {}".format(ex))
+ atexit.register(_on_atexit)
+ n_hooks += 1
+ except Exception as ex:
+ _log("atexit install err: {}".format(ex))
+
+ try:
+ import System
+ def _on_process_exit(*_):
+ try: _write_all("ProcessExit")
+ except Exception as ex: _log("ProcessExit ERROR: {}".format(ex))
+ System.AppDomain.CurrentDomain.ProcessExit += _on_process_exit
+ n_hooks += 1
+ except Exception as ex:
+ _log("ProcessExit install err: {}".format(ex))
+
+ # Idle-Watcher: periodisch (alle ~30s) checken ob unsere XML-Entries
+ # noch da sind. Wenn nein → wieder reinschreiben. Ueberlebt Rhino-
+ # Runtime-Flushes auch ohne Close-Event.
+ try:
+ import Rhino
+ import time as _time
+ _state = {"last": 0.0}
+ def _idle_watcher(*_):
+ try:
+ now = _time.time()
+ if now - _state["last"] < 30.0: return
+ _state["last"] = now
+ # Pruefen ob entries fehlen — wenn ja, alle re-schreiben
+ _write_all("IdleWatch")
+ except Exception as ex:
+ _log("IdleWatch ERROR: {}".format(ex))
+ Rhino.RhinoApp.Idle += _idle_watcher
+ n_hooks += 1
+ _log("IdleWatch installed (30s interval)")
+ except Exception as ex:
+ _log("IdleWatch install err: {}".format(ex))
+
+ _log("Hooks INSTALLED ({} of 4) for {} shortcuts".format(n_hooks, len(pairs)))
+ # Initiale Schreibung im ersten Pass auch — falls Rhino sofort flusht
+ _write_all("InitialWrite")
+ return n_hooks > 0
+
+
+def _resolve_fkey(trigger):
+ """'F3' / 'Shift+F3' / 'Cmd+F3' / 'Cmd+Alt+F3' → ShortcutKey-Enum-Wert.
+ Enum-Naming-Konvention von Rhino: Ctrl → Shift → Alt → KeyName
+ (z.B. CtrlAltF3, CtrlShiftAltF3). Cmd auf Mac mappt auf Ctrl,
+ Option/Opt auf Alt. Sonderzeichen via _SPECIAL_KEY_NAMES."""
+ SK = Rhino.ApplicationSettings.ShortcutKey
+ t = trigger.replace(" ", "")
+ parts = t.split("+")
+ parts = [p for p in parts if p != ""]
+ if not parts: return None
+ raw_last = parts[-1]
+ if raw_last in _SPECIAL_KEY_NAMES:
+ key_part = _SPECIAL_KEY_NAMES[raw_last]
+ else:
+ key_part = raw_last.upper()
+ mods = set(p.lower() for p in parts[:-1])
+ has_ctrl = ("ctrl" in mods) or ("cmd" in mods) or ("command" in mods)
+ has_shift = "shift" in mods
+ has_alt = ("alt" in mods) or ("option" in mods) or ("opt" in mods)
+ prefix = ""
+ if has_ctrl: prefix += "Ctrl"
+ if has_shift: prefix += "Shift"
+ if has_alt: prefix += "Alt"
+ return getattr(SK, prefix + key_part, None)
+
+
+def _resolve_cmd_letter(trigger):
+ """'Cmd+W' / 'Cmd+Shift+W' → ShortcutKey-Enum (Ctrl* auf Rhino-Naming-
+ Konvention; Mac mappt Ctrl auf Cmd intern)."""
+ SK = Rhino.ApplicationSettings.ShortcutKey
+ t = trigger.replace(" ", "")
+ parts = t.split("+")
+ if len(parts) < 2: return None
+ letter = parts[-1].upper()
+ if not (len(letter) == 1 and letter.isalpha()): return None
+ mods = set(p.lower() for p in parts[:-1])
+ has_cmd = ("cmd" in mods) or ("ctrl" in mods)
+ if not has_cmd: return None
+ name = "Ctrl"
+ if "shift" in mods: name += "Shift"
+ if "alt" in mods: name += "Alt"
+ name += letter
+ return getattr(SK, name, None)
+
+
+def apply_all():
+ """Liest Defaults + Overrides, wendet alle Aliases + Shortcuts an.
+ Returnt (n_alias, n_fkey, n_cmd, n_skipped)."""
+ global _quit_xml_pairs
+ _quit_xml_pairs = []
+ defaults = _read_defaults()
+ overrides = _read_user_overrides()
+ aliases = Rhino.ApplicationSettings.CommandAliasList
+ skset = Rhino.ApplicationSettings.ShortcutKeySettings
+ n_alias = n_fkey = n_cmd = n_skipped = 0
+ seen_triggers = {} # trigger_normalized -> action_id (Konflikt-Erkennung)
+
+ for action_id, spec in defaults.items():
+ # User-Override hat Vorrang. Leerer String = Default, None/missing = Default.
+ user_trig = overrides.get(action_id)
+ if user_trig is not None and str(user_trig).strip() == "":
+ user_trig = None
+ trigger = user_trig if user_trig else spec.get("trigger", "")
+ if not trigger:
+ n_skipped += 1
+ continue
+ spec_type = spec.get("type", "alias")
+ macro = _expand_macro(spec.get("macro", ""))
+ if not macro:
+ n_skipped += 1; continue
+
+ # Konflikt-Check (gleicher Trigger → letzter gewinnt, Warning)
+ norm = (spec_type, str(trigger).lower())
+ if norm in seen_triggers:
+ print("[ALIAS-LOADER] Konflikt: '{}' fuer {} bereits von {} belegt"
+ .format(trigger, action_id, seen_triggers[norm]))
+ seen_triggers[norm] = action_id
+
+ try:
+ if spec_type == "alias":
+ tname = str(trigger)
+ try:
+ if aliases.IsAlias(tname):
+ aliases.Delete(tname)
+ except Exception: pass
+ added = False
+ try:
+ added = aliases.Add(tname, macro)
+ except Exception as _addex:
+ print("[ALIAS-LOADER] Add({}, ...) Exception: {}"
+ .format(tname, _addex))
+ if not added:
+ try: aliases.SetMacro(tname, macro)
+ except Exception: pass
+ # Verifizieren ob Alias wirklich registriert ist
+ try:
+ is_ok = aliases.IsAlias(tname)
+ if not is_ok:
+ print("[ALIAS-LOADER] WARN: '{}' (action={}) NICHT registriert "
+ "— Rhino lehnt Namen wahrscheinlich ab (z.B. reine Zahl)"
+ .format(tname, action_id))
+ n_skipped += 1
+ continue
+ except Exception: pass
+ n_alias += 1
+ elif spec_type == "fkey":
+ sk = _resolve_fkey(str(trigger))
+ xml_key = _xml_key_from_trigger(str(trigger))
+ api_ok = False
+ if sk is not None:
+ try:
+ skset.SetMacro(sk, macro)
+ got = skset.GetMacro(sk)
+ api_ok = (got == macro)
+ except Exception as _sex:
+ print("[ALIAS-LOADER] SetMacro({}): {}".format(trigger, _sex))
+ if not api_ok and xml_key:
+ # Enum-Wert fehlt → direkt ins XML (mit verbose-Log).
+ # n_xml=0 kann "schon korrekt" ODER "gescheitert" heissen
+ # — wir checken explizit ob Entry im XML existiert.
+ n_xml = _xml_persist_shortcut(xml_key, macro, verbose=True)
+ if n_xml > 0:
+ _quit_xml_pairs.append((xml_key, macro))
+ else:
+ # n_xml == 0 → entweder "unchanged" (= schon korrekt
+ # im XML) oder "missing path/section". Check via
+ # IsAliasInXml damit wir nicht falsch warnen.
+ if _entry_in_xml(xml_key, macro):
+ # Schon korrekt im XML → fuer Quit-Hook merken
+ # damit Rhino-Quit-Save sie nicht ueberschreibt
+ _quit_xml_pairs.append((xml_key, macro))
+ else:
+ print("[ALIAS-LOADER] WARN F-Key {} ({}) konnte weder "
+ "API noch XML gesetzt werden".format(trigger, action_id))
+ n_skipped += 1; continue
+ n_fkey += 1
+ elif spec_type == "cmd":
+ sk = _resolve_cmd_letter(str(trigger))
+ if sk is None:
+ # Fallback: Cmd+Letter API u.U. nicht im Enum → als Alias mit dem
+ # Letter (single-char) registrieren. User tippt dann Letter+Enter.
+ letter_only = str(trigger).split("+")[-1].lower()
+ if len(letter_only) == 1 and letter_only.isalpha():
+ aliases.SetMacro(letter_only, macro)
+ n_alias += 1
+ print("[ALIAS-LOADER] {} ({}): Cmd+Letter nicht im Enum, "
+ "fallback Alias '{}'".format(action_id, trigger, letter_only))
+ else:
+ n_skipped += 1
+ continue
+ skset.SetMacro(sk, macro)
+ n_cmd += 1
+ else:
+ print("[ALIAS-LOADER] Unbekannter Type:", spec_type); n_skipped += 1
+ except Exception as ex:
+ print("[ALIAS-LOADER] Apply", action_id, "->", trigger, ":", ex)
+ n_skipped += 1
+
+ # Quit-Hook installieren falls XML-only Shortcuts gesetzt wurden — diese
+ # ueberlebt sonst Rhino's Auto-Save beim Quit nicht.
+ if _quit_xml_pairs:
+ _install_quit_xml_save(list(_quit_xml_pairs))
+ print("[ALIAS-LOADER] {} XML-only Shortcuts werden bei Quit "
+ "re-persistiert (Closing-Hook installiert)"
+ .format(len(_quit_xml_pairs)))
+
+ return n_alias, n_fkey, n_cmd, n_skipped
+
+
+if __name__ == "__main__":
+ a, f, c, s = apply_all()
+ print("[ALIAS-LOADER] OK: {} alias, {} fkey, {} cmd, {} skipped"
+ .format(a, f, c, s))
diff --git a/rhino/aliases/shortcuts_default.json b/rhino/aliases/shortcuts_default.json
new file mode 100644
index 0000000..a842fce
--- /dev/null
+++ b/rhino/aliases/shortcuts_default.json
@@ -0,0 +1,66 @@
+{
+ "_meta": {
+ "version": 2,
+ "description": "DOSSIER Default Shortcuts. Schema: F1-F12 = 2D-Werkzeuge (Single-Tastendruck). Shift+F* = Views/Panels. Cmd+F* = BIM-Objekte. F8/F9 bleiben Rhino-Default (Ortho/Snap). 2D-Tools auch als Alias n1-n0 (Fallback fuer typen). 2-Letter-Aliases (st/tg/ra/sy/sp/dh/au) fuer seltenere BIM. User-Overrides leben in dossier_settings.json unter 'shortcuts_user' = {action_id: trigger_string}. Macro-Platzhalter {ALIASDIR} wird zur Laufzeit ersetzt."
+ },
+
+ "wand": { "type": "fkey", "trigger": "Cmd+F1", "label": "DOSSIER Wand erstellen", "macro": "dWall" },
+ "tuer": { "type": "fkey", "trigger": "Cmd+F2", "label": "DOSSIER Tuer erstellen", "macro": "dDoor" },
+ "fenster": { "type": "fkey", "trigger": "Cmd+F3", "label": "DOSSIER Fenster erstellen", "macro": "dWindow" },
+ "decke": { "type": "fkey", "trigger": "Cmd+F4", "label": "DOSSIER Decke erstellen", "macro": "dSlab" },
+ "treppe": { "type": "fkey", "trigger": "Cmd+F5", "label": "DOSSIER Treppe erstellen", "macro": "dStair" },
+ "stuetze": { "type": "fkey", "trigger": "Cmd+F6", "label": "DOSSIER Stuetze erstellen", "macro": "dColumn" },
+ "traeger": { "type": "fkey", "trigger": "Cmd+F7", "label": "DOSSIER Traeger erstellen", "macro": "dBeam" },
+ "raum": { "type": "fkey", "trigger": "Cmd+F10", "label": "DOSSIER Raum erstellen", "macro": "dRoom" },
+ "symbol": { "type": "fkey", "trigger": "Cmd+F11", "label": "DOSSIER Symbol erstellen", "macro": "dSymbol" },
+ "stempel": { "type": "fkey", "trigger": "Cmd+F12", "label": "DOSSIER Stempel erstellen", "macro": "dTag" },
+ "dach": { "type": "alias", "trigger": "dh", "label": "DOSSIER Dach (Alias)", "macro": "dRoof" },
+ "aussparung": { "type": "alias", "trigger": "au", "label": "DOSSIER Aussparung (Alias)", "macro": "dVoid" },
+
+ "text": { "type": "fkey", "trigger": "F1", "label": "Text", "macro": "_Text" },
+ "line": { "type": "fkey", "trigger": "F2", "label": "Linie", "macro": "_Line" },
+ "arc": { "type": "fkey", "trigger": "F3", "label": "Kreisbogen", "macro": "_Arc" },
+ "rectangle": { "type": "fkey", "trigger": "F4", "label": "Rechteck", "macro": "_Rectangle" },
+ "polyline": { "type": "fkey", "trigger": "F5", "label": "Polylinie", "macro": "_Polyline" },
+ "curve": { "type": "fkey", "trigger": "F6", "label": "Spline / Kurve", "macro": "_Curve" },
+ "hatch": { "type": "fkey", "trigger": "F7", "label": "Schraffur", "macro": "_Hatch" },
+ "polygon": { "type": "fkey", "trigger": "F10", "label": "Polygon", "macro": "_Polygon" },
+ "ellipse": { "type": "fkey", "trigger": "F11", "label": "Ellipse", "macro": "_Ellipse" },
+ "circle": { "type": "fkey", "trigger": "F12", "label": "Kreis", "macro": "_Circle" },
+
+ "view_plan": { "type": "fkey", "trigger": "Cmd+K", "label": "Plan-Mode (Top + Dossier Plan)", "macro": "dPlan" },
+ "view_3d": { "type": "fkey", "trigger": "Cmd+L", "label": "3D-Mode (Perspective + Dossier 3D)", "macro": "d3D" },
+ "zoom_ext": { "type": "fkey", "trigger": "Cmd+U", "label": "Zoom Extents", "macro": "_Zoom _All _Extents" },
+ "zoom_sel": { "type": "fkey", "trigger": "Cmd+Shift+U", "label": "Zoom Selected", "macro": "_Zoom _Selected" },
+ "mod_group": { "type": "fkey", "trigger": "Cmd+G", "label": "Gruppieren (Group)", "macro": "_Group" },
+ "geschoss_up": { "type": "alias", "trigger": "gu", "label": "Geschoss hoch (Alias)", "macro": "dLevelUp" },
+ "geschoss_down": { "type": "fkey", "trigger": "Cmd+B", "label": "Geschoss tief", "macro": "dLevelDown" },
+ "view_material": { "type": "alias", "trigger": "ma", "label": "Material-Mode (Alias)", "macro": "dMaterial" },
+ "panel_layer": { "type": "alias", "trigger": "la", "label": "Layer-Panel (Alias)", "macro": "_Layer" },
+ "panel_elemente": { "type": "alias", "trigger": "el", "label": "DOSSIER Elemente-Panel (Alias)", "macro": "-_ShowPanel \"DOSSIER Elemente\"" },
+
+ "mod_mirror": { "type": "fkey", "trigger": "Cmd+I", "label": "Spiegeln (Mirror)", "macro": "_Mirror" },
+ "mod_copy": { "type": "fkey", "trigger": "Cmd+D", "label": "Kopieren (Copy = Duplicate)", "macro": "_Copy" },
+ "mod_rotate": { "type": "fkey", "trigger": "Cmd+R", "label": "Drehen (Rotate)", "macro": "_Rotate" },
+ "mod_trim": { "type": "fkey", "trigger": "Cmd+T", "label": "Trim (Schneiden)", "macro": "_Trim" },
+ "mod_join": { "type": "fkey", "trigger": "Cmd+J", "label": "Verbinden (Smart-Join: Regionen → Union, sonst Join)", "macro": "dJoin" },
+ "mod_explode": { "type": "fkey", "trigger": "Cmd+E", "label": "Trennen (Explode)", "macro": "_Explode" },
+ "mod_fillet": { "type": "fkey", "trigger": "Cmd+Shift+V", "label": "Verrunden (Fillet)", "macro": "_Fillet" },
+ "mod_move": { "type": "fkey", "trigger": "Cmd+M", "label": "Verschieben (Move)", "macro": "_Move" },
+ "mod_offset": { "type": "fkey", "trigger": "Cmd+Shift+P", "label": "Parallele (OffsetCrv)", "macro": "_OffsetCrv" },
+ "mod_split": { "type": "fkey", "trigger": "Cmd+X", "label": "Smart-Split (Splitlinie zeichnen — ueberschreibt Cut)", "macro": "dSplit" },
+ "mod_chamfer": { "type": "fkey", "trigger": "Cmd+Shift+C", "label": "Abfasen (Chamfer)", "macro": "_Chamfer" },
+ "mod_pipette": { "type": "fkey", "trigger": "Cmd+Y", "label": "Pipette (Einstellungen uebernehmen)", "macro": "dPipette" },
+ "cheatsheet": { "type": "fkey", "trigger": "Cmd+-", "label": "DOSSIER Shortcuts-Cheatsheet", "macro": "dKeys" },
+
+ "text_alias": { "type": "alias", "trigger": "n1", "label": "Text (Alias)", "macro": "_Text" },
+ "line_alias": { "type": "alias", "trigger": "n2", "label": "Linie (Alias)", "macro": "_Line" },
+ "arc_alias": { "type": "alias", "trigger": "n3", "label": "Kreisbogen (Alias)", "macro": "_Arc" },
+ "rectangle_alias": { "type": "alias", "trigger": "n4", "label": "Rechteck (Alias)", "macro": "_Rectangle" },
+ "polyline_alias": { "type": "alias", "trigger": "n5", "label": "Polylinie (Alias)", "macro": "_Polyline" },
+ "curve_alias": { "type": "alias", "trigger": "n6", "label": "Kurve (Alias)", "macro": "_Curve" },
+ "hatch_alias": { "type": "alias", "trigger": "n7", "label": "Schraffur (Alias)", "macro": "_Hatch" },
+ "polygon_alias": { "type": "alias", "trigger": "n8", "label": "Polygon (Alias)", "macro": "_Polygon" },
+ "ellipse_alias": { "type": "alias", "trigger": "n9", "label": "Ellipse (Alias)", "macro": "_Ellipse" },
+ "circle_alias": { "type": "alias", "trigger": "n0", "label": "Kreis (Alias)", "macro": "_Circle" }
+}
diff --git a/rhino/aliases/view/geschoss_down.py b/rhino/aliases/view/geschoss_down.py
new file mode 100644
index 0000000..c748c26
--- /dev/null
+++ b/rhino/aliases/view/geschoss_down.py
@@ -0,0 +1,42 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Geschoss runter (zum naechsttieferen Eintrag in der Zeichnungsebenen-Liste)
+import json
+import scriptcontext as sc
+import Rhino
+
+
+def _go(delta):
+ doc = Rhino.RhinoDoc.ActiveDoc
+ if doc is None:
+ print("[GESCHOSS-NAV] kein Doc"); return
+ bridge = sc.sticky.get("ebenen_bridge_ref")
+ if bridge is None:
+ print("[GESCHOSS-NAV] Ebenen-Bridge nicht aktiv (Panel oeffnen)"); return
+ try:
+ zraw = doc.Strings.GetValue("dossier_zeichnungsebenen") or ""
+ zs = json.loads(zraw) if zraw else []
+ if not isinstance(zs, list) or not zs:
+ print("[GESCHOSS-NAV] keine Zeichnungsebenen"); return
+ cur_id = doc.Strings.GetValue("dossier_active_id") or ""
+ idx = -1
+ for i, z in enumerate(zs):
+ if isinstance(z, dict) and z.get("id") == cur_id:
+ idx = i; break
+ if idx < 0:
+ idx = len(zs) # nichts aktiv → starten unten
+ new_idx = max(0, min(len(zs) - 1, idx + delta))
+ if new_idx == idx:
+ print("[GESCHOSS-NAV] schon am {}".format(
+ "untersten" if delta > 0 else "obersten")); return
+ target = zs[new_idx]
+ if not isinstance(target, dict) or not target.get("id"):
+ print("[GESCHOSS-NAV] Zielebene ungueltig"); return
+ print("[GESCHOSS-NAV] wechsle zu '{}'".format(target.get("name") or target["id"]))
+ bridge._set_active_zeichnungsebene(target)
+ except Exception as ex:
+ print("[GESCHOSS-NAV]", ex)
+
+
+# delta=+1 = nach unten (naechster Eintrag in der Liste)
+_go(+1)
diff --git a/rhino/aliases/view/geschoss_up.py b/rhino/aliases/view/geschoss_up.py
new file mode 100644
index 0000000..7e03f4d
--- /dev/null
+++ b/rhino/aliases/view/geschoss_up.py
@@ -0,0 +1,43 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Geschoss hoch (zum naechstoberen Eintrag in der Zeichnungsebenen-Liste)
+import json
+import scriptcontext as sc
+import Rhino
+
+
+def _go(delta):
+ doc = Rhino.RhinoDoc.ActiveDoc
+ if doc is None:
+ print("[GESCHOSS-NAV] kein Doc"); return
+ bridge = sc.sticky.get("ebenen_bridge_ref")
+ if bridge is None:
+ print("[GESCHOSS-NAV] Ebenen-Bridge nicht aktiv (Panel oeffnen)"); return
+ try:
+ zraw = doc.Strings.GetValue("dossier_zeichnungsebenen") or ""
+ zs = json.loads(zraw) if zraw else []
+ if not isinstance(zs, list) or not zs:
+ print("[GESCHOSS-NAV] keine Zeichnungsebenen"); return
+ cur_id = doc.Strings.GetValue("dossier_active_id") or ""
+ idx = -1
+ for i, z in enumerate(zs):
+ if isinstance(z, dict) and z.get("id") == cur_id:
+ idx = i; break
+ if idx < 0:
+ idx = 0 # nichts aktiv → starten oben
+ new_idx = max(0, min(len(zs) - 1, idx + delta))
+ if new_idx == idx:
+ print("[GESCHOSS-NAV] schon am {}".format(
+ "obersten" if delta < 0 else "untersten")); return
+ target = zs[new_idx]
+ if not isinstance(target, dict) or not target.get("id"):
+ print("[GESCHOSS-NAV] Zielebene ungueltig"); return
+ print("[GESCHOSS-NAV] wechsle zu '{}'".format(target.get("name") or target["id"]))
+ bridge._set_active_zeichnungsebene(target)
+ except Exception as ex:
+ print("[GESCHOSS-NAV]", ex)
+
+
+# delta=-1 = nach oben (vorheriger Eintrag in der Liste, weil Listen
+# typischerweise oberste Ebene oben sind)
+_go(-1)
diff --git a/rhino/aliases/view/material.py b/rhino/aliases/view/material.py
new file mode 100644
index 0000000..7eea293
--- /dev/null
+++ b/rhino/aliases/view/material.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer View-Mode 'material'.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_view_mode
+dossier_view_mode._apply("material")
diff --git a/rhino/aliases/view/persp3d.py b/rhino/aliases/view/persp3d.py
new file mode 100644
index 0000000..908abbf
--- /dev/null
+++ b/rhino/aliases/view/persp3d.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer View-Mode 'persp3d'.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_view_mode
+dossier_view_mode._apply("persp3d")
diff --git a/rhino/aliases/view/plan.py b/rhino/aliases/view/plan.py
new file mode 100644
index 0000000..afd7bcb
--- /dev/null
+++ b/rhino/aliases/view/plan.py
@@ -0,0 +1,7 @@
+#! python3
+# -*- coding: utf-8 -*-
+# Auto-Wrapper fuer View-Mode 'plan'.
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import dossier_view_mode
+dossier_view_mode._apply("plan")
diff --git a/rhino/begin_cmd_hook.py b/rhino/begin_cmd_hook.py
new file mode 100644
index 0000000..395af67
--- /dev/null
+++ b/rhino/begin_cmd_hook.py
@@ -0,0 +1,79 @@
+#! python3
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# Copyright (C) 2026 Karim Gabriele Varano
+"""
+begin_cmd_hook.py
+Hook auf Rhino.Commands.Command.BeginCommand. Wenn der User ein Drawing-
+Command startet (Line, Polyline, Rectangle, Circle etc.), oeffnen wir
+automatisch das DOSSIER-Gestaltung-Panel und bringen es in den Vordergrund.
+
+Idempotent — Re-Install nach _reset_panels deregistriert alten Handler.
+"""
+import Rhino
+import scriptcontext as sc
+import System
+
+
+# Commands bei denen wir Gestaltung-Panel fokussieren.
+# CommandEnglishName ohne Underscore-Prefix.
+_DRAWING_COMMANDS = {
+ "Line", "Polyline", "Curve", "InterpCrv",
+ "Arc", "Circle", "Ellipse",
+ "Rectangle", "Polygon",
+ "Hatch", "Text",
+ "Point", "Points",
+ "InfiniteLine",
+}
+
+_GESTALTUNG_PANEL_GUID = "4b8d3f2e-5c9d-4e0f-b2c3-d4e5f6071829"
+_HANDLER_KEY = "_dossier_begin_cmd_handler"
+_VERBOSE_KEY = "_dossier_begin_cmd_verbose"
+
+
+def _on_begin_command(sender, e):
+ try:
+ cmd = getattr(e, "CommandEnglishName", "") or ""
+ if sc.sticky.get(_VERBOSE_KEY):
+ print("[BEGIN-CMD] cmd='{}'".format(cmd))
+ if cmd not in _DRAWING_COMMANDS: return
+ try:
+ guid = System.Guid(_GESTALTUNG_PANEL_GUID)
+ Rhino.UI.Panels.OpenPanel(guid)
+ try:
+ Rhino.UI.Panels.FocusPanel(guid)
+ except Exception: pass
+ if sc.sticky.get(_VERBOSE_KEY):
+ print("[BEGIN-CMD] Gestaltung-Panel geoeffnet/fokussiert")
+ except Exception as ex:
+ print("[BEGIN-CMD] OpenPanel:", ex)
+ try:
+ Rhino.RhinoApp.RunScript(
+ '-_ShowPanel "DOSSIER Gestaltung"', False)
+ except Exception: pass
+ except Exception as ex:
+ print("[BEGIN-CMD] handler:", ex)
+
+
+def install(verbose=False):
+ """Einmalige Registrierung. Bei Re-Install (z.B. nach _reset_panels)
+ wird der alte Handler-Ref aus sc.sticky deregistriert."""
+ old = sc.sticky.get(_HANDLER_KEY)
+ if old is not None:
+ try: Rhino.Commands.Command.BeginCommand -= old
+ except Exception: pass
+ try:
+ Rhino.Commands.Command.BeginCommand += _on_begin_command
+ sc.sticky[_HANDLER_KEY] = _on_begin_command
+ sc.sticky[_VERBOSE_KEY] = bool(verbose)
+ print("[BEGIN-CMD] Hook installed (verbose={})".format(bool(verbose)))
+ except Exception as ex:
+ print("[BEGIN-CMD] install:", ex)
+
+
+def set_verbose(flag):
+ sc.sticky[_VERBOSE_KEY] = bool(flag)
+
+
+if __name__ == "__main__":
+ install(verbose=True)
diff --git a/rhino/elemente.py b/rhino/elemente.py
index cad7d50..a4182bb 100644
--- a/rhino/elemente.py
+++ b/rhino/elemente.py
@@ -40,6 +40,7 @@ _KEY_WAND_LAYERED = "dossier_wand_layered" # "1" = mehrschichtig, sonst solid
_KEY_WAND_LAYERS = "dossier_wand_layers" # JSON-Liste [{name, dicke, color}]
_KEY_WAND_LAYER_IDX = "dossier_wand_layer_idx" # Layer-Index am Volume-Brep
_KEY_WAND_CHAIN_MEMBERS = "dossier_wand_chain_members" # JSON-Liste wand_ids einer Polyline-Chain (nur auf wand_volume)
+_KEY_WAND_STYLE_ID = "dossier_wand_style_id" # Verweis auf Project-Settings wand_styles[].id
_KEY_DACH_NEIGUNG = "dossier_dach_neigung" # Grad als string ("30")
_KEY_DACH_EAVE = "dossier_dach_eave" # Index der Traufkante (string)
_KEY_DACH_TYP = "dossier_dach_typ" # "pult"|"sattel"|"walm"|"mansarde"
@@ -1660,11 +1661,12 @@ def _make_treppe_wendel_preview(center, start, breite, referenz, n_stufen,
return handler
-def _make_treppe_l_corner_preview(p0, breite, referenz, total_n, total_h):
+def _make_treppe_l_corner_preview(p0, breite, referenz, total_n, total_h,
+ max_length=None):
"""Preview fuer den 2. Klick einer L-Treppe (Podest-Eck). Zeigt:
- - Lauflinie + Aussenkanten
- - Step-Lines an A_opt-Abstaenden (zeigt wo jeder Tritt landet)
- - Live-Label mit N1 (Stufen vor Podest) und N2 (nach Podest)
+ - Lauflinie + Aussenkanten (geclamped auf max_length wenn gesetzt)
+ - Step-Lines an A_opt-Abstaenden
+ - Live-Label mit N1 / N2
"""
import System.Drawing as SD
color_axis = SD.Color.FromArgb(255, 95, 200, 180)
@@ -1687,11 +1689,19 @@ def _make_treppe_l_corner_preview(p0, breite, referenz, total_n, total_h):
def handler(sender, e):
try:
- mouse = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0)
- tan_vec = rg.Vector3d(mouse.X - p0_xy.X, mouse.Y - p0_xy.Y, 0)
- L = tan_vec.Length
- if L < 1e-4: return
+ mouse_raw = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0)
+ tan_vec = rg.Vector3d(mouse_raw.X - p0_xy.X, mouse_raw.Y - p0_xy.Y, 0)
+ L_raw = tan_vec.Length
+ if L_raw < 1e-4: return
tan_vec.Unitize()
+ # Max-Length-Clamp (= aktivierter Schrittmass-Lock / Regel-Modus)
+ if max_length is not None and L_raw > max_length:
+ L = max_length
+ mouse = rg.Point3d(p0_xy.X + tan_vec.X * L,
+ p0_xy.Y + tan_vec.Y * L, 0)
+ else:
+ L = L_raw
+ mouse = mouse_raw
perp = rg.Vector3d(-tan_vec.Y, tan_vec.X, 0)
try: e.Display.DrawLine(p0_xy, mouse, color_axis, 2)
@@ -2033,13 +2043,17 @@ def _miter_dir(out_a, out_b):
def _detect_t_junction(doc, geschoss_id, wall_id, endpoint,
- pos_tol=0.01, end_tol=0.05, exclude_ids=None):
+ pos_tol=0.001, end_tol=0.05, exclude_ids=None):
"""Sucht ob `endpoint` auf der INNEREN Achse einer anderen Wand liegt
(T-Stoss). Endpunkte der anderen Wand (Eckverbindung) werden bewusst
ausgeschlossen — die werden bereits durch die Corner-Logik abgedeckt.
`exclude_ids` (optional): zusaetzliche wall_ids die ignoriert werden
sollen (Chain-Members), sonst nur wall_id.
- Liefert (other_wall_id, b_tangent_vec3, b_dicke) oder None."""
+ Liefert (other_wall_id, b_tangent_vec3, b_dicke) oder None.
+
+ pos_tol=1mm: T-Stoss feuert nur bei echtem Snap auf die Achse. Bei
+ lockerer 1cm-Toleranz triggerte T-Stoss bei jeder beliebigen Wand die
+ zufaellig im Nahbereich war (z.B. nach Move/Rotate) → falsche Mitres."""
skip = set(exclude_ids or ())
skip.add(wall_id)
for obj in doc.Objects:
@@ -2068,11 +2082,47 @@ def _detect_t_junction(doc, geschoss_id, wall_id, endpoint,
return None
+def _resolve_corner_miter(doc, meta, p_pt, out_dir,
+ partner_wid, partner_end, partner_out):
+ """Liefert (miter_pt, miter_dir) fuer einen Corner-Joint mit Style-Prio-
+ Dominanz. None = kein Miter (= flat cap an dieser Seite).
+
+ Regel:
+ - Gleiche Prio (oder beide ohne Style): klassischer Winkelhalbierender-Miter
+ - Ich habe hoehere Prio: ich gewinne die Ecke → kein Miter (eigener Cap)
+ - Ich habe niedrigere Prio: ich fuege mich → T-Stoss-Miter an Partner's
+ nahe Aussenflaeche
+ """
+ p_meta = _wand_meta_by_id(doc, partner_wid)
+ my_prio = _wand_meta_prio(doc, meta)
+ other_prio = _wand_meta_prio(doc, p_meta)
+ if my_prio == other_prio:
+ mdir = _miter_dir(out_dir, partner_out)
+ return (p_pt, mdir) if mdir is not None else None
+ if my_prio > other_prio:
+ # Ich dominiere → kein Miter, eigener Endpunkt wird flat gecappt
+ return None
+ # Ich verliere → T-Stoss gegen Partner's Achse
+ partner_axis = _find_axis(doc, partner_wid)
+ if partner_axis is None: return None
+ p_geom = partner_axis.Geometry
+ if not isinstance(p_geom, rg.Curve): return None
+ tan = (p_geom.TangentAtStart if partner_end == "start"
+ else p_geom.TangentAtEnd)
+ b_tan = rg.Vector3d(tan.X, tan.Y, 0)
+ b_dicke = float((p_meta or {}).get("dicke", 0.25))
+ return _t_junction_miter(p_pt, out_dir, b_tan, b_dicke)
+
+
def _t_junction_miter(endpoint, out_dir, b_tan, b_dicke):
"""Berechnet (miter_pt, miter_dir) fuer einen T-Stoss.
miter_dir = Tangente der Durchgangs-Wand (Linie laeuft parallel zu B's Achse).
miter_pt = endpoint verschoben um d_B/2 in Approach-Richtung — also auf
- der NAHEN Aussenflaeche von B (der Seite an der A ankommt)."""
+ der NAHEN Aussenflaeche von B (der Seite an der A ankommt).
+
+ A (= T-Stem, das zweite Wand-Stueck) wird an dieser Stelle abgeschnitten;
+ B (= Durchgangswand) bleibt unveraendert. Das ist das BIM-Standard-
+ Verhalten: 'erste Wand bleibt, zweite haengt sich dran'."""
perp_b = rg.Vector3d(-b_tan.Y, b_tan.X, 0)
try: perp_b.Unitize()
except Exception: return None
@@ -2107,9 +2157,24 @@ def _t_junction_miter(endpoint, out_dir, b_tan, b_dicke):
def _wand_chain_compat(meta_a, meta_b):
"""Sind zwei Waende kompatibel fuer einen gemeinsamen Polyline-Chain?
Wenn irgendein geometrie-relevanter Parameter abweicht: nein. Sonst
- waere das gemeinsame Volume nicht sauber baubar."""
+ waere das gemeinsame Volume nicht sauber baubar.
+
+ Style-Shortcut: wenn beide Walls den GLEICHEN style_id haben → kompatibel
+ (Style-Definition garantiert dass alle abgeleiteten Parameter identisch sind).
+ Wenn unterschiedliche style_ids → NICHT kompatibel (Phase 3: dann gilt
+ Prio-Dominanz mit T-Mitering, kein Chain-Merge)."""
if not meta_a or not meta_b: return False
if meta_a.get("geschoss") != meta_b.get("geschoss"): return False
+ # Style-Shortcut: identische style_ids → direkt kompatibel
+ sa = (meta_a.get("wand_style_id") or "").strip()
+ sb = (meta_b.get("wand_style_id") or "").strip()
+ if sa and sb:
+ return sa == sb
+ # Mixed-Mode (einer hat style_id, anderer nicht): nicht kompatibel —
+ # falls Style gesetzt aber andere Wand legacy ist, wollen wir Solo-Build.
+ if (sa and not sb) or (sb and not sa):
+ return False
+ # Beide ohne style_id → Legacy-Verhalten: alle Parameter vergleichen.
if abs(float(meta_a.get("dicke", 0)) - float(meta_b.get("dicke", 0))) > 1e-6:
return False
if meta_a.get("referenz", "mid") != meta_b.get("referenz", "mid"):
@@ -2135,71 +2200,16 @@ def _wand_chain_compat(meta_a, meta_b):
def _find_wall_chain(doc, wall_id):
- """Liefert ORDERED Liste der wall_ids im Polyline-Chain von wall_id.
- Reihenfolge: vom Chain-Start bis zum Chain-Ende (so dass die Achsen
- aneinander anschliessen). wall_id selbst ist immer dabei.
- Bei nicht-Wand oder Wand nicht gefunden: leere Liste.
- Stop-Bedingungen: Verzweigung (>=2 Nachbarn am Joint), T-Stoss,
- inkompatibler Nachbar, oder kein Nachbar."""
+ """Chain-Logik DEAKTIVIERT (Plan 3B Phase 1): jede Wand baut ihr eigenes
+ Volume. So sind Volumes pro Achsen-Segment einzeln anwaehlbar/loeschbar.
+ Sauberer Joint-Visual kommt ueber per-Wand Miter (siehe miter_start/end
+ in _regenerate_element_body) — adjacent compat-Walls mitern in dieselbe
+ Plane, treffen perfekt aufeinander, Naht ist nur eine 1px-Edge.
+ Section-Hatch-Verbinden wird in Phase 3 ueber Hatch-Boundary-Union geloest."""
src, meta = _find_source(doc, wall_id)
if src is None or meta is None or meta.get("type") != "wand_axis":
return []
- geschoss = meta["geschoss"]
- joints = _collect_wall_joints(doc, geschoss)
- meta_by_id = {wall_id: meta}
- geom_by_id = {wall_id: src.Geometry}
- for obj in doc.Objects:
- m = _read_meta(obj)
- if not m or m["type"] != "wand_axis": continue
- if m["geschoss"] != geschoss: continue
- if m["id"] in meta_by_id: continue
- meta_by_id[m["id"]] = m
- geom_by_id[m["id"]] = obj.Geometry
-
- def _chain_neighbor(cur_id, cur_pt):
- """Am gemeinsamen Punkt: liefert (neighbor_id, neighbor_end) wenn
- es genau einen kompatiblen Nachbarn gibt, sonst None. neighbor_end
- ist "start" oder "end" — welcher Endpunkt von neighbor an cur_pt
- sitzt."""
- key = _pt_key(cur_pt)
- partners = [(p_wid, p_end)
- for (p_wid, p_end, _od) in joints.get(key, [])
- if p_wid != cur_id]
- if len(partners) != 1: return None
- p_wid, p_end = partners[0]
- if not _wand_chain_compat(meta_by_id.get(cur_id),
- meta_by_id.get(p_wid)):
- return None
- return (p_wid, p_end)
-
- chain = [wall_id]
- visited = {wall_id}
- # Vorwaerts: ans "end" der aktuellen Wand entlang
- cur_id = wall_id
- cur_pt = geom_by_id[cur_id].PointAtEnd
- while True:
- nb = _chain_neighbor(cur_id, cur_pt)
- if nb is None: break
- p_wid, p_end = nb
- if p_wid in visited: break
- chain.append(p_wid); visited.add(p_wid)
- cur_id = p_wid
- # Anderer Endpunkt des Nachbarn ist der naechste Walk-Point
- cur_pt = (geom_by_id[p_wid].PointAtEnd if p_end == "start"
- else geom_by_id[p_wid].PointAtStart)
- # Rueckwaerts: ans "start" der aktuellen Wand entlang
- cur_id = wall_id
- cur_pt = geom_by_id[cur_id].PointAtStart
- while True:
- nb = _chain_neighbor(cur_id, cur_pt)
- if nb is None: break
- p_wid, p_end = nb
- if p_wid in visited: break
- chain.insert(0, p_wid); visited.add(p_wid)
- cur_id = p_wid
- cur_pt = (geom_by_id[p_wid].PointAtEnd if p_end == "start"
- else geom_by_id[p_wid].PointAtStart)
- return chain
+ return [wall_id]
def _chain_anchor(chain_ids):
@@ -2457,6 +2467,53 @@ def _get_all_materials(doc):
return merged
+def _get_all_wand_styles(doc):
+ """Liefert alle Wand-Stile aus den Project-Settings. Wenn keine
+ konfiguriert (auch keine Defaults greifbar), leere Liste."""
+ try:
+ import rhinopanel
+ ps = rhinopanel.load_project_settings(doc) if doc else None
+ if isinstance(ps, dict):
+ return list(ps.get("wand_styles", []) or [])
+ except Exception as ex:
+ print("[ELEMENTE] _get_all_wand_styles:", ex)
+ return []
+
+
+def _find_wand_style(doc, style_id):
+ """Liefert das Style-Dict fuer die gegebene id, sonst None."""
+ if not style_id: return None
+ for s in _get_all_wand_styles(doc):
+ if s.get("id") == style_id: return s
+ return None
+
+
+def _default_wand_style(doc):
+ """Liefert den ersten verfuegbaren Wand-Stil (= Default fuer neue Waende
+ ohne explizite Stil-Auswahl). None wenn keiner konfiguriert."""
+ styles = _get_all_wand_styles(doc)
+ return styles[0] if styles else None
+
+
+def _wand_meta_prio(doc, meta):
+ """Liest die Prio (1-999) aus dem zur Wand gehoerenden Style. Default 500
+ wenn kein style_id oder Style nicht in Project-Settings."""
+ if not meta: return 500
+ sid = (meta.get("wand_style_id") or "").strip()
+ if not sid: return 500
+ s = _find_wand_style(doc, sid)
+ if not s: return 500
+ try: return int(s.get("prio", 500))
+ except Exception: return 500
+
+
+def _wand_meta_by_id(doc, wall_id):
+ """Kuerzel: liefert das meta-Dict fuer eine wall_id ueber _find_axis."""
+ obj = _find_axis(doc, wall_id)
+ if obj is None: return None
+ return _read_meta(obj)
+
+
def _set_layer_section_hatch(doc, layer_idx, hatch_name, scale=1.0,
rotation=0.0):
"""Konfiguriert Rhinos native Section-Hatch-Properties am Layer.
@@ -2762,6 +2819,7 @@ def _attach_meta(obj_attrs, wall_id, type_, geschoss, dicke, uk_over, ok_over,
stempel_stil_id=None,
wand_layered=None, wand_layers=None, wand_layer_idx=None,
wand_chain_members=None,
+ wand_style_id=None,
aussp_parent=None):
"""User-Strings auf die Object-Attributes setzen."""
obj_attrs.SetUserString(_KEY_ID, wall_id)
@@ -3059,6 +3117,11 @@ def _attach_meta(obj_attrs, wall_id, type_, geschoss, dicke, uk_over, ok_over,
try: obj_attrs.SetUserString(_KEY_WAND_LAYER_IDX,
"{}".format(int(wand_layer_idx)))
except Exception: pass
+ if wand_style_id is not None:
+ try:
+ obj_attrs.SetUserString(_KEY_WAND_STYLE_ID,
+ str(wand_style_id or ""))
+ except Exception: pass
if wand_chain_members is not None:
try:
import json as _json
@@ -3452,6 +3515,7 @@ def _read_meta(obj):
"wand_layers": w_layers,
"wand_layer_idx": w_layer_idx,
"wand_chain_members": w_chain_members,
+ "wand_style_id": a.GetUserString(_KEY_WAND_STYLE_ID) or "",
"aussp_parent": aussp_parent_raw,
}
except Exception:
@@ -3484,6 +3548,50 @@ def _find_axis(doc, wall_id):
return None
+def _wall_group_index(doc, wall_id, create_if_missing=True):
+ """Liefert den Rhino-Group-Index fuer eine Wand (Group enthaelt axis +
+ alle volumes). Wenn noch keine existiert + create_if_missing: erstelle
+ eine neue Gruppe und ordne die Achse zu. -1 wenn axis nicht gefunden."""
+ axis_obj = _find_axis(doc, wall_id)
+ if axis_obj is None: return -1
+ try:
+ grp_list = axis_obj.Attributes.GetGroupList()
+ except Exception:
+ grp_list = None
+ if grp_list is not None and len(grp_list) > 0:
+ return int(grp_list[0])
+ if not create_if_missing: return -1
+ try:
+ grp_idx = doc.Groups.Add()
+ except Exception as ex:
+ print("[ELEMENTE] wall-group Add:", ex)
+ return -1
+ try:
+ new_attrs = axis_obj.Attributes.Duplicate()
+ new_attrs.AddToGroup(grp_idx)
+ doc.Objects.ModifyAttributes(axis_obj, new_attrs, True)
+ except Exception as ex:
+ print("[ELEMENTE] wall-group attach axis:", ex)
+ return grp_idx
+
+
+def _add_to_wall_group(doc, obj_id, wall_id):
+ """Fuegt obj_id (z.B. neu erzeugtes wand_volume) in die Group der Wand
+ (axis+volumes). UX: damit selektiert ein Klick beide → kein ChooseOne-
+ Dialog + Delete eines selektierten Volumens loescht auch die Achse."""
+ if obj_id is None or obj_id == System.Guid.Empty: return
+ grp_idx = _wall_group_index(doc, wall_id, create_if_missing=True)
+ if grp_idx < 0: return
+ obj = doc.Objects.FindId(obj_id)
+ if obj is None: return
+ try:
+ new_attrs = obj.Attributes.Duplicate()
+ new_attrs.AddToGroup(grp_idx)
+ doc.Objects.ModifyAttributes(obj, new_attrs, True)
+ except Exception as ex:
+ print("[ELEMENTE] wall-group attach:", ex)
+
+
def _find_volume(doc, wall_id):
# Direkter Hit: Volume gehoert genau dieser Wand
for obj, meta in _find_objects_by_wall_id(doc, wall_id, "wand_volume"):
@@ -5855,59 +5963,61 @@ def _line_intersect_xy(p1, dir1, p2, dir2):
def _make_treppe_l_volume(axis_polyline, breite, referenz, n_stufen, uk, ok,
modus="flach", lauf_d=0.18):
- """L-Treppe. Axis kann 3-Punkt (legacy: Start/Eck/End — Podest am
- Eckpunkt) oder 4-Punkt (Start/Eck1/Eck2/End — Podest zwischen Eck1
- und Eck2) sein. Bauet 2 Laufe + Podest dazwischen."""
+ """L-Treppe. Axis 3-Punkt (Kompakt: Start/Eck/End — half_b Cut-Back
+ am Eck) oder 4-Punkt (Mit Trennung: Start/Lauf1-Ende/Lauf2-Anfang/End
+ — explizites Podest-Segment, kein Cut-Back)."""
if not isinstance(axis_polyline, rg.Curve): return None
try:
ok_pl, poly = axis_polyline.TryGetPolyline()
except Exception:
return None
if not ok_pl or poly is None: return None
- # Auf gemeinsamen 3-Punkt-Layout normalisieren:
- # p0=Start, p1=Podest-Mitte (Eck-Surrogat), p2=Ende
- # Bei 4-Punkt-Axis nehmen wir Mitte zwischen Eck1+Eck2 als p1 —
- # die Lauflaengen werden dann effektiv um halbe-Podest gekuerzt.
- if poly.Count == 4:
- p0 = rg.Point3d(poly[0].X, poly[0].Y, 0)
- pc1 = rg.Point3d(poly[1].X, poly[1].Y, 0)
- pc2 = rg.Point3d(poly[2].X, poly[2].Y, 0)
- p2 = rg.Point3d(poly[3].X, poly[3].Y, 0)
- # p1 als Podest-Mitte
- p1 = rg.Point3d((pc1.X + pc2.X) * 0.5, (pc1.Y + pc2.Y) * 0.5, 0)
- elif poly.Count == 3:
- p0 = rg.Point3d(poly[0].X, poly[0].Y, 0)
- p1 = rg.Point3d(poly[1].X, poly[1].Y, 0)
- p2 = rg.Point3d(poly[2].X, poly[2].Y, 0)
- else:
- return None
-
- v1 = rg.Vector3d(p1.X - p0.X, p1.Y - p0.Y, 0)
- v2 = rg.Vector3d(p2.X - p1.X, p2.Y - p1.Y, 0)
- L1 = v1.Length
- L2 = v2.Length
- half_b = float(breite) * 0.5
- # Cut-back am Eckpunkt: Lage=mid → half_b (treppe extends ±b/2 perp,
- # halbe Ueberlappung pro Lauf). Lage=links/rechts → FULL b (treppe
- # extends 0..b auf einer Seite, ganze Ueberlappung pro Lauf).
- cut_back = half_b if referenz == "mid" else float(breite)
- if L1 < cut_back + 0.05 or L2 < cut_back + 0.05:
- print("[ELEMENTE] L-Treppe: Lauflinien zu kurz fuer Podest")
- return None
H = float(ok) - float(uk)
if H <= 1e-6: return None
N = max(2, int(n_stufen))
S = H / N
+ half_b = float(breite) * 0.5
+
+ if poly.Count == 4:
+ # Explizit-Podest: Laeufe gehen exakt von Klick-Punkten ohne Cut-Back.
+ p0 = rg.Point3d(poly[0].X, poly[0].Y, 0)
+ run1_end = rg.Point3d(poly[1].X, poly[1].Y, 0)
+ run2_start = rg.Point3d(poly[2].X, poly[2].Y, 0)
+ p2 = rg.Point3d(poly[3].X, poly[3].Y, 0)
+ v1 = rg.Vector3d(run1_end.X - p0.X, run1_end.Y - p0.Y, 0)
+ v2 = rg.Vector3d(p2.X - run2_start.X, p2.Y - run2_start.Y, 0)
+ eff_L1 = v1.Length
+ eff_L2 = v2.Length
+ if eff_L1 < 0.05 or eff_L2 < 0.05:
+ print("[ELEMENTE] L-Treppe: Lauflinien zu kurz")
+ return None
+ elif poly.Count == 3:
+ # Kompakt: KEIN Cut-Back (lauf geht bis zum Eckpunkt). Lage=links/
+ # rechts hat keine Lauf-Ueberlappung am Eck, also brauchen wir
+ # auch keinen Versatz. Podest = b×b Quadrat am inneren Eck.
+ # Minimum-Lauflaenge: 3 Stufen × A_min ≈ 0.63m.
+ p0 = rg.Point3d(poly[0].X, poly[0].Y, 0)
+ p1 = rg.Point3d(poly[1].X, poly[1].Y, 0)
+ p2 = rg.Point3d(poly[2].X, poly[2].Y, 0)
+ v1 = rg.Vector3d(p1.X - p0.X, p1.Y - p0.Y, 0)
+ v2 = rg.Vector3d(p2.X - p1.X, p2.Y - p1.Y, 0)
+ L1 = v1.Length
+ L2 = v2.Length
+ if L1 < 0.21 or L2 < 0.21:
+ print("[ELEMENTE] L-Treppe: Lauflinie zu kurz (min 1 Stufe)")
+ return None
+ run1_end = rg.Point3d(p1.X, p1.Y, 0)
+ run2_start = rg.Point3d(p1.X, p1.Y, 0)
+ eff_L1 = L1
+ eff_L2 = L2
+ else:
+ return None
- # Stufen-Verteilung: N1 wird aus eff_L1 mit dem optimalen A bestimmt.
- eff_L1 = L1 - cut_back
- eff_L2 = L2 - cut_back
if eff_L1 + eff_L2 <= 0: return None
- A_opt = 0.63 - 2.0 * S
- if A_opt < 0.21: A_opt = 0.21
- if A_opt > 0.35: A_opt = 0.35
- N1 = int(round(eff_L1 / A_opt))
+ # Uniforme A: N1 proportional zu eff_L1 / (eff_L1+eff_L2). So sind die
+ # Tritte in Lauf 1 und Lauf 2 gleich gross (innerhalb der Rundungstoleranz).
+ N1 = int(round(N * eff_L1 / (eff_L1 + eff_L2)))
if N1 < 1: N1 = 1
if N1 > N - 1: N1 = N - 1
N2 = N - N1
@@ -5915,15 +6025,11 @@ def _make_treppe_l_volume(axis_polyline, breite, referenz, n_stufen, uk, ok,
v1u = rg.Vector3d(v1); v1u.Unitize()
v2u = rg.Vector3d(v2); v2u.Unitize()
- # Run 1: von p0 bis p1 - v1u*cut_back
- run1_end = rg.Point3d(p1.X - v1u.X * cut_back, p1.Y - v1u.Y * cut_back, 0)
line1 = rg.LineCurve(p0, run1_end)
z_podest = float(uk) + N1 * S
brep1 = _make_treppe_volume(line1, breite, referenz, N1,
float(uk), z_podest, modus, lauf_d)
- # Run 2: von p1 + v2u*cut_back bis p2
- run2_start = rg.Point3d(p1.X + v2u.X * cut_back, p1.Y + v2u.Y * cut_back, 0)
line2 = rg.LineCurve(run2_start, p2)
brep2 = _make_treppe_volume(line2, breite, referenz, N2,
z_podest, float(ok), modus, lauf_d)
@@ -5935,10 +6041,13 @@ def _make_treppe_l_volume(axis_polyline, breite, referenz, n_stufen, uk, ok,
perp1 = rg.Vector3d(-v1u.Y, v1u.X, 0)
perp2 = rg.Vector3d(-v2u.Y, v2u.X, 0)
b = float(breite)
+ # Konvention identisch zu _make_treppe_volume:
+ # Lage=links → Lauflinie ist linke Treppen-Kante, Lauf nach -perp (rechts).
+ # Lage=rechts → Lauflinie rechts, Lauf nach +perp (links).
if referenz == "links":
- perp_lo, perp_hi = 0.0, +b
- elif referenz == "rechts":
perp_lo, perp_hi = -b, 0.0
+ elif referenz == "rechts":
+ perp_lo, perp_hi = 0.0, +b
else: # mid
perp_lo, perp_hi = -half_b, +half_b
@@ -5965,8 +6074,6 @@ def _make_treppe_l_volume(axis_polyline, breite, referenz, n_stufen, uk, ok,
podest_brep = None
try:
- # Hexagon-Vertices in CCW-Order:
- # end_lo → corner_lo → start_lo → start_hi → corner_hi → end_hi
def _add_unique(arr, p, tol=1e-5):
if p is None: return
if not arr: arr.append(p); return
@@ -5974,6 +6081,10 @@ def _make_treppe_l_volume(axis_polyline, breite, referenz, n_stufen, uk, ok,
if (last.X - p.X) ** 2 + (last.Y - p.Y) ** 2 < tol * tol: return
arr.append(p)
+ # Hexagon CCW: end_lo → corner_lo → start_lo → start_hi → corner_hi → end_hi.
+ # Setzt voraus dass der Lauf auf der AUSSENSEITE des Bends ist
+ # (Bend-Constraint im Construction-Pfad). Sonst kollabiert das
+ # Hexagon zu einem self-intersecting Polygon.
verts = []
_add_unique(verts, end_lo)
_add_unique(verts, corner_lo)
@@ -6088,20 +6199,17 @@ def _treppe_2d_enabled(doc):
def _treppe_2d_arrow_curves(p_tail, p_head, head_size, style="klassisch",
- doc=None, wide_off_l=None, wide_off_r=None):
+ doc=None, wide_off_l=None, wide_off_r=None,
+ with_shaft=True):
"""Liefert Liste von Items (Curves + ggf. Hatch) fuer einen 2D-Pfeil.
- Schaft (Line) + Spitze gemaess style:
- - 'klassisch': offene V-Spitze (zwei Linien, 30° gespreizt)
- - 'filled': Dreieck-Outline + Solid-Hatch (wirklich ausgefuellt)
- - 'breit': V-Spitze 60° + laenger
- - 'voll': Pfeilspitzen reichen bis zu den Treppen-Aussenkanten
- (braucht wide_off_l + wide_off_r vom Caller)
- Caller-Loop muss zwischen Curve und Hatch unterscheiden beim Add.
+ Schaft (optional) + Spitze gemaess style. Bei Wendel mit Bogen-Schaft:
+ with_shaft=False, dann liefert die Funktion nur die Spitzen-Geometrie.
"""
import math
out = []
try:
- out.append(rg.LineCurve(p_tail, p_head)) # Schaft
+ if with_shaft:
+ out.append(rg.LineCurve(p_tail, p_head)) # Schaft
dx = p_head.X - p_tail.X
dy = p_head.Y - p_tail.Y
L = math.hypot(dx, dy)
@@ -6170,8 +6278,10 @@ def _treppe_2d_arrow_curves(p_tail, p_head, head_size, style="klassisch",
def _treppe_2d_side_offsets(breite, referenz):
- """(off_left, off_right) — perpendikulare Offsets von der Lauflinie zu
- den beiden Treppenseiten, vorzeichenbehaftet (perp = rotate90 CCW)."""
+ """(off_a, off_b) — perpendikulare Offsets der beiden Treppenseiten zur
+ Lauflinie. Konvention wie _make_treppe_volume: Lage=links → Lauflinie
+ ist die linke Treppen-Kante, Lauf erstreckt sich nach rechts (perp*[-b,0]).
+ Lage=rechts → Lauflinie rechts, Lauf nach links (perp*[0,+b])."""
b = float(breite)
if referenz == "links": return 0.0, -b
if referenz == "rechts": return 0.0, +b
@@ -6352,15 +6462,24 @@ def _bruch_gerade(axis_curve, breite, referenz, n_stufen, total_h, cut_h, z):
# ----- L-Treppe: delegieren auf beide Laeufe -------------------------------
-def _l_segments(axis_polyline, n_stufen, z):
+def _l_segments(axis_polyline, n_stufen, z, breite=None, total_h=None):
"""Liefert (seg1, seg2, N1, N2, podest_seg) oder None.
- Axis kann 3-Punkt (legacy: P0, corner, P1, podest=None) oder
- 4-Punkt (P0, P1=Ende-Lauf1, P2=Anfang-Lauf2, P3, podest=P1→P2) sein.
- N wird auf die zwei BEGEHBAREN Laeufe verteilt — Podest hat keine
- Stufen."""
+ 3-Punkt-Axis (P0, corner, P2): Laeufe gehen bis zum Eckpunkt (kein
+ Cut-Back). Podest = degeneriertes Segment am Eck (Length 0).
+ 4-Punkt-Axis (P0, P1, P2, P3): expliziter Podest zwischen P1 und P2.
+ N-Verteilung proportional zu Lauflaengen → uniforme Tritte oben/unten."""
if not isinstance(axis_polyline, rg.Curve): return None
ok, poly = axis_polyline.TryGetPolyline()
if not ok or poly is None: return None
+
+ def _n_split(L1, L2):
+ N = max(2, int(n_stufen))
+ if L1 + L2 < 1e-6: return 1, N - 1
+ N1 = int(round(N * L1 / (L1 + L2)))
+ if N1 < 1: N1 = 1
+ if N1 > N - 1: N1 = N - 1
+ return N1, N - N1
+
if poly.Count == 4:
p0 = rg.Point3d(poly[0].X, poly[0].Y, z)
p1 = rg.Point3d(poly[1].X, poly[1].Y, z)
@@ -6369,29 +6488,26 @@ def _l_segments(axis_polyline, n_stufen, z):
L1 = ((p1.X - p0.X) ** 2 + (p1.Y - p0.Y) ** 2) ** 0.5
L2 = ((p3.X - p2.X) ** 2 + (p3.Y - p2.Y) ** 2) ** 0.5
if L1 < 1e-6 or L2 < 1e-6: return None
- N = max(2, int(n_stufen))
- N1 = max(1, int(round(N * L1 / (L1 + L2))))
- N2 = max(1, N - N1)
+ N1, N2 = _n_split(L1, L2)
return (rg.LineCurve(p0, p1), rg.LineCurve(p2, p3), N1, N2,
rg.LineCurve(p1, p2))
if poly.Count == 3:
p0 = rg.Point3d(poly[0].X, poly[0].Y, z)
pc = rg.Point3d(poly[1].X, poly[1].Y, z)
- p1 = rg.Point3d(poly[2].X, poly[2].Y, z)
- L1 = ((pc.X - p0.X) ** 2 + (pc.Y - p0.Y) ** 2) ** 0.5
- L2 = ((p1.X - pc.X) ** 2 + (p1.Y - pc.Y) ** 2) ** 0.5
- if L1 < 1e-6 or L2 < 1e-6: return None
- N = max(2, int(n_stufen))
- N1 = max(1, int(round(N * L1 / (L1 + L2))))
- N2 = max(1, N - N1)
- return rg.LineCurve(p0, pc), rg.LineCurve(pc, p1), N1, N2, None
+ p2 = rg.Point3d(poly[2].X, poly[2].Y, z)
+ L1f = ((pc.X - p0.X) ** 2 + (pc.Y - p0.Y) ** 2) ** 0.5
+ L2f = ((p2.X - pc.X) ** 2 + (p2.Y - pc.Y) ** 2) ** 0.5
+ if L1f < 1e-6 or L2f < 1e-6: return None
+ N1, N2 = _n_split(L1f, L2f)
+ return rg.LineCurve(p0, pc), rg.LineCurve(pc, p2), N1, N2, None
return None
def _tritte_l(axis_polyline, breite, referenz, n_stufen, z,
total_h=0.0, cut_h=0.0):
"""Returnt (lower, upper). Cut bestimmt in welchem Lauf gesplittet wird."""
- segs = _l_segments(axis_polyline, n_stufen, z)
+ segs = _l_segments(axis_polyline, n_stufen, z, breite=breite,
+ total_h=total_h if total_h > 0 else None)
if segs is None: return [], []
s1, s2, N1, N2, _pod = segs
H1 = total_h * (N1 / float(N1 + N2)) if total_h > 0 else 0.0
@@ -6416,20 +6532,22 @@ def _tritte_l(axis_polyline, breite, referenz, n_stufen, z,
def _lauflinie_l(axis_polyline, n_stufen, z, arrow_style="klassisch", doc=None,
- breite=None, referenz=None):
- """Lauflinie ueber die L-Treppe. Bei 4-Punkt-Axis: Schaft1 + Podest-
- Verbindung + Schaft2; Pfeil am Ende. Bei 3-Punkt: Eck-projezierte
- Verbindung wie zuvor."""
- segs = _l_segments(axis_polyline, n_stufen, z)
- if segs is None: return []
- s1, s2, _N1, _N2, podest = segs
+ breite=None, referenz=None, total_h=0.0, cut_h=0.0):
+ """Returnt (lower, upper). Lauflinie ueber die L-Treppe mit EINER Ecke
+ (Schnitt der zwei perp-zentrierten Lauflinien = die natuerliche Mitte
+ des Podests). Bei Cut: Lauflinie wird an der Bruch-Position gesplittet
+ mit Pfeil am unteren Teil (wie _lauflinie_gerade)."""
+ segs = _l_segments(axis_polyline, n_stufen, z, breite=breite,
+ total_h=total_h if total_h > 0 else None)
+ if segs is None: return [], []
+ s1, s2, N1, N2, _pod = segs
P0a = s1.PointAtStart; P1a = s1.PointAtEnd
txa, tya = P1a.X - P0a.X, P1a.Y - P0a.Y
La = (txa * txa + tya * tya) ** 0.5
P0b = s2.PointAtStart; P1b = s2.PointAtEnd
txb, tyb = P1b.X - P0b.X, P1b.Y - P0b.Y
Lb = (txb * txb + tyb * tyb) ** 0.5
- if La < 1e-6 or Lb < 1e-6: return []
+ if La < 1e-6 or Lb < 1e-6: return [], []
uxa, uya = txa / La, tya / La
uxb, uyb = txb / Lb, tyb / Lb
mid_off = 0.0
@@ -6445,47 +6563,48 @@ def _lauflinie_l(axis_polyline, n_stufen, z, arrow_style="klassisch", doc=None,
P1a_s = rg.Point3d(P1a.X + pa_x, P1a.Y + pa_y, z)
P0b_s = rg.Point3d(P0b.X + pb_x, P0b.Y + pb_y, z)
P1b_s = rg.Point3d(P1b.X + pb_x, P1b.Y + pb_y, z)
- out = []
head_size = min(0.25, Lb * 0.05)
- if podest is not None:
- # 4-Punkt: Schaft1 + Podest-Schaft (perp-zentriert) + Schaft2
- Ppc1 = podest.PointAtStart; Ppc2 = podest.PointAtEnd
- txp, typ = Ppc2.X - Ppc1.X, Ppc2.Y - Ppc1.Y
- Lp = (txp * txp + typ * typ) ** 0.5
- if Lp > 1e-6:
- uxp, uyp = txp / Lp, typ / Lp
- pp_x, pp_y = -uyp * mid_off, uxp * mid_off
- Ppc1_s = rg.Point3d(Ppc1.X + pp_x, Ppc1.Y + pp_y, z)
- Ppc2_s = rg.Point3d(Ppc2.X + pp_x, Ppc2.Y + pp_y, z)
- # Eck1: Schnitt Schaft1 + Podest-Schaft
- corner_a = _line_intersect_xy(
- P0a_s, rg.Vector3d(uxa, uya, 0),
- Ppc1_s, rg.Vector3d(uxp, uyp, 0)) if abs(mid_off) > 1e-6 else P1a_s
- if corner_a is None: corner_a = P1a_s
- else: corner_a = rg.Point3d(corner_a.X, corner_a.Y, z)
- # Eck2: Schnitt Podest-Schaft + Schaft2
- corner_b = _line_intersect_xy(
- Ppc1_s, rg.Vector3d(uxp, uyp, 0),
- P0b_s, rg.Vector3d(uxb, uyb, 0)) if abs(mid_off) > 1e-6 else P0b_s
- if corner_b is None: corner_b = P0b_s
- else: corner_b = rg.Point3d(corner_b.X, corner_b.Y, z)
- out.append(rg.LineCurve(P0a_s, corner_a))
- out.append(rg.LineCurve(corner_a, corner_b))
- out.extend(_treppe_2d_arrow_curves(corner_b, P1b_s, head_size,
- arrow_style, doc, wide_l, wide_r))
- return out
- # 3-Punkt-Fallback: Eck als Schnitt projezieren
+ # EINE Ecke: Schnitt der beiden Schaft-Lauflinien (perp-zentriert)
if abs(mid_off) > 1e-6:
meet = _line_intersect_xy(P0a_s, rg.Vector3d(uxa, uya, 0),
P1b_s, rg.Vector3d(uxb, uyb, 0))
- if meet is None: meet = P1a_s
- else: meet = rg.Point3d(meet.X, meet.Y, z)
+ meet = P1a_s if meet is None else rg.Point3d(meet.X, meet.Y, z)
else:
meet = P1a_s
- out.append(rg.LineCurve(P0a_s, meet))
- out.extend(_treppe_2d_arrow_curves(meet, P1b_s, head_size, arrow_style,
- doc, wide_l, wide_r))
- return out
+ # Cut?
+ H1 = total_h * (N1 / float(N1 + N2)) if total_h > 0 else 0.0
+ H2 = total_h - H1
+ cut1 = _cut_idx_gerade(N1, H1, cut_h) if total_h > 0 else -1
+ cut2 = _cut_idx_gerade(N2, H2, cut_h - H1) if total_h > 0 else -1
+ if cut1 < 0 and cut2 < 0:
+ out = [rg.LineCurve(P0a_s, meet)]
+ out.extend(_treppe_2d_arrow_curves(meet, P1b_s, head_size,
+ arrow_style, doc, wide_l, wide_r))
+ return out, []
+ if cut1 >= 0:
+ pos, _d, gap = _bruch_diag_params(La, N1, cut1)
+ t_lo = pos - gap * 0.5
+ t_hi = pos + gap * 0.5
+ lower_head = rg.Point3d(P0a_s.X + uxa * t_lo, P0a_s.Y + uya * t_lo, z)
+ upper_tail = rg.Point3d(P0a_s.X + uxa * t_hi, P0a_s.Y + uya * t_hi, z)
+ lower = _treppe_2d_arrow_curves(P0a_s, lower_head, head_size,
+ arrow_style, doc, wide_l, wide_r)
+ upper = [rg.LineCurve(upper_tail, meet)]
+ upper.extend(_treppe_2d_arrow_curves(meet, P1b_s, head_size,
+ arrow_style, doc, wide_l, wide_r))
+ return lower, upper
+ # cut2 >= 0
+ pos, _d, gap = _bruch_diag_params(Lb, N2, cut2)
+ t_lo = pos - gap * 0.5
+ t_hi = pos + gap * 0.5
+ lower_head = rg.Point3d(P0b_s.X + uxb * t_lo, P0b_s.Y + uyb * t_lo, z)
+ upper_tail = rg.Point3d(P0b_s.X + uxb * t_hi, P0b_s.Y + uyb * t_hi, z)
+ lower = [rg.LineCurve(P0a_s, meet)]
+ lower.extend(_treppe_2d_arrow_curves(meet, lower_head, head_size,
+ arrow_style, doc, wide_l, wide_r))
+ upper = _treppe_2d_arrow_curves(upper_tail, P1b_s, head_size,
+ arrow_style, doc, wide_l, wide_r)
+ return lower, upper
def _aussen_l_polygon(axis_polyline, breite, referenz, z):
@@ -6555,40 +6674,125 @@ def _aussen_l_polygon(axis_polyline, breite, referenz, z):
return None
+def _podest_l_outline(axis_polyline, breite, referenz, z):
+ """Liefert die 2D-Outline des L-Podests (= gleiches Hexagon wie 3D-Podest
+ in _make_treppe_l_volume). 3-Punkt mit cut-back oder 4-Punkt mit
+ explizitem Podest-Segment. Returnt PolylineCurve oder None."""
+ if not isinstance(axis_polyline, rg.Curve): return None
+ try:
+ ok, poly = axis_polyline.TryGetPolyline()
+ except Exception:
+ return None
+ if not ok or poly is None: return None
+ half_b = float(breite) * 0.5
+ if poly.Count == 4:
+ # Explizit: run1_end = poly[1], run2_start = poly[2]
+ p_a = rg.Point3d(poly[0].X, poly[0].Y, z)
+ run1_end = rg.Point3d(poly[1].X, poly[1].Y, z)
+ run2_start = rg.Point3d(poly[2].X, poly[2].Y, z)
+ p_b = rg.Point3d(poly[3].X, poly[3].Y, z)
+ v1u = rg.Vector3d(run1_end.X - p_a.X, run1_end.Y - p_a.Y, 0)
+ v2u = rg.Vector3d(p_b.X - run2_start.X, p_b.Y - run2_start.Y, 0)
+ if v1u.Length < 1e-6 or v2u.Length < 1e-6: return None
+ v1u.Unitize(); v2u.Unitize()
+ elif poly.Count == 3:
+ # KEIN Cut-Back: run1_end = run2_start = pc (Eckpunkt). Podest =
+ # b×b Quadrat am Eck.
+ p0 = rg.Point3d(poly[0].X, poly[0].Y, z)
+ pc = rg.Point3d(poly[1].X, poly[1].Y, z)
+ p2 = rg.Point3d(poly[2].X, poly[2].Y, z)
+ v1u = rg.Vector3d(pc.X - p0.X, pc.Y - p0.Y, 0)
+ v2u = rg.Vector3d(p2.X - pc.X, p2.Y - pc.Y, 0)
+ if v1u.Length < 1e-6 or v2u.Length < 1e-6: return None
+ v1u.Unitize(); v2u.Unitize()
+ run1_end = rg.Point3d(pc.X, pc.Y, z)
+ run2_start = rg.Point3d(pc.X, pc.Y, z)
+ else:
+ return None
+ perp1 = rg.Vector3d(-v1u.Y, v1u.X, 0)
+ perp2 = rg.Vector3d(-v2u.Y, v2u.X, 0)
+ b = float(breite)
+ if referenz == "links":
+ perp_lo, perp_hi = -b, 0.0
+ elif referenz == "rechts":
+ perp_lo, perp_hi = 0.0, +b
+ else:
+ perp_lo, perp_hi = -half_b, +half_b
+ end_lo = rg.Point3d(run1_end.X + perp1.X * perp_lo,
+ run1_end.Y + perp1.Y * perp_lo, z)
+ end_hi = rg.Point3d(run1_end.X + perp1.X * perp_hi,
+ run1_end.Y + perp1.Y * perp_hi, z)
+ start_lo = rg.Point3d(run2_start.X + perp2.X * perp_lo,
+ run2_start.Y + perp2.Y * perp_lo, z)
+ start_hi = rg.Point3d(run2_start.X + perp2.X * perp_hi,
+ run2_start.Y + perp2.Y * perp_hi, z)
+ minus_v2 = rg.Vector3d(-v2u.X, -v2u.Y, 0)
+ corner_lo = _line_intersect_xy(end_lo, v1u, start_lo, minus_v2)
+ corner_hi = _line_intersect_xy(end_hi, v1u, start_hi, minus_v2)
+ def _at_z(p, fb):
+ return rg.Point3d(p.X, p.Y, z) if p is not None else fb
+ corner_lo = _at_z(corner_lo, run1_end)
+ corner_hi = _at_z(corner_hi, run1_end)
+ def _add_unique(arr, p, tol=1e-5):
+ if p is None: return
+ if not arr: arr.append(p); return
+ last = arr[-1]
+ if (last.X - p.X) ** 2 + (last.Y - p.Y) ** 2 < tol * tol: return
+ arr.append(p)
+ verts = []
+ _add_unique(verts, end_lo)
+ _add_unique(verts, corner_lo)
+ _add_unique(verts, start_lo)
+ _add_unique(verts, start_hi)
+ _add_unique(verts, corner_hi)
+ _add_unique(verts, end_hi)
+ if len(verts) < 3: return None
+ verts.append(verts[0])
+ return rg.PolylineCurve(rg.Polyline(verts))
+
+
def _aussen_l(axis_polyline, breite, referenz, z,
total_h=0.0, cut_h=0.0, n_stufen=15):
- """Returnt (lower, upper). Ohne Cut: sauberes L-Polygon (eine Outline).
- Mit Cut: faellt zurueck auf Per-Lauf-Rechtecke mit Diagonal-Cut auf der
- betroffenen Seite — der jeweils andere Lauf wird komplett lower bzw.
- upper (= gestrichelt wenn oben)."""
- segs = _l_segments(axis_polyline, n_stufen, z)
+ """Returnt (lower, upper). Ohne Cut: sauberes L-Polygon + Podest.
+ Mit Cut: Per-Lauf-Rechtecke mit Diagonal-Cut + Podest-Outline."""
+ segs = _l_segments(axis_polyline, n_stufen, z, breite=breite,
+ total_h=total_h if total_h > 0 else None)
if segs is None: return [], []
s1, s2, N1, N2, _pod = segs
H1 = total_h * (N1 / float(N1 + N2)) if total_h > 0 else 0.0
H2 = total_h - H1
cut1 = _cut_idx_gerade(N1, H1, cut_h) if total_h > 0 else -1
cut2 = _cut_idx_gerade(N2, H2, cut_h - H1) if total_h > 0 else -1
- # Ohne Cut: zeichne sauberes L-Polygon
+ podest = _podest_l_outline(axis_polyline, breite, referenz, z)
+ # Ohne Cut: zeichne sauberes L-Polygon + Podest
if cut1 < 0 and cut2 < 0:
poly = _aussen_l_polygon(axis_polyline, breite, referenz, z)
- if poly is not None:
- return [poly], []
- # Fallback: zwei Rechtecke
- l1, _ = _aussen_gerade(s1, breite, referenz, z, -1, None)
- l2, _ = _aussen_gerade(s2, breite, referenz, z, -1, None)
- return l1 + l2, []
+ out_l = [poly] if poly is not None else []
+ if not out_l:
+ l1, _ = _aussen_gerade(s1, breite, referenz, z, -1, None)
+ l2, _ = _aussen_gerade(s2, breite, referenz, z, -1, None)
+ out_l = l1 + l2
+ if podest is not None: out_l.append(podest)
+ return out_l, []
# Mit Cut: per-Lauf, betroffener Lauf wird diagonal gesplittet
if cut1 >= 0:
l1, u1 = _aussen_gerade(s1, breite, referenz, z, cut1, N1)
l2, _ = _aussen_gerade(s2, breite, referenz, z, -1, None)
- return l1, u1 + l2
+ # Podest liegt ueber dem Cut (cut ist in Lauf1) → upper
+ target = u1 + l2
+ if podest is not None: target.append(podest)
+ return l1, target
l1, _ = _aussen_gerade(s1, breite, referenz, z, -1, None)
l2, u2 = _aussen_gerade(s2, breite, referenz, z, cut2, N2)
- return l1 + l2, u2
+ # Podest liegt unter dem Cut (cut ist in Lauf2) → lower
+ target = l1 + l2
+ if podest is not None: target.append(podest)
+ return target, u2
def _bruch_l(axis_polyline, breite, referenz, n_stufen, total_h, cut_h, z):
- segs = _l_segments(axis_polyline, n_stufen, z)
+ segs = _l_segments(axis_polyline, n_stufen, z, breite=breite,
+ total_h=total_h if total_h > 0 else None)
if segs is None: return []
s1, s2, N1, N2, _pod = segs
H1 = total_h * (N1 / float(N1 + N2))
@@ -6642,42 +6846,65 @@ def _tritte_wendel(axis_polyline, breite, referenz, n_stufen, z,
def _lauflinie_wendel(axis_polyline, breite, referenz, n_stufen, z,
- arrow_style="klassisch", doc=None):
+ arrow_style="klassisch", doc=None,
+ total_h=0.0, cut_h=0.0):
+ """Returnt (lower, upper). Bei Cut: Lauflinie wird am Bruch-Winkel
+ gesplittet mit eigenem Pfeil am unteren Teil — wie _lauflinie_gerade.
+ Bogen reicht von a_start bis a_end (= bis zum ersten/letzten Tritt)."""
import math
p = _wendel_params(axis_polyline, breite, referenz, n_stufen, z)
- if p is None: return []
- center, r_inner, r_outer, a_start, delta, _N, da = p
+ if p is None: return [], []
+ center, r_inner, r_outer, a_start, delta, N, da = p
r_mid = (r_inner + r_outer) * 0.5
- margin_a = da * 0.4
- a0 = a_start + margin_a
- a1 = a_start + delta - margin_a
- out = []
- try:
- arc_start = rg.Point3d(center.X + r_mid * math.cos(a0),
- center.Y + r_mid * math.sin(a0), z)
- am = (a0 + a1) * 0.5
- arc_mid = rg.Point3d(center.X + r_mid * math.cos(am),
- center.Y + r_mid * math.sin(am), z)
- arc_end = rg.Point3d(center.X + r_mid * math.cos(a1),
- center.Y + r_mid * math.sin(a1), z)
- arc = rg.Arc(arc_start, arc_mid, arc_end)
- if arc.IsValid:
- out.append(rg.ArcCurve(arc))
- tang_x = -math.sin(a1) * (1 if delta > 0 else -1)
- tang_y = math.cos(a1) * (1 if delta > 0 else -1)
- head_len = min(0.25, abs(r_mid * da) * 0.6)
- tail = rg.Point3d(arc_end.X - tang_x * head_len * 0.3,
- arc_end.Y - tang_y * head_len * 0.3, z)
- # 'voll'-Style: Spitzen-Offsets relativ zum r_mid (Tangenten-perp =
- # radial). Innen → negativ, Aussen → positiv (in perp-Richtung
- # CCW vom Tangenten-Vektor — entspricht der Radial-Richtung).
- wide_in = r_inner - r_mid # negativ
- wide_out = r_outer - r_mid # positiv
- out.extend(_treppe_2d_arrow_curves(tail, arc_end, head_len, arrow_style,
- doc, wide_in, wide_out))
- except Exception as ex:
- print("[ELEMENTE] _lauflinie_wendel:", ex)
- return out
+ a0 = a_start
+ a1 = a_start + delta
+ head_len = min(0.25, abs(r_mid * da) * 0.6)
+ wide_in = r_inner - r_mid
+ wide_out = r_outer - r_mid
+ sign = 1 if delta > 0 else -1
+
+ def _pt(a):
+ return rg.Point3d(center.X + r_mid * math.cos(a),
+ center.Y + r_mid * math.sin(a), z)
+ def _arc(a_from, a_to):
+ try:
+ if abs(a_to - a_from) < 1e-6: return None
+ am = (a_from + a_to) * 0.5
+ arc = rg.Arc(_pt(a_from), _pt(am), _pt(a_to))
+ return rg.ArcCurve(arc) if arc.IsValid else None
+ except Exception: return None
+ def _arrow_at(a_head):
+ # Tangenten-Vektor am Bogen-Ende, Pfeilspitze ohne Schaft
+ # (der Bogen ist der Schaft).
+ tang_x = -math.sin(a_head) * sign
+ tang_y = math.cos(a_head) * sign
+ head = _pt(a_head)
+ tail = rg.Point3d(head.X - tang_x * head_len * 0.3,
+ head.Y - tang_y * head_len * 0.3, z)
+ return _treppe_2d_arrow_curves(tail, head, head_len, arrow_style, doc,
+ wide_in, wide_out, with_shaft=False)
+
+ cut_idx = _cut_idx_gerade(N, total_h, cut_h) if total_h > 0 else -1
+ if cut_idx < 0:
+ out = []
+ a_main = _arc(a0, a1)
+ if a_main: out.append(a_main)
+ out.extend(_arrow_at(a1))
+ return out, []
+ # Mit Cut: split am Bruch-Winkel mit symmetrischem Gap (matcht _bruch_wendel)
+ a_cut = a_start + (cut_idx + 1) * da
+ gap_half = abs(da) * 0.15 * sign
+ a_lo_end = a_cut - gap_half
+ a_hi_start = a_cut + gap_half
+ lower = []
+ a_low_arc = _arc(a0, a_lo_end)
+ if a_low_arc: lower.append(a_low_arc)
+ lower.extend(_arrow_at(a_lo_end))
+ upper = []
+ a_up_arc = _arc(a_hi_start, a1)
+ if a_up_arc: upper.append(a_up_arc)
+ upper.extend(_arrow_at(a1))
+ return lower, upper
def _aussen_wendel(axis_polyline, breite, referenz, z,
@@ -6714,16 +6941,22 @@ def _aussen_wendel(axis_polyline, breite, referenz, z,
out.append(_radial_at(a_start))
out.append(_radial_at(a_end))
return out, []
- # Mit Cut: splitte am Winkel direkt nach cut_idx-Tritt
+ # Mit Cut: splitte am Bruch-Winkel +/- gap_half (matcht _bruch_wendel)
a_cut = a_start + (cut_idx + 1) * da
+ sign = 1 if delta > 0 else -1
+ gap_half = abs(da) * 0.15 * sign
+ a_lo_end = a_cut - gap_half
+ a_hi_start = a_cut + gap_half
lower, upper = [], []
- for arc in (_arc_at(r_inner, a_start, a_cut),
- _arc_at(r_outer, a_start, a_cut)):
+ for arc in (_arc_at(r_inner, a_start, a_lo_end),
+ _arc_at(r_outer, a_start, a_lo_end)):
if arc: lower.append(arc)
lower.append(_radial_at(a_start))
- for arc in (_arc_at(r_inner, a_cut, a_end),
- _arc_at(r_outer, a_cut, a_end)):
+ lower.append(_radial_at(a_lo_end))
+ for arc in (_arc_at(r_inner, a_hi_start, a_end),
+ _arc_at(r_outer, a_hi_start, a_end)):
if arc: upper.append(arc)
+ upper.append(_radial_at(a_hi_start))
upper.append(_radial_at(a_end))
return lower, upper
@@ -6776,8 +7009,10 @@ def _make_treppe_2d_symbol(meta, geom, z, total_h, cut_h, doc=None):
lo, up = _tritte_l(geom, breite, ref, n, z, total_h, eff_cut_h)
solid.extend(lo); upper_target.extend(up)
if show_lau:
- solid.extend(_lauflinie_l(geom, n, z, arrow_style, doc,
- breite, ref))
+ ll_lo, ll_up = _lauflinie_l(geom, n, z, arrow_style, doc,
+ breite, ref,
+ total_h, eff_cut_h)
+ solid.extend(ll_lo); solid.extend(ll_up)
lo, up = _aussen_l(geom, breite, ref, z, total_h, eff_cut_h, n)
solid.extend(lo); upper_target.extend(up)
if show_bru:
@@ -6787,7 +7022,10 @@ def _make_treppe_2d_symbol(meta, geom, z, total_h, cut_h, doc=None):
lo, up = _tritte_wendel(geom, breite, ref, n, z, total_h, eff_cut_h)
solid.extend(lo); upper_target.extend(up)
if show_lau:
- solid.extend(_lauflinie_wendel(geom, breite, ref, n, z, arrow_style, doc))
+ ll_lo, ll_up = _lauflinie_wendel(geom, breite, ref, n, z,
+ arrow_style, doc,
+ total_h, eff_cut_h)
+ solid.extend(ll_lo); solid.extend(ll_up)
lo, up = _aussen_wendel(geom, breite, ref, z, total_h, eff_cut_h, n)
solid.extend(lo); upper_target.extend(up)
if show_bru:
@@ -6830,6 +7068,12 @@ def _regenerate_element(doc, element_id):
return _regenerate_element(doc, parent_id)
return False
geom = src_obj.Geometry
+ # Joint-Cache vor jedem Wand-Regen invalidieren — sonst kann eine alte
+ # Cache-Variante (z.B. von vor einem Replace) noch Stale-T-Junctions zeigen.
+ # Performance: nur O(N) walls beim naechsten _collect_wall_joints-Call,
+ # cache-hit innerhalb des einzelnen Regens bleibt erhalten.
+ if meta.get("type") == "wand_axis":
+ _invalidate_joints_cache(meta.get("geschoss"))
# Stuetze hat Point-Geometrie, alle anderen Source-Typen sind Curves
if meta["type"] == "stuetze_point":
if not (isinstance(geom, rg.Point) or isinstance(geom, rg.Point3d)):
@@ -6968,10 +7212,9 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
for (wid, end, od) in joints.get(key_s, [])
if wid not in chain_set]
if len(partners_s) == 1:
- _wid, _end, other_out = partners_s[0]
- mdir = _miter_dir(out_s, other_out)
- if mdir is not None:
- miter_start = (p_s, mdir)
+ p_wid, p_end, other_out = partners_s[0]
+ miter_start = _resolve_corner_miter(
+ doc, meta, p_s, out_s, p_wid, p_end, other_out)
elif len(partners_s) == 0:
tj = _detect_t_junction(doc, meta["geschoss"],
element_id, p_s,
@@ -6986,10 +7229,9 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
for (wid, end, od) in joints.get(key_e, [])
if wid not in chain_set]
if len(partners_e) == 1:
- _wid, _end, other_out = partners_e[0]
- mdir = _miter_dir(out_e, other_out)
- if mdir is not None:
- miter_end = (p_e, mdir)
+ p_wid, p_end, other_out = partners_e[0]
+ miter_end = _resolve_corner_miter(
+ doc, meta, p_e, out_e, p_wid, p_end, other_out)
elif len(partners_e) == 0:
tj = _detect_t_junction(doc, meta["geschoss"],
element_id, p_e,
@@ -7218,9 +7460,21 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
print("[ELEMENTE] migrate src-layer:", ex)
# Alle alten wand_volume-Objekte loeschen, neue (1..N) hinzufuegen.
+ # Loesche auch alte Chain-Volumes (Anchor-Volumes mit chain_members
+ # die diese Wand mit-enthalten) — sonst bleibt das alte Chain-Brep
+ # stehen wenn der Anchor nicht direkt regent (Migrations-Pfad nach
+ # Chain-Deaktivierung).
for o, _m in _find_objects_by_wall_id(doc, element_id, "wand_volume"):
try: doc.Objects.Delete(o.Id, True)
except Exception: pass
+ for _vobj in list(doc.Objects):
+ _vm = _read_meta(_vobj)
+ if not _vm or _vm.get("type") != "wand_volume": continue
+ if _vm["id"] == element_id: continue # schon oben behandelt
+ _members = _vm.get("wand_chain_members") or []
+ if element_id in _members:
+ try: doc.Objects.Delete(_vobj.Id, True)
+ except Exception: pass
import json as _json
layers_json = (_json.dumps(layers_def, ensure_ascii=False)
if is_layered else "")
@@ -7274,7 +7528,12 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
wand_layer_idx=idx,
wand_chain_members=(chain_ids
if (chain_ids and len(chain_ids) > 1) else None))
- try: doc.Objects.AddBrep(lbrep, attrs)
+ try:
+ _vol_id = doc.Objects.AddBrep(lbrep, attrs)
+ # Auto-Group: axis + alle volume-breps dieser Wand → 1 Rhino-Group.
+ # Click selektiert beide, Delete loescht beide (axis-Delete kaskaden
+ # eh die Volumes mit). Kein ChooseOne-Dialog bei ueberlappenden Clicks.
+ _add_to_wall_group(doc, _vol_id, element_id)
except Exception as ex: print("[ELEMENTE] AddBrep wand layer:", ex)
return True
elif meta["type"] == "decke_outline":
@@ -8426,6 +8685,29 @@ class ElementeBridge(panel_base.BaseBridge):
ok_over = p.get("okOverride", "")
referenz = p.get("referenz") or _last("wand_referenz", "mid")
+ # Wand-Stile aus Project-Settings (kann leer sein bei alten Docs ohne
+ # Style-Setup — dann faellt's auf Legacy loose-Parameter zurueck).
+ # Rhino's AddOptionList akzeptiert nur Single-Word-Labels (keine
+ # Spaces/Spezialzeichen) — daher sanitizen wir die Anzeigenamen.
+ styles = _get_all_wand_styles(doc)
+ def _sanitize_opt_label(s):
+ import re
+ return re.sub(r'[^a-zA-Z0-9_-]', '', s or "") or "?"
+ # style_labels = Rhino-Option-compatible (no spaces). style_names = human-readable.
+ style_names = [s.get("name", s.get("id", "?")) for s in styles]
+ style_labels = [_sanitize_opt_label(n) for n in style_names]
+ # Last-Used Style: id oder Index. Default: erster Style oder ""
+ last_style_id = _last("wand_style_id", styles[0]["id"] if styles else "")
+ style_idx = 0
+ for i, s in enumerate(styles):
+ if s.get("id") == last_style_id:
+ style_idx = i; break
+ # Aktive Style → dessen Geometrie-Defaults uebernehmen (User kann ueberschreiben)
+ if styles:
+ s_active = styles[style_idx]
+ dicke = float(s_active.get("dicke", dicke))
+ referenz = s_active.get("referenz", referenz)
+
try:
import Rhino.Input.Custom as ric
from Rhino.Input import GetResult
@@ -8441,8 +8723,10 @@ class ElementeBridge(panel_base.BaseBridge):
except ValueError: ref_idx = 0
def _build_prompt(base):
- return "{} [Modus={}, Referenz={}, Dicke={:.3f}]".format(
- base, modus, ref_labels[ref_idx], dicke)
+ style_part = (" Stil={}".format(style_names[style_idx])
+ if styles else "")
+ return "{} [Modus={}{}, Referenz={}, Dicke={:.3f}]".format(
+ base, modus, style_part, ref_labels[ref_idx], dicke)
first_pt = None
try:
@@ -8450,6 +8734,9 @@ class ElementeBridge(panel_base.BaseBridge):
gp = ric.GetPoint()
gp.SetCommandPrompt(_build_prompt("Wand: Startpunkt"))
opt_modus = gp.AddOptionList("Modus", modi, modi.index(modus))
+ opt_style = -1
+ if styles:
+ opt_style = gp.AddOptionList("Stil", style_labels, style_idx)
opt_ref = gp.AddOptionList("Referenz", ref_labels, ref_idx)
opt_dicke = gp.AddOption("Dicke")
res = gp.Get()
@@ -8457,6 +8744,15 @@ class ElementeBridge(panel_base.BaseBridge):
if gp.OptionIndex() == opt_modus:
try: modus = modi[gp.Option().CurrentListOptionIndex]
except Exception: pass
+ elif styles and gp.OptionIndex() == opt_style:
+ try:
+ style_idx = gp.Option().CurrentListOptionIndex
+ s_active = styles[style_idx]
+ dicke = float(s_active.get("dicke", dicke))
+ new_ref = s_active.get("referenz", referenz)
+ if new_ref in ref_codes:
+ ref_idx = ref_codes.index(new_ref)
+ except Exception: pass
elif gp.OptionIndex() == opt_ref:
try: ref_idx = gp.Option().CurrentListOptionIndex
except Exception: pass
@@ -8477,6 +8773,7 @@ class ElementeBridge(panel_base.BaseBridge):
print("[ELEMENTE] wand first-point:", ex); return
referenz = ref_codes[ref_idx]
+ style_id = styles[style_idx]["id"] if styles else None
try:
if modus == "Rechteck":
axes = self._collect_wall_rectangle(doc, first_pt, dicke, referenz)
@@ -8484,8 +8781,10 @@ class ElementeBridge(panel_base.BaseBridge):
print("[ELEMENTE] Rechteck abgebrochen"); return
for ac in axes:
self._make_wall_from_axis(doc, ac, geschoss, dicke,
- uk_over, ok_over, referenz)
- _save_last(wand_dicke=dicke, wand_referenz=referenz, wand_modus=modus)
+ uk_over, ok_over, referenz,
+ style_id=style_id)
+ _save_last(wand_dicke=dicke, wand_referenz=referenz, wand_modus=modus,
+ wand_style_id=style_id or "")
self._send_state()
return
if modus == "Polylinie":
@@ -8505,9 +8804,23 @@ class ElementeBridge(panel_base.BaseBridge):
if axis_curve is None:
print("[ELEMENTE] keine gueltige Achse"); return
- self._make_wall_from_axis(doc, axis_curve, geschoss, dicke,
- uk_over, ok_over, referenz)
- _save_last(wand_dicke=dicke, wand_referenz=referenz, wand_modus=modus)
+ # Polylinie-Modus: jedes Segment als eigenstaendige Wand. So kann der
+ # User einzelne Segmente loeschen ohne die Nachbarn zu verlieren. Bei
+ # Linie/Spline/Bogen bleibt's bei einer Wand (semantisch sinnvoll).
+ axes = []
+ if modus == "Polylinie" and isinstance(axis_curve, rg.PolylineCurve):
+ ok, pl = axis_curve.TryGetPolyline()
+ if ok and pl is not None and pl.Count >= 2:
+ for i in range(pl.Count - 1):
+ axes.append(rg.LineCurve(pl[i], pl[i + 1]))
+ if not axes:
+ axes = [axis_curve]
+ for ac in axes:
+ self._make_wall_from_axis(doc, ac, geschoss, dicke,
+ uk_over, ok_over, referenz,
+ style_id=style_id)
+ _save_last(wand_dicke=dicke, wand_referenz=referenz, wand_modus=modus,
+ wand_style_id=style_id or "")
self._send_state()
def _collect_wall_polyline(self, doc, first_pt, dicke, referenz, ends_after=None):
@@ -8649,12 +8962,15 @@ class ElementeBridge(panel_base.BaseBridge):
]
def _make_wall_from_axis(self, doc, axis_curve, geschoss_id, dicke,
- uk_over, ok_over, referenz):
+ uk_over, ok_over, referenz, style_id=None):
"""Erzeugt Wand aus beliebiger Achsen-Curve. Performance-Wrap:
- BeginUndoRecord → eine Undo-Aktion fuer die ganze Wand-Erstellung
(statt mehreren kleinen fuer AddCurve + jedes Layer-Brep)
- Views.RedrawEnabled=False waehrend der Regen-Phase →
- ein einziger Redraw am Ende statt einer pro Add/Delete-Op"""
+ ein einziger Redraw am Ende statt einer pro Add/Delete-Op
+ - style_id: optional, verweist auf Project-Settings wand_styles[].id.
+ Wenn der Style layered ist, werden seine Layers + Layered-Flag in
+ die Wand-Meta uebernommen (Dicke kommt dann aus Summe der Layers)."""
wall_id = "wall_" + uuid.uuid4().hex[:10]
g = _geschoss_by_id(doc, geschoss_id)
geschoss_name = g.get("name", "EG") if g else "EG"
@@ -8665,10 +8981,24 @@ class ElementeBridge(panel_base.BaseBridge):
if abs(z0) > 1e-6:
axis.Transform(rg.Transform.Translation(0, 0, -z0))
except Exception: pass
+ # Style aufloesen: bei layered die Layers + Dicke vom Style ziehen
+ style = _find_wand_style(doc, style_id) if style_id else None
+ wand_layered = None
+ wand_layers = None
+ if style and style.get("layered"):
+ wand_layered = True
+ wand_layers = list(style.get("layers") or [])
+ # Dicke = Summe der Layer-Dicken (ueberschreibt user-Eingabe)
+ try:
+ dicke = sum(float(l.get("dicke", 0)) for l in wand_layers)
+ except Exception: pass
attrs = Rhino.DocObjects.ObjectAttributes()
attrs.LayerIndex = axis_layer
_attach_meta(attrs, wall_id, "wand_axis", geschoss_id,
- dicke, uk_over, ok_over, referenz)
+ dicke, uk_over, ok_over, referenz,
+ wand_style_id=style_id,
+ wand_layered=wand_layered,
+ wand_layers=wand_layers)
undo_serial = doc.BeginUndoRecord("Wand erstellen")
prev_redraw = doc.Views.RedrawEnabled
@@ -9451,6 +9781,16 @@ class ElementeBridge(panel_base.BaseBridge):
# Laenge gezwungen). User kann auf "frei" stellen.
regel_mode = _last("treppe_regel", "regel")
if regel_mode not in ("frei", "regel"): regel_mode = "regel"
+ # Trittmass-Lock aus sticky (= aus letztem Treppe-Property-Edit):
+ # wenn aktiv, ist A fix → discrete Lauflaengen-Snaps mit a_lo=a_hi=target_a
+ a_lock = bool(_last("treppe_lock_s", False))
+ try: a_target = float(_last("treppe_target_a", 0.0))
+ except Exception: a_target = 0.0
+ if a_target <= 1e-4: a_lock = False
+ # L-Treppe Podest-Modus: "kompakt" (3-Punkt, Cut-Back am Eck) oder
+ # "explizit" (4-Punkt, eigenes Podest-Segment zwischen Lauf1 und Lauf2).
+ l_podest_mode = _last("treppe_l_podest_mode", "kompakt")
+ if l_podest_mode not in ("kompakt", "explizit"): l_podest_mode = "kompakt"
# Soll-Werte (Editable in der Treppe-Property-Card) aus sticky laden.
# Default falls noch nichts gesetzt: 0.15-0.20 / 0.21-0.35 / 0.60-0.65.
soll_last = _last("treppe_soll", None)
@@ -9489,6 +9829,11 @@ class ElementeBridge(panel_base.BaseBridge):
lo, hi = _l_range(n, h)
return max(0.3, (lo + hi) * 0.5)
+ # L-Treppe: Lage=mid macht geometrisch keinen Sinn (waere ein
+ # 4-Punkt-Polygon mit eigenem Podest-Segment). Auf links defaulten.
+ if treppe_art == "l" and referenz == "mid":
+ referenz = "links"
+
# Zwei Punkte fuer die Lauflinie einsammeln
first_pt = None
try:
@@ -9503,17 +9848,24 @@ class ElementeBridge(panel_base.BaseBridge):
end_name))
opt_n = gp.AddOption("Stufen")
opt_b = gp.AddOption("Breite")
- opt_ref = gp.AddOptionList("Referenz",
- ["Links", "Mittig", "Rechts"],
- {"links":0, "mid":1, "rechts":2}.get(referenz, 1))
- # Regel-Option nur fuer gerade Treppen (bei L sind 2 Segmente
- # mit Podest dazwischen — die Regel laesst sich nicht trivial
- # auf 2 Klicks aufteilen).
- opt_reg = -1
- if treppe_art != "l":
- opt_reg = gp.AddOptionList("Regel",
- ["frei", "Schrittmass"],
- 1 if regel_mode == "regel" else 0)
+ # L-Treppe: nur links/rechts (mid nicht sinnvoll)
+ if treppe_art == "l":
+ opt_ref = gp.AddOptionList("Referenz",
+ ["Links", "Rechts"],
+ {"links":0, "rechts":1}.get(referenz, 0))
+ else:
+ opt_ref = gp.AddOptionList("Referenz",
+ ["Links", "Mittig", "Rechts"],
+ {"links":0, "mid":1, "rechts":2}.get(referenz, 1))
+ opt_reg = gp.AddOptionList("Regel",
+ ["frei", "Schrittmass"],
+ 1 if regel_mode == "regel" else 0)
+ # L-Treppe: Podest-Modus-Option
+ opt_pod = -1
+ if treppe_art == "l":
+ opt_pod = gp.AddOptionList("Podest",
+ ["Kompakt", "MitTrennung"],
+ 1 if l_podest_mode == "explizit" else 0)
res = gp.Get()
if res == GetResult.Option:
idx = gp.OptionIndex()
@@ -9532,14 +9884,22 @@ class ElementeBridge(panel_base.BaseBridge):
if gn.Get() == GetResult.Number: breite = float(gn.Number())
elif idx == opt_ref:
try:
- v = ["links", "mid", "rechts"][gp.Option().CurrentListOptionIndex]
+ if treppe_art == "l":
+ v = ["links", "rechts"][gp.Option().CurrentListOptionIndex]
+ else:
+ v = ["links", "mid", "rechts"][gp.Option().CurrentListOptionIndex]
referenz = v
except Exception: pass
- elif opt_reg >= 0 and idx == opt_reg:
+ elif idx == opt_reg:
try:
v = ["frei", "regel"][gp.Option().CurrentListOptionIndex]
regel_mode = v
except Exception: pass
+ elif opt_pod >= 0 and idx == opt_pod:
+ try:
+ v = ["kompakt", "explizit"][gp.Option().CurrentListOptionIndex]
+ l_podest_mode = v
+ except Exception: pass
continue
if res != GetResult.Point: return
first_pt = gp.Point()
@@ -9554,14 +9914,36 @@ class ElementeBridge(panel_base.BaseBridge):
gp2 = ric.GetPoint()
L_opt = _l_optimal(n_stufen, H)
if treppe_art == "l":
- # L-Treppe: 2. Klick ist der Podest-Eck. Live-Preview zeigt
- # N1/N2 fuer die Mausposition. Regel-Modus aus (zu komplex).
+ _gp2_prompt = (
+ "L-Treppe Kompakt: Eck-Punkt (Podest-Mitte)"
+ if l_podest_mode == "kompakt"
+ else "L-Treppe Mit Trennung: Endpunkt Lauf 1")
gp2.SetCommandPrompt(
- "L-Treppe: Eck-Punkt (Podest-Mitte) [Stufen={}, Breite={:.2f}]".format(
- n_stufen, breite))
+ "{} [Stufen={}, Breite={:.2f}, Ref={}, Modus={}]".format(
+ _gp2_prompt, n_stufen, breite, referenz, regel_mode))
gp2.SetBasePoint(first_pt, True)
- gp2.DynamicDraw += _make_treppe_l_corner_preview(
- first_pt, breite, referenz, n_stufen, H)
+ # Preview-Max-Length aus Schrittmass-Lock.
+ # Kompakt: L1 = N1*A + half_b. Explizit: L1 = N1*A (kein Cut-Back).
+ preview_max = None
+ if regel_mode == "regel":
+ _cb_pre = 0.0 # kein Cut-Back (Kompakt = Eckpunkt, Explizit = Klick-Punkt)
+ _S_pre = H / max(2, int(n_stufen))
+ if a_lock:
+ _a_hi_pre = a_target
+ else:
+ _a_hi_pre = min(2.0,
+ soll["sa"][1] - 2 * _S_pre if soll["sa"][2] else 2.0,
+ soll["a"][1] if soll["a"][2] else 2.0)
+ preview_max = (n_stufen - 1) * _a_hi_pre + _cb_pre
+ if l_podest_mode == "kompakt":
+ gp2.DynamicDraw += _make_treppe_l_corner_preview(
+ first_pt, breite, referenz, n_stufen, H,
+ max_length=preview_max)
+ else:
+ # Explizit: einfache gerade-Preview fuer Lauf 1
+ gp2.DynamicDraw += _make_treppe_preview_handler(
+ first_pt, breite, referenz, n_stufen,
+ max_length=preview_max)
elif treppe_art == "wendel":
# Wendel: 1. Klick = Mittelpunkt, 2. Klick = Start (Radius
# + Startwinkel). Preview: Linie center→Maus + Kreis.
@@ -9597,9 +9979,7 @@ class ElementeBridge(panel_base.BaseBridge):
first_pt, breite, referenz, n_stufen)
if gp2.Get() != GetResult.Point: return
clicked = gp2.Point()
- # Schrittmass-Clamp im "regel"-Modus — fuer gerade Treppen mit
- # voller n_stufen. Fuer L-Treppen: lockerer Clamp (1..n-1 Stufen
- # in diesem Lauf, da der zweite noch kommt).
+ # Schrittmass-Clamp im "regel"-Modus.
if regel_mode == "regel" and treppe_art in ("gerade", "l"):
dx = clicked.X - first_pt.X
dy = clicked.Y - first_pt.Y
@@ -9607,13 +9987,27 @@ class ElementeBridge(panel_base.BaseBridge):
if dist < 1e-4:
print("[ELEMENTE] Keine Richtung gewaehlt"); return
if treppe_art == "gerade":
- L_min2, L_max2 = _l_range(n_stufen, H)
+ if a_lock:
+ L_min2 = L_max2 = n_stufen * a_target
+ else:
+ L_min2, L_max2 = _l_range(n_stufen, H)
else:
- # L: clamp auf [1 Stufe × A_min, (n-1) Stufen × A_max]
- L1_min, L1_max = _l_range(1, H)
- L_minF, L_maxF = _l_range(max(2, n_stufen - 1), H)
- L_min2 = L1_min
- L_max2 = L_maxF
+ # L-Treppe: Lauf1 = N1 * A (+ half_b im Kompakt-Modus,
+ # 0 im Explizit-Modus). Mindestens 1 Tritt in Lauf 2.
+ _cb = 0.0 # kein Cut-Back
+ _S = H / max(2, int(n_stufen))
+ if a_lock:
+ _a_lo = _a_hi = a_target
+ else:
+ _a_lo = max(0.05,
+ soll["sa"][0] - 2 * _S if soll["sa"][2] else 0.05,
+ soll["a"][0] if soll["a"][2] else 0.05)
+ _a_hi = min(2.0,
+ soll["sa"][1] - 2 * _S if soll["sa"][2] else 2.0,
+ soll["a"][1] if soll["a"][2] else 2.0)
+ if _a_lo > _a_hi: _a_lo = _a_hi = (_a_lo + _a_hi) * 0.5
+ L_min2 = 1 * _a_lo + _cb
+ L_max2 = (n_stufen - 1) * _a_hi + _cb
if abs(L_max2 - L_min2) < 1e-4:
final_L = L_min2
else:
@@ -9624,60 +10018,174 @@ class ElementeBridge(panel_base.BaseBridge):
else:
second_pt = clicked
- # L-Treppe: dritter Punkt einsammeln (Endpunkt nach dem Eck)
- # Lage MUSS aussen liegen — bei Default 'mid' auf 'links' setzen
- # damit die Treppe komplett auf einer Seite der Achse liegt.
+ # L-Treppe: Punkte fuer Lauf 2 einsammeln. Cut-Back im Kompakt-Modus
+ # ist half_b, im Explizit-Modus 0 (Podest ist eigenes Segment).
if treppe_art == "l":
- if referenz == "mid":
- referenz = "links"
- # Erste Lauf-Laenge (bereits via cut_back im Volume reduziert,
- # hier nur fuer Stufen-Verteilung)
d1x = second_pt.X - first_pt.X
d1y = second_pt.Y - first_pt.Y
L1_cur = (d1x * d1x + d1y * d1y) ** 0.5
- gp3 = ric.GetPoint()
- gp3.SetCommandPrompt(
- "L-Treppe: Endpunkt nach Eck [Stufen={}, Breite={:.2f}, Ref={}, Modus={}]".format(
- n_stufen, breite, referenz, regel_mode))
- gp3.SetBasePoint(second_pt, True)
- gp3.DynamicDraw += _make_treppe_preview_handler(
- second_pt, breite, referenz, max(1, n_stufen // 2))
- if gp3.Get() != GetResult.Point: return
- third_pt_raw = gp3.Point()
- # Schrittmass-Clamp fuer Lauf 2: verbleibende Stufen = n - N1_est
- # Mit cut_back-Aware Stufen-Schaetzung: N1_est aus eff_L1 = L1 - cut_back
- if regel_mode == "regel":
- d2x = third_pt_raw.X - second_pt.X
- d2y = third_pt_raw.Y - second_pt.Y
- d2 = (d2x * d2x + d2y * d2y) ** 0.5
- if d2 < 1e-4:
- print("[ELEMENTE] Keine Richtung gewaehlt"); return
- S_cur = H / max(2, int(n_stufen))
- cb = (breite * 0.5) if referenz == "mid" else float(breite)
- eff_L1 = max(0.01, L1_cur - cb)
- A_opt = max(0.21, min(0.35, 0.63 - 2.0 * S_cur))
- N1_est = max(1, min(n_stufen - 1, int(round(eff_L1 / A_opt))))
- N2_est = max(1, n_stufen - N1_est)
- # Range fuer Lauf 2 (mit cut_back-Anteil dazu)
- L2_min, L2_max = _l_range(N2_est, H * N2_est / n_stufen)
- L2_min += cb
- L2_max += cb
- if abs(L2_max - L2_min) < 1e-4:
- final_L2 = L2_min
+ cb_mode = 0.0 # kein Cut-Back (Laeufe gehen bis Eck-/Klick-Punkt)
+
+ def _bend_check(d2x_v, d2y_v):
+ """True = OK, False = Constraint verletzt + Abbruch-Meldung."""
+ if referenz == "mid": return True
+ cross_z_v = d1x * d2y_v - d1y * d2x_v
+ if referenz == "links" and cross_z_v <= 1e-6:
+ print("[ELEMENTE] Lage=links erwartet Linkskurve — "
+ "Endpunkt liegt rechts der ersten Lauflinie, abgebrochen")
+ return False
+ if referenz == "rechts" and cross_z_v >= -1e-6:
+ print("[ELEMENTE] Lage=rechts erwartet Rechtskurve — "
+ "Endpunkt liegt links der ersten Lauflinie, abgebrochen")
+ return False
+ return True
+
+ if l_podest_mode == "explizit":
+ # gp3 = Lauf 2 Anfang (= Podest Ende). Frei waehlbar.
+ gp3 = ric.GetPoint()
+ gp3.SetCommandPrompt(
+ "L-Treppe Mit Trennung: Anfangspunkt Lauf 2 "
+ "[Stufen={}, Breite={:.2f}, Ref={}]".format(
+ n_stufen, breite, referenz))
+ gp3.SetBasePoint(second_pt, True)
+ gp3.DrawLineFromPoint(second_pt, True)
+ if gp3.Get() != GetResult.Point: return
+ l2_start_pt = gp3.Point()
+ if not _bend_check(l2_start_pt.X - second_pt.X,
+ l2_start_pt.Y - second_pt.Y): return
+
+ # gp4 = Lauf 2 Ende — Schrittmass-Clamp wie gp3 im Kompakt
+ gp4 = ric.GetPoint()
+ gp4.SetCommandPrompt(
+ "L-Treppe Mit Trennung: Endpunkt Lauf 2 "
+ "[Stufen={}, Breite={:.2f}, Ref={}, Modus={}]".format(
+ n_stufen, breite, referenz, regel_mode))
+ gp4.SetBasePoint(l2_start_pt, True)
+ preview_l2_min = preview_l2_max = None
+ if regel_mode == "regel":
+ _S_l4 = H / max(2, int(n_stufen))
+ _A_split = a_target if a_lock else max(0.21,
+ min(0.35, 0.63 - 2.0 * _S_l4))
+ _N1_est4 = max(1, min(n_stufen - 1,
+ int(round(L1_cur / _A_split))))
+ _N2_est4 = max(1, n_stufen - _N1_est4)
+ if a_lock:
+ preview_l2_min = preview_l2_max = _N2_est4 * a_target
+ else:
+ _a_lo4 = max(0.05,
+ soll["sa"][0] - 2 * _S_l4 if soll["sa"][2] else 0.05,
+ soll["a"][0] if soll["a"][2] else 0.05)
+ _a_hi4 = min(2.0,
+ soll["sa"][1] - 2 * _S_l4 if soll["sa"][2] else 2.0,
+ soll["a"][1] if soll["a"][2] else 2.0)
+ if _a_lo4 > _a_hi4: _a_lo4 = _a_hi4 = (_a_lo4 + _a_hi4) * 0.5
+ preview_l2_min = _N2_est4 * _a_lo4
+ preview_l2_max = _N2_est4 * _a_hi4
+ if (preview_l2_min == preview_l2_max
+ and preview_l2_min is not None):
+ gp4.DynamicDraw += _make_treppe_preview_handler(
+ l2_start_pt, breite, referenz, max(1, n_stufen // 2),
+ fixed_length=preview_l2_min)
else:
- final_L2 = max(L2_min, min(L2_max, d2))
- third_pt = rg.Point3d(second_pt.X + d2x / d2 * final_L2,
- second_pt.Y + d2y / d2 * final_L2,
- second_pt.Z)
+ gp4.DynamicDraw += _make_treppe_preview_handler(
+ l2_start_pt, breite, referenz, max(1, n_stufen // 2),
+ min_length=preview_l2_min, max_length=preview_l2_max)
+ if gp4.Get() != GetResult.Point: return
+ l2_end_raw = gp4.Point()
+ # Final Clamp Lauf 2
+ if regel_mode == "regel":
+ d4x = l2_end_raw.X - l2_start_pt.X
+ d4y = l2_end_raw.Y - l2_start_pt.Y
+ d4 = (d4x * d4x + d4y * d4y) ** 0.5
+ if d4 < 1e-4:
+ print("[ELEMENTE] Keine Richtung gewaehlt"); return
+ if preview_l2_min is not None:
+ L4 = preview_l2_min if (preview_l2_min == preview_l2_max
+ ) else max(preview_l2_min, min(preview_l2_max, d4))
+ else:
+ L4 = d4
+ l2_end_pt = rg.Point3d(l2_start_pt.X + d4x / d4 * L4,
+ l2_start_pt.Y + d4y / d4 * L4,
+ l2_start_pt.Z)
+ else:
+ l2_end_pt = l2_end_raw
+ # 4-Punkt-Polylinie
+ p0pt = rg.Point3d(first_pt.X, first_pt.Y, 0)
+ p1pt = rg.Point3d(second_pt.X, second_pt.Y, 0)
+ p2pt = rg.Point3d(l2_start_pt.X, l2_start_pt.Y, 0)
+ p3pt = rg.Point3d(l2_end_pt.X, l2_end_pt.Y, 0)
+ pl = rg.Polyline([p0pt, p1pt, p2pt, p3pt])
+ line = rg.PolylineCurve(pl)
+ if line.GetLength() < 0.2:
+ print("[ELEMENTE] L-Lauflinie zu kurz"); return
else:
- third_pt = third_pt_raw
- p_first = rg.Point3d(first_pt.X, first_pt.Y, 0)
- p_corner = rg.Point3d(second_pt.X, second_pt.Y, 0)
- p_end = rg.Point3d(third_pt.X, third_pt.Y, 0)
- pl = rg.Polyline([p_first, p_corner, p_end])
- line = rg.PolylineCurve(pl)
- if line.GetLength() < 0.2:
- print("[ELEMENTE] L-Lauflinie zu kurz"); return
+ # KOMPAKT: gp3 = Lauf2-Ende mit Cut-Back-Schrittmass
+ gp3 = ric.GetPoint()
+ gp3.SetCommandPrompt(
+ "L-Treppe Kompakt: Endpunkt nach Eck "
+ "[Stufen={}, Breite={:.2f}, Ref={}, Modus={}]".format(
+ n_stufen, breite, referenz, regel_mode))
+ gp3.SetBasePoint(second_pt, True)
+ preview_l2_min = preview_l2_max = None
+ if regel_mode == "regel":
+ _cb_l2 = cb_mode
+ _S_l2 = H / max(2, int(n_stufen))
+ _A_split_l2 = a_target if a_lock else max(0.21,
+ min(0.35, 0.63 - 2.0 * _S_l2))
+ _eff_L1 = max(0.01, L1_cur - _cb_l2)
+ _N1_est = max(1, min(n_stufen - 1,
+ int(round(_eff_L1 / _A_split_l2))))
+ _N2_est = max(1, n_stufen - _N1_est)
+ if a_lock:
+ preview_l2_min = preview_l2_max = _N2_est * a_target + _cb_l2
+ else:
+ _a_lo_l2 = max(0.05,
+ soll["sa"][0] - 2 * _S_l2 if soll["sa"][2] else 0.05,
+ soll["a"][0] if soll["a"][2] else 0.05)
+ _a_hi_l2 = min(2.0,
+ soll["sa"][1] - 2 * _S_l2 if soll["sa"][2] else 2.0,
+ soll["a"][1] if soll["a"][2] else 2.0)
+ if _a_lo_l2 > _a_hi_l2:
+ _a_lo_l2 = _a_hi_l2 = (_a_lo_l2 + _a_hi_l2) * 0.5
+ preview_l2_min = _N2_est * _a_lo_l2 + _cb_l2
+ preview_l2_max = _N2_est * _a_hi_l2 + _cb_l2
+ if (preview_l2_min == preview_l2_max
+ and preview_l2_min is not None):
+ gp3.DynamicDraw += _make_treppe_preview_handler(
+ second_pt, breite, referenz, max(1, n_stufen // 2),
+ fixed_length=preview_l2_min)
+ else:
+ gp3.DynamicDraw += _make_treppe_preview_handler(
+ second_pt, breite, referenz, max(1, n_stufen // 2),
+ min_length=preview_l2_min, max_length=preview_l2_max)
+ if gp3.Get() != GetResult.Point: return
+ third_pt_raw = gp3.Point()
+ if not _bend_check(third_pt_raw.X - second_pt.X,
+ third_pt_raw.Y - second_pt.Y): return
+ if regel_mode == "regel":
+ d2x = third_pt_raw.X - second_pt.X
+ d2y = third_pt_raw.Y - second_pt.Y
+ d2 = (d2x * d2x + d2y * d2y) ** 0.5
+ if d2 < 1e-4:
+ print("[ELEMENTE] Keine Richtung gewaehlt"); return
+ if preview_l2_min is not None:
+ L_final = preview_l2_min if (
+ preview_l2_min == preview_l2_max
+ ) else max(preview_l2_min, min(preview_l2_max, d2))
+ else:
+ L_final = d2
+ third_pt = rg.Point3d(second_pt.X + d2x / d2 * L_final,
+ second_pt.Y + d2y / d2 * L_final,
+ second_pt.Z)
+ else:
+ third_pt = third_pt_raw
+ p_first = rg.Point3d(first_pt.X, first_pt.Y, 0)
+ p_corner = rg.Point3d(second_pt.X, second_pt.Y, 0)
+ p_end = rg.Point3d(third_pt.X, third_pt.Y, 0)
+ pl = rg.Polyline([p_first, p_corner, p_end])
+ line = rg.PolylineCurve(pl)
+ if line.GetLength() < 0.2:
+ print("[ELEMENTE] L-Lauflinie zu kurz"); return
elif treppe_art == "wendel":
# Wendel: 3. Klick = Endpunkt (Sweep-Winkel + Drehrichtung).
# Im Regel-Modus wird der Sweep auf den durch r_lauf + Soll
@@ -9763,6 +10271,8 @@ class ElementeBridge(panel_base.BaseBridge):
# auf "frei" zuruecksetzen).
if treppe_art in ("gerade", "wendel"):
save_kwargs["treppe_regel"] = regel_mode
+ if treppe_art == "l":
+ save_kwargs["treppe_l_podest_mode"] = l_podest_mode
_save_last(**save_kwargs)
_regenerate_element(doc, treppe_id)
doc.Views.Redraw()
@@ -13625,6 +14135,16 @@ def _on_object_added(sender, e):
else: prefix = "elem_"
new_id = prefix + uuid.uuid4().hex[:10]
attrs = new_obj.Attributes
+ # Group-Mitgliedschaft vom Original-Duplikat NICHT uebernehmen:
+ # Split/Copy/Mirror brauchen jeweils eine EIGENE Gruppe pro Stueck,
+ # sonst haengen die Split-Pieces alle in der gleichen alten Gruppe
+ # und Click-Selektion erfasst alle gleichzeitig. Die neue Gruppe
+ # legt _add_to_wall_group(...) beim Volume-Regen automatisch an.
+ try:
+ for _old_grp in (attrs.GetGroupList() or []):
+ attrs.RemoveFromGroup(int(_old_grp))
+ except Exception as _ex:
+ print("[ELEMENTE] dup group-clear:", _ex)
_attach_meta(attrs, new_id, meta["type"], meta["geschoss"],
meta["dicke"], meta["uk_override"], meta["ok_override"],
meta.get("referenz", "mid"),
@@ -13764,6 +14284,25 @@ def _on_object_deleted(sender, e):
_queue_regen(wid)
except Exception as ex:
print("[ELEMENTE] del dep regen:", ex)
+ # Chain-Volume-Cleanup: wenn diese Wand in einem alten Chain-
+ # Volume war (chain_members enthaelt sie), volume loeschen +
+ # die anderen ehemaligen Chain-Members fuer Solo-Regen queuen.
+ # Wichtig nach Smart-Split: die neuen Achsen-Stuecke kennen die
+ # alte Chain nicht; ohne diesen Block bleibt das alte Brep stehen.
+ try:
+ for _vobj in list(doc.Objects):
+ _vm = _read_meta(_vobj)
+ if not _vm or _vm.get("type") != "wand_volume": continue
+ _members = _vm.get("wand_chain_members") or []
+ if meta["id"] in _members or _vm["id"] == meta["id"]:
+ try: doc.Objects.Delete(_vobj.Id, True)
+ except Exception: pass
+ # Ueberlebende Chain-Member sollen Solo-Volume bauen
+ for _wid in _members:
+ if _wid != meta["id"]:
+ _queue_regen(_wid)
+ except Exception as ex:
+ print("[ELEMENTE] chain-cleanup:", ex)
# Panel-Sync: bei Bulk-Op nur einmal am CommandEnd, sonst
# sofort. Sonst pusht ein Delete von 50 Waenden 50× state.
if not sc.sticky.get(_BULK_ACTIVE_KEY):
@@ -13800,9 +14339,12 @@ _PAIRED_SOURCE_TYPES = (
def _find_all_volumes(doc, element_id, type_filter=None):
"""Liefert ALLE Volume-Objekte zu element_id (z.B. alle Schichten einer
- mehrlagigen Wand)."""
+ mehrlagigen Wand). HiddenObjects=True damit Delete-Cascade auch greift
+ wenn 3D-Layer hidden ist und User nur 2D selektiert/loescht."""
+ _s = Rhino.DocObjects.ObjectEnumeratorSettings()
+ _s.HiddenObjects = True; _s.LockedObjects = True
out = []
- for obj in doc.Objects:
+ for obj in doc.Objects.GetObjectList(_s):
m = _read_meta(obj)
if m and m["id"] == element_id:
t = m["type"]
@@ -15751,6 +16293,11 @@ def _install_listeners(bridge):
def _bridge_factory():
b = ElementeBridge()
_install_listeners(b)
+ # Sticky-Hook: Alias-Wrapper-Skripte (rhino/aliases/*.py) brauchen
+ # Zugriff auf die Bridge-Methoden um BIM-Commands via Tastatur-Aliases
+ # zu triggern. Bridge wird einmal pro Doc instanziert → die Referenz
+ # bleibt gueltig solange Rhino laeuft (kein Re-Init bei Open File).
+ sc.sticky["dossier_bridge_elemente"] = b
return b
diff --git a/rhino/oberleiste.py b/rhino/oberleiste.py
index 3d447bf..f437cf1 100644
--- a/rhino/oberleiste.py
+++ b/rhino/oberleiste.py
@@ -1507,6 +1507,14 @@ class OberleisteBridge(panel_base.BaseBridge):
except Exception as ex:
print("[OBERLEISTE] open about:", ex)
+ # --- Cheatsheet (Shortcuts-Uebersicht) --------------------------
+ elif t == "OPEN_CHEATSHEET":
+ try:
+ import welcome
+ welcome.show_cheatsheet()
+ except Exception as ex:
+ print("[OBERLEISTE] open cheatsheet:", ex)
+
# --- Text-Erstellung (Floating-Input) ---------------------------
elif t == "CREATE_TEXT":
try:
diff --git a/rhino/panel_base.py b/rhino/panel_base.py
index 2490d99..932d897 100644
--- a/rhino/panel_base.py
+++ b/rhino/panel_base.py
@@ -684,8 +684,8 @@ def make_panel_icon(name_or_letter, bg_hex):
if icon_bmp is not None: chosen_path = png_path
else: print("[panel_base] PNG geladen aber Bitmap None:",
png_path)
- else:
- print("[panel_base] PNG nicht gefunden:", png_path)
+ # PNG-not-found ist normal: Fallback auf SVG dann Material-Font.
+ # Nur loggen wenn final ALLES failt (s.u.).
if icon_bmp is None:
svg_path = os.path.join(_PANEL_ICONS_SVG_DIR,
name_or_letter + ".svg")
@@ -713,10 +713,11 @@ def make_panel_icon(name_or_letter, bg_hex):
if font_family_name:
try:
ff = drawing.FontFamily(font_family_name)
- # FontStyle.None: in Python3 nicht direkt zugreifbar
- # (None ist Keyword) → getattr-Workaround, sonst 0
- try: fs = getattr(drawing.FontStyle, "None")
- except Exception: fs = 0
+ # FontStyle.None: in Python3 ist None ein Keyword, deshalb
+ # via System.Enum.ToObject explizit konstruieren — Python.NET 3
+ # konvertiert int → Enum nicht mehr implizit.
+ import System
+ fs = System.Enum.ToObject(drawing.FontStyle, 0)
font = drawing.Font(ff, 20, fs)
glyph = chr(mat_cp)
_draw_glyph(g, size, font, glyph,
diff --git a/rhino/rhinopanel.py b/rhino/rhinopanel.py
index d45c9cc..52478a6 100644
--- a/rhino/rhinopanel.py
+++ b/rhino/rhinopanel.py
@@ -309,6 +309,47 @@ _PROJECT_SETTINGS_DEFAULTS = {
"unit": "meters", # "meters" | "millimeters" | "centimeters"
},
"materials": [],
+ # Wand-Stile: Wand-Typ-Templates mit Prio fuer Joint-Dominanz.
+ #
+ # SOLID-Style (layered=False):
+ # material = Material-Identitaet (driver fuer Section-Hatch)
+ # dicke = DEFAULT bei Erstellung — User kann pro Wand ueberschreiben
+ # referenz = DEFAULT
+ #
+ # LAYERED-Style (layered=True):
+ # layers = fixe Schicht-Komposition mit Material+Dicke pro Layer
+ # dicke = ignoriert (kommt aus Summe der Layers)
+ #
+ # PRIO (1-999, 999=dominant): zwei Wand-Stile koennen das gleiche Material
+ # haben aber verschiedene Prios (z.B. Beton tragend prio=800,
+ # Beton innen prio=400). Bei Joints zwischen verschiedenen Stilen gewinnt
+ # der mit hoeherer Prio die Ecke (Phase 3, noch nicht implementiert).
+ #
+ # SECTION-MERGE (Phase 2, noch nicht implementiert): Hatches mergen visuell
+ # an Joints zwischen Waenden mit GLEICHEM Material — egal ob gleicher Stil.
+ # So sind „Beton tragend" und „Beton innen" im Schnitt verbunden.
+ "wand_styles": [
+ {"id": "style_beton_tragend", "name": "Beton tragend",
+ "prio": 800, "dicke": 0.25, "referenz": "mid",
+ "layered": False, "material": "Stahlbeton", "layers": []},
+ {"id": "style_beton_innen", "name": "Beton innen",
+ "prio": 400, "dicke": 0.15, "referenz": "mid",
+ "layered": False, "material": "Stahlbeton", "layers": []},
+ {"id": "style_gips", "name": "Gipswand",
+ "prio": 200, "dicke": 0.10, "referenz": "mid",
+ "layered": False, "material": "Putz", "layers": []},
+ {"id": "style_mauerwerk", "name": "Mauerwerk",
+ "prio": 300, "dicke": 0.12, "referenz": "mid",
+ "layered": False, "material": "Mauerwerk", "layers": []},
+ {"id": "style_aussen30", "name": "Aussenwand 30 cm gedaemmt",
+ "prio": 900, "dicke": 0.30, "referenz": "left",
+ "layered": True, "material": "",
+ "layers": [
+ {"material": "Stahlbeton", "dicke": 0.18},
+ {"material": "Daemmung", "dicke": 0.10},
+ {"material": "Putz", "dicke": 0.02},
+ ]},
+ ],
"project": {
"name": "",
"number": "",
@@ -379,6 +420,48 @@ def _normalize_project_meta(p):
}
+def _normalize_wand_style(s):
+ """Garantiert Wand-Style-Schema. Stil = Bundle aus Geometrie + Prio.
+ Felder:
+ - id (str, kebab-case empfohlen), name (str)
+ - prio (int 1-999): bei Joints zwischen verschiedenen Stilen wins der hoehere
+ - dicke (float, Meter)
+ - referenz ('mid'|'left'|'right')
+ - layered (bool): wenn True, layers definieren die Schichten
+ - material (str): bei layered=False der Material-Name
+ - layers (list of {material, dicke}): bei layered=True"""
+ if not isinstance(s, dict): return None
+ sid = str(s.get("id") or "").strip()
+ if not sid: return None
+ try: prio = int(s.get("prio", 500))
+ except Exception: prio = 500
+ prio = max(1, min(999, prio))
+ try: dicke = float(s.get("dicke", 0.25))
+ except Exception: dicke = 0.25
+ ref = str(s.get("referenz") or "mid")
+ if ref not in ("mid", "left", "right"): ref = "mid"
+ layered = bool(s.get("layered"))
+ layers = []
+ if layered and isinstance(s.get("layers"), list):
+ for ly in s["layers"]:
+ if not isinstance(ly, dict): continue
+ try: ld = float(ly.get("dicke", 0))
+ except Exception: ld = 0.0
+ if ld <= 0: continue
+ layers.append({"material": str(ly.get("material") or ""),
+ "dicke": ld})
+ return {
+ "id": sid,
+ "name": str(s.get("name") or sid),
+ "prio": prio,
+ "dicke": dicke,
+ "referenz": ref,
+ "layered": layered,
+ "material": str(s.get("material") or ""),
+ "layers": layers,
+ }
+
+
def _normalize_material(m):
"""Garantiert Material-Schema. Material ist REIN 3D — Section-Hatch
(2D-Schnitt) wird via Ebenen-Settings am Layer konfiguriert.
@@ -440,9 +523,10 @@ def load_project_settings(doc):
try: raw = doc.Strings.GetValue(_PROJECT_SETTINGS_KEY) if doc else None
except Exception: raw = None
out = {
- "defaults": dict(_PROJECT_SETTINGS_DEFAULTS["defaults"]),
- "materials": list(_PROJECT_SETTINGS_DEFAULTS["materials"]),
- "project": dict(_PROJECT_SETTINGS_DEFAULTS["project"]),
+ "defaults": dict(_PROJECT_SETTINGS_DEFAULTS["defaults"]),
+ "materials": list(_PROJECT_SETTINGS_DEFAULTS["materials"]),
+ "wand_styles": [dict(s) for s in _PROJECT_SETTINGS_DEFAULTS["wand_styles"]],
+ "project": dict(_PROJECT_SETTINGS_DEFAULTS["project"]),
}
if raw:
try:
@@ -458,6 +542,12 @@ def load_project_settings(doc):
_normalize_material(x) for x in m
if _normalize_material(x) is not None
]
+ ws = data.get("wand_styles")
+ if isinstance(ws, list):
+ out["wand_styles"] = [
+ _normalize_wand_style(s) for s in ws
+ if _normalize_wand_style(s) is not None
+ ]
pr = data.get("project")
if isinstance(pr, dict):
out["project"] = _normalize_project_meta(pr)
@@ -976,6 +1066,7 @@ class EbenenBridge(panel_base.BaseBridge):
"defaults": current.get("defaults", {}),
"project": current.get("project", {}),
"materials": current.get("materials", []),
+ "wandStyles": current.get("wand_styles", []),
"builtinMaterials": built_in,
"hatchPatterns": _hatch_pattern_names(doc),
"hatchPatternsFull": _list_hatch_patterns_full(doc),
@@ -990,9 +1081,10 @@ class EbenenBridge(panel_base.BaseBridge):
doc2 = Rhino.RhinoDoc.ActiveDoc
if doc2 is None: return
new_settings = {
- "defaults": updated.get("defaults", {}),
- "materials": updated.get("materials", []),
- "project": updated.get("project", {}),
+ "defaults": updated.get("defaults", {}),
+ "materials": updated.get("materials", []),
+ "wand_styles": updated.get("wandStyles", []),
+ "project": updated.get("project", {}),
}
save_project_settings(doc2, new_settings)
_broadcast_state(doc2)
diff --git a/rhino/startup.py b/rhino/startup.py
index 911b15d..d4ab6fc 100644
--- a/rhino/startup.py
+++ b/rhino/startup.py
@@ -309,6 +309,33 @@ def _load_all(sender, e):
_pb._t_mark("post_init", "unit_check", _t_uc)
except Exception as ex:
print("[STARTUP] unit-check active doc:", ex)
+ # Aliases + Shortcuts (Defaults aus rhino/aliases/shortcuts_default.json
+ # + User-Overrides aus dossier_settings.json) registrieren. Idempotent —
+ # SetMacro/SetMacro ueberschreibt vorhandene Eintraege. Wenn Bridge noch
+ # nicht in sticky liegt (elemente-Panel noch nicht geladen) ist das ok,
+ # die Aliases zeigen auf das Dispatch-Skript das die Bridge lazy aus
+ # sticky liest.
+ _t_al = _t.time()
+ try:
+ from aliases import loader as _alias_loader
+ _na, _nf, _nc, _ns = _alias_loader.apply_all()
+ print("[STARTUP] Aliases: {} alias, {} fkey, {} cmd, {} skipped"
+ .format(_na, _nf, _nc, _ns))
+ _pb._t_mark("post_init", "aliases", _t_al)
+ except Exception as ex:
+ print("[STARTUP] alias-loader:", ex)
+ # BeginCommand-Hook: Gestaltung-Panel oeffnen bei Drawing-Commands
+ try:
+ import begin_cmd_hook
+ begin_cmd_hook.install(verbose=True)
+ except Exception as ex:
+ print("[STARTUP] begin_cmd_hook:", ex)
+ # Welcome-Screen einmalig pro Version (markiert sich selbst)
+ try:
+ import welcome
+ welcome.show_welcome(force=False)
+ except Exception as ex:
+ print("[STARTUP] welcome:", ex)
# DOSSIERUI Window-Layout — Hinweis fuer manuelles Laden
_hint_dossier_ui()
# Startup-Timing-Summary 3 Sekunden spaeter (nachdem alle async Idle-
@@ -341,5 +368,12 @@ def _load_all(sender, e):
print("[STARTUP] Fertig")
-Rhino.RhinoApp.Idle += _load_all
-print("[STARTUP] geplant - laedt sobald Rhino idle ist")
+# Idempotency-Guard: wenn beide Pfade gleichzeitig feuern (C#-Plugin OnLoad
+# UND legacy StartupCommands-XML), nur das erste registriert den Idle-Loader.
+# Verhindert doppelte Panel-Registrierung + doppelte Listener.
+if sc.sticky.get("_dossier_startup_scheduled"):
+ print("[STARTUP] schon geplant — skip (parallel-aufruf)")
+else:
+ sc.sticky["_dossier_startup_scheduled"] = True
+ Rhino.RhinoApp.Idle += _load_all
+ print("[STARTUP] geplant - laedt sobald Rhino idle ist")
diff --git a/rhino/treppe_grips.py b/rhino/treppe_grips.py
index e99e423..8c54630 100644
--- a/rhino/treppe_grips.py
+++ b/rhino/treppe_grips.py
@@ -4,19 +4,22 @@
# Copyright (C) 2026 Karim Gabriele Varano
"""
treppe_grips.py
-Display-Conduit fuer gruene Endpunkt-Marker an Treppen-Achsen. Visuelle
+Display-Conduit fuer gruene Marker an Treppen-Achsen. Visuelle
Indikation wie bei Waenden, aber keine eigene Drag-Logik — der normale
Partnership-Cascade (elemente._on_select_objects) + Pure-Transform-Pfad
verschieben die Treppe bereits sauber.
-Endpunkt-Logik pro Treppen-Art:
+Marker-Logik pro Treppen-Art:
- gerade : PointAtStart, PointAtEnd der Linie
- - L : poly[0] (Start), poly[2] (Ende) — poly[1] ist der Eck-Punkt
+ - L (3-Pt): poly[0] (Start), poly[1] (Eck), poly[2] (Ende) — alle 3
+ damit das Eck einzeln gegriffen werden kann
+ - L (4-Pt): alle 4 Punkte (Start, Lauf1-Ende, Lauf2-Anfang, Ende)
- Wendel : poly[1] (Start), poly[2] (Ende) — poly[0] ist Rotations-
zentrum, nicht der Treppen-Anfang
"""
import Rhino
import Rhino.Display as rd
+import Rhino.DocObjects as rdoc
import Rhino.Geometry as rg
import scriptcontext as sc
import System.Drawing as SD
@@ -28,8 +31,7 @@ _MARKER_BORDER = SD.Color.FromArgb(255, 47, 93, 84)
def _treppe_endpoints(axis_obj):
- """Liefert Liste von Point3d fuer Treppen-Start + -Ende. Beachtet
- treppe_art (Wendel hat anderes Polyline-Schema)."""
+ """Liefert Liste von Point3d. Beachtet treppe_art + Polyline-Punktzahl."""
if axis_obj is None or axis_obj.IsDeleted: return []
a = axis_obj.Attributes
if a.GetUserString("dossier_element_type") != "treppe_axis": return []
@@ -41,14 +43,26 @@ def _treppe_endpoints(axis_obj):
ok, poly = geom.TryGetPolyline()
if not ok or poly is None or poly.Count != 3: return []
return [poly[1], poly[2]]
- # gerade + L → Start- und End-Punkt der Curve sind die Treppen-Enden
+ if art == "l":
+ ok, poly = geom.TryGetPolyline()
+ if not ok or poly is None: return []
+ return [poly[i] for i in range(poly.Count)]
return [geom.PointAtStart, geom.PointAtEnd]
except Exception:
return []
+def _enumerator_all():
+ """Iterator-Settings die hidden + locked Objekte mit einschliessen —
+ Mac-Default skipt sonst hidden-Layer-Objekte."""
+ s = rdoc.ObjectEnumeratorSettings()
+ s.HiddenObjects = True
+ s.LockedObjects = True
+ return s
+
+
class _TreppeEndpointConduit(rd.DisplayConduit):
- """Zeichnet gruene Endpunkt-Marker an allen selektierten Treppen-Achsen."""
+ """Zeichnet gruene Marker an allen selektierten Treppen-Achsen."""
def DrawForeground(self, e):
try:
@@ -60,10 +74,10 @@ class _TreppeEndpointConduit(rd.DisplayConduit):
a = obj.Attributes
eid = a.GetUserString("dossier_element_id") or ""
if not eid or eid in seen: continue
- # Source-Axis via element_id finden (kann anderer Obj sein
- # wenn User nur Volume oder 2D-Symbol selektiert hat)
+ # Source-Axis via element_id finden — auch wenn auf hidden
+ # Layer (User hat z.B. nur 2D-Plansymbol selektiert).
axis = None
- for o in doc.Objects:
+ for o in doc.Objects.GetObjectList(_enumerator_all()):
if o is None or o.IsDeleted: continue
try:
a2 = o.Attributes
diff --git a/rhino/welcome.py b/rhino/welcome.py
new file mode 100644
index 0000000..b1588bb
--- /dev/null
+++ b/rhino/welcome.py
@@ -0,0 +1,562 @@
+#! python3
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# Copyright (C) 2026 Karim Gabriele Varano
+"""
+welcome.py
+Welcome-Screen + Shortcuts-Cheatsheet als WebView-Dialog im DOSSIER-Style
+(passend zum Splashscreen — Petrol-Gradient, Mono-Font).
+
+Funktionen:
+ - show_welcome() — erscheint NACH dem Splash (eigener Idle-Timer), einmal
+ pro Version. User kann "nicht mehr anzeigen" rechts unten anklicken.
+ - show_cheatsheet() — DOSSIER-Shortcut-Liste, aufrufbar via dkeys-Alias.
+
+Marker-Datei fuer "schon gesehen" wird in
+~/Library/Application Support/ch.gabrielevarano.Dossier/welcome_shown abgelegt.
+"""
+import os
+import json
+import Rhino
+
+
+DOSSIER_VERSION = "0.6.3"
+DOSSIER_GITHUB = "https://github.com/karimgvarano/DOSSIER"
+DOSSIER_SUPPORT_EMAIL = "karim@gabrielevarano.ch"
+
+_WELCOME_DIR = os.path.expanduser(
+ "~/Library/Application Support/ch.gabrielevarano.Dossier")
+_WELCOME_FLAG = os.path.join(_WELCOME_DIR, "welcome_shown.txt")
+_WELCOME_OPTOUT = os.path.join(_WELCOME_DIR, "welcome_dontshow.txt")
+_SPLASH_MIN_DELAY_SEC = 3.5
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_SHORTCUTS_JSON = os.path.join(_HERE, "aliases", "shortcuts_default.json")
+
+
+def _has_optout():
+ return os.path.exists(_WELCOME_OPTOUT)
+
+
+def _has_seen_version(version):
+ try:
+ if not os.path.exists(_WELCOME_FLAG): return False
+ with open(_WELCOME_FLAG, "r") as f:
+ return f.read().strip() == version
+ except Exception:
+ return False
+
+
+def _mark_seen(version):
+ try:
+ os.makedirs(_WELCOME_DIR, exist_ok=True)
+ with open(_WELCOME_FLAG, "w") as f:
+ f.write(version)
+ except Exception as ex:
+ print("[WELCOME] mark-seen err:", ex)
+
+
+def _write_optout():
+ try:
+ os.makedirs(_WELCOME_DIR, exist_ok=True)
+ with open(_WELCOME_OPTOUT, "w") as f:
+ f.write("1")
+ except Exception as ex:
+ print("[WELCOME] optout-write err:", ex)
+
+
+def _load_shortcuts():
+ try:
+ with open(_SHORTCUTS_JSON, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ items = []
+ for k, v in data.items():
+ if k.startswith("_") or not isinstance(v, dict): continue
+ items.append({
+ "id": k,
+ "trigger": v.get("trigger", ""),
+ "label": v.get("label", k),
+ "type": v.get("type", ""),
+ })
+ return items
+ except Exception as ex:
+ print("[WELCOME] shortcuts-load err:", ex)
+ return []
+
+
+# ---- HTML — DOSSIER-Style passend zum Splash ----------------------------
+
+_WELCOME_HTML = """
+
+
+
+
+
+
+
+
+
DOSSIER.
+
Version {ver}
+
+
+
Willkommen im Studio
+
+ DOSSIER ist dein Architektur-Studio-Plugin fuer Rhino 8 —
+ Waende, Decken, Treppen, Fenster, Tueren, Raumstempel,
+ Layouts. Alles aus einer Hand, im selben Stil.
+
+
+
Einstieg
+
+
+
+
+"""
+
+
+_CHEATSHEET_HTML = """
+
+
+
+
+
+
+
+
+
DOSSIER. Shortcuts
+
v {ver}
+
+ {sections}
+
"""
+
+
+def _build_cheatsheet_html():
+ items = _load_shortcuts()
+ groups = {
+ "DOSSIER BIM": [],
+ "2D-Werkzeuge": [],
+ "Views & Navigation": [],
+ "Modify-Tools": [],
+ "Sonstige Aliases": [],
+ }
+ bim_ids = {"wand", "tuer", "fenster", "decke", "treppe", "stuetze",
+ "traeger", "raum", "symbol", "stempel", "dach", "aussparung"}
+ view_ids = {"view_plan", "view_3d", "view_material", "zoom_ext",
+ "zoom_sel", "geschoss_up", "geschoss_down",
+ "panel_layer", "panel_elemente"}
+ twod_ids = {"text", "line", "arc", "rectangle", "polyline", "curve",
+ "hatch", "polygon", "ellipse", "circle"}
+ for it in items:
+ i = it["id"]
+ if i in bim_ids: groups["DOSSIER BIM"].append(it)
+ elif i in view_ids: groups["Views & Navigation"].append(it)
+ elif i.startswith("mod_"): groups["Modify-Tools"].append(it)
+ elif i in twod_ids or i.endswith("_alias"): groups["2D-Werkzeuge"].append(it)
+ else: groups["Sonstige Aliases"].append(it)
+
+ def _row(it):
+ trig = it["trigger"]
+ trig = trig.replace("Cmd+", "⌘+").replace("Shift+", "⇧+").replace("Alt+", "⌥+")
+ return ('{} '
+ '{} '
+ .format(trig, it["label"]))
+
+ sections = []
+ for gname, gitems in groups.items():
+ if not gitems: continue
+ rows = "".join(_row(it) for it in gitems)
+ sections.append('{} '.format(gname, rows))
+ return _CHEATSHEET_HTML.format(ver=DOSSIER_VERSION, sections="".join(sections))
+
+
+# ---- Dialog-Anzeige ------------------------------------------------------
+
+def _try_borderless_mac(form):
+ """Borderless NSWindow + transparenten Hintergrund (analog _startup_splash)."""
+ try:
+ import System
+ nsw = getattr(form, "ControlObject", None)
+ if nsw is None: return False
+ # StyleMask = 0 (Borderless)
+ try:
+ cur = nsw.StyleMask
+ nsw.StyleMask = System.Enum.ToObject(type(cur), 0)
+ except Exception as ex:
+ print("[WELCOME] StyleMask:", ex)
+ # Transparent background damit border-radius vom HTML sichtbar
+ for prop, val in [("TitlebarAppearsTransparent", True),
+ ("IsOpaque", False), ("HasShadow", True),
+ ("MovableByWindowBackground", True)]:
+ try: setattr(nsw, prop, val)
+ except Exception: pass
+ try:
+ tv_type = type(nsw.TitleVisibility)
+ nsw.TitleVisibility = System.Enum.ToObject(tv_type, 1)
+ except Exception: pass
+ try:
+ from AppKit import NSColor as _NSC
+ clear = getattr(_NSC, "Clear", None) or getattr(_NSC, "ClearColor", None)
+ if clear is not None: nsw.BackgroundColor = clear
+ except Exception: pass
+ return True
+ except Exception as ex:
+ print("[WELCOME] borderless:", ex)
+ return False
+
+
+def _webview_transparent(web):
+ """WKWebView vollstaendig transparent — KVC drawsBackground=NO,
+ UnderPageBackgroundColor=Clear, Layer.BackgroundColor=CGColor.Clear."""
+ wk = getattr(web, "ControlObject", None)
+ if wk is None: return
+ try:
+ from Foundation import NSNumber, NSString
+ try: wk.SetValueForKey(NSNumber.FromBoolean(False), NSString("drawsBackground"))
+ except Exception as ex: print("[WELCOME] KVC drawsBackground:", ex)
+ except Exception as ex: print("[WELCOME] Foundation:", ex)
+ try:
+ from AppKit import NSColor as _NSC
+ clear = getattr(_NSC, "Clear", None) or getattr(_NSC, "ClearColor", None)
+ if clear is not None:
+ try: wk.UnderPageBackgroundColor = clear
+ except Exception: pass
+ try:
+ layer = getattr(wk, "Layer", None)
+ if layer is not None:
+ layer.BackgroundColor = clear.CGColor
+ layer.Opaque = False
+ except Exception as ex: print("[WELCOME] Layer:", ex)
+ except Exception as ex: print("[WELCOME] NSColor:", ex)
+
+
+def _show_html_form(title, html, width=620, height=720, on_navigating=None,
+ borderless=True):
+ """Eto.Forms.Form mit WebView + Inline-HTML. Optional borderless +
+ Navigation-Hook fuer custom URL-Schemes."""
+ try:
+ import Eto.Forms as ef
+ import Eto.Drawing as ed
+ except Exception as ex:
+ print("[WELCOME] Eto.Forms nicht verfuegbar:", ex)
+ return None
+
+ try:
+ form = ef.Form()
+ form.Title = title
+ form.ClientSize = ed.Size(width, height)
+ form.Topmost = False
+ form.Resizable = False
+ if borderless:
+ try: form.WindowStyle = getattr(ef.WindowStyle, "None")
+ except Exception: pass
+ for attr, val in (("Minimizable", False), ("Maximizable", False),
+ ("Closeable", False), ("ShowInTaskbar", False)):
+ try: setattr(form, attr, val)
+ except Exception: pass
+ try: form.BackgroundColor = ed.Colors.Transparent
+ except Exception: pass
+ web = ef.WebView()
+ web.Size = ed.Size(width, height)
+ if borderless:
+ try: web.BackgroundColor = ed.Colors.Transparent
+ except Exception: pass
+ if on_navigating is not None:
+ try: web.DocumentLoading += on_navigating
+ except Exception as ex: print("[WELCOME] nav-hook:", ex)
+ try: web.LoadHtml(html)
+ except Exception as e: print("[WELCOME] LoadHtml:", e)
+ form.Content = web
+ try: form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
+ except Exception: pass
+ form.Show()
+ if borderless:
+ _try_borderless_mac(form)
+ _webview_transparent(web)
+ try: ef.Application.Instance.RunIteration()
+ except Exception: pass
+ return form
+ except Exception as ex:
+ print("[WELCOME] form show:", ex)
+ return None
+
+
+def show_welcome(force=False):
+ """Zeigt Welcome NACH Splash. Erscheint bei jedem Start ausser der
+ User klickt 'Nicht mehr anzeigen' (= optout-File).
+ WICHTIG: UI muss auf Main-Thread laufen (Mac Cocoa) — Rhino-Idle-Event
+ feuert dort, deshalb defern wir die Anzeige."""
+ if not force and _has_optout():
+ print("[WELCOME] optout aktiv ({}) — skip".format(_WELCOME_OPTOUT))
+ return
+ print("[WELCOME] geplant — Anzeige nach Splash (>{:.1f}s)".format(_SPLASH_MIN_DELAY_SEC))
+
+ import time
+ state = {"start": time.time(), "fired": False}
+ def _on_idle(sender, e):
+ if state["fired"]: return
+ if time.time() - state["start"] < _SPLASH_MIN_DELAY_SEC: return
+ state["fired"] = True
+ try:
+ Rhino.RhinoApp.Idle -= _on_idle
+ except Exception: pass
+ try:
+ print("[WELCOME] Anzeige starten")
+ _show_welcome_now()
+ except Exception as ex:
+ print("[WELCOME] show err:", ex)
+ try:
+ Rhino.RhinoApp.Idle += _on_idle
+ except Exception as ex:
+ print("[WELCOME] idle-hook err:", ex)
+
+
+def _show_welcome_now():
+ html = _WELCOME_HTML.format(
+ ver=DOSSIER_VERSION, github=DOSSIER_GITHUB, email=DOSSIER_SUPPORT_EMAIL)
+ form_ref = [None]
+ def _on_nav(sender, e):
+ try:
+ url = e.Uri.ToString() if hasattr(e, "Uri") else str(getattr(e, "Url", ""))
+ except Exception:
+ url = ""
+ if not url: return
+ if url.startswith("dossier:optout"):
+ # Optout-Checkbox-Klick. URL-Form: dossier:optout?true/false
+ checked = url.endswith("true")
+ if checked: _write_optout()
+ else:
+ try:
+ if os.path.exists(_WELCOME_OPTOUT):
+ os.remove(_WELCOME_OPTOUT)
+ except Exception: pass
+ try: e.Cancel = True
+ except Exception: pass
+ elif url.startswith("dossier:cheatsheet"):
+ try: e.Cancel = True
+ except Exception: pass
+ show_cheatsheet()
+ try:
+ if form_ref[0] is not None: form_ref[0].Close()
+ except Exception: pass
+ elif url.startswith("dossier:close"):
+ try: e.Cancel = True
+ except Exception: pass
+ try:
+ if form_ref[0] is not None: form_ref[0].Close()
+ except Exception: pass
+ form_ref[0] = _show_html_form("Willkommen bei DOSSIER", html, 600, 620,
+ on_navigating=_on_nav)
+
+
+def show_cheatsheet():
+ html = _build_cheatsheet_html()
+ form_ref = [None]
+ def _on_nav(sender, e):
+ try:
+ url = e.Uri.ToString() if hasattr(e, "Uri") else str(getattr(e, "Url", ""))
+ except Exception:
+ url = ""
+ if not url: return
+ if url.startswith("dossier:close"):
+ try: e.Cancel = True
+ except Exception: pass
+ try:
+ if form_ref[0] is not None: form_ref[0].Close()
+ except Exception: pass
+ elif url.startswith("dossier:back"):
+ try: e.Cancel = True
+ except Exception: pass
+ try:
+ if form_ref[0] is not None: form_ref[0].Close()
+ except Exception: pass
+ try: _show_welcome_now()
+ except Exception as ex: print("[WELCOME] back:", ex)
+ form_ref[0] = _show_html_form("DOSSIER Shortcuts", html, 640, 760,
+ on_navigating=_on_nav)
+
+
+if __name__ == "__main__":
+ show_cheatsheet()
diff --git a/rhino/workspaces/b6b68c03-3031-4899-bca2-fe6e425146fc.xml b/rhino/workspaces/b6b68c03-3031-4899-bca2-fe6e425146fc.xml
new file mode 100644
index 0000000..c7339f2
--- /dev/null
+++ b/rhino/workspaces/b6b68c03-3031-4899-bca2-fe6e425146fc.xml
@@ -0,0 +1,561 @@
+
+
+
+
+
+ DOSSIERUIV0.2
+
+
+
+
+
+
+
+ Oberleiste
+
+
+
+
+
+
+
+
+ Ebenen
+
+
+
+
+
+
+
+
+
+
+
+ Standard Toolbars
+ Standardní palety nástrojů
+ Standard-Werkzeugleisten
+ Barras de herramientas estándar
+ Barres d'outils Standard
+ Barre degli strumenti standard
+ 標準ツールバー
+ 표준 도구모음
+ Standardowe paski narzędzi
+ Barras de Ferramentas Standard
+ 标准工具列
+ 標準工具列
+ Стандартные панели инструментов
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SubD Sidebar
+ Postranní panel SubD
+ SubD-Seitenleiste
+ SubD (lateral)
+ Volet SubD
+ Barra laterale SubD
+ SubDサイドバー
+ SubD 사이드바
+ SubD - pasek boczny
+ Barra Lateral SubD
+ 细分边栏
+ SubD 邊欄
+ SubD - боковая
+
+
+
+
+
+
+
+
+ Layers
+
+
+
+
+
+
+
+
+ Container
+
+
+
+
+
+
+
+
+ Display
+
+
+
+
+
+
+
+
+ Display & Rendering
+ Zobrazení a renderování
+ Anzeige und Rendering
+ Visualización y renderizado
+ Affichage et rendu
+ Visualizzazione e rendering
+ 表示 & レンダリング
+ 표시와 렌더링
+ Wyświetlanie i rendering
+ Visualização e Renderização
+ 显示 & 渲染
+ 顯示 & 彩現
+ Отображение и визуализация
+
+
+
+
+
+
+
+
+
+ Solids Sidebar
+ Postranní panel Tělesa
+ Volumenkörper-Seitenleiste
+ Sólidos (lateral)
+ Volet Solides
+ Barra laterale Solidi
+ ソリッドサイドバー
+ 솔리드 사이드바
+ Bryły - pasek boczny
+ Barra lateral de Sólidos
+ 实体边栏
+ 實體邊欄
+ Тела - боковая
+
+
+
+
+
+
+
+
+ Elemente
+
+
+
+
+
+
+
+
+ Curve drawing sidebar
+ Postranní panel Křivka
+ Kurvenzeichnung-Seitenleiste
+ Dibujo de curvas (lateral)
+ Volet Dessin de courbes
+ Barra laterale disegno curve
+ 曲線作成サイドバー
+ 커브 그리기 사이드바
+ Rysowanie krzywych - pasek boczny
+ Barra lateral de desenhar curva
+ 曲线绘制边栏
+ 繪製曲線邊欄
+ Кривые - боковая
+
+
+
+
+
+
+
+
+
+
+
+ Oberleiste
+
+
+
+
+
+
+
+
+ Werkzeuge
+
+
+
+
+
+
+
+
+ Right Container
+ Pravý kontejner
+ Rechter Container
+ Contenedor derecho
+ Conteneur droit
+ Contenitore destro
+ 右コンテナ
+ 오른쪽 컨테이너
+ Prawy zbiornik
+ Contentor Direito
+ 右侧容器
+ 右側容器
+ Правый контейнер
+
+
+
+
+
+
+
+
+
+
+
+
+ Gestaltung
+
+
+
+
+
+
+
+
+ Zeichnungsebenen
+
+
+
+
+
+
+
+
+ Render Sidebar
+ Postranní panel Render
+ Render-Seitenleiste
+ Renderizado (lateral)
+ Volet Rendu
+ Barra laterale Rendering
+ レンダリングサイドバー
+ 렌더링 사이드바
+ Rendering - pasek boczny
+ Barra lateral de Renderizar
+ 渲染边栏
+ 彩現邊欄
+ Визуализация - боковая
+
+
+
+
+
+
+
+
+ Ebenen
+
+
+
+
+
+
+
+
+ Ausschnitte
+
+
+
+
+
+
+
+
+ Help 01
+
+
+
+
+
+
+
+
+ Named Views
+ Pojmenované pohledy
+ Benannte Ansichten
+ Vistas guardadas
+ Vues nommées
+ Viste con nome
+ 名前の付いたビュー
+ 명명된 뷰
+ Nazwane widoki
+ Vistas Com Nome
+ 已命名视图
+ 已命名視圖
+ Именованные виды
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dimensionen
+
+
+
+
+
+
+
+
+ Gestaltung
+
+
+
+
+
+
+
+
+
+
+ Surface Sidebar
+ Postranní panel Plocha
+ Flächen-Seitenleiste
+ Superficies (lateral)
+ Volet Surface
+ Barra laterale Superfici
+ サーフェスサイドバー
+ 서피스 사이드바
+ Powierzchnia - pasek boczny
+ Superfícies
+ 曲面边栏
+ 曲面邊欄
+ Поверхности - боковая
+
+
+
+
+
+
+
+
+ Command History
+ 명령 히스토리
+ コマンドヒストリ
+ История команд
+ Befehlsverlauf
+ 指令历史
+ Historial de comandos
+ Historique des commandes
+ 指令歷史
+ Historia poleceń
+ Storico comandi
+ Historie příkazů
+ Histórico de Comandos
+
+
+
+
+
+
+
+
+ Help
+ 도움말
+ ヘルプ
+ Справка
+ Hilfe
+ 说明
+ Ayuda
+ Aide
+ 說明
+ Pomoc
+ Aiuti
+ Nápověda
+ Ajuda
+
+
+
+
+
+
+
+
+ Layouts
+
+
+
+
+
+
+
+
+ Main
+ Hlavní
+ Haupt
+ Principal
+ Principale
+ Principale
+ メイン
+ 메인
+ Główne
+ Principal
+ 主要
+ 主要
+ Главная
+
+
+
+
+
+
+
+
+ OSnap
+ Uchop
+ Ofang
+ RefObj
+ Accrochages
+ Osnap
+ OSnap
+ 개체스냅
+ UchwytOb
+ OSnap
+ 物件锁点
+ 物件鎖點
+ Привязка
+
+
+
+
+
+
+
+
+
+ Properties
+
+
+
+
+
+
+
+
+ Mesh Sidebar
+ Postranní panel Síť
+ Polygonnetz-Seitenleiste
+ Malla (lateral)
+ Volet Maillage
+ Barra laterale Mesh
+ メッシュサイドバー
+ 메쉬 사이드바
+ Siatka - pasek boczny
+ Barra lateral de Malha
+ 网格边栏
+ 網格邊欄
+ Сети - боковая
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/OberleisteApp.jsx b/src/OberleisteApp.jsx
index b3f6bde..2ad979f 100644
--- a/src/OberleisteApp.jsx
+++ b/src/OberleisteApp.jsx
@@ -14,7 +14,7 @@ import {
deleteLayerCombination, openLayerCombinationsDialog,
openDossierSettings, openKameraPanel,
setMasseActive, openMasseSettings,
- openAbout, createText, setTextSettings,
+ openAbout, openCheatsheet, createText, setTextSettings,
applyTextStyle, saveTextStyle, deleteTextStyle,
setDarstellung,
arrangeSelection,
@@ -255,10 +255,10 @@ export default function OberleisteApp() {
overflowX: 'auto', overflowY: 'hidden',
flexShrink: 0,
}}>
- {/* Logo: DOSSIER. + Version darunter (Klick = About-Fenster) */}
+ {/* Logo: DOSSIER. + Version darunter (Klick = Cheatsheet, Shift+Klick = About) */}
openAbout()}
- title="Über Dossier"
+ onClick={(e) => e.shiftKey ? openAbout() : openCheatsheet()}
+ title="Shortcuts (Shift+Klick = Über Dossier)"
style={{
display: 'flex', flexDirection: 'column',
alignItems: 'flex-start', gap: 0,
diff --git a/src/components/EbenenManager.jsx b/src/components/EbenenManager.jsx
index 96a3df7..f854568 100644
--- a/src/components/EbenenManager.jsx
+++ b/src/components/EbenenManager.jsx
@@ -265,20 +265,17 @@ function EbeneRow({ e, depth, hasChildren, expanded, onToggleExpand, active, mod
minHeight: 24,
}}
>
- {/* Chevron sitzt visuell weiter rechts (marginLeft) — marginRight
- kompensiert das, damit die nachfolgenden Elemente (Auge, Code,
- Farbe, Name) nicht mitrutschen. Spacer fuer kinderlose Zeilen
- spiegelt dasselbe Offset, sonst springt die Eye-Spalte zwischen
- Parent- und Leaf-Zeilen. */}
+ {/* Chevron-Slot 12w — identisch zu GeschossManager-Spacer, damit
+ die Eye-Spalten beider Panels auf gleicher Position liegen. */}
{hasChildren ? (
{ ev.stopPropagation(); onToggleExpand() }}
title={expanded ? 'Einklappen' : 'Aufklappen'}
- style={{ width: 12, height: 12, marginLeft: 6, marginRight: -6 }}
+ style={{ width: 12, height: 12, flexShrink: 0 }}
>
) : (
-
+
)}
+ {groups.map((grp) => (
+
+
{grp.label}
+ {grp.items.map((it) => {
+ const isActive = active === it.key
+ return (
+
+ onChange(it.key)}
+ style={{
+ display: 'block', width: '100%',
+ textAlign: 'left',
+ padding: '6px 12px',
+ background: isActive ? 'var(--accent)' : 'transparent',
+ color: isActive ? '#fff' : 'var(--text-primary)',
+ border: 'none',
+ borderRadius: 999,
+ cursor: 'pointer',
+ fontSize: 11,
+ lineHeight: 1.4,
+ }}>
+ {it.label}
+
+
+ )
+ })}
+
+ ))}
+
+ )
+}
+
/* LinetypePreview — SVG-Linie mit Strich-Segmenten. segments = [{length,type}]
type ∈ Line/Space (manchmal auch Continuous-Ableitungen). Width in px;
wir skalieren die Segmente damit das Gesamtmuster in width passt. */
@@ -623,10 +674,13 @@ export default function ProjectSettingsDialog({
}) {
const [tab, setTab] = useState('defaults')
const [draft, setDraft] = useState(() => ({
- defaults: { ...(initial.defaults || {}) },
- materials: [...(initial.materials || [])],
- project: { ...(initial.project || {}) },
+ defaults: { ...(initial.defaults || {}) },
+ materials: [...(initial.materials || [])],
+ wandStyles: [...(initial.wandStyles || [])],
+ project: { ...(initial.project || {}) },
}))
+ const [selWandStyleIdx, setSelWandStyleIdx] = useState(() =>
+ (initial.wandStyles && initial.wandStyles.length) ? 0 : null)
const setProject = (k, v) =>
setDraft(d => ({ ...d, project: { ...(d.project || {}), [k]: v } }))
const [selMat, setSelMat] = useState(() => {
@@ -750,6 +804,42 @@ export default function ProjectSettingsDialog({
setSelMat({ kind: 'local', idx: draft.materials.length })
}
+ // Wand-Stile CRUD — gleiche Pattern wie Materialien
+ const setWandStyle = (i, patch) => setDraft(d => ({
+ ...d, wandStyles: d.wandStyles.map((s, idx) =>
+ idx === i ? { ...s, ...patch } : s),
+ }))
+ const delWandStyle = (i) => {
+ setDraft(d => ({
+ ...d, wandStyles: d.wandStyles.filter((_, idx) => idx !== i),
+ }))
+ setSelWandStyleIdx(null)
+ }
+ const addWandStyle = () => {
+ const newStyle = {
+ id: 'style_' + Math.random().toString(36).slice(2, 10),
+ name: 'Neuer Stil', prio: 500,
+ dicke: 0.25, referenz: 'mid',
+ layered: false, material: '', layers: [],
+ }
+ setDraft(d => ({
+ ...d, wandStyles: [...d.wandStyles, newStyle],
+ }))
+ setSelWandStyleIdx(draft.wandStyles.length)
+ }
+ const dupWandStyle = (i) => {
+ setDraft(d => {
+ const src = d.wandStyles[i]
+ if (!src) return d
+ const copy = { ...src,
+ id: 'style_' + Math.random().toString(36).slice(2, 10),
+ name: (src.name || 'Stil') + ' (Kopie)',
+ }
+ return { ...d, wandStyles: [...d.wandStyles, copy] }
+ })
+ setSelWandStyleIdx(draft.wandStyles.length)
+ }
+
const wrapperStyle = embedded ? {
width: '100%', height: '100%',
background: 'var(--bg-dialog)',
@@ -766,17 +856,30 @@ export default function ProjectSettingsDialog({