diff --git a/rhino/dimensionen.py b/rhino/dimensionen.py index f5e40f6..bf6b610 100644 --- a/rhino/dimensionen.py +++ b/rhino/dimensionen.py @@ -580,6 +580,10 @@ def _install_listeners(bridge): return def on_idle(s, e): + # Waehrend Bulk-Ops (z.B. _Delete bei 6000 Objekten): nicht pollen. + # tick_idle iteriert alle Doc-Objekte, das ist Overhead bei jedem + # Tick zwischen den einzelnen Deletes. CommandEnd refresht. + if sc.sticky.get("_dossier_bulk_op_active"): return b = sc.sticky.get("dimensionen_bridge") if b is not None: try: b.tick_idle() @@ -588,6 +592,7 @@ def _install_listeners(bridge): def on_select(s, e): # Swisstopo-Import feuert tausende Selection-Events → bail. if sc.sticky.get("dossier_swisstopo_busy"): return + if sc.sticky.get("_dossier_bulk_op_active"): 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 adfc67c..4b8de18 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -4661,6 +4661,7 @@ class ElementeBridge(panel_base.BaseBridge): 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 == "OPEN_OSM_DIALOG": self._cmd_open_osm_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")) @@ -6810,6 +6811,15 @@ class ElementeBridge(panel_base.BaseBridge): before_all = set(o.Id for o in d.Objects if o and not o.IsDeleted) + # Cache-Folder pro Projekt setzen (neben der .3dm-Datei). + # Damit reisen die Tiles mit dem Projekt — bei SMB-Sharing + # findet Rhino die TIFs auch von anderen Maschinen, sofern + # der Mount-Pfad identisch ist. Falls Doc unsaved: globaler + # Cache. + cache_dir = swisstopo.get_cache_dir_for_doc(d) + swisstopo.set_cache_dir(cache_dir) + self._push_log("Cache: {}".format(cache_dir)) + # 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, @@ -6820,8 +6830,11 @@ class ElementeBridge(panel_base.BaseBridge): if "buildings" in kinds: variant = (opts.get("buildVariant") or "separated").strip().lower() if variant not in ("separated", "solid"): variant = "separated" + version = (opts.get("buildVersion") or "v2").strip().lower() + if version not in ("v2", "v3"): version = "v2" paths = swisstopo.fetch_buildings_dwg( - bbox, progress=self._push_log, variant=variant) + bbox, progress=self._push_log, + variant=variant, version=version) for idx, p in enumerate(paths): try: size_mb = os.path.getsize(p) / 1e6 except Exception: size_mb = 0 @@ -6952,16 +6965,18 @@ class ElementeBridge(panel_base.BaseBridge): new_obj_ids.extend(o.Id for o in kept) # --- Terrain (XYZ → Mesh) ------------------------------ - if "terrain" in kinds: + # Terrain-Daten (XYZ + Grid) holen, sobald Mesh ODER + # Hoehenlinien gewuenscht sind — beide nutzen das Grid. + need_dem = any(k in kinds for k in + ("terrain", "contours", "contour_tin", "contour_schicht")) + mesh_objects = [] + merged_grid = None + if need_dem: 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 = [] - # Erst ALLE Tiles in Grids parsen, dann mergen, dann - # EIN Mesh bauen — sonst gibt es einen 1m-Streifen - # ohne Faces zwischen benachbarten Tiles. grids = [] for p in xyz_paths: self._push_log("Parse {}...".format(os.path.basename(p))) @@ -6980,20 +6995,137 @@ class ElementeBridge(panel_base.BaseBridge): if merged is None: self._push_log("Merge lieferte None") else: + merged_grid = merged self._push_log("Merge: {} Tiles → {} Punkte ({}×{} Raster)".format( len(grids), len(merged["points"]), len(merged["es"]), len(merged["ns"]))) - mesh = swisstopo.mesh_from_grid( - merged, - 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, merged["bbox"])) except Exception as ex: - self._push_log("Mesh-Bau fehlgeschlagen: {}".format(ex)) + self._push_log("Grid-Merge fehlgeschlagen: {}".format(ex)) + + # 3D-Mesh bauen wenn Terrain gewuenscht — unabhaengig vom + # Ortho. Wenn Ortho auch an ist: Drape-Mesh liegt ueber + # dem Plain-Mesh (User togglet im Layer-Panel was er + # sehen will). + if "terrain" in kinds and merged_grid is not None: + try: + mesh = swisstopo.mesh_from_grid( + merged_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, merged_grid["bbox"])) + except Exception as ex: + self._push_log("Mesh-Bau fehlgeschlagen: {}".format(ex)) + + # Contours sind die Grundlage fuer drei moegliche Outputs: + # 'contours' → flache 2D-Curves auf OKFF + # 'contour_tin' → TIN-Mesh aus Contour-Vertices + # 'contour_schicht' → Planare Flaechen pro Hoehe + # Wir generieren einmal die echten 3D-Curves und teilen + # sie auf die drei Outputs auf. + contour_kinds = ("contours", "contour_tin", "contour_schicht") + need_contours = any(k in kinds for k in contour_kinds) and merged_grid is not None + raw_contours = [] + if need_contours: + try: + interval_c = float(opts.get("contourInterval") or 2.0) + except Exception: interval_c = 2.0 + try: + self._push_log("Hoehenlinien generieren (Abstand {} m, real Z)...".format(interval_c)) + raw_contours = swisstopo.generate_contour_curves( + merged_grid, origin_shift, m_to_unit, + interval=interval_c, + progress=self._push_log) + except Exception as ex: + self._push_log("Contour-Generation-Fehler: {}".format(ex)) + raw_contours = [] + + # 2D-Hoehenlinien auf OKFF des aktiven Geschosses + if "contours" in kinds and raw_contours: + project_zero_doc = 0.0 if shift else project_zero_mum * m_to_unit + active_okff = 0.0 + try: + z_raw = d.Strings.GetValue("dossier_zeichnungsebenen") + zlist = json.loads(z_raw) if z_raw else [] + for z_ in zlist: + if isinstance(z_, dict) and z_.get("id") == z_id: + active_okff = float(z_.get("okff", 0) or 0) + break + except Exception: pass + flatten_z_doc = project_zero_doc + active_okff * m_to_unit + self._push_log("2D-Hoehenlinien auf OKFF Z={:.3f}...".format(flatten_z_doc)) + contour_objs = [] + for c in raw_contours: + # Wichtig: duplizieren, damit das Original (mit + # echtem Z) fuer TIN/Schichten erhalten bleibt. + try: + c_flat = c.DuplicateCurve() + bb_c = c_flat.GetBoundingBox(True) + z_mid = (bb_c.Min.Z + bb_c.Max.Z) * 0.5 + dz = flatten_z_doc - z_mid + if abs(dz) > 1e-9: + c_flat.Translate(rg.Vector3d(0, 0, dz)) + gid = d.Objects.AddCurve(c_flat) + if gid and gid != System.Guid.Empty: + ob = d.Objects.Find(gid) + if ob: contour_objs.append(ob) + except Exception: pass + if z_id and contour_objs: + self._move_to_sublayer( + d, contour_objs, z_id, "14", + tag="contour", + fallback_name="14_Höhenlinien", + fallback_color="#909050") + elif contour_objs: + self._tag_objects(d, contour_objs, "contour") + self._push_log("→ {} Hoehenlinien (2D) auf '14_Höhenlinien'".format( + len(contour_objs))) + + # TIN-Mesh aus Hoehenlinien + if "contour_tin" in kinds and raw_contours: + try: + tin_obj = swisstopo.generate_mesh_from_contours( + d, raw_contours, + m_to_unit=m_to_unit, + progress=self._push_log) + if tin_obj: + # Tag + auf 80_swisstopo Parent + at = tin_obj.Attributes.Duplicate() + at.SetUserString("dossier_swisstopo_kind", "contour_tin") + d.Objects.ModifyAttributes(tin_obj, at, True) + if z_id: + self._move_to_sublayer( + d, [tin_obj], z_id, "80", + tag="contour_tin", + fallback_name="80_swisstopo", + fallback_color="#909090") + except Exception as ex: + self._push_log("TIN-Mesh-Fehler: {}".format(ex)) + + # Schichtenmodell (planare Flaechen pro Hoehe) + if "contour_schicht" in kinds and raw_contours: + try: + schicht_objs = swisstopo.generate_schichtenmodell( + d, raw_contours, progress=self._push_log) + for s in schicht_objs: + try: + at = s.Attributes.Duplicate() + at.SetUserString("dossier_swisstopo_kind", "contour_schicht") + d.Objects.ModifyAttributes(s, at, True) + except Exception: pass + if z_id and schicht_objs: + self._move_to_sublayer( + d, schicht_objs, z_id, "80", + tag="contour_schicht", + fallback_name="80_swisstopo", + fallback_color="#909090") + self._push_log("→ Schichtenmodell: {} Flaechen auf '80_swisstopo'".format( + len(schicht_objs))) + except Exception as ex: + self._push_log("Schichtenmodell-Fehler: {}".format(ex)) # Layer-Move auf aktive Geschoss/80_swisstopo Sublayer if z_id and mesh_objects: sub_name = _find_ebene_sublayer_name( @@ -7008,47 +7140,29 @@ class ElementeBridge(panel_base.BaseBridge): elif mesh_objects: objs = [m[0] for m in mesh_objects] self._tag_objects(d, objs, "terrain") - if "ortho" in kinds and mesh_objects: + if "ortho" in kinds and merged_grid is not None: self._push_log("Hole Orthofoto...") ortho_paths = swisstopo.fetch_orthophoto( bbox, resolution="2.0", progress=self._push_log) if ortho_paths: - # Max-Z des Terrains finden — Plane sitzt knapp darueber - # damit sie in Top-View ueber dem Terrain liegt. - terr_max_z = 0.0 - for tobj, _ in mesh_objects: - try: - bb = tobj.Geometry.GetBoundingBox(True) - if bb.IsValid and bb.Max.Z > terr_max_z: - terr_max_z = bb.Max.Z - except Exception: pass - z_offset = max(0.001, terr_max_z * 1e-4) # winziges Epsilon - plane_z = terr_max_z + z_offset - self._push_log("→ {} Ortho-Tile(s), platziere Plane bei Z={:.3f}".format( - len(ortho_paths), plane_z)) - # Tile-IDs vorab extrahieren + Sub-Ebenen unter - # 80_swisstopo registrieren BEVOR die Pictures - # erzeugt werden. Damit kann jede _-Picture direkt - # auf dem richtigen Sub-Layer landen statt nach- - # traeglich verschoben zu werden (das brach die - # Textur). - import re as _re_o - tile_id_per_path = {} - for op in ortho_paths: - m = _re_o.search(r"(\d{3,4}-\d{2,4})", - os.path.basename(op)) - if m: tile_id_per_path[op] = m.group(1) - if z_id and tile_id_per_path: + # Pro Tile: + # - Drape-Mesh (Foto folgt Topo) auf '80T_Terrain' + # - flache PictureFrame (fuer 2D-Zeichnen) auf '80L_Luftbild' + self._push_log("→ {} Ortho-Tile(s) als Terrain (Drape) + Luftbild (flach)".format( + len(ortho_paths))) + # Sub-Ebenen Terrain + Luftbild sicherstellen + sub_codes = {} + if z_id: _find_ebene_sublayer_name( d, ["swisstopo", "gelaende_topo"], "80", "swisstopo", default_color="#909090", default_lw=0.18) - self._ensure_ortho_tile_ebenen( - d, list(tile_id_per_path.values())) - # Target-Sub-Layer-Index pro Tile holen + sub_codes = self._ensure_swisstopo_subebenen(d) + # Target-Layer-Indices fuer Terrain + Luftbild import layer_builder as _lb_o - tile_layer_idx = {} - if z_id: + terrain_idx = -1 + luftbild_idx = -1 + if z_id and sub_codes: parent_idx = _lb_o._find_top_by_id(d, z_id) if parent_idx >= 0: parent_id_ = d.Layers[parent_idx].Id @@ -7056,10 +7170,20 @@ class ElementeBridge(panel_base.BaseBridge): d, parent_id_, "80") if base_idx >= 0: base_id_ = d.Layers[base_idx].Id - for op, tid in tile_id_per_path.items(): - idx = _lb_o._find_sublayer_by_code( - d, base_id_, tid) - if idx >= 0: tile_layer_idx[op] = idx + if sub_codes.get("terrain"): + terrain_idx = _lb_o._find_sublayer_by_code( + d, base_id_, sub_codes["terrain"]) + if sub_codes.get("luftbild"): + luftbild_idx = _lb_o._find_sublayer_by_code( + d, base_id_, sub_codes["luftbild"]) + # Max-Z des Terrains fuer flache Luftbild-Plane + terr_max_z_doc = 0.0 + if merged_grid: + try: + max_z_m = max(z for z in merged_grid["points"].values()) + terr_max_z_doc = (max_z_m - origin_shift[2]) * m_to_unit + except Exception: pass + flat_z = terr_max_z_doc + max(0.001, terr_max_z_doc * 1e-4) ortho_objs = [] for ortho_path in ortho_paths: tile_bbox = _parse_swisstopo_tile_bbox( @@ -7068,24 +7192,41 @@ class ElementeBridge(panel_base.BaseBridge): self._push_log(" → Tile-bbox nicht ableitbar aus {}".format( os.path.basename(ortho_path))) continue - tgt_idx = tile_layer_idx.get(ortho_path, -1) + # 1) Drape-Mesh auf '80T_Terrain' try: - obj = swisstopo.add_ortho_plane( - d, ortho_path, tile_bbox, - origin_shift, m_to_unit, z_doc=plane_z, - target_layer_idx=tgt_idx) - if obj: - ortho_objs.append(obj) - # Tag fuer Replace-Detection bei naechstem Import + drape = swisstopo.add_ortho_draped_mesh( + d, ortho_path, tile_bbox, merged_grid, + origin_shift, m_to_unit, + z_lift=0.05, + target_layer_idx=terrain_idx) + if drape: + ortho_objs.append(drape) try: - at = obj.Attributes.Duplicate() + at = drape.Attributes.Duplicate() at.SetUserString( "dossier_swisstopo_kind", "ortho") - d.Objects.ModifyAttributes(obj, at, True) + d.Objects.ModifyAttributes(drape, at, True) except Exception: pass except Exception as ex: - self._push_log("Ortho-Apply: {}".format(ex)) - self._push_log("→ {} Ortho-Plane(s) auf eigene Sub-Layer".format( + self._push_log("Drape-Apply: {}".format(ex)) + # 2) Flache Picture auf '80L_Luftbild' + try: + flat = swisstopo.add_ortho_plane( + d, ortho_path, tile_bbox, + origin_shift, m_to_unit, + z_doc=flat_z, + target_layer_idx=luftbild_idx) + if flat: + ortho_objs.append(flat) + try: + at = flat.Attributes.Duplicate() + at.SetUserString( + "dossier_swisstopo_kind", "ortho") + d.Objects.ModifyAttributes(flat, at, True) + except Exception: pass + except Exception as ex: + self._push_log("Flat-Apply: {}".format(ex)) + self._push_log("→ {} Ortho-Objekte (Drape+Flat) auf eigene Sub-Ebenen".format( len(ortho_objs))) # End-Diagnose mit BBox-Koords damit wir sehen # wo die Pictures tatsaechlich gelandet sind. @@ -7138,6 +7279,77 @@ class ElementeBridge(panel_base.BaseBridge): new_obj_ids.extend(o.Id for o in ortho_objs) new_obj_ids.extend(o.Id for o, _ in mesh_objects) + # --- TLM3D Vektor (Strassen/Wasser/Bahn/Vegetation) --- + if "tlm" in kinds: + tlm_kinds = opts.get("tlmKinds") or [] + if tlm_kinds: + self._push_log("TLM3D Vektor holen ({} Kategorien)...".format( + len(tlm_kinds))) + try: + tlm_paths = swisstopo.fetch_tlm3d_vector( + bbox, tlm_kinds, progress=self._push_log) + except Exception as ex: + self._push_log("TLM Fetch-Fehler: {}".format(ex)) + tlm_paths = {} + # Layer-Mapping: TLM-Kategorie → Dossier-Ebenen-Code + tlm_layer_map = { + "streets": "11", # 11_Strasse (Default-Ebene) + "waterways": "15", # 15_Gewässer (auto-add) + "railways": "16", # 16_Bahn (auto-add) + "landcover": "13", # 13_Bäume (Default-Ebene) + } + tlm_fallback_names = { + "11": "11_Strasse", "13": "13_Bäume", + "15": "15_Gewässer", "16": "16_Bahn", + } + for cat, paths_list in tlm_paths.items(): + for tlm_p in paths_list: + self._push_log("Import TLM {}: {}".format( + cat, os.path.basename(tlm_p))) + before_tlm = set(o.Id for o in d.Objects + if o and not o.IsDeleted) + cmd = '_-Import "{}" _Enter'.format( + tlm_p.replace('"', '\\"')) + try: Rhino.RhinoApp.RunScript(cmd, False) + except Exception as ex: + self._push_log(" Import-Fail: {}".format(ex)) + continue + new_tlm = [o for o in d.Objects + if o and not o.IsDeleted + and o.Id not in before_tlm] + self._push_log(" → {} Objekte".format(len(new_tlm))) + # Auto-Skala falls noetig (gleiche Logik wie Buildings) + if new_tlm and abs(eC) > 1.0: + try: + import math as _m + sx = sum(o.Geometry.GetBoundingBox(True).Center.X + for o in new_tlm[:30]) / min(30, len(new_tlm)) + ratio = (eC * m_to_unit) / sx if sx else 1 + snap = 10 ** round(_m.log10(abs(ratio))) + if abs(snap - 1.0) > 0.01: + self._push_log(" TLM Auto-Skala {}×".format(snap)) + self._apply_xform_fast(d, new_tlm, + scale_factor=snap, + translate=(-origin_shift_doc[0], + -origin_shift_doc[1], 0)) + elif shift: + self._apply_xform_fast(d, new_tlm, + translate=(-origin_shift_doc[0], + -origin_shift_doc[1], 0)) + except Exception as ex: + self._push_log(" TLM Skala/Shift: {}".format(ex)) + # Layer + Tag + code = tlm_layer_map.get(cat) + fallback = tlm_fallback_names.get(code) + if z_id and new_tlm and code: + self._move_to_sublayer(d, new_tlm, z_id, + code, tag="tlm_" + cat, + fallback_name=fallback, + fallback_color="#707080") + elif new_tlm: + self._tag_objects(d, new_tlm, "tlm_" + cat) + new_obj_ids.extend(o.Id for o in new_tlm) + self._push_log("Import fertig: {} neue Objekte".format(len(new_obj_ids))) # Auto-Zoom NOCH IM TRY-Block: sticky-Flag bleibt True @@ -7279,11 +7491,15 @@ class ElementeBridge(panel_base.BaseBridge): except Exception as ex: self._push_log(" ortho-per-tile: {}".format(ex)) - def _ensure_ortho_tile_ebenen(self, doc, tile_ids): - """Registriert jeden Tile als Child unter '80_swisstopo' in - dossier_ebenen JSON, baut Layer einmal synchron, broadcastet - an die UI. Duplikate werden uebersprungen.""" - if not tile_ids: return + def _ensure_swisstopo_subebenen(self, doc): + """Stellt sicher dass 80_swisstopo zwei Children hat: + 'Terrain' (Drape-Mesh — Foto folgt Topographie) und + 'Luftbild' (flache Picture ueber max-Z — fuer 2D-Zeichnen). + Liefert {'terrain': '80T', 'luftbild': '80L'}.""" + CHILD_SPEC = [ + ("80T", "Terrain", "#909090", "terrain"), + ("80L", "Luftbild", "#888888", "luftbild"), + ] raw = doc.Strings.GetValue("dossier_ebenen") try: ebenen = json.loads(raw) if raw else [] except Exception: ebenen = [] @@ -7303,27 +7519,27 @@ class ElementeBridge(panel_base.BaseBridge): have = {c.get("code") for c in parent["children"] if isinstance(c, dict)} changed = False - for tile_id in set(tile_ids): - if tile_id in have: continue - parent["children"].append({ - "code": tile_id, "name": "Ortho", - "color": "#909090", "lw": 0.13, - "visible": True, "locked": False, - }) - changed = True - if not changed: return - try: - doc.Strings.SetString("dossier_ebenen", - json.dumps(ebenen, ensure_ascii=False)) - import layer_builder - z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") - zlist = json.loads(z_raw) if z_raw else [] - if zlist: - layer_builder.build_layers(doc, zlist, ebenen) - import rhinopanel - rhinopanel._broadcast_state(doc) - except Exception as ex: - self._push_log(" ortho-ebenen build: {}".format(ex)) + for ccode, cname, ccol, _key in CHILD_SPEC: + if ccode not in have: + parent["children"].append({ + "code": ccode, "name": cname, "color": ccol, + "lw": 0.13, "visible": True, "locked": False, + }) + changed = True + if changed: + try: + doc.Strings.SetString("dossier_ebenen", + json.dumps(ebenen, ensure_ascii=False)) + import layer_builder + z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") + zlist = json.loads(z_raw) if z_raw else [] + if zlist: + layer_builder.build_layers(doc, zlist, ebenen) + import rhinopanel + rhinopanel._broadcast_state(doc) + except Exception as ex: + self._push_log(" swisstopo-ebenen build: {}".format(ex)) + return {key: ccode for ccode, _n, _col, key in CHILD_SPEC} def _ensure_sub_sublayer(self, doc, parent_id, name, color_hex="#888888", lw=0.25): @@ -7557,6 +7773,218 @@ class ElementeBridge(panel_base.BaseBridge): size=(560, 620), bridge=b) + def _cmd_open_osm_dialog(self, p): + """Oeffnet das OSM-Importer-Satelliten-Fenster mit Overpass-API: + Strassen, Gebaeudeumrisse, Wasser, Gruenflaechen, Wege als 2D-Linien.""" + outer = self + bridge_holder = {"form": None} + + class _OsmBridge(panel_base.BaseBridge): + def __init__(self): + panel_base.BaseBridge.__init__(self, "osm") + def _push_log(self, msg): + try: self.send("OSM_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": + pass # nothing to send initially + elif t == "GEOCODE": + import swisstopo + res = swisstopo.geocode(pp.get("text") or "") + self.send("GEOCODE_RESULT", {"result": res}) + elif t == "RUN_OSM_IMPORT": + self._run_osm_import(pp) + elif t == "CANCEL": + try: + f = bridge_holder.get("form") + if f is not None: f.Close() + except Exception: pass + + def _run_osm_import(self, opts): + d = Rhino.RhinoDoc.ActiveDoc + if d is None: + self._push_log("Kein aktives Doc"); return + try: + import osm, 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 200) + except Exception: + self._push_log("Center/Radius ungueltig"); return + categories = opts.get("categories") or [] + if not categories: + self._push_log("Keine Kategorien gewaehlt"); return + shift = bool(opts.get("shiftToOrigin", True)) + replace_existing = bool(opts.get("replaceExisting", True)) + # Doc-Unit + try: + m_to_unit = Rhino.RhinoMath.UnitScale( + Rhino.UnitSystem.Meters, d.ModelUnitSystem) + except Exception: + m_to_unit = 1.0 + # Projekt-Nullpunkt (z-Offset wie bei swisstopo) + try: + z_raw = d.Strings.GetValue("dossier_project_zero_mum") + project_zero_mum = float(z_raw) if z_raw else 0.0 + except Exception: + project_zero_mum = 0.0 + z_offset_m = project_zero_mum if shift else 0.0 + # bbox in LV95-Metern + WGS84 fuer Overpass + bbox_lv95 = (eC - r, nC - r, eC + r, nC + r) + bbox_wgs = swisstopo.lv95_bbox_to_wgs84_bbox(*bbox_lv95) + self._push_log("Center LV95: E={:.1f} N={:.1f} Radius={}m".format(eC, nC, r)) + self._push_log("BBox WGS84: {:.5f},{:.5f} – {:.5f},{:.5f}".format(*bbox_wgs)) + origin_shift = (eC, nC, z_offset_m) if shift else (0, 0, 0) + z_id = d.Strings.GetValue("dossier_active_id") + + # Listener-Suppression + sc.sticky["dossier_swisstopo_busy"] = True + try: + # Bestehende OSM-Objekte loeschen? + if replace_existing: + self._push_log("Loesche bestehende OSM-Objekte...") + removed = 0 + for obj in list(d.Objects): + if obj is None or obj.IsDeleted: continue + try: + tag = obj.Attributes.GetUserString("dossier_osm_kind") + except Exception: tag = None + if tag: + d.Objects.Delete(obj.Id, True); removed += 1 + self._push_log("→ {} alte OSM-Objekte geloescht".format(removed)) + # Sub-Ebenen-Struktur unter '70_osm' sicherstellen + osm_sub_codes = self._ensure_osm_ebenen(d, categories) + # Layer-Indices ermitteln + cat_layer_idx = {} + if z_id: + parent_idx = layer_builder._find_top_by_id(d, z_id) + if parent_idx >= 0: + parent_id_ = d.Layers[parent_idx].Id + base_idx = layer_builder._find_sublayer_by_code( + d, parent_id_, "70") + if base_idx >= 0: + base_id_ = d.Layers[base_idx].Id + for cat, ccode in osm_sub_codes.items(): + idx = layer_builder._find_sublayer_by_code( + d, base_id_, ccode) + if idx >= 0: cat_layer_idx[cat] = idx + # Import via osm-Modul + self._push_log("Hole OSM-Daten...") + created = osm.import_osm_to_doc( + d, bbox_wgs, categories, + shift_lv95=origin_shift, + m_to_unit=m_to_unit, + z_doc=0.0, + progress=self._push_log) + # Layer-Move + Tag pro Objekt + new_obj_ids = [] + moved_by_cat = {} + for item in created: + cat = item["category"] + obj = item["obj"] + tgt_idx = cat_layer_idx.get(cat, -1) + try: + at = obj.Attributes.Duplicate() + if tgt_idx >= 0: at.LayerIndex = tgt_idx + at.SetUserString("dossier_osm_kind", cat) + d.Objects.ModifyAttributes(obj, at, True) + new_obj_ids.append(obj.Id) + moved_by_cat[cat] = moved_by_cat.get(cat, 0) + 1 + except Exception: pass + for cat, n in moved_by_cat.items(): + if cat in cat_layer_idx: + self._push_log(" → {} {} auf '{}'".format( + n, cat, d.Layers[cat_layer_idx[cat]].FullPath)) + else: + self._push_log(" → {} {} (Layer fallback)".format(n, cat)) + self._push_log("Import fertig: {} OSM-Objekte".format( + len(new_obj_ids))) + # Auto-Zoom + if opts.get("autoZoom") and new_obj_ids: + try: + combined = rg.BoundingBox.Empty + for oid in new_obj_ids: + ob = d.Objects.Find(oid) + if ob is None: continue + bb = ob.Geometry.GetBoundingBox(True) + if bb.IsValid: combined.Union(bb) + 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 _ensure_osm_ebenen(self, doc, categories): + """Stellt sicher dass '70_osm' Parent + Children fuer jede + gewuenschte Kategorie in dossier_ebenen existieren. Liefert + {category_key: code} Map.""" + import osm + raw = doc.Strings.GetValue("dossier_ebenen") + try: ebenen = json.loads(raw) if raw else [] + except Exception: ebenen = [] + if not isinstance(ebenen, list): ebenen = [] + parent = next((e for e in ebenen if isinstance(e, dict) + and e.get("code") == "70"), None) + if parent is None: + parent = { + "code": "70", "name": "osm", + "color": "#707080", "lw": 0.13, + "visible": True, "locked": False, + "children": [], + } + ebenen.append(parent) + if not isinstance(parent.get("children"), list): + parent["children"] = [] + have = {c.get("code") for c in parent["children"] + if isinstance(c, dict)} + code_map = {} + changed = False + for cat_key in categories: + spec = osm.CATEGORIES.get(cat_key) + if not spec: continue + code = spec["code"] + code_map[cat_key] = code + if code in have: continue + parent["children"].append({ + "code": code, "name": spec["name"], + "color": spec["color"], "lw": 0.13, + "visible": True, "locked": False, + }) + changed = True + if changed: + try: + doc.Strings.SetString("dossier_ebenen", + json.dumps(ebenen, ensure_ascii=False)) + z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") + zlist = json.loads(z_raw) if z_raw else [] + if zlist: + import layer_builder + layer_builder.build_layers(doc, zlist, ebenen) + import rhinopanel + rhinopanel._broadcast_state(doc) + except Exception as ex: + self._push_log("osm-ebenen build: {}".format(ex)) + return code_map + + b = _OsmBridge() + bridge_holder["form"] = panel_base.open_satellite_window( + "osm", + title="OSM Importer", + size=(520, 620), + bridge=b) + def _update_wall(self, p): """Properties eines Elements aendern (Wand/Decke/Dach/Oeffnung). Volumen wird anschliessend regeneriert.""" @@ -8547,6 +8975,9 @@ def _on_object_deleted(sender, e): """ # Waehrend Swisstopo-Import: keine DOSSIER-Metas vorhanden, nur Overhead if sc.sticky.get("dossier_swisstopo_busy"): return + # Bulk-Delete (z.B. SelAll + Delete bei 6000 OSM-Curves): pro-Event- + # Arbeit waere reiner Overhead. CommandEnd refresht einmalig. + if sc.sticky.get(_BULK_ACTIVE_KEY): 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. @@ -8794,6 +9225,9 @@ def _on_idle_selection(sender, e): Replace-Event) ausgefuehrt. So vermeiden wir Volume-Flicker waehrend fortlaufenden Gumball-/Move-Operationen — der finale Regen rendert nach Drag-Ende, bis dahin uebernimmt Rhinos Transform die Geometrie.""" + # Waehrend Bulk-Op (z.B. _Delete bei 6000 OSM-Curves): nicht pollen. + # Wuerde sonst pro Idle-Tick alle Objekte iterieren = Quasi-Stall. + if sc.sticky.get(_BULK_ACTIVE_KEY): return b = sc.sticky.get("elemente_bridge") if b is None: return doc = Rhino.RhinoDoc.ActiveDoc @@ -8990,6 +9424,16 @@ _USER_TRANSFORM_CMDS = frozenset(( "Drag", "Gumball", "Orient", "Orient3Pt", "RemapCPlane", "Transform", )) +# Bulk-Operations: User selektiert N Objekte + ausfuehrt die Operation +# einmal. Wir suspenden Redraws + Listener-Arbeit damit das nicht +# pro-Object visuell durchrieselt. Beispiel: SelAll + Delete bei 6000 +# Curves → ohne Suspend dauert das ewig + man sieht jedes Element +# einzeln verschwinden. +_USER_BULK_CMDS = frozenset(( + "Delete", "DeleteSelected", "DeleteSubObject", "Cut", +)) +_BULK_ACTIVE_KEY = "_dossier_bulk_op_active" + # Undo/Redo: Rhino restored Objekte aus dem Undo-Stack → feuert Add/Delete- # Events fuer ALLE betroffenen Objekte. Unsere Handler wuerden fuer jedes # einen Regen queuen → Storm. Wir suppressen die Handler komplett; Undo hat @@ -9076,6 +9520,21 @@ def _on_command_begin(sender, e): if name in _USER_UNDO_CMDS: sc.sticky[_UNDO_ACTIVE_KEY] = name return + # Bulk-Ops (z.B. _Delete mit 6000 Selektion): RedrawEnabled aus + + # Listener bail-out — am Ende einmal redrawn. + if name in _USER_BULK_CMDS: + sc.sticky[_BULK_ACTIVE_KEY] = name + print("[ELEMENTE] Bulk-Op start: '{}' — Listener bail aktiv".format(name)) + try: + sc.sticky["_dossier_bulk_redraw_prev"] = bool(doc.Views.RedrawEnabled) + doc.Views.RedrawEnabled = False + except Exception: pass + return + # Diagnose: andere Commands sehen wir hier vorbeiziehen — wenn _Delete + # einen anderen Namen hat als 'Delete', sehen wir's und koennen den + # frozenset anpassen. + if name and "delete" in name.lower(): + print("[ELEMENTE] CmdBegin '{}' (nicht im Bulk-Set — anpassen?)".format(name)) if name not in _USER_TRANSFORM_CMDS: return sc.sticky[_UT_SNAPSHOT_KEY] = _snapshot_source_positions(doc) sc.sticky[_UT_ACTIVE_KEY] = name @@ -9095,6 +9554,22 @@ def _on_command_begin(sender, e): def _on_command_end(sender, e): + # Bulk-Op fertig: RedrawEnabled zurueck + EINMAL redrawn + selection + # refresh ans Gestaltung-Panel. + if sc.sticky.get(_BULK_ACTIVE_KEY): + sc.sticky[_BULK_ACTIVE_KEY] = None + try: + prev = sc.sticky.pop("_dossier_bulk_redraw_prev", True) + doc = Rhino.RhinoDoc.ActiveDoc + if doc is not None: + doc.Views.RedrawEnabled = prev + doc.Views.Redraw() + except Exception: pass + gb = sc.sticky.get("gestaltung_bridge") + if gb is not None: + try: gb._send_selection() + except Exception: pass + return # Undo/Redo abschliessen: nur Flag clearen, kein Regen + ein Selection- # Refresh fuers Gestaltung-Panel (Listener waren waehrend Undo aus). if sc.sticky.get(_UNDO_ACTIVE_KEY): diff --git a/rhino/gestaltung.py b/rhino/gestaltung.py index 8373ec7..996b24b 100644 --- a/rhino/gestaltung.py +++ b/rhino/gestaltung.py @@ -302,9 +302,20 @@ def _ebene_fill_for_layer(doc, layer): print("[GESTALTUNG] _ebene_fill_for_layer: json-Fehler:", ex) return None if not isinstance(ebenen, list): return None - for e in ebenen: - if not isinstance(e, dict): continue - if e.get("code") != code: continue + # Rekursiv durch Tree — Sub-Ebenen sind in children verschachtelt + def _find_by_code(lst, target): + for e in lst: + if not isinstance(e, dict): continue + if e.get("code") == target: return e + kids = e.get("children") + if isinstance(kids, list) and kids: + hit = _find_by_code(kids, target) + if hit is not None: return hit + return None + found = _find_by_code(ebenen, code) + if found is None: return None + e = found + if True: f = e.get("fill") if not isinstance(f, dict): print("[GESTALTUNG] _ebene_fill_for_layer: Ebene code={} hat KEIN fill-Feld".format(code)) @@ -471,19 +482,26 @@ def refresh_layer_fills(doc): if not isinstance(ebenen, list): return 0 - # Code -> fill-dict fuer schnellen Lookup + # Code -> fill-dict fuer schnellen Lookup. Rekursiv durch Children, damit + # Sub-Ebenen-Schraffuren auch wirken (sonst landen Polygone auf z.B. + # 70_osm/7102_Gebaeudeumrisse nie in der Auto-Fill-Logik). + def _walk_fills(lst, out): + for e in lst: + if not isinstance(e, dict): continue + f = e.get("fill") + if isinstance(f, dict) and f.get("pattern") not in (None, "None"): + out[e.get("code")] = { + "pattern": f.get("pattern"), + "source": f.get("source", "layer"), + "color": f.get("color"), + "scale": float(f.get("scale", 1.0)) if f.get("scale") is not None else 1.0, + "rotation": float(f.get("rotation", 0.0)) if f.get("rotation") is not None else 0.0, + } + kids = e.get("children") + if isinstance(kids, list) and kids: + _walk_fills(kids, out) fill_by_code = {} - for e in ebenen: - if not isinstance(e, dict): continue - f = e.get("fill") - if isinstance(f, dict) and f.get("pattern") not in (None, "None"): - fill_by_code[e.get("code")] = { - "pattern": f.get("pattern"), - "source": f.get("source", "layer"), - "color": f.get("color"), - "scale": float(f.get("scale", 1.0)) if f.get("scale") is not None else 1.0, - "rotation": float(f.get("rotation", 0.0)) if f.get("rotation") is not None else 0.0, - } + _walk_fills(ebenen, fill_by_code) if not fill_by_code: return 0 @@ -1367,23 +1385,38 @@ def _install_selection_listener(bridge): if sc.sticky.get(flag): return + # Selection-Refresh wird via Idle-Event debounced: + # Rhino feuert pro Object-Select/Deselect einzeln. Bei mass-Delete von + # 327 Objekten = 327 refresh-Calls → 327 IPC-Sends in den WebView → + # UI haengt + Command-History wird mit '[GESTALTUNG] sel: n=N' + # zugemuellt. Wir setzen nur ein Dirty-Flag und feuern EINMAL beim + # naechsten Idle-Tick. def refresh(*args): - # Waehrend Move/Rotate/Mirror/Scale schweigen — Rhino oszilliert die - # Selection pro transformiertem Object mehrfach (deselect→delete→add→ - # 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 + sc.sticky["_gestaltung_selection_dirty"] = True + + def on_idle_flush(sender, args): + if not sc.sticky.get("_gestaltung_selection_dirty"): return + 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 + if sc.sticky.get("_dossier_bulk_op_active"): return + sc.sticky["_gestaltung_selection_dirty"] = False b = sc.sticky.get("gestaltung_bridge") if b is not None: try: b._send_selection() except Exception: pass + # Idle-Hook nur einmal aufhaengen (sticky guard) + if not sc.sticky.get("_gestaltung_idle_attached"): + try: + Rhino.RhinoApp.Idle += on_idle_flush + sc.sticky["_gestaltung_idle_attached"] = True + except Exception as ex: + print("[GESTALTUNG] Idle-Hook fail:", ex) + def on_replace(sender, args): """Sync Curve↔Hatch bei Move/Replace: - Curve hat _FILL_KEY (= hatch_id) → Hatch via Hatch.Create neu auf die @@ -1484,6 +1517,12 @@ def _install_selection_listener(bridge): Curve aufraeumen damit beim naechsten Toggle keine Geister-Referenz steht.""" if sc.sticky.get("_dossier_undo_active"): return if sc.sticky.get("_elemente_regen_busy"): return + # Bulk-Delete (SelAll + Delete): pro-Object Hatch-Sync ueberspringen + # — bei 6000 Objekten waere das massive Overhead. Hatch-Verweise + # wuerden zwar nicht aufgeraeumt aber das ist tolerierbar + # (Sticky-Cache laeuft auch ohne Cleanup ab, alte Eintraege bleiben + # nur unsichtbar liegen). + if sc.sticky.get("_dossier_bulk_op_active"): return obj = args.TheObject if obj is None or obj.Id in _processing: return diff --git a/rhino/layer_builder.py b/rhino/layer_builder.py index 1475b92..0166532 100644 --- a/rhino/layer_builder.py +++ b/rhino/layer_builder.py @@ -653,14 +653,33 @@ def cleanup_default_layers(doc): print("[EBENEN] Default-Layer entfernt: {}".format(", ".join(deleted))) +def _find_sublayer_by_code_recursive(doc, parent_id, code): + """Sucht einen Sub-Layer mit `code` unter parent_id — auch tief + verschachtelt (Sub-Sub-Layer mit gleichem Code-Prefix). Liefert + layer_index oder -1.""" + prefix = code + "_" + direct = [] + for i, layer in enumerate(doc.Layers): + if layer is None or layer.IsDeleted: continue + if layer.ParentLayerId == parent_id: + if layer.Name.startswith(prefix): return i + direct.append(layer.Id) + for child_id in direct: + idx = _find_sublayer_by_code_recursive(doc, child_id, code) + if idx >= 0: return idx + return -1 + + def set_active_sublayer(doc, zeichnungsebene_id, code): - """Macht den Sublayer 'code' unter Zeichnungsebene 'zeichnungsebene_id' aktiv.""" + """Macht den Sublayer 'code' unter Zeichnungsebene 'zeichnungsebene_id' + aktiv. Sucht rekursiv durch verschachtelte Sub-Layer (z.B. 70_osm/ + 7101_Strassen liegt zwei Ebenen tief).""" parent_idx = _find_top_by_id(doc, zeichnungsebene_id) if parent_idx < 0: print("[EBENEN] Parent-Layer fuer Zeichnungsebene {} nicht gefunden".format(zeichnungsebene_id)) return parent_id = doc.Layers[parent_idx].Id - sub_idx = _find_sublayer_by_code(doc, parent_id, code) + sub_idx = _find_sublayer_by_code_recursive(doc, parent_id, code) if sub_idx >= 0: doc.Layers.SetCurrentLayerIndex(sub_idx, True) else: diff --git a/rhino/osm.py b/rhino/osm.py new file mode 100644 index 0000000..9b49731 --- /dev/null +++ b/rhino/osm.py @@ -0,0 +1,189 @@ +#! python3 +# -*- coding: utf-8 -*- +""" +OSM-Importer fuer Dossier — holt OpenStreetMap-Daten via Overpass-API als +Polylinien (Strassen, Gebaeudeumrisse, Wasser, Gruenflaechen, Wege). + +Pipeline: + Adresse → bbox (LV95) → bbox (WGS84) → Overpass-Query → + JSON-Response → OSM-Ways → Polylinien (in Doc-Units) → Rhino-Layer + +Koord-Konversion WGS84↔LV95 nutzt swisstopo.wgs84_to_lv95 (LV95 ist die +gemeinsame Basis mit dem swisstopo-Importer). +""" + +import os +import json +import urllib.request +import urllib.parse + +import Rhino +import Rhino.Geometry as rg + +import swisstopo # fuer wgs84_to_lv95 + + +OVERPASS_URL = "https://overpass-api.de/api/interpreter" + + +# --- Kategorien ------------------------------------------------------------ +# Jede Kategorie liefert (Overpass-Selektor, Layer-Code, Layer-Name, Color). +# Codes 7100-7199 reserviert fuer OSM-Sub-Ebenen unter '70_osm'. +CATEGORIES = { + "streets": { + "selector": '[highway~"^(motorway|trunk|primary|secondary|tertiary|residential|unclassified|service|living_street|pedestrian)$"]', + "code": "7101", "name": "Strassen", "color": "#a89070", + }, + "buildings": { + "selector": '[building]', + "code": "7102", "name": "Gebaeudeumrisse", "color": "#888888", + "include_relations": True, + }, + "water": { + "selector": '[natural=water]', + "code": "7103", "name": "Wasser", "color": "#4080a0", + "include_relations": True, + }, + "waterways": { + "selector": '[waterway~"^(river|stream|canal)$"]', + "code": "7104", "name": "Wasserlaeufe", "color": "#4080a0", + }, + "parks": { + "selector": '[leisure~"^(park|garden)$"]', + "code": "7105", "name": "Parks", "color": "#60a070", + "include_relations": True, + }, + "forest": { + "selector": '[landuse~"^(forest|grass|meadow)$"]', + "code": "7106", "name": "Wald_Gruen", "color": "#406050", + "include_relations": True, + }, + "footpaths": { + "selector": '[highway~"^(footway|path|track|cycleway)$"]', + "code": "7107", "name": "Wege", "color": "#806040", + }, +} + + +def build_overpass_query(bbox_wgs, categories): + """Baut die Overpass-QL-Query fuer bbox + ausgewaehlte Kategorien. + bbox_wgs: (min_lon, min_lat, max_lon, max_lat) — WGS84.""" + south = bbox_wgs[1]; west = bbox_wgs[0] + north = bbox_wgs[3]; east = bbox_wgs[2] + bbox_str = "{},{},{},{}".format(south, west, north, east) + parts = [] + for cat in categories: + spec = CATEGORIES.get(cat) + if not spec: continue + parts.append('way{}({});'.format(spec["selector"], bbox_str)) + if spec.get("include_relations"): + parts.append('relation{}({});'.format(spec["selector"], bbox_str)) + body = ''.join(parts) + return '[out:json][timeout:60];({});out body;>;out skel qt;'.format(body) + + +def fetch_overpass(bbox_wgs, categories, progress=None): + """Schickt Overpass-Query, liefert JSON-Dict oder None.""" + q = build_overpass_query(bbox_wgs, categories) + if progress: progress("Overpass-Query ({} Kategorien)...".format(len(categories))) + data = urllib.parse.urlencode({"data": q}).encode("utf-8") + req = urllib.request.Request(OVERPASS_URL, data=data, method="POST", + headers={"User-Agent": "Dossier/OSM-Importer"}) + try: + with urllib.request.urlopen(req, timeout=180) as resp: + text = resp.read().decode("utf-8", errors="ignore") + out = json.loads(text) + if progress: progress("Antwort: {} Elemente".format(len(out.get("elements", [])))) + return out + except Exception as ex: + if progress: progress("Overpass fail: {}".format(ex)) + return None + + +def parse_osm_elements(osm_json): + """Zerlegt OSM-JSON in {nodes: {id: (lon, lat)}, ways: [{id, nodes, tags}]}.""" + if not osm_json: return None + nodes = {} + ways = [] + for el in osm_json.get("elements", []): + t = el.get("type") + if t == "node": + nodes[el["id"]] = (el["lon"], el["lat"]) + elif t == "way": + ways.append({ + "id": el["id"], + "nodes": el.get("nodes", []), + "tags": el.get("tags") or {}, + }) + return {"nodes": nodes, "ways": ways} + + +def classify_way(tags): + """Mappt Way-Tags auf eine Kategorie-Key (oder None falls uninteressant).""" + if not tags: return None + hw = tags.get("highway") + if hw in ("motorway","trunk","primary","secondary","tertiary", + "residential","unclassified","service","living_street","pedestrian"): + return "streets" + if hw in ("footway","path","track","cycleway"): return "footpaths" + if tags.get("building"): return "buildings" + if tags.get("natural") == "water": return "water" + ww = tags.get("waterway") + if ww in ("river","stream","canal"): return "waterways" + if tags.get("leisure") in ("park","garden"): return "parks" + if tags.get("landuse") in ("forest","grass","meadow"): return "forest" + return None + + +def way_to_polyline(way_node_ids, nodes, shift_lv95, m_to_unit, z=0.0): + """OSM-Way → Rhino.Polyline in Doc-Units. shift_lv95 = (sx, sy, sz) Origin- + Shift in LV95-Metern (gleicher Pipeline wie swisstopo).""" + pts = [] + sx, sy, sz = shift_lv95 + for nid in way_node_ids: + node = nodes.get(nid) + if node is None: continue + lon, lat = node + e, n = swisstopo.wgs84_to_lv95(lon, lat) + x = (e - sx) * m_to_unit + y = (n - sy) * m_to_unit + pts.append(rg.Point3d(x, y, z)) + if len(pts) < 2: return None + poly = rg.Polyline(pts) + return poly + + +def import_osm_to_doc(doc, bbox_wgs, categories, shift_lv95, m_to_unit, + z_doc=0.0, progress=None): + """End-to-end-Import: Overpass-Query + Polylinien-Erzeugung. Liefert + Liste von dicts: [{category, obj_id, way_tags}, ...] — Aufrufer macht + Layer-Move + Tag selbst.""" + osm_json = fetch_overpass(bbox_wgs, categories, progress=progress) + if osm_json is None: return [] + parsed = parse_osm_elements(osm_json) + if not parsed: return [] + nodes = parsed["nodes"] + ways = parsed["ways"] + if progress: progress("Parse {} Ways...".format(len(ways))) + created = [] + for way in ways: + cat = classify_way(way["tags"]) + if cat is None or cat not in categories: continue + poly = way_to_polyline(way["nodes"], nodes, shift_lv95, + m_to_unit, z=z_doc) + if poly is None or poly.Count < 2: continue + # Wenn Polyline geschlossen ist (erster == letzter Punkt) → als Curve + # mit Schluss-Edge, sonst offene Polyline. + curve = poly.ToNurbsCurve() + if curve is None: continue + gid = doc.Objects.AddCurve(curve) + if gid is None: continue + obj = doc.Objects.Find(gid) + if obj is None: continue + created.append({ + "category": cat, + "obj": obj, + "tags": way["tags"], + }) + if progress: progress("→ {} OSM-Linien erzeugt".format(len(created))) + return created diff --git a/rhino/rhinopanel.py b/rhino/rhinopanel.py index ff1d78c..b90dbda 100644 --- a/rhino/rhinopanel.py +++ b/rhino/rhinopanel.py @@ -585,12 +585,24 @@ class EbenenBridge(panel_base.BaseBridge): try: e_list = json.loads(e_raw) except Exception as ex: print("[EBENEN] save_ebene JSON:", ex); return - replaced = False - for i, e in enumerate(e_list): - if isinstance(e, dict) and e.get("code") == orig_code: - e_list[i] = updated - replaced = True - break + # Rekursive Suche + Replace durch den Tree — Sub-Ebenen + # (children) liegen verschachtelt, nicht in der Top-Level-Liste. + def _replace_in_tree(lst, target_code, new_data): + for i, e in enumerate(lst): + if not isinstance(e, dict): continue + if e.get("code") == target_code: + kids = e.get("children") + merged = dict(new_data) + if isinstance(kids, list) and "children" not in merged: + merged["children"] = kids + lst[i] = merged + return True + kids = e.get("children") + if isinstance(kids, list): + if _replace_in_tree(kids, target_code, new_data): + return True + return False + replaced = _replace_in_tree(e_list, orig_code, updated) if not replaced: print("[EBENEN] save_ebene: code {} nicht gefunden".format(orig_code)) return @@ -639,25 +651,28 @@ class EbenenBridge(panel_base.BaseBridge): def _fill_signature(e_list): out = {} if not isinstance(e_list, list): return out - for e in e_list: - if not isinstance(e, dict): continue - f = e.get("fill") - if not isinstance(f, dict): continue - if f.get("pattern") in (None, "None"): continue - # lw kann None sein -> als Sentinel ein eindeutiger Wert - lw_raw = f.get("lw") - try: - lw_sig = round(float(lw_raw), 6) if lw_raw is not None else None - except Exception: - lw_sig = None - out[e.get("code")] = ( - f.get("pattern"), - f.get("source", "layer"), - (f.get("color") or "").lower(), - round(float(f.get("scale") or 1.0), 6), - round(float(f.get("rotation") or 0.0), 6), - lw_sig, - ) + def _walk(lst): + for e in lst: + if not isinstance(e, dict): continue + f = e.get("fill") + if isinstance(f, dict) and f.get("pattern") not in (None, "None"): + lw_raw = f.get("lw") + try: + lw_sig = round(float(lw_raw), 6) if lw_raw is not None else None + except Exception: + lw_sig = None + out[e.get("code")] = ( + f.get("pattern"), + f.get("source", "layer"), + (f.get("color") or "").lower(), + round(float(f.get("scale") or 1.0), 6), + round(float(f.get("rotation") or 0.0), 6), + lw_sig, + ) + kids = e.get("children") + if isinstance(kids, list) and kids: + _walk(kids) + _walk(e_list) return out old_e_raw = doc.Strings.GetValue("dossier_ebenen") old_sig = {} diff --git a/rhino/swisstopo.py b/rhino/swisstopo.py index ec403d9..43ea645 100644 --- a/rhino/swisstopo.py +++ b/rhino/swisstopo.py @@ -20,11 +20,37 @@ import Rhino import Rhino.Geometry as rg import System -CACHE_DIR = os.path.expanduser("~/Library/Caches/Dossier/swisstopo") +DEFAULT_CACHE_DIR = os.path.expanduser("~/Library/Caches/Dossier/swisstopo") +CACHE_DIR = DEFAULT_CACHE_DIR STAC_BASE = "https://data.geo.admin.ch/api/stac/v1" SEARCH_API = "https://api3.geo.admin.ch/rest/services/api/SearchServer" +def get_cache_dir_for_doc(doc): + """Cache-Pfad fuer ein Doc. Wenn das Doc auf Disk liegt: Subfolder neben + der .3dm-Datei (`/_swisstopo/`). Damit reisen die Files + mit dem Projekt — kann via SMB von anderen Maschinen geoeffnet werden + solange der Mount-Pfad identisch ist. Falls Doc nicht gespeichert: + globaler Fallback-Cache.""" + try: + p = doc.Path if doc else None + if p and os.path.isfile(p): + doc_dir = os.path.dirname(p) + doc_base = os.path.splitext(os.path.basename(p))[0] + return os.path.join(doc_dir, doc_base + "_swisstopo") + except Exception: pass + return DEFAULT_CACHE_DIR + + +def set_cache_dir(path): + """Stellt das aktive Cache-Verzeichnis. Alle nachfolgenden Downloads + landen dort. Aufrufer-Verantwortung: vor jedem Import den richtigen + Cache setzen (per-Doc oder global).""" + global CACHE_DIR + CACHE_DIR = path + _ensure_cache() + + def _ensure_cache(): if not os.path.isdir(CACHE_DIR): try: os.makedirs(CACHE_DIR) @@ -416,24 +442,86 @@ def _fetch_buildings_from_collection(collection_id, bbox_wgs, variant, 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).""" +def fetch_buildings_dwg(bbox_lv95, progress=None, variant="separated", + version="v2"): + """Holt swissBUILDINGS3D Tile-CAD-Files. + + version='v2': stabile 2.0-Variante (1km-Tiles, keine Solid/Separated- + Aufteilung — alle Kategorien auf eigenen DXF-Layern). + version='v3': Beta 3.0-Variante mit Solid/Separated-Wahl. In Staedten + oft >700 MB pro Tile → auto-fallback auf v2 wenn v3 + nichts brauchbares liefert.""" 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). + if version == "v3": + 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...") + paths = _fetch_buildings_from_collection( + _BUILDINGS_COLLECTION_V2, bbox_wgs, variant, progress=progress) + else: 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 +# --- TLM3D Vektor (Strassen / Gewaesser / Bahn / Vegetation) ---------------- +# swisstopo bietet TLM3D unter mehreren Collection-IDs an (genaue Namen +# variieren). Wir probieren defensiv mehrere Kandidaten und nehmen DXF/DWG +# wenn verfuegbar (alles andere — GPKG/SHP — koennen wir nicht parsen). + +# Echte swisstopo TLM-Collections (verifiziert via STAC API): +# ch.swisstopo.swisstlm3d — voller TLM3D Layer (~ganze CH) +# ch.swisstopo.swisstlmregio — kleinere Auflösung 1:200000 +# ch.swisstopo.swissboundaries3d — Verwaltungsgrenzen +# ch.swisstopo.swiss-map-vector25 — 1:25000 Vektor +# Achtung: ALLE liefern nur GDB/SHP/GPKG/XTF — KEIN DXF/DWG. Direkter Rhino- +# Import funktioniert nicht ohne Shapefile-/GPKG-Parser. +_TLM_COLLECTIONS = [ + "ch.swisstopo.swisstlm3d", + "ch.swisstopo.swisstlmregio", + "ch.swisstopo.swissboundaries3d", +] + + +def fetch_tlm3d_vector(bbox_lv95, kinds, progress=None): + """Versucht swissTLM3D-Daten als DXF/DWG zu holen. swisstopo liefert + aktuell NUR GDB/SHP/GPKG-Formate — kein DXF. Diese Funktion findet + daher in den meisten Faellen keine importierbaren Files; sie loggt + aber sauber, was verfuegbar waere, falls wir spaeter einen + Shapefile-Parser einbauen.""" + bbox_wgs = lv95_bbox_to_wgs84_bbox(*bbox_lv95) + out = {} + if progress: + progress("TLM3D-Import: swisstopo bietet aktuell KEINE DXF-Assets") + progress(" (nur GDB/SHP/GPKG — Rhino kann diese nicht nativ lesen)") + progress(" Verfuegbare Collections (zur Info):") + for coll in _TLM_COLLECTIONS: + try: + items = stac_query(coll, bbox_wgs, + asset_extensions=None) # alle Assets + except Exception as ex: + if progress: progress(" {}: HTTP-fail ({})".format(coll, ex)) + continue + if not items: + if progress: progress(" {}: keine Items in der Region".format(coll)) + continue + sample = items[0] + formats = set() + for k, a in (sample.get("assets") or {}).items(): + href = (a.get("href") or "").lower() + for ext in (".gdb.zip", ".shp.zip", ".gpkg.zip", ".gpkg", + ".xtf.zip", ".dxf", ".dwg"): + if href.endswith(ext): formats.add(ext.lstrip(".")) + if progress: progress(" {}: {} Items, Formate: {}".format( + coll, len(items), ", ".join(sorted(formats)) or "?")) + if progress: + progress("→ TLM3D-Direct-Import nicht moeglich. Nutze OSM-Importer " + "fuer Vector-Daten (Strassen/Wasser/Gebaeude).") + return out + + # --- Terrain: swissALTI3D via XYZ ASCII ------------------------------------- def fetch_terrain_xyz(bbox_lv95, resolution="2.0", progress=None): @@ -640,6 +728,151 @@ def mesh_from_grid(grid, origin_shift=(0, 0, 0), unit_scale=1.0): return mesh +def generate_mesh_from_contours(doc, contour_curves, sample_step_m=2.0, + m_to_unit=1.0, progress=None): + """Baut ein TIN-Mesh aus Hoehenlinien-Curves. Jede Curve hat ihre echte + Z-Hoehe — wir sampeln Vertices entlang der Curves und triangulieren + sie via Rhinos _-MeshPatch / _-Delaunay Command. Resultat: Topographie- + Mesh basierend auf den diskreten Hoehenlinien-Stufen. + + Liefert RhinoObject (Mesh) oder None.""" + import System + if not contour_curves: return None + pts = [] + for c in contour_curves: + if c is None: continue + # Polyline-Vertices wenn moeglich (exakt), sonst entlang Curve sampeln + ok, poly = c.TryGetPolyline() + if ok and poly is not None: + for pt in poly: pts.append(rg.Point3d(pt)) + else: + try: + L = c.GetLength() + n = max(2, int(L / (sample_step_m * m_to_unit))) + params = c.DivideByCount(n, True) + if params: + for t in params: pts.append(c.PointAt(t)) + except Exception: pass + if len(pts) < 3: + if progress: progress("Contour-Mesh: zu wenig Vertices ({})".format(len(pts))) + return None + if progress: progress("Contour-Mesh: trianguliere {} Vertices...".format(len(pts))) + # Temp-Points erzeugen + selektieren + temp_pids = [] + try: + for p in pts: + pid = doc.Objects.AddPoint(p) + if pid and pid != System.Guid.Empty: + temp_pids.append(pid) + if not temp_pids: + if progress: progress("Contour-Mesh: keine Temp-Points") + return None + doc.Objects.UnselectAll() + for pid in temp_pids: doc.Objects.Select(pid) + before = set(o.Id for o in doc.Objects + if o and not o.IsDeleted + and isinstance(o.Geometry, rg.Mesh)) + # Mehrere Commands probieren (Mac Rhino 8 vs neuere Versionen) + cmd_tried = None + for cmd in ['_-MeshPatch _Enter _Enter', + '_-Delaunay _Enter', + '_-DelaunayMesh _Enter', + '_-MeshFromPoints _Enter']: + try: + Rhino.RhinoApp.RunScript(cmd, False) + except Exception: continue + cmd_tried = cmd + new_mesh = next((o for o in doc.Objects + if o and not o.IsDeleted + and isinstance(o.Geometry, rg.Mesh) + and o.Id not in before), None) + if new_mesh: + if progress: progress("→ Contour-Mesh via '{}'".format(cmd.split()[0])) + return new_mesh + if progress: + progress("Contour-Mesh: kein Command lieferte ein Mesh " + "(zuletzt: {})".format(cmd_tried)) + return None + finally: + # Temp-Points wieder weg + doc.Objects.UnselectAll() + for pid in temp_pids: + try: doc.Objects.Delete(pid, True) + except Exception: pass + + +def generate_schichtenmodell(doc, contour_curves, progress=None): + """Schichtenmodell: jede geschlossene Hoehenlinie wird zu einer planaren + Flaeche auf ihrer Z-Hoehe. Stacked Discs — der architektonische + 'Pappmodell'-Look. Offene Konturen (typ. am bbox-Rand) werden + uebersprungen. + + Liefert Liste von erzeugten RhinoObjects.""" + import System + if not contour_curves: return [] + created = [] + tol = doc.ModelAbsoluteTolerance + n_open = 0 + for c in contour_curves: + if c is None: continue + try: + if not c.IsClosed: + n_open += 1 + continue + breps = rg.Brep.CreatePlanarBreps(c, tol) + except Exception: + continue + if not breps: continue + for brep in breps: + gid = doc.Objects.AddBrep(brep) + if gid and gid != System.Guid.Empty: + obj = doc.Objects.Find(gid) + if obj: created.append(obj) + if progress: + progress("→ {} Schichten-Flaechen ({} offene Konturen skipped)".format( + len(created), n_open)) + return created + + +def generate_contour_curves(grid, shift_lv95, m_to_unit, interval=2.0, + progress=None): + """Generiert Hoehenlinien (Contour-Curves) aus dem Terrain-Grid via + Mesh.CreateContourCurves. + + interval: Hoehenabstand in REALEN METERN (1.0/2.0/5.0 typisch). + Liefert Liste von rg.Curve-Objekten in Doc-Units. Caller macht + doc.Objects.AddCurve + Layer-Move.""" + if not grid or not grid.get("points"): return [] + # Temp-Mesh aus Grid (gleicher Pipeline wie mesh_from_grid) + mesh = mesh_from_grid(grid, origin_shift=shift_lv95, unit_scale=m_to_unit) + if mesh.Vertices.Count < 3: return [] + bb = mesh.GetBoundingBox(True) + z_min_doc = bb.Min.Z + z_max_doc = bb.Max.Z + interval_doc = interval * m_to_unit + if interval_doc <= 0: return [] + if progress: + z_min_m = z_min_doc / m_to_unit + shift_lv95[2] + z_max_m = z_max_doc / m_to_unit + shift_lv95[2] + progress("Hoehenlinien: Z {:.1f}–{:.1f} m.ü.M, Abstand {} m".format( + z_min_m, z_max_m, interval)) + try: + curves = rg.Mesh.CreateContourCurves( + mesh, + rg.Point3d(0, 0, z_min_doc), + rg.Point3d(0, 0, z_max_doc), + interval_doc) + except Exception as ex: + if progress: progress("Contour fail: {}".format(ex)) + return [] + if not curves: + if progress: progress("Keine Hoehenlinien erzeugt") + return [] + out = list(curves) + if progress: progress("→ {} Hoehenlinien-Kurven".format(len(out))) + return out + + # --- Orthofoto: SWISSIMAGE 10cm via GeoTIFF -------------------------------- def fetch_orthophoto(bbox_lv95, resolution="2.0", progress=None): @@ -737,6 +970,108 @@ def _geotiff_to_png(tif_path, max_dim=2048): return None +def add_ortho_draped_mesh(doc, ortho_path, tile_bbox_lv95, terrain_grid, + shift_lv95, m_to_unit, z_lift=0.05, + target_layer_idx=-1): + """Erzeugt ein Mesh, das der Topographie folgt — textured mit dem Ortho- + Foto. Statt einer flachen Plane: Per-Tile-Sub-Mesh aus dem Terrain-Grid + mit Per-Vertex-UV (0..1 ueber die Tile-Breite). Material kommt von einem + temporaeren PictureFrame (das ist der einzige Weg auf Mac Rhino 8 die + embedded Bitmap in Cycles zur Anzeige zu bringen) — der PictureFrame + wird hinterher geloescht, nur das Drape-Mesh bleibt. + + terrain_grid: dict aus merge_grids() — wir extrahieren daraus die Punkte + innerhalb der Tile-bbox. + z_lift: kleiner Z-Offset (in doc-units) gegen Z-Fighting mit dem + darunterliegenden Terrain-Mesh.""" + if not (ortho_path and os.path.isfile(ortho_path)): return None + # TIF direkt verwenden — Rhino's _Picture liest GeoTIFF nativ ueber + # NSImage (Mac) und behaelt 10cm-Aufloesung (10000×10000 px statt 2k PNG). + e_min, n_min, e_max, n_max = tile_bbox_lv95 + sx, sy, sz = shift_lv95 + # Terrain-Punkte innerhalb des Tiles aus dem Merged-Grid extrahieren + es = sorted(e for e in terrain_grid["es"] + if e_min - 0.01 <= e <= e_max + 0.01) + ns = sorted(n for n in terrain_grid["ns"] + if n_min - 0.01 <= n <= n_max + 0.01) + if len(es) < 2 or len(ns) < 2: + print("[SWISSTOPO] drape: zu wenig Terrain-Punkte fuer Tile") + return None + pts = terrain_grid["points"] + span_e = e_max - e_min + span_n = n_max - n_min + # Half-Pixel-Inset: bei 10000×10000 px Tiles wuerde ein Sample exakt an + # u=0 oder u=1 auf der Pixel-Grenze landen; mit clamp-to-border kann das + # weisse Linien an den Tile-Boundaries erzeugen. Wir verschieben UV + # minimal nach innen. + UV_INSET = 0.5 / 10000.0 # halbe Pixel-Breite im UV-Raum + 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) * m_to_unit, + (ny - sy) * m_to_unit, + (z - sz) * m_to_unit + z_lift) + u = UV_INSET + (ex - e_min) / span_e * (1.0 - 2 * UV_INSET) + v = UV_INSET + (ny - n_min) / span_n * (1.0 - 2 * UV_INSET) + mesh.TextureCoordinates.Add(u, v) + idx_for[(i, j)] = v_idx + n_faces = 0 + 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) + n_faces += 1 + if n_faces == 0: + print("[SWISSTOPO] drape: keine Faces erzeugt") + return None + mesh.Normals.ComputeNormals() + mesh.Compact() + # Temp-PictureFrame off-screen erzeugen — ergibt working RenderMaterial + # mit Bitmap-Texture, das wir auf das Mesh uebertragen. + # embedBitmap=False: Pfad-Referenz statt 70MB-TIF-Embedding ins .3dm. + # Cache ist persistent (~/Library/Caches), Pfad bleibt gueltig. + pf_plane = rg.Plane(rg.Point3d(-1e6, -1e6, -1e6), + rg.Vector3d.XAxis, rg.Vector3d.YAxis) + try: + pf_gid = doc.Objects.AddPictureFrame( + pf_plane, ortho_path, False, 1.0, 1.0, True, False) + except Exception as ex: + print("[SWISSTOPO] drape: PictureFrame-create fail:", ex) + return None + if not pf_gid or pf_gid == System.Guid.Empty: + print("[SWISSTOPO] drape: PictureFrame Empty-GUID") + return None + pf_obj = doc.Objects.Find(pf_gid) + pf_mat_idx = pf_obj.Attributes.MaterialIndex + # Mesh ins Doc + Material vom PictureFrame uebernehmen + mesh_gid = doc.Objects.AddMesh(mesh) + mesh_obj = doc.Objects.Find(mesh_gid) + if mesh_obj is None: + try: doc.Objects.Delete(pf_gid, True) + except Exception: pass + return None + attrs = mesh_obj.Attributes.Duplicate() + attrs.MaterialSource = Rhino.DocObjects.ObjectMaterialSource.MaterialFromObject + attrs.MaterialIndex = pf_mat_idx + if target_layer_idx >= 0: + attrs.LayerIndex = target_layer_idx + doc.Objects.ModifyAttributes(mesh_obj, attrs, True) + # Temp-PictureFrame loeschen — das Mesh hat jetzt das Material + try: doc.Objects.Delete(pf_gid, True) + except Exception: pass + print("[SWISSTOPO] drape mesh: {}x{} grid, {} faces, mat={}".format( + len(es), len(ns), n_faces, pf_mat_idx)) + return mesh_obj + + def add_ortho_plane(doc, ortho_path, tile_bbox_lv95, shift_lv95, m_to_unit, z_doc=0.0, target_layer_idx=-1): """Erzeugt eine planare Brep-Flaeche mit dem SWISSIMAGE-Foto als Material, @@ -749,11 +1084,8 @@ def add_ortho_plane(doc, ortho_path, tile_bbox_lv95, shift_lv95, m_to_unit, 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 + # TIF direkt — Rhino's Picture-Pfad liest GeoTIFF nativ (NSImage auf Mac). + # Behaelt die volle 10cm-Aufloesung statt auf 2k PNG runter zu skalieren. # bbox in Doc-Units (nach Shift + Scale) e_min, n_min, e_max, n_max = tile_bbox_lv95 sx, sy, sz = shift_lv95 @@ -785,7 +1117,8 @@ def add_ortho_plane(doc, ortho_path, tile_bbox_lv95, shift_lv95, m_to_unit, True, # selfIllumination=True — Textur unabhaengig von # Lighting sichtbar (sonst evtl. dunkel in modes # ohne Lichtquellen) - True) # embedBitmap=True (Pfad-Probleme umgehen) + False) # embedBitmap=False — Pfad-Referenz (Cache bleibt + # persistent, kein 70MB-Embedding pro Tile) if gid == System.Guid.Empty: print("[SWISSTOPO] AddPictureFrame: Empty-GUID") return None diff --git a/src/EbenenSettingsApp.jsx b/src/EbenenSettingsApp.jsx index e0128f3..8f76dda 100644 --- a/src/EbenenSettingsApp.jsx +++ b/src/EbenenSettingsApp.jsx @@ -29,14 +29,45 @@ export default function EbenenSettingsApp() { return () => document.removeEventListener('contextmenu', blockContext) }, []) - const sortedEbenen = [...ebenen].sort((a, b) => { - const ca = parseInt(a.code, 10), cb = parseInt(b.code, 10) - if (!isNaN(ca) && !isNaN(cb)) return ca - cb - return (a.code || '').localeCompare(b.code || '') - }) + // Flach durch Tree iterieren — Sub-Ebenen sind verschachtelt in children + const flattenEbenen = (list, depth = 0) => { + const out = [] + for (const e of list) { + if (!e || typeof e !== 'object') continue + out.push({ ...e, _depth: depth }) + if (Array.isArray(e.children) && e.children.length) { + out.push(...flattenEbenen(e.children, depth + 1)) + } + } + return out + } + const flatEbenen = flattenEbenen(ebenen) - const currentEbene = ebenen.find(e => e.code === selectedCode) - || ebenen.find(e => e.code === originalCode) + // Sort: nur Top-Level (depth=0) numerisch sortieren — Children stehen + // direkt hinter ihrem Parent. Beim Picker zeigt das die Hierarchie. + const sortedEbenen = (() => { + const tops = flatEbenen.filter(e => e._depth === 0) + tops.sort((a, b) => { + const ca = parseInt(a.code, 10), cb = parseInt(b.code, 10) + if (!isNaN(ca) && !isNaN(cb)) return ca - cb + return (a.code || '').localeCompare(b.code || '') + }) + const out = [] + for (const top of tops) { + out.push(top) + // Children direkt anhaengen, ebenfalls per Code sortiert + const kids = flatEbenen.filter(e => + e._depth === 1 && + flatEbenen.find(p => p._depth === 0 && Array.isArray(p.children) + && p.children.some(c => c.code === e.code))?.code === top.code) + kids.sort((a, b) => (a.code || '').localeCompare(b.code || '')) + out.push(...kids) + } + return out + })() + + const currentEbene = flatEbenen.find(e => e.code === selectedCode) + || flatEbenen.find(e => e.code === originalCode) || initial.ebene || null diff --git a/src/ElementeApp.jsx b/src/ElementeApp.jsx index b0f274c..8777a80 100644 --- a/src/ElementeApp.jsx +++ b/src/ElementeApp.jsx @@ -6,7 +6,7 @@ import { createFenster, createTuer, createAussparung, createTreppe, createStuetze, createTraeger, createRaum, exportRaeume, - openSwisstopo, openSwisstopoDialog, + openSwisstopo, openSwisstopoDialog, openOsmDialog, updateElement, deleteElement, regenerateAllElements, } from './lib/rhinoBridge' @@ -478,10 +478,13 @@ function NeuesElementSection({ noGeschoss, activeName }) { onClick={() => createRaum({})} /> - - + openSwisstopoDialog()} /> + openOsmDialog()} /> openSwisstopo('both')} /> diff --git a/src/OsmApp.jsx b/src/OsmApp.jsx new file mode 100644 index 0000000..032d607 --- /dev/null +++ b/src/OsmApp.jsx @@ -0,0 +1,309 @@ +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('[OSM] →', type, payload); return } + document.title = 'RHINOMSG::' + JSON.stringify({ type, payload }) +} + +function Field({ label, hint, children }) { + return ( +
+ {label && {label}} +
{children}
+ {hint && ( + {hint} + )} +
+ ) +} + +function SectionLabel({ children }) { + return ( +
{children}
+ ) +} + +function Radio({ value, options, onChange }) { + return ( +
+ {options.map(o => ( + + ))} +
+ ) +} + +// OSM-Kategorien — Keys matchen das Backend (osm.py CATEGORIES). +const CATEGORIES = [ + { key: 'streets', label: 'Strassen', icon: 'route', + hint: 'Autobahn/Hauptstrasse/Quartierstrasse → Polylinien' }, + { key: 'buildings', label: 'Gebäudeumrisse', icon: 'apartment', + hint: 'building=* Umrisse als geschlossene Polylinien' }, + { key: 'water', label: 'Wasser (Flächen)', icon: 'water', + hint: 'natural=water (Seen, Teiche)' }, + { key: 'waterways', label: 'Wasserläufe', icon: 'waves', + hint: 'waterway=river/stream/canal' }, + { key: 'parks', label: 'Parks', icon: 'park', + hint: 'leisure=park/garden' }, + { key: 'forest', label: 'Wald & Grün', icon: 'forest', + hint: 'landuse=forest/grass/meadow' }, + { key: 'footpaths', label: 'Fuss-/Radwege', icon: 'directions_walk', + hint: 'highway=footway/path/track/cycleway' }, +] + +export default function OsmApp() { + // Standort + const [searchText, setSearchText] = useState('') + const [center, setCenter] = useState(null) + const [searching, setSearching] = useState(false) + // Optionen + const [radius, setRadius] = useState(200) + const [selected, setSelected] = useState({ + streets: true, buildings: true, waterways: true, + parks: true, forest: true, + water: false, footpaths: false, + }) + const [shift, setShift] = useState(true) + const [autoZoom, setAutoZoom] = useState(true) + const [replaceExisting, setReplaceExisting] = useState(true) + // Live-Log + const [logs, setLogs] = useState([]) + const [running, setRunning] = useState(false) + const [done, setDone] = useState(false) + const logRef = useRef(null) + + useEffect(() => { + 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('OSM_LOG', ({ msg }) => addLog(msg)) + onMessage('IMPORT_DONE', ({ count }) => { + setRunning(false); setDone(true) + addLog(`✓ Fertig — ${count} OSM-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 + }, []) + + 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 } + const cats = Object.entries(selected).filter(([, v]) => v).map(([k]) => k) + if (cats.length === 0) { addLog('Mindestens eine Kategorie auswählen'); return } + setLogs([]); setRunning(true); setDone(false) + send('RUN_OSM_IMPORT', { + centerE: center.e, centerN: center.n, + radius: Number(radius), + categories: cats, + shiftToOrigin: shift, + autoZoom, + replaceExisting, + }) + } + + const toggleCat = (key) => { + setSelected(s => ({ ...s, [key]: !s[key] })) + } + + 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 + + + + + Kategorien +
+ {CATEGORIES.map(cat => ( + + ))} +
+ + Positionierung + + setShift(v === 'origin')} /> + + + + + + + + + + + Status +
+ {logs.length === 0 + ? Bereit + : logs.map((l, i) =>
{l}
)} + {running &&
Läuft…
} +
+
+ +
+
+ Quelle: Overpass-API · © OpenStreetMap-Mitwirkende (ODbL) +
+ + +
+
+ ) +} diff --git a/src/SwisstopoApp.jsx b/src/SwisstopoApp.jsx index b8094c8..36eb398 100644 --- a/src/SwisstopoApp.jsx +++ b/src/SwisstopoApp.jsx @@ -57,9 +57,19 @@ export default function SwisstopoApp() { // Optionen const [radius, setRadius] = useState(100) const [getBuild, setGetBuild] = useState(true) + const [buildVersion, setBuildVersion] = useState('v2') // v2 (stabil) / v3 (beta) const [buildVariant, setBuildVariant] = useState('separated') const [getTerrain, setGetTerrain] = useState(false) const [getOrtho, setGetOrtho] = useState(false) + const [getContours, setGetContours] = useState(false) + const [getContourTin,setGetContourTin]= useState(false) + const [getContourSchicht, setGetContourSchicht] = useState(false) + const [contourInt, setContourInt] = useState('2.0') + // TLM3D deaktiviert: swisstopo liefert nur GDB/SHP/GPKG — kein DXF. + // Rhino kann das nicht nativ importieren; OSM-Importer ist die Alternative + // fuer Vektordaten (Strassen/Wasser/Gebaeude). + const getTlm = false + const tlmKinds = {} const [shift, setShift] = useState(true) const [autoZoom, setAutoZoom] = useState(true) const [replaceExisting, setReplaceExisting] = useState(true) @@ -124,14 +134,21 @@ export default function SwisstopoApp() { const handleImport = () => { if (!center) { addLog('Bitte zuerst einen Standort wählen'); return } - if (!getBuild && !getTerrain) { addLog('Mindestens Gebäude oder Terrain auswählen'); return } + if (!getBuild && !getTerrain && !getContours && !getContourTin && !getContourSchicht && !getTlm) { + addLog('Mindestens eine Datenquelle wählen'); return + } setLogs([]) setRunning(true) setDone(false) const kinds = [] - if (getBuild) kinds.push('buildings') - if (getTerrain) kinds.push('terrain') + if (getBuild) kinds.push('buildings') + if (getTerrain) kinds.push('terrain') if (getOrtho && getTerrain) kinds.push('ortho') + if (getContours) kinds.push('contours') + if (getContourTin) kinds.push('contour_tin') + if (getContourSchicht)kinds.push('contour_schicht') + if (getTlm) kinds.push('tlm') + const tlmList = Object.entries(tlmKinds).filter(([, v]) => v).map(([k]) => k) send('RUN_IMPORT', { centerE: center.e, centerN: center.n, @@ -142,7 +159,10 @@ export default function SwisstopoApp() { replaceExisting, clipToBbox, terrainResolution: terrainRes, + buildVersion, buildVariant, + contourInterval: contourInt, + tlmKinds: tlmList, }) } @@ -237,10 +257,23 @@ export default function SwisstopoApp() { {getBuild && ( + + + + )} + {getBuild && buildVersion === 'v3' && ( + + + + + + + + + + {(getContours || getContourTin || getContourSchicht) && ( + + + + )} + Positionierung
- {center ? `Tiles werden gecacht in ~/Library/Caches/Dossier/swisstopo/` : 'Wähle zuerst einen Standort'} + {center ? `Tiles werden im Projekt-Ordner neben der .3dm gecacht (Fallback: ~/Library/Caches/Dossier/swisstopo/ wenn ungespeichert)` : 'Wähle zuerst einen Standort'}