Files
DOSSIER/src/OsmApp.jsx
T
karim 13a5e1eb7a AGPL-3.0 Dual-Lizenz + Pill-Stil-UI + Section-Style-Overhaul + Plan-Mode-Template
Lizenz:
- AGPL-3.0 LICENSE-File im Repo-Root (GNU Volltext)
- SPDX-Header + Copyright in allen Source-Files (Python/JSX/JS/Rust)
- license-Feld in package.json + Cargo.toml
- About-App komplett neu: Dual-Lizenz-Block (AGPL + Commercial),
  openbureau-Branding, Version-Pills, made-in-Switzerland-Footer

UI-Restyle (3 Wellen) — alle Dialoge + Satellites + Panel-Sidebars
auf gemeinsamen Pill-Stil aus BarControls (BarToggle/BarButton/BarCombo):
- Welle 1: GeschossDialog/Settings, AusschnittSettings, LayoutDialog
- Welle 2: ConfirmDeleteEbene, Kamera, MasseSettings, Osm, Swisstopo,
  TextEditor, AusschnittLayerDialog, LayerCombinations
- Welle 3: LayoutsApp, MassstabApp, WerkzeugeApp, OverridesApp,
  ZeichnungsebenenApp; Werkzeuge mit ElementeApp-PillGroup-Layout

GeschossDialog Header-Refactor: +Geschoss/+Zeichnung in Toolbar oben,
move-Pfeile-Spalte breiter (kein Overlap mit G-Haken)

Ausschnitte Rows als Pills, kein Outer-Border ums Suchfeld

Section-Style komplett neu (gestaltung.py + GestaltungApp.jsx):
- ObjectSectionAttributesSource.FromObject (richtiger Enum-Name fuer Mac)
- HatchPatternPrintColor + BoundaryPrintColor mit-setzen (Display = Print)
- BoundaryColor nur bei explizitem User-Override, sonst Rhino-Default
- background_color_hex Parameter (BackgroundFillMode=SolidColor)
- Readback aus GetCustomSectionStyle statt direkt aus Attributes
- UI: Schnittkante > Section Style > Solid-Fill mit proper SectionHead
- 'Boundary' (3D Pen) -> 'Background' weil sich's wie Section-Hintergrund verhaelt

Plan-Mode 'Dossier Plan' via Template:
- rhino/templates/dossier_plan.ini wird direkt geladen
- Fallback auf Technical-Clone + ini-Patch wenn Template fehlt
- Auto-Cleanup von Orphan-Modes vor Import (Name- oder Guid-Match)
- ClipSectionUsage=1 + TechnicalMask=15 als bekannte Soll-Werte
- Bei Template-Pfad keine ini-Patches (1:1 wie User exportiert)
- Sanity-Print listet alle registrierten Modes nach Anlegen

Bridge-Unification: 4 Settings-Apps (Ebenen/Project/Geschoss*Dialog)
benutzen jetzt chunkende send() statt eigene bridgeSend ohne Chunk-
Logik -> grosse Payloads (Hatch-Refs etc.) kommen nicht mehr truncated
bei Python an (loeste 'JSON-Fehler char 990'-Regression in Ebenen-
Settings)

Library-Imports robust: 'import library' jetzt Top-Level in elemente.py
+ rhinopanel.py (statt Lazy in Methoden) -> 'No module named library'-
Crashes weg auch wenn sys.path zwischendurch resettet wird

Tools fuer Display-Mode-Maintenance:
- _clean_display_modes.py (loescht alle Custom-Modes, Built-ins bleiben)
- _inspect_plan_mode.py / _inspect_obj_section.py / _inspect_obj_boundary.py
  (Diagnose-Skripte fuer SectionStyle-Property-Reverse-Engineering)
- _reset_rhino_settings.sh (Backup + Nuke der Rhino-Settings als
  letzte Bastion gegen korrupte Display-Modes)
2026-05-26 17:09:18 +02:00

315 lines
11 KiB
React

// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Karim Gabriele Varano
import { useState, useEffect, useRef } from 'react'
import Icon from './components/Icon'
import { BarToggle, BAR_H } from './components/BarControls'
import { onMessage, notifyReady } from './lib/rhinoBridge'
function send(type, payload = {}) {
if (!window.RHINO_MODE) { console.log('[OSM] →', type, payload); return }
document.title = 'RHINOMSG::' + JSON.stringify({ type, payload })
}
const pillInput = {
height: BAR_H,
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 999,
color: 'var(--text-primary)',
fontSize: 11, fontFamily: 'var(--font)',
padding: '0 10px',
outline: 'none',
boxSizing: 'border-box',
}
function Field({ label, hint, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '6px 0' }}>
{label && <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 => (
<BarToggle key={o.value} label={o.label}
active={value === o.value}
onClick={() => onChange(o.value)} />
))}
</div>
)
}
// OSM-Kategorien — Keys matchen das Backend (osm.py CATEGORIES).
const CATEGORIES = [
{ key: 'streets', label: 'Strassen', icon: 'route',
hint: 'Autobahn/Hauptstrasse/Quartierstrasse → Polylinien' },
{ key: 'buildings', label: 'Gebäudeumrisse', icon: 'apartment',
hint: 'building=* Umrisse als geschlossene Polylinien' },
{ key: 'water', label: 'Wasser (Flächen)', icon: 'water',
hint: 'natural=water (Seen, Teiche)' },
{ key: 'waterways', label: 'Wasserläufe', icon: 'waves',
hint: 'waterway=river/stream/canal' },
{ key: 'parks', label: 'Parks', icon: 'park',
hint: 'leisure=park/garden' },
{ key: 'forest', label: 'Wald & Grün', icon: 'forest',
hint: 'landuse=forest/grass/meadow' },
{ key: 'footpaths', label: 'Fuss-/Radwege', icon: 'directions_walk',
hint: 'highway=footway/path/track/cycleway' },
]
export default function OsmApp() {
// Standort
const [searchText, setSearchText] = useState('')
const [center, setCenter] = useState(null)
const [searching, setSearching] = useState(false)
// Optionen
const [radius, setRadius] = useState(200)
const [selected, setSelected] = useState({
streets: true, buildings: true, waterways: true,
parks: true, forest: true,
water: false, footpaths: false,
})
const [shift, setShift] = useState(true)
const [autoZoom, setAutoZoom] = useState(true)
const [replaceExisting, setReplaceExisting] = useState(true)
// Live-Log
const [logs, setLogs] = useState([])
const [running, setRunning] = useState(false)
const [done, setDone] = useState(false)
const logRef = useRef(null)
useEffect(() => {
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('OSM_LOG', ({ msg }) => addLog(msg))
onMessage('IMPORT_DONE', ({ count }) => {
setRunning(false); setDone(true)
addLog(`✓ Fertig — ${count} OSM-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
}, [])
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 }
const cats = Object.entries(selected).filter(([, v]) => v).map(([k]) => k)
if (cats.length === 0) { addLog('Mindestens eine Kategorie auswählen'); return }
setLogs([]); setRunning(true); setDone(false)
send('RUN_OSM_IMPORT', {
centerE: center.e, centerN: center.n,
radius: Number(radius),
categories: cats,
shiftToOrigin: shift,
autoZoom,
replaceExisting,
})
}
const toggleCat = (key) => {
setSelected(s => ({ ...s, [key]: !s[key] }))
}
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"'>
<input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch() }}
placeholder="Adresse oder Ortsname"
style={{ ...pillInput, flex: 1 }}
/>
<BarToggle label={searching ? '…' : 'Suchen'}
onClick={handleSearch}
disabled={searching || !searchText.trim()} />
</Field>
<Field label="ODER LV95-KOORDS (E / N)"
hint="Falls aus Swisstopo-Import übernommen">
<input placeholder="E"
onChange={(e) => handleManualCoords(e.target.value, center?.n || '')}
style={{ ...pillInput, width: 110, fontFamily: 'DM Mono, monospace' }} />
<span style={{ color: 'var(--text-muted)' }}>/</span>
<input placeholder="N"
onChange={(e) => handleManualCoords(center?.e || '', e.target.value)}
style={{ ...pillInput, width: 110, fontFamily: 'DM Mono, monospace' }} />
</Field>
{center && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 12px',
background: 'var(--accent-dim)',
border: '1px solid var(--accent-border)',
borderRadius: 999,
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">
<Radio value={radius}
options={[
{ value: 100, label: '100 m' },
{ value: 200, label: '200 m' },
{ value: 500, label: '500 m' },
{ value: 1000, label: '1 km' },
]}
onChange={setRadius} />
</Field>
<SectionLabel>Kategorien</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{CATEGORIES.map(cat => (
<label key={cat.key}
style={{ display: 'flex', alignItems: 'center', gap: 8,
fontSize: 11, cursor: 'pointer', padding: '3px 0' }}
title={cat.hint}>
<input type="checkbox" checked={!!selected[cat.key]}
onChange={() => toggleCat(cat.key)} />
<Icon name={cat.icon} size={13} />
<span>{cat.label}</span>
</label>
))}
</div>
<SectionLabel>Positionierung</SectionLabel>
<Field label="ORIGIN"
hint="LV95-Koords sind im Mio-Bereich. Auf 0/0/0 verschiebt 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="Bestehende OSM-Objekte (Tag dossier_osm_kind) werden vorher gelöscht.">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={replaceExisting}
onChange={(e) => setReplaceExisting(e.target.checked)} />
Bestehende OSM-Objekte vorher löschen
</label>
</Field>
<SectionLabel>Status</SectionLabel>
<div ref={logRef} style={{
height: 140, overflowY: 'auto',
padding: 8, fontSize: 10,
fontFamily: 'DM Mono, monospace',
background: 'var(--bg-base)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--r-lg)',
color: 'var(--text-secondary)',
whiteSpace: 'pre-wrap',
}}>
{logs.length === 0
? <span style={{ color: 'var(--text-muted)' }}>Bereit</span>
: logs.map((l, i) => <div key={i}>{l}</div>)}
{running && <div style={{ color: 'var(--accent)' }}>Läuft</div>}
</div>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 12px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
}}>
<div style={{ fontSize: 9, color: 'var(--text-muted)', flex: 1 }}>
Quelle: Overpass-API · © OpenStreetMap-Mitwirkende (ODbL)
</div>
<BarToggle label="Abbrechen" onClick={() => send('CANCEL')} />
<BarToggle icon="download"
label={running ? 'Lädt…' : 'Importieren'}
active
disabled={running || !center}
onClick={handleImport} />
</div>
</div>
)
}