import React, { useEffect, useState, useMemo, useCallback } from 'react' import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog' import UpdateNotifier from './components/UpdateNotifier.jsx' import Icon from './components/Icon.jsx' import { checkForAppUpdate, installAppUpdate, skipUpdateVersion, getLastUpdateCheck, formatLastCheck, isTauri, } from './utils/updater.js' // Transitive Dependency-Aufloesung: gibt Set aller Module zurueck, die // aktiviert sein muessen, wenn `selected` aktiviert sind. function resolveDeps(selected, allModules) { const byId = Object.fromEntries(allModules.map(m => [m.id, m])) const out = new Set(selected) let changed = true while (changed) { changed = false for (const id of [...out]) { const m = byId[id] if (!m) continue for (const dep of (m.dependsOn || [])) { if (!out.has(dep)) { out.add(dep); changed = true } } } } return out } function formatRelative(iso) { if (!iso) return '' try { const d = new Date(iso) const now = new Date() const diff = (now - d) / 1000 if (diff < 60) return 'vor wenigen Sek.' if (diff < 3600) return `vor ${Math.floor(diff/60)} Min.` if (diff < 86400) return `vor ${Math.floor(diff/3600)} h` if (diff < 86400 * 7) return `vor ${Math.floor(diff/86400)} Tagen` return d.toLocaleDateString('de-CH') } catch { return '' } } function formatFileSize(bytes) { if (!bytes && bytes !== 0) return '' const mb = bytes / (1024 * 1024) if (mb >= 1) return `${mb.toFixed(1)} MB` return `${(bytes / 1024).toFixed(0)} KB` } // Sortierung: gepinnte zuerst (intern nach lastOpened), dann der Rest nach // lastOpened. Stabil — ein Toggle der Pin-Eigenschaft veraendert die Reihenfolge // vorhersagbar. function sortProjects(list) { return [...list].sort((a, b) => { if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1 return (b.lastOpened || '').localeCompare(a.lastOpened || '') }) } function newTagId() { return 'tag_' + Math.random().toString(36).slice(2, 9) } // Schwarz/Weiss-Kontrast fuer Tag-Chip-Text function isDarkColor(hex) { if (!hex) return true const h = hex.replace('#', '') const n = h.length === 3 ? [0,1,2].map(i => parseInt(h[i] + h[i], 16)) : [0,2,4].map(i => parseInt(h.slice(i, i+2), 16)) // Perceived brightness return (n[0] * 0.299 + n[1] * 0.587 + n[2] * 0.114) < 140 } export default function App() { const [recent, setRecent] = useState([]) const [modules, setModules] = useState([]) const [dialog, setDialog] = useState(null) // null | { mode: 'new'|'edit', project? } const [settingsOpen, setSettingsOpen] = useState(false) const [settingsTab, setSettingsTab] = useState('rhino') const [busy, setBusy] = useState(false) const [query, setQuery] = useState('') // Window-Layouts + aktuell gewaehltes Default — fuer Quick-Switcher in Topbar const [layouts, setLayouts] = useState([]) const [activeLayout, setActiveLayout] = useState('') // Rhino-Status: alle 5s pollen const [rhinoRunning, setRhinoRunning] = useState(false) // Tags aus den globalen Settings — Settings-Tab editiert sie, Dialog + Card // konsumieren sie. Single Source of Truth ist die Tauri-settings.json. const [tags, setTags] = useState([]) // File-Meta pro Pfad (Map). Wird beim Mount + nach OpenProject neu geladen. const [fileMeta, setFileMeta] = useState({}) // Aktiv gefilterte Tag-IDs (Topbar-Filter). Leeres Set = alle anzeigen. const [tagFilter, setTagFilter] = useState(new Set()) // Snapshot-Toast (Pfad oder null) const [snapshotToast, setSnapshotToast] = useState(null) // View-Tabs: 'recent' = kuerzlich geoeffnete, 'all' = alle mit Sort/Group const [view, setView] = useState('recent') const [sortBy, setSortBy] = useState('lastOpened') // lastOpened | name | createdAt const [groupByTag, setGroupByTag] = useState(false) // Pro-Projekt-Pfad -> bool (.rhl-Lock vorhanden?) const [openMap, setOpenMap] = useState({}) // Pro-Pfad -> dataUrl (PNG-Thumbnail) | null const [thumbMap, setThumbMap] = useState({}) useEffect(() => { invoke('list_recent').then(r => setRecent(sortProjects(r || []))).catch(console.error) invoke('read_modules_manifest').then(setModules).catch(console.error) invoke('list_window_layouts').then(setLayouts).catch(() => {}) invoke('read_dossier_settings').then(ds => setActiveLayout(ds?.windowLayout || '')).catch(() => {}) invoke('read_settings').then(s => setTags(s?.tags || [])).catch(() => {}) }, []) // File-Meta laden sobald recent sich aendert useEffect(() => { let alive = true Promise.all((recent || []).map(p => invoke('get_file_meta', { path: p.path }) .then(m => [p.path, m]).catch(() => [p.path, null]) )).then(pairs => { if (!alive) return const map = {} for (const [k, v] of pairs) map[k] = v setFileMeta(map) }) return () => { alive = false } }, [recent]) // Thumbnails laden — Initial + alle 30s neu (Cache-Refresh nach Saves) useEffect(() => { let alive = true const load = async () => { if (recent.length === 0) return const pairs = await Promise.all(recent.map(p => invoke('read_thumbnail', { path: p.path }) .then(r => [p.path, r?.dataUrl || null]) .catch(() => [p.path, null]) )) if (!alive) return const next = {} for (const [k, v] of pairs) next[k] = v setThumbMap(next) } load() const t = setInterval(load, 30000) return () => { alive = false; clearInterval(t) } }, [recent]) // Rhino-Status-Poll + Open-Status pro File (.rhl-Check fuer primary + extras) useEffect(() => { let alive = true const check = async () => { try { const running = await invoke('is_rhino_running') if (!alive) return setRhinoRunning(!!running) if (running && recent.length > 0) { // Alle zu checkenden Pfade sammeln (primary + extras) ohne Duplikate const paths = new Set() for (const p of recent) { paths.add(p.path) for (const f of (p.extraFiles || [])) paths.add(f) } const results = await Promise.all([...paths].map(pth => invoke('is_project_open', { path: pth }) .then(b => [pth, !!b]).catch(() => [pth, false]) )) if (!alive) return const map = {} for (const [k, v] of results) map[k] = v setOpenMap(map) } else { if (alive) setOpenMap({}) } } catch {} } check() const t = setInterval(check, 5000) return () => { alive = false; clearInterval(t) } }, [recent]) // Tray neu bauen sobald recent sich aendert (Reihenfolge, Inhalt) const syncTray = useCallback(() => { invoke('refresh_tray_menu').catch(() => {}) }, []) // Gefiltert nach Query + aktiven Tag-Filtern const filteredRecent = useMemo(() => { const q = query.trim().toLowerCase() return recent.filter(p => { if (q) { const inText = (p.name || '').toLowerCase().includes(q) || (p.path || '').toLowerCase().includes(q) if (!inText) return false } if (tagFilter.size > 0) { const pTags = new Set(p.tagIds || []) for (const t of tagFilter) if (!pTags.has(t)) return false } return true }) }, [recent, query, tagFilter]) // 'Recent'-View: nur die letzten 8, ohne Tag-Filter const recentSlice = useMemo(() => { return [...recent] .sort((a, b) => { if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1 return (b.lastOpened || '').localeCompare(a.lastOpened || '') }) .slice(0, 8) }, [recent]) // 'Alle'-View: Sortierung nach gewaehltem Kriterium (Pin immer oben) const allSorted = useMemo(() => { const cmp = (a, b) => { if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1 switch (sortBy) { case 'name': return (a.name || '').localeCompare(b.name || '') case 'createdAt': return (b.createdAt || b.lastOpened || '').localeCompare(a.createdAt || a.lastOpened || '') case 'lastOpened': default: return (b.lastOpened || '').localeCompare(a.lastOpened || '') } } return [...filteredRecent].sort(cmp) }, [filteredRecent, sortBy]) // Wenn group-by-tag aktiv: Map tagId -> Projekte. Untagged kommt zuletzt. const allGrouped = useMemo(() => { if (!groupByTag) return null const groups = new Map() for (const t of tags) groups.set(t.id, []) groups.set('__untagged__', []) for (const p of allSorted) { const ids = (p.tagIds || []).filter(id => groups.has(id)) if (ids.length === 0) { groups.get('__untagged__').push(p) } else { for (const id of ids) groups.get(id).push(p) } } // Leere Gruppen wegwerfen return Array.from(groups.entries()).filter(([_, ps]) => ps.length > 0) }, [allSorted, groupByTag, tags]) const togglePin = async (proj) => { const next = sortProjects(recent.map(p => p.path === proj.path ? { ...p, pinned: !p.pinned } : p )) setRecent(next) await invoke('save_recent', { projects: next }).catch(() => {}) syncTray() } const snapshotProject = async (proj) => { try { const target = await invoke('create_snapshot', { path: proj.path }) setSnapshotToast(target) setTimeout(() => setSnapshotToast(null), 2400) } catch (ex) { alert(`Snapshot fehlgeschlagen: ${ex}`) } } const toggleTagFilter = (tagId) => { setTagFilter(prev => { const next = new Set(prev) if (next.has(tagId)) next.delete(tagId); else next.add(tagId) return next }) } const onPickLayout = async (name) => { setActiveLayout(name) // Default speichern (fuer Auto-Apply beim naechsten Start) const cur = await invoke('read_dossier_settings').catch(() => ({})) await invoke('save_dossier_settings', { settings: { ...cur, windowLayout: name || null }, }).catch(() => {}) if (name) { // Bei aktiver Rhino-Session: direkt anwenden via Polling-Flag await invoke('apply_layout_now', { name }).catch(() => {}) } } // Tray-Menue navigiert ueber das `dossier:navigate` Event. useEffect(() => { let unlisten = null listen('dossier:navigate', (e) => { const view = e.payload if (view === 'settings') { setSettingsTab('rhino'); setSettingsOpen(true) } else if (view === 'new') { setDialog({ mode: 'new' }) } else if (view === 'check-update') { setSettingsTab('updates') setSettingsOpen(true) // Modal-Pfad ueber UpdateNotifier wuerde nur bei verfuegbarem Update // erscheinen — fuer den manuellen Check zeigen wir den Settings-Tab, // der direkt den Check-Button hat. } }).then(fn => { unlisten = fn }) return () => { if (unlisten) unlisten() } }, []) const openProject = async (proj, fileOverride = null) => { if (busy) return const target = fileOverride || proj.path setBusy(true) try { // Per-Projekt-Layout NUR beim Oeffnen der Primary-Datei anwenden — // bei Extra-Files wollen wir vermutlich nicht das Layout switchen. if (!fileOverride && proj.windowLayout) { try { await invoke('apply_layout_now', { name: proj.windowLayout }) } catch (ex) { console.warn('per-project layout:', ex) } } await invoke('open_rhino', { path3dm: target }) const next = recent .map(p => p.path === proj.path ? { ...p, lastOpened: new Date().toISOString() } : p) .sort((a, b) => (b.lastOpened || '').localeCompare(a.lastOpened || '')) await invoke('save_recent', { projects: next }) setRecent(next) syncTray() } catch (ex) { alert(`Rhino-Start fehlgeschlagen: ${ex}`) } finally { setBusy(false) } } const revealProject = async (proj) => { try { await invoke('show_in_finder', { path: proj.path }) } catch (ex) { alert(`Im Finder zeigen: ${ex}`) } } const editProject = (proj) => { setDialog({ mode: 'edit', project: proj }) } const saveProject = async ({ name, path, modules: mods, useTemplate, windowLayout, tagIds, extraFiles }) => { if (!path) { alert('Bitte eine .3dm-Datei auswaehlen.'); return } if (!name.trim()) { alert('Bitte einen Projekt-Namen angeben.'); return } try { if (useTemplate) { try { await invoke('copy_template_to', { targetPath: path }) } catch (ex) { console.warn('Template-Kopie:', ex) } } await invoke('write_project_config', { path3dm: path, name: name.trim(), modules: mods }) const existing = recent.find(p => p.path === path) const others = recent.filter(p => p.path !== path) const now = new Date().toISOString() const merged = { name: name.trim(), path, modules: mods, windowLayout: windowLayout || null, tagIds: tagIds || [], extraFiles: extraFiles || [], pinned: existing?.pinned || false, createdAt: existing?.createdAt || now, lastOpened: existing?.lastOpened || now, } const next = sortProjects([merged, ...others]) await invoke('save_recent', { projects: next }) setRecent(next) syncTray() setDialog(null) } catch (ex) { alert(`Speichern fehlgeschlagen: ${ex}`) } } const removeProject = async (proj) => { const next = recent.filter(p => p.path !== proj.path) await invoke('save_recent', { projects: next }) setRecent(next) syncTray() } return (
DOSSIER. v0.6.3
Pre-Release Rhino {rhinoRunning ? 'aktiv' : 'offline'}
{layouts.length > 0 && (
LAYOUT
)}
{recent.length > 0 && (
setQuery(e.target.value)} /> {query && ( )}
)}
{view === 'all' && (
Sortieren {tags.length > 0 && ( )}
)} {view === 'all' && tags.length > 0 && !groupByTag && (
Filter: {tags.map(t => { const active = tagFilter.has(t.id) return ( ) })} {tagFilter.size > 0 && ( )}
)} {recent.length === 0 ? (
Noch leer Lege dein erstes Dossier-Projekt an —
wähle eine bestehende .3dm oder erstelle eine neue.
) : view === 'recent' ? (
{recentSlice.map(p => )}
) : groupByTag && allGrouped ? (
{allGrouped.map(([tagId, ps]) => { const tag = tagId === '__untagged__' ? null : tags.find(t => t.id === tagId) return (
{tag ? ( {tag.name} ) : ( Ohne Tag )} {ps.length}
{ps.map(p => )}
) })}
) : allSorted.length === 0 ? (
Keine Treffer Kein Projekt entspricht den Filterkriterien.
) : (
{allSorted.map(p => )}
)}
{dialog && ( setDialog(null)} onSave={saveProject} /> )} {settingsOpen && ( { setSettingsOpen(false); invoke('read_settings').then(s => setTags(s?.tags || [])).catch(() => {}) }} /> )} {snapshotToast && (
Snapshot erstellt: {snapshotToast.split('/').pop()}
)}
) } function ProjectCard({ p, tags, fileMeta, openMap, thumbMap = {}, busy, onOpen, onEdit, onReveal, onSnapshot, onTogglePin, onRemove }) { const [expanded, setExpanded] = useState(false) const meta = fileMeta[p.path] const extras = p.extraFiles || [] const thumb = thumbMap[p.path] // Open-Status: PRIMARY oder einer der Extras offen const isOpen = !!openMap[p.path] || extras.some(f => openMap[f]) const projTags = (p.tagIds || []) .map(id => tags.find(t => t.id === id)) .filter(Boolean) return (
onOpen(p)}>
{thumb ? (
) : (
)}
{p.name} {p.modules?.length || 0} Module {extras.length > 0 && ( )} {projTags.map(t => ( {t.name} ))}
{p.path}
{isOpen && }
{formatRelative(p.lastOpened)}
{meta?.exists && (
{formatFileSize(meta.size)} {meta.modifiedIso && ` · ${formatRelative(meta.modifiedIso)}`}
)} {meta && !meta.exists && (
Datei fehlt
)}
{expanded && extras.length > 0 && (
onOpen(p)} /> {extras.map(f => ( onOpen(p, f)} /> ))}
)}
) } function FileRow({ path, isPrimary = false, isOpen, busy, onOpen }) { const name = path.split('/').pop() return (
{isOpen && }
{isPrimary && Hauptdatei} {name}
{path}
) } function SettingsDialog({ initialTab = 'rhino', onClose }) { const [tab, setTab] = useState(initialTab) return (
{ if (e.target === e.currentTarget) onClose() }}>
Einstellungen
setTab('rhino')}>Rhino setTab('view')}>View setTab('ebenen')}>Ebenen setTab('tags')}>Tags setTab('layout')}>Layout setTab('updates')}>Updates
{tab === 'rhino' && } {tab === 'view' && } {tab === 'ebenen' && } {tab === 'tags' && } {tab === 'layout' && } {tab === 'updates' && }
) } function TabBtn({ active, onClick, children }) { return ( ) } function RhinoSettings() { const [rhinoApp, setRhinoApp] = useState('') const [templatePath, setTemplatePath] = useState('') const [autoLoadPlugin, setAutoLoadPlugin] = useState(false) const [pluginStartupPath, setPluginStartupPath] = useState('') const [defaultStartup, setDefaultStartup] = useState('') const [loaded, setLoaded] = useState(false) const [saved, setSaved] = useState(false) const [pluginLoading, setPluginLoading] = useState(false) useEffect(() => { Promise.all([ invoke('read_settings').catch(() => ({})), invoke('get_default_plugin_startup_path').catch(() => ''), ]).then(([s, def]) => { setRhinoApp(s.rhinoApp || 'Rhinoceros 8') setTemplatePath(s.templatePath || '') setAutoLoadPlugin(!!s.autoLoadPlugin) setPluginStartupPath(s.pluginStartupPath || '') setDefaultStartup(def || '') setLoaded(true) }) }, []) const pickRhino = async () => { const sel = await openDialog({ title: 'Rhino-App waehlen', filters: [{ name: 'Anwendung', extensions: ['app'] }], defaultPath: '/Applications', }) if (typeof sel === 'string') setRhinoApp(sel) } const pickTemplate = async () => { const sel = await openDialog({ title: 'Template-3dm waehlen', filters: [{ name: 'Rhino', extensions: ['3dm'] }], }) if (typeof sel === 'string') setTemplatePath(sel) } const pickStartup = async () => { const sel = await openDialog({ title: 'startup.py des Dossier-Plugins', filters: [{ name: 'Python', extensions: ['py'] }], }) if (typeof sel === 'string') setPluginStartupPath(sel) } const clearTemplate = () => setTemplatePath('') const useDefault = () => setRhinoApp('Rhinoceros 8') const useDefaultStartup = () => setPluginStartupPath('') const loadNow = async () => { try { setPluginLoading(true) await invoke('trigger_plugin_load_now') setTimeout(() => setPluginLoading(false), 4000) } catch (ex) { setPluginLoading(false) alert(`Plugin-Load: ${ex}`) } } const save = async () => { try { const cur = await invoke('read_settings').catch(() => ({})) await invoke('save_settings', { settings: { ...cur, rhinoApp: rhinoApp.trim() || 'Rhinoceros 8', templatePath: templatePath.trim() || null, autoLoadPlugin: !!autoLoadPlugin, pluginStartupPath: pluginStartupPath.trim() || null, }, }) setSaved(true) setTimeout(() => setSaved(false), 1500) } catch (ex) { alert(`Speichern fehlgeschlagen: ${ex}`) } } if (!loaded) return null return ( <>
setRhinoApp(e.target.value)} placeholder="Rhinoceros 8" />
App-Name (in /Applications gesucht) oder absoluter Pfad zur .app.
Beispiele Rhinoceros 8 /Applications/Rhino 8.app /Applications/Rhino 8 WIP.app
setTemplatePath(e.target.value)} placeholder="(keine — Rhino-Standard wird verwendet)" /> {templatePath && }
Wird bei Neues Projekt als Startdatei kopiert — inkl. aller Layer, Schraffuren, Linientypen und Plot-Stile. So bleibt deine Ebenen-Hierarchie über alle Projekte konsistent.
setPluginStartupPath(e.target.value)} placeholder={defaultStartup || 'gebündelte startup.py wird verwendet'} />
Aktiv: der Launcher trägt _RunPythonScript <Pfad> in Rhinos Run these commands every time a model is opened Liste ein (Options → General → Command Lists). Damit lädt das Plugin bei jedem Rhino-Start automatisch — egal ob via Launcher oder direkt. Idempotent: wird nichts überschrieben, wenn der Eintrag schon da ist.
(greift beim nächsten Rhino-Start — Rhino dafür kurz beenden)
{saved && ✓ Gespeichert}
) } function TagsSettings() { const [tags, setTags] = useState([]) const [loaded, setLoaded] = useState(false) const [draftName, setDraftName] = useState('') const [draftColor, setDraftColor] = useState('#5fa896') useEffect(() => { invoke('read_settings').then(s => { setTags(s?.tags || []) setLoaded(true) }).catch(() => setLoaded(true)) }, []) const persist = async (next) => { setTags(next) const cur = await invoke('read_settings').catch(() => ({})) await invoke('save_settings', { settings: { ...cur, tags: next }, }).catch(ex => alert(`Tags speichern: ${ex}`)) } const addTag = () => { const name = draftName.trim() if (!name) return if (tags.some(t => t.name.toLowerCase() === name.toLowerCase())) { alert('Es gibt schon einen Tag mit diesem Namen.') return } const next = [...tags, { id: newTagId(), name, color: draftColor }] persist(next) setDraftName('') } const removeTag = (id) => { if (!confirm('Tag löschen? Projekte mit diesem Tag verlieren ihn.')) return persist(tags.filter(t => t.id !== id)) } const updateTag = (id, patch) => { persist(tags.map(t => t.id === id ? { ...t, ...patch } : t)) } if (!loaded) return null return ( <>
setDraftColor(e.target.value)} className="color-input" title="Farbe" /> setDraftName(e.target.value)} onKeyDown={e => e.key === 'Enter' && addTag()} placeholder="z.B. Wohnbau, Wettbewerb, Studie…" />
{tags.length === 0 ? (
Noch keine Tags. Mit dem Formular oben kannst du Kategorien anlegen — Name + Farbe. Sie sind dann im Projekt-Dialog wählbar und filtern die Projektliste.
) : (
{tags.map(t => (
updateTag(t.id, { color: e.target.value })} className="color-input" title="Farbe" /> updateTag(t.id, { name: e.target.value })} style={{ flex: 1 }} /> {t.name || '—'}
))}
)}
) } // Mapping Frontend-Key -> Label. Reihenfolge spielt fuer die UI eine Rolle. const VIEW_COLOR_FIELDS = [ { key: 'background', label: 'Viewport-Hintergrund', default: '#f0f0f0' }, { key: 'gridLine', label: 'Raster (Minor)', default: '#bcbcbc' }, { key: 'gridMajor', label: 'Raster (Major)', default: '#9e9e9e' }, { key: 'gridX', label: 'Raster X-Achse', default: '#c84040' }, { key: 'gridY', label: 'Raster Y-Achse', default: '#40a040' }, { key: 'worldX', label: 'Welt X-Achse', default: '#ff0000' }, { key: 'worldY', label: 'Welt Y-Achse', default: '#00b000' }, { key: 'worldZ', label: 'Welt Z-Achse', default: '#0080ff' }, ] // Default-Schema fuer neue Projekte. Muss im Sync mit INITIAL_EBENEN in // src/App.jsx des Rhino-Panels bleiben — wenn der User keine eigene Schema // definiert, schickt das Plugin diese hier als FIRST_RUN-Default. const DEFAULT_LAYER_SCHEMA = [ { code: '00', name: 'RASTER', color: '#484850', lw: 0.13 }, { code: '01', name: 'VERMESSUNG', color: '#707078', lw: 0.18 }, { code: '10', name: 'SITUATION', color: '#909090', lw: 0.18 }, { code: '11', name: 'STRASSE', color: '#a89070', lw: 0.18 }, { code: '12', name: 'GEBAEUDE', color: '#888888', lw: 0.25 }, { code: '13', name: 'BAEUME', color: '#50a050', lw: 0.13 }, { code: '14', name: 'HOEHENLINIEN', color: '#909050', lw: 0.18 }, { code: '20', name: 'WAENDE', color: '#0a0a0a', lw: 0.50 }, { code: '21', name: 'TUEREN_FENSTER', color: '#5080c8', lw: 0.25 }, { code: '22', name: 'MOEBEL', color: '#909090', lw: 0.13 }, { code: '25', name: 'STUETZEN', color: '#c87050', lw: 0.50 }, { code: '30', name: 'DECKEN', color: '#605850', lw: 0.35 }, { code: '31', name: 'DAECHER', color: '#7a4a3a', lw: 0.35 }, { code: '35', name: 'TRAEGER', color: '#a87858', lw: 0.50 }, { code: '50', name: 'TEXT', color: '#d0d0d0', lw: 0.13 }, { code: '60', name: 'PLANGRAFIK', color: '#c0a040', lw: 0.13 }, { code: '90', name: 'REFERENZEN', color: '#585860', lw: 0.13 }, { code: '99', name: 'KONSTRUKTION', color: '#404048', lw: 0.13 }, ] // Built-in Presets — nicht in den Settings gespeichert, immer verfuegbar. const BUILTIN_PRESETS = [ { id: 'builtin:rhino', name: 'Rhino-Standard', builtin: true, colors: {}, // leere Colors = Rhino-Defaults nicht ueberschreiben }, { id: 'builtin:dossier', name: 'Dossier-Standard', builtin: true, colors: { background: '#1c2421', gridLine: '#2a3330', gridMajor: '#3a4540', gridX: '#7a4a3a', gridY: '#4a7a5a', worldX: '#c84a3a', worldY: '#5fa896', worldZ: '#4a8ac8', }, }, ] function ViewSettings() { const [colors, setColors] = useState({}) const [autoApply, setAutoApply] = useState(false) const [loaded, setLoaded] = useState(false) const [saved, setSaved] = useState(false) const [importBusy, setImportBusy] = useState(false) const [customPresets, setCustomPresets] = useState([]) const allPresets = useMemo(() => [...BUILTIN_PRESETS, ...customPresets], [customPresets]) useEffect(() => { invoke('read_dossier_settings').then(ds => { setColors(ds?.viewportColors || {}) setAutoApply(!!ds?.autoApplyViewColors) setLoaded(true) }).catch(() => setLoaded(true)) invoke('read_settings').then(s => setCustomPresets(s?.viewColorPresets || [])) .catch(() => {}) }, []) // Match: welcher Preset entspricht den aktuellen Colors exakt? const currentPresetId = useMemo(() => { const keys = VIEW_COLOR_FIELDS.map(f => f.key) const norm = (obj) => keys.map(k => (obj?.[k] || '').toLowerCase()).join('|') const cur = norm(colors) return allPresets.find(p => norm(p.colors) === cur)?.id || '' }, [colors, allPresets]) const applyPreset = (presetId) => { const p = allPresets.find(pp => pp.id === presetId) if (!p) return const next = { ...p.colors } setColors(next) persist(next, autoApply) } const persistCustomPresets = async (next) => { setCustomPresets(next) const cur = await invoke('read_settings').catch(() => ({})) await invoke('save_settings', { settings: { ...cur, viewColorPresets: next }, }).catch(ex => console.warn('preset save:', ex)) } const saveAsPreset = async () => { const name = prompt('Name für dieses Preset:') if (!name || !name.trim()) return const id = 'custom_' + Math.random().toString(36).slice(2, 9) persistCustomPresets([...customPresets, { id, name: name.trim(), colors }]) } const deletePreset = async (id) => { const p = customPresets.find(pp => pp.id === id) if (!p) return if (!confirm(`Preset „${p.name}" löschen?`)) return persistCustomPresets(customPresets.filter(pp => pp.id !== id)) } const exportPreset = async (id) => { const p = allPresets.find(pp => pp.id === id) || { name: 'export', colors } const payload = JSON.stringify({ dossier: { kind: 'view-color-preset', version: 1 }, name: p.name, colors: p.colors, }, null, 2) const target = await saveDialog({ title: 'Preset exportieren', defaultPath: `dossier-view-${p.name.replace(/\s+/g, '-').toLowerCase()}.json`, filters: [{ name: 'JSON', extensions: ['json'] }], }) if (typeof target !== 'string') return try { await invoke('write_text_file', { path: target, content: payload }) alert(`Preset exportiert:\n${target}`) } catch (ex) { alert(`Export: ${ex}`) } } const importPreset = async () => { const sel = await openDialog({ title: 'Preset importieren', filters: [{ name: 'JSON', extensions: ['json'] }], }) if (typeof sel !== 'string') return try { const raw = await invoke('read_text_file', { path: sel }) const parsed = JSON.parse(raw) if (!parsed?.colors || !parsed?.name) { alert('Format unbekannt — JSON muss "name" und "colors" enthalten.') return } const id = 'custom_' + Math.random().toString(36).slice(2, 9) persistCustomPresets([...customPresets, { id, name: parsed.name, colors: parsed.colors }]) alert(`Preset „${parsed.name}" importiert.`) } catch (ex) { alert(`Import: ${ex}`) } } // Lokale Aenderung sofort persistieren, ohne Apply const persist = async (nextColors, nextAuto) => { const cur = await invoke('read_dossier_settings').catch(() => ({})) await invoke('save_dossier_settings', { settings: { ...cur, viewportColors: nextColors, autoApplyViewColors: !!nextAuto, }, }).catch(ex => console.warn('view-colors save:', ex)) } const setColor = (key, value) => { const next = { ...colors, [key]: value || null } setColors(next) persist(next, autoApply) } const setAutoApplyFlag = (v) => { setAutoApply(v) persist(colors, v) } const applyNow = async () => { try { await invoke('apply_view_colors_now') setSaved(true) setTimeout(() => setSaved(false), 1800) } catch (ex) { alert(`Anwenden fehlgeschlagen: ${ex}`) } } const resetAll = () => { if (!confirm('Alle Farben zurücksetzen?')) return setColors({}) persist({}, autoApply) } const importDisplayMode = async () => { const sel = await openDialog({ title: 'Display-Mode (.ini) wählen', filters: [{ name: 'Rhino Display Mode', extensions: ['ini'] }], }) if (typeof sel !== 'string') return try { setImportBusy(true) await invoke('queue_import_display_mode', { path: sel }) alert(`Importiert. Rhino-Plugin übernimmt beim nächsten Idle-Tick (~1 s).\n\n${sel}`) } catch (ex) { alert(`Import fehlgeschlagen: ${ex}`) } finally { setImportBusy(false) } } if (!loaded) return null return ( <>
{currentPresetId && currentPresetId.startsWith('custom_') && ( )}
{currentPresetId && ( )}
{VIEW_COLOR_FIELDS.map(f => (
setColor(f.key, e.target.value)} className="color-input" />
{f.label}
{colors[f.key] || '— Rhino-Default'}
{colors[f.key] && ( )}
))}
{saved && ✓ Angewendet}
Importiert eine Display-Mode .ini-Datei in Rhino. Die Stile lassen sich in Rhinos Options → Display Modes → Export erzeugen. So baust du dir deine Dossier-Stile (Hardline, Soft-Shaded, Plan-1:50, etc.) einmal und kannst sie auf jedem Mac in zwei Klicks importieren.
) } function EbenenSchemaSettings() { const [schema, setSchema] = useState([]) const [loaded, setLoaded] = useState(false) const [saved, setSaved] = useState(false) const [importing, setImporting] = useState(false) const reload = useCallback(() => { return invoke('read_dossier_settings').then(ds => { const s = (ds?.layerSchema && ds.layerSchema.length) ? ds.layerSchema : DEFAULT_LAYER_SCHEMA setSchema(s) setLoaded(true) }) }, []) useEffect(() => { reload().catch(() => { setSchema(DEFAULT_LAYER_SCHEMA); setLoaded(true) }) }, [reload]) const persist = async (next) => { setSchema(next) const cur = await invoke('read_dossier_settings').catch(() => ({})) await invoke('save_dossier_settings', { settings: { ...cur, layerSchema: next }, }).catch(ex => console.warn('schema save:', ex)) setSaved(true) setTimeout(() => setSaved(false), 1500) } const updateRow = (i, patch) => { persist(schema.map((row, idx) => idx === i ? { ...row, ...patch } : row)) } const addRow = () => { // Naechste freie Code aus dem hoechsten + 1 const max = schema.reduce((m, r) => Math.max(m, parseInt(r.code, 10) || 0), 0) const code = String(max + 1).padStart(2, '0') persist([...schema, { code, name: 'NEU', color: '#888888', lw: 0.13 }]) } const removeRow = (i) => { if (!confirm(`Ebene „${schema[i].code}_${schema[i].name}" entfernen?`)) return persist(schema.filter((_, idx) => idx !== i)) } const moveRow = (i, dir) => { const j = i + dir if (j < 0 || j >= schema.length) return const next = [...schema] ;[next[i], next[j]] = [next[j], next[i]] persist(next) } const resetToDefault = () => { if (!confirm('Schema auf Dossier-Standard zurücksetzen?')) return persist(DEFAULT_LAYER_SCHEMA) } const importFromRhino = async () => { if (!confirm('Aktuelles Schema mit den Ebenen aus dem laufenden Rhino-Projekt überschreiben?')) return try { setImporting(true) await invoke('request_ebenen_export') // Plugin verarbeitet im naechsten Idle-Tick (~1s). Wir pollen kurz. for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, 600)) const ds = await invoke('read_dossier_settings').catch(() => null) if (ds && !ds.pendingExportEbenen) { await reload() setImporting(false) return } } setImporting(false) alert('Rhino hat nicht innerhalb von 5 s reagiert. Läuft Rhino mit aktivem Dossier-Plugin?') } catch (ex) { setImporting(false) alert(`Import: ${ex}`) } } const exportSchema = async () => { const payload = JSON.stringify({ dossier: { kind: 'layer-schema', version: 1 }, schema, }, null, 2) const target = await saveDialog({ title: 'Schema exportieren', defaultPath: 'dossier-ebenen-schema.json', filters: [{ name: 'JSON', extensions: ['json'] }], }) if (typeof target !== 'string') return try { await invoke('write_text_file', { path: target, content: payload }) alert(`Schema exportiert:\n${target}`) } catch (ex) { alert(`Export: ${ex}`) } } const importSchema = async () => { const sel = await openDialog({ title: 'Schema importieren', filters: [{ name: 'JSON', extensions: ['json'] }], }) if (typeof sel !== 'string') return try { const raw = await invoke('read_text_file', { path: sel }) const parsed = JSON.parse(raw) const next = parsed?.schema || parsed if (!Array.isArray(next)) { alert('Format unbekannt — JSON muss ein Array mit Layer-Templates enthalten.') return } // Validierung: jeder Eintrag braucht code, name, color, lw const clean = next.filter(r => r.code && r.name && r.color !== undefined && r.lw !== undefined) if (clean.length === 0) { alert('Keine gültigen Einträge im Import gefunden.') return } persist(clean) alert(`${clean.length} Ebenen importiert.`) } catch (ex) { alert(`Import: ${ex}`) } } if (!loaded) return null return ( <>
Diese Liste wird bei Neues Projekt in Rhino als Anfangs-Schema verwendet. Pro Sublayer: Code (für die Hierarchie 01_WAND, …), Name, Farbe und Stiftdicke. Reihenfolge entspricht der Anzeige in Rhinos Layer-Panel.
Code
Name
Farbe
Stift (mm)
{schema.map((row, i) => (
updateRow(i, { code: e.target.value.toUpperCase() })} style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }} /> updateRow(i, { name: e.target.value.toUpperCase() })} style={{ fontFamily: 'var(--font-mono)' }} /> updateRow(i, { color: e.target.value })} className="color-input" style={{ margin: '0 auto', display: 'block' }} /> updateRow(i, { lw: parseFloat(e.target.value) || 0.13 })} style={{ fontFamily: 'var(--font-mono)' }} />
))}
{saved && ✓ Gespeichert}
) } function LayoutSettings() { const [layouts, setLayouts] = useState([]) const [selected, setSelected] = useState('') const [autoApply, setAutoApply] = useState(false) const [loaded, setLoaded] = useState(false) const [saved, setSaved] = useState(false) useEffect(() => { Promise.all([ invoke('list_window_layouts').catch(() => []), invoke('read_dossier_settings').catch(() => ({})), ]).then(([list, ds]) => { setLayouts(list || []) setSelected(ds?.windowLayout || '') setAutoApply(!!ds?.autoApplyLayout) setLoaded(true) }) }, []) const save = async () => { try { await invoke('save_dossier_settings', { settings: { windowLayout: selected || null, autoApplyLayout: autoApply }, }) setSaved(true) setTimeout(() => setSaved(false), 1500) } catch (ex) { alert(`Speichern fehlgeschlagen: ${ex}`) } } if (!loaded) return null return (
{layouts.length === 0 ? (
Keine Rhino Window-Layouts gefunden. Erstelle erst eines in Rhino via Window → Window Layouts → Save, dann hier auswählen.
) : ( )}
Dossier liest diese Einstellung beim Öffnen eines Projekts und ruft Rhinos Window-Layout auf. So spart man sich manuelles Wiederherstellen von Panel-Anordnungen.
{saved && ✓ Gespeichert}
) } function UpdatesSettings() { const [version, setVersion] = useState('') const [lastCheck, setLastCheck] = useState(() => getLastUpdateCheck()) const [state, setState] = useState('idle') const [update, setUpdate] = useState(null) const [error, setError] = useState(null) const [downloaded, setDownloaded] = useState(0) const [total, setTotal] = useState(0) useEffect(() => { if (!isTauri()) return import('@tauri-apps/api/app').then(({ getVersion }) => { getVersion().then(setVersion).catch(() => {}) }) }, []) const runCheck = useCallback(async () => { setError(null) if (!isTauri()) { setState('not-tauri') return } setState('checking') try { const res = await checkForAppUpdate({ respectSkip: false }) setLastCheck(getLastUpdateCheck()) if (res.available) { setUpdate(res.update) setState('available') } else { setUpdate(null) setState('no-update') } } catch (e) { console.error('Update-Check fehlgeschlagen:', e) setError(String(e?.message || e)) setState('idle') } }, []) const install = async () => { if (!update) return setError(null) try { setState('downloading') setDownloaded(0) setTotal(0) await installAppUpdate(update, (event) => { if (event.event === 'Started') { setTotal(event.data.contentLength || 0) } else if (event.event === 'Progress') { setDownloaded((d) => d + (event.data.chunkLength || 0)) } else if (event.event === 'Finished') { setState('installing') } }) } catch (e) { setError(String(e?.message || e)) setState('available') } } const skipVersion = () => { skipUpdateVersion(update?.version) setUpdate(null) setState('idle') } const isBusy = state === 'downloading' || state === 'installing' const pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null return (
Aktuelle Version {version || '—'}
Letzte Prüfung {formatLastCheck(lastCheck)}
{state === 'no-update' && (
✓ Dossier ist auf dem neuesten Stand.
)} {state === 'available' && update && (
UPDATE VERFÜGBAR
Dossier {update.version}
{update.body && (
{update.body}
)}
)} {isBusy && (
{state === 'downloading' ? (pct !== null ? `Wird heruntergeladen … ${pct}%` : 'Wird heruntergeladen …') : 'Wird installiert …'}
)} {state === 'not-tauri' && (
Updates sind nur in der Desktop-App verfügbar.
)} {error && (
{error}
)}

Updates werden automatisch beim Start der App geprüft.

) } function ProjectDialog({ mode, project, modules, onCancel, onSave }) { const [name, setName] = useState(project?.name || '') const [path, setPath] = useState(project?.path || '') const [picked, setPicked] = useState(new Set(project?.modules || ['ebenen', 'oberleiste'])) const [useTemplate, setUseTemplate] = useState(true) // nur bei mode='new' wirksam const [hasTemplate, setHasTemplate] = useState(false) const [windowLayout, setWindowLayout] = useState(project?.windowLayout || '') const [layouts, setLayouts] = useState([]) const [tagIds, setTagIds] = useState(new Set(project?.tagIds || [])) const [allTags, setAllTags] = useState([]) const [extraFiles, setExtraFiles] = useState(project?.extraFiles || []) useEffect(() => { invoke('read_settings').then(s => { if (mode === 'new') setHasTemplate(!!s?.templatePath) setAllTags(s?.tags || []) }).catch(() => {}) invoke('list_window_layouts').then(setLayouts).catch(() => {}) }, [mode]) const toggleTag = (id) => { setTagIds(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) return next }) } const addExtraFile = async () => { const sel = await openDialog({ title: 'Weitere Projektdatei (.3dm) wählen', filters: [{ name: 'Rhino', extensions: ['3dm'] }], }) if (typeof sel !== 'string') return if (sel === path) { alert('Das ist bereits die Hauptdatei.'); return } if (extraFiles.includes(sel)) return setExtraFiles([...extraFiles, sel]) } const removeExtraFile = (p) => { setExtraFiles(extraFiles.filter(x => x !== p)) } const effective = useMemo(() => resolveDeps(picked, modules), [picked, modules]) const locked = useMemo(() => { const out = new Set() for (const id of effective) { if (!picked.has(id)) out.add(id) } return out }, [picked, effective, modules]) const toggle = (id) => { if (locked.has(id)) return setPicked(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) return next }) } const pickFile = async () => { // Bei "neu" wollen wir wirklich einen NEUEN Pfad — saveDialog zeigt das // dem User klar an ("Save As") und akzeptiert auch noch nicht existierende // Dateien. Bei "edit" zeigen wir openDialog (Datei muss existieren). const sel = mode === 'new' ? await saveDialog({ title: '3dm-Datei anlegen', defaultPath: name.trim() ? `${name.trim()}.3dm` : 'untitled.3dm', filters: [{ name: 'Rhino', extensions: ['3dm'] }], }) : await openDialog({ title: '3dm-Datei waehlen', filters: [{ name: 'Rhino', extensions: ['3dm'] }], }) if (typeof sel === 'string') setPath(sel) } return (
{ if (e.target === e.currentTarget) onCancel() }}>
{mode === 'new' ? 'Neues Projekt' : 'Projekt bearbeiten'}
setName(e.target.value)} placeholder="z.B. Wohnhaus Brunner" autoFocus />
{mode === 'new' && hasTemplate && ( )} {mode === 'new' && !hasTemplate && (
Keine Vorlage gesetzt — die Datei wird mit Rhinos Standard angelegt. In den Einstellungen kannst du eine Template-3dm definieren.
)}
{extraFiles.length === 0 ? (
Keine. Klick „Datei hinzufügen", wenn dein Projekt aus mehreren .3dm-Dateien besteht (z.B. Plan + Details + Präsentation).
) : (
{extraFiles.map(f => (
{f.split('/').pop()}
))}
)}
{allTags.length === 0 ? (
Noch keine Tags definiert. In den Einstellungen → Tags kannst du eigene Kategorien (Name + Farbe) anlegen.
) : (
{allTags.map(t => { const active = tagIds.has(t.id) return ( ) })}
)}
{layouts.length === 0 ? (
Keine Rhino-Window-Layouts gefunden. In Rhino: Window → Window Layouts → Save…
) : ( )}
Wird beim Öffnen dieses Projekts in Rhino angewendet. Überschreibt das globale Default-Layout.
{modules.map(m => { const isActive = effective.has(m.id) const isLocked = locked.has(m.id) return (
toggle(m.id)} title={isLocked ? 'Wird von einem anderen Modul gebraucht' : ''} >
{isActive ? (isLocked ? '🔒' : '✓') : ''}
{m.name}
{m.description}
{(m.dependsOn || []).length > 0 && (
braucht: {m.dependsOn.join(', ')}
)}
) })}
) }