Swisstopo Importer: STAC-API + Terrain-Mesh + Ortho-Drape (Iteration 1)

Frontend:
- src/SwisstopoApp.jsx NEU: Satelliten-Fenster mit Adresse-Suche, Radius-
  Wahl, Daten-Checkboxen (Gebäude/Terrain/Luftbild), Origin-Handling, Live-
  Log
- ElementeApp Swisstopo-Gruppe: Importer-Button + Karte-Button

Backend:
- rhino/swisstopo.py NEU: STAC-API-Client, Geocoding via swisstopo Search,
  LV95↔WGS84-Konvertierung, GeoTIFF/XYZ-Cache, mesh_from_grid + Ortho-Material
- swissBUILDINGS3D 2.0 (DXF/DWG) via STAC; Per-Tile-Filter (_NNNN-NN_)
  schuetzt vor versehentlichem Download der 3.5 GB National-Geodatabase
- swissALTI3D als XYZ-ZIP mit zipfile-Extraction, raeumliches Sub-Sampling
  statt Zeilen-Step (keine Streifen mehr im Terrain-Mesh)
- SWISSIMAGE 10cm GeoTIFF als RenderMaterial-DiffuseBitmap mit planarem
  UV-Mapping auf den Terrain-Mesh-bbox

Robustheit:
- Auto-Skala-Erkennung: Rhinos DXF-Parser scaliert je nach \$INSUNITS auf
  unerwartete Doc-Units; wir messen aus ersten 50 Objekten + snappen auf
  Zehnerpotenz (1, 0.001, 1000)
- bbox + origin_shift in doc-units (m_to_unit aus UnitScale + Auto-Detect)
- Tags via UserString dossier_swisstopo_kind=buildings/terrain fuer
  Replace-Detection bei erneutem Import desselben Gebiets
- BBox-Clip jetzt OPTIONAL (Default OFF, Checkbox) — bei InstanceReferences
  GetBoundingBox + Delete teuer
- Batch-Transform via System.Collections.Generic.List[Guid] statt
  Python-Loop (Python.NET-Overload-Match)
- Listener-Suppression in elemente.py + gestaltung.py + dimensionen.py
  via sticky dossier_swisstopo_busy — kein Per-Object-Spam mehr bei
  Selection/Add/Delete waehrend 5000+ Imports
- Auto-Zoom via view.ZoomBoundingBox(combined) statt Select-Loop
- Year-Dedupe auf Tile-Coord (Pattern YYYY oder YYYY-MM unterstuetzt) fuer
  alle Collections — aeltere Versionen werden ausgefiltert
