Files
DOSSIER/rhino/elemente.py
T
karim b425421fdd Swisstopo + OSM Importer + Höhenlinien + Bulk-Op Performance
Swisstopo Iter 3:
- Ortho-Drape: TIN-Mesh aus Terrain-Grid mit per-vertex UVs + PictureFrame-Material
- Project-Cache: TIFs werden neben .3dm gespeichert (SMB-shareable)
- Layer-Restruktur: 80_swisstopo/{Terrain, Luftbild} Sub-Ebenen
- TIFs direkt (kein PNG-Downsampling) für volle Auflösung
- UV-Inset gegen weisse Streifen zwischen Kacheln
- Hoehenlinien (2D, swissALTI3D) auf aktives Geschoss OKFF projiziert
- TIN-Mesh + Schichtenmodell aus Contours (separate Optionen)
- TLM3D entfernt (swisstopo liefert nur GDB/SHP, kein DXF)

OSM Importer (neu):
- rhino/osm.py: Overpass-API-Client
- src/OsmApp.jsx: React-Dialog mit Adresse + Radius + 7 Kategorien
- Strassen/Gebäude/Wasser/Wasserläufe/Parks/Wald/Fusswege (Codes 7101-7107)
- ElementeApp: PillGroup "Importer" mit Swisstopo + OSM Buttons

Sub-Ebenen — rekursiv durch hierarchische Ebenen:
- Visibility-Toggle: slimEbene rekursiv (children bleiben erhalten)
- Settings-Dialog: _find_sublayer_by_code_recursive + _replace_in_tree
- Hatch Auto-Fill: refresh_layer_fills + _fill_signature + _ebene_fill_for_layer
  alle rekursiv durch children
- EbenenSettingsApp: flattenEbenen-Helper

Bulk-Op Performance (Delete/Cut/etc.):
- _USER_BULK_CMDS + _BULK_ACTIVE_KEY Sticky-Flag
- CommandBegin: doc.Views.RedrawEnabled = False + Listener-Bail aktiv
- CommandEnd: RedrawEnabled restore + 1× Redraw + Selection-Refresh
- Bail-outs in dimensionen.on_idle/on_select, elemente._on_idle_selection,
  gestaltung.on_idle_flush/on_delete
- Verhindert das sichtbare "Runterzählen" pro Element bei Bulk-Delete

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 02:42:45 +02:00

10178 lines
472 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#! 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 `<geschoss>::20_WAENDE::<material>` 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 = "{}".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 <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
<z_id>/<code>_*. 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 "<empty>"
actual_ok = verify.Attributes.GetUserString(_KEY_OK_OVER) or "<empty>"
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
# Bulk-Delete (z.B. SelAll + Delete bei 6000 OSM-Curves): pro-Event-
# Arbeit waere reiner Overhead. CommandEnd refresht einmalig.
if sc.sticky.get(_BULK_ACTIVE_KEY): 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
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)
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",
)
_PAIRED_SOURCE_TYPES = (
"wand_axis", "decke_outline", "dach_outline",
"oeffnung_point", "treppe_axis",
"stuetze_point", "traeger_axis",
)
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
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 _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
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
try:
prev = sc.sticky.pop("_dossier_bulk_redraw_prev", True)
doc = Rhino.RhinoDoc.ActiveDoc
if doc is not None:
doc.Views.RedrawEnabled = prev
doc.Views.Redraw()
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"))