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 (`