From 059cbf8d4dccd6c38842bcc87c28c70a8876a756 Mon Sep 17 00:00:00 2001 From: karim Date: Sat, 23 May 2026 18:28:59 +0200 Subject: [PATCH] Schnitt/Ansicht-Feature + Terrain-Volumen + Geschoss-Add-Dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schnitt-Feature V1+V2: - Neues rhino/schnitte.py mit Pick-Workflow, Activation (Clipping-Planes + Parallel-View), 2D-Plan-Symbol auf 18_Schnittlinien-Sublayer - Doppelklick auf Symbol aktiviert den Schnitt - Schnitt-Settings (cutAtLine/Tiefe/Höhen/Blickrichtung) im GeschossSettingsDialog - View-Snapshot + Restore beim Wechsel Schnitt → Geschoss - Symbol-Cleanup bei Delete via normalem Ebenen-Menü Terrain als Volumen: - swisstopo.volumize_terrain_object: Skirt + Bottom-Cap auf Mesh/Brep damit Clipping-Planes gefuellte Querschnitte erzeugen - UI im SwisstopoApp mit Nachbearbeitung-Section + Tiefen-Eingabe Geschoss-Add mit Dialog: - + im GeschossManager oeffnet 3-Optionen-Picker (Geschoss/Schnitt/Zeichnung) - Geschoss-Dialog mit Anker-Dropdown, Position über/unter, Auto-Name, Höhen-Prefill aus Anker Fix: _send_state fallback — Element gilt als selektiert wenn Source ODER Volume in der Selection ist (robust gegen Layer-Visibility wenn Referenz- linien-Layer im aktuellen Mode versteckt ist) Co-Authored-By: Claude Opus 4.7 --- rhino/elemente.py | 59 +- rhino/rhinopanel.py | 132 +++++ rhino/schnitte.py | 629 ++++++++++++++++++++++ rhino/swisstopo.py | 127 +++++ src/SwisstopoApp.jsx | 32 ++ src/components/GeschossManager.jsx | 291 +++++++++- src/components/GeschossSettingsDialog.jsx | 98 +++- src/lib/rhinoBridge.js | 10 + 8 files changed, 1356 insertions(+), 22 deletions(-) create mode 100644 rhino/schnitte.py diff --git a/rhino/elemente.py b/rhino/elemente.py index 3cf00f4..611d81f 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -657,6 +657,16 @@ def _layer_path_referenz(doc, geschoss_name): return "{}::{}".format(geschoss_name, sub) +def _layer_path_schnittlinie(doc, geschoss_name): + """Sublayer 'Schnittlinien' (Code 18) — fuer die 2D-Plan-Symbole von + Schnitten/Ansichten. Eigene Ebene damit der User sie unabhaengig vom + Restplan ein-/ausblenden kann. Konvention SIA: dunkle Linie + Pfeile.""" + sub = _find_ebene_sublayer_name(doc, ["schnittlinie", "schnittlinien"], + "18", "Schnittlinien", + default_color="#404040", default_lw=0.35) + return "{}::{}".format(geschoss_name, sub) + + def _ensure_oeff_ebenen_in_doc(doc): """Registriert die Oeffnungen-Ebene + alle Piece-Children im `dossier_ebenen`-JSON-Tree (als Children der WAENDE-Ebene). Damit @@ -5551,6 +5561,23 @@ class ElementeBridge(panel_base.BaseBridge): self.send("STATE", {"elements": [], "geschosse": [], "selection": None}) return geschosse = _load_geschosse(doc) + # Vorpass: alle Element-IDs sammeln deren Source ODER irgendein + # Volume gerade selektiert ist. Macht die Selection-Detection + # robust gegen Layer-Visibility-Edge-Cases: wenn die Source-Curve + # auf einem hidden Layer liegt (z.B. Referenzlinien ausgeblendet + # oder im "Nur aktive"-Visibility-Mode), wuerde der Partner-Sync + # in _on_select_objects das Source-Objekt nicht selektieren + # koennen — der User klickt aber das sichtbare Volume an, also + # ist die Element-Auswahl trotzdem eindeutig. + sel_eids = set() + try: + for o in doc.Objects.GetSelectedObjects(False, False): + m = _read_meta(o) + if not m: continue + t = m.get("type") + if t in SOURCE_TYPES or t in VOLUME_TYPES: + sel_eids.add(m["id"]) + except Exception: pass # Alle Source-Objekte (Achsen + Outlines) durchgehen elements = [] seen_ids = set() @@ -5562,7 +5589,7 @@ class ElementeBridge(panel_base.BaseBridge): 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 + selected = (meta["id"] in sel_eids) or (obj.IsSelected(False) > 0) base = { "id": meta["id"], "objectId": str(obj.Id), @@ -7968,6 +7995,13 @@ class ElementeBridge(panel_base.BaseBridge): except Exception as ex: self._push_log("Grid-Merge fehlgeschlagen: {}".format(ex)) + # Terrain-Volumize-Option: aus Opts lesen. depth in + # METERN aus UI → in Doc-Units konvertieren. + as_volume = bool(opts.get("terrainAsVolume")) + try: vol_depth_m = float(opts.get("terrainVolumeDepth") or 10.0) + except Exception: vol_depth_m = 10.0 + vol_depth_doc = max(0.01, vol_depth_m) * m_to_unit + # 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 @@ -7982,6 +8016,11 @@ class ElementeBridge(panel_base.BaseBridge): mesh.Vertices.Count, mesh.Faces.Count)) gid = d.Objects.AddMesh(mesh) obj = d.Objects.Find(gid) + if obj and as_volume: + vol_obj = swisstopo.volumize_terrain_object( + d, obj, vol_depth_doc, + progress=self._push_log) + if vol_obj is not None: obj = vol_obj if obj: mesh_objects.append((obj, merged_grid["bbox"])) except Exception as ex: self._push_log("Mesh-Bau fehlgeschlagen: {}".format(ex)) @@ -8059,6 +8098,11 @@ class ElementeBridge(panel_base.BaseBridge): m_to_unit=m_to_unit, progress=self._push_log) if tin_obj: + if as_volume: + vol_obj = swisstopo.volumize_terrain_object( + d, tin_obj, vol_depth_doc, + progress=self._push_log) + if vol_obj is not None: tin_obj = vol_obj # Tag + auf 80_swisstopo Parent at = tin_obj.Attributes.Duplicate() at.SetUserString("dossier_swisstopo_kind", "contour_tin") @@ -8100,6 +8144,11 @@ class ElementeBridge(panel_base.BaseBridge): patch_obj = swisstopo.generate_patch_from_contours( d, raw_contours, progress=self._push_log) if patch_obj: + if as_volume: + vol_obj = swisstopo.volumize_terrain_object( + d, patch_obj, vol_depth_doc, + progress=self._push_log) + if vol_obj is not None: patch_obj = vol_obj at = patch_obj.Attributes.Duplicate() at.SetUserString("dossier_swisstopo_kind", "contour_patch") d.Objects.ModifyAttributes(patch_obj, at, True) @@ -11496,6 +11545,14 @@ def _install_listeners(bridge): "cmd_end": _on_command_end, } print("[ELEMENTE] Listener aktiv (Replace + Add + Delete + Select + Idle + Cmd)") + # Schnitt-Doppelklick-Handler global registrieren — idempotent via + # sticky-Flag im schnitte-Modul, mehrfache Aufrufe bei Re-Loads + # raeumen den alten Handler vorher weg. + try: + import schnitte + schnitte.install_double_click_handler() + except Exception as ex: + print("[ELEMENTE] schnitt dblclick install:", ex) def _bridge_factory(): diff --git a/rhino/rhinopanel.py b/rhino/rhinopanel.py index f4592e7..a73961c 100644 --- a/rhino/rhinopanel.py +++ b/rhino/rhinopanel.py @@ -433,6 +433,40 @@ class EbenenBridge(panel_base.BaseBridge): self._update_ebene_field(p["code"], "lw", p["lw"]) elif t == "SET_ACTIVE": self._set_active_zeichnungsebene(p) + elif t == "CREATE_SCHNITT": + # Interaktiver Pick: 2 Punkte fuer Schnittlinie + Klick fuer + # Blickrichtung. Defaults aus payload (vom UI vorbelegt). + try: + import schnitte + sid = schnitte.pick_schnitt_interactive(doc, defaults={ + "depthBack": float(p.get("depthBack", 8.0)), + "heightMin": float(p.get("heightMin", -1.0)), + "heightMax": float(p.get("heightMax", 12.0)), + "cutAtLine": bool(p.get("cutAtLine", True)), + "namePrefix": p.get("namePrefix", "S"), + }) + if sid: + _broadcast_state(doc) + # Auto-aktivieren nach Erstellung + try: + zraw = doc.Strings.GetValue("dossier_zeichnungsebenen") + z_list = json.loads(zraw) if zraw else [] + new_z = next((x for x in z_list + if isinstance(x, dict) and x.get("id") == sid), + None) + if new_z is not None: + self._set_active_zeichnungsebene(new_z) + except Exception as ex: + print("[SCHNITT] auto-activate:", ex) + except Exception as ex: + print("[SCHNITT] CREATE_SCHNITT:", ex) + elif t == "DELETE_SCHNITT": + try: + import schnitte + if schnitte.delete_schnitt_entry(doc, p.get("id") or ""): + _broadcast_state(doc) + except Exception as ex: + print("[SCHNITT] DELETE_SCHNITT:", ex) elif t == "SET_ACTIVE_LAYER": code = p.get("code", "") if code: @@ -551,6 +585,18 @@ class EbenenBridge(panel_base.BaseBridge): try: e_list = json.loads(e_raw) if e_raw else [] except Exception: e_list = [] self._apply(z_list, e_list, save_z=True, save_e=False) + # Schnitt-Refresh: wenn der geaenderte Eintrag ein Schnitt ist + # UND aktuell aktiv ist, Clipping-Planes + View neu aufbauen + # damit die neuen Werte (depthBack, heightRange, cutAtLine etc.) + # sofort wirken. + try: + if updated.get("type") == "schnitt": + active_id = doc.Strings.GetValue("dossier_active_id") or "" + if active_id == updated.get("id"): + import schnitte + schnitte.activate_schnitt(doc, updated) + except Exception as ex: + print("[SCHNITT] post-save reactivate:", ex) panel_base.open_satellite_window( "geschoss_settings", params=params, @@ -709,6 +755,22 @@ class EbenenBridge(panel_base.BaseBridge): new_sig = _fill_signature(ebenen) fill_changed = (old_sig != new_sig) + # Schnitt-Cleanup-Detection: alt vs neu Schnitt-Ids vergleichen. + # Wenn ein Schnitt entfernt wurde (via normalem Delete-Menue), die + # 2D-Plan-Symbole + ggf. Clipping-Planes aufraeumen. Sonst bleiben + # Waisen im Doc. + schnitte_removed = set() + if save_z: + try: + import schnitte as _schn + old_z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") + old_z = json.loads(old_z_raw) if old_z_raw else [] + old_ids = _schn.schnitt_ids_in_list(old_z) + new_ids = _schn.schnitt_ids_in_list(zeichnungsebenen) + schnitte_removed = old_ids - new_ids + except Exception as ex: + print("[SCHNITT] cleanup detection:", ex) + _set_processing(True) try: print("[EBENEN] _apply: build_layers ...") @@ -724,6 +786,21 @@ class EbenenBridge(panel_base.BaseBridge): doc.Strings.SetString("dossier_zeichnungsebenen", z_json) if save_e: doc.Strings.SetString("dossier_ebenen", e_json) + # Cleanup geloeschter Schnitte: 2D-Symbole + ggf. Clipping-Planes. + # Muss NACH dem SetString passieren damit dossier_active_id-Check + # in cleanup_schnitt_artifacts den korrekten Stand sieht. + if schnitte_removed: + try: + import schnitte as _schn + active_id = doc.Strings.GetValue("dossier_active_id") or "" + n_total = 0 + for sid in schnitte_removed: + n_total += _schn.cleanup_schnitt_artifacts( + doc, sid, active_id=active_id) + print("[SCHNITT] {} Schnitt(e) geloescht, {} Symbol-Curves entfernt".format( + len(schnitte_removed), n_total)) + except Exception as ex: + print("[SCHNITT] artifact cleanup:", ex) # Smart-Elemente (Waende) regenerieren — Geschoss-Hoehen/OKFF # haben sich evtl. geaendert, gebundene Waende muessen neu # extrudiert werden. Best-effort, faengt jeden Fehler ab. @@ -943,6 +1020,61 @@ class EbenenBridge(panel_base.BaseBridge): # Vorigen Stand merken um redundante teure Operationen zu sparen prev_active_id = doc.Strings.GetValue("dossier_active_id") or "" doc.Strings.SetString("dossier_active_id", z_id) + # Schnitt-Typ: Spezial-Pfad. Vertikale Clipping-Planes + Parallel- + # View statt der ueblichen horizontalen Geschoss-Clipping-Logik. + # Den vollen Record aus doc.Strings holen (z-Payload aus React ist + # minimal, hat type/linePts/etc nicht zwingend dabei). + z_full = z + try: + zraw = doc.Strings.GetValue("dossier_zeichnungsebenen") + if zraw: + for cand in json.loads(zraw): + if isinstance(cand, dict) and cand.get("id") == z_id: + z_full = cand; break + except Exception: pass + # Vorheriger Eintrag ein Schnitt? Brauchen wir fuer View-Snapshot- + # Logik: Geschoss → Schnitt snapshot, Schnitt → Geschoss restore. + prev_was_schnitt = False + try: + import schnitte as _schn_check + prev_was_schnitt = _schn_check.is_schnitt_id(doc, prev_active_id) + except Exception: pass + + if isinstance(z_full, dict) and z_full.get("type") == "schnitt": + try: + import schnitte + # Pre-Schnitt-View snapshotten — aber NUR beim Wechsel von + # einem Nicht-Schnitt. Schnitt→Schnitt-Wechsel soll den + # urspruenglichen Plan-View nicht ueberschreiben. + if not prev_was_schnitt: + schnitte.save_pre_schnitt_view(doc) + # Horizontale Geschoss-Clipping aufraeumen falls aktiv — + # die existiert parallel zur Schnitt-Clipping und wuerde + # die Sicht doppelt schneiden. + try: + existing_geschoss = layer_builder._find_clipping_plane(doc) + if existing_geschoss is not None: + doc.Objects.Delete(existing_geschoss.Id, True) + except Exception: pass + schnitte.activate_schnitt(doc, z_full) + _broadcast_state(doc) + # Elemente-Panel auch informieren + try: + eb = sc.sticky.get("elemente_bridge") + if eb is not None: eb._notify_active_geschoss() + except Exception: pass + except Exception as ex: + print("[SCHNITT] activate fehler:", ex) + return + # Geschoss-Pfad (default): falls vorher ein Schnitt aktiv war, + # dessen Clipping-Planes aufraeumen + Pre-Schnitt-View restoren. + try: + import schnitte + schnitte.clear_schnitt_clipping(doc) + if prev_was_schnitt: + schnitte.restore_pre_schnitt_view(doc) + except Exception as ex: + print("[SCHNITT] cleanup beim Wechsel auf Geschoss:", ex) # Aktiven Sublayer auf die GLEICHE Ebene unter dem neuen Geschoss # umschalten — wenn User auf "20 Wände" steht und das Geschoss # wechselt, soll Rhino's aktiver Layer "1OG::20_Wände" werden statt diff --git a/rhino/schnitte.py b/rhino/schnitte.py new file mode 100644 index 0000000..ce3fada --- /dev/null +++ b/rhino/schnitte.py @@ -0,0 +1,629 @@ +#! python3 +# -*- coding: utf-8 -*- +""" +schnitte.py +Schnitte + Ansichten als Zeichnungsebenen-Typ. + +Datenmodell: in dossier_zeichnungsebenen-JSON ergaenzt jeder Schnitt einen +Eintrag mit type="schnitt" + linePts/dirSign/depthBack/cutAtLine/heightMin/ +heightMax. Geschoss-Eintraege haben type="geschoss" (oder fehlend = legacy). + +Aktivierung (vom Ebenen-Panel via SET_ACTIVE getriggert): + - 1 Clipping-Plane (Ansicht) oder 2 Clipping-Planes (Schnitt) erzeugen + - View auf Parallel-Projektion umstellen, Kamera senkrecht zur Linie + - Zoom auf BBox (linePts + heightMin/Max + depthBack) + +2D-Plan-Symbol: Linie + Pfeile an den Enden + Beschriftung — bleibt im +Grundriss sichtbar wenn man wieder im Geschoss ist. +""" +import math +import json +import uuid +import Rhino +import Rhino.Geometry as rg +import System +import scriptcontext as sc + +# UserStrings auf Clipping-Plane- und Symbol-Objekten — fuer Wiederfinden + +# Cleanup beim Wechsel der Zeichnungsebene. +_KEY_SCHNITT_CLIP = "dossier_schnitt_clip" # "1" auf Clipping-Plane-Objekten +_KEY_SCHNITT_ROLE = "dossier_schnitt_role" # "cut" | "back" +_KEY_SCHNITT_SYMBOL = "dossier_schnitt_symbol" # "1" auf 2D-Symbol-Curves +_KEY_SCHNITT_ID = "dossier_schnitt_id" # zugehoerige Schnitt-Id + + +def _line_vectors(p1, p2, dir_sign): + """Liefert (line_dir, view_dir, mid). view_dir = perp zur Linie in XY, + Richtung definiert durch dir_sign (+1 = CCW-perp, -1 = CW-perp). + Das ist die Blickrichtung — wohin der Pfeil im Plan zeigt.""" + line = rg.Vector3d(p2.X - p1.X, p2.Y - p1.Y, 0) + if line.Length < 1e-6: + return None, None, None + line.Unitize() + perp = rg.Vector3d(-line.Y, line.X, 0) + if dir_sign < 0: + perp = -perp + mid = rg.Point3d((p1.X + p2.X) * 0.5, (p1.Y + p2.Y) * 0.5, + (p1.Z + p2.Z) * 0.5) + return line, perp, mid + + +def make_schnitt_symbol(p1, p2, dir_sign, name="A"): + """Erzeugt die 2D-Plan-Markierung: Hauptlinie + 2 Endpfeile in + view_dir-Richtung. Liefert Liste von Curves auf p1.Z.""" + line_dir, view_dir, _ = _line_vectors(p1, p2, dir_sign) + if line_dir is None: return [] + out = [] + # Hauptlinie + out.append(rg.LineCurve(p1, p2)) + # Pfeile an beiden Enden — Stiel (perp zur Linie, in view_dir) + 2 Spitzen + arrow_stem = 0.5 # Meter (Doc-Units) — kompromiss zwischen sichtbar bei 1:200 und nicht zu gross bei 1:50 + arrow_head = 0.15 + for ep in (p1, p2): + tip = rg.Point3d(ep.X + view_dir.X * arrow_stem, + ep.Y + view_dir.Y * arrow_stem, ep.Z) + out.append(rg.LineCurve(ep, tip)) + # zwei Spitzen am Pfeil-Tip, gegen view_dir zurueck, seitlich gespreizt + h1 = rg.Point3d( + tip.X - view_dir.X * arrow_head + line_dir.X * arrow_head, + tip.Y - view_dir.Y * arrow_head + line_dir.Y * arrow_head, tip.Z) + h2 = rg.Point3d( + tip.X - view_dir.X * arrow_head - line_dir.X * arrow_head, + tip.Y - view_dir.Y * arrow_head - line_dir.Y * arrow_head, tip.Z) + out.append(rg.LineCurve(tip, h1)) + out.append(rg.LineCurve(tip, h2)) + return out + + +def _collect_viewport_ids(doc): + """Alle Modell-Viewports — die Clipping-Plane soll in jedem schneiden, + sonst sieht der User beim View-Wechsel das geclippte Modell nur in einem.""" + ids = [] + seen = set() + try: + for view in doc.Views: + try: + vid = view.ActiveViewport.Id + k = str(vid) + if k not in seen and vid != System.Guid.Empty: + seen.add(k); ids.append(vid) + except Exception: pass + except Exception: pass + return ids + + +def find_schnitt_clip_objects(doc): + """Findet alle Clipping-Plane-Objekte die zu einem aktiven Schnitt + gehoeren (UserString _KEY_SCHNITT_CLIP gesetzt).""" + out = [] + try: + for obj in doc.Objects: + if obj is None or obj.IsDeleted: continue + try: + if obj.Attributes.GetUserString(_KEY_SCHNITT_CLIP) == "1": + out.append(obj) + except Exception: pass + except Exception: pass + return out + + +def clear_schnitt_clipping(doc): + """Loescht alle Schnitt-Clipping-Planes. Wird beim Wechsel weg vom + Schnitt-Modus aufgerufen (auf Geschoss oder anderen Schnitt).""" + n = 0 + for obj in find_schnitt_clip_objects(doc): + try: + doc.Objects.Delete(obj.Id, True) + n += 1 + except Exception as ex: + print("[SCHNITT] clear: {}".format(ex)) + if n: + print("[SCHNITT] {} Clipping-Plane(s) entfernt".format(n)) + + +def _add_clipping_plane(doc, plane, du, dv, vp_ids, role): + """Wrapper: legt eine Clipping-Plane mit dem Schnitt-UserString an.""" + try: + gid = doc.Objects.AddClippingPlane(plane, du, dv, vp_ids) + if gid is None or gid == System.Guid.Empty: + print("[SCHNITT] AddClippingPlane lieferte Empty Guid") + return None + obj = doc.Objects.FindId(gid) + if obj is None: return None + attrs = obj.Attributes.Duplicate() + attrs.SetUserString(_KEY_SCHNITT_CLIP, "1") + attrs.SetUserString(_KEY_SCHNITT_ROLE, role) + # Locked-Mode: User kann die Plane nicht versehentlich greifen. + # Mac Rhino rendert Locked-Planes teilweise nur als blasse Edge — + # das ist ok, wir wollen sie eh unauffaellig. + attrs.Mode = Rhino.DocObjects.ObjectMode.Locked + doc.Objects.ModifyAttributes(obj, attrs, True) + return obj + except Exception as ex: + print("[SCHNITT] AddClippingPlane Fehler ({}):".format(role), ex) + return None + + +def activate_schnitt(doc, z): + """Hauptfunktion: setzt Clipping-Planes + View fuer einen Schnitt- + oder Ansicht-Eintrag. + + Plane-Logik: + - view_dir = senkrecht zur Linie in XY, Richtung = dir_sign (Pfeil + zeigt in view_dir = Blickrichtung weg vom Betrachter zum Subjekt) + - Cut-Plane (nur bei cutAtLine=True, also Schnitt): liegt auf + Schnittlinie, Normal = +view_dir → visible Seite = +view_dir + (Subjekt), -view_dir (Betrachter) wird geclippt + - Back-Plane: parallel, depthBack in +view_dir entfernt, Normal = + -view_dir → visible Seite = -view_dir (Subjekt-Zone), alles + dahinter weg + + View-Logik: Parallel-Projektion, Kamera bei mid - view_dir * dist, + target bei mid. Zoom auf bbox. + """ + if z is None: return + pts = z.get("linePts") or [] + if len(pts) < 2: + print("[SCHNITT] '{}' hat keine linePts".format(z.get("name"))) + return + try: + p1 = rg.Point3d(float(pts[0][0]), float(pts[0][1]), 0) + p2 = rg.Point3d(float(pts[1][0]), float(pts[1][1]), 0) + except Exception as ex: + print("[SCHNITT] linePts ungueltig:", ex) + return + dir_sign = 1 if int(z.get("dirSign", 1) or 1) >= 0 else -1 + depth_back = max(0.5, float(z.get("depthBack", 8.0) or 8.0)) + cut_at_line = bool(z.get("cutAtLine", True)) + h_min = float(z.get("heightMin", -1.0) or -1.0) + h_max = float(z.get("heightMax", 12.0) or 12.0) + if h_max <= h_min: + h_max = h_min + 3.0 + + line_dir, view_dir, mid = _line_vectors(p1, p2, dir_sign) + if line_dir is None: + print("[SCHNITT] '{}' hat zu kurze Linie".format(z.get("name"))) + return + line_len = p1.DistanceTo(p2) + + # Clipping-Planes vorher aufraeumen (Re-Aktivierung mit neuen Werten) + clear_schnitt_clipping(doc) + + # Plane-Dimensionen — gross genug fuer typische Architekturmodelle + margin = 5.0 + du = line_len + margin * 2 + dv = (h_max - h_min) + margin * 2 + plane_z = (h_min + h_max) * 0.5 + + vp_ids = _collect_viewport_ids(doc) + if not vp_ids: + print("[SCHNITT] keine Viewports — Plane wuerde nichts schneiden") + return + + n_planes = 0 + + # Cut-Plane (nur bei echtem Schnitt) — sitzt AUF der Linie, schneidet + # alles vor der Linie (Betrachter-Seite) weg + if cut_at_line: + # Plane.Origin = mid auf Schnittlinie + Hoehen-Mitte + # X-Axis = line_dir (entlang Linie) + # Y-Axis = +Z (vertikal) + # → Normal = X × Y = line_dir × Z + cut_origin = rg.Point3d(mid.X, mid.Y, plane_z) + cut_plane = rg.Plane(cut_origin, line_dir, rg.Vector3d(0, 0, 1)) + # Pruefen ob Normal in +view_dir zeigt (sonst flippen via -X-Axis) + actual_n = rg.Vector3d.CrossProduct(line_dir, rg.Vector3d(0, 0, 1)) + if actual_n * view_dir < 0: + cut_plane = rg.Plane(cut_origin, -line_dir, rg.Vector3d(0, 0, 1)) + obj = _add_clipping_plane(doc, cut_plane, du, dv, vp_ids, "cut") + if obj is not None: n_planes += 1 + + # Back-Plane (bei BEIDEN: Schnitt UND Ansicht) — sitzt depthBack hinter + # der Schnittlinie in view_dir-Richtung, Normal zeigt zurueck (-view_dir) + back_origin = rg.Point3d( + mid.X + view_dir.X * depth_back, + mid.Y + view_dir.Y * depth_back, + plane_z) + back_plane = rg.Plane(back_origin, line_dir, rg.Vector3d(0, 0, 1)) + actual_nb = rg.Vector3d.CrossProduct(line_dir, rg.Vector3d(0, 0, 1)) + # Wir wollen Normal = -view_dir, also flippen wenn actual zu +view_dir zeigt + if actual_nb * view_dir > 0: + back_plane = rg.Plane(back_origin, -line_dir, rg.Vector3d(0, 0, 1)) + obj = _add_clipping_plane(doc, back_plane, du, dv, vp_ids, "back") + if obj is not None: n_planes += 1 + + # View setzen: Parallel-Projektion, Kamera senkrecht zur Linie + try: + view = doc.Views.ActiveView + if view is None: + for v in doc.Views: view = v; break + if view is not None: + vp = view.ActiveViewport + cam_dist = max(50.0, depth_back * 3 + line_len) + cam_pos = rg.Point3d( + mid.X - view_dir.X * cam_dist, + mid.Y - view_dir.Y * cam_dist, + plane_z) + target = rg.Point3d( + mid.X + view_dir.X * (depth_back * 0.5), + mid.Y + view_dir.Y * (depth_back * 0.5), + plane_z) + vp.ChangeToParallelProjection(True) + vp.SetCameraLocations(target, cam_pos) + vp.CameraUp = rg.Vector3d(0, 0, 1) + # Zoom auf Schnitt-BoundingBox + etwas Rand + bb = rg.BoundingBox( + rg.Point3d(min(p1.X, p2.X) - margin, min(p1.Y, p2.Y) - margin, + h_min - margin), + rg.Point3d(max(p1.X, p2.X) + margin + view_dir.X * depth_back, + max(p1.Y, p2.Y) + margin + view_dir.Y * depth_back, + h_max + margin)) + vp.ZoomBoundingBox(bb) + view.Redraw() + except Exception as ex: + print("[SCHNITT] view setup:", ex) + + kind = "Schnitt" if cut_at_line else "Ansicht" + print("[SCHNITT] {} '{}' aktiviert: {} Plane(s), depthBack={:.1f}m".format( + kind, z.get("name"), n_planes, depth_back)) + + +def find_symbol_objects_for(doc, schnitt_id): + """Findet alle 2D-Symbol-Curves zu einer Schnitt-ID.""" + out = [] + try: + for obj in doc.Objects: + if obj is None or obj.IsDeleted: continue + try: + if obj.Attributes.GetUserString(_KEY_SCHNITT_SYMBOL) != "1": continue + if obj.Attributes.GetUserString(_KEY_SCHNITT_ID) != schnitt_id: continue + out.append(obj) + except Exception: pass + except Exception: pass + return out + + +_STICKY_PRE_VIEW = "_dossier_pre_schnitt_view" + + +def save_pre_schnitt_view(doc): + """Snapshot der aktiven Viewport-Stellung in sc.sticky. Wird genau + EINMAL pro Schnitt-Sitzung aufgerufen: beim Wechsel von einem + Geschoss auf einen Schnitt. Verhindert dass Schnitt→Schnitt-Wechsel + den Original-Plan-View ueberschreibt. + + Pro Doc separate Key (RuntimeSerialNumber). Speichert + Projection-Mode, Kamera-Pos/Target, CameraUp — alles was nach dem + Schnitt restored werden muss.""" + if doc is None: return + try: + view = doc.Views.ActiveView + if view is None: return + vp = view.ActiveViewport + snap = { + "is_parallel": bool(vp.IsParallelProjection), + "cam_pos": (vp.CameraLocation.X, vp.CameraLocation.Y, vp.CameraLocation.Z), + "target": (vp.CameraTarget.X, vp.CameraTarget.Y, vp.CameraTarget.Z), + "cam_up": (vp.CameraUp.X, vp.CameraUp.Y, vp.CameraUp.Z), + } + try: key = _STICKY_PRE_VIEW + "_" + str(doc.RuntimeSerialNumber) + except Exception: key = _STICKY_PRE_VIEW + "_default" + sc.sticky[key] = snap + except Exception as ex: + print("[SCHNITT] save view:", ex) + + +def restore_pre_schnitt_view(doc): + """Restored den letzten Pre-Schnitt-View (Plan-Top etc.) und entfernt + den Snapshot aus sticky. No-op wenn kein Snapshot da. Wird beim + Wechsel von einem Schnitt zurueck auf ein Geschoss aufgerufen.""" + if doc is None: return False + try: key = _STICKY_PRE_VIEW + "_" + str(doc.RuntimeSerialNumber) + except Exception: key = _STICKY_PRE_VIEW + "_default" + snap = sc.sticky.get(key) + if not snap: return False + try: + view = doc.Views.ActiveView + if view is None: return False + vp = view.ActiveViewport + if snap.get("is_parallel"): + vp.ChangeToParallelProjection(True) + else: + vp.ChangeToPerspectiveProjection(True, 50.0) + pos = rg.Point3d(*snap["cam_pos"]) + tgt = rg.Point3d(*snap["target"]) + up = rg.Vector3d(*snap["cam_up"]) + vp.SetCameraLocations(tgt, pos) + vp.CameraUp = up + view.Redraw() + try: del sc.sticky[key] + except Exception: pass + print("[SCHNITT] Pre-Schnitt-View restored") + return True + except Exception as ex: + print("[SCHNITT] restore view:", ex) + return False + + +def is_schnitt_id(doc, z_id): + """True wenn die gegebene Zeichnungsebenen-Id ein Schnitt-Eintrag ist.""" + if not z_id or doc is None: return False + try: + raw = doc.Strings.GetValue("dossier_zeichnungsebenen") + if not raw: return False + for z in json.loads(raw): + if isinstance(z, dict) and z.get("id") == z_id: + return z.get("type") == "schnitt" + except Exception: pass + return False + + +def cleanup_schnitt_artifacts(doc, schnitt_id, active_id=None): + """Loescht alle Doc-Artefakte eines Schnitts: 2D-Plan-Symbole UND + Clipping-Planes (falls der Schnitt aktuell aktiv ist). Beruehrt + `dossier_zeichnungsebenen` NICHT — das macht der Caller (oder ist + schon passiert im _apply-Pfad). Idempotent: doppeltes Cleanup ist + harmlos.""" + if not schnitt_id: return 0 + n = 0 + for obj in find_symbol_objects_for(doc, schnitt_id): + try: + if doc.Objects.Delete(obj.Id, True): n += 1 + except Exception: pass + # Wenn der geloeschte Schnitt aktiv war: Clipping-Planes auch weg + if active_id and active_id == schnitt_id: + try: clear_schnitt_clipping(doc) + except Exception: pass + return n + + +def delete_schnitt_entry(doc, schnitt_id): + """Loescht einen Schnitt-Eintrag komplett: aus dossier_zeichnungsebenen + + alle 2D-Symbol-Curves + Clipping-Planes (falls aktiv).""" + active_id = "" + try: active_id = doc.Strings.GetValue("dossier_active_id") or "" + except Exception: pass + try: + raw = doc.Strings.GetValue("dossier_zeichnungsebenen") or "[]" + lst = json.loads(raw) + if not isinstance(lst, list): return False + new_lst = [e for e in lst + if not (isinstance(e, dict) and e.get("id") == schnitt_id + and e.get("type") == "schnitt")] + if len(new_lst) == len(lst): return False + doc.Strings.SetString("dossier_zeichnungsebenen", + json.dumps(new_lst, ensure_ascii=False)) + except Exception as ex: + print("[SCHNITT] delete entry:", ex) + return False + cleanup_schnitt_artifacts(doc, schnitt_id, active_id=active_id) + return True + + +def schnitt_ids_in_list(z_list): + """Liefert die set der Schnitt-Ids in einer dossier_zeichnungsebenen-Liste. + Helper fuer Cleanup-Detection im _apply-Pfad (alt vs neu vergleichen).""" + out = set() + if not isinstance(z_list, list): return out + for z in z_list: + if isinstance(z, dict) and z.get("type") == "schnitt" and z.get("id"): + out.add(z["id"]) + return out + + +def create_schnitt_entry(doc, name, p1, p2, dir_sign=1, depth_back=8.0, + cut_at_line=True, height_min=-1.0, height_max=12.0, + symbol_layer_idx=-1): + """Erzeugt einen neuen Schnitt-Eintrag: appended an + dossier_zeichnungsebenen + erzeugt 2D-Plan-Symbol. + + Liefert die Schnitt-Id (str). Caller sollte broadcast_state + ggf. + SET_ACTIVE auf die neue Id triggern damit das Panel den Eintrag + sieht und ihn auto-aktiviert.""" + schnitt_id = "schnitt_" + uuid.uuid4().hex[:10] + entry = { + "id": schnitt_id, + "name": name or "Schnitt", + "type": "schnitt", + "linePts": [[float(p1.X), float(p1.Y)], [float(p2.X), float(p2.Y)]], + "dirSign": int(1 if dir_sign >= 0 else -1), + "depthBack": float(depth_back), + "cutAtLine": bool(cut_at_line), + "heightMin": float(height_min), + "heightMax": float(height_max), + "visible": True, + "locked": False, + "isGeschoss": False, + } + raw = doc.Strings.GetValue("dossier_zeichnungsebenen") or "[]" + try: lst = json.loads(raw) + except Exception: lst = [] + if not isinstance(lst, list): lst = [] + lst.append(entry) + try: + doc.Strings.SetString("dossier_zeichnungsebenen", + json.dumps(lst, ensure_ascii=False)) + except Exception as ex: + print("[SCHNITT] persist entry:", ex) + return None + + # 2D-Symbol auf Plan + curves = make_schnitt_symbol(p1, p2, dir_sign, name) + for crv in curves: + try: + attrs = Rhino.DocObjects.ObjectAttributes() + if symbol_layer_idx >= 0: + attrs.LayerIndex = symbol_layer_idx + attrs.SetUserString(_KEY_SCHNITT_SYMBOL, "1") + attrs.SetUserString(_KEY_SCHNITT_ID, schnitt_id) + doc.Objects.AddCurve(crv, attrs) + except Exception as ex: + print("[SCHNITT] add symbol curve:", ex) + return schnitt_id + + +def activate_schnitt_by_id(doc, schnitt_id): + """Aktiviert einen Schnitt anhand seiner Id. Routet ueber die + EbenenBridge damit der ganze _set_active_zeichnungsebene-Pfad + durchlaeuft (View-Snapshot, Clipping-Setup, broadcast). Liefert True + bei Erfolg. + + Wird vom Doppelklick-Handler genutzt damit der User vom Plan-Symbol + direkt in die Section springen kann ohne den Umweg ueber den + Geschoss-Switcher.""" + if not schnitt_id or doc is None: return False + try: + raw = doc.Strings.GetValue("dossier_zeichnungsebenen") + if not raw: return False + z_list = json.loads(raw) + z = next((x for x in z_list + if isinstance(x, dict) and x.get("id") == schnitt_id + and x.get("type") == "schnitt"), None) + if z is None: return False + eb = sc.sticky.get("ebenen_bridge_ref") \ + or sc.sticky.get("zeichnungsebenen_bridge_ref") + if eb is None: + # Fallback: direkt aktivieren ohne broadcast + print("[SCHNITT] keine EbenenBridge — direkt aktivieren") + activate_schnitt(doc, z) + return True + eb._set_active_zeichnungsebene(z) + return True + except Exception as ex: + print("[SCHNITT] activate_by_id:", ex) + return False + + +class _SchnittDoubleClickHandler(Rhino.UI.MouseCallback): + """MouseCallback: erkennt Doppelklick auf ein 2D-Schnittsymbol und + aktiviert den zugehoerigen Schnitt. Erkennung via UserString + dossier_schnitt_symbol=1 + dossier_schnitt_id. + + Wichtig: die Klicks selektieren das Curve vorab (Rhino-Default), wir + pruefen also einfach die aktuelle Selection. Bei Treffer wird der + Schnitt aktiviert + e.Cancel=True gesetzt damit Rhinos default + Edit-Modus nicht zusaetzlich aufpoppt.""" + def OnMouseDoubleClick(self, e): + try: + view = e.View + if view is None: return + doc = view.Document + if doc is None: return + sel = list(doc.Objects.GetSelectedObjects(False, False)) + if not sel: return + for obj in sel: + try: + sym = obj.Attributes.GetUserString("dossier_schnitt_symbol") + sid = obj.Attributes.GetUserString("dossier_schnitt_id") + if sym == "1" and sid: + if activate_schnitt_by_id(doc, sid): + try: e.Cancel = True + except Exception: pass + return + except Exception: pass + except Exception as ex: + print("[SCHNITT] OnMouseDoubleClick:", ex) + + +def install_double_click_handler(): + """Registriert den Schnittsymbol-Doppelklick-Handler global. Idempotent + via sticky-Flag — bei Modul-Reload wird der alte Handler erst + disabled, dann neu erstellt. Sonst wuerde nach jedem _reset_panels + eine zweite Instanz mit-feuern.""" + try: + old = sc.sticky.get("_dossier_schnitt_dblclick_handler") + if old is not None: + try: old.Enabled = False + except Exception: pass + h = _SchnittDoubleClickHandler() + h.Enabled = True + sc.sticky["_dossier_schnitt_dblclick_handler"] = h + print("[SCHNITT] Doppelklick-Handler aktiv") + except Exception as ex: + print("[SCHNITT] install_double_click_handler:", ex) + + +def pick_schnitt_interactive(doc, defaults=None): + """Interaktiver Pick: 2 Punkte + Dir-Pfeil + Defaults aus settings. + Liefert die neue Schnitt-Id oder None bei Abbruch. + + defaults: {depthBack, heightMin, heightMax, cutAtLine, namePrefix}""" + defaults = defaults or {} + name_prefix = defaults.get("namePrefix", "S") + depth_back = float(defaults.get("depthBack", 8.0)) + h_min = float(defaults.get("heightMin", -1.0)) + h_max = float(defaults.get("heightMax", 12.0)) + cut_at_line = bool(defaults.get("cutAtLine", True)) + + # Pick Punkt 1 + gp = Rhino.Input.Custom.GetPoint() + gp.SetCommandPrompt("Schnittlinie: Startpunkt") + gp.Get() + if gp.CommandResult() != Rhino.Commands.Result.Success: return None + p1 = gp.Point() + p1 = rg.Point3d(p1.X, p1.Y, 0) + + # Pick Punkt 2 mit Vorschau-Linie + gp2 = Rhino.Input.Custom.GetPoint() + gp2.SetCommandPrompt("Schnittlinie: Endpunkt") + gp2.SetBasePoint(p1, True) + gp2.DrawLineFromPoint(p1, True) + gp2.Get() + if gp2.CommandResult() != Rhino.Commands.Result.Success: return None + p2 = gp2.Point() + p2 = rg.Point3d(p2.X, p2.Y, 0) + + if p1.DistanceTo(p2) < 0.01: + print("[SCHNITT] Linie zu kurz") + return None + + # Pick Blickrichtung (welche Seite ist "hinten") + gp3 = Rhino.Input.Custom.GetPoint() + gp3.SetCommandPrompt("Blickrichtung: Punkt auf der Subjekt-Seite klicken") + mid = rg.Point3d((p1.X + p2.X) * 0.5, (p1.Y + p2.Y) * 0.5, 0) + gp3.SetBasePoint(mid, True) + gp3.DrawLineFromPoint(mid, True) + gp3.Get() + if gp3.CommandResult() != Rhino.Commands.Result.Success: return None + p3 = gp3.Point() + + # dir_sign aus Klick-Position: ist p3 auf der +perp oder -perp Seite? + line = rg.Vector3d(p2.X - p1.X, p2.Y - p1.Y, 0) + perp_default = rg.Vector3d(-line.Y, line.X, 0) + perp_default.Unitize() + to_p3 = rg.Vector3d(p3.X - mid.X, p3.Y - mid.Y, 0) + dir_sign = 1 if (perp_default * to_p3) >= 0 else -1 + + # Auto-Name: zaehle existierende Schnitte + raw = doc.Strings.GetValue("dossier_zeichnungsebenen") or "[]" + try: + existing = [e for e in json.loads(raw) + if isinstance(e, dict) and e.get("type") == "schnitt"] + n = len(existing) + 1 + except Exception: n = 1 + auto_name = "{}-{}".format(name_prefix, n) + + # Symbol-Layer ermitteln: '18_Schnittlinien' unter dem aktiven Geschoss. + # Fallback auf Default-Layer wenn nichts resolvbar. + symbol_layer_idx = -1 + try: + import elemente as _el + active_id = doc.Strings.GetValue("dossier_active_id") or "" + geschoss = _el._geschoss_by_id(doc, active_id) if active_id else None + if geschoss is None: + # erstes Geschoss in der Liste als Fallback + for g in _el._load_geschosse(doc): + if isinstance(g, dict) and g.get("isGeschoss"): + geschoss = g; break + if geschoss and geschoss.get("name"): + sym_path = _el._layer_path_schnittlinie(doc, geschoss["name"]) + symbol_layer_idx = _el._ensure_layer(doc, sym_path) + except Exception as ex: + print("[SCHNITT] symbol-layer resolve:", ex) + + sid = create_schnitt_entry(doc, auto_name, p1, p2, + dir_sign=dir_sign, depth_back=depth_back, + cut_at_line=cut_at_line, + height_min=h_min, height_max=h_max, + symbol_layer_idx=symbol_layer_idx) + return sid diff --git a/rhino/swisstopo.py b/rhino/swisstopo.py index 8b9e09f..a7f67dc 100644 --- a/rhino/swisstopo.py +++ b/rhino/swisstopo.py @@ -909,6 +909,133 @@ def generate_patch_from_contours(doc, contour_curves, progress=None): return None +def volumize_terrain_object(doc, top_obj, depth_doc, progress=None): + """Wandelt ein offenes Terrain (Mesh ODER Brep) in ein geschlossenes + Mesh-Volumen um: Skirt um den Boundary + planarer Boden bei + (min_z - depth_doc). Resultat hat eine Section beim Schneiden mit + einer Clipping-Plane. + + Strategie: + 1. Mesh-Source ermitteln (Brep → Mesh.CreateFromBrep, Mesh → direkt) + 2. GetNakedEdges() liefert die Boundary-Loop(s) als Polylines + 3. Pro Loop: Skirt-Quads zwischen Top-Edge und Bottom-Vertices + 4. Pro Loop: Bottom-Cap via Mesh.CreateFromClosedPolyline (Rhino + triangliert auch nicht-konvexe Boundaries sauber) + 5. CombineIdentical schweisst Top + Skirt-Top zusammen + + Ersetzt das Original im Doc (Delete+Add mit gleichen Attributes). + Liefert das neue RhinoObject oder None bei Fehler.""" + import System + if top_obj is None or top_obj.IsDeleted: return None + geom = top_obj.Geometry + if geom is None: return None + # 1) Top-Mesh ermitteln (Brep meshen wenn noetig) + top_mesh = None + if isinstance(geom, rg.Mesh): + top_mesh = geom.Duplicate() + elif isinstance(geom, rg.Brep): + try: + mp = rg.MeshingParameters.Default + meshes = rg.Mesh.CreateFromBrep(geom, mp) + if meshes and len(meshes) > 0: + joined = rg.Mesh() + for m in meshes: joined.Append(m) + top_mesh = joined + except Exception as ex: + if progress: progress("Volumize: Brep-Meshing-Fehler: {}".format(ex)) + return None + elif isinstance(geom, rg.Extrusion): + try: + brep = geom.ToBrep(False) + mp = rg.MeshingParameters.Default + meshes = rg.Mesh.CreateFromBrep(brep, mp) + if meshes and len(meshes) > 0: + joined = rg.Mesh() + for m in meshes: joined.Append(m) + top_mesh = joined + except Exception: pass + if top_mesh is None or top_mesh.Vertices.Count < 3: + if progress: progress("Volumize: kein Mesh-Top") + return None + # 2) Boundary-Loops + naked = top_mesh.GetNakedEdges() + if naked is None or len(naked) == 0: + if progress: progress("Volumize: keine Boundary — Terrain schon geschlossen") + return None + # 3) Bottom-Z = min_z des Top - depth + bb = top_mesh.GetBoundingBox(True) + if not bb.IsValid: + if progress: progress("Volumize: ungueltige BoundingBox") + return None + bottom_z = bb.Min.Z - float(depth_doc) + if progress: + progress("Volumize: {} Boundary-Loop(s), Boden bei Z={:.3f}".format( + len(naked), bottom_z)) + # 4) Volumen-Mesh aufbauen: top + Skirt + Bottom-Cap + vol = top_mesh.Duplicate() + for loop in naked: + try: + if loop is None or loop.Count < 3: continue + # Polyline-Punkte (offene Form — closing point ggf. entfernen) + pts = [rg.Point3d(p) for p in loop] + if len(pts) > 1 and pts[0].DistanceTo(pts[-1]) < 1e-6: + pts = pts[:-1] + n = len(pts) + if n < 3: continue + # Top + Bottom Vertices anfuegen + top_idx = [] + bot_idx = [] + for p in pts: + top_idx.append(vol.Vertices.Add(p.X, p.Y, p.Z)) + bot_idx.append(vol.Vertices.Add(p.X, p.Y, bottom_z)) + # Skirt: Quads zwischen aufeinanderfolgenden Top/Bottom-Paaren. + # Faces sind "innen orientiert" — bei Bedarf normals + # umdrehen via ComputeNormals + RebuildNormals. + for i in range(n): + j = (i + 1) % n + vol.Faces.AddFace(top_idx[i], top_idx[j], + bot_idx[j], bot_idx[i]) + # Bottom-Cap via planar Polyline → Mesh + bot_pts = [rg.Point3d(p.X, p.Y, bottom_z) for p in pts] + bot_pts.append(bot_pts[0]) # schliessen + bot_poly = rg.Polyline(bot_pts) + cap = rg.Mesh.CreateFromClosedPolyline(bot_poly) + if cap is not None and cap.Vertices.Count >= 3: + vol.Append(cap) + except Exception as ex: + if progress: progress("Volumize: Loop-Fehler: {}".format(ex)) + # 5) Cleanup + try: vol.Vertices.CombineIdentical(True, True) + except Exception: pass + try: vol.Compact() + except Exception: pass + try: + vol.Normals.ComputeNormals() + vol.FaceNormals.ComputeFaceNormals() + # Topologie pruefen + Naked-Edges-Anzahl loggen + post_naked = vol.GetNakedEdges() + if progress: + n_naked = len(post_naked) if post_naked else 0 + progress("Volumize: Resultat {} naked-edge-loops (0 = closed)".format( + n_naked)) + except Exception: pass + # 6) Original ersetzen — Attributes + LayerIndex behalten + try: + attrs = top_obj.Attributes.Duplicate() + old_id = top_obj.Id + new_gid = doc.Objects.AddMesh(vol, attrs) + if new_gid is None or new_gid == System.Guid.Empty: + if progress: progress("Volumize: AddMesh fehlgeschlagen") + return None + doc.Objects.Delete(old_id, True) + new_obj = doc.Objects.Find(new_gid) + if progress: progress("→ Terrain-Volumen erzeugt") + return new_obj + except Exception as ex: + if progress: progress("Volumize: Replace-Fehler: {}".format(ex)) + return None + + def generate_contour_curves(grid, shift_lv95, m_to_unit, interval=2.0, progress=None): """Generiert Hoehenlinien (Contour-Curves) aus dem Terrain-Grid via diff --git a/src/SwisstopoApp.jsx b/src/SwisstopoApp.jsx index 4189ed2..fb7db40 100644 --- a/src/SwisstopoApp.jsx +++ b/src/SwisstopoApp.jsx @@ -76,6 +76,11 @@ export default function SwisstopoApp() { const [replaceExisting, setReplaceExisting] = useState(true) const [clipToBbox, setClipToBbox] = useState(false) const [terrainRes, setTerrainRes] = useState('2.0') + // Terrain als geschlossenes Volumen (mit Boden 10m unter tiefstem Punkt) + // damit Section-Cuts gefuellte Querschnitte zeigen statt nur Linien. + // Wirkt auf 3D-Mesh / TIN / Patch — nicht auf 2D-Hoehenlinien und Schichten. + const [terrainVolume, setTerrainVolume] = useState(false) + const [terrainVolumeDepth, setTerrainVolumeDepth] = useState('10') // Live-Log const [logs, setLogs] = useState([]) const [running, setRunning] = useState(false) @@ -165,6 +170,8 @@ export default function SwisstopoApp() { buildVariant, contourInterval: contourInt, tlmKinds: tlmList, + terrainAsVolume: terrainVolume, + terrainVolumeDepth: parseFloat(terrainVolumeDepth) || 10, }) } @@ -366,6 +373,31 @@ export default function SwisstopoApp() { onChange={setContourInt} /> )} + {(getTerrain || getContourTin || getContourPatch) && ( + <> + Nachbearbeitung + + + + {terrainVolume && ( + + setTerrainVolumeDepth(e.target.value)} + style={{ width: 60, textAlign: 'right' }} /> + + m unter tiefstem Punkt + + + )} + + )} Positionierung diff --git a/src/components/GeschossManager.jsx b/src/components/GeschossManager.jsx index 1c5896b..8607225 100644 --- a/src/components/GeschossManager.jsx +++ b/src/components/GeschossManager.jsx @@ -2,7 +2,7 @@ import { useState } from 'react' import Icon from './Icon' import ContextMenu from './ContextMenu' import { BarCombo, BarButton } from './BarControls' -import { openGeschossSettings, openGeschossDialog } from '../lib/rhinoBridge' +import { openGeschossSettings, openGeschossDialog, createSchnitt } from '../lib/rhinoBridge' function GeschossBadge({ name }) { return {name} @@ -13,6 +13,10 @@ function ZeichnungsebeneRow({ onToggleVisible, onToggleLock, onToggleClipping, onDelete, }) { const isGeschoss = !!z.isGeschoss + const isSchnitt = z.type === 'schnitt' + // Schnitt vs Ansicht: cutAtLine!=false = Schnitt (mit Front-Cut), sonst Ansicht + const schnittIcon = (z.cutAtLine === false) ? 'visibility' : 'content_cut' + const schnittLabel = (z.cutAtLine === false) ? 'Ansicht' : 'Schnitt' // Eye-Logik: die aktive Z ist IMMER sichtbar (Backend forciert das), also // zeigen wir ihr Auge immer als "an" — ohne Ruecksicht aufs visible-Flag. // Nicht-aktive: in 'all_force' ist visible-Flag ueberschrieben (alle an), @@ -85,6 +89,18 @@ function ZeichnungsebeneRow({ {isGeschoss && } + {isSchnitt && ( + + + + )} + {isGeschoss ? ( + + + + + ) + })()} ) } diff --git a/src/components/GeschossSettingsDialog.jsx b/src/components/GeschossSettingsDialog.jsx index eead6f1..fa2a986 100644 --- a/src/components/GeschossSettingsDialog.jsx +++ b/src/components/GeschossSettingsDialog.jsx @@ -39,12 +39,19 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe const [draft, setDraft] = useState({ ...geschoss }) const set = (patch) => setDraft({ ...draft, ...patch }) - const isG = !!draft.isGeschoss - const hoehe = draft.hoehe ?? 3.0 - const schnitt = draft.schnitthoehe ?? 1.0 - const hasClip = !!draft.hasClipping - const okff = draft.okff ?? 0 - const clipZ = (okff + schnitt).toFixed(2) + const isG = !!draft.isGeschoss + const isSchnitt = draft.type === 'schnitt' + const hoehe = draft.hoehe ?? 3.0 + const schnitt = draft.schnitthoehe ?? 1.0 + const hasClip = !!draft.hasClipping + const okff = draft.okff ?? 0 + const clipZ = (okff + schnitt).toFixed(2) + // Schnitt-Felder + const cutAtLine = draft.cutAtLine !== false // default true = Schnitt + const depthBack = draft.depthBack ?? 8.0 + const heightMin = draft.heightMin ?? -1.0 + const heightMax = draft.heightMax ?? 12.0 + const dirSign = draft.dirSign ?? 1 // embedded=true: in einem Satelliten-Fenster gerendert — kein Backdrop, // keine Width-Constraint, fuellt das ganze WebView. @@ -103,14 +110,72 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe /> - set({ isGeschoss: v })} - hint={isG ? 'Höhe & Clipping verfügbar' : 'reines Zeichenblatt'} - /> + {/* Geschoss-Toggle nur fuer non-schnitt Eintraege — Schnitt-Type + ist exklusiv (kein Geschoss zugleich). */} + {!isSchnitt && ( + set({ isGeschoss: v })} + hint={isG ? 'Höhe & Clipping verfügbar' : 'reines Zeichenblatt'} + /> + )} - {isG && ( + {isSchnitt && ( + <> +
+ + set({ cutAtLine: v })} + hint={cutAtLine + ? 'Schnitt: alles vor der Schnittlinie wird weggeschnitten' + : 'Ansicht: nur Tiefenbegrenzung hinten, kein Front-Cut'} + /> + + + set({ depthBack: parseFloat(ev.target.value) || 8.0 })} + style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }} + /> + + +
+ + set({ heightMin: parseFloat(ev.target.value) })} + style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }} + /> + + + set({ heightMax: parseFloat(ev.target.value) })} + style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }} + /> + +
+ + + + + + + )} + + {isG && !isSchnitt && ( <>
@@ -180,6 +245,13 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe if (out.hoehe == null) out.hoehe = 3.0 if (out.schnitthoehe == null) out.schnitthoehe = 1.0 } + if (out.type === 'schnitt') { + if (out.depthBack == null) out.depthBack = 8.0 + if (out.heightMin == null) out.heightMin = -1.0 + if (out.heightMax == null) out.heightMax = 12.0 + if (out.dirSign == null) out.dirSign = 1 + if (out.cutAtLine == null) out.cutAtLine = true + } onSave(out) }}>Übernehmen
diff --git a/src/lib/rhinoBridge.js b/src/lib/rhinoBridge.js index 9cc72ec..2466ea2 100644 --- a/src/lib/rhinoBridge.js +++ b/src/lib/rhinoBridge.js @@ -175,6 +175,16 @@ export function openElementeProperties() { send('OPEN_ELEMENTE_PROPERTIES', {}) export function setDarstellung(d) { send('SET_DARSTELLUNG', { darstellung: d || '' }) } // Anordnen — 2D-Z-Stack via Rhino-DisplayOrder. dir: 'front'|'forward'|'backward'|'back' export function arrangeSelection(dir) { send('ARRANGE', { dir }) } +// Schnitt/Ansicht — interaktiver 2-Punkt-Pick im Rhino-Viewport. Erzeugt +// eine neue Zeichnungsebene type=schnitt + 2D-Plan-Symbol + aktiviert sie. +// opts: { cutAtLine: bool, depthBack: m, heightMin: m, heightMax: m, namePrefix } +export function createSchnitt(opts = {}) { + send('CREATE_SCHNITT', { + cutAtLine: true, depthBack: 8.0, heightMin: -1.0, heightMax: 12.0, + ...opts, + }) +} +export function deleteSchnitt(id) { send('DELETE_SCHNITT', { id }) } export function saveOeffStyle(name, settings) { send('SAVE_OEFF_STYLE', { name, settings }) }