b425421fdd
Swisstopo Iter 3:
- Ortho-Drape: TIN-Mesh aus Terrain-Grid mit per-vertex UVs + PictureFrame-Material
- Project-Cache: TIFs werden neben .3dm gespeichert (SMB-shareable)
- Layer-Restruktur: 80_swisstopo/{Terrain, Luftbild} Sub-Ebenen
- TIFs direkt (kein PNG-Downsampling) für volle Auflösung
- UV-Inset gegen weisse Streifen zwischen Kacheln
- Hoehenlinien (2D, swissALTI3D) auf aktives Geschoss OKFF projiziert
- TIN-Mesh + Schichtenmodell aus Contours (separate Optionen)
- TLM3D entfernt (swisstopo liefert nur GDB/SHP, kein DXF)
OSM Importer (neu):
- rhino/osm.py: Overpass-API-Client
- src/OsmApp.jsx: React-Dialog mit Adresse + Radius + 7 Kategorien
- Strassen/Gebäude/Wasser/Wasserläufe/Parks/Wald/Fusswege (Codes 7101-7107)
- ElementeApp: PillGroup "Importer" mit Swisstopo + OSM Buttons
Sub-Ebenen — rekursiv durch hierarchische Ebenen:
- Visibility-Toggle: slimEbene rekursiv (children bleiben erhalten)
- Settings-Dialog: _find_sublayer_by_code_recursive + _replace_in_tree
- Hatch Auto-Fill: refresh_layer_fills + _fill_signature + _ebene_fill_for_layer
alle rekursiv durch children
- EbenenSettingsApp: flattenEbenen-Helper
Bulk-Op Performance (Delete/Cut/etc.):
- _USER_BULK_CMDS + _BULK_ACTIVE_KEY Sticky-Flag
- CommandBegin: doc.Views.RedrawEnabled = False + Listener-Bail aktiv
- CommandEnd: RedrawEnabled restore + 1× Redraw + Selection-Refresh
- Bail-outs in dimensionen.on_idle/on_select, elemente._on_idle_selection,
gestaltung.on_idle_flush/on_delete
- Verhindert das sichtbare "Runterzählen" pro Element bei Bulk-Delete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
442 lines
18 KiB
React
442 lines
18 KiB
React
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 [buildVersion, setBuildVersion] = useState('v2') // v2 (stabil) / v3 (beta)
|
||
const [buildVariant, setBuildVariant] = useState('separated')
|
||
const [getTerrain, setGetTerrain] = useState(false)
|
||
const [getOrtho, setGetOrtho] = useState(false)
|
||
const [getContours, setGetContours] = useState(false)
|
||
const [getContourTin,setGetContourTin]= useState(false)
|
||
const [getContourSchicht, setGetContourSchicht] = useState(false)
|
||
const [contourInt, setContourInt] = useState('2.0')
|
||
// TLM3D deaktiviert: swisstopo liefert nur GDB/SHP/GPKG — kein DXF.
|
||
// Rhino kann das nicht nativ importieren; OSM-Importer ist die Alternative
|
||
// fuer Vektordaten (Strassen/Wasser/Gebaeude).
|
||
const getTlm = false
|
||
const tlmKinds = {}
|
||
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 && !getContours && !getContourTin && !getContourSchicht && !getTlm) {
|
||
addLog('Mindestens eine Datenquelle wä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')
|
||
if (getContours) kinds.push('contours')
|
||
if (getContourTin) kinds.push('contour_tin')
|
||
if (getContourSchicht)kinds.push('contour_schicht')
|
||
if (getTlm) kinds.push('tlm')
|
||
const tlmList = Object.entries(tlmKinds).filter(([, v]) => v).map(([k]) => k)
|
||
send('RUN_IMPORT', {
|
||
centerE: center.e,
|
||
centerN: center.n,
|
||
radius: Number(radius),
|
||
kinds,
|
||
shiftToOrigin: shift,
|
||
autoZoom,
|
||
replaceExisting,
|
||
clipToBbox,
|
||
terrainResolution: terrainRes,
|
||
buildVersion,
|
||
buildVariant,
|
||
contourInterval: contourInt,
|
||
tlmKinds: tlmList,
|
||
})
|
||
}
|
||
|
||
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'000–2'850'000, North ~ 1'070'000–1'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>
|
||
{getBuild && (
|
||
<Field label="VERSION"
|
||
hint="2.0 = stabil, kein Solid/Separated-Split (alle Kategorien auf eigenen DXF-Layern innerhalb einer DWG). 3.0 = neuer, Beta — kann manchmal Probleme mit Variant-Erkennung haben.">
|
||
<Radio
|
||
value={buildVersion}
|
||
options={[
|
||
{ value: 'v2', label: '2.0 (stabil)' },
|
||
{ value: 'v3', label: '3.0 (beta)' },
|
||
]}
|
||
onChange={setBuildVersion}
|
||
/>
|
||
</Field>
|
||
)}
|
||
{getBuild && buildVersion === 'v3' && (
|
||
<Field label="GEBÄUDE-VARIANTE"
|
||
hint="Solid: ein geschlossenes Solid pro Gebäude (klein, schnell). Separated: Dach/Fassade/Wand als separate Objekte (mehr Detail, ermoeglicht z.B. Dach auszublenden).">
|
||
<Radio
|
||
value={buildVariant}
|
||
options={[
|
||
{ value: 'separated', label: 'Separated (Dach/Fassade getrennt)' },
|
||
{ value: 'solid', label: 'Solid (ein Volumen pro Gebäude)' },
|
||
]}
|
||
onChange={setBuildVariant}
|
||
/>
|
||
</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>
|
||
|
||
<Field label=""
|
||
hint="2D-Höhenlinien aus dem swissALTI3D-DEM. Werden flach auf die OKFF-Ebene des aktiven Geschosses gelegt — direkt zeichnungstauglich. Unabhängig vom 3D-Mesh.">
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||
fontSize: 11, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={getContours}
|
||
onChange={(e) => setGetContours(e.target.checked)} />
|
||
<Icon name="terrain" size={13} /> Höhenlinien (2D, auf aktivem Geschoss)
|
||
</label>
|
||
</Field>
|
||
<Field label=""
|
||
hint="3D-TIN-Mesh aus den Vertices der Höhenlinien — Delaunay-trianguliert. Stilisierter Topo-Look mit weniger Polygonen als das DEM-Mesh.">
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||
fontSize: 11, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={getContourTin}
|
||
onChange={(e) => setGetContourTin(e.target.checked)} />
|
||
<Icon name="lan" size={13} /> TIN-Mesh aus Höhenlinien
|
||
</label>
|
||
</Field>
|
||
<Field label=""
|
||
hint="Schichtenmodell: jede geschlossene Höhenlinie wird zur planaren Fläche auf ihrer Z-Höhe — der architektonische Pappmodell-Look.">
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
|
||
fontSize: 11, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={getContourSchicht}
|
||
onChange={(e) => setGetContourSchicht(e.target.checked)} />
|
||
<Icon name="stacks" size={13} /> Schichtenmodell aus Höhenlinien
|
||
</label>
|
||
</Field>
|
||
{(getContours || getContourTin || getContourSchicht) && (
|
||
<Field label="HÖHEN-ABSTAND">
|
||
<Radio value={contourInt}
|
||
options={[
|
||
{ value: '1.0', label: '1 m (fein)' },
|
||
{ value: '2.0', label: '2 m (Standard)' },
|
||
{ value: '5.0', label: '5 m (grob)' },
|
||
]}
|
||
onChange={setContourInt} />
|
||
</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 im Projekt-Ordner neben der .3dm gecacht (Fallback: ~/Library/Caches/Dossier/swisstopo/ wenn ungespeichert)` : '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>
|
||
)
|
||
}
|