#! 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 -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: liefert mehrere Formate (DXF/DWG/OBJ/IFC/3DTiles) und # variant-Filter (solid/separated). In Staedten sind die 3.0-Tiles aber riesig # (>700 MB), weil nicht 1km-strukturiert — dann fallen wir auf 2.0 zurueck # (verlaesslich 1km-Tiles, ~50 MB). _BUILDINGS_COLLECTION_V3 = "ch.swisstopo.swissbuildings3d_3_0" _BUILDINGS_COLLECTION_V2 = "ch.swisstopo.swissbuildings3d_2" def _fetch_buildings_from_collection(collection_id, bbox_wgs, variant, progress=None): """Holt Tile-CAD-Files aus EINER STAC-Collection. Liefert Liste Pfade oder [] wenn nichts brauchbar geladen werden konnte (z.B. alle ueber Size-Limit).""" if progress: progress("STAC-Query {} (variant={})...".format( collection_id, variant)) items = stac_query(collection_id, bbox_wgs, asset_extensions=[".dwg", ".dxf", ".obj", ".ifc", ".dwg.zip", ".dxf.zip", ".obj.zip", ".ifc.zip"]) items = _dedupe_latest(items) if not items: if progress: progress(" Keine Tiles in der Region") return [] variant_marker = "_{}_".format(variant.lower()) items_v = [it for it in items if variant_marker in (it.get("id") or "").lower() or any(variant_marker in (a.get("href") or "").lower() for a in it.get("assets", {}).values())] if items_v and len(items_v) < len(items): if progress: progress(" Item-Filter: {}/{} matchen variant '{}'".format( len(items_v), len(items), variant)) items = items_v paths = [] 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 # Varianten-Filter: `_solid_` vs `_separated_` im Filename. Default # ist 'separated'. Falls keine Asset mit dem Marker matcht (alte # Collection-Version o.ae.), fallen wir auf alle per-tile zurueck. variant_marker = "_{}_".format(variant.lower()) per_tile_v = [(k, a) for k, a in per_tile if variant_marker in a["href"].lower()] if per_tile_v: per_tile = per_tile_v if progress: progress(" → {} Asset(s) matchen variant '{}'".format( len(per_tile), variant)) else: if progress: hrefs_short = ", ".join(os.path.basename(a["href"]) for _, a in per_tile[:3]) progress(" → kein '{}' Marker, nehme erstes Asset (verfuegbar: {})".format( variant, hrefs_short)) # Priorisierung: DXF/DWG (am stabilsten in Rhino) > OBJ > IFC chosen = None for prio_ext in [(".dxf", ".dwg"), (".obj",), (".ifc",), (".dxf.zip", ".dwg.zip"), (".obj.zip",), (".ifc.zip",)]: for k, a in per_tile: low = a["href"].lower() if low.endswith(prio_ext): chosen = a["href"]; break if chosen is not None: 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 + Variant-Filter (ZIP kann beide DWGs enthalten) if p.lower().endswith(".zip"): extracted = _extract_zip_to_dir( p, os.path.join(CACHE_DIR, "buildings3d_dwg", "_unzipped")) cads_all = [e for e in extracted if e.lower().endswith((".dwg", ".dxf", ".obj", ".ifc"))] cads_v = [e for e in cads_all if variant_marker in os.path.basename(e).lower()] cads = cads_v if cads_v else cads_all if cads_v and len(cads_v) < len(cads_all): if progress: progress(" ZIP-Filter: {}/{} Files matchen '{}'".format( len(cads_v), len(cads_all), variant)) paths.extend(cads) else: paths.append(p) return paths def fetch_buildings_dwg(bbox_lv95, progress=None, variant="separated"): """Holt swissBUILDINGS3D Tile-CAD-Files. Versucht erst v3.0 (separated/ solid Varianten), faellt automatisch auf v2.0 zurueck wenn v3.0 in der Region keine brauchbaren Files liefert (typisch in Staedten — die 3.0- Tiles sind dort >700 MB pro Stueck und werden vom Size-Limit geblockt).""" bbox_wgs = lv95_bbox_to_wgs84_bbox(*bbox_lv95) paths = _fetch_buildings_from_collection( _BUILDINGS_COLLECTION_V3, bbox_wgs, variant, progress=progress) if not paths: if progress: progress("v3.0 lieferte keine Tiles — fallback auf v2.0 (1km-Tiles)...") # v2.0 hat keine variant-Marker im Filename, ist immer "separated"- # artig (Kategorien auf eigenen DXF-Layern innerhalb einer DWG). paths = _fetch_buildings_from_collection( _BUILDINGS_COLLECTION_V2, bbox_wgs, variant, progress=progress) 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 merge_grids(grids): """Vereint mehrere Grid-Dicts (eines pro XYZ-Tile) zu einem zusammen- haengenden Grid. swissALTI3D-Tiles liefern jeweils 1km×1km Punkte — benachbarte Tiles teilen KEINE Rand-Punkte (Tile A endet z.B. bei e=2700999.5, Tile B startet bei e=2701000.0). Beim getrennten Meshen entsteht dadurch ein 1m-Streifen ohne Faces. Hier mergen wir die Punkte VORHER zu einem unified Grid, dann verbindet mesh_from_grid die Tile-Grenze automatisch (benachbarte Spalten = ein step Abstand nach Sub-Sampling). Annahme: alle Grids teilen sich denselben step (gleicher target_step + Quell-Resolution). Origin-Alignment ist gegeben, weil swissALTI3D auf einem globalen 0.5m-Raster liegt.""" if not grids: return None grids = [g for g in grids if g is not None] if not grids: return None if len(grids) == 1: return grids[0] step = grids[0]["step"] all_points = {} all_es = set(); all_ns = set() for g in grids: for (e, n), z in g["points"].items(): all_points[(e, n)] = z all_es.add(e); all_ns.add(n) if not all_points: return None es_sorted = sorted(all_es); ns_sorted = sorted(all_ns) return { "bbox": (es_sorted[0], ns_sorted[0], es_sorted[-1], ns_sorted[-1]), "step": step, "es": es_sorted, "ns": ns_sorted, "points": all_points, } def mesh_from_grid(grid, origin_shift=(0, 0, 0), unit_scale=1.0): """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 _geotiff_to_png(tif_path, max_dim=2048): """SWISSIMAGE kommt als GeoTIFF — Rhinos Material-Bitmap kann GeoTIFF nicht direkt lesen. Konvertiere zu PNG. Zwei Wege: 1) Pillow (wenn in Rhinos CPython verfuegbar) — universell + downsample 2) Eto.Drawing.Bitmap (Mac: NSImage liest TIFF nativ) — Fallback Liefert PNG-Pfad oder None bei Fehler.""" if not tif_path: return None base, _ = os.path.splitext(tif_path) png_path = base + "_2k.png" if os.path.isfile(png_path) and os.path.getsize(png_path) > 0: print("[SWISSTOPO] PNG-Cache:", os.path.basename(png_path)) return png_path # --- Variante 1: Pillow try: from PIL import Image img = Image.open(tif_path) if max(img.width, img.height) > max_dim: scale = max_dim / float(max(img.width, img.height)) new_w = max(1, int(img.width * scale)) new_h = max(1, int(img.height * scale)) img = img.resize((new_w, new_h), Image.LANCZOS) if img.mode not in ("RGB", "RGBA"): img = img.convert("RGB") img.save(png_path, "PNG", optimize=False) print("[SWISSTOPO] Pillow: {} → {} ({}x{}px)".format( os.path.basename(tif_path), os.path.basename(png_path), img.width, img.height)) return png_path except ImportError: print("[SWISSTOPO] Pillow nicht verfuegbar — versuche Eto.Drawing") except Exception as ex: print("[SWISSTOPO] Pillow-convert fail:", ex) # --- Variante 2: Eto.Drawing (Mac NSImage liest TIFF) try: import Eto.Drawing as _ed bmp_src = _ed.Bitmap(tif_path) if bmp_src is None: print("[SWISSTOPO] Eto konnte TIFF nicht laden") return None # Downsample falls > max_dim w, h = bmp_src.Width, bmp_src.Height if max(w, h) > max_dim: scale = max_dim / float(max(w, h)) new_w = max(1, int(w * scale)) new_h = max(1, int(h * scale)) target = _ed.Bitmap(new_w, new_h, _ed.PixelFormat.Format32bppRgba) g = _ed.Graphics(target) try: try: g.AntiAlias = True except Exception: pass g.DrawImage(bmp_src, 0, 0, new_w, new_h) finally: g.Dispose() bmp_src = target w, h = new_w, new_h try: bmp_src.Save(png_path, _ed.ImageFormat.Png) except Exception: # Eto.ImageFormat-Variante kann je nach Eto-Version variieren bmp_src.Save(png_path) print("[SWISSTOPO] Eto: {} → {} ({}x{}px)".format( os.path.basename(tif_path), os.path.basename(png_path), w, h)) return png_path except Exception as ex: print("[SWISSTOPO] Eto-convert fail:", ex) return None def add_ortho_plane(doc, ortho_path, tile_bbox_lv95, shift_lv95, m_to_unit, z_doc=0.0): """Erzeugt eine planare Brep-Flaeche mit dem SWISSIMAGE-Foto als Material, direkt sichtbar in Top/Shaded/Rendered Display-Mode. tile_bbox_lv95: (e_min, n_min, e_max, n_max) in LV95-Metern der Tile-Region shift_lv95: (sx, sy, sz) — Origin-Shift in LV95-Metern (typisch eC,nC) m_to_unit: Skalierung m → doc-units (z.B. 0.001 fuer km-Doc) z_doc: Z-Hoehe der Plane in Doc-Units (typisch max-Terrain-Z + Epsilon) Liefert den RhinoObject der erzeugten Plane (oder None).""" if not (ortho_path and os.path.isfile(ortho_path)): return None # GeoTIFF → PNG damit Rhino's Material-Bitmap es als Diffuse nehmen kann if ortho_path.lower().endswith((".tif", ".tiff")): png = _geotiff_to_png(ortho_path) if not png: return None ortho_path = png # bbox in Doc-Units (nach Shift + Scale) e_min, n_min, e_max, n_max = tile_bbox_lv95 sx, sy, sz = shift_lv95 x_min = (e_min - sx) * m_to_unit x_max = (e_max - sx) * m_to_unit y_min = (n_min - sy) * m_to_unit y_max = (n_max - sy) * m_to_unit # Mesh-Quad mit expliziten Per-Vertex-UV-Koordinaten — bombensicher # fuer Cycles/Raytraced. Eine Brep-Plane braucht erst Render-Mesh- # Erzeugung + TextureMapping, was diverse Fallstricke hat. mesh = rg.Mesh() mesh.Vertices.Add(x_min, y_min, z_doc) # 0 → UV (0,0) mesh.Vertices.Add(x_max, y_min, z_doc) # 1 → UV (1,0) mesh.Vertices.Add(x_max, y_max, z_doc) # 2 → UV (1,1) mesh.Vertices.Add(x_min, y_max, z_doc) # 3 → UV (0,1) mesh.Faces.AddFace(0, 1, 2, 3) mesh.TextureCoordinates.Add(0.0, 0.0) mesh.TextureCoordinates.Add(1.0, 0.0) mesh.TextureCoordinates.Add(1.0, 1.0) mesh.TextureCoordinates.Add(0.0, 1.0) mesh.Normals.ComputeNormals() mesh.Compact() gid = doc.Objects.AddMesh(mesh) obj = doc.Objects.Find(gid) if obj is None: return None # Material: Legacy + ToPhysicallyBased + PBR_BaseColor-Texture. # Bekannt instabil unter Mac Rhino 8 für Raytraced (Cycles greift den # Shim nicht zuverlaessig); zumindest Shaded zeigt die Textur. try: mat = Rhino.DocObjects.Material() mat.Name = "swisstopo_ortho" mat.SetBitmapTexture(ortho_path) mat.ToPhysicallyBased() tex = Rhino.DocObjects.Texture() tex.FileName = ortho_path tex.Enabled = True mat.SetTexture(tex, Rhino.DocObjects.TextureType.PBR_BaseColor) midx = doc.Materials.Add(mat) attrs = obj.Attributes.Duplicate() attrs.MaterialSource = Rhino.DocObjects.ObjectMaterialSource.MaterialFromObject attrs.MaterialIndex = midx doc.Objects.ModifyAttributes(obj, attrs, True) except Exception as ex: print("[SWISSTOPO] ortho-material:", ex) return obj