Files
DOSSIER/rhino/swisstopo.py
T
karim 4111f12f32 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>
2026-05-19 18:22:48 +02:00

637 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#! 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)