Files
DOSSIER/src/SwisstopoApp.jsx
T
karim 8f691e37c4 Projektdaten in Project-Settings + Swisstopo-Adress-Prefill
Schema-Erweiterung:
- _PROJECT_SETTINGS_DEFAULTS hat jetzt 'project'-Block mit
  name / number / address / bauherr / architekt / notes / projectZeroMum
- _normalize_project_meta stripped Strings + clampt mum als float
- load/save_project_settings handeln das 'project'-feld
- save_project_settings spiegelt projectZeroMum auch in den Legacy-Key
  dossier_project_zero_mum (fuer Geschoss-Settings-Dialog)
- load_project_settings liest Legacy-Key als Fallback wenn neuer Wert
  noch nicht gesetzt

UI:
- InlineTextField + TextareaField Helpers (Pill-Stil)
- Projektdaten-Section in Voreinstellungen-Tab:
  Name, Projekt-Nr., Adresse, Bauherrschaft, Architekt:in,
  EG-Nullpunkt m.ü.M (mit Hinweis auf Swisstopo-Nutzung), Notizen

Swisstopo:
- _cmd_open_swisstopo_dialog laedt Projekt-Adresse + sendet projectAddress
  im SWISSTOPO_STATE
- SwisstopoApp: vorbelegt searchText mit projectAddress wenn Feld leer
  ist (User-Input wird nicht ueberschrieben)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 03:21:19 +02:00

490 lines
20 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 [getContourPatch, setGetContourPatch] = 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')
// Terrain als geschlossenes Volumen (mit Boden 10m unter tiefstem Punkt)
// damit Section-Cuts gefuellte Querschnitte zeigen statt nur Linien.
// Wirkt auf 3D-Mesh / TIN / Patch — nicht auf 2D-Hoehenlinien und Schichten.
const [terrainVolume, setTerrainVolume] = useState(false)
const [terrainVolumeDepth, setTerrainVolumeDepth] = useState('10')
// 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, projectAddress }) => {
if (Array.isArray(ebenen)) setEbenen(ebenen)
// Projekt-Adresse aus Project-Settings als Vorschlag — nur belegen
// wenn das Feld noch leer ist (User-Input nicht ueberschreiben).
if (projectAddress) {
setSearchText(prev => prev && prev.trim() ? prev : projectAddress)
}
})
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 && !getContourPatch && !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 (getContourPatch) kinds.push('contour_patch')
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,
terrainAsVolume: terrainVolume,
terrainVolumeDepth: parseFloat(terrainVolumeDepth) || 10,
})
}
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>
{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>
<Field label=""
hint="Patch-Terrain: NURBS-Surface gefittet durch alle Höhenlinien (Rhinos Patch-Befehl). Glatte, kontinuierliche Topo-Oberfläche — die kanonische Methode für Terrain aus Konturen.">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={getContourPatch}
onChange={(e) => setGetContourPatch(e.target.checked)} />
<Icon name="landscape" size={13} /> Patch-Terrain aus Höhenlinien
</label>
</Field>
{(getContours || getContourTin || getContourSchicht || getContourPatch) && (
<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>
)}
{(getTerrain || getContourTin || getContourPatch) && (
<>
<SectionLabel>Nachbearbeitung</SectionLabel>
<Field label=""
hint="Wandelt die oben gewaehlten 3D-Terrain-Quellen (Terrain / TIN / Patch) in geschlossene Mesh-Volumen um — Skirt + Boden bei (min_z Tiefe). Damit liefert eine Clipping-Plane einen gefuellten Querschnitt statt nur Konturlinien. 2D-Linien und Schichten sind nicht betroffen.">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={terrainVolume}
onChange={(e) => setTerrainVolume(e.target.checked)} />
<Icon name="layers" size={13} /> Terrain als Volumen (mit Boden, schneidbar)
</label>
</Field>
{terrainVolume && (
<Field label="TIEFE">
<input type="text"
value={terrainVolumeDepth}
onChange={(e) => setTerrainVolumeDepth(e.target.value)}
style={{ width: 60, textAlign: 'right' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
m unter tiefstem Punkt
</span>
</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>
)
}