#! python 3 # -*- coding: utf-8 -*- """ elemente.py ELEMENTE-Panel: Smart Architektur-Elemente. Phase 1: Waende — Achsen-Linie (editierbar) + Volumen (auto-generiert). Achse ist die Quelle der Wahrheit, Volumen wird bei jeder Achsen-Aenderung oder Geschoss-Aenderung neu gebaut. """ import os import sys import json import uuid import Rhino import Rhino.Geometry as rg import System 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 PANEL_GUID_STR = "5a6b7c8d-9e0f-4a1b-c2d3-e4f5061728e0" # UserString-Keys auf Elementen _KEY_ID = "dossier_element_id" # gemeinsame UUID Achse+Volumen _KEY_TYPE = "dossier_element_type" # "wand_axis" | "wand_volume" _KEY_GESCHOSS = "dossier_geschoss" _KEY_DICKE = "dossier_dicke" # in doc-units _KEY_UK_OVER = "dossier_uk_override" # "" = auto, sonst float _KEY_OK_OVER = "dossier_ok_override" _KEY_REFERENZ = "dossier_referenz" # "mid" | "left" | "right" _KEY_WAND_LAYERED = "dossier_wand_layered" # "1" = mehrschichtig, sonst solid _KEY_WAND_LAYERS = "dossier_wand_layers" # JSON-Liste [{name, dicke, color}] _KEY_WAND_LAYER_IDX = "dossier_wand_layer_idx" # Layer-Index am Volume-Brep _KEY_DACH_NEIGUNG = "dossier_dach_neigung" # Grad als string ("30") _KEY_DACH_EAVE = "dossier_dach_eave" # Index der Traufkante (string) _KEY_DACH_TYP = "dossier_dach_typ" # "pult"|"sattel"|"walm"|"mansarde" _KEY_DACH_NEIG_UNTEN = "dossier_dach_neigung_unten" # Mansarde: untere Neigung _KEY_DACH_KNICK_H = "dossier_dach_knick_h" # Mansarde: Hoehe des Knicks _KEY_DACH_VARIANTE = "dossier_dach_variante" # Mansarde: "walm" | "giebel" | "walm_giebel" # Decken-Aussparungen — Source = geschlossene Outline-Curve, Parent = Decke _KEY_AUSSP_PARENT = "dossier_aussp_parent" # decke_id der Eltern-Decke # Oeffnungen (Fenster/Tueren) — Source = Point auf Wand-Achse _KEY_OEFF_TYP = "dossier_oeff_typ" # "fenster" | "tuer" _KEY_OEFF_PARENT = "dossier_oeff_parent" # parent wand element_id _KEY_OEFF_BREITE = "dossier_oeff_breite" _KEY_OEFF_HOEHE = "dossier_oeff_hoehe" _KEY_OEFF_BRUEST = "dossier_oeff_brueest" # Bruestungshoehe (nur Fenster, sonst 0) _KEY_OEFF_RAHMEN_B = "dossier_oeff_rahmen_b" # Rahmen-Riegel-Breite (Profilbreite, in der Wandflaeche) _KEY_OEFF_RAHMEN_TIEFE = "dossier_oeff_rahmen_tiefe" # Rahmen-Tiefe (entlang Wandnormale) _KEY_OEFF_RAHMEN_POS = "dossier_oeff_rahmen_pos" # "aussen" | "mid" | "innen" — Lage im Wandquerschnitt _KEY_OEFF_FLUEGEL = "dossier_oeff_fluegel" # Anzahl Fluegel (1,2,3,4) _KEY_OEFF_SIMS_AUS = "dossier_oeff_sims_aus" # Style: "ohne"|"schmal"|"standard"|"breit" _KEY_OEFF_SIMS_IN = "dossier_oeff_sims_in" # Style: "ohne"|"schmal"|"standard"|"breit" _KEY_OEFF_GLAS = "dossier_oeff_glas" # "1"|"0" — sichtbare Glas-Scheibe _KEY_OEFF_REFERENZ = "dossier_oeff_referenz" # "mid" | "links" | "rechts" — Lage des Klick-Punkts in der Oeffnung _OEFF_REFERENZ_OPTIONS = ("mid", "links", "rechts") # Treppen-spezifische Keys _KEY_GESCHOSS_END = "dossier_geschoss_end" # Zielgeschoss-ID (Treppe) _KEY_TREPPE_BREITE = "dossier_treppe_breite" _KEY_TREPPE_N = "dossier_treppe_n" # Anzahl Stufen (Steigungen) _KEY_TREPPE_REFERENZ = "dossier_treppe_referenz" # "mid"|"links"|"rechts" — Lage der Lauflinie zur Treppe _KEY_TREPPE_MODUS = "dossier_treppe_modus" # "massiv"|"flach"|"plattenrand" _KEY_TREPPE_LAUF_D = "dossier_treppe_lauf_d" # Lauf-Plattendicke (m) _KEY_TREPPE_ART = "dossier_treppe_art" # "gerade"|"l"|"wendel" _KEY_TREPPE_H_OVER = "dossier_treppe_h_over" # eigene Hoehe (m); leer = Geschoss _KEY_TREPPE_SOLL = "dossier_treppe_soll" # JSON {s:[lo,hi,on], a:[lo,hi,on], sa:[lo,hi,on]} # Tragwerk: Stuetze / Traeger / Unterzug — gemeinsames Querschnitts-System _KEY_TRAG_KIND = "dossier_trag_kind" # "stuetze" | "traeger" | "unterzug" _KEY_TRAG_PROFIL = "dossier_trag_profil" # "quadrat"|"rechteck"|"rund"|"i_profil"|"rohr" _KEY_TRAG_B = "dossier_trag_b" # Breite/Hauptdim (m) _KEY_TRAG_H = "dossier_trag_h" # Hoehe (Rechteck/I) (m) _KEY_TRAG_D = "dossier_trag_d" # Durchmesser (Rund/Rohr) (m) _KEY_TRAG_T = "dossier_trag_t" # Wanddicke (Rohr/I-tweb-tflange) (m) _KEY_TRAG_ANGLE = "dossier_trag_angle" # Rotation um Z (Grad) _KEY_TRAG_Z_OVER = "dossier_trag_z_over" # Z-Override (m) — leer = automatisch _TRAG_PROFILE = ("quadrat", "rechteck", "rund", "i_profil", "rohr") _TRAG_KINDS = ("stuetze", "traeger") # Raum (Raumstempel) — Source = geschlossene Outline-Curve, Volume = TextEntity _KEY_RAUM_NAME = "dossier_raum_name" _KEY_RAUM_NUMMER = "dossier_raum_nummer" _KEY_RAUM_FUNKTION = "dossier_raum_funktion" _KEY_RAUM_RUNDUNG = "dossier_raum_rundung" # "exakt"|"0.01"|"0.1"|"0.5"|"1" _KEY_RAUM_TXT_H = "dossier_raum_txt_h" # Texthoehe in m _KEY_RAUM_ALIGN = "dossier_raum_align" # "links"|"mid"|"rechts" _KEY_RAUM_SIA = "dossier_raum_sia" # "" | "hnf" | "nnf" | "vf" | "ff" _KEY_RAUM_FUELL = "dossier_raum_fuellung" # "" (keine) | "Solid" | Pattern-Name | "ByLayer" _RAUM_RUNDUNGEN = ("exakt", "0.01", "0.1", "0.5", "1") _RAUM_ALIGN = ("links", "mid", "rechts") _RAUM_SIA_KINDS = ("", "hnf", "nnf", "vf", "ff") _RAUM_FUNKTIONEN = ( "wohnen", "schlafen", "bad", "kueche", "essen", "flur", "diele", "buero", "atelier", "lager", "technik", "balkon", "terrasse", "sonstiges", ) # SIA-416 Farbpalette nach CH-Buero-Konvention (helle, kraeftige Pastelltoene). _SIA_COLORS_HEX = { "hnf": "#e8a8a8", # Hauptnutzflaeche — Rot "nnf": "#e8c498", # Nebennutzflaeche — Orange "vf": "#e8d878", # Verkehrsflaeche — Gelb "ff": "#a8c8e0", # Funktionsflaeche — Hellblau } _SIA_LABELS = { "": "—", "hnf": "HNF", "nnf": "NNF", "vf": "VF", "ff": "FF", } # Cross-Doc Preset-Name fuer Override-Engine. Steuert auch das siaFillMode- # Flag im UI: aktives Preset == diesem Namen ⇒ SIA-Modus ist an. _SIA_PRESET_NAME = "SIA-Raeume" def _build_sia_preset_rules(): """Erzeugt die 4 Override-Regeln fuer SIA-416-Klassifikation. Matcht auf den UserString `dossier_raum_sia` und setzt Outline-Farbe (+ Hatch Pattern Solid falls ein Fuell-Hatch via Gestaltung am Raum haengt).""" rules = [] order = [("hnf", "HNF Hauptnutz"), ("nnf", "NNF Nebennutz"), ("vf", "VF Verkehr"), ("ff", "FF Funktion")] for code, name in order: rules.append({ "id": "sia_" + code, "name": name, "enabled": True, "condition": { "type": "user_string", "operator": "equals", "key": _KEY_RAUM_SIA, "value": code, }, "actions": { "color": _SIA_COLORS_HEX[code], "hatchPattern": "Solid", }, }) return rules def _ensure_sia_preset(force=False): """Stellt sicher dass das SIA-Preset im cross-doc Presets-File existiert. force=True: ueberschreibt bestehendes Preset (Single-Source-of-Truth aus elemente.py). force=False: nur anlegen wenn noch nicht vorhanden — so bleiben User-Anpassungen aus dem Overrides-Panel erhalten.""" try: import overrides as _ov if force or _ov.load_preset(_SIA_PRESET_NAME) is None: _ov.save_preset(_SIA_PRESET_NAME, _build_sia_preset_rules()) except Exception as ex: print("[ELEMENTE] ensure_sia_preset:", ex) def _sia_fill_enabled(doc): """SIA-Modus aktiv? Wahr nur wenn Override-Engine global enabled IST UND das SIA-Preset als active markiert ist. Falls der User die Engine via Overrides-Panel ausschaltet, muss force_solid sofort entfallen.""" try: import overrides as _ov cfg = _ov.load_config(doc) return (bool(cfg.get("enabled")) and cfg.get("activePreset") == _SIA_PRESET_NAME) except Exception: return False def _list_hatch_patterns(doc): """Liefert alle nicht-geloeschten Hatch-Pattern-Namen aus dem Doc.""" out = [] try: for i in range(doc.HatchPatterns.Count): hp = doc.HatchPatterns[i] if hp is None or hp.IsDeleted: continue name = hp.Name if name and name not in out: out.append(name) except Exception as ex: print("[ELEMENTE] list_hatch_patterns:", ex) return out _TREPPE_SOLL_DEFAULT = { "s": [0.15, 0.20, True], "a": [0.21, 0.35, True], "sa": [0.60, 0.65, True], } _TREPPE_MODI = ("massiv", "flach", "plattenrand") _TREPPE_ARTEN = ("gerade", "l", "wendel") # Sims-Stile (Aussen/Innen) — Dicke (Z), Auskragung (perp), Ueberhang seitlich _OEFF_SIMS_STYLES = { "ohne": None, "schmal": {"dicke": 0.03, "aus": 0.08, "ueberhang": 0.03}, "standard": {"dicke": 0.04, "aus": 0.14, "ueberhang": 0.05}, "breit": {"dicke": 0.05, "aus": 0.22, "ueberhang": 0.06}, } _OEFF_RAHMEN_POS_OPTIONS = ("aussen", "mid", "innen") # --- Last-Used-Defaults (sticky, session-life) ------------------------------ # Speichert die letzten Werte (Dicke, Referenz, Modus, Neigung), damit der # naechste Create-Befehl mit denselben Defaults startet. Sticky ueberlebt # Doc-Wechsel, aber NICHT Rhino-Restart — was passt: "ich hab gerade 0.30 # fuer eine Wand benutzt, neue Wand soll auch 0.30 sein". def _last(key, default): # `_reset_panels.py` cleart sticky via `= None` (statt del), daher kann # sc.sticky.get() den default ueberlesen und ein None zurueckgeben. # Hier defensive Fallback → default wenn der Wert None ist. v = sc.sticky.get("elemente_last_" + key, default) return default if v is None else v def _save_last(**kwargs): for k, v in kwargs.items(): sc.sticky["elemente_last_" + k] = v # --- Geschoss-Lookup -------------------------------------------------------- def _load_geschosse(doc): """Liest die Geschoss/Ebenen-Liste aus doc.Strings (vom Ebenen-Manager).""" raw = doc.Strings.GetValue("dossier_zeichnungsebenen") or \ doc.Strings.GetValue("dossier_ebenen") if not raw: return [] try: data = json.loads(raw) return data if isinstance(data, list) else [] except Exception: return [] def _geschoss_by_id(doc, gid): if not gid: return None for e in _load_geschosse(doc): if isinstance(e, dict) and e.get("id") == gid: return e return None def _active_geschoss_id(doc): """Liefert die ID des aktuell aktiven Geschosses (= im Ebenen-Manager blau hervorgehoben). Falls keins gesetzt oder das aktive keine Geschoss-Ebene ist (z.B. Schnitt/Ansicht), wird das erste echte Geschoss zurueckgegeben.""" try: active = doc.Strings.GetValue("dossier_active_id") or "" except Exception: active = "" geschosse = [g for g in _load_geschosse(doc) if isinstance(g, dict) and g.get("isGeschoss")] if active and any(g.get("id") == active for g in geschosse): return active return geschosse[0].get("id") if geschosse else "" def _active_geschoss_name(doc): """Name des aktiven Geschosses fuer UI-Anzeige.""" gid = _active_geschoss_id(doc) g = _geschoss_by_id(doc, gid) return g.get("name", "") if g else "" def _resolve_uk_ok(doc, gid, uk_over, ok_over): """Wand: UK = OKFF, OK = OKFF + Hoehe (Standard fuer Geschoss-volle Wand).""" g = _geschoss_by_id(doc, gid) if g is None: uk = float(uk_over) if uk_over not in (None, "") else 0.0 ok = float(ok_over) if ok_over not in (None, "") else 3.0 return uk, ok okff = float(g.get("okff", 0.0)) hoehe = float(g.get("hoehe", 3.0)) auto_uk = okff auto_ok = okff + hoehe uk = float(uk_over) if uk_over not in (None, "") else auto_uk ok = float(ok_over) if ok_over not in (None, "") else auto_ok return uk, ok def _resolve_decke_z(doc, gid, dicke, uk_over, ok_over): """Decke: OK = OKFF des verknuepften Geschosses (= Bodenkante = 0.00 relativ zum Geschoss). UK = OK - dicke (Decke geht NACH UNTEN). OK ist der natuerliche Fixpunkt: aendert sich die Dicke, wandert UK mit. Override-Logik: - Nur OK_override gesetzt → OK = override, UK = OK - dicke - Nur UK_override gesetzt → UK = override, OK = UK + dicke - Beide gesetzt → beide literal""" g = _geschoss_by_id(doc, gid) okff = float(g.get("okff", 0.0)) if g else 0.0 auto_ok = okff has_ok = ok_over not in (None, "") has_uk = uk_over not in (None, "") if has_ok and has_uk: return float(uk_over), float(ok_over) if has_ok: ok = float(ok_over) return ok - float(dicke), ok if has_uk: uk = float(uk_over) return uk, uk + float(dicke) # Beide auto return auto_ok - float(dicke), auto_ok # --- Layer-Pfade ------------------------------------------------------------ def _find_ebene_sublayer_name(doc, keywords, default_code, default_name, default_color="#888888", default_lw=0.35): """Findet aus der Ebenen-Liste den ersten Sublayer der einem der Keywords entspricht. Wenn nicht gefunden, wird der Sublayer mit den Default-Werten AUTOMATISCH in die Ebenen-Liste eingetragen (damit er auch im Ebenen- Manager-UI erscheint) und der Rhinopanel-Bridge ein State-Refresh getriggert. Ergebnis: 'CODE_NAME' wie 'WAENDE'.""" raw = doc.Strings.GetValue("dossier_ebenen") ebenen = [] if raw: try: data = json.loads(raw) if isinstance(data, list): ebenen = data except Exception as ex: print("[ELEMENTE] sublayer-lookup:", ex) # 1) Per Keyword in der Liste suchen for e in ebenen: if not isinstance(e, dict): continue name = (e.get("name") or "") low = name.lower() for kw in keywords: if kw in low: return "{}_{}".format(e.get("code", default_code), name or default_name) # 2) Auto-Add: weder Keyword noch Code vorhanden → Eintrag anlegen if ebenen and not any(isinstance(e, dict) and e.get("code") == default_code for e in ebenen): ebenen.append({ "code": default_code, "name": default_name, "color": default_color, "lw": default_lw, "visible": True, "locked": False, }) try: doc.Strings.SetString("dossier_ebenen", json.dumps(ebenen, ensure_ascii=False)) print("[ELEMENTE] Ebene '{}_{}' automatisch hinzugefuegt".format( default_code, default_name)) # build_layers synchron damit Rhino-Layer existieren bevor # Objekte verschoben werden try: import layer_builder z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") zlist = json.loads(z_raw) if z_raw else [] if zlist: layer_builder.build_layers(doc, zlist, ebenen) except Exception as ex: print("[ELEMENTE] build_layers nach auto-add:", ex) # Ebenen-Manager UI mit-informieren via broadcast_state try: import rhinopanel rhinopanel._broadcast_state(doc) except Exception as ex: print("[ELEMENTE] broadcast_state:", ex) except Exception as ex: print("[ELEMENTE] Auto-Add fehler:", ex) return "{}_{}".format(default_code, default_name) def _parse_swisstopo_tile_bbox(filename): """Aus einem swisstopo-Filename die LV95-Tile-bbox ableiten. Filename-Pattern: swissimage-dop10_2025_2763-1254_0.1_2056.tif (1km x 1km Tile) SWISSALTI3D_..._2763-1254.xyz (LV95-1km) Wichtig: Separator zwischen den beiden Coords MUSS Hyphen sein (`2763-1254`), sonst matcht der Regex faelschlich auf `_YEAR_EAST_` Strukturen wie `_2025_2763_` und liefert Tile-Koords vom Jahr 2025. Liefert (e_min, n_min, e_max, n_max) in Metern oder None.""" import re as _re if not filename: return None m = _re.search(r"[_-](\d{4})-(\d{2,4})(?:[-_]|\.)", filename) if not m: return None e_k = int(m.group(1)); n_k = int(m.group(2)) e_min = e_k * 1000.0 n_min = n_k * 1000.0 return (e_min, n_min, e_min + 1000.0, n_min + 1000.0) def _layer_path_axis(doc, geschoss_name): """Wand-Achse + Volumen — Sublayer 'Wände' (Code 20).""" sub = _find_ebene_sublayer_name(doc, ["wand", "wände", "waende"], "20", "Wände", default_color="#0a0a0a", default_lw=0.50) return "{}::{}".format(geschoss_name, sub) def _layer_path_volume(doc, geschoss_name): return _layer_path_axis(doc, geschoss_name) def _layer_path_decke(doc, geschoss_name): """Decken-Outline + Volumen — Sublayer 'DECKEN' (Code 30).""" sub = _find_ebene_sublayer_name(doc, ["decke"], "30", "DECKEN", default_color="#605850", default_lw=0.35) return "{}::{}".format(geschoss_name, sub) def _layer_path_dach(doc, geschoss_name): """Dach-Outline + Volumen — Sublayer 'DÄCHER' (Code 31).""" sub = _find_ebene_sublayer_name(doc, ["dach", "däch", "daech"], "31", "DÄCHER", default_color="#7a4a3a", default_lw=0.35) return "{}::{}".format(geschoss_name, sub) def _layer_path_treppe(doc, geschoss_name): """Treppen-Lauflinie + Volumen — Sublayer 'TREPPEN' (Code 40).""" sub = _find_ebene_sublayer_name(doc, ["trepp"], "40", "TREPPEN", default_color="#a08040", default_lw=0.35) return "{}::{}".format(geschoss_name, sub) def _layer_path_tragwerk(doc, geschoss_name): """Tragwerk (Stuetze/Traeger/Unterzug) — Sublayer 'TRAGWERK' (Code 50).""" sub = _find_ebene_sublayer_name(doc, ["trag", "stütz", "stuetz"], "50", "TRAGWERK", default_color="#2f5d54", default_lw=0.50) return "{}::{}".format(geschoss_name, sub) def _layer_path_raum(doc, geschoss_name): """Raeume (Outline + Stempel) — Sublayer 'RAEUME' (Code 60).""" sub = _find_ebene_sublayer_name(doc, ["raum", "räum", "raeum"], "60", "RAEUME", default_color="#7a8a9a", default_lw=0.13) return "{}::{}".format(geschoss_name, sub) def _ensure_layer(doc, path): """Stellt sicher, dass ein Layer-Pfad existiert. Liefert Layer-Index.""" idx = doc.Layers.FindByFullPath(path, -1) if idx >= 0: return idx # Schrittweise anlegen parts = path.split("::") parent_id = System.Guid.Empty cur_path = "" for part in parts: cur_path = part if not cur_path else (cur_path + "::" + part) idx = doc.Layers.FindByFullPath(cur_path, -1) if idx < 0: from Rhino.DocObjects import Layer layer = Layer() layer.Name = part if parent_id != System.Guid.Empty: layer.ParentLayerId = parent_id idx = doc.Layers.Add(layer) parent_id = doc.Layers[idx].Id return idx # --- Wall-Konstruktion ------------------------------------------------------ def _make_rectangle_preview(c1): """Preview: 4 gruene Kanten des Rechtecks waehrend des Ziehens.""" import System.Drawing as SD color = SD.Color.FromArgb(255, 95, 200, 180) def handler(sender, e): try: cx, cy = e.CurrentPoint.X, e.CurrentPoint.Y p1 = rg.Point3d(c1.X, c1.Y, 0) p2 = rg.Point3d(cx, c1.Y, 0) p3 = rg.Point3d(cx, cy, 0) p4 = rg.Point3d(c1.X, cy, 0) for a, b in ((p1, p2), (p2, p3), (p3, p4), (p4, p1)): e.Display.DrawLine(a, b, color, 2) except Exception: pass return handler def _make_rect3pt_preview(c1, c2): """Preview fuer 3-Punkt-Rechteck. c2=None waehrend Sammlung der zweiten Ecke (zeige Linie c1→Maus), sonst zeige rotiertes Rechteck.""" import System.Drawing as SD color = SD.Color.FromArgb(255, 95, 200, 180) p1 = rg.Point3d(c1.X, c1.Y, 0) def handler(sender, e): try: cur = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0) if c2 is None: e.Display.DrawLine(p1, cur, color, 2) return p2 = rg.Point3d(c2.X, c2.Y, 0) ex = p2.X - p1.X; ey = p2.Y - p1.Y edge_len = (ex * ex + ey * ey) ** 0.5 if edge_len < 1e-9: e.Display.DrawLine(p1, p2, color, 2); return px = -ey / edge_len; py = ex / edge_len d = (cur.X - p2.X) * px + (cur.Y - p2.Y) * py p3 = rg.Point3d(p2.X + d * px, p2.Y + d * py, 0) p4 = rg.Point3d(p1.X + d * px, p1.Y + d * py, 0) for a, b in ((p1, p2), (p2, p3), (p3, p4), (p4, p1)): e.Display.DrawLine(a, b, color, 2) except Exception: pass return handler def _make_circle_preview(center): """Preview: Kreis vom Mittelpunkt zum Mauspunkt.""" import System.Drawing as SD color = SD.Color.FromArgb(255, 95, 200, 180) cen = rg.Point3d(center.X, center.Y, 0) def handler(sender, e): try: cur = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0) r = cen.DistanceTo(cur) if r <= 1e-9: return try: e.Display.DrawCircle(rg.Circle(rg.Plane.WorldXY, cen, r), color, 2) except Exception: # Fallback: NurbsCurve zeichnen e.Display.DrawCurve(rg.Circle(rg.Plane.WorldXY, cen, r).ToNurbsCurve(), color, 2) except Exception: pass return handler def _make_oeffnung_preview(axis_curve, wall_dicke, breite, hoehe, brueest, base_z, typ): """Preview fuer Fenster/Tuer-Platzierung. Zeigt: - Voller 3D-Quader des Oeffnungs-Cutouts (mit Wand-Dicke) - Glas-Diagonalen auf Vorder- und Hinterflaeche - Brueestungs-Markierung (gepunktet) bei Fenster mit Brueest > 0 - Achs-Marker (Strich auf der Wand-Achse) - Mass-Label oberhalb des Sturzes (B x H, ggf. Brueest) Aktualisiert sich live wenn User Optionen aendert.""" import System.Drawing as SD color_main = SD.Color.FromArgb(255, 95, 200, 180) # Accent gruen, voll color_soft = SD.Color.FromArgb(160, 95, 200, 180) # halbtransparent color_dotted = SD.Color.FromArgb(210, 95, 200, 180) # Brueest-/Achs-Marker hd = wall_dicke * 0.5 def handler(sender, e): try: cur = e.CurrentPoint ok, t = axis_curve.ClosestPoint(cur) if not ok: return on_axis = axis_curve.PointAt(t) tan = axis_curve.TangentAt(t) tlen = (tan.X * tan.X + tan.Y * tan.Y) ** 0.5 if tlen < 1e-9: return tx = tan.X / tlen; ty = tan.Y / tlen # Normale zur Tangente in XY (90deg gegen Uhrzeigersinn) nx, ny = -ty, tx hb = breite * 0.5 z_bot = base_z + brueest z_top = z_bot + hoehe cx, cy = on_axis.X, on_axis.Y # 8 Ecken des Quaders. side_t = ±1 (links/rechts entlang Tangente), # side_n = ±1 (vorne/hinten entlang Normale) def pt(side_t, side_n, z): return rg.Point3d( cx + side_t * hb * tx + side_n * hd * nx, cy + side_t * hb * ty + side_n * hd * ny, z) lbf = pt(-1, +1, z_bot); rbf = pt(+1, +1, z_bot) rtf = pt(+1, +1, z_top); ltf = pt(-1, +1, z_top) lbb = pt(-1, -1, z_bot); rbb = pt(+1, -1, z_bot) rtb = pt(+1, -1, z_top); ltb = pt(-1, -1, z_top) # 12 Quader-Kanten for a, b in ( (lbf, rbf), (rbf, rtf), (rtf, ltf), (ltf, lbf), # vorne (lbb, rbb), (rbb, rtb), (rtb, ltb), (ltb, lbb), # hinten (lbf, lbb), (rbf, rbb), (rtf, rtb), (ltf, ltb), # Tiefen-Kanten ): e.Display.DrawLine(a, b, color_main, 2) # Glas-Diagonalen (vorne + hinten) e.Display.DrawLine(lbf, rtf, color_soft, 1) e.Display.DrawLine(ltf, rbf, color_soft, 1) e.Display.DrawLine(lbb, rtb, color_soft, 1) e.Display.DrawLine(ltb, rbb, color_soft, 1) # Achs-Marker (durchgestrichen wo das Loch sitzt) ax_l = rg.Point3d(cx - hb * tx, cy - hb * ty, on_axis.Z) ax_r = rg.Point3d(cx + hb * tx, cy + hb * ty, on_axis.Z) try: e.Display.DrawDottedLine(ax_l, ax_r, color_dotted) except Exception: e.Display.DrawLine(ax_l, ax_r, color_dotted, 1) # Brueestungs-Linie (gepunktet, nur Fenster mit Brueest > 0) if typ == "fenster" and brueest > 1e-4: # quer ueber die Vorderflaeche bL = rg.Point3d(cx - hb * tx + hd * nx, cy - hb * ty + hd * ny, z_bot) bR = rg.Point3d(cx + hb * tx + hd * nx, cy + hb * ty + hd * ny, z_bot) try: e.Display.DrawDottedLine(bL, bR, color_dotted) except Exception: pass # Mass-Label ueberm Sturz try: if typ == "fenster": label = "{:.2f} x {:.2f} Br {:.2f}".format(breite, hoehe, brueest) else: label = "{:.2f} x {:.2f}".format(breite, hoehe) anchor = rg.Point3d(cx, cy, z_top + 0.12) # Plane: X = Tangente (horizontal an Wand), Y = vertikal text_plane = rg.Plane(anchor, rg.Vector3d(tx, ty, 0), rg.Vector3d(0, 0, 1)) t3d = Rhino.Display.Text3d(label, text_plane, 0.10) try: t3d.HorizontalAlignment = Rhino.DocObjects.TextHorizontalAlignment.Center except Exception: pass e.Display.Draw3dText(t3d, color_main) except Exception: pass except Exception: pass return handler def _collect_rectangle(doc, c1): """Achsen-aligned Rechteck aus 2 diagonalen Ecken. Liefert geschlossene PolylineCurve in XY-Ebene auf Z=0.""" try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception: return None gp = ric.GetPoint() gp.SetCommandPrompt("Gegenueberliegende Ecke") try: gp.SetBasePoint(c1, True) except Exception: pass try: gp.DynamicDraw += _make_rectangle_preview(c1) except Exception: pass res = gp.Get() if res != GetResult.Point: return None c2 = gp.Point() pts = [ rg.Point3d(c1.X, c1.Y, 0), rg.Point3d(c2.X, c1.Y, 0), rg.Point3d(c2.X, c2.Y, 0), rg.Point3d(c1.X, c2.Y, 0), rg.Point3d(c1.X, c1.Y, 0), ] return rg.PolylineCurve(rg.Polyline(pts)) def _collect_rectangle_3pt(doc, c1): """3-Punkt-Rechteck: c1 = erste Ecke, c2 = Ende der ersten Kante (definiert Richtung), c3 = Punkt auf der gegenueberliegenden Seite (definiert Hoehe). Erzeugt rotiertes Rechteck.""" try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception: return None gp = ric.GetPoint() gp.SetCommandPrompt("Ende der ersten Kante") try: gp.SetBasePoint(c1, True) except Exception: pass try: gp.DynamicDraw += _make_rect3pt_preview(c1, None) except Exception: pass res = gp.Get() if res != GetResult.Point: return None c2 = gp.Point() gp = ric.GetPoint() gp.SetCommandPrompt("Hoehe (Punkt auf gegenueberliegender Seite)") try: gp.SetBasePoint(c2, True) except Exception: pass try: gp.DynamicDraw += _make_rect3pt_preview(c1, c2) except Exception: pass res = gp.Get() if res != GetResult.Point: return None c3 = gp.Point() ex = c2.X - c1.X ey = c2.Y - c1.Y edge_len = (ex * ex + ey * ey) ** 0.5 if edge_len < 1e-9: return None # Perpendikular in XY (links der edge-Richtung) px = -ey / edge_len py = ex / edge_len # Signierte Distanz von c3 zur Edge (c1-c2) d = (c3.X - c2.X) * px + (c3.Y - c2.Y) * py p1 = rg.Point3d(c1.X, c1.Y, 0) p2 = rg.Point3d(c2.X, c2.Y, 0) p3 = rg.Point3d(c2.X + d * px, c2.Y + d * py, 0) p4 = rg.Point3d(c1.X + d * px, c1.Y + d * py, 0) return rg.PolylineCurve(rg.Polyline([p1, p2, p3, p4, p1])) def _collect_circle(doc, center): """Kreis aus Mittelpunkt + Radiuspunkt. Liefert NurbsCurve.""" try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception: return None gp = ric.GetPoint() gp.SetCommandPrompt("Radiuspunkt") try: gp.SetBasePoint(center, True) except Exception: pass try: gp.DynamicDraw += _make_circle_preview(center) except Exception: pass res = gp.Get() if res != GetResult.Point: return None rp = gp.Point() cen = rg.Point3d(center.X, center.Y, 0) rad_pt = rg.Point3d(rp.X, rp.Y, 0) radius = cen.DistanceTo(rad_pt) if radius <= 1e-9: return None return rg.Circle(rg.Plane.WorldXY, cen, radius).ToNurbsCurve() def _make_decke_preview_handler(committed_points): """Live-Preview waehrend Decken-Outline gezeichnet wird: gesetzte Segmente + Rubberband + gestrichelte Schliessungs-Linie zurueck zum Startpunkt.""" import System.Drawing as SD color_line = SD.Color.FromArgb(255, 95, 200, 180) color_close = SD.Color.FromArgb(180, 150, 230, 205) color_node = SD.Color.FromArgb(255, 255, 255, 255) def handler(sender, e): try: cur = e.CurrentPoint cur_xy = rg.Point3d(cur.X, cur.Y, 0) pts = list(committed_points) + [cur_xy] for i in range(len(pts) - 1): e.Display.DrawLine(pts[i], pts[i + 1], color_line, 2) # Schliessungs-Hinweis: gestrichelte Linie zurueck zum Startpunkt if len(committed_points) >= 2: try: e.Display.DrawDottedLine(cur_xy, committed_points[0], color_close) except Exception: e.Display.DrawLine(cur_xy, committed_points[0], color_close, 1) for pp in committed_points: try: e.Display.DrawPoint(pp, color_node) except Exception: pass except Exception: pass return handler def _draw_axis_with_offsets(display, axis_curve, dicke, referenz, color_axis, color_edge): """Zeichnet eine Wand-Achse + ihre Offset-Kanten (Aussenkanten der Wand). Wird von allen Wand-Preview-Handlern wiederverwendet.""" try: display.DrawCurve(axis_curve, color_axis, 2) except Exception: pass plane = rg.Plane.WorldXY tol = 0.001 half = float(dicke) / 2.0 if referenz == "left": offsets = [0.0, -float(dicke)] elif referenz == "right": offsets = [+float(dicke), 0.0] else: offsets = [+half, -half] for d in offsets: try: if abs(d) < 1e-9: display.DrawCurve(axis_curve, color_edge, 1) else: result = axis_curve.Offset(plane, d, tol, rg.CurveOffsetCornerStyle.Sharp) if result: for c in result: display.DrawCurve(c, color_edge, 1) except Exception: pass def _make_treppe_preview_handler(p0, breite, referenz, n_stufen, fixed_length=None, min_length=None, max_length=None): """Live-Preview fuer die gerade Treppe waehrend der Lauflinien-Wahl. Zeichnet: Lauflinie (Mitte), die zwei Aussenkanten (je nach Referenz) sowie kurze Querstriche an jeder Setzstufen-Position. Laengen-Steuerung (von hoechster zu niedrigster Prio): - `fixed_length`: Mausvektor wird genau auf diese Laenge reskaliert - `min_length` / `max_length`: Mausvektor wird in dieser Range geclampt (frei innerhalb, Stop bei den Grenzen) - sonst: Mausvektor wird unveraendert benutzt""" import System.Drawing as SD color_axis = SD.Color.FromArgb(255, 95, 200, 180) color_edge = SD.Color.FromArgb(180, 140, 215, 200) color_step = SD.Color.FromArgb(200, 180, 240, 220) p0_xy = rg.Point3d(p0.X, p0.Y, 0) N = max(2, int(n_stufen)) b = float(breite) if referenz == "links": perp_lo, perp_hi = 0.0, -b elif referenz == "rechts": perp_lo, perp_hi = 0.0, +b else: perp_lo, perp_hi = -b * 0.5, +b * 0.5 def handler(sender, e): try: mouse = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0) tan_vec = rg.Vector3d(mouse.X - p0_xy.X, mouse.Y - p0_xy.Y, 0) mouse_dist = tan_vec.Length if mouse_dist < 1e-4: return tan_vec.Unitize() # Bei Regel-Modus: Endpunkt entweder fix oder in einer Range. if fixed_length is not None and fixed_length > 1e-4: L = float(fixed_length) cur = rg.Point3d(p0_xy.X + tan_vec.X * L, p0_xy.Y + tan_vec.Y * L, 0) elif min_length is not None or max_length is not None: lo = float(min_length) if min_length is not None else 1e-4 hi = float(max_length) if max_length is not None else 1e9 L = mouse_dist if L < lo: L = lo if L > hi: L = hi cur = rg.Point3d(p0_xy.X + tan_vec.X * L, p0_xy.Y + tan_vec.Y * L, 0) else: L = mouse_dist cur = mouse perp = rg.Vector3d(-tan_vec.Y, tan_vec.X, 0) # Lauflinie (Mittel-Achse) gruen try: e.Display.DrawLine(p0_xy, cur, color_axis, 2) except Exception: pass # Aussenkanten der Treppe (zwei Parallelen je nach Referenz) def edge_at(perp_off): ax = rg.Point3d(p0_xy.X + perp.X * perp_off, p0_xy.Y + perp.Y * perp_off, 0) bx = rg.Point3d(cur.X + perp.X * perp_off, cur.Y + perp.Y * perp_off, 0) try: e.Display.DrawLine(ax, bx, color_edge, 1) except Exception: pass edge_at(perp_lo); edge_at(perp_hi) # Querstriche an jeder Setzstufen-Position (N Auftritte) A = L / max(1, N) for k in range(1, N + 1): x = k * A if x > L + 1e-6: break mid = rg.Point3d(p0_xy.X + tan_vec.X * x, p0_xy.Y + tan_vec.Y * x, 0) a = rg.Point3d(mid.X + perp.X * perp_lo, mid.Y + perp.Y * perp_lo, 0) bp = rg.Point3d(mid.X + perp.X * perp_hi, mid.Y + perp.Y * perp_hi, 0) try: e.Display.DrawLine(a, bp, color_step, 1) except Exception: pass except Exception: pass return handler def _make_treppe_wendel_preview(center, start, breite, referenz, n_stufen, total_h=None, soll=None, regel_mode="frei"): """Live-Preview fuer den 3. Klick einer Wendeltreppe. Zeichnet: Mittelpunkt-Lauflinie + alle N Keile fuer die aktuelle End-Position. Bei `regel_mode == "regel"` wird der Sweep auf einen gueltigen Bereich geclampt — die Richtung kommt aus der Maus, die Drehung wird auf den Soll-A-Wert beschraenkt. So bleiben Auftritt + 2S+A im Soll.""" import System.Drawing as SD import math color_axis = SD.Color.FromArgb(255, 95, 200, 180) color_edge = SD.Color.FromArgb(180, 140, 215, 200) color_step = SD.Color.FromArgb(200, 180, 240, 220) cx, cy = center.X, center.Y sx, sy = start.X, start.Y r_click = math.sqrt((sx - cx) ** 2 + (sy - cy) ** 2) if r_click < 0.05: r_click = 0.05 r_inner, r_outer = _wendel_radii(r_click, breite, referenz) a_start_fixed = math.atan2(sy - cy, sx - cx) N = max(2, int(n_stufen)) def handler(sender, e): try: mouse = e.CurrentPoint cross_z = ((sx - cx) * (mouse.Y - cy) - (sy - cy) * (mouse.X - cx)) sweep_sign = 1.0 if cross_z >= 0 else -1.0 a_end_raw = math.atan2(mouse.Y - cy, mouse.X - cx) delta = a_end_raw - a_start_fixed if sweep_sign > 0: while delta < 0: delta += 2.0 * math.pi else: while delta > 0: delta -= 2.0 * math.pi if abs(delta) < 0.02: return # Clamp Sweep im Regel-Modus — Auftritt-Soll wird ueber die # GANZE Trittbreite (innen + aussen) erzwungen. if regel_mode == "regel" and total_h is not None and soll is not None: try: s_lo, s_hi = _wendel_sweep_range( r_click, breite, referenz, N, total_h, soll) raw = abs(delta) if raw < s_lo: clamped = s_lo elif raw > s_hi: clamped = s_hi else: clamped = raw delta = clamped * (1.0 if delta >= 0 else -1.0) except Exception: pass da = delta / N # Lauflinie center→mouse try: e.Display.DrawLine(rg.Point3d(cx, cy, 0), rg.Point3d(mouse.X, mouse.Y, 0), color_axis, 1) except Exception: pass # Alle N Keile als Quad-Linien for k in range(N): a0 = a_start_fixed + k * da a1 = a_start_fixed + (k + 1) * da pi0 = rg.Point3d(cx + r_inner * math.cos(a0), cy + r_inner * math.sin(a0), 0) po0 = rg.Point3d(cx + r_outer * math.cos(a0), cy + r_outer * math.sin(a0), 0) pi1 = rg.Point3d(cx + r_inner * math.cos(a1), cy + r_inner * math.sin(a1), 0) po1 = rg.Point3d(cx + r_outer * math.cos(a1), cy + r_outer * math.sin(a1), 0) # Riser bei a0 (radial-Linie) try: e.Display.DrawLine(pi0, po0, color_step, 1) except Exception: pass # Inner & outer "Bogen" (linear approximiert) try: e.Display.DrawLine(pi0, pi1, color_edge, 1) except Exception: pass try: e.Display.DrawLine(po0, po1, color_edge, 1) except Exception: pass # Letzter Riser bei alpha_final a_f = a_start_fixed + delta pif = rg.Point3d(cx + r_inner * math.cos(a_f), cy + r_inner * math.sin(a_f), 0) pof = rg.Point3d(cx + r_outer * math.cos(a_f), cy + r_outer * math.sin(a_f), 0) try: e.Display.DrawLine(pif, pof, color_step, 1) except Exception: pass # Live-Label: Stufen, Sweep, Auftritt an Innen/Lauf/Aussen try: deg = abs(delta) * 180.0 / math.pi A_in = abs(da) * r_inner A_lauf = abs(da) * r_click A_out = abs(da) * r_outer lbl = "St {} | {:.0f}° | A i/l/a: {:.2f}/{:.2f}/{:.2f}".format( N, deg, A_in, A_lauf, A_out) if regel_mode == "regel": lbl += " (Regel)" e.Display.DrawDot(rg.Point3d(mouse.X, mouse.Y, 0), lbl) except Exception: pass except Exception: pass return handler def _make_treppe_l_corner_preview(p0, breite, referenz, total_n, total_h): """Preview fuer den 2. Klick einer L-Treppe (Podest-Eck). Zeigt: - Lauflinie + Aussenkanten - Step-Lines an A_opt-Abstaenden (zeigt wo jeder Tritt landet) - Live-Label mit N1 (Stufen vor Podest) und N2 (nach Podest) """ import System.Drawing as SD color_axis = SD.Color.FromArgb(255, 95, 200, 180) color_edge = SD.Color.FromArgb(180, 140, 215, 200) color_step = SD.Color.FromArgb(200, 180, 240, 220) p0_xy = rg.Point3d(p0.X, p0.Y, 0) half_b = float(breite) * 0.5 if referenz == "links": perp_lo, perp_hi = 0.0, -float(breite) elif referenz == "rechts": perp_lo, perp_hi = 0.0, +float(breite) else: perp_lo, perp_hi = -half_b, +half_b # A_opt aus Soll-Schrittmass 0.63 - 2*S, geclampt auf erlaubten Bereich S = float(total_h) / max(1, int(total_n)) A_opt = 0.63 - 2.0 * S if A_opt < 0.21: A_opt = 0.21 if A_opt > 0.35: A_opt = 0.35 def handler(sender, e): try: mouse = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0) tan_vec = rg.Vector3d(mouse.X - p0_xy.X, mouse.Y - p0_xy.Y, 0) L = tan_vec.Length if L < 1e-4: return tan_vec.Unitize() perp = rg.Vector3d(-tan_vec.Y, tan_vec.X, 0) try: e.Display.DrawLine(p0_xy, mouse, color_axis, 2) except Exception: pass def edge_at(perp_off): a = rg.Point3d(p0_xy.X + perp.X * perp_off, p0_xy.Y + perp.Y * perp_off, 0) b = rg.Point3d(mouse.X + perp.X * perp_off, mouse.Y + perp.Y * perp_off, 0) try: e.Display.DrawLine(a, b, color_edge, 1) except Exception: pass edge_at(perp_lo); edge_at(perp_hi) # N1 = Stufen die in Run 1 passen (effektive Laenge = L - half_b # weil Podest die Haelfte einnimmt). N2 = restliche Stufen. eff_L1 = max(0.0, L - half_b) N1 = max(0, int(round(eff_L1 / A_opt))) N1 = min(N1, int(total_n) - 1) N2 = max(0, int(total_n) - N1) # Step-Lines an N1 Positionen for k in range(1, N1 + 1): x = k * A_opt if x > L + 1e-6: break mid = rg.Point3d(p0_xy.X + tan_vec.X * x, p0_xy.Y + tan_vec.Y * x, 0) a = rg.Point3d(mid.X + perp.X * perp_lo, mid.Y + perp.Y * perp_lo, 0) bp = rg.Point3d(mid.X + perp.X * perp_hi, mid.Y + perp.Y * perp_hi, 0) try: e.Display.DrawLine(a, bp, color_step, 1) except Exception: pass # Live-Label am Mauspunkt try: e.Display.DrawDot(mouse, "Vor Podest: {} | Nach: {}".format(N1, N2)) except Exception: pass except Exception: pass return handler def _make_spline_preview_handler(committed_points, dicke, referenz): """Preview fuer Spline-Wand: interpolierter NURBS durch committed + Maus.""" import System.Drawing as SD color_axis = SD.Color.FromArgb(255, 95, 200, 180) color_edge = SD.Color.FromArgb(180, 140, 215, 200) color_node = SD.Color.FromArgb(255, 255, 255, 255) def handler(sender, e): try: cur_xy = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0) pts = list(committed_points) + [cur_xy] if len(pts) < 2: return axis = None if len(pts) == 2: axis = rg.LineCurve(pts[0], pts[1]) else: try: axis = rg.Curve.CreateInterpolatedCurve(pts, 3) except Exception: axis = rg.PolylineCurve(rg.Polyline(pts)) if axis is None: return _draw_axis_with_offsets(e.Display, axis, dicke, referenz, color_axis, color_edge) for pp in committed_points: try: e.Display.DrawPoint(pp, color_node) except Exception: pass except Exception: pass return handler def _make_arc_preview_handler(p0, p_mid, dicke, referenz): """Preview fuer Bogen-Wand. p_mid=None waehrend Sammlung des Mittelpunkts (nur Linie zeigen), sonst echter Bogen p0→p_mid→Maus.""" import System.Drawing as SD color_axis = SD.Color.FromArgb(255, 95, 200, 180) color_edge = SD.Color.FromArgb(180, 140, 215, 200) color_node = SD.Color.FromArgb(255, 255, 255, 255) p0_xy = rg.Point3d(p0.X, p0.Y, 0) def handler(sender, e): try: cur_xy = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0) try: e.Display.DrawPoint(p0_xy, color_node) except Exception: pass if p_mid is None: # Phase 1: Mittelpunkt — Rubberband-Linie e.Display.DrawLine(p0_xy, cur_xy, color_axis, 2) return mid_xy = rg.Point3d(p_mid.X, p_mid.Y, 0) try: e.Display.DrawPoint(mid_xy, color_node) except Exception: pass # Phase 2: Endpunkt — Bogen + Offsets try: arc = rg.Arc(p0_xy, mid_xy, cur_xy) except Exception: return if not arc.IsValid: return axis = rg.ArcCurve(arc) _draw_axis_with_offsets(e.Display, axis, dicke, referenz, color_axis, color_edge) except Exception: pass return handler def _make_rectangle_wall_preview_handler(c1, dicke, referenz): """Preview fuer Wand-Rechteck: 4 Linien-Achsen + ihre Offsets.""" import System.Drawing as SD color_axis = SD.Color.FromArgb(255, 95, 200, 180) color_edge = SD.Color.FromArgb(180, 140, 215, 200) c1_xy = rg.Point3d(c1.X, c1.Y, 0) def handler(sender, e): try: cx = e.CurrentPoint.X cy = e.CurrentPoint.Y p1 = c1_xy p2 = rg.Point3d(cx, c1_xy.Y, 0) p3 = rg.Point3d(cx, cy, 0) p4 = rg.Point3d(c1_xy.X, cy, 0) corners = [p1, p2, p3, p4, p1] for i in range(4): axis = rg.LineCurve(corners[i], corners[i + 1]) _draw_axis_with_offsets(e.Display, axis, dicke, referenz, color_axis, color_edge) except Exception: pass return handler def _make_stuetze_preview(profil, B, H, D, t, angle): """Preview-Handler fuer Stuetze: zeichnet die Profil-Kontur am Cursor.""" import System.Drawing as SD color_main = SD.Color.FromArgb(255, 95, 200, 180) color_axis = SD.Color.FromArgb(180, 140, 215, 200) def handler(sender, e): try: cur = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0) crv = _trag_profile_curve(profil, B, H, D, t, cur, angle) if crv is not None: try: e.Display.DrawCurve(crv, color_main, 2) except Exception: pass # Bei Rohr zusaetzlich inneren Kreis if profil == "rohr": wall_t = max(0.002, float(t)) inner_d = max(0.01, float(D) - 2.0 * wall_t) inner = _trag_profile_curve("rund", 0, 0, inner_d, 0, cur, 0) if inner is not None: try: e.Display.DrawCurve(inner, color_axis, 1) except Exception: pass try: e.Display.DrawPoint(cur, color_main) except Exception: pass except Exception: pass return handler def _make_traeger_preview(first_pt, profil, B, H, D, t, angle): """Preview-Handler fuer Traeger/Unterzug: Rubberband-Linie + Profil- Kontur am Anfang und Ende der Achse.""" import System.Drawing as SD color_main = SD.Color.FromArgb(255, 95, 200, 180) color_axis = SD.Color.FromArgb(180, 140, 215, 200) def handler(sender, e): try: p0 = rg.Point3d(first_pt.X, first_pt.Y, 0) p1 = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0) # Rubberband-Achse try: e.Display.DrawLine(rg.Line(p0, p1), color_main, 2) except Exception: pass # Profil an Anfangs- + Endpunkt for ctr in (p0, p1): crv = _trag_profile_curve(profil, B, H, D, t, ctr, angle) if crv is not None: try: e.Display.DrawCurve(crv, color_axis, 1) except Exception: pass try: e.Display.DrawPoint(p0, color_main) except Exception: pass except Exception: pass return handler def _make_preview_handler(committed_points, dicke, referenz): """Preview fuer Polylinie-Wand: gesetzte Punkte + Rubberband + Wand-Kanten.""" import System.Drawing as SD color_axis = SD.Color.FromArgb(255, 95, 200, 180) color_edge = SD.Color.FromArgb(180, 140, 215, 200) color_node = SD.Color.FromArgb(255, 255, 255, 255) def handler(sender, e): try: cur_xy = rg.Point3d(e.CurrentPoint.X, e.CurrentPoint.Y, 0) pts = list(committed_points) + [cur_xy] if len(pts) < 2: return axis = (rg.LineCurve(pts[0], pts[1]) if len(pts) == 2 else rg.PolylineCurve(rg.Polyline(pts))) _draw_axis_with_offsets(e.Display, axis, dicke, referenz, color_axis, color_edge) for pp in committed_points: try: e.Display.DrawPoint(pp, color_node) except Exception: pass except Exception: pass return handler def _make_axis_geometry(p0, p1): """2D-Wandlinie: Linie auf der UK-Hoehe. Aber damit der User die Achse frei editieren kann, behalten wir die Linie in der XY-Ebene auf Z=0 — UK/OK kommen aus dem Geschoss-Lookup, nicht aus der Linie. """ return rg.LineCurve(rg.Point3d(p0.X, p0.Y, 0), rg.Point3d(p1.X, p1.Y, 0)) def _make_volume_from_line(p0_xy, p1_xy, dicke, uk, ok): """Fallback: gerade Wand aus zwei XY-Punkten.""" p0 = rg.Point3d(p0_xy.X, p0_xy.Y, uk) p1 = rg.Point3d(p1_xy.X, p1_xy.Y, uk) direction = rg.Vector3d(p1 - p0) if direction.Length < 1e-9: return None direction.Unitize() normal = rg.Vector3d(-direction.Y, direction.X, 0) half = dicke / 2.0 a = p0 + normal * half b = p0 - normal * half c = p1 - normal * half d = p1 + normal * half poly = rg.Polyline([a, b, c, d, a]) profile = rg.PolylineCurve(poly) height = ok - uk if height <= 0: return None extrusion = rg.Extrusion.Create(profile, height, True) if extrusion is None: return None return extrusion.ToBrep() def _offset_curve(curve, plane, distance, tol): """Curve.Offset-Wrapper der distance=0 als reine Kopie behandelt.""" if abs(distance) < 1e-9: return [curve.DuplicateCurve()] try: result = curve.Offset(plane, distance, tol, rg.CurveOffsetCornerStyle.Sharp) if result is None or len(result) == 0: return None return list(result) except Exception: return None def _wall_out_dirs(axis_curve): """Liefert (out_start, out_end) — XY-Einheitsvektoren die AUSSERHALB der Wand zeigen am Start- bzw. Endpunkt der Achse. Bei Fehler: (None, None).""" try: t_s = axis_curve.TangentAtStart t_e = axis_curve.TangentAtEnd except Exception: return None, None out_s = rg.Vector3d(-t_s.X, -t_s.Y, 0) out_e = rg.Vector3d(t_e.X, t_e.Y, 0) try: out_s.Unitize() except Exception: pass try: out_e.Unitize() except Exception: pass return out_s, out_e def _pt_key(p, decimals=4): """Hash-Key fuer Punkt-Matching mit ~0.1mm Genauigkeit.""" return (round(p.X, decimals), round(p.Y, decimals)) # --- Performance: Joint-Cache + Timing ------------------------------------- _JOINTS_CACHE_KEY = "_dossier_joints_cache" _TIMING_KEY = "_dossier_timing_enabled" def _invalidate_joints_cache(geschoss_id=None): """Invalidiert den Wand-Joint-Cache. None = alle Geschosse leeren.""" cache = sc.sticky.get(_JOINTS_CACHE_KEY) if not isinstance(cache, dict): return if geschoss_id is None: cache.clear() else: cache.pop(geschoss_id, None) class _TimedBlock(object): """Context-Manager fuer Performance-Messung. Aktivierbar via sc.sticky['_dossier_timing_enabled'] = True — sonst no-op.""" def __init__(self, label): self.label = label self.t0 = None def __enter__(self): if sc.sticky.get(_TIMING_KEY): import time self.t0 = time.perf_counter() return self def __exit__(self, *args): if self.t0 is not None: import time ms = (time.perf_counter() - self.t0) * 1000 print("[TIMING] {}: {:.1f}ms".format(self.label, ms)) def _collect_wall_joints(doc, geschoss_id): """Sammelt alle Wand-Endpunkte im Geschoss. Liefert: {point_key: [(wall_id, "start"|"end", out_dir_vector), ...]}. GECACHT pro geschoss_id in sc.sticky — Cache wird von den Listenern invalidiert wenn wand_axis-Objekte hinzukommen/weichen/sich aendern. Spart bei mehreren Walls im Doc viel Zeit (O(n) statt O(n²)).""" cache = sc.sticky.get(_JOINTS_CACHE_KEY) if not isinstance(cache, dict): cache = {} sc.sticky[_JOINTS_CACHE_KEY] = cache cached = cache.get(geschoss_id) if cached is not None: return cached joints = {} for obj in doc.Objects: meta = _read_meta(obj) if not meta or meta["type"] != "wand_axis": continue if meta["geschoss"] != geschoss_id: continue geom = obj.Geometry if not isinstance(geom, rg.Curve): continue p_s = geom.PointAtStart p_e = geom.PointAtEnd out_s, out_e = _wall_out_dirs(geom) if out_s is None or out_e is None: continue joints.setdefault(_pt_key(p_s), []).append( (meta["id"], "start", out_s)) joints.setdefault(_pt_key(p_e), []).append( (meta["id"], "end", out_e)) cache[geschoss_id] = joints return joints def _miter_dir(out_a, out_b): """Miter-Linien-Richtung (Vector3d in XY) am Joint zwischen zwei Waenden. out_a/out_b sind die Einheitsvektoren die AUSSERHALB der Wand zeigen am gemeinsamen Punkt. Die Miter-Linie verlaeuft entlang der Winkel- halbierenden dieser beiden Vektoren — also direkt unit(out_a + out_b). None bei colinearen Walls (180°, gerade Fortsetzung — kein Miter noetig).""" bx = out_a.X + out_b.X by = out_a.Y + out_b.Y length = (bx*bx + by*by) ** 0.5 if length < 1e-6: return None return rg.Vector3d(bx/length, by/length, 0) def _detect_t_junction(doc, geschoss_id, wall_id, endpoint, pos_tol=0.01, end_tol=0.05): """Sucht ob `endpoint` auf der INNEREN Achse einer anderen Wand liegt (T-Stoss). Endpunkte der anderen Wand (Eckverbindung) werden bewusst ausgeschlossen — die werden bereits durch die Corner-Logik abgedeckt. Liefert (other_wall_id, b_tangent_vec3, b_dicke) oder None.""" for obj in doc.Objects: meta = _read_meta(obj) if not meta or meta["type"] != "wand_axis": continue if meta["geschoss"] != geschoss_id: continue if meta["id"] == wall_id: continue geom = obj.Geometry if not isinstance(geom, rg.Curve): continue try: ok, t = geom.ClosestPoint(endpoint) if not ok: continue cp = geom.PointAt(t) dx = cp.X - endpoint.X; dy = cp.Y - endpoint.Y if (dx*dx + dy*dy) ** 0.5 > pos_tol: continue # Nicht in der Naehe der Endpunkte (sonst Corner statt T) ps = geom.PointAtStart; pe = geom.PointAtEnd d_s = ((ps.X-endpoint.X)**2 + (ps.Y-endpoint.Y)**2) ** 0.5 d_e = ((pe.X-endpoint.X)**2 + (pe.Y-endpoint.Y)**2) ** 0.5 if d_s < end_tol or d_e < end_tol: continue tan = geom.TangentAt(t) return (meta["id"], rg.Vector3d(tan.X, tan.Y, 0), float(meta["dicke"])) except Exception: continue return None def _t_junction_miter(endpoint, out_dir, b_tan, b_dicke): """Berechnet (miter_pt, miter_dir) fuer einen T-Stoss. miter_dir = Tangente der Durchgangs-Wand (Linie laeuft parallel zu B's Achse). miter_pt = endpoint verschoben um d_B/2 in Approach-Richtung — also auf der NAHEN Aussenflaeche von B (der Seite an der A ankommt).""" perp_b = rg.Vector3d(-b_tan.Y, b_tan.X, 0) try: perp_b.Unitize() except Exception: return None # A's Body liegt auf der Seite -out_dir. Approach-Seite (perp_b # ausgerichtet zur Approach) = sign(dot(-out_dir, perp_b)). s = -(out_dir.X * perp_b.X + out_dir.Y * perp_b.Y) if abs(s) < 1e-6: # A parallel zu B — kein sauberer T-Stoss return None side = 1.0 if s > 0 else -1.0 off = float(b_dicke) * 0.5 * side mpt = rg.Point3d(endpoint.X + perp_b.X * off, endpoint.Y + perp_b.Y * off, 0) mdir = rg.Vector3d(b_tan.X, b_tan.Y, 0) try: mdir.Unitize() except Exception: pass return (mpt, mdir) def _find_dependent_walls(doc, geschoss_id, moving_wall_id, old_curve, new_curve, pos_tol=0.01): """Findet alle Waende deren Geometrie sich aendert wenn moving_wall sich aendert: jede Wand deren ENDPUNKT auf der alten oder neuen Achse von moving_wall liegt. Deckt sowohl Corner (Endpunkt-Endpunkt-Match) als auch T-Stoss (Endpunkt auf Achse) ab.""" deps = set() for obj in doc.Objects: m = _read_meta(obj) if not m or m["type"] != "wand_axis": continue if m["geschoss"] != geschoss_id: continue if m["id"] == moving_wall_id: continue geom = obj.Geometry if not isinstance(geom, rg.Curve): continue hit = False for ep in (geom.PointAtStart, geom.PointAtEnd): for ag in (old_curve, new_curve): if not isinstance(ag, rg.Curve): continue try: ok, t = ag.ClosestPoint(ep) if not ok: continue cp = ag.PointAt(t) if ((cp.X-ep.X)**2 + (cp.Y-ep.Y)**2) ** 0.5 < pos_tol: hit = True; break except Exception: continue if hit: break if hit: deps.add(m["id"]) return deps def _line_line_xy(p1, d1, p2, d2): """Schnittpunkt zweier Geraden in XY. None bei parallel.""" cross = d1.X * d2.Y - d1.Y * d2.X if abs(cross) < 1e-9: return None dx = p2.X - p1.X dy = p2.Y - p1.Y t = (dx * d2.Y - dy * d2.X) / cross return rg.Point3d(p1.X + t * d1.X, p1.Y + t * d1.Y, 0) def _set_curve_endpoint(crv, which, new_pt): """Ersetzt Start- oder Endpunkt einer Curve. Funktioniert fuer LineCurve + PolylineCurve. Bei anderen Typen None (Miter wird dann uebersprungen → Fallback perpendicular cap).""" if isinstance(crv, rg.LineCurve): if which == "start": return rg.LineCurve(new_pt, crv.PointAtEnd) return rg.LineCurve(crv.PointAtStart, new_pt) if isinstance(crv, rg.PolylineCurve): pts = [crv.Point(i) for i in range(crv.PointCount)] if which == "start": pts[0] = new_pt else: pts[-1] = new_pt return rg.PolylineCurve(rg.Polyline(pts)) return None def _apply_miter(curve, which, miter_pt, miter_dir, max_extend): """Trimmt/erweitert eine Offset-Curve so dass ihr 'which'-Endpunkt auf der Miter-Linie liegt. max_extend = Sicherheitsgrenze (Miter-Limit) — bei sehr spitzen Winkeln wird sonst der Schnittpunkt nach unendlich fliegen. Falls limit ueberschritten oder Schnitt nicht moeglich, Originalkurve zurueckgeben.""" if curve is None: return curve try: if which == "start": tan = curve.TangentAtStart base = curve.PointAtStart else: tan = curve.TangentAtEnd base = curve.PointAtEnd tan = rg.Vector3d(tan.X, tan.Y, 0) ipt = _line_line_xy(base, tan, miter_pt, miter_dir) if ipt is None: return curve # Miter-Limit: verschiebung darf max_extend nicht uebersteigen dx = ipt.X - base.X; dy = ipt.Y - base.Y if (dx*dx + dy*dy) ** 0.5 > max_extend: return curve modified = _set_curve_endpoint(curve, which, ipt) return modified if modified is not None else curve except Exception: return curve def _make_wall_layer_brep(axis_curve, d_left, d_right, uk, ok, miter_start=None, miter_end=None, max_miter_extend=None): """Baut einen einzelnen Schicht-Brep zwischen den Offsets d_left und d_right von der Achse. d_left > d_right; positive Werte zeigen auf die +perp Seite. Wird sowohl fuer solide Waende als auch einzelne Schichten eines mehrlagigen Aufbaus verwendet.""" if not isinstance(axis_curve, rg.Curve): return None thickness = float(d_left) - float(d_right) if abs(thickness) < 1e-9: return None height = float(ok) - float(uk) if height <= 0: return None plane = rg.Plane.WorldXY tol = 0.001 left = _offset_curve(axis_curve, plane, float(d_left), tol) right = _offset_curve(axis_curve, plane, float(d_right), tol) if not left or not right: return None L = left[0]; R = right[0] if max_miter_extend is None: max_miter_extend = abs(thickness) * 5.0 if miter_start is not None: m_pt, m_dir = miter_start L = _apply_miter(L, "start", m_pt, m_dir, max_miter_extend) R = _apply_miter(R, "start", m_pt, m_dir, max_miter_extend) if miter_end is not None: m_pt, m_dir = miter_end L = _apply_miter(L, "end", m_pt, m_dir, max_miter_extend) R = _apply_miter(R, "end", m_pt, m_dir, max_miter_extend) try: R.Reverse() cap_start = rg.LineCurve(L.PointAtEnd, R.PointAtStart) cap_end = rg.LineCurve(R.PointAtEnd, L.PointAtStart) joined = rg.Curve.JoinCurves([L, cap_start, R, cap_end], tol) except Exception: joined = None if not joined or len(joined) == 0 or not joined[0].IsClosed: return None profile = joined[0].DuplicateCurve() if abs(uk) > 1e-9: profile.Transform(rg.Transform.Translation(0, 0, uk)) extrusion = rg.Extrusion.Create(profile, height, True) if extrusion is None: return None return extrusion.ToBrep() def _wall_offsets_from_referenz(dicke, referenz): """Liefert (start_offset, d_total) — start_offset ist der Wert von 'links' relativ zur Achse, d_total ist die Summe der Wand-Dicke (immer positiv).""" dicke = float(dicke) half = dicke / 2.0 if referenz == "left": return (0.0, dicke) # Achse auf linker Aussenkante if referenz == "right": return (+dicke, dicke) # Achse auf rechter Aussenkante return (+half, dicke) # mid def _make_volume_geometry(axis_curve, dicke, uk, ok, referenz="mid", miter_start=None, miter_end=None): """Solide Wand — duenner Wrapper um _make_wall_layer_brep mit Offsets abgeleitet aus referenz.""" start_off, d_total = _wall_offsets_from_referenz(dicke, referenz) d_left = start_off d_right = start_off - d_total brep = _make_wall_layer_brep(axis_curve, d_left, d_right, uk, ok, miter_start=miter_start, miter_end=miter_end) if brep is None: return _make_volume_from_line(axis_curve.PointAtStart, axis_curve.PointAtEnd, dicke, uk, ok) return brep # Material-Bibliothek fuer Wand-Schichten (entwurfs-/fruephasen-orientiert). # Jedes Material hat: # - color: Hex-Farbe (Surface + Layer-Color) # - hatch: Hatch-Pattern-Name (Section-Hatch in Rhino's Layer-Properties) # - scale: Hatch-Skalierung # Beim Regen wird pro Material eine Sub-Ebene unter 20_WAENDE erzeugt # (z.B. `EG::20_WAENDE::Beton`) und die Section-Hatch der Sub-Ebene # konfiguriert — sobald der User eine Clipping Plane setzt, zeigt Rhino # automatisch die korrekte Schnitt-Symbolik pro Schicht. _MATERIAL_LIBRARY = { "Beton": {"color": "#9a9a9a", "hatch": "Hatch3", "scale": 1.0}, "Stahlbeton": {"color": "#888888", "hatch": "Hatch3", "scale": 0.5}, "Mauerwerk": {"color": "#b67860", "hatch": "Hatch1", "scale": 1.0}, "Dämmung": {"color": "#f4e4a0", "hatch": "Hatch2", "scale": 0.5}, "Holz": {"color": "#c89a5a", "hatch": "HatchDash", "scale": 1.0}, "Stahl": {"color": "#7a7a7a", "hatch": "Solid", "scale": 1.0}, "Putz": {"color": "#ede4d6", "hatch": "Solid", "scale": 1.0}, "Glas": {"color": "#bcd4e0", "hatch": "Solid", "scale": 1.0}, } def _set_layer_section_hatch(doc, layer_idx, hatch_name, scale=1.0, rotation=0.0): """Konfiguriert Rhinos native Section-Hatch-Properties am Layer. Sobald eine Clipping Plane Objekte auf diesem Layer schneidet, wird die Schnittflaeche on-the-fly mit dem konfigurierten Hatch gefuellt. Defensiv geschrieben — falls die API-Properties in einer Rhino-Version fehlen, geht die Funktion still durch.""" if layer_idx < 0 or layer_idx >= doc.Layers.Count: return False try: layer = doc.Layers[layer_idx] hp_idx = doc.HatchPatterns.Find(hatch_name or "Solid", True) if hp_idx < 0: hp_idx = doc.HatchPatterns.Find("Solid", True) if hp_idx < 0: return False changed = False try: layer.SectionHatchIndex = hp_idx; changed = True except Exception: pass try: layer.SectionHatchScale = float(scale) except Exception: pass try: layer.SectionHatchRotation = float(rotation) except Exception: pass if changed: try: doc.Layers.Modify(layer, layer_idx, True) except Exception: pass return changed except Exception as ex: print("[ELEMENTE] _set_layer_section_hatch:", ex) return False def _ensure_material_sublayer(doc, geschoss_name, material_name): """Stellt sicher dass `::20_WAENDE::` existiert, mit Material-Farbe + Section-Hatch konfiguriert. Liefert Layer-Index. Bei leerem oder unbekanntem Material: Fallback auf das normale Wand-Volume-Layer (= Standard fuer Solid-Waende).""" if not material_name or material_name not in _MATERIAL_LIBRARY: return _ensure_layer(doc, _layer_path_volume(doc, geschoss_name)) mat = _MATERIAL_LIBRARY[material_name] parent_path = _layer_path_volume(doc, geschoss_name) full_path = "{}::{}".format(parent_path, material_name) idx = _ensure_layer(doc, full_path) if idx < 0: return idx try: import System.Drawing as SD layer = doc.Layers[idx] hex_str = mat["color"].lstrip("#") r = int(hex_str[0:2], 16); g = int(hex_str[2:4], 16); b = int(hex_str[4:6], 16) new_col = SD.Color.FromArgb(255, r, g, b) # Nur aendern wenn die Farbe abweicht (vermeidet unnoetige Doc-Dirty) try: if int(layer.Color.ToArgb()) != int(new_col.ToArgb()): layer.Color = new_col doc.Layers.Modify(layer, idx, True) except Exception: pass _set_layer_section_hatch(doc, idx, mat["hatch"], mat.get("scale", 1.0)) except Exception as ex: print("[ELEMENTE] _ensure_material_sublayer:", ex) return idx def _ensure_material(doc, hex_color): """Findet oder erstellt ein Material mit der gegebenen Hex-Diffuse-Farbe. Cached pro hex → Index in sc.sticky, dedupliziert geteilte Farben. Liefert Material-Index oder -1.""" if not hex_color or not isinstance(hex_color, str): return -1 s = hex_color.strip() if not s.startswith("#") or len(s) < 7: return -1 key = s.lower() cache = sc.sticky.get("_dossier_material_cache") if not isinstance(cache, dict): cache = {} sc.sticky["_dossier_material_cache"] = cache # Validiere gecachte Eintraege cached = cache.get(key) if cached is not None: try: if 0 <= cached < doc.Materials.Count: m = doc.Materials[cached] if m is not None and not m.IsDeleted: return cached except Exception: pass # stale → neu anlegen del cache[key] try: import System.Drawing as SD h = key.lstrip("#") r = int(h[0:2], 16); g = int(h[2:4], 16); b = int(h[4:6], 16) mat = Rhino.DocObjects.Material() mat.Name = "Dossier_Schicht_" + h mat.DiffuseColor = SD.Color.FromArgb(255, r, g, b) idx = doc.Materials.Add(mat) if idx >= 0: cache[key] = idx return idx except Exception as ex: print("[ELEMENTE] _ensure_material:", ex) return -1 def _make_wand_layer_breps(axis_curve, layers, dicke, referenz, uk, ok, miter_start=None, miter_end=None): """Baut eine Liste (brep, color_hex, name) pro Schicht. Schicht-Reihen- folge: von der +perp-Seite zur -perp-Seite (links→rechts entlang der Wand-Achse). layers = Liste von dicts mit Keys 'dicke', 'color', 'name'.""" out = [] if not layers: return out start_off, d_total = _wall_offsets_from_referenz(dicke, referenz) cur = start_off max_ext = float(d_total) * 5.0 for layer in layers: try: d = float(layer.get("dicke", 0)) except Exception: d = 0.0 if d <= 0: continue d_left = cur d_right = cur - d brep = _make_wall_layer_brep(axis_curve, d_left, d_right, uk, ok, miter_start=miter_start, miter_end=miter_end, max_miter_extend=max_ext) out.append((brep, layer.get("color", ""), layer.get("name", ""))) cur = d_right return out def _attach_meta(obj_attrs, wall_id, type_, geschoss, dicke, uk_over, ok_over, referenz="mid", neigung=None, eave_idx=None, dach_typ=None, neigung_unten=None, knick_h=None, dach_variante=None, oeff_typ=None, oeff_parent=None, oeff_breite=None, oeff_hoehe=None, oeff_brueest=None, oeff_rahmen_b=None, oeff_rahmen_tiefe=None, oeff_rahmen_pos=None, oeff_fluegel=None, oeff_sims_aus=None, oeff_sims_in=None, oeff_glas=None, oeff_referenz=None, geschoss_end=None, treppe_breite=None, treppe_n=None, treppe_referenz=None, treppe_modus=None, treppe_lauf_d=None, treppe_art=None, treppe_h_over=None, treppe_soll=None, trag_kind=None, trag_profil=None, trag_b=None, trag_h=None, trag_d=None, trag_t=None, trag_angle=None, trag_z_over=None, raum_name=None, raum_nummer=None, raum_funktion=None, raum_rundung=None, raum_txt_h=None, raum_align=None, raum_sia=None, raum_fuellung=None, wand_layered=None, wand_layers=None, wand_layer_idx=None, aussp_parent=None): """User-Strings auf die Object-Attributes setzen.""" obj_attrs.SetUserString(_KEY_ID, wall_id) obj_attrs.SetUserString(_KEY_TYPE, type_) obj_attrs.SetUserString(_KEY_GESCHOSS, geschoss or "") obj_attrs.SetUserString(_KEY_DICKE, "{:.6f}".format(float(dicke))) obj_attrs.SetUserString(_KEY_UK_OVER, "" if uk_over in (None, "") else "{:.6f}".format(float(uk_over))) obj_attrs.SetUserString(_KEY_OK_OVER, "" if ok_over in (None, "") else "{:.6f}".format(float(ok_over))) obj_attrs.SetUserString(_KEY_REFERENZ, referenz if referenz in ("mid", "left", "right") else "mid") if neigung is not None: obj_attrs.SetUserString(_KEY_DACH_NEIGUNG, "{:.4f}".format(float(neigung))) if eave_idx is not None: obj_attrs.SetUserString(_KEY_DACH_EAVE, "{}".format(int(eave_idx))) if dach_typ is not None and dach_typ in ("pult", "sattel", "walm", "mansarde"): obj_attrs.SetUserString(_KEY_DACH_TYP, dach_typ) if neigung_unten is not None: obj_attrs.SetUserString(_KEY_DACH_NEIG_UNTEN, "{:.4f}".format(float(neigung_unten))) if knick_h is not None: obj_attrs.SetUserString(_KEY_DACH_KNICK_H, "{:.6f}".format(float(knick_h))) if dach_variante is not None and dach_variante in ("walm", "giebel", "walm_giebel"): obj_attrs.SetUserString(_KEY_DACH_VARIANTE, dach_variante) if oeff_typ is not None and oeff_typ in ("fenster", "tuer"): obj_attrs.SetUserString(_KEY_OEFF_TYP, oeff_typ) if oeff_parent is not None: obj_attrs.SetUserString(_KEY_OEFF_PARENT, str(oeff_parent)) if oeff_breite is not None: obj_attrs.SetUserString(_KEY_OEFF_BREITE, "{:.4f}".format(float(oeff_breite))) if oeff_hoehe is not None: obj_attrs.SetUserString(_KEY_OEFF_HOEHE, "{:.4f}".format(float(oeff_hoehe))) if oeff_brueest is not None: obj_attrs.SetUserString(_KEY_OEFF_BRUEST, "{:.4f}".format(float(oeff_brueest))) if oeff_rahmen_b is not None: obj_attrs.SetUserString(_KEY_OEFF_RAHMEN_B, "{:.4f}".format(float(oeff_rahmen_b))) if oeff_rahmen_tiefe is not None: obj_attrs.SetUserString(_KEY_OEFF_RAHMEN_TIEFE, "{:.4f}".format(float(oeff_rahmen_tiefe))) if oeff_rahmen_pos is not None and oeff_rahmen_pos in _OEFF_RAHMEN_POS_OPTIONS: obj_attrs.SetUserString(_KEY_OEFF_RAHMEN_POS, oeff_rahmen_pos) if oeff_fluegel is not None: obj_attrs.SetUserString(_KEY_OEFF_FLUEGEL, "{}".format(int(oeff_fluegel))) if oeff_sims_aus is not None: # akzeptiere Bool (legacy) oder Style-String if isinstance(oeff_sims_aus, bool): v = "standard" if oeff_sims_aus else "ohne" else: v = str(oeff_sims_aus) if v not in _OEFF_SIMS_STYLES: v = "ohne" obj_attrs.SetUserString(_KEY_OEFF_SIMS_AUS, v) if oeff_sims_in is not None: if isinstance(oeff_sims_in, bool): v = "standard" if oeff_sims_in else "ohne" else: v = str(oeff_sims_in) if v not in _OEFF_SIMS_STYLES: v = "ohne" obj_attrs.SetUserString(_KEY_OEFF_SIMS_IN, v) if oeff_glas is not None: obj_attrs.SetUserString(_KEY_OEFF_GLAS, "1" if bool(oeff_glas) else "0") if oeff_referenz is not None and oeff_referenz in _OEFF_REFERENZ_OPTIONS: obj_attrs.SetUserString(_KEY_OEFF_REFERENZ, oeff_referenz) # --- Treppen-Felder --- if geschoss_end is not None: obj_attrs.SetUserString(_KEY_GESCHOSS_END, geschoss_end or "") if treppe_breite is not None: obj_attrs.SetUserString(_KEY_TREPPE_BREITE, "{:.4f}".format(float(treppe_breite))) if treppe_n is not None: obj_attrs.SetUserString(_KEY_TREPPE_N, "{}".format(int(treppe_n))) if treppe_referenz is not None and treppe_referenz in ("mid", "links", "rechts"): obj_attrs.SetUserString(_KEY_TREPPE_REFERENZ, treppe_referenz) if treppe_modus is not None and treppe_modus in _TREPPE_MODI: obj_attrs.SetUserString(_KEY_TREPPE_MODUS, treppe_modus) if treppe_lauf_d is not None: obj_attrs.SetUserString(_KEY_TREPPE_LAUF_D, "{:.4f}".format(float(treppe_lauf_d))) if treppe_art is not None and treppe_art in _TREPPE_ARTEN: obj_attrs.SetUserString(_KEY_TREPPE_ART, treppe_art) if treppe_h_over is not None: if treppe_h_over == "" or treppe_h_over is None: obj_attrs.SetUserString(_KEY_TREPPE_H_OVER, "") else: try: obj_attrs.SetUserString(_KEY_TREPPE_H_OVER, "{:.4f}".format(float(treppe_h_over))) except Exception: pass if treppe_soll is not None: try: import json obj_attrs.SetUserString(_KEY_TREPPE_SOLL, json.dumps(treppe_soll)) except Exception: pass # Tragwerk-Felder if trag_kind is not None and trag_kind in _TRAG_KINDS: obj_attrs.SetUserString(_KEY_TRAG_KIND, trag_kind) if trag_profil is not None and trag_profil in _TRAG_PROFILE: obj_attrs.SetUserString(_KEY_TRAG_PROFIL, trag_profil) if trag_b is not None: obj_attrs.SetUserString(_KEY_TRAG_B, "{:.4f}".format(float(trag_b))) if trag_h is not None: obj_attrs.SetUserString(_KEY_TRAG_H, "{:.4f}".format(float(trag_h))) if trag_d is not None: obj_attrs.SetUserString(_KEY_TRAG_D, "{:.4f}".format(float(trag_d))) if trag_t is not None: obj_attrs.SetUserString(_KEY_TRAG_T, "{:.4f}".format(float(trag_t))) if trag_angle is not None: obj_attrs.SetUserString(_KEY_TRAG_ANGLE, "{:.4f}".format(float(trag_angle))) if trag_z_over is not None: if trag_z_over == "" or trag_z_over is None: obj_attrs.SetUserString(_KEY_TRAG_Z_OVER, "") else: try: obj_attrs.SetUserString(_KEY_TRAG_Z_OVER, "{:.4f}".format(float(trag_z_over))) except Exception: pass # Raum-Felder if raum_name is not None: obj_attrs.SetUserString(_KEY_RAUM_NAME, str(raum_name)) if raum_nummer is not None: obj_attrs.SetUserString(_KEY_RAUM_NUMMER, str(raum_nummer)) if raum_funktion is not None: obj_attrs.SetUserString(_KEY_RAUM_FUNKTION, str(raum_funktion)) if raum_rundung is not None and raum_rundung in _RAUM_RUNDUNGEN: obj_attrs.SetUserString(_KEY_RAUM_RUNDUNG, raum_rundung) if raum_txt_h is not None: try: obj_attrs.SetUserString(_KEY_RAUM_TXT_H, "{:.4f}".format(float(raum_txt_h))) except Exception: pass if raum_align is not None and raum_align in _RAUM_ALIGN: obj_attrs.SetUserString(_KEY_RAUM_ALIGN, raum_align) if raum_sia is not None and raum_sia in _RAUM_SIA_KINDS: obj_attrs.SetUserString(_KEY_RAUM_SIA, raum_sia) if raum_fuellung is not None: # Akzeptiere Bool (legacy) oder Pattern-Name. Bool True -> "Solid". if isinstance(raum_fuellung, bool): v = "Solid" if raum_fuellung else "" else: v = str(raum_fuellung) obj_attrs.SetUserString(_KEY_RAUM_FUELL, v) # Wand-Schichten if wand_layered is not None: obj_attrs.SetUserString(_KEY_WAND_LAYERED, "1" if bool(wand_layered) else "") if wand_layers is not None: try: import json as _json if isinstance(wand_layers, str): # akzeptiere bereits-JSON-String obj_attrs.SetUserString(_KEY_WAND_LAYERS, wand_layers) else: obj_attrs.SetUserString(_KEY_WAND_LAYERS, _json.dumps(wand_layers, ensure_ascii=False)) except Exception: pass if wand_layer_idx is not None: try: obj_attrs.SetUserString(_KEY_WAND_LAYER_IDX, "{}".format(int(wand_layer_idx))) except Exception: pass # Decken-Aussparung if aussp_parent is not None: obj_attrs.SetUserString(_KEY_AUSSP_PARENT, str(aussp_parent)) def _read_meta(obj): """Liest Element-Metadaten von einem Rhino-Objekt. Liefert dict oder None.""" try: a = obj.Attributes type_ = a.GetUserString(_KEY_TYPE) if not type_: return None ref = a.GetUserString(_KEY_REFERENZ) or "mid" if ref not in ("mid", "left", "right"): ref = "mid" try: neigung = float(a.GetUserString(_KEY_DACH_NEIGUNG) or "30") except Exception: neigung = 30.0 try: eave = int(a.GetUserString(_KEY_DACH_EAVE) or "0") except Exception: eave = 0 dt = a.GetUserString(_KEY_DACH_TYP) or "pult" if dt not in ("pult", "sattel", "walm", "mansarde"): dt = "pult" try: nu = float(a.GetUserString(_KEY_DACH_NEIG_UNTEN) or "60") except Exception: nu = 60.0 try: kh = float(a.GetUserString(_KEY_DACH_KNICK_H) or "2.0") except Exception: kh = 2.0 dv = a.GetUserString(_KEY_DACH_VARIANTE) or "walm" if dv not in ("walm", "giebel", "walm_giebel"): dv = "walm" ot = a.GetUserString(_KEY_OEFF_TYP) or "" if ot not in ("fenster", "tuer"): ot = "" try: ob = float(a.GetUserString(_KEY_OEFF_BREITE) or "1.0") except Exception: ob = 1.0 try: oh = float(a.GetUserString(_KEY_OEFF_HOEHE) or "1.4") except Exception: oh = 1.4 try: obr = float(a.GetUserString(_KEY_OEFF_BRUEST) or "0.9") except Exception: obr = 0.9 try: or_b = float(a.GetUserString(_KEY_OEFF_RAHMEN_B) or "0.06") except Exception: or_b = 0.06 try: or_t = float(a.GetUserString(_KEY_OEFF_RAHMEN_TIEFE) or "0.08") except Exception: or_t = 0.08 or_p = a.GetUserString(_KEY_OEFF_RAHMEN_POS) or "mid" if or_p not in _OEFF_RAHMEN_POS_OPTIONS: or_p = "mid" try: ofl = int(a.GetUserString(_KEY_OEFF_FLUEGEL) or "1") except Exception: ofl = 1 if ofl < 1: ofl = 1 # Sims-Stile + Glas-Default is_fenster = (ot == "fenster") def _sims_style(raw, default_fenster): if raw in _OEFF_SIMS_STYLES: return raw if raw == "1": return "standard" # Legacy bool true if raw == "0": return "ohne" # Legacy bool false return ("standard" if is_fenster else "ohne") if default_fenster else "ohne" sa_raw = a.GetUserString(_KEY_OEFF_SIMS_AUS) or "" si_raw = a.GetUserString(_KEY_OEFF_SIMS_IN) or "" osa = _sims_style(sa_raw, True) osi = _sims_style(si_raw, True) og_raw = a.GetUserString(_KEY_OEFF_GLAS) ogl = (og_raw == "1") if og_raw in ("0", "1") else is_fenster oref = a.GetUserString(_KEY_OEFF_REFERENZ) or "mid" if oref not in _OEFF_REFERENZ_OPTIONS: oref = "mid" # Treppen-Felder gend = a.GetUserString(_KEY_GESCHOSS_END) or "" try: tb = float(a.GetUserString(_KEY_TREPPE_BREITE) or "1.0") except Exception: tb = 1.0 try: tn = int(a.GetUserString(_KEY_TREPPE_N) or "15") except Exception: tn = 15 if tn < 2: tn = 2 tref = a.GetUserString(_KEY_TREPPE_REFERENZ) or "mid" if tref not in ("mid", "links", "rechts"): tref = "mid" tmod = a.GetUserString(_KEY_TREPPE_MODUS) or "flach" if tmod not in _TREPPE_MODI: tmod = "flach" try: tld = float(a.GetUserString(_KEY_TREPPE_LAUF_D) or "0.18") except Exception: tld = 0.18 tart = a.GetUserString(_KEY_TREPPE_ART) or "gerade" if tart not in _TREPPE_ARTEN: tart = "gerade" thov = a.GetUserString(_KEY_TREPPE_H_OVER) or "" # Soll-Werte JSON, mit Defaults wenn nicht gesetzt import json tsoll = dict(_TREPPE_SOLL_DEFAULT) soll_raw = a.GetUserString(_KEY_TREPPE_SOLL) if soll_raw: try: parsed = json.loads(soll_raw) if isinstance(parsed, dict): for k in ("s", "a", "sa"): if k in parsed and isinstance(parsed[k], list) and len(parsed[k]) >= 3: tsoll[k] = [float(parsed[k][0]), float(parsed[k][1]), bool(parsed[k][2])] except Exception: pass # Tragwerk-Felder tk_raw = a.GetUserString(_KEY_TRAG_KIND) or "" if tk_raw not in _TRAG_KINDS: tk_raw = "" tp_raw = a.GetUserString(_KEY_TRAG_PROFIL) or "quadrat" if tp_raw not in _TRAG_PROFILE: tp_raw = "quadrat" try: t_b = float(a.GetUserString(_KEY_TRAG_B) or "0.25") except Exception: t_b = 0.25 try: t_h = float(a.GetUserString(_KEY_TRAG_H) or "0.25") except Exception: t_h = 0.25 try: t_d = float(a.GetUserString(_KEY_TRAG_D) or "0.25") except Exception: t_d = 0.25 try: t_t = float(a.GetUserString(_KEY_TRAG_T) or "0.01") except Exception: t_t = 0.01 try: t_ang = float(a.GetUserString(_KEY_TRAG_ANGLE) or "0") except Exception: t_ang = 0.0 t_zov = a.GetUserString(_KEY_TRAG_Z_OVER) or "" # Raum-Felder r_name = a.GetUserString(_KEY_RAUM_NAME) or "" r_num = a.GetUserString(_KEY_RAUM_NUMMER) or "" r_fkt = a.GetUserString(_KEY_RAUM_FUNKTION) or "" r_rnd = a.GetUserString(_KEY_RAUM_RUNDUNG) or "0.1" if r_rnd not in _RAUM_RUNDUNGEN: r_rnd = "0.1" try: r_th = float(a.GetUserString(_KEY_RAUM_TXT_H) or "0.20") except Exception: r_th = 0.20 r_align = a.GetUserString(_KEY_RAUM_ALIGN) or "mid" if r_align not in _RAUM_ALIGN: r_align = "mid" r_sia = a.GetUserString(_KEY_RAUM_SIA) or "" if r_sia not in _RAUM_SIA_KINDS: r_sia = "" # Default: Fuellung AUS ("" = kein Hatch). User kann Pattern-Namen # waehlen (z.B. "Solid", "Hatch1", ...) oder "ByLayer". Legacy- # Migration: alter Bool-Wert "1" wird zu "Solid", "0" zu "". r_fuell_raw = a.GetUserString(_KEY_RAUM_FUELL) if r_fuell_raw == "1": r_fuell = "Solid" elif r_fuell_raw == "0": r_fuell = "" else: r_fuell = r_fuell_raw or "" # Wand-Schichten w_layered = (a.GetUserString(_KEY_WAND_LAYERED) == "1") w_layers_raw = a.GetUserString(_KEY_WAND_LAYERS) or "" w_layers = [] if w_layers_raw: try: import json as _json parsed = _json.loads(w_layers_raw) if isinstance(parsed, list): for ly in parsed: if not isinstance(ly, dict): continue try: d = float(ly.get("dicke", 0)) except Exception: d = 0.0 if d <= 0: continue w_layers.append({ "name": str(ly.get("name", "")), "dicke": d, "color": str(ly.get("color", "")), "material": str(ly.get("material", "")), }) except Exception: pass try: w_layer_idx = int(a.GetUserString(_KEY_WAND_LAYER_IDX) or "-1") except Exception: w_layer_idx = -1 aussp_parent_raw = a.GetUserString(_KEY_AUSSP_PARENT) or "" return { "id": a.GetUserString(_KEY_ID) or "", "type": type_, "geschoss": a.GetUserString(_KEY_GESCHOSS) or "", "dicke": float(a.GetUserString(_KEY_DICKE) or "0.25"), "uk_override": a.GetUserString(_KEY_UK_OVER) or "", "ok_override": a.GetUserString(_KEY_OK_OVER) or "", "referenz": ref, "neigung": neigung, "eave_idx": eave, "dach_typ": dt, "neigung_unten": nu, "knick_h": kh, "dach_variante": dv, "oeff_typ": ot, "oeff_parent": a.GetUserString(_KEY_OEFF_PARENT) or "", "oeff_breite": ob, "oeff_hoehe": oh, "oeff_brueest": obr, "oeff_rahmen_b": or_b, "oeff_rahmen_tiefe": or_t, "oeff_rahmen_pos": or_p, "oeff_fluegel": ofl, "oeff_sims_aus": osa, "oeff_sims_in": osi, "oeff_glas": ogl, "oeff_referenz": oref, "geschoss_end": gend, "treppe_breite": tb, "treppe_n": tn, "treppe_referenz": tref, "treppe_modus": tmod, "treppe_lauf_d": tld, "treppe_art": tart, "treppe_h_over": thov, "treppe_soll": tsoll, "trag_kind": tk_raw, "trag_profil": tp_raw, "trag_b": t_b, "trag_h": t_h, "trag_d": t_d, "trag_t": t_t, "trag_angle": t_ang, "trag_z_over": t_zov, "raum_name": r_name, "raum_nummer": r_num, "raum_funktion": r_fkt, "raum_rundung": r_rnd, "raum_txt_h": r_th, "raum_align": r_align, "raum_sia": r_sia, "raum_fuellung": r_fuell, "wand_layered": w_layered, "wand_layers": w_layers, "wand_layer_idx": w_layer_idx, "aussp_parent": aussp_parent_raw, } except Exception: return None def _find_objects_by_wall_id(doc, wall_id, type_filter=None): """Findet alle Rhino-Objekte mit der gegebenen wall_id.""" out = [] for obj in doc.Objects: meta = _read_meta(obj) if meta and meta["id"] == wall_id: if type_filter is None or meta["type"] == type_filter: out.append((obj, meta)) return out def _find_axis(doc, wall_id): for obj, meta in _find_objects_by_wall_id(doc, wall_id, "wand_axis"): return obj return None def _find_volume(doc, wall_id): for obj, meta in _find_objects_by_wall_id(doc, wall_id, "wand_volume"): return obj return None def _find_openings_for_wall(doc, wall_id): """Alle Oeffnungs-Points (oeffnung_point) deren oeff_parent == wall_id.""" out = [] for obj in doc.Objects: meta = _read_meta(obj) if meta is None: continue if meta["type"] != "oeffnung_point": continue if meta.get("oeff_parent") != wall_id: continue out.append((obj, meta)) return out def _oeff_effective_axis_point(axis_curve, point_on_axis, breite, referenz): """Berechnet den effektiven Zentrums-Punkt der Oeffnung auf der Wand- Achse — abhaengig davon ob der Klick-Punkt am linken/rechten Rand oder in der Mitte der Oeffnung liegen soll. referenz="mid" → Punkt liegt mittig: Zentrum = Klick-Punkt referenz="links" → Klick-Punkt am linken Rand: Zentrum = pt + tan*half referenz="rechts" → Klick-Punkt am rechten Rand: Zentrum = pt - tan*half "links"/"rechts" beziehen sich auf die +tan/-tan Richtung der Wand- Achse am Klick-Punkt. Mathematisch geht der Walk entlang der echten Bogenlaenge auf der Kurve — funktioniert auch fuer gebogene Achsen.""" if referenz not in ("links", "rechts"): return point_on_axis if not isinstance(axis_curve, rg.Curve): return point_on_axis half = float(breite) * 0.5 try: ok, t = axis_curve.ClosestPoint(point_on_axis) if not ok: return point_on_axis # Aktuelle Bogenlaenge vom Kurvenanfang bis t sub = rg.Interval(axis_curve.Domain.Min, t) try: arc_cur = axis_curve.GetLength(sub) except Exception: dom = axis_curve.Domain arc_cur = ((t - dom.Min) / dom.Length) * axis_curve.GetLength() if referenz == "links": arc_new = arc_cur + half # Zentrum +tan/2 vom Klick else: # rechts arc_new = arc_cur - half # Zentrum -tan/2 total = axis_curve.GetLength() if arc_new < 0: arc_new = 0 if arc_new > total: arc_new = total lp = axis_curve.LengthParameter(arc_new) t_new = None if isinstance(lp, tuple) and len(lp) >= 2 and lp[0]: t_new = lp[1] if t_new is None: # Fallback: lineare Parameter-Interpolation dom = axis_curve.Domain t_new = dom.Min + (arc_new / total) * dom.Length return axis_curve.PointAt(t_new) except Exception: return point_on_axis def _make_oeffnung_cutout(axis_curve, point_on_axis, wall_dicke, breite, hoehe, brueest_h, base_z): """Baut eine Cutout-Box die bei einer Wand-Regen via Boolean-Difference abgezogen wird. Box ist zentriert am Point, entlang der Wand-Tangente ausgerichtet, in Z von base_z+brueest bis base_z+brueest+hoehe, und in Wand-Querrichtung leicht ueberdimensioniert (1.5x dicke) damit der Schnitt sauber durch die Wand geht.""" if not isinstance(axis_curve, rg.Curve): return None try: ok, t = axis_curve.ClosestPoint(point_on_axis) if not ok: return None pt = axis_curve.PointAt(t) tan = axis_curve.TangentAt(t) tan = rg.Vector3d(tan.X, tan.Y, 0) if tan.Length < 1e-9: return None tan.Unitize() perp = rg.Vector3d(-tan.Y, tan.X, 0) half_b = float(breite) * 0.5 half_d = float(wall_dicke) * 1.5 # ueberdimensioniert quer zur Wand z_low = float(base_z) + float(brueest_h) z_high = z_low + float(hoehe) if z_high <= z_low + 1e-9: return None c0 = rg.Point3d(pt.X - tan.X * half_b - perp.X * half_d, pt.Y - tan.Y * half_b - perp.Y * half_d, z_low) c1 = rg.Point3d(pt.X + tan.X * half_b - perp.X * half_d, pt.Y + tan.Y * half_b - perp.Y * half_d, z_low) c2 = rg.Point3d(pt.X + tan.X * half_b + perp.X * half_d, pt.Y + tan.Y * half_b + perp.Y * half_d, z_low) c3 = rg.Point3d(pt.X - tan.X * half_b + perp.X * half_d, pt.Y - tan.Y * half_b + perp.Y * half_d, z_low) poly = rg.Polyline([c0, c1, c2, c3, c0]) base_curve = rg.PolylineCurve(poly) extrusion = rg.Extrusion.Create(base_curve, z_high - z_low, True) if extrusion is None: return None return extrusion.ToBrep() except Exception as ex: print("[ELEMENTE] _make_oeffnung_cutout:", ex) return None def _oeff_axis_frame(axis_curve, point_on_axis): """Liefert (pt, tan, perp) — pt projiziert auf Achse, tan = Wandrichtung (XY-Projektion), perp = 90° CCW von tan (in XY). Return None bei Fehler.""" if not isinstance(axis_curve, rg.Curve): return None try: ok, t = axis_curve.ClosestPoint(point_on_axis) if not ok: return None pt = axis_curve.PointAt(t) tan = axis_curve.TangentAt(t) tan = rg.Vector3d(tan.X, tan.Y, 0) if tan.Length < 1e-9: return None tan.Unitize() perp = rg.Vector3d(-tan.Y, tan.X, 0) return pt, tan, perp except Exception: return None def _make_oeff_box(pt, tan, tan_lo, tan_hi, z_lo, z_hi, perp_lo, perp_hi): """Baut eine achsen-orientierte Box-Brep im lokalen tan/Z/perp-System relativ zur Wand-Achse am Punkt `pt`. Liefert Brep oder None.""" try: plane = rg.Plane(rg.Point3d(pt.X, pt.Y, 0), tan, rg.Vector3d(0, 0, 1)) if tan_hi <= tan_lo + 1e-9 or z_hi <= z_lo + 1e-9 or perp_hi <= perp_lo + 1e-9: return None box = rg.Box(plane, rg.Interval(tan_lo, tan_hi), rg.Interval(z_lo, z_hi), rg.Interval(perp_lo, perp_hi)) return box.ToBrep() except Exception as ex: print("[ELEMENTE] _make_oeff_box:", ex) return None def _resolve_rahmen_perp_range(half_d, rahmen_tiefe, rahmen_pos): """Berechnet (perp_lo, perp_hi) entlang Plane.ZAxis fuer den Rahmen je nach Position-Praeset. half_d = halbe Wandtiefe, rahmen_tiefe = Profil-Tiefe entlang Wandnormale. Konvention: Plane.ZAxis fuer unsere Box-Konstruktion ist tan x (0,0,1) — d.h. eine Seite der Wand. "aussen" mappt empirisch auf +Plane.ZAxis-Seite (kann je nach Wand-Achsrichtung andersrum sein — User kann Wand-Achse mit Rhino-Befehl Dir umdrehen).""" rt = max(0.01, float(rahmen_tiefe)) # Wenn Tiefe groesser als Wand → klammern (Inset 1mm damit kein Z-Fight) rt = min(rt, 2.0 * half_d - 0.002) if rt <= 0: rt = max(0.01, 2.0 * half_d - 0.002) inset = 0.001 if rahmen_pos == "aussen": hi = +half_d - inset lo = hi - rt elif rahmen_pos == "innen": lo = -half_d + inset hi = lo + rt else: # mid lo = -rt * 0.5 hi = +rt * 0.5 return lo, hi def _make_oeffnung_pieces(axis_curve, point_on_axis, wall_dicke, oeff_meta, base_z): """Baut die einzelnen Brep-Pieces der Oeffnung — Rahmen (single Brep via Boolean-Differenz), Mittelpfosten (pro Fluegel), Glas, Sims aussen, Sims innen. Liefert eine Liste von Breps. Caller persistiert jedes als eigenes 'oeffnung_volume' Object mit der gleichen Oeffnungs-ID.""" frame = _oeff_axis_frame(axis_curve, point_on_axis) if frame is None: return [] pt, tan, perp = frame breite = float(oeff_meta.get("oeff_breite", 1.0)) hoehe = float(oeff_meta.get("oeff_hoehe", 1.4)) brueest = float(oeff_meta.get("oeff_brueest", 0.9)) rahmen_b = float(oeff_meta.get("oeff_rahmen_b", 0.06)) rahmen_t = float(oeff_meta.get("oeff_rahmen_tiefe", 0.08)) rahmen_pos = oeff_meta.get("oeff_rahmen_pos", "mid") fluegel = max(1, int(oeff_meta.get("oeff_fluegel", 1))) sims_aus_style = oeff_meta.get("oeff_sims_aus", "ohne") sims_in_style = oeff_meta.get("oeff_sims_in", "ohne") has_glas = bool(oeff_meta.get("oeff_glas", False)) is_tuer = (oeff_meta.get("oeff_typ") == "tuer") half_b = breite * 0.5 half_d = float(wall_dicke) * 0.5 z_lo = float(base_z) + brueest z_hi = z_lo + hoehe inner_l = -half_b + rahmen_b inner_r = +half_b - rahmen_b # Bei Tueren: KEIN unterer Riegel (Zarge ist 3-seitig). Das Innen- # Loch geht bis unter den Outer-Box-Boden, sodass der Boolean-Diff # den unteren Riegel wegschneidet. inner_z_lo_frame = (z_lo - 0.01) if is_tuer else (z_lo + rahmen_b) inner_z_hi_frame = z_hi - rahmen_b # Fuer Mittelpfosten/Glas: bei Tueren beginnen die bei z_lo (Boden), # bei Fenstern oberhalb des unteren Rahmens. payload_z_lo = z_lo if is_tuer else (z_lo + rahmen_b) payload_z_hi = z_hi - rahmen_b if inner_l >= inner_r - 1e-6 or payload_z_lo >= payload_z_hi - 1e-6: return [] # Rahmen-Profil zu dick fuer Oeffnung frame_perp_lo, frame_perp_hi = _resolve_rahmen_perp_range( half_d, rahmen_t, rahmen_pos) pieces = [] # --- RAHMEN: outer box - inner box, sauberer single-Brep try: outer_box = _make_oeff_box(pt, tan, -half_b, +half_b, z_lo, z_hi, frame_perp_lo, frame_perp_hi) # Inner box leicht laenger in perp Richtung damit der Diff sauber # durchschneidet (keine Hauchschicht uebrig). inner_box = _make_oeff_box(pt, tan, inner_l, inner_r, inner_z_lo_frame, inner_z_hi_frame, frame_perp_lo - 0.01, frame_perp_hi + 0.01) if outer_box is not None and inner_box is not None: diff = rg.Brep.CreateBooleanDifference( [outer_box], [inner_box], 0.001) if diff and len(diff) > 0: pieces.append(diff[0]) else: pieces.append(outer_box) except Exception as ex: print("[ELEMENTE] Rahmen BoolDiff:", ex) # --- Mittelpfosten (Fluegel > 1): kleine Stege im inneren Bereich if fluegel > 1: span = inner_r - inner_l for i in range(1, fluegel): x_mid = inner_l + span * (float(i) / fluegel) x_lo = x_mid - rahmen_b * 0.5 x_hi = x_mid + rahmen_b * 0.5 mp = _make_oeff_box(pt, tan, x_lo, x_hi, payload_z_lo, payload_z_hi, frame_perp_lo, frame_perp_hi) if mp is not None: pieces.append(mp) # --- INNERE FUELLUNG: Glas (Fenster oder verglaste Tuer) ODER # Tuerblatt (massive Tuere ohne Glas). Beides als Box-Brep pro Fluegel. if is_tuer and not has_glas: # Tuerblatt — 40 mm massive Platte, mittig in Rahmen-Tiefe fill_t = 0.04 elif has_glas: fill_t = 0.012 # 12 mm Glas else: fill_t = 0 # nichts (z.B. Fenster ohne Glas) if fill_t > 0: fill_mid = (frame_perp_lo + frame_perp_hi) * 0.5 fill_lo = fill_mid - fill_t * 0.5 fill_hi = fill_mid + fill_t * 0.5 if fluegel > 1: span = inner_r - inner_l for i in range(fluegel): fx_lo = inner_l + span * (float(i) / fluegel) fx_hi = inner_l + span * (float(i + 1) / fluegel) if i > 0: fx_lo += rahmen_b * 0.5 if i < fluegel - 1: fx_hi -= rahmen_b * 0.5 fp = _make_oeff_box(pt, tan, fx_lo, fx_hi, payload_z_lo, payload_z_hi, fill_lo, fill_hi) if fp is not None: pieces.append(fp) else: fp = _make_oeff_box(pt, tan, inner_l, inner_r, payload_z_lo, payload_z_hi, fill_lo, fill_hi) if fp is not None: pieces.append(fp) # --- SIMS AUSSEN (+Plane.ZAxis-Seite) — Platte unter der Oeffnung sa = _OEFF_SIMS_STYLES.get(sims_aus_style) if sa is not None: s_t = sa["dicke"]; s_pr = sa["aus"]; s_oh = sa["ueberhang"] s_lo = z_lo - s_t sb = _make_oeff_box(pt, tan, -half_b - s_oh, +half_b + s_oh, s_lo, z_lo, +half_d, +half_d + s_pr) if sb is not None: pieces.append(sb) # --- SIMS INNEN (-Plane.ZAxis-Seite) — Platte unter der Oeffnung si = _OEFF_SIMS_STYLES.get(sims_in_style) if si is not None: s_t = si["dicke"]; s_pr = si["aus"]; s_oh = si["ueberhang"] s_lo = z_lo - s_t sb = _make_oeff_box(pt, tan, -half_b - s_oh, +half_b + s_oh, s_lo, z_lo, -half_d - s_pr, -half_d) if sb is not None: pieces.append(sb) return pieces SOURCE_TYPES = ("wand_axis", "decke_outline", "dach_outline", "oeffnung_point", "treppe_axis", "stuetze_point", "traeger_axis", "raum_outline", "decke_aussparung_outline") VOLUME_TYPES = ("wand_volume", "decke_volume", "dach_volume", "oeffnung_volume", "treppe_volume", "stuetze_volume", "traeger_volume", "raum_stamp", "raum_fill") # Oeffnungs-Cutout: Boolean-Difference aus Wand. Zusaetzlich kriegt die # Oeffnung ihr eigenes Volumen (Rahmen + Sims + Glas) als Sub-Element. def _find_source(doc, element_id): """Source-Objekt — Achse (Wand) bzw. Outline (Decke/Dach).""" for obj, meta in _find_objects_by_wall_id(doc, element_id): if meta["type"] in SOURCE_TYPES: return obj, meta return None, None def _find_target_volume(doc, element_id): """Volumen-Objekt (Brep).""" for obj, meta in _find_objects_by_wall_id(doc, element_id): if meta["type"] in VOLUME_TYPES: return obj return None # --- Dach-Helpers (Pultdach) ------------------------------------------------- def _resolve_dach_base(doc, gid, uk_over): """Basis-Hoehe des Dachs an der Traufe (= Eave) = OKFF + Hoehe des Geschosses (Oberkante der Waende). uk_override kann das ueberschreiben.""" g = _geschoss_by_id(doc, gid) if g is None: return float(uk_over) if uk_over not in (None, "") else 3.0 okff = float(g.get("okff", 0.0)) hoehe = float(g.get("hoehe", 3.0)) auto = okff + hoehe return float(uk_over) if uk_over not in (None, "") else auto def _outline_points_xy(outline_curve): """Extrahiert XY-Vertices einer geschlossenen Polyline (ohne Schluss- Duplikat). Liefert Liste von Point3d mit Z=0.""" if not isinstance(outline_curve, rg.Curve): return [] ok, poly = outline_curve.TryGetPolyline() if not ok or poly is None: return [] pts = [poly[i] for i in range(poly.Count)] if len(pts) > 1 and pts[0].DistanceTo(pts[-1]) < 1e-6: pts = pts[:-1] return [rg.Point3d(p.X, p.Y, 0) for p in pts] def _thicken_roof_inward(top_brep, dicke, tol=0.001): """Verdickt eine offene Brep-Schale (oberes Dachshell) entlang der Flaechen-Normalen um `dicke` nach innen — d.h. senkrecht zur jeweiligen Dachflaeche, nicht nur vertikal. Liefert geschlossenen Festkoerper-Brep. Die Normalen-Richtung der gejointen Schale haengt von der Zeichen- richtung der Outline ab (CW vs CCW von oben). Statt blind ein Vorzeichen zu raten probiert die Funktion beide Richtungen und waehlt das Resultat das tatsaechlich nach UNTEN extrudiert (d.h. die Unterseite des Daches unter der originalen Aussenflaeche).""" if top_brep is None: return None d = float(dicke) if d <= 1e-9: return top_brep orig_bbox = top_brep.GetBoundingBox(True) orig_min_z = orig_bbox.Min.Z def _try(distance, extend): try: result = rg.Brep.CreateOffsetBrep(top_brep, distance, True, extend, tol) if isinstance(result, tuple): arr = result[0] elif hasattr(result, '__len__'): arr = result else: arr = None if arr and len(arr) > 0: return arr[0] except Exception as ex: print("[ELEMENTE] CreateOffsetBrep ({}, extend={}):".format(distance, extend), ex) return None # Probiere beide Vorzeichen (+/-d), beide extend-Varianten. candidates = [] for distance in (-d, d): r = _try(distance, False) if r is None: r = _try(distance, True) if r is not None: candidates.append(r) if not candidates: return None # Bevorzuge das Resultat das nach UNTEN extrudiert — die Unterseite des # Daches muss unter dem original Top-Brep-Min-Z liegen. Sonst geht die # Verdickung nach aussen (nach oben), was wir nicht wollen. threshold = max(1e-4, d * 0.05) for c in candidates: bb = c.GetBoundingBox(True) if bb.Min.Z < orig_min_z - threshold: return c return candidates[0] def _join_open_shell(faces, tol=0.001): """Joined eine Liste planarer Brep-Faces zu einer offenen Brep-Schale.""" valid = [f for f in faces if f is not None] if not valid: return None try: joined = rg.Brep.JoinBreps(valid, tol) if joined and len(joined) > 0: return joined[0] except Exception as ex: print("[ELEMENTE] _join_open_shell:", ex) return None def _make_pultdach_volume(outline_curve, dicke, base_height, slope_deg, eave_idx): """Pultdach: obere Dachflaeche liegt geneigt auf der Eckpunkt-Hoehe je Punkt (Eave bei base_height, opposite Seite ansteigend). Die Dicke wird senkrecht zur Dachflaeche nach innen extrudiert (via CreateOffsetBrep). Liefert geschlossenen Festkoerper-Brep oder None.""" import math pts_xy = _outline_points_xy(outline_curve) n = len(pts_xy) if n < 3: return None if eave_idx < 0 or eave_idx >= n: eave_idx = 0 a = pts_xy[eave_idx] b = pts_xy[(eave_idx + 1) % n] eave_vec = rg.Vector3d(b.X - a.X, b.Y - a.Y, 0) if eave_vec.Length < 1e-9: return None eave_vec.Unitize() perp = rg.Vector3d(-eave_vec.Y, eave_vec.X, 0) sample_idx = (eave_idx + 2) % n sv = rg.Vector3d(pts_xy[sample_idx].X - a.X, pts_xy[sample_idx].Y - a.Y, 0) d_sample = sv.X * perp.X + sv.Y * perp.Y if d_sample < 0: perp = -perp tan_s = math.tan(math.radians(float(slope_deg))) top_pts = [] for p in pts_xy: dv = rg.Vector3d(p.X - a.X, p.Y - a.Y, 0) d = dv.X * perp.X + dv.Y * perp.Y if d < 0: d = 0.0 z_top = base_height + d * tan_s top_pts.append(rg.Point3d(p.X, p.Y, z_top)) top_pts.append(top_pts[0]) tol = 0.001 try: top_curve = rg.PolylineCurve(rg.Polyline(top_pts)) top_faces = rg.Brep.CreatePlanarBreps([top_curve], tol) if not top_faces or len(top_faces) == 0: return None top_brep = top_faces[0] return _thicken_roof_inward(top_brep, dicke, tol) except Exception as ex: print("[ELEMENTE] Pultdach Brep:", ex) return None def _make_satteldach_brep(outline_curve, dicke, base_height, slope_deg, ridge_along='long'): """Satteldach: zwei geneigte Trapeze treffen sich am First, mit senkrechter Dicke nach innen extrudiert via CreateOffsetBrep. Erfordert eine 4-Punkt-Outline (Rechteck). ridge_along='long' → First entlang der laengeren Achse, 'short' → entlang der kuerzeren.""" import math pts = _outline_points_xy(outline_curve) if len(pts) != 4: return None e01 = pts[0].DistanceTo(pts[1]) e12 = pts[1].DistanceTo(pts[2]) long_axis_is_01 = e01 >= e12 use_01_as_long = long_axis_is_01 if ridge_along == 'long' else (not long_axis_is_01) short_len = min(e01, e12) if use_01_as_long else max(e01, e12) # short_len = die Spannweite quer zur First-Achse if use_01_as_long: span = e12 # quer zur First-Achse (= zu Edges 0-1, 2-3) else: span = e01 half_span = span * 0.5 ridge_z = base_height + half_span * math.tan(math.radians(float(slope_deg))) c0 = rg.Point3d(pts[0].X, pts[0].Y, base_height) c1 = rg.Point3d(pts[1].X, pts[1].Y, base_height) c2 = rg.Point3d(pts[2].X, pts[2].Y, base_height) c3 = rg.Point3d(pts[3].X, pts[3].Y, base_height) if use_01_as_long: # First parallel zu 0-1 und 2-3 → Endpunkte mid(1-2) und mid(3-0) ridge_a = rg.Point3d((pts[3].X + pts[0].X) * 0.5, (pts[3].Y + pts[0].Y) * 0.5, ridge_z) ridge_b = rg.Point3d((pts[1].X + pts[2].X) * 0.5, (pts[1].Y + pts[2].Y) * 0.5, ridge_z) # Trapeze (outer-facing, CCW von aussen) face_a_pts = [c0, c1, ridge_b, ridge_a, c0] # Seite 0-1 face_b_pts = [c2, c3, ridge_a, ridge_b, c2] # Seite 2-3 else: ridge_a = rg.Point3d((pts[0].X + pts[1].X) * 0.5, (pts[0].Y + pts[1].Y) * 0.5, ridge_z) ridge_b = rg.Point3d((pts[2].X + pts[3].X) * 0.5, (pts[2].Y + pts[3].Y) * 0.5, ridge_z) face_a_pts = [c1, c2, ridge_b, ridge_a, c1] # Seite 1-2 face_b_pts = [c3, c0, ridge_a, ridge_b, c3] # Seite 3-0 tol = 0.001 faces = [_planar_face_from_pts(face_a_pts, tol), _planar_face_from_pts(face_b_pts, tol)] top_brep = _join_open_shell(faces, tol) return _thicken_roof_inward(top_brep, dicke, tol) def _make_walmdach_brep(outline_curve, dicke, base_height, slope_deg): """Walmdach fuer Rechteck-Outline: 4 geneigte Flaechen, die am First zusammenlaufen. Bei einem Quadrat → Zeltdach (= Pyramidendach).""" import math pts = _outline_points_xy(outline_curve) if len(pts) != 4: return None e01 = pts[0].DistanceTo(pts[1]) e12 = pts[1].DistanceTo(pts[2]) long_axis_is_01 = e01 >= e12 # First entlang langer Achse; Laenge des Firsts = laengere Seite minus # kuerzere Seite (= 2x hip-inset) long_len = max(e01, e12) short_len = min(e01, e12) half_short = short_len * 0.5 tan_s = math.tan(math.radians(float(slope_deg))) ridge_height = half_short * tan_s # Firstpunkte = mittlere Punkte der kurzen Kanten, jeweils nach innen # verschoben um half_short (= hip-inset, damit alle 4 Walmflaechen # denselben Neigungswinkel haben) if long_axis_is_01: # Lange Kanten: 0-1 und 2-3. Kurze Kanten: 1-2 und 3-0. mid_12 = rg.Point3d((pts[1].X + pts[2].X) * 0.5, (pts[1].Y + pts[2].Y) * 0.5, 0) mid_30 = rg.Point3d((pts[3].X + pts[0].X) * 0.5, (pts[3].Y + pts[0].Y) * 0.5, 0) long_dir = pts[1] - pts[0] long_dir.Z = 0 long_unit = rg.Vector3d(long_dir); long_unit.Unitize() # ridge points sind die mids nach innen entlang -long_unit / +long_unit ridge_a = rg.Point3d(mid_30.X + long_unit.X * half_short, mid_30.Y + long_unit.Y * half_short, base_height + ridge_height) ridge_b = rg.Point3d(mid_12.X - long_unit.X * half_short, mid_12.Y - long_unit.Y * half_short, base_height + ridge_height) else: mid_01 = rg.Point3d((pts[0].X + pts[1].X) * 0.5, (pts[0].Y + pts[1].Y) * 0.5, 0) mid_23 = rg.Point3d((pts[2].X + pts[3].X) * 0.5, (pts[2].Y + pts[3].Y) * 0.5, 0) long_dir = pts[2] - pts[1] long_dir.Z = 0 long_unit = rg.Vector3d(long_dir); long_unit.Unitize() ridge_a = rg.Point3d(mid_01.X + long_unit.X * half_short, mid_01.Y + long_unit.Y * half_short, base_height + ridge_height) ridge_b = rg.Point3d(mid_23.X - long_unit.X * half_short, mid_23.Y - long_unit.Y * half_short, base_height + ridge_height) # Outer (oben sichtbar) Eckpunkte auf base_height c0 = rg.Point3d(pts[0].X, pts[0].Y, base_height) c1 = rg.Point3d(pts[1].X, pts[1].Y, base_height) c2 = rg.Point3d(pts[2].X, pts[2].Y, base_height) c3 = rg.Point3d(pts[3].X, pts[3].Y, base_height) is_square = abs(long_len - short_len) < 1e-6 tol = 0.001 faces = [] def add_face(pts): f = _planar_face_from_pts(pts, tol) if f: faces.append(f) # NUR die Outer-Schraegflaechen — die Dicke wird per CreateOffsetBrep # senkrecht zur jeweiligen Flaeche nach innen extrudiert. if long_axis_is_01: if is_square: apex_o = ridge_a add_face([c0, c1, apex_o, c0]) add_face([c1, c2, apex_o, c1]) add_face([c2, c3, apex_o, c2]) add_face([c3, c0, apex_o, c3]) else: add_face([c0, c1, ridge_b, ridge_a, c0]) add_face([c2, c3, ridge_a, ridge_b, c2]) add_face([c1, c2, ridge_b, c1]) add_face([c3, c0, ridge_a, c3]) else: if is_square: apex_o = ridge_a add_face([c0, c1, apex_o, c0]) add_face([c1, c2, apex_o, c1]) add_face([c2, c3, apex_o, c2]) add_face([c3, c0, apex_o, c3]) else: add_face([c1, c2, ridge_b, ridge_a, c1]) add_face([c3, c0, ridge_a, ridge_b, c3]) add_face([c0, c1, ridge_a, c0]) add_face([c2, c3, ridge_b, c2]) top_brep = _join_open_shell(faces, tol) return _thicken_roof_inward(top_brep, dicke, tol) def _make_mansardendach_brep(outline_curve, dicke, base_height, slope_upper_deg, slope_lower_deg, knick_h, variante="walm"): """Dispatcher: 'walm' = 4-seitig mit Knick rundum (Mansarden-Walm). 'giebel' = klassisches Mansardendach mit vertikalen Giebel-Pentagonen an den Schmalseiten (DACH-Region-Standard). 'walm_giebel' = unten Walm (Knick rundum), oben Giebel (First ueber voller Laenge, vertikale Dreieck-Giebel an den Schmalseiten).""" if variante == "giebel": return _make_mansardendach_giebel(outline_curve, dicke, base_height, slope_upper_deg, slope_lower_deg, knick_h) if variante == "walm_giebel": return _make_mansardendach_walm_giebel(outline_curve, dicke, base_height, slope_upper_deg, slope_lower_deg, knick_h) return _make_mansardendach_walm(outline_curve, dicke, base_height, slope_upper_deg, slope_lower_deg, knick_h) def _make_mansardendach_giebel(outline_curve, dicke, base_height, slope_upper_deg, slope_lower_deg, knick_h): """Klassisches Mansardendach: Knick nur auf den 2 langen Seiten, an den Schmalseiten vertikale Giebelwand-Pentagone bis hoch zum First.""" import math pts = _outline_points_xy(outline_curve) if len(pts) != 4: return None e01 = pts[0].DistanceTo(pts[1]) e12 = pts[1].DistanceTo(pts[2]) # Corners so umsortieren dass corners[0]→corners[1] die erste lange Kante ist if e01 >= e12: corners = [pts[0], pts[1], pts[2], pts[3]] short_len = e12 else: corners = [pts[1], pts[2], pts[3], pts[0]] short_len = e01 half_short = short_len * 0.5 # Long-Edge-Richtung + Inward-Perpendicular (zeigt vom 1. langen Edge weg) lv = rg.Vector3d(corners[1].X - corners[0].X, corners[1].Y - corners[0].Y, 0) if lv.Length < 1e-9: return None lv.Unitize() perp = rg.Vector3d(-lv.Y, lv.X, 0) mid_first = rg.Point3d((corners[0].X + corners[1].X) * 0.5, (corners[0].Y + corners[1].Y) * 0.5, 0) mid_opp = rg.Point3d((corners[2].X + corners[3].X) * 0.5, (corners[2].Y + corners[3].Y) * 0.5, 0) if (mid_opp.X - mid_first.X) * perp.X + (mid_opp.Y - mid_first.Y) * perp.Y < 0: perp = -perp tan_lower = math.tan(math.radians(float(slope_lower_deg))) tan_upper = math.tan(math.radians(float(slope_upper_deg))) if tan_lower <= 1e-9: return _make_walmdach_brep(outline_curve, dicke, base_height, slope_upper_deg) knick_inset = float(knick_h) / tan_lower if knick_inset >= half_short - 1e-6: return _make_walmdach_brep(outline_curve, dicke, base_height, slope_lower_deg) remaining = half_short - knick_inset ridge_above_knick = remaining * tan_upper total_h = float(knick_h) + ridge_above_knick # Eave-Ecken c = [rg.Point3d(p.X, p.Y, base_height) for p in corners] # Knick-Ecken: corners[0,1] auf 1. langer Kante → Knick in +perp; # corners[2,3] auf gegenueberliegender Kante → Knick in -perp. # WICHTIG: in der GIEBEL-Variante bleiben die Knick-Ecken auf demselben # X (entlang langer Achse) wie die zugehoerige Eave-Ecke — KEIN diagonaler # Inset wie bei Walm. So liegen sie in der Vertikal-Ebene der Gable. k = [ rg.Point3d(c[0].X + perp.X * knick_inset, c[0].Y + perp.Y * knick_inset, base_height + knick_h), rg.Point3d(c[1].X + perp.X * knick_inset, c[1].Y + perp.Y * knick_inset, base_height + knick_h), rg.Point3d(c[2].X - perp.X * knick_inset, c[2].Y - perp.Y * knick_inset, base_height + knick_h), rg.Point3d(c[3].X - perp.X * knick_inset, c[3].Y - perp.Y * knick_inset, base_height + knick_h), ] # First-Endpunkte: Mittelpunkte der Gable-Kanten — diese liegen # geometrisch BEREITS auf der Centerline der Langachse. Kein zusaetzlicher # Inset noetig (der war im alten Code falsch — verschob den First auf # einen Eckpunkt und stauchte die Gable-Pentagone). ridge_w = rg.Point3d((c[3].X + c[0].X) * 0.5, (c[3].Y + c[0].Y) * 0.5, base_height + total_h) ridge_e = rg.Point3d((c[1].X + c[2].X) * 0.5, (c[1].Y + c[2].Y) * 0.5, base_height + total_h) tol = 0.001 faces = [] def add(pl): f = _planar_face_from_pts(pl, tol) if f: faces.append(f) # NUR Outer-Schale (4 Dachflaechen + 2 vertikale Giebel-Pentagone). # Dicke wird per CreateOffsetBrep senkrecht zur jeweiligen Flaeche # nach innen extrudiert. add([c[0], c[1], k[1], k[0], c[0]]) add([c[2], c[3], k[3], k[2], c[2]]) add([k[0], k[1], ridge_e, ridge_w, k[0]]) add([k[2], k[3], ridge_w, ridge_e, k[2]]) add([c[0], c[3], k[3], ridge_w, k[0], c[0]]) # West-Giebel add([c[1], c[2], k[2], ridge_e, k[1], c[1]]) # Ost-Giebel top_brep = _join_open_shell(faces, tol) return _thicken_roof_inward(top_brep, dicke, tol) def _make_mansardendach_walm(outline_curve, dicke, base_height, slope_upper_deg, slope_lower_deg, knick_h): """Mansardendach (4-seitig, Walm-Mansarde): 4 Eaves mit Knick rundum, unten steile Flaeche, oben flachere Walm-Kappe. Erfordert 4-Punkt-Outline.""" import math pts = _outline_points_xy(outline_curve) if len(pts) != 3 + 1 and len(pts) != 4: return None if len(pts) != 4: return None e01 = pts[0].DistanceTo(pts[1]) e12 = pts[1].DistanceTo(pts[2]) long_axis_is_01 = e01 >= e12 short_len = min(e01, e12) half_short = short_len * 0.5 tan_lower = math.tan(math.radians(float(slope_lower_deg))) tan_upper = math.tan(math.radians(float(slope_upper_deg))) knick_h = float(knick_h) if tan_lower <= 1e-9: return _make_walmdach_brep(outline_curve, dicke, base_height, slope_upper_deg) knick_inset = knick_h / tan_lower if knick_inset >= half_short - 1e-6: # Knick zu hoch → degeneriert zu reinem Walm return _make_walmdach_brep(outline_curve, dicke, base_height, slope_lower_deg) remaining = half_short - knick_inset ridge_above_knick = remaining * tan_upper total_height = knick_h + ridge_above_knick # Eave-Ecken auf base_height c0 = rg.Point3d(pts[0].X, pts[0].Y, base_height) c1 = rg.Point3d(pts[1].X, pts[1].Y, base_height) c2 = rg.Point3d(pts[2].X, pts[2].Y, base_height) c3 = rg.Point3d(pts[3].X, pts[3].Y, base_height) # Knick-Ecken: nach innen verschoben (Diagonal-Approximation fuer Rechteck) cx = (pts[0].X + pts[1].X + pts[2].X + pts[3].X) * 0.25 cy = (pts[0].Y + pts[1].Y + pts[2].Y + pts[3].Y) * 0.25 def inset_corner(corner): dx = cx - corner.X; dy = cy - corner.Y L = (dx * dx + dy * dy) ** 0.5 if L < 1e-9: return rg.Point3d(corner.X, corner.Y, base_height + knick_h) # Diagonale Verschiebung = knick_inset / cos(45°) ≈ knick_inset * sqrt(2) # fuer ein achsenaligned Rechteck; sonst Approximation. f = (knick_inset * 1.41421356) / L return rg.Point3d(corner.X + dx * f, corner.Y + dy * f, base_height + knick_h) k0 = inset_corner(pts[0]) k1 = inset_corner(pts[1]) k2 = inset_corner(pts[2]) k3 = inset_corner(pts[3]) # Ridge entlang langer Achse — MIT Hip-Inset, damit oben drauf ein # echtes Walmdach steht (nicht ein Sattel mit degenerierten # Walm-Dreiecken). Inset-Distanz = `remaining` = halbe kurze Seite # des Knick-Polygons. So treffen sich die Walm-Hipflaechen unter dem # gleichen Neigungswinkel wie die Trapezflaechen. if long_axis_is_01: long_dir = rg.Vector3d(pts[1].X - pts[0].X, pts[1].Y - pts[0].Y, 0) else: long_dir = rg.Vector3d(pts[2].X - pts[1].X, pts[2].Y - pts[1].Y, 0) long_dir.Z = 0 long_dir.Unitize() if long_axis_is_01: mid_west = rg.Point3d((k3.X + k0.X) * 0.5, (k3.Y + k0.Y) * 0.5, 0) mid_east = rg.Point3d((k1.X + k2.X) * 0.5, (k1.Y + k2.Y) * 0.5, 0) ra = rg.Point3d(mid_west.X + long_dir.X * remaining, mid_west.Y + long_dir.Y * remaining, base_height + total_height) rb = rg.Point3d(mid_east.X - long_dir.X * remaining, mid_east.Y - long_dir.Y * remaining, base_height + total_height) else: mid_south = rg.Point3d((k0.X + k1.X) * 0.5, (k0.Y + k1.Y) * 0.5, 0) mid_north = rg.Point3d((k2.X + k3.X) * 0.5, (k2.Y + k3.Y) * 0.5, 0) ra = rg.Point3d(mid_south.X + long_dir.X * remaining, mid_south.Y + long_dir.Y * remaining, base_height + total_height) rb = rg.Point3d(mid_north.X - long_dir.X * remaining, mid_north.Y - long_dir.Y * remaining, base_height + total_height) is_square = abs(ra.DistanceTo(rb)) < 1e-6 # → Zelt-Mansarde tol = 0.001 faces = [] def add(pts_list): f = _planar_face_from_pts(pts_list, tol) if f: faces.append(f) # NUR Outer-Schale — Dicke wird via CreateOffsetBrep senkrecht zur # jeweiligen Flaeche nach innen extrudiert. # 1) Untere steile Mansarde-Flaechen (4) add([c0, c1, k1, k0, c0]) add([c1, c2, k2, k1, c1]) add([c2, c3, k3, k2, c2]) add([c3, c0, k0, k3, c3]) # 2) Obere flachere Walm-Kappe if long_axis_is_01: if is_square: apex_o = ra for tri in ((k0, k1, apex_o), (k1, k2, apex_o), (k2, k3, apex_o), (k3, k0, apex_o)): add([tri[0], tri[1], tri[2], tri[0]]) else: add([k0, k1, rb, ra, k0]) add([k2, k3, ra, rb, k2]) add([k1, k2, rb, k1]) add([k3, k0, ra, k3]) else: if is_square: apex_o = ra for tri in ((k0, k1, apex_o), (k1, k2, apex_o), (k2, k3, apex_o), (k3, k0, apex_o)): add([tri[0], tri[1], tri[2], tri[0]]) else: add([k1, k2, rb, ra, k1]) add([k3, k0, ra, rb, k3]) add([k0, k1, ra, k0]) add([k2, k3, rb, k2]) top_brep = _join_open_shell(faces, tol) return _thicken_roof_inward(top_brep, dicke, tol) def _make_mansardendach_walm_giebel(outline_curve, dicke, base_height, slope_upper_deg, slope_lower_deg, knick_h): """Mansardendach Walm-Giebel: unten Walm (Knick rundum, 4 steile Mansarde-Flaechen), oben Giebel (First ueber voller Laenge, 2 obere Dachflaechen entlang der langen Seiten, vertikale Dreieck-Giebel an den Schmalseiten). Erfordert Rechteck-Outline.""" import math pts = _outline_points_xy(outline_curve) if len(pts) != 4: return None e01 = pts[0].DistanceTo(pts[1]) e12 = pts[1].DistanceTo(pts[2]) long_axis_is_01 = e01 >= e12 short_len = min(e01, e12) half_short = short_len * 0.5 tan_lower = math.tan(math.radians(float(slope_lower_deg))) tan_upper = math.tan(math.radians(float(slope_upper_deg))) knick_h = float(knick_h) if tan_lower <= 1e-9: # Untere Mansarde degeneriert → reines Satteldach mit slope_upper return _make_satteldach_brep(outline_curve, dicke, base_height, slope_upper_deg) knick_inset = knick_h / tan_lower if knick_inset >= half_short - 1e-6: # Knick zu hoch → unten ginge bis zur Spitze, oben kein Platz mehr return _make_walmdach_brep(outline_curve, dicke, base_height, slope_lower_deg) remaining = half_short - knick_inset ridge_above_knick = remaining * tan_upper total_height = knick_h + ridge_above_knick # Eave-Ecken c0 = rg.Point3d(pts[0].X, pts[0].Y, base_height) c1 = rg.Point3d(pts[1].X, pts[1].Y, base_height) c2 = rg.Point3d(pts[2].X, pts[2].Y, base_height) c3 = rg.Point3d(pts[3].X, pts[3].Y, base_height) cx = (pts[0].X + pts[1].X + pts[2].X + pts[3].X) * 0.25 cy = (pts[0].Y + pts[1].Y + pts[2].Y + pts[3].Y) * 0.25 def inset_corner(corner): dx = cx - corner.X; dy = cy - corner.Y L = (dx * dx + dy * dy) ** 0.5 if L < 1e-9: return rg.Point3d(corner.X, corner.Y, base_height + knick_h) f = (knick_inset * 1.41421356) / L return rg.Point3d(corner.X + dx * f, corner.Y + dy * f, base_height + knick_h) k0 = inset_corner(pts[0]) k1 = inset_corner(pts[1]) k2 = inset_corner(pts[2]) k3 = inset_corner(pts[3]) # First: ueber voller Laenge des Knick-Polygons (KEIN Hip-Inset → Giebel oben) if long_axis_is_01: # Lange Seiten: k0-k1 und k2-k3 ; Schmalseiten: k1-k2 (Ost), k3-k0 (West) ra = rg.Point3d((k3.X + k0.X) * 0.5, (k3.Y + k0.Y) * 0.5, base_height + total_height) # West rb = rg.Point3d((k1.X + k2.X) * 0.5, (k1.Y + k2.Y) * 0.5, base_height + total_height) # Ost else: # Lange Seiten: k1-k2 und k3-k0 ; Schmalseiten: k0-k1 (Sued), k2-k3 (Nord) ra = rg.Point3d((k0.X + k1.X) * 0.5, (k0.Y + k1.Y) * 0.5, base_height + total_height) # Sued rb = rg.Point3d((k2.X + k3.X) * 0.5, (k2.Y + k3.Y) * 0.5, base_height + total_height) # Nord tol = 0.001 faces = [] def add(pts_list): f = _planar_face_from_pts(pts_list, tol) if f: faces.append(f) # NUR Outer-Schale — Dicke wird via CreateOffsetBrep senkrecht zur # jeweiligen Flaeche nach innen extrudiert. # 1) Untere steile Mansarde-Flaechen (4) add([c0, c1, k1, k0, c0]) add([c1, c2, k2, k1, c1]) add([c2, c3, k3, k2, c2]) add([c3, c0, k0, k3, c3]) # 2) Obere Giebel-Section: 2 Sattel-Flaechen + 2 vertikale Dreieck-Giebel if long_axis_is_01: add([k0, k1, rb, ra, k0]) add([k2, k3, ra, rb, k2]) add([k3, k0, ra, k3]) add([k1, k2, rb, k1]) else: add([k1, k2, rb, ra, k1]) add([k3, k0, ra, rb, k3]) add([k0, k1, ra, k0]) add([k2, k3, rb, k2]) top_brep = _join_open_shell(faces, tol) return _thicken_roof_inward(top_brep, dicke, tol) def _planar_face_from_pts(pts, tol): """Erzeugt eine planare Brep-Flaeche aus einer Liste von Eckpunkten.""" try: curve = rg.PolylineCurve(rg.Polyline(pts)) result = rg.Brep.CreatePlanarBreps([curve], tol) if result and len(result) > 0: return result[0] except Exception: pass return None # --- Decken-Volumen --------------------------------------------------------- def _trag_profile_curve(profil_typ, B, H, D, t, center_pt, angle_deg=0.0): """Liefert eine geschlossene PolylineCurve (oder Curve) im Querschnitt eines Tragwerk-Elements, am center_pt in der XY-Ebene, mit Rotation angle_deg um die Z-Achse. profil_typ: 'quadrat' — B × B 'rechteck' — B × H 'rund' — Kreis mit Durchmesser D 'i_profil' — I-Querschnitt (HEB-Style): Flanschbreite B, Hoehe H, Flanschdicke + Stegdicke = t (vereinfacht) 'rohr' — Hohl-Rund: Aussen-D, Wand-t (gibt einen geschlossenen Ring zurueck — fuer Brep direkt verwendbar)""" import math cx, cy = center_pt.X, center_pt.Y cz = center_pt.Z a = math.radians(float(angle_deg)) cos_a, sin_a = math.cos(a), math.sin(a) def _xy(lx, ly): # Lokale Koords (lx, ly) am center_pt rotieren + verschieben wx = cx + lx * cos_a - ly * sin_a wy = cy + lx * sin_a + ly * cos_a return rg.Point3d(wx, wy, cz) if profil_typ == "quadrat": hb = float(B) * 0.5 pts = [_xy(-hb, -hb), _xy(+hb, -hb), _xy(+hb, +hb), _xy(-hb, +hb), _xy(-hb, -hb)] return rg.PolylineCurve(rg.Polyline(pts)) if profil_typ == "rechteck": hb = float(B) * 0.5 hh = float(H) * 0.5 pts = [_xy(-hb, -hh), _xy(+hb, -hh), _xy(+hb, +hh), _xy(-hb, +hh), _xy(-hb, -hh)] return rg.PolylineCurve(rg.Polyline(pts)) if profil_typ == "rund": r = float(D) * 0.5 plane = rg.Plane(center_pt, rg.Vector3d.ZAxis) return rg.NurbsCurve.CreateFromCircle(rg.Circle(plane, r)) if profil_typ == "i_profil": # HEB-Stil: Flansch breite B, Gesamthoehe H, Flansch/Steg-Dicke = t bf = float(B) * 0.5 hf = float(H) * 0.5 tf = max(0.005, float(t)) # Flanschdicke tw = max(0.005, float(t) * 0.6) # Stegdicke etwas duenner wf = tw * 0.5 # halbe Stegbreite # 12-Punkt-Polygon (I-Form, CCW) local = [ (-bf, -hf), (+bf, -hf), (+bf, -hf + tf), (+wf, -hf + tf), (+wf, +hf - tf), (+bf, +hf - tf), (+bf, +hf), (-bf, +hf), (-bf, +hf - tf), (-wf, +hf - tf), (-wf, -hf + tf), (-bf, -hf + tf), ] pts = [_xy(lx, ly) for (lx, ly) in local] pts.append(pts[0]) # close return rg.PolylineCurve(rg.Polyline(pts)) if profil_typ == "rohr": # Wird im Brep-Builder als Aussen-Curve und Innen-Curve gehandhabt. # Hier nur die Aussenkante als Kreis zurueckgeben — der Builder # macht die Subtraktion separat. r = float(D) * 0.5 plane = rg.Plane(center_pt, rg.Vector3d.ZAxis) return rg.NurbsCurve.CreateFromCircle(rg.Circle(plane, r)) return None def _make_stuetze_volume(point, profil_typ, B, H, D, t, angle, uk, ok): """Vertikale Extrusion eines Querschnitts von uk bis ok an `point`.""" height = float(ok) - float(uk) if height <= 1e-6: return None base_pt = rg.Point3d(point.X, point.Y, float(uk)) crv = _trag_profile_curve(profil_typ, B, H, D, t, base_pt, angle) if crv is None: return None try: ext = rg.Extrusion.Create(crv, height, True) if ext is None: return None outer = ext.ToBrep() # Bei Rohr: zusaetzlich Innen-Kreis ausstanzen if profil_typ == "rohr" and outer is not None: wall_t = max(0.002, float(t)) inner_d = max(0.01, float(D) - 2.0 * wall_t) inner_crv = _trag_profile_curve("rund", 0, 0, inner_d, 0, base_pt, 0) if inner_crv is not None: inner_ext = rg.Extrusion.Create(inner_crv, height, True) if inner_ext is not None: inner_brep = inner_ext.ToBrep() diff = rg.Brep.CreateBooleanDifference( [outer], [inner_brep], 0.001) if diff and len(diff) > 0: return diff[0] return outer except Exception as ex: print("[ELEMENTE] Stuetze extrusion:", ex) return None def _trag_profile_in_plane(profil_typ, B, H, D, t, plane, angle_deg=0.0): """Variante von _trag_profile_curve, die das Profil in einer beliebigen Ebene (statt XY) aufbaut. Das Profil wird zunaechst flach in WorldXY am Origin gebaut und dann via PlaneToPlane in die Zielebene transformiert.""" flat = _trag_profile_curve(profil_typ, B, H, D, t, rg.Point3d(0, 0, 0), angle_deg) if flat is None: return None try: xform = rg.Transform.PlaneToPlane(rg.Plane.WorldXY, plane) flat.Transform(xform) except Exception: pass return flat def _make_traeger_volume(axis_curve, profil_typ, B, H, D, t, angle, z_top): """Horizontaler Traeger entlang einer Achse. Profil sitzt in einer Ebene senkrecht zur Achse — wird zur kompletten Extrusion entlang der Achse. `z_top` ist die OBERKANTE des Traegers.""" if not isinstance(axis_curve, rg.Curve): return None try: # Profil-Hoehe ermitteln (fuer Z-Versatz nach unten) if profil_typ == "quadrat": prof_h = float(B) elif profil_typ == "rechteck": prof_h = float(H) elif profil_typ in ("rund", "rohr"): prof_h = float(D) elif profil_typ == "i_profil": prof_h = float(H) else: prof_h = 0.3 z_center = float(z_top) - prof_h * 0.5 # Achse-Richtung (XY-Ebene) p0 = axis_curve.PointAtStart p1 = axis_curve.PointAtEnd tan = rg.Vector3d(p1.X - p0.X, p1.Y - p0.Y, 0) length = tan.Length if length < 1e-6: return None tan.Unitize() # Profil-Ebene: Origin am Centerline-Anfang, X = horizontal-perp, # Y = WeltZ (hoch), Normal (Z der Ebene) = Achsenrichtung perp = rg.Vector3d(-tan.Y, tan.X, 0) origin = rg.Point3d(p0.X, p0.Y, z_center) try: plane = rg.Plane(origin, perp, rg.Vector3d.ZAxis) except Exception: plane = rg.Plane.WorldXY crv = _trag_profile_in_plane(profil_typ, B, H, D, t, plane, angle) if crv is None: return None # Extrusion in Richtung der Achse, Laenge = length try: ext = rg.Extrusion.CreateExtrusion(crv, tan * length) if ext is None: return None outer = ext.ToBrep() if outer is None: return None outer = outer.CapPlanarHoles(0.001) or outer except Exception as ex: print("[ELEMENTE] Traeger extrusion:", ex) return None # Bei Rohr: Innen-Kreis ausstanzen if profil_typ == "rohr": wall_t = max(0.002, float(t)) inner_d = max(0.01, float(D) - 2.0 * wall_t) inner_crv = _trag_profile_in_plane("rund", 0, 0, inner_d, 0, plane, 0) if inner_crv is not None: try: inner_ext = rg.Extrusion.CreateExtrusion(inner_crv, tan * length) if inner_ext is not None: inner_brep = inner_ext.ToBrep() if inner_brep is not None: inner_brep = inner_brep.CapPlanarHoles(0.001) or inner_brep diff = rg.Brep.CreateBooleanDifference( [outer], [inner_brep], 0.001) if diff and len(diff) > 0: outer = diff[0] except Exception as ex: print("[ELEMENTE] Traeger rohr-diff:", ex) return outer except Exception as ex: print("[ELEMENTE] Traeger:", ex) return None def _make_decke_volume(outline_curve, dicke, uk, ok, hole_curves=None): """Decke als Extrusion zwischen UK und OK. Optional mit Loechern (Aussparungen) — wird ueber `Brep.CreatePlanarBreps([outer, holes...])` + `BrepFace.CreateExtrusion` gebaut, kein BoolDiff. Das ist deutlich robuster fuer duenne Slabs.""" if not isinstance(outline_curve, rg.Curve): return None if not outline_curve.IsClosed: return None height = float(ok) - float(uk) if height <= 0: return None # Kein Loch → klassischer Extrusion-Pfad if not hole_curves: profile = outline_curve.DuplicateCurve() if abs(uk) > 1e-9: profile.Transform(rg.Transform.Translation(0, 0, uk)) extrusion = rg.Extrusion.Create(profile, height, True) if extrusion is None: return None return extrusion.ToBrep() # Mit Loechern: Curves auf z=uk bringen, planar-brep mit Loechern bauen, # diese Face entlang Z extrudieren — fertig. outer = outline_curve.DuplicateCurve() holes = [] for h in hole_curves: if not isinstance(h, rg.Curve): continue if not h.IsClosed: continue holes.append(h.DuplicateCurve()) # Alle auf z=uk normalisieren (BoundingBox Min.Z) def _to_z(c, target_z): try: bb = c.GetBoundingBox(True) cur = bb.Min.Z dz = float(target_z) - cur if abs(dz) > 1e-9: c.Transform(rg.Transform.Translation(0, 0, dz)) except Exception: pass _to_z(outer, uk) for h in holes: _to_z(h, uk) try: tol = 0.001 planar = rg.Brep.CreatePlanarBreps([outer] + holes, tol) if not planar or len(planar) == 0: print("[ELEMENTE] Decke planar w/ holes — CreatePlanarBreps " "lieferte nichts. Fallback ohne Loch.") return _make_decke_volume(outline_curve, dicke, uk, ok, None) base = planar[0] if base.Faces.Count == 0: return _make_decke_volume(outline_curve, dicke, uk, ok, None) face = base.Faces[0] path = rg.LineCurve(rg.Point3d(0, 0, uk), rg.Point3d(0, 0, ok)) result = face.CreateExtrusion(path, True) if result is None or not result.IsValid: print("[ELEMENTE] BrepFace.CreateExtrusion fehlgeschlagen — " "Fallback ohne Loch.") return _make_decke_volume(outline_curve, dicke, uk, ok, None) return result except Exception as ex: print("[ELEMENTE] Decke mit Loch:", ex) return _make_decke_volume(outline_curve, dicke, uk, ok, None) def _find_aussparungen_for_decke(doc, decke_id): """Alle decke_aussparung_outline Source-Curves deren aussp_parent == decke_id. Liefert Liste von (obj, meta).""" out = [] for obj in doc.Objects: meta = _read_meta(obj) if meta is None: continue if meta["type"] != "decke_aussparung_outline": continue if meta.get("aussp_parent") != decke_id: continue out.append((obj, meta)) return out def _point_in_curve_xy(curve, pt, tol): """Liefert True wenn pt INNERHALB der geschlossenen 2D-Curve liegt (XY-Projektion). Robust gegen Enum-int-Konvertierungs-Probleme in IronPython3 — vergleicht direkt mit dem PointContainment-Enum, Fallback auf int-Vergleich.""" try: test = curve.Contains(pt, rg.Plane.WorldXY, tol) except Exception: try: test = curve.Contains(pt) except Exception: return False # Direkter Enum-Vergleich try: if test == rg.PointContainment.Inside: return True if test == rg.PointContainment.Coincident: return True return False except Exception: pass # Fallback: int-Vergleich (Inside=2, Coincident=3) try: return int(test) in (2, 3) except Exception: return False def _find_decke_containing_point(doc, geschoss_id, point_xy): """Sucht die Decke deren Outline (in XY-Projektion) den Punkt umschliesst. Bevorzugt Decken im aktiven Geschoss, faellt sonst auf Decken in ANDEREN Geschossen zurueck (User kann z.B. in 1OG ein Loch in die EG-Decke setzen). Bei mehreren Treffern: kleinste Flaeche.""" active = [] any_ = [] tol = max(doc.ModelAbsoluteTolerance, 1e-4) for obj in doc.Objects: meta = _read_meta(obj) if meta is None: continue if meta["type"] != "decke_outline": continue geom = obj.Geometry if not isinstance(geom, rg.Curve): continue if not geom.IsClosed: continue if not _point_in_curve_xy(geom, point_xy, tol): continue try: amp = rg.AreaMassProperties.Compute(geom) area = abs(amp.Area) if amp is not None else 0.0 except Exception: area = 0.0 bucket = active if meta["geschoss"] == geschoss_id else any_ bucket.append((area, meta["id"])) pool = active if active else any_ if not pool: return None pool.sort(key=lambda x: x[0]) return pool[0][1] # --- Raum (Raumstempel) ----------------------------------------------------- def _raum_amp(outline_curve): """Liefert (area, perimeter, centroid) fuer eine geschlossene Outline. Bei Fehler oder offener Kurve: (0, 0, Point3d(0,0,0)).""" if not isinstance(outline_curve, rg.Curve): return 0.0, 0.0, rg.Point3d(0, 0, 0) if not outline_curve.IsClosed: return 0.0, 0.0, rg.Point3d(0, 0, 0) try: amp = rg.AreaMassProperties.Compute(outline_curve) if amp is None: return 0.0, 0.0, rg.Point3d(0, 0, 0) area = float(amp.Area) ctr = amp.Centroid try: perim = float(outline_curve.GetLength()) except Exception: perim = 0.0 return abs(area), perim, ctr except Exception: return 0.0, 0.0, rg.Point3d(0, 0, 0) def _format_area(area, rundung): """Formatiert eine Flaeche in m^2 gemaess Rundungs-Regel. 'exakt' -> 2 NK; '0.01' -> 2 NK; '0.1' -> 1 NK; '0.5' -> 1 NK, halbiert; '1' -> ganze m2.""" try: a = float(area) except Exception: a = 0.0 if rundung == "1": return "{:.0f}".format(round(a)) if rundung == "0.5": return "{:.1f}".format(round(a * 2.0) / 2.0) if rundung == "0.1": return "{:.1f}".format(round(a, 1)) if rundung == "0.01": return "{:.2f}".format(round(a, 2)) return "{:.2f}".format(a) # exakt def _make_raum_stamp_text(centroid, name, nummer, funktion, area, rundung, text_height, z=0.0, align="mid"): """Baut eine TextEntity am Centroid: 'Nummer Name\nA m^2'. align: 'links' | 'mid' | 'rechts' — wirkt auf die Justification.""" try: plane = rg.Plane(rg.Point3d(centroid.X, centroid.Y, float(z)), rg.Vector3d.ZAxis) te = rg.TextEntity() # Zeile 1: Nummer + Name (falls vorhanden), sonst nur Name line1 = (name or "Raum").strip() if nummer and str(nummer).strip(): line1 = "{} {}".format(str(nummer).strip(), line1) # Zeile 2: Flaeche area_line = "{} m²".format(_format_area(area, rundung)) te.Text = "{}\n{}".format(line1, area_line) te.Plane = plane try: te.TextHeight = float(text_height) except Exception: te.TextHeight = 0.20 try: if align == "links": te.Justification = rg.TextJustification.MiddleLeft elif align == "rechts": te.Justification = rg.TextJustification.MiddleRight else: te.Justification = rg.TextJustification.MiddleCenter except Exception: pass return te except Exception as ex: print("[ELEMENTE] Raum Stamp:", ex) return None def _make_raum_hatch(outline_curve, z_uk, doc, pattern_name="Solid"): """Erzeugt einen Hatch unter der Raum-Outline am Z=z_uk + 1 mm mit gegebenem Pattern. Color = ByObject (default hell). Override-System kann den Hatch via Pattern + Color umfaerben.""" if not isinstance(outline_curve, rg.Curve): return None if not outline_curve.IsClosed: return None try: # Pattern aufloesen — Fallback: Solid, dann Current pattern_idx = doc.HatchPatterns.Find(pattern_name or "Solid", True) if pattern_idx < 0: pattern_idx = doc.HatchPatterns.Find("Solid", True) if pattern_idx < 0: pattern_idx = doc.HatchPatterns.CurrentHatchPatternIndex if pattern_idx < 0: return None crv = outline_curve.DuplicateCurve() # Minimal nach oben — vermeidet Z-Fighting mit Decken z_off = float(z_uk) + 0.001 crv.Transform(rg.Transform.Translation(0, 0, z_off)) hatches = rg.Hatch.Create(crv, pattern_idx, 0.0, 1.0, 0.001) if not hatches or len(hatches) == 0: return None return hatches[0] except Exception as ex: print("[ELEMENTE] Raum Hatch:", ex) return None # --- Treppen-Volumen -------------------------------------------------------- def _treppe_profile_2d(N, S, A, uk, modus, lauf_d): """Liefert das 2D-Seitenprofil der Treppe als Liste von (x, z)-Tupeln. Konvention: N Steigungen + N Auftritte (oberster Tritt gehoert zur Treppe — fuegt sich sauber in die obere Decke ein, ohne freistehende Setzstufe oben). Lauflaenge = N*A, Hoehe = N*S. Modi: 'massiv' — Block bis zum Boden (uk) 'flach' — schraege Plattenunterseite parallel zur Steigungslinie, Plattendicke lauf_d vertikal gemessen 'plattenrand' — Faltwerk-Treppe mit echten Vertikalen unter den Setzstufen (Konvex-Ecken truncated, Konkav-Ecken haben zusaetzliche L-Verlaengerung — Slab folgt dem oberen Profil parallel) """ Z0 = float(uk) d = float(lauf_d) # Oberes Profil: N Risers + N Treads (der letzte Tritt ist enthalten) top = [(0.0, Z0)] for k in range(N): top.append((k * A, Z0 + (k + 1) * S)) # top of riser k top.append(((k + 1) * A, Z0 + (k + 1) * S)) # end of tread k # top endet bei (N*A, Z0 + N*S) x_end = N * A z_top = Z0 + N * S if modus == "massiv": return top + [(x_end, Z0), (0.0, Z0)] if modus == "flach": # Soffit parallel zur Steigungslinie (von (0,Z0) bis (NA, NS+Z0)), # vertikal um lauf_d nach unten versetzt. return top + [(x_end, z_top - d), (0.0, Z0 - d), (0.0, Z0)] # plattenrand: Faltwerk-Treppe. Innere (Soffit-) Punkte mit Konvex/ # Konkav-Handling, sodass die Vertikalen unter den Setzstufen ihre # eigene D-Tiefe haben (kein Klotz-Look mit floatenden Tritten). inner = [] inner.append((d, Z0)) # Start: rechts vom Riser-Anfang (Konvex-Ecke (0,Z0)) for k in range(N): # Konvex-Ecke (k*A, Z0+(k+1)*S): Top-Eck rechts oben des Risers # Innere Eck-Punkt bei (k*A + D, (k+1)S - D) inner.append((k * A + d, Z0 + (k + 1) * S - d)) # Konkav-Ecke ((k+1)*A, Z0+(k+1)*S): Tread-Ende, naechster Riser # geht hoch. Extra L-Verlaengerung — ausser beim allerletzten Schritt, # wo der Tritt zur Decke laeuft. if k < N - 1: inner.append(((k + 1) * A, Z0 + (k + 1) * S - d)) inner.append(((k + 1) * A + d, Z0 + (k + 1) * S - d)) else: inner.append((x_end, Z0 + N * S - d)) # Schliessen: Top-right Drop + Inner reversed + zurueck zum Start # inner letzter Punkt ist (x_end, z_top - d). Top-right ist (x_end, z_top). inner_rev = list(reversed(inner)) # endet bei (d, Z0) return top + inner_rev + [(0.0, Z0)] def _wendel_radii(r_click, breite, referenz): """Berechnet (r_inner, r_outer) der Wendeltreppe basierend auf dem Klick-Radius (= Lauflinien-Position) und der Referenz. Konvention: 'links' → Lauflinie auf AUSSEN-Kante (body extends inward) 'mid' → Lauflinie mittig 'rechts' → Lauflinie auf INNEN-Kante (body extends outward) r_inner wird absolut auf >= 0.01m geclampt. Bei r_inner < 0.05m schaltet die Geometrie auf Cone-Wedge um (Innenkante kollabiert zur Mittelachse — Spindeltreppe-Style).""" half_b = float(breite) * 0.5 MIN_R = 0.01 if referenz == "links": return (max(MIN_R, r_click - float(breite)), r_click) if referenz == "rechts": return (max(MIN_R, r_click), r_click + float(breite)) return (max(MIN_R, r_click - half_b), r_click + half_b) def _wendel_sweep(center, p_start, p_end): """Liefert (alpha_start, delta) — Startwinkel und signed Sweep-Winkel in Rad. Sweep-Richtung kommt aus dem Cross-Product start vs. end.""" import math sx, sy = p_start.X - center.X, p_start.Y - center.Y ex, ey = p_end.X - center.X, p_end.Y - center.Y a_start = math.atan2(sy, sx) a_end_raw = math.atan2(ey, ex) cross_z = sx * ey - sy * ex sweep_sign = 1.0 if cross_z >= 0 else -1.0 delta = a_end_raw - a_start if sweep_sign > 0: while delta < 0: delta += 2.0 * math.pi else: while delta > 0: delta -= 2.0 * math.pi return a_start, delta def _wendel_wedge_cone_brep(center, r_out, a0, a1, top_z, bot_z_a0, bot_z_a1, tol=0.001): """Cone-Wedge fuer Spindeltreppen: innere Kante kollabiert zur Mittelachse. 5 Vertices (t_c, t_o0, t_o1, b_c, b_o0, b_o1) und 5-6 Faces (Top-Dreieck, Bottom-Dreieck, Aussen-Quad, 2 Radial- Quads). Vermeidet degenerierte Innen-Faces bei r_inner ≈ 0.""" import math cx, cy = center.X, center.Y t_c = rg.Point3d(cx, cy, top_z) t_o0 = rg.Point3d(cx + r_out * math.cos(a0), cy + r_out * math.sin(a0), top_z) t_o1 = rg.Point3d(cx + r_out * math.cos(a1), cy + r_out * math.sin(a1), top_z) b_o0 = rg.Point3d(cx + r_out * math.cos(a0), cy + r_out * math.sin(a0), bot_z_a0) b_o1 = rg.Point3d(cx + r_out * math.cos(a1), cy + r_out * math.sin(a1), bot_z_a1) # Bottom-Center: bei flach-Modus = Mittel der beiden bot_z, sonst gleich. b_c_z = (bot_z_a0 + bot_z_a1) * 0.5 b_c = rg.Point3d(cx, cy, b_c_z) da = a1 - a0 flat_bot = abs(bot_z_a0 - bot_z_a1) < 1e-6 faces = [] # Top Dreieck — CCW von oben if da > 0: faces.append([t_c, t_o0, t_o1, t_c]) else: faces.append([t_c, t_o1, t_o0, t_c]) # Bottom Dreieck — reversed if da > 0: faces.append([b_c, b_o1, b_o0, b_c]) else: faces.append([b_c, b_o0, b_o1, b_c]) # Aussen-Seite (planar bei flat_bot, sonst trianguliert) if flat_bot: faces.append([t_o0, b_o0, b_o1, t_o1, t_o0]) else: faces.append([t_o0, b_o0, b_o1, t_o0]) faces.append([t_o0, b_o1, t_o1, t_o0]) # Radiale Seiten (immer planar — alle in radial Ebene) faces.append([t_c, t_o0, b_o0, b_c, t_c]) faces.append([t_c, b_c, b_o1, t_o1, t_c]) breps = [] for face_pts in faces: try: f = _planar_face_from_pts(face_pts, tol) if f: breps.append(f) except Exception: pass if not breps: return None try: joined = rg.Brep.JoinBreps(breps, tol) if joined and len(joined) > 0: return joined[0] except Exception: pass return breps[0] if breps else None def _wendel_wedge_brep(center, r_in, r_out, a0, a1, top_z, bot_z_a0, bot_z_a1, tol=0.001): """Bauet einen Wendel-Tritt als 8-Vertex-Polyeder: - Flat top bei top_z - Bottom Z-Werte koennen pro Winkel-Seite differieren (flach-Modus: schraeg parallel zur Steigungslinie; plattenrand-Modus: flat). - 4 Seitenflaechen (innen, aussen, radial a0, radial a1). Bei r_in < 0.05m → Cone-Wedge (Spindeltreppe-Style) — verhindert degenerierte Geometrie an der Mittelachse.""" import math if r_in < 0.05: return _wendel_wedge_cone_brep(center, r_out, a0, a1, top_z, bot_z_a0, bot_z_a1, tol) cx, cy = center.X, center.Y t_i0 = rg.Point3d(cx + r_in * math.cos(a0), cy + r_in * math.sin(a0), top_z) t_o0 = rg.Point3d(cx + r_out * math.cos(a0), cy + r_out * math.sin(a0), top_z) t_i1 = rg.Point3d(cx + r_in * math.cos(a1), cy + r_in * math.sin(a1), top_z) t_o1 = rg.Point3d(cx + r_out * math.cos(a1), cy + r_out * math.sin(a1), top_z) b_i0 = rg.Point3d(cx + r_in * math.cos(a0), cy + r_in * math.sin(a0), bot_z_a0) b_o0 = rg.Point3d(cx + r_out * math.cos(a0), cy + r_out * math.sin(a0), bot_z_a0) b_i1 = rg.Point3d(cx + r_in * math.cos(a1), cy + r_in * math.sin(a1), bot_z_a1) b_o1 = rg.Point3d(cx + r_out * math.cos(a1), cy + r_out * math.sin(a1), bot_z_a1) da = a1 - a0 flat_bot = abs(bot_z_a0 - bot_z_a1) < 1e-6 faces = [] # Top — planar if da > 0: faces.append([t_i0, t_o0, t_o1, t_i1, t_i0]) else: faces.append([t_i0, t_i1, t_o1, t_o0, t_i0]) # Bottom — planar wenn flat, sonst in 2 Dreiecke teilen if da > 0: bot_order = [b_i0, b_i1, b_o1, b_o0] else: bot_order = [b_i0, b_o0, b_o1, b_i1] if flat_bot: faces.append(bot_order + [bot_order[0]]) else: faces.append([bot_order[0], bot_order[1], bot_order[2], bot_order[0]]) faces.append([bot_order[0], bot_order[2], bot_order[3], bot_order[0]]) # Inner side: planar wenn flat, sonst Dreiecke if flat_bot: faces.append([t_i0, t_i1, b_i1, b_i0, t_i0]) else: faces.append([t_i0, t_i1, b_i1, t_i0]) faces.append([t_i0, b_i1, b_i0, t_i0]) # Outer side: planar wenn flat, sonst Dreiecke if flat_bot: faces.append([t_o0, b_o0, b_o1, t_o1, t_o0]) else: faces.append([t_o0, b_o0, b_o1, t_o0]) faces.append([t_o0, b_o1, t_o1, t_o0]) # Radiale Seiten — immer planar (alle Punkte auf derselben Radial-Ebene) faces.append([t_i0, t_o0, b_o0, b_i0, t_i0]) faces.append([t_i1, b_i1, b_o1, t_o1, t_i1]) breps = [] for face_pts in faces: try: f = _planar_face_from_pts(face_pts, tol) if f: breps.append(f) except Exception: pass if not breps: return None try: joined = rg.Brep.JoinBreps(breps, tol) if joined and len(joined) > 0: return joined[0] except Exception as ex: print("[ELEMENTE] wendel wedge join:", ex) return breps[0] if breps else None def _wendel_sweep_range(r_lauf, breite, referenz, n_stufen, H, soll): """Liefert (sweep_min, sweep_max) in Radian — gueltiger Drehwinkel. Erzwingt Soll-Auftritt-Range UEBER DIE GANZE TRITTBREITE (von r_inner bis r_outer), nicht nur an der Lauflinie: A = r * (sweep/N) A_inner = r_inner * (sweep/N) >= a_lo → sweep >= N*a_lo/r_inner A_outer = r_outer * (sweep/N) <= a_hi → sweep <= N*a_hi/r_outer So liegen beide Enden eines Tritts im Soll. Wenn der Bereich widerspruechlich ist (zu breite Treppe fuer kleinen Radius), fallback auf Lauflinie-basiertes Clamping.""" r_inner, r_outer = _wendel_radii(r_lauf, breite, referenz) n = max(1, int(n_stufen)) s = float(H) / n a_lo, a_hi = 0.05, 2.0 if soll.get("sa", [0, 0, False])[2]: a_lo = max(a_lo, float(soll["sa"][0]) - 2.0 * s) a_hi = min(a_hi, float(soll["sa"][1]) - 2.0 * s) if soll.get("a", [0, 0, False])[2]: a_lo = max(a_lo, float(soll["a"][0])) a_hi = min(a_hi, float(soll["a"][1])) if a_lo > a_hi: mid = (a_lo + a_hi) * 0.5 a_lo = a_hi = mid ri = max(0.01, float(r_inner)) ro = max(0.01, float(r_outer)) sweep_lo = n * a_lo / ri # tightest lower (kleinster Radius) sweep_hi = n * a_hi / ro # tightest upper (groesster Radius) if sweep_lo > sweep_hi: # Range nicht erfuellbar (zu breite Treppe oder zu enger Radius) # → Fallback: nur an der Lauflinie clampen, der User sieht im Label # dass A_inner/A_outer ausserhalb Soll sind. rl = max(0.01, float(r_lauf)) sweep_lo = n * a_lo / rl sweep_hi = n * a_hi / rl return (sweep_lo, sweep_hi) def _make_treppe_wendel_volume(axis_polyline, breite, referenz, n_stufen, uk, ok, modus="flach", lauf_d=0.18): """Wendeltreppe aus 3-Punkt-Polylinie [center, start, end]. Bauet N Stufen als gestapelte trapezfoermige Keile um die Mittelachse. Lauflinien-Radius = Distanz center→start. Sweep-Winkel und -Richtung werden aus der End-Position abgeleitet (Cross-Product gibt Drehsinn, Winkel ist die natuerliche Strecke in dieser Richtung). Modus: 'massiv' — jeder Keil reicht von UK bis Step-Top (Wedding-Cake) 'flach' / 'plattenrand' — jeder Keil ist nur lauf_d dick (floating steps); bei Wendel keine echte Helix-Soffit fuer Entwurfs-Niveau.""" import math if not isinstance(axis_polyline, rg.Curve): return None try: ok_pl, poly = axis_polyline.TryGetPolyline() except Exception: return None if not ok_pl or poly is None or poly.Count != 3: return None center = rg.Point3d(poly[0].X, poly[0].Y, 0) p_start = rg.Point3d(poly[1].X, poly[1].Y, 0) p_end = rg.Point3d(poly[2].X, poly[2].Y, 0) r_click = math.sqrt((p_start.X - center.X) ** 2 + (p_start.Y - center.Y) ** 2) if r_click < 0.2: return None r_inner, r_outer = _wendel_radii(r_click, breite, referenz) if r_outer - r_inner < 0.05: return None a_start, delta = _wendel_sweep(center, p_start, p_end) if abs(delta) < 0.05: return None H = float(ok) - float(uk) if H <= 1e-6: return None N = max(2, int(n_stufen)) S = H / N da = delta / N parts = [] for k in range(N): a0 = a_start + k * da a1 = a_start + (k + 1) * da z_top = float(uk) + (k + 1) * S if modus == "massiv": # Wedding-Cake: Block bis zum Boden, einfache Extrusion z_bot = float(uk) c0i = (center.X + r_inner * math.cos(a0), center.Y + r_inner * math.sin(a0)) c0o = (center.X + r_outer * math.cos(a0), center.Y + r_outer * math.sin(a0)) c1i = (center.X + r_inner * math.cos(a1), center.Y + r_inner * math.sin(a1)) c1o = (center.X + r_outer * math.cos(a1), center.Y + r_outer * math.sin(a1)) if da > 0: corners = [c0i, c0o, c1o, c1i] else: corners = [c0i, c1i, c1o, c0o] pts = [rg.Point3d(x, y, z_bot) for (x, y) in corners] pts.append(pts[0]) try: crv = rg.PolylineCurve(rg.Polyline(pts)) ext = rg.Extrusion.Create(crv, z_top - z_bot, True) if ext is not None: parts.append(ext.ToBrep()) except Exception as ex: print("[ELEMENTE] Wendel massiv step:", ex) elif modus == "plattenrand": # Gestuft: flat bottom bei z_top - D unter jedem Tritt. # Adjacent Wedges haben unterschiedliche Bottom-Z → visible # "Schritt" auf der Unterseite (= Faltwerk-Look). bot = z_top - float(lauf_d) wedge = _wendel_wedge_brep(center, r_inner, r_outer, a0, a1, z_top, bot, bot) if wedge is not None: parts.append(wedge) else: # flach # Helikoide Unterseite: Bottom verlaeuft schraeg parallel zur # Steigungslinie. Bei a0 bei (uk + k*S - D), bei a1 bei # (uk + (k+1)*S - D). Adjacent Wedges meet seamlessly an der # gemeinsamen Kante → kontinuierlicher Spiral-Slab. bot_a0 = float(uk) + k * S - float(lauf_d) bot_a1 = float(uk) + (k + 1) * S - float(lauf_d) wedge = _wendel_wedge_brep(center, r_inner, r_outer, a0, a1, z_top, bot_a0, bot_a1) if wedge is not None: parts.append(wedge) if not parts: return None if len(parts) == 1: return parts[0] try: merged = rg.Brep.MergeBreps(parts, 0.001) if merged is not None: return merged except Exception: pass try: joined = rg.Brep.JoinBreps(parts, 0.001) if joined and len(joined) > 0: return joined[0] except Exception: pass return parts[0] def _line_intersect_xy(p1, dir1, p2, dir2): """2D-Linien-Schnittpunkt in der XY-Ebene. dir1, dir2 = Richtungs- vektoren (muessen nicht normalisiert sein). Liefert Point3d (Z=0) oder None bei Parallelitaet.""" det = dir1.X * (-dir2.Y) - dir1.Y * (-dir2.X) if abs(det) < 1e-9: return None dx = p2.X - p1.X dy = p2.Y - p1.Y t1 = (dx * (-dir2.Y) - dy * (-dir2.X)) / det return rg.Point3d(p1.X + dir1.X * t1, p1.Y + dir1.Y * t1, 0) def _make_treppe_l_volume(axis_polyline, breite, referenz, n_stufen, uk, ok, modus="flach", lauf_d=0.18): """L-Treppe aus 3-Punkt-Polylinie (Start, Eck-Punkt, End). Bauet 2 gerade Laufe + 1 Podest am Eck zusammen. Hoehe wird proportional zu den Lauflinien-Laengen auf die beiden Laufe verteilt.""" if not isinstance(axis_polyline, rg.Curve): return None try: ok_pl, poly = axis_polyline.TryGetPolyline() except Exception: return None if not ok_pl or poly is None or poly.Count != 3: return None p0 = rg.Point3d(poly[0].X, poly[0].Y, 0) p1 = rg.Point3d(poly[1].X, poly[1].Y, 0) p2 = rg.Point3d(poly[2].X, poly[2].Y, 0) v1 = rg.Vector3d(p1.X - p0.X, p1.Y - p0.Y, 0) v2 = rg.Vector3d(p2.X - p1.X, p2.Y - p1.Y, 0) L1 = v1.Length L2 = v2.Length half_b = float(breite) * 0.5 if L1 < half_b + 0.05 or L2 < half_b + 0.05: print("[ELEMENTE] L-Treppe: Lauflinien zu kurz fuer Podest") return None H = float(ok) - float(uk) if H <= 1e-6: return None N = max(2, int(n_stufen)) S = H / N # Stufen-Verteilung: N1 wird aus L1 mit dem optimalen A bestimmt, # damit die Klick-Position des Users direkt N1 (Stufen vor Podest) # entspricht — genauso wie's der Live-Preview anzeigt. eff_L1 = L1 - half_b eff_L2 = L2 - half_b if eff_L1 + eff_L2 <= 0: return None A_opt = 0.63 - 2.0 * S if A_opt < 0.21: A_opt = 0.21 if A_opt > 0.35: A_opt = 0.35 N1 = int(round(eff_L1 / A_opt)) if N1 < 1: N1 = 1 if N1 > N - 1: N1 = N - 1 N2 = N - N1 v1u = rg.Vector3d(v1); v1u.Unitize() v2u = rg.Vector3d(v2); v2u.Unitize() # Run 1: von p0 bis p1 - v1u*half_b run1_end = rg.Point3d(p1.X - v1u.X * half_b, p1.Y - v1u.Y * half_b, 0) line1 = rg.LineCurve(p0, run1_end) z_podest = float(uk) + N1 * S brep1 = _make_treppe_volume(line1, breite, referenz, N1, float(uk), z_podest, modus, lauf_d) # Run 2: von p1 + v2u*half_b bis p2 run2_start = rg.Point3d(p1.X + v2u.X * half_b, p1.Y + v2u.Y * half_b, 0) line2 = rg.LineCurve(run2_start, p2) brep2 = _make_treppe_volume(line2, breite, referenz, N2, z_podest, float(ok), modus, lauf_d) # Podest am Eck p1 — adaptives Hexagon das die zwei Lauf-Querschnitte # an ihren tatsaechlichen Richtungen verbindet. Bei 90° L kollabiert # zu Quadrat, bei flacheren/spitzeren Winkeln wird's ein 5/6-Eck mit # den Schnittpunkten der ausserhalbliegenden Seitenwaende. perp1 = rg.Vector3d(-v1u.Y, v1u.X, 0) perp2 = rg.Vector3d(-v2u.Y, v2u.X, 0) b = float(breite) if referenz == "links": perp_lo, perp_hi = 0.0, +b elif referenz == "rechts": perp_lo, perp_hi = -b, 0.0 else: # mid perp_lo, perp_hi = -half_b, +half_b # End-Querschnitt von Run 1 + Start-Querschnitt von Run 2 end_lo = rg.Point3d(run1_end.X + perp1.X * perp_lo, run1_end.Y + perp1.Y * perp_lo, 0) end_hi = rg.Point3d(run1_end.X + perp1.X * perp_hi, run1_end.Y + perp1.Y * perp_hi, 0) start_lo = rg.Point3d(run2_start.X + perp2.X * perp_lo, run2_start.Y + perp2.Y * perp_lo, 0) start_hi = rg.Point3d(run2_start.X + perp2.X * perp_hi, run2_start.Y + perp2.Y * perp_hi, 0) # Eckpunkte = Schnitt der Seitenwand-Linien (Run 1 weiter entlang v1, # Run 2 zurueck entlang -v2) minus_v2 = rg.Vector3d(-v2u.X, -v2u.Y, 0) corner_lo = _line_intersect_xy(end_lo, v1u, start_lo, minus_v2) corner_hi = _line_intersect_xy(end_hi, v1u, start_hi, minus_v2) if modus == "massiv": z_lo = float(uk) else: z_lo = z_podest - float(lauf_d) z_hi = z_podest podest_brep = None try: # Hexagon-Vertices in CCW-Order: # end_lo → corner_lo → start_lo → start_hi → corner_hi → end_hi def _add_unique(arr, p, tol=1e-5): if p is None: return if not arr: arr.append(p); return last = arr[-1] if (last.X - p.X) ** 2 + (last.Y - p.Y) ** 2 < tol * tol: return arr.append(p) verts = [] _add_unique(verts, end_lo) _add_unique(verts, corner_lo) _add_unique(verts, start_lo) _add_unique(verts, start_hi) _add_unique(verts, corner_hi) _add_unique(verts, end_hi) if len(verts) >= 3: # CCW-Check via Shoelace — sonst umdrehen damit Extrusion in +Z geht area2 = 0.0 n_v = len(verts) for i in range(n_v): pa = verts[i] pb = verts[(i + 1) % n_v] area2 += pa.X * pb.Y - pa.Y * pb.X if area2 < 0: verts = list(reversed(verts)) pts_bot = [rg.Point3d(p.X, p.Y, z_lo) for p in verts] pts_bot.append(pts_bot[0]) bot_curve = rg.PolylineCurve(rg.Polyline(pts_bot)) ext = rg.Extrusion.Create(bot_curve, z_hi - z_lo, True) if ext is not None: podest_brep = ext.ToBrep() except Exception as ex: print("[ELEMENTE] Podest hexagon:", ex) parts = [b for b in (brep1, podest_brep, brep2) if b is not None] if not parts: return None if len(parts) == 1: return parts[0] # MergeBreps versucht alle Brep-Teile zu einem einzelnen Brep # (mit ggf. mehreren disjunkten Shells) zu kombinieren. try: merged = rg.Brep.MergeBreps(parts, 0.001) if merged is not None: return merged except Exception: pass try: joined = rg.Brep.JoinBreps(parts, 0.001) if joined and len(joined) > 0: return joined[0] except Exception: pass return parts[0] def _make_treppe_volume(axis_curve, breite, referenz, n_stufen, uk, ok, modus="flach", lauf_d=0.18): """Gerade Treppe: bauet ein Seitenprofil (Step-Polygon) entlang der Lauflinie und extrudiert es senkrecht um `breite`. Einzelnes sauberes Brep-Volumen. `modus` bestimmt die Form der Unterseite.""" if not isinstance(axis_curve, rg.Curve): return None try: P0 = axis_curve.PointAtStart P1 = axis_curve.PointAtEnd tan_vec = rg.Vector3d(P1.X - P0.X, P1.Y - P0.Y, 0) L = tan_vec.Length if L < 1e-6: return None tan_vec.Unitize() perp = rg.Vector3d(-tan_vec.Y, tan_vec.X, 0) H = float(ok) - float(uk) if H <= 1e-6: return None N = max(2, int(n_stufen)) S = H / N A = L / N # N Auftritte (oberster Tritt inkl.) if modus not in _TREPPE_MODI: modus = "flach" profile_2d = _treppe_profile_2d(N, S, A, uk, modus, lauf_d) b = float(breite) if referenz == "links": perp_start, perp_end = 0.0, -b elif referenz == "rechts": perp_start, perp_end = 0.0, +b else: perp_start, perp_end = -b * 0.5, +b * 0.5 shift0 = rg.Vector3d(perp.X * perp_start, perp.Y * perp_start, 0) world_pts = [] for (px, pz) in profile_2d: wp = rg.Point3d(P0.X + tan_vec.X * px + shift0.X, P0.Y + tan_vec.Y * px + shift0.Y, pz) world_pts.append(wp) poly = rg.Polyline(world_pts) profile_curve = rg.PolylineCurve(poly) if not profile_curve.IsClosed: return None extrude_len = perp_end - perp_start try: ext = rg.Extrusion.Create(profile_curve, extrude_len, True) if ext is not None: return ext.ToBrep() except Exception as ex: print("[ELEMENTE] Treppe Extrusion:", ex) return None except Exception as ex: print("[ELEMENTE] _make_treppe_volume:", ex) return None def _regenerate_element(doc, element_id): """Regeneriert das Volumen eines Elements (Wand oder Decke) anhand seines Source-Objekts (Achse bzw. Outline).""" src_obj, meta = _find_source(doc, element_id) if src_obj is None or meta is None: return False # Oeffnung selbst hat kein Volumen — stattdessen die Elternwand regen if meta["type"] == "oeffnung_point": parent_id = meta.get("oeff_parent") or "" if parent_id: return _regenerate_element(doc, parent_id) return False # Decken-Aussparung hat kein eigenes Volumen → Eltern-Decke regen if meta["type"] == "decke_aussparung_outline": parent_id = meta.get("aussp_parent") or "" if parent_id: return _regenerate_element(doc, parent_id) return False geom = src_obj.Geometry # Stuetze hat Point-Geometrie, alle anderen Source-Typen sind Curves if meta["type"] == "stuetze_point": if not (isinstance(geom, rg.Point) or isinstance(geom, rg.Point3d)): return False else: if not isinstance(geom, rg.Curve): return False g = _geschoss_by_id(doc, meta["geschoss"]) geschoss_name = g.get("name", "EG") if g else "EG" # _REGEN_BUSY waehrend Regen setzen — verhindert dass die Listener # (Add/Replace/Delete) waehrend dem Erstellen/Loeschen von Volume- # Objekten Dedup oder Cascade-Logik ausfuehren. Wichtig fuer # Oeffnungen, wo mehrere Brep-Pieces mit gleicher ID hinzugefuegt # werden. _was_busy = sc.sticky.get(_REGEN_BUSY, False) sc.sticky[_REGEN_BUSY] = True try: return _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name) finally: sc.sticky[_REGEN_BUSY] = _was_busy def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name): """Eigentliche Implementierung des Regen — der aeussere Wrapper `_regenerate_element` setzt _REGEN_BUSY und dispatcht oeffnung_point.""" if meta["type"] == "wand_axis": uk, ok = _resolve_uk_ok(doc, meta["geschoss"], meta["uk_override"], meta["ok_override"]) print("[ELEMENTE] regen wand {}: uk={:.3f} ok={:.3f} (uk_over='{}' ok_over='{}')".format( element_id, uk, ok, meta.get("uk_override", ""), meta.get("ok_override", ""))) # Wand-Verbindungen: Miter-Linien aus Nachbarwand-Joints (Corner + T). miter_start = None miter_end = None try: joints = _collect_wall_joints(doc, meta["geschoss"]) out_s, out_e = _wall_out_dirs(geom) p_s = geom.PointAtStart p_e = geom.PointAtEnd if out_s is not None: key_s = _pt_key(p_s) partners_s = [(wid, end, od) for (wid, end, od) in joints.get(key_s, []) if wid != element_id] if len(partners_s) == 1: _wid, _end, other_out = partners_s[0] mdir = _miter_dir(out_s, other_out) if mdir is not None: miter_start = (p_s, mdir) elif len(partners_s) == 0: tj = _detect_t_junction(doc, meta["geschoss"], element_id, p_s) if tj is not None: _oid, b_tan, b_dicke = tj tm = _t_junction_miter(p_s, out_s, b_tan, b_dicke) if tm is not None: miter_start = tm if out_e is not None: key_e = _pt_key(p_e) partners_e = [(wid, end, od) for (wid, end, od) in joints.get(key_e, []) if wid != element_id] if len(partners_e) == 1: _wid, _end, other_out = partners_e[0] mdir = _miter_dir(out_e, other_out) if mdir is not None: miter_end = (p_e, mdir) elif len(partners_e) == 0: tj = _detect_t_junction(doc, meta["geschoss"], element_id, p_e) if tj is not None: _oid, b_tan, b_dicke = tj tm = _t_junction_miter(p_e, out_e, b_tan, b_dicke) if tm is not None: miter_end = tm except Exception as ex: print("[ELEMENTE] wall joints:", ex) # Schichten ermitteln. Layered + nicht-leere Liste → mehrere Breps, # sonst ein einzelnes (solid). layers_def = meta.get("wand_layers") or [] is_layered = bool(meta.get("wand_layered")) and len(layers_def) > 0 if is_layered: layer_breps = _make_wand_layer_breps( geom, layers_def, meta["dicke"], meta.get("referenz", "mid"), uk, ok, miter_start=miter_start, miter_end=miter_end) else: single_brep = _make_volume_geometry( geom, meta["dicke"], uk, ok, meta.get("referenz", "mid"), miter_start=miter_start, miter_end=miter_end) layer_breps = [(single_brep, "", "")] if single_brep else [] # Oeffnungen einsammeln + Cutouts pro Schicht anwenden. opening_jobs = [] cutouts = [] for op_obj, op_meta in _find_openings_for_wall(doc, element_id): pt_geom = op_obj.Geometry pt_loc = None if hasattr(pt_geom, 'Location'): pt_loc = pt_geom.Location elif isinstance(pt_geom, rg.Point3d): pt_loc = pt_geom if pt_loc is None: continue eff_pt = _oeff_effective_axis_point( geom, pt_loc, op_meta["oeff_breite"], op_meta.get("oeff_referenz", "mid")) cutout = _make_oeffnung_cutout( geom, eff_pt, meta["dicke"], op_meta["oeff_breite"], op_meta["oeff_hoehe"], op_meta["oeff_brueest"], uk) if cutout is None: continue cutouts.append(cutout) opening_jobs.append((op_meta, eff_pt, uk)) if cutouts: # Performance: alle Cutouts in EINEM BooleanDifference-Call pro # Schicht subtrahieren (statt N Einzel-Diffs). Bei 3-schichtiger # Wand mit 2 Oeffnungen reduziert das 6 BoolDiff-Ops auf 3. # Toleranz fix 0.001 m = 1 mm. Architektur-Genauigkeit reicht das, # und Boolean-Diff laeuft mit groesserer Toleranz spuerbar schneller # als mit z.B. 0.0001. tol = 0.001 new_layer_breps = [] for (brep, color, lname) in layer_breps: if brep is None: new_layer_breps.append((None, color, lname)); continue try: diff = rg.Brep.CreateBooleanDifference( [brep], cutouts, tol) if diff and len(diff) > 0: brep = diff[0] except Exception as ex: print("[ELEMENTE] BoolDiff layer:", ex) new_layer_breps.append((brep, color, lname)) layer_breps = new_layer_breps # Oeffnungs-Sub-Volumina (Rahmen+Sims+Glas) erzeugen. # Nicht-destruktiv wenn moeglich: wenn die Anzahl der Sub-Volumen gleich # bleibt (z.B. bei Bruestung-/Hoehe-/XY-Aenderung), nutzen wir # `doc.Objects.Replace` auf die existierenden IDs statt Delete+AddBrep. # Damit kollidiert ein laufender `_Move`-Command nicht mehr mit dem # Wand-Regen → kein „Unable to transform"-Fehler mehr. Bei Anzahl- # Aenderung (z.B. Fluegel-Wechsel) Fallback auf Delete+Add. op_layer = _ensure_layer(doc, _layer_path_volume(doc, geschoss_name)) for op_meta, pt_loc, op_uk in opening_jobs: old_objs = list(_find_objects_by_wall_id(doc, op_meta["id"], "oeffnung_volume")) pieces = _make_oeffnung_pieces(geom, pt_loc, meta["dicke"], op_meta, op_uk) if len(old_objs) == len(pieces) and len(pieces) > 0: for (old_obj, _old_meta), pbrep in zip(old_objs, pieces): try: doc.Objects.Replace(old_obj.Id, pbrep) except Exception as ex: print("[ELEMENTE] replace oeff vol:", ex) continue # Fallback: Anzahl hat sich geaendert → alte loeschen + neue adden. for o, _m in old_objs: try: doc.Objects.Delete(o.Id, True) except Exception as ex: print("[ELEMENTE] del old oeff vol:", ex) for pbrep in pieces: op_attrs = Rhino.DocObjects.ObjectAttributes() op_attrs.LayerIndex = op_layer _attach_meta(op_attrs, op_meta["id"], "oeffnung_volume", op_meta["geschoss"], meta["dicke"], "", "", oeff_typ=op_meta.get("oeff_typ"), oeff_parent=op_meta.get("oeff_parent"), oeff_breite=op_meta.get("oeff_breite"), oeff_hoehe=op_meta.get("oeff_hoehe"), oeff_brueest=op_meta.get("oeff_brueest"), oeff_rahmen_b=op_meta.get("oeff_rahmen_b"), oeff_rahmen_tiefe=op_meta.get("oeff_rahmen_tiefe"), oeff_rahmen_pos=op_meta.get("oeff_rahmen_pos"), oeff_fluegel=op_meta.get("oeff_fluegel"), oeff_sims_aus=op_meta.get("oeff_sims_aus"), oeff_sims_in=op_meta.get("oeff_sims_in"), oeff_glas=op_meta.get("oeff_glas"), oeff_referenz=op_meta.get("oeff_referenz")) doc.Objects.AddBrep(pbrep, op_attrs) # Source-Layer migrieren + Volumen-Layer-Index ermitteln layer = _ensure_layer(doc, _layer_path_volume(doc, geschoss_name)) src_layer = _ensure_layer(doc, _layer_path_axis(doc, geschoss_name)) try: if src_layer >= 0 and src_obj.Attributes.LayerIndex != src_layer: new_attrs = src_obj.Attributes.Duplicate() new_attrs.LayerIndex = src_layer doc.Objects.ModifyAttributes(src_obj, new_attrs, True) except Exception as ex: print("[ELEMENTE] migrate src-layer:", ex) # Alle alten wand_volume-Objekte loeschen, neue (1..N) hinzufuegen. for o, _m in _find_objects_by_wall_id(doc, element_id, "wand_volume"): try: doc.Objects.Delete(o.Id, True) except Exception: pass import json as _json layers_json = (_json.dumps(layers_def, ensure_ascii=False) if is_layered else "") # Per-Schicht Material-Lookup: wenn ein Material-Name aus der # Library gesetzt ist, nimm dessen Farbe/Hatch + leg den Brep auf # die Material-Sub-Ebene (Section-Hatch greift dann bei Clipping # Planes). Sonst Fallback auf inline color + Standard-Wand-Layer. for idx, (lbrep, color, lname) in enumerate(layer_breps): if lbrep is None: continue # Material-Name aus dem layers_def-Eintrag (gleicher Index) lay_def = layers_def[idx] if idx < len(layers_def) else {} mat_name = lay_def.get("material", "") if is_layered else "" effective_color = color target_layer = layer if mat_name and mat_name in _MATERIAL_LIBRARY: effective_color = _MATERIAL_LIBRARY[mat_name]["color"] target_layer = _ensure_material_sublayer(doc, geschoss_name, mat_name) if target_layer < 0: target_layer = layer attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = target_layer # Edges immer SCHWARZ — entkoppelt von Sublayer-Farbe und # Material-Diffuse. Sonst werden die Outlines material-faerbig. try: import System.Drawing as SD attrs.ColorSource = ( Rhino.DocObjects.ObjectColorSource.ColorFromObject) attrs.ObjectColor = SD.Color.FromArgb(255, 0, 0, 0) except Exception: pass # Faces via Material (DiffuseColor) — getrennt vom ObjectColor. if effective_color: mat_idx = _ensure_material(doc, effective_color) if mat_idx >= 0: attrs.MaterialIndex = mat_idx attrs.MaterialSource = ( Rhino.DocObjects.ObjectMaterialSource.MaterialFromObject) _attach_meta(attrs, element_id, "wand_volume", meta["geschoss"], meta["dicke"], meta["uk_override"], meta["ok_override"], meta.get("referenz", "mid"), wand_layered=is_layered, wand_layers=layers_json, wand_layer_idx=idx) try: doc.Objects.AddBrep(lbrep, attrs) except Exception as ex: print("[ELEMENTE] AddBrep wand layer:", ex) return True elif meta["type"] == "decke_outline": uk, ok = _resolve_decke_z(doc, meta["geschoss"], meta["dicke"], meta["uk_override"], meta["ok_override"]) # Aussparungs-Outline-Curves einsammeln und direkt an # _make_decke_volume durchreichen — der baut den Slab mit den # Loechern via planar Brep + Extrusion (kein BoolDiff). aps = _find_aussparungen_for_decke(doc, element_id) hole_curves = [] for ap_obj, ap_meta in aps: ap_geom = ap_obj.Geometry if isinstance(ap_geom, rg.Curve) and ap_geom.IsClosed: hole_curves.append(ap_geom) else: print("[ELEMENTE] Aussparung", ap_meta["id"], "Source ist keine geschlossene Curve — uebersprungen") brep = _make_decke_volume(geom, meta["dicke"], uk, ok, hole_curves) vol_type = "decke_volume" layer = _ensure_layer(doc, _layer_path_decke(doc, geschoss_name)) src_layer = layer elif meta["type"] == "dach_outline": base = _resolve_dach_base(doc, meta["geschoss"], meta["uk_override"]) dt = meta.get("dach_typ", "pult") if dt == "sattel": brep = _make_satteldach_brep(geom, meta["dicke"], base, meta.get("neigung", 30.0)) elif dt == "walm": brep = _make_walmdach_brep(geom, meta["dicke"], base, meta.get("neigung", 30.0)) elif dt == "mansarde": brep = _make_mansardendach_brep( geom, meta["dicke"], base, meta.get("neigung", 30.0), meta.get("neigung_unten", 60.0), meta.get("knick_h", 2.0), variante=meta.get("dach_variante", "walm")) else: # pult brep = _make_pultdach_volume(geom, meta["dicke"], base, meta.get("neigung", 30.0), meta.get("eave_idx", 0)) vol_type = "dach_volume" layer = _ensure_layer(doc, _layer_path_dach(doc, geschoss_name)) src_layer = layer elif meta["type"] == "treppe_axis": # Start- und Zielgeschoss → uk/ok aus OKFF-Differenz. # H-Override hat Vorrang vor Zielgeschoss. g_start = _geschoss_by_id(doc, meta["geschoss"]) g_end = _geschoss_by_id(doc, meta.get("geschoss_end", "")) if g_start is None: return False uk = float(g_start.get("okff", 0.0)) h_over = meta.get("treppe_h_over", "") if h_over: try: ok = uk + float(h_over) except Exception: ok = uk + float(g_start.get("hoehe", 3.0)) elif g_end is not None: ok = float(g_end.get("okff", uk + 3.0)) else: ok = uk + float(g_start.get("hoehe", 3.0)) art = meta.get("treppe_art", "gerade") if art == "l": brep = _make_treppe_l_volume(geom, meta.get("treppe_breite", 1.0), meta.get("treppe_referenz", "mid"), meta.get("treppe_n", 15), uk, ok, modus=meta.get("treppe_modus", "flach"), lauf_d=meta.get("treppe_lauf_d", 0.18)) elif art == "wendel": brep = _make_treppe_wendel_volume( geom, meta.get("treppe_breite", 1.0), meta.get("treppe_referenz", "mid"), meta.get("treppe_n", 15), uk, ok, modus=meta.get("treppe_modus", "flach"), lauf_d=meta.get("treppe_lauf_d", 0.18)) else: brep = _make_treppe_volume(geom, meta.get("treppe_breite", 1.0), meta.get("treppe_referenz", "mid"), meta.get("treppe_n", 15), uk, ok, modus=meta.get("treppe_modus", "flach"), lauf_d=meta.get("treppe_lauf_d", 0.18)) vol_type = "treppe_volume" layer = _ensure_layer(doc, _layer_path_treppe(doc, geschoss_name)) src_layer = layer elif meta["type"] == "stuetze_point": # Punkt-Geometrie: aus dem geom (Point oder Point3d) Location holen if isinstance(geom, rg.Point): pt = geom.Location else: pt = geom # Point3d g_start = _geschoss_by_id(doc, meta["geschoss"]) uk = float(g_start.get("okff", 0.0)) if g_start else 0.0 z_over = meta.get("trag_z_over", "") if z_over: try: ok = uk + float(z_over) except Exception: ok = uk + float(g_start.get("hoehe", 3.0)) if g_start else uk + 3.0 else: ok = uk + float(g_start.get("hoehe", 3.0)) if g_start else uk + 3.0 brep = _make_stuetze_volume(pt, meta.get("trag_profil", "quadrat"), meta.get("trag_b", 0.25), meta.get("trag_h", 0.25), meta.get("trag_d", 0.25), meta.get("trag_t", 0.01), meta.get("trag_angle", 0.0), uk, ok) vol_type = "stuetze_volume" layer = _ensure_layer(doc, _layer_path_tragwerk(doc, geschoss_name)) src_layer = layer elif meta["type"] == "traeger_axis": # Achse + z_top aus Geschoss + Override g_start = _geschoss_by_id(doc, meta["geschoss"]) uk = float(g_start.get("okff", 0.0)) if g_start else 0.0 h = float(g_start.get("hoehe", 3.0)) if g_start else 3.0 z_over = meta.get("trag_z_over", "") kind = meta.get("trag_kind", "traeger") if z_over: try: z_top = uk + float(z_over) except Exception: z_top = uk + h # an OK Geschoss else: # Unterzug haengt unter Deckenoberkante (uk + h), # Traeger sitzt mit Oberkante an OK Geschoss z_top = uk + h brep = _make_traeger_volume(geom, meta.get("trag_profil", "rechteck"), meta.get("trag_b", 0.20), meta.get("trag_h", 0.40), meta.get("trag_d", 0.25), meta.get("trag_t", 0.01), meta.get("trag_angle", 0.0), z_top) vol_type = "traeger_volume" layer = _ensure_layer(doc, _layer_path_tragwerk(doc, geschoss_name)) src_layer = layer elif meta["type"] == "raum_outline": # Raum: Source = geschlossene Outline; Volumes = TextEntity (Stempel) # + optional Brep-Fuellung (SIA-Modus). Geht NICHT durch den # Brep-Replace-Pfad — wird direkt hier emittiert. layer = _ensure_layer(doc, _layer_path_raum(doc, geschoss_name)) src_layer = layer # Source-Outline ggf. auf richtigen Layer migrieren try: if src_layer >= 0 and src_obj.Attributes.LayerIndex != src_layer: new_attrs = src_obj.Attributes.Duplicate() new_attrs.LayerIndex = src_layer doc.Objects.ModifyAttributes(src_obj, new_attrs, True) except Exception: pass area, perim, ctr = _raum_amp(geom) # Z-Lage: auf Geschoss-OKFF g_start = _geschoss_by_id(doc, meta["geschoss"]) z_uk = float(g_start.get("okff", 0.0)) if g_start else 0.0 # Alte Stempel + Fills loeschen for o, _m in _find_objects_by_wall_id(doc, element_id, "raum_stamp"): try: doc.Objects.Delete(o.Id, True) except Exception: pass for o, _m in _find_objects_by_wall_id(doc, element_id, "raum_fill"): try: doc.Objects.Delete(o.Id, True) except Exception: pass # Defensiv: auch die Hatch loeschen die via curve.ebenen_fill_hatch_id # verlinkt ist — falls die Metadaten-UserStrings aus irgendeinem Grund # verloren gingen (z.B. nach Override-Replace), wuerde der Loop oben # sie nicht finden und es bliebe eine Geister-Hatch zurueck. try: hid_s = src_obj.Attributes.GetUserString("ebenen_fill_hatch_id") if hid_s: old_h = doc.Objects.FindId(System.Guid(hid_s)) if old_h is not None and not old_h.IsDeleted: doc.Objects.Delete(old_h.Id, True) except Exception: pass # Fuell-Hatch: Pattern aus raum_fuellung (string). Sonderfaelle: # "" = kein Hatch (User will leeren Raum) # "ByLayer" = Hatch mit ColorSource=ByLayer, Pattern=Solid # sonst = Pattern-Name (Solid, Hatch1, Beton, …) # SIA-Modus: wenn aktiv UND Raum klassifiziert, IMMER Solid erzwingen # damit der Override eine Flaeche zum Einfaerben hat. fuell_raw = meta.get("raum_fuellung", "") sia_code = meta.get("raum_sia") or "" force_solid = (_sia_fill_enabled(doc) and sia_code in _SIA_COLORS_HEX) by_layer = (fuell_raw == "ByLayer") pattern_for_hatch = "" if force_solid: pattern_for_hatch = "Solid" elif fuell_raw: pattern_for_hatch = "Solid" if by_layer else fuell_raw new_hatch_id = None hatch_geom = (_make_raum_hatch(geom, z_uk, doc, pattern_for_hatch) if pattern_for_hatch else None) if hatch_geom is not None: try: import System.Drawing as SD h_attrs = Rhino.DocObjects.ObjectAttributes() h_attrs.LayerIndex = layer # Default-Farbe = sehr helles Grau (wirkt fast transparent) default_col = SD.Color.FromArgb(255, 245, 245, 245) if by_layer and not force_solid: # Hatch folgt Layerfarbe (Standard-Rhino-Verhalten) h_attrs.ColorSource = ( Rhino.DocObjects.ObjectColorSource.ColorFromLayer) elif force_solid: # SIA-Modus aktiv + Raum klassifiziert → DIREKT mit SIA- # Farbe erstellen. Zusaetzlich Override-Backup-UserStrings # setzen, damit restore_all beim Toggle-Off auf Default # zurueckkommt. hex_str = _SIA_COLORS_HEX[sia_code] h = hex_str.lstrip("#") r = int(h[0:2], 16); g = int(h[2:4], 16); bl = int(h[4:6], 16) h_attrs.ColorSource = ( Rhino.DocObjects.ObjectColorSource.ColorFromObject) h_attrs.ObjectColor = SD.Color.FromArgb(255, r, g, bl) # Override-Backup setzen: bei Restore zurueck auf # ColorFromObject + #f5f5f5 (Standard-Rhino-Verhalten). h_attrs.SetUserString("dossier_or_hatch_csrc", "1") h_attrs.SetUserString("dossier_or_hatch_color", "#f5f5f5") h_attrs.SetUserString("dossier_or_hatch_color_done", "1") else: # User hat Pattern gewaehlt (z.B. "Solid"), kein SIA → # heller Default damit's dezent wirkt. h_attrs.ColorSource = ( Rhino.DocObjects.ObjectColorSource.ColorFromObject) h_attrs.ObjectColor = default_col # WICHTIG: keine raum_* UserStrings auf die Hatch setzen. # Sonst matcht der Override-Engine die Hatch DIREKT zusaetzlich # zum Curve-Pfad → doppelte Backup-Strings, korruptes Restore. _attach_meta(h_attrs, element_id, "raum_fill", meta["geschoss"], 0.0, "", "", "mid") new_hatch_id = doc.Objects.AddHatch(hatch_geom, h_attrs) # Backup-UserStrings im SIA-Fall nochmal POST-AddHatch # setzen — manche IronPython3/Rhino-Versionen verlieren # einzelne UserStrings ueber den AddHatch-Roundtrip. if (force_solid and new_hatch_id is not None and new_hatch_id != System.Guid.Empty): new_h = doc.Objects.FindId(new_hatch_id) if new_h is not None: fresh = new_h.Attributes.Duplicate() fresh.SetUserString("dossier_or_hatch_csrc", "1") fresh.SetUserString("dossier_or_hatch_color", "#f5f5f5") fresh.SetUserString("dossier_or_hatch_color_done", "1") doc.Objects.ModifyAttributes(new_h, fresh, True) except Exception as ex: print("[ELEMENTE] Raum AddHatch:", ex) # Hatch via Gestaltung-Konvention an die Curve linken (UserString # `ebenen_fill_hatch_id` auf der Source) — damit das Override- # System ihn ueber hatchPattern/-Color-Actions modifizieren kann. if new_hatch_id is not None and new_hatch_id != System.Guid.Empty: try: src_attrs = src_obj.Attributes.Duplicate() src_attrs.SetUserString("ebenen_fill_hatch_id", str(new_hatch_id)) doc.Objects.ModifyAttributes(src_obj, src_attrs, True) except Exception as ex: print("[ELEMENTE] Raum link hatch:", ex) # Override-Engine zusaetzlich anstossen (Safety-Net + Pattern- # Override). Der ModifyAttributes-Aufruf oben loest KEIN # ReplaceRhinoObject-Event aus, also wuerde der Override-Listener # die neue Hatch sonst nicht erreichen. Bei force_solid haben wir # die Farbe schon direkt gesetzt — das hier wirkt idempotent. try: import overrides as _ov cfg = _ov.load_config(doc) if cfg.get("enabled") and cfg.get("rules"): cur = doc.Objects.FindId(src_obj.Id) if cur is not None: was = sc.sticky.get("overrides_applying", False) sc.sticky["overrides_applying"] = True try: _ov._apply_to_single_object(doc, cur) finally: sc.sticky["overrides_applying"] = was except Exception as ex: print("[ELEMENTE] override re-apply:", ex) # Stempel te = _make_raum_stamp_text( ctr, meta.get("raum_name", "Raum"), meta.get("raum_nummer", ""), meta.get("raum_funktion", ""), area, meta.get("raum_rundung", "0.1"), meta.get("raum_txt_h", 0.20), z=z_uk, align=meta.get("raum_align", "mid")) if te is None: return True # Outline evtl. offen — Source behalten attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = layer _attach_meta(attrs, element_id, "raum_stamp", meta["geschoss"], 0.0, "", "", "mid", raum_name=meta.get("raum_name"), raum_nummer=meta.get("raum_nummer"), raum_funktion=meta.get("raum_funktion"), raum_rundung=meta.get("raum_rundung"), raum_txt_h=meta.get("raum_txt_h"), raum_align=meta.get("raum_align"), raum_sia=meta.get("raum_sia"), raum_fuellung=meta.get("raum_fuellung")) try: doc.Objects.AddText(te, attrs) except Exception as ex: print("[ELEMENTE] Raum AddText:", ex) return True else: return False # Migration: Source-Objekt (Achse/Outline) auf den aktuellen Layer # schieben, falls es noch auf einem alten Layer steht (z.B. von einer # frueheren Bug-Version auf "01_WAND" / "06_3D_VOLUMEN") try: if src_layer >= 0 and src_obj.Attributes.LayerIndex != src_layer: new_attrs = src_obj.Attributes.Duplicate() new_attrs.LayerIndex = src_layer doc.Objects.ModifyAttributes(src_obj, new_attrs, True) except Exception as ex: print("[ELEMENTE] migrate src-layer:", ex) if brep is None: return False vol_obj = _find_target_volume(doc, element_id) attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = layer _attach_meta(attrs, element_id, vol_type, meta["geschoss"], meta["dicke"], meta["uk_override"], meta["ok_override"], meta.get("referenz", "mid"), neigung=meta.get("neigung"), eave_idx=meta.get("eave_idx"), dach_typ=meta.get("dach_typ"), neigung_unten=meta.get("neigung_unten"), knick_h=meta.get("knick_h"), dach_variante=meta.get("dach_variante"), geschoss_end=meta.get("geschoss_end"), treppe_breite=meta.get("treppe_breite"), treppe_n=meta.get("treppe_n"), treppe_referenz=meta.get("treppe_referenz"), treppe_modus=meta.get("treppe_modus"), treppe_lauf_d=meta.get("treppe_lauf_d"), treppe_art=meta.get("treppe_art"), treppe_h_over=meta.get("treppe_h_over"), treppe_soll=meta.get("treppe_soll"), trag_kind=meta.get("trag_kind"), trag_profil=meta.get("trag_profil"), trag_b=meta.get("trag_b"), trag_h=meta.get("trag_h"), trag_d=meta.get("trag_d"), trag_t=meta.get("trag_t"), trag_angle=meta.get("trag_angle"), trag_z_over=meta.get("trag_z_over"), raum_name=meta.get("raum_name"), raum_nummer=meta.get("raum_nummer"), raum_funktion=meta.get("raum_funktion"), raum_rundung=meta.get("raum_rundung"), raum_txt_h=meta.get("raum_txt_h"), raum_align=meta.get("raum_align"), raum_sia=meta.get("raum_sia"), raum_fuellung=meta.get("raum_fuellung")) if vol_obj is not None: doc.Objects.Replace(vol_obj.Id, brep) vol_obj_new = doc.Objects.Find(vol_obj.Id) if vol_obj_new is not None: vol_obj_new.Attributes = attrs vol_obj_new.CommitChanges() else: doc.Objects.AddBrep(brep, attrs) return True # Alias fuer Backwards-Compat / interne Aufrufer _regenerate_volume = _regenerate_element # --- Bridge ----------------------------------------------------------------- class ElementeBridge(panel_base.BaseBridge): def __init__(self): panel_base.BaseBridge.__init__(self, "elemente") self._last_selection_ids = () # SIA-Praeset einmalig in den cross-doc Presets-Store legen, damit es # im Overrides-Panel direkt sichtbar ist (force=False ⇒ User-Anpassungen # bleiben erhalten). try: _ensure_sia_preset(force=False) except Exception: pass def _on_ready(self): self._send_state() 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 = {} if t == "READY": self._on_ready() elif t == "LIST": self._send_state() elif t == "CREATE_WALL": self._cmd_create_wall(p) elif t == "CREATE_DECKE": self._cmd_create_decke(p) elif t == "CREATE_AUSSPARUNG": self._cmd_create_aussparung(p) elif t == "CREATE_DACH": self._cmd_create_dach(p) elif t == "CREATE_FENSTER": self._cmd_create_oeffnung(p, "fenster") elif t == "CREATE_TUER": self._cmd_create_oeffnung(p, "tuer") elif t == "CREATE_TREPPE": self._cmd_create_treppe(p) elif t == "CREATE_STUETZE": self._cmd_create_stuetze(p) elif t == "CREATE_TRAEGER": self._cmd_create_traeger(p) elif t == "CREATE_RAUM": self._cmd_create_raum(p) elif t == "EXPORT_RAEUME": self._cmd_export_raeume(p) elif t == "OPEN_SWISSTOPO": self._cmd_open_swisstopo(p) elif t == "IMPORT_SWISSTOPO": self._cmd_import_swisstopo(p) elif t == "OPEN_SWISSTOPO_DIALOG": self._cmd_open_swisstopo_dialog(p) elif t == "OPEN_OSM_DIALOG": self._cmd_open_osm_dialog(p) elif t == "UPDATE_WALL": self._update_wall(p) elif t == "UPDATE_ELEMENT": self._update_wall(p) # gleiche Logik fuer alle elif t == "DELETE_WALL": self._delete_wall(p.get("id")) elif t == "DELETE_ELEMENT": self._delete_wall(p.get("id")) elif t == "REGENERATE_ALL": self._regenerate_all() def _notify_active_geschoss(self): """Schlanker Partial-Push: nur activeGeschoss + activeGeschossName. Wird vom Ebenen-Bridge bei Geschoss-Wechsel gerufen — die Element- Liste ist davon nicht betroffen, ein voller _send_state mit Re- Enumeration aller Smart-Elemente (200+ in echten Projekten) waere teuer und unnoetig. React-State macht Shallow-Merge, der Rest des States bleibt.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return try: self.send("STATE", { "activeGeschoss": _active_geschoss_id(doc), "activeGeschossName": _active_geschoss_name(doc), }) except Exception as ex: print("[ELEMENTE] _notify_active_geschoss:", ex) def _send_state(self): doc = Rhino.RhinoDoc.ActiveDoc if doc is None: self.send("STATE", {"elements": [], "geschosse": [], "selection": None}) return geschosse = _load_geschosse(doc) # Alle Source-Objekte (Achsen + Outlines) durchgehen elements = [] seen_ids = set() for obj in doc.Objects: meta = _read_meta(obj) if meta is None: continue if meta["type"] not in SOURCE_TYPES: continue if meta["id"] in seen_ids: continue seen_ids.add(meta["id"]) g = _geschoss_by_id(doc, meta["geschoss"]) geschoss_name = g.get("name", "?") if g else "?" selected = obj.IsSelected(False) > 0 base = { "id": meta["id"], "objectId": str(obj.Id), "geschoss": meta["geschoss"], "geschossName": geschoss_name, "dicke": meta["dicke"], "ukOverride": meta["uk_override"], "okOverride": meta["ok_override"], "selected": selected, } if meta["type"] == "wand_axis": uk, ok = _resolve_uk_ok(doc, meta["geschoss"], meta["uk_override"], meta["ok_override"]) base.update({ "kind": "wand", "referenz": meta.get("referenz", "mid"), "uk": uk, "ok": ok, "layered": bool(meta.get("wand_layered", False)), "layers": meta.get("wand_layers", []), }) elif meta["type"] == "decke_outline": uk, ok = _resolve_decke_z(doc, meta["geschoss"], meta["dicke"], meta["uk_override"], meta["ok_override"]) base.update({ "kind": "decke", "uk": uk, "ok": ok, }) elif meta["type"] == "dach_outline": base_h = _resolve_dach_base(doc, meta["geschoss"], meta["uk_override"]) base.update({ "kind": "dach", "uk": base_h, "ok": base_h, "neigung": meta.get("neigung", 30.0), "eaveIdx": meta.get("eave_idx", 0), "dachTyp": meta.get("dach_typ", "pult"), "neigungUnten": meta.get("neigung_unten", 60.0), "knickH": meta.get("knick_h", 2.0), "dachVariante": meta.get("dach_variante", "walm"), }) elif meta["type"] == "decke_aussparung_outline": try: area, perim, _ctr = _raum_amp(obj.Geometry) except Exception: area, perim = 0.0, 0.0 base.update({ "kind": "aussparung", "parentId": meta.get("aussp_parent", ""), "area": area, "umfang": perim, }) elif meta["type"] == "oeffnung_point": base.update({ "kind": meta.get("oeff_typ", "fenster"), "parentId": meta.get("oeff_parent", ""), "breite": meta.get("oeff_breite", 1.0), "hoehe": meta.get("oeff_hoehe", 1.4), "brueest": meta.get("oeff_brueest", 0.9), "rahmenB": meta.get("oeff_rahmen_b", 0.06), "rahmenTiefe": meta.get("oeff_rahmen_tiefe", 0.08), "rahmenPos": meta.get("oeff_rahmen_pos", "mid"), "fluegel": meta.get("oeff_fluegel", 1), "simsAus": meta.get("oeff_sims_aus", "ohne"), "simsIn": meta.get("oeff_sims_in", "ohne"), "glas": bool(meta.get("oeff_glas", False)), "oeffReferenz": meta.get("oeff_referenz", "mid"), }) elif meta["type"] == "treppe_axis": gs = _geschoss_by_id(doc, meta["geschoss"]) ge = _geschoss_by_id(doc, meta.get("geschoss_end", "")) try: uk = float(gs.get("okff", 0.0)) if gs else 0.0 except Exception: uk = 0.0 hov = meta.get("treppe_h_over", "") if hov: try: ok = uk + float(hov) except Exception: ok = uk + 3.0 elif ge is not None: try: ok = float(ge.get("okff", uk + 3.0)) except Exception: ok = uk + 3.0 else: try: ok = uk + float(gs.get("hoehe", 3.0)) if gs else uk + 3.0 except Exception: ok = uk + 3.0 # Lauflinien-Laenge aus dem Source-Curve try: lauf_len = float(obj.Geometry.GetLength()) except Exception: lauf_len = 0.0 base.update({ "kind": "treppe", "geschossEnd": meta.get("geschoss_end", ""), "geschossEndName": (ge.get("name") if ge else ""), "breite": meta.get("treppe_breite", 1.0), "nStufen": meta.get("treppe_n", 15), "treppeReferenz": meta.get("treppe_referenz", "mid"), "treppeModus": meta.get("treppe_modus", "flach"), "treppeArt": meta.get("treppe_art", "gerade"), "laufD": meta.get("treppe_lauf_d", 0.18), "laufLen": lauf_len, "uk": uk, "ok": ok, "hOver": meta.get("treppe_h_over", ""), "soll": meta.get("treppe_soll", _TREPPE_SOLL_DEFAULT), }) elif meta["type"] == "raum_outline": # Raum: Flaeche + Umfang aus der Outline-Curve try: g_obj = obj.Geometry area, perim, _ctr = _raum_amp(g_obj) except Exception: area, perim = 0.0, 0.0 rnd = meta.get("raum_rundung", "0.1") base.update({ "kind": "raum", "name": meta.get("raum_name", "Raum"), "nummer": meta.get("raum_nummer", ""), "funktion": meta.get("raum_funktion", ""), "rundung": rnd, "txtH": meta.get("raum_txt_h", 0.20), "align": meta.get("raum_align", "mid"), "sia": meta.get("raum_sia", ""), "fuellung": bool(meta.get("raum_fuellung", False)), "area": area, "areaFmt": _format_area(area, rnd), "umfang": perim, }) elif meta["type"] in ("stuetze_point", "traeger_axis"): # Tragwerk: Stuetze (Punkt) oder Traeger/Unterzug (Achse) gs = _geschoss_by_id(doc, meta["geschoss"]) try: uk = float(gs.get("okff", 0.0)) if gs else 0.0 except Exception: uk = 0.0 try: h_g = float(gs.get("hoehe", 3.0)) if gs else 3.0 except Exception: h_g = 3.0 zov = meta.get("trag_z_over", "") if zov: try: ok_val = uk + float(zov) except Exception: ok_val = uk + h_g else: ok_val = uk + h_g kind_str = meta.get("trag_kind", "") # Fallback: ohne Kind aus type ableiten if not kind_str: kind_str = ("stuetze" if meta["type"] == "stuetze_point" else "traeger") axis_len = 0.0 if meta["type"] == "traeger_axis": try: axis_len = float(obj.Geometry.GetLength()) except Exception: axis_len = 0.0 base.update({ "kind": kind_str, # "stuetze" | "traeger" | "unterzug" "profil": meta.get("trag_profil", "quadrat"), "b": meta.get("trag_b", 0.25), "h": meta.get("trag_h", 0.25), "d": meta.get("trag_d", 0.25), "t": meta.get("trag_t", 0.01), "angle": meta.get("trag_angle", 0.0), "zOver": meta.get("trag_z_over", ""), "uk": uk, "ok": ok_val, "axisLen": axis_len, }) elements.append(base) sel_id = next((e["id"] for e in elements if e["selected"]), None) self.send("STATE", { "elements": elements, "geschosse": [{"id": g.get("id"), "name": g.get("name")} for g in geschosse if isinstance(g, dict)], "selection": sel_id, "activeGeschoss": _active_geschoss_id(doc), "activeGeschossName": _active_geschoss_name(doc), "siaFillMode": _sia_fill_enabled(doc), "hatchPatterns": _list_hatch_patterns(doc), "materials": [ {"name": n, "color": m["color"], "hatch": m.get("hatch", ""), "scale": m.get("scale", 1.0)} for n, m in _MATERIAL_LIBRARY.items()], }) # --- Wand-Befehle ------------------------------------------------------- def _cmd_create_wall(self, p): """Interaktive Wand-Erzeugung mit Modus-Auswahl. Modi: - Polylinie: mehrere Punkte, Enter / Klick auf letzten = fertig - Linie: 2 Punkte, danach automatisch fertig - Spline: mehrere Kontrollpunkte, Enter = fertig, Achse wird ein interpolierter NURBS - Bogen: 3-Punkt-Bogen (Anfang, Mittelpunkt, Ende) Plus Optionen: Referenz, Dicke.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return geschoss = p.get("geschoss") or _active_geschoss_id(doc) if not geschoss: print("[ELEMENTE] Kein Geschoss aktiv"); return # Last-Used: Werte ueberleben den Befehl. Frontend kann ueberschreiben, # sonst nehmen wir den letzten Wert aus der Session. d_in = p.get("dicke") try: dicke = float(d_in) if d_in else _last("wand_dicke", 0.25) except Exception: dicke = _last("wand_dicke", 0.25) uk_over = p.get("ukOverride", "") ok_over = p.get("okOverride", "") referenz = p.get("referenz") or _last("wand_referenz", "mid") try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception as ex: print("[ELEMENTE] Imports:", ex); return modi = ["Polylinie", "Linie", "Rechteck", "Spline", "Bogen"] modus = p.get("modus") or _last("wand_modus", "Polylinie") if modus not in modi: modus = "Polylinie" ref_codes = ["mid", "left", "right"] ref_labels = ["Mittig", "Links", "Rechts"] try: ref_idx = ref_codes.index(referenz) except ValueError: ref_idx = 0 def _build_prompt(base): return "{} [Modus={}, Referenz={}, Dicke={:.3f}]".format( base, modus, ref_labels[ref_idx], dicke) first_pt = None try: while True: gp = ric.GetPoint() gp.SetCommandPrompt(_build_prompt("Wand: Startpunkt")) opt_modus = gp.AddOptionList("Modus", modi, modi.index(modus)) opt_ref = gp.AddOptionList("Referenz", ref_labels, ref_idx) opt_dicke = gp.AddOption("Dicke") res = gp.Get() if res == GetResult.Option: if gp.OptionIndex() == opt_modus: try: modus = modi[gp.Option().CurrentListOptionIndex] except Exception: pass elif gp.OptionIndex() == opt_ref: try: ref_idx = gp.Option().CurrentListOptionIndex except Exception: pass elif gp.OptionIndex() == opt_dicke: try: gn = ric.GetNumber() gn.SetCommandPrompt("Wand-Dicke") gn.SetDefaultNumber(dicke) gn.SetLowerLimit(0.01, False) if gn.Get() == GetResult.Number: dicke = float(gn.Number()) except Exception as ex: print("[ELEMENTE] GetNumber:", ex) continue if res != GetResult.Point: return first_pt = gp.Point() break except Exception as ex: print("[ELEMENTE] wand first-point:", ex); return referenz = ref_codes[ref_idx] try: if modus == "Rechteck": axes = self._collect_wall_rectangle(doc, first_pt, dicke, referenz) if not axes: print("[ELEMENTE] Rechteck abgebrochen"); return for ac in axes: self._make_wall_from_axis(doc, ac, geschoss, dicke, uk_over, ok_over, referenz) _save_last(wand_dicke=dicke, wand_referenz=referenz, wand_modus=modus) self._send_state() return if modus == "Polylinie": axis_curve = self._collect_wall_polyline(doc, first_pt, dicke, referenz, ends_after=None) elif modus == "Linie": axis_curve = self._collect_wall_polyline(doc, first_pt, dicke, referenz, ends_after=1) elif modus == "Spline": axis_curve = self._collect_wall_spline(doc, first_pt, dicke, referenz) elif modus == "Bogen": axis_curve = self._collect_wall_arc(doc, first_pt, dicke, referenz) else: axis_curve = None except Exception as ex: print("[ELEMENTE] wand collect:", ex); return if axis_curve is None: print("[ELEMENTE] keine gueltige Achse"); return self._make_wall_from_axis(doc, axis_curve, geschoss, dicke, uk_over, ok_over, referenz) _save_last(wand_dicke=dicke, wand_referenz=referenz, wand_modus=modus) self._send_state() def _collect_wall_polyline(self, doc, first_pt, dicke, referenz, ends_after=None): """Sammelt eine Polyline-Achse. ends_after=N: nach N weiteren Punkten automatisch beenden (1 = klassische Linie aus 2 Punkten).""" try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception: return None points = [first_pt] tol = max(doc.ModelAbsoluteTolerance, 1e-4) while True: if ends_after is not None and len(points) > ends_after: break gp = ric.GetPoint() label = "Endpunkt" if ends_after == 1 else \ "Naechster Punkt (Enter = fertig)" gp.SetCommandPrompt(label) if ends_after != 1: gp.AcceptNothing(True) try: gp.SetBasePoint(points[-1], True) except Exception: pass try: preview = _make_preview_handler(list(points), dicke, referenz) gp.DynamicDraw += preview except Exception: pass res = gp.Get() if res == GetResult.Nothing: break if res != GetResult.Point: break pt = gp.Point() if pt.DistanceTo(points[-1]) < tol and ends_after != 1: break points.append(pt) if len(points) < 2: return None pts3d = [rg.Point3d(p.X, p.Y, 0) for p in points] if len(pts3d) == 2: return rg.LineCurve(pts3d[0], pts3d[1]) return rg.PolylineCurve(rg.Polyline(pts3d)) def _collect_wall_spline(self, doc, first_pt, dicke, referenz): """Spline-Wand: interpolierter NURBS durch Kontrollpunkte. Live-Preview zeigt Kurve + Wand-Kanten.""" try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception: return None points = [first_pt] tol = max(doc.ModelAbsoluteTolerance, 1e-4) while True: gp = ric.GetPoint() gp.SetCommandPrompt("Spline-Kontrollpunkt (Enter = fertig)") gp.AcceptNothing(True) try: gp.SetBasePoint(points[-1], True) except Exception: pass try: preview = _make_spline_preview_handler(list(points), dicke, referenz) gp.DynamicDraw += preview except Exception: pass res = gp.Get() if res == GetResult.Nothing: break if res != GetResult.Point: break pt = gp.Point() if pt.DistanceTo(points[-1]) < tol: break points.append(pt) if len(points) < 2: return None pts3d = [rg.Point3d(p.X, p.Y, 0) for p in points] if len(pts3d) == 2: return rg.LineCurve(pts3d[0], pts3d[1]) try: return rg.Curve.CreateInterpolatedCurve(pts3d, 3) except Exception: return rg.PolylineCurve(rg.Polyline(pts3d)) def _collect_wall_arc(self, doc, first_pt, dicke, referenz): """3-Punkt-Bogen mit Live-Preview ueber 2 Phasen (Mittel- und Endpunkt).""" try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception: return None # Phase 1: Punkt auf dem Bogen gp = ric.GetPoint() gp.SetCommandPrompt("Punkt auf dem Bogen") try: gp.SetBasePoint(first_pt, True) except Exception: pass try: preview = _make_arc_preview_handler(first_pt, None, dicke, referenz) gp.DynamicDraw += preview except Exception: pass res = gp.Get() if res != GetResult.Point: return None mid = gp.Point() # Phase 2: Endpunkt — mit Bogen-Preview gp = ric.GetPoint() gp.SetCommandPrompt("Endpunkt") try: gp.SetBasePoint(mid, True) except Exception: pass try: preview = _make_arc_preview_handler(first_pt, mid, dicke, referenz) gp.DynamicDraw += preview except Exception: pass res = gp.Get() if res != GetResult.Point: return None end = gp.Point() p0 = rg.Point3d(first_pt.X, first_pt.Y, 0) p1 = rg.Point3d(mid.X, mid.Y, 0) p2 = rg.Point3d(end.X, end.Y, 0) arc = rg.Arc(p0, p1, p2) if not arc.IsValid: return None return rg.ArcCurve(arc) def _collect_wall_rectangle(self, doc, c1, dicke, referenz): """Rechteck-Wand: zweite (diagonale) Ecke. Liefert Liste von 4 Line-Curves (eine pro Seite) — vier eigenstaendige Waende.""" try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception: return None gp = ric.GetPoint() gp.SetCommandPrompt("Gegenueberliegende Ecke") try: gp.SetBasePoint(c1, True) except Exception: pass try: preview = _make_rectangle_wall_preview_handler(c1, dicke, referenz) gp.DynamicDraw += preview except Exception: pass res = gp.Get() if res != GetResult.Point: return None c2 = gp.Point() p1 = rg.Point3d(c1.X, c1.Y, 0) p2 = rg.Point3d(c2.X, c1.Y, 0) p3 = rg.Point3d(c2.X, c2.Y, 0) p4 = rg.Point3d(c1.X, c2.Y, 0) # Im Uhrzeigersinn — Referenz "links" landet damit innen, "rechts" aussen return [ rg.LineCurve(p1, p2), rg.LineCurve(p2, p3), rg.LineCurve(p3, p4), rg.LineCurve(p4, p1), ] def _make_wall_from_axis(self, doc, axis_curve, geschoss_id, dicke, uk_over, ok_over, referenz): """Erzeugt Wand aus beliebiger Achsen-Curve. Performance-Wrap: - BeginUndoRecord → eine Undo-Aktion fuer die ganze Wand-Erstellung (statt mehreren kleinen fuer AddCurve + jedes Layer-Brep) - Views.RedrawEnabled=False waehrend der Regen-Phase → ein einziger Redraw am Ende statt einer pro Add/Delete-Op""" wall_id = "wall_" + uuid.uuid4().hex[:10] g = _geschoss_by_id(doc, geschoss_id) geschoss_name = g.get("name", "EG") if g else "EG" axis_layer = _ensure_layer(doc, _layer_path_axis(doc, geschoss_name)) axis = axis_curve.DuplicateCurve() try: z0 = axis.PointAtStart.Z if abs(z0) > 1e-6: axis.Transform(rg.Transform.Translation(0, 0, -z0)) except Exception: pass attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = axis_layer _attach_meta(attrs, wall_id, "wand_axis", geschoss_id, dicke, uk_over, ok_over, referenz) undo_serial = doc.BeginUndoRecord("Wand erstellen") prev_redraw = doc.Views.RedrawEnabled doc.Views.RedrawEnabled = False try: with _TimedBlock("Wand AddCurve + Regen"): if doc.Objects.AddCurve(axis, attrs) == System.Guid.Empty: print("[ELEMENTE] Wand AddCurve fehlgeschlagen"); return # Joint-Cache invalidieren — neue Wand-Achse ist im Doc. _invalidate_joints_cache(geschoss_id) _regenerate_element(doc, wall_id) # Eckverbindungen/T-Stoesse: abhaengige Nachbarn regen. try: deps = _find_dependent_walls(doc, geschoss_id, wall_id, None, axis) for wid in deps: try: _regenerate_element(doc, wid) except Exception as ex: print("[ELEMENTE] dep regen:", ex) except Exception as ex: print("[ELEMENTE] deps:", ex) finally: doc.Views.RedrawEnabled = prev_redraw try: doc.EndUndoRecord(undo_serial) except Exception: pass doc.Views.Redraw() print("[ELEMENTE] Wand erzeugt: {}".format(wall_id)) def _cmd_create_decke(self, p): """Decken-Erzeugung mit Modus-Auswahl: Polylinie, Rechteck, Rechteck-3-Punkte oder Kreis. Modus + Dicke per Command-Option.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return geschoss = p.get("geschoss") or _active_geschoss_id(doc) if not geschoss: print("[ELEMENTE] Kein Geschoss aktiv"); return d_in = p.get("dicke") try: dicke = float(d_in) if d_in else _last("decke_dicke", 0.20) except Exception: dicke = _last("decke_dicke", 0.20) uk_over = p.get("ukOverride", "") ok_over = p.get("okOverride", "") try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception as ex: print("[ELEMENTE] Imports:", ex); return modi = ["Polylinie", "Rechteck", "Rechteck3Punkte", "Kreis"] modus = p.get("modus") or _last("decke_modus", "Polylinie") if modus not in modi: modus = "Polylinie" def _build_prompt(base): return "{} [Modus={}, Dicke={:.3f}]".format(base, modus, dicke) first_pt = None try: # Erster Punkt + Optionen while True: gp = ric.GetPoint() gp.SetCommandPrompt(_build_prompt("Decke: Startpunkt")) opt_modus = gp.AddOptionList("Modus", modi, modi.index(modus)) opt_dicke = gp.AddOption("Dicke") res = gp.Get() if res == GetResult.Option: if gp.OptionIndex() == opt_modus: try: modus = modi[gp.Option().CurrentListOptionIndex] except Exception: pass elif gp.OptionIndex() == opt_dicke: try: gn = ric.GetNumber() gn.SetCommandPrompt("Decken-Dicke") gn.SetDefaultNumber(dicke) gn.SetLowerLimit(0.01, False) if gn.Get() == GetResult.Number: dicke = float(gn.Number()) except Exception as ex: print("[ELEMENTE] GetNumber:", ex) continue if res != GetResult.Point: return first_pt = gp.Point() break except Exception as ex: print("[ELEMENTE] decke first-point:", ex); return outline_curve = None try: if modus == "Polylinie": outline_curve = self._collect_polyline_outline(doc, first_pt) elif modus == "Rechteck": outline_curve = _collect_rectangle(doc, first_pt) elif modus == "Rechteck3Punkte": outline_curve = _collect_rectangle_3pt(doc, first_pt) elif modus == "Kreis": outline_curve = _collect_circle(doc, first_pt) except Exception as ex: print("[ELEMENTE] decke collect:", ex) return if outline_curve is None or not outline_curve.IsClosed: print("[ELEMENTE] keine gueltige Outline") return self._make_decke_from_outline(doc, outline_curve, geschoss, dicke, uk_over, ok_over) _save_last(decke_dicke=dicke, decke_modus=modus) self._send_state() def _collect_polyline_outline(self, doc, first_pt): """Sammelt eine geschlossene Polyline via aufeinanderfolgendes GetPoint mit Live-Preview. Enter / Klick auf Startpunkt schliesst.""" try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception: return None points = [first_pt] tol = max(doc.ModelAbsoluteTolerance, 1e-4) while True: gp = ric.GetPoint() gp.SetCommandPrompt("Naechster Punkt (Enter / Klick auf Start = schliessen)") gp.AcceptNothing(True) try: gp.SetBasePoint(points[-1], True) except Exception: pass try: preview = _make_decke_preview_handler(list(points)) gp.DynamicDraw += preview except Exception: pass res = gp.Get() if res == GetResult.Nothing: break if res != GetResult.Point: break pt = gp.Point() if pt.DistanceTo(points[0]) < tol: break if pt.DistanceTo(points[-1]) < tol: break points.append(pt) if len(points) < 3: return None pts3d = [rg.Point3d(p.X, p.Y, 0) for p in points] if pts3d[0].DistanceTo(pts3d[-1]) > 1e-6: pts3d.append(pts3d[0]) return rg.PolylineCurve(rg.Polyline(pts3d)) def _make_decke_from_outline(self, doc, outline_curve, geschoss_id, dicke, uk_over, ok_over): """Decke aus beliebiger geschlossener Outline-Curve (Polyline, Rechteck, Kreis, ...). Curve wird so wie sie ist gespeichert; das Volumen wird per Extrusion erzeugt.""" element_id = "decke_" + uuid.uuid4().hex[:10] g = _geschoss_by_id(doc, geschoss_id) geschoss_name = g.get("name", "EG") if g else "EG" layer = _ensure_layer(doc, _layer_path_decke(doc, geschoss_name)) # Sicherstellen dass die Curve auf Z=0 liegt outline = outline_curve.DuplicateCurve() try: z0 = outline.PointAtStart.Z if abs(z0) > 1e-6: outline.Transform(rg.Transform.Translation(0, 0, -z0)) except Exception: pass attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = layer _attach_meta(attrs, element_id, "decke_outline", geschoss_id, dicke, uk_over, ok_over, "mid") outline_id = doc.Objects.AddCurve(outline, attrs) if outline_id == System.Guid.Empty: print("[ELEMENTE] Decke AddCurve fehlgeschlagen"); return _regenerate_element(doc, element_id) doc.Views.Redraw() print("[ELEMENTE] Decke erzeugt: {}".format(element_id)) def _cmd_create_aussparung(self, p): """Decken-Aussparung: geschlossene Outline auf einer Decke. Outline wird als Cutout aus der Decke abgezogen — fuer Treppenloecher, Atrien, Schaechte. Eltern-Decke wird automatisch detektiert ueber den Centroid der gezeichneten Outline.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return geschoss = p.get("geschoss") or _active_geschoss_id(doc) if not geschoss: print("[ELEMENTE] Kein Geschoss aktiv"); return try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception as ex: print("[ELEMENTE] Imports:", ex); return modi = ["Polylinie", "Rechteck", "Rechteck3Punkte", "Kreis"] modus = p.get("modus") or _last("aussp_modus", "Rechteck") if modus not in modi: modus = "Rechteck" def _prompt(base): return "{} [Modus={}]".format(base, modus) first_pt = None try: while True: gp = ric.GetPoint() gp.SetCommandPrompt(_prompt("Aussparung: Startpunkt")) opt_modus = gp.AddOptionList("Modus", modi, modi.index(modus)) res = gp.Get() if res == GetResult.Option: if gp.OptionIndex() == opt_modus: try: modus = modi[gp.Option().CurrentListOptionIndex] except Exception: pass continue if res != GetResult.Point: return first_pt = gp.Point() break except Exception as ex: print("[ELEMENTE] aussp first-pt:", ex); return outline_curve = None try: if modus == "Polylinie": outline_curve = self._collect_polyline_outline(doc, first_pt) elif modus == "Rechteck": outline_curve = _collect_rectangle(doc, first_pt) elif modus == "Rechteck3Punkte": outline_curve = _collect_rectangle_3pt(doc, first_pt) elif modus == "Kreis": outline_curve = _collect_circle(doc, first_pt) except Exception as ex: print("[ELEMENTE] aussp collect:", ex); return if outline_curve is None or not outline_curve.IsClosed: print("[ELEMENTE] keine gueltige Outline"); return # Outline auf Z=0 normalisieren outline = outline_curve.DuplicateCurve() try: z0 = outline.PointAtStart.Z if abs(z0) > 1e-6: outline.Transform(rg.Transform.Translation(0, 0, -z0)) except Exception: pass # Eltern-Decke via Centroid + Point-in-Curve-Test finden. # Aktives Geschoss wird bevorzugt, faellt aber sonst auf jede # andere Decke zurueck (User koennte z.B. in 1OG ein Loch in der # EG-Decke setzen wollen). try: amp = rg.AreaMassProperties.Compute(outline) ctr = amp.Centroid if amp is not None else outline.PointAtStart except Exception: ctr = outline.PointAtStart ctr_xy = rg.Point3d(ctr.X, ctr.Y, 0) decke_id = _find_decke_containing_point(doc, geschoss, ctr_xy) if decke_id is None: # Diagnose: ueberhaupt eine Decke im Doc? n_decken = 0 for obj in doc.Objects: m = _read_meta(obj) if m and m["type"] == "decke_outline": n_decken += 1 if n_decken == 0: print("[ELEMENTE] Aussparung: keine Decke im Dokument — " "erst eine Decke zeichnen.") else: print(("[ELEMENTE] Aussparung: keine der {} Decken enthaelt " "den Centroid ({:.3f}, {:.3f}). Outline muss " "geometrisch INNERHALB einer Decken-Outline " "liegen.").format(n_decken, ctr_xy.X, ctr_xy.Y)) return # Element anlegen element_id = "aussp_" + uuid.uuid4().hex[:10] g = _geschoss_by_id(doc, geschoss) geschoss_name = g.get("name", "EG") if g else "EG" layer = _ensure_layer(doc, _layer_path_decke(doc, geschoss_name)) attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = layer _attach_meta(attrs, element_id, "decke_aussparung_outline", geschoss, 0.0, "", "", "mid", aussp_parent=decke_id) new_id = doc.Objects.AddCurve(outline, attrs) if new_id == System.Guid.Empty: print("[ELEMENTE] Aussparung AddCurve fehlgeschlagen"); return _save_last(aussp_modus=modus) # Eltern-Decke regenerieren — das Loch wird abgezogen _regenerate_element(doc, decke_id) doc.Views.Redraw() print("[ELEMENTE] Aussparung erzeugt: {}".format(element_id)) self._send_state() def _cmd_create_dach(self, p): """Pultdach-Erzeugung mit Modus-Auswahl: Polylinie / Rechteck / Rechteck-3-Punkte. Die ERSTE Kante (Punkt 1 → Punkt 2) ist die Traufkante. Neigung + Dicke per Command-Option.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return geschoss = p.get("geschoss") or _active_geschoss_id(doc) if not geschoss: print("[ELEMENTE] Kein Geschoss aktiv"); return d_in = p.get("dicke") try: dicke = float(d_in) if d_in else _last("dach_dicke", 0.20) except Exception: dicke = _last("dach_dicke", 0.20) n_in = p.get("neigung") try: neigung = float(n_in) if n_in else _last("dach_neigung", 30.0) except Exception: neigung = _last("dach_neigung", 30.0) uk_over = p.get("ukOverride", "") try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception as ex: print("[ELEMENTE] Imports:", ex); return modi = ["Polylinie", "Rechteck", "Rechteck3Punkte"] modus = p.get("modus") or _last("dach_modus", "Polylinie") if modus not in modi: modus = "Polylinie" typen = ["Pult", "Sattel", "Walm", "Mansarde"] dach_typ = p.get("dachTyp") or _last("dach_typ", "Pult") if dach_typ not in typen: dach_typ = "Pult" # Mansarde-spezifische Defaults try: neigung_unten = float(p.get("neigungUnten") or _last("dach_neigung_unten", 60.0)) except Exception: neigung_unten = 60.0 try: knick_h = float(p.get("knickH") or _last("dach_knick_h", 2.0)) except Exception: knick_h = 2.0 varianten = ["Walm", "Giebel", "Walm-Giebel"] variante_code_map = {"Walm": "walm", "Giebel": "giebel", "Walm-Giebel": "walm_giebel"} dach_variante = p.get("dachVariante") or _last("dach_variante", "Walm") if dach_variante not in varianten: dach_variante = "Walm" def _build_prompt(base): extra = "" if dach_typ == "Mansarde": extra = ", Variante={}".format(dach_variante) return "{} [Typ={}{}, Modus={}, Neigung={:.1f}°, Dicke={:.3f}]".format( base, dach_typ, extra, modus, neigung, dicke) first_pt = None try: while True: gp = ric.GetPoint() gp.SetCommandPrompt(_build_prompt("Dach: Startpunkt (1. Kante = Traufe)")) opt_typ = gp.AddOptionList("Typ", typen, typen.index(dach_typ)) opt_var = None if dach_typ == "Mansarde": opt_var = gp.AddOptionList("Variante", varianten, varianten.index(dach_variante)) opt_modus = gp.AddOptionList("Modus", modi, modi.index(modus)) opt_n = gp.AddOption("Neigung") opt_d = gp.AddOption("Dicke") res = gp.Get() if res == GetResult.Option: if gp.OptionIndex() == opt_typ: try: dach_typ = typen[gp.Option().CurrentListOptionIndex] except Exception: pass elif opt_var is not None and gp.OptionIndex() == opt_var: try: dach_variante = varianten[gp.Option().CurrentListOptionIndex] except Exception: pass elif gp.OptionIndex() == opt_modus: try: modus = modi[gp.Option().CurrentListOptionIndex] except Exception: pass elif gp.OptionIndex() == opt_n: try: gn = ric.GetNumber() gn.SetCommandPrompt("Dach-Neigung in Grad") gn.SetDefaultNumber(neigung) gn.SetLowerLimit(0.0, False) gn.SetUpperLimit(89.0, False) if gn.Get() == GetResult.Number: neigung = float(gn.Number()) except Exception as ex: print("[ELEMENTE] GetNumber:", ex) elif gp.OptionIndex() == opt_d: try: gn = ric.GetNumber() gn.SetCommandPrompt("Dach-Dicke") gn.SetDefaultNumber(dicke) gn.SetLowerLimit(0.01, False) if gn.Get() == GetResult.Number: dicke = float(gn.Number()) except Exception as ex: print("[ELEMENTE] GetNumber:", ex) continue if res != GetResult.Point: return first_pt = gp.Point() break except Exception as ex: print("[ELEMENTE] dach first-point:", ex); return outline_curve = None try: if modus == "Polylinie": outline_curve = self._collect_polyline_outline(doc, first_pt) elif modus == "Rechteck": outline_curve = _collect_rectangle(doc, first_pt) elif modus == "Rechteck3Punkte": outline_curve = _collect_rectangle_3pt(doc, first_pt) except Exception as ex: print("[ELEMENTE] dach collect:", ex); return if outline_curve is None or not outline_curve.IsClosed: print("[ELEMENTE] keine gueltige Outline"); return # Sattel/Walm/Mansarde brauchen Rechteck-Outline. Sonst Fallback Pult. dt_code = dach_typ.lower() if dt_code in ("sattel", "walm", "mansarde"): try: ok, poly = outline_curve.TryGetPolyline() if not ok or poly is None or poly.Count != 5: print("[ELEMENTE] {} braucht Rechteck-Outline — Fallback Pult".format(dach_typ)) dt_code = "pult" except Exception: dt_code = "pult" self._make_dach_from_outline(doc, outline_curve, geschoss, dicke, neigung, 0, uk_over, dt_code, neigung_unten=neigung_unten, knick_h=knick_h, dach_variante=variante_code_map.get( dach_variante, "walm")) _save_last(dach_dicke=dicke, dach_neigung=neigung, dach_modus=modus, dach_typ=dach_typ, dach_neigung_unten=neigung_unten, dach_knick_h=knick_h, dach_variante=dach_variante) self._send_state() def _make_dach_from_outline(self, doc, outline_curve, geschoss_id, dicke, neigung, eave_idx, uk_over, dach_typ="pult", neigung_unten=60.0, knick_h=2.0, dach_variante="walm"): """Dach aus geschlossener Outline-PolylineCurve.""" element_id = "dach_" + uuid.uuid4().hex[:10] g = _geschoss_by_id(doc, geschoss_id) geschoss_name = g.get("name", "EG") if g else "EG" layer = _ensure_layer(doc, _layer_path_dach(doc, geschoss_name)) outline = outline_curve.DuplicateCurve() try: z0 = outline.PointAtStart.Z if abs(z0) > 1e-6: outline.Transform(rg.Transform.Translation(0, 0, -z0)) except Exception: pass attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = layer _attach_meta(attrs, element_id, "dach_outline", geschoss_id, dicke, uk_over, "", "mid", neigung=neigung, eave_idx=eave_idx, dach_typ=dach_typ, neigung_unten=neigung_unten, knick_h=knick_h, dach_variante=dach_variante) outline_id = doc.Objects.AddCurve(outline, attrs) if outline_id == System.Guid.Empty: print("[ELEMENTE] Dach AddCurve fehlgeschlagen"); return _regenerate_element(doc, element_id) doc.Views.Redraw() print("[ELEMENTE] Dach erzeugt: {} ({}, neigung={}°)".format( element_id, dach_typ, neigung)) def _cmd_create_oeffnung(self, p, typ): """Fenster/Tuer-Erzeugung: User klickt auf eine Wand-Achse, dann einen Punkt darauf. Punkt-Position wird auf die Achse projiziert. Optionen: Breite, Hoehe, (Bruestung nur fuer Fenster).""" if typ not in ("fenster", "tuer"): return doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception as ex: print("[ELEMENTE] Imports:", ex); return # Defaults if typ == "fenster": b_def = _last("fenster_breite", 1.20) h_def = _last("fenster_hoehe", 1.40) br_def = _last("fenster_brueest", 0.90) else: b_def = _last("tuer_breite", 0.90) h_def = _last("tuer_hoehe", 2.10) br_def = 0.0 try: breite = float(p.get("breite") or b_def) except Exception: breite = b_def try: hoehe = float(p.get("hoehe") or h_def) except Exception: hoehe = h_def if typ == "fenster": try: brueest = float(p.get("brueest") or br_def) except Exception: brueest = br_def else: brueest = 0.0 # 1) Wand-Achse waehlen try: gw = ric.GetObject() gw.SetCommandPrompt("Wand-Achse fuer {} waehlen".format( "Fenster" if typ == "fenster" else "Tuer")) gw.GeometryFilter = Rhino.DocObjects.ObjectType.Curve def _filter_wand(rhObj, geom, ci): m = _read_meta(rhObj) return m is not None and m.get("type") == "wand_axis" gw.SetCustomGeometryFilter(_filter_wand) res = gw.Get() if res != GetResult.Object: return wall_obj = gw.Object(0).Object() except Exception as ex: print("[ELEMENTE] Wand-Auswahl:", ex); return wall_meta = _read_meta(wall_obj) if wall_meta is None: return axis_curve = wall_obj.Geometry if not isinstance(axis_curve, rg.Curve): return # Base-Z fuer das Preview: UK des Geschosses, damit das Brueest- # Offset visuell stimmt (Achse selbst kann auf einem anderen Z # liegen je nach Modellierung). try: wuk, _wok = _resolve_uk_ok(doc, wall_meta.get("geschoss"), wall_meta.get("uk_override"), wall_meta.get("ok_override")) preview_base_z = float(wuk) except Exception: try: preview_base_z = float(axis_curve.PointAtStart.Z) except Exception: preview_base_z = 0.0 # 2) Punkt auf der Achse — constrained an die Wand-Achse try: while True: gp = ric.GetPoint() prompt = "Position fuer {} [B={:.2f}, H={:.2f}".format( "Fenster" if typ == "fenster" else "Tuer", breite, hoehe) if typ == "fenster": prompt += ", Br={:.2f}".format(brueest) prompt += "]" gp.SetCommandPrompt(prompt) try: gp.Constrain(axis_curve, False) except Exception: pass # Live-Preview: gruener Oeffnungs-Quader mit Glas-Diagonalen, # Brueest-Marker und Mass-Label oberhalb des Sturzes try: wall_dicke = float(wall_meta.get("dicke", 0.15)) except Exception: wall_dicke = 0.15 try: gp.DynamicDraw += _make_oeffnung_preview( axis_curve, wall_dicke, breite, hoehe, brueest, preview_base_z, typ) except Exception: pass opt_b = gp.AddOption("Breite") opt_h = gp.AddOption("Hoehe") opt_br = gp.AddOption("Bruestung") if typ == "fenster" else None rp = gp.Get() if rp == GetResult.Option: idx = gp.OptionIndex() if idx == opt_b: gn = ric.GetNumber() gn.SetCommandPrompt("Breite") gn.SetDefaultNumber(breite) gn.SetLowerLimit(0.05, False) if gn.Get() == GetResult.Number: breite = float(gn.Number()) elif idx == opt_h: gn = ric.GetNumber() gn.SetCommandPrompt("Hoehe") gn.SetDefaultNumber(hoehe) gn.SetLowerLimit(0.05, False) if gn.Get() == GetResult.Number: hoehe = float(gn.Number()) elif opt_br is not None and idx == opt_br: gn = ric.GetNumber() gn.SetCommandPrompt("Bruestungshoehe") gn.SetDefaultNumber(brueest) gn.SetLowerLimit(0.0, True) if gn.Get() == GetResult.Number: brueest = float(gn.Number()) continue if rp != GetResult.Point: return click_pt = gp.Point() break except Exception as ex: print("[ELEMENTE] Oeffnung point:", ex); return # Auf Achse projizieren (Constrain garantiert das eigentlich schon) try: ok, t = axis_curve.ClosestPoint(click_pt) if not ok: return on_axis = axis_curve.PointAt(t) except Exception as ex: print("[ELEMENTE] ClosestPoint:", ex); return # Point-Objekt mit Metadaten anlegen prefix = "fenster_" if typ == "fenster" else "tuer_" oeff_id = prefix + uuid.uuid4().hex[:10] geschoss = wall_meta["geschoss"] g = _geschoss_by_id(doc, geschoss) geschoss_name = g.get("name", "EG") if g else "EG" layer = _ensure_layer(doc, _layer_path_axis(doc, geschoss_name)) # Entwurfs-Defaults pro Typ is_fenster = (typ == "fenster") rahmen_b_def = _last("oeff_rahmen_b", 0.06) rahmen_t_def = _last("oeff_rahmen_tiefe", 0.08) rahmen_p_def = _last("oeff_rahmen_pos", "mid") fluegel_def = _last("{}_fluegel".format(typ), 2 if is_fenster else 1) simsa_def = "standard" if is_fenster else "ohne" simsi_def = "standard" if is_fenster else "ohne" glas_def = is_fenster referenz_def = _last("oeff_referenz", "mid") attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = layer _attach_meta(attrs, oeff_id, "oeffnung_point", geschoss, wall_meta["dicke"], "", "", oeff_typ=typ, oeff_parent=wall_meta["id"], oeff_breite=breite, oeff_hoehe=hoehe, oeff_brueest=brueest, oeff_rahmen_b=rahmen_b_def, oeff_rahmen_tiefe=rahmen_t_def, oeff_rahmen_pos=rahmen_p_def, oeff_fluegel=fluegel_def, oeff_sims_aus=simsa_def, oeff_sims_in=simsi_def, oeff_glas=glas_def, oeff_referenz=referenz_def) # Oeffnungs-Punkt auf UK+Brueestung-Hoehe platzieren (= visuell auf # Unterkante Oeffnung). Constraint vergleicht spaeter pt.Z mit # UK+brueest — wenn der Punkt am axis.Z=0 saesse, wuerde der erste # Move die Brueest auf 0 droppen. Hier UK auflösen (Geschoss-OKFF + # ggf. Override) und Punkt direkt auf richtige Welt-Z setzen. try: wall_uk, _ = _resolve_uk_ok(doc, geschoss, wall_meta.get("uk_override", ""), wall_meta.get("ok_override", "")) except Exception: wall_uk = 0.0 pt_at_brueest = rg.Point3d(on_axis.X, on_axis.Y, wall_uk + float(brueest)) new_id = doc.Objects.AddPoint(pt_at_brueest, attrs) if new_id == System.Guid.Empty: print("[ELEMENTE] AddPoint fehlgeschlagen"); return # Last-used if typ == "fenster": _save_last(fenster_breite=breite, fenster_hoehe=hoehe, fenster_brueest=brueest) else: _save_last(tuer_breite=breite, tuer_hoehe=hoehe) # Eltern-Wand regen _regenerate_element(doc, wall_meta["id"]) doc.Views.Redraw() print("[ELEMENTE] {} erzeugt: {} an Wand {}".format( "Fenster" if typ == "fenster" else "Tuer", oeff_id, wall_meta["id"])) self._send_state() def _cmd_create_treppe(self, p): """Treppen-Erzeugung. Hoehe automatisch aus Geschoss-OKFF-Differenz. treppeArt aus Payload: 'gerade' (2 Punkte) | 'l' (3 Punkte mit Eck).""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return treppe_art = p.get("treppeArt") or "gerade" if treppe_art not in _TREPPE_ARTEN: treppe_art = "gerade" geschoss_start = p.get("geschoss") or _active_geschoss_id(doc) if not geschoss_start: print("[ELEMENTE] Kein Geschoss aktiv"); return try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception as ex: print("[ELEMENTE] Imports:", ex); return # Zielgeschoss-Default: das naechste Geschoss vom gleichen Typ in der # Liste, oder None falls keins existiert. geschosse = _load_geschosse(doc) gs = _geschoss_by_id(doc, geschoss_start) if gs is None: print("[ELEMENTE] Startgeschoss nicht gefunden"); return geschoss_end = p.get("geschossEnd") or "" if not geschoss_end: # naechstes Geschoss > start_okff try: start_okff = float(gs.get("okff", 0.0)) candidates = [] for g in geschosse: if not isinstance(g, dict): continue if g.get("type") != "grundriss": continue if g.get("id") == geschoss_start: continue try: o = float(g.get("okff", 0.0)) except Exception: continue if o > start_okff + 1e-6: candidates.append((o, g.get("id", ""))) candidates.sort() if candidates: geschoss_end = candidates[0][1] except Exception: pass g_end = _geschoss_by_id(doc, geschoss_end) if geschoss_end else None # Defaults try: breite = float(p.get("breite") or _last("treppe_breite", 1.0)) except Exception: breite = 1.0 referenz = p.get("referenz") or _last("treppe_referenz", "mid") if referenz not in ("mid", "links", "rechts"): referenz = "mid" # N: bei bekannter Hoehe ~S=0.18 berechnen try: uk = float(gs.get("okff", 0.0)) except Exception: uk = 0.0 if g_end is not None: try: ok = float(g_end.get("okff", uk + 3.0)) except Exception: ok = uk + 3.0 else: try: ok = uk + float(gs.get("hoehe", 3.0)) except Exception: ok = uk + 3.0 H = max(0.001, ok - uk) n_default = int(round(H / 0.18)) if n_default < 2: n_default = 2 try: n_stufen = int(p.get("nStufen") or _last("treppe_n", n_default)) except Exception: n_stufen = n_default if n_stufen < 2: n_stufen = 2 # Schrittmass-Regel: Default "regel" (Lauflinie wird auf optimale # Laenge gezwungen). User kann auf "frei" stellen. regel_mode = _last("treppe_regel", "regel") if regel_mode not in ("frei", "regel"): regel_mode = "regel" # Soll-Werte (Editable in der Treppe-Property-Card) aus sticky laden. # Default falls noch nichts gesetzt: 0.15-0.20 / 0.21-0.35 / 0.60-0.65. soll_last = _last("treppe_soll", None) soll = dict(_TREPPE_SOLL_DEFAULT) if soll_last: try: import json parsed = json.loads(soll_last) if isinstance(soll_last, str) else soll_last if isinstance(parsed, dict): for k in ("s", "a", "sa"): v = parsed.get(k) if isinstance(v, list) and len(v) >= 3: soll[k] = [float(v[0]), float(v[1]), bool(v[2])] except Exception: pass # Lauflaengen-Range fuer aktuelle N & H aus enabled Soll-Werten: # - 2S+A in [sa_lo, sa_hi] (enabled) → A in [sa_lo-2S, sa_hi-2S] # - A in [a_lo, a_hi] (enabled) # → A_range = Schnitt aller Constraints, L_range = N*A_range # Optimaler Mittelwert: L_opt = 0.63*N - 2*H def _l_range(n, h): n = max(1, int(n)) s = float(h) / n a_lo, a_hi = 0.05, 2.0 # weit-offene Defaults if soll["sa"][2]: # 2S+A enabled a_lo = max(a_lo, soll["sa"][0] - 2 * s) a_hi = min(a_hi, soll["sa"][1] - 2 * s) if soll["a"][2]: # A enabled a_lo = max(a_lo, soll["a"][0]) a_hi = min(a_hi, soll["a"][1]) if a_lo > a_hi: # widerspruechliche Constraints — gib Range zurueck zentriert mid = (a_lo + a_hi) * 0.5 a_lo = a_hi = mid return (n * a_lo, n * a_hi) def _l_optimal(n, h): lo, hi = _l_range(n, h) return max(0.3, (lo + hi) * 0.5) # Zwei Punkte fuer die Lauflinie einsammeln first_pt = None try: while True: gp = ric.GetPoint() end_name = (g_end.get("name") if g_end else "(naechste Ebene)") L_opt_show = _l_optimal(n_stufen, H) gp.SetCommandPrompt( "Treppe: Startpunkt [Hoehe {:.2f}, Stufen={}, Breite={:.2f}, Ref={}, Modus={}{}, Ziel={}]".format( H, n_stufen, breite, referenz, regel_mode, " (L_opt={:.2f})".format(L_opt_show) if regel_mode == "regel" else "", end_name)) opt_n = gp.AddOption("Stufen") opt_b = gp.AddOption("Breite") opt_ref = gp.AddOptionList("Referenz", ["Links", "Mittig", "Rechts"], {"links":0, "mid":1, "rechts":2}.get(referenz, 1)) # Regel-Option nur fuer gerade Treppen (bei L sind 2 Segmente # mit Podest dazwischen — die Regel laesst sich nicht trivial # auf 2 Klicks aufteilen). opt_reg = -1 if treppe_art != "l": opt_reg = gp.AddOptionList("Regel", ["frei", "Schrittmass"], 1 if regel_mode == "regel" else 0) res = gp.Get() if res == GetResult.Option: idx = gp.OptionIndex() if idx == opt_n: gn = ric.GetInteger() gn.SetCommandPrompt("Anzahl Stufen (Steigungen)") gn.SetDefaultInteger(n_stufen) gn.SetLowerLimit(2, False) gn.SetUpperLimit(40, False) if gn.Get() == GetResult.Number: n_stufen = int(gn.Number()) elif idx == opt_b: gn = ric.GetNumber() gn.SetCommandPrompt("Treppen-Breite") gn.SetDefaultNumber(breite) gn.SetLowerLimit(0.3, False) if gn.Get() == GetResult.Number: breite = float(gn.Number()) elif idx == opt_ref: try: v = ["links", "mid", "rechts"][gp.Option().CurrentListOptionIndex] referenz = v except Exception: pass elif opt_reg >= 0 and idx == opt_reg: try: v = ["frei", "regel"][gp.Option().CurrentListOptionIndex] regel_mode = v except Exception: pass continue if res != GetResult.Point: return first_pt = gp.Point() break except Exception as ex: print("[ELEMENTE] treppe first-pt:", ex); return # Zweiter Punkt mit DynamicDraw. Bei Regel-Modus: Maus gibt nur # die RICHTUNG vor — die Lauflinien-Laenge wird auf den optimalen # Wert fixiert (wie Rhinos Rotate-Befehl). Kein Kreis-Constraint # — der wuerde blockieren wenn die Maus weit weg ist. gp2 = ric.GetPoint() L_opt = _l_optimal(n_stufen, H) if treppe_art == "l": # L-Treppe: 2. Klick ist der Podest-Eck. Live-Preview zeigt # N1/N2 fuer die Mausposition. Regel-Modus aus (zu komplex). gp2.SetCommandPrompt( "L-Treppe: Eck-Punkt (Podest-Mitte) [Stufen={}, Breite={:.2f}]".format( n_stufen, breite)) gp2.SetBasePoint(first_pt, True) gp2.DynamicDraw += _make_treppe_l_corner_preview( first_pt, breite, referenz, n_stufen, H) elif treppe_art == "wendel": # Wendel: 1. Klick = Mittelpunkt, 2. Klick = Start (Radius # + Startwinkel). Preview: Linie center→Maus + Kreis. gp2.SetCommandPrompt( "Wendeltreppe: Start der Lauflinie (definiert Radius) [Stufen={}, Breite={:.2f}]".format( n_stufen, breite)) gp2.SetBasePoint(first_pt, True) gp2.DrawLineFromPoint(first_pt, True) elif regel_mode == "regel": L_min, L_max = _l_range(n_stufen, H) same = abs(L_max - L_min) < 1e-4 if same: gp2.SetCommandPrompt( "Treppe: Richtung (Lauflaenge {:.2f} m, Schrittmass-Regel)".format(L_min)) else: gp2.SetCommandPrompt( "Treppe: Endpunkt (Lauflaenge {:.2f}–{:.2f} m, Schrittmass-Regel)".format( L_min, L_max)) gp2.SetBasePoint(first_pt, True) if same: gp2.DynamicDraw += _make_treppe_preview_handler( first_pt, breite, referenz, n_stufen, fixed_length=L_min) else: gp2.DynamicDraw += _make_treppe_preview_handler( first_pt, breite, referenz, n_stufen, min_length=L_min, max_length=L_max) else: gp2.SetCommandPrompt( "Treppe: Endpunkt der Lauflinie (frei) [Stufen={}, Breite={:.2f}, Ref={}]".format( n_stufen, breite, referenz)) gp2.SetBasePoint(first_pt, True) gp2.DynamicDraw += _make_treppe_preview_handler( first_pt, breite, referenz, n_stufen) if gp2.Get() != GetResult.Point: return clicked = gp2.Point() if regel_mode == "regel" and treppe_art == "gerade": dx = clicked.X - first_pt.X dy = clicked.Y - first_pt.Y dist = (dx * dx + dy * dy) ** 0.5 if dist < 1e-4: print("[ELEMENTE] Keine Richtung gewaehlt"); return L_min2, L_max2 = _l_range(n_stufen, H) # Clamp Mauspos-Distanz in die Range (oder reskaliere auf fix # wenn Range gleich null). if abs(L_max2 - L_min2) < 1e-4: final_L = L_min2 else: final_L = max(L_min2, min(L_max2, dist)) second_pt = rg.Point3d(first_pt.X + dx / dist * final_L, first_pt.Y + dy / dist * final_L, first_pt.Z) else: second_pt = clicked # L-Treppe: dritter Punkt einsammeln (Endpunkt nach dem Eck) if treppe_art == "l": gp3 = ric.GetPoint() gp3.SetCommandPrompt( "L-Treppe: Endpunkt nach dem Podest [Stufen={}, Breite={:.2f}]".format( n_stufen, breite)) gp3.SetBasePoint(second_pt, True) gp3.DynamicDraw += _make_treppe_preview_handler( second_pt, breite, referenz, max(1, n_stufen // 2)) if gp3.Get() != GetResult.Point: return third_pt = gp3.Point() p_first = rg.Point3d(first_pt.X, first_pt.Y, 0) p_corner = rg.Point3d(second_pt.X, second_pt.Y, 0) p_end = rg.Point3d(third_pt.X, third_pt.Y, 0) pl = rg.Polyline([p_first, p_corner, p_end]) line = rg.PolylineCurve(pl) if line.GetLength() < 0.2: print("[ELEMENTE] L-Lauflinie zu kurz"); return elif treppe_art == "wendel": # Wendel: 3. Klick = Endpunkt (Sweep-Winkel + Drehrichtung). # Im Regel-Modus wird der Sweep auf den durch r_lauf + Soll # zulaessigen Bereich geclampt. gp3 = ric.GetPoint() gp3.SetCommandPrompt( "Wendeltreppe: Endpunkt der Lauflinie (definiert Drehwinkel) [Stufen={}, Modus={}]".format( n_stufen, regel_mode)) gp3.SetBasePoint(first_pt, True) gp3.DynamicDraw += _make_treppe_wendel_preview( first_pt, second_pt, breite, referenz, n_stufen, total_h=H, soll=soll, regel_mode=regel_mode) if gp3.Get() != GetResult.Point: return third_pt = gp3.Point() p_center = rg.Point3d(first_pt.X, first_pt.Y, 0) p_start_w = rg.Point3d(second_pt.X, second_pt.Y, 0) p_end_w = rg.Point3d(third_pt.X, third_pt.Y, 0) # Sweep + (im Regel-Modus) clampen auf gueltigen Bereich. # Dann den Endpunkt entsprechend reskalieren. import math a_s_w, dlt_w = _wendel_sweep(p_center, p_start_w, p_end_w) if regel_mode == "regel": r_lauf = math.sqrt( (p_start_w.X - p_center.X) ** 2 + (p_start_w.Y - p_center.Y) ** 2) try: s_lo, s_hi = _wendel_sweep_range( r_lauf, breite, referenz, n_stufen, H, soll) except Exception: s_lo, s_hi = 0.05, 2.0 * math.pi raw = abs(dlt_w) if raw < s_lo: clamped = s_lo elif raw > s_hi: clamped = s_hi else: clamped = raw dlt_clamped = clamped * (1.0 if dlt_w >= 0 else -1.0) # Neuen Endpunkt auf Kreis r_lauf bei Winkel a_s_w + dlt_clamped a_final = a_s_w + dlt_clamped p_end_w = rg.Point3d( p_center.X + r_lauf * math.cos(a_final), p_center.Y + r_lauf * math.sin(a_final), 0) # Wichtig: dlt_w fuer den nachfolgenden < 0.05 Check aktualisieren dlt_w = dlt_clamped if abs(dlt_w) < 0.05: print("[ELEMENTE] Wendel-Sweep zu klein"); return pl = rg.Polyline([p_center, p_start_w, p_end_w]) line = rg.PolylineCurve(pl) else: line = rg.LineCurve(rg.Point3d(first_pt.X, first_pt.Y, 0), rg.Point3d(second_pt.X, second_pt.Y, 0)) if line.GetLength() < 0.1: print("[ELEMENTE] Lauflinie zu kurz"); return # Element anlegen treppe_id = "treppe_" + uuid.uuid4().hex[:10] geschoss_name = gs.get("name", "EG") layer = _ensure_layer(doc, _layer_path_treppe(doc, geschoss_name)) attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = layer modus_def = _last("treppe_modus", "flach") if modus_def not in _TREPPE_MODI: modus_def = "flach" try: lauf_d_def = float(_last("treppe_lauf_d", 0.18)) except Exception: lauf_d_def = 0.18 _attach_meta(attrs, treppe_id, "treppe_axis", geschoss_start, breite, "", "", "mid", geschoss_end=geschoss_end, treppe_breite=breite, treppe_n=n_stufen, treppe_referenz=referenz, treppe_modus=modus_def, treppe_lauf_d=lauf_d_def, treppe_art=treppe_art) new_id = doc.Objects.AddCurve(line, attrs) if new_id == System.Guid.Empty: print("[ELEMENTE] AddCurve fehlgeschlagen"); return save_kwargs = dict(treppe_breite=breite, treppe_n=n_stufen, treppe_referenz=referenz, treppe_modus=modus_def, treppe_lauf_d=lauf_d_def, treppe_art=treppe_art) # regel_mode fuer gerade + wendel speichern (L hat keinen # sinnvollen Regel-Modus — wuerde sonst die User-Praeferenz # auf "frei" zuruecksetzen). if treppe_art in ("gerade", "wendel"): save_kwargs["treppe_regel"] = regel_mode _save_last(**save_kwargs) _regenerate_element(doc, treppe_id) doc.Views.Redraw() print("[ELEMENTE] Treppe erzeugt: {}".format(treppe_id)) self._send_state() def _cmd_create_stuetze(self, p): """Stuetzen-Erzeugung: 1 Klick (Punkt) + Optionen. Erzeugt ein Source-Point + vertikales Tragwerk-Volume zwischen Geschoss-UK und Geschoss-OK (oder Z-Override). Profile: quadrat, rechteck, rund, i_profil, rohr.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return geschoss = p.get("geschoss") or _active_geschoss_id(doc) if not geschoss: print("[ELEMENTE] Kein Geschoss aktiv"); return try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception as ex: print("[ELEMENTE] Imports:", ex); return profil = p.get("profil") or _last("stuetze_profil", "quadrat") if profil not in _TRAG_PROFILE: profil = "quadrat" try: B = float(p.get("b") or _last("stuetze_b", 0.30)) except Exception: B = 0.30 try: H = float(p.get("h") or _last("stuetze_h", 0.30)) except Exception: H = 0.30 try: D = float(p.get("d") or _last("stuetze_d", 0.25)) except Exception: D = 0.25 try: t_wall = float(p.get("t") or _last("stuetze_t", 0.01)) except Exception: t_wall = 0.01 try: angle = float(p.get("angle") or _last("stuetze_angle", 0.0)) except Exception: angle = 0.0 z_over = p.get("zOver", "") prof_labels = ["Quadrat", "Rechteck", "Rund", "I-Profil", "Rohr"] def _prompt(base): return "{} [Profil={}, B={:.2f}{}{}, Drehung={:.0f}°]".format( base, prof_labels[_TRAG_PROFILE.index(profil)], B, ", H={:.2f}".format(H) if profil in ("rechteck", "i_profil") else "", ", D={:.2f}".format(D) if profil in ("rund", "rohr") else "", angle) pt = None try: while True: gp = ric.GetPoint() gp.SetCommandPrompt(_prompt("Stuetze: Position")) try: gp.DynamicDraw += _make_stuetze_preview( profil, B, H, D, t_wall, angle) except Exception: pass opt_pr = gp.AddOptionList("Profil", prof_labels, _TRAG_PROFILE.index(profil)) opt_b = gp.AddOption("Breite") opt_h = -1; opt_d = -1; opt_t = -1 if profil in ("rechteck", "i_profil"): opt_h = gp.AddOption("Hoehe") if profil in ("rund", "rohr"): opt_d = gp.AddOption("Durchmesser") if profil in ("i_profil", "rohr"): opt_t = gp.AddOption("Wanddicke") opt_a = gp.AddOption("Drehung") res = gp.Get() if res == GetResult.Option: idx = gp.OptionIndex() if idx == opt_pr: try: profil = _TRAG_PROFILE[gp.Option().CurrentListOptionIndex] except Exception: pass elif idx == opt_b: gn = ric.GetNumber(); gn.SetCommandPrompt("Breite (m)") gn.SetDefaultNumber(B); gn.SetLowerLimit(0.02, False) if gn.Get() == GetResult.Number: B = float(gn.Number()) elif opt_h >= 0 and idx == opt_h: gn = ric.GetNumber(); gn.SetCommandPrompt("Hoehe (m)") gn.SetDefaultNumber(H); gn.SetLowerLimit(0.02, False) if gn.Get() == GetResult.Number: H = float(gn.Number()) elif opt_d >= 0 and idx == opt_d: gn = ric.GetNumber(); gn.SetCommandPrompt("Durchmesser (m)") gn.SetDefaultNumber(D); gn.SetLowerLimit(0.02, False) if gn.Get() == GetResult.Number: D = float(gn.Number()) elif opt_t >= 0 and idx == opt_t: gn = ric.GetNumber(); gn.SetCommandPrompt("Wanddicke (m)") gn.SetDefaultNumber(t_wall); gn.SetLowerLimit(0.002, False) if gn.Get() == GetResult.Number: t_wall = float(gn.Number()) elif idx == opt_a: gn = ric.GetNumber(); gn.SetCommandPrompt("Drehung (Grad)") gn.SetDefaultNumber(angle) if gn.Get() == GetResult.Number: angle = float(gn.Number()) continue if res != GetResult.Point: return pt = gp.Point() break except Exception as ex: print("[ELEMENTE] stuetze point:", ex); return # Source-Point auf Z=0 setzen pt3 = rg.Point3d(pt.X, pt.Y, 0) element_id = "trag_" + uuid.uuid4().hex[:10] g = _geschoss_by_id(doc, geschoss) geschoss_name = g.get("name", "EG") if g else "EG" layer = _ensure_layer(doc, _layer_path_tragwerk(doc, geschoss_name)) attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = layer _attach_meta(attrs, element_id, "stuetze_point", geschoss, 0.0, "", "", "mid", trag_kind="stuetze", trag_profil=profil, trag_b=B, trag_h=H, trag_d=D, trag_t=t_wall, trag_angle=angle, trag_z_over=z_over) new_id = doc.Objects.AddPoint(pt3, attrs) if new_id == System.Guid.Empty: print("[ELEMENTE] Stuetze AddPoint fehlgeschlagen"); return _save_last(stuetze_profil=profil, stuetze_b=B, stuetze_h=H, stuetze_d=D, stuetze_t=t_wall, stuetze_angle=angle) _regenerate_element(doc, element_id) doc.Views.Redraw() print("[ELEMENTE] Stuetze erzeugt: {}".format(element_id)) self._send_state() def _cmd_create_traeger(self, p): """Traeger-Erzeugung: 2 Klicks (Achse) + Optionen. Profile: quadrat, rechteck, rund, i_profil, rohr.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return kind = "traeger" geschoss = p.get("geschoss") or _active_geschoss_id(doc) if not geschoss: print("[ELEMENTE] Kein Geschoss aktiv"); return try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception as ex: print("[ELEMENTE] Imports:", ex); return profil = p.get("profil") or _last("traeger_profil", "rechteck") if profil not in _TRAG_PROFILE: profil = "rechteck" try: B = float(p.get("b") or _last("traeger_b", 0.20)) except Exception: B = 0.20 try: H = float(p.get("h") or _last("traeger_h", 0.30)) except Exception: H = 0.30 try: D = float(p.get("d") or _last("traeger_d", 0.25)) except Exception: D = 0.25 try: t_wall = float(p.get("t") or _last("traeger_t", 0.01)) except Exception: t_wall = 0.01 try: angle = float(p.get("angle") or _last("traeger_angle", 0.0)) except Exception: angle = 0.0 z_over = p.get("zOver", "") prof_labels = ["Quadrat", "Rechteck", "Rund", "I-Profil", "Rohr"] def _prompt(base): return "Traeger: {} [Profil={}, B={:.2f}{}{}]".format( base, prof_labels[_TRAG_PROFILE.index(profil)], B, ", H={:.2f}".format(H) if profil in ("rechteck", "i_profil") else "", ", D={:.2f}".format(D) if profil in ("rund", "rohr") else "") first_pt = None try: while True: gp = ric.GetPoint() gp.SetCommandPrompt(_prompt("Anfangspunkt")) try: gp.DynamicDraw += _make_stuetze_preview( profil, B, H, D, t_wall, angle) except Exception: pass opt_pr = gp.AddOptionList("Profil", prof_labels, _TRAG_PROFILE.index(profil)) opt_b = gp.AddOption("Breite") opt_h = -1; opt_d = -1; opt_t = -1 if profil in ("rechteck", "i_profil"): opt_h = gp.AddOption("Hoehe") if profil in ("rund", "rohr"): opt_d = gp.AddOption("Durchmesser") if profil in ("i_profil", "rohr"): opt_t = gp.AddOption("Wanddicke") opt_a = gp.AddOption("Drehung") res = gp.Get() if res == GetResult.Option: idx = gp.OptionIndex() if idx == opt_pr: try: profil = _TRAG_PROFILE[gp.Option().CurrentListOptionIndex] except Exception: pass elif idx == opt_b: gn = ric.GetNumber(); gn.SetCommandPrompt("Breite (m)") gn.SetDefaultNumber(B); gn.SetLowerLimit(0.02, False) if gn.Get() == GetResult.Number: B = float(gn.Number()) elif opt_h >= 0 and idx == opt_h: gn = ric.GetNumber(); gn.SetCommandPrompt("Hoehe (m)") gn.SetDefaultNumber(H); gn.SetLowerLimit(0.02, False) if gn.Get() == GetResult.Number: H = float(gn.Number()) elif opt_d >= 0 and idx == opt_d: gn = ric.GetNumber(); gn.SetCommandPrompt("Durchmesser (m)") gn.SetDefaultNumber(D); gn.SetLowerLimit(0.02, False) if gn.Get() == GetResult.Number: D = float(gn.Number()) elif opt_t >= 0 and idx == opt_t: gn = ric.GetNumber(); gn.SetCommandPrompt("Wanddicke (m)") gn.SetDefaultNumber(t_wall); gn.SetLowerLimit(0.002, False) if gn.Get() == GetResult.Number: t_wall = float(gn.Number()) elif idx == opt_a: gn = ric.GetNumber(); gn.SetCommandPrompt("Drehung (Grad)") gn.SetDefaultNumber(angle) if gn.Get() == GetResult.Number: angle = float(gn.Number()) continue if res != GetResult.Point: return first_pt = gp.Point() break except Exception as ex: print("[ELEMENTE] traeger first-pt:", ex); return gp2 = ric.GetPoint() gp2.SetCommandPrompt(_prompt("Endpunkt")) try: gp2.SetBasePoint(first_pt, True) except Exception: pass try: gp2.DynamicDraw += _make_traeger_preview( first_pt, profil, B, H, D, t_wall, angle) except Exception: pass res = gp2.Get() if res != GetResult.Point: return end_pt = gp2.Point() p0 = rg.Point3d(first_pt.X, first_pt.Y, 0) p1 = rg.Point3d(end_pt.X, end_pt.Y, 0) if p0.DistanceTo(p1) < 1e-6: print("[ELEMENTE] Achse zu kurz"); return line = rg.LineCurve(p0, p1) element_id = "trag_" + uuid.uuid4().hex[:10] g = _geschoss_by_id(doc, geschoss) geschoss_name = g.get("name", "EG") if g else "EG" layer = _ensure_layer(doc, _layer_path_tragwerk(doc, geschoss_name)) attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = layer _attach_meta(attrs, element_id, "traeger_axis", geschoss, 0.0, "", "", "mid", trag_kind=kind, trag_profil=profil, trag_b=B, trag_h=H, trag_d=D, trag_t=t_wall, trag_angle=angle, trag_z_over=z_over) new_id = doc.Objects.AddCurve(line, attrs) if new_id == System.Guid.Empty: print("[ELEMENTE] Traeger AddCurve fehlgeschlagen"); return _save_last(traeger_profil=profil, traeger_b=B, traeger_h=H, traeger_d=D, traeger_t=t_wall, traeger_angle=angle) _regenerate_element(doc, element_id) doc.Views.Redraw() print("[ELEMENTE] Traeger erzeugt: {}".format(element_id)) self._send_state() def _cmd_create_raum(self, p): """Raum-Erzeugung: geschlossene Outline + Stempel mit Name + Flaeche. Modi: Polylinie | Rechteck | Rechteck3Punkte | Kreis.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return geschoss = p.get("geschoss") or _active_geschoss_id(doc) if not geschoss: print("[ELEMENTE] Kein Geschoss aktiv"); return try: import Rhino.Input.Custom as ric from Rhino.Input import GetResult except Exception as ex: print("[ELEMENTE] Imports:", ex); return modi = ["Polylinie", "Rechteck", "Rechteck3Punkte", "Kreis"] modus = p.get("modus") or _last("raum_modus", "Polylinie") if modus not in modi: modus = "Polylinie" name = p.get("name") or _last("raum_name_last", "Raum") rundung = p.get("rundung") or _last("raum_rundung", "0.1") if rundung not in _RAUM_RUNDUNGEN: rundung = "0.1" funktion = p.get("funktion") or _last("raum_funktion", "") try: txt_h = float(p.get("txtH") or _last("raum_txt_h", 0.20)) except Exception: txt_h = 0.20 def _build_prompt(base): return "{} [Modus={}, Name='{}', Rundung={}]".format( base, modus, name, rundung) first_pt = None try: while True: gp = ric.GetPoint() gp.SetCommandPrompt(_build_prompt("Raum: Startpunkt")) opt_modus = gp.AddOptionList("Modus", modi, modi.index(modus)) opt_rund = gp.AddOptionList("Rundung", list(_RAUM_RUNDUNGEN), _RAUM_RUNDUNGEN.index(rundung)) opt_name = gp.AddOption("Name") res = gp.Get() if res == GetResult.Option: idx = gp.OptionIndex() if idx == opt_modus: try: modus = modi[gp.Option().CurrentListOptionIndex] except Exception: pass elif idx == opt_rund: try: rundung = _RAUM_RUNDUNGEN[ gp.Option().CurrentListOptionIndex] except Exception: pass elif idx == opt_name: try: gs = ric.GetString() gs.SetCommandPrompt("Raum-Name") gs.SetDefaultString(name) if gs.Get() == GetResult.String: name = (gs.StringResult() or "Raum").strip() except Exception as ex: print("[ELEMENTE] GetString:", ex) continue if res != GetResult.Point: return first_pt = gp.Point() break except Exception as ex: print("[ELEMENTE] raum first-pt:", ex); return outline_curve = None try: if modus == "Polylinie": outline_curve = self._collect_polyline_outline(doc, first_pt) elif modus == "Rechteck": outline_curve = _collect_rectangle(doc, first_pt) elif modus == "Rechteck3Punkte": outline_curve = _collect_rectangle_3pt(doc, first_pt) elif modus == "Kreis": outline_curve = _collect_circle(doc, first_pt) except Exception as ex: print("[ELEMENTE] raum collect:", ex); return if outline_curve is None or not outline_curve.IsClosed: print("[ELEMENTE] keine gueltige Outline"); return # Element anlegen raum_id = "raum_" + uuid.uuid4().hex[:10] g = _geschoss_by_id(doc, geschoss) geschoss_name = g.get("name", "EG") if g else "EG" layer = _ensure_layer(doc, _layer_path_raum(doc, geschoss_name)) attrs = Rhino.DocObjects.ObjectAttributes() attrs.LayerIndex = layer _attach_meta(attrs, raum_id, "raum_outline", geschoss, 0.0, "", "", "mid", raum_name=name, raum_nummer="", raum_funktion=funktion, raum_rundung=rundung, raum_txt_h=txt_h) new_id = doc.Objects.AddCurve(outline_curve, attrs) if new_id == System.Guid.Empty: print("[ELEMENTE] Raum AddCurve fehlgeschlagen"); return _save_last(raum_modus=modus, raum_name_last=name, raum_rundung=rundung, raum_funktion=funktion, raum_txt_h=txt_h) _regenerate_element(doc, raum_id) doc.Views.Redraw() print("[ELEMENTE] Raum erzeugt: {} ({})".format(name, raum_id)) self._send_state() def _cmd_export_raeume(self, p): """Schreibt CSV mit allen Raeumen: Nummer, Name, Geschoss, Funktion, SIA, Flaeche, Umfang. Datei via SaveFileDialog.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return try: from Rhino.UI import SaveFileDialog sfd = SaveFileDialog() sfd.DefaultExt = "csv" sfd.Filter = "CSV (*.csv)|*.csv" sfd.FileName = "raeume.csv" ok = False try: ok = sfd.ShowSaveDialog() except Exception: try: ok = sfd.ShowDialog() except Exception: ok = False if not ok: print("[ELEMENTE] Export abgebrochen"); return path = sfd.FileName except Exception as ex: print("[ELEMENTE] SaveFileDialog:", ex); return rows = [] for obj in doc.Objects: m = _read_meta(obj) if not m or m["type"] != "raum_outline": continue try: g = _geschoss_by_id(doc, m["geschoss"]) gname = g.get("name", "?") if g else "?" area, perim, _c = _raum_amp(obj.Geometry) rnd = m.get("raum_rundung", "0.1") rows.append({ "nummer": m.get("raum_nummer", ""), "name": m.get("raum_name", "Raum"), "geschoss": gname, "funktion": m.get("raum_funktion", ""), "sia": _SIA_LABELS.get(m.get("raum_sia", ""), ""), "area": "{:.4f}".format(area), "area_fmt": _format_area(area, rnd), "umfang": "{:.4f}".format(perim), }) except Exception as ex: print("[ELEMENTE] export row:", ex) # Stabil sortieren: Geschoss, Nummer, Name rows.sort(key=lambda r: (r["geschoss"], r["nummer"], r["name"])) # CSV schreiben — Semikolon (DE/CH Excel) + UTF-8 BOM try: import io with io.open(path, "w", encoding="utf-8-sig", newline="") as f: f.write("Nummer;Name;Geschoss;Funktion;SIA;" "Flaeche (m2);Flaeche gerundet (m2);Umfang (m)\n") for r in rows: def esc(s): s = str(s) if ";" in s or '"' in s or "\n" in s: return '"' + s.replace('"', '""') + '"' return s f.write(";".join([ esc(r["nummer"]), esc(r["name"]), esc(r["geschoss"]), esc(r["funktion"]), esc(r["sia"]), r["area"].replace(".", ","), r["area_fmt"].replace(".", ","), r["umfang"].replace(".", ","), ]) + "\n") print("[ELEMENTE] Export Raeume: {} ({} Zeilen)".format( path, len(rows))) except Exception as ex: print("[ELEMENTE] CSV schreiben:", ex) # ------------------------------------------------------------------ # Swisstopo — Option-A-Workflow: # 1) "Karte oeffnen": map.geo.admin.ch im Browser mit vorausgewaehlten # Layern swissALTI3D + swissBUILDINGS3D. User waehlt sein Gebiet, # laedt DWG/OBJ/DAE runter. # 2) "Importieren": File-Picker -> Rhinos _-Import -> Plugin verschiebt # die NEU importierten Objekte auf den gewuenschten DOSSIER-Sublayer # unter dem aktiven Geschoss. # ------------------------------------------------------------------ def _cmd_open_swisstopo(self, p): """Oeffnet map.geo.admin.ch im Default-Browser mit den swisstopo- Layern voreingestellt. Param `mode`: 'buildings' | 'terrain' | 'both'. Optionaler `center`: 'CH1903_E,CH1903_N' fuer initiale Karten- Position; sonst Default-Center (Schweiz Mitte).""" import subprocess mode = (p.get("mode") or "both").lower() if mode == "buildings": layers = "ch.swisstopo.swissbuildings3d_3" elif mode == "terrain": layers = "ch.swisstopo.swissalti3d" else: layers = "ch.swisstopo.swissalti3d,ch.swisstopo.swissbuildings3d_3" # Default-Zentrum (Schweiz Mitte LV95) center = (p.get("center") or "2660000,1190000").strip() try: E, N = [s.strip() for s in center.split(",", 1)] except Exception: E, N = "2660000", "1190000" url = ("https://map.geo.admin.ch/" "?lang=de&topic=ech&bgLayer=ch.swisstopo.pixelkarte-farbe" "&E={}&N={}&zoom=8&layers={}").format(E, N, layers) try: subprocess.Popen(["open", url]) print("[ELEMENTE] Swisstopo Karte geoeffnet:", url) except Exception as ex: print("[ELEMENTE] Karte oeffnen fehlgeschlagen:", ex) def _cmd_import_swisstopo(self, p): """File-Picker -> Rhino _-Import -> bewege neue Objekte auf den DOSSIER-Sublayer. `kind`: 'buildings' (12_Gebaeude) | 'terrain' (10_Situation) | 'vermessung' (01_Vermessung) | 'other' (aktiver Layer wird nicht geaendert).""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return kind = (p.get("kind") or "buildings").lower() # Target-Sublayer ableiten (auto-anlegen wenn nicht vorhanden) if kind == "buildings": sub_name = _find_ebene_sublayer_name( doc, ["gebaeude", "gebäude", "buildings"], "12", "Gebäude", default_color="#888888", default_lw=0.25) elif kind == "terrain": sub_name = _find_ebene_sublayer_name( doc, ["situation", "terrain", "gelaende"], "10", "Situation", default_color="#909090", default_lw=0.18) elif kind == "vermessung": sub_name = _find_ebene_sublayer_name( doc, ["vermessung", "survey"], "01", "Vermessung", default_color="#707078", default_lw=0.18) else: sub_name = None # File-Picker try: from Rhino.UI import OpenFileDialog ofd = OpenFileDialog() ofd.Filter = ("Swisstopo Imports (*.dwg;*.dxf;*.obj;*.dae;*.ifc;*.3dm;*.ply;*.stl)|" "*.dwg;*.dxf;*.obj;*.dae;*.ifc;*.3dm;*.ply;*.stl|" "Alle Dateien (*.*)|*.*") ok = False try: ok = ofd.ShowOpenDialog() except Exception: try: ok = ofd.ShowDialog() except Exception: ok = False if not ok: print("[ELEMENTE] Swisstopo-Import abgebrochen"); return path = ofd.FileName except Exception as ex: print("[ELEMENTE] OpenFileDialog:", ex); return if not path or not os.path.isfile(path): print("[ELEMENTE] Pfad ungueltig:", path); return # Snapshot vor Import: existierende Object-IDs before_ids = set() try: for obj in doc.Objects: if obj is None or obj.IsDeleted: continue before_ids.add(obj.Id) except Exception: pass # Pfad fuer Rhino-Command escapen (Spaces!) cmd_path = path.replace('"', '\\"') cmd = '_-Import "{}" _Enter'.format(cmd_path) print("[ELEMENTE] Swisstopo-Import:", cmd) try: Rhino.RhinoApp.RunScript(cmd, False) except Exception as ex: print("[ELEMENTE] _-Import fehlgeschlagen:", ex); return # Differenz: neu hinzugekommene Objekte new_objs = [] try: for obj in doc.Objects: if obj is None or obj.IsDeleted: continue if obj.Id not in before_ids: new_objs.append(obj) except Exception: pass print("[ELEMENTE] Swisstopo-Import: {} neue Objekte".format(len(new_objs))) if not new_objs: return # Target-Layer finden + Objekte verschieben (nur wenn sub_name gesetzt) if sub_name: z_id = doc.Strings.GetValue("dossier_active_id") if not z_id: print("[ELEMENTE] Swisstopo: kein aktives Geschoss — Objekte " "bleiben auf Import-Default-Layer"); return try: import layer_builder parent_idx = layer_builder._find_top_by_id(doc, z_id) if parent_idx < 0: print("[ELEMENTE] Swisstopo: Parent-Layer nicht gefunden") return parent_id = doc.Layers[parent_idx].Id code = sub_name.split("_", 1)[0] sub_idx = layer_builder._find_sublayer_by_code(doc, parent_id, code) if sub_idx < 0: print("[ELEMENTE] Swisstopo: Sublayer {} nicht gefunden " "— bitte erst Ebenen-Apply ausloesen".format(code)) return moved = 0 for obj in new_objs: try: attrs = obj.Attributes.Duplicate() attrs.LayerIndex = sub_idx if doc.Objects.ModifyAttributes(obj, attrs, True): moved += 1 except Exception: pass print("[ELEMENTE] Swisstopo: {} Objekte auf {} verschoben".format( moved, doc.Layers[sub_idx].FullPath)) except Exception as ex: print("[ELEMENTE] Layer-Move fehler:", ex) try: doc.Views.Redraw() except Exception: pass def _cmd_open_swisstopo_dialog(self, p): """Oeffnet das volle Swisstopo-Importer-Satelliten-Fenster mit API- Anbindung (Adresse-Suche, Auto-Tiles, Terrain+Orthofoto).""" outer = self bridge_holder = {"form": None} # Initial-State fuer den Dialog: aktuelle Ebenen-Liste + Default- # Layer-Codes fuer die Auto-Sublayer-Erkennung doc = Rhino.RhinoDoc.ActiveDoc try: e_raw = doc.Strings.GetValue("dossier_ebenen") if doc else None ebenen = json.loads(e_raw) if e_raw else [] except Exception: ebenen = [] class _SwisstopoBridge(panel_base.BaseBridge): def __init__(self): panel_base.BaseBridge.__init__(self, "swisstopo") def _on_ready(self): self.send("SWISSTOPO_STATE", { "ebenen": ebenen, "cacheDir": __import__("swisstopo").CACHE_DIR, }) def _push_log(self, msg): try: self.send("SWISSTOPO_LOG", {"msg": str(msg)}) except Exception: pass def handle(self, data): if not isinstance(data, dict): return t = data.get("type", "") pp = data.get("payload") or {} if t == "READY": self._on_ready() elif t == "GEOCODE": import swisstopo res = swisstopo.geocode(pp.get("text") or "") self.send("GEOCODE_RESULT", {"result": res}) elif t == "RUN_IMPORT": self._run_import(pp) elif t == "CANCEL": try: f = bridge_holder.get("form") if f is not None: f.Close() except Exception: pass def _run_import(self, opts): """opts = {centerE, centerN, radius, kinds: ['buildings','terrain','ortho'], shiftToOrigin, autoZoom, layerBuildings, layerTerrain, terrainResolution}""" d = Rhino.RhinoDoc.ActiveDoc if d is None: self._push_log("Kein aktives Doc"); return try: import swisstopo, layer_builder except Exception as ex: self._push_log("Module-Import-Fehler: {}".format(ex)); return try: eC = float(opts.get("centerE")); nC = float(opts.get("centerN")) r = float(opts.get("radius") or 100) except Exception: self._push_log("Center/Radius ungueltig"); return kinds = set(opts.get("kinds") or ["buildings"]) shift = bool(opts.get("shiftToOrigin", True)) replace_existing = bool(opts.get("replaceExisting", True)) clip_to_bbox = bool(opts.get("clipToBbox", False)) # Doc-Unit-Skalierung. LV95-Werte sind in Metern. Wenn der # Rhino-Doc in mm/km/inch laeuft, muessen wir die bbox + den # shift in Doc-Units umrechnen — sonst stimmt der Clip-Check # mit den (auto-skaliert importierten) DXF-Coords nicht ueberein. try: m_to_unit = Rhino.RhinoMath.UnitScale( Rhino.UnitSystem.Meters, d.ModelUnitSystem) except Exception: m_to_unit = 1.0 # Projekt-Nullpunkt in m.ü.M lesen — wird als Z-Offset # angewandt damit Real-Welt-Höhen auf Doc-Z relativ zu OKFF=0 # liegen (sonst zeichnet man Geschosse 400m unter dem Terrain). try: z_mum_raw = d.Strings.GetValue("dossier_project_zero_mum") project_zero_mum = float(z_mum_raw) if z_mum_raw else 0.0 except Exception: project_zero_mum = 0.0 eC_u = eC * m_to_unit nC_u = nC * m_to_unit r_u = r * m_to_unit z_offset_m = project_zero_mum if shift else 0.0 # m z_offset_u = z_offset_m * m_to_unit # doc-units bbox = (eC - r, nC - r, eC + r, nC + r) # m (fuer STAC-Query) bbox_doc = (eC_u - r_u, nC_u - r_u, eC_u + r_u, nC_u + r_u) # in Doc-Units origin_shift = (eC, nC, z_offset_m) if shift else (0, 0, 0) origin_shift_doc = (eC_u, nC_u, z_offset_u) if shift else (0, 0, 0) if shift and abs(project_zero_mum) > 1e-6: self._push_log("Projekt-Nullpunkt: {:g} m.ü.M → Z-Offset {:g}m".format( project_zero_mum, z_offset_m)) self._push_log("Center LV95: E={:.1f} N={:.1f} Radius={}m".format(eC, nC, r)) self._push_log("BBox (m): {:.0f}-{:.0f} / {:.0f}-{:.0f}".format(*bbox)) if m_to_unit != 1.0: self._push_log("Doc-Unit: {} → m_to_unit={} (Skalierung aktiv)".format( d.ModelUnitSystem, m_to_unit)) z_id = d.Strings.GetValue("dossier_active_id") if not z_id: self._push_log("Achtung: kein aktives Geschoss — Objekte bleiben auf Default-Layer") # Bestehende swisstopo-Objekte loeschen wenn gewuenscht. # Tag wird beim Import gesetzt (UserString dossier_swisstopo_kind). if replace_existing: self._push_log("Loesche bestehende swisstopo-Objekte (alte Imports)...") removed = 0 for obj in list(d.Objects): if obj is None or obj.IsDeleted: continue try: tag = obj.Attributes.GetUserString("dossier_swisstopo_kind") except Exception: tag = None if tag: d.Objects.Delete(obj.Id, True) removed += 1 self._push_log("→ {} alte swisstopo-Objekte geloescht".format(removed)) new_obj_ids = [] def _track_new(before_ids): out = [] for obj in d.Objects: if obj is None or obj.IsDeleted: continue if obj.Id not in before_ids: out.append(obj) return out before_all = set(o.Id for o in d.Objects if o and not o.IsDeleted) # Cache-Folder pro Projekt setzen (neben der .3dm-Datei). # Damit reisen die Tiles mit dem Projekt — bei SMB-Sharing # findet Rhino die TIFs auch von anderen Maschinen, sofern # der Mount-Pfad identisch ist. Falls Doc unsaved: globaler # Cache. cache_dir = swisstopo.get_cache_dir_for_doc(d) swisstopo.set_cache_dir(cache_dir) self._push_log("Cache: {}".format(cache_dir)) # Listener-Suppression: elemente.py + gestaltung.py haben Add/ # Replace-Listener die pro neu importiertem Objekt feuern. Bei # 5000+ DXF-Objekten erstickt das den Import. Sticky-Flag setzen, # die Listener bailen früh (siehe _is_swisstopo_busy unten). sc.sticky["dossier_swisstopo_busy"] = True try: # --- Buildings (DWG) ----------------------------------- if "buildings" in kinds: variant = (opts.get("buildVariant") or "separated").strip().lower() if variant not in ("separated", "solid"): variant = "separated" version = (opts.get("buildVersion") or "v2").strip().lower() if version not in ("v2", "v3"): version = "v2" paths = swisstopo.fetch_buildings_dwg( bbox, progress=self._push_log, variant=variant, version=version) for idx, p in enumerate(paths): try: size_mb = os.path.getsize(p) / 1e6 except Exception: size_mb = 0 self._push_log("Import {}/{}: {} ({:.0f} MB) — Rhinos DXF-Parser, kann ein paar Sekunden dauern...".format( idx + 1, len(paths), os.path.basename(p), size_mb)) try: swisstopo._yield_ui() except Exception: pass before = set(o.Id for o in d.Objects if o and not o.IsDeleted) cmd = '_-Import "{}" _Enter'.format(p.replace('"', '\\"')) try: Rhino.RhinoApp.RunScript(cmd, False) except Exception as ex: self._push_log("Import {}: {}".format(p, ex)); continue new = _track_new(before) self._push_log("→ Import fertig: {} neue Objekte".format(len(new))) # Auto-Skala-Erkennung: Rhinos DXF-Parser kann je # nach $INSUNITS und Doc-Unit unerwartet 1000x rauf/ # runter skalieren. swissBUILDINGS3D 3.0 z.B. liefert # Werte in KM (Center bei ~2764, statt 2'763'800m). # Wir korrigieren das per Scale-Faktor auf den # importierten Objekten (nicht durch Verkleinern # der User-bbox — sonst sind die Objekte spaeter # 1000x zu klein relativ zu allem anderen im Doc). scale_correction = 1.0 if new and idx == 0: try: import math as _m samples = 0; sum_x = 0.0 for o in new[:50]: bb = o.Geometry.GetBoundingBox(True) sum_x += bb.Center.X samples += 1 avg_x = sum_x / max(1, samples) expected_x = eC * m_to_unit if abs(expected_x) > 1.0 and avg_x != 0: ratio = expected_x / avg_x snap = 10 ** round(_m.log10(abs(ratio))) if abs(snap - 1.0) > 0.01: scale_correction = snap self._push_log("AUTO-SKALA: imports {}× off — scale-up {:g}×".format( "klein" if snap > 1 else "gross", snap)) except Exception as ex: self._push_log("Auto-Skala-Erkennung: {}".format(ex)) # Diagnose if new: try: obb0 = new[0].Geometry.GetBoundingBox(True) self._push_log(" Erste obj bbox (Doc-Units): " "({:.3f},{:.3f},{:.3f}) - ({:.3f},{:.3f},{:.3f})".format( obb0.Min.X, obb0.Min.Y, obb0.Min.Z, obb0.Max.X, obb0.Max.Y, obb0.Max.Z)) self._push_log(" bbox_doc (nach Auto-Skala): " "{:.3f}-{:.3f} / {:.3f}-{:.3f}".format(*bbox_doc)) except Exception: pass # Post-Clip nur wenn User es will (Default OFF) — bei # InstanceReferences ist GetBoundingBox + Delete teuer. # Tile = 1km², User-radius typisch 100m → ohne Clip # hast du das ganze Dorf, aber Import bleibt schnell. if clip_to_bbox: self._push_log("→ Clippe auf User-bbox...") kept = [] outside = [] for o in new: try: obb = o.Geometry.GetBoundingBox(True) cx = (obb.Min.X + obb.Max.X) * 0.5 cy = (obb.Min.Y + obb.Max.Y) * 0.5 if (bbox_doc[0] <= cx <= bbox_doc[2] and bbox_doc[1] <= cy <= bbox_doc[3]): kept.append(o) else: outside.append(o) except Exception: kept.append(o) if not kept and outside: self._push_log(" → Clip waere {}/{} → bbox passt nicht zu Doc-Coords, alle behalten".format( len(outside), len(new))) kept = new else: # Batch-Delete: deutlich schneller als per-obj out_ids = [o.Id for o in outside] for oid in out_ids: try: d.Objects.Delete(oid, True) except Exception: pass self._push_log(" → {} behalten, {} ausserhalb bbox geloescht".format( len(kept), len(out_ids))) else: # Kein Clip — alle behalten kept = new try: swisstopo._yield_ui() except Exception: pass # Scale + Move via Rhinos eingebaute Commands auf # Selektion — die batchen intern und sind bei 7000 # Objekten in Sekunden durch (statt Minuten mit # einzeln-Transform-Loop). translate_doc = None if shift: translate_doc = (-origin_shift_doc[0], -origin_shift_doc[1], -origin_shift_doc[2]) ops = [] if abs(scale_correction - 1.0) > 1e-6: ops.append("Scale {}×".format(scale_correction)) if shift: ops.append("Shift→Origin") if ops and kept: self._push_log("→ {} ({} Obj)...".format( " + ".join(ops), len(kept))) self._apply_xform_fast( d, kept, scale_factor=scale_correction, translate=translate_doc) # Layer-Konsolidierung: # 81_Swissbuildings ist hierarchische Ebene mit # Children Build/Roof/Wall/Floor (codes 8101-8104). # _consolidate_buildings stellt die Hierarchie in # dossier_ebenen sicher + verschiebt Objekte auf # die richtige Child-Layer + loescht leere # DWG-Source-Layer. Im Ebenen-Manager sind die # Children dann als Sub-Ebenen sichtbar (aufklappen). if z_id and kept: if variant == "solid": self._push_log("→ Buildings auf '81_Swissbuildings' (solid)...") else: self._push_log("→ Layer konsolidieren (Build/Roof/Wall/Floor)...") self._consolidate_buildings(d, kept, z_id, target_code="81", variant=variant) else: self._tag_objects(d, kept, "buildings") new_obj_ids.extend(o.Id for o in kept) # --- Terrain (XYZ → Mesh) ------------------------------ # Terrain-Daten (XYZ + Grid) holen, sobald Mesh ODER # Hoehenlinien gewuenscht sind — beide nutzen das Grid. need_dem = any(k in kinds for k in ("terrain", "contours", "contour_tin", "contour_schicht")) mesh_objects = [] merged_grid = None if need_dem: res = (opts.get("terrainResolution") or "2.0").strip() try: target_step = float(res) except Exception: target_step = 2.0 xyz_paths = swisstopo.fetch_terrain_xyz( bbox, resolution=res, progress=self._push_log) grids = [] for p in xyz_paths: self._push_log("Parse {}...".format(os.path.basename(p))) try: grid = swisstopo.xyz_to_grid( p, target_step=target_step, clip_bbox=bbox, progress=self._push_log) if grid is not None: grids.append(grid) except Exception as ex: self._push_log("XYZ-Parse fail: {}".format(ex)) if grids: try: merged = swisstopo.merge_grids(grids) if merged is None: self._push_log("Merge lieferte None") else: merged_grid = merged self._push_log("Merge: {} Tiles → {} Punkte ({}×{} Raster)".format( len(grids), len(merged["points"]), len(merged["es"]), len(merged["ns"]))) except Exception as ex: self._push_log("Grid-Merge fehlgeschlagen: {}".format(ex)) # 3D-Mesh bauen wenn Terrain gewuenscht — unabhaengig vom # Ortho. Wenn Ortho auch an ist: Drape-Mesh liegt ueber # dem Plain-Mesh (User togglet im Layer-Panel was er # sehen will). if "terrain" in kinds and merged_grid is not None: try: mesh = swisstopo.mesh_from_grid( merged_grid, origin_shift=origin_shift, unit_scale=m_to_unit) self._push_log("→ Mesh: {} Vertices / {} Faces".format( mesh.Vertices.Count, mesh.Faces.Count)) gid = d.Objects.AddMesh(mesh) obj = d.Objects.Find(gid) if obj: mesh_objects.append((obj, merged_grid["bbox"])) except Exception as ex: self._push_log("Mesh-Bau fehlgeschlagen: {}".format(ex)) # Contours sind die Grundlage fuer drei moegliche Outputs: # 'contours' → flache 2D-Curves auf OKFF # 'contour_tin' → TIN-Mesh aus Contour-Vertices # 'contour_schicht' → Planare Flaechen pro Hoehe # Wir generieren einmal die echten 3D-Curves und teilen # sie auf die drei Outputs auf. contour_kinds = ("contours", "contour_tin", "contour_schicht") need_contours = any(k in kinds for k in contour_kinds) and merged_grid is not None raw_contours = [] if need_contours: try: interval_c = float(opts.get("contourInterval") or 2.0) except Exception: interval_c = 2.0 try: self._push_log("Hoehenlinien generieren (Abstand {} m, real Z)...".format(interval_c)) raw_contours = swisstopo.generate_contour_curves( merged_grid, origin_shift, m_to_unit, interval=interval_c, progress=self._push_log) except Exception as ex: self._push_log("Contour-Generation-Fehler: {}".format(ex)) raw_contours = [] # 2D-Hoehenlinien auf OKFF des aktiven Geschosses if "contours" in kinds and raw_contours: project_zero_doc = 0.0 if shift else project_zero_mum * m_to_unit active_okff = 0.0 try: z_raw = d.Strings.GetValue("dossier_zeichnungsebenen") zlist = json.loads(z_raw) if z_raw else [] for z_ in zlist: if isinstance(z_, dict) and z_.get("id") == z_id: active_okff = float(z_.get("okff", 0) or 0) break except Exception: pass flatten_z_doc = project_zero_doc + active_okff * m_to_unit self._push_log("2D-Hoehenlinien auf OKFF Z={:.3f}...".format(flatten_z_doc)) contour_objs = [] for c in raw_contours: # Wichtig: duplizieren, damit das Original (mit # echtem Z) fuer TIN/Schichten erhalten bleibt. try: c_flat = c.DuplicateCurve() bb_c = c_flat.GetBoundingBox(True) z_mid = (bb_c.Min.Z + bb_c.Max.Z) * 0.5 dz = flatten_z_doc - z_mid if abs(dz) > 1e-9: c_flat.Translate(rg.Vector3d(0, 0, dz)) gid = d.Objects.AddCurve(c_flat) if gid and gid != System.Guid.Empty: ob = d.Objects.Find(gid) if ob: contour_objs.append(ob) except Exception: pass if z_id and contour_objs: self._move_to_sublayer( d, contour_objs, z_id, "14", tag="contour", fallback_name="14_Höhenlinien", fallback_color="#909050") elif contour_objs: self._tag_objects(d, contour_objs, "contour") self._push_log("→ {} Hoehenlinien (2D) auf '14_Höhenlinien'".format( len(contour_objs))) # TIN-Mesh aus Hoehenlinien if "contour_tin" in kinds and raw_contours: try: tin_obj = swisstopo.generate_mesh_from_contours( d, raw_contours, m_to_unit=m_to_unit, progress=self._push_log) if tin_obj: # Tag + auf 80_swisstopo Parent at = tin_obj.Attributes.Duplicate() at.SetUserString("dossier_swisstopo_kind", "contour_tin") d.Objects.ModifyAttributes(tin_obj, at, True) if z_id: self._move_to_sublayer( d, [tin_obj], z_id, "80", tag="contour_tin", fallback_name="80_swisstopo", fallback_color="#909090") except Exception as ex: self._push_log("TIN-Mesh-Fehler: {}".format(ex)) # Schichtenmodell (planare Flaechen pro Hoehe) if "contour_schicht" in kinds and raw_contours: try: schicht_objs = swisstopo.generate_schichtenmodell( d, raw_contours, progress=self._push_log) for s in schicht_objs: try: at = s.Attributes.Duplicate() at.SetUserString("dossier_swisstopo_kind", "contour_schicht") d.Objects.ModifyAttributes(s, at, True) except Exception: pass if z_id and schicht_objs: self._move_to_sublayer( d, schicht_objs, z_id, "80", tag="contour_schicht", fallback_name="80_swisstopo", fallback_color="#909090") self._push_log("→ Schichtenmodell: {} Flaechen auf '80_swisstopo'".format( len(schicht_objs))) except Exception as ex: self._push_log("Schichtenmodell-Fehler: {}".format(ex)) # Layer-Move auf aktive Geschoss/80_swisstopo Sublayer if z_id and mesh_objects: sub_name = _find_ebene_sublayer_name( d, ["swisstopo", "gelaende_topo"], "80", "swisstopo", default_color="#909090", default_lw=0.18) objs = [m[0] for m in mesh_objects] self._move_to_sublayer(d, objs, z_id, sub_name.split("_", 1)[0], tag="terrain", fallback_name=sub_name, fallback_color="#909090") elif mesh_objects: objs = [m[0] for m in mesh_objects] self._tag_objects(d, objs, "terrain") if "ortho" in kinds and merged_grid is not None: self._push_log("Hole Orthofoto...") ortho_paths = swisstopo.fetch_orthophoto( bbox, resolution="2.0", progress=self._push_log) if ortho_paths: # Pro Tile: # - Drape-Mesh (Foto folgt Topo) auf '80T_Terrain' # - flache PictureFrame (fuer 2D-Zeichnen) auf '80L_Luftbild' self._push_log("→ {} Ortho-Tile(s) als Terrain (Drape) + Luftbild (flach)".format( len(ortho_paths))) # Sub-Ebenen Terrain + Luftbild sicherstellen sub_codes = {} if z_id: _find_ebene_sublayer_name( d, ["swisstopo", "gelaende_topo"], "80", "swisstopo", default_color="#909090", default_lw=0.18) sub_codes = self._ensure_swisstopo_subebenen(d) # Target-Layer-Indices fuer Terrain + Luftbild import layer_builder as _lb_o terrain_idx = -1 luftbild_idx = -1 if z_id and sub_codes: parent_idx = _lb_o._find_top_by_id(d, z_id) if parent_idx >= 0: parent_id_ = d.Layers[parent_idx].Id base_idx = _lb_o._find_sublayer_by_code( d, parent_id_, "80") if base_idx >= 0: base_id_ = d.Layers[base_idx].Id if sub_codes.get("terrain"): terrain_idx = _lb_o._find_sublayer_by_code( d, base_id_, sub_codes["terrain"]) if sub_codes.get("luftbild"): luftbild_idx = _lb_o._find_sublayer_by_code( d, base_id_, sub_codes["luftbild"]) # Max-Z des Terrains fuer flache Luftbild-Plane terr_max_z_doc = 0.0 if merged_grid: try: max_z_m = max(z for z in merged_grid["points"].values()) terr_max_z_doc = (max_z_m - origin_shift[2]) * m_to_unit except Exception: pass flat_z = terr_max_z_doc + max(0.001, terr_max_z_doc * 1e-4) ortho_objs = [] for ortho_path in ortho_paths: tile_bbox = _parse_swisstopo_tile_bbox( os.path.basename(ortho_path)) if tile_bbox is None: self._push_log(" → Tile-bbox nicht ableitbar aus {}".format( os.path.basename(ortho_path))) continue # 1) Drape-Mesh auf '80T_Terrain' try: drape = swisstopo.add_ortho_draped_mesh( d, ortho_path, tile_bbox, merged_grid, origin_shift, m_to_unit, z_lift=0.05, target_layer_idx=terrain_idx) if drape: ortho_objs.append(drape) try: at = drape.Attributes.Duplicate() at.SetUserString( "dossier_swisstopo_kind", "ortho") d.Objects.ModifyAttributes(drape, at, True) except Exception: pass except Exception as ex: self._push_log("Drape-Apply: {}".format(ex)) # 2) Flache Picture auf '80L_Luftbild' try: flat = swisstopo.add_ortho_plane( d, ortho_path, tile_bbox, origin_shift, m_to_unit, z_doc=flat_z, target_layer_idx=luftbild_idx) if flat: ortho_objs.append(flat) try: at = flat.Attributes.Duplicate() at.SetUserString( "dossier_swisstopo_kind", "ortho") d.Objects.ModifyAttributes(flat, at, True) except Exception: pass except Exception as ex: self._push_log("Flat-Apply: {}".format(ex)) self._push_log("→ {} Ortho-Objekte (Drape+Flat) auf eigene Sub-Ebenen".format( len(ortho_objs))) # End-Diagnose mit BBox-Koords damit wir sehen # wo die Pictures tatsaechlich gelandet sind. try: diag = [] for o in d.Objects: if o is None or o.IsDeleted: continue tag = o.Attributes.GetUserString("dossier_swisstopo_kind") if tag != "ortho": continue li = o.Attributes.LayerIndex lay = d.Layers[li] try: bb = o.Geometry.GetBoundingBox(True) except Exception: bb = None diag.append({ "id": str(o.Id)[:8], "lay": lay.FullPath, "vis": lay.IsVisible, "lck": lay.IsLocked, "hid": o.IsHidden, "typ": type(o.Geometry).__name__, "bb": bb, }) self._push_log("DIAG: {} Ortho-Objekte im Doc".format(len(diag))) for s in diag[:4]: bb = s["bb"] bbstr = "bb=({:.0f},{:.0f},{:.0f})→({:.0f},{:.0f},{:.0f})".format( bb.Min.X, bb.Min.Y, bb.Min.Z, bb.Max.X, bb.Max.Y, bb.Max.Z) if bb else "bb=?" self._push_log(" {} typ={} {} vis={} hid={} lay='{}'".format( s["id"], s["typ"], bbstr, s["vis"], s["hid"], s["lay"])) # Building-bbox zum Vergleich bb_b = rg.BoundingBox.Empty n_b = 0 for o in d.Objects: if o is None or o.IsDeleted: continue tag = o.Attributes.GetUserString("dossier_swisstopo_kind") if tag != "buildings": continue try: bb_b.Union(o.Geometry.GetBoundingBox(True)) n_b += 1 except Exception: pass if bb_b.IsValid: self._push_log("DIAG: Buildings ({} Obj) bb=({:.0f},{:.0f},{:.0f})→({:.0f},{:.0f},{:.0f})".format( n_b, bb_b.Min.X, bb_b.Min.Y, bb_b.Min.Z, bb_b.Max.X, bb_b.Max.Y, bb_b.Max.Z)) except Exception as ex: self._push_log("DIAG fail: {}".format(ex)) new_obj_ids.extend(o.Id for o in ortho_objs) new_obj_ids.extend(o.Id for o, _ in mesh_objects) # --- TLM3D Vektor (Strassen/Wasser/Bahn/Vegetation) --- if "tlm" in kinds: tlm_kinds = opts.get("tlmKinds") or [] if tlm_kinds: self._push_log("TLM3D Vektor holen ({} Kategorien)...".format( len(tlm_kinds))) try: tlm_paths = swisstopo.fetch_tlm3d_vector( bbox, tlm_kinds, progress=self._push_log) except Exception as ex: self._push_log("TLM Fetch-Fehler: {}".format(ex)) tlm_paths = {} # Layer-Mapping: TLM-Kategorie → Dossier-Ebenen-Code tlm_layer_map = { "streets": "11", # 11_Strasse (Default-Ebene) "waterways": "15", # 15_Gewässer (auto-add) "railways": "16", # 16_Bahn (auto-add) "landcover": "13", # 13_Bäume (Default-Ebene) } tlm_fallback_names = { "11": "11_Strasse", "13": "13_Bäume", "15": "15_Gewässer", "16": "16_Bahn", } for cat, paths_list in tlm_paths.items(): for tlm_p in paths_list: self._push_log("Import TLM {}: {}".format( cat, os.path.basename(tlm_p))) before_tlm = set(o.Id for o in d.Objects if o and not o.IsDeleted) cmd = '_-Import "{}" _Enter'.format( tlm_p.replace('"', '\\"')) try: Rhino.RhinoApp.RunScript(cmd, False) except Exception as ex: self._push_log(" Import-Fail: {}".format(ex)) continue new_tlm = [o for o in d.Objects if o and not o.IsDeleted and o.Id not in before_tlm] self._push_log(" → {} Objekte".format(len(new_tlm))) # Auto-Skala falls noetig (gleiche Logik wie Buildings) if new_tlm and abs(eC) > 1.0: try: import math as _m sx = sum(o.Geometry.GetBoundingBox(True).Center.X for o in new_tlm[:30]) / min(30, len(new_tlm)) ratio = (eC * m_to_unit) / sx if sx else 1 snap = 10 ** round(_m.log10(abs(ratio))) if abs(snap - 1.0) > 0.01: self._push_log(" TLM Auto-Skala {}×".format(snap)) self._apply_xform_fast(d, new_tlm, scale_factor=snap, translate=(-origin_shift_doc[0], -origin_shift_doc[1], 0)) elif shift: self._apply_xform_fast(d, new_tlm, translate=(-origin_shift_doc[0], -origin_shift_doc[1], 0)) except Exception as ex: self._push_log(" TLM Skala/Shift: {}".format(ex)) # Layer + Tag code = tlm_layer_map.get(cat) fallback = tlm_fallback_names.get(code) if z_id and new_tlm and code: self._move_to_sublayer(d, new_tlm, z_id, code, tag="tlm_" + cat, fallback_name=fallback, fallback_color="#707080") elif new_tlm: self._tag_objects(d, new_tlm, "tlm_" + cat) new_obj_ids.extend(o.Id for o in new_tlm) self._push_log("Import fertig: {} neue Objekte".format(len(new_obj_ids))) # Auto-Zoom NOCH IM TRY-Block: sticky-Flag bleibt True # damit der Select-Roundtrip nicht 3000 Listener weckt. # ZoomBoundingBox + UnionBBox aller neuen → 1 API-Call # statt Select-Loop. if opts.get("autoZoom") and new_obj_ids: try: combined = rg.BoundingBox.Empty for oid in new_obj_ids: obj = d.Objects.Find(oid) if obj is None: continue try: bb = obj.Geometry.GetBoundingBox(True) if bb.IsValid: combined.Union(bb) except Exception: pass if combined.IsValid: view = d.Views.ActiveView if view is not None: view.ActiveViewport.ZoomBoundingBox(combined) except Exception as ex: self._push_log("Auto-Zoom: {}".format(ex)) try: d.Views.Redraw() except Exception: pass self.send("IMPORT_DONE", {"count": len(new_obj_ids)}) finally: sc.sticky["dossier_swisstopo_busy"] = False def _apply_xform_fast(self, doc, objs, scale_factor=1.0, translate=None): """Scale+Move via Rhinos eingebaute _-Scale/_-Move Commands auf einer Selektion. Die sind C++-intern hochoptimiert und deutlich schneller als RhinoCommon API-Calls — bei 7000+ Objekten Sekunden statt Minuten. Scale-Syntax: 3-Punkt-Form `_-Scale base ref target` mit ref=1 Einheit, target=N Einheiten → Faktor N eindeutig. Move-Syntax: 2-Punkt-Form `_-Move base target`.""" if not objs: return True need_scale = abs(scale_factor - 1.0) > 1e-6 need_move = translate is not None and any( abs(v) > 1e-9 for v in translate) if not (need_scale or need_move): return True try: # Selektion via Batch-Select doc.Objects.UnselectAll() from System.Collections.Generic import List as _List from System import Guid as _Guid sel = _List[_Guid]() for o in objs: sel.Add(o.Id) try: n_sel = doc.Objects.Select(sel, True) except Exception: n_sel = 0 for o in objs: if doc.Objects.Select(o.Id, True): n_sel += 1 self._push_log(" {} Obj selektiert".format(n_sel)) # Scale: 3-Punkt if need_scale: cmd = "_-Scale 0,0,0 1,0,0 {:.0f},0,0 _Enter".format( scale_factor) ok = Rhino.RhinoApp.RunScript(cmd, False) self._push_log(" _-Scale {:g}× → {}".format( scale_factor, ok)) # Move: 2-Punkt if need_move: dx, dy, dz = translate cmd = "_-Move 0,0,0 {:.6f},{:.6f},{:.6f} _Enter".format( dx, dy, dz) ok = Rhino.RhinoApp.RunScript(cmd, False) self._push_log(" _-Move {} → {}".format( (round(dx), round(dy), round(dz)), ok)) doc.Objects.UnselectAll() return True except Exception as ex: self._push_log(" _apply_xform_fast: {}".format(ex)) try: doc.Objects.UnselectAll() except Exception: pass return False def _tag_objects(self, doc, objs, tag): """Setzt nur den dossier_swisstopo_kind UserString — fuer den Fall dass kein Geschoss aktiv ist und wir den Layer-Move ueberspringen, aber den Marker fuers Replace-Erkennen brauchen.""" for o in objs: try: attrs = o.Attributes.Duplicate() attrs.SetUserString("dossier_swisstopo_kind", tag) doc.Objects.ModifyAttributes(o, attrs, True) except Exception: pass def _move_orthos_to_per_tile_layers(self, doc, objs_with_paths, z_id): """Jedes Ortho-Tile bekommt eine eigene Sub-Ebene unter 80_swisstopo (Code=Tile-ID, z.B. '2763-1254'). Die Sub-Ebene wird via dossier_ebenen JSON registriert → erscheint sowohl im Dossier-Ebenen-Manager als auch im Rhino-Layer-Panel. User kann jedes Tile einzeln togglen.""" import re as _re try: # Schritt 1: alle tile_ids ermitteln tiles = [] for obj, path in objs_with_paths: m = _re.search(r"(\d{3,4}-\d{2,4})", os.path.basename(path)) tile_id = m.group(1) if m else None if tile_id: tiles.append((obj, tile_id)) if not tiles: return # Schritt 2: alle als Children von 80_swisstopo registrieren self._ensure_ortho_tile_ebenen( doc, [t for _, t in tiles]) # Schritt 3: Objekte auf die jetzt existierenden Sublayer import layer_builder parent_idx = layer_builder._find_top_by_id(doc, z_id) if parent_idx < 0: return parent_id = doc.Layers[parent_idx].Id base_idx = layer_builder._find_sublayer_by_code( doc, parent_id, "80") if base_idx < 0: self._push_log(" 80_swisstopo nicht gefunden") return base_id = doc.Layers[base_idx].Id moved = 0 for obj, tile_id in tiles: sub_idx = layer_builder._find_sublayer_by_code( doc, base_id, tile_id) if sub_idx < 0: self._push_log(" Sub-Layer fuer {} nicht gefunden".format(tile_id)) continue try: attrs = obj.Attributes.Duplicate() attrs.LayerIndex = sub_idx attrs.SetUserString("dossier_swisstopo_kind", "ortho") doc.Objects.ModifyAttributes(obj, attrs, True) moved += 1 except Exception as ex: self._push_log(" ortho-move {}: {}".format(tile_id, ex)) self._push_log(" → {} Ortho-Tile(s) auf eigene Sub-Ebene".format(moved)) except Exception as ex: self._push_log(" ortho-per-tile: {}".format(ex)) def _ensure_swisstopo_subebenen(self, doc): """Stellt sicher dass 80_swisstopo zwei Children hat: 'Terrain' (Drape-Mesh — Foto folgt Topographie) und 'Luftbild' (flache Picture ueber max-Z — fuer 2D-Zeichnen). Liefert {'terrain': '80T', 'luftbild': '80L'}.""" CHILD_SPEC = [ ("80T", "Terrain", "#909090", "terrain"), ("80L", "Luftbild", "#888888", "luftbild"), ] raw = doc.Strings.GetValue("dossier_ebenen") try: ebenen = json.loads(raw) if raw else [] except Exception: ebenen = [] if not isinstance(ebenen, list): ebenen = [] parent = next((e for e in ebenen if isinstance(e, dict) and e.get("code") == "80"), None) if parent is None: parent = { "code": "80", "name": "swisstopo", "color": "#909090", "lw": 0.18, "visible": True, "locked": False, "children": [], } ebenen.append(parent) if not isinstance(parent.get("children"), list): parent["children"] = [] have = {c.get("code") for c in parent["children"] if isinstance(c, dict)} changed = False for ccode, cname, ccol, _key in CHILD_SPEC: if ccode not in have: parent["children"].append({ "code": ccode, "name": cname, "color": ccol, "lw": 0.13, "visible": True, "locked": False, }) changed = True if changed: try: doc.Strings.SetString("dossier_ebenen", json.dumps(ebenen, ensure_ascii=False)) import layer_builder z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") zlist = json.loads(z_raw) if z_raw else [] if zlist: layer_builder.build_layers(doc, zlist, ebenen) import rhinopanel rhinopanel._broadcast_state(doc) except Exception as ex: self._push_log(" swisstopo-ebenen build: {}".format(ex)) return {key: ccode for ccode, _n, _col, key in CHILD_SPEC} def _ensure_sub_sublayer(self, doc, parent_id, name, color_hex="#888888", lw=0.25): """Findet oder erstellt einen Sub-Layer mit Name direkt unter parent_id. Liefert layer_index oder -1.""" try: import System.Drawing as SD for i in range(doc.Layers.Count): lay = doc.Layers[i] if lay is None or lay.IsDeleted: continue if lay.ParentLayerId == parent_id and lay.Name == name: return i new_lay = Rhino.DocObjects.Layer() new_lay.Name = name new_lay.ParentLayerId = parent_id try: h = color_hex.lstrip("#") r = int(h[0:2], 16); g = int(h[2:4], 16); b = int(h[4:6], 16) new_lay.Color = SD.Color.FromArgb(255, r, g, b) except Exception: pass try: new_lay.PlotWeight = float(lw) except Exception: pass return doc.Layers.Add(new_lay) except Exception as ex: self._push_log("ensure_sub_sublayer: {}".format(ex)) return -1 def _ensure_swissbuildings_ebene(self, doc, with_children=True): """Stellt sicher dass 81_Swissbuildings in dossier_ebenen existiert. Bei with_children=True (separated-Variante) auch die vier Children Build/Roof/Wall/Floor; bei False (solid) bleibt sie ein flacher Layer ohne Sub-Aufteilung. Triggert build_layers synchron, damit die Rhino-Layer real existieren bevor wir Objekte verschieben. Liefert {build,roof,wall,floor} → Sub-Sub-Layer-Code wenn with_children=True, sonst {}.""" CHILD_SPEC = [ ("8101", "Build", "#888888", "build"), ("8102", "Roof", "#a64d4d", "roof"), ("8103", "Wall", "#666666", "wall"), ("8104", "Floor", "#555555", "floor"), ] raw = doc.Strings.GetValue("dossier_ebenen") try: ebenen = json.loads(raw) if raw else [] except Exception: ebenen = [] if not isinstance(ebenen, list): ebenen = [] sb = next((e for e in ebenen if isinstance(e, dict) and e.get("code") == "81"), None) changed = False if sb is None: sb = { "code": "81", "name": "Swissbuildings", "color": "#888888", "lw": 0.25, "visible": True, "locked": False, "children": [], } ebenen.append(sb) changed = True if with_children: if not isinstance(sb.get("children"), list): sb["children"] = [] changed = True have_codes = {c.get("code") for c in sb["children"] if isinstance(c, dict)} for ccode, cname, ccol, _key in CHILD_SPEC: if ccode not in have_codes: sb["children"].append({ "code": ccode, "name": cname, "color": ccol, "lw": 0.25, "visible": True, "locked": False, }) changed = True if changed: try: doc.Strings.SetString("dossier_ebenen", json.dumps(ebenen, ensure_ascii=False)) except Exception as ex: self._push_log("save dossier_ebenen: {}".format(ex)) # Layers synchron erzeugen try: import layer_builder z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") zlist = json.loads(z_raw) if z_raw else [] if zlist: layer_builder.build_layers(doc, zlist, ebenen) except Exception as ex: self._push_log("build_layers: {}".format(ex)) # UI informieren — broadcast_state schickt STATE_SYNC an # ebenen_bridge_ref + zeichnungsebenen_bridge_ref try: import rhinopanel rhinopanel._broadcast_state(doc) except Exception as ex: self._push_log("broadcast_state: {}".format(ex)) if not with_children: return {} return {key: ccode for ccode, _n, _col, key in CHILD_SPEC} def _consolidate_buildings(self, doc, objs, z_id, target_code="81", target_name="Swissbuildings", variant="separated"): """Verschiebt Buildings auf den 81_Swissbuildings-Layer. - separated: Sub-Sub-Layer Build/Roof/Wall/Floor basierend auf dem DWG-Source-Layer-Prefix. - solid: alles direkt auf den Parent-Sublayer (keine Children). Loescht leere DWG-Source-Layer am Ende.""" if not objs: return solid = (variant == "solid") try: import layer_builder # Ebene + Children (bei separated) sicherstellen + bauen child_codes = self._ensure_swissbuildings_ebene( doc, with_children=not solid) parent_idx = layer_builder._find_top_by_id(doc, z_id) if parent_idx < 0: self._push_log(" Geschoss nicht gefunden"); return parent_id = doc.Layers[parent_idx].Id base_idx = layer_builder._find_sublayer_by_code( doc, parent_id, target_code) if base_idx < 0: self._push_log(" 81_Swissbuildings nicht im aktiven Geschoss") return base_id = doc.Layers[base_idx].Id # Target-Mapping if solid: # Alle Objekte landen direkt auf base_idx target = {"all": base_idx} else: target = {} for key, ccode in child_codes.items(): idx = layer_builder._find_sublayer_by_code( doc, base_id, ccode) if idx >= 0: target[key] = idx if not target: self._push_log(" Children-Layer fehlen — Build_layers nicht durchgelaufen?") return # Objekte umlayern source_indices = set() counts = {k: 0 for k in target} for o in objs: try: src_idx = o.Attributes.LayerIndex source_indices.add(src_idx) if solid: tgt_idx = target["all"] counts["all"] += 1 else: src_name = doc.Layers[src_idx].Name.lower() tgt_idx = None for key in ("roof", "wall", "floor", "build"): if src_name.startswith(key): tgt_idx = target.get(key) if tgt_idx is not None: counts[key] += 1 break if tgt_idx is None: tgt_idx = target.get("build") if tgt_idx is None: continue attrs = o.Attributes.Duplicate() attrs.LayerIndex = tgt_idx attrs.SetUserString("dossier_swisstopo_kind", "buildings") doc.Objects.ModifyAttributes(o, attrs, True) except Exception: pass for key, n in counts.items(): if n > 0: self._push_log(" → {} Obj auf '{}'".format( n, doc.Layers[target[key]].FullPath)) # Leere DWG-Source-Layer loeschen (descending index) target_set = set(target.values()) deleted = 0 for src_idx in sorted(source_indices, reverse=True): if src_idx in target_set: continue try: lay = doc.Layers[src_idx] if lay is None or lay.IsDeleted: continue has = False for o in doc.Objects: if o and not o.IsDeleted \ and o.Attributes.LayerIndex == src_idx: has = True; break if not has: if doc.Layers.Delete(src_idx, True): deleted += 1 except Exception: pass if deleted: self._push_log(" {} leere Source-Layer geloescht".format(deleted)) except Exception as ex: self._push_log("Konsolidieren: {}".format(ex)) def _move_to_sublayer(self, doc, objs, z_id, code, tag=None, fallback_name=None, fallback_color="#888888"): """Verschiebt Liste von Rhino-Objekten auf den DOSSIER-Sublayer /_*. Optional: Tag (UserString dossier_swisstopo_kind) setzen — wird beim naechsten Import erkannt + ggf. geloescht. fallback_name: wenn Sublayer noch nicht existiert (Ebene wurde gerade erst angelegt, build_layers noch nicht gelaufen), wird er hiermit erzeugt — sonst landen Objekte gar nirgends.""" if not objs: return try: import layer_builder parent_idx = layer_builder._find_top_by_id(doc, z_id) if parent_idx < 0: return parent_id = doc.Layers[parent_idx].Id sub_idx = layer_builder._find_sublayer_by_code(doc, parent_id, code) if sub_idx < 0 and fallback_name: sub_idx = self._ensure_sub_sublayer( doc, parent_id, fallback_name, color_hex=fallback_color) if sub_idx >= 0: try: doc.Layers[sub_idx].SetUserString( "dossier_code", code) except Exception: pass if sub_idx < 0: return n = 0 for o in objs: try: attrs = o.Attributes.Duplicate() attrs.LayerIndex = sub_idx if tag: attrs.SetUserString("dossier_swisstopo_kind", tag) if doc.Objects.ModifyAttributes(o, attrs, True): n += 1 except Exception: pass self._push_log("→ {} Obj auf '{}'".format(n, doc.Layers[sub_idx].FullPath)) except Exception as ex: self._push_log("Layer-Move: {}".format(ex)) b = _SwisstopoBridge() bridge_holder["form"] = panel_base.open_satellite_window( "swisstopo", title="swisstopo Importer", size=(560, 620), bridge=b) def _cmd_open_osm_dialog(self, p): """Oeffnet das OSM-Importer-Satelliten-Fenster mit Overpass-API: Strassen, Gebaeudeumrisse, Wasser, Gruenflaechen, Wege als 2D-Linien.""" outer = self bridge_holder = {"form": None} class _OsmBridge(panel_base.BaseBridge): def __init__(self): panel_base.BaseBridge.__init__(self, "osm") def _push_log(self, msg): try: self.send("OSM_LOG", {"msg": str(msg)}) except Exception: pass def handle(self, data): if not isinstance(data, dict): return t = data.get("type", "") pp = data.get("payload") or {} if t == "READY": pass # nothing to send initially elif t == "GEOCODE": import swisstopo res = swisstopo.geocode(pp.get("text") or "") self.send("GEOCODE_RESULT", {"result": res}) elif t == "RUN_OSM_IMPORT": self._run_osm_import(pp) elif t == "CANCEL": try: f = bridge_holder.get("form") if f is not None: f.Close() except Exception: pass def _run_osm_import(self, opts): d = Rhino.RhinoDoc.ActiveDoc if d is None: self._push_log("Kein aktives Doc"); return try: import osm, swisstopo, layer_builder except Exception as ex: self._push_log("Module-Import-Fehler: {}".format(ex)); return try: eC = float(opts.get("centerE")) nC = float(opts.get("centerN")) r = float(opts.get("radius") or 200) except Exception: self._push_log("Center/Radius ungueltig"); return categories = opts.get("categories") or [] if not categories: self._push_log("Keine Kategorien gewaehlt"); return shift = bool(opts.get("shiftToOrigin", True)) replace_existing = bool(opts.get("replaceExisting", True)) # Doc-Unit try: m_to_unit = Rhino.RhinoMath.UnitScale( Rhino.UnitSystem.Meters, d.ModelUnitSystem) except Exception: m_to_unit = 1.0 # Projekt-Nullpunkt (z-Offset wie bei swisstopo) try: z_raw = d.Strings.GetValue("dossier_project_zero_mum") project_zero_mum = float(z_raw) if z_raw else 0.0 except Exception: project_zero_mum = 0.0 z_offset_m = project_zero_mum if shift else 0.0 # bbox in LV95-Metern + WGS84 fuer Overpass bbox_lv95 = (eC - r, nC - r, eC + r, nC + r) bbox_wgs = swisstopo.lv95_bbox_to_wgs84_bbox(*bbox_lv95) self._push_log("Center LV95: E={:.1f} N={:.1f} Radius={}m".format(eC, nC, r)) self._push_log("BBox WGS84: {:.5f},{:.5f} – {:.5f},{:.5f}".format(*bbox_wgs)) origin_shift = (eC, nC, z_offset_m) if shift else (0, 0, 0) z_id = d.Strings.GetValue("dossier_active_id") # Listener-Suppression sc.sticky["dossier_swisstopo_busy"] = True try: # Bestehende OSM-Objekte loeschen? if replace_existing: self._push_log("Loesche bestehende OSM-Objekte...") removed = 0 for obj in list(d.Objects): if obj is None or obj.IsDeleted: continue try: tag = obj.Attributes.GetUserString("dossier_osm_kind") except Exception: tag = None if tag: d.Objects.Delete(obj.Id, True); removed += 1 self._push_log("→ {} alte OSM-Objekte geloescht".format(removed)) # Sub-Ebenen-Struktur unter '70_osm' sicherstellen osm_sub_codes = self._ensure_osm_ebenen(d, categories) # Layer-Indices ermitteln cat_layer_idx = {} if z_id: parent_idx = layer_builder._find_top_by_id(d, z_id) if parent_idx >= 0: parent_id_ = d.Layers[parent_idx].Id base_idx = layer_builder._find_sublayer_by_code( d, parent_id_, "70") if base_idx >= 0: base_id_ = d.Layers[base_idx].Id for cat, ccode in osm_sub_codes.items(): idx = layer_builder._find_sublayer_by_code( d, base_id_, ccode) if idx >= 0: cat_layer_idx[cat] = idx # Import via osm-Modul self._push_log("Hole OSM-Daten...") created = osm.import_osm_to_doc( d, bbox_wgs, categories, shift_lv95=origin_shift, m_to_unit=m_to_unit, z_doc=0.0, progress=self._push_log) # Layer-Move + Tag pro Objekt new_obj_ids = [] moved_by_cat = {} for item in created: cat = item["category"] obj = item["obj"] tgt_idx = cat_layer_idx.get(cat, -1) try: at = obj.Attributes.Duplicate() if tgt_idx >= 0: at.LayerIndex = tgt_idx at.SetUserString("dossier_osm_kind", cat) d.Objects.ModifyAttributes(obj, at, True) new_obj_ids.append(obj.Id) moved_by_cat[cat] = moved_by_cat.get(cat, 0) + 1 except Exception: pass for cat, n in moved_by_cat.items(): if cat in cat_layer_idx: self._push_log(" → {} {} auf '{}'".format( n, cat, d.Layers[cat_layer_idx[cat]].FullPath)) else: self._push_log(" → {} {} (Layer fallback)".format(n, cat)) self._push_log("Import fertig: {} OSM-Objekte".format( len(new_obj_ids))) # Auto-Zoom if opts.get("autoZoom") and new_obj_ids: try: combined = rg.BoundingBox.Empty for oid in new_obj_ids: ob = d.Objects.Find(oid) if ob is None: continue bb = ob.Geometry.GetBoundingBox(True) if bb.IsValid: combined.Union(bb) if combined.IsValid: view = d.Views.ActiveView if view is not None: view.ActiveViewport.ZoomBoundingBox(combined) except Exception as ex: self._push_log("Auto-Zoom: {}".format(ex)) try: d.Views.Redraw() except Exception: pass self.send("IMPORT_DONE", {"count": len(new_obj_ids)}) finally: sc.sticky["dossier_swisstopo_busy"] = False def _ensure_osm_ebenen(self, doc, categories): """Stellt sicher dass '70_osm' Parent + Children fuer jede gewuenschte Kategorie in dossier_ebenen existieren. Liefert {category_key: code} Map.""" import osm raw = doc.Strings.GetValue("dossier_ebenen") try: ebenen = json.loads(raw) if raw else [] except Exception: ebenen = [] if not isinstance(ebenen, list): ebenen = [] parent = next((e for e in ebenen if isinstance(e, dict) and e.get("code") == "70"), None) if parent is None: parent = { "code": "70", "name": "osm", "color": "#707080", "lw": 0.13, "visible": True, "locked": False, "children": [], } ebenen.append(parent) if not isinstance(parent.get("children"), list): parent["children"] = [] have = {c.get("code") for c in parent["children"] if isinstance(c, dict)} code_map = {} changed = False for cat_key in categories: spec = osm.CATEGORIES.get(cat_key) if not spec: continue code = spec["code"] code_map[cat_key] = code if code in have: continue parent["children"].append({ "code": code, "name": spec["name"], "color": spec["color"], "lw": 0.13, "visible": True, "locked": False, }) changed = True if changed: try: doc.Strings.SetString("dossier_ebenen", json.dumps(ebenen, ensure_ascii=False)) z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") zlist = json.loads(z_raw) if z_raw else [] if zlist: import layer_builder layer_builder.build_layers(doc, zlist, ebenen) import rhinopanel rhinopanel._broadcast_state(doc) except Exception as ex: self._push_log("osm-ebenen build: {}".format(ex)) return code_map b = _OsmBridge() bridge_holder["form"] = panel_base.open_satellite_window( "osm", title="OSM Importer", size=(520, 620), bridge=b) def _update_wall(self, p): """Properties eines Elements aendern (Wand/Decke/Dach/Oeffnung). Volumen wird anschliessend regeneriert.""" doc = Rhino.RhinoDoc.ActiveDoc wall_id = p.get("id") if not wall_id: return axis_obj, old_meta = _find_source(doc, wall_id) if axis_obj is None or old_meta is None: return # Tragwerk: Stuetze (Punkt) oder Traeger/Unterzug (Achse) if old_meta["type"] in ("stuetze_point", "traeger_axis"): kind = p.get("kind", old_meta.get("trag_kind", "stuetze")) if kind not in _TRAG_KINDS: kind = old_meta.get("trag_kind", "stuetze") profil = p.get("profil", old_meta.get("trag_profil", "quadrat")) if profil not in _TRAG_PROFILE: profil = old_meta.get("trag_profil", "quadrat") try: B = float(p.get("b", old_meta.get("trag_b", 0.25))) except Exception: B = old_meta.get("trag_b", 0.25) try: H = float(p.get("h", old_meta.get("trag_h", 0.25))) except Exception: H = old_meta.get("trag_h", 0.25) try: D = float(p.get("d", old_meta.get("trag_d", 0.25))) except Exception: D = old_meta.get("trag_d", 0.25) try: t_wall = float(p.get("t", old_meta.get("trag_t", 0.01))) except Exception: t_wall = old_meta.get("trag_t", 0.01) try: angle = float(p.get("angle", old_meta.get("trag_angle", 0.0))) except Exception: angle = old_meta.get("trag_angle", 0.0) z_over = p.get("zOver", old_meta.get("trag_z_over", "")) if z_over is None: z_over = "" if isinstance(z_over, (int, float)): z_over = "{:.4f}".format(float(z_over)) gstart = p.get("geschoss", old_meta["geschoss"]) attrs = axis_obj.Attributes if gstart != old_meta["geschoss"]: gs = _geschoss_by_id(doc, gstart) gn = gs.get("name", "EG") if gs else "EG" attrs.LayerIndex = _ensure_layer(doc, _layer_path_tragwerk(doc, gn)) _attach_meta(attrs, wall_id, old_meta["type"], gstart, 0.0, "", "", "mid", trag_kind=kind, trag_profil=profil, trag_b=B, trag_h=H, trag_d=D, trag_t=t_wall, trag_angle=angle, trag_z_over=z_over) axis_obj.Attributes = attrs axis_obj.CommitChanges() _regenerate_volume(doc, wall_id) doc.Views.Redraw() self._send_state() return # Raum: Name/Nummer/Funktion/Rundung/Texthoehe/Align/SIA if old_meta["type"] == "raum_outline": r_name = p.get("name", old_meta.get("raum_name", "Raum")) r_num = p.get("nummer", old_meta.get("raum_nummer", "")) r_fkt = p.get("funktion", old_meta.get("raum_funktion", "")) r_rnd = p.get("rundung", old_meta.get("raum_rundung", "0.1")) if r_rnd not in _RAUM_RUNDUNGEN: r_rnd = "0.1" try: r_th = float(p.get("txtH", old_meta.get("raum_txt_h", 0.20))) except Exception: r_th = 0.20 r_align = p.get("align", old_meta.get("raum_align", "mid")) if r_align not in _RAUM_ALIGN: r_align = "mid" r_sia = p.get("sia", old_meta.get("raum_sia", "")) if r_sia not in _RAUM_SIA_KINDS: r_sia = "" r_fuell = p.get("fuellung", old_meta.get("raum_fuellung", "")) # Akzeptiert String (Pattern-Name) ODER Legacy-Bool. Niemals # nach Bool casten — sonst kollabieren alle Pattern-Strings auf "Solid". if isinstance(r_fuell, bool): r_fuell = "Solid" if r_fuell else "" elif r_fuell is None: r_fuell = "" else: r_fuell = str(r_fuell) gstart = p.get("geschoss", old_meta["geschoss"]) attrs = axis_obj.Attributes if gstart != old_meta["geschoss"]: gs = _geschoss_by_id(doc, gstart) gn = gs.get("name", "EG") if gs else "EG" attrs.LayerIndex = _ensure_layer(doc, _layer_path_raum(doc, gn)) _attach_meta(attrs, wall_id, "raum_outline", gstart, 0.0, "", "", "mid", raum_name=r_name, raum_nummer=r_num, raum_funktion=r_fkt, raum_rundung=r_rnd, raum_txt_h=r_th, raum_align=r_align, raum_sia=r_sia, raum_fuellung=r_fuell) axis_obj.Attributes = attrs axis_obj.CommitChanges() _save_last(raum_name_last=r_name, raum_rundung=r_rnd, raum_funktion=r_fkt, raum_txt_h=r_th, raum_align=r_align, raum_sia=r_sia, raum_fuellung=r_fuell) _regenerate_volume(doc, wall_id) doc.Views.Redraw() self._send_state() return # Treppe: Breite/Anzahl Stufen/Referenz/Zielgeschoss if old_meta["type"] == "treppe_axis": try: tb = float(p.get("breite", old_meta.get("treppe_breite", 1.0))) except Exception: tb = old_meta.get("treppe_breite", 1.0) try: tn = int(p.get("nStufen", old_meta.get("treppe_n", 15))) except Exception: tn = old_meta.get("treppe_n", 15) if tn < 2: tn = 2 tref = p.get("treppeReferenz", old_meta.get("treppe_referenz", "mid")) if tref not in ("mid", "links", "rechts"): tref = "mid" tmod = p.get("treppeModus", old_meta.get("treppe_modus", "flach")) if tmod not in _TREPPE_MODI: tmod = "flach" try: tld = float(p.get("laufD", old_meta.get("treppe_lauf_d", 0.18))) except Exception: tld = old_meta.get("treppe_lauf_d", 0.18) gend = p.get("geschossEnd", old_meta.get("geschoss_end", "")) gstart = p.get("geschoss", old_meta["geschoss"]) attrs = axis_obj.Attributes if gstart != old_meta["geschoss"]: gs = _geschoss_by_id(doc, gstart) gn = gs.get("name", "EG") if gs else "EG" attrs.LayerIndex = _ensure_layer(doc, _layer_path_treppe(doc, gn)) # Custom H + Soll-Werte h_over = p.get("hOver", old_meta.get("treppe_h_over", "")) if h_over is None: h_over = "" if isinstance(h_over, (int, float)): h_over = "{:.4f}".format(float(h_over)) soll_in = p.get("soll", old_meta.get("treppe_soll", _TREPPE_SOLL_DEFAULT)) # Auf Form normieren if not isinstance(soll_in, dict): soll_in = dict(_TREPPE_SOLL_DEFAULT) soll_norm = {} for k, dv in _TREPPE_SOLL_DEFAULT.items(): v = soll_in.get(k, dv) if isinstance(v, list) and len(v) >= 3: try: soll_norm[k] = [float(v[0]), float(v[1]), bool(v[2])] except Exception: soll_norm[k] = list(dv) else: soll_norm[k] = list(dv) _attach_meta(attrs, wall_id, "treppe_axis", gstart, tb, "", "", "mid", geschoss_end=gend, treppe_breite=tb, treppe_n=tn, treppe_referenz=tref, treppe_modus=tmod, treppe_lauf_d=tld, treppe_art=old_meta.get("treppe_art", "gerade"), treppe_h_over=h_over, treppe_soll=soll_norm) # Persistenz fuer Creation Default try: import json _save_last(treppe_soll=json.dumps(soll_norm)) except Exception: pass axis_obj.Attributes = attrs axis_obj.CommitChanges() _regenerate_volume(doc, wall_id) doc.Views.Redraw() self._send_state() return # Oeffnung: Breite/Hoehe/Bruestung + Rahmen/Fluegel/Sims/Glas if old_meta["type"] == "oeffnung_point": try: breite = float(p.get("breite", old_meta.get("oeff_breite", 1.0))) except Exception: breite = old_meta.get("oeff_breite", 1.0) try: hoehe = float(p.get("hoehe", old_meta.get("oeff_hoehe", 1.4))) except Exception: hoehe = old_meta.get("oeff_hoehe", 1.4) otyp = old_meta.get("oeff_typ", "fenster") # Bruestung gilt fuer ALLE Oeffnungstypen — Tueren koennen via # Z-Drag oder Panel auch hochgehoben werden (Schwelle, Stufe). # Default je nach Typ: Fenster 0.9, Tuer 0.0. bruest_default = 0.9 if otyp == "fenster" else 0.0 try: brueest = float(p.get("brueest", old_meta.get("oeff_brueest", bruest_default))) except Exception: brueest = old_meta.get("oeff_brueest", bruest_default) try: rahmen_b = float(p.get("rahmenB", old_meta.get("oeff_rahmen_b", 0.06))) except Exception: rahmen_b = old_meta.get("oeff_rahmen_b", 0.06) try: rahmen_t = float(p.get("rahmenTiefe", old_meta.get("oeff_rahmen_tiefe", 0.08))) except Exception: rahmen_t = old_meta.get("oeff_rahmen_tiefe", 0.08) rahmen_p = p.get("rahmenPos", old_meta.get("oeff_rahmen_pos", "mid")) if rahmen_p not in _OEFF_RAHMEN_POS_OPTIONS: rahmen_p = "mid" try: fluegel = int(p.get("fluegel", old_meta.get("oeff_fluegel", 1))) except Exception: fluegel = old_meta.get("oeff_fluegel", 1) if fluegel < 1: fluegel = 1 simsa = p.get("simsAus", old_meta.get("oeff_sims_aus", "standard" if otyp == "fenster" else "ohne")) simsi = p.get("simsIn", old_meta.get("oeff_sims_in", "standard" if otyp == "fenster" else "ohne")) # Legacy: bool von alter UI - in String konvertieren if isinstance(simsa, bool): simsa = "standard" if simsa else "ohne" if isinstance(simsi, bool): simsi = "standard" if simsi else "ohne" if simsa not in _OEFF_SIMS_STYLES: simsa = "ohne" if simsi not in _OEFF_SIMS_STYLES: simsi = "ohne" glas = bool(p.get("glas", old_meta.get("oeff_glas", otyp == "fenster"))) oref = p.get("oeffReferenz", old_meta.get("oeff_referenz", "mid")) if oref not in _OEFF_REFERENZ_OPTIONS: oref = "mid" attrs = axis_obj.Attributes _attach_meta(attrs, wall_id, "oeffnung_point", old_meta["geschoss"], old_meta["dicke"], "", "", "mid", oeff_typ=otyp, oeff_parent=old_meta.get("oeff_parent", ""), oeff_breite=breite, oeff_hoehe=hoehe, oeff_brueest=brueest, oeff_rahmen_b=rahmen_b, oeff_rahmen_tiefe=rahmen_t, oeff_rahmen_pos=rahmen_p, oeff_fluegel=fluegel, oeff_sims_aus=simsa, oeff_sims_in=simsi, oeff_glas=glas, oeff_referenz=oref) axis_obj.Attributes = attrs axis_obj.CommitChanges() parent_id = old_meta.get("oeff_parent", "") if parent_id: _regenerate_element(doc, parent_id) doc.Views.Redraw() self._send_state() return # Neue Werte mergen geschoss = p.get("geschoss", old_meta["geschoss"]) try: dicke = float(p.get("dicke", old_meta["dicke"])) except Exception: dicke = old_meta["dicke"] uk_over = p.get("ukOverride", old_meta["uk_override"]) ok_over = p.get("okOverride", old_meta["ok_override"]) referenz = p.get("referenz", old_meta.get("referenz", "mid")) if referenz not in ("mid", "left", "right"): referenz = "mid" # Dach-spezifische Felder try: neigung = float(p.get("neigung", old_meta.get("neigung", 30.0))) except Exception: neigung = old_meta.get("neigung", 30.0) try: eave_idx = int(p.get("eaveIdx", old_meta.get("eave_idx", 0))) except Exception: eave_idx = old_meta.get("eave_idx", 0) dach_typ = p.get("dachTyp", old_meta.get("dach_typ", "pult")) if dach_typ not in ("pult", "sattel", "walm", "mansarde"): dach_typ = "pult" try: neigung_unten = float(p.get("neigungUnten", old_meta.get("neigung_unten", 60.0))) except Exception: neigung_unten = old_meta.get("neigung_unten", 60.0) try: knick_h = float(p.get("knickH", old_meta.get("knick_h", 2.0))) except Exception: knick_h = old_meta.get("knick_h", 2.0) dach_variante = p.get("dachVariante", old_meta.get("dach_variante", "walm")) if dach_variante not in ("walm", "giebel", "walm_giebel"): dach_variante = "walm" # Wand-Schichten if "layered" in p: wand_layered = bool(p.get("layered")) else: wand_layered = bool(old_meta.get("wand_layered", False)) wand_layers = p.get("layers", None) if wand_layers is None: wand_layers = old_meta.get("wand_layers", []) # Wenn layered an aber Liste leer → eine Default-Schicht anlegen if wand_layered and not wand_layers: try: total_d = float(dicke) except Exception: total_d = 0.25 wand_layers = [{"name": "Schicht 1", "dicke": total_d, "color": "#cccccc"}] # Source-Attributes updaten attrs = axis_obj.Attributes # Bei Geschoss-Wechsel: Layer wechseln (passend zum Element-Typ) if geschoss != old_meta["geschoss"]: g = _geschoss_by_id(doc, geschoss) geschoss_name = g.get("name", "EG") if g else "EG" if old_meta["type"] == "wand_axis": attrs.LayerIndex = _ensure_layer(doc, _layer_path_axis(doc, geschoss_name)) elif old_meta["type"] == "decke_outline": attrs.LayerIndex = _ensure_layer(doc, _layer_path_decke(doc, geschoss_name)) elif old_meta["type"] == "dach_outline": attrs.LayerIndex = _ensure_layer(doc, _layer_path_dach(doc, geschoss_name)) # Wenn layered + Layers gegeben: dicke aus Summe nachfuehren if wand_layered and wand_layers: try: dicke = sum(float(l.get("dicke", 0)) for l in wand_layers) except Exception: pass _attach_meta(attrs, wall_id, old_meta["type"], geschoss, dicke, uk_over, ok_over, referenz, neigung=neigung, eave_idx=eave_idx, dach_typ=dach_typ, neigung_unten=neigung_unten, knick_h=knick_h, dach_variante=dach_variante, wand_layered=wand_layered, wand_layers=wand_layers if wand_layered else []) axis_obj.Attributes = attrs axis_obj.CommitChanges() # Volumen regenerieren (Layer ggf. anpassen) _regenerate_volume(doc, wall_id) doc.Views.Redraw() self._send_state() def _delete_wall(self, wall_id): """Achse + Volumen + Children loeschen. Bei Oeffnung wird die Elternwand nach dem Loeschen regeneriert (Loch verschwindet).""" doc = Rhino.RhinoDoc.ActiveDoc if not wall_id: return # Check ob es eine Oeffnung ist src_obj, src_meta = _find_source(doc, wall_id) parent_id = None if src_meta and src_meta["type"] == "oeffnung_point": parent_id = src_meta.get("oeff_parent") or None # Wenn eine Wand geloescht wird: zugehoerige Oeffnungen kaskadieren cascade_ids = [] if src_meta and src_meta["type"] == "wand_axis": for op_obj, op_meta in _find_openings_for_wall(doc, wall_id): cascade_ids.append(op_meta["id"]) for cid in cascade_ids: for obj, _m in _find_objects_by_wall_id(doc, cid): try: doc.Objects.Delete(obj.Id, True) except Exception: pass # Haupt-Element loeschen for obj, meta in _find_objects_by_wall_id(doc, wall_id): try: doc.Objects.Delete(obj.Id, True) except Exception as ex: print("[ELEMENTE] delete:", ex) # Bei Oeffnung-Delete: Elternwand regen if parent_id: _regenerate_element(doc, parent_id) doc.Views.Redraw() self._send_state() def _regenerate_all(self): """Alle Elemente (Waende + Decken) neu generieren — nuetzlich nach Geschoss-Aenderung.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return seen = set() for obj in list(doc.Objects): meta = _read_meta(obj) if meta is None: continue if meta["type"] not in SOURCE_TYPES: continue if meta["id"] in seen: continue seen.add(meta["id"]) _regenerate_element(doc, meta["id"]) doc.Views.Redraw() self._send_state() # --- Event-Listener --------------------------------------------------------- # Re-Entry-Guard: wenn _regenerate_volume die Brep ersetzt, feuert das # Rhino-Event nochmal — wir wollen nicht in eine Schleife geraten. _REGEN_BUSY = "_elemente_regen_busy" # Pending-Regenerate-Queue: alle wall_ids die beim naechsten Idle-Tick # regeneriert werden sollen. Debounct mehrfache Replace-Events waehrend # eines Gumball-Drags. def _pending_set(): s = sc.sticky.get("elemente_pending_regen") if s is None: s = set() sc.sticky["elemente_pending_regen"] = s return s def _queue_regen(wall_id): _pending_set().add(wall_id) def _apply_wand_z_drag_constraint(new_obj, meta): """Wand-Achse Z-Drag → unbound mode. Der User zieht einen End-Grip der wand_axis in Z. Default-Verhalten von Rhino waere: schraege Linie im Raum. Wir wollen stattdessen: Wand bleibt XY-planar, der Z-Drag wird als VERSCHIEBUNG der ganzen Wand interpretiert (uk + ok beide um delta), und die Wand entkoppelt sich vom Geschoss (Override-UK/OK in `_KEY_UK_OVER` / `_KEY_OK_OVER` geschrieben). Delta-Logik: max-magnitude der zwei Endpunkt-Z's gewinnt — entspricht 'letzter Drag gewinnt' wenn nur ein End-Grip gezogen wurde. Geometry-Z wird auf 0 zurueckgesetzt. """ geom = new_obj.Geometry # Sticky reset — bei JEDEM Replace, damit kein alter Delta haengt sc.sticky["_elemente_wand_z_delta"] = None if not isinstance(geom, rg.Curve): print("[ELEMENTE] wand z-drag skip: geom is {}".format(type(geom).__name__)) return False # Z aus den Endpunkten der Curve (funktioniert fuer Line, Polyline, Spline). z0 = geom.PointAtStart.Z z1 = geom.PointAtEnd.Z if abs(z0) < 1e-6 and abs(z1) < 1e-6: return False delta = z1 if abs(z1) > abs(z0) else z0 print("[ELEMENTE] wand z-drag triggered: z0={:.3f} z1={:.3f} delta={:.3f}".format(z0, z1, delta)) doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return False uk_cur, ok_cur = _resolve_uk_ok(doc, meta["geschoss"], meta["uk_override"], meta["ok_override"]) new_uk = uk_cur + delta new_ok = ok_cur + delta print("[ELEMENTE] wand z-drag: uk_cur={:.3f} ok_cur={:.3f} new_uk={:.3f} new_ok={:.3f} (meta uk_over='{}' ok_over='{}')".format( uk_cur, ok_cur, new_uk, new_ok, meta.get("uk_override", ""), meta.get("ok_override", ""))) attrs = new_obj.Attributes.Duplicate() attrs.SetUserString(_KEY_UK_OVER, "{:.6f}".format(new_uk)) attrs.SetUserString(_KEY_OK_OVER, "{:.6f}".format(new_ok)) mod_ok = doc.Objects.ModifyAttributes(new_obj.Id, attrs, True) # Verifikation: UK_OVER wirklich in Doc geschrieben? verify = doc.Objects.FindId(new_obj.Id) if verify is not None: actual_uk = verify.Attributes.GetUserString(_KEY_UK_OVER) or "" actual_ok = verify.Attributes.GetUserString(_KEY_OK_OVER) or "" print("[ELEMENTE] wand z-drag ModifyAttributes returned={} → stored uk_over='{}' ok_over='{}'".format( mod_ok, actual_uk, actual_ok)) else: print("[ELEMENTE] wand z-drag verify: FindId returned None!") # Curve auf Z=0 fixen. LineCurve: explizit beide Endpunkte (auch bei # einzelnem End-Grip-Drag). Andere Curves: ueber Translation (akzeptiert # leichten Schraeg bei End-Grip-Drag, gleicht sich beim naechsten # Replace aus). if isinstance(geom, rg.LineCurve): line = geom.Line flat = rg.LineCurve( rg.Point3d(line.From.X, line.From.Y, 0.0), rg.Point3d(line.To.X, line.To.Y, 0.0)) else: flat = geom.DuplicateCurve() flat.Translate(rg.Vector3d(0, 0, -delta)) doc.Objects.Replace(new_obj.Id, flat) # Bruestungs-Mitnahme bei Wand-Z-Drag: ausgelagert in `_migrate_openings_to_new_axis`, # damit's in EINEM Schritt mit der XY-Migration passiert (sonst kollidiert # ein zusaetzliches Replace hier mit dem Migrate-Replace dort und der # Wand-Regen verliert die Volumen). Delta in sticky stellen, Migrate liest. if abs(delta) >= 1e-6: sc.sticky["_elemente_wand_z_delta"] = (meta["id"], delta) # KEIN synchroner Regen hier: der Replace-Handler ruft danach noch # `_migrate_openings_to_new_axis` + `_queue_regen` auf. Ein hier-jetzt- # Regen wuerde die Wand mit den ALTEN Oeffnungs-Positionen neu generieren # — Cutouts an falscher Stelle, Oeffnungs-Volumen verschoben („Symbole # zerschossen"). Der debounced Idle-Regen sieht migrierte Oeffnungen # und macht's konsistent. return True def _apply_oeffnung_constraint(new_obj, meta, old_obj=None): """Oeffnungs-Point Constraints: - **XY-Drag** → direktionale Projektion: Drag-Vektor wird auf die Wand-Tangente projiziert, dann arc-length entlang Wand verschoben. Damit folgt der Punkt dem Drag *im Mass* und *in der Wand-Richtung* (nicht orthogonal zur Wand wie bei ClosestPoint). Bei fehlender Alt-Position (z.B. First-Placement): Fallback auf nearest-point. - **Z-Drag** → Bruestungshoehe (`_KEY_OEFF_BRUEST`) wird um delta-Z angepasst. Oeffnungs-Hoehe selbst bleibt gleich (= Drag verschiebt, nicht streckt). Geometry-Z wird auf 0 fixiert. Synchroner Regen am Ende — sonst sieht der User die alten Volumen noch ~50 ms an der User-Drag-Position bis der debounced Idle-Regen sie zurueckspringen laesst (sichtbares Flickern). """ geom_new = new_obj.Geometry if not isinstance(geom_new, rg.Point): return False pt_new = geom_new.Location pt_old = None if old_obj is not None: try: og = old_obj.Geometry if isinstance(og, rg.Point): pt_old = og.Location except Exception: pass parent_id = meta.get("oeff_parent") parent_curve = None parent_meta = None doc = Rhino.RhinoDoc.ActiveDoc if doc is not None and parent_id: for obj in doc.Objects: m = _read_meta(obj) if m and m.get("id") == parent_id and m.get("type") == "wand_axis": cg = obj.Geometry if isinstance(cg, rg.Curve): parent_curve = cg parent_meta = m break target_x, target_y = pt_new.X, pt_new.Y # Wenn die Wand gerade migrate'd wurde (Rotation/Reshape/XY-Move) → # XY-Projektion HIER UEBERSPRINGEN. Migrate hat den Punkt schon per # Bogenlaengen-Mapping auf die neue Achse gesetzt. Eine zweite XY- # Projektion mit ClosestPoint(pt_old) auf der NEUEN Achse wuerde die # Position wieder verschieben (Rotation: pt_old liegt nicht mehr auf # der neuen Achse → ClosestPoint+Tangent stimmen nicht zusammen). migrated_walls = sc.sticky.get("_dossier_migrated_walls") skip_xy_projection = (isinstance(migrated_walls, set) and parent_id in migrated_walls) if parent_curve is not None and not skip_xy_projection: if pt_old is not None: try: rc, t_old = parent_curve.ClosestPoint( rg.Point3d(pt_old.X, pt_old.Y, 0.0)) if rc: tangent = parent_curve.TangentAt(t_old) tangent.Z = 0.0 if tangent.Unitize(): drag = rg.Vector3d( pt_new.X - pt_old.X, pt_new.Y - pt_old.Y, 0.0) step = drag * tangent # Dot-Produkt = Komponente entlang Tangente arc_old = parent_curve.GetLength( rg.Interval(parent_curve.Domain.T0, t_old)) full_len = parent_curve.GetLength() arc_new = max(0.0, min(full_len, arc_old + step)) rc2, t_new = parent_curve.LengthParameter(arc_new) if rc2: p_on = parent_curve.PointAt(t_new) target_x, target_y = p_on.X, p_on.Y except Exception as ex: print("[ELEMENTE] tangential project:", ex) else: try: rc, t = parent_curve.ClosestPoint( rg.Point3d(pt_new.X, pt_new.Y, 0.0)) if rc: p_on = parent_curve.PointAt(t) target_x, target_y = p_on.X, p_on.Y except Exception: pass # Aktuelle Bruestung lesen — Default je nach Oeffnungs-Typ cur_bruest = meta.get("oeff_brueest") try: cur_bruest_val = float(cur_bruest) if cur_bruest not in (None, "") else 0.9 except (ValueError, TypeError): cur_bruest_val = 0.9 # Z-Delta gegen den ERWARTETEN Welt-Z des Punktes = Wand-UK + Brueest. # Bruestung ist relativ zur Wand-UK gespeichert. Wenn die Wand # hochgezogen wurde (UK_OVER += z_delta) und der Wand-Loop den # Oeffnungs-Punkt um z_delta translatet hat, sitzt der Punkt jetzt auf # `new_UK + cur_brueest` = `expected_pt_z`. delta_z = 0 → kein # Bruestungs-Update (gut so, sonst doppelt). Wenn der User nur den # Punkt allein vertikal gezogen hat (Brueestung-Drag), divergiert # pt_new.Z vom expected_pt_z → delta_z entspricht der echten User- # Eingabe → Bruestung wird angepasst. wall_uk = 0.0 if parent_meta is not None: try: wall_uk, _ = _resolve_uk_ok(doc, parent_meta["geschoss"], parent_meta["uk_override"], parent_meta["ok_override"]) except Exception: wall_uk = 0.0 expected_pt_z = wall_uk + cur_bruest_val delta_z = pt_new.Z - expected_pt_z new_bruest = cur_bruest_val if abs(delta_z) >= 1e-6: new_bruest = max(0.0, cur_bruest_val + delta_z) # Punkt visuell auf der Unterkante der Oeffnung in Welt-Z platzieren = # Wand-UK + Brueest. So sieht der User wo die Oeffnung beginnt, auch # wenn die Wand auf einem hoeheren Geschoss steht. target_z = wall_uk + new_bruest geom_changed = not ( abs(target_x - pt_new.X) < 1e-9 and abs(target_y - pt_new.Y) < 1e-9 and abs(pt_new.Z - target_z) < 1e-6 ) # WICHTIG Reihenfolge: erst Replace (Geometry), dann ModifyAttributes auf # das jetzt frische Object. Anders herum verliert der Replace die vorher # geupdateten Attributes — der Wand-Regen liest dann die alte Bruestung # und Cutout/Sub-Volumen rendern inkonsistent. if geom_changed and doc is not None: doc.Objects.Replace(new_obj.Id, rg.Point(rg.Point3d(target_x, target_y, target_z))) if abs(delta_z) >= 1e-6 and doc is not None: current = doc.Objects.FindId(new_obj.Id) if current is not None: attrs = current.Attributes.Duplicate() attrs.SetUserString(_KEY_OEFF_BRUEST, "{:.6f}".format(new_bruest)) doc.Objects.ModifyAttributes(current.Id, attrs, True) if doc is not None and parent_id: # Skip Sync-Regen wenn wir gerade in einer Batch-Verarbeitung sind # (Command-End): dort macht der Caller EINEN Sync-Regen pro Wand # am Schluss → spart Mehrfach-Regen bei mehreren Öffnungen pro Wand. if not sc.sticky.get("_dossier_skip_sync_regen"): try: _regenerate_element(doc, parent_id) except Exception as ex: print("[ELEMENTE] sync regen oeffnung:", ex) return geom_changed def _on_object_replaced(sender, e): """Wenn eine Source (Wand-Achse/Decke-Outline/etc.) veraendert wird → Regeneration queuen (debounct ueber Idle, 50 ms Ruhe). Wrappt den ganzen Handler in einem Undo-Record. Sonst sind die nachgeschalteten Delete/Create-Operationen vom Regen ausserhalb des Rhino-User-Undo-Steps → Cmd-Z setzt nur den Drag zurueck, laesst aber die regenerierten Volumen liegen. """ if sc.sticky.get(_REGEN_BUSY): return # Wenn ein User-Transform-Command (_Move/_Rotate/etc.) aktiv ist: GAR # NICHTS hier tun (Rhinos Move soll konfliktfrei durchlaufen). Erstes # Event = User hat geklickt → Redraw ab jetzt suppressen, sonst Mismatch- # Frame zwischen Rhinos Auto-Redraw und unserem Regen. if sc.sticky.get(_UT_ACTIVE_KEY): _suppress_redraw_until_cmd_end() return # Undo/Redo: Rhino restored den Zustand → wir machen NICHTS, sonst # Regen-Storm fuer jedes restored Object. if sc.sticky.get(_UNDO_ACTIVE_KEY): return doc = Rhino.RhinoDoc.ActiveDoc # Snapshot der aktuell selektierten IDs — damit Migrate die Objekte # skippen kann die Rhinos Move/Rotate gerade transformiert (sonst # kollidiert mein Replace mit Rhinos Transform → „Unable to transform"). # Wichtig: hier nehmen, nicht spaeter — sobald ein User-Move erstes # Replace feuert, kann obj.IsSelected() unzuverlaessig werden. try: if doc is not None: sel_ids = set() for so in doc.Objects.GetSelectedObjects(False, False): sel_ids.add(str(so.Id)) sc.sticky["_elemente_replace_selected_ids"] = sel_ids except Exception: pass undo_serial = None if doc is not None: try: undo_serial = doc.BeginUndoRecord("Dossier: Element Update") except Exception: undo_serial = None try: _on_object_replaced_body(sender, e) finally: if undo_serial is not None and doc is not None: try: doc.EndUndoRecord(undo_serial) except Exception: pass def _on_object_replaced_body(sender, e): try: import time sc.sticky["_elemente_last_replace_time"] = time.time() except Exception: pass try: meta = None try: meta = _read_meta(e.NewRhinoObject) except Exception: pass if meta is None: try: meta = _read_meta(e.OldRhinoObject) except Exception: pass if meta is None or meta.get("type") not in SOURCE_TYPES: return try: new_obj = e.NewRhinoObject if new_obj and not _read_meta(new_obj): attrs = new_obj.Attributes _attach_meta(attrs, meta["id"], meta["type"], meta["geschoss"], meta["dicke"], meta["uk_override"], meta["ok_override"], meta.get("referenz", "mid"), neigung=meta.get("neigung"), eave_idx=meta.get("eave_idx"), dach_typ=meta.get("dach_typ"), neigung_unten=meta.get("neigung_unten"), knick_h=meta.get("knick_h"), dach_variante=meta.get("dach_variante"), oeff_typ=meta.get("oeff_typ") or None, oeff_parent=meta.get("oeff_parent") or None, oeff_breite=meta.get("oeff_breite"), oeff_hoehe=meta.get("oeff_hoehe"), oeff_brueest=meta.get("oeff_brueest"), oeff_rahmen_b=meta.get("oeff_rahmen_b"), oeff_rahmen_tiefe=meta.get("oeff_rahmen_tiefe"), oeff_rahmen_pos=meta.get("oeff_rahmen_pos"), oeff_fluegel=meta.get("oeff_fluegel"), oeff_sims_aus=meta.get("oeff_sims_aus"), oeff_sims_in=meta.get("oeff_sims_in"), oeff_glas=meta.get("oeff_glas"), oeff_referenz=meta.get("oeff_referenz"), geschoss_end=meta.get("geschoss_end"), treppe_breite=meta.get("treppe_breite"), treppe_n=meta.get("treppe_n"), treppe_referenz=meta.get("treppe_referenz"), treppe_modus=meta.get("treppe_modus"), treppe_lauf_d=meta.get("treppe_lauf_d"), treppe_art=meta.get("treppe_art"), treppe_h_over=meta.get("treppe_h_over"), treppe_soll=meta.get("treppe_soll"), trag_kind=meta.get("trag_kind") or None, trag_profil=meta.get("trag_profil") or None, trag_b=meta.get("trag_b"), trag_h=meta.get("trag_h"), trag_d=meta.get("trag_d"), trag_t=meta.get("trag_t"), trag_angle=meta.get("trag_angle"), trag_z_over=meta.get("trag_z_over"), raum_name=meta.get("raum_name") or None, raum_nummer=meta.get("raum_nummer") or None, raum_funktion=meta.get("raum_funktion") or None, raum_rundung=meta.get("raum_rundung") or None, raum_txt_h=meta.get("raum_txt_h"), raum_align=meta.get("raum_align") or None, raum_sia=meta.get("raum_sia") or None, raum_fuellung=meta.get("raum_fuellung")) new_obj.Attributes = attrs new_obj.CommitChanges() except Exception: pass # Grip-Constraints: Z-Drag bei Wand → unbound mode (Override-UK/OK, # Geometry-Z fix); XY-Drag bei Oeffnung → snap auf Eltern-Wand-Achse. # WICHTIG _REGEN_BUSY waehrend der Korrektur, sonst loest CommitChanges # einen rekursiven Replace-Event aus. _was_busy = sc.sticky.get(_REGEN_BUSY, False) sc.sticky[_REGEN_BUSY] = True try: if meta.get("type") == "wand_axis": _apply_wand_z_drag_constraint(e.NewRhinoObject, meta) elif meta.get("type") == "oeffnung_point": _apply_oeffnung_constraint(e.NewRhinoObject, meta, e.OldRhinoObject) except Exception as ex: print("[ELEMENTE] grip constraint:", ex) finally: sc.sticky[_REGEN_BUSY] = _was_busy # Oeffnungen entlang der neuen Achse migrieren + Regen einreihen. if meta.get("type") == "wand_axis": # Joint-Cache invalidieren — Wand hat sich geaendert _invalidate_joints_cache(meta.get("geschoss")) try: old_geom = e.OldRhinoObject.Geometry if e.OldRhinoObject else None new_geom = e.NewRhinoObject.Geometry if e.NewRhinoObject else None _migrate_openings_to_new_axis(meta["id"], old_geom, new_geom) except Exception as ex: print("[ELEMENTE] migrate openings:", ex) # Wand-Verbindungen: alle ABHAENGIGEN Waende mit re-regenerieren. # Das umfasst sowohl Corner-Partner (Endpunkte teilen) als auch # T-Stoss-Wande (Endpunkt liegt auf der bewegten Achse). Wir # checken gegen ALTE und NEUE Geometrie damit auch sich-loesende # Verbindungen erkannt werden. try: doc2 = Rhino.RhinoDoc.ActiveDoc if doc2 is not None: deps = _find_dependent_walls(doc2, meta["geschoss"], meta["id"], old_geom, new_geom) for wid in deps: _queue_regen(wid) except Exception as ex: print("[ELEMENTE] dep regen:", ex) _queue_regen(meta["id"]) except Exception as ex: print("[ELEMENTE] on_object_replaced:", ex) def _migrate_openings_to_new_axis(wall_id, old_geom, new_geom, old_positions=None): """Verschiebt alle Oeffnungs-Points einer Wand mit, wenn deren Achse veraendert wird. Mapping ueber relative Bogenlaenge: ein Oeffnungs- Punkt bei 30 % der alten Kurve sitzt nachher bei 30 % der neuen. So bleiben die Oeffnungen 'sticky' an der Wand bei Verschieben, Drehen, Skalieren oder Reshape der Achse. `old_positions` (optional): {opening_id: (x, y, z)} — Pre-Transform Snapshot der Oeffnungs-Punkte. WICHTIG bei Rotation/Move: nach Rhinos Transform liegen die Punkte schon NICHT MEHR auf der alten Axis → `ClosestPoint(current_pos)` an old_geom snappt zum naechsten Endpunkt statt zur echten Bogenlaengen-Position → alle Oeffnungen landen am selben Ende. Bei Reshape-Operationen ohne Snapshot: Fallback auf aktuelle Geometrie (Punkt liegt dort noch auf alter Axis).""" if not isinstance(old_geom, rg.Curve) or not isinstance(new_geom, rg.Curve): return doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return try: old_len = old_geom.GetLength() new_len = new_geom.GetLength() except Exception: return if old_len < 1e-9 or new_len < 1e-9: return # Wand-UK aufloesen damit Oeffnungs-Punkte auf UK+Brueestung gesetzt # werden (= visuell auf Unterkante Oeffnung). Sonst landen sie auf # reiner Brueest-Hoehe und der nachfolgende Constraint interpretiert # die Diskrepanz als User-Z-Drag → Brueest dropt. wall_uk = 0.0 src = _find_axis(doc, wall_id) if src is not None: wm = _read_meta(src) if wm: try: wall_uk, _ = _resolve_uk_ok(doc, wm["geschoss"], wm["uk_override"], wm["ok_override"]) except Exception: wall_uk = 0.0 # Migrierten Wand registrieren — der Constraint soll fuer Oeffnungen # dieser Wand die XY-Projektion ueberspringen (migrate hat XY bereits # via Bogenlaengen-Mapping korrekt gesetzt). migrated = sc.sticky.get("_dossier_migrated_walls") if not isinstance(migrated, set): migrated = set() migrated.add(wall_id) sc.sticky["_dossier_migrated_walls"] = migrated # Selected-Snapshot vom Replace-Handler — nicht live IsSelected, weil # op_obj im laufenden Move-Event evtl. schon stale ist. # Snapshot der vom User selektierten IDs vom Replace-Handler ziehen UND # gleich consumen — sonst bleibt eine stale Liste im sticky und wirkt sich # auf spaetere unverwandte Migrations aus. skip_ids = sc.sticky.get("_elemente_replace_selected_ids") or set() sc.sticky["_elemente_replace_selected_ids"] = None _was_busy = sc.sticky.get(_REGEN_BUSY, False) sc.sticky[_REGEN_BUSY] = True try: for op_obj, op_meta in _find_openings_for_wall(doc, wall_id): try: # Skip Oeffnungs-Punkte die der User gerade selbst im Multi- # Select transformiert — Rhinos Move kuemmert sich um sie, # ein zusaetzliches `doc.Objects.Replace` hier kollidiert mit # Rhinos parallel-laufender Move-Operation → „Unable to # transform" + ganzer Regen-Undo-Record wird rollbacked. if str(op_obj.Id) in skip_ids: continue # Pre-Transform Position bevorzugen — die liegt garantiert # auf der alten Axis. Aktuelle (post-transform) Position kann # bei Rotation weit weg liegen → ClosestPoint snappt zum # falschen Endpunkt. src_pos = None if old_positions is not None: src_pos = old_positions.get(op_meta["id"]) if src_pos is None: pt_geom = op_obj.Geometry if hasattr(pt_geom, 'Location'): loc = pt_geom.Location src_pos = (loc.X, loc.Y, loc.Z) elif isinstance(pt_geom, rg.Point3d): src_pos = (pt_geom.X, pt_geom.Y, pt_geom.Z) else: continue # XY-only ClosestPoint — sonst zieht eine non-zero Z-Komponente # (Bruestungs-Hoehe) den Parameter bei kurvigen Wand-Achsen # leicht weg von der „echten" Position. ok_old, t_old = old_geom.ClosestPoint( rg.Point3d(src_pos[0], src_pos[1], 0.0)) if not ok_old: continue # Bogenlaenge auf alter Kurve bis t_old → relative Position sub = rg.Interval(old_geom.Domain.Min, t_old) try: arc_old = old_geom.GetLength(sub) except Exception: # Fallback: lineare Parameter-Interpolation dom_len = old_geom.Domain.Length arc_old = ((t_old - old_geom.Domain.Min) / dom_len) * old_len relative = arc_old / old_len if old_len > 1e-9 else 0.0 if relative < 0: relative = 0 if relative > 1: relative = 1 arc_new = relative * new_len # Parameter auf neuer Kurve bei dieser Bogenlaenge lp = new_geom.LengthParameter(arc_new) # LengthParameter Rueckgabe ist (bool, double) Tuple in IronPython3 t_new = None if isinstance(lp, tuple) and len(lp) >= 2 and lp[0]: t_new = lp[1] if t_new is None: # Fallback: lineare Parameter-Interpolation new_dom = new_geom.Domain t_new = new_dom.Min + relative * new_dom.Length new_pos = new_geom.PointAt(t_new) # Z aus Bruestung des Oeffnungs-UserStrings — sonst rutscht # der Punkt auf Wand-Achsen-Z=0 zurueck und verliert seine # visuelle Lage unter der Oeffnung. Bruestungs-MITNAHME bei # Wand-Z-Drag passiert im IDLE-Pfad (siehe `_on_idle_selection`, # liest sticky `_elemente_wand_z_delta`) — NICHT hier, sonst # kollidiert die zusaetzliche ModifyAttributes/Replace-Sequenz # mit Rhinos Move/Rotate-Operation („Unable to transform"). bruest = op_meta.get("oeff_brueest") try: bruest_z = float(bruest) if bruest not in (None, "") else 0.0 except (ValueError, TypeError): bruest_z = 0.0 # Welt-Z = Wand-UK + Brueestung (Konvention: Punkt sitzt # visuell auf Unterkante Oeffnung). new_pos = rg.Point3d(new_pos.X, new_pos.Y, wall_uk + bruest_z) doc.Objects.Replace(op_obj.Id, rg.Point(new_pos)) except Exception as ex: print("[ELEMENTE] migrate one opening:", ex) finally: sc.sticky[_REGEN_BUSY] = _was_busy def _count_same_id_type(doc, element_id, type_): """Zaehlt Objekte mit derselben element_id + type. Skipt deleted Objekte — sonst wird beim Move-Transform (Delete+Add) das alte Object kurz mitgezaehlt → False-positive „Duplikat" → der `_on_object_added`-Handler vergibt eine neue ID an die Tuer/das Fenster → Bezug zur Eltern-Wand geht verloren → Sub-Volumen werden nie regeneriert (Rahmen bleibt stehen).""" n = 0 for obj in doc.Objects: try: if obj.IsDeleted: continue except Exception: pass m = _read_meta(obj) if m and m["id"] == element_id and m["type"] == type_: n += 1 if n > 1: return n return n def _on_object_added(sender, e): """Faengt Duplikate ab (Copy/Mirror/Rotate-Copy): Rhino kopiert die UserStrings auf das neue Objekt mit. Source-Duplikate kriegen eine neue UUID, Volume-Duplikate werden geloescht (Regen baut das neue Volumen am richtigen Ort).""" # Swisstopo-Import importiert tausende Objekte am Stueck — die haben # keine DOSSIER-Metas, jeder Listener-Call ist reine Verschwendung. if sc.sticky.get("dossier_swisstopo_busy"): return if sc.sticky.get(_REGEN_BUSY): return # Waehrend Move/Rotate/Mirror/Scale: Rhino feuert intern Delete+Add fuer # jedes transformierte Objekt. CommandEnd uebernimmt die Re-Sync — # diese Events ignorieren, sonst laeuft die Regen-Pipeline trotz # Pure-Translate-Skip. if sc.sticky.get(_UT_ACTIVE_KEY): _suppress_redraw_until_cmd_end() return if sc.sticky.get(_UNDO_ACTIVE_KEY): return try: new_obj = e.TheObject meta = _read_meta(new_obj) if meta is None: return # Wenn dieselbe wand_axis-ID gerade in der Cascade-Queue ist und jetzt # zurueckkommt → war ein Transform-Delete (Rotate/Move/Mirror), keine # echte Loeschung. Queue entfernen, sonst killen wir gleich alle # Oeffnungen obwohl die Wand noch lebt. if meta.get("type") == "wand_axis": pending = sc.sticky.get("_elemente_pending_wand_cascade") if isinstance(pending, dict) and meta["id"] in pending: pending.pop(meta["id"], None) # Source-Cascade canceln wenn die Source mit gleicher ID # zurueckkommt (= war Transform, kein User-Delete). if meta.get("type") in SOURCE_TYPES: pending_src = sc.sticky.get("_elemente_pending_source_cascade") if isinstance(pending_src, dict) and meta["id"] in pending_src: pending_src.pop(meta["id"], None) doc = Rhino.RhinoDoc.ActiveDoc same_count = _count_same_id_type(doc, meta["id"], meta["type"]) if same_count <= 1: return # einziges Objekt mit dieser id, kein Duplikat if meta["type"] in SOURCE_TYPES: # Joint-Cache invalidieren bei Wand-Duplikat if meta["type"] == "wand_axis": _invalidate_joints_cache(meta.get("geschoss")) # Source-Duplikat: neue ID + Volumen regenerieren if meta["type"] == "wand_axis": prefix = "wall_" elif meta["type"] == "decke_outline": prefix = "decke_" elif meta["type"] == "dach_outline": prefix = "dach_" elif meta["type"] == "oeffnung_point": prefix = "fenster_" if meta.get("oeff_typ") == "fenster" else "tuer_" elif meta["type"] == "treppe_axis": prefix = "treppe_" elif meta["type"] in ("stuetze_point", "traeger_axis"): prefix = "trag_" elif meta["type"] == "raum_outline": prefix = "raum_" elif meta["type"] == "decke_aussparung_outline": prefix = "aussp_" else: prefix = "elem_" new_id = prefix + uuid.uuid4().hex[:10] attrs = new_obj.Attributes _attach_meta(attrs, new_id, meta["type"], meta["geschoss"], meta["dicke"], meta["uk_override"], meta["ok_override"], meta.get("referenz", "mid"), neigung=meta.get("neigung"), eave_idx=meta.get("eave_idx"), dach_typ=meta.get("dach_typ"), neigung_unten=meta.get("neigung_unten"), knick_h=meta.get("knick_h"), dach_variante=meta.get("dach_variante"), oeff_typ=meta.get("oeff_typ") or None, oeff_parent=meta.get("oeff_parent") or None, oeff_breite=meta.get("oeff_breite"), oeff_hoehe=meta.get("oeff_hoehe"), oeff_brueest=meta.get("oeff_brueest"), trag_kind=meta.get("trag_kind") or None, trag_profil=meta.get("trag_profil") or None, trag_b=meta.get("trag_b"), trag_h=meta.get("trag_h"), trag_d=meta.get("trag_d"), trag_t=meta.get("trag_t"), trag_angle=meta.get("trag_angle"), trag_z_over=meta.get("trag_z_over"), raum_name=meta.get("raum_name") or None, raum_nummer=meta.get("raum_nummer") or None, raum_funktion=meta.get("raum_funktion") or None, raum_rundung=meta.get("raum_rundung") or None, raum_txt_h=meta.get("raum_txt_h")) new_obj.Attributes = attrs new_obj.CommitChanges() print("[ELEMENTE] Source-Duplikat erkannt — neue ID {}".format(new_id)) _queue_regen(new_id) elif meta["type"] in VOLUME_TYPES: # Volume-Duplikat: das mit-kopierte Volumen ist verwaist, # weil das Source-Duplikat eine neue ID bekommt. Loeschen — # die Regen-Pipeline erstellt das richtige Volumen am # korrekten Ort fuer die neue ID. try: doc.Objects.Delete(new_obj.Id, True) except Exception as ex: print("[ELEMENTE] dup-volume delete:", ex) except Exception as ex: print("[ELEMENTE] on_object_added:", ex) def _on_object_deleted(sender, e): """Wenn das Source-Objekt (Achse/Outline/Oeffnungs-Point) manuell geloescht wird → verknuepftes Volumen entfernen. Bei Oeffnung: Elternwand regenerieren damit das Loch verschwindet. WICHTIG: Volume-Delete wird in eine Queue gelegt + im Idle prozessiert. Sofortiges Loeschen kollidiert mit _Move/_Rotate/_Mirror, die das Source-Object via Delete+Re-Add transformieren — der naechste Move- Schritt fuer das Volume bekommt dann „Unable to transform" weil das Volume schon weg ist. Die Queue wird in `_on_object_added` gecancelt wenn die Source mit gleicher ID zurueckkommt (= Transform, kein User- Delete). """ # Waehrend Swisstopo-Import: keine DOSSIER-Metas vorhanden, nur Overhead if sc.sticky.get("dossier_swisstopo_busy"): return # Waehrend Move/Rotate/Mirror/Scale: CommandEnd-Pfad uebernimmt das # Re-Sync. Sonst queued der Delete-Event ueberfluessige Regen-Calls die # den Pure-Translate-Skip wieder zunichtemachen. if sc.sticky.get(_UT_ACTIVE_KEY): _suppress_redraw_until_cmd_end() return if sc.sticky.get(_UNDO_ACTIVE_KEY): return try: obj = e.TheObject # Schneller Filter: ohne DOSSIER-Type-UserString gibt es nichts zu # cascaden. Greift fuer OSM-Curves, Swisstopo-Geometrie, importierte # Linien etc. → kein per-Event-Overhead bei Bulk-Delete mit 6000 # Fremd-Objekten. Wand-Achsen/Oeffnungs-Punkte/etc behalten ihre # Cascade auch waehrend Bulk-Ops, sonst zerfaellt die Verknuepfung # (Volumen orphaned, Oeffnungen in Wand bleiben liegen). try: _type_quick = obj.Attributes.GetUserString(_KEY_TYPE) except Exception: _type_quick = None if not _type_quick: return meta = _read_meta(obj) if meta and meta.get("type") in SOURCE_TYPES: doc = Rhino.RhinoDoc.ActiveDoc # Source-Cascade in die Queue (alle Sub-Volumen sammeln — # Tueren/Fenster haben mehrere: Rahmen, Sims, Glas, Fluegel). try: import time vol_ids = [v.Id for v in _find_all_volumes(doc, meta["id"])] if vol_ids: pending = sc.sticky.get("_elemente_pending_source_cascade") if not isinstance(pending, dict): pending = {} sc.sticky["_elemente_pending_source_cascade"] = pending parent_id = (meta.get("oeff_parent") or meta.get("aussp_parent") or "") pending[meta["id"]] = { "ts": time.time(), "volumes": vol_ids, "type": meta["type"], "parent": parent_id, } except Exception as ex: print("[ELEMENTE] queue source cascade:", ex) if meta["type"] == "oeffnung_point": parent_id = meta.get("oeff_parent") if parent_id: _queue_regen(parent_id) if meta["type"] == "decke_aussparung_outline": parent_id = meta.get("aussp_parent") if parent_id: _queue_regen(parent_id) # Wand-Verbindung: alle abhaengigen Waende (Corner + T-Stoss) # neu generieren — ihre Miter loest sich auf da die Partnerwand # weg ist. Zusaetzlich: alle in der Wand sitzenden Oeffnungen # (Tueren/Fenster via oeff_parent) zur Loesch-Queue. Die wird im # Idle erst abgearbeitet (mit Check ob wand_axis re-added wurde), # weil _Rotate/_Move/_Mirror eine Wand intern via Delete+Re-Add # transformieren — sofortiges Loeschen wuerde alle Oeffnungen # vernichten obwohl die Wand gleich zurueck kommt. if meta["type"] == "wand_axis": _invalidate_joints_cache(meta.get("geschoss")) try: import time op_ids = [op_meta["id"] for _op_obj, op_meta in _find_openings_for_wall(doc, meta["id"])] if op_ids: pending = sc.sticky.get("_elemente_pending_wand_cascade") if not isinstance(pending, dict): pending = {} sc.sticky["_elemente_pending_wand_cascade"] = pending pending[meta["id"]] = {"ts": time.time(), "openings": op_ids} except Exception as ex: print("[ELEMENTE] queue cascade:", ex) try: geom = obj.Geometry if obj is not None else None if isinstance(geom, rg.Curve): deps = _find_dependent_walls(doc, meta["geschoss"], meta["id"], geom, None) for wid in deps: _queue_regen(wid) except Exception as ex: print("[ELEMENTE] del dep regen:", ex) # Panel-Sync: bei Bulk-Op nur einmal am CommandEnd, sonst # sofort. Sonst pusht ein Delete von 50 Waenden 50× state. if not sc.sticky.get(_BULK_ACTIVE_KEY): b = sc.sticky.get("elemente_bridge") if b is not None: b._send_state() except Exception as ex: print("[ELEMENTE] on_object_deleted:", ex) _SELECT_BUSY = "_elemente_select_busy" # Welche Typen werden gekoppelt? source ↔ volume bidirectional. # Klick auf eine Surface zieht alle Schichten/Sub-Volumen desselben Bauteils # mit (Rahmen+Sims+Fluegel bei Oeffnungen, Stufen bei Treppen, Schichten bei # Wand/Decke). Source-Achse/Punkt kriegt zusaetzlich Grips zum Editieren. _PAIRED_VOLUME_TYPES = ( "wand_volume", "decke_volume", "dach_volume", "oeffnung_volume", "treppe_volume", "stuetze_volume", "traeger_volume", # Raum: Stempel-Text + SIA-Fuellung haengen an raum_outline. Damit die # drei gemeinsam markiert + via Rhino-Move zusammen verschoben werden. "raum_stamp", "raum_fill", ) _PAIRED_SOURCE_TYPES = ( "wand_axis", "decke_outline", "dach_outline", "oeffnung_point", "treppe_axis", "stuetze_point", "traeger_axis", "raum_outline", ) def _find_all_volumes(doc, element_id, type_filter=None): """Liefert ALLE Volume-Objekte zu element_id (z.B. alle Schichten einer mehrlagigen Wand).""" out = [] for obj in doc.Objects: m = _read_meta(obj) if m and m["id"] == element_id: t = m["type"] if t not in VOLUME_TYPES: continue if type_filter is None or t == type_filter: out.append(obj) return out def _collect_partners(doc, rhino_objects): """Sammelt Partner-Objekte fuer Selection-Sync und die Source-Objekte die Grips brauchen. Bei mehrschichtigen Waenden werden ALLE Schicht- Volumen als Partner gesammelt — die Wand verhaelt sich dann als ein zusammenhaengender Bauteil-Verbund. Liefert (partners, sources).""" partners = [] sources = [] seen_partner_ids = set() seen_source_ids = set() def _add_partner(o): if o is None: return sid = str(o.Id) if sid in seen_partner_ids: return partners.append(o); seen_partner_ids.add(sid) def _add_source(o): if o is None: return sid = str(o.Id) if sid in seen_source_ids: return sources.append(o); seen_source_ids.add(sid) for obj in rhino_objects: meta = _read_meta(obj) if meta is None: continue t = meta.get("type", "") if t in _PAIRED_VOLUME_TYPES: # Klick auf Volume (oder eine Schicht) → Source + alle Geschwister- # Volumen (andere Schichten derselben Wand) mitsammeln. src, _ = _find_source(doc, meta["id"]) if src is not None: _add_partner(src); _add_source(src) for v in _find_all_volumes(doc, meta["id"]): if str(v.Id) != str(obj.Id): _add_partner(v) elif t in _PAIRED_SOURCE_TYPES: # Klick auf Source → ALLE Volumen (alle Schichten) mitsammeln. for v in _find_all_volumes(doc, meta["id"]): _add_partner(v) _add_source(obj) return partners, sources def _on_select_objects(sender, e): """ArchiCAD-Style bidirektionaler Selection-Sync: - Klick auf Volumen (Wand/Decke) → Source-Achse mitselektieren + Grips an - Klick auf Source-Achse → Volumen mitselektieren + Grips an So bewegen sich beide synchron bei Move/Gumball, und die Endpunkte der Lauflinie sind als Grips zum Drag verfuegbar.""" if sc.sticky.get("dossier_swisstopo_busy"): return if sc.sticky.get(_SELECT_BUSY): return if sc.sticky.get(_REGEN_BUSY): return try: doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return partners, sources = _collect_partners(doc, e.RhinoObjects) if not partners and not sources: return sc.sticky[_SELECT_BUSY] = True try: # Partner selektieren — idempotent for p in partners: try: if p.IsSelected(False) == 0: doc.Objects.Select(p.Id, True) except Exception as ex: print("[ELEMENTE] select partner:", ex) # Grips an Source — idempotent for s in sources: try: if not s.GripsOn: s.GripsOn = True s.CommitChanges() except Exception as ex: print("[ELEMENTE] grips on:", ex) finally: sc.sticky[_SELECT_BUSY] = False except Exception as ex: print("[ELEMENTE] on_select:", ex) def _on_deselect_objects(sender, e): """Bidirektional zu _on_select_objects: - Volume deselektiert → Source deselektieren + Grips aus - Source deselektiert → Volume deselektieren + Grips aus""" if sc.sticky.get("dossier_swisstopo_busy"): return if sc.sticky.get(_SELECT_BUSY): return if sc.sticky.get(_REGEN_BUSY): return try: doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return partners, sources = _collect_partners(doc, e.RhinoObjects) if not partners and not sources: return sc.sticky[_SELECT_BUSY] = True try: for p in partners: try: if p.IsSelected(False) > 0: doc.Objects.Select(p.Id, False) except Exception as ex: print("[ELEMENTE] deselect partner:", ex) for s in sources: try: if s.GripsOn: s.GripsOn = False s.CommitChanges() except Exception as ex: print("[ELEMENTE] grips off:", ex) finally: sc.sticky[_SELECT_BUSY] = False except Exception as ex: print("[ELEMENTE] on_deselect:", ex) def _sync_orphan_grips(doc): """Cleanup: alle Source-Objekte mit GripsOn=True die NICHT mehr selektiert sind → Grips abschalten. Verhindert dass Grips nach Deselect-Events haengen bleiben.""" if sc.sticky.get(_SELECT_BUSY): return if sc.sticky.get(_REGEN_BUSY): return sc.sticky[_SELECT_BUSY] = True try: for obj in doc.Objects: try: if not obj.GripsOn: continue meta = _read_meta(obj) if meta is None: continue if meta.get("type") not in _PAIRED_SOURCE_TYPES: continue if obj.IsSelected(False) == 0: obj.GripsOn = False obj.CommitChanges() except Exception: pass finally: sc.sticky[_SELECT_BUSY] = False def _on_idle_selection(sender, e): """Pollt periodisch die Selektion + verarbeitet Pending-Regenerate-Queue. Debouncing: Pending-Regens werden erst nach 80 ms Ruhe (kein neues Replace-Event) ausgefuehrt. So vermeiden wir Volume-Flicker waehrend fortlaufenden Gumball-/Move-Operationen — der finale Regen rendert nach Drag-Ende, bis dahin uebernimmt Rhinos Transform die Geometrie.""" # Waehrend Bulk-Op (z.B. _Delete bei 6000 OSM-Curves): nicht pollen. # Wuerde sonst pro Idle-Tick alle Objekte iterieren = Quasi-Stall. if sc.sticky.get(_BULK_ACTIVE_KEY): return # Waehrend User-Transform (Move/Drag/Gumball): keine Regen-/Cascade- # Verarbeitung. Sonst kann ein gequeuter Regen die Geometrie wegziehen # die Rhino gerade interaktiv transformiert → Element zerfaellt # waehrend des Verschiebens. Die Transform-CommandEnd-Logik kuemmert # sich selber um Volume-Sync am Ende. if sc.sticky.get(_UT_ACTIVE_KEY): return b = sc.sticky.get("elemente_bridge") if b is None: return doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return # 1) Pending Regenerations abarbeiten — debounct (50 ms Ruhe). # Kein EnableDrawing-Suspend mehr (das hat User-Feedback langsamer # gemacht und konnte das Volumen "verschwinden" lassen wenn der # Toggle nicht sauber zurueck-flippt). pending = _pending_set() if pending: try: import time last_replace = sc.sticky.get("_elemente_last_replace_time", 0.0) now = time.time() quiet_for = now - last_replace except Exception: quiet_for = 1.0 if quiet_for >= 0.05: ids = list(pending) pending.clear() sc.sticky[_REGEN_BUSY] = True # Bulk-Performance: ein einziger Undo-Record fuer alle queued # Regens + Redraw nur am Ende (statt einem pro AddBrep/Delete). undo_serial = doc.BeginUndoRecord( "Elemente regenerieren ({})".format(len(ids))) prev_redraw = doc.Views.RedrawEnabled doc.Views.RedrawEnabled = False try: with _TimedBlock("Idle-Regen-Batch x{}".format(len(ids))): for wid in ids: try: _regenerate_volume(doc, wid) except Exception as ex: print("[ELEMENTE] regen", wid, ex) finally: doc.Views.RedrawEnabled = prev_redraw try: doc.EndUndoRecord(undo_serial) except Exception: pass sc.sticky[_REGEN_BUSY] = False try: doc.Views.Redraw() except Exception: pass try: b._send_state() except Exception: pass # 1a) Pending Wand-Cascade-Deletes verarbeiten. # Bei Wand-Delete queuen wir die zugehoerigen Oeffnungs-IDs (statt sofort # zu loeschen), weil _Rotate/_Move/_Mirror die Wand intern via Delete+ # Re-Add transformieren. Im Re-Add wird der Eintrag entfernt (kein # echter Delete). Bleibt der Eintrag nach 500 ms uebrig → echter User- # Delete → Oeffnungen kaskaden. pending_cascade = sc.sticky.get("_elemente_pending_wand_cascade") if isinstance(pending_cascade, dict) and pending_cascade: try: import time now = time.time() to_run = [] for wall_id, info in list(pending_cascade.items()): if now - info.get("ts", 0) >= 0.5: to_run.append((wall_id, info.get("openings", []))) del pending_cascade[wall_id] for wall_id, op_ids in to_run: # Doppelt-Check: lebt die Wand noch wirklich nicht mehr? still_there = False for obj in doc.Objects: m = _read_meta(obj) if m and m.get("id") == wall_id and m.get("type") == "wand_axis": still_there = True break if still_there: continue _was = sc.sticky.get(_REGEN_BUSY, False) sc.sticky[_REGEN_BUSY] = True try: for op_id in op_ids: for vol_obj in _find_all_volumes(doc, op_id): try: doc.Objects.Delete(vol_obj.Id, True) except Exception: pass # Source-Point auch loeschen for obj, _m in _find_objects_by_wall_id(doc, op_id): if _m.get("type") == "oeffnung_point": try: doc.Objects.Delete(obj.Id, True) except Exception: pass finally: sc.sticky[_REGEN_BUSY] = _was if to_run: try: doc.Views.Redraw() except Exception: pass try: b._send_state() except Exception: pass except Exception as ex: print("[ELEMENTE] pending cascade:", ex) # 1a2) Pending Source-Cascade verarbeiten (Tueren/Fenster/Aussparungen). # Analog Wand-Cascade: Source-Delete koennte Transform sein (Move/Rotate), # daher 500 ms warten + Check ob Source mit gleicher ID re-added wurde. pending_src = sc.sticky.get("_elemente_pending_source_cascade") if isinstance(pending_src, dict) and pending_src: try: import time now = time.time() to_run_src = [] for src_id, info in list(pending_src.items()): if now - info.get("ts", 0) >= 0.5: to_run_src.append((src_id, info)) del pending_src[src_id] for src_id, info in to_run_src: still_there = False for obj in doc.Objects: m = _read_meta(obj) if m and m.get("id") == src_id and m.get("type") == info.get("type"): still_there = True break if still_there: continue _was = sc.sticky.get(_REGEN_BUSY, False) sc.sticky[_REGEN_BUSY] = True try: for vol_id in info.get("volumes", []): try: doc.Objects.Delete(vol_id, True) except Exception: pass finally: sc.sticky[_REGEN_BUSY] = _was parent_id = info.get("parent") if parent_id: _queue_regen(parent_id) if to_run_src: try: doc.Views.Redraw() except Exception: pass try: b._send_state() except Exception: pass except Exception as ex: print("[ELEMENTE] pending source cascade:", ex) # 1b) SIA-State-Change-Detection — wenn der User den Override-Modus # ausserhalb meines SIA-Buttons aendert (z.B. via Overrides-Panel- # Master-Toggle oder via Preset-Dropdown), regen wir alle Raeume. try: cur_sia = _sia_fill_enabled(doc) last_sia = getattr(b, "_last_sia_state", None) if last_sia is None: b._last_sia_state = cur_sia elif last_sia != cur_sia: b._last_sia_state = cur_sia ids = [] for obj in doc.Objects: mm = _read_meta(obj) if mm and mm["type"] == "raum_outline": ids.append(mm["id"]) if ids: sc.sticky[_REGEN_BUSY] = True undo_serial = doc.BeginUndoRecord( "SIA-Modus Regen ({})".format(len(ids))) prev_redraw = doc.Views.RedrawEnabled doc.Views.RedrawEnabled = False try: with _TimedBlock("SIA-Regen x{}".format(len(ids))): for rid in ids: try: _regenerate_element(doc, rid) except Exception: pass finally: doc.Views.RedrawEnabled = prev_redraw try: doc.EndUndoRecord(undo_serial) except Exception: pass sc.sticky[_REGEN_BUSY] = False try: doc.Views.Redraw() except Exception: pass try: b._send_state() except Exception: pass except Exception: pass # 2) Grips-Sync — sicherstellen dass keine "orphan" Grips visible bleiben try: _sync_orphan_grips(doc) except Exception: pass # 3) Selektions-Poll (langsamer, ~5/s) try: b._idle_count = getattr(b, "_idle_count", 0) + 1 if b._idle_count < 10: return b._idle_count = 0 ids = tuple(sorted(str(o.Id) for o in doc.Objects.GetSelectedObjects(False, False))) if ids != getattr(b, "_last_selection_ids", ()): b._last_selection_ids = ids b._send_state() except Exception: pass # Welche Rhino-Commands transformieren mehrere Objekte gleichzeitig — bei # diesen lassen wir Rhinos Move/Rotate KOMPLETT durchlaufen und feuern den # Wand-Regen erst NACH CommandEnd. So gibt's keine „Unable to transform"- # Kollision mehr zwischen meinem Sync-Regen und Rhinos pending Transforms. _USER_TRANSFORM_CMDS = frozenset(( "Move", "Rotate", "Rotate3D", "Mirror", "Scale", "Scale1D", "Scale2D", "Drag", "Gumball", "Orient", "Orient3Pt", "RemapCPlane", "Transform", )) # Bulk-Operations: User selektiert N Objekte + ausfuehrt die Operation # einmal. Wir suspenden Redraws + Listener-Arbeit damit das nicht # pro-Object visuell durchrieselt. Beispiel: SelAll + Delete bei 6000 # Curves → ohne Suspend dauert das ewig + man sieht jedes Element # einzeln verschwinden. _USER_BULK_CMDS = frozenset(( "Delete", "DeleteSelected", "DeleteSubObject", "Cut", )) _BULK_ACTIVE_KEY = "_dossier_bulk_op_active" # Undo/Redo: Rhino restored Objekte aus dem Undo-Stack → feuert Add/Delete- # Events fuer ALLE betroffenen Objekte. Unsere Handler wuerden fuer jedes # einen Regen queuen → Storm. Wir suppressen die Handler komplett; Undo hat # den Zustand schon konsistent wiederhergestellt, kein Regen noetig. _USER_UNDO_CMDS = frozenset(("Undo", "Redo")) _UT_ACTIVE_KEY = "_dossier_user_transform_active" _UT_SNAPSHOT_KEY = "_dossier_pre_transform_snapshot" _UNDO_ACTIVE_KEY = "_dossier_undo_active" def _snapshot_source_positions(doc): """Schnappschuss vor einem User-Transform: Source-Geometrien + Volume- BBox-Centers. Source-Map (key=element_id) füttert Constraint+Migrate. Volume-Map (key=obj.Id-string) erlaubt im CommandEnd die Pure-Translate- Detection — wir checken pro Volume ob es schon vom Rhinos Move transformed wurde, oder noch ge-translaten werden muss. obj_ids-Set: alle pre-Command Rhino-Object-IDs. Wird in CommandEnd benutzt um Mirror/Copy-Duplikate zu erkennen (= neue Objs mit IDs die nicht im Snapshot waren).""" snap = {"sources": {}, "volumes": {}, "obj_ids": set()} if doc is None: return snap for obj in doc.Objects: try: snap["obj_ids"].add(str(obj.Id)) m = _read_meta(obj) if not m: continue t = m.get("type") geom = obj.Geometry if t in SOURCE_TYPES: parent = m.get("oeff_parent") or "" if hasattr(geom, "Location"): p = geom.Location snap["sources"][m["id"]] = {"type": t, "oeff_parent": parent, "pos": (p.X, p.Y, p.Z)} elif isinstance(geom, rg.Curve): s = geom.PointAtStart; e = geom.PointAtEnd snap["sources"][m["id"]] = {"type": t, "oeff_parent": parent, "start": (s.X, s.Y, s.Z), "end": (e.X, e.Y, e.Z)} elif t in VOLUME_TYPES: try: bb = geom.GetBoundingBox(True) if bb.IsValid: c = bb.Center snap["volumes"][str(obj.Id)] = { "element_id": m["id"], "type": t, "center": (c.X, c.Y, c.Z)} except Exception: pass except Exception: pass return snap def _suppress_redraw_until_cmd_end(): """Schaltet RedrawEnabled erst auf False sobald das ERSTE Object-Event waehrend eines User-Transform-Commands feuert. Damit bleiben Rubber- Band-Linie und Drag-Vorschau waehrend des Pickings sichtbar (Picking feuert keine Object-Events), aber Rhinos automatischer Post-Move- Redraw (kommt nach dem Klick, direkt nach den Replace-Events) wird unterdrueckt. Wird im selben Command nur einmal aktiv.""" if sc.sticky.get("_dossier_cmd_redraw_suppressed"): return doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return try: sc.sticky["_dossier_cmd_redraw_prev"] = bool(doc.Views.RedrawEnabled) doc.Views.RedrawEnabled = False sc.sticky["_dossier_cmd_redraw_suppressed"] = True except Exception as ex: print("[ELEMENTE] suppress redraw:", ex) def _flush_pending_cascades_sync(doc): """Verarbeitet die wand_cascade- und source_cascade-Queues SOFORT (ohne 500-ms-Wait). Aufgerufen aus _on_command_end fuer Delete-Cmds: der User hat explizit geloescht, die Source kommt nicht zurueck — also kein Grund zu warten. Vorteil: laeuft noch im selben Undo-Record wie Rhinos User-Delete → ein Cmd+Z stellt alles wieder her. """ pending_cascade = sc.sticky.get("_elemente_pending_wand_cascade") if isinstance(pending_cascade, dict) and pending_cascade: to_run = [(wid, info.get("openings", [])) for wid, info in list(pending_cascade.items())] pending_cascade.clear() for wall_id, op_ids in to_run: still_there = False for obj in doc.Objects: m = _read_meta(obj) if m and m.get("id") == wall_id and m.get("type") == "wand_axis": still_there = True break if still_there: continue _was = sc.sticky.get(_REGEN_BUSY, False) sc.sticky[_REGEN_BUSY] = True try: for op_id in op_ids: for vol_obj in _find_all_volumes(doc, op_id): try: doc.Objects.Delete(vol_obj.Id, True) except Exception: pass for obj, _m in _find_objects_by_wall_id(doc, op_id): if _m.get("type") == "oeffnung_point": try: doc.Objects.Delete(obj.Id, True) except Exception: pass finally: sc.sticky[_REGEN_BUSY] = _was pending_src = sc.sticky.get("_elemente_pending_source_cascade") if isinstance(pending_src, dict) and pending_src: to_run_src = list(pending_src.items()) pending_src.clear() for src_id, info in to_run_src: still_there = False for obj in doc.Objects: m = _read_meta(obj) if m and m.get("id") == src_id and m.get("type") == info.get("type"): still_there = True break if still_there: continue _was = sc.sticky.get(_REGEN_BUSY, False) sc.sticky[_REGEN_BUSY] = True try: for vol_id in info.get("volumes", []): try: doc.Objects.Delete(vol_id, True) except Exception: pass finally: sc.sticky[_REGEN_BUSY] = _was parent_id = info.get("parent") if parent_id: _queue_regen(parent_id) def _on_command_begin(sender, e): try: name = getattr(e, "CommandEnglishName", "") or "" except Exception: name = "" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return # Undo/Redo: nur Flag setzen, KEIN Snapshot, KEIN Redraw-Suppress — # Rhinos Undo verwaltet RedrawEnabled selbst. Event-Handler ignorieren # waehrend dieser Phase alle Add/Delete/Replace-Events → kein Regen- # Storm. if name in _USER_UNDO_CMDS: sc.sticky[_UNDO_ACTIVE_KEY] = name return # Bulk-Ops (z.B. _Delete mit 6000 Selektion): RedrawEnabled aus + # Listener bail-out — am Ende einmal redrawn. if name in _USER_BULK_CMDS: sc.sticky[_BULK_ACTIVE_KEY] = name print("[ELEMENTE] Bulk-Op start: '{}' — Listener bail aktiv".format(name)) try: sc.sticky["_dossier_bulk_redraw_prev"] = bool(doc.Views.RedrawEnabled) doc.Views.RedrawEnabled = False except Exception: pass # Undo-Record umschliesst Rhinos User-Delete + unsere Cascade # (Volume-Delete, Oeffnungs-Delete) in EINEM Cmd+Z-Schritt. # Sonst bleibt nach Cmd+Z nur die Achse uebrig (Volumen kamen aus # separatem Idle-Record und der hatte schon gepop't). try: serial = doc.BeginUndoRecord("Element-Loeschen") sc.sticky["_dossier_bulk_undo_serial"] = serial except Exception as ex: print("[ELEMENTE] bulk undo record begin:", ex) sc.sticky["_dossier_bulk_undo_serial"] = None return # Diagnose: andere Commands sehen wir hier vorbeiziehen — wenn _Delete # einen anderen Namen hat als 'Delete', sehen wir's und koennen den # frozenset anpassen. if name and "delete" in name.lower(): print("[ELEMENTE] CmdBegin '{}' (nicht im Bulk-Set — anpassen?)".format(name)) if name not in _USER_TRANSFORM_CMDS: return sc.sticky[_UT_SNAPSHOT_KEY] = _snapshot_source_positions(doc) sc.sticky[_UT_ACTIVE_KEY] = name # RedrawEnabled bleibt HIER auf True. Wird erst beim ersten Object-Event # (= nach dem Klick) via `_suppress_redraw_until_cmd_end` ausgeschaltet. # Rubber-Band-Linie + Drag-Vorschau bleiben dadurch wahrend Picking # sichtbar. # Undo-Record umschliesst Rhinos Move + unseren Regen in EINEM Undo- # Schritt. Sonst macht jedes Delete/AddBrep eine eigene Undo-Entry und # Cmd+Z bringt nur halbe Wand zurueck → Duplikate. try: serial = doc.BeginUndoRecord("Element-Transform") sc.sticky["_dossier_undo_serial"] = serial except Exception as ex: print("[ELEMENTE] cmd-begin undo record:", ex) sc.sticky["_dossier_undo_serial"] = None def _on_command_end(sender, e): # Bulk-Op fertig: RedrawEnabled zurueck + EINMAL redrawn + selection # refresh ans Gestaltung-Panel. if sc.sticky.get(_BULK_ACTIVE_KEY): sc.sticky[_BULK_ACTIVE_KEY] = None doc = Rhino.RhinoDoc.ActiveDoc # Cascade synchron flushen WAEHREND der Undo-Record noch offen ist # und RedrawEnabled noch False ist (kein Flicker, ein Cmd+Z fuer # alles). Nur fuer Delete-Cmds relevant — andere Bulk-Cmds queuen # nichts. try: if doc is not None: _flush_pending_cascades_sync(doc) except Exception as ex: print("[ELEMENTE] bulk cascade flush:", ex) # Undo-Record schliessen — alles seit BeginUndoRecord (User-Delete + # Cascade) wird als EIN Cmd+Z behandelt. try: serial = sc.sticky.pop("_dossier_bulk_undo_serial", None) if serial is not None and doc is not None: doc.EndUndoRecord(serial) except Exception as ex: print("[ELEMENTE] bulk undo record end:", ex) try: prev = sc.sticky.pop("_dossier_bulk_redraw_prev", True) if doc is not None: doc.Views.RedrawEnabled = prev doc.Views.Redraw() except Exception: pass eb = sc.sticky.get("elemente_bridge") if eb is not None: try: eb._send_state() except Exception: pass gb = sc.sticky.get("gestaltung_bridge") if gb is not None: try: gb._send_selection() except Exception: pass return # Undo/Redo abschliessen: nur Flag clearen, kein Regen + ein Selection- # Refresh fuers Gestaltung-Panel (Listener waren waehrend Undo aus). if sc.sticky.get(_UNDO_ACTIVE_KEY): sc.sticky[_UNDO_ACTIVE_KEY] = None gb = sc.sticky.get("gestaltung_bridge") if gb is not None: try: gb._send_selection() except Exception: pass b = sc.sticky.get("elemente_bridge") if b is not None: try: b._send_state() except Exception: pass return name = sc.sticky.get(_UT_ACTIVE_KEY) if not name: return # _UT_ACTIVE_KEY bleibt gesetzt bis am Ende der Funktion — sonst feuern # gestaltungs Listener auf die Replace-Events die wir hier selber # erzeugen (Pure-Translate translates Volumen via Replace; Regen-Pfad # ersetzt Sub-Volumen). Cleanup im finally-Block am Ende. snapshot = sc.sticky.get(_UT_SNAPSHOT_KEY) or {} sc.sticky[_UT_SNAPSHOT_KEY] = None doc = Rhino.RhinoDoc.ActiveDoc if doc is None: sc.sticky[_UT_ACTIVE_KEY] = None sc.sticky["_dossier_cmd_redraw_suppressed"] = None sc.sticky["_dossier_cmd_redraw_prev"] = None sc.sticky["_dossier_undo_serial"] = None return # RedrawEnabled wurde idR schon beim ersten Object-Event nach dem # User-Klick auf False gesetzt (`_suppress_redraw_until_cmd_end`). Den # gemerkten prev-Wert lesen. Falls kein Event gefeuert hat (z.B. Move # ohne tatsaechliche Aenderung), suppressen wir jetzt selber. if sc.sticky.get("_dossier_cmd_redraw_suppressed"): prev_redraw_enabled = sc.sticky.get("_dossier_cmd_redraw_prev", True) sc.sticky["_dossier_cmd_redraw_suppressed"] = None sc.sticky["_dossier_cmd_redraw_prev"] = None else: prev_redraw_enabled = doc.Views.RedrawEnabled doc.Views.RedrawEnabled = False sources_snap = snapshot.get("sources", {}) if isinstance(snapshot, dict) else {} volumes_snap = snapshot.get("volumes", {}) if isinstance(snapshot, dict) else {} old_obj_ids = snapshot.get("obj_ids", set()) if isinstance(snapshot, dict) else set() # ─── Mirror/Copy-Duplikat-Detection ───────────────────────────────────── # Rhinos Mirror/Copy/Array erzeugt KOPIEN selektierter Objekte mit ihren # UserStrings (= Metadata). Resultat: Duplikat-IDs im Doc — z.B. zwei # `wand_axis` mit `id=wall_xxx`. Unser System haelt die fuer „dasselbe # Element", was zu „verkoppelten" Elementen fuehrt und zu kaputten # Pure-Transform-Detections. # # Fix: alle NEUEN Objs (obj.Id nicht im Snapshot) deren UserString-id # bereits im Snapshot existiert → neue UUID. Sub-Volumen und # Oeffnungs-Parent-Refs werden konsistent umgehaengt. _type_to_prefix = { "wand_axis": "wall_", "decke_outline": "decke_", "dach_outline": "dach_", "treppe_axis": "treppe_", "stuetze_point": "trag_", "traeger_axis": "trag_", "raum_outline": "raum_", "decke_aussparung_outline": "aussp_", } # Pass A: identifiziere neue Sources mit dup-IDs, sammle (obj, alte_id, neue_id) dup_source_renames = [] # list of (obj, old_id, new_id, type) for obj in doc.Objects: try: if str(obj.Id) in old_obj_ids: continue # original existed pre-command m = _read_meta(obj) if not m: continue t = m.get("type") if t not in SOURCE_TYPES: continue old_id = m["id"] if old_id not in sources_snap: continue # echtes neues Element if t == "oeffnung_point": prefix = "fenster_" if m.get("oeff_typ") == "fenster" else "tuer_" else: prefix = _type_to_prefix.get(t, "elem_") new_id = prefix + uuid.uuid4().hex[:10] dup_source_renames.append((obj, old_id, new_id, t)) except Exception as ex: print("[ELEMENTE] dup detection:", ex) # Pass B: neue Volumes mit dup-IDs identifizieren (alte UserString-id ist # eine umbenannte Source). Mapping alte_id → neue_id zum Lookup. elem_id_map = {old_id: new_id for (_, old_id, new_id, _) in dup_source_renames} dup_volume_renames = [] # list of (obj, new_id, oeff_parent_old, oeff_parent_new) for obj in doc.Objects: try: if str(obj.Id) in old_obj_ids: continue m = _read_meta(obj) if not m: continue t = m.get("type") if t not in VOLUME_TYPES: continue old_vol_id = m["id"] new_vol_id = elem_id_map.get(old_vol_id) if not new_vol_id: continue # Volume gehoert nicht zu einem renamed Source # oeff_parent rewire bei oeffnung_volume old_parent = m.get("oeff_parent") or "" new_parent = elem_id_map.get(old_parent, old_parent) dup_volume_renames.append((obj, new_vol_id, old_parent, new_parent)) except Exception as ex: print("[ELEMENTE] dup volume detection:", ex) # Pass C: oeffnung_point's oeff_parent rewire (nicht-Volume, also Sources) # Wenn eine Wand umbenannt wurde, alle (umbenannten) Oeffnungen die zu ihr # gehoeren auch auf neue Wand-id umhaengen. if elem_id_map: # In dup_source_renames Liste: fuer oeffnung_point-Renames pruefen, ob # ihr oeff_parent in elem_id_map ist → updaten. for i, (obj, old_id, new_id, t) in enumerate(dup_source_renames): if t != "oeffnung_point": continue try: m = _read_meta(obj) if not m: continue old_parent = m.get("oeff_parent") or "" new_parent = elem_id_map.get(old_parent, old_parent) # Tuple aktualisieren (alte vs neue parent-ID, fuer apply unten) dup_source_renames[i] = (obj, old_id, new_id, t, new_parent) except Exception: pass # Pass D: alle gesammelten Renames anwenden n_renamed = 0 for entry in dup_source_renames: try: if len(entry) == 5: obj, old_id, new_id, t, new_parent = entry else: obj, old_id, new_id, t = entry new_parent = None attrs = obj.Attributes.Duplicate() attrs.SetUserString(_KEY_ID, new_id) if new_parent is not None: attrs.SetUserString(_KEY_OEFF_PARENT, new_parent) doc.Objects.ModifyAttributes(obj.Id, attrs, True) n_renamed += 1 except Exception as ex: print("[ELEMENTE] apply source rename:", ex) for obj, new_vol_id, old_parent, new_parent in dup_volume_renames: try: attrs = obj.Attributes.Duplicate() attrs.SetUserString(_KEY_ID, new_vol_id) if old_parent and new_parent and new_parent != old_parent: attrs.SetUserString(_KEY_OEFF_PARENT, new_parent) doc.Objects.ModifyAttributes(obj.Id, attrs, True) n_renamed += 1 except Exception as ex: print("[ELEMENTE] apply volume rename:", ex) if n_renamed > 0: print("[ELEMENTE] mirror/copy-Duplikate: {} Objs neu-ID'd".format(n_renamed)) # Wenn ALLE bewegten Sources sich mit dem gleichen Rigid-2D-Transform # abbilden lassen (Translation und/oder Rotation um Z-Achse, KEIN Scale, # KEIN Z-Drag, KEIN End-Grip-Drag, KEIN Mirror), reicht eine Transform- # Anwendung auf alle noch unbewegten Volumen + Punkte. KEIN Wand-Regen, # KEIN Boolean-Diff. Geht instant. import math as _math def _source_rigid_transform(obj, old): """Berechnet den Rigid-2D-Transform (Translation + Z-Rotation) der alte Source-Geometrie auf die aktuelle abbildet. Returns None wenn Z-Drag/Scale/End-Grip/Mirror erkannt.""" geom = obj.Geometry if isinstance(geom, rg.Curve): os_pt = old.get("start"); oe_pt = old.get("end") if os_pt is None or oe_pt is None: return None ns = geom.PointAtStart; ne = geom.PointAtEnd # Z-Aenderung verbietet Pure-Transform (= Z-Drag → UK_OVER muss # geschrieben werden → Regen-Pfad). if (abs(ns.Z - os_pt[2]) > 1e-6 or abs(ne.Z - oe_pt[2]) > 1e-6): return None old_dx = oe_pt[0] - os_pt[0]; old_dy = oe_pt[1] - os_pt[1] new_dx = ne.X - ns.X; new_dy = ne.Y - ns.Y old_len = _math.hypot(old_dx, old_dy) new_len = _math.hypot(new_dx, new_dy) if old_len < 1e-9: return None # Laengenaenderung → Scale (oder einzelner Endpunkt-Drag) if abs(old_len - new_len) > 1e-6: return None # Drehwinkel um Z aus Richtungsvektoren old_angle = _math.atan2(old_dy, old_dx) new_angle = _math.atan2(new_dy, new_dx) angle = new_angle - old_angle # Transform: erst um old_start zentrieren, dann rotieren, dann # zu new_start translaten. So mappen sowohl old_start→new_start # als auch old_end→new_end korrekt. to_origin = rg.Transform.Translation(-os_pt[0], -os_pt[1], -os_pt[2]) rotate = rg.Transform.Rotation(angle, rg.Vector3d.ZAxis, rg.Point3d.Origin) to_new = rg.Transform.Translation(ns.X, ns.Y, ns.Z) return to_new * rotate * to_origin if hasattr(geom, "Location"): op = old.get("pos") if op is None: return None p = geom.Location # Punkt: keine Orientierungs-Info → nur Translation ableitbar. # Konsistenz mit Curve-Transform wird in Phase 2 geprueft. return rg.Transform.Translation(p.X - op[0], p.Y - op[1], p.Z - op[2]) return None def _is_identity_transform(t, tol=1e-6): for i in range(4): for j in range(4): ref = 1.0 if i == j else 0.0 if abs(t[i, j] - ref) > tol: return False return True def _transforms_equal(t1, t2, tol=1e-6): for i in range(4): for j in range(4): if abs(t1[i, j] - t2[i, j]) > tol: return False return True # Phase 1: Transform pro Source berechnen, abort bei non-rigid source_transforms = {} abort_pure = False for obj in doc.Objects: try: m = _read_meta(obj) if not m: continue if m.get("type") not in SOURCE_TYPES: continue old = sources_snap.get(m["id"]) if old is None: continue t = _source_rigid_transform(obj, old) if t is None: abort_pure = True break source_transforms[m["id"]] = t except Exception: pass # Phase 2: moved_ids + canonical (bevorzugt Curve-Source fuer # Rotations-Info; Points haben nur Translation) moved_ids = {eid for eid, t in source_transforms.items() if not _is_identity_transform(t)} canonical = None for eid in moved_ids: old = sources_snap.get(eid) if old and "start" in old: canonical = source_transforms[eid] break if canonical is None and moved_ids: # Keine Curve bewegt → nimm irgendeinen Point-Transform for eid in moved_ids: canonical = source_transforms[eid] break # Phase 3: alle bewegten Sources MUESSEN canonical erfuellen all_consistent = True if canonical is not None and not abort_pure: for eid in moved_ids: old = sources_snap.get(eid) if old is None: continue if "start" in old: # Curve: Transform muss canonical sein if not _transforms_equal(source_transforms[eid], canonical): all_consistent = False break else: # Point: canonical applied to old_pos muss aktuelle Position sein op = old.get("pos") if op is None: continue expected = rg.Point3d(op[0], op[1], op[2]) expected.Transform(canonical) actual = None for obj in doc.Objects: mm = _read_meta(obj) if mm and mm.get("id") == eid and mm.get("type") == old.get("type"): gg = obj.Geometry if hasattr(gg, "Location"): actual = gg.Location break if actual is None: continue if (abs(actual.X - expected.X) > 1e-6 or abs(actual.Y - expected.Y) > 1e-6 or abs(actual.Z - expected.Z) > 1e-6): all_consistent = False break # Orphan-Oeffnung erkennen: bewegte Oeffnung deren Eltern-Wand NICHT # mitbewegt wurde. Cutout muss regen. orphan_opening = False for eid in moved_ids: old = sources_snap.get(eid) if old and old.get("type") == "oeffnung_point": parent = old.get("oeff_parent") if parent and parent not in moved_ids: orphan_opening = True break pure_transform = None if abort_pure: print("[ELEMENTE] no pure-transform: z-drag/scale/end-grip detected") elif orphan_opening: print("[ELEMENTE] no pure-transform: opening moved without parent wall (cutout muss regen)") elif not all_consistent: print("[ELEMENTE] no pure-transform: sources moved with different transforms") elif canonical is not None: pure_transform = canonical if pure_transform is not None: # PURE-TRANSFORM PFAD: Transform auf alle Geometries anwenden die # nicht schon vom User-Move transformed wurden. Funktioniert fuer # Translation UND Rotation. → instant feedback. tx = pure_transform[0, 3] ty = pure_transform[1, 3] tz = pure_transform[2, 3] # Rotations-Anteil aus m00/m01 (Z-Rotation der 2x2 oberen Submatrix) rot_deg = _math.degrees(_math.atan2(pure_transform[1, 0], pure_transform[0, 0])) print("[ELEMENTE] pure-transform: tx={:.3f} ty={:.3f} tz={:.3f} rot={:.1f}°".format( tx, ty, tz, rot_deg)) # Eltern→Kind-Cascade: nur bewegte Sources + deren Children folgen. def _should_follow(m): eid = m.get("id") if eid in moved_ids: return True parent = m.get("oeff_parent") if parent and parent in moved_ids: return True return False _was_busy = sc.sticky.get(_REGEN_BUSY, False) sc.sticky[_REGEN_BUSY] = True try: for obj in list(doc.Objects): try: m = _read_meta(obj) if not m: continue t = m.get("type") if not _should_follow(m): continue # Sources die nicht bewegt wurden (= identity transform) # transformen — nur via _should_follow erlaubt (Cascade). if t in SOURCE_TYPES: src_t = source_transforms.get(m["id"]) if src_t is not None and not _is_identity_transform(src_t): continue # Rhino hat bereits transformed new_geom = obj.Geometry.Duplicate() new_geom.Transform(pure_transform) doc.Objects.Replace(obj.Id, new_geom) continue # Volumes: bb-Center gegen Snapshot vergleichen. Unbewegt # → transformen. Bereits transformed (Rhino) → skip. if t in VOLUME_TYPES: vol_snap = volumes_snap.get(str(obj.Id)) if vol_snap is None: continue try: bb = obj.Geometry.GetBoundingBox(True) if not bb.IsValid: continue c_now = bb.Center c_old = vol_snap["center"] dx = c_now.X - c_old[0] dy = c_now.Y - c_old[1] dz = c_now.Z - c_old[2] if (abs(dx) < 1e-6 and abs(dy) < 1e-6 and abs(dz) < 1e-6): new_geom = obj.Geometry.Duplicate() new_geom.Transform(pure_transform) doc.Objects.Replace(obj.Id, new_geom) except Exception: pass except Exception as ex: print("[ELEMENTE] pure-transform:", ex) finally: sc.sticky[_REGEN_BUSY] = _was_busy doc.Views.RedrawEnabled = prev_redraw_enabled try: doc.Views.Redraw() except Exception: pass # Flag erst HIER cleren, nachdem alle Replace-Events durch sind — # sonst feuert gestaltung.on_replace pro Volume. sc.sticky[_UT_ACTIVE_KEY] = None # Undo-Record schliessen — alles seit BeginUndoRecord landet in # einem einzelnen Cmd+Z-Schritt. undo_serial = sc.sticky.get("_dossier_undo_serial") if undo_serial: try: doc.EndUndoRecord(undo_serial) except Exception: pass sc.sticky["_dossier_undo_serial"] = None sc.sticky["_dossier_migrated_walls"] = None b = sc.sticky.get("elemente_bridge") if b is not None: try: b._send_state() except Exception: pass # Gestaltung-Panel einmalig nachziehen — Listener waren waehrend # des User-Transform-Commands suspendiert. gb = sc.sticky.get("gestaltung_bridge") if gb is not None: try: gb._send_selection() except Exception: pass return # ─── Regulärer Pfad: Constraints + Migrate + Regen (existing flow) ────── # Pseudo-Object Wrapper damit _apply_oeffnung_constraint pt_old.Location # lesen kann ohne den echten alten RhinoObject zu kennen. class _PseudoOld(object): def __init__(self, pt): self.Geometry = rg.Point(pt) affected_walls = set() _was_busy = sc.sticky.get(_REGEN_BUSY, False) sc.sticky[_REGEN_BUSY] = True # Skip-Flag: in der Schleife wuerde jeder Constraint einen eigenen Sync- # Regen ausloesen → mehrere Regens pro Wand. Wir machen am Schluss EINEN # Regen pro affected_wall — viel schneller bei mehreren Oeffnungen. sc.sticky["_dossier_skip_sync_regen"] = True # RedrawEnabled wurde schon in _on_command_begin auf False gesetzt — # damit unterdruecken wir auch Rhinos automatischen Post-Move-Redraw # (sonst kurzer Mismatch-Frame: Oeffnung an neuer Pos, Wand-Loch noch # an alter Pos). try: for obj in list(doc.Objects): try: m = _read_meta(obj) if not m: continue t = m.get("type") if t not in SOURCE_TYPES: continue old = sources_snap.get(m["id"]) if old is None: continue if t == "wand_axis": geom = obj.Geometry if not isinstance(geom, rg.Curve): continue os = old.get("start"); oe = old.get("end") # Migrate NUR wenn XY tatsaechlich geaendert. Bei reinem # Z-Drag (XY identisch) waere Migrate ein no-op-Loop ueber # alle Oeffnungen mit Replace-Ops je Punkt — spart die # ganze Pass + die nachfolgenden Replace-Events. if os and oe: xy_changed = ( abs(geom.PointAtStart.X - os[0]) > 1e-6 or abs(geom.PointAtStart.Y - os[1]) > 1e-6 or abs(geom.PointAtEnd.X - oe[0]) > 1e-6 or abs(geom.PointAtEnd.Y - oe[1]) > 1e-6 ) if xy_changed: try: old_line = rg.LineCurve( rg.Point3d(os[0], os[1], os[2]), rg.Point3d(oe[0], oe[1], oe[2])) # Pre-Transform Oeffnungs-Positionen aus dem # Snapshot ziehen — Migrate braucht sie um die # Bogenlaengen-Position auf der ALTEN Axis zu # finden (sonst bei Rotation falscher Snap). old_op_positions = {} for snap_id, snap_data in sources_snap.items(): if snap_data.get("type") != "oeffnung_point": continue if snap_data.get("oeff_parent") != m["id"]: continue pos = snap_data.get("pos") if pos: old_op_positions[snap_id] = pos _migrate_openings_to_new_axis( m["id"], old_line, geom, old_op_positions) except Exception as ex: print("[ELEMENTE] post-cmd migrate:", ex) # Z-Drag detect + Brüstungs-Mitnahme. Constraint setzt # sticky-delta wenn Z geaendert; wir consumen es direkt. _apply_wand_z_drag_constraint(obj, m) z_entry = sc.sticky.get("_elemente_wand_z_delta") z_delta = 0.0 if isinstance(z_entry, tuple) and len(z_entry) == 2 \ and z_entry[0] == m["id"]: try: z_delta = float(z_entry[1]) except (ValueError, TypeError): z_delta = 0.0 sc.sticky["_elemente_wand_z_delta"] = None if abs(z_delta) >= 1e-6: # OPTION A: Brueest ist RELATIV zur Wand-UK. Da UK # in `_apply_wand_z_drag_constraint` schon um z_delta # geaendert wurde, folgt die Oeffnung automatisch via # Regen (cutout = new_UK + brueest = old_world_Z + # z_delta). Wir muessen NICHT die brueest-UserString # aktualisieren — sonst gaebe es Doppel-Addition. # Den Oeffnungs-Punkt setzen wir auf Snapshot-Z + # z_delta. So funktioniert es egal ob Rhino die # Oeffnung schon mit-bewegt hat (User-Multi-Select) # oder nicht — das End-Z ist immer das richtige. for op_obj, op_meta in _find_openings_for_wall(doc, m["id"]): try: op_snap = sources_snap.get(op_meta["id"]) if not op_snap: continue op_pos = op_snap.get("pos") if op_pos is None: continue pt_geom = op_obj.Geometry if not hasattr(pt_geom, "Location"): continue pt = pt_geom.Location target_z = op_pos[2] + z_delta doc.Objects.Replace(op_obj.Id, rg.Point(rg.Point3d(pt.X, pt.Y, target_z))) except Exception as ex: print("[ELEMENTE] post-cmd brueest pt-shift:", ex) affected_walls.add(m["id"]) elif t == "oeffnung_point": op_pos = old.get("pos") if op_pos is None: continue pseudo = _PseudoOld(rg.Point3d(op_pos[0], op_pos[1], op_pos[2])) _apply_oeffnung_constraint(obj, m, pseudo) pid = m.get("oeff_parent") if pid: affected_walls.add(pid) except Exception as ex: print("[ELEMENTE] post-cmd source:", ex) finally: sc.sticky[_REGEN_BUSY] = _was_busy sc.sticky["_dossier_skip_sync_regen"] = None # Sync-Regen aller betroffenen Wände — Move ist sauber abgeschlossen, # kein Konflikt mehr moeglich. EIN Regen pro Wand (nicht pro Oeffnung). # Display bleibt suppressed bis ALLE Wände durch sind → kein „Aufbauen". for wid in affected_walls: try: _regenerate_element(doc, wid) except Exception as ex: print("[ELEMENTE] post-cmd regen:", ex) doc.Views.RedrawEnabled = prev_redraw_enabled try: doc.Views.Redraw() except Exception: pass # Flag erst HIER cleren — nach dem Regen-Pfad, der via _regenerate_element # viele Replace-Events erzeugt die wir auch suppressen wollen. sc.sticky[_UT_ACTIVE_KEY] = None # Undo-Record schliessen — alles seit BeginUndoRecord landet in # einem einzelnen Cmd+Z-Schritt. undo_serial = sc.sticky.get("_dossier_undo_serial") if undo_serial: try: doc.EndUndoRecord(undo_serial) except Exception: pass sc.sticky["_dossier_undo_serial"] = None sc.sticky["_dossier_migrated_walls"] = None b = sc.sticky.get("elemente_bridge") if b is not None: try: b._send_state() except Exception: pass # Gestaltung-Panel einmalig nachziehen — Listener waren waehrend # des Commands + des Regens suspendiert. gb = sc.sticky.get("gestaltung_bridge") if gb is not None: try: gb._send_selection() except Exception: pass def _install_listeners(bridge): """Listener-Registrierung mit Re-Reload-Schutz. Problem: `_reset_panels.py` cleart sticky-Flags + lädt das Modul neu. Die alten Listener-Function-Refs bleiben aber in Rhinos Event-Liste (`-=` auf den Bool-Flag entfernt sie nicht). Resultat nach Reload: Listener doppelt registriert → jedes Event feuert zweimal → Migrate laeuft parallel → Race-Condition + Symbole hängen. Fix: Function-Refs in sticky merken. Vor Re-Registrierung die alten Refs explizit deregistrieren — `-=` funktioniert weil wir denselben Pointer in sticky aufbewahrt haben. """ # Wichtig: refs_key darf KEINEN Modul-Namen enthalten (z.B. "elemente"), # sonst cleart `_reset_panels.py` ihn auf None vor dem Re-Install → wir # finden die alten Function-Refs nicht mehr → Listener werden nicht # deregistriert → mehrfache Registrierung nach jedem Reload. refs_key = "_dossier_runtime_event_refs" sc.sticky["elemente_bridge"] = bridge old_refs = sc.sticky.get(refs_key) if isinstance(old_refs, dict): try: if old_refs.get("replace"): Rhino.RhinoDoc.ReplaceRhinoObject -= old_refs["replace"] except Exception: pass try: if old_refs.get("add"): Rhino.RhinoDoc.AddRhinoObject -= old_refs["add"] except Exception: pass try: if old_refs.get("delete"): Rhino.RhinoDoc.DeleteRhinoObject -= old_refs["delete"] except Exception: pass try: if old_refs.get("select"): Rhino.RhinoDoc.SelectObjects -= old_refs["select"] except Exception: pass try: if old_refs.get("deselect"): Rhino.RhinoDoc.DeselectObjects -= old_refs["deselect"] except Exception: pass try: if old_refs.get("idle"): Rhino.RhinoApp.Idle -= old_refs["idle"] except Exception: pass try: if old_refs.get("cmd_begin"): Rhino.Commands.Command.BeginCommand -= old_refs["cmd_begin"] except Exception: pass try: if old_refs.get("cmd_end"): Rhino.Commands.Command.EndCommand -= old_refs["cmd_end"] except Exception: pass Rhino.RhinoDoc.ReplaceRhinoObject += _on_object_replaced Rhino.RhinoDoc.AddRhinoObject += _on_object_added Rhino.RhinoDoc.DeleteRhinoObject += _on_object_deleted Rhino.RhinoDoc.SelectObjects += _on_select_objects Rhino.RhinoDoc.DeselectObjects += _on_deselect_objects Rhino.RhinoApp.Idle += _on_idle_selection Rhino.Commands.Command.BeginCommand += _on_command_begin Rhino.Commands.Command.EndCommand += _on_command_end sc.sticky[refs_key] = { "replace": _on_object_replaced, "add": _on_object_added, "delete": _on_object_deleted, "select": _on_select_objects, "deselect": _on_deselect_objects, "idle": _on_idle_selection, "cmd_begin": _on_command_begin, "cmd_end": _on_command_end, } print("[ELEMENTE] Listener aktiv (Replace + Add + Delete + Select + Idle + Cmd)") def _bridge_factory(): b = ElementeBridge() _install_listeners(b) return b panel_base.register_and_open("elemente", "Elemente", PANEL_GUID_STR, _bridge_factory, icon_spec=("foundation", "#5fa896"))