- Download-Safety: > 200 MB wird abgebrochen + Live-Progress alle 2 MB
  mit UI-Yield via Rhino.RhinoApp.Wait()

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 18:22:48 +02:00
parent 41b6f8ac51
commit 4111f12f32
8 changed files with 1557 additions and 0 deletions
+10
View File
@@ -6,6 +6,7 @@ import {
createFenster, createTuer, createAussparung, createTreppe,
createStuetze, createTraeger, createRaum,
exportRaeume,
openSwisstopo, openSwisstopoDialog,
updateElement, deleteElement, regenerateAllElements,
} from './lib/rhinoBridge'
@@ -476,6 +477,15 @@ function NeuesElementSection({ noGeschoss, activeName }) {
disabled={dis}
onClick={() => createRaum({})} />
</PillGroup>
<PillGroup label="Swisstopo">
<PillButton icon="download" label="Importer…"
hint="Vollautomatischer Import via swisstopo STAC-API: Adresse suchen, Radius wählen, Gebäude + Terrain + Luftbild holen — Tiles werden gecacht"
onClick={() => openSwisstopoDialog()} />
<PillButton icon="map" label="Karte"
hint="Öffnet map.geo.admin.ch im Browser zur visuellen Inspektion"
onClick={() => openSwisstopo('both')} />
</PillGroup>
</div>
)
}
+354
View File
@@ -0,0 +1,354 @@
import { useState, useEffect, useRef } from 'react'
import Icon from './components/Icon'
import { onMessage, notifyReady } from './lib/rhinoBridge'
function send(type, payload = {}) {
if (!window.RHINO_MODE) { console.log('[Swisstopo] →', type, payload); return }
document.title = 'RHINOMSG::' + JSON.stringify({ type, payload })
}
function Field({ label, hint, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '6px 0' }}>
<span className="label-xs">{label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>{children}</div>
{hint && (
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4 }}>{hint}</span>
)}
</div>
)
}
function SectionLabel({ children }) {
return (
<div style={{
fontSize: 9, color: 'var(--text-muted)', fontWeight: 600,
letterSpacing: 0.5, textTransform: 'uppercase',
padding: '10px 0 4px',
borderTop: '1px solid var(--border-light)',
marginTop: 8,
}}>{children}</div>
)
}
function Radio({ value, options, onChange }) {
return (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{options.map(o => (
<button key={o.value}
onClick={() => onChange(o.value)}
className={value === o.value ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 10 }}
title={o.hint || ''}
>
{o.label}
</button>
))}
</div>
)
}
export default function SwisstopoApp() {
const [ebenen, setEbenen] = useState([])
// Standort
const [searchText, setSearchText] = useState('')
const [center, setCenter] = useState(null) // {e, n, label}
const [searching, setSearching] = useState(false)
// Optionen
const [radius, setRadius] = useState(100)
const [getBuild, setGetBuild] = useState(true)
const [getTerrain, setGetTerrain] = useState(false)
const [getOrtho, setGetOrtho] = useState(false)
const [shift, setShift] = useState(true)
const [autoZoom, setAutoZoom] = useState(true)
const [replaceExisting, setReplaceExisting] = useState(true)
const [clipToBbox, setClipToBbox] = useState(false)
const [terrainRes, setTerrainRes] = useState('2.0')
// Live-Log
const [logs, setLogs] = useState([])
const [running, setRunning] = useState(false)
const [done, setDone] = useState(false)
const logRef = useRef(null)
useEffect(() => {
onMessage('SWISSTOPO_STATE', ({ ebenen }) => {
if (Array.isArray(ebenen)) setEbenen(ebenen)
})
onMessage('GEOCODE_RESULT', ({ result }) => {
setSearching(false)
if (result && result.e != null && result.n != null) {
setCenter({ e: result.e, n: result.n, label: result.label || searchText })
} else {
setCenter(null)
addLog('Keine Adresse gefunden')
}
})
onMessage('SWISSTOPO_LOG', ({ msg }) => addLog(msg))
onMessage('IMPORT_DONE', ({ count }) => {
setRunning(false)
setDone(true)
addLog(`✓ Fertig — ${count} Objekt(e) importiert`)
})
notifyReady()
const blockContext = (ev) => ev.preventDefault()
document.addEventListener('contextmenu', blockContext)
return () => document.removeEventListener('contextmenu', blockContext)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Auto-Scroll Log
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [logs])
const addLog = (m) => setLogs(l => [...l, m])
const handleSearch = () => {
const t = searchText.trim()
if (!t) return
setSearching(true)
setCenter(null)
addLog(`Suche '${t}'...`)
send('GEOCODE', { text: t })
}
const handleManualCoords = (eRaw, nRaw) => {
const e = parseFloat(eRaw), n = parseFloat(nRaw)
if (e > 2000000 && n > 1000000) {
setCenter({ e, n, label: `LV95 manuell` })
} else {
setCenter(null)
}
}
const handleImport = () => {
if (!center) { addLog('Bitte zuerst einen Standort wählen'); return }
if (!getBuild && !getTerrain) { addLog('Mindestens Gebäude oder Terrain auswählen'); return }
setLogs([])
setRunning(true)
setDone(false)
const kinds = []
if (getBuild) kinds.push('buildings')
if (getTerrain) kinds.push('terrain')
if (getOrtho && getTerrain) kinds.push('ortho')
send('RUN_IMPORT', {
centerE: center.e,
centerN: center.n,
radius: Number(radius),
kinds,
shiftToOrigin: shift,
autoZoom,
replaceExisting,
clipToBbox,
terrainResolution: terrainRes,
})
}
return (
<div style={{
position: 'absolute', inset: 0,
background: 'var(--bg-dialog)',
display: 'flex', flexDirection: 'column',
fontFamily: 'var(--font)', color: 'var(--text-primary)',
overflow: 'hidden',
}}>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 16px' }}>
<SectionLabel>Standort</SectionLabel>
<Field label="ADRESSE / ORT" hint='z.B. "Bahnhofstrasse 1, Zürich" oder "Bern"'>
<input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch() }}
placeholder="Adresse oder Ortsname"
style={{ flex: 1, fontSize: 11, padding: '5px 8px' }}
/>
<button
className="btn-outlined"
onClick={handleSearch}
disabled={searching || !searchText.trim()}
style={{ padding: '4px 10px', fontSize: 11 }}
>
{searching ? '…' : 'Suchen'}
</button>
</Field>
<Field label="ODER LV95-KOORDS (E / N)"
hint="East ~ 2'500'0002'850'000, North ~ 1'070'0001'300'000">
<input
placeholder="E"
onChange={(e) => handleManualCoords(e.target.value, center?.n || '')}
style={{ width: 110, fontSize: 11, fontFamily: 'DM Mono, monospace', padding: '5px 8px' }}
/>
<span style={{ color: 'var(--text-muted)' }}>/</span>
<input
placeholder="N"
onChange={(e) => handleManualCoords(center?.e || '', e.target.value)}
style={{ width: 110, fontSize: 11, fontFamily: 'DM Mono, monospace', padding: '5px 8px' }}
/>
</Field>
{center && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 10px',
background: 'var(--accent-dim)',
border: '1px solid var(--accent-border)',
borderRadius: 'var(--r)',
marginTop: 4,
}}>
<Icon name="location_on" size={14} style={{ color: 'var(--accent)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{center.label}
</div>
<div style={{ fontSize: 9, fontFamily: 'DM Mono, monospace',
color: 'var(--text-muted)' }}>
E {Math.round(center.e)} · N {Math.round(center.n)}
</div>
</div>
</div>
)}
<SectionLabel>Bereich</SectionLabel>
<Field label="RADIUS"
hint="Halbe Kantenlänge der quadratischen Bounding-Box um den Standort">
<Radio
value={radius}
options={[
{ value: 50, label: '50 m' },
{ value: 100, label: '100 m' },
{ value: 200, label: '200 m' },
{ value: 500, label: '500 m' },
{ value: 1000, label: '1 km' },
]}
onChange={setRadius}
/>
</Field>
<SectionLabel>Was holen</SectionLabel>
<Field label="DATEN">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={getBuild} onChange={(e) => setGetBuild(e.target.checked)} />
<Icon name="location_city" size={13} /> Bestand-Gebäude (swissBUILDINGS3D, DWG)
</label>
</Field>
<Field label="">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={getTerrain} onChange={(e) => setGetTerrain(e.target.checked)} />
<Icon name="terrain" size={13} /> Terrain (swissALTI3D Mesh)
</label>
</Field>
{getTerrain && (
<Field label="TERRAIN-AUFLÖSUNG">
<Radio
value={terrainRes}
options={[
{ value: '0.5', label: '0.5 m (sehr fein)',
hint: 'Bei 200m bbox: 400×400 = 160k Quads — bei 500m bbox 1M Quads!' },
{ value: '2.0', label: '2 m (Standard)',
hint: 'Gute Balance — bei 200m bbox: 100×100 = 10k Quads' },
]}
onChange={setTerrainRes}
/>
</Field>
)}
<Field label="">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer',
opacity: getTerrain ? 1 : 0.4 }}>
<input type="checkbox" checked={getOrtho} disabled={!getTerrain}
onChange={(e) => setGetOrtho(e.target.checked)} />
<Icon name="image" size={13} /> Orthofoto auf Terrain mappen (SWISSIMAGE 10cm)
</label>
</Field>
<SectionLabel>Positionierung</SectionLabel>
<Field label="ORIGIN"
hint="LV95-Koords sind im Bereich 2.6 Mio / 1.2 Mio — fürs Architekturmodell zu gross. Auf 0/0/0 verschiebt alles zum aktiven Standort.">
<Radio
value={shift ? 'origin' : 'lv95'}
options={[
{ value: 'origin', label: 'Auf Welt-Origin verschieben' },
{ value: 'lv95', label: 'Original LV95 lassen' },
]}
onChange={(v) => setShift(v === 'origin')}
/>
</Field>
<Field label="">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={autoZoom} onChange={(e) => setAutoZoom(e.target.checked)} />
Auto-Zoom auf importierte Objekte
</label>
</Field>
<Field label="" hint="Vorhandene swisstopo-Objekte (mit Tag dossier_swisstopo_kind) werden vor dem neuen Import gelöscht. Verhindert doppelte Gebäude bei mehrfachem Import desselben Gebiets.">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={replaceExisting}
onChange={(e) => setReplaceExisting(e.target.checked)} />
Bestehende swisstopo-Objekte vorher löschen
</label>
</Field>
<Field label="" hint="Filter: nur Gebäude/Terrain innerhalb des Radius behalten. AUS = ganzer 1km² Tile bleibt drin (schneller Import, kann manuell gelöscht werden). AN = präziser aber langsam bei InstanceReferences.">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={clipToBbox}
onChange={(e) => setClipToBbox(e.target.checked)} />
Auf Radius zuschneiden (langsam, optional)
</label>
</Field>
{(logs.length > 0 || running) && (
<>
<SectionLabel>Status</SectionLabel>
<div ref={logRef} style={{
fontSize: 10, fontFamily: 'DM Mono, monospace',
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--r)',
padding: 8,
maxHeight: 140,
overflowY: 'auto',
color: 'var(--text-secondary)',
lineHeight: 1.5,
}}>
{logs.map((m, i) => <div key={i}>{m}</div>)}
{running && <div style={{ color: 'var(--accent)' }}>Läuft</div>}
</div>
</>
)}
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 14px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
}}>
<div style={{ flex: 1, fontSize: 10, color: 'var(--text-muted)' }}>
{center ? `Tiles werden gecacht in ~/Library/Caches/Dossier/swisstopo/` : 'Wähle zuerst einen Standort'}
</div>
<button className="btn-text" onClick={() => send('CANCEL', {})}
disabled={running}>
{done ? 'Schliessen' : 'Abbrechen'}
</button>
<button className="btn-contained" onClick={handleImport}
disabled={!center || running}>
<Icon name="download" size={13} />
<span>{running ? 'Importiere…' : 'Importieren'}</span>
</button>
</div>
</div>
)
}
+3
View File
@@ -258,6 +258,9 @@ export function createStuetze(p) { send('CREATE_STUETZE', p || {}) }
export function createTraeger(p) { send('CREATE_TRAEGER', p || {}) }
export function createRaum(p) { send('CREATE_RAUM', p || {}) }
export function exportRaeume() { send('EXPORT_RAEUME', {}) }
export function openSwisstopo(mode) { send('OPEN_SWISSTOPO', { mode: mode || 'both' }) }
export function importSwisstopo(kind) { send('IMPORT_SWISSTOPO', { kind: kind || 'buildings' }) }
export function openSwisstopoDialog() { send('OPEN_SWISSTOPO_DIALOG', {}) }
export function updateElement(id, patch) { send('UPDATE_ELEMENT', { id, ...(patch || {}) }) }
export function deleteElement(id) { send('DELETE_ELEMENT', { id }) }
// Backwards-Compat-Aliases
+2
View File
@@ -9,6 +9,7 @@ import GeschossDialogApp from './GeschossDialogApp.jsx'
import LayerCombinationsApp from './LayerCombinationsApp.jsx'
import AusschnittSettingsApp from './AusschnittSettingsApp.jsx'
import LayoutDialogApp from './LayoutDialogApp.jsx'
import SwisstopoApp from './SwisstopoApp.jsx'
import GestaltungApp from './GestaltungApp.jsx'
import AusschnitteApp from './AusschnitteApp.jsx'
import MassstabApp from './MassstabApp.jsx'
@@ -36,6 +37,7 @@ const RootApp = mode === 'gestaltung' ? GestaltungApp
: mode === 'layer_combinations' ? LayerCombinationsApp
: mode === 'ausschnitt_settings' ? AusschnittSettingsApp
: mode === 'layout_dialog' ? LayoutDialogApp
: mode === 'swisstopo' ? SwisstopoApp
: App
window.onerror = function (msg, src, line, col, err) {