Schnitt/Ansicht-Feature + Terrain-Volumen + Geschoss-Add-Dialog
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 <noreply@anthropic.com>
This commit is contained in:
+58
-1
@@ -657,6 +657,16 @@ def _layer_path_referenz(doc, geschoss_name):
|
|||||||
return "{}::{}".format(geschoss_name, sub)
|
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):
|
def _ensure_oeff_ebenen_in_doc(doc):
|
||||||
"""Registriert die Oeffnungen-Ebene + alle Piece-Children im
|
"""Registriert die Oeffnungen-Ebene + alle Piece-Children im
|
||||||
`dossier_ebenen`-JSON-Tree (als Children der WAENDE-Ebene). Damit
|
`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})
|
self.send("STATE", {"elements": [], "geschosse": [], "selection": None})
|
||||||
return
|
return
|
||||||
geschosse = _load_geschosse(doc)
|
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
|
# Alle Source-Objekte (Achsen + Outlines) durchgehen
|
||||||
elements = []
|
elements = []
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
@@ -5562,7 +5589,7 @@ class ElementeBridge(panel_base.BaseBridge):
|
|||||||
seen_ids.add(meta["id"])
|
seen_ids.add(meta["id"])
|
||||||
g = _geschoss_by_id(doc, meta["geschoss"])
|
g = _geschoss_by_id(doc, meta["geschoss"])
|
||||||
geschoss_name = g.get("name", "?") if g else "?"
|
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 = {
|
base = {
|
||||||
"id": meta["id"],
|
"id": meta["id"],
|
||||||
"objectId": str(obj.Id),
|
"objectId": str(obj.Id),
|
||||||
@@ -7968,6 +7995,13 @@ class ElementeBridge(panel_base.BaseBridge):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
self._push_log("Grid-Merge fehlgeschlagen: {}".format(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
|
# 3D-Mesh bauen wenn Terrain gewuenscht — unabhaengig vom
|
||||||
# Ortho. Wenn Ortho auch an ist: Drape-Mesh liegt ueber
|
# Ortho. Wenn Ortho auch an ist: Drape-Mesh liegt ueber
|
||||||
# dem Plain-Mesh (User togglet im Layer-Panel was er
|
# 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))
|
mesh.Vertices.Count, mesh.Faces.Count))
|
||||||
gid = d.Objects.AddMesh(mesh)
|
gid = d.Objects.AddMesh(mesh)
|
||||||
obj = d.Objects.Find(gid)
|
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"]))
|
if obj: mesh_objects.append((obj, merged_grid["bbox"]))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
self._push_log("Mesh-Bau fehlgeschlagen: {}".format(ex))
|
self._push_log("Mesh-Bau fehlgeschlagen: {}".format(ex))
|
||||||
@@ -8059,6 +8098,11 @@ class ElementeBridge(panel_base.BaseBridge):
|
|||||||
m_to_unit=m_to_unit,
|
m_to_unit=m_to_unit,
|
||||||
progress=self._push_log)
|
progress=self._push_log)
|
||||||
if tin_obj:
|
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
|
# Tag + auf 80_swisstopo Parent
|
||||||
at = tin_obj.Attributes.Duplicate()
|
at = tin_obj.Attributes.Duplicate()
|
||||||
at.SetUserString("dossier_swisstopo_kind", "contour_tin")
|
at.SetUserString("dossier_swisstopo_kind", "contour_tin")
|
||||||
@@ -8100,6 +8144,11 @@ class ElementeBridge(panel_base.BaseBridge):
|
|||||||
patch_obj = swisstopo.generate_patch_from_contours(
|
patch_obj = swisstopo.generate_patch_from_contours(
|
||||||
d, raw_contours, progress=self._push_log)
|
d, raw_contours, progress=self._push_log)
|
||||||
if patch_obj:
|
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 = patch_obj.Attributes.Duplicate()
|
||||||
at.SetUserString("dossier_swisstopo_kind", "contour_patch")
|
at.SetUserString("dossier_swisstopo_kind", "contour_patch")
|
||||||
d.Objects.ModifyAttributes(patch_obj, at, True)
|
d.Objects.ModifyAttributes(patch_obj, at, True)
|
||||||
@@ -11496,6 +11545,14 @@ def _install_listeners(bridge):
|
|||||||
"cmd_end": _on_command_end,
|
"cmd_end": _on_command_end,
|
||||||
}
|
}
|
||||||
print("[ELEMENTE] Listener aktiv (Replace + Add + Delete + Select + Idle + Cmd)")
|
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():
|
def _bridge_factory():
|
||||||
|
|||||||
@@ -433,6 +433,40 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
self._update_ebene_field(p["code"], "lw", p["lw"])
|
self._update_ebene_field(p["code"], "lw", p["lw"])
|
||||||
elif t == "SET_ACTIVE":
|
elif t == "SET_ACTIVE":
|
||||||
self._set_active_zeichnungsebene(p)
|
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":
|
elif t == "SET_ACTIVE_LAYER":
|
||||||
code = p.get("code", "")
|
code = p.get("code", "")
|
||||||
if code:
|
if code:
|
||||||
@@ -551,6 +585,18 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
try: e_list = json.loads(e_raw) if e_raw else []
|
try: e_list = json.loads(e_raw) if e_raw else []
|
||||||
except Exception: e_list = []
|
except Exception: e_list = []
|
||||||
self._apply(z_list, e_list, save_z=True, save_e=False)
|
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(
|
panel_base.open_satellite_window(
|
||||||
"geschoss_settings",
|
"geschoss_settings",
|
||||||
params=params,
|
params=params,
|
||||||
@@ -709,6 +755,22 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
new_sig = _fill_signature(ebenen)
|
new_sig = _fill_signature(ebenen)
|
||||||
fill_changed = (old_sig != new_sig)
|
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)
|
_set_processing(True)
|
||||||
try:
|
try:
|
||||||
print("[EBENEN] _apply: build_layers ...")
|
print("[EBENEN] _apply: build_layers ...")
|
||||||
@@ -724,6 +786,21 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
doc.Strings.SetString("dossier_zeichnungsebenen", z_json)
|
doc.Strings.SetString("dossier_zeichnungsebenen", z_json)
|
||||||
if save_e:
|
if save_e:
|
||||||
doc.Strings.SetString("dossier_ebenen", e_json)
|
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
|
# Smart-Elemente (Waende) regenerieren — Geschoss-Hoehen/OKFF
|
||||||
# haben sich evtl. geaendert, gebundene Waende muessen neu
|
# haben sich evtl. geaendert, gebundene Waende muessen neu
|
||||||
# extrudiert werden. Best-effort, faengt jeden Fehler ab.
|
# 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
|
# Vorigen Stand merken um redundante teure Operationen zu sparen
|
||||||
prev_active_id = doc.Strings.GetValue("dossier_active_id") or ""
|
prev_active_id = doc.Strings.GetValue("dossier_active_id") or ""
|
||||||
doc.Strings.SetString("dossier_active_id", z_id)
|
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
|
# Aktiven Sublayer auf die GLEICHE Ebene unter dem neuen Geschoss
|
||||||
# umschalten — wenn User auf "20 Wände" steht und das Geschoss
|
# umschalten — wenn User auf "20 Wände" steht und das Geschoss
|
||||||
# wechselt, soll Rhino's aktiver Layer "1OG::20_Wände" werden statt
|
# wechselt, soll Rhino's aktiver Layer "1OG::20_Wände" werden statt
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -909,6 +909,133 @@ def generate_patch_from_contours(doc, contour_curves, progress=None):
|
|||||||
return 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,
|
def generate_contour_curves(grid, shift_lv95, m_to_unit, interval=2.0,
|
||||||
progress=None):
|
progress=None):
|
||||||
"""Generiert Hoehenlinien (Contour-Curves) aus dem Terrain-Grid via
|
"""Generiert Hoehenlinien (Contour-Curves) aus dem Terrain-Grid via
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export default function SwisstopoApp() {
|
|||||||
const [replaceExisting, setReplaceExisting] = useState(true)
|
const [replaceExisting, setReplaceExisting] = useState(true)
|
||||||
const [clipToBbox, setClipToBbox] = useState(false)
|
const [clipToBbox, setClipToBbox] = useState(false)
|
||||||
const [terrainRes, setTerrainRes] = useState('2.0')
|
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
|
// Live-Log
|
||||||
const [logs, setLogs] = useState([])
|
const [logs, setLogs] = useState([])
|
||||||
const [running, setRunning] = useState(false)
|
const [running, setRunning] = useState(false)
|
||||||
@@ -165,6 +170,8 @@ export default function SwisstopoApp() {
|
|||||||
buildVariant,
|
buildVariant,
|
||||||
contourInterval: contourInt,
|
contourInterval: contourInt,
|
||||||
tlmKinds: tlmList,
|
tlmKinds: tlmList,
|
||||||
|
terrainAsVolume: terrainVolume,
|
||||||
|
terrainVolumeDepth: parseFloat(terrainVolumeDepth) || 10,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +373,31 @@ export default function SwisstopoApp() {
|
|||||||
onChange={setContourInt} />
|
onChange={setContourInt} />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
{(getTerrain || getContourTin || getContourPatch) && (
|
||||||
|
<>
|
||||||
|
<SectionLabel>Nachbearbeitung</SectionLabel>
|
||||||
|
<Field label=""
|
||||||
|
hint="Wandelt die oben gewaehlten 3D-Terrain-Quellen (Terrain / TIN / Patch) in geschlossene Mesh-Volumen um — Skirt + Boden bei (min_z − Tiefe). Damit liefert eine Clipping-Plane einen gefuellten Querschnitt statt nur Konturlinien. 2D-Linien und Schichten sind nicht betroffen.">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 11, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={terrainVolume}
|
||||||
|
onChange={(e) => setTerrainVolume(e.target.checked)} />
|
||||||
|
<Icon name="layers" size={13} /> Terrain als Volumen (mit Boden, schneidbar)
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
{terrainVolume && (
|
||||||
|
<Field label="TIEFE">
|
||||||
|
<input type="text"
|
||||||
|
value={terrainVolumeDepth}
|
||||||
|
onChange={(e) => setTerrainVolumeDepth(e.target.value)}
|
||||||
|
style={{ width: 60, textAlign: 'right' }} />
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||||
|
m unter tiefstem Punkt
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<SectionLabel>Positionierung</SectionLabel>
|
<SectionLabel>Positionierung</SectionLabel>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import Icon from './Icon'
|
import Icon from './Icon'
|
||||||
import ContextMenu from './ContextMenu'
|
import ContextMenu from './ContextMenu'
|
||||||
import { BarCombo, BarButton } from './BarControls'
|
import { BarCombo, BarButton } from './BarControls'
|
||||||
import { openGeschossSettings, openGeschossDialog } from '../lib/rhinoBridge'
|
import { openGeschossSettings, openGeschossDialog, createSchnitt } from '../lib/rhinoBridge'
|
||||||
|
|
||||||
function GeschossBadge({ name }) {
|
function GeschossBadge({ name }) {
|
||||||
return <span className="chip chip-info">{name}</span>
|
return <span className="chip chip-info">{name}</span>
|
||||||
@@ -13,6 +13,10 @@ function ZeichnungsebeneRow({
|
|||||||
onToggleVisible, onToggleLock, onToggleClipping, onDelete,
|
onToggleVisible, onToggleLock, onToggleClipping, onDelete,
|
||||||
}) {
|
}) {
|
||||||
const isGeschoss = !!z.isGeschoss
|
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
|
// Eye-Logik: die aktive Z ist IMMER sichtbar (Backend forciert das), also
|
||||||
// zeigen wir ihr Auge immer als "an" — ohne Ruecksicht aufs visible-Flag.
|
// zeigen wir ihr Auge immer als "an" — ohne Ruecksicht aufs visible-Flag.
|
||||||
// Nicht-aktive: in 'all_force' ist visible-Flag ueberschrieben (alle an),
|
// Nicht-aktive: in 'all_force' ist visible-Flag ueberschrieben (alle an),
|
||||||
@@ -85,6 +89,18 @@ function ZeichnungsebeneRow({
|
|||||||
|
|
||||||
{isGeschoss && <GeschossBadge name={z.name} />}
|
{isGeschoss && <GeschossBadge name={z.name} />}
|
||||||
|
|
||||||
|
{isSchnitt && (
|
||||||
|
<span title={schnittLabel}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||||
|
fontSize: 10, color: 'var(--text-muted)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}>
|
||||||
|
<Icon name={schnittIcon} size={11}
|
||||||
|
style={{ color: active ? 'var(--accent)' : 'var(--text-muted)' }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{isGeschoss ? (
|
{isGeschoss ? (
|
||||||
<button
|
<button
|
||||||
className={`btn-icon-xs ${z.hasClipping ? 'is-on' : ''}`}
|
className={`btn-icon-xs ${z.hasClipping ? 'is-on' : ''}`}
|
||||||
@@ -128,14 +144,14 @@ export default function GeschossManager({
|
|||||||
mode, onModeChange,
|
mode, onModeChange,
|
||||||
}) {
|
}) {
|
||||||
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, id }
|
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, id }
|
||||||
|
const [addMenu, setAddMenu] = useState(null) // { x, y } — Picker beim +
|
||||||
|
const [geschossDialog, setGeschossDialog] = useState(null) // { x, y, pos:'above'|'below', name, hoehe, schnitthoehe, anchorId }
|
||||||
|
|
||||||
const sorted = [...zeichnungsebenen].reverse()
|
const sorted = [...zeichnungsebenen].reverse()
|
||||||
|
|
||||||
const addQuick = () => {
|
const addZeichnung = () => {
|
||||||
// Standard: NICHT-Geschoss-Zeichnungsebene (z.B. Möblierung, Bemassung,
|
const nonGeschossCount = zeichnungsebenen.filter(
|
||||||
// Plangrafik etc.). User kann via Row-Kontextmenue auf Geschoss
|
z => !z.isGeschoss && z.type !== "schnitt").length
|
||||||
// umschalten oder via Bearbeiten-Dialog (Pencil) ein Geschoss erstellen.
|
|
||||||
const nonGeschossCount = zeichnungsebenen.filter(z => !z.isGeschoss).length
|
|
||||||
const newZ = {
|
const newZ = {
|
||||||
id: `z_${Date.now()}`,
|
id: `z_${Date.now()}`,
|
||||||
name: `Zeichnung ${nonGeschossCount + 1}`,
|
name: `Zeichnung ${nonGeschossCount + 1}`,
|
||||||
@@ -145,6 +161,151 @@ export default function GeschossManager({
|
|||||||
onChange([...zeichnungsebenen, newZ])
|
onChange([...zeichnungsebenen, newZ])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vorgeschlagener Geschossname relativ zum Anker. Logik:
|
||||||
|
// EG + above → 1OG, EG + below → UG
|
||||||
|
// <N>OG + above → <N+1>OG, <N>OG + below → wenn N=1 dann EG, sonst <N-1>OG
|
||||||
|
// <N>UG + above → wenn N=1 dann EG, sonst <N-1>UG; <N>UG + below → <N+1>UG
|
||||||
|
// sonst: 'Neu'
|
||||||
|
const suggestGeschossName = (anchor, pos) => {
|
||||||
|
const nm = ((anchor?.name) || '').trim().toUpperCase()
|
||||||
|
const ogMatch = nm.match(/^(\d*)OG$/)
|
||||||
|
const ugMatch = nm.match(/^(\d*)UG$/)
|
||||||
|
if (nm === 'EG') return pos === 'above' ? '1OG' : 'UG'
|
||||||
|
if (ogMatch) {
|
||||||
|
const n = parseInt(ogMatch[1] || '1', 10)
|
||||||
|
if (pos === 'above') return `${n + 1}OG`
|
||||||
|
return n <= 1 ? 'EG' : `${n - 1}OG`
|
||||||
|
}
|
||||||
|
if (ugMatch) {
|
||||||
|
const n = parseInt(ugMatch[1] || '1', 10)
|
||||||
|
if (pos === 'below') return `${n + 1}UG`
|
||||||
|
return n <= 1 ? 'EG' : `${n - 1}UG`
|
||||||
|
}
|
||||||
|
return 'Neu'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project-Default-Hoehe: erst aktives Geschoss, dann erstes Geschoss in
|
||||||
|
// der Liste, dann hartcodiert 3.0. So uebernimmt jeder neue Eintrag den
|
||||||
|
// "typischen" Wert des Projekts ohne dass der User irgendwo setzen muss.
|
||||||
|
const defaultGeschossHoehe = () => {
|
||||||
|
const act = zeichnungsebenen.find(z => z.id === activeId)
|
||||||
|
if (act?.isGeschoss && act.hoehe != null) return act.hoehe
|
||||||
|
const first = zeichnungsebenen.find(z => z.isGeschoss && z.hoehe != null)
|
||||||
|
return first?.hoehe ?? 3.0
|
||||||
|
}
|
||||||
|
const defaultSchnitthoehe = () => {
|
||||||
|
const act = zeichnungsebenen.find(z => z.id === activeId)
|
||||||
|
if (act?.isGeschoss && act.schnitthoehe != null) return act.schnitthoehe
|
||||||
|
const first = zeichnungsebenen.find(z => z.isGeschoss && z.schnitthoehe != null)
|
||||||
|
return first?.schnitthoehe ?? 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const openGeschossPrompt = (ev) => {
|
||||||
|
// Anker = aktives Geschoss (oder erstes Geschoss in der Liste). Falls
|
||||||
|
// gar keins da: einfach default-Werte ohne Anker.
|
||||||
|
const anchor = zeichnungsebenen.find(z => z.id === activeId && z.isGeschoss)
|
||||||
|
|| zeichnungsebenen.find(z => z.isGeschoss)
|
||||||
|
|| null
|
||||||
|
const pos = 'above'
|
||||||
|
const rect = ev?.currentTarget?.getBoundingClientRect()
|
||||||
|
setGeschossDialog({
|
||||||
|
x: rect ? rect.right - 240 : 200,
|
||||||
|
y: rect ? rect.bottom + 4 : 100,
|
||||||
|
pos,
|
||||||
|
name: suggestGeschossName(anchor, pos),
|
||||||
|
hoehe: defaultGeschossHoehe().toFixed(2),
|
||||||
|
schnitthoehe: defaultSchnitthoehe().toFixed(2),
|
||||||
|
anchorId: anchor?.id || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmGeschoss = () => {
|
||||||
|
if (!geschossDialog) return
|
||||||
|
const { pos, name, hoehe, schnitthoehe, anchorId } = geschossDialog
|
||||||
|
const h = parseFloat(String(hoehe).replace(',', '.'))
|
||||||
|
const sh = parseFloat(String(schnitthoehe).replace(',', '.'))
|
||||||
|
const newZ = {
|
||||||
|
id: `z_${Date.now()}`,
|
||||||
|
name: (name || 'Neu').trim(),
|
||||||
|
isGeschoss: true,
|
||||||
|
hoehe: isFinite(h) && h > 0 ? h : 3.0,
|
||||||
|
schnitthoehe: isFinite(sh) && sh > 0 ? sh : 1.0,
|
||||||
|
visible: true,
|
||||||
|
}
|
||||||
|
// Insertion-Index: anchor finden, dann +1 (above) oder vorne ein (below).
|
||||||
|
// Array-Reihenfolge = bottom-up (recalcOkff stacks von index 0 aufwaerts),
|
||||||
|
// also "above" = nach anchor, "below" = vor anchor.
|
||||||
|
let nextList
|
||||||
|
if (anchorId) {
|
||||||
|
const idx = zeichnungsebenen.findIndex(z => z.id === anchorId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
const insertAt = pos === 'above' ? idx + 1 : idx
|
||||||
|
nextList = [
|
||||||
|
...zeichnungsebenen.slice(0, insertAt),
|
||||||
|
newZ,
|
||||||
|
...zeichnungsebenen.slice(insertAt),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
nextList = [...zeichnungsebenen, newZ]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextList = [...zeichnungsebenen, newZ]
|
||||||
|
}
|
||||||
|
onChange(nextList)
|
||||||
|
setGeschossDialog(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateGeschossDialog = (patch) => {
|
||||||
|
if (!geschossDialog) return
|
||||||
|
const next = { ...geschossDialog, ...patch }
|
||||||
|
// Wenn pos ODER anchorId sich aendert: Name + Hoehen neu vorschlagen,
|
||||||
|
// aber nur wenn der User die jeweiligen Felder nicht schon manuell
|
||||||
|
// ueberschrieben hat (heuristisch: aktueller Wert === alter Vorschlag).
|
||||||
|
const posChanged = patch.pos && patch.pos !== geschossDialog.pos
|
||||||
|
const anchorChanged = patch.anchorId && patch.anchorId !== geschossDialog.anchorId
|
||||||
|
if (posChanged || anchorChanged) {
|
||||||
|
const oldAnchor = zeichnungsebenen.find(z => z.id === geschossDialog.anchorId)
|
||||||
|
const newAnchor = zeichnungsebenen.find(z => z.id === (patch.anchorId || geschossDialog.anchorId))
|
||||||
|
const oldSuggestedName = suggestGeschossName(oldAnchor, geschossDialog.pos)
|
||||||
|
if (geschossDialog.name === oldSuggestedName) {
|
||||||
|
next.name = suggestGeschossName(newAnchor, next.pos)
|
||||||
|
}
|
||||||
|
// Bei Anchor-Wechsel: Hoehe/Schnitthoehe auch nachziehen wenn unveraendert
|
||||||
|
if (anchorChanged && newAnchor?.isGeschoss) {
|
||||||
|
const oldHoeheStr = (oldAnchor?.hoehe ?? 3.0).toFixed(2)
|
||||||
|
const oldShStr = (oldAnchor?.schnitthoehe ?? 1.0).toFixed(2)
|
||||||
|
if (geschossDialog.hoehe === oldHoeheStr && newAnchor.hoehe != null) {
|
||||||
|
next.hoehe = newAnchor.hoehe.toFixed(2)
|
||||||
|
}
|
||||||
|
if (geschossDialog.schnitthoehe === oldShStr && newAnchor.schnitthoehe != null) {
|
||||||
|
next.schnitthoehe = newAnchor.schnitthoehe.toFixed(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setGeschossDialog(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSchnitt = () => createSchnitt() // triggert interaktiven Pick + Auto-Activate
|
||||||
|
|
||||||
|
const openAddMenu = (ev) => {
|
||||||
|
if (!ev) { addZeichnung(); return } // Fallback ohne Event-Position
|
||||||
|
const rect = ev.currentTarget.getBoundingClientRect()
|
||||||
|
setAddMenu({ x: rect.right - 180, y: rect.bottom + 4 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextMenu Items — onClick kriegt KEIN Event. Daher fuer Geschoss
|
||||||
|
// einen kleinen Trick: ContextMenu schliesst, dann oeffnen wir den
|
||||||
|
// Dialog mit einer kuenstlichen Position (rechts vom Panel).
|
||||||
|
const addMenuItems = [
|
||||||
|
{ label: 'Geschoss', icon: 'layers',
|
||||||
|
onClick: () => openGeschossPrompt({
|
||||||
|
currentTarget: { getBoundingClientRect: () =>
|
||||||
|
({ right: addMenu?.x + 180, bottom: addMenu?.y + 20 }) } }) },
|
||||||
|
{ label: 'Schnitt / Ansicht', icon: 'content_cut', onClick: addSchnitt },
|
||||||
|
{ divider: true },
|
||||||
|
{ label: 'Zeichnung', icon: 'edit_note', onClick: addZeichnung },
|
||||||
|
]
|
||||||
|
|
||||||
const toggleVisible = (id) => {
|
const toggleVisible = (id) => {
|
||||||
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z))
|
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z))
|
||||||
// In "active" / "all_force" greift visible-Flag nicht — wer aufs Auge
|
// In "active" / "all_force" greift visible-Flag nicht — wer aufs Auge
|
||||||
@@ -226,9 +387,9 @@ export default function GeschossManager({
|
|||||||
onGear={() => openGeschossDialog(zeichnungsebenen)}
|
onGear={() => openGeschossDialog(zeichnungsebenen)}
|
||||||
gearIcon="settings"
|
gearIcon="settings"
|
||||||
gearTitle="Einstellungen"
|
gearTitle="Einstellungen"
|
||||||
onSecond={addQuick}
|
onSecond={openAddMenu}
|
||||||
secondIcon="add"
|
secondIcon="add"
|
||||||
secondTitle="Zeichnungsebene hinzufügen"
|
secondTitle="Hinzufuegen: Geschoss / Schnitt / Zeichnung"
|
||||||
>
|
>
|
||||||
{MODES.map(m => (
|
{MODES.map(m => (
|
||||||
<option key={m.value} value={m.value}>{m.label}</option>
|
<option key={m.value} value={m.value}>{m.label}</option>
|
||||||
@@ -309,6 +470,120 @@ export default function GeschossManager({
|
|||||||
onClose={() => setCtxMenu(null)}
|
onClose={() => setCtxMenu(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{addMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={addMenu.x} y={addMenu.y}
|
||||||
|
items={addMenuItems}
|
||||||
|
onClose={() => setAddMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{geschossDialog && (() => {
|
||||||
|
// Geschoss-Liste (top-down sortiert wie im Panel) fuer das Dropdown
|
||||||
|
const geschossOptions = [...zeichnungsebenen]
|
||||||
|
.filter(z => z.isGeschoss)
|
||||||
|
.reverse()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop — Klick schliesst */}
|
||||||
|
<div onClick={() => setGeschossDialog(null)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 999,
|
||||||
|
background: 'transparent' }} />
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', zIndex: 1000,
|
||||||
|
left: Math.max(8, Math.min(geschossDialog.x, window.innerWidth - 260)),
|
||||||
|
top: Math.max(8, Math.min(geschossDialog.y, window.innerHeight - 220)),
|
||||||
|
width: 240,
|
||||||
|
background: 'var(--bg-dialog)', color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 6,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
||||||
|
padding: 10,
|
||||||
|
fontSize: 11, fontFamily: 'var(--font)',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 8,
|
||||||
|
}}
|
||||||
|
onClick={(ev) => ev.stopPropagation()}>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-muted)',
|
||||||
|
fontWeight: 600, letterSpacing: 0.4,
|
||||||
|
textTransform: 'uppercase' }}>
|
||||||
|
Neues Geschoss
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position: Anker-Dropdown + Über/Unter-Toggle */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>
|
||||||
|
Position relativ zu
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<select
|
||||||
|
value={geschossDialog.anchorId || ''}
|
||||||
|
onChange={(ev) => updateGeschossDialog({ anchorId: ev.target.value })}
|
||||||
|
disabled={geschossOptions.length === 0}
|
||||||
|
style={{ flex: 1, fontSize: 11, minWidth: 0 }}>
|
||||||
|
{geschossOptions.length === 0 && <option value="">— keins —</option>}
|
||||||
|
{geschossOptions.map(g => (
|
||||||
|
<option key={g.id} value={g.id}>{g.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<BarButton icon="arrow_upward"
|
||||||
|
active={geschossDialog.pos === 'above'}
|
||||||
|
onClick={() => updateGeschossDialog({ pos: 'above' })}
|
||||||
|
title="Über dem Anker einfügen" />
|
||||||
|
<BarButton icon="arrow_downward"
|
||||||
|
active={geschossDialog.pos === 'below'}
|
||||||
|
onClick={() => updateGeschossDialog({ pos: 'below' })}
|
||||||
|
title="Unter dem Anker einfügen" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>Name</span>
|
||||||
|
<input type="text" value={geschossDialog.name}
|
||||||
|
onChange={(ev) => updateGeschossDialog({ name: ev.target.value })}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter') confirmGeschoss()
|
||||||
|
else if (ev.key === 'Escape') setGeschossDialog(null)
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hoehe + Schnitthoehe */}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>Höhe (m)</span>
|
||||||
|
<input type="text" value={geschossDialog.hoehe}
|
||||||
|
onChange={(ev) => updateGeschossDialog({ hoehe: ev.target.value })}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter') confirmGeschoss()
|
||||||
|
else if (ev.key === 'Escape') setGeschossDialog(null)
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', textAlign: 'right' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}
|
||||||
|
title="Höhe der horizontalen Schnitt-Plane über OKFF">
|
||||||
|
Schnitt (m)
|
||||||
|
</span>
|
||||||
|
<input type="text" value={geschossDialog.schnitthoehe}
|
||||||
|
onChange={(ev) => updateGeschossDialog({ schnitthoehe: ev.target.value })}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter') confirmGeschoss()
|
||||||
|
else if (ev.key === 'Escape') setGeschossDialog(null)
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', textAlign: 'right' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 4, marginTop: 4 }}>
|
||||||
|
<button className="btn-text"
|
||||||
|
onClick={() => setGeschossDialog(null)}>Abbrechen</button>
|
||||||
|
<button className="btn-contained"
|
||||||
|
onClick={confirmGeschoss}>Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,11 +40,18 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
|
|||||||
const set = (patch) => setDraft({ ...draft, ...patch })
|
const set = (patch) => setDraft({ ...draft, ...patch })
|
||||||
|
|
||||||
const isG = !!draft.isGeschoss
|
const isG = !!draft.isGeschoss
|
||||||
|
const isSchnitt = draft.type === 'schnitt'
|
||||||
const hoehe = draft.hoehe ?? 3.0
|
const hoehe = draft.hoehe ?? 3.0
|
||||||
const schnitt = draft.schnitthoehe ?? 1.0
|
const schnitt = draft.schnitthoehe ?? 1.0
|
||||||
const hasClip = !!draft.hasClipping
|
const hasClip = !!draft.hasClipping
|
||||||
const okff = draft.okff ?? 0
|
const okff = draft.okff ?? 0
|
||||||
const clipZ = (okff + schnitt).toFixed(2)
|
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,
|
// embedded=true: in einem Satelliten-Fenster gerendert — kein Backdrop,
|
||||||
// keine Width-Constraint, fuellt das ganze WebView.
|
// keine Width-Constraint, fuellt das ganze WebView.
|
||||||
@@ -103,14 +110,72 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{/* Geschoss-Toggle nur fuer non-schnitt Eintraege — Schnitt-Type
|
||||||
|
ist exklusiv (kein Geschoss zugleich). */}
|
||||||
|
{!isSchnitt && (
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Ist Geschoss"
|
label="Ist Geschoss"
|
||||||
checked={isG}
|
checked={isG}
|
||||||
onChange={(v) => set({ isGeschoss: v })}
|
onChange={(v) => set({ isGeschoss: v })}
|
||||||
hint={isG ? 'Höhe & Clipping verfügbar' : 'reines Zeichenblatt'}
|
hint={isG ? 'Höhe & Clipping verfügbar' : 'reines Zeichenblatt'}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isG && (
|
{isSchnitt && (
|
||||||
|
<>
|
||||||
|
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
label="Front-Cut (Schnitt durchschneiden)"
|
||||||
|
checked={cutAtLine}
|
||||||
|
onChange={(v) => set({ cutAtLine: v })}
|
||||||
|
hint={cutAtLine
|
||||||
|
? 'Schnitt: alles vor der Schnittlinie wird weggeschnitten'
|
||||||
|
: 'Ansicht: nur Tiefenbegrenzung hinten, kein Front-Cut'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field label="TIEFE HINTEN (m)"
|
||||||
|
hint="Wie weit hinter der Schnittlinie noch sichtbar ist">
|
||||||
|
<input
|
||||||
|
type="number" step="0.5" min="0.5"
|
||||||
|
value={depthBack}
|
||||||
|
onChange={(ev) => set({ depthBack: parseFloat(ev.target.value) || 8.0 })}
|
||||||
|
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<Field label="HÖHE UNTEN (m)">
|
||||||
|
<input
|
||||||
|
type="number" step="0.1"
|
||||||
|
value={heightMin}
|
||||||
|
onChange={(ev) => set({ heightMin: parseFloat(ev.target.value) })}
|
||||||
|
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="HÖHE OBEN (m)">
|
||||||
|
<input
|
||||||
|
type="number" step="0.1"
|
||||||
|
value={heightMax}
|
||||||
|
onChange={(ev) => set({ heightMax: parseFloat(ev.target.value) })}
|
||||||
|
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="BLICKRICHTUNG"
|
||||||
|
hint="Wechselt zwischen den beiden Seiten der Schnittlinie">
|
||||||
|
<button className={dirSign >= 0 ? 'btn-contained' : 'btn-outlined'}
|
||||||
|
onClick={() => set({ dirSign: 1 })}
|
||||||
|
style={{ flex: 1, fontSize: 11 }}>← Seite A</button>
|
||||||
|
<button className={dirSign < 0 ? 'btn-contained' : 'btn-outlined'}
|
||||||
|
onClick={() => set({ dirSign: -1 })}
|
||||||
|
style={{ flex: 1, fontSize: 11 }}>Seite B →</button>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isG && !isSchnitt && (
|
||||||
<>
|
<>
|
||||||
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
|
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
|
||||||
|
|
||||||
@@ -180,6 +245,13 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
|
|||||||
if (out.hoehe == null) out.hoehe = 3.0
|
if (out.hoehe == null) out.hoehe = 3.0
|
||||||
if (out.schnitthoehe == null) out.schnitthoehe = 1.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)
|
onSave(out)
|
||||||
}}>Übernehmen</button>
|
}}>Übernehmen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -175,6 +175,16 @@ export function openElementeProperties() { send('OPEN_ELEMENTE_PROPERTIES', {})
|
|||||||
export function setDarstellung(d) { send('SET_DARSTELLUNG', { darstellung: d || '' }) }
|
export function setDarstellung(d) { send('SET_DARSTELLUNG', { darstellung: d || '' }) }
|
||||||
// Anordnen — 2D-Z-Stack via Rhino-DisplayOrder. dir: 'front'|'forward'|'backward'|'back'
|
// Anordnen — 2D-Z-Stack via Rhino-DisplayOrder. dir: 'front'|'forward'|'backward'|'back'
|
||||||
export function arrangeSelection(dir) { send('ARRANGE', { dir }) }
|
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) {
|
export function saveOeffStyle(name, settings) {
|
||||||
send('SAVE_OEFF_STYLE', { name, settings })
|
send('SAVE_OEFF_STYLE', { name, settings })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user