a597b58c93
Project-Settings hat jetzt 4 Tabs:
- Voreinstellungen (kompakte InlineNumberField, gruppiert in Sections)
- Materialien (List/Detail, ohne Hatch)
- Linientypen (List/Detail mit SVG-Strich-Vorschau)
- Schraffuren (List/Detail mit echtem HatchLine-Renderer)
Backend (rhinopanel.py):
- _list_linetypes_full liefert Segmente {length, type: Line/Space/Dot}
(Mac Rhino 8 GetSegment returnt (length, isLine: bool))
- _list_hatch_patterns_full liefert HatchLines mit angle/base/offset/dashes
(hl.Dashes optional ueber 3 API-Variants)
- CRUD: RENAME / DELETE / LOAD_DEFAULTS
- File-Import: IMPORT_LINETYPE_FILE (.lin), IMPORT_HATCH_FILE (.pat)
via Eto.OpenFileDialog → Linetypes.Load / HatchPatterns.LoadFromFile
Frontend (ProjectSettingsDialog.jsx):
- LinetypePreview: SVG mit tile-fenster (4 Repetitions), Line als <line>,
Dot als <circle>, currentColor fuer Renderer-Robustheit
- HatchPreview: rendert pro HatchLine alle parallelen Linien mit Angle,
Offset (Spacing + Stagger), Dashes als stroke-dasharray
- TABLES_UPDATED Message vom Backend re-rendert Listen
- Import-Pills im List-Footer
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2409 lines
106 KiB
Python
2409 lines
106 KiB
Python
#! 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 _list_hatch_patterns_full(doc):
|
|
"""Vollstaendiges Hatch-Pattern-Listing fuer die Verwaltungs-UI.
|
|
Inkludiert die HatchLines (angle, base, offset, dashes) damit
|
|
Frontend echte Pattern-Previews rendern kann statt Platzhalter."""
|
|
out = []
|
|
try:
|
|
for i in range(doc.HatchPatterns.Count):
|
|
try:
|
|
hp = doc.HatchPatterns[i]
|
|
if hp is None: continue
|
|
try:
|
|
if bool(hp.IsDeleted): continue
|
|
except Exception: pass
|
|
ftype = "Lines"
|
|
try:
|
|
ft = hp.FillType
|
|
ftype = str(ft).split(".")[-1]
|
|
except Exception: pass
|
|
ref = 0
|
|
try: ref = int(hp.Reference)
|
|
except Exception: pass
|
|
desc = ""
|
|
try: desc = str(hp.Description) or ""
|
|
except Exception: pass
|
|
# HatchLines extrahieren (nur Lines-Typ)
|
|
hlines = []
|
|
if ftype.lower() == "lines":
|
|
try:
|
|
# Probiere zuerst die Property, dann GetHatchLines()
|
|
lines = None
|
|
try: lines = hp.HatchLines
|
|
except Exception: pass
|
|
if lines is None:
|
|
try: lines = hp.GetHatchLines()
|
|
except Exception: pass
|
|
if lines is None:
|
|
print("[EBENEN] hp[{}] '{}' HatchLines=None".format(
|
|
i, hp.Name))
|
|
else:
|
|
try: cnt = len(lines)
|
|
except Exception:
|
|
try: cnt = lines.Count
|
|
except Exception: cnt = -1
|
|
print("[EBENEN] hp[{}] '{}' HatchLines type={} count={}".format(
|
|
i, hp.Name, type(lines).__name__, cnt))
|
|
for hl in lines:
|
|
try:
|
|
bp = hl.BasePoint
|
|
off = hl.Offset
|
|
# Dashes optional — Property heisst auf
|
|
# manchen Rhino-Versionen GetDashes() oder
|
|
# DashCount/GetDash(i)
|
|
dashes = []
|
|
try:
|
|
dr = hl.Dashes
|
|
if dr is not None:
|
|
for d in dr: dashes.append(float(d))
|
|
except Exception:
|
|
# Versuche GetDashes()
|
|
try:
|
|
dr = hl.GetDashes()
|
|
if dr is not None:
|
|
for d in dr: dashes.append(float(d))
|
|
except Exception:
|
|
# Versuche DashCount + GetDash(i)
|
|
try:
|
|
dc = int(hl.DashCount)
|
|
for k in range(dc):
|
|
dashes.append(float(hl.GetDash(k)))
|
|
except Exception: pass
|
|
entry = {
|
|
"angle": float(hl.Angle),
|
|
"baseX": float(bp.X),
|
|
"baseY": float(bp.Y),
|
|
"offX": float(off.X),
|
|
"offY": float(off.Y),
|
|
"dashes": dashes,
|
|
}
|
|
hlines.append(entry)
|
|
except Exception as ex:
|
|
print("[EBENEN] hp[{}] hl FAIL:".format(i), ex)
|
|
except Exception as ex:
|
|
print("[EBENEN] hp[{}] HatchLines outer FAIL:".format(i), ex)
|
|
out.append({
|
|
"index": i,
|
|
"name": hp.Name or "",
|
|
"fillType": ftype,
|
|
"description": desc,
|
|
"isReference": (ref > 0),
|
|
"hatchLines": hlines,
|
|
})
|
|
except Exception:
|
|
continue
|
|
except Exception as ex:
|
|
print("[EBENEN] _list_hatch_patterns_full:", ex)
|
|
return out
|
|
|
|
|
|
def _list_linetypes_full(doc):
|
|
"""Vollstaendiges Linetype-Listing fuer die Verwaltungs-UI.
|
|
Mac Rhino 8 GetSegment(i) returnt (length: float, isLine: bool):
|
|
True = Line-Segment, False = Space (Gap). Bei Dot ist length=0.0
|
|
+ isLine=True (Punkt = unendlich kurzer Strich)."""
|
|
out = []
|
|
try:
|
|
for i in range(doc.Linetypes.Count):
|
|
try:
|
|
lt = doc.Linetypes[i]
|
|
if lt is None or lt.IsDeleted: continue
|
|
segs = []
|
|
sc_cnt = 0
|
|
try: sc_cnt = int(lt.SegmentCount)
|
|
except Exception: pass
|
|
for s in range(sc_cnt):
|
|
try:
|
|
seg = lt.GetSegment(s)
|
|
if seg is None: continue
|
|
length = float(seg[0])
|
|
# seg[1]: True = Line/Dot, False = Space/Gap
|
|
is_line = bool(seg[1])
|
|
if is_line and length == 0.0:
|
|
stype = "Dot"
|
|
elif is_line:
|
|
stype = "Line"
|
|
else:
|
|
stype = "Space"
|
|
segs.append({"length": length, "type": stype})
|
|
except Exception as ex:
|
|
print("[EBENEN] lt[{}] GetSegment({}) FAIL: {}".format(
|
|
i, s, ex))
|
|
ref = 0
|
|
try: ref = int(lt.Reference)
|
|
except Exception: pass
|
|
out.append({
|
|
"index": i,
|
|
"name": lt.Name or "",
|
|
"segments": segs,
|
|
"isContinuous": (len(segs) == 0),
|
|
"isReference": (ref > 0),
|
|
})
|
|
except Exception as ex:
|
|
print("[EBENEN] linetype outer FAIL:", ex)
|
|
continue
|
|
except Exception as ex:
|
|
print("[EBENEN] _list_linetypes_full:", ex)
|
|
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),
|
|
"hatchPatternsFull": _list_hatch_patterns_full(doc),
|
|
"linetypes": _list_linetypes_full(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)
|
|
elif t == "RENAME_LINETYPE":
|
|
self._rename_linetype(p)
|
|
elif t == "DELETE_LINETYPE":
|
|
self._delete_linetype(p)
|
|
elif t == "LOAD_LINETYPE_DEFAULTS":
|
|
self._load_linetype_defaults()
|
|
elif t == "IMPORT_LINETYPE_FILE":
|
|
self._import_linetype_file()
|
|
elif t == "RENAME_HATCH":
|
|
self._rename_hatch(p)
|
|
elif t == "DELETE_HATCH":
|
|
self._delete_hatch(p)
|
|
elif t == "IMPORT_HATCH_FILE":
|
|
self._import_hatch_file()
|
|
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})
|
|
|
|
# ---- Linetype CRUD ----
|
|
def _rename_linetype(self, payload):
|
|
idx = payload.get("index")
|
|
new_name = (payload.get("name") or "").strip()
|
|
if idx is None or not new_name: return
|
|
d = Rhino.RhinoDoc.ActiveDoc
|
|
if d is None: return
|
|
try:
|
|
lt = d.Linetypes[int(idx)]
|
|
if lt is None or lt.IsDeleted: return
|
|
lt.Name = new_name
|
|
d.Linetypes.Modify(lt, int(idx), True)
|
|
except Exception as ex:
|
|
print("[PROJECT-SETTINGS] rename_linetype:", ex)
|
|
self._send_tables()
|
|
|
|
def _delete_linetype(self, payload):
|
|
idx = payload.get("index")
|
|
if idx is None: return
|
|
d = Rhino.RhinoDoc.ActiveDoc
|
|
if d is None: return
|
|
try:
|
|
lt = d.Linetypes[int(idx)]
|
|
if lt is None: return
|
|
# Default-Linetypes (Continuous, ByLayer) sollten nicht
|
|
# geloescht werden — Rhino's Delete macht es uns aber
|
|
# einfach: gibt False zurueck wenn nicht erlaubt.
|
|
d.Linetypes.Delete(int(idx), True)
|
|
except Exception as ex:
|
|
print("[PROJECT-SETTINGS] delete_linetype:", ex)
|
|
self._send_tables()
|
|
|
|
def _load_linetype_defaults(self):
|
|
d = Rhino.RhinoDoc.ActiveDoc
|
|
if d is None: return
|
|
try:
|
|
d.Linetypes.LoadDefaultLinetypes(True)
|
|
except Exception as ex:
|
|
print("[PROJECT-SETTINGS] load_linetype_defaults:", ex)
|
|
self._send_tables()
|
|
|
|
def _import_linetype_file(self):
|
|
"""Datei-Picker fuer .lin (AutoCAD-Linetype) → Linetypes.Load."""
|
|
d = Rhino.RhinoDoc.ActiveDoc
|
|
if d is None: return
|
|
try:
|
|
import Eto.Forms as forms
|
|
dlg = forms.OpenFileDialog()
|
|
dlg.Title = "Linientyp-Datei waehlen (.lin)"
|
|
dlg.MultiSelect = False
|
|
dlg.Filters.Add(forms.FileFilter("AutoCAD Linetypes", ".lin"))
|
|
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": return
|
|
path = dlg.FileName or ""
|
|
if not path: return
|
|
cnt_before = d.Linetypes.Count
|
|
# Rhino-API: Linetypes.Load(filename) — Achtung in
|
|
# manchen Builds heisst es LoadLinetypeFile(...).
|
|
ok = False
|
|
try:
|
|
ok = bool(d.Linetypes.Load(path))
|
|
except Exception:
|
|
try: ok = bool(d.Linetypes.LoadLinetypeFile(path))
|
|
except Exception as ex:
|
|
print("[PROJECT-SETTINGS] Linetypes.Load:", ex)
|
|
cnt_after = d.Linetypes.Count
|
|
print("[PROJECT-SETTINGS] linetype import: ok={} {} -> {} ({} neu)".format(
|
|
ok, cnt_before, cnt_after, cnt_after - cnt_before))
|
|
except Exception as ex:
|
|
print("[PROJECT-SETTINGS] import_linetype_file:", ex)
|
|
self._send_tables()
|
|
|
|
def _import_hatch_file(self):
|
|
"""Datei-Picker fuer .pat (AutoCAD-Hatch) →
|
|
HatchPatterns.LoadFromFile."""
|
|
d = Rhino.RhinoDoc.ActiveDoc
|
|
if d is None: return
|
|
try:
|
|
import Eto.Forms as forms
|
|
dlg = forms.OpenFileDialog()
|
|
dlg.Title = "Schraffur-Datei waehlen (.pat)"
|
|
dlg.MultiSelect = False
|
|
dlg.Filters.Add(forms.FileFilter("AutoCAD Hatches", ".pat"))
|
|
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": return
|
|
path = dlg.FileName or ""
|
|
if not path: return
|
|
cnt_before = d.HatchPatterns.Count
|
|
cnt_imported = 0
|
|
try:
|
|
# LoadFromFile(file, replaceExisting) — returnt int
|
|
cnt_imported = int(d.HatchPatterns.LoadFromFile(path, False))
|
|
except Exception:
|
|
try:
|
|
cnt_imported = int(d.HatchPatterns.Load(path))
|
|
except Exception as ex:
|
|
print("[PROJECT-SETTINGS] HatchPatterns.Load:", ex)
|
|
cnt_after = d.HatchPatterns.Count
|
|
print("[PROJECT-SETTINGS] hatch import: {} (Tabelle {} -> {})".format(
|
|
cnt_imported, cnt_before, cnt_after))
|
|
except Exception as ex:
|
|
print("[PROJECT-SETTINGS] import_hatch_file:", ex)
|
|
self._send_tables()
|
|
|
|
# ---- Hatch-Pattern CRUD ----
|
|
def _rename_hatch(self, payload):
|
|
idx = payload.get("index")
|
|
new_name = (payload.get("name") or "").strip()
|
|
if idx is None or not new_name: return
|
|
d = Rhino.RhinoDoc.ActiveDoc
|
|
if d is None: return
|
|
try:
|
|
hp = d.HatchPatterns[int(idx)]
|
|
if hp is None or hp.IsDeleted: return
|
|
hp.Name = new_name
|
|
d.HatchPatterns.Modify(hp, int(idx), True)
|
|
except Exception as ex:
|
|
print("[PROJECT-SETTINGS] rename_hatch:", ex)
|
|
self._send_tables()
|
|
|
|
def _delete_hatch(self, payload):
|
|
idx = payload.get("index")
|
|
if idx is None: return
|
|
d = Rhino.RhinoDoc.ActiveDoc
|
|
if d is None: return
|
|
try:
|
|
d.HatchPatterns.Delete(int(idx), True)
|
|
except Exception as ex:
|
|
print("[PROJECT-SETTINGS] delete_hatch:", ex)
|
|
self._send_tables()
|
|
|
|
def _send_tables(self):
|
|
"""Sendet aktuelle Linetype + Hatch-Tabellen ans Frontend
|
|
(TABLES_UPDATED-Message). Frontend re-rendert die Listen."""
|
|
d = Rhino.RhinoDoc.ActiveDoc
|
|
if d is None: return
|
|
self.send("TABLES_UPDATED", {
|
|
"linetypes": _list_linetypes_full(d),
|
|
"hatchPatternsFull": _list_hatch_patterns_full(d),
|
|
})
|
|
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"))
|