Compare commits
3 Commits
3dc6e31374
...
736325fba1
| Author | SHA1 | Date | |
|---|---|---|---|
| 736325fba1 | |||
| 059cbf8d4d | |||
| 3277f61ced |
+694
-59
File diff suppressed because it is too large
Load Diff
+191
-1
@@ -863,7 +863,15 @@ class OberleisteBridge(panel_base.BaseBridge):
|
||||
self._send_state(force=True)
|
||||
elif t == "SET_LINEWEIGHTS":
|
||||
doc, _ = massstab._active_vp()
|
||||
massstab._set_lineweights_enabled(doc, bool(p.get("enabled")))
|
||||
enabled = bool(p.get("enabled"))
|
||||
massstab._set_lineweights_enabled(doc, enabled)
|
||||
# Print-View AN → Referenzlinien automatisch ausblenden (im
|
||||
# gedruckten Plan haben Hilfslinien nichts verloren). Beim
|
||||
# Ausschalten den vorherigen Sichtbarkeits-Stand restoren.
|
||||
try:
|
||||
self._sync_referenz_for_print(doc, enabled)
|
||||
except Exception as ex:
|
||||
print("[OBERLEISTE] sync referenz for print:", ex)
|
||||
self._send_state(force=True)
|
||||
elif t == "SET_DPI":
|
||||
doc, _ = massstab._active_vp()
|
||||
@@ -1087,6 +1095,102 @@ class OberleisteBridge(panel_base.BaseBridge):
|
||||
except Exception as ex:
|
||||
print("[OBERLEISTE] open layer-combinations:", ex)
|
||||
|
||||
# --- Referenzlinien-Sichtbarkeit togglen ------------------------
|
||||
# Shortcut fuer die Layer-Sichtbarkeit der Referenzlinien-Ebene
|
||||
# (Code 19). Bleibt eine echte Ebene → Ausschnitte speichern den
|
||||
# State automatisch mit. Oberleiste ist nur ein schnellerer Weg
|
||||
# dazu als der Ebenen-Manager.
|
||||
elif t == "TOGGLE_REFERENZLINIEN":
|
||||
try:
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return
|
||||
want_visible = bool(p.get("visible"))
|
||||
print("[OBERLEISTE] TOGGLE_REFERENZLINIEN -> {}".format(want_visible))
|
||||
|
||||
# Keyword-driven: alle Ebenen mit Namen 'Referenz' im JSON
|
||||
# finden (rekursiv), deren Codes sammeln, dann ALLE Rhino-
|
||||
# Layer mit diesen Code-Praefixen toggeln. Funktioniert
|
||||
# bauteil-uebergreifend: WAENDE::20r_Referenz heute,
|
||||
# DECKEN::30r_Referenz / TRAGWERK::50r_Referenz morgen.
|
||||
#
|
||||
# Backwards-Kompat: erfasst auch das alte top-level
|
||||
# '19_Referenzlinien' (Name beginnt mit "Referenz").
|
||||
raw = doc.Strings.GetValue("dossier_ebenen")
|
||||
ebenen = _json.loads(raw) if raw else []
|
||||
if not isinstance(ebenen, list): ebenen = []
|
||||
|
||||
codes = [] # alle Codes deren Layer wir toggeln
|
||||
def _collect(lst):
|
||||
for e in lst:
|
||||
if not isinstance(e, dict): continue
|
||||
nm = (e.get("name") or "").strip().lower()
|
||||
cd = e.get("code")
|
||||
if cd and (nm == "referenz" or nm.startswith("referenz")):
|
||||
codes.append(cd)
|
||||
# Visible-Flag im JSON gleich mit-setzen
|
||||
if e.get("visible", True) != want_visible:
|
||||
e["visible"] = want_visible
|
||||
kids = e.get("children")
|
||||
if isinstance(kids, list): _collect(kids)
|
||||
_collect(ebenen)
|
||||
|
||||
if codes:
|
||||
try:
|
||||
doc.Strings.SetString("dossier_ebenen",
|
||||
_json.dumps(ebenen, ensure_ascii=False))
|
||||
except Exception: pass
|
||||
print("[OBERLEISTE] Referenz-Codes gefunden: {}".format(codes))
|
||||
|
||||
# Rhino-Layer fuer jeden Code toggeln (Praefix-Match)
|
||||
n_toggled = 0
|
||||
if codes:
|
||||
prefixes = tuple(c + "_" for c in codes)
|
||||
for i in range(doc.Layers.Count):
|
||||
layer = doc.Layers[i]
|
||||
if layer is None or layer.IsDeleted: continue
|
||||
if not layer.Name.startswith(prefixes): continue
|
||||
try:
|
||||
if layer.IsVisible != want_visible:
|
||||
layer.IsVisible = want_visible
|
||||
doc.Layers.Modify(layer, i, True)
|
||||
n_toggled += 1
|
||||
except Exception: pass
|
||||
print("[OBERLEISTE] {} Rhino-Layer getoggelt".format(n_toggled))
|
||||
|
||||
try: doc.Views.Redraw()
|
||||
except Exception: pass
|
||||
|
||||
rhinopanel._broadcast_state(doc)
|
||||
self._send_state(force=True)
|
||||
except Exception as ex:
|
||||
print("[OBERLEISTE] TOGGLE_REFERENZLINIEN:", ex)
|
||||
|
||||
# --- Anordnen (DisplayOrder Z-Stack) ----------------------------
|
||||
# Nutzt Rhinos native _BringToFront / _BringForward / _SendBackward
|
||||
# / _SendToBack. Diese setzen Attributes.DisplayOrder — keine
|
||||
# Geometrie-Aenderung, kein Z-Offset. Selection-Check verhindert
|
||||
# nervigen "Select objects"-Prompt wenn der User den Button leer
|
||||
# drueckt.
|
||||
elif t == "ARRANGE":
|
||||
cmd = {
|
||||
"front": "_BringToFront",
|
||||
"forward": "_BringForward",
|
||||
"backward": "_SendBackward",
|
||||
"back": "_SendToBack",
|
||||
}.get(p.get("dir"))
|
||||
if cmd:
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is not None:
|
||||
try:
|
||||
sel = list(doc.Objects.GetSelectedObjects(False, False))
|
||||
except Exception: sel = []
|
||||
if sel:
|
||||
try:
|
||||
Rhino.RhinoApp.SendKeystrokes("\x1b", False)
|
||||
Rhino.RhinoApp.RunScript(cmd, False)
|
||||
except Exception as ex:
|
||||
print("[OBERLEISTE] arrange {}: {}".format(cmd, ex))
|
||||
|
||||
# --- Command-Line Integration -----------------------------------
|
||||
elif t == "RUN_COMMAND":
|
||||
cmd = (p.get("cmd") or "").strip()
|
||||
@@ -1169,6 +1273,69 @@ class OberleisteBridge(panel_base.BaseBridge):
|
||||
"autoApplyLayout": bool(cfg.get("autoApplyLayout", False)),
|
||||
})
|
||||
|
||||
def _sync_referenz_for_print(self, doc, print_enabled):
|
||||
"""Druck-View AN → alle Referenz-Sub-Layer ausblenden + Sichtbarkeits-
|
||||
Snapshot in sticky speichern. AUS → vorherige States restoren.
|
||||
|
||||
Findet die Codes keyword-basiert via dossier_ebenen-Tree (Name
|
||||
startswith 'referenz'). Aendert NICHT das visible-Flag im JSON —
|
||||
das gehoert dem User, wir overriden nur fuer die Druck-Sitzung."""
|
||||
if doc is None: return
|
||||
try: key = "_dossier_referenz_print_snapshot_" + str(doc.RuntimeSerialNumber)
|
||||
except Exception: key = "_dossier_referenz_print_snapshot_default"
|
||||
# Codes sammeln
|
||||
try:
|
||||
raw = doc.Strings.GetValue("dossier_ebenen")
|
||||
ebenen = _json.loads(raw) if raw else []
|
||||
except Exception: ebenen = []
|
||||
codes = []
|
||||
def _collect(lst):
|
||||
for e in lst:
|
||||
if not isinstance(e, dict): continue
|
||||
nm = (e.get("name") or "").strip().lower()
|
||||
cd = e.get("code")
|
||||
if cd and (nm == "referenz" or nm.startswith("referenz")):
|
||||
codes.append(cd)
|
||||
kids = e.get("children")
|
||||
if isinstance(kids, list): _collect(kids)
|
||||
_collect(ebenen)
|
||||
if not codes: return
|
||||
prefixes = tuple(c + "_" for c in codes)
|
||||
if print_enabled:
|
||||
# Snapshot + Hide
|
||||
snap = {}
|
||||
for i in range(doc.Layers.Count):
|
||||
layer = doc.Layers[i]
|
||||
if layer is None or layer.IsDeleted: continue
|
||||
if not layer.Name.startswith(prefixes): continue
|
||||
snap[str(layer.Id)] = bool(layer.IsVisible)
|
||||
if layer.IsVisible:
|
||||
layer.IsVisible = False
|
||||
doc.Layers.Modify(layer, i, True)
|
||||
sc.sticky[key] = snap
|
||||
try: doc.Views.Redraw()
|
||||
except Exception: pass
|
||||
print("[OBERLEISTE] Print AN: {} Referenz-Layer ausgeblendet".format(len(snap)))
|
||||
else:
|
||||
# Restore
|
||||
snap = sc.sticky.get(key) or {}
|
||||
n = 0
|
||||
for i in range(doc.Layers.Count):
|
||||
layer = doc.Layers[i]
|
||||
if layer is None or layer.IsDeleted: continue
|
||||
if not layer.Name.startswith(prefixes): continue
|
||||
was = snap.get(str(layer.Id))
|
||||
if was is None: continue
|
||||
if layer.IsVisible != was:
|
||||
layer.IsVisible = was
|
||||
doc.Layers.Modify(layer, i, True)
|
||||
n += 1
|
||||
try: del sc.sticky[key]
|
||||
except Exception: pass
|
||||
try: doc.Views.Redraw()
|
||||
except Exception: pass
|
||||
print("[OBERLEISTE] Print AUS: {} Referenz-Layer-Sichtbarkeit restored".format(n))
|
||||
|
||||
def _send_state(self, force=False):
|
||||
doc, vp = massstab._active_vp()
|
||||
info = massstab._compute_scale(doc, vp)
|
||||
@@ -1252,6 +1419,28 @@ class OberleisteBridge(panel_base.BaseBridge):
|
||||
info["northAngle"] = 0
|
||||
# Letzte ueber Topbar gesetzte Ansicht (fuer Active-Highlight)
|
||||
info["lastSetView"] = self._last_set_view
|
||||
# Referenzlinien-Sichtbarkeit fuer den Oberleiste-Toggle: alle
|
||||
# Ebenen mit Name 'Referenz...' (keyword-driven, bauteil-uebergreifend)
|
||||
# finden. Wenn ALLE visible → Button-State 'an', wenn min. eine
|
||||
# unsichtbar → 'aus'. Default True (= an) wenn nichts gefunden.
|
||||
ref_visible = True
|
||||
try:
|
||||
e_raw = doc.Strings.GetValue("dossier_ebenen") if doc else None
|
||||
if e_raw:
|
||||
vis_states = []
|
||||
def _collect(lst):
|
||||
for e in lst:
|
||||
if not isinstance(e, dict): continue
|
||||
nm = (e.get("name") or "").strip().lower()
|
||||
if nm == "referenz" or nm.startswith("referenz"):
|
||||
vis_states.append(bool(e.get("visible", True)))
|
||||
kids = e.get("children")
|
||||
if isinstance(kids, list): _collect(kids)
|
||||
_collect(_json.loads(e_raw))
|
||||
if vis_states:
|
||||
ref_visible = all(vis_states)
|
||||
except Exception: pass
|
||||
info["referenzlinienVisible"] = ref_visible
|
||||
# Command-Line State
|
||||
prompt = _get_command_prompt()
|
||||
info["cmdPrompt"] = prompt
|
||||
@@ -1289,6 +1478,7 @@ class OberleisteBridge(panel_base.BaseBridge):
|
||||
info.get("textStyleActiveId"),
|
||||
len(info.get("textStyles") or []),
|
||||
info.get("lastSetView"),
|
||||
info.get("referenzlinienVisible"),
|
||||
prompt,
|
||||
)
|
||||
if not force and sig == self._last_state_sig:
|
||||
|
||||
+148
-4
@@ -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
|
||||
@@ -1148,10 +1280,22 @@ class EbenenBridge(panel_base.BaseBridge):
|
||||
return
|
||||
try:
|
||||
ebenen = json.loads(raw)
|
||||
for e in ebenen:
|
||||
if e.get("code") == code:
|
||||
e[field] = value
|
||||
break
|
||||
# Rekursive Suche — Sub-Ebenen (z.B. WAENDE→Öffnungen→Sturz mit
|
||||
# Code 20o7) liegen mehrere Ebenen tief. Frueher nur Top-Level
|
||||
# iteriert → Style-Changes an nested Sublayer wurden nicht
|
||||
# persistiert und kamen beim naechsten broadcast als alte Werte
|
||||
# zurueck.
|
||||
def _set_in_tree(lst):
|
||||
for e in lst:
|
||||
if not isinstance(e, dict): continue
|
||||
if e.get("code") == code:
|
||||
e[field] = value
|
||||
return True
|
||||
kids = e.get("children")
|
||||
if isinstance(kids, list) and _set_in_tree(kids):
|
||||
return True
|
||||
return False
|
||||
_set_in_tree(ebenen)
|
||||
doc.Strings.SetString("dossier_ebenen", json.dumps(ebenen, ensure_ascii=False))
|
||||
_broadcast_state(doc)
|
||||
except Exception as ex:
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
#! python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
schnitt_grips.py
|
||||
Endpoint-Grips fuer Schnitt/Ansicht-Symbole im Plan.
|
||||
|
||||
Selber Pattern wie wand_grips.py: DisplayConduit zeichnet dicke Marker
|
||||
an P1/P2 der Schnittlinie wenn das 2D-Symbol selektiert ist, MouseCallback
|
||||
erkennt Klick + triggert GetPoint mit dem anderen Endpunkt als Anker.
|
||||
|
||||
Nach Confirm:
|
||||
1. linePts im dossier_zeichnungsebenen-JSON updaten
|
||||
2. Altes 2D-Symbol loeschen + neu generieren (mit korrekt rotierten Pfeilen)
|
||||
3. Wenn der Schnitt aktiv ist: Clipping-Planes + View neu aufbauen
|
||||
4. Panel-State broadcasten
|
||||
|
||||
Das war der einzige bisher fehlende Edit-Pfad fuer Schnitte — vorher musste
|
||||
man linePts im JSON-Dialog manuell aendern, was unmoeglich war. Jetzt:
|
||||
Symbol klicken, Marker greifen, ziehen, fertig.
|
||||
"""
|
||||
import json
|
||||
import Rhino
|
||||
import Rhino.Display as rd
|
||||
import Rhino.Geometry as rg
|
||||
import scriptcontext as sc
|
||||
import System
|
||||
import System.Drawing as SD
|
||||
|
||||
|
||||
# Selbe Konstanten wie wand_grips fuer visuelle Konsistenz
|
||||
_HIT_RADIUS_PX = 14
|
||||
_MARKER_RADIUS_PX = 7
|
||||
_MARKER_RADIUS_HOVER_PX = 10
|
||||
_MARKER_FILL = SD.Color.FromArgb(220, 95, 168, 150)
|
||||
_MARKER_BORDER = SD.Color.FromArgb(255, 47, 93, 84)
|
||||
_MARKER_HOVER = SD.Color.FromArgb(255, 255, 140, 60)
|
||||
|
||||
|
||||
# --- Helpers --------------------------------------------------------------
|
||||
|
||||
def _read_schnitt_id(obj):
|
||||
"""Wenn obj eine Schnittsymbol-Curve ist: liefere schnitt_id, sonst None."""
|
||||
if obj is None or obj.IsDeleted: return None
|
||||
try:
|
||||
if obj.Attributes.GetUserString("dossier_schnitt_symbol") != "1":
|
||||
return None
|
||||
sid = obj.Attributes.GetUserString("dossier_schnitt_id")
|
||||
return sid if sid else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _find_schnitt_entry(doc, schnitt_id):
|
||||
"""Holt den Schnitt-Eintrag aus dossier_zeichnungsebenen."""
|
||||
if not schnitt_id: return None
|
||||
try:
|
||||
raw = doc.Strings.GetValue("dossier_zeichnungsebenen")
|
||||
if not raw: return None
|
||||
for z in json.loads(raw):
|
||||
if isinstance(z, dict) and z.get("id") == schnitt_id \
|
||||
and z.get("type") == "schnitt":
|
||||
return z
|
||||
except Exception: pass
|
||||
return None
|
||||
|
||||
|
||||
def _schnitt_endpoints(z_entry):
|
||||
"""(P1, P2) aus linePts. Z=0 (alle Schnittsymbole liegen flach im Plan)."""
|
||||
pts = z_entry.get("linePts") if z_entry else None
|
||||
if not pts or len(pts) < 2: return None, None
|
||||
try:
|
||||
p1 = rg.Point3d(float(pts[0][0]), float(pts[0][1]), 0.0)
|
||||
p2 = rg.Point3d(float(pts[1][0]), float(pts[1][1]), 0.0)
|
||||
return p1, p2
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _update_linePts(doc, schnitt_id, new_p1, new_p2):
|
||||
"""Setzt linePts = [new_p1, new_p2] (beide rg.Point3d), regeneriert
|
||||
das 2D-Symbol + re-aktiviert den Schnitt wenn aktiv + broadcastet.
|
||||
Generische Funktion fuer Endpoint-Drag UND Mid-Drag (Whole-Line-
|
||||
Translate). Liefert True bei Erfolg."""
|
||||
try:
|
||||
raw = doc.Strings.GetValue("dossier_zeichnungsebenen")
|
||||
if not raw: return False
|
||||
z_list = json.loads(raw)
|
||||
target = None
|
||||
for z in z_list:
|
||||
if isinstance(z, dict) and z.get("id") == schnitt_id \
|
||||
and z.get("type") == "schnitt":
|
||||
target = z; break
|
||||
if target is None: return False
|
||||
pts = [[float(new_p1.X), float(new_p1.Y)],
|
||||
[float(new_p2.X), float(new_p2.Y)]]
|
||||
target["linePts"] = pts
|
||||
try:
|
||||
doc.Strings.SetString("dossier_zeichnungsebenen",
|
||||
json.dumps(z_list, ensure_ascii=False))
|
||||
except Exception as ex:
|
||||
print("[SCHNITT_GRIPS] persist linePts:", ex)
|
||||
return False
|
||||
|
||||
# Symbol regenerieren — Layer aus altem Symbol uebernehmen
|
||||
# (geschoss-spezifisch, soll nicht auf default-Layer wandern).
|
||||
import schnitte
|
||||
old_objs = schnitte.find_symbol_objects_for(doc, schnitt_id)
|
||||
symbol_layer_idx = -1
|
||||
if old_objs:
|
||||
try: symbol_layer_idx = old_objs[0].Attributes.LayerIndex
|
||||
except Exception: pass
|
||||
for o in old_objs:
|
||||
try: doc.Objects.Delete(o.Id, True)
|
||||
except Exception: pass
|
||||
|
||||
# Neue Curves mit aktualisierten linePts erzeugen
|
||||
p1 = rg.Point3d(float(pts[0][0]), float(pts[0][1]), 0)
|
||||
p2 = rg.Point3d(float(pts[1][0]), float(pts[1][1]), 0)
|
||||
dir_sign = int(target.get("dirSign", 1) or 1)
|
||||
new_curves = schnitte.make_schnitt_symbol(p1, p2, dir_sign,
|
||||
target.get("name", ""))
|
||||
first_new_id = None
|
||||
for i, crv in enumerate(new_curves):
|
||||
try:
|
||||
attrs = Rhino.DocObjects.ObjectAttributes()
|
||||
if symbol_layer_idx >= 0:
|
||||
attrs.LayerIndex = symbol_layer_idx
|
||||
attrs.SetUserString("dossier_schnitt_symbol", "1")
|
||||
attrs.SetUserString("dossier_schnitt_id", schnitt_id)
|
||||
gid = doc.Objects.AddCurve(crv, attrs)
|
||||
if i == 0 and gid and gid != System.Guid.Empty:
|
||||
first_new_id = gid
|
||||
except Exception as ex:
|
||||
print("[SCHNITT_GRIPS] add new symbol curve:", ex)
|
||||
|
||||
# Neue Hauptlinie selektieren — damit der Conduit die Marker
|
||||
# gleich wieder zeigt (sonst muesste der User nochmal klicken).
|
||||
if first_new_id:
|
||||
try: doc.Objects.Select(first_new_id, True)
|
||||
except Exception: pass
|
||||
|
||||
# Re-aktivieren falls dieser Schnitt aktiv ist — aber NUR die
|
||||
# Clipping-Planes neu aufbauen, View komplett in Ruhe lassen
|
||||
# (skip_view=True). User editiert im Plan, soll nicht ploetzlich
|
||||
# in die Section-View geschleudert oder gezoomt werden.
|
||||
try:
|
||||
active_id = doc.Strings.GetValue("dossier_active_id") or ""
|
||||
if active_id == schnitt_id:
|
||||
schnitte.activate_schnitt(doc, target, skip_view=True)
|
||||
except Exception as ex:
|
||||
print("[SCHNITT_GRIPS] re-activate:", ex)
|
||||
|
||||
# Panel-Broadcast (linePts haben sich geaendert, Ebenen-Panel will
|
||||
# ggf. mit-rendern)
|
||||
try:
|
||||
import rhinopanel
|
||||
rhinopanel._broadcast_state(doc)
|
||||
except Exception: pass
|
||||
try: doc.Views.Redraw()
|
||||
except Exception: pass
|
||||
return True
|
||||
except Exception as ex:
|
||||
print("[SCHNITT_GRIPS] update endpoint:", ex)
|
||||
return False
|
||||
|
||||
|
||||
# --- Display-Conduit ------------------------------------------------------
|
||||
|
||||
class _SchnittEndpointConduit(rd.DisplayConduit):
|
||||
def __init__(self):
|
||||
rd.DisplayConduit.__init__(self)
|
||||
self.hot_key = None # (schnitt_id, 'p1'|'p2')
|
||||
self.drag_key = None # waehrend aktivem Drag
|
||||
self.drag_preview = None # rg.Line — Live-Vorschau
|
||||
|
||||
def _collect(self, doc):
|
||||
"""Liefert Liste von (schnitt_id, z_entry, kind, world_pt) fuer alle
|
||||
Schnitte deren Symbol-Curves selektiert sind (dedupliziert nach Id).
|
||||
|
||||
Drei Marker pro Schnitt:
|
||||
- kind='p1' / 'p2' : Endpunkte (Endpoint-Drag)
|
||||
- kind='mid' : Mittelpunkt (ganze Linie translaten)"""
|
||||
out = []
|
||||
seen = set()
|
||||
try:
|
||||
sel = list(doc.Objects.GetSelectedObjects(False, False))
|
||||
except Exception: return out
|
||||
for obj in sel:
|
||||
sid = _read_schnitt_id(obj)
|
||||
if not sid or sid in seen: continue
|
||||
seen.add(sid)
|
||||
z = _find_schnitt_entry(doc, sid)
|
||||
if z is None: continue
|
||||
p1, p2 = _schnitt_endpoints(z)
|
||||
if p1 is not None: out.append((sid, z, "p1", p1))
|
||||
if p2 is not None: out.append((sid, z, "p2", p2))
|
||||
if p1 is not None and p2 is not None:
|
||||
mid = rg.Point3d((p1.X + p2.X) * 0.5,
|
||||
(p1.Y + p2.Y) * 0.5, 0)
|
||||
out.append((sid, z, "mid", mid))
|
||||
return out
|
||||
|
||||
def DrawForeground(self, e):
|
||||
try:
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return
|
||||
# Bei mid-Drag alle Marker desselben Schnitts ausblenden — die
|
||||
# ganze Linie bewegt sich, da macht das Zeichnen alter Positionen
|
||||
# nur Verwirrung. Bei Endpoint-Drag: nur den gezogenen Marker
|
||||
# ausblenden, andere bleiben als visueller Anker.
|
||||
is_mid_drag = (self.drag_key is not None
|
||||
and self.drag_key[1] == "mid")
|
||||
for sid, _z, kind, pt in self._collect(doc):
|
||||
if self.drag_key:
|
||||
if is_mid_drag and self.drag_key[0] == sid:
|
||||
continue
|
||||
if self.drag_key == (sid, kind):
|
||||
continue
|
||||
is_hot = self.hot_key and self.hot_key == (sid, kind)
|
||||
r = _MARKER_RADIUS_HOVER_PX if is_hot else _MARKER_RADIUS_PX
|
||||
fill = _MARKER_HOVER if is_hot else _MARKER_FILL
|
||||
# Mid-Marker visuell anders (Quadrat statt Kreis) damit
|
||||
# User sofort sieht: das verschiebt die ganze Linie.
|
||||
style = rd.PointStyle.Square if kind == "mid" \
|
||||
else rd.PointStyle.RoundControlPoint
|
||||
try:
|
||||
e.Display.DrawPoint(pt, style, r, fill)
|
||||
except Exception:
|
||||
try:
|
||||
e.Display.DrawDot(pt, "●", fill, _MARKER_BORDER)
|
||||
except Exception: pass
|
||||
if self.drag_preview is not None:
|
||||
try:
|
||||
e.Display.DrawLine(self.drag_preview, _MARKER_HOVER, 2)
|
||||
except Exception: pass
|
||||
except Exception as ex:
|
||||
print("[SCHNITT_GRIPS] DrawForeground:", ex)
|
||||
|
||||
|
||||
# --- MouseCallback --------------------------------------------------------
|
||||
|
||||
class _SchnittMouseHandler(Rhino.UI.MouseCallback):
|
||||
def __init__(self, conduit):
|
||||
Rhino.UI.MouseCallback.__init__(self)
|
||||
self.conduit = conduit
|
||||
self._busy = False
|
||||
|
||||
def _hit_test(self, view, screen_pt):
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return None
|
||||
try: vp = view.ActiveViewport
|
||||
except Exception: return None
|
||||
thresh2 = _HIT_RADIUS_PX * _HIT_RADIUS_PX
|
||||
for sid, z, kind, world_pt in self.conduit._collect(doc):
|
||||
try:
|
||||
s = vp.WorldToClient(world_pt)
|
||||
dx = s.X - screen_pt.X
|
||||
dy = s.Y - screen_pt.Y
|
||||
if (dx * dx + dy * dy) <= thresh2:
|
||||
return sid, z, kind, world_pt
|
||||
except Exception: continue
|
||||
return None
|
||||
|
||||
def OnMouseMove(self, e):
|
||||
if self._busy: return
|
||||
try:
|
||||
view = e.View
|
||||
if view is None: return
|
||||
hit = self._hit_test(view, e.ViewportPoint)
|
||||
new_key = (hit[0], hit[2]) if hit else None
|
||||
if new_key != self.conduit.hot_key:
|
||||
self.conduit.hot_key = new_key
|
||||
try: view.Redraw()
|
||||
except Exception: pass
|
||||
except Exception: pass
|
||||
|
||||
def OnMouseDown(self, e):
|
||||
if self._busy: return
|
||||
try:
|
||||
try:
|
||||
if "Left" not in str(e.MouseButton): return
|
||||
except Exception: pass
|
||||
view = e.View
|
||||
if view is None: return
|
||||
hit = self._hit_test(view, e.ViewportPoint)
|
||||
if hit is None: return
|
||||
try: e.Cancel = True
|
||||
except Exception: pass
|
||||
sid, z, kind, anchor_pt = hit
|
||||
self._start_drag(view.Document, sid, z, kind, anchor_pt)
|
||||
except Exception as ex:
|
||||
print("[SCHNITT_GRIPS] OnMouseDown:", ex)
|
||||
|
||||
def _start_drag(self, doc, schnitt_id, z, kind, anchor_pt):
|
||||
if doc is None: return
|
||||
p1, p2 = _schnitt_endpoints(z)
|
||||
if p1 is None or p2 is None: return
|
||||
|
||||
# Drei Drag-Modi:
|
||||
# - kind='p1'/'p2': Endpunkt verschieben (anderer bleibt fix)
|
||||
# - kind='mid' : ganze Linie translaten (Delta auf beide)
|
||||
is_mid = (kind == "mid")
|
||||
if is_mid:
|
||||
anchor = rg.Point3d((p1.X + p2.X) * 0.5,
|
||||
(p1.Y + p2.Y) * 0.5, 0)
|
||||
prompt = "Schnittlinie verschieben (Esc=Abbruch)"
|
||||
preview_initial = rg.Line(p1, p2)
|
||||
else:
|
||||
anchor = p2 if kind == "p1" else p1 # fixer Punkt
|
||||
prompt = "Schnittlinie-Endpunkt: neuer Punkt (Esc=Abbruch)"
|
||||
preview_initial = rg.Line(anchor, anchor_pt)
|
||||
|
||||
self.conduit.drag_key = (schnitt_id, kind)
|
||||
self.conduit.drag_preview = preview_initial
|
||||
self._busy = True
|
||||
|
||||
# Aktive Schnitt-Clipping-Planes sind gelockte Objekte mit grosser
|
||||
# Ausdehnung — wenn der Cursor waehrend GetPoint deren Edge kreuzt,
|
||||
# zeigt Rhino das "Verbot"-Cursor-Symbol (kein Pick auf Locked).
|
||||
# Workaround: vor dem GetPoint verstecken, nach Confirm/Cancel
|
||||
# restoren. Bei Confirm reaktiviert _update_linePts ohnehin die
|
||||
# Planes neu, das Restore ist dann no-op.
|
||||
hidden_clip_ids = []
|
||||
try:
|
||||
for obj in doc.Objects:
|
||||
if obj is None or obj.IsDeleted: continue
|
||||
try:
|
||||
if obj.Attributes.GetUserString("dossier_schnitt_clip") == "1" \
|
||||
and obj.Visible:
|
||||
if doc.Objects.Hide(obj.Id, True):
|
||||
hidden_clip_ids.append(obj.Id)
|
||||
except Exception: pass
|
||||
except Exception: pass
|
||||
|
||||
confirmed = False
|
||||
try:
|
||||
gp = Rhino.Input.Custom.GetPoint()
|
||||
gp.SetCommandPrompt(prompt)
|
||||
gp.SetBasePoint(anchor, True)
|
||||
gp.DrawLineFromPoint(anchor, True)
|
||||
def _on_mouse_move(sender, args):
|
||||
try:
|
||||
cur = args.Point
|
||||
if is_mid:
|
||||
dx = cur.X - anchor.X
|
||||
dy = cur.Y - anchor.Y
|
||||
np1 = rg.Point3d(p1.X + dx, p1.Y + dy, 0)
|
||||
np2 = rg.Point3d(p2.X + dx, p2.Y + dy, 0)
|
||||
self.conduit.drag_preview = rg.Line(np1, np2)
|
||||
else:
|
||||
self.conduit.drag_preview = rg.Line(anchor, cur)
|
||||
except Exception: pass
|
||||
try: gp.MouseMove += _on_mouse_move
|
||||
except Exception: pass
|
||||
res = gp.Get()
|
||||
if res == Rhino.Input.GetResult.Point:
|
||||
new_pt = gp.Point()
|
||||
if is_mid:
|
||||
dx = new_pt.X - anchor.X
|
||||
dy = new_pt.Y - anchor.Y
|
||||
new_p1 = rg.Point3d(p1.X + dx, p1.Y + dy, 0)
|
||||
new_p2 = rg.Point3d(p2.X + dx, p2.Y + dy, 0)
|
||||
else:
|
||||
if kind == "p1":
|
||||
new_p1, new_p2 = new_pt, p2
|
||||
else:
|
||||
new_p1, new_p2 = p1, new_pt
|
||||
confirmed = bool(_update_linePts(
|
||||
doc, schnitt_id, new_p1, new_p2))
|
||||
except Exception as ex:
|
||||
print("[SCHNITT_GRIPS] _start_drag:", ex)
|
||||
finally:
|
||||
if not confirmed:
|
||||
for pid in hidden_clip_ids:
|
||||
try: doc.Objects.Show(pid, True)
|
||||
except Exception: pass
|
||||
self.conduit.drag_key = None
|
||||
self.conduit.drag_preview = None
|
||||
self._busy = False
|
||||
try: doc.Views.Redraw()
|
||||
except Exception: pass
|
||||
|
||||
|
||||
# --- Install / Teardown ---------------------------------------------------
|
||||
|
||||
_STICKY_CONDUIT = "_dossier_schnitt_grips_conduit"
|
||||
_STICKY_HANDLER = "_dossier_schnitt_grips_handler"
|
||||
|
||||
|
||||
def install_handlers():
|
||||
"""Idempotent. Re-Load via _reset_panels.py disabled alte Refs zuerst."""
|
||||
try:
|
||||
old_conduit = sc.sticky.get(_STICKY_CONDUIT)
|
||||
if old_conduit is not None:
|
||||
try: old_conduit.Enabled = False
|
||||
except Exception: pass
|
||||
old_handler = sc.sticky.get(_STICKY_HANDLER)
|
||||
if old_handler is not None:
|
||||
try: old_handler.Enabled = False
|
||||
except Exception: pass
|
||||
conduit = _SchnittEndpointConduit()
|
||||
conduit.Enabled = True
|
||||
handler = _SchnittMouseHandler(conduit)
|
||||
handler.Enabled = True
|
||||
sc.sticky[_STICKY_CONDUIT] = conduit
|
||||
sc.sticky[_STICKY_HANDLER] = handler
|
||||
print("[SCHNITT_GRIPS] Endpoint-Conduit + Mouse-Handler aktiv")
|
||||
except Exception as ex:
|
||||
print("[SCHNITT_GRIPS] install:", ex)
|
||||
@@ -0,0 +1,646 @@
|
||||
#! 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).
|
||||
|
||||
Wichtig: die Planes sind Mode=Locked (User soll sie nicht greifen).
|
||||
doc.Objects.Delete() respektiert das Lock-Flag und schlaegt still
|
||||
fehl. Deshalb erst auf Normal-Mode wechseln, dann loeschen."""
|
||||
n = 0
|
||||
for obj in find_schnitt_clip_objects(doc):
|
||||
try:
|
||||
try:
|
||||
attrs = obj.Attributes.Duplicate()
|
||||
attrs.Mode = Rhino.DocObjects.ObjectMode.Normal
|
||||
doc.Objects.ModifyAttributes(obj, attrs, True)
|
||||
except Exception: pass
|
||||
if 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, skip_view=False):
|
||||
"""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.
|
||||
|
||||
skip_view=True: nur Clipping-Planes neu aufbauen, View komplett in
|
||||
Ruhe lassen. Nutzt der Grip-Drag-Pfad — User editiert die linePts
|
||||
im Plan, will NICHT dass die View ploetzlich in die Section springt
|
||||
und re-zoomt. Bei Doppelklick / Panel-Klick bleibt skip_view=False
|
||||
fuer den vollen Aktivierungs-Effekt.
|
||||
"""
|
||||
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.
|
||||
# Bei skip_view=True (Grip-Drag-Re-Activate) komplett ueberspringen.
|
||||
if not skip_view:
|
||||
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
|
||||
@@ -844,6 +844,198 @@ def generate_schichtenmodell(doc, contour_curves, progress=None):
|
||||
return created
|
||||
|
||||
|
||||
def generate_patch_from_contours(doc, contour_curves, progress=None):
|
||||
"""Erzeugt eine NURBS-Patch-Flaeche durch alle Hoehenlinien — der
|
||||
kanonische Rhino-Workflow fuer Terrain aus Konturen (Brep.CreatePatch
|
||||
fittet eine Surface durch Curves + Points). Loft funktioniert hier
|
||||
nicht, weil benachbarte Konturen unterschiedliche Topologie haben
|
||||
(Inseln, Halbinseln, geschlossen vs. offen am Rand).
|
||||
|
||||
UV-Spans werden aus dem Bounding-Box-Aspect-Ratio abgeleitet, so
|
||||
dass ein rechteckiger Site mehr Spans in der laengeren Richtung
|
||||
bekommt.
|
||||
|
||||
Liefert das erstellte RhinoObject oder None."""
|
||||
import System
|
||||
if not contour_curves: return None
|
||||
valid = [c for c in contour_curves if c is not None]
|
||||
if len(valid) < 2:
|
||||
if progress: progress("Patch braucht mindestens 2 Hoehenlinien")
|
||||
return None
|
||||
bb = rg.BoundingBox.Unset
|
||||
for c in valid:
|
||||
bb_c = c.GetBoundingBox(True)
|
||||
if not bb_c.IsValid: continue
|
||||
if not bb.IsValid:
|
||||
bb = bb_c
|
||||
else:
|
||||
bb.Union(bb_c)
|
||||
if not bb.IsValid:
|
||||
if progress: progress("Patch: ungueltige Bounding-Box")
|
||||
return None
|
||||
dx = bb.Max.X - bb.Min.X
|
||||
dy = bb.Max.Y - bb.Min.Y
|
||||
base_span = 40
|
||||
if dx >= dy and dy > 1e-6:
|
||||
aspect = dx / dy
|
||||
u_spans = int(round(base_span * math.sqrt(aspect)))
|
||||
v_spans = base_span
|
||||
elif dx > 1e-6:
|
||||
aspect = dy / dx
|
||||
u_spans = base_span
|
||||
v_spans = int(round(base_span * math.sqrt(aspect)))
|
||||
else:
|
||||
u_spans = v_spans = base_span
|
||||
u_spans = max(8, min(200, u_spans))
|
||||
v_spans = max(8, min(200, v_spans))
|
||||
if progress:
|
||||
progress("Patch aus {} Hoehenlinien ({}x{} UV-Spans)...".format(
|
||||
len(valid), u_spans, v_spans))
|
||||
geom_list = System.Collections.Generic.List[rg.GeometryBase]()
|
||||
for c in valid: geom_list.Add(c)
|
||||
try:
|
||||
brep = rg.Brep.CreatePatch(
|
||||
geom_list, u_spans, v_spans, doc.ModelAbsoluteTolerance)
|
||||
if brep is None:
|
||||
if progress: progress("Patch fehlgeschlagen (None zurueck)")
|
||||
return None
|
||||
gid = doc.Objects.AddBrep(brep)
|
||||
if gid and gid != System.Guid.Empty:
|
||||
obj = doc.Objects.Find(gid)
|
||||
if progress: progress("→ Patch-Terrain erzeugt")
|
||||
return obj
|
||||
except Exception as ex:
|
||||
if progress: progress("Patch-Fehler: {}".format(ex))
|
||||
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
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
#! python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
wand_grips.py
|
||||
Custom Endpoint-Grips fuer Waende — Display-Conduit + MouseCallback Overlay.
|
||||
|
||||
Problem das geloest wird:
|
||||
- wand_axis liegt auf dem Referenzlinien-Sublayer (Code 19). Wenn der
|
||||
User in einem Visibility-Mode ist der diesen Layer ausblendet, sind
|
||||
die Achsen + ihre nativen Rhino-Grips unsichtbar.
|
||||
- Native Grips sind 5–6 Pixel klein, schwer zu treffen.
|
||||
- Klick neben den Grip greift das Wand-Volumen → ganze Wand wird
|
||||
statt nur des Endpunkts verschoben.
|
||||
|
||||
Loesung:
|
||||
- Display-Conduit zeichnet bei jeder selektierten Wand zwei dicke,
|
||||
farbige Kreise an den Achs-Endpunkten — unabhaengig von der Layer-
|
||||
Visibility (Conduit-Overlay laeuft ueber dem normalen Rendering).
|
||||
- MouseCallback erkennt Mouse-Down nahe eines Markers, triggert eine
|
||||
Rhino-GetPoint-Interaktion (mit Snap-Engine, OrthoMode, Tracking-
|
||||
Linie zum fixen Endpunkt) und ersetzt nach Confirm den wand_axis.
|
||||
- Der existierende _on_object_replaced-Handler regiert das Volumen
|
||||
automatisch neu — keine manuelle Regen-Logik noetig.
|
||||
|
||||
Funktioniert sowohl wenn das wand_axis-Objekt eine Line ist als auch
|
||||
Polyline (Multi-Segment-Wand). Bei Polyline: nur erster + letzter
|
||||
Vertex sind als Endpoint-Grips ausgewiesen.
|
||||
|
||||
Module-Singleton — registriert sich einmal pro Rhino-Session via
|
||||
sticky-Flag, Re-Loads ueber _reset_panels raeumen den alten Handler
|
||||
sauber weg.
|
||||
"""
|
||||
import Rhino
|
||||
import Rhino.Display as rd
|
||||
import Rhino.Geometry as rg
|
||||
import scriptcontext as sc
|
||||
import System
|
||||
import System.Drawing as SD
|
||||
|
||||
|
||||
# --- Konstanten ------------------------------------------------------------
|
||||
|
||||
# Hit-Radius in Pixeln fuer Marker-Klick-Detection. Bewusst grosszuegig
|
||||
# (~ 14px) damit der User nicht zielen muss.
|
||||
_HIT_RADIUS_PX = 14
|
||||
|
||||
# Marker-Radius in Pixeln fuer das Drawing. 8px ist gut sichtbar ohne zu
|
||||
# stoeren. Bei Hover etwas groesser (10px).
|
||||
_MARKER_RADIUS_PX = 7
|
||||
_MARKER_RADIUS_HOVER_PX = 10
|
||||
|
||||
# Farben — accent-gruen analog zum Dossier-Theme.
|
||||
_MARKER_FILL = SD.Color.FromArgb(220, 95, 168, 150)
|
||||
_MARKER_BORDER = SD.Color.FromArgb(255, 47, 93, 84)
|
||||
_MARKER_HOVER = SD.Color.FromArgb(255, 255, 140, 60)
|
||||
|
||||
|
||||
# --- Helpers --------------------------------------------------------------
|
||||
|
||||
def _read_axis_type(obj):
|
||||
"""Schnelle Pruefung ob obj eine wand_axis ist. Importiert elemente
|
||||
lazy um Circular-Import beim Modul-Load zu vermeiden."""
|
||||
if obj is None or obj.IsDeleted: return False
|
||||
try:
|
||||
return obj.Attributes.GetUserString("dossier_element_type") == "wand_axis"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _find_axis_for_obj(doc, obj):
|
||||
"""Gibt die wand_axis zurueck zu der dieses Objekt gehoert.
|
||||
- Wenn obj selber eine wand_axis ist: return obj
|
||||
- Wenn obj ein wand_volume ist: suche Source via element_id
|
||||
|
||||
Liefert None bei Mismatch oder fehlenden Tags."""
|
||||
if obj is None or obj.IsDeleted: return None
|
||||
attrs = obj.Attributes
|
||||
try:
|
||||
t = attrs.GetUserString("dossier_element_type")
|
||||
eid = attrs.GetUserString("dossier_element_id")
|
||||
if not t or not eid: return None
|
||||
if t == "wand_axis": return obj
|
||||
if t != "wand_volume": return None
|
||||
# Source suchen — iteriere doc, finde wand_axis mit gleicher id
|
||||
for o in doc.Objects:
|
||||
if o is None or o.IsDeleted: continue
|
||||
a2 = o.Attributes
|
||||
try:
|
||||
if a2.GetUserString("dossier_element_id") == eid and \
|
||||
a2.GetUserString("dossier_element_type") == "wand_axis":
|
||||
return o
|
||||
except Exception: continue
|
||||
except Exception: pass
|
||||
return None
|
||||
|
||||
|
||||
def _curve_endpoints(curve):
|
||||
"""Liefert (start_pt, end_pt) fuer eine wand_axis. Funktioniert fuer
|
||||
LineCurve, PolylineCurve, NurbsCurve etc — alle Curve-Typen haben
|
||||
PointAtStart/PointAtEnd. Bei degenerierten Curves None."""
|
||||
if curve is None: return None, None
|
||||
try:
|
||||
return curve.PointAtStart, curve.PointAtEnd
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _replace_axis_endpoint(doc, axis_obj, kind, new_pt):
|
||||
"""Tauscht den Start- (kind='start') oder Endpunkt (kind='end') der
|
||||
wand_axis-Curve gegen new_pt. Geht intelligent um mit:
|
||||
- LineCurve: erzeuge neue Line vom fixen Punkt zum neuen Punkt
|
||||
- PolylineCurve: ersetze ersten/letzten Vertex, Rest bleibt
|
||||
- andere Curve-Typen: aktuell nur Line-Fallback (Erst/Letzt-Vertex
|
||||
rekonstruieren)
|
||||
Setzt die neue Geometrie via Objects.Replace — das feuert
|
||||
ReplaceRhinoObject-Event, was den existierenden Wand-Regen anwirft."""
|
||||
if axis_obj is None or axis_obj.IsDeleted: return False
|
||||
geom = axis_obj.Geometry
|
||||
if geom is None: return False
|
||||
try:
|
||||
# PolylineCurve mit > 2 Vertices: ersten/letzten Vertex ersetzen
|
||||
if isinstance(geom, rg.PolylineCurve):
|
||||
poly = geom.ToPolyline()
|
||||
if poly is None or poly.Count < 2: return False
|
||||
pts = list(poly)
|
||||
if kind == "start":
|
||||
pts[0] = new_pt
|
||||
else:
|
||||
pts[-1] = new_pt
|
||||
new_poly = rg.Polyline(pts)
|
||||
new_curve = rg.PolylineCurve(new_poly)
|
||||
else:
|
||||
# LineCurve oder unbekannter Typ → reduziere auf Line zwischen
|
||||
# neuem + altem fixen Punkt.
|
||||
p_start, p_end = _curve_endpoints(geom)
|
||||
if p_start is None or p_end is None: return False
|
||||
if kind == "start":
|
||||
new_curve = rg.LineCurve(new_pt, p_end)
|
||||
else:
|
||||
new_curve = rg.LineCurve(p_start, new_pt)
|
||||
return doc.Objects.Replace(axis_obj.Id, new_curve)
|
||||
except Exception as ex:
|
||||
print("[WAND_GRIPS] replace endpoint:", ex)
|
||||
return False
|
||||
|
||||
|
||||
# --- Display-Conduit -------------------------------------------------------
|
||||
|
||||
class _EndpointConduit(rd.DisplayConduit):
|
||||
"""Zeichnet bei jeder selektierten Wand zwei dicke Marker an den
|
||||
Achs-Endpunkten. hot_key (axis_guid_str, 'start'|'end') hebt einen
|
||||
Marker als Hover-Highlight hervor."""
|
||||
|
||||
def __init__(self):
|
||||
rd.DisplayConduit.__init__(self)
|
||||
self.hot_key = None # (axis_id_str, kind) — fuer Hover
|
||||
self.drag_key = None # (axis_id_str, kind) — waehrend aktivem Drag
|
||||
self.drag_preview = None # rg.Line — Live-Vorschau waehrend GetPoint
|
||||
|
||||
def _collect_endpoints(self, doc):
|
||||
"""Liefert Liste von (axis_obj, kind, world_pt) fuer alle selektier-
|
||||
ten Waende. Iteriert die Selektion + dedupliziert Achsen (jede
|
||||
Wand erscheint nur einmal, auch wenn mehrere Volumen mit-selek-
|
||||
tiert sind)."""
|
||||
out = []
|
||||
seen_axis = set()
|
||||
try:
|
||||
sel = list(doc.Objects.GetSelectedObjects(False, False))
|
||||
except Exception: return out
|
||||
for obj in sel:
|
||||
axis = _find_axis_for_obj(doc, obj)
|
||||
if axis is None: continue
|
||||
aid = str(axis.Id)
|
||||
if aid in seen_axis: continue
|
||||
seen_axis.add(aid)
|
||||
p_start, p_end = _curve_endpoints(axis.Geometry)
|
||||
if p_start is not None:
|
||||
out.append((axis, "start", p_start))
|
||||
if p_end is not None:
|
||||
out.append((axis, "end", p_end))
|
||||
return out
|
||||
|
||||
def DrawForeground(self, e):
|
||||
try:
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return
|
||||
for axis, kind, pt in self._collect_endpoints(doc):
|
||||
aid = str(axis.Id)
|
||||
# Skip den gerade gezogenen Marker — der wird via
|
||||
# drag_preview separat dargestellt.
|
||||
if self.drag_key and self.drag_key == (aid, kind):
|
||||
continue
|
||||
is_hot = self.hot_key and self.hot_key == (aid, kind)
|
||||
r = _MARKER_RADIUS_HOVER_PX if is_hot else _MARKER_RADIUS_PX
|
||||
fill = _MARKER_HOVER if is_hot else _MARKER_FILL
|
||||
# DrawPoint mit RoundControlPoint = gefuellter Kreis +
|
||||
# Border. Sieht aus wie ein dicker Grip-Punkt.
|
||||
try:
|
||||
e.Display.DrawPoint(
|
||||
pt, rd.PointStyle.RoundControlPoint, r, fill)
|
||||
except Exception:
|
||||
# Fallback fuer aeltere Rhino-Versionen: einfacher
|
||||
# DrawDot mit Label "●"
|
||||
e.Display.DrawDot(pt, "●", fill, _MARKER_BORDER)
|
||||
# Drag-Preview-Linie waehrend GetPoint aktiv ist
|
||||
if self.drag_preview is not None:
|
||||
try:
|
||||
e.Display.DrawLine(self.drag_preview, _MARKER_HOVER, 2)
|
||||
except Exception: pass
|
||||
except Exception as ex:
|
||||
print("[WAND_GRIPS] DrawForeground:", ex)
|
||||
|
||||
|
||||
# --- Mouse-Handler --------------------------------------------------------
|
||||
|
||||
class _EndpointMouseHandler(Rhino.UI.MouseCallback):
|
||||
"""Erkennt Mouse-Down nahe eines Endpoint-Markers + triggert Rhino-
|
||||
GetPoint fuer den neuen Endpunkt. Hover-Update via OnMouseMove fuer
|
||||
visuelles Highlight."""
|
||||
|
||||
def __init__(self, conduit):
|
||||
Rhino.UI.MouseCallback.__init__(self)
|
||||
self.conduit = conduit
|
||||
self._busy = False # Re-Entry-Schutz waehrend Drag-Get-Point
|
||||
|
||||
def _hit_test(self, view, screen_pt):
|
||||
"""Liefert (axis, kind, world_pt) wenn screen_pt nahe eines Endpoint-
|
||||
Markers liegt, sonst None. Iteriert die aktuelle Conduit-Liste."""
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return None
|
||||
try:
|
||||
vp = view.ActiveViewport
|
||||
except Exception: return None
|
||||
thresh2 = _HIT_RADIUS_PX * _HIT_RADIUS_PX
|
||||
for axis, kind, world_pt in self.conduit._collect_endpoints(doc):
|
||||
try:
|
||||
s = vp.WorldToClient(world_pt)
|
||||
dx = s.X - screen_pt.X
|
||||
dy = s.Y - screen_pt.Y
|
||||
if (dx * dx + dy * dy) <= thresh2:
|
||||
return axis, kind, world_pt
|
||||
except Exception: continue
|
||||
return None
|
||||
|
||||
def OnMouseMove(self, e):
|
||||
if self._busy: return
|
||||
try:
|
||||
view = e.View
|
||||
if view is None: return
|
||||
hit = self._hit_test(view, e.ViewportPoint)
|
||||
new_key = (str(hit[0].Id), hit[1]) if hit else None
|
||||
if new_key != self.conduit.hot_key:
|
||||
self.conduit.hot_key = new_key
|
||||
try: view.Redraw()
|
||||
except Exception: pass
|
||||
except Exception: pass
|
||||
|
||||
def OnMouseDown(self, e):
|
||||
if self._busy: return
|
||||
try:
|
||||
# Nur linke Maustaste
|
||||
try:
|
||||
btn = e.MouseButton
|
||||
btn_str = str(btn)
|
||||
if "Left" not in btn_str:
|
||||
return
|
||||
except Exception: pass
|
||||
view = e.View
|
||||
if view is None: return
|
||||
hit = self._hit_test(view, e.ViewportPoint)
|
||||
if hit is None: return
|
||||
# Default-Klick (Selection) abwuergen — wir uebernehmen
|
||||
try: e.Cancel = True
|
||||
except Exception: pass
|
||||
axis, kind, world_pt = hit
|
||||
self._start_drag(view.Document, axis, kind, world_pt)
|
||||
except Exception as ex:
|
||||
print("[WAND_GRIPS] OnMouseDown:", ex)
|
||||
|
||||
def _start_drag(self, doc, axis, kind, anchor_pt):
|
||||
"""Startet eine Rhino-GetPoint-Interaktion um den neuen Endpunkt
|
||||
zu picken. Der ANDERE Endpunkt (Fix-Punkt) wird als BasePoint
|
||||
gesetzt — damit kriegt der User Tracking-Linie, Ortho-Mode etc.
|
||||
wie bei _Move."""
|
||||
if doc is None: return
|
||||
geom = axis.Geometry
|
||||
if geom is None: return
|
||||
p_start, p_end = _curve_endpoints(geom)
|
||||
if p_start is None or p_end is None: return
|
||||
fixed_pt = p_end if kind == "start" else p_start
|
||||
# Conduit-State: drag-Marker hervorheben + Preview-Linie
|
||||
self.conduit.drag_key = (str(axis.Id), kind)
|
||||
self.conduit.drag_preview = rg.Line(fixed_pt, anchor_pt)
|
||||
self._busy = True
|
||||
try:
|
||||
gp = Rhino.Input.Custom.GetPoint()
|
||||
gp.SetCommandPrompt("Wand-Endpunkt: neuer Punkt (Esc=Abbruch)")
|
||||
gp.SetBasePoint(fixed_pt, True)
|
||||
gp.DrawLineFromPoint(fixed_pt, True)
|
||||
# Live-Preview ueber Conduit (zusaetzlich zu Rhinos eigener
|
||||
# Tracking-Linie) — sieht ueblich, hilft beim Verstehen welcher
|
||||
# Endpunkt sich bewegt.
|
||||
def _on_mouse_move(sender, args):
|
||||
try:
|
||||
self.conduit.drag_preview = rg.Line(fixed_pt, args.Point)
|
||||
except Exception: pass
|
||||
try: gp.MouseMove += _on_mouse_move
|
||||
except Exception: pass
|
||||
res = gp.Get()
|
||||
if res == Rhino.Input.GetResult.Point:
|
||||
new_pt = gp.Point()
|
||||
_replace_axis_endpoint(doc, axis, kind, new_pt)
|
||||
except Exception as ex:
|
||||
print("[WAND_GRIPS] _start_drag:", ex)
|
||||
finally:
|
||||
self.conduit.drag_key = None
|
||||
self.conduit.drag_preview = None
|
||||
self._busy = False
|
||||
try: doc.Views.Redraw()
|
||||
except Exception: pass
|
||||
|
||||
|
||||
# --- Install / Teardown ---------------------------------------------------
|
||||
|
||||
_STICKY_CONDUIT = "_dossier_wand_grips_conduit"
|
||||
_STICKY_HANDLER = "_dossier_wand_grips_handler"
|
||||
|
||||
|
||||
def install_handlers():
|
||||
"""Idempotente Registrierung. Bei Modul-Reload wird der alte Conduit
|
||||
+ Mouse-Handler zuerst disabled, dann neu erstellt + enabled. Sticky
|
||||
haelt die Referenzen am Leben (sonst Garbage-Collection)."""
|
||||
try:
|
||||
old_conduit = sc.sticky.get(_STICKY_CONDUIT)
|
||||
if old_conduit is not None:
|
||||
try: old_conduit.Enabled = False
|
||||
except Exception: pass
|
||||
old_handler = sc.sticky.get(_STICKY_HANDLER)
|
||||
if old_handler is not None:
|
||||
try: old_handler.Enabled = False
|
||||
except Exception: pass
|
||||
|
||||
conduit = _EndpointConduit()
|
||||
conduit.Enabled = True
|
||||
handler = _EndpointMouseHandler(conduit)
|
||||
handler.Enabled = True
|
||||
sc.sticky[_STICKY_CONDUIT] = conduit
|
||||
sc.sticky[_STICKY_HANDLER] = handler
|
||||
print("[WAND_GRIPS] Endpoint-Conduit + Mouse-Handler aktiv")
|
||||
except Exception as ex:
|
||||
print("[WAND_GRIPS] install:", ex)
|
||||
@@ -90,13 +90,13 @@ export default function AusschnittSettingsApp() {
|
||||
</Field>
|
||||
|
||||
<Field label="DARSTELLUNG"
|
||||
hint="SIA-400 Detaillierungsgrad — leer = per-Element-Setting respektieren">
|
||||
hint="SIA-400 Detaillierungsgrad fuer diesen Ausschnitt — leer = beim Restore nicht aendern">
|
||||
<select
|
||||
value={snap.darstellung || ''}
|
||||
onChange={(ev) => set({ darstellung: ev.target.value })}
|
||||
style={{ flex: 1, fontSize: 11, minWidth: 0 }}
|
||||
>
|
||||
<option value="">— per Element —</option>
|
||||
<option value="">— nicht aendern —</option>
|
||||
<option value="einfach">Einfach (1:100)</option>
|
||||
<option value="standard">Standard (1:50)</option>
|
||||
<option value="detail">Detail (1:20)</option>
|
||||
|
||||
+10
-3
@@ -22,6 +22,8 @@ function fmtNum(v) {
|
||||
|
||||
// Input-Komponente: zeigt formatierten Wert, sendet onCommit bei Enter/Blur.
|
||||
// Verhindert Update waehrend des Tippens, damit der Cursor nicht springt.
|
||||
// Pill-Chrome (Border, Radius, bg-input) kommt aus dem globalen CSS —
|
||||
// hier nur der Flex-Container fuer Input + optionalen Suffix.
|
||||
function NumInput({ value, onCommit, disabled, suffix, width }) {
|
||||
const [text, setText] = useState(fmtNum(value))
|
||||
const [focused, setFocused] = useState(false)
|
||||
@@ -32,7 +34,8 @@ function NumInput({ value, onCommit, disabled, suffix, width }) {
|
||||
else setText(fmtNum(value)) // ungueltig → zurueck auf alten Wert
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flex: width ? 0 : 1, width }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4,
|
||||
flex: width ? 0 : 1, width }}>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
@@ -44,9 +47,13 @@ function NumInput({ value, onCommit, disabled, suffix, width }) {
|
||||
if (e.key === 'Enter') { e.target.blur() }
|
||||
else if (e.key === 'Escape') { setText(fmtNum(value)); e.target.blur() }
|
||||
}}
|
||||
style={{ flex: 1, width: '100%', fontFamily: 'DM Mono, monospace', fontSize: 11, textAlign: 'right' }}
|
||||
style={{ flex: 1, width: '100%', textAlign: 'right' }}
|
||||
/>
|
||||
{suffix && <span style={{ fontSize: 10, color: 'var(--text-muted)', flexShrink: 0 }}>{suffix}</span>}
|
||||
{suffix && (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', flexShrink: 0 }}>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+21
-2
@@ -1777,9 +1777,10 @@ function OeffnungProperties({ oeff, onUpdate, onDelete, oeffStyles = [] }) {
|
||||
</span>
|
||||
<div style={{ flex: 1, display: 'flex' }}>
|
||||
<BarCombo
|
||||
value={oeff.darstellung || 'standard'}
|
||||
value={oeff.darstellung || 'auto'}
|
||||
onChange={(v) => onUpdate({ darstellung: v })}
|
||||
title="Detaillierungsgrad — beeinflusst die generierte Geometrie">
|
||||
title="Detaillierungsgrad — Auto folgt der Modelldarstellung in der Topbar">
|
||||
<option value="auto">Nach Modelldarstellung</option>
|
||||
<option value="einfach">Einfach (1:100)</option>
|
||||
<option value="standard">Standard (1:50)</option>
|
||||
<option value="detail">Detail (1:20)</option>
|
||||
@@ -1874,6 +1875,24 @@ function OeffnungProperties({ oeff, onUpdate, onDelete, oeffStyles = [] }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFenster && (oeff.tuerTyp || 'normal') === 'normal' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
|
||||
title="Sturzlinien-Anzeige bei 1:100 (gestrichelt). Aussen = Linie an Wand-Aussenseite, Innen = Wand-Innenseite, Beide = beide Linien">
|
||||
Sturz
|
||||
</span>
|
||||
<select
|
||||
value={oeff.sturz || 'beide'}
|
||||
onChange={(e) => onUpdate({ sturz: e.target.value })}
|
||||
style={{ flex: 1, fontSize: 11 }}>
|
||||
<option value="keine">Keine</option>
|
||||
<option value="innen">Innen</option>
|
||||
<option value="aussen">Aussen</option>
|
||||
<option value="beide">Beide</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Breite</span>
|
||||
<input type="text" value={breite}
|
||||
|
||||
+138
-63
@@ -15,6 +15,8 @@ import {
|
||||
openAbout, createText, setTextSettings,
|
||||
applyTextStyle, saveTextStyle, deleteTextStyle,
|
||||
setDarstellung,
|
||||
arrangeSelection,
|
||||
toggleReferenzlinien,
|
||||
} from './lib/rhinoBridge'
|
||||
|
||||
const PRESETS = [
|
||||
@@ -403,12 +405,11 @@ export default function OberleisteApp() {
|
||||
{/* Reihe 1, Spalte 2: Modelldarstellung (SIA-400 LoD) */}
|
||||
<BarCombo
|
||||
icon="tune"
|
||||
value={state.aktiveDarstellung || ''}
|
||||
value={state.aktiveDarstellung || 'einfach'}
|
||||
onChange={(v) => setDarstellung(v)}
|
||||
title="Darstellungs-Override fuer Fenster/Tueren (SIA-400 LoD)"
|
||||
title="Modelldarstellung — Default fuer Fenster/Tueren auf 'Auto'. Einzelobjekt-Override im Properties-Panel."
|
||||
width={PRESET_W}
|
||||
>
|
||||
<option value="">— per Element —</option>
|
||||
<option value="einfach">Einfach (1:100)</option>
|
||||
<option value="standard">Standard (1:50)</option>
|
||||
<option value="detail">Detail (1:20)</option>
|
||||
@@ -470,7 +471,7 @@ export default function OberleisteApp() {
|
||||
// Buttons-Pill: gleiche Logik wie View-Toggle (weiss default,
|
||||
// grün on hover, accent-fill wenn active)
|
||||
const PILL_W = 140 // Gleiche Breite fuer Dropdown + Buttons-Pill
|
||||
const N_BTN = 4
|
||||
const N_BTN = 3 // ohne Lineweights — der sitzt jetzt oben neben Dropdown
|
||||
const BTN_W = Math.floor(PILL_W / N_BTN) // jeder Button gleich breit
|
||||
const SegBtn = ({ icon, onClick, title, disabled, active, isFirst, isLast }) => (
|
||||
<button onClick={onClick} disabled={disabled} title={title}
|
||||
@@ -560,70 +561,85 @@ export default function OberleisteApp() {
|
||||
</div>
|
||||
{/* Reihe 1, Spalte 2: Gesetzter Massstab Dropdown — KEIN Icon, gleiche
|
||||
Breite wie Buttons-Pill darunter, exakt uebereinander */}
|
||||
{customMode ? (
|
||||
<input
|
||||
ref={customInputRef}
|
||||
disabled={isPerspective}
|
||||
type="text" placeholder="1:N"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') applyDraft()
|
||||
else if (e.key === 'Escape') cancelDraft()
|
||||
}}
|
||||
onBlur={applyDraft}
|
||||
style={{
|
||||
height: BAR_H, width: PILL_W,
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 999,
|
||||
padding: '0 12px', fontSize: 11,
|
||||
fontFamily: 'DM Mono, monospace',
|
||||
outline: 'none',
|
||||
}}
|
||||
title="Massstab eingeben (Enter = uebernehmen, Esc = abbrechen)"
|
||||
/>
|
||||
) : (
|
||||
<BarCombo
|
||||
value={dropdownValue}
|
||||
onChange={(v) => applyDropdown(v)}
|
||||
disabled={isPerspective}
|
||||
width={PILL_W}
|
||||
title="Gesetzter Massstab"
|
||||
>
|
||||
<option value="__none__">—</option>
|
||||
{PRESETS.map(p => (
|
||||
<option key={p.value} value={String(p.value)}>{p.label}</option>
|
||||
))}
|
||||
{appliedScale != null && !PRESETS.some(p => p.value === appliedScale) && (
|
||||
<option value={String(appliedScale)}>1:{appliedScale}</option>
|
||||
)}
|
||||
<option value="__custom__">Eigener…</option>
|
||||
</BarCombo>
|
||||
)}
|
||||
{/* Reihe 2, Spalte 2: Buttons-Pill — gleiche Breite wie Dropdown */}
|
||||
<div style={{
|
||||
display: 'inline-flex', width: PILL_W,
|
||||
height: BAR_H + 2, boxSizing: 'border-box',
|
||||
border: '1px solid var(--border)', borderRadius: 999,
|
||||
overflow: 'hidden', flexShrink: 0, justifySelf: 'start',
|
||||
}}>
|
||||
<SegBtn icon="percent" onClick={apply100} isFirst
|
||||
disabled={isPerspective || !appliedScale}
|
||||
title={appliedScale ? `Zoom auf 1:${appliedScale} snappen` : 'Erst einen Massstab wählen'} />
|
||||
<SegBtn icon="fit_screen" onClick={zoomExtents}
|
||||
title="Auf gesamten Inhalt zoomen" />
|
||||
<SegBtn icon="center_focus_strong" onClick={zoomSelection}
|
||||
title="Auf Selektion zoomen" />
|
||||
<SegBtn
|
||||
{/* Dropdown + Druck-Ansicht-Toggle in einer Flex-Reihe — der
|
||||
Toggle sitzt jetzt oben statt unten im Zoom-Pill, weil er
|
||||
massstabs-nah ist (Print-View = scale-korrekte Strichstaerken). */}
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
{customMode ? (
|
||||
<input
|
||||
ref={customInputRef}
|
||||
disabled={isPerspective}
|
||||
type="text" placeholder="1:N"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') applyDraft()
|
||||
else if (e.key === 'Escape') cancelDraft()
|
||||
}}
|
||||
onBlur={applyDraft}
|
||||
style={{
|
||||
height: BAR_H, width: PILL_W,
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 999,
|
||||
padding: '0 12px', fontSize: 11,
|
||||
fontFamily: 'DM Mono, monospace',
|
||||
outline: 'none',
|
||||
}}
|
||||
title="Massstab eingeben (Enter = uebernehmen, Esc = abbrechen)"
|
||||
/>
|
||||
) : (
|
||||
<BarCombo
|
||||
value={dropdownValue}
|
||||
onChange={(v) => applyDropdown(v)}
|
||||
disabled={isPerspective}
|
||||
width={PILL_W}
|
||||
title="Gesetzter Massstab"
|
||||
>
|
||||
<option value="__none__">—</option>
|
||||
{PRESETS.map(p => (
|
||||
<option key={p.value} value={String(p.value)}>{p.label}</option>
|
||||
))}
|
||||
{appliedScale != null && !PRESETS.some(p => p.value === appliedScale) && (
|
||||
<option value={String(appliedScale)}>1:{appliedScale}</option>
|
||||
)}
|
||||
<option value="__custom__">Eigener…</option>
|
||||
</BarCombo>
|
||||
)}
|
||||
<BarButton
|
||||
icon={state.showLineweights ? 'print' : 'edit'}
|
||||
active={state.showLineweights}
|
||||
onClick={() => setShowLineweights(!state.showLineweights)}
|
||||
isLast
|
||||
title={state.showLineweights
|
||||
? 'Print-View aktiv — klick zum Ausschalten'
|
||||
: 'Strichstärken anzeigen (Print-View)'} />
|
||||
: 'Strichstaerken anzeigen (Print-View)'} />
|
||||
</div>
|
||||
{/* Reihe 2, Spalte 2: Zoom-Pill + Referenzlinien-Toggle.
|
||||
Symmetrisch zur Reihe 1 (Dropdown + Lineweights-Button). */}
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<div style={{
|
||||
display: 'inline-flex', width: PILL_W,
|
||||
height: BAR_H + 2, boxSizing: 'border-box',
|
||||
border: '1px solid var(--border)', borderRadius: 999,
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
<SegBtn icon="percent" onClick={apply100} isFirst
|
||||
disabled={isPerspective || !appliedScale}
|
||||
title={appliedScale ? `Zoom auf 1:${appliedScale} snappen` : 'Erst einen Massstab wählen'} />
|
||||
<SegBtn icon="fit_screen" onClick={zoomExtents}
|
||||
title="Auf gesamten Inhalt zoomen" />
|
||||
<SegBtn icon="center_focus_strong" onClick={zoomSelection}
|
||||
isLast
|
||||
title="Auf Selektion zoomen" />
|
||||
</div>
|
||||
<BarButton
|
||||
icon={state.referenzlinienVisible === false ? 'visibility_off' : 'visibility'}
|
||||
active={state.referenzlinienVisible !== false}
|
||||
onClick={() => toggleReferenzlinien(state.referenzlinienVisible === false)}
|
||||
title={state.referenzlinienVisible === false
|
||||
? 'Referenzlinien einblenden (Wandachsen, Oeffnungs-Punkte)'
|
||||
: 'Referenzlinien ausblenden (Wandachsen, Oeffnungs-Punkte)'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -631,6 +647,65 @@ export default function OberleisteApp() {
|
||||
|
||||
<div style={sep} />
|
||||
|
||||
{/* ====== ANORDNEN (2D-Z-Stack via Rhino-DisplayOrder) ======
|
||||
2x2-Grid (quadratisch): oben Aufwaerts-Aktionen, unten Abwaerts.
|
||||
Reihe 1: Vorderste | 1 hoch (vertical_align_top, expand_less)
|
||||
Reihe 2: 1 runter | Hinterste (expand_more, vertical_align_bottom)
|
||||
Selection-Check + Rhino-DisplayOrder im Backend, keine Z-Offsets. */}
|
||||
{(() => {
|
||||
const CELL = 26 // quadratisch: 2 * CELL Breite, ~2 * BAR_H Hoehe
|
||||
const Btn = ({ icon, dir, title, isFirst }) => (
|
||||
<button onClick={() => arrangeSelection(dir)} title={title}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-item-hover)'
|
||||
e.currentTarget.style.color = 'var(--accent-light)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-input)'
|
||||
e.currentTarget.style.color = 'var(--text-primary)'
|
||||
}}
|
||||
style={{
|
||||
height: BAR_H, minHeight: BAR_H, maxHeight: BAR_H, width: CELL,
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||
border: 'none',
|
||||
borderLeft: isFirst ? 'none' : '1px solid var(--border)',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0,
|
||||
appearance: 'none', WebkitAppearance: 'none',
|
||||
lineHeight: 1, boxSizing: 'border-box',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}>
|
||||
<Icon name={icon} size={11} />
|
||||
</button>
|
||||
)
|
||||
const rowStyle = {
|
||||
display: 'inline-flex', width: CELL * 2,
|
||||
height: BAR_H + 2, boxSizing: 'border-box',
|
||||
border: '1px solid var(--border)', borderRadius: 999,
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0,
|
||||
}}>
|
||||
<div style={rowStyle}>
|
||||
<Btn icon="vertical_align_top" dir="front" isFirst
|
||||
title="In den Vordergrund (Bring to Front)" />
|
||||
<Btn icon="expand_less" dir="forward"
|
||||
title="Eine Stufe hoch (Bring Forward)" />
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<Btn icon="expand_more" dir="backward" isFirst
|
||||
title="Eine Stufe runter (Send Backward)" />
|
||||
<Btn icon="vertical_align_bottom" dir="back"
|
||||
title="In den Hintergrund (Send to Back)" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
<div style={sep} />
|
||||
|
||||
{/* ====== TEXT-Block (Vectorworks-Stil) ======
|
||||
Reihe 1: Style ▼ | Font ▼ | Size ▼
|
||||
Reihe 2: [B][I][U] | [L][C][R] | [+]
|
||||
|
||||
+45
-2
@@ -64,6 +64,7 @@ export default function SwisstopoApp() {
|
||||
const [getContours, setGetContours] = useState(false)
|
||||
const [getContourTin,setGetContourTin]= useState(false)
|
||||
const [getContourSchicht, setGetContourSchicht] = useState(false)
|
||||
const [getContourPatch, setGetContourPatch] = useState(false)
|
||||
const [contourInt, setContourInt] = useState('2.0')
|
||||
// TLM3D deaktiviert: swisstopo liefert nur GDB/SHP/GPKG — kein DXF.
|
||||
// Rhino kann das nicht nativ importieren; OSM-Importer ist die Alternative
|
||||
@@ -75,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)
|
||||
@@ -134,7 +140,7 @@ export default function SwisstopoApp() {
|
||||
|
||||
const handleImport = () => {
|
||||
if (!center) { addLog('Bitte zuerst einen Standort wählen'); return }
|
||||
if (!getBuild && !getTerrain && !getContours && !getContourTin && !getContourSchicht && !getTlm) {
|
||||
if (!getBuild && !getTerrain && !getContours && !getContourTin && !getContourSchicht && !getContourPatch && !getTlm) {
|
||||
addLog('Mindestens eine Datenquelle wählen'); return
|
||||
}
|
||||
setLogs([])
|
||||
@@ -147,6 +153,7 @@ export default function SwisstopoApp() {
|
||||
if (getContours) kinds.push('contours')
|
||||
if (getContourTin) kinds.push('contour_tin')
|
||||
if (getContourSchicht)kinds.push('contour_schicht')
|
||||
if (getContourPatch) kinds.push('contour_patch')
|
||||
if (getTlm) kinds.push('tlm')
|
||||
const tlmList = Object.entries(tlmKinds).filter(([, v]) => v).map(([k]) => k)
|
||||
send('RUN_IMPORT', {
|
||||
@@ -163,6 +170,8 @@ export default function SwisstopoApp() {
|
||||
buildVariant,
|
||||
contourInterval: contourInt,
|
||||
tlmKinds: tlmList,
|
||||
terrainAsVolume: terrainVolume,
|
||||
terrainVolumeDepth: parseFloat(terrainVolumeDepth) || 10,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -344,7 +353,16 @@ export default function SwisstopoApp() {
|
||||
<Icon name="stacks" size={13} /> Schichtenmodell aus Höhenlinien
|
||||
</label>
|
||||
</Field>
|
||||
{(getContours || getContourTin || getContourSchicht) && (
|
||||
<Field label=""
|
||||
hint="Patch-Terrain: NURBS-Surface gefittet durch alle Höhenlinien (Rhinos Patch-Befehl). Glatte, kontinuierliche Topo-Oberfläche — die kanonische Methode für Terrain aus Konturen.">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||||
fontSize: 11, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={getContourPatch}
|
||||
onChange={(e) => setGetContourPatch(e.target.checked)} />
|
||||
<Icon name="landscape" size={13} /> Patch-Terrain aus Höhenlinien
|
||||
</label>
|
||||
</Field>
|
||||
{(getContours || getContourTin || getContourSchicht || getContourPatch) && (
|
||||
<Field label="HÖHEN-ABSTAND">
|
||||
<Radio value={contourInt}
|
||||
options={[
|
||||
@@ -355,6 +373,31 @@ export default function SwisstopoApp() {
|
||||
onChange={setContourInt} />
|
||||
</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>
|
||||
|
||||
|
||||
@@ -263,15 +263,20 @@ function EbeneRow({ e, depth, hasChildren, expanded, onToggleExpand, active, mod
|
||||
minHeight: 24,
|
||||
}}
|
||||
>
|
||||
{/* Chevron sitzt visuell weiter rechts (marginLeft) — marginRight
|
||||
kompensiert das, damit die nachfolgenden Elemente (Auge, Code,
|
||||
Farbe, Name) nicht mitrutschen. Spacer fuer kinderlose Zeilen
|
||||
spiegelt dasselbe Offset, sonst springt die Eye-Spalte zwischen
|
||||
Parent- und Leaf-Zeilen. */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="btn-icon-xs"
|
||||
onClick={(ev) => { ev.stopPropagation(); onToggleExpand() }}
|
||||
title={expanded ? 'Einklappen' : 'Aufklappen'}
|
||||
style={{ width: 12, height: 12 }}
|
||||
style={{ width: 12, height: 12, marginLeft: 6, marginRight: -6 }}
|
||||
><Icon name={expanded ? 'expand_more' : 'chevron_right'} size={11} /></button>
|
||||
) : (
|
||||
<span style={{ width: 12, flexShrink: 0 }} />
|
||||
<span style={{ width: 12, flexShrink: 0, marginLeft: 6, marginRight: -6 }} />
|
||||
)}
|
||||
<button
|
||||
className={`btn-icon-sm ${eyeOn ? 'is-on' : ''}`}
|
||||
|
||||
@@ -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 <span className="chip chip-info">{name}</span>
|
||||
@@ -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 && <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 ? (
|
||||
<button
|
||||
className={`btn-icon-xs ${z.hasClipping ? 'is-on' : ''}`}
|
||||
@@ -128,14 +144,14 @@ export default function GeschossManager({
|
||||
mode, onModeChange,
|
||||
}) {
|
||||
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 addQuick = () => {
|
||||
// Standard: NICHT-Geschoss-Zeichnungsebene (z.B. Möblierung, Bemassung,
|
||||
// Plangrafik etc.). User kann via Row-Kontextmenue auf Geschoss
|
||||
// umschalten oder via Bearbeiten-Dialog (Pencil) ein Geschoss erstellen.
|
||||
const nonGeschossCount = zeichnungsebenen.filter(z => !z.isGeschoss).length
|
||||
const addZeichnung = () => {
|
||||
const nonGeschossCount = zeichnungsebenen.filter(
|
||||
z => !z.isGeschoss && z.type !== "schnitt").length
|
||||
const newZ = {
|
||||
id: `z_${Date.now()}`,
|
||||
name: `Zeichnung ${nonGeschossCount + 1}`,
|
||||
@@ -145,6 +161,151 @@ export default function GeschossManager({
|
||||
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) => {
|
||||
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z))
|
||||
// In "active" / "all_force" greift visible-Flag nicht — wer aufs Auge
|
||||
@@ -226,9 +387,9 @@ export default function GeschossManager({
|
||||
onGear={() => openGeschossDialog(zeichnungsebenen)}
|
||||
gearIcon="settings"
|
||||
gearTitle="Einstellungen"
|
||||
onSecond={addQuick}
|
||||
onSecond={openAddMenu}
|
||||
secondIcon="add"
|
||||
secondTitle="Zeichnungsebene hinzufügen"
|
||||
secondTitle="Hinzufuegen: Geschoss / Schnitt / Zeichnung"
|
||||
>
|
||||
{MODES.map(m => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
@@ -309,6 +470,120 @@ export default function GeschossManager({
|
||||
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>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
label="Ist Geschoss"
|
||||
checked={isG}
|
||||
onChange={(v) => 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 && (
|
||||
<Toggle
|
||||
label="Ist Geschoss"
|
||||
checked={isG}
|
||||
onChange={(v) => set({ isGeschoss: v })}
|
||||
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' }} />
|
||||
|
||||
@@ -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</button>
|
||||
</div>
|
||||
|
||||
+20
-4
@@ -161,17 +161,33 @@ input, select {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.16s, box-shadow 0.16s;
|
||||
transition: border-color 0.16s, background 0.16s, box-shadow 0.16s;
|
||||
}
|
||||
input:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-item-hover);
|
||||
}
|
||||
input:hover { border-color: var(--text-muted); }
|
||||
input:focus, select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||
}
|
||||
input[type="number"]::-webkit-inner-spin-button { opacity: 0.3; }
|
||||
/* Checkboxes + Color-Picker: kein Pill — native rendering. */
|
||||
input[type="checkbox"], input[type="radio"], input[type="color"],
|
||||
input[type="file"], input[type="range"] {
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
input[type="checkbox"]:hover, input[type="radio"]:hover,
|
||||
input[type="color"]:hover, input[type="file"]:hover,
|
||||
input[type="range"]:hover {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* Pill-shaped select */
|
||||
select {
|
||||
|
||||
@@ -173,6 +173,24 @@ export function openKameraPanel() { send('OPEN_KAMERA_PANEL', {}) }
|
||||
export function openElementeUebersicht() { send('OPEN_ELEMENTE_UEBERSICHT', {}) }
|
||||
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 }) }
|
||||
// Referenzlinien-Layer (Code 19) on/off — Shortcut zur Layer-Sichtbarkeit
|
||||
// damit der User nicht durchs Ebenen-Panel muss. Layer bleibt erhalten,
|
||||
// Ausschnitte speichern den State automatisch mit.
|
||||
export function toggleReferenzlinien(visible) {
|
||||
send('TOGGLE_REFERENZLINIEN', { visible: !!visible })
|
||||
}
|
||||
// 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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user