From 4111f12f3208b9bd76a92d5be3c077e594618d8e Mon Sep 17 00:00:00 2001 From: karim Date: Tue, 19 May 2026 18:22:48 +0200 Subject: [PATCH] Swisstopo Importer: STAC-API + Terrain-Mesh + Ortho-Drape (Iteration 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- rhino/dimensionen.py | 2 + rhino/elemente.py | 544 +++++++++++++++++++++++++++++++++++ rhino/gestaltung.py | 6 + rhino/swisstopo.py | 636 +++++++++++++++++++++++++++++++++++++++++ src/ElementeApp.jsx | 10 + src/SwisstopoApp.jsx | 354 +++++++++++++++++++++++ src/lib/rhinoBridge.js | 3 + src/main.jsx | 2 + 8 files changed, 1557 insertions(+) create mode 100644 rhino/swisstopo.py create mode 100644 src/SwisstopoApp.jsx diff --git a/rhino/dimensionen.py b/rhino/dimensionen.py index dbfc7e9..f5e40f6 100644 --- a/rhino/dimensionen.py +++ b/rhino/dimensionen.py @@ -586,6 +586,8 @@ def _install_listeners(bridge): except Exception as ex: print("[DIMENSIONEN] idle:", ex) def on_select(s, e): + # Swisstopo-Import feuert tausende Selection-Events → bail. + if sc.sticky.get("dossier_swisstopo_busy"): return b = sc.sticky.get("dimensionen_bridge") if b is not None: try: b._send_state(force=True) diff --git a/rhino/elemente.py b/rhino/elemente.py index 8ad7d5e..0f18909 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -4628,6 +4628,9 @@ class ElementeBridge(panel_base.BaseBridge): elif t == "CREATE_TRAEGER": self._cmd_create_traeger(p) elif t == "CREATE_RAUM": self._cmd_create_raum(p) elif t == "EXPORT_RAEUME": self._cmd_export_raeume(p) + elif t == "OPEN_SWISSTOPO": self._cmd_open_swisstopo(p) + elif t == "IMPORT_SWISSTOPO": self._cmd_import_swisstopo(p) + elif t == "OPEN_SWISSTOPO_DIALOG": self._cmd_open_swisstopo_dialog(p) elif t == "UPDATE_WALL": self._update_wall(p) elif t == "UPDATE_ELEMENT": self._update_wall(p) # gleiche Logik fuer alle elif t == "DELETE_WALL": self._delete_wall(p.get("id")) @@ -6507,6 +6510,540 @@ class ElementeBridge(panel_base.BaseBridge): except Exception as ex: print("[ELEMENTE] CSV schreiben:", ex) + # ------------------------------------------------------------------ + # Swisstopo — Option-A-Workflow: + # 1) "Karte oeffnen": map.geo.admin.ch im Browser mit vorausgewaehlten + # Layern swissALTI3D + swissBUILDINGS3D. User waehlt sein Gebiet, + # laedt DWG/OBJ/DAE runter. + # 2) "Importieren": File-Picker -> Rhinos _-Import -> Plugin verschiebt + # die NEU importierten Objekte auf den gewuenschten DOSSIER-Sublayer + # unter dem aktiven Geschoss. + # ------------------------------------------------------------------ + + def _cmd_open_swisstopo(self, p): + """Oeffnet map.geo.admin.ch im Default-Browser mit den swisstopo- + Layern voreingestellt. Param `mode`: 'buildings' | 'terrain' | 'both'. + Optionaler `center`: 'CH1903_E,CH1903_N' fuer initiale Karten- + Position; sonst Default-Center (Schweiz Mitte).""" + import subprocess + mode = (p.get("mode") or "both").lower() + if mode == "buildings": + layers = "ch.swisstopo.swissbuildings3d_3" + elif mode == "terrain": + layers = "ch.swisstopo.swissalti3d" + else: + layers = "ch.swisstopo.swissalti3d,ch.swisstopo.swissbuildings3d_3" + # Default-Zentrum (Schweiz Mitte LV95) + center = (p.get("center") or "2660000,1190000").strip() + try: + E, N = [s.strip() for s in center.split(",", 1)] + except Exception: + E, N = "2660000", "1190000" + url = ("https://map.geo.admin.ch/" + "?lang=de&topic=ech&bgLayer=ch.swisstopo.pixelkarte-farbe" + "&E={}&N={}&zoom=8&layers={}").format(E, N, layers) + try: + subprocess.Popen(["open", url]) + print("[ELEMENTE] Swisstopo Karte geoeffnet:", url) + except Exception as ex: + print("[ELEMENTE] Karte oeffnen fehlgeschlagen:", ex) + + def _cmd_import_swisstopo(self, p): + """File-Picker -> Rhino _-Import -> bewege neue Objekte auf den + DOSSIER-Sublayer. `kind`: 'buildings' (12_Gebaeude) | 'terrain' + (10_Situation) | 'vermessung' (01_Vermessung) | 'other' (aktiver + Layer wird nicht geaendert).""" + doc = Rhino.RhinoDoc.ActiveDoc + if doc is None: return + kind = (p.get("kind") or "buildings").lower() + # Target-Sublayer ableiten (auto-anlegen wenn nicht vorhanden) + if kind == "buildings": + sub_name = _find_ebene_sublayer_name( + doc, ["gebaeude", "gebäude", "buildings"], + "12", "Gebäude", default_color="#888888", default_lw=0.25) + elif kind == "terrain": + sub_name = _find_ebene_sublayer_name( + doc, ["situation", "terrain", "gelaende"], + "10", "Situation", default_color="#909090", default_lw=0.18) + elif kind == "vermessung": + sub_name = _find_ebene_sublayer_name( + doc, ["vermessung", "survey"], + "01", "Vermessung", default_color="#707078", default_lw=0.18) + else: + sub_name = None + + # File-Picker + try: + from Rhino.UI import OpenFileDialog + ofd = OpenFileDialog() + ofd.Filter = ("Swisstopo Imports (*.dwg;*.dxf;*.obj;*.dae;*.ifc;*.3dm;*.ply;*.stl)|" + "*.dwg;*.dxf;*.obj;*.dae;*.ifc;*.3dm;*.ply;*.stl|" + "Alle Dateien (*.*)|*.*") + ok = False + try: ok = ofd.ShowOpenDialog() + except Exception: + try: ok = ofd.ShowDialog() + except Exception: ok = False + if not ok: + print("[ELEMENTE] Swisstopo-Import abgebrochen"); return + path = ofd.FileName + except Exception as ex: + print("[ELEMENTE] OpenFileDialog:", ex); return + if not path or not os.path.isfile(path): + print("[ELEMENTE] Pfad ungueltig:", path); return + + # Snapshot vor Import: existierende Object-IDs + before_ids = set() + try: + for obj in doc.Objects: + if obj is None or obj.IsDeleted: continue + before_ids.add(obj.Id) + except Exception: pass + + # Pfad fuer Rhino-Command escapen (Spaces!) + cmd_path = path.replace('"', '\\"') + cmd = '_-Import "{}" _Enter'.format(cmd_path) + print("[ELEMENTE] Swisstopo-Import:", cmd) + try: + Rhino.RhinoApp.RunScript(cmd, False) + except Exception as ex: + print("[ELEMENTE] _-Import fehlgeschlagen:", ex); return + + # Differenz: neu hinzugekommene Objekte + new_objs = [] + try: + for obj in doc.Objects: + if obj is None or obj.IsDeleted: continue + if obj.Id not in before_ids: + new_objs.append(obj) + except Exception: pass + print("[ELEMENTE] Swisstopo-Import: {} neue Objekte".format(len(new_objs))) + if not new_objs: return + + # Target-Layer finden + Objekte verschieben (nur wenn sub_name gesetzt) + if sub_name: + z_id = doc.Strings.GetValue("dossier_active_id") + if not z_id: + print("[ELEMENTE] Swisstopo: kein aktives Geschoss — Objekte " + "bleiben auf Import-Default-Layer"); return + try: + import layer_builder + parent_idx = layer_builder._find_top_by_id(doc, z_id) + if parent_idx < 0: + print("[ELEMENTE] Swisstopo: Parent-Layer nicht gefunden") + return + parent_id = doc.Layers[parent_idx].Id + code = sub_name.split("_", 1)[0] + sub_idx = layer_builder._find_sublayer_by_code(doc, parent_id, code) + if sub_idx < 0: + print("[ELEMENTE] Swisstopo: Sublayer {} nicht gefunden " + "— bitte erst Ebenen-Apply ausloesen".format(code)) + return + moved = 0 + for obj in new_objs: + try: + attrs = obj.Attributes.Duplicate() + attrs.LayerIndex = sub_idx + if doc.Objects.ModifyAttributes(obj, attrs, True): + moved += 1 + except Exception: pass + print("[ELEMENTE] Swisstopo: {} Objekte auf {} verschoben".format( + moved, doc.Layers[sub_idx].FullPath)) + except Exception as ex: + print("[ELEMENTE] Layer-Move fehler:", ex) + try: doc.Views.Redraw() + except Exception: pass + + def _cmd_open_swisstopo_dialog(self, p): + """Oeffnet das volle Swisstopo-Importer-Satelliten-Fenster mit API- + Anbindung (Adresse-Suche, Auto-Tiles, Terrain+Orthofoto).""" + outer = self + bridge_holder = {"form": None} + # Initial-State fuer den Dialog: aktuelle Ebenen-Liste + Default- + # Layer-Codes fuer die Auto-Sublayer-Erkennung + doc = Rhino.RhinoDoc.ActiveDoc + try: + e_raw = doc.Strings.GetValue("dossier_ebenen") if doc else None + ebenen = json.loads(e_raw) if e_raw else [] + except Exception: ebenen = [] + + class _SwisstopoBridge(panel_base.BaseBridge): + def __init__(self): + panel_base.BaseBridge.__init__(self, "swisstopo") + def _on_ready(self): + self.send("SWISSTOPO_STATE", { + "ebenen": ebenen, + "cacheDir": __import__("swisstopo").CACHE_DIR, + }) + def _push_log(self, msg): + try: self.send("SWISSTOPO_LOG", {"msg": str(msg)}) + except Exception: pass + def handle(self, data): + if not isinstance(data, dict): return + t = data.get("type", "") + pp = data.get("payload") or {} + if t == "READY": + self._on_ready() + elif t == "GEOCODE": + import swisstopo + res = swisstopo.geocode(pp.get("text") or "") + self.send("GEOCODE_RESULT", {"result": res}) + elif t == "RUN_IMPORT": + self._run_import(pp) + elif t == "CANCEL": + try: + f = bridge_holder.get("form") + if f is not None: f.Close() + except Exception: pass + def _run_import(self, opts): + """opts = {centerE, centerN, radius, kinds: ['buildings','terrain','ortho'], + shiftToOrigin, autoZoom, + layerBuildings, layerTerrain, terrainResolution}""" + d = Rhino.RhinoDoc.ActiveDoc + if d is None: + self._push_log("Kein aktives Doc"); return + try: + import swisstopo, layer_builder + except Exception as ex: + self._push_log("Module-Import-Fehler: {}".format(ex)); return + try: + eC = float(opts.get("centerE")); nC = float(opts.get("centerN")) + r = float(opts.get("radius") or 100) + except Exception: + self._push_log("Center/Radius ungueltig"); return + kinds = set(opts.get("kinds") or ["buildings"]) + shift = bool(opts.get("shiftToOrigin", True)) + replace_existing = bool(opts.get("replaceExisting", True)) + clip_to_bbox = bool(opts.get("clipToBbox", False)) + + # Doc-Unit-Skalierung. LV95-Werte sind in Metern. Wenn der + # Rhino-Doc in mm/km/inch laeuft, muessen wir die bbox + den + # shift in Doc-Units umrechnen — sonst stimmt der Clip-Check + # mit den (auto-skaliert importierten) DXF-Coords nicht ueberein. + try: + m_to_unit = Rhino.RhinoMath.UnitScale( + Rhino.UnitSystem.Meters, d.ModelUnitSystem) + except Exception: + m_to_unit = 1.0 + eC_u = eC * m_to_unit + nC_u = nC * m_to_unit + r_u = r * m_to_unit + bbox = (eC - r, nC - r, eC + r, nC + r) # m (fuer STAC-Query) + bbox_doc = (eC_u - r_u, nC_u - r_u, eC_u + r_u, nC_u + r_u) # in Doc-Units + origin_shift = (eC, nC, 0) if shift else (0, 0, 0) + origin_shift_doc = (eC_u, nC_u, 0) if shift else (0, 0, 0) + self._push_log("Center LV95: E={:.1f} N={:.1f} Radius={}m".format(eC, nC, r)) + self._push_log("BBox (m): {:.0f}-{:.0f} / {:.0f}-{:.0f}".format(*bbox)) + if m_to_unit != 1.0: + self._push_log("Doc-Unit: {} → m_to_unit={} (Skalierung aktiv)".format( + d.ModelUnitSystem, m_to_unit)) + + z_id = d.Strings.GetValue("dossier_active_id") + if not z_id: + self._push_log("Achtung: kein aktives Geschoss — Objekte bleiben auf Default-Layer") + + # Bestehende swisstopo-Objekte loeschen wenn gewuenscht. + # Tag wird beim Import gesetzt (UserString dossier_swisstopo_kind). + if replace_existing: + self._push_log("Loesche bestehende swisstopo-Objekte (alte Imports)...") + removed = 0 + for obj in list(d.Objects): + if obj is None or obj.IsDeleted: continue + try: + tag = obj.Attributes.GetUserString("dossier_swisstopo_kind") + except Exception: tag = None + if tag: + d.Objects.Delete(obj.Id, True) + removed += 1 + self._push_log("→ {} alte swisstopo-Objekte geloescht".format(removed)) + + new_obj_ids = [] + def _track_new(before_ids): + out = [] + for obj in d.Objects: + if obj is None or obj.IsDeleted: continue + if obj.Id not in before_ids: out.append(obj) + return out + + before_all = set(o.Id for o in d.Objects if o and not o.IsDeleted) + + # Listener-Suppression: elemente.py + gestaltung.py haben Add/ + # Replace-Listener die pro neu importiertem Objekt feuern. Bei + # 5000+ DXF-Objekten erstickt das den Import. Sticky-Flag setzen, + # die Listener bailen früh (siehe _is_swisstopo_busy unten). + sc.sticky["dossier_swisstopo_busy"] = True + try: + # --- Buildings (DWG) ----------------------------------- + if "buildings" in kinds: + paths = swisstopo.fetch_buildings_dwg(bbox, progress=self._push_log) + for idx, p in enumerate(paths): + try: size_mb = os.path.getsize(p) / 1e6 + except Exception: size_mb = 0 + self._push_log("Import {}/{}: {} ({:.0f} MB) — Rhinos DXF-Parser, kann ein paar Sekunden dauern...".format( + idx + 1, len(paths), os.path.basename(p), size_mb)) + try: swisstopo._yield_ui() + except Exception: pass + before = set(o.Id for o in d.Objects if o and not o.IsDeleted) + cmd = '_-Import "{}" _Enter'.format(p.replace('"', '\\"')) + try: Rhino.RhinoApp.RunScript(cmd, False) + except Exception as ex: + self._push_log("Import {}: {}".format(p, ex)); continue + new = _track_new(before) + self._push_log("→ Import fertig: {} neue Objekte".format(len(new))) + # Auto-Skala-Erkennung: Rhinos DXF-Parser kann je + # nach $INSUNITS und Doc-Unit unerwartet 1000x rauf/ + # runter skalieren. Wir messen aus den Objekten und + # SNAPPEN auf naechste Zehnerpotenz (1, 0.001, 1000) + # damit kleine Mess-Streuung nicht eine off-by-1m + # bbox produziert. + if new and idx == 0: + try: + import math as _m + samples = 0; sum_x = 0.0 + for o in new[:50]: + bb = o.Geometry.GetBoundingBox(True) + sum_x += bb.Center.X + samples += 1 + avg_x = sum_x / max(1, samples) + if abs(eC) > 1.0 and avg_x != 0: + raw_ratio = avg_x / eC + snapped = 10 ** round(_m.log10(abs(raw_ratio))) + if abs(snapped - m_to_unit) > 1e-9: + self._push_log("AUTO-SKALA: raw_ratio={:.6f} → snap to 1m={:g} doc-units (war {:g})".format( + raw_ratio, snapped, m_to_unit)) + m_to_unit = snapped + eC_u = eC * m_to_unit + nC_u = nC * m_to_unit + r_u = r * m_to_unit + bbox_doc = (eC_u - r_u, nC_u - r_u, + eC_u + r_u, nC_u + r_u) + origin_shift_doc = (eC_u, nC_u, 0) if shift else (0, 0, 0) + except Exception as ex: + self._push_log("Auto-Skala-Erkennung: {}".format(ex)) + # Diagnose + if new: + try: + obb0 = new[0].Geometry.GetBoundingBox(True) + self._push_log(" Erste obj bbox (Doc-Units): " + "({:.3f},{:.3f},{:.3f}) - ({:.3f},{:.3f},{:.3f})".format( + obb0.Min.X, obb0.Min.Y, obb0.Min.Z, + obb0.Max.X, obb0.Max.Y, obb0.Max.Z)) + self._push_log(" bbox_doc (nach Auto-Skala): " + "{:.3f}-{:.3f} / {:.3f}-{:.3f}".format(*bbox_doc)) + except Exception: pass + # Post-Clip nur wenn User es will (Default OFF) — bei + # InstanceReferences ist GetBoundingBox + Delete teuer. + # Tile = 1km², User-radius typisch 100m → ohne Clip + # hast du das ganze Dorf, aber Import bleibt schnell. + if clip_to_bbox: + self._push_log("→ Clippe auf User-bbox...") + kept = [] + outside = [] + for o in new: + try: + obb = o.Geometry.GetBoundingBox(True) + cx = (obb.Min.X + obb.Max.X) * 0.5 + cy = (obb.Min.Y + obb.Max.Y) * 0.5 + if (bbox_doc[0] <= cx <= bbox_doc[2] and + bbox_doc[1] <= cy <= bbox_doc[3]): + kept.append(o) + else: + outside.append(o) + except Exception: + kept.append(o) + if not kept and outside: + self._push_log(" → Clip waere {}/{} → bbox passt nicht zu Doc-Coords, alle behalten".format( + len(outside), len(new))) + kept = new + else: + # Batch-Delete: deutlich schneller als per-obj + out_ids = [o.Id for o in outside] + for oid in out_ids: + try: d.Objects.Delete(oid, True) + except Exception: pass + self._push_log(" → {} behalten, {} ausserhalb bbox geloescht".format( + len(kept), len(out_ids))) + else: + # Kein Clip — alle behalten + kept = new + try: swisstopo._yield_ui() + except Exception: pass + # Shift falls aktiviert — Batch via System.List[Guid] + # damit Python.NET den richtigen Overload erwischt. + if shift and kept: + self._push_log("→ Shift {} Objekte zum Welt-Origin (Batch)...".format(len(kept))) + xform = rg.Transform.Translation(-origin_shift_doc[0], + -origin_shift_doc[1], + -origin_shift_doc[2]) + try: + from System.Collections.Generic import List as _List + from System import Guid as _Guid + ids = _List[_Guid]() + for o in kept: ids.Add(o.Id) + n_shifted = d.Objects.Transform(ids, xform, True) + self._push_log(" → {} Objekte verschoben".format(n_shifted)) + except Exception as ex: + self._push_log(" Batch-Shift fehlgeschlagen, Loop-Fallback: {}".format(ex)) + for o in kept: + try: d.Objects.Transform(o.Id, xform, True) + except Exception: pass + # Layer-Move + Tag + if z_id and kept: + self._push_log("→ Layer-Move auf 12_Gebäude...") + sub_name = _find_ebene_sublayer_name( + d, ["gebaeude", "gebäude", "buildings"], + "12", "Gebäude", + default_color="#888888", default_lw=0.25) + self._move_to_sublayer(d, kept, z_id, + sub_name.split("_", 1)[0], tag="buildings") + else: + # Kein aktives Geschoss → nur Tag setzen + self._tag_objects(d, kept, "buildings") + new_obj_ids.extend(o.Id for o in kept) + + # --- Terrain (XYZ → Mesh) ------------------------------ + if "terrain" in kinds: + res = (opts.get("terrainResolution") or "2.0").strip() + try: target_step = float(res) + except Exception: target_step = 2.0 + xyz_paths = swisstopo.fetch_terrain_xyz( + bbox, resolution=res, progress=self._push_log) + mesh_objects = [] + for p in xyz_paths: + self._push_log("Mesh aus {}...".format(os.path.basename(p))) + try: + # xyz_to_grid arbeitet in LV95-Metern (Quelle). + # Erst grid bauen, dann beim mesh_from_grid auf + # doc-units skalieren + shiften. + grid = swisstopo.xyz_to_grid( + p, + target_step=target_step, + clip_bbox=bbox, # User-bbox in m! + progress=self._push_log) + if grid is None: + self._push_log("→ leeres Grid"); continue + # Mesh in Doc-Units bauen: shift in m (LV95), + # dann beim Vertex-Add * m_to_unit + mesh = swisstopo.mesh_from_grid( + grid, + origin_shift=origin_shift, + unit_scale=m_to_unit) + self._push_log("→ Mesh: {} Vertices / {} Faces".format( + mesh.Vertices.Count, mesh.Faces.Count)) + gid = d.Objects.AddMesh(mesh) + obj = d.Objects.Find(gid) + if obj: mesh_objects.append((obj, grid["bbox"])) + except Exception as ex: + self._push_log("Mesh-Bau fehlgeschlagen: {}".format(ex)) + # Layer-Move + Ortho-Drape + if z_id and mesh_objects: + sub_name = _find_ebene_sublayer_name( + d, ["situation", "terrain", "gelaende"], + "10", "Situation", + default_color="#909090", default_lw=0.18) + objs = [m[0] for m in mesh_objects] + self._move_to_sublayer(d, objs, z_id, + sub_name.split("_", 1)[0], tag="terrain") + elif mesh_objects: + objs = [m[0] for m in mesh_objects] + self._tag_objects(d, objs, "terrain") + if "ortho" in kinds and mesh_objects: + self._push_log("Hole Orthofoto...") + ortho_paths = swisstopo.fetch_orthophoto( + bbox, resolution="2.0", progress=self._push_log) + if ortho_paths: + # Erstes Ortho-Tile auf alle Meshes (MVP — pro Mesh + # eigenes Mapping waere genauer, kommt spaeter) + for obj, mbbox in mesh_objects: + try: + if shift: + # Mesh ist verschoben → bbox auch + mbbox_shifted = ( + mbbox[0] - origin_shift[0], + mbbox[1] - origin_shift[1], + mbbox[2] - origin_shift[0], + mbbox[3] - origin_shift[1]) + else: + mbbox_shifted = mbbox + swisstopo.apply_ortho_material( + d, obj, ortho_paths[0], mbbox_shifted) + except Exception as ex: + self._push_log("Ortho-Apply: {}".format(ex)) + new_obj_ids.extend(o.Id for o, _ in mesh_objects) + + self._push_log("Import fertig: {} neue Objekte".format(len(new_obj_ids))) + + # Auto-Zoom NOCH IM TRY-Block: sticky-Flag bleibt True + # damit der Select-Roundtrip nicht 3000 Listener weckt. + # ZoomBoundingBox + UnionBBox aller neuen → 1 API-Call + # statt Select-Loop. + if opts.get("autoZoom") and new_obj_ids: + try: + combined = rg.BoundingBox.Empty + for oid in new_obj_ids: + obj = d.Objects.Find(oid) + if obj is None: continue + try: + bb = obj.Geometry.GetBoundingBox(True) + if bb.IsValid: combined.Union(bb) + except Exception: pass + if combined.IsValid: + view = d.Views.ActiveView + if view is not None: + view.ActiveViewport.ZoomBoundingBox(combined) + except Exception as ex: + self._push_log("Auto-Zoom: {}".format(ex)) + try: d.Views.Redraw() + except Exception: pass + self.send("IMPORT_DONE", {"count": len(new_obj_ids)}) + finally: + sc.sticky["dossier_swisstopo_busy"] = False + + def _tag_objects(self, doc, objs, tag): + """Setzt nur den dossier_swisstopo_kind UserString — fuer + den Fall dass kein Geschoss aktiv ist und wir den Layer-Move + ueberspringen, aber den Marker fuers Replace-Erkennen + brauchen.""" + for o in objs: + try: + attrs = o.Attributes.Duplicate() + attrs.SetUserString("dossier_swisstopo_kind", tag) + doc.Objects.ModifyAttributes(o, attrs, True) + except Exception: pass + + def _move_to_sublayer(self, doc, objs, z_id, code, tag=None): + """Verschiebt Liste von Rhino-Objekten auf den DOSSIER-Sublayer + /_*. Optional: Tag (UserString + dossier_swisstopo_kind) setzen — wird beim naechsten Import + erkannt + ggf. geloescht.""" + if not objs: return + try: + import layer_builder + parent_idx = layer_builder._find_top_by_id(doc, z_id) + if parent_idx < 0: return + parent_id = doc.Layers[parent_idx].Id + sub_idx = layer_builder._find_sublayer_by_code(doc, parent_id, code) + if sub_idx < 0: return + n = 0 + for o in objs: + try: + attrs = o.Attributes.Duplicate() + attrs.LayerIndex = sub_idx + if tag: + attrs.SetUserString("dossier_swisstopo_kind", tag) + if doc.Objects.ModifyAttributes(o, attrs, True): n += 1 + except Exception: pass + self._push_log("→ {} Obj auf '{}'".format(n, doc.Layers[sub_idx].FullPath)) + except Exception as ex: + self._push_log("Layer-Move: {}".format(ex)) + + b = _SwisstopoBridge() + bridge_holder["form"] = panel_base.open_satellite_window( + "swisstopo", + title="swisstopo Importer", + size=(560, 620), + bridge=b) + def _update_wall(self, p): """Properties eines Elements aendern (Wand/Decke/Dach/Oeffnung). Volumen wird anschliessend regeneriert.""" @@ -7388,6 +7925,9 @@ def _on_object_added(sender, e): UserStrings auf das neue Objekt mit. Source-Duplikate kriegen eine neue UUID, Volume-Duplikate werden geloescht (Regen baut das neue Volumen am richtigen Ort).""" + # Swisstopo-Import importiert tausende Objekte am Stueck — die haben + # keine DOSSIER-Metas, jeder Listener-Call ist reine Verschwendung. + if sc.sticky.get("dossier_swisstopo_busy"): return if sc.sticky.get(_REGEN_BUSY): return # Waehrend Move/Rotate/Mirror/Scale: Rhino feuert intern Delete+Add fuer # jedes transformierte Objekt. CommandEnd uebernimmt die Re-Sync — @@ -7492,6 +8032,8 @@ def _on_object_deleted(sender, e): wenn die Source mit gleicher ID zurueckkommt (= Transform, kein User- Delete). """ + # Waehrend Swisstopo-Import: keine DOSSIER-Metas vorhanden, nur Overhead + if sc.sticky.get("dossier_swisstopo_busy"): return # Waehrend Move/Rotate/Mirror/Scale: CommandEnd-Pfad uebernimmt das # Re-Sync. Sonst queued der Delete-Event ueberfluessige Regen-Calls die # den Pure-Translate-Skip wieder zunichtemachen. @@ -7646,6 +8188,7 @@ def _on_select_objects(sender, e): So bewegen sich beide synchron bei Move/Gumball, und die Endpunkte der Lauflinie sind als Grips zum Drag verfuegbar.""" + if sc.sticky.get("dossier_swisstopo_busy"): return if sc.sticky.get(_SELECT_BUSY): return if sc.sticky.get(_REGEN_BUSY): return try: @@ -7680,6 +8223,7 @@ def _on_deselect_objects(sender, e): """Bidirektional zu _on_select_objects: - Volume deselektiert → Source deselektieren + Grips aus - Source deselektiert → Volume deselektieren + Grips aus""" + if sc.sticky.get("dossier_swisstopo_busy"): return if sc.sticky.get(_SELECT_BUSY): return if sc.sticky.get(_REGEN_BUSY): return try: diff --git a/rhino/gestaltung.py b/rhino/gestaltung.py index 032c39b..8373ec7 100644 --- a/rhino/gestaltung.py +++ b/rhino/gestaltung.py @@ -1373,6 +1373,10 @@ def _install_selection_listener(bridge): # reselect). Bei 7 Objekten sind das ~100 IPC-Sends in den WebView, # was sich als „Regen" anfuehlt. elemente._on_command_end refresht # nach dem Command einmalig. + # Waehrend Swisstopo-Import: Rhino selektiert jedes neu importierte + # Objekt → 5000 selection-changes → 5000 send-Calls in den WebView → + # erstickt den UI-Thread. Sticky-Flag => bail. + if sc.sticky.get("dossier_swisstopo_busy"): return if sc.sticky.get("_dossier_user_transform_active"): return if sc.sticky.get("_dossier_undo_active"): return b = sc.sticky.get("gestaltung_bridge") @@ -1558,6 +1562,8 @@ def _install_selection_listener(bridge): - Wenn das Objekt eben gerade als Teil eines Drag/Move geloescht wurde, stellen wir die Hatch mit den gemerkten Metadaten wieder her. - Sonst pruefen wir ob die Ebene ein Auto-Fill konfiguriert hat.""" + # Swisstopo-Import importiert tausende Objekte am Stueck — bail. + if sc.sticky.get("dossier_swisstopo_busy"): return if sc.sticky.get("_dossier_undo_active"): return if sc.sticky.get("_elemente_regen_busy"): return obj = args.TheObject diff --git a/rhino/swisstopo.py b/rhino/swisstopo.py new file mode 100644 index 0000000..0abfdc3 --- /dev/null +++ b/rhino/swisstopo.py @@ -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 -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) diff --git a/src/ElementeApp.jsx b/src/ElementeApp.jsx index cb456d7..b0f274c 100644 --- a/src/ElementeApp.jsx +++ b/src/ElementeApp.jsx @@ -6,6 +6,7 @@ import { createFenster, createTuer, createAussparung, createTreppe, createStuetze, createTraeger, createRaum, exportRaeume, + openSwisstopo, openSwisstopoDialog, updateElement, deleteElement, regenerateAllElements, } from './lib/rhinoBridge' @@ -476,6 +477,15 @@ function NeuesElementSection({ noGeschoss, activeName }) { disabled={dis} onClick={() => createRaum({})} /> + + + openSwisstopoDialog()} /> + openSwisstopo('both')} /> + ) } diff --git a/src/SwisstopoApp.jsx b/src/SwisstopoApp.jsx new file mode 100644 index 0000000..6fa3368 --- /dev/null +++ b/src/SwisstopoApp.jsx @@ -0,0 +1,354 @@ +import { useState, useEffect, useRef } from 'react' +import Icon from './components/Icon' +import { onMessage, notifyReady } from './lib/rhinoBridge' + +function send(type, payload = {}) { + if (!window.RHINO_MODE) { console.log('[Swisstopo] →', type, payload); return } + document.title = 'RHINOMSG::' + JSON.stringify({ type, payload }) +} + +function Field({ label, hint, children }) { + return ( +
+ {label} +
{children}
+ {hint && ( + {hint} + )} +
+ ) +} + +function SectionLabel({ children }) { + return ( +
{children}
+ ) +} + +function Radio({ value, options, onChange }) { + return ( +
+ {options.map(o => ( + + ))} +
+ ) +} + +export default function SwisstopoApp() { + const [ebenen, setEbenen] = useState([]) + // Standort + const [searchText, setSearchText] = useState('') + const [center, setCenter] = useState(null) // {e, n, label} + const [searching, setSearching] = useState(false) + // Optionen + const [radius, setRadius] = useState(100) + const [getBuild, setGetBuild] = useState(true) + const [getTerrain, setGetTerrain] = useState(false) + const [getOrtho, setGetOrtho] = useState(false) + const [shift, setShift] = useState(true) + const [autoZoom, setAutoZoom] = useState(true) + const [replaceExisting, setReplaceExisting] = useState(true) + const [clipToBbox, setClipToBbox] = useState(false) + const [terrainRes, setTerrainRes] = useState('2.0') + // Live-Log + const [logs, setLogs] = useState([]) + const [running, setRunning] = useState(false) + const [done, setDone] = useState(false) + const logRef = useRef(null) + + useEffect(() => { + onMessage('SWISSTOPO_STATE', ({ ebenen }) => { + if (Array.isArray(ebenen)) setEbenen(ebenen) + }) + onMessage('GEOCODE_RESULT', ({ result }) => { + setSearching(false) + if (result && result.e != null && result.n != null) { + setCenter({ e: result.e, n: result.n, label: result.label || searchText }) + } else { + setCenter(null) + addLog('Keine Adresse gefunden') + } + }) + onMessage('SWISSTOPO_LOG', ({ msg }) => addLog(msg)) + onMessage('IMPORT_DONE', ({ count }) => { + setRunning(false) + setDone(true) + addLog(`✓ Fertig — ${count} Objekt(e) importiert`) + }) + notifyReady() + const blockContext = (ev) => ev.preventDefault() + document.addEventListener('contextmenu', blockContext) + return () => document.removeEventListener('contextmenu', blockContext) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Auto-Scroll Log + useEffect(() => { + if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight + }, [logs]) + + const addLog = (m) => setLogs(l => [...l, m]) + + const handleSearch = () => { + const t = searchText.trim() + if (!t) return + setSearching(true) + setCenter(null) + addLog(`Suche '${t}'...`) + send('GEOCODE', { text: t }) + } + + const handleManualCoords = (eRaw, nRaw) => { + const e = parseFloat(eRaw), n = parseFloat(nRaw) + if (e > 2000000 && n > 1000000) { + setCenter({ e, n, label: `LV95 manuell` }) + } else { + setCenter(null) + } + } + + const handleImport = () => { + if (!center) { addLog('Bitte zuerst einen Standort wählen'); return } + if (!getBuild && !getTerrain) { addLog('Mindestens Gebäude oder Terrain auswählen'); return } + setLogs([]) + setRunning(true) + setDone(false) + const kinds = [] + if (getBuild) kinds.push('buildings') + if (getTerrain) kinds.push('terrain') + if (getOrtho && getTerrain) kinds.push('ortho') + send('RUN_IMPORT', { + centerE: center.e, + centerN: center.n, + radius: Number(radius), + kinds, + shiftToOrigin: shift, + autoZoom, + replaceExisting, + clipToBbox, + terrainResolution: terrainRes, + }) + } + + return ( +
+
+ + Standort + + + setSearchText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSearch() }} + placeholder="Adresse oder Ortsname" + style={{ flex: 1, fontSize: 11, padding: '5px 8px' }} + /> + + + + + handleManualCoords(e.target.value, center?.n || '')} + style={{ width: 110, fontSize: 11, fontFamily: 'DM Mono, monospace', padding: '5px 8px' }} + /> + / + handleManualCoords(center?.e || '', e.target.value)} + style={{ width: 110, fontSize: 11, fontFamily: 'DM Mono, monospace', padding: '5px 8px' }} + /> + + + {center && ( +
+ +
+
+ {center.label} +
+
+ E {Math.round(center.e)} · N {Math.round(center.n)} +
+
+
+ )} + + Bereich + + + + + + Was holen + + + + + + + + {getTerrain && ( + + + + )} + + + + + Positionierung + + + setShift(v === 'origin')} + /> + + + + + + + + + + + + + + + {(logs.length > 0 || running) && ( + <> + Status +
+ {logs.map((m, i) =>
{m}
)} + {running &&
Läuft…
} +
+ + )} +
+ +
+
+ {center ? `Tiles werden gecacht in ~/Library/Caches/Dossier/swisstopo/` : 'Wähle zuerst einen Standort'} +
+ + +
+
+ ) +} diff --git a/src/lib/rhinoBridge.js b/src/lib/rhinoBridge.js index 0bc7e9e..b4e0a73 100644 --- a/src/lib/rhinoBridge.js +++ b/src/lib/rhinoBridge.js @@ -258,6 +258,9 @@ export function createStuetze(p) { send('CREATE_STUETZE', p || {}) } export function createTraeger(p) { send('CREATE_TRAEGER', p || {}) } export function createRaum(p) { send('CREATE_RAUM', p || {}) } export function exportRaeume() { send('EXPORT_RAEUME', {}) } +export function openSwisstopo(mode) { send('OPEN_SWISSTOPO', { mode: mode || 'both' }) } +export function importSwisstopo(kind) { send('IMPORT_SWISSTOPO', { kind: kind || 'buildings' }) } +export function openSwisstopoDialog() { send('OPEN_SWISSTOPO_DIALOG', {}) } export function updateElement(id, patch) { send('UPDATE_ELEMENT', { id, ...(patch || {}) }) } export function deleteElement(id) { send('DELETE_ELEMENT', { id }) } // Backwards-Compat-Aliases diff --git a/src/main.jsx b/src/main.jsx index 76f3d8b..e520a34 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -9,6 +9,7 @@ import GeschossDialogApp from './GeschossDialogApp.jsx' import LayerCombinationsApp from './LayerCombinationsApp.jsx' import AusschnittSettingsApp from './AusschnittSettingsApp.jsx' import LayoutDialogApp from './LayoutDialogApp.jsx' +import SwisstopoApp from './SwisstopoApp.jsx' import GestaltungApp from './GestaltungApp.jsx' import AusschnitteApp from './AusschnitteApp.jsx' import MassstabApp from './MassstabApp.jsx' @@ -36,6 +37,7 @@ const RootApp = mode === 'gestaltung' ? GestaltungApp : mode === 'layer_combinations' ? LayerCombinationsApp : mode === 'ausschnitt_settings' ? AusschnittSettingsApp : mode === 'layout_dialog' ? LayoutDialogApp + : mode === 'swisstopo' ? SwisstopoApp : App window.onerror = function (msg, src, line, col, err) {