Compare commits
4 Commits
4111f12f32
...
b425421fdd
| Author | SHA1 | Date | |
|---|---|---|---|
| b425421fdd | |||
| 1e6bc68156 | |||
| 85f09390bc | |||
| afb59b6626 |
@@ -580,6 +580,10 @@ def _install_listeners(bridge):
|
|||||||
return
|
return
|
||||||
|
|
||||||
def on_idle(s, e):
|
def on_idle(s, e):
|
||||||
|
# Waehrend Bulk-Ops (z.B. _Delete bei 6000 Objekten): nicht pollen.
|
||||||
|
# tick_idle iteriert alle Doc-Objekte, das ist Overhead bei jedem
|
||||||
|
# Tick zwischen den einzelnen Deletes. CommandEnd refresht.
|
||||||
|
if sc.sticky.get("_dossier_bulk_op_active"): return
|
||||||
b = sc.sticky.get("dimensionen_bridge")
|
b = sc.sticky.get("dimensionen_bridge")
|
||||||
if b is not None:
|
if b is not None:
|
||||||
try: b.tick_idle()
|
try: b.tick_idle()
|
||||||
@@ -588,6 +592,7 @@ def _install_listeners(bridge):
|
|||||||
def on_select(s, e):
|
def on_select(s, e):
|
||||||
# Swisstopo-Import feuert tausende Selection-Events → bail.
|
# Swisstopo-Import feuert tausende Selection-Events → bail.
|
||||||
if sc.sticky.get("dossier_swisstopo_busy"): return
|
if sc.sticky.get("dossier_swisstopo_busy"): return
|
||||||
|
if sc.sticky.get("_dossier_bulk_op_active"): return
|
||||||
b = sc.sticky.get("dimensionen_bridge")
|
b = sc.sticky.get("dimensionen_bridge")
|
||||||
if b is not None:
|
if b is not None:
|
||||||
try: b._send_state(force=True)
|
try: b._send_state(force=True)
|
||||||
|
|||||||
+1076
-88
File diff suppressed because it is too large
Load Diff
+53
-14
@@ -302,9 +302,20 @@ def _ebene_fill_for_layer(doc, layer):
|
|||||||
print("[GESTALTUNG] _ebene_fill_for_layer: json-Fehler:", ex)
|
print("[GESTALTUNG] _ebene_fill_for_layer: json-Fehler:", ex)
|
||||||
return None
|
return None
|
||||||
if not isinstance(ebenen, list): return None
|
if not isinstance(ebenen, list): return None
|
||||||
for e in ebenen:
|
# Rekursiv durch Tree — Sub-Ebenen sind in children verschachtelt
|
||||||
|
def _find_by_code(lst, target):
|
||||||
|
for e in lst:
|
||||||
if not isinstance(e, dict): continue
|
if not isinstance(e, dict): continue
|
||||||
if e.get("code") != code: continue
|
if e.get("code") == target: return e
|
||||||
|
kids = e.get("children")
|
||||||
|
if isinstance(kids, list) and kids:
|
||||||
|
hit = _find_by_code(kids, target)
|
||||||
|
if hit is not None: return hit
|
||||||
|
return None
|
||||||
|
found = _find_by_code(ebenen, code)
|
||||||
|
if found is None: return None
|
||||||
|
e = found
|
||||||
|
if True:
|
||||||
f = e.get("fill")
|
f = e.get("fill")
|
||||||
if not isinstance(f, dict):
|
if not isinstance(f, dict):
|
||||||
print("[GESTALTUNG] _ebene_fill_for_layer: Ebene code={} hat KEIN fill-Feld".format(code))
|
print("[GESTALTUNG] _ebene_fill_for_layer: Ebene code={} hat KEIN fill-Feld".format(code))
|
||||||
@@ -471,19 +482,26 @@ def refresh_layer_fills(doc):
|
|||||||
if not isinstance(ebenen, list):
|
if not isinstance(ebenen, list):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Code -> fill-dict fuer schnellen Lookup
|
# Code -> fill-dict fuer schnellen Lookup. Rekursiv durch Children, damit
|
||||||
fill_by_code = {}
|
# Sub-Ebenen-Schraffuren auch wirken (sonst landen Polygone auf z.B.
|
||||||
for e in ebenen:
|
# 70_osm/7102_Gebaeudeumrisse nie in der Auto-Fill-Logik).
|
||||||
|
def _walk_fills(lst, out):
|
||||||
|
for e in lst:
|
||||||
if not isinstance(e, dict): continue
|
if not isinstance(e, dict): continue
|
||||||
f = e.get("fill")
|
f = e.get("fill")
|
||||||
if isinstance(f, dict) and f.get("pattern") not in (None, "None"):
|
if isinstance(f, dict) and f.get("pattern") not in (None, "None"):
|
||||||
fill_by_code[e.get("code")] = {
|
out[e.get("code")] = {
|
||||||
"pattern": f.get("pattern"),
|
"pattern": f.get("pattern"),
|
||||||
"source": f.get("source", "layer"),
|
"source": f.get("source", "layer"),
|
||||||
"color": f.get("color"),
|
"color": f.get("color"),
|
||||||
"scale": float(f.get("scale", 1.0)) if f.get("scale") is not None else 1.0,
|
"scale": float(f.get("scale", 1.0)) if f.get("scale") is not None else 1.0,
|
||||||
"rotation": float(f.get("rotation", 0.0)) if f.get("rotation") is not None else 0.0,
|
"rotation": float(f.get("rotation", 0.0)) if f.get("rotation") is not None else 0.0,
|
||||||
}
|
}
|
||||||
|
kids = e.get("children")
|
||||||
|
if isinstance(kids, list) and kids:
|
||||||
|
_walk_fills(kids, out)
|
||||||
|
fill_by_code = {}
|
||||||
|
_walk_fills(ebenen, fill_by_code)
|
||||||
if not fill_by_code:
|
if not fill_by_code:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -1367,23 +1385,38 @@ def _install_selection_listener(bridge):
|
|||||||
if sc.sticky.get(flag):
|
if sc.sticky.get(flag):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Selection-Refresh wird via Idle-Event debounced:
|
||||||
|
# Rhino feuert pro Object-Select/Deselect einzeln. Bei mass-Delete von
|
||||||
|
# 327 Objekten = 327 refresh-Calls → 327 IPC-Sends in den WebView →
|
||||||
|
# UI haengt + Command-History wird mit '[GESTALTUNG] sel: n=N'
|
||||||
|
# zugemuellt. Wir setzen nur ein Dirty-Flag und feuern EINMAL beim
|
||||||
|
# naechsten Idle-Tick.
|
||||||
def refresh(*args):
|
def refresh(*args):
|
||||||
# Waehrend Move/Rotate/Mirror/Scale schweigen — Rhino oszilliert die
|
|
||||||
# Selection pro transformiertem Object mehrfach (deselect→delete→add→
|
|
||||||
# reselect). Bei 7 Objekten sind das ~100 IPC-Sends in den WebView,
|
|
||||||
# was sich als „Regen" anfuehlt. elemente._on_command_end refresht
|
|
||||||
# nach dem Command einmalig.
|
|
||||||
# Waehrend Swisstopo-Import: Rhino selektiert jedes neu importierte
|
|
||||||
# Objekt → 5000 selection-changes → 5000 send-Calls in den WebView →
|
|
||||||
# erstickt den UI-Thread. Sticky-Flag => bail.
|
|
||||||
if sc.sticky.get("dossier_swisstopo_busy"): return
|
if sc.sticky.get("dossier_swisstopo_busy"): return
|
||||||
if sc.sticky.get("_dossier_user_transform_active"): return
|
if sc.sticky.get("_dossier_user_transform_active"): return
|
||||||
if sc.sticky.get("_dossier_undo_active"): return
|
if sc.sticky.get("_dossier_undo_active"): return
|
||||||
|
sc.sticky["_gestaltung_selection_dirty"] = True
|
||||||
|
|
||||||
|
def on_idle_flush(sender, args):
|
||||||
|
if not sc.sticky.get("_gestaltung_selection_dirty"): return
|
||||||
|
if sc.sticky.get("dossier_swisstopo_busy"): return
|
||||||
|
if sc.sticky.get("_dossier_user_transform_active"): return
|
||||||
|
if sc.sticky.get("_dossier_undo_active"): return
|
||||||
|
if sc.sticky.get("_dossier_bulk_op_active"): return
|
||||||
|
sc.sticky["_gestaltung_selection_dirty"] = False
|
||||||
b = sc.sticky.get("gestaltung_bridge")
|
b = sc.sticky.get("gestaltung_bridge")
|
||||||
if b is not None:
|
if b is not None:
|
||||||
try: b._send_selection()
|
try: b._send_selection()
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
|
|
||||||
|
# Idle-Hook nur einmal aufhaengen (sticky guard)
|
||||||
|
if not sc.sticky.get("_gestaltung_idle_attached"):
|
||||||
|
try:
|
||||||
|
Rhino.RhinoApp.Idle += on_idle_flush
|
||||||
|
sc.sticky["_gestaltung_idle_attached"] = True
|
||||||
|
except Exception as ex:
|
||||||
|
print("[GESTALTUNG] Idle-Hook fail:", ex)
|
||||||
|
|
||||||
def on_replace(sender, args):
|
def on_replace(sender, args):
|
||||||
"""Sync Curve↔Hatch bei Move/Replace:
|
"""Sync Curve↔Hatch bei Move/Replace:
|
||||||
- Curve hat _FILL_KEY (= hatch_id) → Hatch via Hatch.Create neu auf die
|
- Curve hat _FILL_KEY (= hatch_id) → Hatch via Hatch.Create neu auf die
|
||||||
@@ -1484,6 +1517,12 @@ def _install_selection_listener(bridge):
|
|||||||
Curve aufraeumen damit beim naechsten Toggle keine Geister-Referenz steht."""
|
Curve aufraeumen damit beim naechsten Toggle keine Geister-Referenz steht."""
|
||||||
if sc.sticky.get("_dossier_undo_active"): return
|
if sc.sticky.get("_dossier_undo_active"): return
|
||||||
if sc.sticky.get("_elemente_regen_busy"): return
|
if sc.sticky.get("_elemente_regen_busy"): return
|
||||||
|
# Bulk-Delete (SelAll + Delete): pro-Object Hatch-Sync ueberspringen
|
||||||
|
# — bei 6000 Objekten waere das massive Overhead. Hatch-Verweise
|
||||||
|
# wuerden zwar nicht aufgeraeumt aber das ist tolerierbar
|
||||||
|
# (Sticky-Cache laeuft auch ohne Cleanup ab, alte Eintraege bleiben
|
||||||
|
# nur unsichtbar liegen).
|
||||||
|
if sc.sticky.get("_dossier_bulk_op_active"): return
|
||||||
obj = args.TheObject
|
obj = args.TheObject
|
||||||
if obj is None or obj.Id in _processing:
|
if obj is None or obj.Id in _processing:
|
||||||
return
|
return
|
||||||
|
|||||||
+143
-87
@@ -287,19 +287,84 @@ def _apply_section_style(doc, layer, section_cfg, layer_color):
|
|||||||
print(diag, "OK applied")
|
print(diag, "OK applied")
|
||||||
|
|
||||||
|
|
||||||
|
def walk_ebenen(ebenen, parent_path=()):
|
||||||
|
"""Iteriert Ebenen-Baum (flach + Children). Liefert Tuples
|
||||||
|
(path, ebene) wobei path ein Tuple der Codes von der Root bis zu dieser
|
||||||
|
Ebene ist (inkl. eigener Code). Beispiel:
|
||||||
|
walk_ebenen([{'code':'20','children':[{'code':'01'}]}])
|
||||||
|
→ [(('20',), e20), (('20','01'), e01)]"""
|
||||||
|
out = []
|
||||||
|
if not ebenen: return out
|
||||||
|
for e in ebenen:
|
||||||
|
if not isinstance(e, dict): continue
|
||||||
|
code = e.get("code")
|
||||||
|
if not code: continue
|
||||||
|
path = parent_path + (code,)
|
||||||
|
out.append((path, e))
|
||||||
|
children = e.get("children")
|
||||||
|
if isinstance(children, list) and children:
|
||||||
|
out.extend(walk_ebenen(children, path))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ebene_layer(doc, parent_id, e, diag_prefix=""):
|
||||||
|
"""Findet/erstellt einen Sublayer fuer eine Ebene unter parent_id.
|
||||||
|
Liefert den layer_idx oder -1. Setzt Farbe/LW/Section-Style."""
|
||||||
|
code = e.get("code") or ""
|
||||||
|
name = e.get("name") or "Ebene"
|
||||||
|
sub_name = "{}_{}".format(code, name) if code else name
|
||||||
|
col = _color(e.get("color"))
|
||||||
|
lw = float(e.get("lw", 0.13))
|
||||||
|
sub_idx = _find_sublayer_by_code(doc, parent_id, code) if code else -1
|
||||||
|
if sub_idx < 0:
|
||||||
|
sub_idx = _add_layer(doc, sub_name, parent_id, col, lw)
|
||||||
|
if sub_idx >= 0 and code:
|
||||||
|
doc.Layers[sub_idx].SetUserString("dossier_code", code)
|
||||||
|
else:
|
||||||
|
sub = doc.Layers[sub_idx]
|
||||||
|
if sub.Name != sub_name: sub.Name = sub_name
|
||||||
|
sub.Color = col
|
||||||
|
try:
|
||||||
|
import massstab as _ms
|
||||||
|
_ms.write_plotweight(doc, sub, float(lw))
|
||||||
|
except Exception:
|
||||||
|
sub.PlotWeight = lw
|
||||||
|
if code: sub.SetUserString("dossier_code", code)
|
||||||
|
# Section Style anwenden (Py3-only — IPy 2.7 no-op)
|
||||||
|
try:
|
||||||
|
_apply_section_style(doc, doc.Layers[sub_idx],
|
||||||
|
e.get("section"), e.get("color"))
|
||||||
|
except Exception as ex:
|
||||||
|
print("[EBENEN] section-style apply ({}{}): {}".format(
|
||||||
|
diag_prefix, sub_name, ex))
|
||||||
|
return sub_idx
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ebenen_recursive(doc, parent_id, ebenen, diag_prefix=""):
|
||||||
|
"""Rekursive Ebenen-Erstellung: jeder Eintrag wird als Sublayer angelegt,
|
||||||
|
seine 'children' werden unter dem neu erstellten Sublayer angelegt."""
|
||||||
|
if not ebenen: return
|
||||||
|
for e in ebenen:
|
||||||
|
if not isinstance(e, dict): continue
|
||||||
|
sub_idx = _build_ebene_layer(doc, parent_id, e, diag_prefix=diag_prefix)
|
||||||
|
if sub_idx < 0: continue
|
||||||
|
children = e.get("children")
|
||||||
|
if isinstance(children, list) and children:
|
||||||
|
child_parent_id = doc.Layers[sub_idx].Id
|
||||||
|
_build_ebenen_recursive(doc, child_parent_id, children,
|
||||||
|
diag_prefix=diag_prefix + e.get("name", "") + "/")
|
||||||
|
|
||||||
|
|
||||||
def build_layers(doc, zeichnungsebenen, ebenen):
|
def build_layers(doc, zeichnungsebenen, ebenen):
|
||||||
"""
|
"""Stellt sicher dass fuer jede Zeichnungsebene ein Parent-Layer existiert
|
||||||
Stellt sicher dass fuer jede Zeichnungsebene ein Parent-Layer existiert
|
und unter jedem alle Ebenen (rekursiv inkl. children) als Sublayer angelegt
|
||||||
und unter jedem alle Ebenen als Sublayer angelegt/aktualisiert sind.
|
/ aktualisiert sind."""
|
||||||
"""
|
|
||||||
for z in zeichnungsebenen:
|
for z in zeichnungsebenen:
|
||||||
z_id = z["id"]
|
z_id = z["id"]
|
||||||
z_name = z["name"]
|
z_name = z["name"]
|
||||||
|
|
||||||
# Parent finden oder anlegen
|
# Parent finden oder anlegen
|
||||||
idx = _find_top_by_id(doc, z_id)
|
idx = _find_top_by_id(doc, z_id)
|
||||||
if idx < 0:
|
if idx < 0: idx = _find_top_by_name(doc, z_name)
|
||||||
idx = _find_top_by_name(doc, z_name)
|
|
||||||
if idx < 0:
|
if idx < 0:
|
||||||
idx = _add_layer(doc, z_name)
|
idx = _add_layer(doc, z_name)
|
||||||
doc.Layers[idx].SetUserString("dossier_id", z_id)
|
doc.Layers[idx].SetUserString("dossier_id", z_id)
|
||||||
@@ -308,55 +373,34 @@ def build_layers(doc, zeichnungsebenen, ebenen):
|
|||||||
if parent.Name != z_name:
|
if parent.Name != z_name:
|
||||||
parent.Name = z_name
|
parent.Name = z_name
|
||||||
parent.SetUserString("dossier_id", z_id)
|
parent.SetUserString("dossier_id", z_id)
|
||||||
|
|
||||||
parent_id = doc.Layers[idx].Id
|
parent_id = doc.Layers[idx].Id
|
||||||
|
_build_ebenen_recursive(doc, parent_id, ebenen,
|
||||||
# Sublayer pro Ebene
|
diag_prefix=z_name + "/")
|
||||||
for e in ebenen:
|
|
||||||
sub_name = "{}_{}".format(e["code"], e["name"])
|
|
||||||
col = _color(e.get("color"))
|
|
||||||
lw = float(e.get("lw", 0.13))
|
|
||||||
sub_idx = _find_sublayer_by_code(doc, parent_id, e["code"])
|
|
||||||
if sub_idx < 0:
|
|
||||||
sub_idx = _add_layer(doc, sub_name, parent_id, col, lw)
|
|
||||||
doc.Layers[sub_idx].SetUserString("dossier_code", e["code"])
|
|
||||||
else:
|
|
||||||
sub = doc.Layers[sub_idx]
|
|
||||||
if sub.Name != sub_name:
|
|
||||||
sub.Name = sub_name
|
|
||||||
sub.Color = col
|
|
||||||
try:
|
|
||||||
import massstab as _ms
|
|
||||||
_ms.write_plotweight(doc, sub, float(lw))
|
|
||||||
except Exception:
|
|
||||||
sub.PlotWeight = lw
|
|
||||||
sub.SetUserString("dossier_code", e["code"])
|
|
||||||
|
|
||||||
# Section Style anwenden (Py3-only — IPy 2.7 no-op)
|
|
||||||
try:
|
|
||||||
_apply_section_style(doc, doc.Layers[sub_idx],
|
|
||||||
e.get("section"), e.get("color"))
|
|
||||||
except Exception as ex:
|
|
||||||
print("[EBENEN] section-style apply ({}): {}".format(sub_name, ex))
|
|
||||||
|
|
||||||
doc.Views.Redraw()
|
doc.Views.Redraw()
|
||||||
print("[EBENEN] {} Zeichnungsebenen x {} Ebenen aktualisiert".format(
|
n_total = len(walk_ebenen(ebenen))
|
||||||
len(zeichnungsebenen), len(ebenen)))
|
print("[EBENEN] {} Zeichnungsebenen x {} Ebenen aktualisiert (inkl. {} Sub)".format(
|
||||||
|
len(zeichnungsebenen), len(ebenen), max(0, n_total - len(ebenen))))
|
||||||
|
|
||||||
|
|
||||||
|
def _layer_matches_code(layer, code):
|
||||||
|
"""True wenn der Layer zu der Ebene mit `code` gehoert. Akzeptiert
|
||||||
|
sowohl Top-Sub-Layer (Geschoss/CODE_Name) als auch Sub-Sub-Layer
|
||||||
|
(Geschoss/Parent/CODE_Name) — Match via Name-Prefix `code_`."""
|
||||||
|
if _is_top_level(layer): return False
|
||||||
|
return layer.Name.startswith(code + "_")
|
||||||
|
|
||||||
|
|
||||||
def update_layer_style(doc, code, color_hex=None, lw=None):
|
def update_layer_style(doc, code, color_hex=None, lw=None):
|
||||||
"""Aendert Farbe und/oder Stiftdicke fuer alle Sublayer mit dem gegebenen Code."""
|
"""Aendert Farbe und/oder Stiftdicke fuer alle Sublayer mit dem gegebenen
|
||||||
|
Code — auch tief verschachtelte (Sub-Sub-Layer mit gleichem Code-Prefix)."""
|
||||||
col = _color(color_hex) if color_hex else None
|
col = _color(color_hex) if color_hex else None
|
||||||
try:
|
try:
|
||||||
import massstab as _ms
|
import massstab as _ms
|
||||||
except Exception:
|
except Exception:
|
||||||
_ms = None
|
_ms = None
|
||||||
for i, layer in enumerate(doc.Layers):
|
for layer in doc.Layers:
|
||||||
if _is_top_level(layer):
|
if not _layer_matches_code(layer, code): continue
|
||||||
continue
|
if col is not None: layer.Color = col
|
||||||
if layer.Name.startswith(code + "_"):
|
|
||||||
if col is not None:
|
|
||||||
layer.Color = col
|
|
||||||
if lw is not None:
|
if lw is not None:
|
||||||
if _ms is not None:
|
if _ms is not None:
|
||||||
_ms.write_plotweight(doc, layer, float(lw))
|
_ms.write_plotweight(doc, layer, float(lw))
|
||||||
@@ -366,20 +410,16 @@ def update_layer_style(doc, code, color_hex=None, lw=None):
|
|||||||
|
|
||||||
|
|
||||||
def set_ebene_visible(doc, code, visible):
|
def set_ebene_visible(doc, code, visible):
|
||||||
"""Schaltet alle Sublayer mit Code in/aus Zeichnungsebenen."""
|
"""Schaltet alle Sublayer mit Code in/aus (auch tief verschachtelte)."""
|
||||||
for i, layer in enumerate(doc.Layers):
|
for layer in doc.Layers:
|
||||||
if _is_top_level(layer):
|
if _layer_matches_code(layer, code):
|
||||||
continue
|
|
||||||
if layer.Name.startswith(code + "_"):
|
|
||||||
layer.IsVisible = visible
|
layer.IsVisible = visible
|
||||||
doc.Views.Redraw()
|
doc.Views.Redraw()
|
||||||
|
|
||||||
|
|
||||||
def set_ebene_locked(doc, code, locked):
|
def set_ebene_locked(doc, code, locked):
|
||||||
for i, layer in enumerate(doc.Layers):
|
for layer in doc.Layers:
|
||||||
if _is_top_level(layer):
|
if _layer_matches_code(layer, code):
|
||||||
continue
|
|
||||||
if layer.Name.startswith(code + "_"):
|
|
||||||
layer.IsLocked = locked
|
layer.IsLocked = locked
|
||||||
doc.Views.Redraw()
|
doc.Views.Redraw()
|
||||||
|
|
||||||
@@ -613,14 +653,33 @@ def cleanup_default_layers(doc):
|
|||||||
print("[EBENEN] Default-Layer entfernt: {}".format(", ".join(deleted)))
|
print("[EBENEN] Default-Layer entfernt: {}".format(", ".join(deleted)))
|
||||||
|
|
||||||
|
|
||||||
|
def _find_sublayer_by_code_recursive(doc, parent_id, code):
|
||||||
|
"""Sucht einen Sub-Layer mit `code` unter parent_id — auch tief
|
||||||
|
verschachtelt (Sub-Sub-Layer mit gleichem Code-Prefix). Liefert
|
||||||
|
layer_index oder -1."""
|
||||||
|
prefix = code + "_"
|
||||||
|
direct = []
|
||||||
|
for i, layer in enumerate(doc.Layers):
|
||||||
|
if layer is None or layer.IsDeleted: continue
|
||||||
|
if layer.ParentLayerId == parent_id:
|
||||||
|
if layer.Name.startswith(prefix): return i
|
||||||
|
direct.append(layer.Id)
|
||||||
|
for child_id in direct:
|
||||||
|
idx = _find_sublayer_by_code_recursive(doc, child_id, code)
|
||||||
|
if idx >= 0: return idx
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
def set_active_sublayer(doc, zeichnungsebene_id, code):
|
def set_active_sublayer(doc, zeichnungsebene_id, code):
|
||||||
"""Macht den Sublayer 'code' unter Zeichnungsebene 'zeichnungsebene_id' aktiv."""
|
"""Macht den Sublayer 'code' unter Zeichnungsebene 'zeichnungsebene_id'
|
||||||
|
aktiv. Sucht rekursiv durch verschachtelte Sub-Layer (z.B. 70_osm/
|
||||||
|
7101_Strassen liegt zwei Ebenen tief)."""
|
||||||
parent_idx = _find_top_by_id(doc, zeichnungsebene_id)
|
parent_idx = _find_top_by_id(doc, zeichnungsebene_id)
|
||||||
if parent_idx < 0:
|
if parent_idx < 0:
|
||||||
print("[EBENEN] Parent-Layer fuer Zeichnungsebene {} nicht gefunden".format(zeichnungsebene_id))
|
print("[EBENEN] Parent-Layer fuer Zeichnungsebene {} nicht gefunden".format(zeichnungsebene_id))
|
||||||
return
|
return
|
||||||
parent_id = doc.Layers[parent_idx].Id
|
parent_id = doc.Layers[parent_idx].Id
|
||||||
sub_idx = _find_sublayer_by_code(doc, parent_id, code)
|
sub_idx = _find_sublayer_by_code_recursive(doc, parent_id, code)
|
||||||
if sub_idx >= 0:
|
if sub_idx >= 0:
|
||||||
doc.Layers.SetCurrentLayerIndex(sub_idx, True)
|
doc.Layers.SetCurrentLayerIndex(sub_idx, True)
|
||||||
else:
|
else:
|
||||||
@@ -631,10 +690,16 @@ def apply_visibility(doc, zeichnungsebenen, ebenen, active_z_id, active_code, z_
|
|||||||
"""
|
"""
|
||||||
Kombinierte Sichtbarkeit aus Z-Mode (Zeichnungsebenen) und E-Mode (Ebenen).
|
Kombinierte Sichtbarkeit aus Z-Mode (Zeichnungsebenen) und E-Mode (Ebenen).
|
||||||
Beide Modi: 'all' | 'active' | 'grey' | 'grey_locked'
|
Beide Modi: 'all' | 'active' | 'grey' | 'grey_locked'
|
||||||
|
|
||||||
|
Versteht den hierarchischen Ebenen-Baum: Children erben ParentLayerId vom
|
||||||
|
Sub-Layer (nicht vom Geschoss). Sub-Sub-Layer werden rekursiv mitgepflegt.
|
||||||
"""
|
"""
|
||||||
canonical = {e["code"]: _color(e.get("color")) for e in ebenen}
|
# Flat walk durch Ebenen-Tree (top + children) — alle Codes mit ihren
|
||||||
e_eye_vis = {e["code"]: e.get("visible", True) for e in ebenen}
|
# Eye/Lock-Flags.
|
||||||
e_eye_locked = {e["code"]: e.get("locked", False) for e in ebenen}
|
flat_ebenen = [e for _path, e in walk_ebenen(ebenen)]
|
||||||
|
canonical = {e["code"]: _color(e.get("color")) for e in flat_ebenen}
|
||||||
|
e_eye_vis = {e["code"]: e.get("visible", True) for e in flat_ebenen}
|
||||||
|
e_eye_locked = {e["code"]: e.get("locked", False) for e in flat_ebenen}
|
||||||
|
|
||||||
id_to_top, name_to_top, children_by_parent = {}, {}, {}
|
id_to_top, name_to_top, children_by_parent = {}, {}, {}
|
||||||
for layer in doc.Layers:
|
for layer in doc.Layers:
|
||||||
@@ -693,17 +758,15 @@ def apply_visibility(doc, zeichnungsebenen, ebenen, active_z_id, active_code, z_
|
|||||||
if not p_vis:
|
if not p_vis:
|
||||||
continue # Children erben Parent-Hidden
|
continue # Children erben Parent-Hidden
|
||||||
|
|
||||||
# E-Mode -> Sublayer-Zustand
|
# E-Mode → Sub-Layer (rekursiv durch Tree; Sub-Sub-Layer haben Parent
|
||||||
for child in children:
|
# = Sub-Layer, nicht das Geschoss — also iterativ in die Tiefe).
|
||||||
if "_" not in child.Name:
|
def _apply_to_sublayer(child, p_grey_eff):
|
||||||
continue
|
if "_" not in child.Name: return
|
||||||
code = child.Name.split("_", 1)[0]
|
code = child.Name.split("_", 1)[0]
|
||||||
if code not in canonical:
|
if code not in canonical: return
|
||||||
continue
|
|
||||||
is_active_e = (code == active_code)
|
is_active_e = (code == active_code)
|
||||||
eye_v = e_eye_vis.get(code, True)
|
eye_v = e_eye_vis.get(code, True)
|
||||||
eye_l = e_eye_locked.get(code, False)
|
eye_l = e_eye_locked.get(code, False)
|
||||||
|
|
||||||
if is_active_e:
|
if is_active_e:
|
||||||
e_vis, e_grey, e_lock = True, False, False
|
e_vis, e_grey, e_lock = True, False, False
|
||||||
elif e_mode == "active":
|
elif e_mode == "active":
|
||||||
@@ -716,35 +779,28 @@ def apply_visibility(doc, zeichnungsebenen, ebenen, active_z_id, active_code, z_
|
|||||||
e_vis, e_grey, e_lock = True, True, True
|
e_vis, e_grey, e_lock = True, True, True
|
||||||
else: # grey
|
else: # grey
|
||||||
e_vis, e_grey, e_lock = True, True, False
|
e_vis, e_grey, e_lock = True, True, False
|
||||||
|
|
||||||
# Kombination
|
|
||||||
child_vis = e_vis
|
child_vis = e_vis
|
||||||
child_grey = p_grey or e_grey
|
child_grey = p_grey_eff or e_grey
|
||||||
child_lock = e_lock or eye_l
|
child_lock = e_lock or eye_l
|
||||||
|
|
||||||
changed = False
|
changed = False
|
||||||
if child.IsVisible != child_vis:
|
if child.IsVisible != child_vis:
|
||||||
child.IsVisible = child_vis
|
child.IsVisible = child_vis; changed = True
|
||||||
changed = True
|
|
||||||
if child.IsLocked != child_lock:
|
if child.IsLocked != child_lock:
|
||||||
child.IsLocked = child_lock
|
child.IsLocked = child_lock; changed = True
|
||||||
changed = True
|
|
||||||
if child_grey:
|
if child_grey:
|
||||||
if child.Color != GREY:
|
if child.Color != GREY:
|
||||||
child.Color = GREY
|
child.Color = GREY; changed = True
|
||||||
changed = True
|
|
||||||
else:
|
else:
|
||||||
canon = canonical.get(code)
|
canon = canonical.get(code)
|
||||||
if canon is not None and child.Color != canon:
|
if canon is not None and child.Color != canon:
|
||||||
child.Color = canon
|
child.Color = canon; changed = True
|
||||||
changed = True
|
|
||||||
# In neueren Rhino-Versionen committed der Property-Setter direkt,
|
|
||||||
# in manchen Faellen (besonders auf Mac) wird IsLocked nicht
|
|
||||||
# persistiert ohne explizites Modify. Defensiv:
|
|
||||||
if changed:
|
if changed:
|
||||||
try:
|
try: doc.Layers.Modify(child, child.LayerIndex, True)
|
||||||
doc.Layers.Modify(child, child.LayerIndex, True)
|
except Exception: pass
|
||||||
except Exception:
|
# Sub-Sub-Layer rekursiv (Children dieses Sub-Layers).
|
||||||
pass
|
# Sub-Sub-Layer erben den 'grey'-Zustand des Parents.
|
||||||
|
for grand in children_by_parent.get(child.Id, []):
|
||||||
|
_apply_to_sublayer(grand, child_grey)
|
||||||
|
for child in children:
|
||||||
|
_apply_to_sublayer(child, p_grey)
|
||||||
doc.Views.Redraw()
|
doc.Views.Redraw()
|
||||||
|
|||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
#! python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
OSM-Importer fuer Dossier — holt OpenStreetMap-Daten via Overpass-API als
|
||||||
|
Polylinien (Strassen, Gebaeudeumrisse, Wasser, Gruenflaechen, Wege).
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
Adresse → bbox (LV95) → bbox (WGS84) → Overpass-Query →
|
||||||
|
JSON-Response → OSM-Ways → Polylinien (in Doc-Units) → Rhino-Layer
|
||||||
|
|
||||||
|
Koord-Konversion WGS84↔LV95 nutzt swisstopo.wgs84_to_lv95 (LV95 ist die
|
||||||
|
gemeinsame Basis mit dem swisstopo-Importer).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import Rhino
|
||||||
|
import Rhino.Geometry as rg
|
||||||
|
|
||||||
|
import swisstopo # fuer wgs84_to_lv95
|
||||||
|
|
||||||
|
|
||||||
|
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kategorien ------------------------------------------------------------
|
||||||
|
# Jede Kategorie liefert (Overpass-Selektor, Layer-Code, Layer-Name, Color).
|
||||||
|
# Codes 7100-7199 reserviert fuer OSM-Sub-Ebenen unter '70_osm'.
|
||||||
|
CATEGORIES = {
|
||||||
|
"streets": {
|
||||||
|
"selector": '[highway~"^(motorway|trunk|primary|secondary|tertiary|residential|unclassified|service|living_street|pedestrian)$"]',
|
||||||
|
"code": "7101", "name": "Strassen", "color": "#a89070",
|
||||||
|
},
|
||||||
|
"buildings": {
|
||||||
|
"selector": '[building]',
|
||||||
|
"code": "7102", "name": "Gebaeudeumrisse", "color": "#888888",
|
||||||
|
"include_relations": True,
|
||||||
|
},
|
||||||
|
"water": {
|
||||||
|
"selector": '[natural=water]',
|
||||||
|
"code": "7103", "name": "Wasser", "color": "#4080a0",
|
||||||
|
"include_relations": True,
|
||||||
|
},
|
||||||
|
"waterways": {
|
||||||
|
"selector": '[waterway~"^(river|stream|canal)$"]',
|
||||||
|
"code": "7104", "name": "Wasserlaeufe", "color": "#4080a0",
|
||||||
|
},
|
||||||
|
"parks": {
|
||||||
|
"selector": '[leisure~"^(park|garden)$"]',
|
||||||
|
"code": "7105", "name": "Parks", "color": "#60a070",
|
||||||
|
"include_relations": True,
|
||||||
|
},
|
||||||
|
"forest": {
|
||||||
|
"selector": '[landuse~"^(forest|grass|meadow)$"]',
|
||||||
|
"code": "7106", "name": "Wald_Gruen", "color": "#406050",
|
||||||
|
"include_relations": True,
|
||||||
|
},
|
||||||
|
"footpaths": {
|
||||||
|
"selector": '[highway~"^(footway|path|track|cycleway)$"]',
|
||||||
|
"code": "7107", "name": "Wege", "color": "#806040",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_overpass_query(bbox_wgs, categories):
|
||||||
|
"""Baut die Overpass-QL-Query fuer bbox + ausgewaehlte Kategorien.
|
||||||
|
bbox_wgs: (min_lon, min_lat, max_lon, max_lat) — WGS84."""
|
||||||
|
south = bbox_wgs[1]; west = bbox_wgs[0]
|
||||||
|
north = bbox_wgs[3]; east = bbox_wgs[2]
|
||||||
|
bbox_str = "{},{},{},{}".format(south, west, north, east)
|
||||||
|
parts = []
|
||||||
|
for cat in categories:
|
||||||
|
spec = CATEGORIES.get(cat)
|
||||||
|
if not spec: continue
|
||||||
|
parts.append('way{}({});'.format(spec["selector"], bbox_str))
|
||||||
|
if spec.get("include_relations"):
|
||||||
|
parts.append('relation{}({});'.format(spec["selector"], bbox_str))
|
||||||
|
body = ''.join(parts)
|
||||||
|
return '[out:json][timeout:60];({});out body;>;out skel qt;'.format(body)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_overpass(bbox_wgs, categories, progress=None):
|
||||||
|
"""Schickt Overpass-Query, liefert JSON-Dict oder None."""
|
||||||
|
q = build_overpass_query(bbox_wgs, categories)
|
||||||
|
if progress: progress("Overpass-Query ({} Kategorien)...".format(len(categories)))
|
||||||
|
data = urllib.parse.urlencode({"data": q}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(OVERPASS_URL, data=data, method="POST",
|
||||||
|
headers={"User-Agent": "Dossier/OSM-Importer"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||||
|
text = resp.read().decode("utf-8", errors="ignore")
|
||||||
|
out = json.loads(text)
|
||||||
|
if progress: progress("Antwort: {} Elemente".format(len(out.get("elements", []))))
|
||||||
|
return out
|
||||||
|
except Exception as ex:
|
||||||
|
if progress: progress("Overpass fail: {}".format(ex))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_osm_elements(osm_json):
|
||||||
|
"""Zerlegt OSM-JSON in {nodes: {id: (lon, lat)}, ways: [{id, nodes, tags}]}."""
|
||||||
|
if not osm_json: return None
|
||||||
|
nodes = {}
|
||||||
|
ways = []
|
||||||
|
for el in osm_json.get("elements", []):
|
||||||
|
t = el.get("type")
|
||||||
|
if t == "node":
|
||||||
|
nodes[el["id"]] = (el["lon"], el["lat"])
|
||||||
|
elif t == "way":
|
||||||
|
ways.append({
|
||||||
|
"id": el["id"],
|
||||||
|
"nodes": el.get("nodes", []),
|
||||||
|
"tags": el.get("tags") or {},
|
||||||
|
})
|
||||||
|
return {"nodes": nodes, "ways": ways}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_way(tags):
|
||||||
|
"""Mappt Way-Tags auf eine Kategorie-Key (oder None falls uninteressant)."""
|
||||||
|
if not tags: return None
|
||||||
|
hw = tags.get("highway")
|
||||||
|
if hw in ("motorway","trunk","primary","secondary","tertiary",
|
||||||
|
"residential","unclassified","service","living_street","pedestrian"):
|
||||||
|
return "streets"
|
||||||
|
if hw in ("footway","path","track","cycleway"): return "footpaths"
|
||||||
|
if tags.get("building"): return "buildings"
|
||||||
|
if tags.get("natural") == "water": return "water"
|
||||||
|
ww = tags.get("waterway")
|
||||||
|
if ww in ("river","stream","canal"): return "waterways"
|
||||||
|
if tags.get("leisure") in ("park","garden"): return "parks"
|
||||||
|
if tags.get("landuse") in ("forest","grass","meadow"): return "forest"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def way_to_polyline(way_node_ids, nodes, shift_lv95, m_to_unit, z=0.0):
|
||||||
|
"""OSM-Way → Rhino.Polyline in Doc-Units. shift_lv95 = (sx, sy, sz) Origin-
|
||||||
|
Shift in LV95-Metern (gleicher Pipeline wie swisstopo)."""
|
||||||
|
pts = []
|
||||||
|
sx, sy, sz = shift_lv95
|
||||||
|
for nid in way_node_ids:
|
||||||
|
node = nodes.get(nid)
|
||||||
|
if node is None: continue
|
||||||
|
lon, lat = node
|
||||||
|
e, n = swisstopo.wgs84_to_lv95(lon, lat)
|
||||||
|
x = (e - sx) * m_to_unit
|
||||||
|
y = (n - sy) * m_to_unit
|
||||||
|
pts.append(rg.Point3d(x, y, z))
|
||||||
|
if len(pts) < 2: return None
|
||||||
|
poly = rg.Polyline(pts)
|
||||||
|
return poly
|
||||||
|
|
||||||
|
|
||||||
|
def import_osm_to_doc(doc, bbox_wgs, categories, shift_lv95, m_to_unit,
|
||||||
|
z_doc=0.0, progress=None):
|
||||||
|
"""End-to-end-Import: Overpass-Query + Polylinien-Erzeugung. Liefert
|
||||||
|
Liste von dicts: [{category, obj_id, way_tags}, ...] — Aufrufer macht
|
||||||
|
Layer-Move + Tag selbst."""
|
||||||
|
osm_json = fetch_overpass(bbox_wgs, categories, progress=progress)
|
||||||
|
if osm_json is None: return []
|
||||||
|
parsed = parse_osm_elements(osm_json)
|
||||||
|
if not parsed: return []
|
||||||
|
nodes = parsed["nodes"]
|
||||||
|
ways = parsed["ways"]
|
||||||
|
if progress: progress("Parse {} Ways...".format(len(ways)))
|
||||||
|
created = []
|
||||||
|
for way in ways:
|
||||||
|
cat = classify_way(way["tags"])
|
||||||
|
if cat is None or cat not in categories: continue
|
||||||
|
poly = way_to_polyline(way["nodes"], nodes, shift_lv95,
|
||||||
|
m_to_unit, z=z_doc)
|
||||||
|
if poly is None or poly.Count < 2: continue
|
||||||
|
# Wenn Polyline geschlossen ist (erster == letzter Punkt) → als Curve
|
||||||
|
# mit Schluss-Edge, sonst offene Polyline.
|
||||||
|
curve = poly.ToNurbsCurve()
|
||||||
|
if curve is None: continue
|
||||||
|
gid = doc.Objects.AddCurve(curve)
|
||||||
|
if gid is None: continue
|
||||||
|
obj = doc.Objects.Find(gid)
|
||||||
|
if obj is None: continue
|
||||||
|
created.append({
|
||||||
|
"category": cat,
|
||||||
|
"obj": obj,
|
||||||
|
"tags": way["tags"],
|
||||||
|
})
|
||||||
|
if progress: progress("→ {} OSM-Linien erzeugt".format(len(created)))
|
||||||
|
return created
|
||||||
+100
-23
@@ -87,9 +87,15 @@ def _broadcast_state(doc=None, hatch_patterns=None):
|
|||||||
try:
|
try:
|
||||||
z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen")
|
z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen")
|
||||||
e_raw = doc.Strings.GetValue("dossier_ebenen")
|
e_raw = doc.Strings.GetValue("dossier_ebenen")
|
||||||
|
# Projekt-Nullpunkt in m.ü.M — wird beim Swisstopo-Import als
|
||||||
|
# Z-Offset angewandt (Real-Welt-Höhen → Doc-Z relativ zu OKFF=0).
|
||||||
|
zero_raw = doc.Strings.GetValue("dossier_project_zero_mum")
|
||||||
|
try: zero_mum = float(zero_raw) if zero_raw else 0.0
|
||||||
|
except Exception: zero_mum = 0.0
|
||||||
payload = {
|
payload = {
|
||||||
"zeichnungsebenen": json.loads(z_raw) if z_raw else None,
|
"zeichnungsebenen": json.loads(z_raw) if z_raw else None,
|
||||||
"ebenen": json.loads(e_raw) if e_raw else None,
|
"ebenen": json.loads(e_raw) if e_raw else None,
|
||||||
|
"projectZeroMum": zero_mum,
|
||||||
"hatchPatterns": hatch_patterns if hatch_patterns is not None
|
"hatchPatterns": hatch_patterns if hatch_patterns is not None
|
||||||
else _hatch_pattern_names(doc),
|
else _hatch_pattern_names(doc),
|
||||||
}
|
}
|
||||||
@@ -367,9 +373,13 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
layer_builder.build_layers(doc, z, e)
|
layer_builder.build_layers(doc, z, e)
|
||||||
layer_builder.cleanup_default_layers(doc)
|
layer_builder.cleanup_default_layers(doc)
|
||||||
self._ensure_active_sublayer()
|
self._ensure_active_sublayer()
|
||||||
|
zero_raw = doc.Strings.GetValue("dossier_project_zero_mum")
|
||||||
|
try: zero_mum = float(zero_raw) if zero_raw else 0.0
|
||||||
|
except Exception: zero_mum = 0.0
|
||||||
self.send("STATE_SYNC", {
|
self.send("STATE_SYNC", {
|
||||||
"zeichnungsebenen": z,
|
"zeichnungsebenen": z,
|
||||||
"ebenen": e,
|
"ebenen": e,
|
||||||
|
"projectZeroMum": zero_mum,
|
||||||
"hatchPatterns": _hatch_pattern_names(doc),
|
"hatchPatterns": _hatch_pattern_names(doc),
|
||||||
})
|
})
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@@ -471,9 +481,28 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
print("[EBENEN] open_geschoss_settings: kein Geschoss-Payload")
|
print("[EBENEN] open_geschoss_settings: kein Geschoss-Payload")
|
||||||
return
|
return
|
||||||
gid = geschoss["id"]
|
gid = geschoss["id"]
|
||||||
|
doc = Rhino.RhinoDoc.ActiveDoc
|
||||||
|
# Projekt-Nullpunkt (m.ü.M) mit ins Param-Bundle — als projektweite
|
||||||
|
# Settings auch im Geschoss-Dialog editierbar.
|
||||||
|
try:
|
||||||
|
z_mum_raw = doc.Strings.GetValue("dossier_project_zero_mum") if doc else None
|
||||||
|
project_zero_mum = float(z_mum_raw) if z_mum_raw else 0.0
|
||||||
|
except Exception:
|
||||||
|
project_zero_mum = 0.0
|
||||||
|
params = dict(geschoss)
|
||||||
|
params["projectZeroMum"] = project_zero_mum
|
||||||
def on_save(updated):
|
def on_save(updated):
|
||||||
doc = Rhino.RhinoDoc.ActiveDoc
|
doc = Rhino.RhinoDoc.ActiveDoc
|
||||||
if doc is None: return
|
if doc is None: return
|
||||||
|
# Projekt-Nullpunkt extrahieren (project-weit, nicht pro Geschoss)
|
||||||
|
try:
|
||||||
|
if "projectZeroMum" in updated:
|
||||||
|
val = updated.pop("projectZeroMum")
|
||||||
|
val = float(val) if val is not None else 0.0
|
||||||
|
doc.Strings.SetString("dossier_project_zero_mum", str(val))
|
||||||
|
print("[EBENEN] project_zero_mum = {} m.ü.M".format(val))
|
||||||
|
except Exception as ex:
|
||||||
|
print("[EBENEN] project_zero_mum save:", ex)
|
||||||
z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen")
|
z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen")
|
||||||
if not z_raw:
|
if not z_raw:
|
||||||
print("[EBENEN] save_geschoss: kein z-Store"); return
|
print("[EBENEN] save_geschoss: kein z-Store"); return
|
||||||
@@ -497,9 +526,9 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
self._apply(z_list, e_list, save_z=True, save_e=False)
|
self._apply(z_list, e_list, save_z=True, save_e=False)
|
||||||
panel_base.open_satellite_window(
|
panel_base.open_satellite_window(
|
||||||
"geschoss_settings",
|
"geschoss_settings",
|
||||||
params=geschoss,
|
params=params,
|
||||||
title="Zeichnungsebene: {}".format(geschoss.get("name", "")),
|
title="Zeichnungsebene: {}".format(geschoss.get("name", "")),
|
||||||
size=(380, 540),
|
size=(380, 580),
|
||||||
on_save=on_save)
|
on_save=on_save)
|
||||||
|
|
||||||
def _open_ebenen_settings(self, ebene, hatch_patterns):
|
def _open_ebenen_settings(self, ebene, hatch_patterns):
|
||||||
@@ -556,12 +585,24 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
try: e_list = json.loads(e_raw)
|
try: e_list = json.loads(e_raw)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[EBENEN] save_ebene JSON:", ex); return
|
print("[EBENEN] save_ebene JSON:", ex); return
|
||||||
replaced = False
|
# Rekursive Suche + Replace durch den Tree — Sub-Ebenen
|
||||||
for i, e in enumerate(e_list):
|
# (children) liegen verschachtelt, nicht in der Top-Level-Liste.
|
||||||
if isinstance(e, dict) and e.get("code") == orig_code:
|
def _replace_in_tree(lst, target_code, new_data):
|
||||||
e_list[i] = updated
|
for i, e in enumerate(lst):
|
||||||
replaced = True
|
if not isinstance(e, dict): continue
|
||||||
break
|
if e.get("code") == target_code:
|
||||||
|
kids = e.get("children")
|
||||||
|
merged = dict(new_data)
|
||||||
|
if isinstance(kids, list) and "children" not in merged:
|
||||||
|
merged["children"] = kids
|
||||||
|
lst[i] = merged
|
||||||
|
return True
|
||||||
|
kids = e.get("children")
|
||||||
|
if isinstance(kids, list):
|
||||||
|
if _replace_in_tree(kids, target_code, new_data):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
replaced = _replace_in_tree(e_list, orig_code, updated)
|
||||||
if not replaced:
|
if not replaced:
|
||||||
print("[EBENEN] save_ebene: code {} nicht gefunden".format(orig_code))
|
print("[EBENEN] save_ebene: code {} nicht gefunden".format(orig_code))
|
||||||
return
|
return
|
||||||
@@ -610,12 +651,11 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
def _fill_signature(e_list):
|
def _fill_signature(e_list):
|
||||||
out = {}
|
out = {}
|
||||||
if not isinstance(e_list, list): return out
|
if not isinstance(e_list, list): return out
|
||||||
for e in e_list:
|
def _walk(lst):
|
||||||
|
for e in lst:
|
||||||
if not isinstance(e, dict): continue
|
if not isinstance(e, dict): continue
|
||||||
f = e.get("fill")
|
f = e.get("fill")
|
||||||
if not isinstance(f, dict): continue
|
if isinstance(f, dict) and f.get("pattern") not in (None, "None"):
|
||||||
if f.get("pattern") in (None, "None"): continue
|
|
||||||
# lw kann None sein -> als Sentinel ein eindeutiger Wert
|
|
||||||
lw_raw = f.get("lw")
|
lw_raw = f.get("lw")
|
||||||
try:
|
try:
|
||||||
lw_sig = round(float(lw_raw), 6) if lw_raw is not None else None
|
lw_sig = round(float(lw_raw), 6) if lw_raw is not None else None
|
||||||
@@ -629,6 +669,10 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
round(float(f.get("rotation") or 0.0), 6),
|
round(float(f.get("rotation") or 0.0), 6),
|
||||||
lw_sig,
|
lw_sig,
|
||||||
)
|
)
|
||||||
|
kids = e.get("children")
|
||||||
|
if isinstance(kids, list) and kids:
|
||||||
|
_walk(kids)
|
||||||
|
_walk(e_list)
|
||||||
return out
|
return out
|
||||||
old_e_raw = doc.Strings.GetValue("dossier_ebenen")
|
old_e_raw = doc.Strings.GetValue("dossier_ebenen")
|
||||||
old_sig = {}
|
old_sig = {}
|
||||||
@@ -733,6 +777,18 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
return
|
return
|
||||||
payload_z = p.get("zeichnungsebenen") or []
|
payload_z = p.get("zeichnungsebenen") or []
|
||||||
payload_e = p.get("ebenen") or []
|
payload_e = p.get("ebenen") or []
|
||||||
|
# Hilfsfunktion: alle Codes (inkl. Children) als flat dict {code: ebene}
|
||||||
|
def _walk_codes(lst):
|
||||||
|
out = {}
|
||||||
|
if not isinstance(lst, list): return out
|
||||||
|
for x in lst:
|
||||||
|
if not isinstance(x, dict): continue
|
||||||
|
c = x.get("code")
|
||||||
|
if c: out[c] = x
|
||||||
|
kids = x.get("children")
|
||||||
|
if isinstance(kids, list):
|
||||||
|
out.update(_walk_codes(kids))
|
||||||
|
return out
|
||||||
# Strukturelle Aenderung pending? Wenn React-Payload IDs/Codes enthaelt
|
# Strukturelle Aenderung pending? Wenn React-Payload IDs/Codes enthaelt
|
||||||
# die noch nicht in doc.Strings sind (= User hat gerade neue Ebene
|
# die noch nicht in doc.Strings sind (= User hat gerade neue Ebene
|
||||||
# angelegt aber der strukturelle APPLY ist noch in der 200ms-Debounce),
|
# angelegt aber der strukturelle APPLY ist noch in der 200ms-Debounce),
|
||||||
@@ -740,15 +796,17 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
# den geplanten APPLY-Save und die neue Ebene geht in der Race
|
# den geplanten APPLY-Save und die neue Ebene geht in der Race
|
||||||
# verloren.
|
# verloren.
|
||||||
payload_z_ids = {z.get("id") for z in payload_z if isinstance(z, dict)}
|
payload_z_ids = {z.get("id") for z in payload_z if isinstance(z, dict)}
|
||||||
payload_e_codes = {e.get("code") for e in payload_e if isinstance(e, dict)}
|
payload_e_codes = set(_walk_codes(payload_e).keys())
|
||||||
existing_z_ids = {z.get("id") for z in z_full if isinstance(z, dict)}
|
existing_z_ids = {z.get("id") for z in z_full if isinstance(z, dict)}
|
||||||
existing_e_codes = {e.get("code") for e in e_full if isinstance(e, dict)}
|
existing_e_codes = set(_walk_codes(e_full).keys())
|
||||||
has_new_structural = (
|
has_new_structural = (
|
||||||
bool(payload_z_ids - existing_z_ids - {None}) or
|
bool(payload_z_ids - existing_z_ids - {None}) or
|
||||||
bool(payload_e_codes - existing_e_codes - {None})
|
bool(payload_e_codes - existing_e_codes - {None})
|
||||||
)
|
)
|
||||||
z_state = {z["id"]: z for z in payload_z if isinstance(z, dict) and z.get("id")}
|
z_state = {z["id"]: z for z in payload_z if isinstance(z, dict) and z.get("id")}
|
||||||
e_state = {e["code"]: e for e in payload_e if isinstance(e, dict) and e.get("code")}
|
# e_state ist flach (Code → Ebene) ueber den ganzen Tree des Payloads,
|
||||||
|
# damit auch Child-Visibility-Toggles ankommen.
|
||||||
|
e_state = _walk_codes(payload_e)
|
||||||
merged_z = []
|
merged_z = []
|
||||||
for z in z_full:
|
for z in z_full:
|
||||||
if not isinstance(z, dict): continue
|
if not isinstance(z, dict): continue
|
||||||
@@ -758,23 +816,40 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
m["visible"] = s.get("visible", True)
|
m["visible"] = s.get("visible", True)
|
||||||
m["locked"] = s.get("locked", False)
|
m["locked"] = s.get("locked", False)
|
||||||
merged_z.append(m)
|
merged_z.append(m)
|
||||||
merged_e = []
|
# Merge fuer Ebenen rekursiv: jedes Element behaelt seine Position +
|
||||||
for e in e_full:
|
# children-Struktur, nur visible/locked werden ueberschrieben falls
|
||||||
|
# im Payload anwesend.
|
||||||
|
def _merge_ebenen_tree(orig_list):
|
||||||
|
out = []
|
||||||
|
for e in orig_list:
|
||||||
if not isinstance(e, dict): continue
|
if not isinstance(e, dict): continue
|
||||||
m = dict(e)
|
m = dict(e)
|
||||||
s = e_state.get(e.get("code"))
|
s = e_state.get(e.get("code"))
|
||||||
if s is not None:
|
if s is not None:
|
||||||
m["visible"] = s.get("visible", True)
|
m["visible"] = s.get("visible", True)
|
||||||
m["locked"] = s.get("locked", False)
|
m["locked"] = s.get("locked", False)
|
||||||
merged_e.append(m)
|
kids = e.get("children")
|
||||||
|
if isinstance(kids, list):
|
||||||
|
m["children"] = _merge_ebenen_tree(kids)
|
||||||
|
out.append(m)
|
||||||
|
return out
|
||||||
|
merged_e = _merge_ebenen_tree(e_full)
|
||||||
# Detect whether the merge actually changed any visible/locked values.
|
# Detect whether the merge actually changed any visible/locked values.
|
||||||
# Wenn nicht: das ist nur der Echo-Roundtrip eines apply_layer_preset
|
# Wenn nicht: das ist nur der Echo-Roundtrip eines apply_layer_preset
|
||||||
# (React-State == doc.Strings → kein User-Click) und wir wollen das
|
# (React-State == doc.Strings → kein User-Click) und wir wollen das
|
||||||
# aktive Preset NICHT clearen.
|
# aktive Preset NICHT clearen. Bei Ebenen rekursiv durch Children.
|
||||||
|
def _flatten(lst):
|
||||||
|
out = []
|
||||||
|
for x in (lst or []):
|
||||||
|
if not isinstance(x, dict): continue
|
||||||
|
out.append(x)
|
||||||
|
kids = x.get("children")
|
||||||
|
if isinstance(kids, list):
|
||||||
|
out.extend(_flatten(kids))
|
||||||
|
return out
|
||||||
def _vis_lock_changed(old, new):
|
def _vis_lock_changed(old, new):
|
||||||
old_by = {x.get("id") or x.get("code"): x for x in old if isinstance(x, dict)}
|
old_by = {x.get("id") or x.get("code"): x for x in _flatten(old)}
|
||||||
for nx in new:
|
for nx in _flatten(new):
|
||||||
if not isinstance(nx, dict): continue
|
|
||||||
key = nx.get("id") or nx.get("code")
|
key = nx.get("id") or nx.get("code")
|
||||||
if key is None: continue
|
if key is None: continue
|
||||||
ox = old_by.get(key)
|
ox = old_by.get(key)
|
||||||
@@ -815,10 +890,12 @@ class EbenenBridge(panel_base.BaseBridge):
|
|||||||
bool(z.get("visible", True)),
|
bool(z.get("visible", True)),
|
||||||
bool(z.get("locked", False)))
|
bool(z.get("locked", False)))
|
||||||
for z in zlist if isinstance(z, dict))
|
for z in zlist if isinstance(z, dict))
|
||||||
|
# Ebenen flat ueber Children — sonst dedupt der Cache auch nach
|
||||||
|
# einem Child-Toggle, weil die Top-Level-Liste identisch aussieht.
|
||||||
es = tuple((e.get("code"),
|
es = tuple((e.get("code"),
|
||||||
bool(e.get("visible", True)),
|
bool(e.get("visible", True)),
|
||||||
bool(e.get("locked", False)))
|
bool(e.get("locked", False)))
|
||||||
for e in elist if isinstance(e, dict))
|
for e in _flatten(elist))
|
||||||
return (active_z_id, active_code, z_mode, e_mode, zs, es)
|
return (active_z_id, active_code, z_mode, e_mode, zs, es)
|
||||||
cur_sig = _sig(merged_z, merged_e)
|
cur_sig = _sig(merged_z, merged_e)
|
||||||
if sc.sticky.get("_vis_last_sig") == cur_sig and not any_changed:
|
if sc.sticky.get("_vis_last_sig") == cur_sig and not any_changed:
|
||||||
|
|||||||
+596
-64
@@ -18,12 +18,39 @@ import urllib.request
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import Rhino
|
import Rhino
|
||||||
import Rhino.Geometry as rg
|
import Rhino.Geometry as rg
|
||||||
|
import System
|
||||||
|
|
||||||
CACHE_DIR = os.path.expanduser("~/Library/Caches/Dossier/swisstopo")
|
DEFAULT_CACHE_DIR = os.path.expanduser("~/Library/Caches/Dossier/swisstopo")
|
||||||
|
CACHE_DIR = DEFAULT_CACHE_DIR
|
||||||
STAC_BASE = "https://data.geo.admin.ch/api/stac/v1"
|
STAC_BASE = "https://data.geo.admin.ch/api/stac/v1"
|
||||||
SEARCH_API = "https://api3.geo.admin.ch/rest/services/api/SearchServer"
|
SEARCH_API = "https://api3.geo.admin.ch/rest/services/api/SearchServer"
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache_dir_for_doc(doc):
|
||||||
|
"""Cache-Pfad fuer ein Doc. Wenn das Doc auf Disk liegt: Subfolder neben
|
||||||
|
der .3dm-Datei (`<dir>/<basename>_swisstopo/`). Damit reisen die Files
|
||||||
|
mit dem Projekt — kann via SMB von anderen Maschinen geoeffnet werden
|
||||||
|
solange der Mount-Pfad identisch ist. Falls Doc nicht gespeichert:
|
||||||
|
globaler Fallback-Cache."""
|
||||||
|
try:
|
||||||
|
p = doc.Path if doc else None
|
||||||
|
if p and os.path.isfile(p):
|
||||||
|
doc_dir = os.path.dirname(p)
|
||||||
|
doc_base = os.path.splitext(os.path.basename(p))[0]
|
||||||
|
return os.path.join(doc_dir, doc_base + "_swisstopo")
|
||||||
|
except Exception: pass
|
||||||
|
return DEFAULT_CACHE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def set_cache_dir(path):
|
||||||
|
"""Stellt das aktive Cache-Verzeichnis. Alle nachfolgenden Downloads
|
||||||
|
landen dort. Aufrufer-Verantwortung: vor jedem Import den richtigen
|
||||||
|
Cache setzen (per-Doc oder global)."""
|
||||||
|
global CACHE_DIR
|
||||||
|
CACHE_DIR = path
|
||||||
|
_ensure_cache()
|
||||||
|
|
||||||
|
|
||||||
def _ensure_cache():
|
def _ensure_cache():
|
||||||
if not os.path.isdir(CACHE_DIR):
|
if not os.path.isdir(CACHE_DIR):
|
||||||
try: os.makedirs(CACHE_DIR)
|
try: os.makedirs(CACHE_DIR)
|
||||||
@@ -323,25 +350,38 @@ def _extract_zip_to_dir(zip_path, dest_dir):
|
|||||||
|
|
||||||
# --- Buildings: 3D-Gebaeude DWG --------------------------------------------
|
# --- Buildings: 3D-Gebaeude DWG --------------------------------------------
|
||||||
|
|
||||||
# swissBUILDINGS3D 3.0 ist Cesium-3D-Tiles (kein DWG). Fuer DWG-Import nutzen
|
# swissBUILDINGS3D 3.0: liefert mehrere Formate (DXF/DWG/OBJ/IFC/3DTiles) und
|
||||||
# wir die 2.0-Variante.
|
# variant-Filter (solid/separated). In Staedten sind die 3.0-Tiles aber riesig
|
||||||
_BUILDINGS_COLLECTION = "ch.swisstopo.swissbuildings3d_2"
|
# (>700 MB), weil nicht 1km-strukturiert — dann fallen wir auf 2.0 zurueck
|
||||||
|
# (verlaesslich 1km-Tiles, ~50 MB).
|
||||||
|
_BUILDINGS_COLLECTION_V3 = "ch.swisstopo.swissbuildings3d_3_0"
|
||||||
|
_BUILDINGS_COLLECTION_V2 = "ch.swisstopo.swissbuildings3d_2"
|
||||||
|
|
||||||
|
|
||||||
def fetch_buildings_dwg(bbox_lv95, progress=None):
|
def _fetch_buildings_from_collection(collection_id, bbox_wgs, variant,
|
||||||
"""Holt swissBUILDINGS3D 2.0 Tile-DXF/DWG-Files fuer eine LV95-bbox.
|
progress=None):
|
||||||
Wichtig: filtert NUR per-Tile-Assets (Pattern `_NNNN-NN_`). National-
|
"""Holt Tile-CAD-Files aus EINER STAC-Collection. Liefert Liste Pfade
|
||||||
Geodatabase-Assets (>1 GB) werden NICHT gematcht — sonst laedt das Plugin
|
oder [] wenn nichts brauchbar geladen werden konnte (z.B. alle ueber
|
||||||
versehentlich den gesamt-CH-Datensatz."""
|
Size-Limit)."""
|
||||||
bbox_wgs = lv95_bbox_to_wgs84_bbox(*bbox_lv95)
|
if progress: progress("STAC-Query {} (variant={})...".format(
|
||||||
if progress: progress("STAC-Query " + _BUILDINGS_COLLECTION + "...")
|
collection_id, variant))
|
||||||
items = stac_query(_BUILDINGS_COLLECTION, bbox_wgs,
|
items = stac_query(collection_id, bbox_wgs,
|
||||||
asset_extensions=[".dwg", ".dxf",
|
asset_extensions=[".dwg", ".dxf", ".obj", ".ifc",
|
||||||
".dwg.zip", ".dxf.zip"])
|
".dwg.zip", ".dxf.zip",
|
||||||
|
".obj.zip", ".ifc.zip"])
|
||||||
items = _dedupe_latest(items)
|
items = _dedupe_latest(items)
|
||||||
if not items:
|
if not items:
|
||||||
if progress: progress("Keine Tiles in der Region (collection={})".format(_BUILDINGS_COLLECTION))
|
if progress: progress(" Keine Tiles in der Region")
|
||||||
return []
|
return []
|
||||||
|
variant_marker = "_{}_".format(variant.lower())
|
||||||
|
items_v = [it for it in items
|
||||||
|
if variant_marker in (it.get("id") or "").lower()
|
||||||
|
or any(variant_marker in (a.get("href") or "").lower()
|
||||||
|
for a in it.get("assets", {}).values())]
|
||||||
|
if items_v and len(items_v) < len(items):
|
||||||
|
if progress: progress(" Item-Filter: {}/{} matchen variant '{}'".format(
|
||||||
|
len(items_v), len(items), variant))
|
||||||
|
items = items_v
|
||||||
paths = []
|
paths = []
|
||||||
for i, item in enumerate(items):
|
for i, item in enumerate(items):
|
||||||
if progress: progress("Lade Tile {}/{}: {}".format(
|
if progress: progress("Lade Tile {}/{}: {}".format(
|
||||||
@@ -353,35 +393,135 @@ def fetch_buildings_dwg(bbox_lv95, progress=None):
|
|||||||
if not per_tile:
|
if not per_tile:
|
||||||
if progress: progress("→ kein Per-Tile-Asset, skip")
|
if progress: progress("→ kein Per-Tile-Asset, skip")
|
||||||
continue
|
continue
|
||||||
# Priorisierung: direkt-DXF/DWG > ZIP-DXF/DWG
|
# Varianten-Filter: `_solid_` vs `_separated_` im Filename. Default
|
||||||
|
# ist 'separated'. Falls keine Asset mit dem Marker matcht (alte
|
||||||
|
# Collection-Version o.ae.), fallen wir auf alle per-tile zurueck.
|
||||||
|
variant_marker = "_{}_".format(variant.lower())
|
||||||
|
per_tile_v = [(k, a) for k, a in per_tile
|
||||||
|
if variant_marker in a["href"].lower()]
|
||||||
|
if per_tile_v:
|
||||||
|
per_tile = per_tile_v
|
||||||
|
if progress: progress(" → {} Asset(s) matchen variant '{}'".format(
|
||||||
|
len(per_tile), variant))
|
||||||
|
else:
|
||||||
|
if progress:
|
||||||
|
hrefs_short = ", ".join(os.path.basename(a["href"])
|
||||||
|
for _, a in per_tile[:3])
|
||||||
|
progress(" → kein '{}' Marker, nehme erstes Asset (verfuegbar: {})".format(
|
||||||
|
variant, hrefs_short))
|
||||||
|
# Priorisierung: DXF/DWG (am stabilsten in Rhino) > OBJ > IFC
|
||||||
chosen = None
|
chosen = None
|
||||||
|
for prio_ext in [(".dxf", ".dwg"), (".obj",), (".ifc",),
|
||||||
|
(".dxf.zip", ".dwg.zip"), (".obj.zip",), (".ifc.zip",)]:
|
||||||
for k, a in per_tile:
|
for k, a in per_tile:
|
||||||
low = a["href"].lower()
|
low = a["href"].lower()
|
||||||
if low.endswith((".dxf", ".dwg")):
|
if low.endswith(prio_ext):
|
||||||
chosen = a["href"]; break
|
|
||||||
if chosen is None:
|
|
||||||
for k, a in per_tile:
|
|
||||||
low = a["href"].lower()
|
|
||||||
if low.endswith((".dxf.zip", ".dwg.zip")):
|
|
||||||
chosen = a["href"]; break
|
chosen = a["href"]; break
|
||||||
|
if chosen is not None: break
|
||||||
if chosen is None:
|
if chosen is None:
|
||||||
chosen = per_tile[0][1]["href"]
|
chosen = per_tile[0][1]["href"]
|
||||||
|
|
||||||
p = download_asset(chosen, subdir="buildings3d_dwg", status=progress)
|
p = download_asset(chosen, subdir="buildings3d_dwg", status=progress)
|
||||||
if not p: continue
|
if not p: continue
|
||||||
|
|
||||||
# ZIP-Wrapper aufloesen
|
# ZIP-Wrapper aufloesen + Variant-Filter (ZIP kann beide DWGs enthalten)
|
||||||
if p.lower().endswith(".zip"):
|
if p.lower().endswith(".zip"):
|
||||||
extracted = _extract_zip_to_dir(
|
extracted = _extract_zip_to_dir(
|
||||||
p, os.path.join(CACHE_DIR, "buildings3d_dwg", "_unzipped"))
|
p, os.path.join(CACHE_DIR, "buildings3d_dwg", "_unzipped"))
|
||||||
dwgs = [e for e in extracted if e.lower().endswith((".dwg", ".dxf"))]
|
cads_all = [e for e in extracted
|
||||||
paths.extend(dwgs)
|
if e.lower().endswith((".dwg", ".dxf", ".obj", ".ifc"))]
|
||||||
|
cads_v = [e for e in cads_all
|
||||||
|
if variant_marker in os.path.basename(e).lower()]
|
||||||
|
cads = cads_v if cads_v else cads_all
|
||||||
|
if cads_v and len(cads_v) < len(cads_all):
|
||||||
|
if progress: progress(" ZIP-Filter: {}/{} Files matchen '{}'".format(
|
||||||
|
len(cads_v), len(cads_all), variant))
|
||||||
|
paths.extend(cads)
|
||||||
else:
|
else:
|
||||||
paths.append(p)
|
paths.append(p)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_buildings_dwg(bbox_lv95, progress=None, variant="separated",
|
||||||
|
version="v2"):
|
||||||
|
"""Holt swissBUILDINGS3D Tile-CAD-Files.
|
||||||
|
|
||||||
|
version='v2': stabile 2.0-Variante (1km-Tiles, keine Solid/Separated-
|
||||||
|
Aufteilung — alle Kategorien auf eigenen DXF-Layern).
|
||||||
|
version='v3': Beta 3.0-Variante mit Solid/Separated-Wahl. In Staedten
|
||||||
|
oft >700 MB pro Tile → auto-fallback auf v2 wenn v3
|
||||||
|
nichts brauchbares liefert."""
|
||||||
|
bbox_wgs = lv95_bbox_to_wgs84_bbox(*bbox_lv95)
|
||||||
|
if version == "v3":
|
||||||
|
paths = _fetch_buildings_from_collection(
|
||||||
|
_BUILDINGS_COLLECTION_V3, bbox_wgs, variant, progress=progress)
|
||||||
|
if not paths:
|
||||||
|
if progress: progress("v3.0 lieferte keine Tiles — fallback auf v2.0...")
|
||||||
|
paths = _fetch_buildings_from_collection(
|
||||||
|
_BUILDINGS_COLLECTION_V2, bbox_wgs, variant, progress=progress)
|
||||||
|
else:
|
||||||
|
paths = _fetch_buildings_from_collection(
|
||||||
|
_BUILDINGS_COLLECTION_V2, bbox_wgs, variant, progress=progress)
|
||||||
if progress: progress("{} CAD-Datei(en) bereit".format(len(paths)))
|
if progress: progress("{} CAD-Datei(en) bereit".format(len(paths)))
|
||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
# --- TLM3D Vektor (Strassen / Gewaesser / Bahn / Vegetation) ----------------
|
||||||
|
# swisstopo bietet TLM3D unter mehreren Collection-IDs an (genaue Namen
|
||||||
|
# variieren). Wir probieren defensiv mehrere Kandidaten und nehmen DXF/DWG
|
||||||
|
# wenn verfuegbar (alles andere — GPKG/SHP — koennen wir nicht parsen).
|
||||||
|
|
||||||
|
# Echte swisstopo TLM-Collections (verifiziert via STAC API):
|
||||||
|
# ch.swisstopo.swisstlm3d — voller TLM3D Layer (~ganze CH)
|
||||||
|
# ch.swisstopo.swisstlmregio — kleinere Auflösung 1:200000
|
||||||
|
# ch.swisstopo.swissboundaries3d — Verwaltungsgrenzen
|
||||||
|
# ch.swisstopo.swiss-map-vector25 — 1:25000 Vektor
|
||||||
|
# Achtung: ALLE liefern nur GDB/SHP/GPKG/XTF — KEIN DXF/DWG. Direkter Rhino-
|
||||||
|
# Import funktioniert nicht ohne Shapefile-/GPKG-Parser.
|
||||||
|
_TLM_COLLECTIONS = [
|
||||||
|
"ch.swisstopo.swisstlm3d",
|
||||||
|
"ch.swisstopo.swisstlmregio",
|
||||||
|
"ch.swisstopo.swissboundaries3d",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_tlm3d_vector(bbox_lv95, kinds, progress=None):
|
||||||
|
"""Versucht swissTLM3D-Daten als DXF/DWG zu holen. swisstopo liefert
|
||||||
|
aktuell NUR GDB/SHP/GPKG-Formate — kein DXF. Diese Funktion findet
|
||||||
|
daher in den meisten Faellen keine importierbaren Files; sie loggt
|
||||||
|
aber sauber, was verfuegbar waere, falls wir spaeter einen
|
||||||
|
Shapefile-Parser einbauen."""
|
||||||
|
bbox_wgs = lv95_bbox_to_wgs84_bbox(*bbox_lv95)
|
||||||
|
out = {}
|
||||||
|
if progress:
|
||||||
|
progress("TLM3D-Import: swisstopo bietet aktuell KEINE DXF-Assets")
|
||||||
|
progress(" (nur GDB/SHP/GPKG — Rhino kann diese nicht nativ lesen)")
|
||||||
|
progress(" Verfuegbare Collections (zur Info):")
|
||||||
|
for coll in _TLM_COLLECTIONS:
|
||||||
|
try:
|
||||||
|
items = stac_query(coll, bbox_wgs,
|
||||||
|
asset_extensions=None) # alle Assets
|
||||||
|
except Exception as ex:
|
||||||
|
if progress: progress(" {}: HTTP-fail ({})".format(coll, ex))
|
||||||
|
continue
|
||||||
|
if not items:
|
||||||
|
if progress: progress(" {}: keine Items in der Region".format(coll))
|
||||||
|
continue
|
||||||
|
sample = items[0]
|
||||||
|
formats = set()
|
||||||
|
for k, a in (sample.get("assets") or {}).items():
|
||||||
|
href = (a.get("href") or "").lower()
|
||||||
|
for ext in (".gdb.zip", ".shp.zip", ".gpkg.zip", ".gpkg",
|
||||||
|
".xtf.zip", ".dxf", ".dwg"):
|
||||||
|
if href.endswith(ext): formats.add(ext.lstrip("."))
|
||||||
|
if progress: progress(" {}: {} Items, Formate: {}".format(
|
||||||
|
coll, len(items), ", ".join(sorted(formats)) or "?"))
|
||||||
|
if progress:
|
||||||
|
progress("→ TLM3D-Direct-Import nicht moeglich. Nutze OSM-Importer "
|
||||||
|
"fuer Vector-Daten (Strassen/Wasser/Gebaeude).")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# --- Terrain: swissALTI3D via XYZ ASCII -------------------------------------
|
# --- Terrain: swissALTI3D via XYZ ASCII -------------------------------------
|
||||||
|
|
||||||
def fetch_terrain_xyz(bbox_lv95, resolution="2.0", progress=None):
|
def fetch_terrain_xyz(bbox_lv95, resolution="2.0", progress=None):
|
||||||
@@ -520,6 +660,41 @@ def xyz_to_grid(path, target_step=2.0, clip_bbox=None, progress=None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_grids(grids):
|
||||||
|
"""Vereint mehrere Grid-Dicts (eines pro XYZ-Tile) zu einem zusammen-
|
||||||
|
haengenden Grid. swissALTI3D-Tiles liefern jeweils 1km×1km Punkte —
|
||||||
|
benachbarte Tiles teilen KEINE Rand-Punkte (Tile A endet z.B. bei
|
||||||
|
e=2700999.5, Tile B startet bei e=2701000.0). Beim getrennten Meshen
|
||||||
|
entsteht dadurch ein 1m-Streifen ohne Faces. Hier mergen wir die
|
||||||
|
Punkte VORHER zu einem unified Grid, dann verbindet mesh_from_grid
|
||||||
|
die Tile-Grenze automatisch (benachbarte Spalten = ein step Abstand
|
||||||
|
nach Sub-Sampling).
|
||||||
|
|
||||||
|
Annahme: alle Grids teilen sich denselben step (gleicher target_step
|
||||||
|
+ Quell-Resolution). Origin-Alignment ist gegeben, weil swissALTI3D
|
||||||
|
auf einem globalen 0.5m-Raster liegt."""
|
||||||
|
if not grids: return None
|
||||||
|
grids = [g for g in grids if g is not None]
|
||||||
|
if not grids: return None
|
||||||
|
if len(grids) == 1: return grids[0]
|
||||||
|
step = grids[0]["step"]
|
||||||
|
all_points = {}
|
||||||
|
all_es = set(); all_ns = set()
|
||||||
|
for g in grids:
|
||||||
|
for (e, n), z in g["points"].items():
|
||||||
|
all_points[(e, n)] = z
|
||||||
|
all_es.add(e); all_ns.add(n)
|
||||||
|
if not all_points: return None
|
||||||
|
es_sorted = sorted(all_es); ns_sorted = sorted(all_ns)
|
||||||
|
return {
|
||||||
|
"bbox": (es_sorted[0], ns_sorted[0], es_sorted[-1], ns_sorted[-1]),
|
||||||
|
"step": step,
|
||||||
|
"es": es_sorted,
|
||||||
|
"ns": ns_sorted,
|
||||||
|
"points": all_points,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def mesh_from_grid(grid, origin_shift=(0, 0, 0), unit_scale=1.0):
|
def mesh_from_grid(grid, origin_shift=(0, 0, 0), unit_scale=1.0):
|
||||||
"""Baut ein Rhino-Mesh aus dem XYZ-Grid. origin_shift wird auf jeden
|
"""Baut ein Rhino-Mesh aus dem XYZ-Grid. origin_shift wird auf jeden
|
||||||
Vertex angewendet (typisch: bbox-Center zu Welt-0/0/0 schieben).
|
Vertex angewendet (typisch: bbox-Center zu Welt-0/0/0 schieben).
|
||||||
@@ -553,6 +728,151 @@ def mesh_from_grid(grid, origin_shift=(0, 0, 0), unit_scale=1.0):
|
|||||||
return mesh
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
|
def generate_mesh_from_contours(doc, contour_curves, sample_step_m=2.0,
|
||||||
|
m_to_unit=1.0, progress=None):
|
||||||
|
"""Baut ein TIN-Mesh aus Hoehenlinien-Curves. Jede Curve hat ihre echte
|
||||||
|
Z-Hoehe — wir sampeln Vertices entlang der Curves und triangulieren
|
||||||
|
sie via Rhinos _-MeshPatch / _-Delaunay Command. Resultat: Topographie-
|
||||||
|
Mesh basierend auf den diskreten Hoehenlinien-Stufen.
|
||||||
|
|
||||||
|
Liefert RhinoObject (Mesh) oder None."""
|
||||||
|
import System
|
||||||
|
if not contour_curves: return None
|
||||||
|
pts = []
|
||||||
|
for c in contour_curves:
|
||||||
|
if c is None: continue
|
||||||
|
# Polyline-Vertices wenn moeglich (exakt), sonst entlang Curve sampeln
|
||||||
|
ok, poly = c.TryGetPolyline()
|
||||||
|
if ok and poly is not None:
|
||||||
|
for pt in poly: pts.append(rg.Point3d(pt))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
L = c.GetLength()
|
||||||
|
n = max(2, int(L / (sample_step_m * m_to_unit)))
|
||||||
|
params = c.DivideByCount(n, True)
|
||||||
|
if params:
|
||||||
|
for t in params: pts.append(c.PointAt(t))
|
||||||
|
except Exception: pass
|
||||||
|
if len(pts) < 3:
|
||||||
|
if progress: progress("Contour-Mesh: zu wenig Vertices ({})".format(len(pts)))
|
||||||
|
return None
|
||||||
|
if progress: progress("Contour-Mesh: trianguliere {} Vertices...".format(len(pts)))
|
||||||
|
# Temp-Points erzeugen + selektieren
|
||||||
|
temp_pids = []
|
||||||
|
try:
|
||||||
|
for p in pts:
|
||||||
|
pid = doc.Objects.AddPoint(p)
|
||||||
|
if pid and pid != System.Guid.Empty:
|
||||||
|
temp_pids.append(pid)
|
||||||
|
if not temp_pids:
|
||||||
|
if progress: progress("Contour-Mesh: keine Temp-Points")
|
||||||
|
return None
|
||||||
|
doc.Objects.UnselectAll()
|
||||||
|
for pid in temp_pids: doc.Objects.Select(pid)
|
||||||
|
before = set(o.Id for o in doc.Objects
|
||||||
|
if o and not o.IsDeleted
|
||||||
|
and isinstance(o.Geometry, rg.Mesh))
|
||||||
|
# Mehrere Commands probieren (Mac Rhino 8 vs neuere Versionen)
|
||||||
|
cmd_tried = None
|
||||||
|
for cmd in ['_-MeshPatch _Enter _Enter',
|
||||||
|
'_-Delaunay _Enter',
|
||||||
|
'_-DelaunayMesh _Enter',
|
||||||
|
'_-MeshFromPoints _Enter']:
|
||||||
|
try:
|
||||||
|
Rhino.RhinoApp.RunScript(cmd, False)
|
||||||
|
except Exception: continue
|
||||||
|
cmd_tried = cmd
|
||||||
|
new_mesh = next((o for o in doc.Objects
|
||||||
|
if o and not o.IsDeleted
|
||||||
|
and isinstance(o.Geometry, rg.Mesh)
|
||||||
|
and o.Id not in before), None)
|
||||||
|
if new_mesh:
|
||||||
|
if progress: progress("→ Contour-Mesh via '{}'".format(cmd.split()[0]))
|
||||||
|
return new_mesh
|
||||||
|
if progress:
|
||||||
|
progress("Contour-Mesh: kein Command lieferte ein Mesh "
|
||||||
|
"(zuletzt: {})".format(cmd_tried))
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
# Temp-Points wieder weg
|
||||||
|
doc.Objects.UnselectAll()
|
||||||
|
for pid in temp_pids:
|
||||||
|
try: doc.Objects.Delete(pid, True)
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
|
||||||
|
def generate_schichtenmodell(doc, contour_curves, progress=None):
|
||||||
|
"""Schichtenmodell: jede geschlossene Hoehenlinie wird zu einer planaren
|
||||||
|
Flaeche auf ihrer Z-Hoehe. Stacked Discs — der architektonische
|
||||||
|
'Pappmodell'-Look. Offene Konturen (typ. am bbox-Rand) werden
|
||||||
|
uebersprungen.
|
||||||
|
|
||||||
|
Liefert Liste von erzeugten RhinoObjects."""
|
||||||
|
import System
|
||||||
|
if not contour_curves: return []
|
||||||
|
created = []
|
||||||
|
tol = doc.ModelAbsoluteTolerance
|
||||||
|
n_open = 0
|
||||||
|
for c in contour_curves:
|
||||||
|
if c is None: continue
|
||||||
|
try:
|
||||||
|
if not c.IsClosed:
|
||||||
|
n_open += 1
|
||||||
|
continue
|
||||||
|
breps = rg.Brep.CreatePlanarBreps(c, tol)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not breps: continue
|
||||||
|
for brep in breps:
|
||||||
|
gid = doc.Objects.AddBrep(brep)
|
||||||
|
if gid and gid != System.Guid.Empty:
|
||||||
|
obj = doc.Objects.Find(gid)
|
||||||
|
if obj: created.append(obj)
|
||||||
|
if progress:
|
||||||
|
progress("→ {} Schichten-Flaechen ({} offene Konturen skipped)".format(
|
||||||
|
len(created), n_open))
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def generate_contour_curves(grid, shift_lv95, m_to_unit, interval=2.0,
|
||||||
|
progress=None):
|
||||||
|
"""Generiert Hoehenlinien (Contour-Curves) aus dem Terrain-Grid via
|
||||||
|
Mesh.CreateContourCurves.
|
||||||
|
|
||||||
|
interval: Hoehenabstand in REALEN METERN (1.0/2.0/5.0 typisch).
|
||||||
|
Liefert Liste von rg.Curve-Objekten in Doc-Units. Caller macht
|
||||||
|
doc.Objects.AddCurve + Layer-Move."""
|
||||||
|
if not grid or not grid.get("points"): return []
|
||||||
|
# Temp-Mesh aus Grid (gleicher Pipeline wie mesh_from_grid)
|
||||||
|
mesh = mesh_from_grid(grid, origin_shift=shift_lv95, unit_scale=m_to_unit)
|
||||||
|
if mesh.Vertices.Count < 3: return []
|
||||||
|
bb = mesh.GetBoundingBox(True)
|
||||||
|
z_min_doc = bb.Min.Z
|
||||||
|
z_max_doc = bb.Max.Z
|
||||||
|
interval_doc = interval * m_to_unit
|
||||||
|
if interval_doc <= 0: return []
|
||||||
|
if progress:
|
||||||
|
z_min_m = z_min_doc / m_to_unit + shift_lv95[2]
|
||||||
|
z_max_m = z_max_doc / m_to_unit + shift_lv95[2]
|
||||||
|
progress("Hoehenlinien: Z {:.1f}–{:.1f} m.ü.M, Abstand {} m".format(
|
||||||
|
z_min_m, z_max_m, interval))
|
||||||
|
try:
|
||||||
|
curves = rg.Mesh.CreateContourCurves(
|
||||||
|
mesh,
|
||||||
|
rg.Point3d(0, 0, z_min_doc),
|
||||||
|
rg.Point3d(0, 0, z_max_doc),
|
||||||
|
interval_doc)
|
||||||
|
except Exception as ex:
|
||||||
|
if progress: progress("Contour fail: {}".format(ex))
|
||||||
|
return []
|
||||||
|
if not curves:
|
||||||
|
if progress: progress("Keine Hoehenlinien erzeugt")
|
||||||
|
return []
|
||||||
|
out = list(curves)
|
||||||
|
if progress: progress("→ {} Hoehenlinien-Kurven".format(len(out)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# --- Orthofoto: SWISSIMAGE 10cm via GeoTIFF --------------------------------
|
# --- Orthofoto: SWISSIMAGE 10cm via GeoTIFF --------------------------------
|
||||||
|
|
||||||
def fetch_orthophoto(bbox_lv95, resolution="2.0", progress=None):
|
def fetch_orthophoto(bbox_lv95, resolution="2.0", progress=None):
|
||||||
@@ -584,53 +904,265 @@ def fetch_orthophoto(bbox_lv95, resolution="2.0", progress=None):
|
|||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
def apply_ortho_material(doc, mesh_obj, ortho_path, mesh_bbox_lv95):
|
def _geotiff_to_png(tif_path, max_dim=2048):
|
||||||
"""Erzeugt Rhino-Material mit dem SWISSIMAGE-GeoTIFF als Bitmap-Texture,
|
"""SWISSIMAGE kommt als GeoTIFF — Rhinos Material-Bitmap kann GeoTIFF nicht
|
||||||
weist es dem mesh_obj zu. UV-Mapping kommt aus den XY-Koords (linear auf
|
direkt lesen. Konvertiere zu PNG. Zwei Wege:
|
||||||
der bbox)."""
|
1) Pillow (wenn in Rhinos CPython verfuegbar) — universell + downsample
|
||||||
if not (ortho_path and os.path.isfile(ortho_path)): return
|
2) Eto.Drawing.Bitmap (Mac: NSImage liest TIFF nativ) — Fallback
|
||||||
|
Liefert PNG-Pfad oder None bei Fehler."""
|
||||||
|
if not tif_path: return None
|
||||||
|
base, _ = os.path.splitext(tif_path)
|
||||||
|
png_path = base + "_2k.png"
|
||||||
|
if os.path.isfile(png_path) and os.path.getsize(png_path) > 0:
|
||||||
|
print("[SWISSTOPO] PNG-Cache:", os.path.basename(png_path))
|
||||||
|
return png_path
|
||||||
|
# --- Variante 1: Pillow
|
||||||
try:
|
try:
|
||||||
rdoc = doc.RenderMaterials
|
from PIL import Image
|
||||||
from Rhino.Render import RenderMaterial, RenderContent
|
img = Image.open(tif_path)
|
||||||
|
if max(img.width, img.height) > max_dim:
|
||||||
|
scale = max_dim / float(max(img.width, img.height))
|
||||||
|
new_w = max(1, int(img.width * scale))
|
||||||
|
new_h = max(1, int(img.height * scale))
|
||||||
|
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||||||
|
if img.mode not in ("RGB", "RGBA"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(png_path, "PNG", optimize=False)
|
||||||
|
print("[SWISSTOPO] Pillow: {} → {} ({}x{}px)".format(
|
||||||
|
os.path.basename(tif_path), os.path.basename(png_path),
|
||||||
|
img.width, img.height))
|
||||||
|
return png_path
|
||||||
|
except ImportError:
|
||||||
|
print("[SWISSTOPO] Pillow nicht verfuegbar — versuche Eto.Drawing")
|
||||||
|
except Exception as ex:
|
||||||
|
print("[SWISSTOPO] Pillow-convert fail:", ex)
|
||||||
|
# --- Variante 2: Eto.Drawing (Mac NSImage liest TIFF)
|
||||||
try:
|
try:
|
||||||
mat = RenderMaterial.CreateBasicMaterial(
|
import Eto.Drawing as _ed
|
||||||
Rhino.DocObjects.Material(), doc)
|
bmp_src = _ed.Bitmap(tif_path)
|
||||||
except Exception:
|
if bmp_src is None:
|
||||||
mat = RenderMaterial.CreateBasicMaterial(
|
print("[SWISSTOPO] Eto konnte TIFF nicht laden")
|
||||||
Rhino.DocObjects.Material())
|
return None
|
||||||
try: mat.Name = "swisstopo_ortho_" + os.path.basename(ortho_path)
|
# Downsample falls > max_dim
|
||||||
|
w, h = bmp_src.Width, bmp_src.Height
|
||||||
|
if max(w, h) > max_dim:
|
||||||
|
scale = max_dim / float(max(w, h))
|
||||||
|
new_w = max(1, int(w * scale))
|
||||||
|
new_h = max(1, int(h * scale))
|
||||||
|
target = _ed.Bitmap(new_w, new_h, _ed.PixelFormat.Format32bppRgba)
|
||||||
|
g = _ed.Graphics(target)
|
||||||
|
try:
|
||||||
|
try: g.AntiAlias = True
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
# Bitmap zuweisen — Property-Name variiert mit Rhino-Version
|
g.DrawImage(bmp_src, 0, 0, new_w, new_h)
|
||||||
try:
|
finally: g.Dispose()
|
||||||
mat.SetParameter("diffuse-bitmap-filename", ortho_path)
|
bmp_src = target
|
||||||
except Exception as ex:
|
w, h = new_w, new_h
|
||||||
print("[SWISSTOPO] material bitmap:", ex)
|
try: bmp_src.Save(png_path, _ed.ImageFormat.Png)
|
||||||
try:
|
|
||||||
mid = rdoc.Add(mat)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
mid = doc.Materials.Add()
|
# Eto.ImageFormat-Variante kann je nach Eto-Version variieren
|
||||||
# UV-Mapping: planar in XY-bbox
|
bmp_src.Save(png_path)
|
||||||
e_min, n_min, e_max, n_max = mesh_bbox_lv95
|
print("[SWISSTOPO] Eto: {} → {} ({}x{}px)".format(
|
||||||
try:
|
os.path.basename(tif_path), os.path.basename(png_path), w, h))
|
||||||
plane = rg.Plane(rg.Point3d((e_min + e_max) / 2.0,
|
return png_path
|
||||||
(n_min + n_max) / 2.0, 0),
|
|
||||||
rg.Vector3d.ZAxis)
|
|
||||||
dx = abs(e_max - e_min)
|
|
||||||
dy = abs(n_max - n_min)
|
|
||||||
mapping = Rhino.Render.TextureMapping.CreatePlaneMapping(
|
|
||||||
plane, rg.Interval(-dx/2.0, dx/2.0),
|
|
||||||
rg.Interval(-dy/2.0, dy/2.0),
|
|
||||||
rg.Interval(-1, 1))
|
|
||||||
doc.Objects.ModifyTextureMapping(mesh_obj, 1, mapping)
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[SWISSTOPO] uv-mapping:", ex)
|
print("[SWISSTOPO] Eto-convert fail:", ex)
|
||||||
# Material aufs Object setzen
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def add_ortho_draped_mesh(doc, ortho_path, tile_bbox_lv95, terrain_grid,
|
||||||
|
shift_lv95, m_to_unit, z_lift=0.05,
|
||||||
|
target_layer_idx=-1):
|
||||||
|
"""Erzeugt ein Mesh, das der Topographie folgt — textured mit dem Ortho-
|
||||||
|
Foto. Statt einer flachen Plane: Per-Tile-Sub-Mesh aus dem Terrain-Grid
|
||||||
|
mit Per-Vertex-UV (0..1 ueber die Tile-Breite). Material kommt von einem
|
||||||
|
temporaeren PictureFrame (das ist der einzige Weg auf Mac Rhino 8 die
|
||||||
|
embedded Bitmap in Cycles zur Anzeige zu bringen) — der PictureFrame
|
||||||
|
wird hinterher geloescht, nur das Drape-Mesh bleibt.
|
||||||
|
|
||||||
|
terrain_grid: dict aus merge_grids() — wir extrahieren daraus die Punkte
|
||||||
|
innerhalb der Tile-bbox.
|
||||||
|
z_lift: kleiner Z-Offset (in doc-units) gegen Z-Fighting mit dem
|
||||||
|
darunterliegenden Terrain-Mesh."""
|
||||||
|
if not (ortho_path and os.path.isfile(ortho_path)): return None
|
||||||
|
# TIF direkt verwenden — Rhino's _Picture liest GeoTIFF nativ ueber
|
||||||
|
# NSImage (Mac) und behaelt 10cm-Aufloesung (10000×10000 px statt 2k PNG).
|
||||||
|
e_min, n_min, e_max, n_max = tile_bbox_lv95
|
||||||
|
sx, sy, sz = shift_lv95
|
||||||
|
# Terrain-Punkte innerhalb des Tiles aus dem Merged-Grid extrahieren
|
||||||
|
es = sorted(e for e in terrain_grid["es"]
|
||||||
|
if e_min - 0.01 <= e <= e_max + 0.01)
|
||||||
|
ns = sorted(n for n in terrain_grid["ns"]
|
||||||
|
if n_min - 0.01 <= n <= n_max + 0.01)
|
||||||
|
if len(es) < 2 or len(ns) < 2:
|
||||||
|
print("[SWISSTOPO] drape: zu wenig Terrain-Punkte fuer Tile")
|
||||||
|
return None
|
||||||
|
pts = terrain_grid["points"]
|
||||||
|
span_e = e_max - e_min
|
||||||
|
span_n = n_max - n_min
|
||||||
|
# Half-Pixel-Inset: bei 10000×10000 px Tiles wuerde ein Sample exakt an
|
||||||
|
# u=0 oder u=1 auf der Pixel-Grenze landen; mit clamp-to-border kann das
|
||||||
|
# weisse Linien an den Tile-Boundaries erzeugen. Wir verschieben UV
|
||||||
|
# minimal nach innen.
|
||||||
|
UV_INSET = 0.5 / 10000.0 # halbe Pixel-Breite im UV-Raum
|
||||||
|
mesh = rg.Mesh()
|
||||||
|
idx_for = {}
|
||||||
|
for j, ny in enumerate(ns):
|
||||||
|
for i, ex in enumerate(es):
|
||||||
|
z = pts.get((ex, ny))
|
||||||
|
if z is None: continue
|
||||||
|
v_idx = mesh.Vertices.Add(
|
||||||
|
(ex - sx) * m_to_unit,
|
||||||
|
(ny - sy) * m_to_unit,
|
||||||
|
(z - sz) * m_to_unit + z_lift)
|
||||||
|
u = UV_INSET + (ex - e_min) / span_e * (1.0 - 2 * UV_INSET)
|
||||||
|
v = UV_INSET + (ny - n_min) / span_n * (1.0 - 2 * UV_INSET)
|
||||||
|
mesh.TextureCoordinates.Add(u, v)
|
||||||
|
idx_for[(i, j)] = v_idx
|
||||||
|
n_faces = 0
|
||||||
|
for j in range(len(ns) - 1):
|
||||||
|
for i in range(len(es) - 1):
|
||||||
|
a = idx_for.get((i, j))
|
||||||
|
b = idx_for.get((i+1, j))
|
||||||
|
c = idx_for.get((i+1, j+1))
|
||||||
|
d = idx_for.get((i, j+1))
|
||||||
|
if a is None or b is None or c is None or d is None: continue
|
||||||
|
mesh.Faces.AddFace(a, b, c, d)
|
||||||
|
n_faces += 1
|
||||||
|
if n_faces == 0:
|
||||||
|
print("[SWISSTOPO] drape: keine Faces erzeugt")
|
||||||
|
return None
|
||||||
|
mesh.Normals.ComputeNormals()
|
||||||
|
mesh.Compact()
|
||||||
|
# Temp-PictureFrame off-screen erzeugen — ergibt working RenderMaterial
|
||||||
|
# mit Bitmap-Texture, das wir auf das Mesh uebertragen.
|
||||||
|
# embedBitmap=False: Pfad-Referenz statt 70MB-TIF-Embedding ins .3dm.
|
||||||
|
# Cache ist persistent (~/Library/Caches), Pfad bleibt gueltig.
|
||||||
|
pf_plane = rg.Plane(rg.Point3d(-1e6, -1e6, -1e6),
|
||||||
|
rg.Vector3d.XAxis, rg.Vector3d.YAxis)
|
||||||
try:
|
try:
|
||||||
|
pf_gid = doc.Objects.AddPictureFrame(
|
||||||
|
pf_plane, ortho_path, False, 1.0, 1.0, True, False)
|
||||||
|
except Exception as ex:
|
||||||
|
print("[SWISSTOPO] drape: PictureFrame-create fail:", ex)
|
||||||
|
return None
|
||||||
|
if not pf_gid or pf_gid == System.Guid.Empty:
|
||||||
|
print("[SWISSTOPO] drape: PictureFrame Empty-GUID")
|
||||||
|
return None
|
||||||
|
pf_obj = doc.Objects.Find(pf_gid)
|
||||||
|
pf_mat_idx = pf_obj.Attributes.MaterialIndex
|
||||||
|
# Mesh ins Doc + Material vom PictureFrame uebernehmen
|
||||||
|
mesh_gid = doc.Objects.AddMesh(mesh)
|
||||||
|
mesh_obj = doc.Objects.Find(mesh_gid)
|
||||||
|
if mesh_obj is None:
|
||||||
|
try: doc.Objects.Delete(pf_gid, True)
|
||||||
|
except Exception: pass
|
||||||
|
return None
|
||||||
attrs = mesh_obj.Attributes.Duplicate()
|
attrs = mesh_obj.Attributes.Duplicate()
|
||||||
attrs.MaterialSource = Rhino.DocObjects.ObjectMaterialSource.MaterialFromObject
|
attrs.MaterialSource = Rhino.DocObjects.ObjectMaterialSource.MaterialFromObject
|
||||||
attrs.RenderMaterial = mat
|
attrs.MaterialIndex = pf_mat_idx
|
||||||
|
if target_layer_idx >= 0:
|
||||||
|
attrs.LayerIndex = target_layer_idx
|
||||||
doc.Objects.ModifyAttributes(mesh_obj, attrs, True)
|
doc.Objects.ModifyAttributes(mesh_obj, attrs, True)
|
||||||
|
# Temp-PictureFrame loeschen — das Mesh hat jetzt das Material
|
||||||
|
try: doc.Objects.Delete(pf_gid, True)
|
||||||
|
except Exception: pass
|
||||||
|
print("[SWISSTOPO] drape mesh: {}x{} grid, {} faces, mat={}".format(
|
||||||
|
len(es), len(ns), n_faces, pf_mat_idx))
|
||||||
|
return mesh_obj
|
||||||
|
|
||||||
|
|
||||||
|
def add_ortho_plane(doc, ortho_path, tile_bbox_lv95, shift_lv95, m_to_unit,
|
||||||
|
z_doc=0.0, target_layer_idx=-1):
|
||||||
|
"""Erzeugt eine planare Brep-Flaeche mit dem SWISSIMAGE-Foto als Material,
|
||||||
|
direkt sichtbar in Top/Shaded/Rendered Display-Mode.
|
||||||
|
|
||||||
|
tile_bbox_lv95: (e_min, n_min, e_max, n_max) in LV95-Metern der Tile-Region
|
||||||
|
shift_lv95: (sx, sy, sz) — Origin-Shift in LV95-Metern (typisch eC,nC)
|
||||||
|
m_to_unit: Skalierung m → doc-units (z.B. 0.001 fuer km-Doc)
|
||||||
|
z_doc: Z-Hoehe der Plane in Doc-Units (typisch max-Terrain-Z + Epsilon)
|
||||||
|
|
||||||
|
Liefert den RhinoObject der erzeugten Plane (oder None)."""
|
||||||
|
if not (ortho_path and os.path.isfile(ortho_path)): return None
|
||||||
|
# TIF direkt — Rhino's Picture-Pfad liest GeoTIFF nativ (NSImage auf Mac).
|
||||||
|
# Behaelt die volle 10cm-Aufloesung statt auf 2k PNG runter zu skalieren.
|
||||||
|
# bbox in Doc-Units (nach Shift + Scale)
|
||||||
|
e_min, n_min, e_max, n_max = tile_bbox_lv95
|
||||||
|
sx, sy, sz = shift_lv95
|
||||||
|
x_min = (e_min - sx) * m_to_unit
|
||||||
|
x_max = (e_max - sx) * m_to_unit
|
||||||
|
y_min = (n_min - sy) * m_to_unit
|
||||||
|
y_max = (n_max - sy) * m_to_unit
|
||||||
|
# PictureFrame mit embedded Bitmap. AddPictureFrame interpretiert
|
||||||
|
# plane.Origin als BOTTOM-LEFT corner der Picture (nicht als Zentrum!).
|
||||||
|
# Width geht in +X-Richtung, Height in +Y-Richtung des Planes.
|
||||||
|
width = abs(x_max - x_min)
|
||||||
|
height = abs(y_max - y_min)
|
||||||
|
plane = rg.Plane(rg.Point3d(x_min, y_min, z_doc),
|
||||||
|
rg.Vector3d.XAxis, rg.Vector3d.YAxis)
|
||||||
|
try:
|
||||||
|
size_mb = os.path.getsize(ortho_path) / 1e6
|
||||||
|
print("[SWISSTOPO] PictureFrame src: {} ({:.1f} MB)".format(
|
||||||
|
os.path.basename(ortho_path), size_mb))
|
||||||
|
except Exception:
|
||||||
|
print("[SWISSTOPO] file nicht lesbar:", ortho_path)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
gid = doc.Objects.AddPictureFrame(
|
||||||
|
plane, ortho_path,
|
||||||
|
False, # asMesh=False (Brep) — Mac Rhino 8 ignoriert die
|
||||||
|
# Plane bei asMesh=True, alle Pictures landen
|
||||||
|
# uebereinander
|
||||||
|
width, height,
|
||||||
|
True, # selfIllumination=True — Textur unabhaengig von
|
||||||
|
# Lighting sichtbar (sonst evtl. dunkel in modes
|
||||||
|
# ohne Lichtquellen)
|
||||||
|
False) # embedBitmap=False — Pfad-Referenz (Cache bleibt
|
||||||
|
# persistent, kein 70MB-Embedding pro Tile)
|
||||||
|
if gid == System.Guid.Empty:
|
||||||
|
print("[SWISSTOPO] AddPictureFrame: Empty-GUID")
|
||||||
|
return None
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[SWISSTOPO] material assign:", ex)
|
print("[SWISSTOPO] AddPictureFrame exception:", ex)
|
||||||
|
return None
|
||||||
|
obj = doc.Objects.Find(gid)
|
||||||
|
if obj is None: return None
|
||||||
|
# Auf Ziel-Layer schieben (nachträglich; Material bleibt auf Object).
|
||||||
|
if target_layer_idx >= 0:
|
||||||
|
try:
|
||||||
|
at = obj.Attributes.Duplicate()
|
||||||
|
at.LayerIndex = target_layer_idx
|
||||||
|
doc.Objects.ModifyAttributes(obj, at, True)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[SWISSTOPO] apply_ortho_material:", ex)
|
print("[SWISSTOPO] Layer-Move fail:", ex)
|
||||||
|
# Diagnose: hat das Material tatsaechlich eine Bitmap-Textur drin?
|
||||||
|
try:
|
||||||
|
o2 = doc.Objects.Find(gid)
|
||||||
|
a = o2.Attributes
|
||||||
|
print("[SWISSTOPO] PictureFrame OK id={} layer='{}' MatSrc={} MatIdx={} hidden={}".format(
|
||||||
|
gid, doc.Layers[a.LayerIndex].FullPath,
|
||||||
|
a.MaterialSource, a.MaterialIndex, o2.IsHidden))
|
||||||
|
# Material-Inspect
|
||||||
|
mat = None
|
||||||
|
try:
|
||||||
|
if a.MaterialIndex >= 0 and a.MaterialIndex < doc.Materials.Count:
|
||||||
|
mat = doc.Materials[a.MaterialIndex]
|
||||||
|
except Exception: pass
|
||||||
|
if mat is not None:
|
||||||
|
try: bmp_fn = mat.GetBitmapTexture().FileName if mat.GetBitmapTexture() else None
|
||||||
|
except Exception: bmp_fn = None
|
||||||
|
try: tex = mat.GetTexture(Rhino.DocObjects.TextureType.Bitmap)
|
||||||
|
except Exception: tex = None
|
||||||
|
print("[SWISSTOPO] material[{}].Name='{}' bitmap='{}' tex={} textures={}".format(
|
||||||
|
a.MaterialIndex, mat.Name, bmp_fn, tex,
|
||||||
|
mat.GetTextures().Length if hasattr(mat, "GetTextures") else "?"))
|
||||||
|
# RenderMaterial-Inspect
|
||||||
|
try:
|
||||||
|
rm = a.RenderMaterial
|
||||||
|
print("[SWISSTOPO] RenderMaterial: {}".format(
|
||||||
|
rm.Name if rm else "None"))
|
||||||
|
except Exception as ex:
|
||||||
|
print("[SWISSTOPO] RenderMaterial-check fail:", ex)
|
||||||
|
except Exception as ex:
|
||||||
|
print("[SWISSTOPO] diag fail:", ex)
|
||||||
|
return obj
|
||||||
|
|||||||
+20
-4
@@ -62,9 +62,18 @@ export default function App() {
|
|||||||
|
|
||||||
// Sichtbarkeit live anwenden bei Layer-Aenderungen. Zeichnungsebenen-Slice
|
// Sichtbarkeit live anwenden bei Layer-Aenderungen. Zeichnungsebenen-Slice
|
||||||
// bleibt leer — Backend mergt mit doc.Strings.
|
// bleibt leer — Backend mergt mit doc.Strings.
|
||||||
|
// Rekursiv durch Children — sonst feuert das useEffect nicht wenn nur die
|
||||||
|
// Visibility/Lock einer Sub-Ebene geaendert wurde.
|
||||||
|
const visKeyFor = (e) => {
|
||||||
|
const own = `${e.code}:${e.visible !== false ? 1 : 0}:${e.locked === true ? 1 : 0}`
|
||||||
|
const kids = Array.isArray(e.children) && e.children.length
|
||||||
|
? '(' + e.children.map(visKeyFor).join(',') + ')'
|
||||||
|
: ''
|
||||||
|
return own + kids
|
||||||
|
}
|
||||||
const visibilityKey = useMemo(() => (
|
const visibilityKey = useMemo(() => (
|
||||||
activeCode + '|' + eMode + '|' +
|
activeCode + '|' + eMode + '|' +
|
||||||
ebenen.map(e => `${e.code}:${e.visible !== false ? 1 : 0}:${e.locked === true ? 1 : 0}`).join(',')
|
ebenen.map(visKeyFor).join(',')
|
||||||
), [activeCode, eMode, ebenen])
|
), [activeCode, eMode, ebenen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,17 +82,24 @@ export default function App() {
|
|||||||
}, [visibilityKey])
|
}, [visibilityKey])
|
||||||
|
|
||||||
// Auto-Apply bei strukturellen Aenderungen (name, fill) — wieder nur unsere
|
// Auto-Apply bei strukturellen Aenderungen (name, fill) — wieder nur unsere
|
||||||
// Slice, Backend mergt.
|
// Slice, Backend mergt. Rekursiv durch Children.
|
||||||
const fillSig = (e) => {
|
const fillSig = (e) => {
|
||||||
const f = e.fill
|
const f = e.fill
|
||||||
if (!f || !f.pattern || f.pattern === 'None') return ''
|
if (!f || !f.pattern || f.pattern === 'None') return ''
|
||||||
return [f.pattern, f.source || 'layer', f.color || '', f.scale ?? 1, f.rotation ?? 0].join('|')
|
return [f.pattern, f.source || 'layer', f.color || '', f.scale ?? 1, f.rotation ?? 0].join('|')
|
||||||
}
|
}
|
||||||
|
const structKeyFor = (e) => {
|
||||||
|
const own = `${e.code}:${e.name}:${fillSig(e)}`
|
||||||
|
const kids = Array.isArray(e.children) && e.children.length
|
||||||
|
? '(' + e.children.map(structKeyFor).join(',') + ')'
|
||||||
|
: ''
|
||||||
|
return own + kids
|
||||||
|
}
|
||||||
const structureKey = useMemo(() => (
|
const structureKey = useMemo(() => (
|
||||||
ebenen.map(e => `${e.code}:${e.name}:${fillSig(e)}`).join(',')
|
ebenen.map(structKeyFor).join(',')
|
||||||
), [ebenen])
|
), [ebenen])
|
||||||
const appliedStructureKey = useMemo(() => (
|
const appliedStructureKey = useMemo(() => (
|
||||||
appliedE.map(e => `${e.code}:${e.name}:${fillSig(e)}`).join(',')
|
appliedE.map(structKeyFor).join(',')
|
||||||
), [appliedE])
|
), [appliedE])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,14 +29,45 @@ export default function EbenenSettingsApp() {
|
|||||||
return () => document.removeEventListener('contextmenu', blockContext)
|
return () => document.removeEventListener('contextmenu', blockContext)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const sortedEbenen = [...ebenen].sort((a, b) => {
|
// Flach durch Tree iterieren — Sub-Ebenen sind verschachtelt in children
|
||||||
|
const flattenEbenen = (list, depth = 0) => {
|
||||||
|
const out = []
|
||||||
|
for (const e of list) {
|
||||||
|
if (!e || typeof e !== 'object') continue
|
||||||
|
out.push({ ...e, _depth: depth })
|
||||||
|
if (Array.isArray(e.children) && e.children.length) {
|
||||||
|
out.push(...flattenEbenen(e.children, depth + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const flatEbenen = flattenEbenen(ebenen)
|
||||||
|
|
||||||
|
// Sort: nur Top-Level (depth=0) numerisch sortieren — Children stehen
|
||||||
|
// direkt hinter ihrem Parent. Beim Picker zeigt das die Hierarchie.
|
||||||
|
const sortedEbenen = (() => {
|
||||||
|
const tops = flatEbenen.filter(e => e._depth === 0)
|
||||||
|
tops.sort((a, b) => {
|
||||||
const ca = parseInt(a.code, 10), cb = parseInt(b.code, 10)
|
const ca = parseInt(a.code, 10), cb = parseInt(b.code, 10)
|
||||||
if (!isNaN(ca) && !isNaN(cb)) return ca - cb
|
if (!isNaN(ca) && !isNaN(cb)) return ca - cb
|
||||||
return (a.code || '').localeCompare(b.code || '')
|
return (a.code || '').localeCompare(b.code || '')
|
||||||
})
|
})
|
||||||
|
const out = []
|
||||||
|
for (const top of tops) {
|
||||||
|
out.push(top)
|
||||||
|
// Children direkt anhaengen, ebenfalls per Code sortiert
|
||||||
|
const kids = flatEbenen.filter(e =>
|
||||||
|
e._depth === 1 &&
|
||||||
|
flatEbenen.find(p => p._depth === 0 && Array.isArray(p.children)
|
||||||
|
&& p.children.some(c => c.code === e.code))?.code === top.code)
|
||||||
|
kids.sort((a, b) => (a.code || '').localeCompare(b.code || ''))
|
||||||
|
out.push(...kids)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})()
|
||||||
|
|
||||||
const currentEbene = ebenen.find(e => e.code === selectedCode)
|
const currentEbene = flatEbenen.find(e => e.code === selectedCode)
|
||||||
|| ebenen.find(e => e.code === originalCode)
|
|| flatEbenen.find(e => e.code === originalCode)
|
||||||
|| initial.ebene
|
|| initial.ebene
|
||||||
|| null
|
|| null
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -6,7 +6,7 @@ import {
|
|||||||
createFenster, createTuer, createAussparung, createTreppe,
|
createFenster, createTuer, createAussparung, createTreppe,
|
||||||
createStuetze, createTraeger, createRaum,
|
createStuetze, createTraeger, createRaum,
|
||||||
exportRaeume,
|
exportRaeume,
|
||||||
openSwisstopo, openSwisstopoDialog,
|
openSwisstopo, openSwisstopoDialog, openOsmDialog,
|
||||||
updateElement, deleteElement, regenerateAllElements,
|
updateElement, deleteElement, regenerateAllElements,
|
||||||
} from './lib/rhinoBridge'
|
} from './lib/rhinoBridge'
|
||||||
|
|
||||||
@@ -478,10 +478,13 @@ function NeuesElementSection({ noGeschoss, activeName }) {
|
|||||||
onClick={() => createRaum({})} />
|
onClick={() => createRaum({})} />
|
||||||
</PillGroup>
|
</PillGroup>
|
||||||
|
|
||||||
<PillGroup label="Swisstopo">
|
<PillGroup label="Importer">
|
||||||
<PillButton icon="download" label="Importer…"
|
<PillButton icon="download" label="Swisstopo"
|
||||||
hint="Vollautomatischer Import via swisstopo STAC-API: Adresse suchen, Radius wählen, Gebäude + Terrain + Luftbild holen — Tiles werden gecacht"
|
hint="Vollautomatischer Import via swisstopo STAC-API: Adresse suchen, Radius wählen, Gebäude + Terrain + Luftbild holen"
|
||||||
onClick={() => openSwisstopoDialog()} />
|
onClick={() => openSwisstopoDialog()} />
|
||||||
|
<PillButton icon="public" label="OSM"
|
||||||
|
hint="OpenStreetMap-Daten via Overpass-API als 2D-Linien: Strassen, Gebäudeumrisse, Wasser, Grünflächen, Wege"
|
||||||
|
onClick={() => openOsmDialog()} />
|
||||||
<PillButton icon="map" label="Karte"
|
<PillButton icon="map" label="Karte"
|
||||||
hint="Öffnet map.geo.admin.ch im Browser zur visuellen Inspektion"
|
hint="Öffnet map.geo.admin.ch im Browser zur visuellen Inspektion"
|
||||||
onClick={() => openSwisstopo('both')} />
|
onClick={() => openSwisstopo('both')} />
|
||||||
|
|||||||
+65
-35
@@ -21,10 +21,10 @@ const PRESETS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const VIEWS = [
|
const VIEWS = [
|
||||||
{ value: 'Top', icon: 'north', label: 'Top' },
|
{ value: 'Top', icon: 'view_quilt', label: 'Top' },
|
||||||
{ value: 'Front', icon: 'view_in_ar', label: 'Front' },
|
{ value: 'Front', icon: 'north', label: 'Front' },
|
||||||
{ value: 'Right', icon: 'east', label: 'Right' },
|
{ value: 'Right', icon: 'east', label: 'Right' },
|
||||||
{ value: 'Perspective', icon: 'view_quilt', label: 'Persp' },
|
{ value: 'Perspective', icon: 'view_in_ar', label: 'Persp' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function fmtScale(s) {
|
function fmtScale(s) {
|
||||||
@@ -207,21 +207,37 @@ export default function OberleisteApp() {
|
|||||||
{/* === Toolbar (View, Display, Massstab, Snap, Overrides) === */}
|
{/* === Toolbar (View, Display, Massstab, Snap, Overrides) === */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
padding: '6px 12px',
|
padding: '4px 12px 8px',
|
||||||
overflowX: 'auto', overflowY: 'hidden',
|
overflowX: 'auto', overflowY: 'hidden',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<span
|
{/* Logo: DOSSIER. (Petrol-Punkt) + Launcher-Version */}
|
||||||
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11, fontWeight: 600, letterSpacing: '0.08em',
|
display: 'flex', alignItems: 'baseline', gap: 8,
|
||||||
color: 'var(--text-muted)',
|
|
||||||
fontFamily: 'DM Mono, monospace',
|
|
||||||
flexShrink: 0, userSelect: 'none',
|
flexShrink: 0, userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
title={`Dossier ${__APP_VERSION__} — Teil von OpenStudio`}
|
title={`Dossier ${__LAUNCHER_VERSION__} (Plugin ${__APP_VERSION__}) — Teil von OpenStudio`}
|
||||||
>
|
>
|
||||||
DOSSIER <span style={{ opacity: 0.55 }}>{__APP_VERSION__}</span>
|
<span style={{
|
||||||
|
fontFamily: "Krungthep, 'Archivo Black', sans-serif",
|
||||||
|
fontSize: 18,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
DOSSIER<span style={{ color: 'var(--accent)' }}>.</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'DM Mono, monospace',
|
||||||
|
fontSize: 9,
|
||||||
|
letterSpacing: '0.14em',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
|
v{__LAUNCHER_VERSION__}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => openDossierSettings()}
|
onClick={() => openDossierSettings()}
|
||||||
title="Dossier-Einstellungen"
|
title="Dossier-Einstellungen"
|
||||||
@@ -344,8 +360,22 @@ export default function OberleisteApp() {
|
|||||||
{/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar
|
{/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar
|
||||||
schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */}
|
schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */}
|
||||||
|
|
||||||
{/* ====== GRUPPE: OVERRIDES ====== */}
|
{/* ====== STACK: Overrides + Kombi uebereinander ======
|
||||||
<span style={groupLabel}>Overrides</span>
|
Beide Zeilen haben identisches Spalten-Layout (Label-Spalte fix,
|
||||||
|
Dropdown gleich breit), damit Dropdowns vertikal aligned sind. */}
|
||||||
|
{(() => {
|
||||||
|
const STACK_LABEL_W = 60 // gleich breit fuer beide Zeilen
|
||||||
|
const STACK_DROPDOWN_W = 150
|
||||||
|
const stackLabel = { ...groupLabel, width: STACK_LABEL_W,
|
||||||
|
padding: 0, textAlign: 'left' }
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 4,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{/* Overrides */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={stackLabel}>Overrides</span>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
onClick={() => toggleOverrides(!state.overridesEnabled)}
|
onClick={() => toggleOverrides(!state.overridesEnabled)}
|
||||||
active={state.overridesEnabled}
|
active={state.overridesEnabled}
|
||||||
@@ -355,9 +385,6 @@ export default function OberleisteApp() {
|
|||||||
? `Grafische Overrides aktiv — klick zum Ausschalten`
|
? `Grafische Overrides aktiv — klick zum Ausschalten`
|
||||||
: `Grafische Overrides ausgeschaltet`}
|
: `Grafische Overrides ausgeschaltet`}
|
||||||
/>
|
/>
|
||||||
{/* Preset-Dropdown: aktive Kombination waehlen. "—" = keine Kombination
|
|
||||||
(Doc-Rules sind frei editiert oder leer). "Konfigurieren…" oeffnet
|
|
||||||
den grossen Regel-Editor (OVERRIDES-Panel). */}
|
|
||||||
<select
|
<select
|
||||||
value={state.overridesActivePreset || '__none__'}
|
value={state.overridesActivePreset || '__none__'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -365,7 +392,7 @@ export default function OberleisteApp() {
|
|||||||
if (v === '__configure__') { openOverridesPanel(); return }
|
if (v === '__configure__') { openOverridesPanel(); return }
|
||||||
setOverridesPreset(v === '__none__' ? null : v)
|
setOverridesPreset(v === '__none__' ? null : v)
|
||||||
}}
|
}}
|
||||||
style={{ ...pillSelect, width: 140 }}
|
style={{ ...pillSelect, width: STACK_DROPDOWN_W }}
|
||||||
title={state.overridesActivePreset
|
title={state.overridesActivePreset
|
||||||
? `Aktives Preset: ${state.overridesActivePreset} (${state.overridesCount} Regeln)`
|
? `Aktives Preset: ${state.overridesActivePreset} (${state.overridesCount} Regeln)`
|
||||||
: `Kein Preset aktiv (${state.overridesCount} Regeln, frei editiert)`}
|
: `Kein Preset aktiv (${state.overridesCount} Regeln, frei editiert)`}
|
||||||
@@ -382,11 +409,23 @@ export default function OberleisteApp() {
|
|||||||
icon="settings"
|
icon="settings"
|
||||||
title="Overrides-Regel-Editor öffnen"
|
title="Overrides-Regel-Editor öffnen"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div style={sep} />
|
{/* Kombi */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
{/* ====== GRUPPE: EBENENKOMBINATION ====== */}
|
<span style={stackLabel}>Kombi</span>
|
||||||
<span style={groupLabel}>Kombi</span>
|
<ToolButton
|
||||||
|
onClick={() => {
|
||||||
|
const suggested = state.layerCombinationActive
|
||||||
|
|| `Kombi ${(state.layerCombinations || []).length + 1}`
|
||||||
|
const name = (window.prompt('Name für Ebenenkombination:', suggested) || '').trim()
|
||||||
|
if (!name) return
|
||||||
|
if ((state.layerCombinations || []).includes(name) &&
|
||||||
|
!window.confirm(`"${name}" überschreiben?`)) return
|
||||||
|
saveLayerCombination(name)
|
||||||
|
}}
|
||||||
|
icon="add"
|
||||||
|
title="Aktuelle Sichtbarkeit als neue Kombination speichern"
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
value={state.layerCombinationActive || '__none__'}
|
value={state.layerCombinationActive || '__none__'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -400,7 +439,7 @@ export default function OberleisteApp() {
|
|||||||
}
|
}
|
||||||
pickLayerCombination(v === '__none__' ? null : v)
|
pickLayerCombination(v === '__none__' ? null : v)
|
||||||
}}
|
}}
|
||||||
style={{ ...pillSelect, width: 140 }}
|
style={{ ...pillSelect, width: STACK_DROPDOWN_W }}
|
||||||
title={state.layerCombinationActive
|
title={state.layerCombinationActive
|
||||||
? `Aktive Kombi: ${state.layerCombinationActive}`
|
? `Aktive Kombi: ${state.layerCombinationActive}`
|
||||||
: 'Keine Kombination — manuelle Sichtbarkeit'}
|
: 'Keine Kombination — manuelle Sichtbarkeit'}
|
||||||
@@ -418,24 +457,15 @@ export default function OberleisteApp() {
|
|||||||
<option disabled>──────────</option>
|
<option disabled>──────────</option>
|
||||||
<option value="__configure__">Bearbeiten…</option>
|
<option value="__configure__">Bearbeiten…</option>
|
||||||
</select>
|
</select>
|
||||||
<ToolButton
|
|
||||||
onClick={() => {
|
|
||||||
const suggested = state.layerCombinationActive
|
|
||||||
|| `Kombi ${(state.layerCombinations || []).length + 1}`
|
|
||||||
const name = (window.prompt('Name für Ebenenkombination:', suggested) || '').trim()
|
|
||||||
if (!name) return
|
|
||||||
if ((state.layerCombinations || []).includes(name) &&
|
|
||||||
!window.confirm(`"${name}" überschreiben?`)) return
|
|
||||||
saveLayerCombination(name)
|
|
||||||
}}
|
|
||||||
icon="add"
|
|
||||||
title="Aktuelle Sichtbarkeit als neue Kombination speichern"
|
|
||||||
/>
|
|
||||||
<ToolButton
|
<ToolButton
|
||||||
onClick={openLayerCombinationsDialog}
|
onClick={openLayerCombinationsDialog}
|
||||||
icon="edit"
|
icon="edit"
|
||||||
title="Ebenenkombinationen bearbeiten"
|
title="Ebenenkombinationen bearbeiten"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Spacer am rechten Rand */}
|
{/* Spacer am rechten Rand */}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import Icon from './components/Icon'
|
||||||
|
import { onMessage, notifyReady } from './lib/rhinoBridge'
|
||||||
|
|
||||||
|
function send(type, payload = {}) {
|
||||||
|
if (!window.RHINO_MODE) { console.log('[OSM] →', type, payload); return }
|
||||||
|
document.title = 'RHINOMSG::' + JSON.stringify({ type, payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, hint, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '6px 0' }}>
|
||||||
|
{label && <span className="label-xs">{label}</span>}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>{children}</div>
|
||||||
|
{hint && (
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4 }}>{hint}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({ children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 9, color: 'var(--text-muted)', fontWeight: 600,
|
||||||
|
letterSpacing: 0.5, textTransform: 'uppercase',
|
||||||
|
padding: '10px 0 4px',
|
||||||
|
borderTop: '1px solid var(--border-light)',
|
||||||
|
marginTop: 8,
|
||||||
|
}}>{children}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Radio({ value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{options.map(o => (
|
||||||
|
<button key={o.value}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
className={value === o.value ? 'btn-contained' : 'btn-outlined'}
|
||||||
|
style={{ padding: '4px 10px', fontSize: 10 }}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSM-Kategorien — Keys matchen das Backend (osm.py CATEGORIES).
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ key: 'streets', label: 'Strassen', icon: 'route',
|
||||||
|
hint: 'Autobahn/Hauptstrasse/Quartierstrasse → Polylinien' },
|
||||||
|
{ key: 'buildings', label: 'Gebäudeumrisse', icon: 'apartment',
|
||||||
|
hint: 'building=* Umrisse als geschlossene Polylinien' },
|
||||||
|
{ key: 'water', label: 'Wasser (Flächen)', icon: 'water',
|
||||||
|
hint: 'natural=water (Seen, Teiche)' },
|
||||||
|
{ key: 'waterways', label: 'Wasserläufe', icon: 'waves',
|
||||||
|
hint: 'waterway=river/stream/canal' },
|
||||||
|
{ key: 'parks', label: 'Parks', icon: 'park',
|
||||||
|
hint: 'leisure=park/garden' },
|
||||||
|
{ key: 'forest', label: 'Wald & Grün', icon: 'forest',
|
||||||
|
hint: 'landuse=forest/grass/meadow' },
|
||||||
|
{ key: 'footpaths', label: 'Fuss-/Radwege', icon: 'directions_walk',
|
||||||
|
hint: 'highway=footway/path/track/cycleway' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function OsmApp() {
|
||||||
|
// Standort
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
const [center, setCenter] = useState(null)
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
// Optionen
|
||||||
|
const [radius, setRadius] = useState(200)
|
||||||
|
const [selected, setSelected] = useState({
|
||||||
|
streets: true, buildings: true, waterways: true,
|
||||||
|
parks: true, forest: true,
|
||||||
|
water: false, footpaths: false,
|
||||||
|
})
|
||||||
|
const [shift, setShift] = useState(true)
|
||||||
|
const [autoZoom, setAutoZoom] = useState(true)
|
||||||
|
const [replaceExisting, setReplaceExisting] = useState(true)
|
||||||
|
// Live-Log
|
||||||
|
const [logs, setLogs] = useState([])
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
const logRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMessage('GEOCODE_RESULT', ({ result }) => {
|
||||||
|
setSearching(false)
|
||||||
|
if (result && result.e != null && result.n != null) {
|
||||||
|
setCenter({ e: result.e, n: result.n, label: result.label || searchText })
|
||||||
|
} else {
|
||||||
|
setCenter(null)
|
||||||
|
addLog('Keine Adresse gefunden')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onMessage('OSM_LOG', ({ msg }) => addLog(msg))
|
||||||
|
onMessage('IMPORT_DONE', ({ count }) => {
|
||||||
|
setRunning(false); setDone(true)
|
||||||
|
addLog(`✓ Fertig — ${count} OSM-Objekt(e) importiert`)
|
||||||
|
})
|
||||||
|
notifyReady()
|
||||||
|
const blockContext = (ev) => ev.preventDefault()
|
||||||
|
document.addEventListener('contextmenu', blockContext)
|
||||||
|
return () => document.removeEventListener('contextmenu', blockContext)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
||||||
|
}, [logs])
|
||||||
|
|
||||||
|
const addLog = (m) => setLogs(l => [...l, m])
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const t = searchText.trim()
|
||||||
|
if (!t) return
|
||||||
|
setSearching(true); setCenter(null)
|
||||||
|
addLog(`Suche '${t}'...`)
|
||||||
|
send('GEOCODE', { text: t })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualCoords = (eRaw, nRaw) => {
|
||||||
|
const e = parseFloat(eRaw), n = parseFloat(nRaw)
|
||||||
|
if (e > 2000000 && n > 1000000) {
|
||||||
|
setCenter({ e, n, label: `LV95 manuell` })
|
||||||
|
} else {
|
||||||
|
setCenter(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
if (!center) { addLog('Bitte zuerst einen Standort wählen'); return }
|
||||||
|
const cats = Object.entries(selected).filter(([, v]) => v).map(([k]) => k)
|
||||||
|
if (cats.length === 0) { addLog('Mindestens eine Kategorie auswählen'); return }
|
||||||
|
setLogs([]); setRunning(true); setDone(false)
|
||||||
|
send('RUN_OSM_IMPORT', {
|
||||||
|
centerE: center.e, centerN: center.n,
|
||||||
|
radius: Number(radius),
|
||||||
|
categories: cats,
|
||||||
|
shiftToOrigin: shift,
|
||||||
|
autoZoom,
|
||||||
|
replaceExisting,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCat = (key) => {
|
||||||
|
setSelected(s => ({ ...s, [key]: !s[key] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
background: 'var(--bg-dialog)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
fontFamily: 'var(--font)', color: 'var(--text-primary)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 16px' }}>
|
||||||
|
|
||||||
|
<SectionLabel>Standort</SectionLabel>
|
||||||
|
|
||||||
|
<Field label="ADRESSE / ORT" hint='z.B. "Bahnhofstrasse 1, Zürich"'>
|
||||||
|
<input
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch() }}
|
||||||
|
placeholder="Adresse oder Ortsname"
|
||||||
|
style={{ flex: 1, fontSize: 11, padding: '5px 8px' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-outlined"
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={searching || !searchText.trim()}
|
||||||
|
style={{ padding: '4px 10px', fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{searching ? '…' : 'Suchen'}
|
||||||
|
</button>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="ODER LV95-KOORDS (E / N)"
|
||||||
|
hint="Falls aus Swisstopo-Import übernommen">
|
||||||
|
<input placeholder="E"
|
||||||
|
onChange={(e) => handleManualCoords(e.target.value, center?.n || '')}
|
||||||
|
style={{ width: 110, fontSize: 11, fontFamily: 'DM Mono, monospace', padding: '5px 8px' }} />
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>/</span>
|
||||||
|
<input placeholder="N"
|
||||||
|
onChange={(e) => handleManualCoords(center?.e || '', e.target.value)}
|
||||||
|
style={{ width: 110, fontSize: 11, fontFamily: 'DM Mono, monospace', padding: '5px 8px' }} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{center && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: 'var(--accent-dim)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
borderRadius: 'var(--r)',
|
||||||
|
marginTop: 4,
|
||||||
|
}}>
|
||||||
|
<Icon name="location_on" size={14} style={{ color: 'var(--accent)' }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{center.label}</div>
|
||||||
|
<div style={{ fontSize: 9, fontFamily: 'DM Mono, monospace',
|
||||||
|
color: 'var(--text-muted)' }}>
|
||||||
|
E {Math.round(center.e)} · N {Math.round(center.n)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionLabel>Bereich</SectionLabel>
|
||||||
|
<Field label="RADIUS">
|
||||||
|
<Radio value={radius}
|
||||||
|
options={[
|
||||||
|
{ value: 100, label: '100 m' },
|
||||||
|
{ value: 200, label: '200 m' },
|
||||||
|
{ value: 500, label: '500 m' },
|
||||||
|
{ value: 1000, label: '1 km' },
|
||||||
|
]}
|
||||||
|
onChange={setRadius} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<SectionLabel>Kategorien</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{CATEGORIES.map(cat => (
|
||||||
|
<label key={cat.key}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
fontSize: 11, cursor: 'pointer', padding: '3px 0' }}
|
||||||
|
title={cat.hint}>
|
||||||
|
<input type="checkbox" checked={!!selected[cat.key]}
|
||||||
|
onChange={() => toggleCat(cat.key)} />
|
||||||
|
<Icon name={cat.icon} size={13} />
|
||||||
|
<span>{cat.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionLabel>Positionierung</SectionLabel>
|
||||||
|
<Field label="ORIGIN"
|
||||||
|
hint="LV95-Koords sind im Mio-Bereich. Auf 0/0/0 verschiebt zum aktiven Standort.">
|
||||||
|
<Radio value={shift ? 'origin' : 'lv95'}
|
||||||
|
options={[
|
||||||
|
{ value: 'origin', label: 'Auf Welt-Origin verschieben' },
|
||||||
|
{ value: 'lv95', label: 'Original LV95 lassen' },
|
||||||
|
]}
|
||||||
|
onChange={(v) => setShift(v === 'origin')} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 11, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={autoZoom}
|
||||||
|
onChange={(e) => setAutoZoom(e.target.checked)} />
|
||||||
|
Auto-Zoom auf importierte Objekte
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label=""
|
||||||
|
hint="Bestehende OSM-Objekte (Tag dossier_osm_kind) werden vorher gelöscht.">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 11, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={replaceExisting}
|
||||||
|
onChange={(e) => setReplaceExisting(e.target.checked)} />
|
||||||
|
Bestehende OSM-Objekte vorher löschen
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<SectionLabel>Status</SectionLabel>
|
||||||
|
<div ref={logRef} style={{
|
||||||
|
height: 140, overflowY: 'auto',
|
||||||
|
padding: 8, fontSize: 10,
|
||||||
|
fontFamily: 'DM Mono, monospace',
|
||||||
|
background: 'var(--bg-base)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
borderRadius: 'var(--r)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}>
|
||||||
|
{logs.length === 0
|
||||||
|
? <span style={{ color: 'var(--text-muted)' }}>Bereit</span>
|
||||||
|
: logs.map((l, i) => <div key={i}>{l}</div>)}
|
||||||
|
{running && <div style={{ color: 'var(--accent)' }}>Läuft…</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-section)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--text-muted)', flex: 1 }}>
|
||||||
|
Quelle: Overpass-API · © OpenStreetMap-Mitwirkende (ODbL)
|
||||||
|
</div>
|
||||||
|
<button className="btn-text" onClick={() => send('CANCEL')}>Abbrechen</button>
|
||||||
|
<button className="btn-contained"
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={running || !center}>
|
||||||
|
<Icon name="download" size={12} />
|
||||||
|
{running ? 'Lädt…' : 'Importieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+89
-2
@@ -57,8 +57,19 @@ export default function SwisstopoApp() {
|
|||||||
// Optionen
|
// Optionen
|
||||||
const [radius, setRadius] = useState(100)
|
const [radius, setRadius] = useState(100)
|
||||||
const [getBuild, setGetBuild] = useState(true)
|
const [getBuild, setGetBuild] = useState(true)
|
||||||
|
const [buildVersion, setBuildVersion] = useState('v2') // v2 (stabil) / v3 (beta)
|
||||||
|
const [buildVariant, setBuildVariant] = useState('separated')
|
||||||
const [getTerrain, setGetTerrain] = useState(false)
|
const [getTerrain, setGetTerrain] = useState(false)
|
||||||
const [getOrtho, setGetOrtho] = useState(false)
|
const [getOrtho, setGetOrtho] = useState(false)
|
||||||
|
const [getContours, setGetContours] = useState(false)
|
||||||
|
const [getContourTin,setGetContourTin]= useState(false)
|
||||||
|
const [getContourSchicht, setGetContourSchicht] = 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
|
||||||
|
// fuer Vektordaten (Strassen/Wasser/Gebaeude).
|
||||||
|
const getTlm = false
|
||||||
|
const tlmKinds = {}
|
||||||
const [shift, setShift] = useState(true)
|
const [shift, setShift] = useState(true)
|
||||||
const [autoZoom, setAutoZoom] = useState(true)
|
const [autoZoom, setAutoZoom] = useState(true)
|
||||||
const [replaceExisting, setReplaceExisting] = useState(true)
|
const [replaceExisting, setReplaceExisting] = useState(true)
|
||||||
@@ -123,7 +134,9 @@ export default function SwisstopoApp() {
|
|||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
if (!center) { addLog('Bitte zuerst einen Standort wählen'); return }
|
if (!center) { addLog('Bitte zuerst einen Standort wählen'); return }
|
||||||
if (!getBuild && !getTerrain) { addLog('Mindestens Gebäude oder Terrain auswählen'); return }
|
if (!getBuild && !getTerrain && !getContours && !getContourTin && !getContourSchicht && !getTlm) {
|
||||||
|
addLog('Mindestens eine Datenquelle wählen'); return
|
||||||
|
}
|
||||||
setLogs([])
|
setLogs([])
|
||||||
setRunning(true)
|
setRunning(true)
|
||||||
setDone(false)
|
setDone(false)
|
||||||
@@ -131,6 +144,11 @@ export default function SwisstopoApp() {
|
|||||||
if (getBuild) kinds.push('buildings')
|
if (getBuild) kinds.push('buildings')
|
||||||
if (getTerrain) kinds.push('terrain')
|
if (getTerrain) kinds.push('terrain')
|
||||||
if (getOrtho && getTerrain) kinds.push('ortho')
|
if (getOrtho && getTerrain) kinds.push('ortho')
|
||||||
|
if (getContours) kinds.push('contours')
|
||||||
|
if (getContourTin) kinds.push('contour_tin')
|
||||||
|
if (getContourSchicht)kinds.push('contour_schicht')
|
||||||
|
if (getTlm) kinds.push('tlm')
|
||||||
|
const tlmList = Object.entries(tlmKinds).filter(([, v]) => v).map(([k]) => k)
|
||||||
send('RUN_IMPORT', {
|
send('RUN_IMPORT', {
|
||||||
centerE: center.e,
|
centerE: center.e,
|
||||||
centerN: center.n,
|
centerN: center.n,
|
||||||
@@ -141,6 +159,10 @@ export default function SwisstopoApp() {
|
|||||||
replaceExisting,
|
replaceExisting,
|
||||||
clipToBbox,
|
clipToBbox,
|
||||||
terrainResolution: terrainRes,
|
terrainResolution: terrainRes,
|
||||||
|
buildVersion,
|
||||||
|
buildVariant,
|
||||||
|
contourInterval: contourInt,
|
||||||
|
tlmKinds: tlmList,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +260,32 @@ export default function SwisstopoApp() {
|
|||||||
<Icon name="location_city" size={13} /> Bestand-Gebäude (swissBUILDINGS3D, DWG)
|
<Icon name="location_city" size={13} /> Bestand-Gebäude (swissBUILDINGS3D, DWG)
|
||||||
</label>
|
</label>
|
||||||
</Field>
|
</Field>
|
||||||
|
{getBuild && (
|
||||||
|
<Field label="VERSION"
|
||||||
|
hint="2.0 = stabil, kein Solid/Separated-Split (alle Kategorien auf eigenen DXF-Layern innerhalb einer DWG). 3.0 = neuer, Beta — kann manchmal Probleme mit Variant-Erkennung haben.">
|
||||||
|
<Radio
|
||||||
|
value={buildVersion}
|
||||||
|
options={[
|
||||||
|
{ value: 'v2', label: '2.0 (stabil)' },
|
||||||
|
{ value: 'v3', label: '3.0 (beta)' },
|
||||||
|
]}
|
||||||
|
onChange={setBuildVersion}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{getBuild && buildVersion === 'v3' && (
|
||||||
|
<Field label="GEBÄUDE-VARIANTE"
|
||||||
|
hint="Solid: ein geschlossenes Solid pro Gebäude (klein, schnell). Separated: Dach/Fassade/Wand als separate Objekte (mehr Detail, ermoeglicht z.B. Dach auszublenden).">
|
||||||
|
<Radio
|
||||||
|
value={buildVariant}
|
||||||
|
options={[
|
||||||
|
{ value: 'separated', label: 'Separated (Dach/Fassade getrennt)' },
|
||||||
|
{ value: 'solid', label: 'Solid (ein Volumen pro Gebäude)' },
|
||||||
|
]}
|
||||||
|
onChange={setBuildVariant}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
<Field label="">
|
<Field label="">
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||||||
fontSize: 11, cursor: 'pointer' }}>
|
fontSize: 11, cursor: 'pointer' }}>
|
||||||
@@ -269,6 +317,45 @@ export default function SwisstopoApp() {
|
|||||||
</label>
|
</label>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label=""
|
||||||
|
hint="2D-Höhenlinien aus dem swissALTI3D-DEM. Werden flach auf die OKFF-Ebene des aktiven Geschosses gelegt — direkt zeichnungstauglich. Unabhängig vom 3D-Mesh.">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 11, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={getContours}
|
||||||
|
onChange={(e) => setGetContours(e.target.checked)} />
|
||||||
|
<Icon name="terrain" size={13} /> Höhenlinien (2D, auf aktivem Geschoss)
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
<Field label=""
|
||||||
|
hint="3D-TIN-Mesh aus den Vertices der Höhenlinien — Delaunay-trianguliert. Stilisierter Topo-Look mit weniger Polygonen als das DEM-Mesh.">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 11, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={getContourTin}
|
||||||
|
onChange={(e) => setGetContourTin(e.target.checked)} />
|
||||||
|
<Icon name="lan" size={13} /> TIN-Mesh aus Höhenlinien
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
<Field label=""
|
||||||
|
hint="Schichtenmodell: jede geschlossene Höhenlinie wird zur planaren Fläche auf ihrer Z-Höhe — der architektonische Pappmodell-Look.">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 11, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={getContourSchicht}
|
||||||
|
onChange={(e) => setGetContourSchicht(e.target.checked)} />
|
||||||
|
<Icon name="stacks" size={13} /> Schichtenmodell aus Höhenlinien
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
{(getContours || getContourTin || getContourSchicht) && (
|
||||||
|
<Field label="HÖHEN-ABSTAND">
|
||||||
|
<Radio value={contourInt}
|
||||||
|
options={[
|
||||||
|
{ value: '1.0', label: '1 m (fein)' },
|
||||||
|
{ value: '2.0', label: '2 m (Standard)' },
|
||||||
|
{ value: '5.0', label: '5 m (grob)' },
|
||||||
|
]}
|
||||||
|
onChange={setContourInt} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
<SectionLabel>Positionierung</SectionLabel>
|
<SectionLabel>Positionierung</SectionLabel>
|
||||||
|
|
||||||
<Field label="ORIGIN"
|
<Field label="ORIGIN"
|
||||||
@@ -337,7 +424,7 @@ export default function SwisstopoApp() {
|
|||||||
background: 'var(--bg-section)',
|
background: 'var(--bg-section)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ flex: 1, fontSize: 10, color: 'var(--text-muted)' }}>
|
<div style={{ flex: 1, fontSize: 10, color: 'var(--text-muted)' }}>
|
||||||
{center ? `Tiles werden gecacht in ~/Library/Caches/Dossier/swisstopo/` : 'Wähle zuerst einen Standort'}
|
{center ? `Tiles werden im Projekt-Ordner neben der .3dm gecacht (Fallback: ~/Library/Caches/Dossier/swisstopo/ wenn ungespeichert)` : 'Wähle zuerst einen Standort'}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-text" onClick={() => send('CANCEL', {})}
|
<button className="btn-text" onClick={() => send('CANCEL', {})}
|
||||||
disabled={running}>
|
disabled={running}>
|
||||||
|
|||||||
@@ -145,7 +145,69 @@ function EditableText({ value, onCommit, style, fontWeight, fontSize, autoEditTr
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EbeneRow({ e, active, mode, onClick, onContextMenu, onToggleVisible, onToggleLock, onColorChange, onLwChange, onNameChange, onCodeChange, onDelete, autoEditCode, autoEditName, rowRef }) {
|
// --- Tree-Helper -----------------------------------------------------------
|
||||||
|
// Rekursive Updates: code ist global eindeutig (Children duerfen keinen
|
||||||
|
// bestehenden Top-Level Code haben). Helper finden/aendern den passenden
|
||||||
|
// Eintrag irgendwo im Tree.
|
||||||
|
function _updateInTree(ebenen, code, patch) {
|
||||||
|
return ebenen.map(e => {
|
||||||
|
if (e.code === code) return { ...e, ...patch }
|
||||||
|
if (Array.isArray(e.children) && e.children.length) {
|
||||||
|
return { ...e, children: _updateInTree(e.children, code, patch) }
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function _removeFromTree(ebenen, code) {
|
||||||
|
const out = []
|
||||||
|
for (const e of ebenen) {
|
||||||
|
if (e.code === code) continue
|
||||||
|
if (Array.isArray(e.children) && e.children.length) {
|
||||||
|
out.push({ ...e, children: _removeFromTree(e.children, code) })
|
||||||
|
} else {
|
||||||
|
out.push(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function _addChildInTree(ebenen, parentCode, child) {
|
||||||
|
return ebenen.map(e => {
|
||||||
|
if (e.code === parentCode) {
|
||||||
|
const kids = Array.isArray(e.children) ? e.children : []
|
||||||
|
return { ...e, children: [...kids, child] }
|
||||||
|
}
|
||||||
|
if (Array.isArray(e.children) && e.children.length) {
|
||||||
|
return { ...e, children: _addChildInTree(e.children, parentCode, child) }
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findInTree(ebenen, code) {
|
||||||
|
for (const e of ebenen) {
|
||||||
|
if (e.code === code) return e
|
||||||
|
if (Array.isArray(e.children) && e.children.length) {
|
||||||
|
const f = _findInTree(e.children, code)
|
||||||
|
if (f) return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function _allCodes(ebenen) {
|
||||||
|
const out = []
|
||||||
|
for (const e of ebenen) {
|
||||||
|
out.push(e.code)
|
||||||
|
if (Array.isArray(e.children) && e.children.length) {
|
||||||
|
out.push(..._allCodes(e.children))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function EbeneRow({ e, depth, hasChildren, expanded, onToggleExpand, active, mode, onClick, onContextMenu, onToggleVisible, onToggleLock, onColorChange, onLwChange, onNameChange, onCodeChange, onDelete, autoEditCode, autoEditName, rowRef }) {
|
||||||
// Auge zeigt den Eye-State (User-Intention) — auch fuer die aktive Ebene.
|
// Auge zeigt den Eye-State (User-Intention) — auch fuer die aktive Ebene.
|
||||||
// So sieht man auf einen Blick ob sie "normalerweise" sichtbar waere.
|
// So sieht man auf einen Blick ob sie "normalerweise" sichtbar waere.
|
||||||
// Aktive Ebene rendert Rhino zwar immer sichtbar, das visible-Flag bleibt
|
// Aktive Ebene rendert Rhino zwar immer sichtbar, das visible-Flag bleibt
|
||||||
@@ -160,6 +222,7 @@ function EbeneRow({ e, active, mode, onClick, onContextMenu, onToggleVisible, on
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5,
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
padding: '3px 12px',
|
padding: '3px 12px',
|
||||||
|
paddingLeft: 12 + (depth || 0) * 14,
|
||||||
margin: active ? '1px 6px' : '0',
|
margin: active ? '1px 6px' : '0',
|
||||||
background: active ? 'var(--active-dim)'
|
background: active ? 'var(--active-dim)'
|
||||||
: (e.visible !== false) ? 'var(--bg-item)'
|
: (e.visible !== false) ? 'var(--bg-item)'
|
||||||
@@ -174,6 +237,16 @@ function EbeneRow({ e, active, mode, onClick, onContextMenu, onToggleVisible, on
|
|||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
className="btn-icon-xs"
|
||||||
|
onClick={(ev) => { ev.stopPropagation(); onToggleExpand() }}
|
||||||
|
title={expanded ? 'Einklappen' : 'Aufklappen'}
|
||||||
|
style={{ width: 14, height: 14 }}
|
||||||
|
><Icon name={expanded ? 'expand_more' : 'chevron_right'} size={12} /></button>
|
||||||
|
) : (
|
||||||
|
<span style={{ width: 14, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
{eyeShown ? (
|
{eyeShown ? (
|
||||||
<button
|
<button
|
||||||
className={`btn-icon-sm ${e.visible !== false ? 'is-on' : ''}`}
|
className={`btn-icon-sm ${e.visible !== false ? 'is-on' : ''}`}
|
||||||
@@ -262,6 +335,7 @@ export default function EbenenManager({
|
|||||||
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, code }
|
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, code }
|
||||||
const [clipboard, setClipboard] = useState(null) // { color, lw }
|
const [clipboard, setClipboard] = useState(null) // { color, lw }
|
||||||
const [autoEdit, setAutoEdit] = useState(null) // { code, field, token }
|
const [autoEdit, setAutoEdit] = useState(null) // { code, field, token }
|
||||||
|
const [expanded, setExpanded] = useState({}) // { code: true }
|
||||||
// Settings-Dialog laeuft jetzt in einem echten Rhino-Fenster (Satellite-
|
// Settings-Dialog laeuft jetzt in einem echten Rhino-Fenster (Satellite-
|
||||||
// Window via Eto.Form + WebView). State hier nicht mehr noetig.
|
// Window via Eto.Form + WebView). State hier nicht mehr noetig.
|
||||||
|
|
||||||
@@ -279,9 +353,9 @@ export default function EbenenManager({
|
|||||||
else { setSortBy(key); setSortDir('asc') }
|
else { setSortBy(key); setSortDir('asc') }
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedEbenen = useMemo(() => {
|
const sortByCurrent = (arr) => {
|
||||||
const arr = [...ebenen]
|
const sorted = [...arr]
|
||||||
arr.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
let cmp = 0
|
let cmp = 0
|
||||||
if (sortBy === 'code') {
|
if (sortBy === 'code') {
|
||||||
const ca = parseInt(a.code, 10), cb = parseInt(b.code, 10)
|
const ca = parseInt(a.code, 10), cb = parseInt(b.code, 10)
|
||||||
@@ -293,19 +367,24 @@ export default function EbenenManager({
|
|||||||
}
|
}
|
||||||
return sortDir === 'desc' ? -cmp : cmp
|
return sortDir === 'desc' ? -cmp : cmp
|
||||||
})
|
})
|
||||||
return arr
|
return sorted
|
||||||
}, [ebenen, sortBy, sortDir])
|
}
|
||||||
|
|
||||||
|
// Sort wirkt innerhalb jeder Ebene des Baums — Children behalten ihre
|
||||||
|
// Beziehung zum Parent, werden aber unter sich sortiert.
|
||||||
|
const sortedEbenen = useMemo(() => sortByCurrent(ebenen),
|
||||||
|
[ebenen, sortBy, sortDir])
|
||||||
|
|
||||||
const updateByCode = (code, patch) => {
|
const updateByCode = (code, patch) => {
|
||||||
onChange(ebenen.map(e => e.code === code ? { ...e, ...patch } : e))
|
onChange(_updateInTree(ebenen, code, patch))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleVisible = (code) => {
|
const handleToggleVisible = (code) => {
|
||||||
const cur = ebenen.find(e => e.code === code)
|
const cur = _findInTree(ebenen, code)
|
||||||
if (cur) updateByCode(code, { visible: !(cur.visible !== false) })
|
if (cur) updateByCode(code, { visible: !(cur.visible !== false) })
|
||||||
}
|
}
|
||||||
const handleToggleLock = (code) => {
|
const handleToggleLock = (code) => {
|
||||||
const cur = ebenen.find(e => e.code === code)
|
const cur = _findInTree(ebenen, code)
|
||||||
if (cur) updateByCode(code, { locked: !cur.locked })
|
if (cur) updateByCode(code, { locked: !cur.locked })
|
||||||
}
|
}
|
||||||
const handleColorChange = (code, color) => {
|
const handleColorChange = (code, color) => {
|
||||||
@@ -324,8 +403,9 @@ export default function EbenenManager({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleCodeChange = (oldCode, newCode) => {
|
const handleCodeChange = (oldCode, newCode) => {
|
||||||
if (ebenen.some(e => e.code === newCode && e.code !== oldCode)) return
|
// Code muss global eindeutig sein (sonst gibt es mehrdeutige Layer-Matches)
|
||||||
onChange(ebenen.map(e => e.code === oldCode ? { ...e, code: newCode } : e))
|
if (_allCodes(ebenen).some(c => c === newCode && c !== oldCode)) return
|
||||||
|
onChange(_updateInTree(ebenen, oldCode, { code: newCode }))
|
||||||
// Phase weiterschalten: Code -> Name
|
// Phase weiterschalten: Code -> Name
|
||||||
if (autoEdit && autoEdit.code === oldCode && autoEdit.field === 'code') {
|
if (autoEdit && autoEdit.code === oldCode && autoEdit.field === 'code') {
|
||||||
setAutoEdit({ code: newCode, field: 'name', token: Date.now() })
|
setAutoEdit({ code: newCode, field: 'name', token: Date.now() })
|
||||||
@@ -339,25 +419,27 @@ export default function EbenenManager({
|
|||||||
const confirmDelete = (moveToCode) => {
|
const confirmDelete = (moveToCode) => {
|
||||||
const code = deleteTarget
|
const code = deleteTarget
|
||||||
deleteEbene(code, moveToCode)
|
deleteEbene(code, moveToCode)
|
||||||
onChange(ebenen.filter(e => e.code !== code))
|
onChange(_removeFromTree(ebenen, code))
|
||||||
if (activeCode === code) {
|
if (activeCode === code) {
|
||||||
const next = ebenen.find(e => e.code !== code)
|
const flat = ebenen.flatMap(e =>
|
||||||
|
[e, ...(Array.isArray(e.children) ? e.children : [])])
|
||||||
|
const next = flat.find(e => e.code !== code)
|
||||||
if (next) onActiveChange(next.code)
|
if (next) onActiveChange(next.code)
|
||||||
}
|
}
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextFreeAfter = (afterCode) => {
|
const nextFreeAfter = (afterCode) => {
|
||||||
// Naechste freie Nummer NACH afterCode (= activeCode). Wenn afterCode
|
// Naechste freie Nummer NACH afterCode. Codes sind global eindeutig
|
||||||
// = "20", probiert "21", dann "22", etc. Fallback: max+1.
|
// (auch ueber Children) — also alle Codes als Konfliktraum.
|
||||||
const existing = new Set(ebenen.map(e => e.code))
|
const existing = new Set(_allCodes(ebenen))
|
||||||
let n = parseInt(afterCode, 10)
|
let n = parseInt(afterCode, 10)
|
||||||
if (isNaN(n)) n = 49
|
if (isNaN(n)) n = 49
|
||||||
for (let i = 1; i < 100; i++) {
|
for (let i = 1; i < 1000; i++) {
|
||||||
const c = String(n + i).padStart(2, '0')
|
const c = String(n + i).padStart(2, '0')
|
||||||
if (!existing.has(c)) return c
|
if (!existing.has(c)) return c
|
||||||
}
|
}
|
||||||
const codes = ebenen.map(e => parseInt(e.code, 10)).filter(x => !isNaN(x))
|
const codes = _allCodes(ebenen).map(c => parseInt(c, 10)).filter(x => !isNaN(x))
|
||||||
return String((codes.length ? Math.max(...codes) : 49) + 1).padStart(2, '0')
|
return String((codes.length ? Math.max(...codes) : 49) + 1).padStart(2, '0')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,11 +461,28 @@ export default function EbenenManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const duplicateEbene = (code) => {
|
const duplicateEbene = (code) => {
|
||||||
const src = ebenen.find(e => e.code === code)
|
const src = _findInTree(ebenen, code)
|
||||||
if (!src) return
|
if (!src) return
|
||||||
onChange([...ebenen, {
|
const dupCode = nextFreeAfter(code)
|
||||||
...src, code: nextFreeCode(), name: src.name + ' KOPIE',
|
const dup = { ...src, code: dupCode, name: src.name + ' KOPIE' }
|
||||||
}])
|
// Top-Level Eintrag — wir haengen Duplikat einfach hinten an
|
||||||
|
onChange([...ebenen, dup])
|
||||||
|
}
|
||||||
|
|
||||||
|
const addChild = (parentCode) => {
|
||||||
|
const code = nextFreeAfter(parentCode)
|
||||||
|
const child = {
|
||||||
|
code, name: 'NEU',
|
||||||
|
color: '#888888', lw: 0.18, visible: true, locked: false,
|
||||||
|
}
|
||||||
|
onChange(_addChildInTree(ebenen, parentCode, child))
|
||||||
|
// Parent expanden damit der neue Eintrag sichtbar ist
|
||||||
|
setExpanded(s => ({ ...s, [parentCode]: true }))
|
||||||
|
setAutoEdit({ code, field: 'code', token: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExpand = (code) => {
|
||||||
|
setExpanded(s => ({ ...s, [code]: !s[code] }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyProps = (code) => {
|
const copyProps = (code) => {
|
||||||
@@ -405,10 +504,11 @@ export default function EbenenManager({
|
|||||||
|
|
||||||
const ctxItems = (code) => [
|
const ctxItems = (code) => [
|
||||||
{ label: 'Ebeneneinstellungen…', icon: 'settings', onClick: () => {
|
{ label: 'Ebeneneinstellungen…', icon: 'settings', onClick: () => {
|
||||||
const target = ebenen.find(e => e.code === code)
|
const target = _findInTree(ebenen, code)
|
||||||
if (target) openEbenenSettings(target, hatchPatterns)
|
if (target) openEbenenSettings(target, hatchPatterns)
|
||||||
} },
|
} },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
|
{ label: 'Sub-Ebene hinzufügen…', icon: 'add', onClick: () => addChild(code) },
|
||||||
{ label: 'Selektion hierher übertragen', icon: 'move_down', onClick: () => moveSelectionToEbene(code) },
|
{ label: 'Selektion hierher übertragen', icon: 'move_down', onClick: () => moveSelectionToEbene(code) },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{ label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicateEbene(code) },
|
{ label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicateEbene(code) },
|
||||||
@@ -490,10 +590,20 @@ export default function EbenenManager({
|
|||||||
<div style={{ width: 18 }} />
|
<div style={{ width: 18 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedEbenen.map(e => (
|
{(() => {
|
||||||
|
// Rekursives Rendern: jede Ebene + sortierte Children (falls expanded)
|
||||||
|
const renderRow = (e, depth) => {
|
||||||
|
const kids = Array.isArray(e.children) ? e.children : []
|
||||||
|
const hasChildren = kids.length > 0
|
||||||
|
const isExpanded = !!expanded[e.code]
|
||||||
|
const rows = [
|
||||||
<EbeneRow
|
<EbeneRow
|
||||||
key={e.code}
|
key={e.code}
|
||||||
e={e}
|
e={e}
|
||||||
|
depth={depth}
|
||||||
|
hasChildren={hasChildren}
|
||||||
|
expanded={isExpanded}
|
||||||
|
onToggleExpand={() => toggleExpand(e.code)}
|
||||||
active={e.code === activeCode}
|
active={e.code === activeCode}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onClick={() => onActiveChange(e.code)}
|
onClick={() => onActiveChange(e.code)}
|
||||||
@@ -508,7 +618,16 @@ export default function EbenenManager({
|
|||||||
autoEditCode={autoEdit && autoEdit.code === e.code && autoEdit.field === 'code' ? autoEdit.token : null}
|
autoEditCode={autoEdit && autoEdit.code === e.code && autoEdit.field === 'code' ? autoEdit.token : null}
|
||||||
autoEditName={autoEdit && autoEdit.code === e.code && autoEdit.field === 'name' ? autoEdit.token : null}
|
autoEditName={autoEdit && autoEdit.code === e.code && autoEdit.field === 'name' ? autoEdit.token : null}
|
||||||
/>
|
/>
|
||||||
))}
|
]
|
||||||
|
if (hasChildren && isExpanded) {
|
||||||
|
for (const child of sortByCurrent(kids)) {
|
||||||
|
rows.push(...renderRow(child, depth + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
return sortedEbenen.flatMap(e => renderRow(e, 0))
|
||||||
|
})()}
|
||||||
|
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export default function GeschossManager({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Master-Row: Master-Eye links + Master-Lock rechts (analog
|
{/* Master-Row: Master-Eye links + Master-Lock rechts (analog
|
||||||
EbenenManager). */}
|
EbenenManager). */}
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -146,6 +146,20 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="0-KOTE m.ü.M (PROJEKTWEIT)"
|
||||||
|
hint="Höhe ü. Meer am OKFF=0. Wird beim Swisstopo-Import als Z-Offset benutzt — alle Real-Welt-Höhen werden um diesen Wert runtergeschoben. Gilt projektweit (nicht nur dieses Geschoss).">
|
||||||
|
<input
|
||||||
|
type="number" step="0.01"
|
||||||
|
value={draft.projectZeroMum ?? 0}
|
||||||
|
onChange={(ev) => set({ projectZeroMum: parseFloat(ev.target.value) || 0 })}
|
||||||
|
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0,
|
||||||
|
fontFamily: 'var(--font-mono)' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
+15
-3
@@ -261,6 +261,7 @@ export function exportRaeume() { send('EXPORT_RAEUME', {}) }
|
|||||||
export function openSwisstopo(mode) { send('OPEN_SWISSTOPO', { mode: mode || 'both' }) }
|
export function openSwisstopo(mode) { send('OPEN_SWISSTOPO', { mode: mode || 'both' }) }
|
||||||
export function importSwisstopo(kind) { send('IMPORT_SWISSTOPO', { kind: kind || 'buildings' }) }
|
export function importSwisstopo(kind) { send('IMPORT_SWISSTOPO', { kind: kind || 'buildings' }) }
|
||||||
export function openSwisstopoDialog() { send('OPEN_SWISSTOPO_DIALOG', {}) }
|
export function openSwisstopoDialog() { send('OPEN_SWISSTOPO_DIALOG', {}) }
|
||||||
|
export function openOsmDialog() { send('OPEN_OSM_DIALOG', {}) }
|
||||||
export function updateElement(id, patch) { send('UPDATE_ELEMENT', { id, ...(patch || {}) }) }
|
export function updateElement(id, patch) { send('UPDATE_ELEMENT', { id, ...(patch || {}) }) }
|
||||||
export function deleteElement(id) { send('DELETE_ELEMENT', { id }) }
|
export function deleteElement(id) { send('DELETE_ELEMENT', { id }) }
|
||||||
// Backwards-Compat-Aliases
|
// Backwards-Compat-Aliases
|
||||||
@@ -318,9 +319,20 @@ export function applyVisibility(activeZ, zeichnungsebenen, activeCode, ebenen, z
|
|||||||
visible: z.visible !== false,
|
visible: z.visible !== false,
|
||||||
locked: z.locked === true,
|
locked: z.locked === true,
|
||||||
}))
|
}))
|
||||||
const slimE = eList.map(e => ({
|
// Rekursiv durch Children — sonst landen Sub-Ebenen-Toggles nicht beim
|
||||||
code: e.code, visible: e.visible !== false, locked: e.locked === true,
|
// Backend.
|
||||||
}))
|
const slimEbene = (e) => {
|
||||||
|
const out = {
|
||||||
|
code: e.code,
|
||||||
|
visible: e.visible !== false,
|
||||||
|
locked: e.locked === true,
|
||||||
|
}
|
||||||
|
if (Array.isArray(e.children) && e.children.length) {
|
||||||
|
out.children = e.children.map(slimEbene)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const slimE = eList.map(slimEbene)
|
||||||
send('SET_VISIBILITY', {
|
send('SET_VISIBILITY', {
|
||||||
activeZ: a.activeZ ? { id: a.activeZ.id } : null,
|
activeZ: a.activeZ ? { id: a.activeZ.id } : null,
|
||||||
activeCode: a.activeCode,
|
activeCode: a.activeCode,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import LayerCombinationsApp from './LayerCombinationsApp.jsx'
|
|||||||
import AusschnittSettingsApp from './AusschnittSettingsApp.jsx'
|
import AusschnittSettingsApp from './AusschnittSettingsApp.jsx'
|
||||||
import LayoutDialogApp from './LayoutDialogApp.jsx'
|
import LayoutDialogApp from './LayoutDialogApp.jsx'
|
||||||
import SwisstopoApp from './SwisstopoApp.jsx'
|
import SwisstopoApp from './SwisstopoApp.jsx'
|
||||||
|
import OsmApp from './OsmApp.jsx'
|
||||||
import GestaltungApp from './GestaltungApp.jsx'
|
import GestaltungApp from './GestaltungApp.jsx'
|
||||||
import AusschnitteApp from './AusschnitteApp.jsx'
|
import AusschnitteApp from './AusschnitteApp.jsx'
|
||||||
import MassstabApp from './MassstabApp.jsx'
|
import MassstabApp from './MassstabApp.jsx'
|
||||||
@@ -38,6 +39,7 @@ const RootApp = mode === 'gestaltung' ? GestaltungApp
|
|||||||
: mode === 'ausschnitt_settings' ? AusschnittSettingsApp
|
: mode === 'ausschnitt_settings' ? AusschnittSettingsApp
|
||||||
: mode === 'layout_dialog' ? LayoutDialogApp
|
: mode === 'layout_dialog' ? LayoutDialogApp
|
||||||
: mode === 'swisstopo' ? SwisstopoApp
|
: mode === 'swisstopo' ? SwisstopoApp
|
||||||
|
: mode === 'osm' ? OsmApp
|
||||||
: App
|
: App
|
||||||
|
|
||||||
window.onerror = function (msg, src, line, col, err) {
|
window.onerror = function (msg, src, line, col, err) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import pkg from './package.json' with { type: 'json' }
|
import pkg from './package.json' with { type: 'json' }
|
||||||
|
import launcherPkg from './launcher/package.json' with { type: 'json' }
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -8,5 +9,6 @@ export default defineConfig({
|
|||||||
base: './', // relative paths so file:// URLs in WKWebView funktionieren
|
base: './', // relative paths so file:// URLs in WKWebView funktionieren
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
__LAUNCHER_VERSION__: JSON.stringify(launcherPkg.version),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user