Swisstopo Iter 2 + hierarchische Ebenen + 0-Kote m.ü.M

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 23:21:45 +02:00
parent 4111f12f32
commit afb59b6626
10 changed files with 1103 additions and 326 deletions
+236 -72
View File
@@ -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