e1b63aa4e6
Beim Hinzufuegen oder Importieren eines Library-Items wird automatisch ein PNG-Thumbnail vom Item generiert (Top-View, 128x128) und in library/previews/<id>.png abgelegt. Frontend rendert die Previews als Base64-Data-URIs (sicher gegen WebKit-file://-Restriktionen). library.py: - _previews_dir(): legt previews/-Folder an - _preview_rel_for(asset_rel): predictable PNG-Pfad pro Item - _capture_thumbnail_of_objects(): hided andere Objekte temporaer, switcht auf Top-Parallel, ZoomBoundingBox, CaptureToBitmap → PNG, restored Viewport + Hidden-State - read_preview_data_uri(): liest PNG + encoded als data:image/png;base64 - Hook in convert_to_3dm_via_import (vor Cleanup) + save_selection_to_asset rhinopanel.py: - _enrich_library_items_with_previews(): haengt previewDataUri an jedes Item das ein preview-Feld hat - Initial-Params + _send_library + ElementeBridge._cmd_list_library liefern angereicherte Items - _add_library_file + _save_selection_as_library setzen preview-Pfad im Item wenn Thumbnail-Datei existiert Frontend: - SymbolPicker.ItemPreview: rendert <div backgroundImage> mit Base64-URI wenn vorhanden, sonst Icon-Fallback - ProjectSettingsDialog Symbole-Tab: List-Row + Detail-Identity zeigen Thumbnail (32px in Liste, 56px im Detail), Icon nur als Fallback Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
204 lines
6.7 KiB
React
204 lines
6.7 KiB
React
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 }) {
|
|
// Thumbnail wenn vorhanden, sonst type-spezifisches Icon
|
|
if (item.previewDataUri) {
|
|
return (
|
|
<div style={{
|
|
height: 80,
|
|
background: 'var(--bg-input)',
|
|
borderBottom: '1px solid var(--border-light)',
|
|
backgroundImage: `url("${item.previewDataUri}")`,
|
|
backgroundSize: 'contain',
|
|
backgroundPosition: 'center',
|
|
backgroundRepeat: 'no-repeat',
|
|
}} />
|
|
)
|
|
}
|
|
const iconName = item.type === 'symbol' ? 'navigation' : 'forest'
|
|
return (
|
|
<div style={{
|
|
height: 80,
|
|
background: 'var(--bg-input)',
|
|
borderBottom: '1px solid var(--border-light)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<Icon name={iconName} size={28}
|
|
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, embedded = false }) {
|
|
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])
|
|
|
|
const innerStyle = embedded ? {
|
|
width: '100%', height: '100%',
|
|
background: 'var(--bg-dialog)',
|
|
display: 'flex', flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
color: 'var(--text-primary)',
|
|
fontFamily: 'var(--font)', fontSize: 11,
|
|
} : {
|
|
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)',
|
|
}
|
|
|
|
const content = (
|
|
<div style={innerStyle}>
|
|
{/* 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>
|
|
)
|
|
|
|
if (embedded) return content
|
|
return (
|
|
<div onClick={onClose}
|
|
style={{
|
|
position: 'fixed', inset: 0, zIndex: 200,
|
|
background: 'var(--bg-overlay)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<div onClick={(e) => e.stopPropagation()}>{content}</div>
|
|
</div>
|
|
)
|
|
}
|