Swisstopo Importer: STAC-API + Terrain-Mesh + Ortho-Drape (Iteration 1)
Frontend: - src/SwisstopoApp.jsx NEU: Satelliten-Fenster mit Adresse-Suche, Radius- Wahl, Daten-Checkboxen (Gebäude/Terrain/Luftbild), Origin-Handling, Live- Log - ElementeApp Swisstopo-Gruppe: Importer-Button + Karte-Button Backend: - rhino/swisstopo.py NEU: STAC-API-Client, Geocoding via swisstopo Search, LV95↔WGS84-Konvertierung, GeoTIFF/XYZ-Cache, mesh_from_grid + Ortho-Material - swissBUILDINGS3D 2.0 (DXF/DWG) via STAC; Per-Tile-Filter (_NNNN-NN_) schuetzt vor versehentlichem Download der 3.5 GB National-Geodatabase - swissALTI3D als XYZ-ZIP mit zipfile-Extraction, raeumliches Sub-Sampling statt Zeilen-Step (keine Streifen mehr im Terrain-Mesh) - SWISSIMAGE 10cm GeoTIFF als RenderMaterial-DiffuseBitmap mit planarem UV-Mapping auf den Terrain-Mesh-bbox Robustheit: - Auto-Skala-Erkennung: Rhinos DXF-Parser scaliert je nach \$INSUNITS auf unerwartete Doc-Units; wir messen aus ersten 50 Objekten + snappen auf Zehnerpotenz (1, 0.001, 1000) - bbox + origin_shift in doc-units (m_to_unit aus UnitScale + Auto-Detect) - Tags via UserString dossier_swisstopo_kind=buildings/terrain fuer Replace-Detection bei erneutem Import desselben Gebiets - BBox-Clip jetzt OPTIONAL (Default OFF, Checkbox) — bei InstanceReferences GetBoundingBox + Delete teuer - Batch-Transform via System.Collections.Generic.List[Guid] statt Python-Loop (Python.NET-Overload-Match) - Listener-Suppression in elemente.py + gestaltung.py + dimensionen.py via sticky dossier_swisstopo_busy — kein Per-Object-Spam mehr bei Selection/Add/Delete waehrend 5000+ Imports - Auto-Zoom via view.ZoomBoundingBox(combined) statt Select-Loop - Year-Dedupe auf Tile-Coord (Pattern YYYY oder YYYY-MM unterstuetzt) fuer alle Collections — aeltere Versionen werden ausgefiltert - Download-Safety: > 200 MB wird abgebrochen + Live-Progress alle 2 MB mit UI-Yield via Rhino.RhinoApp.Wait() Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -586,6 +586,8 @@ def _install_listeners(bridge):
|
||||
except Exception as ex: print("[DIMENSIONEN] idle:", ex)
|
||||
|
||||
def on_select(s, e):
|
||||
# Swisstopo-Import feuert tausende Selection-Events → bail.
|
||||
if sc.sticky.get("dossier_swisstopo_busy"): return
|
||||
b = sc.sticky.get("dimensionen_bridge")
|
||||
if b is not None:
|
||||
try: b._send_state(force=True)
|
||||
|
||||
@@ -4628,6 +4628,9 @@ class ElementeBridge(panel_base.BaseBridge):
|
||||
elif t == "CREATE_TRAEGER": self._cmd_create_traeger(p)
|
||||
elif t == "CREATE_RAUM": self._cmd_create_raum(p)
|
||||
elif t == "EXPORT_RAEUME": self._cmd_export_raeume(p)
|
||||
elif t == "OPEN_SWISSTOPO": self._cmd_open_swisstopo(p)
|
||||
elif t == "IMPORT_SWISSTOPO": self._cmd_import_swisstopo(p)
|
||||
elif t == "OPEN_SWISSTOPO_DIALOG": self._cmd_open_swisstopo_dialog(p)
|
||||
elif t == "UPDATE_WALL": self._update_wall(p)
|
||||
elif t == "UPDATE_ELEMENT": self._update_wall(p) # gleiche Logik fuer alle
|
||||
elif t == "DELETE_WALL": self._delete_wall(p.get("id"))
|
||||
@@ -6507,6 +6510,540 @@ class ElementeBridge(panel_base.BaseBridge):
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] CSV schreiben:", ex)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Swisstopo — Option-A-Workflow:
|
||||
# 1) "Karte oeffnen": map.geo.admin.ch im Browser mit vorausgewaehlten
|
||||
# Layern swissALTI3D + swissBUILDINGS3D. User waehlt sein Gebiet,
|
||||
# laedt DWG/OBJ/DAE runter.
|
||||
# 2) "Importieren": File-Picker -> Rhinos _-Import -> Plugin verschiebt
|
||||
# die NEU importierten Objekte auf den gewuenschten DOSSIER-Sublayer
|
||||
# unter dem aktiven Geschoss.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cmd_open_swisstopo(self, p):
|
||||
"""Oeffnet map.geo.admin.ch im Default-Browser mit den swisstopo-
|
||||
Layern voreingestellt. Param `mode`: 'buildings' | 'terrain' | 'both'.
|
||||
Optionaler `center`: 'CH1903_E,CH1903_N' fuer initiale Karten-
|
||||
Position; sonst Default-Center (Schweiz Mitte)."""
|
||||
import subprocess
|
||||
mode = (p.get("mode") or "both").lower()
|
||||
if mode == "buildings":
|
||||
layers = "ch.swisstopo.swissbuildings3d_3"
|
||||
elif mode == "terrain":
|
||||
layers = "ch.swisstopo.swissalti3d"
|
||||
else:
|
||||
layers = "ch.swisstopo.swissalti3d,ch.swisstopo.swissbuildings3d_3"
|
||||
# Default-Zentrum (Schweiz Mitte LV95)
|
||||
center = (p.get("center") or "2660000,1190000").strip()
|
||||
try:
|
||||
E, N = [s.strip() for s in center.split(",", 1)]
|
||||
except Exception:
|
||||
E, N = "2660000", "1190000"
|
||||
url = ("https://map.geo.admin.ch/"
|
||||
"?lang=de&topic=ech&bgLayer=ch.swisstopo.pixelkarte-farbe"
|
||||
"&E={}&N={}&zoom=8&layers={}").format(E, N, layers)
|
||||
try:
|
||||
subprocess.Popen(["open", url])
|
||||
print("[ELEMENTE] Swisstopo Karte geoeffnet:", url)
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] Karte oeffnen fehlgeschlagen:", ex)
|
||||
|
||||
def _cmd_import_swisstopo(self, p):
|
||||
"""File-Picker -> Rhino _-Import -> bewege neue Objekte auf den
|
||||
DOSSIER-Sublayer. `kind`: 'buildings' (12_Gebaeude) | 'terrain'
|
||||
(10_Situation) | 'vermessung' (01_Vermessung) | 'other' (aktiver
|
||||
Layer wird nicht geaendert)."""
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return
|
||||
kind = (p.get("kind") or "buildings").lower()
|
||||
# Target-Sublayer ableiten (auto-anlegen wenn nicht vorhanden)
|
||||
if kind == "buildings":
|
||||
sub_name = _find_ebene_sublayer_name(
|
||||
doc, ["gebaeude", "gebäude", "buildings"],
|
||||
"12", "Gebäude", default_color="#888888", default_lw=0.25)
|
||||
elif kind == "terrain":
|
||||
sub_name = _find_ebene_sublayer_name(
|
||||
doc, ["situation", "terrain", "gelaende"],
|
||||
"10", "Situation", default_color="#909090", default_lw=0.18)
|
||||
elif kind == "vermessung":
|
||||
sub_name = _find_ebene_sublayer_name(
|
||||
doc, ["vermessung", "survey"],
|
||||
"01", "Vermessung", default_color="#707078", default_lw=0.18)
|
||||
else:
|
||||
sub_name = None
|
||||
|
||||
# File-Picker
|
||||
try:
|
||||
from Rhino.UI import OpenFileDialog
|
||||
ofd = OpenFileDialog()
|
||||
ofd.Filter = ("Swisstopo Imports (*.dwg;*.dxf;*.obj;*.dae;*.ifc;*.3dm;*.ply;*.stl)|"
|
||||
"*.dwg;*.dxf;*.obj;*.dae;*.ifc;*.3dm;*.ply;*.stl|"
|
||||
"Alle Dateien (*.*)|*.*")
|
||||
ok = False
|
||||
try: ok = ofd.ShowOpenDialog()
|
||||
except Exception:
|
||||
try: ok = ofd.ShowDialog()
|
||||
except Exception: ok = False
|
||||
if not ok:
|
||||
print("[ELEMENTE] Swisstopo-Import abgebrochen"); return
|
||||
path = ofd.FileName
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] OpenFileDialog:", ex); return
|
||||
if not path or not os.path.isfile(path):
|
||||
print("[ELEMENTE] Pfad ungueltig:", path); return
|
||||
|
||||
# Snapshot vor Import: existierende Object-IDs
|
||||
before_ids = set()
|
||||
try:
|
||||
for obj in doc.Objects:
|
||||
if obj is None or obj.IsDeleted: continue
|
||||
before_ids.add(obj.Id)
|
||||
except Exception: pass
|
||||
|
||||
# Pfad fuer Rhino-Command escapen (Spaces!)
|
||||
cmd_path = path.replace('"', '\\"')
|
||||
cmd = '_-Import "{}" _Enter'.format(cmd_path)
|
||||
print("[ELEMENTE] Swisstopo-Import:", cmd)
|
||||
try:
|
||||
Rhino.RhinoApp.RunScript(cmd, False)
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] _-Import fehlgeschlagen:", ex); return
|
||||
|
||||
# Differenz: neu hinzugekommene Objekte
|
||||
new_objs = []
|
||||
try:
|
||||
for obj in doc.Objects:
|
||||
if obj is None or obj.IsDeleted: continue
|
||||
if obj.Id not in before_ids:
|
||||
new_objs.append(obj)
|
||||
except Exception: pass
|
||||
print("[ELEMENTE] Swisstopo-Import: {} neue Objekte".format(len(new_objs)))
|
||||
if not new_objs: return
|
||||
|
||||
# Target-Layer finden + Objekte verschieben (nur wenn sub_name gesetzt)
|
||||
if sub_name:
|
||||
z_id = doc.Strings.GetValue("dossier_active_id")
|
||||
if not z_id:
|
||||
print("[ELEMENTE] Swisstopo: kein aktives Geschoss — Objekte "
|
||||
"bleiben auf Import-Default-Layer"); return
|
||||
try:
|
||||
import layer_builder
|
||||
parent_idx = layer_builder._find_top_by_id(doc, z_id)
|
||||
if parent_idx < 0:
|
||||
print("[ELEMENTE] Swisstopo: Parent-Layer nicht gefunden")
|
||||
return
|
||||
parent_id = doc.Layers[parent_idx].Id
|
||||
code = sub_name.split("_", 1)[0]
|
||||
sub_idx = layer_builder._find_sublayer_by_code(doc, parent_id, code)
|
||||
if sub_idx < 0:
|
||||
print("[ELEMENTE] Swisstopo: Sublayer {} nicht gefunden "
|
||||
"— bitte erst Ebenen-Apply ausloesen".format(code))
|
||||
return
|
||||
moved = 0
|
||||
for obj in new_objs:
|
||||
try:
|
||||
attrs = obj.Attributes.Duplicate()
|
||||
attrs.LayerIndex = sub_idx
|
||||
if doc.Objects.ModifyAttributes(obj, attrs, True):
|
||||
moved += 1
|
||||
except Exception: pass
|
||||
print("[ELEMENTE] Swisstopo: {} Objekte auf {} verschoben".format(
|
||||
moved, doc.Layers[sub_idx].FullPath))
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] Layer-Move fehler:", ex)
|
||||
try: doc.Views.Redraw()
|
||||
except Exception: pass
|
||||
|
||||
def _cmd_open_swisstopo_dialog(self, p):
|
||||
"""Oeffnet das volle Swisstopo-Importer-Satelliten-Fenster mit API-
|
||||
Anbindung (Adresse-Suche, Auto-Tiles, Terrain+Orthofoto)."""
|
||||
outer = self
|
||||
bridge_holder = {"form": None}
|
||||
# Initial-State fuer den Dialog: aktuelle Ebenen-Liste + Default-
|
||||
# Layer-Codes fuer die Auto-Sublayer-Erkennung
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
try:
|
||||
e_raw = doc.Strings.GetValue("dossier_ebenen") if doc else None
|
||||
ebenen = json.loads(e_raw) if e_raw else []
|
||||
except Exception: ebenen = []
|
||||
|
||||
class _SwisstopoBridge(panel_base.BaseBridge):
|
||||
def __init__(self):
|
||||
panel_base.BaseBridge.__init__(self, "swisstopo")
|
||||
def _on_ready(self):
|
||||
self.send("SWISSTOPO_STATE", {
|
||||
"ebenen": ebenen,
|
||||
"cacheDir": __import__("swisstopo").CACHE_DIR,
|
||||
})
|
||||
def _push_log(self, msg):
|
||||
try: self.send("SWISSTOPO_LOG", {"msg": str(msg)})
|
||||
except Exception: pass
|
||||
def handle(self, data):
|
||||
if not isinstance(data, dict): return
|
||||
t = data.get("type", "")
|
||||
pp = data.get("payload") or {}
|
||||
if t == "READY":
|
||||
self._on_ready()
|
||||
elif t == "GEOCODE":
|
||||
import swisstopo
|
||||
res = swisstopo.geocode(pp.get("text") or "")
|
||||
self.send("GEOCODE_RESULT", {"result": res})
|
||||
elif t == "RUN_IMPORT":
|
||||
self._run_import(pp)
|
||||
elif t == "CANCEL":
|
||||
try:
|
||||
f = bridge_holder.get("form")
|
||||
if f is not None: f.Close()
|
||||
except Exception: pass
|
||||
def _run_import(self, opts):
|
||||
"""opts = {centerE, centerN, radius, kinds: ['buildings','terrain','ortho'],
|
||||
shiftToOrigin, autoZoom,
|
||||
layerBuildings, layerTerrain, terrainResolution}"""
|
||||
d = Rhino.RhinoDoc.ActiveDoc
|
||||
if d is None:
|
||||
self._push_log("Kein aktives Doc"); return
|
||||
try:
|
||||
import swisstopo, layer_builder
|
||||
except Exception as ex:
|
||||
self._push_log("Module-Import-Fehler: {}".format(ex)); return
|
||||
try:
|
||||
eC = float(opts.get("centerE")); nC = float(opts.get("centerN"))
|
||||
r = float(opts.get("radius") or 100)
|
||||
except Exception:
|
||||
self._push_log("Center/Radius ungueltig"); return
|
||||
kinds = set(opts.get("kinds") or ["buildings"])
|
||||
shift = bool(opts.get("shiftToOrigin", True))
|
||||
replace_existing = bool(opts.get("replaceExisting", True))
|
||||
clip_to_bbox = bool(opts.get("clipToBbox", False))
|
||||
|
||||
# Doc-Unit-Skalierung. LV95-Werte sind in Metern. Wenn der
|
||||
# Rhino-Doc in mm/km/inch laeuft, muessen wir die bbox + den
|
||||
# shift in Doc-Units umrechnen — sonst stimmt der Clip-Check
|
||||
# mit den (auto-skaliert importierten) DXF-Coords nicht ueberein.
|
||||
try:
|
||||
m_to_unit = Rhino.RhinoMath.UnitScale(
|
||||
Rhino.UnitSystem.Meters, d.ModelUnitSystem)
|
||||
except Exception:
|
||||
m_to_unit = 1.0
|
||||
eC_u = eC * m_to_unit
|
||||
nC_u = nC * m_to_unit
|
||||
r_u = r * m_to_unit
|
||||
bbox = (eC - r, nC - r, eC + r, nC + r) # m (fuer STAC-Query)
|
||||
bbox_doc = (eC_u - r_u, nC_u - r_u, eC_u + r_u, nC_u + r_u) # in Doc-Units
|
||||
origin_shift = (eC, nC, 0) if shift else (0, 0, 0)
|
||||
origin_shift_doc = (eC_u, nC_u, 0) if shift else (0, 0, 0)
|
||||
self._push_log("Center LV95: E={:.1f} N={:.1f} Radius={}m".format(eC, nC, r))
|
||||
self._push_log("BBox (m): {:.0f}-{:.0f} / {:.0f}-{:.0f}".format(*bbox))
|
||||
if m_to_unit != 1.0:
|
||||
self._push_log("Doc-Unit: {} → m_to_unit={} (Skalierung aktiv)".format(
|
||||
d.ModelUnitSystem, m_to_unit))
|
||||
|
||||
z_id = d.Strings.GetValue("dossier_active_id")
|
||||
if not z_id:
|
||||
self._push_log("Achtung: kein aktives Geschoss — Objekte bleiben auf Default-Layer")
|
||||
|
||||
# Bestehende swisstopo-Objekte loeschen wenn gewuenscht.
|
||||
# Tag wird beim Import gesetzt (UserString dossier_swisstopo_kind).
|
||||
if replace_existing:
|
||||
self._push_log("Loesche bestehende swisstopo-Objekte (alte Imports)...")
|
||||
removed = 0
|
||||
for obj in list(d.Objects):
|
||||
if obj is None or obj.IsDeleted: continue
|
||||
try:
|
||||
tag = obj.Attributes.GetUserString("dossier_swisstopo_kind")
|
||||
except Exception: tag = None
|
||||
if tag:
|
||||
d.Objects.Delete(obj.Id, True)
|
||||
removed += 1
|
||||
self._push_log("→ {} alte swisstopo-Objekte geloescht".format(removed))
|
||||
|
||||
new_obj_ids = []
|
||||
def _track_new(before_ids):
|
||||
out = []
|
||||
for obj in d.Objects:
|
||||
if obj is None or obj.IsDeleted: continue
|
||||
if obj.Id not in before_ids: out.append(obj)
|
||||
return out
|
||||
|
||||
before_all = set(o.Id for o in d.Objects if o and not o.IsDeleted)
|
||||
|
||||
# Listener-Suppression: elemente.py + gestaltung.py haben Add/
|
||||
# Replace-Listener die pro neu importiertem Objekt feuern. Bei
|
||||
# 5000+ DXF-Objekten erstickt das den Import. Sticky-Flag setzen,
|
||||
# die Listener bailen früh (siehe _is_swisstopo_busy unten).
|
||||
sc.sticky["dossier_swisstopo_busy"] = True
|
||||
try:
|
||||
# --- Buildings (DWG) -----------------------------------
|
||||
if "buildings" in kinds:
|
||||
paths = swisstopo.fetch_buildings_dwg(bbox, progress=self._push_log)
|
||||
for idx, p in enumerate(paths):
|
||||
try: size_mb = os.path.getsize(p) / 1e6
|
||||
except Exception: size_mb = 0
|
||||
self._push_log("Import {}/{}: {} ({:.0f} MB) — Rhinos DXF-Parser, kann ein paar Sekunden dauern...".format(
|
||||
idx + 1, len(paths), os.path.basename(p), size_mb))
|
||||
try: swisstopo._yield_ui()
|
||||
except Exception: pass
|
||||
before = set(o.Id for o in d.Objects if o and not o.IsDeleted)
|
||||
cmd = '_-Import "{}" _Enter'.format(p.replace('"', '\\"'))
|
||||
try: Rhino.RhinoApp.RunScript(cmd, False)
|
||||
except Exception as ex:
|
||||
self._push_log("Import {}: {}".format(p, ex)); continue
|
||||
new = _track_new(before)
|
||||
self._push_log("→ Import fertig: {} neue Objekte".format(len(new)))
|
||||
# Auto-Skala-Erkennung: Rhinos DXF-Parser kann je
|
||||
# nach $INSUNITS und Doc-Unit unerwartet 1000x rauf/
|
||||
# runter skalieren. Wir messen aus den Objekten und
|
||||
# SNAPPEN auf naechste Zehnerpotenz (1, 0.001, 1000)
|
||||
# damit kleine Mess-Streuung nicht eine off-by-1m
|
||||
# bbox produziert.
|
||||
if new and idx == 0:
|
||||
try:
|
||||
import math as _m
|
||||
samples = 0; sum_x = 0.0
|
||||
for o in new[:50]:
|
||||
bb = o.Geometry.GetBoundingBox(True)
|
||||
sum_x += bb.Center.X
|
||||
samples += 1
|
||||
avg_x = sum_x / max(1, samples)
|
||||
if abs(eC) > 1.0 and avg_x != 0:
|
||||
raw_ratio = avg_x / eC
|
||||
snapped = 10 ** round(_m.log10(abs(raw_ratio)))
|
||||
if abs(snapped - m_to_unit) > 1e-9:
|
||||
self._push_log("AUTO-SKALA: raw_ratio={:.6f} → snap to 1m={:g} doc-units (war {:g})".format(
|
||||
raw_ratio, snapped, m_to_unit))
|
||||
m_to_unit = snapped
|
||||
eC_u = eC * m_to_unit
|
||||
nC_u = nC * m_to_unit
|
||||
r_u = r * m_to_unit
|
||||
bbox_doc = (eC_u - r_u, nC_u - r_u,
|
||||
eC_u + r_u, nC_u + r_u)
|
||||
origin_shift_doc = (eC_u, nC_u, 0) if shift else (0, 0, 0)
|
||||
except Exception as ex:
|
||||
self._push_log("Auto-Skala-Erkennung: {}".format(ex))
|
||||
# Diagnose
|
||||
if new:
|
||||
try:
|
||||
obb0 = new[0].Geometry.GetBoundingBox(True)
|
||||
self._push_log(" Erste obj bbox (Doc-Units): "
|
||||
"({:.3f},{:.3f},{:.3f}) - ({:.3f},{:.3f},{:.3f})".format(
|
||||
obb0.Min.X, obb0.Min.Y, obb0.Min.Z,
|
||||
obb0.Max.X, obb0.Max.Y, obb0.Max.Z))
|
||||
self._push_log(" bbox_doc (nach Auto-Skala): "
|
||||
"{:.3f}-{:.3f} / {:.3f}-{:.3f}".format(*bbox_doc))
|
||||
except Exception: pass
|
||||
# Post-Clip nur wenn User es will (Default OFF) — bei
|
||||
# InstanceReferences ist GetBoundingBox + Delete teuer.
|
||||
# Tile = 1km², User-radius typisch 100m → ohne Clip
|
||||
# hast du das ganze Dorf, aber Import bleibt schnell.
|
||||
if clip_to_bbox:
|
||||
self._push_log("→ Clippe auf User-bbox...")
|
||||
kept = []
|
||||
outside = []
|
||||
for o in new:
|
||||
try:
|
||||
obb = o.Geometry.GetBoundingBox(True)
|
||||
cx = (obb.Min.X + obb.Max.X) * 0.5
|
||||
cy = (obb.Min.Y + obb.Max.Y) * 0.5
|
||||
if (bbox_doc[0] <= cx <= bbox_doc[2] and
|
||||
bbox_doc[1] <= cy <= bbox_doc[3]):
|
||||
kept.append(o)
|
||||
else:
|
||||
outside.append(o)
|
||||
except Exception:
|
||||
kept.append(o)
|
||||
if not kept and outside:
|
||||
self._push_log(" → Clip waere {}/{} → bbox passt nicht zu Doc-Coords, alle behalten".format(
|
||||
len(outside), len(new)))
|
||||
kept = new
|
||||
else:
|
||||
# Batch-Delete: deutlich schneller als per-obj
|
||||
out_ids = [o.Id for o in outside]
|
||||
for oid in out_ids:
|
||||
try: d.Objects.Delete(oid, True)
|
||||
except Exception: pass
|
||||
self._push_log(" → {} behalten, {} ausserhalb bbox geloescht".format(
|
||||
len(kept), len(out_ids)))
|
||||
else:
|
||||
# Kein Clip — alle behalten
|
||||
kept = new
|
||||
try: swisstopo._yield_ui()
|
||||
except Exception: pass
|
||||
# Shift falls aktiviert — Batch via System.List[Guid]
|
||||
# damit Python.NET den richtigen Overload erwischt.
|
||||
if shift and kept:
|
||||
self._push_log("→ Shift {} Objekte zum Welt-Origin (Batch)...".format(len(kept)))
|
||||
xform = rg.Transform.Translation(-origin_shift_doc[0],
|
||||
-origin_shift_doc[1],
|
||||
-origin_shift_doc[2])
|
||||
try:
|
||||
from System.Collections.Generic import List as _List
|
||||
from System import Guid as _Guid
|
||||
ids = _List[_Guid]()
|
||||
for o in kept: ids.Add(o.Id)
|
||||
n_shifted = d.Objects.Transform(ids, xform, True)
|
||||
self._push_log(" → {} Objekte verschoben".format(n_shifted))
|
||||
except Exception as ex:
|
||||
self._push_log(" Batch-Shift fehlgeschlagen, Loop-Fallback: {}".format(ex))
|
||||
for o in kept:
|
||||
try: d.Objects.Transform(o.Id, xform, True)
|
||||
except Exception: pass
|
||||
# Layer-Move + Tag
|
||||
if z_id and kept:
|
||||
self._push_log("→ Layer-Move auf 12_Gebäude...")
|
||||
sub_name = _find_ebene_sublayer_name(
|
||||
d, ["gebaeude", "gebäude", "buildings"],
|
||||
"12", "Gebäude",
|
||||
default_color="#888888", default_lw=0.25)
|
||||
self._move_to_sublayer(d, kept, z_id,
|
||||
sub_name.split("_", 1)[0], tag="buildings")
|
||||
else:
|
||||
# Kein aktives Geschoss → nur Tag setzen
|
||||
self._tag_objects(d, kept, "buildings")
|
||||
new_obj_ids.extend(o.Id for o in kept)
|
||||
|
||||
# --- Terrain (XYZ → Mesh) ------------------------------
|
||||
if "terrain" in kinds:
|
||||
res = (opts.get("terrainResolution") or "2.0").strip()
|
||||
try: target_step = float(res)
|
||||
except Exception: target_step = 2.0
|
||||
xyz_paths = swisstopo.fetch_terrain_xyz(
|
||||
bbox, resolution=res, progress=self._push_log)
|
||||
mesh_objects = []
|
||||
for p in xyz_paths:
|
||||
self._push_log("Mesh aus {}...".format(os.path.basename(p)))
|
||||
try:
|
||||
# xyz_to_grid arbeitet in LV95-Metern (Quelle).
|
||||
# Erst grid bauen, dann beim mesh_from_grid auf
|
||||
# doc-units skalieren + shiften.
|
||||
grid = swisstopo.xyz_to_grid(
|
||||
p,
|
||||
target_step=target_step,
|
||||
clip_bbox=bbox, # User-bbox in m!
|
||||
progress=self._push_log)
|
||||
if grid is None:
|
||||
self._push_log("→ leeres Grid"); continue
|
||||
# Mesh in Doc-Units bauen: shift in m (LV95),
|
||||
# dann beim Vertex-Add * m_to_unit
|
||||
mesh = swisstopo.mesh_from_grid(
|
||||
grid,
|
||||
origin_shift=origin_shift,
|
||||
unit_scale=m_to_unit)
|
||||
self._push_log("→ Mesh: {} Vertices / {} Faces".format(
|
||||
mesh.Vertices.Count, mesh.Faces.Count))
|
||||
gid = d.Objects.AddMesh(mesh)
|
||||
obj = d.Objects.Find(gid)
|
||||
if obj: mesh_objects.append((obj, grid["bbox"]))
|
||||
except Exception as ex:
|
||||
self._push_log("Mesh-Bau fehlgeschlagen: {}".format(ex))
|
||||
# Layer-Move + Ortho-Drape
|
||||
if z_id and mesh_objects:
|
||||
sub_name = _find_ebene_sublayer_name(
|
||||
d, ["situation", "terrain", "gelaende"],
|
||||
"10", "Situation",
|
||||
default_color="#909090", default_lw=0.18)
|
||||
objs = [m[0] for m in mesh_objects]
|
||||
self._move_to_sublayer(d, objs, z_id,
|
||||
sub_name.split("_", 1)[0], tag="terrain")
|
||||
elif mesh_objects:
|
||||
objs = [m[0] for m in mesh_objects]
|
||||
self._tag_objects(d, objs, "terrain")
|
||||
if "ortho" in kinds and mesh_objects:
|
||||
self._push_log("Hole Orthofoto...")
|
||||
ortho_paths = swisstopo.fetch_orthophoto(
|
||||
bbox, resolution="2.0", progress=self._push_log)
|
||||
if ortho_paths:
|
||||
# Erstes Ortho-Tile auf alle Meshes (MVP — pro Mesh
|
||||
# eigenes Mapping waere genauer, kommt spaeter)
|
||||
for obj, mbbox in mesh_objects:
|
||||
try:
|
||||
if shift:
|
||||
# Mesh ist verschoben → bbox auch
|
||||
mbbox_shifted = (
|
||||
mbbox[0] - origin_shift[0],
|
||||
mbbox[1] - origin_shift[1],
|
||||
mbbox[2] - origin_shift[0],
|
||||
mbbox[3] - origin_shift[1])
|
||||
else:
|
||||
mbbox_shifted = mbbox
|
||||
swisstopo.apply_ortho_material(
|
||||
d, obj, ortho_paths[0], mbbox_shifted)
|
||||
except Exception as ex:
|
||||
self._push_log("Ortho-Apply: {}".format(ex))
|
||||
new_obj_ids.extend(o.Id for o, _ in mesh_objects)
|
||||
|
||||
self._push_log("Import fertig: {} neue Objekte".format(len(new_obj_ids)))
|
||||
|
||||
# Auto-Zoom NOCH IM TRY-Block: sticky-Flag bleibt True
|
||||
# damit der Select-Roundtrip nicht 3000 Listener weckt.
|
||||
# ZoomBoundingBox + UnionBBox aller neuen → 1 API-Call
|
||||
# statt Select-Loop.
|
||||
if opts.get("autoZoom") and new_obj_ids:
|
||||
try:
|
||||
combined = rg.BoundingBox.Empty
|
||||
for oid in new_obj_ids:
|
||||
obj = d.Objects.Find(oid)
|
||||
if obj is None: continue
|
||||
try:
|
||||
bb = obj.Geometry.GetBoundingBox(True)
|
||||
if bb.IsValid: combined.Union(bb)
|
||||
except Exception: pass
|
||||
if combined.IsValid:
|
||||
view = d.Views.ActiveView
|
||||
if view is not None:
|
||||
view.ActiveViewport.ZoomBoundingBox(combined)
|
||||
except Exception as ex:
|
||||
self._push_log("Auto-Zoom: {}".format(ex))
|
||||
try: d.Views.Redraw()
|
||||
except Exception: pass
|
||||
self.send("IMPORT_DONE", {"count": len(new_obj_ids)})
|
||||
finally:
|
||||
sc.sticky["dossier_swisstopo_busy"] = False
|
||||
|
||||
def _tag_objects(self, doc, objs, tag):
|
||||
"""Setzt nur den dossier_swisstopo_kind UserString — fuer
|
||||
den Fall dass kein Geschoss aktiv ist und wir den Layer-Move
|
||||
ueberspringen, aber den Marker fuers Replace-Erkennen
|
||||
brauchen."""
|
||||
for o in objs:
|
||||
try:
|
||||
attrs = o.Attributes.Duplicate()
|
||||
attrs.SetUserString("dossier_swisstopo_kind", tag)
|
||||
doc.Objects.ModifyAttributes(o, attrs, True)
|
||||
except Exception: pass
|
||||
|
||||
def _move_to_sublayer(self, doc, objs, z_id, code, tag=None):
|
||||
"""Verschiebt Liste von Rhino-Objekten auf den DOSSIER-Sublayer
|
||||
<z_id>/<code>_*. Optional: Tag (UserString
|
||||
dossier_swisstopo_kind) setzen — wird beim naechsten Import
|
||||
erkannt + ggf. geloescht."""
|
||||
if not objs: return
|
||||
try:
|
||||
import layer_builder
|
||||
parent_idx = layer_builder._find_top_by_id(doc, z_id)
|
||||
if parent_idx < 0: return
|
||||
parent_id = doc.Layers[parent_idx].Id
|
||||
sub_idx = layer_builder._find_sublayer_by_code(doc, parent_id, code)
|
||||
if sub_idx < 0: return
|
||||
n = 0
|
||||
for o in objs:
|
||||
try:
|
||||
attrs = o.Attributes.Duplicate()
|
||||
attrs.LayerIndex = sub_idx
|
||||
if tag:
|
||||
attrs.SetUserString("dossier_swisstopo_kind", tag)
|
||||
if doc.Objects.ModifyAttributes(o, attrs, True): n += 1
|
||||
except Exception: pass
|
||||
self._push_log("→ {} Obj auf '{}'".format(n, doc.Layers[sub_idx].FullPath))
|
||||
except Exception as ex:
|
||||
self._push_log("Layer-Move: {}".format(ex))
|
||||
|
||||
b = _SwisstopoBridge()
|
||||
bridge_holder["form"] = panel_base.open_satellite_window(
|
||||
"swisstopo",
|
||||
title="swisstopo Importer",
|
||||
size=(560, 620),
|
||||
bridge=b)
|
||||
|
||||
def _update_wall(self, p):
|
||||
"""Properties eines Elements aendern (Wand/Decke/Dach/Oeffnung).
|
||||
Volumen wird anschliessend regeneriert."""
|
||||
@@ -7388,6 +7925,9 @@ def _on_object_added(sender, e):
|
||||
UserStrings auf das neue Objekt mit. Source-Duplikate kriegen eine
|
||||
neue UUID, Volume-Duplikate werden geloescht (Regen baut das neue
|
||||
Volumen am richtigen Ort)."""
|
||||
# Swisstopo-Import importiert tausende Objekte am Stueck — die haben
|
||||
# keine DOSSIER-Metas, jeder Listener-Call ist reine Verschwendung.
|
||||
if sc.sticky.get("dossier_swisstopo_busy"): return
|
||||
if sc.sticky.get(_REGEN_BUSY): return
|
||||
# Waehrend Move/Rotate/Mirror/Scale: Rhino feuert intern Delete+Add fuer
|
||||
# jedes transformierte Objekt. CommandEnd uebernimmt die Re-Sync —
|
||||
@@ -7492,6 +8032,8 @@ def _on_object_deleted(sender, e):
|
||||
wenn die Source mit gleicher ID zurueckkommt (= Transform, kein User-
|
||||
Delete).
|
||||
"""
|
||||
# Waehrend Swisstopo-Import: keine DOSSIER-Metas vorhanden, nur Overhead
|
||||
if sc.sticky.get("dossier_swisstopo_busy"): return
|
||||
# Waehrend Move/Rotate/Mirror/Scale: CommandEnd-Pfad uebernimmt das
|
||||
# Re-Sync. Sonst queued der Delete-Event ueberfluessige Regen-Calls die
|
||||
# den Pure-Translate-Skip wieder zunichtemachen.
|
||||
@@ -7646,6 +8188,7 @@ def _on_select_objects(sender, e):
|
||||
|
||||
So bewegen sich beide synchron bei Move/Gumball, und die Endpunkte
|
||||
der Lauflinie sind als Grips zum Drag verfuegbar."""
|
||||
if sc.sticky.get("dossier_swisstopo_busy"): return
|
||||
if sc.sticky.get(_SELECT_BUSY): return
|
||||
if sc.sticky.get(_REGEN_BUSY): return
|
||||
try:
|
||||
@@ -7680,6 +8223,7 @@ def _on_deselect_objects(sender, e):
|
||||
"""Bidirektional zu _on_select_objects:
|
||||
- Volume deselektiert → Source deselektieren + Grips aus
|
||||
- Source deselektiert → Volume deselektieren + Grips aus"""
|
||||
if sc.sticky.get("dossier_swisstopo_busy"): return
|
||||
if sc.sticky.get(_SELECT_BUSY): return
|
||||
if sc.sticky.get(_REGEN_BUSY): return
|
||||
try:
|
||||
|
||||
@@ -1373,6 +1373,10 @@ def _install_selection_listener(bridge):
|
||||
# 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_user_transform_active"): return
|
||||
if sc.sticky.get("_dossier_undo_active"): return
|
||||
b = sc.sticky.get("gestaltung_bridge")
|
||||
@@ -1558,6 +1562,8 @@ def _install_selection_listener(bridge):
|
||||
- Wenn das Objekt eben gerade als Teil eines Drag/Move geloescht wurde,
|
||||
stellen wir die Hatch mit den gemerkten Metadaten wieder her.
|
||||
- Sonst pruefen wir ob die Ebene ein Auto-Fill konfiguriert hat."""
|
||||
# Swisstopo-Import importiert tausende Objekte am Stueck — bail.
|
||||
if sc.sticky.get("dossier_swisstopo_busy"): return
|
||||
if sc.sticky.get("_dossier_undo_active"): return
|
||||
if sc.sticky.get("_elemente_regen_busy"): return
|
||||
obj = args.TheObject
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
#! python 3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
swisstopo.py
|
||||
STAC-API-Client + GeoTIFF/XYZ-Parser + Mesh-Builder fuer swisstopo-Daten.
|
||||
Alle APIs sind offen + ohne Auth, ohne Key.
|
||||
|
||||
Collections die wir nutzen:
|
||||
- ch.swisstopo.swissbuildings3d_3 : 3D-Gebaeudemodelle (DWG/OBJ/DAE/IFC)
|
||||
- ch.swisstopo.swissalti3d : Hoehenmodell DTM (GeoTIFF/XYZ)
|
||||
- ch.swisstopo.swissimage-dop10 : Orthofoto 10 cm (GeoTIFF, RGB)
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import zipfile
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import Rhino
|
||||
import Rhino.Geometry as rg
|
||||
|
||||
CACHE_DIR = os.path.expanduser("~/Library/Caches/Dossier/swisstopo")
|
||||
STAC_BASE = "https://data.geo.admin.ch/api/stac/v1"
|
||||
SEARCH_API = "https://api3.geo.admin.ch/rest/services/api/SearchServer"
|
||||
|
||||
|
||||
def _ensure_cache():
|
||||
if not os.path.isdir(CACHE_DIR):
|
||||
try: os.makedirs(CACHE_DIR)
|
||||
except Exception as ex: print("[SWISSTOPO] cache mkdir:", ex)
|
||||
|
||||
|
||||
def _http_get_json(url, timeout=30):
|
||||
"""HTTP GET + JSON-decode. Wirft bei Fehler."""
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
_MAX_SAFE_DOWNLOAD_MB = 200 # ueber diesem Wert: Download skippen + Warn
|
||||
|
||||
|
||||
def _yield_ui():
|
||||
"""Gibt der UI eine Chance zu repainten waehrend einer langen Operation.
|
||||
Ohne diesen Aufruf friert die WebView ein bis _run_import fertig ist."""
|
||||
try: Rhino.RhinoApp.Wait()
|
||||
except Exception: pass
|
||||
|
||||
|
||||
def _http_download(url, dest_path, timeout=120, progress=None, status=None):
|
||||
"""Download URL → dest_path. Skippt wenn Datei schon im Cache existiert
|
||||
(gleicher Name, > 0 Bytes). Liefert True bei Erfolg. Bricht ab wenn
|
||||
Content-Length > _MAX_SAFE_DOWNLOAD_MB (Schutz vor versehentlichen
|
||||
Mega-Downloads wie der gesamt-CH-GDB).
|
||||
progress: optional callable(bytes_done, bytes_total).
|
||||
status: optional callable(msg) fuer Log-Updates."""
|
||||
if os.path.isfile(dest_path) and os.path.getsize(dest_path) > 0:
|
||||
if status: status("Cache: {} ({:.1f} MB)".format(
|
||||
os.path.basename(dest_path), os.path.getsize(dest_path) / 1e6))
|
||||
return True
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Dossier/0.6"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
total = int(resp.headers.get("Content-Length") or -1)
|
||||
if total > 0:
|
||||
size_mb = total / 1e6
|
||||
if status: status("Download {:.1f} MB...".format(size_mb))
|
||||
if size_mb > _MAX_SAFE_DOWNLOAD_MB:
|
||||
if status: status("→ Abgebrochen: > {} MB (vermutlich kein "
|
||||
"Per-Tile-Asset)".format(_MAX_SAFE_DOWNLOAD_MB))
|
||||
return False
|
||||
done = 0
|
||||
last_log_mb = 0
|
||||
with open(dest_path + ".part", "wb") as f:
|
||||
while True:
|
||||
chunk = resp.read(65536)
|
||||
if not chunk: break
|
||||
f.write(chunk)
|
||||
done += len(chunk)
|
||||
if progress:
|
||||
try: progress(done, total)
|
||||
except Exception: pass
|
||||
# Alle 2 MB einen Status + UI-Yield
|
||||
mb_done = done // (2 * 1024 * 1024)
|
||||
if status and mb_done > last_log_mb:
|
||||
last_log_mb = mb_done
|
||||
if total > 0:
|
||||
pct = 100.0 * done / total
|
||||
status(" {:.0f}% ({:.1f}/{:.1f} MB)".format(
|
||||
pct, done/1e6, total/1e6))
|
||||
else:
|
||||
status(" {:.1f} MB...".format(done/1e6))
|
||||
_yield_ui()
|
||||
os.rename(dest_path + ".part", dest_path)
|
||||
return True
|
||||
except Exception as ex:
|
||||
print("[SWISSTOPO] download {}: {}".format(url, ex))
|
||||
try:
|
||||
if os.path.isfile(dest_path + ".part"): os.remove(dest_path + ".part")
|
||||
except Exception: pass
|
||||
return False
|
||||
|
||||
|
||||
# --- Koordinaten-Transformationen ------------------------------------------
|
||||
# Schweizer Koordinaten (LV95 = EPSG:2056) zu/von WGS84.
|
||||
# Approximations-Formeln aus dem swisstopo-Dokument
|
||||
# "Naeherungsloesungen fuer die direkte Transformation CH1903<->WGS84".
|
||||
# Genau auf ca. 1m im gesamten Schweizer Gebiet — fuer unsere Zwecke ueppig.
|
||||
|
||||
def lv95_to_wgs84(e, n):
|
||||
"""LV95 (E, N) -> WGS84 (lon, lat)."""
|
||||
e_n = (e - 2600000) / 1_000_000.0
|
||||
n_n = (n - 1200000) / 1_000_000.0
|
||||
lon_p = (2.6779094
|
||||
+ 4.728982 * e_n
|
||||
+ 0.791484 * e_n * n_n
|
||||
+ 0.1306 * e_n * n_n * n_n
|
||||
- 0.0436 * e_n * e_n * e_n)
|
||||
lat_p = (16.9023892
|
||||
+ 3.238272 * n_n
|
||||
- 0.270978 * e_n * e_n
|
||||
- 0.002528 * n_n * n_n
|
||||
- 0.0447 * e_n * e_n * n_n
|
||||
- 0.0140 * n_n * n_n * n_n)
|
||||
lon = lon_p * 100 / 36
|
||||
lat = lat_p * 100 / 36
|
||||
return (lon, lat)
|
||||
|
||||
|
||||
def wgs84_to_lv95(lon, lat):
|
||||
"""WGS84 (lon, lat) -> LV95 (E, N)."""
|
||||
lon_p = (lon * 3600 - 26782.5) / 10000.0
|
||||
lat_p = (lat * 3600 - 169028.66) / 10000.0
|
||||
e = (2600072.37
|
||||
+ 211455.93 * lon_p
|
||||
- 10938.51 * lon_p * lat_p
|
||||
- 0.36 * lon_p * lat_p * lat_p
|
||||
- 44.54 * lon_p * lon_p * lon_p)
|
||||
n = (1200147.07
|
||||
+ 308807.95 * lat_p
|
||||
+ 3745.25 * lon_p * lon_p
|
||||
+ 76.63 * lat_p * lat_p
|
||||
- 194.56 * lon_p * lon_p * lat_p
|
||||
+ 119.79 * lat_p * lat_p * lat_p)
|
||||
return (e, n)
|
||||
|
||||
|
||||
def lv95_bbox_to_wgs84_bbox(e_min, n_min, e_max, n_max):
|
||||
"""4 Ecken transformieren + min/max in WGS84 zurueck."""
|
||||
corners = [
|
||||
lv95_to_wgs84(e_min, n_min),
|
||||
lv95_to_wgs84(e_max, n_min),
|
||||
lv95_to_wgs84(e_max, n_max),
|
||||
lv95_to_wgs84(e_min, n_max),
|
||||
]
|
||||
lons = [c[0] for c in corners]
|
||||
lats = [c[1] for c in corners]
|
||||
return (min(lons), min(lats), max(lons), max(lats))
|
||||
|
||||
|
||||
# --- Geocoding -------------------------------------------------------------
|
||||
|
||||
def geocode(text):
|
||||
"""Sucht Adresse via swisstopo Search-API. Liefert dict mit:
|
||||
{label, e, n, lon, lat, origin} oder None.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
try:
|
||||
params = {
|
||||
"searchText": text.strip(),
|
||||
"type": "locations",
|
||||
"origins": "address,gg25,gazetteer,parcel",
|
||||
"sr": "2056", # LV95-Koords im Response
|
||||
"limit": "1",
|
||||
}
|
||||
url = SEARCH_API + "?" + urllib.parse.urlencode(params)
|
||||
data = _http_get_json(url, timeout=15)
|
||||
results = data.get("results") or []
|
||||
if not results: return None
|
||||
attrs = results[0].get("attrs") or {}
|
||||
# Im LV95-Modus liefert die API y=East, x=North (Geo-Admin-Konvention).
|
||||
e = attrs.get("y")
|
||||
n = attrs.get("x")
|
||||
label = attrs.get("label") or attrs.get("detail") or text
|
||||
if e is None or n is None: return None
|
||||
e = float(e); n = float(n)
|
||||
lon, lat = lv95_to_wgs84(e, n)
|
||||
return {
|
||||
"label": _strip_html(label),
|
||||
"e": e,
|
||||
"n": n,
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"origin": attrs.get("origin"),
|
||||
}
|
||||
except Exception as ex:
|
||||
print("[SWISSTOPO] geocode '{}':".format(text), ex)
|
||||
return None
|
||||
|
||||
|
||||
def _strip_html(s):
|
||||
"""Search-API liefert Labels mit <b>-Tags fuer Highlighting."""
|
||||
try:
|
||||
import re
|
||||
return re.sub(r"<[^>]+>", "", s or "")
|
||||
except Exception:
|
||||
return s
|
||||
|
||||
|
||||
# --- STAC-Queries ----------------------------------------------------------
|
||||
|
||||
def stac_query(collection_id, bbox_wgs84, asset_extensions=None, limit=100):
|
||||
"""STAC-Items in einer WGS84-bbox. Liefert Liste von:
|
||||
{id, bbox_wgs84, assets: {key: {href, type, size?}}}
|
||||
asset_extensions: optional list von suffixen (z.B. ['.dwg', '.tif']) zum
|
||||
filtern. Default: alle Assets behalten.
|
||||
"""
|
||||
if not bbox_wgs84 or len(bbox_wgs84) != 4: return []
|
||||
try:
|
||||
url = ("{base}/collections/{cid}/items"
|
||||
"?bbox={mn_lon},{mn_lat},{mx_lon},{mx_lat}"
|
||||
"&limit={lim}").format(
|
||||
base=STAC_BASE, cid=collection_id,
|
||||
mn_lon=bbox_wgs84[0], mn_lat=bbox_wgs84[1],
|
||||
mx_lon=bbox_wgs84[2], mx_lat=bbox_wgs84[3],
|
||||
lim=int(limit))
|
||||
data = _http_get_json(url, timeout=30)
|
||||
except Exception as ex:
|
||||
print("[SWISSTOPO] STAC '{}': {}".format(collection_id, ex))
|
||||
return []
|
||||
out = []
|
||||
for feat in (data.get("features") or []):
|
||||
assets_raw = feat.get("assets") or {}
|
||||
assets = {}
|
||||
for k, v in assets_raw.items():
|
||||
href = v.get("href")
|
||||
if not href: continue
|
||||
if asset_extensions:
|
||||
low = href.lower()
|
||||
if not any(low.endswith(ext) for ext in asset_extensions): continue
|
||||
assets[k] = {
|
||||
"href": href,
|
||||
"type": v.get("type"),
|
||||
"size": v.get("file:size"),
|
||||
}
|
||||
if not assets: continue
|
||||
out.append({
|
||||
"id": feat.get("id"),
|
||||
"bbox_wgs84": feat.get("bbox"),
|
||||
"assets": assets,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# --- Download helpers ------------------------------------------------------
|
||||
|
||||
def download_asset(href, subdir="misc", progress=None, status=None):
|
||||
"""Laedt asset, liefert lokalen Pfad oder None bei Fehler.
|
||||
status: optional callable(msg) fuer Live-Progress-Log."""
|
||||
_ensure_cache()
|
||||
sub = os.path.join(CACHE_DIR, subdir)
|
||||
if not os.path.isdir(sub):
|
||||
try: os.makedirs(sub)
|
||||
except Exception: pass
|
||||
fn = os.path.basename(urllib.parse.urlparse(href).path) or "asset"
|
||||
dest = os.path.join(sub, fn)
|
||||
if _http_download(href, dest, progress=progress, status=status):
|
||||
return dest
|
||||
return None
|
||||
|
||||
|
||||
# --- Helpers: Tile-Dedupe + ZIP-Extract ------------------------------------
|
||||
|
||||
# Item-IDs haben verschiedene Year-Patterns:
|
||||
# swissalti3d: "_YYYY_NNNN-NNNN" (z.B. _2022_2664-1212)
|
||||
# swissbuildings3d_2: "_YYYY-MM_NNNN-NN" (z.B. _2022-12_1150-23)
|
||||
# Beide gleichzeitig matchen — Year-Sort dann via String-Compare (lex == chrono).
|
||||
_TILE_PAT = re.compile(r"_(\d{4}(?:-\d{2})?)_(\d{3,4}-\d{2,4})")
|
||||
# Filter fuer per-Tile Assets — die URL/Filename MUSS eine Tile-Coord-
|
||||
# Markierung haben (z.B. `_1150-23_`). Bewahrt vor versehentlichem Download
|
||||
# der gesamt-CH-Geodatabase (>1 GB).
|
||||
_TILE_FILE_PAT = re.compile(r"_\d{3,4}-\d{2,4}[_.]")
|
||||
|
||||
|
||||
def _dedupe_latest(items):
|
||||
"""Bei Items mit gleicher Tile-Coord aber unterschiedlicher Year-Markierung:
|
||||
nur das neueste behalten (Year-String-Compare: '2022-12' > '2020-03' ✓)."""
|
||||
keep = {}
|
||||
for it in items:
|
||||
m = _TILE_PAT.search(it.get("id") or "")
|
||||
if not m:
|
||||
keep[it["id"]] = it
|
||||
continue
|
||||
year = m.group(1); tile = m.group(2)
|
||||
prev = keep.get(tile)
|
||||
if prev is None or year > prev["_year"]:
|
||||
it["_year"] = year
|
||||
keep[tile] = it
|
||||
return list(keep.values())
|
||||
|
||||
|
||||
def _extract_zip_to_dir(zip_path, dest_dir):
|
||||
"""Entpackt alle Files aus einem ZIP nach dest_dir. Liefert Liste der
|
||||
extrahierten Pfade."""
|
||||
if not os.path.isdir(dest_dir):
|
||||
try: os.makedirs(dest_dir)
|
||||
except Exception: pass
|
||||
paths = []
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith("/"): continue # Verzeichnis
|
||||
out = os.path.join(dest_dir, os.path.basename(name))
|
||||
if not os.path.isfile(out) or os.path.getsize(out) == 0:
|
||||
with zf.open(name) as src, open(out, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
paths.append(out)
|
||||
except Exception as ex:
|
||||
print("[SWISSTOPO] zip extract {}: {}".format(zip_path, ex))
|
||||
return paths
|
||||
|
||||
|
||||
# --- Buildings: 3D-Gebaeude DWG --------------------------------------------
|
||||
|
||||
# swissBUILDINGS3D 3.0 ist Cesium-3D-Tiles (kein DWG). Fuer DWG-Import nutzen
|
||||
# wir die 2.0-Variante.
|
||||
_BUILDINGS_COLLECTION = "ch.swisstopo.swissbuildings3d_2"
|
||||
|
||||
|
||||
def fetch_buildings_dwg(bbox_lv95, progress=None):
|
||||
"""Holt swissBUILDINGS3D 2.0 Tile-DXF/DWG-Files fuer eine LV95-bbox.
|
||||
Wichtig: filtert NUR per-Tile-Assets (Pattern `_NNNN-NN_`). National-
|
||||
Geodatabase-Assets (>1 GB) werden NICHT gematcht — sonst laedt das Plugin
|
||||
versehentlich den gesamt-CH-Datensatz."""
|
||||
bbox_wgs = lv95_bbox_to_wgs84_bbox(*bbox_lv95)
|
||||
if progress: progress("STAC-Query " + _BUILDINGS_COLLECTION + "...")
|
||||
items = stac_query(_BUILDINGS_COLLECTION, bbox_wgs,
|
||||
asset_extensions=[".dwg", ".dxf",
|
||||
".dwg.zip", ".dxf.zip"])
|
||||
items = _dedupe_latest(items)
|
||||
if not items:
|
||||
if progress: progress("Keine Tiles in der Region (collection={})".format(_BUILDINGS_COLLECTION))
|
||||
return []
|
||||
paths = []
|
||||
for i, item in enumerate(items):
|
||||
if progress: progress("Lade Tile {}/{}: {}".format(
|
||||
i+1, len(items), item["id"]))
|
||||
# Asset waehlen: nur Per-Tile-Files (Pattern `_NNNN-NN_`), bevorzugt
|
||||
# direkt-DXF/DWG, sonst ZIP. Filter eliminiert auch national-GDB.
|
||||
per_tile = [(k, a) for k, a in item["assets"].items()
|
||||
if _TILE_FILE_PAT.search(a["href"])]
|
||||
if not per_tile:
|
||||
if progress: progress("→ kein Per-Tile-Asset, skip")
|
||||
continue
|
||||
# Priorisierung: direkt-DXF/DWG > ZIP-DXF/DWG
|
||||
chosen = None
|
||||
for k, a in per_tile:
|
||||
low = a["href"].lower()
|
||||
if low.endswith((".dxf", ".dwg")):
|
||||
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
|
||||
if chosen is None:
|
||||
chosen = per_tile[0][1]["href"]
|
||||
|
||||
p = download_asset(chosen, subdir="buildings3d_dwg", status=progress)
|
||||
if not p: continue
|
||||
|
||||
# ZIP-Wrapper aufloesen
|
||||
if p.lower().endswith(".zip"):
|
||||
extracted = _extract_zip_to_dir(
|
||||
p, os.path.join(CACHE_DIR, "buildings3d_dwg", "_unzipped"))
|
||||
dwgs = [e for e in extracted if e.lower().endswith((".dwg", ".dxf"))]
|
||||
paths.extend(dwgs)
|
||||
else:
|
||||
paths.append(p)
|
||||
if progress: progress("{} CAD-Datei(en) bereit".format(len(paths)))
|
||||
return paths
|
||||
|
||||
|
||||
# --- Terrain: swissALTI3D via XYZ ASCII -------------------------------------
|
||||
|
||||
def fetch_terrain_xyz(bbox_lv95, resolution="2.0", progress=None):
|
||||
"""Holt swissALTI3D-XYZ-Punktwolke. resolution: '0.5' oder '2.0' (m).
|
||||
Tiles kommen als .xyz.zip — wir packen lokal aus. Liefert Liste lokaler
|
||||
.xyz-Pfade (ausgepackt)."""
|
||||
bbox_wgs = lv95_bbox_to_wgs84_bbox(*bbox_lv95)
|
||||
if progress: progress("STAC-Query swissALTI3D...")
|
||||
items = stac_query("ch.swisstopo.swissalti3d",
|
||||
bbox_wgs, asset_extensions=[".xyz.zip", ".xyz"])
|
||||
items = _dedupe_latest(items)
|
||||
if not items:
|
||||
if progress: progress("Keine ALTI-Tiles in der Region gefunden")
|
||||
return []
|
||||
target_tag = "_{}_".format(resolution)
|
||||
paths = []
|
||||
for i, item in enumerate(items):
|
||||
if progress: progress("Lade Terrain {}/{}: {}".format(
|
||||
i+1, len(items), item["id"]))
|
||||
# Asset mit gewuenschter Resolution suchen, sonst irgendeines
|
||||
chosen_href = None
|
||||
for k, a in item["assets"].items():
|
||||
if target_tag in a["href"]:
|
||||
chosen_href = a["href"]; break
|
||||
if chosen_href is None:
|
||||
chosen_href = next(iter(item["assets"].values()))["href"]
|
||||
p = download_asset(chosen_href, subdir="alti3d_xyz", status=progress)
|
||||
if not p: continue
|
||||
# ZIP-Wrapper aufloesen
|
||||
if p.lower().endswith(".zip"):
|
||||
extracted = _extract_zip_to_dir(
|
||||
p, os.path.join(CACHE_DIR, "alti3d_xyz", "_unzipped"))
|
||||
xyzs = [e for e in extracted if e.lower().endswith(".xyz")]
|
||||
paths.extend(xyzs)
|
||||
else:
|
||||
paths.append(p)
|
||||
if progress: progress("{} XYZ-Datei(en) bereit".format(len(paths)))
|
||||
return paths
|
||||
|
||||
|
||||
def xyz_to_grid(path, target_step=2.0, clip_bbox=None, progress=None):
|
||||
"""Parse swissALTI3D-XYZ-ASCII. Format: jede Zeile 'E N Z' (LV95).
|
||||
Liest binaer ein (XYZ-Header/Footer kann Sonderzeichen enthalten).
|
||||
|
||||
target_step: gewuenschter Vertex-Abstand in Metern. Sub-Sampling laeuft
|
||||
RAEUMLICH (jeder N-te Punkt im E×N-Raster), nicht ueber
|
||||
Zeilen-Indizes — sonst entstehen Aliasing-Streifen weil
|
||||
die Zeilenzahl in der Datei nicht zwingend ein ganzzahliges
|
||||
Vielfaches der Reihenbreite ist.
|
||||
clip_bbox: optionales (e_min, n_min, e_max, n_max) — Punkte ausserhalb
|
||||
werden verworfen. Reduziert das Mesh auf das User-Gebiet
|
||||
statt der ganzen 1km×1km Tile."""
|
||||
# Binaer + ASCII-decode mit errors='ignore' damit BOM/Sonderzeichen am
|
||||
# File-Start nicht crashen
|
||||
with open(path, "rb") as fb:
|
||||
raw = fb.read()
|
||||
text = raw.decode("ascii", errors="ignore")
|
||||
lines = text.split("\n")
|
||||
# Header-Detection
|
||||
start_idx = 0
|
||||
if lines:
|
||||
first = lines[0].split()
|
||||
if len(first) < 3:
|
||||
start_idx = 1
|
||||
else:
|
||||
try: float(first[0])
|
||||
except Exception: start_idx = 1
|
||||
|
||||
# --- 1. Pass: raw Step + Origin aus ersten ~200 Punkten erkennen
|
||||
sample = []
|
||||
for ln in lines[start_idx:start_idx + 500]:
|
||||
parts = ln.split()
|
||||
if len(parts) < 3: continue
|
||||
try:
|
||||
e = float(parts[0]); n = float(parts[1])
|
||||
except Exception: continue
|
||||
sample.append((e, n))
|
||||
if len(sample) >= 200: break
|
||||
if len(sample) < 4:
|
||||
if progress: progress("XYZ leer / nicht parsebar")
|
||||
return None
|
||||
# Smallest non-zero E-diff = raw_e_step
|
||||
e_diffs = sorted({round(abs(sample[i+1][0] - sample[i][0]), 3)
|
||||
for i in range(len(sample) - 1)
|
||||
if abs(sample[i+1][0] - sample[i][0]) > 0.001})
|
||||
raw_e_step = e_diffs[0] if e_diffs else 0.5
|
||||
n_diffs = sorted({round(abs(sample[i+1][1] - sample[i][1]), 3)
|
||||
for i in range(len(sample) - 1)
|
||||
if abs(sample[i+1][1] - sample[i][1]) > 0.001})
|
||||
raw_n_step = n_diffs[0] if n_diffs else 0.5
|
||||
origin_e = min(p[0] for p in sample)
|
||||
origin_n = min(p[1] for p in sample)
|
||||
# Sub-Sampling-Faktoren — nur ganzzahlig damit das Raster regulaer bleibt
|
||||
factor_e = max(1, int(round(target_step / raw_e_step)))
|
||||
factor_n = max(1, int(round(target_step / raw_n_step)))
|
||||
actual_step_e = raw_e_step * factor_e
|
||||
actual_step_n = raw_n_step * factor_n
|
||||
if progress:
|
||||
progress("XYZ raw {:.2f}m → target {:.2f}m → sub-sample {}x{} ({:.2f}m actual)".format(
|
||||
raw_e_step, target_step, factor_e, factor_n, actual_step_e))
|
||||
|
||||
# --- 2. Pass: alle Punkte auf dem Sub-Raster behalten (+ optional clip)
|
||||
points = {}
|
||||
es = set(); ns = set()
|
||||
cb = clip_bbox
|
||||
for ln in lines[start_idx:]:
|
||||
parts = ln.split()
|
||||
if len(parts) < 3: continue
|
||||
try:
|
||||
e = float(parts[0]); n = float(parts[1]); z = float(parts[2])
|
||||
except Exception: continue
|
||||
if cb is not None:
|
||||
if e < cb[0] or e > cb[2] or n < cb[1] or n > cb[3]: continue
|
||||
# Raster-Pruefung: nur jeden factor_e-ten E-Schritt + factor_n-ten N-Schritt
|
||||
di = int(round((e - origin_e) / raw_e_step))
|
||||
dj = int(round((n - origin_n) / raw_n_step))
|
||||
if di % factor_e != 0 or dj % factor_n != 0: continue
|
||||
# Auf snapped Koords runden um Float-Drift zu vermeiden
|
||||
e_snap = origin_e + di * raw_e_step
|
||||
n_snap = origin_n + dj * raw_n_step
|
||||
points[(e_snap, n_snap)] = z
|
||||
es.add(e_snap); ns.add(n_snap)
|
||||
|
||||
if not points:
|
||||
if progress: progress("Keine Punkte im Clipping-Gebiet / nach Sub-Sample")
|
||||
return None
|
||||
es_sorted = sorted(es); ns_sorted = sorted(ns)
|
||||
if progress: progress("XYZ → {} Vertices ({}×{} Raster)".format(
|
||||
len(points), len(es_sorted), len(ns_sorted)))
|
||||
return {
|
||||
"bbox": (es_sorted[0], ns_sorted[0], es_sorted[-1], ns_sorted[-1]),
|
||||
"step": (actual_step_e, actual_step_n),
|
||||
"es": es_sorted,
|
||||
"ns": ns_sorted,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
Vertex angewendet (typisch: bbox-Center zu Welt-0/0/0 schieben).
|
||||
unit_scale: Skalierung von Meter (Quelle XYZ) auf Doc-Units. Bei
|
||||
mm-Doc = 1000, bei m-Doc = 1.0 ."""
|
||||
es = grid["es"]; ns = grid["ns"]
|
||||
pts = grid["points"]
|
||||
sx, sy, sz = origin_shift
|
||||
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) * unit_scale,
|
||||
(ny - sy) * unit_scale,
|
||||
(z - sz) * unit_scale)
|
||||
idx_for[(i, j)] = v_idx
|
||||
# Quads
|
||||
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)
|
||||
mesh.Normals.ComputeNormals()
|
||||
mesh.Compact()
|
||||
return mesh
|
||||
|
||||
|
||||
# --- Orthofoto: SWISSIMAGE 10cm via GeoTIFF --------------------------------
|
||||
|
||||
def fetch_orthophoto(bbox_lv95, resolution="2.0", progress=None):
|
||||
"""Holt SWISSIMAGE-10cm-Tiles fuer LV95-bbox. resolution: '0.1' (10 cm,
|
||||
sehr gross!), '0.5' (50 cm), '2.0' (2 m, Default — Material-Texture
|
||||
braucht keine 10cm)."""
|
||||
bbox_wgs = lv95_bbox_to_wgs84_bbox(*bbox_lv95)
|
||||
if progress: progress("STAC-Query SWISSIMAGE...")
|
||||
items = stac_query("ch.swisstopo.swissimage-dop10",
|
||||
bbox_wgs, asset_extensions=[".tif"])
|
||||
items = _dedupe_latest(items)
|
||||
if not items:
|
||||
if progress: progress("Kein Luftbild in der Region gefunden")
|
||||
return []
|
||||
target_tag = "_{}_".format(resolution)
|
||||
paths = []
|
||||
for i, item in enumerate(items):
|
||||
if progress: progress("Lade Luftbild {}/{}: {}".format(
|
||||
i+1, len(items), item["id"]))
|
||||
chosen_href = None
|
||||
for k, a in item["assets"].items():
|
||||
if target_tag in a["href"]:
|
||||
chosen_href = a["href"]; break
|
||||
if chosen_href is None:
|
||||
chosen_href = next(iter(item["assets"].values()))["href"]
|
||||
p = download_asset(chosen_href, subdir="swissimage_tif", status=progress)
|
||||
if p: paths.append(p)
|
||||
if progress: progress("{} Luftbild-Tile(s) bereit".format(len(paths)))
|
||||
return paths
|
||||
|
||||
|
||||
def apply_ortho_material(doc, mesh_obj, ortho_path, mesh_bbox_lv95):
|
||||
"""Erzeugt Rhino-Material mit dem SWISSIMAGE-GeoTIFF als Bitmap-Texture,
|
||||
weist es dem mesh_obj zu. UV-Mapping kommt aus den XY-Koords (linear auf
|
||||
der bbox)."""
|
||||
if not (ortho_path and os.path.isfile(ortho_path)): return
|
||||
try:
|
||||
rdoc = doc.RenderMaterials
|
||||
from Rhino.Render import RenderMaterial, RenderContent
|
||||
try:
|
||||
mat = RenderMaterial.CreateBasicMaterial(
|
||||
Rhino.DocObjects.Material(), doc)
|
||||
except Exception:
|
||||
mat = RenderMaterial.CreateBasicMaterial(
|
||||
Rhino.DocObjects.Material())
|
||||
try: mat.Name = "swisstopo_ortho_" + os.path.basename(ortho_path)
|
||||
except Exception: pass
|
||||
# Bitmap zuweisen — Property-Name variiert mit Rhino-Version
|
||||
try:
|
||||
mat.SetParameter("diffuse-bitmap-filename", ortho_path)
|
||||
except Exception as ex:
|
||||
print("[SWISSTOPO] material bitmap:", ex)
|
||||
try:
|
||||
mid = rdoc.Add(mat)
|
||||
except Exception:
|
||||
mid = doc.Materials.Add()
|
||||
# UV-Mapping: planar in XY-bbox
|
||||
e_min, n_min, e_max, n_max = mesh_bbox_lv95
|
||||
try:
|
||||
plane = rg.Plane(rg.Point3d((e_min + e_max) / 2.0,
|
||||
(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:
|
||||
print("[SWISSTOPO] uv-mapping:", ex)
|
||||
# Material aufs Object setzen
|
||||
try:
|
||||
attrs = mesh_obj.Attributes.Duplicate()
|
||||
attrs.MaterialSource = Rhino.DocObjects.ObjectMaterialSource.MaterialFromObject
|
||||
attrs.RenderMaterial = mat
|
||||
doc.Objects.ModifyAttributes(mesh_obj, attrs, True)
|
||||
except Exception as ex:
|
||||
print("[SWISSTOPO] material assign:", ex)
|
||||
except Exception as ex:
|
||||
print("[SWISSTOPO] apply_ortho_material:", ex)
|
||||
Reference in New Issue
Block a user