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:
+236
-72
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user