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:
2026-05-19 18:22:48 +02:00
parent 41b6f8ac51
commit 4111f12f32
8 changed files with 1557 additions and 0 deletions
+636
View File
@@ -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)