#! 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 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)