Symbol-Funktion in Elemente-Panel (Phase S1+S2)
Schema (library.py): - Item-Format erweitert: files2d + files3d (Backwards-compat zu 'files') - _build_variant_block + _place_instance + Layer-Routing pro Variante - import_item akzeptiert at_point + layer2d/layer3d - _ensure_block_definition mit variant-Suffix (dossier_lib_<id>_2d/_3d) Backend (elemente.py): - _layer_path_symbole(geschoss_name, variant) → <geschoss>::40_SYMBOLE:: SYMBOLE_2D bzw. SYMBOLE_3D - Default-Ebene 40 SYMBOLE via _find_ebene_sublayer_name - LIST_LIBRARY-Handler: sendet Library-Manifest als LIBRARY_LIST - CREATE_SYMBOL-Handler: interactive GetPoint im aktiven Viewport, laedt Block-Def + platziert Instanz(en) auf den richtigen Ebenen - Pair-Items (2D+3D) werden an gleichem Punkt beidseitig platziert → Top zeigt 2D-Layer, Persp zeigt 3D-Layer wenn User entsprechend Sichtbarkeit setzt Frontend: - SymbolPicker Modal-Component: Grid mit Symbol/Object-Cards, Search, Type-Filter (Alle/Symbole/Objekte), Doppelklick = Pick - Symbol-Button in ElementeApp (PillGroup "Library") oeffnet Modal + triggert listLibrary() fuer aktuelle Items - createSymbol(id) → Backend → GetPoint → Place Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,9 @@ import {
|
||||
openSwisstopo, openSwisstopoDialog, openOsmDialog,
|
||||
updateElement, deleteElement, openElementeUebersicht, openElementeProperties,
|
||||
saveOeffStyle, deleteOeffStyle,
|
||||
listLibrary, createSymbol,
|
||||
} from './lib/rhinoBridge'
|
||||
import SymbolPicker from './components/SymbolPicker'
|
||||
|
||||
const labelXs = {
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||
@@ -327,7 +329,16 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) {
|
||||
const [treppeMenuOpen, setTreppeMenuOpen] = useState(false)
|
||||
const [stuetzeMenuOpen, setStuetzeMenuOpen] = useState(false)
|
||||
const [traegerMenuOpen, setTraegerMenuOpen] = useState(false)
|
||||
const [symbolPickerOpen, setSymbolPickerOpen] = useState(false)
|
||||
const [libraryItems, setLibraryItems] = useState([])
|
||||
const treppeWrapperRef = useRef(null)
|
||||
|
||||
// Library-Items kommen via LIBRARY_LIST message vom Backend nach LIST_LIBRARY.
|
||||
useEffect(() => {
|
||||
onMessage('LIBRARY_LIST', ({ items }) => {
|
||||
if (Array.isArray(items)) setLibraryItems(items)
|
||||
})
|
||||
}, [])
|
||||
const dis = noGeschoss
|
||||
const baseHint = (label) =>
|
||||
noGeschoss ? 'Erst im Ebenen-Manager ein Geschoss aktivieren'
|
||||
@@ -477,6 +488,14 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) {
|
||||
onClick={() => createRaum({})} />
|
||||
</PillGroup>
|
||||
|
||||
<PillGroup label="Library">
|
||||
<PillButton icon="inventory_2" label="Symbol"
|
||||
hint={dis ? baseHint('Symbol') :
|
||||
'Library-Item auswählen · im Viewport Punkt klicken zum Platzieren'}
|
||||
disabled={dis}
|
||||
onClick={() => { listLibrary(); setSymbolPickerOpen(true) }} />
|
||||
</PillGroup>
|
||||
|
||||
<PillGroup label="Importer">
|
||||
<PillButton icon="download" label="Swisstopo"
|
||||
hint="Vollautomatischer Import via swisstopo STAC-API: Adresse suchen, Radius wählen, Gebäude + Terrain + Luftbild holen"
|
||||
@@ -488,6 +507,16 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) {
|
||||
hint="Öffnet map.geo.admin.ch im Browser zur visuellen Inspektion"
|
||||
onClick={() => openSwisstopo('both')} />
|
||||
</PillGroup>
|
||||
|
||||
{symbolPickerOpen && (
|
||||
<SymbolPicker
|
||||
items={libraryItems}
|
||||
onPick={(id) => {
|
||||
setSymbolPickerOpen(false)
|
||||
createSymbol(id)
|
||||
}}
|
||||
onClose={() => setSymbolPickerOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import Icon from './Icon'
|
||||
import { BarToggle, BarButton, BAR_H } from './BarControls'
|
||||
|
||||
/* SymbolPicker — Modal-Overlay: zeigt Library-Items (symbol/object) als
|
||||
Grid. Klick auf Item → onPick(id). Schliesst via onClose. */
|
||||
|
||||
function ItemPreview({ item }) {
|
||||
// Vorschau: type-spezifisches Icon im Center auf bg-input.
|
||||
// Spaeter: PNG-Thumbnail aus library/previews/.
|
||||
const iconName = item.type === 'symbol' ? 'navigation' : 'forest'
|
||||
return (
|
||||
<div style={{
|
||||
height: 56,
|
||||
background: 'var(--bg-input)',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Icon name={iconName} size={24}
|
||||
style={{ color: 'var(--text-muted)' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemCard({ item, onPick }) {
|
||||
return (
|
||||
<button onClick={() => onPick(item.id)}
|
||||
onDoubleClick={() => onPick(item.id)}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
border: '1px solid var(--border)', borderRadius: 8,
|
||||
background: 'var(--bg-section)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer', padding: 0, textAlign: 'left',
|
||||
transition: 'border-color 0.15s, transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border)'
|
||||
}}
|
||||
title="Klick zum Platzieren"
|
||||
>
|
||||
<ItemPreview item={item} />
|
||||
<div style={{ padding: '6px 8px',
|
||||
display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{item.name}</span>
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{item.type === 'symbol' ? '2D-Symbol'
|
||||
: item.type === 'object' ? '2D + 3D'
|
||||
: item.type}
|
||||
{((item.tags || []).length > 0) && ' · ' + item.tags.slice(0, 2).join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SymbolPicker({ items, onPick, onClose }) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
|
||||
// Nur symbol/object — material wird hier nicht gepicked
|
||||
const placable = useMemo(() =>
|
||||
(items || []).filter(it =>
|
||||
it.type === 'symbol' || it.type === 'object'),
|
||||
[items])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return placable.filter(it => {
|
||||
if (typeFilter !== 'all' && it.type !== typeFilter) return false
|
||||
if (!q) return true
|
||||
if ((it.name || '').toLowerCase().includes(q)) return true
|
||||
if ((it.tags || []).some(t => t.toLowerCase().includes(q))) return true
|
||||
return false
|
||||
})
|
||||
}, [placable, search, typeFilter])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 200,
|
||||
background: 'var(--bg-overlay)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}} onClick={onClose}>
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '90%', maxWidth: 640, maxHeight: '80vh',
|
||||
background: 'var(--bg-dialog)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'var(--font)', fontSize: 11,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<Icon name="inventory_2" size={14}
|
||||
style={{ color: 'var(--accent)' }} />
|
||||
<span style={{ flex: 1, fontWeight: 600, fontSize: 12 }}>
|
||||
Symbol / Objekt einfuegen
|
||||
</span>
|
||||
<BarButton icon="close" onClick={onClose} title="Schliessen" />
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<input type="text" value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Suchen (Name oder Tag)…"
|
||||
autoFocus
|
||||
style={{ flex: 1, height: BAR_H, padding: '0 12px',
|
||||
fontSize: 11 }} />
|
||||
<BarToggle label="Alle" active={typeFilter === 'all'}
|
||||
onClick={() => setTypeFilter('all')} />
|
||||
<BarToggle label="Symbole" active={typeFilter === 'symbol'}
|
||||
onClick={() => setTypeFilter('symbol')} />
|
||||
<BarToggle label="Objekte" active={typeFilter === 'object'}
|
||||
onClick={() => setTypeFilter('object')} />
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{ flex: 1, minHeight: 200, overflowY: 'auto',
|
||||
padding: 12 }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: 40, textAlign: 'center',
|
||||
color: 'var(--text-muted)' }}>
|
||||
{placable.length === 0
|
||||
? 'Keine Symbole/Objekte in der Library.'
|
||||
: 'Nichts gefunden.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))',
|
||||
gap: 8,
|
||||
}}>
|
||||
{filtered.map(it => (
|
||||
<ItemCard key={it.id} item={it} onPick={onPick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '8px 14px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-section)',
|
||||
fontSize: 10, color: 'var(--text-muted)',
|
||||
}}>
|
||||
Klick auf Item → im Viewport Punkt waehlen zum Platzieren.
|
||||
{filtered.length > 0 && (
|
||||
<span> · {filtered.length} / {placable.length}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -335,6 +335,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', {}) }
|
||||
// Library-Symbol/Object — Picker im Elemente-Panel
|
||||
export function listLibrary() { send('LIST_LIBRARY', {}) }
|
||||
export function createSymbol(id) { send('CREATE_SYMBOL', { id }) }
|
||||
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', {}) }
|
||||
|
||||
Reference in New Issue
Block a user