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:
2026-05-25 03:31:54 +02:00
parent 8f691e37c4
commit 8184f559fc
5 changed files with 394 additions and 56 deletions
+85
View File
@@ -929,6 +929,20 @@ def _layer_path_raum(doc, geschoss_name):
return "{}::{}".format(geschoss_name, sub) return "{}::{}".format(geschoss_name, sub)
def _layer_path_symbole(doc, geschoss_name, variant):
"""Symbol-Layer (Library-Items). variant = '2d' oder '3d'.
Pfad: <geschoss>::40_SYMBOLE::SYMBOLE_2D bzw. SYMBOLE_3D.
User kann die Top-Level-Ebene 40 frei rein-/raus-schalten die
Sub-Layer 2D/3D regeln View-spezifische Sichtbarkeit (Top zeigt 2D,
Persp zeigt 3D)."""
sub = _find_ebene_sublayer_name(doc, ["symbol"],
"40", "SYMBOLE",
default_color="#5fa896", default_lw=0.13)
if variant.lower() == "3d":
return "{}::{}::SYMBOLE_3D".format(geschoss_name, sub)
return "{}::{}::SYMBOLE_2D".format(geschoss_name, sub)
def _ensure_layer(doc, path): def _ensure_layer(doc, path):
"""Stellt sicher, dass ein Layer-Pfad existiert. Liefert Layer-Index.""" """Stellt sicher, dass ein Layer-Pfad existiert. Liefert Layer-Index."""
idx = doc.Layers.FindByFullPath(path, -1) idx = doc.Layers.FindByFullPath(path, -1)
@@ -6030,6 +6044,8 @@ class ElementeBridge(panel_base.BaseBridge):
elif t == "CREATE_TRAEGER": self._cmd_create_traeger(p) elif t == "CREATE_TRAEGER": self._cmd_create_traeger(p)
elif t == "CREATE_RAUM": self._cmd_create_raum(p) elif t == "CREATE_RAUM": self._cmd_create_raum(p)
elif t == "EXPORT_RAEUME": self._cmd_export_raeume(p) elif t == "EXPORT_RAEUME": self._cmd_export_raeume(p)
elif t == "LIST_LIBRARY": self._cmd_list_library(p)
elif t == "CREATE_SYMBOL": self._cmd_create_symbol(p)
elif t == "OPEN_SWISSTOPO": self._cmd_open_swisstopo(p) elif t == "OPEN_SWISSTOPO": self._cmd_open_swisstopo(p)
elif t == "IMPORT_SWISSTOPO": self._cmd_import_swisstopo(p) elif t == "IMPORT_SWISSTOPO": self._cmd_import_swisstopo(p)
elif t == "OPEN_SWISSTOPO_DIALOG": self._cmd_open_swisstopo_dialog(p) elif t == "OPEN_SWISSTOPO_DIALOG": self._cmd_open_swisstopo_dialog(p)
@@ -8071,6 +8087,75 @@ class ElementeBridge(panel_base.BaseBridge):
# unter dem aktiven Geschoss. # unter dem aktiven Geschoss.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# ------------------------------------------------------------------
# Library-Symbol/Object — listet + platziert Library-Items im Doc
# ------------------------------------------------------------------
def _cmd_list_library(self, p):
"""Liefert Library-Manifest ans Frontend (fuer den Symbol-Picker
im Elemente-Panel). Antwort: LIBRARY_LIST message."""
try:
import library
manifest = library.load_manifest()
self.send("LIBRARY_LIST", {
"items": manifest.get("items", []),
"name": manifest.get("name", "Dossier-Library"),
})
except Exception as ex:
print("[ELEMENTE] list library:", ex)
self.send("LIBRARY_LIST", {"items": [], "name": ""})
def _cmd_create_symbol(self, p):
"""Platziert ein Library-Item (symbol/object) im Doc. Interactive
GetPoint im aktiven Viewport User klickt Position.
Payload: { id: libraryId }. Layer-Routing auf 40_SYMBOLE::2D/3D
unter aktivem Geschoss."""
lib_id = (p.get("id") or "").strip()
if not lib_id:
print("[ELEMENTE] CREATE_SYMBOL ohne id")
return
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: return
import library
item = library.find_item(lib_id)
if item is None:
print("[ELEMENTE] CREATE_SYMBOL: item not found", lib_id)
return
if item.get("type") not in ("symbol", "object"):
print("[ELEMENTE] CREATE_SYMBOL: ungueltiger Typ", item.get("type"))
return
# Interactive point pick
try:
import Rhino.Input.Custom as ric
from Rhino.Input import GetResult
except Exception as ex:
print("[ELEMENTE] Imports:", ex); return
try:
gp = ric.GetPoint()
gp.SetCommandPrompt("Symbol platzieren — Punkt waehlen")
res = gp.Get()
if res != GetResult.Point: return
pt = gp.Point()
except Exception as ex:
print("[ELEMENTE] GetPoint:", ex); return
# Layer-Routing
geschoss_id = _active_geschoss_id(doc)
g = _geschoss_by_id(doc, geschoss_id) if geschoss_id else None
geschoss_name = (g.get("name") if g else "EG") or "EG"
layer2d = _ensure_layer(doc, _layer_path_symbole(doc, geschoss_name, "2d"))
layer3d = _ensure_layer(doc, _layer_path_symbole(doc, geschoss_name, "3d"))
# Import + place
undo_serial = doc.BeginUndoRecord(
"Symbol einfuegen: " + (item.get("name") or ""))
try:
ok, msg = library.import_item(doc, lib_id, at_point=pt,
layer2d=layer2d, layer3d=layer3d)
print("[ELEMENTE] CREATE_SYMBOL '{}': ok={} ({})".format(
item.get("name"), ok, msg))
finally:
try: doc.EndUndoRecord(undo_serial)
except Exception: pass
def _cmd_open_swisstopo(self, p): def _cmd_open_swisstopo(self, p):
"""Oeffnet map.geo.admin.ch im Default-Browser mit den swisstopo- """Oeffnet map.geo.admin.ch im Default-Browser mit den swisstopo-
Layern voreingestellt. Param `mode`: 'buildings' | 'terrain' | 'both'. Layern voreingestellt. Param `mode`: 'buildings' | 'terrain' | 'both'.
+99 -55
View File
@@ -156,6 +156,14 @@ def _empty_manifest():
def _normalize_item(it): def _normalize_item(it):
if not isinstance(it, dict): return None if not isinstance(it, dict): return None
if not it.get("id") or not it.get("type"): return None if not it.get("id") or not it.get("type"): return None
# 2D/3D-Files: ein Item kann beide haben (Objekt mit Plan-Darstellung 2D
# + perspektivischem 3D-Modell) oder nur eines. Legacy 'files'-Feld wird
# als files2d interpretiert (Symbole = nur 2D historisch).
files_legacy = list(it.get("files") or [])
files2d = list(it.get("files2d") or [])
files3d = list(it.get("files3d") or [])
if not files2d and not files3d and files_legacy:
files2d = files_legacy
out = { out = {
"id": str(it["id"]), "id": str(it["id"]),
"type": str(it["type"]), "type": str(it["type"]),
@@ -164,9 +172,10 @@ def _normalize_item(it):
"tags": list(it.get("tags") or []), "tags": list(it.get("tags") or []),
"preview": it.get("preview"), "preview": it.get("preview"),
"data": it.get("data") or {}, "data": it.get("data") or {},
# files: relative Pfade (zur library_root()) auf .3dm-Fragmente. "files2d": files2d,
# Symbol/Object-Import liest die ueber File3dm.Read. "files3d": files3d,
"files": list(it.get("files") or []), # legacy "files" mitgeben fuer Backwards-Kompatibilitaet
"files": files_legacy or files2d,
} }
return out return out
@@ -220,13 +229,14 @@ def _lib_asset_path(rel_path):
return os.path.join(library_root(), rel_path) return os.path.join(library_root(), rel_path)
def _block_name_for(item): def _block_name_for(item, variant=""):
"""Stabiler Block-Name fuer InstanceDefinition. Format: """Stabiler Block-Name fuer InstanceDefinition. Format:
'dossier_lib_<libraryId>' — Dedup ueber Lib-ID, nicht Item-Name (sonst 'dossier_lib_<libraryId>[_<variant>]'. variant '2d'/'3d' fuer Pair-Items."""
Konflikt bei Umbenennen)."""
lid = item.get("id") or "" lid = item.get("id") or ""
safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in lid) safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in lid)
return "dossier_lib_" + safe name = "dossier_lib_" + safe
if variant: name += "_" + variant
return name
def _read_3dm_geometry(abs_path): def _read_3dm_geometry(abs_path):
@@ -256,24 +266,24 @@ def _read_3dm_geometry(abs_path):
return [] return []
def _ensure_block_definition(doc, item, geometry_attrs): def _ensure_block_definition(doc, item, geometry_attrs, variant=""):
"""Erstellt InstanceDefinition fuer dieses Library-Item wenn noch nicht """Erstellt InstanceDefinition fuer dieses Library-Item wenn noch nicht
da. Returns (idx, was_created). idx<0 bei Fehler.""" da. variant unterscheidet '2d'/'3d'. Returns (idx, was_created)."""
if Rhino is None: return -1, False if Rhino is None: return -1, False
name = _block_name_for(item) name = _block_name_for(item, variant=variant)
try: try:
existing = doc.InstanceDefinitions.Find(name) existing = doc.InstanceDefinitions.Find(name)
except Exception: except Exception:
existing = None existing = None
if existing is not None: if existing is not None:
return existing.Index, False return existing.Index, False
# Geometry + Attributes separat sammeln
geoms = [g for g, _ in geometry_attrs if g is not None] geoms = [g for g, _ in geometry_attrs if g is not None]
attrs = [a for _, a in geometry_attrs] attrs = [a for _, a in geometry_attrs]
if not geoms: if not geoms:
return -1, False return -1, False
base_pt = Rhino.Geometry.Point3d(0, 0, 0) base_pt = Rhino.Geometry.Point3d(0, 0, 0)
desc = "Dossier-Library: " + (item.get("name") or "") desc_suffix = " ({})".format(variant.upper()) if variant else ""
desc = "Dossier-Library: " + (item.get("name") or "") + desc_suffix
try: try:
idx = doc.InstanceDefinitions.Add(name, desc, base_pt, geoms, attrs) idx = doc.InstanceDefinitions.Add(name, desc, base_pt, geoms, attrs)
return idx, True return idx, True
@@ -282,52 +292,83 @@ def _ensure_block_definition(doc, item, geometry_attrs):
return -1, False return -1, False
def import_symbol(doc, item): def _build_variant_block(doc, item, variant_files, variant_label):
"""Importiert ein Symbol-Item (= 2D-Block) in das Doc. Liest die """Liest .3dm-Files und legt eine InstanceDefinition (variant) an.
.3dm-Datei(en) aus item.files, erstellt eine InstanceDefinition mit Returns Block-Index oder -1."""
stabilem Namen, fuegt eine Instanz am Ursprung ein. if not variant_files: return -1
Returns (ok, message)."""
return _import_block_like(doc, item, kind="symbol")
def import_object(doc, item):
"""Importiert ein Object-Item (= 3D-Block, BIM-Element) in das Doc.
Gleiche Pipeline wie import_symbol — Symbol/Object unterscheiden sich
nur in der UI-Kategorisierung."""
return _import_block_like(doc, item, kind="object")
def _import_block_like(doc, item, kind):
if doc is None: return False, "Kein aktives Dokument"
files = item.get("files") or []
if not files:
return False, "Item hat keine .3dm-Files: " + str(item.get("id"))
# Alle Files zusammen in eine Block-Definition packen.
all_geom = [] all_geom = []
for f in files: for f in variant_files:
abs_p = _lib_asset_path(f) abs_p = _lib_asset_path(f)
all_geom.extend(_read_3dm_geometry(abs_p)) all_geom.extend(_read_3dm_geometry(abs_p))
if not all_geom: if not all_geom:
return False, "Keine importierbare Geometrie in {}".format(files) return -1
idx, created = _ensure_block_definition(doc, item, all_geom) idx, _ = _ensure_block_definition(doc, item, all_geom, variant=variant_label)
if idx < 0: return idx
return False, "InstanceDefinition konnte nicht erstellt werden"
# Instanz am Ursprung einfuegen — User kann danach verschieben.
def _place_instance(doc, block_idx, point, layer_idx=-1):
"""Platziert eine InstanceObject am gegebenen Punkt, optional auf
spezifischem Layer. Returns Guid oder None."""
if block_idx < 0: return None
try: try:
xform = Rhino.Geometry.Transform.Identity xform = Rhino.Geometry.Transform.Translation(
inst_id = doc.Objects.AddInstanceObject(idx, xform) point.X, point.Y, point.Z)
if inst_id == System_Guid_Empty(): attrs = Rhino.DocObjects.ObjectAttributes()
return False, "AddInstanceObject fehlgeschlagen" if layer_idx >= 0:
attrs.LayerIndex = layer_idx
gid = doc.Objects.AddInstanceObject(block_idx, xform, attrs)
return gid
except Exception as ex:
print("[LIBRARY] _place_instance:", ex)
return None
def import_symbol(doc, item, at_point=None, layer2d=-1, layer3d=-1):
return _import_block_like(doc, item, at_point=at_point,
layer2d=layer2d, layer3d=layer3d)
def import_object(doc, item, at_point=None, layer2d=-1, layer3d=-1):
return _import_block_like(doc, item, at_point=at_point,
layer2d=layer2d, layer3d=layer3d)
def _import_block_like(doc, item, at_point=None, layer2d=-1, layer3d=-1):
"""Platziert ein Library-Item im Doc. Item kann files2d und/oder files3d
haben → beide Varianten werden geladen + an gleichem Punkt platziert auf
ihren respektiven Layern.
at_point: Point3d (default = Origin).
layer2d/3d: optionale Layer-Indizes (default = aktiver Layer)."""
if doc is None: return False, "Kein aktives Dokument"
files2d = item.get("files2d") or []
files3d = item.get("files3d") or []
if not files2d and not files3d:
# Legacy fallback
legacy = item.get("files") or []
if legacy: files2d = legacy
if not files2d and not files3d:
return False, "Item hat keine .3dm-Files: " + str(item.get("id"))
if at_point is None:
at_point = Rhino.Geometry.Point3d(0, 0, 0)
placed = []
if files2d:
idx2 = _build_variant_block(doc, item, files2d, "2d")
if idx2 >= 0:
gid = _place_instance(doc, idx2, at_point, layer_idx=layer2d)
if gid is not None and gid != System_Guid_Empty():
placed.append("2D")
if files3d:
idx3 = _build_variant_block(doc, item, files3d, "3d")
if idx3 >= 0:
gid = _place_instance(doc, idx3, at_point, layer_idx=layer3d)
if gid is not None and gid != System_Guid_Empty():
placed.append("3D")
if not placed:
return False, "Konnte keinen Block platzieren (Files fehlen?)"
try: doc.Views.Redraw() try: doc.Views.Redraw()
except Exception: pass except Exception: pass
msg = ("Block '{}' importiert + am Ursprung eingefuegt".format( return True, "{} eingefuegt ({})".format(
item.get("name") or "") item.get("name") or "", "+".join(placed))
if created else
"Block bereits vorhanden — neue Instanz eingefuegt")
return True, msg
except Exception as ex:
print("[LIBRARY] AddInstanceObject:", ex)
return False, str(ex)
def System_Guid_Empty(): def System_Guid_Empty():
@@ -339,16 +380,19 @@ def System_Guid_Empty():
return None return None
def import_item(doc, item_id): def import_item(doc, item_id, at_point=None, layer2d=-1, layer3d=-1):
"""Type-dispatching Import. material → Project-Settings-Liste. """Type-dispatching Import. material → Project-Settings-Liste.
symbol/object → InstanceDefinition im Doc via File3dm.Read.""" symbol/object → InstanceDefinition im Doc via File3dm.Read.
at_point + layer2d/3d nur fuer symbol/object."""
item = find_item(item_id) item = find_item(item_id)
if item is None: return False, "Item nicht gefunden: " + str(item_id) if item is None: return False, "Item nicht gefunden: " + str(item_id)
t = item.get("type") t = item.get("type")
if t == "material": if t == "material":
return import_material(doc, item) return import_material(doc, item)
if t == "symbol": if t == "symbol":
return import_symbol(doc, item) return import_symbol(doc, item, at_point=at_point,
layer2d=layer2d, layer3d=layer3d)
if t == "object": if t == "object":
return import_object(doc, item) return import_object(doc, item, at_point=at_point,
layer2d=layer2d, layer3d=layer3d)
return False, "Unbekannter Typ: '{}'".format(t) return False, "Unbekannter Typ: '{}'".format(t)
+29
View File
@@ -9,7 +9,9 @@ import {
openSwisstopo, openSwisstopoDialog, openOsmDialog, openSwisstopo, openSwisstopoDialog, openOsmDialog,
updateElement, deleteElement, openElementeUebersicht, openElementeProperties, updateElement, deleteElement, openElementeUebersicht, openElementeProperties,
saveOeffStyle, deleteOeffStyle, saveOeffStyle, deleteOeffStyle,
listLibrary, createSymbol,
} from './lib/rhinoBridge' } from './lib/rhinoBridge'
import SymbolPicker from './components/SymbolPicker'
const labelXs = { const labelXs = {
fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
@@ -327,7 +329,16 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) {
const [treppeMenuOpen, setTreppeMenuOpen] = useState(false) const [treppeMenuOpen, setTreppeMenuOpen] = useState(false)
const [stuetzeMenuOpen, setStuetzeMenuOpen] = useState(false) const [stuetzeMenuOpen, setStuetzeMenuOpen] = useState(false)
const [traegerMenuOpen, setTraegerMenuOpen] = useState(false) const [traegerMenuOpen, setTraegerMenuOpen] = useState(false)
const [symbolPickerOpen, setSymbolPickerOpen] = useState(false)
const [libraryItems, setLibraryItems] = useState([])
const treppeWrapperRef = useRef(null) 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 dis = noGeschoss
const baseHint = (label) => const baseHint = (label) =>
noGeschoss ? 'Erst im Ebenen-Manager ein Geschoss aktivieren' noGeschoss ? 'Erst im Ebenen-Manager ein Geschoss aktivieren'
@@ -477,6 +488,14 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) {
onClick={() => createRaum({})} /> onClick={() => createRaum({})} />
</PillGroup> </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"> <PillGroup label="Importer">
<PillButton icon="download" label="Swisstopo" <PillButton icon="download" label="Swisstopo"
hint="Vollautomatischer Import via swisstopo STAC-API: Adresse suchen, Radius wählen, Gebäude + Terrain + Luftbild holen" 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" hint="Öffnet map.geo.admin.ch im Browser zur visuellen Inspektion"
onClick={() => openSwisstopo('both')} /> onClick={() => openSwisstopo('both')} />
</PillGroup> </PillGroup>
{symbolPickerOpen && (
<SymbolPicker
items={libraryItems}
onPick={(id) => {
setSymbolPickerOpen(false)
createSymbol(id)
}}
onClose={() => setSymbolPickerOpen(false)} />
)}
</div> </div>
) )
} }
+177
View File
@@ -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>
)
}
+3
View File
@@ -335,6 +335,9 @@ export function createStuetze(p) { send('CREATE_STUETZE', p || {}) }
export function createTraeger(p) { send('CREATE_TRAEGER', p || {}) } export function createTraeger(p) { send('CREATE_TRAEGER', p || {}) }
export function createRaum(p) { send('CREATE_RAUM', p || {}) } export function createRaum(p) { send('CREATE_RAUM', p || {}) }
export function exportRaeume() { send('EXPORT_RAEUME', {}) } 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 openSwisstopo(mode) { send('OPEN_SWISSTOPO', { mode: mode || 'both' }) }
export function importSwisstopo(kind) { send('IMPORT_SWISSTOPO', { kind: kind || 'buildings' }) } export function importSwisstopo(kind) { send('IMPORT_SWISSTOPO', { kind: kind || 'buildings' }) }
export function openSwisstopoDialog() { send('OPEN_SWISSTOPO_DIALOG', {}) } export function openSwisstopoDialog() { send('OPEN_SWISSTOPO_DIALOG', {}) }