#! python 3 # -*- coding: utf-8 -*- """ rhinopanel.py Oeffnet das EBENEN-Panel (Zeichnungsebenen + globale Ebenen). """ import os import sys import json import Rhino import scriptcontext as sc _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) import panel_base import layer_builder PANEL_GUID_STR = "3a7f2e1d-4b8c-4d9e-a1b2-c3d4e5f60718" # Zweites Panel fuer Zeichnungsebenen (Geschoss-Liste + Clipping). UX-Split # damit der User nicht beide grossen Sections in einem Panel scrollen muss. # Beide Bridges sharen den State via doc.Strings und synchronisieren sich # gegenseitig via STATE_SYNC-Broadcast. PANEL_GUID_STR_Z = "3a7f2e1d-4b8c-4d9e-a1b2-c3d4e5f60719" # Loop-Guard fuer Layer-Events (verhindert Endlos-Schleife bei eigenen Aenderungen) def _is_processing(): return bool(sc.sticky.get("ebenen_processing_layer", False)) def _set_processing(v): sc.sticky["ebenen_processing_layer"] = bool(v) def _hatch_pattern_names(doc): """Liefert alle Hatch-Pattern-Namen aus doc.HatchPatterns als Liste.""" out = [] try: for i in range(doc.HatchPatterns.Count): try: hp = doc.HatchPatterns[i] if hp is None or hp.IsDeleted: continue if hp.Name: out.append(hp.Name) except Exception: continue except Exception: pass if not out: out = ["Solid"] return out def _read_launcher_schema(): """Liest das Default-Layer-Schema aus dossier_settings.json (Launcher-Pfad). Liefert eine Liste {code, name, color, lw} oder None wenn nicht gesetzt.""" paths = [ os.path.expanduser("~/Library/Application Support/" "ch.gabrielevarano.Dossier/dossier_settings.json"), os.path.expanduser("~/Library/Application Support/" "RhinoPanel/dossier_settings.json"), ] for p in paths: try: if not os.path.isfile(p): continue with open(p, "rb") as f: data = json.loads(f.read().decode("utf-8")) schema = (data or {}).get("layerSchema") if isinstance(schema, list) and schema: # Sanity: alle vier Pflichtfelder vorhanden clean = [r for r in schema if isinstance(r, dict) and r.get("code") and r.get("name") and r.get("color") is not None and r.get("lw") is not None] if clean: return clean except Exception as ex: print("[EBENEN] launcher-schema lesen ({}):".format(p), ex) return None def _broadcast_state(doc=None, hatch_patterns=None): """STATE_SYNC an alle registrierten Bridges schicken — beide Panels (Ebenen + Zeichnungsebenen) sollen denselben State sehen. Liest aktuell aus doc.Strings; jede React-App pickt sich ihre Slice.""" if doc is None: doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return try: z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") e_raw = doc.Strings.GetValue("dossier_ebenen") # Projekt-Nullpunkt in m.ü.M — wird beim Swisstopo-Import als # Z-Offset angewandt (Real-Welt-Höhen → Doc-Z relativ zu OKFF=0). zero_raw = doc.Strings.GetValue("dossier_project_zero_mum") try: zero_mum = float(zero_raw) if zero_raw else 0.0 except Exception: zero_mum = 0.0 payload = { "zeichnungsebenen": json.loads(z_raw) if z_raw else None, "ebenen": json.loads(e_raw) if e_raw else None, "projectZeroMum": zero_mum, "projectSettings": load_project_settings(doc), "hatchPatterns": hatch_patterns if hatch_patterns is not None else _hatch_pattern_names(doc), "layerCombinations": list_layer_preset_names(doc), "layerCombinationActive": get_active_comb_name(doc), } except Exception as ex: print("[EBENEN] broadcast prepare:", ex) return for key in ("ebenen_bridge_ref", "zeichnungsebenen_bridge_ref"): b = sc.sticky.get(key) if b is None: continue try: b.send("STATE_SYNC", payload) except Exception: pass # --- Layer-Kombinationen: Modul-Level Helpers ------------------------------ # Diese Helfer werden sowohl von EbenenBridge (Ebenen-Panel) als auch von # OberleisteBridge (Top-Bar) und LayerCombinationsBridge (Satelliten-Editor) # benutzt. doc.Strings ist die einzige Quelle der Wahrheit; nach jedem Write # rufen die Caller _broadcast_state(doc) + invalidate cross-bridge caches. _PRESETS_KEY = "dossier_layer_presets" _ACTIVE_COMB_KEY = "dossier_layer_active_comb" # Projekt-Einstellungen: zentrale Voreinstellungen die beim Erstellen # neuer Geschosse / Schnitte / etc. als Default genommen werden. Pro- # Element-Werte ueberschreiben das natuerlich — das hier ist nur die # Voreinstellung. Persistiert pro Doc. _PROJECT_SETTINGS_KEY = "dossier_project_settings" _PROJECT_SETTINGS_DEFAULTS = { "defaults": { "geschossHoehe": 3.0, "schnitthoehe": 1.0, "schnittDepthBack": 8.0, "schnittHeightMin": -1.0, "schnittHeightMax": 12.0, }, "materials": [], # User-erweiterte Materialien (zusaetzlich zur # hardcoded _MATERIAL_LIBRARY in elemente.py) } def _normalize_material(m): """Garantiert Material-Schema. Material ist REIN 3D — Section-Hatch (2D-Schnitt) wird via Ebenen-Settings am Layer konfiguriert. Felder: - Identitaet: name, source ('local'|'library'|'builtin'), libraryId - 3D-Farbe: color - PBR (3D-Render): roughness, reflection, transparency (0..1), iorN (1.0..2.5) - UV: uvScaleM (= 1 Welt-Meter ≙ wieviel der Textur) - Texturen: textures = { diffuse, bump, roughness, transparency } pro Slot {path: absolute string} oder null. Strength fuer Bump.""" if not isinstance(m, dict): return None textures = m.get("textures") or {} if not isinstance(textures, dict): textures = {} def _tex(slot): t = textures.get(slot) if not isinstance(t, dict): return None p = t.get("path") if not p: return None out = {"path": str(p)} if slot == "bump": try: out["strength"] = float(t.get("strength", 0.5)) except Exception: out["strength"] = 0.5 return out return { "name": m.get("name") or "Unbenannt", "color": m.get("color") or "#888888", "source": m.get("source") or "local", "libraryId": m.get("libraryId"), "roughness": _clamp01(m.get("roughness", 0.7)), "reflection": _clamp01(m.get("reflection", 0.1)), "transparency": _clamp01(m.get("transparency", 0.0)), "iorN": _clamp(m.get("iorN", 1.0), 1.0, 2.5), "uvScaleM": float(m.get("uvScaleM", 1.0) or 1.0), "textures": { "diffuse": _tex("diffuse"), "bump": _tex("bump"), "roughness": _tex("roughness"), "transparency": _tex("transparency"), }, } def _clamp01(v): try: return max(0.0, min(1.0, float(v))) except Exception: return 0.0 def _clamp(v, lo, hi): try: return max(lo, min(hi, float(v))) except Exception: return lo def load_project_settings(doc): """Liefert die Project-Settings als dict — mit Defaults-Merge wenn Felder fehlen. Garantiert dass `defaults` und `materials` immer da sind, und Materialien normalisiert (source + libraryId).""" raw = None 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"]), } if not raw: return out try: data = json.loads(raw) if isinstance(data, dict): d = data.get("defaults") if isinstance(d, dict): for k, v in d.items(): out["defaults"][k] = v m = data.get("materials") if isinstance(m, list): out["materials"] = [ _normalize_material(x) for x in m if _normalize_material(x) is not None ] except Exception as ex: print("[PROJECT-SETTINGS] load:", ex) return out def save_project_settings(doc, settings): """Persistiert Settings in doc.Strings. settings: dict mit 'defaults' + 'materials'. Caller broadcastet ggf. selbst.""" if doc is None or not isinstance(settings, dict): return False try: doc.Strings.SetString(_PROJECT_SETTINGS_KEY, json.dumps(settings, ensure_ascii=False)) return True except Exception as ex: print("[PROJECT-SETTINGS] save:", ex) return False def load_layer_presets(doc): raw = doc.Strings.GetValue(_PRESETS_KEY) if not raw: return [] try: data = json.loads(raw) return data if isinstance(data, list) else [] except Exception: return [] def store_layer_presets(doc, presets): try: doc.Strings.SetString(_PRESETS_KEY, json.dumps(presets, ensure_ascii=False)) except Exception as ex: print("[EBENEN] store_layer_presets:", ex) def get_active_comb_name(doc): try: v = doc.Strings.GetValue(_ACTIVE_COMB_KEY) return v if v else None except Exception: return None def set_active_comb_name(doc, name): try: doc.Strings.SetString(_ACTIVE_COMB_KEY, name or "") except Exception as ex: print("[EBENEN] set_active_comb_name:", ex) def list_layer_preset_names(doc): return [p.get("name") for p in load_layer_presets(doc) if isinstance(p, dict) and p.get("name")] def _notify_oberleiste_combs(): """Cache der Oberleiste invalidieren + force-send. Wird gerufen wenn die Combinations-Liste oder activeCombName sich aendert.""" try: b = sc.sticky.get("oberleiste_bridge") if b is not None: b._cached_combinations = None b._send_state(force=True) except Exception as ex: print("[EBENEN] notify oberleiste combs:", ex) def _notify_layer_combinations_editor(): """Satelliten-Fenster (Editor) informieren falls offen — Layer-/Preset- Liste hat sich geaendert.""" try: b = sc.sticky.get("layer_combinations_bridge") if b is not None: b._send_state() except Exception as ex: print("[EBENEN] notify layer-combinations editor:", ex) def apply_layer_preset_by_name(doc, name): """Laedt Preset `name` und wendet es an. Setzt active_comb_name. Liefert True wenn erfolgreich.""" if not name: return False presets = load_layer_presets(doc) preset = next((p for p in presets if p.get("name") == name), None) if preset is None: print("[EBENEN] apply_layer_preset_by_name: '{}' nicht gefunden".format(name)) return False payload = { "layers": preset.get("layers") or [], "dossierEbenen": preset.get("dossierEbenen"), "dossierZeichnungsebenen": preset.get("dossierZeichnungsebenen"), } # Routing: wenn die EbenenBridge existiert, delegiere — die hat den # vollen Eye-State-Pfad inkl. STATE_SYNC + Redraw. Sonst inline applien. eb = sc.sticky.get("ebenen_bridge_ref") if eb is not None: try: eb._apply_combination(payload) except Exception as ex: print("[EBENEN] apply via bridge:", ex) return False else: # Fallback: direkt doc.Strings + doc.Layer setzen (kein Bridge offen) _apply_layer_preset_inline(doc, payload) set_active_comb_name(doc, name) _notify_oberleiste_combs() return True def _apply_layer_preset_inline(doc, payload): """Fallback wenn keine EbenenBridge offen ist — minimaler Layer-State- Pfad. Setzt doc.Strings + doc.Layer.IsVisible direkt.""" pe = payload.get("dossierEbenen") pz = payload.get("dossierZeichnungsebenen") try: e_raw = doc.Strings.GetValue("dossier_ebenen") or "[]" z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") or "[]" e_list = json.loads(e_raw) or [] z_list = json.loads(z_raw) or [] if isinstance(pe, list): by_code = {x.get("code"): x for x in pe if isinstance(x, dict) and x.get("code")} for e in e_list: if not isinstance(e, dict): continue s = by_code.get(e.get("code")) if s is None: continue e["visible"] = bool(s.get("visible", True)) e["locked"] = bool(s.get("locked", False)) if isinstance(pz, list): by_id = {x.get("id"): x for x in pz if isinstance(x, dict) and x.get("id")} for z in z_list: if not isinstance(z, dict): continue s = by_id.get(z.get("id")) if s is None: continue z["visible"] = bool(s.get("visible", True)) doc.Strings.SetString("dossier_ebenen", json.dumps(e_list, ensure_ascii=False)) doc.Strings.SetString("dossier_zeichnungsebenen", json.dumps(z_list, ensure_ascii=False)) except Exception as ex: print("[EBENEN] inline preset-apply (eye-state):", ex) # Layer-ID-Pfad als Sekundaer (AUSSCHNITTE-Kompat) layer_states = payload.get("layers") or [] if layer_states: by_id = {} try: for layer in doc.Layers: if not layer.IsDeleted: by_id[str(layer.Id)] = layer except Exception: pass _set_processing(True) try: for ls in layer_states: layer = by_id.get(ls.get("id")) if layer is None: continue try: want_vis = bool(ls.get("visible", True)) want_lck = bool(ls.get("locked", False)) if layer.IsVisible != want_vis: layer.IsVisible = want_vis if layer.IsLocked != want_lck: layer.IsLocked = want_lck except Exception: pass finally: _set_processing(False) try: doc.Views.Redraw() except Exception: pass _broadcast_state(doc) def save_current_as_layer_preset(doc, name): """Speichert die aktuellen Eye-States als Preset. Setzt active_comb_name.""" name = (name or "").strip() if not name: return False # 1) doc.Layer state (Kompat mit AUSSCHNITTE) layers = [] try: for layer in doc.Layers: if layer is None or layer.IsDeleted: continue layers.append({ "id": str(layer.Id), "visible": bool(layer.IsVisible), "locked": bool(layer.IsLocked), }) except Exception as ex: print("[EBENEN] save_current_as_layer_preset enum:", ex) # 2) Eye-States aus dossier_ebenen / dossier_zeichnungsebenen pe_state, pz_state = [], [] try: e_raw = doc.Strings.GetValue("dossier_ebenen") if e_raw: for e in (json.loads(e_raw) or []): if isinstance(e, dict) and e.get("code"): pe_state.append({ "code": e["code"], "visible": bool(e.get("visible", True)), "locked": bool(e.get("locked", False)), }) z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") if z_raw: for z in (json.loads(z_raw) or []): if isinstance(z, dict) and z.get("id"): pz_state.append({ "id": z["id"], "visible": bool(z.get("visible", True)), }) except Exception as ex: print("[EBENEN] save_current_as_layer_preset eye-states:", ex) presets = load_layer_presets(doc) new_data = { "name": name, "layers": layers, "dossierEbenen": pe_state, "dossierZeichnungsebenen": pz_state, } existing = next((p for p in presets if p.get("name") == name), None) if existing is not None: existing.update(new_data) else: presets.append(new_data) store_layer_presets(doc, presets) set_active_comb_name(doc, name) _notify_oberleiste_combs() _notify_layer_combinations_editor() print("[EBENEN] '{}' gespeichert: {} Layer + {} Ebenen Eye-State".format( name, len(layers), len(pe_state))) return True def delete_layer_preset(doc, name): name = (name or "").strip() if not name: return False presets = [p for p in load_layer_presets(doc) if p.get("name") != name] store_layer_presets(doc, presets) if get_active_comb_name(doc) == name: set_active_comb_name(doc, None) _notify_oberleiste_combs() _notify_layer_combinations_editor() print("[EBENEN] Kombination '{}' geloescht".format(name)) return True def clear_active_comb_name(doc): """Wird vom EbenenBridge SET_VISIBILITY / APPLY-Pfad gerufen wenn der User per Hand etwas am Layer-State aendert — dann passt das Preset nicht mehr und wir markieren 'Eigene'.""" if get_active_comb_name(doc): set_active_comb_name(doc, None) _notify_oberleiste_combs() class EbenenBridge(panel_base.BaseBridge): """Gemeinsame Bridge-Klasse fuer beide Panels (Ebenen + Zeichnungsebenen). Mode bestimmt nur welches WebView die Bridge bedient + welcher sticky-Slot fuer die Cross-Sync genutzt wird. Beide Bridges koennen die gleichen Messages verarbeiten — jede React-App schickt nur die fuer ihre Section relevanten.""" def __init__(self, mode="ebenen"): panel_base.BaseBridge.__init__(self, mode) self._mode = mode def _on_ready(self): doc = Rhino.RhinoDoc.ActiveDoc z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") e_raw = doc.Strings.GetValue("dossier_ebenen") # FIRST_RUN-Entscheidung ist MODE-AWARE: jedes Panel sendet FIRST_RUN # wenn SEINE Slice in doc.Strings fehlt. Sonst race-conditiont das # erste APPLY (das nur eine Slice schreibt) und das andere Panel # kriegt STATE_SYNC mit leerer Slice → leere UI. my_slice_present = (e_raw if self._mode == "ebenen" else z_raw) if my_slice_present: try: z = json.loads(z_raw) if z_raw else None e = json.loads(e_raw) if e_raw else None if z and e: layer_builder.build_layers(doc, z, e) layer_builder.cleanup_default_layers(doc) self._ensure_active_sublayer() zero_raw = doc.Strings.GetValue("dossier_project_zero_mum") try: zero_mum = float(zero_raw) if zero_raw else 0.0 except Exception: zero_mum = 0.0 self.send("STATE_SYNC", { "zeichnungsebenen": z, "ebenen": e, "projectZeroMum": zero_mum, "hatchPatterns": _hatch_pattern_names(doc), }) except Exception as ex: print("[EBENEN] State-Sync:", ex) else: payload = {"hatchPatterns": _hatch_pattern_names(doc)} # Falls der User im Launcher eigene Default-Ebenen definiert hat, # mitschicken — React nimmt's statt seiner hardcoded INITIAL_EBENEN. launcher_schema = _read_launcher_schema() if launcher_schema: payload["defaultEbenen"] = launcher_schema print("[EBENEN] FIRST_RUN mit Launcher-Schema ({} Ebenen)".format( len(launcher_schema))) self.send("FIRST_RUN", payload) def handle(self, data): if not isinstance(data, dict): return t = data.get("type", "") p = data.get("payload") or {} if not isinstance(p, dict): p = {} doc = Rhino.RhinoDoc.ActiveDoc if t == "READY": self._on_ready() elif t == "APPLY": print("[EBENEN-BE] APPLY from mode={} payload-z={} payload-e={}".format( self._mode, len(p.get("zeichnungsebenen") or []), len(p.get("ebenen") or []))) if self._mode == "zeichnungsebenen": z_payload = p.get("zeichnungsebenen") or [] e_raw = doc.Strings.GetValue("dossier_ebenen") e_payload = json.loads(e_raw) if e_raw else [] print("[EBENEN-BE] mode=zeichnungsebenen: e from doc.Strings n={}".format(len(e_payload))) self._apply(z_payload, e_payload, save_z=True, save_e=False) else: e_payload = p.get("ebenen") or [] z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") z_payload = json.loads(z_raw) if z_raw else [] print("[EBENEN-BE] mode=ebenen: z from doc.Strings n={}".format(len(z_payload))) self._apply(z_payload, e_payload, save_z=False, save_e=True) elif t == "LAYER_STYLE": layer_builder.update_layer_style(doc, p["code"], p.get("color"), p.get("lw")) if p.get("color") is not None: self._update_ebene_field(p["code"], "color", p["color"]) if p.get("lw") is not None: self._update_ebene_field(p["code"], "lw", p["lw"]) elif t == "SET_ACTIVE": self._set_active_zeichnungsebene(p) elif t == "CREATE_SCHNITT": # Interaktiver Pick: 2 Punkte fuer Schnittlinie + Klick fuer # Blickrichtung. Defaults aus payload (vom UI vorbelegt). try: import schnitte sid = schnitte.pick_schnitt_interactive(doc, defaults={ "depthBack": float(p.get("depthBack", 8.0)), "heightMin": float(p.get("heightMin", -1.0)), "heightMax": float(p.get("heightMax", 12.0)), "cutAtLine": bool(p.get("cutAtLine", True)), "namePrefix": p.get("namePrefix", "S"), }) if sid: _broadcast_state(doc) # Auto-aktivieren nach Erstellung try: zraw = doc.Strings.GetValue("dossier_zeichnungsebenen") z_list = json.loads(zraw) if zraw else [] 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: self._set_active_zeichnungsebene(new_z) except Exception as ex: print("[SCHNITT] auto-activate:", ex) except Exception as ex: print("[SCHNITT] CREATE_SCHNITT:", ex) elif t == "DELETE_SCHNITT": try: import schnitte if schnitte.delete_schnitt_entry(doc, p.get("id") or ""): _broadcast_state(doc) except Exception as ex: print("[SCHNITT] DELETE_SCHNITT:", ex) elif t == "SET_ACTIVE_LAYER": code = p.get("code", "") if code: doc.Strings.SetString("dossier_active_code", code) self._set_active_sublayer(code) elif t == "DELETE_EBENE": layer_builder.delete_ebene(doc, p.get("code", ""), p.get("moveTo")) self._remove_ebene_from_state(p.get("code", "")) elif t == "MOVE_SELECTION_TO_LAYER": self._move_selection_to_layer(p.get("code", "")) elif t == "SET_VISIBILITY": self._apply_visibility(p) elif t == "SET_CLIPPING": # Toggle ohne Full-Apply — wirkt live auf das aktuell aktive # Geschoss. Erwartet payload {enabled: bool}. enabled = bool(p.get("enabled")) self._toggle_clipping_for_active(enabled) # --- Ebenen-Kombinationen (geteilter Store mit Ausschnitten) ------- elif t == "GET_COMBINATION": self._send_combination() elif t == "APPLY_COMBINATION": self._apply_combination(p) self._send_combination() elif t == "SAVE_PRESET": self._save_preset(p.get("name") or "", p.get("layers") or []) self._send_combination() elif t == "SAVE_CURRENT_AS_PRESET": self._save_current_as_preset(p.get("name") or "") self._send_combination() elif t == "DELETE_PRESET": self._delete_preset(p.get("name") or "") self._send_combination() elif t == "OPEN_GESCHOSS_SETTINGS": self._open_geschoss_settings(p.get("geschoss") or {}) elif t == "OPEN_EBENEN_SETTINGS": self._open_ebenen_settings(p.get("ebene") or {}, p.get("hatchPatterns") or []) elif t == "OPEN_GESCHOSS_DIALOG": self._open_geschoss_dialog(p.get("zeichnungsebenen") or []) elif t == "PICK_LAYER_COMBINATION": doc = Rhino.RhinoDoc.ActiveDoc name = (p.get("name") or "").strip() if name: apply_layer_preset_by_name(doc, name) else: set_active_comb_name(doc, None) _broadcast_state(doc) _notify_oberleiste_combs() elif t == "SAVE_LAYER_COMBINATION": doc = Rhino.RhinoDoc.ActiveDoc name = (p.get("name") or "").strip() if name: save_current_as_layer_preset(doc, name) _broadcast_state(doc) _notify_oberleiste_combs() elif t == "DELETE_LAYER_COMBINATION": doc = Rhino.RhinoDoc.ActiveDoc delete_layer_preset(doc, p.get("name") or "") _broadcast_state(doc) _notify_oberleiste_combs() elif t == "OPEN_LAYER_COMBINATIONS_DIALOG": try: open_layer_combinations_window() except Exception as ex: print("[EBENEN] open layer-combinations:", ex) elif t == "OPEN_PROJECT_SETTINGS": try: self._open_project_settings() except Exception as ex: print("[EBENEN] open project-settings:", ex) elif t == "OPEN_LIBRARY": try: self._open_library() except Exception as ex: print("[EBENEN] open library:", ex) elif t == "PICK_TEXTURE_FILE": # Oeffnet macOS-File-Picker fuer Bild-Dateien. Antwort an # Frontend via TEXTURE_PICKED-Message. try: self._pick_texture_file(p) except Exception as ex: print("[EBENEN] pick texture:", ex) # ---- Helpers ---- def _open_project_settings(self): """Oeffnet React-Satellite mit Projekt-Voreinstellungen (Geschoss-/ Schnitt-Defaults + Material-Library). Werte werden nur als Voreinstellung beim Erstellen neuer Elemente genutzt — pro-Element editierte Werte bleiben davon unberuehrt.""" doc = Rhino.RhinoDoc.ActiveDoc current = load_project_settings(doc) # Hardcoded Material-Library aus elemente.py mit-laden damit # Frontend die Defaults zeigt + User sie ggf. anpassen kann. try: import elemente built_in = [] for name, m in elemente._MATERIAL_LIBRARY.items(): built_in.append({ "name": name, "color": m.get("color", "#888888"), "hatch": m.get("hatch", "Solid"), "scale": float(m.get("scale", 1.0)), "source": "builtin", "libraryId": None, "builtin": True, # legacy flag fuer aelteren UI-Code }) except Exception: built_in = [] params = { "defaults": current.get("defaults", {}), "materials": current.get("materials", []), "builtinMaterials": built_in, "hatchPatterns": _hatch_pattern_names(doc), } def on_save(updated): doc2 = Rhino.RhinoDoc.ActiveDoc if doc2 is None: return new_settings = { "defaults": updated.get("defaults", {}), "materials": updated.get("materials", []), } save_project_settings(doc2, new_settings) _broadcast_state(doc2) # Material-Cache invalidieren (PBR-Cache hashed Color+Texturen+ # PBR-Werte — wenn der User ein Material aendert, muss der # Cache leer sein, sonst kriegen Waende stale Material-Indizes). try: import scriptcontext as sc sc.sticky["_dossier_pbr_material_cache"] = {} sc.sticky["_dossier_material_cache"] = {} except Exception: pass try: import scriptcontext as sc eb = sc.sticky.get("elemente_bridge") if eb is not None: eb._send_state() except Exception as ex: print("[PROJECT-SETTINGS] elemente refresh:", ex) # Alle wand_axis im Doc regenen damit Material-Aenderungen # (PBR/Texturen/Hatch) auf existing Waende durchschlagen. try: import elemente undo_serial = doc2.BeginUndoRecord("Material-Update Regen") prev_redraw = doc2.Views.RedrawEnabled doc2.Views.RedrawEnabled = False wall_ids = [] for obj in doc2.Objects: m = elemente._read_meta(obj) if m and m.get("type") == "wand_axis": wall_ids.append(m["id"]) # Chain-Anchor regent automatisch alle members — wir koennen # trotzdem alle einzeln triggern, _REGEN_BUSY-Guard verhindert # Doppel-Arbeit. Einfacher als Anchor-Election hier. try: for wid in wall_ids: try: elemente._regenerate_element(doc2, wid) except Exception as ex: print("[PROJECT-SETTINGS] regen", wid, ex) finally: doc2.Views.RedrawEnabled = prev_redraw try: doc2.EndUndoRecord(undo_serial) except Exception: pass try: doc2.Views.Redraw() except Exception: pass print("[PROJECT-SETTINGS] {} Waende regenert".format(len(wall_ids))) except Exception as ex: print("[PROJECT-SETTINGS] wall regen:", ex) # Custom-Bridge fuer Project-Settings: handelt SAVE/CANCEL + # PICK_TEXTURE_FILE direkt in dieser Satellite-WebView. Inline-Bridge # konnte nur READY/SAVE/CANCEL → Texture-Picker-Messages gingen # verloren. bridge_holder = {"form": None} class _ProjectSettingsBridge(panel_base.BaseBridge): def __init__(self): panel_base.BaseBridge.__init__(self, "project_settings") def handle(self, data): if not isinstance(data, dict): return t = data.get("type", "") p = data.get("payload") or {} if t == "READY": pass elif t == "SAVE": try: on_save(p) except Exception as ex: print("[PROJECT-SETTINGS] on_save:", ex) try: f = bridge_holder.get("form") if f is not None: f.Close() except Exception: pass elif t == "CANCEL": try: f = bridge_holder.get("form") if f is not None: f.Close() except Exception: pass elif t == "PICK_TEXTURE_FILE": self._pick_texture(p) def _pick_texture(self, payload): slot = payload.get("slot") or "diffuse" try: import Eto.Forms as forms dlg = forms.OpenFileDialog() dlg.Title = "Textur waehlen ({})".format(slot) dlg.MultiSelect = False dlg.Filters.Add(forms.FileFilter("Bilder", ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".tga")) dlg.Filters.Add(forms.FileFilter("Alle", ".*")) parent = bridge_holder.get("form") res = dlg.ShowDialog(parent) if parent else dlg.ShowDialog(None) if str(res) != "Ok": self.send("TEXTURE_PICKED", {"slot": slot, "path": None}) return self.send("TEXTURE_PICKED", {"slot": slot, "path": dlg.FileName or ""}) except Exception as ex: print("[PROJECT-SETTINGS] pick_texture:", ex) self.send("TEXTURE_PICKED", {"slot": slot, "path": None}) b = _ProjectSettingsBridge() bridge_holder["form"] = panel_base.open_satellite_window( "project_settings", params=params, title="Projekt-Einstellungen", size=(440, 540), bridge=b) def _pick_texture_file(self, payload): """Oeffnet macOS-File-Picker (via Eto.Forms.OpenFileDialog) und schickt den ausgewaehlten Pfad zurueck ans Frontend. Payload: {slot: 'diffuse'|'bump'|...} — slot wird mit zurueckgegeben damit das Frontend weiss welches Slot-Field aktualisieren.""" slot = payload.get("slot") or "diffuse" try: import Eto.Forms as forms dlg = forms.OpenFileDialog() dlg.Title = "Textur waehlen ({})".format(slot) dlg.MultiSelect = False f = forms.FileFilter("Bilder", ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".tga") dlg.Filters.Add(f) dlg.Filters.Add(forms.FileFilter("Alle", ".*")) try: parent_form = sc.sticky.get("_dossier_active_settings_form") except Exception: parent_form = None res = (dlg.ShowDialog(parent_form) if parent_form else dlg.ShowDialog(None)) if str(res) != "Ok": self.send("TEXTURE_PICKED", {"slot": slot, "path": None}) return path = dlg.FileName or "" self.send("TEXTURE_PICKED", {"slot": slot, "path": path}) except Exception as ex: print("[EBENEN] pick texture:", ex) self.send("TEXTURE_PICKED", {"slot": slot, "path": None}) def _open_library(self): """Oeffnet den Library-Browser als Satellite. Bridge bleibt offen damit User mehrere Items hintereinander importieren kann; nach jedem Import wird der Material-Status zurueckgemeldet damit das UI 'importiert' anzeigt.""" import library bridge_holder = {"form": None} class _LibraryBridge(panel_base.BaseBridge): def __init__(self): panel_base.BaseBridge.__init__(self, "library") def _on_ready(self): self._send_state() def _send_state(self): doc = Rhino.RhinoDoc.ActiveDoc manifest = library.load_manifest() imported_ids = set() if doc is not None: settings = load_project_settings(doc) for m in settings.get("materials", []): lid = m.get("libraryId") if lid: imported_ids.add(lid) self.send("LIBRARY_STATE", { "manifest": manifest, "importedIds": list(imported_ids), "libraryRoot": library.library_root(), }) def handle(self, data): if not isinstance(data, dict): return t = data.get("type", "") p = data.get("payload") or {} if t == "READY": self._on_ready() elif t == "IMPORT_ITEM": doc = Rhino.RhinoDoc.ActiveDoc ok, msg = library.import_item(doc, p.get("id")) print("[LIBRARY] import {}: {} ({})".format( p.get("id"), ok, msg)) if ok: _broadcast_state(doc) # Elemente-Panel-Dropdown muss neu laden (materials) try: import scriptcontext as sc eb = sc.sticky.get("elemente_bridge") if eb is not None: eb._send_state() except Exception as ex: print("[LIBRARY] elemente refresh:", ex) self._send_state() elif t == "RELOAD": self._send_state() elif t == "CLOSE": try: f = bridge_holder.get("form") if f is not None: f.Close() except Exception: pass b = _LibraryBridge() bridge_holder["form"] = panel_base.open_satellite_window( "library", params={}, title="Dossier-Library", size=(640, 560), bridge=b) def _open_geschoss_settings(self, geschoss): """Oeffnet ein echtes Rhino-Fenster (Eto.Form mit WebView) mit dem GeschossSettingsDialog. Save updated den Eintrag in doc.Strings + triggert Cross-Panel-Sync.""" if not isinstance(geschoss, dict) or not geschoss.get("id"): print("[EBENEN] open_geschoss_settings: kein Geschoss-Payload") return gid = geschoss["id"] doc = Rhino.RhinoDoc.ActiveDoc # Projekt-Nullpunkt (m.ü.M) mit ins Param-Bundle — als projektweite # Settings auch im Geschoss-Dialog editierbar. try: z_mum_raw = doc.Strings.GetValue("dossier_project_zero_mum") if doc else None project_zero_mum = float(z_mum_raw) if z_mum_raw else 0.0 except Exception: project_zero_mum = 0.0 params = dict(geschoss) params["projectZeroMum"] = project_zero_mum def on_save(updated): doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return # Projekt-Nullpunkt extrahieren (project-weit, nicht pro Geschoss) try: if "projectZeroMum" in updated: val = updated.pop("projectZeroMum") val = float(val) if val is not None else 0.0 doc.Strings.SetString("dossier_project_zero_mum", str(val)) print("[EBENEN] project_zero_mum = {} m.ü.M".format(val)) except Exception as ex: print("[EBENEN] project_zero_mum save:", ex) z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") if not z_raw: print("[EBENEN] save_geschoss: kein z-Store"); return try: z_list = json.loads(z_raw) except Exception as ex: print("[EBENEN] save_geschoss JSON:", ex); return replaced = False for i, z in enumerate(z_list): if isinstance(z, dict) and z.get("id") == gid: z_list[i] = updated replaced = True break if not replaced: print("[EBENEN] save_geschoss: id {} nicht gefunden".format(gid)) return # Build_layers + Save via _apply (durchlaeuft ohne save_e) e_raw = doc.Strings.GetValue("dossier_ebenen") try: e_list = json.loads(e_raw) if e_raw else [] except Exception: e_list = [] self._apply(z_list, e_list, save_z=True, save_e=False) # Schnitt-Refresh: wenn der geaenderte Eintrag ein Schnitt ist # UND aktuell aktiv ist, Clipping-Planes + View neu aufbauen # damit die neuen Werte (depthBack, heightRange, cutAtLine etc.) # sofort wirken. try: if updated.get("type") == "schnitt": active_id = doc.Strings.GetValue("dossier_active_id") or "" if active_id == updated.get("id"): import schnitte schnitte.activate_schnitt(doc, updated) except Exception as ex: print("[SCHNITT] post-save reactivate:", ex) panel_base.open_satellite_window( "geschoss_settings", params=params, title="Zeichnungsebene: {}".format(geschoss.get("name", "")), size=(380, 580), on_save=on_save) def _open_ebenen_settings(self, ebene, hatch_patterns): """Oeffnet ein echtes Rhino-Fenster mit dem EbenenSettingsDialog. Mit Dropdown zum Wechsel zwischen Ebenen; jeder Switch persistiert die aktuelle Ebene live (SAVE_KEEP), Schliess-/Übernehmen-Knopf persistiert + schliesst (SAVE).""" if not isinstance(ebene, dict) or not ebene.get("code"): print("[EBENEN] open_ebenen_settings: kein Ebene-Payload") return bridge_holder = {"form": None} apply_self = self class _EbenenSettingsBridge(panel_base.BaseBridge): def __init__(self): panel_base.BaseBridge.__init__(self, "ebenen_settings") def _on_ready(self): self._send_state() def _send_state(self): doc = Rhino.RhinoDoc.ActiveDoc e_raw = doc.Strings.GetValue("dossier_ebenen") if doc else None try: e_list = json.loads(e_raw) if e_raw else [] except Exception: e_list = [] self.send("EBENEN_SETTINGS_STATE", { "ebenen": e_list, "hatchPatterns": hatch_patterns, }) def handle(self, data): if not isinstance(data, dict): return t = data.get("type", "") p = data.get("payload") or {} if t == "READY": self._on_ready() elif t == "SAVE_KEEP": self._persist(p) elif t == "SAVE": self._persist(p) try: f = bridge_holder.get("form") if f is not None: f.Close() except Exception: pass elif t == "CANCEL": try: f = bridge_holder.get("form") if f is not None: f.Close() except Exception: pass def _persist(self, p): doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return updated = p.get("ebene") or {} orig_code = p.get("originalCode") or updated.get("code") if not (isinstance(updated, dict) and updated.get("code")): return e_raw = doc.Strings.GetValue("dossier_ebenen") if not e_raw: return try: e_list = json.loads(e_raw) except Exception as ex: print("[EBENEN] save_ebene JSON:", ex); return # Rekursive Suche + Replace durch den Tree — Sub-Ebenen # (children) liegen verschachtelt, nicht in der Top-Level-Liste. def _replace_in_tree(lst, target_code, new_data): for i, e in enumerate(lst): if not isinstance(e, dict): continue if e.get("code") == target_code: kids = e.get("children") merged = dict(new_data) if isinstance(kids, list) and "children" not in merged: merged["children"] = kids lst[i] = merged return True kids = e.get("children") if isinstance(kids, list): if _replace_in_tree(kids, target_code, new_data): return True return False replaced = _replace_in_tree(e_list, orig_code, updated) if not replaced: print("[EBENEN] save_ebene: code {} nicht gefunden".format(orig_code)) return z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") try: z_list = json.loads(z_raw) if z_raw else [] except Exception: z_list = [] apply_self._apply(z_list, e_list, save_z=False, save_e=True) self._send_state() b = _EbenenSettingsBridge() bridge_holder["form"] = panel_base.open_satellite_window( "ebenen_settings", params={"currentCode": ebene["code"], "hatchPatterns": hatch_patterns}, title="Ebenen-Einstellungen", size=(420, 600), bridge=b) def _open_geschoss_dialog(self, zeichnungsebenen): """Oeffnet den vollen GeschossDialog (Mehrfach-Editor) als Satelliten-Fenster. Save schreibt die ganze z-Liste neu.""" if not isinstance(zeichnungsebenen, list): print("[EBENEN] open_geschoss_dialog: keine Liste"); return def on_save(payload): doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return new_z = payload.get("zeichnungsebenen") or [] if not new_z: return e_raw = doc.Strings.GetValue("dossier_ebenen") try: e_list = json.loads(e_raw) if e_raw else [] except Exception: e_list = [] self._apply(new_z, e_list, save_z=True, save_e=False) panel_base.open_satellite_window( "geschoss_dialog", params={"zeichnungsebenen": zeichnungsebenen}, title="Zeichnungsebenen bearbeiten", size=(560, 620), on_save=on_save) def _apply(self, zeichnungsebenen, ebenen, save_z=True, save_e=True): print("[EBENEN] _apply START z={} e={} (save_z={} save_e={})".format( len(zeichnungsebenen) if zeichnungsebenen else 0, len(ebenen) if ebenen else 0, save_z, save_e)) doc = Rhino.RhinoDoc.ActiveDoc # Vor dem Schreiben: alten Fill-Stand snapshotten, damit wir hinterher # entscheiden koennen ob refresh_layer_fills sich lohnt. def _fill_signature(e_list): out = {} if not isinstance(e_list, list): return out def _walk(lst): for e in lst: if not isinstance(e, dict): continue f = e.get("fill") if isinstance(f, dict) and f.get("pattern") not in (None, "None"): lw_raw = f.get("lw") try: lw_sig = round(float(lw_raw), 6) if lw_raw is not None else None except Exception: lw_sig = None out[e.get("code")] = ( f.get("pattern"), f.get("source", "layer"), (f.get("color") or "").lower(), round(float(f.get("scale") or 1.0), 6), round(float(f.get("rotation") or 0.0), 6), lw_sig, ) kids = e.get("children") if isinstance(kids, list) and kids: _walk(kids) _walk(e_list) return out old_e_raw = doc.Strings.GetValue("dossier_ebenen") old_sig = {} if old_e_raw: try: old_sig = _fill_signature(json.loads(old_e_raw)) except Exception: old_sig = {} new_sig = _fill_signature(ebenen) fill_changed = (old_sig != new_sig) # Schnitt-Cleanup-Detection: alt vs neu Schnitt-Ids vergleichen. # Wenn ein Schnitt entfernt wurde (via normalem Delete-Menue), die # 2D-Plan-Symbole + ggf. Clipping-Planes aufraeumen. Sonst bleiben # Waisen im Doc. schnitte_removed = set() if save_z: try: import schnitte as _schn old_z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") old_z = json.loads(old_z_raw) if old_z_raw else [] old_ids = _schn.schnitt_ids_in_list(old_z) new_ids = _schn.schnitt_ids_in_list(zeichnungsebenen) schnitte_removed = old_ids - new_ids except Exception as ex: print("[SCHNITT] cleanup detection:", ex) _set_processing(True) try: print("[EBENEN] _apply: build_layers ...") layer_builder.build_layers(doc, zeichnungsebenen, ebenen) print("[EBENEN] _apply: json.dumps ...") # WICHTIG: ensure_ascii=False umgeht einen Bug in Rhinos eigener # json/encoder.py die bei ASCII-escape s.decode('utf-8') aufruft # und dabei mit 0xC4 (Umlaut) in den CP1252-Decoder lauft. z_json = json.dumps(zeichnungsebenen, ensure_ascii=False) e_json = json.dumps(ebenen, ensure_ascii=False) print("[EBENEN] _apply: SetString ...") if save_z: doc.Strings.SetString("dossier_zeichnungsebenen", z_json) if save_e: doc.Strings.SetString("dossier_ebenen", e_json) # Cleanup geloeschter Schnitte: 2D-Symbole + ggf. Clipping-Planes. # Muss NACH dem SetString passieren damit dossier_active_id-Check # in cleanup_schnitt_artifacts den korrekten Stand sieht. if schnitte_removed: try: import schnitte as _schn active_id = doc.Strings.GetValue("dossier_active_id") or "" n_total = 0 for sid in schnitte_removed: n_total += _schn.cleanup_schnitt_artifacts( doc, sid, active_id=active_id) print("[SCHNITT] {} Schnitt(e) geloescht, {} Symbol-Curves entfernt".format( len(schnitte_removed), n_total)) except Exception as ex: print("[SCHNITT] artifact cleanup:", ex) # Smart-Elemente (Waende) regenerieren — Geschoss-Hoehen/OKFF # haben sich evtl. geaendert, gebundene Waende muessen neu # extrudiert werden. Best-effort, faengt jeden Fehler ab. try: elem_bridge = sc.sticky.get("elemente_bridge") if elem_bridge is not None: elem_bridge._regenerate_all() except Exception as _ex: print("[EBENEN] elemente regen:", _ex) n_with_fill = sum(1 for e in ebenen if isinstance(e, dict) and isinstance(e.get("fill"), dict) and e["fill"].get("pattern") not in (None, "None")) print("[EBENEN] dossier_ebenen gespeichert: {} Ebenen, davon {} mit fill, JSON-len={}".format( len(ebenen), n_with_fill, len(e_json))) re_read = doc.Strings.GetValue("dossier_ebenen") print("[EBENEN] dossier_ebenen verifiziert: len={}".format(len(re_read) if re_read else 0)) print("[EBENEN] _apply: cleanup_default_layers ...") layer_builder.cleanup_default_layers(doc) print("[EBENEN] _apply: ensure_active_sublayer ...") self._ensure_active_sublayer() # Existierende 'Nach Ebene'-Hatches an neue Pattern/Skala/Drehung # angleichen — ABER nur wenn die Fill-Signatur sich tatsaechlich # geaendert hat (nicht bei reinen Name/Farb-Aenderungen, die das # Settings-Dialog auch triggern koennte). try: import gestaltung if fill_changed: gestaltung.refresh_layer_fills(doc) else: print("[EBENEN] _apply: fill-Signatur unveraendert -> kein Hatch-Refresh") # Plot-Color Repair laeuft immer (no-op falls schon synchron) gestaltung.repair_plot_colors(doc) except Exception as ex: print("[EBENEN] gestaltung sync:", ex) finally: _set_processing(False) print("[EBENEN] _apply: update_clipping ...") self._update_clipping() print("[EBENEN] _apply: send APPLY_OK") self.send("APPLY_OK", {}) # Strukturelle Aenderung (neue/umbenannte/geloeschte Ebene) → aktives # Preset passt nicht mehr exakt. clear_active_comb_name(doc) # Anderes Panel (Zeichnungsebenen/Ebenen) ueber den neuen State # informieren — sonst hinkt es hinter der DOM-Persistenz her. _broadcast_state(doc) print("[EBENEN] _apply: DONE") def _ensure_active_sublayer(self): """Setzt den aktiven Rhino-Layer auf den DOSSIER-Sublayer (Fallback: erste Z + 20_WAENDE).""" doc = Rhino.RhinoDoc.ActiveDoc z_id = doc.Strings.GetValue("dossier_active_id") code = doc.Strings.GetValue("dossier_active_code") or "20" if not z_id: z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") if z_raw: try: z_list = json.loads(z_raw) if z_list: z_id = z_list[0].get("id", "") if z_id: doc.Strings.SetString("dossier_active_id", z_id) except Exception: pass if z_id and code: layer_builder.set_active_sublayer(doc, z_id, code) def _apply_visibility(self, p): doc = Rhino.RhinoDoc.ActiveDoc z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") e_raw = doc.Strings.GetValue("dossier_ebenen") if not z_raw or not e_raw: return try: z_full = json.loads(z_raw) or [] e_full = json.loads(e_raw) or [] except Exception: return payload_z = p.get("zeichnungsebenen") or [] payload_e = p.get("ebenen") or [] # Hilfsfunktion: alle Codes (inkl. Children) als flat dict {code: ebene} def _walk_codes(lst): out = {} if not isinstance(lst, list): return out for x in lst: if not isinstance(x, dict): continue c = x.get("code") if c: out[c] = x kids = x.get("children") if isinstance(kids, list): out.update(_walk_codes(kids)) return out # Strukturelle Aenderung pending? Wenn React-Payload IDs/Codes enthaelt # die noch nicht in doc.Strings sind (= User hat gerade neue Ebene # angelegt aber der strukturelle APPLY ist noch in der 200ms-Debounce), # NICHT speichern. Sonst ueberschreibt die schnellere SET_VISIBILITY # den geplanten APPLY-Save und die neue Ebene geht in der Race # verloren. payload_z_ids = {z.get("id") for z in payload_z if isinstance(z, dict)} payload_e_codes = set(_walk_codes(payload_e).keys()) existing_z_ids = {z.get("id") for z in z_full if isinstance(z, dict)} existing_e_codes = set(_walk_codes(e_full).keys()) has_new_structural = ( bool(payload_z_ids - existing_z_ids - {None}) or bool(payload_e_codes - existing_e_codes - {None}) ) z_state = {z["id"]: z for z in payload_z if isinstance(z, dict) and z.get("id")} # e_state ist flach (Code → Ebene) ueber den ganzen Tree des Payloads, # damit auch Child-Visibility-Toggles ankommen. e_state = _walk_codes(payload_e) merged_z = [] for z in z_full: if not isinstance(z, dict): continue m = dict(z) s = z_state.get(z.get("id")) if s is not None: m["visible"] = s.get("visible", True) m["locked"] = s.get("locked", False) merged_z.append(m) # Merge fuer Ebenen rekursiv: jedes Element behaelt seine Position + # children-Struktur, nur visible/locked werden ueberschrieben falls # im Payload anwesend. def _merge_ebenen_tree(orig_list): out = [] for e in orig_list: if not isinstance(e, dict): continue m = dict(e) s = e_state.get(e.get("code")) if s is not None: m["visible"] = s.get("visible", True) m["locked"] = s.get("locked", False) kids = e.get("children") if isinstance(kids, list): m["children"] = _merge_ebenen_tree(kids) out.append(m) return out merged_e = _merge_ebenen_tree(e_full) # Detect whether the merge actually changed any visible/locked values. # Wenn nicht: das ist nur der Echo-Roundtrip eines apply_layer_preset # (React-State == doc.Strings → kein User-Click) und wir wollen das # aktive Preset NICHT clearen. Bei Ebenen rekursiv durch Children. def _flatten(lst): out = [] for x in (lst or []): if not isinstance(x, dict): continue out.append(x) kids = x.get("children") if isinstance(kids, list): out.extend(_flatten(kids)) return out def _vis_lock_changed(old, new): old_by = {x.get("id") or x.get("code"): x for x in _flatten(old)} for nx in _flatten(new): key = nx.get("id") or nx.get("code") if key is None: continue ox = old_by.get(key) if ox is None: continue if (ox.get("visible", True) != nx.get("visible", True) or ox.get("locked", False) != nx.get("locked", False)): return True return False any_changed = (_vis_lock_changed(z_full, merged_z) or _vis_lock_changed(e_full, merged_e)) if has_new_structural: print("[EBENEN] _apply_visibility: structural change pending → skip save (waiting for APPLY)") else: doc.Strings.SetString("dossier_zeichnungsebenen", json.dumps(merged_z, ensure_ascii=False)) doc.Strings.SetString("dossier_ebenen", json.dumps(merged_e, ensure_ascii=False)) # User hat per Hand Eye/Lock geaendert → aktives Preset passt nicht # mehr, auf "Eigene" zuruecksetzen. if any_changed: clear_active_comb_name(doc) # zMode + eMode persistieren, damit bei Split-Send (nur eine # Panel-Slice) der andere Mode aus dem Doc-Storage faellt anstatt # auf den Default zu rutschen. z_mode = p.get("zMode") or doc.Strings.GetValue("dossier_z_mode") or "active" e_mode = p.get("eMode") or doc.Strings.GetValue("dossier_e_mode") or "all" try: doc.Strings.SetString("dossier_z_mode", z_mode) doc.Strings.SetString("dossier_e_mode", e_mode) except Exception: pass active_z = p.get("activeZ") or {} if not isinstance(active_z, dict): active_z = {} active_z_id = active_z.get("id") or doc.Strings.GetValue("dossier_active_id") active_code = p.get("activeCode") or doc.Strings.GetValue("dossier_active_code") # Dedupe: identisches SET_VISIBILITY (z.B. STATE_SYNC-Echo nach # Preset-Apply) loopt sonst unnoetig durch alle ~100 Doc-Layer. # Signatur aus active-id/code + mode + vis/lock-Liste. def _sig(zlist, elist): zs = tuple((z.get("id"), bool(z.get("visible", True)), bool(z.get("locked", False))) for z in zlist if isinstance(z, dict)) # Ebenen flat ueber Children — sonst dedupt der Cache auch nach # einem Child-Toggle, weil die Top-Level-Liste identisch aussieht. es = tuple((e.get("code"), bool(e.get("visible", True)), bool(e.get("locked", False))) for e in _flatten(elist)) return (active_z_id, active_code, z_mode, e_mode, zs, es) cur_sig = _sig(merged_z, merged_e) if sc.sticky.get("_vis_last_sig") == cur_sig and not any_changed: # Nichts Neues — Rhino-Layer-State ist schon korrekt. return sc.sticky["_vis_last_sig"] = cur_sig layer_builder.apply_visibility( doc, merged_z, merged_e, active_z_id, active_code, z_mode, e_mode) # Cross-Panel-Sync NUR wenn wir nicht in einem structural-pending # State sind — sonst broadcasten wir die unvollstaendige Liste und # React ueberschreibt die gerade vom User hinzugefuegte Ebene. if not has_new_structural: _broadcast_state(doc) def _set_active_zeichnungsebene(self, z): doc = Rhino.RhinoDoc.ActiveDoc z_id = z.get("id", "") # Vorigen Stand merken um redundante teure Operationen zu sparen prev_active_id = doc.Strings.GetValue("dossier_active_id") or "" doc.Strings.SetString("dossier_active_id", z_id) # Schnitt-Typ: Spezial-Pfad. Vertikale Clipping-Planes + Parallel- # View statt der ueblichen horizontalen Geschoss-Clipping-Logik. # Den vollen Record aus doc.Strings holen (z-Payload aus React ist # minimal, hat type/linePts/etc nicht zwingend dabei). z_full = z try: zraw = doc.Strings.GetValue("dossier_zeichnungsebenen") if zraw: for cand in json.loads(zraw): if isinstance(cand, dict) and cand.get("id") == z_id: z_full = cand; break except Exception: pass # Vorheriger Eintrag ein Schnitt? Brauchen wir fuer View-Snapshot- # Logik: Geschoss → Schnitt snapshot, Schnitt → Geschoss restore. prev_was_schnitt = False try: import schnitte as _schn_check prev_was_schnitt = _schn_check.is_schnitt_id(doc, prev_active_id) except Exception: pass if isinstance(z_full, dict) and z_full.get("type") == "schnitt": try: import schnitte # Pre-Schnitt-View snapshotten — aber NUR beim Wechsel von # einem Nicht-Schnitt. Schnitt→Schnitt-Wechsel soll den # urspruenglichen Plan-View nicht ueberschreiben. if not prev_was_schnitt: schnitte.save_pre_schnitt_view(doc) # Horizontale Geschoss-Clipping aufraeumen falls aktiv — # die existiert parallel zur Schnitt-Clipping und wuerde # die Sicht doppelt schneiden. try: existing_geschoss = layer_builder._find_clipping_plane(doc) if existing_geschoss is not None: doc.Objects.Delete(existing_geschoss.Id, True) except Exception: pass schnitte.activate_schnitt(doc, z_full) _broadcast_state(doc) # Elemente-Panel auch informieren try: eb = sc.sticky.get("elemente_bridge") if eb is not None: eb._notify_active_geschoss() except Exception: pass except Exception as ex: print("[SCHNITT] activate fehler:", ex) return # Geschoss-Pfad (default): falls vorher ein Schnitt aktiv war, # dessen Clipping-Planes aufraeumen + Pre-Schnitt-View restoren. try: import schnitte schnitte.clear_schnitt_clipping(doc) if prev_was_schnitt: schnitte.restore_pre_schnitt_view(doc) except Exception as ex: print("[SCHNITT] cleanup beim Wechsel auf Geschoss:", ex) # Aktiven Sublayer auf die GLEICHE Ebene unter dem neuen Geschoss # umschalten — wenn User auf "20 Wände" steht und das Geschoss # wechselt, soll Rhino's aktiver Layer "1OG::20_Wände" werden statt # auf der vorigen Geschoss-Ebene haengen zu bleiben. self._ensure_active_sublayer() # Cross-Panel-Sync: Ebenen-Panel muss aktive Geschoss-Auswahl # mitbekommen falls es im "active"-Filter-Mode laeuft. _broadcast_state(doc) # Clipping nur antasten wenn entweder das alte oder das neue Geschoss # eine Clipping-Plane hatte — sonst sparen wir Plane-Delete + Build # + View-Redraw bei jedem Geschoss-Wechsel ganz. if self._needs_clipping_update(doc, prev_active_id, z): self._update_clipping(active_z=z) # Elemente-Panel informieren: das aktive Geschoss hat gewechselt, # neue Elemente sollen jetzt automatisch dort verlinkt werden. # Wichtig: NICHT _send_state() rufen (re-enumeriert alle Elemente, # 200+ in echten Projekten = spuerbar). Schlanker Partial-Push. try: eb = sc.sticky.get("elemente_bridge") if eb is not None: eb._notify_active_geschoss() except Exception: pass if not (z.get("isGeschoss") and z.get("okff") is not None): return okff = float(z["okff"]) updated = 0 for view in doc.Views: try: vp = view.ActiveViewport cp = vp.ConstructionPlane() plane = cp.Plane if hasattr(cp, "Plane") else cp # Nur Views deren CPlane horizontal liegt (Normal in +/-Z) - # also Top/Plan-Style. Right/Front/Perspective haben vertikale # CPlanes; ein Z-Shift waere dort optisch verwirrend. if abs(plane.Normal.Z) < 0.99: continue new_plane = Rhino.Geometry.Plane( Rhino.Geometry.Point3d(plane.Origin.X, plane.Origin.Y, okff), plane.XAxis, plane.YAxis, ) vp.SetConstructionPlane(new_plane) updated += 1 except Exception as ex: print("[EBENEN] CPlane fehler ({}): {}".format(vp.Name if vp else "?", ex)) # KEIN doc.Views.Redraw() hier — die folgende SET_VISIBILITY-Round- # trip (30 ms debounce in React) feuert ohnehin layer_builder # .apply_visibility() das am Ende selbst redrawt. Sparen wir uns # einen doppelten Full-Repaint pro Geschoss-Klick. print("[EBENEN] CPlane Z={} auf {} Top-Style View(s) gesetzt".format(okff, updated)) def _needs_clipping_update(self, doc, prev_active_id, new_z): """Liefert True wenn entweder das alte oder das neue Geschoss hasClipping=True hat. Sonst kann _update_clipping skipped werden (Plane existiert nicht und muss auch nicht neu gebaut werden).""" new_has = bool(new_z.get("hasClipping")) if new_has: return True if not prev_active_id: return False try: z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") if not z_raw: return False for z in (json.loads(z_raw) or []): if isinstance(z, dict) and z.get("id") == prev_active_id: return bool(z.get("hasClipping")) except Exception: pass return False def _toggle_clipping_for_active(self, enabled): """Setzt hasClipping fuer das aktuell aktive Geschoss + persistiert in doc.Strings + triggert plane-update. Wird vom React-Toggle 'Clipping Plane' direkt aufgerufen (ohne Full-Apply).""" doc = Rhino.RhinoDoc.ActiveDoc z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") active_id = doc.Strings.GetValue("dossier_active_id") if not z_raw or not active_id: print("[CLIP] toggle: kein aktives Geschoss") return try: z_list = json.loads(z_raw) except Exception as ex: print("[CLIP] toggle JSON-decode:", ex); return active_z = None for z in z_list: if z.get("id") == active_id: z["hasClipping"] = bool(enabled) active_z = z break if active_z is None: print("[CLIP] toggle: active_id={} nicht in Liste".format(active_id)) return try: doc.Strings.SetString("dossier_zeichnungsebenen", json.dumps(z_list, ensure_ascii=False)) except Exception as ex: print("[CLIP] toggle SetString:", ex) self._update_clipping(active_z=active_z) # State an BEIDE React-Panels zuruekspiegeln. Eye-Icon im # ZeichnungsebenenPanel + Status im Ebenen-Panel synchron halten. _broadcast_state(doc) def _update_clipping(self, active_z=None): """Clipping-Plane folgt aktivem Geschoss — nur wenn dessen hasClipping=True. IMMER aus doc.Strings lesen statt das uebergebene `active_z` direkt zu verwenden — der SET_ACTIVE-Pfad schickt ein Minimal-Payload (nur id/name/isGeschoss/okff) ohne hasClipping/schnitthoehe. Wenn wir uns darauf verließen, wuerde der Geschoss-Wechsel jede Clipping-Plane loeschen weil enabled=False auswertet. Der persistierte Record in doc.Strings hat die vollen Daten.""" doc = Rhino.RhinoDoc.ActiveDoc # ID aus Hint oder aus dem persistierten active_id active_id = None if isinstance(active_z, dict): active_id = active_z.get("id") if not active_id: active_id = doc.Strings.GetValue("dossier_active_id") # Vollen Record aus doc.Strings holen z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") if z_raw and active_id: try: z_list = json.loads(z_raw) active_z = next((z for z in z_list if z.get("id") == active_id), None) except Exception: pass # Volles Dump des Active-Geschosses fuer Diagnose. try: print("[CLIP] active_z keys: {}".format( sorted(active_z.keys()) if active_z else None)) print("[CLIP] active_z dump: {}".format(json.dumps(active_z, ensure_ascii=False))) except Exception: pass enabled = bool(active_z and active_z.get("hasClipping")) _set_processing(True) try: layer_builder.update_clipping_plane(doc, active_z, enabled) finally: _set_processing(False) def _move_selection_to_layer(self, code): if not code: return doc = Rhino.RhinoDoc.ActiveDoc z_id = doc.Strings.GetValue("dossier_active_id") if not z_id: print("[EBENEN] Keine aktive Zeichnungsebene") return parent_idx = layer_builder._find_top_by_id(doc, z_id) if parent_idx < 0: print("[EBENEN] Parent fuer aktive Zeichnungsebene nicht gefunden") return parent_id = doc.Layers[parent_idx].Id sub_idx = layer_builder._find_sublayer_by_code(doc, parent_id, code) if sub_idx < 0: print("[EBENEN] Sublayer {} unter {} nicht gefunden".format(code, doc.Layers[parent_idx].Name)) return objs = list(doc.Objects.GetSelectedObjects(False, False)) moved = 0 for obj in objs: attrs = obj.Attributes.Duplicate() attrs.LayerIndex = sub_idx if doc.Objects.ModifyAttributes(obj, attrs, True): moved += 1 doc.Views.Redraw() print("[EBENEN] {} Objekt(e) auf {} verschoben".format(moved, doc.Layers[sub_idx].FullPath)) def _set_active_sublayer(self, code): if not code: return doc = Rhino.RhinoDoc.ActiveDoc z_id = doc.Strings.GetValue("dossier_active_id") if not z_id: # Fallback: erste Zeichnungsebene aus persistiertem State z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") if z_raw: try: z_list = json.loads(z_raw) if z_list: z_id = z_list[0].get("id", "") if z_id: doc.Strings.SetString("dossier_active_id", z_id) except Exception: pass if z_id: layer_builder.set_active_sublayer(doc, z_id, code) else: print("[EBENEN] Aktive Zeichnungsebene unbekannt — Layer wird nicht gesetzt") def _remove_ebene_from_state(self, code): doc = Rhino.RhinoDoc.ActiveDoc raw = doc.Strings.GetValue("dossier_ebenen") if not raw: return try: ebenen = [e for e in json.loads(raw) if e.get("code") != code] doc.Strings.SetString("dossier_ebenen", json.dumps(ebenen, ensure_ascii=False)) _broadcast_state(doc) except Exception as ex: print("[EBENEN] remove:", ex) def _update_ebene_field(self, code, field, value): doc = Rhino.RhinoDoc.ActiveDoc raw = doc.Strings.GetValue("dossier_ebenen") if not raw: return try: ebenen = json.loads(raw) # Rekursive Suche — Sub-Ebenen (z.B. WAENDE→Öffnungen→Sturz mit # Code 20o7) liegen mehrere Ebenen tief. Frueher nur Top-Level # iteriert → Style-Changes an nested Sublayer wurden nicht # persistiert und kamen beim naechsten broadcast als alte Werte # zurueck. def _set_in_tree(lst): for e in lst: if not isinstance(e, dict): continue if e.get("code") == code: e[field] = value return True kids = e.get("children") if isinstance(kids, list) and _set_in_tree(kids): return True return False _set_in_tree(ebenen) doc.Strings.SetString("dossier_ebenen", json.dumps(ebenen, ensure_ascii=False)) _broadcast_state(doc) except Exception as ex: print("[EBENEN] update:", ex) # ---- Ebenen-Kombinationen / Presets (geteilt mit AUSSCHNITTE) -------- _PRESETS_KEY = "dossier_layer_presets" def _load_presets(self, doc): return load_layer_presets(doc) def _store_presets(self, doc, presets): store_layer_presets(doc, presets) def _send_combination(self): """Schickt aktuelles Layer-State + alle Presets ans Frontend.""" doc = Rhino.RhinoDoc.ActiveDoc layers_out = [] try: for layer in doc.Layers: if layer is None or layer.IsDeleted: continue lid = str(layer.Id) try: fp = layer.FullPath or layer.Name except Exception: fp = layer.Name or "" try: col = "#%02x%02x%02x" % (layer.Color.R, layer.Color.G, layer.Color.B) except Exception: col = "#888888" layers_out.append({ "id": lid, "name": layer.Name, "fullPath": fp, "color": col, "visible": bool(layer.IsVisible), "locked": bool(layer.IsLocked), }) layers_out.sort(key=lambda x: x["fullPath"]) except Exception as ex: print("[EBENEN] _send_combination layers:", ex) try: presets = self._load_presets(doc) except Exception: presets = [] self.send("COMBINATION_DATA", { "layers": layers_out, "presets": presets, }) def _apply_combination(self, payload): """Wendet Preset an. payload kann sein: - Liste [{id, visible, locked}, ...] (alt / AUSSCHNITTE-Dialog) - Dict { layers, dossierEbenen?, dossierZeichnungsebenen? } (neu) Eye-State-Pfad (bevorzugt): aktualisiert dossier_ebenen und dossier_zeichnungsebenen direkt, pusht STATE_SYNC. React triggert dann SET_VISIBILITY und apply_visibility setzt doc.Layer korrekt unter Beruecksichtigung von z_mode/e_mode. Layer-ID-Pfad (Fallback): setzt doc.Layer.IsVisible direkt. """ doc = Rhino.RhinoDoc.ActiveDoc # Payload normalisieren if isinstance(payload, dict): layer_states = payload.get("layers") or [] pe_states = payload.get("dossierEbenen") pz_states = payload.get("dossierZeichnungsebenen") else: layer_states = payload or [] pe_states = None pz_states = None # --- Eye-State-Pfad (wenn vorhanden) --- if pe_states is not None or pz_states is not None: try: e_raw = doc.Strings.GetValue("dossier_ebenen") or "[]" z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") or "[]" e_list = json.loads(e_raw) or [] z_list = json.loads(z_raw) or [] if pe_states is not None: by_code = {x.get("code"): x for x in pe_states if isinstance(x, dict) and x.get("code")} for e in e_list: if not isinstance(e, dict): continue s = by_code.get(e.get("code")) if s is None: continue e["visible"] = bool(s.get("visible", True)) e["locked"] = bool(s.get("locked", False)) if pz_states is not None: by_id = {x.get("id"): x for x in pz_states if isinstance(x, dict) and x.get("id")} for z in z_list: if not isinstance(z, dict): continue s = by_id.get(z.get("id")) if s is None: continue z["visible"] = bool(s.get("visible", True)) doc.Strings.SetString("dossier_ebenen", json.dumps(e_list, ensure_ascii=False)) doc.Strings.SetString("dossier_zeichnungsebenen", json.dumps(z_list, ensure_ascii=False)) # STATE_SYNC pushen — React's visibilityKey aendert sich, # applyVisibility fires, backend apply_visibility setzt doc.Layer # state korrekt unter z_mode/e_mode-Beachtung. self.send("STATE_SYNC", { "zeichnungsebenen": z_list, "ebenen": e_list, }) try: doc.Views.Redraw() except Exception: pass print("[EBENEN] Eye-State-Preset angewandt: {} Ebenen, {} Zeichnungsebenen".format( len(pe_states or []), len(pz_states or []))) return except Exception as ex: print("[EBENEN] _apply_combination eye-state:", ex) # Fall through zum Layer-ID-Pfad als Fallback # --- Layer-ID-Pfad (alt / AUSSCHNITTE) --- by_id = {} for layer in doc.Layers: if not layer.IsDeleted: by_id[str(layer.Id)] = layer n = 0 # Erst: doc.Layer Visibility setzen _set_processing(True) try: for ls in (layer_states or []): layer = by_id.get(ls.get("id")) if layer is None: continue try: want_vis = bool(ls.get("visible", True)) want_lck = bool(ls.get("locked", False)) if layer.IsVisible != want_vis: layer.IsVisible = want_vis if layer.IsLocked != want_lck: layer.IsLocked = want_lck n += 1 except Exception: pass finally: _set_processing(False) # Dann: dossier_ebenen/dossier_zeichnungsebenen Eye-State synchronisieren. # Map: doc.Layer.Id -> {visible, locked} state_by_id = {ls.get("id"): ls for ls in (layer_states or []) if ls.get("id")} try: e_raw = doc.Strings.GetValue("dossier_ebenen") z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") ebenen_list = json.loads(e_raw) if e_raw else [] z_list = json.loads(z_raw) if z_raw else [] # Sublayer -> dossier_code mapping via Rhino-Layer UserString code_by_layer_id = {} zid_by_layer_id = {} for layer in doc.Layers: if layer is None or layer.IsDeleted: continue c = layer.GetUserString("dossier_code") i = layer.GetUserString("dossier_id") if c: code_by_layer_id[str(layer.Id)] = c if i: zid_by_layer_id[str(layer.Id)] = i # Pro Dossier-Ebene: wenn mind. ein matchender Sublayer im preset war, # sync visible/locked. updated_e = False for e in ebenen_list: if not isinstance(e, dict): continue code = e.get("code") if not code: continue # Suche eine Layer-Id mit diesem code, deren state im preset ist for lid, c in code_by_layer_id.items(): if c != code: continue s = state_by_id.get(lid) if s is None: continue new_vis = bool(s.get("visible", True)) new_lck = bool(s.get("locked", False)) if e.get("visible", True) != new_vis: e["visible"] = new_vis updated_e = True if (e.get("locked", False)) != new_lck: e["locked"] = new_lck updated_e = True break updated_z = False for z in z_list: if not isinstance(z, dict): continue zid = z.get("id") if not zid: continue for lid, z_uid in zid_by_layer_id.items(): if z_uid != zid: continue s = state_by_id.get(lid) if s is None: continue new_vis = bool(s.get("visible", True)) if z.get("visible", True) != new_vis: z["visible"] = new_vis updated_z = True break if updated_e: doc.Strings.SetString("dossier_ebenen", json.dumps(ebenen_list, ensure_ascii=False)) if updated_z: doc.Strings.SetString("dossier_zeichnungsebenen", json.dumps(z_list, ensure_ascii=False)) # STATE_SYNC ans React-Panel pushen damit Eye-Icons matchen if updated_e or updated_z: try: self.send("STATE_SYNC", { "zeichnungsebenen": z_list, "ebenen": ebenen_list, }) except Exception as ex: print("[EBENEN] STATE_SYNC push:", ex) except Exception as ex: print("[EBENEN] _apply_combination sync:", ex) try: doc.Views.Redraw() except Exception: pass print("[EBENEN] Kombination angewandt: {} Layer".format(n)) def _save_preset(self, name, layers): name = (name or "").strip() if not name: return doc = Rhino.RhinoDoc.ActiveDoc presets = load_layer_presets(doc) clean = [] for ls in (layers or []): lid = ls.get("id") if not lid: continue clean.append({ "id": lid, "visible": bool(ls.get("visible", True)), "locked": bool(ls.get("locked", False)), }) existing = next((p for p in presets if p.get("name") == name), None) if existing is not None: existing["layers"] = clean else: presets.append({"name": name, "layers": clean}) store_layer_presets(doc, presets) _notify_oberleiste_combs() _notify_layer_combinations_editor() print("[EBENEN] Kombination '{}' gespeichert ({} Layer)".format(name, len(clean))) def _save_current_as_preset(self, name): """Speichert die aktuellen Eye-States (dossier_ebenen + dossier_zeichnungs- ebenen) als Preset — NICHT die berechneten doc.Layer.IsVisible-Werte. Sonst wuerde der z_mode/e_mode-Override (z.B. 'active' nur 1 Layer sichtbar) ins Preset einbacken und beim Apply nicht wieder restorbar sein. layers (doc.Layer-Liste) wird parallel mitgespeichert fuer Kompat mit AUSSCHNITTE (das vom doc.Layer-State liest).""" save_current_as_layer_preset(Rhino.RhinoDoc.ActiveDoc, name) def _delete_preset(self, name): delete_layer_preset(Rhino.RhinoDoc.ActiveDoc, name) class LayerCombinationsBridge(panel_base.BaseBridge): """Bridge fuer das Satelliten-Fenster mit dem grossen Ebenenkombinationen- Editor (AusschnittLayerDialog). Wird vom Oberleiste-Bridge geoeffnet bei OPEN_LAYER_COMBINATIONS_DIALOG. State wird beim READY-Event geschickt und bei jeder Aenderung re-emittet.""" def __init__(self): panel_base.BaseBridge.__init__(self, "layer_combinations") def _on_ready(self): self._send_state() def _send_state(self): doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return layers_out = [] try: for layer in doc.Layers: if layer is None or layer.IsDeleted: continue lid = str(layer.Id) try: fp = layer.FullPath or layer.Name except Exception: fp = layer.Name or "" try: col = "#%02x%02x%02x" % (layer.Color.R, layer.Color.G, layer.Color.B) except Exception: col = "#888888" layers_out.append({ "id": lid, "name": layer.Name, "fullPath": fp, "color": col, "visible": bool(layer.IsVisible), "locked": bool(layer.IsLocked), }) layers_out.sort(key=lambda x: x["fullPath"]) except Exception as ex: print("[LAYER-COMB] enum:", ex) self.send("LAYER_COMBINATIONS_STATE", { "layers": layers_out, "presets": load_layer_presets(doc), }) def handle(self, data): if not isinstance(data, dict): return t = data.get("type", "") p = data.get("payload") or {} if not isinstance(p, dict): p = {} doc = Rhino.RhinoDoc.ActiveDoc if t == "READY" or t == "REQUEST_STATE": self._on_ready() elif t == "APPLY_COMBINATION": # Editor wendet eine Layer-State-Liste an (ohne Preset-Name — # also "Eigene"). Wir delegieren an die Ebenen-Bridge wenn offen, # sonst inline. activeCombName auf None setzen. eb = sc.sticky.get("ebenen_bridge_ref") if eb is not None: try: eb._apply_combination(p) except Exception as ex: print("[LAYER-COMB] apply via bridge:", ex) else: _apply_layer_preset_inline(doc, p) set_active_comb_name(doc, None) _notify_oberleiste_combs() self._send_state() elif t == "SAVE_PRESET": # Editor speichert die im Dialog kuratierte Liste unter Namen. name = (p.get("name") or "").strip() layers = p.get("layers") or [] if name: presets = load_layer_presets(doc) clean = [] for ls in layers: lid = ls.get("id") if not lid: continue clean.append({ "id": lid, "visible": bool(ls.get("visible", True)), "locked": bool(ls.get("locked", False)), }) existing = next((pp for pp in presets if pp.get("name") == name), None) if existing is not None: existing["layers"] = clean else: presets.append({"name": name, "layers": clean}) store_layer_presets(doc, presets) _notify_oberleiste_combs() self._send_state() elif t == "DELETE_PRESET": delete_layer_preset(doc, p.get("name") or "") self._send_state() elif t == "CANCEL": try: form = sc.sticky.get("layer_combinations_form") if form is not None: form.Close() except Exception: pass def open_layer_combinations_window(): """Oeffnet den Editor als echtes Rhino-Fenster (Eto.Form + WebView). Wird vom Oberleiste-Bridge bei OPEN_LAYER_COMBINATIONS_DIALOG gerufen.""" b = LayerCombinationsBridge() sc.sticky["layer_combinations_bridge"] = b form = panel_base.open_satellite_window( "layer_combinations", title="Ebenenkombinationen", size=(540, 640), bridge=b) sc.sticky["layer_combinations_form"] = form def _ebenen_bridge_factory(): bridge = EbenenBridge(mode="ebenen") sc.sticky["ebenen_bridge_ref"] = bridge _install_layer_listener(bridge) return bridge def _zeichnungsebenen_bridge_factory(): bridge = EbenenBridge(mode="zeichnungsebenen") sc.sticky["zeichnungsebenen_bridge_ref"] = bridge return bridge def _install_layer_listener(bridge): """Reagiert auf externe Aenderungen in Rhinos Layer-Tabelle (Rename, Delete). Nur EINMAL global registrieren — Bridge-Referenz kommt aus dem Cross-Sync-Broadcast (alle aktuell offenen Panels werden benachrichtigt). """ if sc.sticky.get("ebenen_layer_listener"): return def on_layer_event(sender, args): if _is_processing(): return try: doc = args.Document evt = args.EventType # Nur Modify-Events interessieren uns (Rename, Color etc.) if evt != Rhino.DocObjects.Tables.LayerTableEventType.Modified: return idx = args.LayerIndex if idx < 0 or idx >= doc.Layers.Count: return layer = doc.Layers[idx] dossier_id = layer.GetUserString("dossier_id") dossier_code = layer.GetUserString("dossier_code") if not (dossier_id or dossier_code): return updated = False if dossier_id: raw = doc.Strings.GetValue("dossier_zeichnungsebenen") if raw: try: z_list = json.loads(raw) for z in z_list: if z.get("id") == dossier_id and z.get("name") != layer.Name: z["name"] = layer.Name updated = True break if updated: doc.Strings.SetString("dossier_zeichnungsebenen", json.dumps(z_list, ensure_ascii=False)) except Exception: pass elif dossier_code: raw = doc.Strings.GetValue("dossier_ebenen") if raw: try: e_list = json.loads(raw) # Layer-Name ist "CC_NAME" — wir extrahieren NAME if "_" in layer.Name: new_name = layer.Name.split("_", 1)[1] for e in e_list: if e.get("code") == dossier_code and e.get("name") != new_name: e["name"] = new_name updated = True break if updated: doc.Strings.SetString("dossier_ebenen", json.dumps(e_list, ensure_ascii=False)) except Exception: pass if updated: _broadcast_state(doc) except Exception as ex: print("[EBENEN] Layer-Event:", ex) Rhino.RhinoDoc.LayerTableEvent += on_layer_event sc.sticky["ebenen_layer_listener"] = True print("[EBENEN] Layer-Listener aktiv") panel_base.register_and_open("ebenen", "Ebenen", PANEL_GUID_STR, _ebenen_bridge_factory, icon_spec=("layers", "#3a6fa8")) panel_base.register_and_open("zeichnungsebenen", "Zeichnungsebenen", PANEL_GUID_STR_Z, _zeichnungsebenen_bridge_factory, icon_spec=("levels", "#3a6fa8"))