From afb59b6626988bd1de110d1af16ff0e8b9922e30 Mon Sep 17 00:00:00 2001 From: karim Date: Tue, 19 May 2026 23:21:45 +0200 Subject: [PATCH] =?UTF-8?q?Swisstopo=20Iter=202=20+=20hierarchische=20Eben?= =?UTF-8?q?en=20+=200-Kote=20m.=C3=BC.M?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swisstopo - swissBUILDINGS3D 3.0 + Variant-Toggle (separated/solid) im Dialog - Auto-Fallback auf 2.0 wenn 3.0-Tiles ueber 200 MB sind (Stadt-Fall) - Defensiver Variant-Filter auf 3 Ebenen (Item, Asset, ZIP-Extract) — keine Doppelimporte mehr - Auto-Skala korrigiert jetzt die importierten Objekte (×1000) statt die User-bbox zu schrumpfen — Buildings bleiben in m-Doc-Skala - merge_grids: XYZ-Tiles werden vor dem Mesh-Bau vereint, kein 1m-Streifen zwischen Tiles mehr - Layer-Konsolidierung: Build_*/Roof_*/Wall_*/Floor_* DWG-Source-Layer werden auf Sub-Sub-Layer unter 81_Swissbuildings/{Build,Roof,Wall,Floor} gemappt; solid-Variante landet flach direkt auf dem Parent - 0-Kote m.ü.M (Projekt-Nullpunkt) wird beim Import als Z-Offset angewandt Hierarchische Ebenen - dossier_ebenen unterstuetzt jetzt 'children'-Array (rekursiv) - layer_builder.build_layers rekursiv (Parent + Children unter jedem Geschoss) - apply_visibility/update_layer_style/set_ebene_visible/set_ebene_locked walken den Tree (Sub-Sub-Layer mit gleichem Code-Prefix werden mit-gepflegt) - EbenenManager mit Chevron-Toggle + Indent pro Level + Context-Menue-Item 'Sub-Ebene hinzufuegen' - rhinoBridge.applyVisibility schickt Children-Tree (nicht nur Top-Level) — sonst kommen Sub-Toggles nicht beim Backend an - Visibility-Key in App.jsx rekursiv durch Children — useEffect feuert jetzt auch bei Sub-Eye-Toggles 0-Kote m.ü.M - Eingabefeld im Geschoss-Settings-Dialog (projektweit) - Speicherung als dossier_project_zero_mum in doc.Strings - Wird im Swisstopo-Import als Z-Offset (m + doc-units) angewandt Co-Authored-By: Claude Opus 4.7 --- rhino/elemente.py | 524 ++++++++++++++++++---- rhino/layer_builder.py | 217 +++++---- rhino/rhinopanel.py | 104 ++++- rhino/swisstopo.py | 308 ++++++++++--- src/App.jsx | 24 +- src/SwisstopoApp.jsx | 17 +- src/components/EbenenManager.jsx | 203 +++++++-- src/components/GeschossManager.jsx | 1 + src/components/GeschossSettingsDialog.jsx | 14 + src/lib/rhinoBridge.js | 17 +- 10 files changed, 1103 insertions(+), 326 deletions(-) diff --git a/rhino/elemente.py b/rhino/elemente.py index 0f18909..f65a886 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -353,18 +353,48 @@ def _find_ebene_sublayer_name(doc, keywords, default_code, default_name, json.dumps(ebenen, ensure_ascii=False)) print("[ELEMENTE] Ebene '{}_{}' automatisch hinzugefuegt".format( default_code, default_name)) - # Ebenen-Manager UI mit-informieren - b = sc.sticky.get("ebenen_bridge_ref") \ - or sc.sticky.get("ebenen_bridge") \ - or sc.sticky.get("rhinopanel_bridge") - if b is not None and hasattr(b, "_send_state"): - try: b._send_state() - except Exception: pass + # build_layers synchron damit Rhino-Layer existieren bevor + # Objekte verschoben werden + try: + 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) + except Exception as ex: + print("[ELEMENTE] build_layers nach auto-add:", ex) + # Ebenen-Manager UI mit-informieren via broadcast_state + try: + import rhinopanel + rhinopanel._broadcast_state(doc) + except Exception as ex: + print("[ELEMENTE] broadcast_state:", ex) except Exception as ex: print("[ELEMENTE] Auto-Add fehler:", ex) return "{}_{}".format(default_code, default_name) +def _parse_swisstopo_tile_bbox(filename): + """Aus einem swisstopo-Filename die LV95-Tile-bbox ableiten. + + Filename-Pattern: + swissimage-dop10_2025_2763-1254_0.1_2056.tif (1km x 1km Tile) + swissimage-dop10_2025_2763-1254-12_0.5_2056.tif (250m Sub-Tile) + SWISSALTI3D_..._2763_1254.xyz (LV95-1km) + + Tile-Coords sind in 100m-Einheiten (E/N x 100). 2763-1254 = LV95 + E=2'763'000, N=1'254'000 → bbox = (2763000, 1254000, 2764000, 1255000). + Liefert (e_min, n_min, e_max, n_max) in Metern oder None.""" + import re as _re + if not filename: return None + # Erst per-1km-Tile probieren: _NNNN-NNNN_ oder _NNNN_NNNN_ + m = _re.search(r"[_-](\d{4})[-_](\d{4})(?:[-_]|\.)", filename) + if not m: return None + e_k = int(m.group(1)); n_k = int(m.group(2)) + e_min = e_k * 1000.0 + n_min = n_k * 1000.0 + return (e_min, n_min, e_min + 1000.0, n_min + 1000.0) + + def _layer_path_axis(doc, geschoss_name): """Wand-Achse + Volumen — Sublayer 'Wände' (Code 20).""" sub = _find_ebene_sublayer_name(doc, ["wand", "wände", "waende"], @@ -6725,13 +6755,26 @@ class ElementeBridge(panel_base.BaseBridge): Rhino.UnitSystem.Meters, d.ModelUnitSystem) except Exception: m_to_unit = 1.0 + # Projekt-Nullpunkt in m.ü.M lesen — wird als Z-Offset + # angewandt damit Real-Welt-Höhen auf Doc-Z relativ zu OKFF=0 + # liegen (sonst zeichnet man Geschosse 400m unter dem Terrain). + try: + z_mum_raw = d.Strings.GetValue("dossier_project_zero_mum") + project_zero_mum = float(z_mum_raw) if z_mum_raw else 0.0 + except Exception: + project_zero_mum = 0.0 eC_u = eC * m_to_unit nC_u = nC * m_to_unit r_u = r * m_to_unit + z_offset_m = project_zero_mum if shift else 0.0 # m + z_offset_u = z_offset_m * m_to_unit # doc-units bbox = (eC - r, nC - r, eC + r, nC + r) # m (fuer STAC-Query) bbox_doc = (eC_u - r_u, nC_u - r_u, eC_u + r_u, nC_u + r_u) # in Doc-Units - origin_shift = (eC, nC, 0) if shift else (0, 0, 0) - origin_shift_doc = (eC_u, nC_u, 0) if shift else (0, 0, 0) + origin_shift = (eC, nC, z_offset_m) if shift else (0, 0, 0) + origin_shift_doc = (eC_u, nC_u, z_offset_u) if shift else (0, 0, 0) + if shift and abs(project_zero_mum) > 1e-6: + self._push_log("Projekt-Nullpunkt: {:g} m.ü.M → Z-Offset {:g}m".format( + project_zero_mum, z_offset_m)) self._push_log("Center LV95: E={:.1f} N={:.1f} Radius={}m".format(eC, nC, r)) self._push_log("BBox (m): {:.0f}-{:.0f} / {:.0f}-{:.0f}".format(*bbox)) if m_to_unit != 1.0: @@ -6775,7 +6818,10 @@ class ElementeBridge(panel_base.BaseBridge): try: # --- Buildings (DWG) ----------------------------------- if "buildings" in kinds: - paths = swisstopo.fetch_buildings_dwg(bbox, progress=self._push_log) + variant = (opts.get("buildVariant") or "separated").strip().lower() + if variant not in ("separated", "solid"): variant = "separated" + paths = swisstopo.fetch_buildings_dwg( + bbox, progress=self._push_log, variant=variant) for idx, p in enumerate(paths): try: size_mb = os.path.getsize(p) / 1e6 except Exception: size_mb = 0 @@ -6792,10 +6838,13 @@ class ElementeBridge(panel_base.BaseBridge): self._push_log("→ Import fertig: {} neue Objekte".format(len(new))) # Auto-Skala-Erkennung: Rhinos DXF-Parser kann je # nach $INSUNITS und Doc-Unit unerwartet 1000x rauf/ - # runter skalieren. Wir messen aus den Objekten und - # SNAPPEN auf naechste Zehnerpotenz (1, 0.001, 1000) - # damit kleine Mess-Streuung nicht eine off-by-1m - # bbox produziert. + # runter skalieren. swissBUILDINGS3D 3.0 z.B. liefert + # Werte in KM (Center bei ~2764, statt 2'763'800m). + # Wir korrigieren das per Scale-Faktor auf den + # importierten Objekten (nicht durch Verkleinern + # der User-bbox — sonst sind die Objekte spaeter + # 1000x zu klein relativ zu allem anderen im Doc). + scale_correction = 1.0 if new and idx == 0: try: import math as _m @@ -6805,19 +6854,14 @@ class ElementeBridge(panel_base.BaseBridge): sum_x += bb.Center.X samples += 1 avg_x = sum_x / max(1, samples) - if abs(eC) > 1.0 and avg_x != 0: - raw_ratio = avg_x / eC - snapped = 10 ** round(_m.log10(abs(raw_ratio))) - if abs(snapped - m_to_unit) > 1e-9: - self._push_log("AUTO-SKALA: raw_ratio={:.6f} → snap to 1m={:g} doc-units (war {:g})".format( - raw_ratio, snapped, m_to_unit)) - m_to_unit = snapped - eC_u = eC * m_to_unit - nC_u = nC * m_to_unit - r_u = r * m_to_unit - bbox_doc = (eC_u - r_u, nC_u - r_u, - eC_u + r_u, nC_u + r_u) - origin_shift_doc = (eC_u, nC_u, 0) if shift else (0, 0, 0) + expected_x = eC * m_to_unit + if abs(expected_x) > 1.0 and avg_x != 0: + ratio = expected_x / avg_x + snap = 10 ** round(_m.log10(abs(ratio))) + if abs(snap - 1.0) > 0.01: + scale_correction = snap + self._push_log("AUTO-SKALA: imports {}× off — scale-up {:g}×".format( + "klein" if snap > 1 else "gross", snap)) except Exception as ex: self._push_log("Auto-Skala-Erkennung: {}".format(ex)) # Diagnose @@ -6868,36 +6912,42 @@ class ElementeBridge(panel_base.BaseBridge): kept = new try: swisstopo._yield_ui() except Exception: pass - # Shift falls aktiviert — Batch via System.List[Guid] - # damit Python.NET den richtigen Overload erwischt. - if shift and kept: - self._push_log("→ Shift {} Objekte zum Welt-Origin (Batch)...".format(len(kept))) - xform = rg.Transform.Translation(-origin_shift_doc[0], - -origin_shift_doc[1], - -origin_shift_doc[2]) - try: - from System.Collections.Generic import List as _List - from System import Guid as _Guid - ids = _List[_Guid]() - for o in kept: ids.Add(o.Id) - n_shifted = d.Objects.Transform(ids, xform, True) - self._push_log(" → {} Objekte verschoben".format(n_shifted)) - except Exception as ex: - self._push_log(" Batch-Shift fehlgeschlagen, Loop-Fallback: {}".format(ex)) - for o in kept: - try: d.Objects.Transform(o.Id, xform, True) - except Exception: pass - # Layer-Move + Tag + # Scale + Move via Rhinos eingebaute Commands auf + # Selektion — die batchen intern und sind bei 7000 + # Objekten in Sekunden durch (statt Minuten mit + # einzeln-Transform-Loop). + translate_doc = None + if shift: + translate_doc = (-origin_shift_doc[0], + -origin_shift_doc[1], + -origin_shift_doc[2]) + ops = [] + if abs(scale_correction - 1.0) > 1e-6: + ops.append("Scale {}×".format(scale_correction)) + if shift: ops.append("Shift→Origin") + if ops and kept: + self._push_log("→ {} ({} Obj)...".format( + " + ".join(ops), len(kept))) + self._apply_xform_fast( + d, kept, + scale_factor=scale_correction, + translate=translate_doc) + # Layer-Konsolidierung: + # 81_Swissbuildings ist hierarchische Ebene mit + # Children Build/Roof/Wall/Floor (codes 8101-8104). + # _consolidate_buildings stellt die Hierarchie in + # dossier_ebenen sicher + verschiebt Objekte auf + # die richtige Child-Layer + loescht leere + # DWG-Source-Layer. Im Ebenen-Manager sind die + # Children dann als Sub-Ebenen sichtbar (aufklappen). if z_id and kept: - self._push_log("→ Layer-Move auf 12_Gebäude...") - sub_name = _find_ebene_sublayer_name( - d, ["gebaeude", "gebäude", "buildings"], - "12", "Gebäude", - default_color="#888888", default_lw=0.25) - self._move_to_sublayer(d, kept, z_id, - sub_name.split("_", 1)[0], tag="buildings") + if variant == "solid": + self._push_log("→ Buildings auf '81_Swissbuildings' (solid)...") + else: + self._push_log("→ Layer konsolidieren (Build/Roof/Wall/Floor)...") + self._consolidate_buildings(d, kept, z_id, + target_code="81", variant=variant) else: - # Kein aktives Geschoss → nur Tag setzen self._tag_objects(d, kept, "buildings") new_obj_ids.extend(o.Id for o in kept) @@ -6909,41 +6959,52 @@ class ElementeBridge(panel_base.BaseBridge): 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("Mesh aus {}...".format(os.path.basename(p))) + self._push_log("Parse {}...".format(os.path.basename(p))) try: - # xyz_to_grid arbeitet in LV95-Metern (Quelle). - # Erst grid bauen, dann beim mesh_from_grid auf - # doc-units skalieren + shiften. grid = swisstopo.xyz_to_grid( p, target_step=target_step, - clip_bbox=bbox, # User-bbox in m! + clip_bbox=bbox, progress=self._push_log) - if grid is None: - self._push_log("→ leeres Grid"); continue - # Mesh in Doc-Units bauen: shift in m (LV95), - # dann beim Vertex-Add * m_to_unit - mesh = swisstopo.mesh_from_grid( - grid, - origin_shift=origin_shift, - unit_scale=m_to_unit) - self._push_log("→ Mesh: {} Vertices / {} Faces".format( - mesh.Vertices.Count, mesh.Faces.Count)) - gid = d.Objects.AddMesh(mesh) - obj = d.Objects.Find(gid) - if obj: mesh_objects.append((obj, grid["bbox"])) + if grid is not None: grids.append(grid) + except Exception as ex: + self._push_log("XYZ-Parse fail: {}".format(ex)) + if grids: + try: + merged = swisstopo.merge_grids(grids) + if merged is None: + self._push_log("Merge lieferte None") + else: + 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)) - # Layer-Move + Ortho-Drape + # Layer-Move auf aktive Geschoss/80_swisstopo Sublayer if z_id and mesh_objects: sub_name = _find_ebene_sublayer_name( - d, ["situation", "terrain", "gelaende"], - "10", "Situation", + d, ["swisstopo", "gelaende_topo"], + "80", "swisstopo", default_color="#909090", default_lw=0.18) objs = [m[0] for m in mesh_objects] self._move_to_sublayer(d, objs, z_id, - sub_name.split("_", 1)[0], tag="terrain") + sub_name.split("_", 1)[0], tag="terrain", + fallback_name=sub_name, + fallback_color="#909090") elif mesh_objects: objs = [m[0] for m in mesh_objects] self._tag_objects(d, objs, "terrain") @@ -6952,23 +7013,50 @@ class ElementeBridge(panel_base.BaseBridge): ortho_paths = swisstopo.fetch_orthophoto( bbox, resolution="2.0", progress=self._push_log) if ortho_paths: - # Erstes Ortho-Tile auf alle Meshes (MVP — pro Mesh - # eigenes Mapping waere genauer, kommt spaeter) - for obj, mbbox in mesh_objects: + # 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: - if shift: - # Mesh ist verschoben → bbox auch - mbbox_shifted = ( - mbbox[0] - origin_shift[0], - mbbox[1] - origin_shift[1], - mbbox[2] - origin_shift[0], - mbbox[3] - origin_shift[1]) - else: - mbbox_shifted = mbbox - swisstopo.apply_ortho_material( - d, obj, ortho_paths[0], mbbox_shifted) + 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)) + ortho_objs = [] + for ortho_path in ortho_paths: + # tile_bbox aus Filename ableiten — swissimage + # tile_id = "1076-33" o.ae. → LV95 Tile-Origin + tile_bbox = _parse_swisstopo_tile_bbox( + os.path.basename(ortho_path)) + if tile_bbox is None: + self._push_log(" → Tile-bbox nicht ableitbar aus {}".format( + os.path.basename(ortho_path))) + continue + try: + obj = swisstopo.add_ortho_plane( + d, ortho_path, tile_bbox, + origin_shift, m_to_unit, z_doc=plane_z) + if obj: ortho_objs.append(obj) except Exception as ex: self._push_log("Ortho-Apply: {}".format(ex)) + self._push_log("→ {} Ortho-Plane(s) erstellt".format(len(ortho_objs))) + # Layer (gleicher Geschoss-Sublayer 80_swisstopo wie Terrain) + if z_id and ortho_objs: + sub_name = _find_ebene_sublayer_name( + d, ["swisstopo", "gelaende_topo"], + "80", "swisstopo", + default_color="#909090", default_lw=0.18) + self._move_to_sublayer(d, ortho_objs, z_id, + sub_name.split("_", 1)[0], tag="ortho", + fallback_name=sub_name, + fallback_color="#909090") + elif ortho_objs: + self._tag_objects(d, ortho_objs, "ortho") + new_obj_ids.extend(o.Id for o in ortho_objs) new_obj_ids.extend(o.Id for o, _ in mesh_objects) self._push_log("Import fertig: {} neue Objekte".format(len(new_obj_ids))) @@ -6999,6 +7087,57 @@ class ElementeBridge(panel_base.BaseBridge): finally: sc.sticky["dossier_swisstopo_busy"] = False + def _apply_xform_fast(self, doc, objs, scale_factor=1.0, + translate=None): + """Scale+Move via Rhinos eingebaute _-Scale/_-Move Commands + auf einer Selektion. Die sind C++-intern hochoptimiert und + deutlich schneller als RhinoCommon API-Calls — bei 7000+ + Objekten Sekunden statt Minuten. + + Scale-Syntax: 3-Punkt-Form `_-Scale base ref target` mit + ref=1 Einheit, target=N Einheiten → Faktor N eindeutig. + Move-Syntax: 2-Punkt-Form `_-Move base target`.""" + if not objs: return True + need_scale = abs(scale_factor - 1.0) > 1e-6 + need_move = translate is not None and any( + abs(v) > 1e-9 for v in translate) + if not (need_scale or need_move): return True + try: + # Selektion via Batch-Select + doc.Objects.UnselectAll() + from System.Collections.Generic import List as _List + from System import Guid as _Guid + sel = _List[_Guid]() + for o in objs: sel.Add(o.Id) + try: n_sel = doc.Objects.Select(sel, True) + except Exception: + n_sel = 0 + for o in objs: + if doc.Objects.Select(o.Id, True): n_sel += 1 + self._push_log(" {} Obj selektiert".format(n_sel)) + # Scale: 3-Punkt + if need_scale: + cmd = "_-Scale 0,0,0 1,0,0 {:.0f},0,0 _Enter".format( + scale_factor) + ok = Rhino.RhinoApp.RunScript(cmd, False) + self._push_log(" _-Scale {:g}× → {}".format( + scale_factor, ok)) + # Move: 2-Punkt + if need_move: + dx, dy, dz = translate + cmd = "_-Move 0,0,0 {:.6f},{:.6f},{:.6f} _Enter".format( + dx, dy, dz) + ok = Rhino.RhinoApp.RunScript(cmd, False) + self._push_log(" _-Move {} → {}".format( + (round(dx), round(dy), round(dz)), ok)) + doc.Objects.UnselectAll() + return True + except Exception as ex: + self._push_log(" _apply_xform_fast: {}".format(ex)) + try: doc.Objects.UnselectAll() + except Exception: pass + return False + def _tag_objects(self, doc, objs, tag): """Setzt nur den dossier_swisstopo_kind UserString — fuer den Fall dass kein Geschoss aktiv ist und wir den Layer-Move @@ -7011,11 +7150,201 @@ class ElementeBridge(panel_base.BaseBridge): doc.Objects.ModifyAttributes(o, attrs, True) except Exception: pass - def _move_to_sublayer(self, doc, objs, z_id, code, tag=None): + def _ensure_sub_sublayer(self, doc, parent_id, name, + color_hex="#888888", lw=0.25): + """Findet oder erstellt einen Sub-Layer mit Name direkt + unter parent_id. Liefert layer_index oder -1.""" + try: + import System.Drawing as SD + for i in range(doc.Layers.Count): + lay = doc.Layers[i] + if lay is None or lay.IsDeleted: continue + if lay.ParentLayerId == parent_id and lay.Name == name: + return i + new_lay = Rhino.DocObjects.Layer() + new_lay.Name = name + new_lay.ParentLayerId = parent_id + try: + h = color_hex.lstrip("#") + r = int(h[0:2], 16); g = int(h[2:4], 16); b = int(h[4:6], 16) + new_lay.Color = SD.Color.FromArgb(255, r, g, b) + except Exception: pass + try: new_lay.PlotWeight = float(lw) + except Exception: pass + return doc.Layers.Add(new_lay) + except Exception as ex: + self._push_log("ensure_sub_sublayer: {}".format(ex)) + return -1 + + def _ensure_swissbuildings_ebene(self, doc, with_children=True): + """Stellt sicher dass 81_Swissbuildings in dossier_ebenen + existiert. Bei with_children=True (separated-Variante) auch + die vier Children Build/Roof/Wall/Floor; bei False (solid) + bleibt sie ein flacher Layer ohne Sub-Aufteilung. + Triggert build_layers synchron, damit die Rhino-Layer real + existieren bevor wir Objekte verschieben. + Liefert {build,roof,wall,floor} → Sub-Sub-Layer-Code wenn + with_children=True, sonst {}.""" + CHILD_SPEC = [ + ("8101", "Build", "#888888", "build"), + ("8102", "Roof", "#a64d4d", "roof"), + ("8103", "Wall", "#666666", "wall"), + ("8104", "Floor", "#555555", "floor"), + ] + raw = doc.Strings.GetValue("dossier_ebenen") + try: ebenen = json.loads(raw) if raw else [] + except Exception: ebenen = [] + if not isinstance(ebenen, list): ebenen = [] + sb = next((e for e in ebenen if isinstance(e, dict) + and e.get("code") == "81"), None) + changed = False + if sb is None: + sb = { + "code": "81", "name": "Swissbuildings", + "color": "#888888", "lw": 0.25, + "visible": True, "locked": False, + "children": [], + } + ebenen.append(sb) + changed = True + if with_children: + if not isinstance(sb.get("children"), list): + sb["children"] = [] + changed = True + have_codes = {c.get("code") for c in sb["children"] + if isinstance(c, dict)} + for ccode, cname, ccol, _key in CHILD_SPEC: + if ccode not in have_codes: + sb["children"].append({ + "code": ccode, "name": cname, "color": ccol, + "lw": 0.25, "visible": True, "locked": False, + }) + changed = True + if changed: + try: + doc.Strings.SetString("dossier_ebenen", + json.dumps(ebenen, ensure_ascii=False)) + except Exception as ex: + self._push_log("save dossier_ebenen: {}".format(ex)) + # Layers synchron erzeugen + try: + 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) + except Exception as ex: + self._push_log("build_layers: {}".format(ex)) + # UI informieren — broadcast_state schickt STATE_SYNC an + # ebenen_bridge_ref + zeichnungsebenen_bridge_ref + try: + import rhinopanel + rhinopanel._broadcast_state(doc) + except Exception as ex: + self._push_log("broadcast_state: {}".format(ex)) + if not with_children: return {} + return {key: ccode for ccode, _n, _col, key in CHILD_SPEC} + + def _consolidate_buildings(self, doc, objs, z_id, + target_code="81", + target_name="Swissbuildings", + variant="separated"): + """Verschiebt Buildings auf den 81_Swissbuildings-Layer. + - separated: Sub-Sub-Layer Build/Roof/Wall/Floor basierend + auf dem DWG-Source-Layer-Prefix. + - solid: alles direkt auf den Parent-Sublayer (keine Children). + Loescht leere DWG-Source-Layer am Ende.""" + if not objs: return + solid = (variant == "solid") + try: + import layer_builder + # Ebene + Children (bei separated) sicherstellen + bauen + child_codes = self._ensure_swissbuildings_ebene( + doc, with_children=not solid) + parent_idx = layer_builder._find_top_by_id(doc, z_id) + if parent_idx < 0: + self._push_log(" Geschoss nicht gefunden"); return + parent_id = doc.Layers[parent_idx].Id + base_idx = layer_builder._find_sublayer_by_code( + doc, parent_id, target_code) + if base_idx < 0: + self._push_log(" 81_Swissbuildings nicht im aktiven Geschoss") + return + base_id = doc.Layers[base_idx].Id + # Target-Mapping + if solid: + # Alle Objekte landen direkt auf base_idx + target = {"all": base_idx} + else: + target = {} + for key, ccode in child_codes.items(): + idx = layer_builder._find_sublayer_by_code( + doc, base_id, ccode) + if idx >= 0: target[key] = idx + if not target: + self._push_log(" Children-Layer fehlen — Build_layers nicht durchgelaufen?") + return + # Objekte umlayern + source_indices = set() + counts = {k: 0 for k in target} + for o in objs: + try: + src_idx = o.Attributes.LayerIndex + source_indices.add(src_idx) + if solid: + tgt_idx = target["all"] + counts["all"] += 1 + else: + src_name = doc.Layers[src_idx].Name.lower() + tgt_idx = None + for key in ("roof", "wall", "floor", "build"): + if src_name.startswith(key): + tgt_idx = target.get(key) + if tgt_idx is not None: counts[key] += 1 + break + if tgt_idx is None: + tgt_idx = target.get("build") + if tgt_idx is None: continue + attrs = o.Attributes.Duplicate() + attrs.LayerIndex = tgt_idx + attrs.SetUserString("dossier_swisstopo_kind", + "buildings") + doc.Objects.ModifyAttributes(o, attrs, True) + except Exception: pass + for key, n in counts.items(): + if n > 0: + self._push_log(" → {} Obj auf '{}'".format( + n, doc.Layers[target[key]].FullPath)) + # Leere DWG-Source-Layer loeschen (descending index) + target_set = set(target.values()) + deleted = 0 + for src_idx in sorted(source_indices, reverse=True): + if src_idx in target_set: continue + try: + lay = doc.Layers[src_idx] + if lay is None or lay.IsDeleted: continue + has = False + for o in doc.Objects: + if o and not o.IsDeleted \ + and o.Attributes.LayerIndex == src_idx: + has = True; break + if not has: + if doc.Layers.Delete(src_idx, True): deleted += 1 + except Exception: pass + if deleted: + self._push_log(" {} leere Source-Layer geloescht".format(deleted)) + except Exception as ex: + self._push_log("Konsolidieren: {}".format(ex)) + + def _move_to_sublayer(self, doc, objs, z_id, code, tag=None, + fallback_name=None, fallback_color="#888888"): """Verschiebt Liste von Rhino-Objekten auf den DOSSIER-Sublayer /_*. Optional: Tag (UserString dossier_swisstopo_kind) setzen — wird beim naechsten Import - erkannt + ggf. geloescht.""" + erkannt + ggf. geloescht. + fallback_name: wenn Sublayer noch nicht existiert (Ebene wurde + gerade erst angelegt, build_layers noch nicht gelaufen), wird + er hiermit erzeugt — sonst landen Objekte gar nirgends.""" if not objs: return try: import layer_builder @@ -7023,6 +7352,15 @@ class ElementeBridge(panel_base.BaseBridge): if parent_idx < 0: return parent_id = doc.Layers[parent_idx].Id sub_idx = layer_builder._find_sublayer_by_code(doc, parent_id, code) + if sub_idx < 0 and fallback_name: + sub_idx = self._ensure_sub_sublayer( + doc, parent_id, fallback_name, + color_hex=fallback_color) + if sub_idx >= 0: + try: + doc.Layers[sub_idx].SetUserString( + "dossier_code", code) + except Exception: pass if sub_idx < 0: return n = 0 for o in objs: diff --git a/rhino/layer_builder.py b/rhino/layer_builder.py index 8db8385..1475b92 100644 --- a/rhino/layer_builder.py +++ b/rhino/layer_builder.py @@ -287,19 +287,84 @@ def _apply_section_style(doc, layer, section_cfg, layer_color): print(diag, "OK applied") +def walk_ebenen(ebenen, parent_path=()): + """Iteriert Ebenen-Baum (flach + Children). Liefert Tuples + (path, ebene) wobei path ein Tuple der Codes von der Root bis zu dieser + Ebene ist (inkl. eigener Code). Beispiel: + walk_ebenen([{'code':'20','children':[{'code':'01'}]}]) + → [(('20',), e20), (('20','01'), e01)]""" + out = [] + if not ebenen: return out + for e in ebenen: + if not isinstance(e, dict): continue + code = e.get("code") + if not code: continue + path = parent_path + (code,) + out.append((path, e)) + children = e.get("children") + if isinstance(children, list) and children: + out.extend(walk_ebenen(children, path)) + return out + + +def _build_ebene_layer(doc, parent_id, e, diag_prefix=""): + """Findet/erstellt einen Sublayer fuer eine Ebene unter parent_id. + Liefert den layer_idx oder -1. Setzt Farbe/LW/Section-Style.""" + code = e.get("code") or "" + name = e.get("name") or "Ebene" + sub_name = "{}_{}".format(code, name) if code else name + col = _color(e.get("color")) + lw = float(e.get("lw", 0.13)) + sub_idx = _find_sublayer_by_code(doc, parent_id, code) if code else -1 + if sub_idx < 0: + sub_idx = _add_layer(doc, sub_name, parent_id, col, lw) + if sub_idx >= 0 and code: + doc.Layers[sub_idx].SetUserString("dossier_code", code) + else: + sub = doc.Layers[sub_idx] + if sub.Name != sub_name: sub.Name = sub_name + sub.Color = col + try: + import massstab as _ms + _ms.write_plotweight(doc, sub, float(lw)) + except Exception: + sub.PlotWeight = lw + if code: sub.SetUserString("dossier_code", code) + # Section Style anwenden (Py3-only — IPy 2.7 no-op) + try: + _apply_section_style(doc, doc.Layers[sub_idx], + e.get("section"), e.get("color")) + except Exception as ex: + print("[EBENEN] section-style apply ({}{}): {}".format( + diag_prefix, sub_name, ex)) + return sub_idx + + +def _build_ebenen_recursive(doc, parent_id, ebenen, diag_prefix=""): + """Rekursive Ebenen-Erstellung: jeder Eintrag wird als Sublayer angelegt, + seine 'children' werden unter dem neu erstellten Sublayer angelegt.""" + if not ebenen: return + for e in ebenen: + if not isinstance(e, dict): continue + sub_idx = _build_ebene_layer(doc, parent_id, e, diag_prefix=diag_prefix) + if sub_idx < 0: continue + children = e.get("children") + if isinstance(children, list) and children: + child_parent_id = doc.Layers[sub_idx].Id + _build_ebenen_recursive(doc, child_parent_id, children, + diag_prefix=diag_prefix + e.get("name", "") + "/") + + def build_layers(doc, zeichnungsebenen, ebenen): - """ - Stellt sicher dass fuer jede Zeichnungsebene ein Parent-Layer existiert - und unter jedem alle Ebenen als Sublayer angelegt/aktualisiert sind. - """ + """Stellt sicher dass fuer jede Zeichnungsebene ein Parent-Layer existiert + und unter jedem alle Ebenen (rekursiv inkl. children) als Sublayer angelegt + / aktualisiert sind.""" for z in zeichnungsebenen: z_id = z["id"] z_name = z["name"] - # Parent finden oder anlegen idx = _find_top_by_id(doc, z_id) - if idx < 0: - idx = _find_top_by_name(doc, z_name) + if idx < 0: idx = _find_top_by_name(doc, z_name) if idx < 0: idx = _add_layer(doc, z_name) doc.Layers[idx].SetUserString("dossier_id", z_id) @@ -308,78 +373,53 @@ def build_layers(doc, zeichnungsebenen, ebenen): if parent.Name != z_name: parent.Name = z_name parent.SetUserString("dossier_id", z_id) - parent_id = doc.Layers[idx].Id - - # Sublayer pro Ebene - for e in ebenen: - sub_name = "{}_{}".format(e["code"], e["name"]) - col = _color(e.get("color")) - lw = float(e.get("lw", 0.13)) - sub_idx = _find_sublayer_by_code(doc, parent_id, e["code"]) - if sub_idx < 0: - sub_idx = _add_layer(doc, sub_name, parent_id, col, lw) - doc.Layers[sub_idx].SetUserString("dossier_code", e["code"]) - else: - sub = doc.Layers[sub_idx] - if sub.Name != sub_name: - sub.Name = sub_name - sub.Color = col - try: - import massstab as _ms - _ms.write_plotweight(doc, sub, float(lw)) - except Exception: - sub.PlotWeight = lw - sub.SetUserString("dossier_code", e["code"]) - - # Section Style anwenden (Py3-only — IPy 2.7 no-op) - try: - _apply_section_style(doc, doc.Layers[sub_idx], - e.get("section"), e.get("color")) - except Exception as ex: - print("[EBENEN] section-style apply ({}): {}".format(sub_name, ex)) - + _build_ebenen_recursive(doc, parent_id, ebenen, + diag_prefix=z_name + "/") doc.Views.Redraw() - print("[EBENEN] {} Zeichnungsebenen x {} Ebenen aktualisiert".format( - len(zeichnungsebenen), len(ebenen))) + n_total = len(walk_ebenen(ebenen)) + print("[EBENEN] {} Zeichnungsebenen x {} Ebenen aktualisiert (inkl. {} Sub)".format( + len(zeichnungsebenen), len(ebenen), max(0, n_total - len(ebenen)))) + + +def _layer_matches_code(layer, code): + """True wenn der Layer zu der Ebene mit `code` gehoert. Akzeptiert + sowohl Top-Sub-Layer (Geschoss/CODE_Name) als auch Sub-Sub-Layer + (Geschoss/Parent/CODE_Name) — Match via Name-Prefix `code_`.""" + if _is_top_level(layer): return False + return layer.Name.startswith(code + "_") def update_layer_style(doc, code, color_hex=None, lw=None): - """Aendert Farbe und/oder Stiftdicke fuer alle Sublayer mit dem gegebenen Code.""" + """Aendert Farbe und/oder Stiftdicke fuer alle Sublayer mit dem gegebenen + Code — auch tief verschachtelte (Sub-Sub-Layer mit gleichem Code-Prefix).""" col = _color(color_hex) if color_hex else None try: import massstab as _ms except Exception: _ms = None - for i, layer in enumerate(doc.Layers): - if _is_top_level(layer): - continue - if layer.Name.startswith(code + "_"): - if col is not None: - layer.Color = col - if lw is not None: - if _ms is not None: - _ms.write_plotweight(doc, layer, float(lw)) - else: - layer.PlotWeight = float(lw) + for layer in doc.Layers: + if not _layer_matches_code(layer, code): continue + if col is not None: layer.Color = col + if lw is not None: + if _ms is not None: + _ms.write_plotweight(doc, layer, float(lw)) + else: + layer.PlotWeight = float(lw) doc.Views.Redraw() def set_ebene_visible(doc, code, visible): - """Schaltet alle Sublayer mit Code in/aus Zeichnungsebenen.""" - for i, layer in enumerate(doc.Layers): - if _is_top_level(layer): - continue - if layer.Name.startswith(code + "_"): + """Schaltet alle Sublayer mit Code in/aus (auch tief verschachtelte).""" + for layer in doc.Layers: + if _layer_matches_code(layer, code): layer.IsVisible = visible doc.Views.Redraw() def set_ebene_locked(doc, code, locked): - for i, layer in enumerate(doc.Layers): - if _is_top_level(layer): - continue - if layer.Name.startswith(code + "_"): + for layer in doc.Layers: + if _layer_matches_code(layer, code): layer.IsLocked = locked doc.Views.Redraw() @@ -631,10 +671,16 @@ def apply_visibility(doc, zeichnungsebenen, ebenen, active_z_id, active_code, z_ """ Kombinierte Sichtbarkeit aus Z-Mode (Zeichnungsebenen) und E-Mode (Ebenen). Beide Modi: 'all' | 'active' | 'grey' | 'grey_locked' + + Versteht den hierarchischen Ebenen-Baum: Children erben ParentLayerId vom + Sub-Layer (nicht vom Geschoss). Sub-Sub-Layer werden rekursiv mitgepflegt. """ - canonical = {e["code"]: _color(e.get("color")) for e in ebenen} - e_eye_vis = {e["code"]: e.get("visible", True) for e in ebenen} - e_eye_locked = {e["code"]: e.get("locked", False) for e in ebenen} + # Flat walk durch Ebenen-Tree (top + children) — alle Codes mit ihren + # Eye/Lock-Flags. + flat_ebenen = [e for _path, e in walk_ebenen(ebenen)] + canonical = {e["code"]: _color(e.get("color")) for e in flat_ebenen} + e_eye_vis = {e["code"]: e.get("visible", True) for e in flat_ebenen} + e_eye_locked = {e["code"]: e.get("locked", False) for e in flat_ebenen} id_to_top, name_to_top, children_by_parent = {}, {}, {} for layer in doc.Layers: @@ -693,17 +739,15 @@ def apply_visibility(doc, zeichnungsebenen, ebenen, active_z_id, active_code, z_ if not p_vis: continue # Children erben Parent-Hidden - # E-Mode -> Sublayer-Zustand - for child in children: - if "_" not in child.Name: - continue + # E-Mode → Sub-Layer (rekursiv durch Tree; Sub-Sub-Layer haben Parent + # = Sub-Layer, nicht das Geschoss — also iterativ in die Tiefe). + def _apply_to_sublayer(child, p_grey_eff): + if "_" not in child.Name: return code = child.Name.split("_", 1)[0] - if code not in canonical: - continue + if code not in canonical: return is_active_e = (code == active_code) eye_v = e_eye_vis.get(code, True) eye_l = e_eye_locked.get(code, False) - if is_active_e: e_vis, e_grey, e_lock = True, False, False elif e_mode == "active": @@ -716,35 +760,28 @@ def apply_visibility(doc, zeichnungsebenen, ebenen, active_z_id, active_code, z_ e_vis, e_grey, e_lock = True, True, True else: # grey e_vis, e_grey, e_lock = True, True, False - - # Kombination child_vis = e_vis - child_grey = p_grey or e_grey + child_grey = p_grey_eff or e_grey child_lock = e_lock or eye_l - changed = False if child.IsVisible != child_vis: - child.IsVisible = child_vis - changed = True + child.IsVisible = child_vis; changed = True if child.IsLocked != child_lock: - child.IsLocked = child_lock - changed = True + child.IsLocked = child_lock; changed = True if child_grey: if child.Color != GREY: - child.Color = GREY - changed = True + child.Color = GREY; changed = True else: canon = canonical.get(code) if canon is not None and child.Color != canon: - child.Color = canon - changed = True - # In neueren Rhino-Versionen committed der Property-Setter direkt, - # in manchen Faellen (besonders auf Mac) wird IsLocked nicht - # persistiert ohne explizites Modify. Defensiv: + child.Color = canon; changed = True if changed: - try: - doc.Layers.Modify(child, child.LayerIndex, True) - except Exception: - pass - + try: doc.Layers.Modify(child, child.LayerIndex, True) + except Exception: pass + # Sub-Sub-Layer rekursiv (Children dieses Sub-Layers). + # Sub-Sub-Layer erben den 'grey'-Zustand des Parents. + for grand in children_by_parent.get(child.Id, []): + _apply_to_sublayer(grand, child_grey) + for child in children: + _apply_to_sublayer(child, p_grey) doc.Views.Redraw() diff --git a/rhino/rhinopanel.py b/rhino/rhinopanel.py index 06b4963..ff1d78c 100644 --- a/rhino/rhinopanel.py +++ b/rhino/rhinopanel.py @@ -87,9 +87,15 @@ def _broadcast_state(doc=None, hatch_patterns=None): try: z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") e_raw = doc.Strings.GetValue("dossier_ebenen") + # Projekt-Nullpunkt in m.ü.M — wird beim Swisstopo-Import als + # Z-Offset angewandt (Real-Welt-Höhen → Doc-Z relativ zu OKFF=0). + zero_raw = doc.Strings.GetValue("dossier_project_zero_mum") + try: zero_mum = float(zero_raw) if zero_raw else 0.0 + except Exception: zero_mum = 0.0 payload = { "zeichnungsebenen": json.loads(z_raw) if z_raw else None, "ebenen": json.loads(e_raw) if e_raw else None, + "projectZeroMum": zero_mum, "hatchPatterns": hatch_patterns if hatch_patterns is not None else _hatch_pattern_names(doc), } @@ -367,9 +373,13 @@ class EbenenBridge(panel_base.BaseBridge): layer_builder.build_layers(doc, z, e) layer_builder.cleanup_default_layers(doc) self._ensure_active_sublayer() + zero_raw = doc.Strings.GetValue("dossier_project_zero_mum") + try: zero_mum = float(zero_raw) if zero_raw else 0.0 + except Exception: zero_mum = 0.0 self.send("STATE_SYNC", { "zeichnungsebenen": z, "ebenen": e, + "projectZeroMum": zero_mum, "hatchPatterns": _hatch_pattern_names(doc), }) except Exception as ex: @@ -471,9 +481,28 @@ class EbenenBridge(panel_base.BaseBridge): print("[EBENEN] open_geschoss_settings: kein Geschoss-Payload") return gid = geschoss["id"] + doc = Rhino.RhinoDoc.ActiveDoc + # Projekt-Nullpunkt (m.ü.M) mit ins Param-Bundle — als projektweite + # Settings auch im Geschoss-Dialog editierbar. + try: + z_mum_raw = doc.Strings.GetValue("dossier_project_zero_mum") if doc else None + project_zero_mum = float(z_mum_raw) if z_mum_raw else 0.0 + except Exception: + project_zero_mum = 0.0 + params = dict(geschoss) + params["projectZeroMum"] = project_zero_mum def on_save(updated): doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return + # Projekt-Nullpunkt extrahieren (project-weit, nicht pro Geschoss) + try: + if "projectZeroMum" in updated: + val = updated.pop("projectZeroMum") + val = float(val) if val is not None else 0.0 + doc.Strings.SetString("dossier_project_zero_mum", str(val)) + print("[EBENEN] project_zero_mum = {} m.ü.M".format(val)) + except Exception as ex: + print("[EBENEN] project_zero_mum save:", ex) z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen") if not z_raw: print("[EBENEN] save_geschoss: kein z-Store"); return @@ -497,9 +526,9 @@ class EbenenBridge(panel_base.BaseBridge): self._apply(z_list, e_list, save_z=True, save_e=False) panel_base.open_satellite_window( "geschoss_settings", - params=geschoss, + params=params, title="Zeichnungsebene: {}".format(geschoss.get("name", "")), - size=(380, 540), + size=(380, 580), on_save=on_save) def _open_ebenen_settings(self, ebene, hatch_patterns): @@ -733,22 +762,36 @@ class EbenenBridge(panel_base.BaseBridge): return payload_z = p.get("zeichnungsebenen") or [] payload_e = p.get("ebenen") or [] + # Hilfsfunktion: alle Codes (inkl. Children) als flat dict {code: ebene} + def _walk_codes(lst): + out = {} + if not isinstance(lst, list): return out + for x in lst: + if not isinstance(x, dict): continue + c = x.get("code") + if c: out[c] = x + kids = x.get("children") + if isinstance(kids, list): + out.update(_walk_codes(kids)) + return out # Strukturelle Aenderung pending? Wenn React-Payload IDs/Codes enthaelt # die noch nicht in doc.Strings sind (= User hat gerade neue Ebene # angelegt aber der strukturelle APPLY ist noch in der 200ms-Debounce), # NICHT speichern. Sonst ueberschreibt die schnellere SET_VISIBILITY # den geplanten APPLY-Save und die neue Ebene geht in der Race # verloren. - payload_z_ids = {z.get("id") for z in payload_z if isinstance(z, dict)} - payload_e_codes = {e.get("code") for e in payload_e if isinstance(e, dict)} - existing_z_ids = {z.get("id") for z in z_full if isinstance(z, dict)} - existing_e_codes = {e.get("code") for e in e_full if isinstance(e, dict)} + payload_z_ids = {z.get("id") for z in payload_z if isinstance(z, dict)} + payload_e_codes = set(_walk_codes(payload_e).keys()) + existing_z_ids = {z.get("id") for z in z_full if isinstance(z, dict)} + existing_e_codes = set(_walk_codes(e_full).keys()) has_new_structural = ( bool(payload_z_ids - existing_z_ids - {None}) or bool(payload_e_codes - existing_e_codes - {None}) ) z_state = {z["id"]: z for z in payload_z if isinstance(z, dict) and z.get("id")} - e_state = {e["code"]: e for e in payload_e if isinstance(e, dict) and e.get("code")} + # e_state ist flach (Code → Ebene) ueber den ganzen Tree des Payloads, + # damit auch Child-Visibility-Toggles ankommen. + e_state = _walk_codes(payload_e) merged_z = [] for z in z_full: if not isinstance(z, dict): continue @@ -758,23 +801,40 @@ class EbenenBridge(panel_base.BaseBridge): m["visible"] = s.get("visible", True) m["locked"] = s.get("locked", False) merged_z.append(m) - merged_e = [] - for e in e_full: - if not isinstance(e, dict): continue - m = dict(e) - s = e_state.get(e.get("code")) - if s is not None: - m["visible"] = s.get("visible", True) - m["locked"] = s.get("locked", False) - merged_e.append(m) + # Merge fuer Ebenen rekursiv: jedes Element behaelt seine Position + + # children-Struktur, nur visible/locked werden ueberschrieben falls + # im Payload anwesend. + def _merge_ebenen_tree(orig_list): + out = [] + for e in orig_list: + if not isinstance(e, dict): continue + m = dict(e) + s = e_state.get(e.get("code")) + if s is not None: + m["visible"] = s.get("visible", True) + m["locked"] = s.get("locked", False) + kids = e.get("children") + if isinstance(kids, list): + m["children"] = _merge_ebenen_tree(kids) + out.append(m) + return out + merged_e = _merge_ebenen_tree(e_full) # Detect whether the merge actually changed any visible/locked values. # Wenn nicht: das ist nur der Echo-Roundtrip eines apply_layer_preset # (React-State == doc.Strings → kein User-Click) und wir wollen das - # aktive Preset NICHT clearen. + # aktive Preset NICHT clearen. Bei Ebenen rekursiv durch Children. + def _flatten(lst): + out = [] + for x in (lst or []): + if not isinstance(x, dict): continue + out.append(x) + kids = x.get("children") + if isinstance(kids, list): + out.extend(_flatten(kids)) + return out def _vis_lock_changed(old, new): - old_by = {x.get("id") or x.get("code"): x for x in old if isinstance(x, dict)} - for nx in new: - if not isinstance(nx, dict): continue + old_by = {x.get("id") or x.get("code"): x for x in _flatten(old)} + for nx in _flatten(new): key = nx.get("id") or nx.get("code") if key is None: continue ox = old_by.get(key) @@ -815,10 +875,12 @@ class EbenenBridge(panel_base.BaseBridge): bool(z.get("visible", True)), bool(z.get("locked", False))) for z in zlist if isinstance(z, dict)) + # Ebenen flat ueber Children — sonst dedupt der Cache auch nach + # einem Child-Toggle, weil die Top-Level-Liste identisch aussieht. es = tuple((e.get("code"), bool(e.get("visible", True)), bool(e.get("locked", False))) - for e in elist if isinstance(e, dict)) + for e in _flatten(elist)) return (active_z_id, active_code, z_mode, e_mode, zs, es) cur_sig = _sig(merged_z, merged_e) if sc.sticky.get("_vis_last_sig") == cur_sig and not any_changed: diff --git a/rhino/swisstopo.py b/rhino/swisstopo.py index 0abfdc3..dd5545a 100644 --- a/rhino/swisstopo.py +++ b/rhino/swisstopo.py @@ -323,25 +323,38 @@ def _extract_zip_to_dir(zip_path, dest_dir): # --- Buildings: 3D-Gebaeude DWG -------------------------------------------- -# swissBUILDINGS3D 3.0 ist Cesium-3D-Tiles (kein DWG). Fuer DWG-Import nutzen -# wir die 2.0-Variante. -_BUILDINGS_COLLECTION = "ch.swisstopo.swissbuildings3d_2" +# swissBUILDINGS3D 3.0: liefert mehrere Formate (DXF/DWG/OBJ/IFC/3DTiles) und +# variant-Filter (solid/separated). In Staedten sind die 3.0-Tiles aber riesig +# (>700 MB), weil nicht 1km-strukturiert — dann fallen wir auf 2.0 zurueck +# (verlaesslich 1km-Tiles, ~50 MB). +_BUILDINGS_COLLECTION_V3 = "ch.swisstopo.swissbuildings3d_3_0" +_BUILDINGS_COLLECTION_V2 = "ch.swisstopo.swissbuildings3d_2" -def fetch_buildings_dwg(bbox_lv95, progress=None): - """Holt swissBUILDINGS3D 2.0 Tile-DXF/DWG-Files fuer eine LV95-bbox. - Wichtig: filtert NUR per-Tile-Assets (Pattern `_NNNN-NN_`). National- - Geodatabase-Assets (>1 GB) werden NICHT gematcht — sonst laedt das Plugin - versehentlich den gesamt-CH-Datensatz.""" - bbox_wgs = lv95_bbox_to_wgs84_bbox(*bbox_lv95) - if progress: progress("STAC-Query " + _BUILDINGS_COLLECTION + "...") - items = stac_query(_BUILDINGS_COLLECTION, bbox_wgs, - asset_extensions=[".dwg", ".dxf", - ".dwg.zip", ".dxf.zip"]) +def _fetch_buildings_from_collection(collection_id, bbox_wgs, variant, + progress=None): + """Holt Tile-CAD-Files aus EINER STAC-Collection. Liefert Liste Pfade + oder [] wenn nichts brauchbar geladen werden konnte (z.B. alle ueber + Size-Limit).""" + if progress: progress("STAC-Query {} (variant={})...".format( + collection_id, variant)) + items = stac_query(collection_id, bbox_wgs, + asset_extensions=[".dwg", ".dxf", ".obj", ".ifc", + ".dwg.zip", ".dxf.zip", + ".obj.zip", ".ifc.zip"]) items = _dedupe_latest(items) if not items: - if progress: progress("Keine Tiles in der Region (collection={})".format(_BUILDINGS_COLLECTION)) + if progress: progress(" Keine Tiles in der Region") return [] + variant_marker = "_{}_".format(variant.lower()) + items_v = [it for it in items + if variant_marker in (it.get("id") or "").lower() + or any(variant_marker in (a.get("href") or "").lower() + for a in it.get("assets", {}).values())] + if items_v and len(items_v) < len(items): + if progress: progress(" Item-Filter: {}/{} matchen variant '{}'".format( + len(items_v), len(items), variant)) + items = items_v paths = [] for i, item in enumerate(items): if progress: progress("Lade Tile {}/{}: {}".format( @@ -353,31 +366,69 @@ def fetch_buildings_dwg(bbox_lv95, progress=None): if not per_tile: if progress: progress("→ kein Per-Tile-Asset, skip") continue - # Priorisierung: direkt-DXF/DWG > ZIP-DXF/DWG + # Varianten-Filter: `_solid_` vs `_separated_` im Filename. Default + # ist 'separated'. Falls keine Asset mit dem Marker matcht (alte + # Collection-Version o.ae.), fallen wir auf alle per-tile zurueck. + variant_marker = "_{}_".format(variant.lower()) + per_tile_v = [(k, a) for k, a in per_tile + if variant_marker in a["href"].lower()] + if per_tile_v: + per_tile = per_tile_v + if progress: progress(" → {} Asset(s) matchen variant '{}'".format( + len(per_tile), variant)) + else: + if progress: + hrefs_short = ", ".join(os.path.basename(a["href"]) + for _, a in per_tile[:3]) + progress(" → kein '{}' Marker, nehme erstes Asset (verfuegbar: {})".format( + variant, hrefs_short)) + # Priorisierung: DXF/DWG (am stabilsten in Rhino) > OBJ > IFC chosen = None - for k, a in per_tile: - low = a["href"].lower() - if low.endswith((".dxf", ".dwg")): - chosen = a["href"]; break - if chosen is None: + for prio_ext in [(".dxf", ".dwg"), (".obj",), (".ifc",), + (".dxf.zip", ".dwg.zip"), (".obj.zip",), (".ifc.zip",)]: for k, a in per_tile: low = a["href"].lower() - if low.endswith((".dxf.zip", ".dwg.zip")): + if low.endswith(prio_ext): chosen = a["href"]; break + if chosen is not None: break if chosen is None: chosen = per_tile[0][1]["href"] p = download_asset(chosen, subdir="buildings3d_dwg", status=progress) if not p: continue - # ZIP-Wrapper aufloesen + # ZIP-Wrapper aufloesen + Variant-Filter (ZIP kann beide DWGs enthalten) if p.lower().endswith(".zip"): extracted = _extract_zip_to_dir( p, os.path.join(CACHE_DIR, "buildings3d_dwg", "_unzipped")) - dwgs = [e for e in extracted if e.lower().endswith((".dwg", ".dxf"))] - paths.extend(dwgs) + cads_all = [e for e in extracted + if e.lower().endswith((".dwg", ".dxf", ".obj", ".ifc"))] + cads_v = [e for e in cads_all + if variant_marker in os.path.basename(e).lower()] + cads = cads_v if cads_v else cads_all + if cads_v and len(cads_v) < len(cads_all): + if progress: progress(" ZIP-Filter: {}/{} Files matchen '{}'".format( + len(cads_v), len(cads_all), variant)) + paths.extend(cads) else: paths.append(p) + return paths + + +def fetch_buildings_dwg(bbox_lv95, progress=None, variant="separated"): + """Holt swissBUILDINGS3D Tile-CAD-Files. Versucht erst v3.0 (separated/ + solid Varianten), faellt automatisch auf v2.0 zurueck wenn v3.0 in der + Region keine brauchbaren Files liefert (typisch in Staedten — die 3.0- + Tiles sind dort >700 MB pro Stueck und werden vom Size-Limit geblockt).""" + bbox_wgs = lv95_bbox_to_wgs84_bbox(*bbox_lv95) + paths = _fetch_buildings_from_collection( + _BUILDINGS_COLLECTION_V3, bbox_wgs, variant, progress=progress) + if not paths: + if progress: progress("v3.0 lieferte keine Tiles — fallback auf v2.0 (1km-Tiles)...") + # v2.0 hat keine variant-Marker im Filename, ist immer "separated"- + # artig (Kategorien auf eigenen DXF-Layern innerhalb einer DWG). + paths = _fetch_buildings_from_collection( + _BUILDINGS_COLLECTION_V2, bbox_wgs, variant, progress=progress) if progress: progress("{} CAD-Datei(en) bereit".format(len(paths))) return paths @@ -520,6 +571,41 @@ def xyz_to_grid(path, target_step=2.0, clip_bbox=None, progress=None): } +def merge_grids(grids): + """Vereint mehrere Grid-Dicts (eines pro XYZ-Tile) zu einem zusammen- + haengenden Grid. swissALTI3D-Tiles liefern jeweils 1km×1km Punkte — + benachbarte Tiles teilen KEINE Rand-Punkte (Tile A endet z.B. bei + e=2700999.5, Tile B startet bei e=2701000.0). Beim getrennten Meshen + entsteht dadurch ein 1m-Streifen ohne Faces. Hier mergen wir die + Punkte VORHER zu einem unified Grid, dann verbindet mesh_from_grid + die Tile-Grenze automatisch (benachbarte Spalten = ein step Abstand + nach Sub-Sampling). + + Annahme: alle Grids teilen sich denselben step (gleicher target_step + + Quell-Resolution). Origin-Alignment ist gegeben, weil swissALTI3D + auf einem globalen 0.5m-Raster liegt.""" + if not grids: return None + grids = [g for g in grids if g is not None] + if not grids: return None + if len(grids) == 1: return grids[0] + step = grids[0]["step"] + all_points = {} + all_es = set(); all_ns = set() + for g in grids: + for (e, n), z in g["points"].items(): + all_points[(e, n)] = z + all_es.add(e); all_ns.add(n) + if not all_points: return None + es_sorted = sorted(all_es); ns_sorted = sorted(all_ns) + return { + "bbox": (es_sorted[0], ns_sorted[0], es_sorted[-1], ns_sorted[-1]), + "step": step, + "es": es_sorted, + "ns": ns_sorted, + "points": all_points, + } + + def mesh_from_grid(grid, origin_shift=(0, 0, 0), unit_scale=1.0): """Baut ein Rhino-Mesh aus dem XYZ-Grid. origin_shift wird auf jeden Vertex angewendet (typisch: bbox-Center zu Welt-0/0/0 schieben). @@ -584,53 +670,131 @@ def fetch_orthophoto(bbox_lv95, resolution="2.0", progress=None): return paths -def apply_ortho_material(doc, mesh_obj, ortho_path, mesh_bbox_lv95): - """Erzeugt Rhino-Material mit dem SWISSIMAGE-GeoTIFF als Bitmap-Texture, - weist es dem mesh_obj zu. UV-Mapping kommt aus den XY-Koords (linear auf - der bbox).""" - if not (ortho_path and os.path.isfile(ortho_path)): return +def _geotiff_to_png(tif_path, max_dim=2048): + """SWISSIMAGE kommt als GeoTIFF — Rhinos Material-Bitmap kann GeoTIFF nicht + direkt lesen. Konvertiere zu PNG. Zwei Wege: + 1) Pillow (wenn in Rhinos CPython verfuegbar) — universell + downsample + 2) Eto.Drawing.Bitmap (Mac: NSImage liest TIFF nativ) — Fallback + Liefert PNG-Pfad oder None bei Fehler.""" + if not tif_path: return None + base, _ = os.path.splitext(tif_path) + png_path = base + "_2k.png" + if os.path.isfile(png_path) and os.path.getsize(png_path) > 0: + print("[SWISSTOPO] PNG-Cache:", os.path.basename(png_path)) + return png_path + # --- Variante 1: Pillow try: - rdoc = doc.RenderMaterials - from Rhino.Render import RenderMaterial, RenderContent - try: - mat = RenderMaterial.CreateBasicMaterial( - Rhino.DocObjects.Material(), doc) - except Exception: - mat = RenderMaterial.CreateBasicMaterial( - Rhino.DocObjects.Material()) - try: mat.Name = "swisstopo_ortho_" + os.path.basename(ortho_path) - except Exception: pass - # Bitmap zuweisen — Property-Name variiert mit Rhino-Version - try: - mat.SetParameter("diffuse-bitmap-filename", ortho_path) - except Exception as ex: - print("[SWISSTOPO] material bitmap:", ex) - try: - mid = rdoc.Add(mat) - except Exception: - mid = doc.Materials.Add() - # UV-Mapping: planar in XY-bbox - e_min, n_min, e_max, n_max = mesh_bbox_lv95 - try: - plane = rg.Plane(rg.Point3d((e_min + e_max) / 2.0, - (n_min + n_max) / 2.0, 0), - rg.Vector3d.ZAxis) - dx = abs(e_max - e_min) - dy = abs(n_max - n_min) - mapping = Rhino.Render.TextureMapping.CreatePlaneMapping( - plane, rg.Interval(-dx/2.0, dx/2.0), - rg.Interval(-dy/2.0, dy/2.0), - rg.Interval(-1, 1)) - doc.Objects.ModifyTextureMapping(mesh_obj, 1, mapping) - except Exception as ex: - print("[SWISSTOPO] uv-mapping:", ex) - # Material aufs Object setzen - try: - attrs = mesh_obj.Attributes.Duplicate() - attrs.MaterialSource = Rhino.DocObjects.ObjectMaterialSource.MaterialFromObject - attrs.RenderMaterial = mat - doc.Objects.ModifyAttributes(mesh_obj, attrs, True) - except Exception as ex: - print("[SWISSTOPO] material assign:", ex) + from PIL import Image + img = Image.open(tif_path) + if max(img.width, img.height) > max_dim: + scale = max_dim / float(max(img.width, img.height)) + new_w = max(1, int(img.width * scale)) + new_h = max(1, int(img.height * scale)) + img = img.resize((new_w, new_h), Image.LANCZOS) + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") + img.save(png_path, "PNG", optimize=False) + print("[SWISSTOPO] Pillow: {} → {} ({}x{}px)".format( + os.path.basename(tif_path), os.path.basename(png_path), + img.width, img.height)) + return png_path + except ImportError: + print("[SWISSTOPO] Pillow nicht verfuegbar — versuche Eto.Drawing") except Exception as ex: - print("[SWISSTOPO] apply_ortho_material:", ex) + print("[SWISSTOPO] Pillow-convert fail:", ex) + # --- Variante 2: Eto.Drawing (Mac NSImage liest TIFF) + try: + import Eto.Drawing as _ed + bmp_src = _ed.Bitmap(tif_path) + if bmp_src is None: + print("[SWISSTOPO] Eto konnte TIFF nicht laden") + return None + # Downsample falls > max_dim + w, h = bmp_src.Width, bmp_src.Height + if max(w, h) > max_dim: + scale = max_dim / float(max(w, h)) + new_w = max(1, int(w * scale)) + new_h = max(1, int(h * scale)) + target = _ed.Bitmap(new_w, new_h, _ed.PixelFormat.Format32bppRgba) + g = _ed.Graphics(target) + try: + try: g.AntiAlias = True + except Exception: pass + g.DrawImage(bmp_src, 0, 0, new_w, new_h) + finally: g.Dispose() + bmp_src = target + w, h = new_w, new_h + try: bmp_src.Save(png_path, _ed.ImageFormat.Png) + except Exception: + # Eto.ImageFormat-Variante kann je nach Eto-Version variieren + bmp_src.Save(png_path) + print("[SWISSTOPO] Eto: {} → {} ({}x{}px)".format( + os.path.basename(tif_path), os.path.basename(png_path), w, h)) + return png_path + except Exception as ex: + print("[SWISSTOPO] Eto-convert fail:", ex) + return None + + +def add_ortho_plane(doc, ortho_path, tile_bbox_lv95, shift_lv95, m_to_unit, + z_doc=0.0): + """Erzeugt eine planare Brep-Flaeche mit dem SWISSIMAGE-Foto als Material, + direkt sichtbar in Top/Shaded/Rendered Display-Mode. + + tile_bbox_lv95: (e_min, n_min, e_max, n_max) in LV95-Metern der Tile-Region + shift_lv95: (sx, sy, sz) — Origin-Shift in LV95-Metern (typisch eC,nC) + m_to_unit: Skalierung m → doc-units (z.B. 0.001 fuer km-Doc) + z_doc: Z-Hoehe der Plane in Doc-Units (typisch max-Terrain-Z + Epsilon) + + Liefert den RhinoObject der erzeugten Plane (oder None).""" + if not (ortho_path and os.path.isfile(ortho_path)): return None + # GeoTIFF → PNG damit Rhino's Material-Bitmap es als Diffuse nehmen kann + if ortho_path.lower().endswith((".tif", ".tiff")): + png = _geotiff_to_png(ortho_path) + if not png: return None + ortho_path = png + # bbox in Doc-Units (nach Shift + Scale) + e_min, n_min, e_max, n_max = tile_bbox_lv95 + sx, sy, sz = shift_lv95 + x_min = (e_min - sx) * m_to_unit + x_max = (e_max - sx) * m_to_unit + y_min = (n_min - sy) * m_to_unit + y_max = (n_max - sy) * m_to_unit + # Mesh-Quad mit expliziten Per-Vertex-UV-Koordinaten — bombensicher + # fuer Cycles/Raytraced. Eine Brep-Plane braucht erst Render-Mesh- + # Erzeugung + TextureMapping, was diverse Fallstricke hat. + mesh = rg.Mesh() + mesh.Vertices.Add(x_min, y_min, z_doc) # 0 → UV (0,0) + mesh.Vertices.Add(x_max, y_min, z_doc) # 1 → UV (1,0) + mesh.Vertices.Add(x_max, y_max, z_doc) # 2 → UV (1,1) + mesh.Vertices.Add(x_min, y_max, z_doc) # 3 → UV (0,1) + mesh.Faces.AddFace(0, 1, 2, 3) + mesh.TextureCoordinates.Add(0.0, 0.0) + mesh.TextureCoordinates.Add(1.0, 0.0) + mesh.TextureCoordinates.Add(1.0, 1.0) + mesh.TextureCoordinates.Add(0.0, 1.0) + mesh.Normals.ComputeNormals() + mesh.Compact() + gid = doc.Objects.AddMesh(mesh) + obj = doc.Objects.Find(gid) + if obj is None: return None + # Material: Legacy + ToPhysicallyBased + PBR_BaseColor-Texture. + # Bekannt instabil unter Mac Rhino 8 für Raytraced (Cycles greift den + # Shim nicht zuverlaessig); zumindest Shaded zeigt die Textur. + try: + mat = Rhino.DocObjects.Material() + mat.Name = "swisstopo_ortho" + mat.SetBitmapTexture(ortho_path) + mat.ToPhysicallyBased() + tex = Rhino.DocObjects.Texture() + tex.FileName = ortho_path + tex.Enabled = True + mat.SetTexture(tex, Rhino.DocObjects.TextureType.PBR_BaseColor) + midx = doc.Materials.Add(mat) + attrs = obj.Attributes.Duplicate() + attrs.MaterialSource = Rhino.DocObjects.ObjectMaterialSource.MaterialFromObject + attrs.MaterialIndex = midx + doc.Objects.ModifyAttributes(obj, attrs, True) + except Exception as ex: + print("[SWISSTOPO] ortho-material:", ex) + return obj diff --git a/src/App.jsx b/src/App.jsx index 4bbd844..2de3936 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -62,9 +62,18 @@ export default function App() { // Sichtbarkeit live anwenden bei Layer-Aenderungen. Zeichnungsebenen-Slice // bleibt leer — Backend mergt mit doc.Strings. + // Rekursiv durch Children — sonst feuert das useEffect nicht wenn nur die + // Visibility/Lock einer Sub-Ebene geaendert wurde. + const visKeyFor = (e) => { + const own = `${e.code}:${e.visible !== false ? 1 : 0}:${e.locked === true ? 1 : 0}` + const kids = Array.isArray(e.children) && e.children.length + ? '(' + e.children.map(visKeyFor).join(',') + ')' + : '' + return own + kids + } const visibilityKey = useMemo(() => ( activeCode + '|' + eMode + '|' + - ebenen.map(e => `${e.code}:${e.visible !== false ? 1 : 0}:${e.locked === true ? 1 : 0}`).join(',') + ebenen.map(visKeyFor).join(',') ), [activeCode, eMode, ebenen]) useEffect(() => { @@ -73,17 +82,24 @@ export default function App() { }, [visibilityKey]) // Auto-Apply bei strukturellen Aenderungen (name, fill) — wieder nur unsere - // Slice, Backend mergt. + // Slice, Backend mergt. Rekursiv durch Children. const fillSig = (e) => { const f = e.fill if (!f || !f.pattern || f.pattern === 'None') return '' return [f.pattern, f.source || 'layer', f.color || '', f.scale ?? 1, f.rotation ?? 0].join('|') } + const structKeyFor = (e) => { + const own = `${e.code}:${e.name}:${fillSig(e)}` + const kids = Array.isArray(e.children) && e.children.length + ? '(' + e.children.map(structKeyFor).join(',') + ')' + : '' + return own + kids + } const structureKey = useMemo(() => ( - ebenen.map(e => `${e.code}:${e.name}:${fillSig(e)}`).join(',') + ebenen.map(structKeyFor).join(',') ), [ebenen]) const appliedStructureKey = useMemo(() => ( - appliedE.map(e => `${e.code}:${e.name}:${fillSig(e)}`).join(',') + appliedE.map(structKeyFor).join(',') ), [appliedE]) useEffect(() => { diff --git a/src/SwisstopoApp.jsx b/src/SwisstopoApp.jsx index 6fa3368..b8094c8 100644 --- a/src/SwisstopoApp.jsx +++ b/src/SwisstopoApp.jsx @@ -57,6 +57,7 @@ export default function SwisstopoApp() { // Optionen const [radius, setRadius] = useState(100) const [getBuild, setGetBuild] = useState(true) + const [buildVariant, setBuildVariant] = useState('separated') const [getTerrain, setGetTerrain] = useState(false) const [getOrtho, setGetOrtho] = useState(false) const [shift, setShift] = useState(true) @@ -141,6 +142,7 @@ export default function SwisstopoApp() { replaceExisting, clipToBbox, terrainResolution: terrainRes, + buildVariant, }) } @@ -235,9 +237,22 @@ export default function SwisstopoApp() { + {getBuild && ( + + + + )}