import { useState, useEffect, useRef } from 'react' import Icon from './components/Icon' import { onMessage, notifyReady, requestMassstab, setMassstab, zoomExtents, zoomSelection, setShowLineweights, setMassstabDpi, detectMassstabDpi, setView, setDisplayMode, toggleOverrides, setOverridesPreset, openOverridesPanel, pickLayerCombination, saveLayerCombination, deleteLayerCombination, openLayerCombinationsDialog, openDossierSettings, openKameraPanel, setMasseActive, openMasseSettings, openAbout, } from './lib/rhinoBridge' const PRESETS = [ { value: 1, label: '1:1' }, { value: 5, label: '1:5' }, { value: 10, label: '1:10' }, { value: 20, label: '1:20' }, { value: 25, label: '1:25' }, { value: 50, label: '1:50' }, { value: 100, label: '1:100' }, { value: 200, label: '1:200' }, { value: 500, label: '1:500' }, { value: 1000, label: '1:1000' }, ] const VIEWS = [ { value: 'Top', icon: 'view_quilt', label: 'Top' }, { value: 'Front', icon: 'north', label: 'Front' }, { value: 'Right', icon: 'east', label: 'Right' }, { value: 'Iso', icon: 'view_in_ar', label: 'Iso' }, { value: 'Perspective', icon: '3d_rotation', label: 'Persp' }, ] function fmtScale(s) { if (s == null) return '—' if (s >= 1) return '1:' + (s >= 10 ? s.toFixed(0) : s.toFixed(1)) return (1 / s).toFixed(2) + ':1' } function snapToPreset(s, tol = 0.03) { if (s == null) return null for (const p of PRESETS) { if (Math.abs(s - p.value) / p.value <= tol) return p.value } return null } function parseScale(input) { if (!input) return null const s = String(input).trim() for (const sep of [':', '=', '/']) { if (s.includes(sep)) { const [a, b] = s.split(sep, 2) const pa = parseFloat(a), pb = parseFloat(b) if (pa > 0 && pb > 0) return pb / pa return null } } const n = parseFloat(s) return n > 0 ? n : null } // --------------------------------------------------------------------------- // Vectorworks-inspirierte Bar-Widgets: Icon links, Label/Select Mitte, // Caret rechts — alle in einem rechteckigen Container, sichtbare Trennung // zwischen Icon-Kompartiment und Inhalt. const PILL_H = 20 // alte Pill-Hoehe (Buttons/Chips die nicht migriert sind) const BAR_H = 22 // neue Widget-Hoehe (BarSelect, BarButton, BarGroup) const sep = { width: 1, height: 20, background: 'var(--border)', flexShrink: 0, margin: '0 3px', } const groupLabel = { fontSize: 9, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, alignSelf: 'center', whiteSpace: 'nowrap', padding: '0 4px', } const pillSelect = { height: PILL_H, lineHeight: PILL_H + 'px', padding: '0 20px 0 10px', boxSizing: 'border-box', fontSize: 10, } // BarSelect: Icon roh links + custom-pill onChange(e.target.value)} style={{ height: BAR_H, width, background: 'var(--bg-item)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderTopLeftRadius: 999, borderBottomLeftRadius: 999, borderTopRightRadius: joinedRight ? 0 : 999, borderBottomRightRadius: joinedRight ? 0 : 999, borderRight: joinedRight ? 'none' : '1px solid var(--border)', padding: '0 24px 0 12px', fontSize: 11, fontFamily: 'var(--font)', appearance: 'none', WebkitAppearance: 'none', backgroundImage: 'var(--select-arrow)', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 9px center', cursor: disabled ? 'not-allowed' : 'pointer', flexShrink: 0, outline: 'none', letterSpacing: 0, }} >{children} ) } // BarCombo: dunklerer (bg-input) Pill-Container der select + optional gear // als EINE nahtlose Box rendert. Icon roh links daneben (kein Container). // iconClickable=true macht das Icon zum Toggle-Button (Overrides etc.). // valueAccent=true faerbt den Select-Text accent (fuer Massstab "gesetzt"). function BarCombo({ icon, iconActive, iconClickable, onIconClick, iconTitle, value, onChange, width, title, children, disabled, onGear, gearTitle, valueAccent, }) { return (
{/* Icon links — fixe Breite fuer X-Axis-Alignment zwischen Reihen */} {iconClickable ? ( ) : ( )} {/* Combined pill: select + optional gear, gemeinsamer bg + border */}
{ if (disabled) return e.currentTarget.style.borderColor = 'var(--accent)' e.currentTarget.style.background = 'var(--bg-item-hover)' }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)' e.currentTarget.style.background = 'var(--bg-input)' }} style={{ display: 'inline-flex', alignItems: 'stretch', height: BAR_H, width, background: 'var(--bg-input)', border: '1px solid var(--border)', borderRadius: 999, overflow: 'hidden', transition: 'border-color 0.15s, background 0.15s', }}> {onGear && ( )}
) } // BarButton: pill-foermiger Icon-Button im selben Stil wie BarSelect. // joinedLeft = linke Kante flach (dockt rechts an einen BarSelect-joinedRight). function BarButton({ icon, onClick, title, disabled, active, joinedLeft }) { return ( ) } const pillInput = { height: PILL_H, lineHeight: PILL_H + 'px', padding: '0 8px', boxSizing: 'border-box', borderRadius: 999, fontSize: 10, } const pillChip = { height: PILL_H, lineHeight: PILL_H + 'px', padding: '0 8px', boxSizing: 'border-box', display: 'inline-flex', alignItems: 'center', fontSize: 9, } const pillIconBtn = { width: PILL_H, height: PILL_H, borderRadius: '50%', boxSizing: 'border-box', } // Tonal button helper (filled when active, outlined when not) function ToolButton({ active, onClick, icon, label, title, disabled }) { return ( ) } // --------------------------------------------------------------------------- export default function OberleisteApp() { const [state, setState] = useState({ viewName: null, parallel: false, scale: null, pixelWidth: null, pixelHeight: null, unitSystem: '?', dpi: 96, dpiSource: 'default', showLineweights: false, viewMode: null, displayMode: null, displayModes: [], ortho: false, gridSnap: false, osnap: false, overridesEnabled: false, overridesCount: 0, cmdPrompt: '', cmdOptions: [], overridesActivePreset: null, overridesPresets: [], layerCombinations: [], layerCombinationActive: null, massePresets: [], masseActiveId: null, }) const [appliedScale, setAppliedScale] = useState(null) const appliedScaleRef = useRef(null) const [draft, setDraft] = useState('') const [customMode, setCustomMode] = useState(false) // Dropdown -> Custom-Input switch const customInputRef = useRef(null) useEffect(() => { onMessage('STATE', (s) => { setState((prev) => ({ ...prev, ...s })) // Dropdown spiegelt EXAKT den Backend-appliedScale fuer den aktuellen // Viewport. Kein Live-Skala-Fallback — das Dropdown ist statisch und // pro Viewport gebunden. Backend gibt null zurueck wenn der aktive // Viewport noch keinen gesetzten Massstab hat → Dropdown zeigt "1:?". const next = (typeof s?.appliedScale === 'number' && s.appliedScale > 0) ? s.appliedScale : null if (next !== appliedScaleRef.current) { setAppliedScale(next) appliedScaleRef.current = next } }) notifyReady() setTimeout(() => requestMassstab(), 50) }, []) const isPerspective = state.parallel === false const scaleVal = state.scale const dropdownValue = appliedScale != null ? String(appliedScale) : '__none__' const applyDropdown = (val) => { if (val === '__none__') return if (val === '__custom__') { setDraft(appliedScale ? `1:${appliedScale}` : '') setCustomMode(true) // Nach dem Render: Focus + Selektion fuer schnelles Eintippen. setTimeout(() => { customInputRef.current?.focus() customInputRef.current?.select() }, 0) return } const r = parseFloat(val) if (r > 0) { setAppliedScale(r); appliedScaleRef.current = r; setMassstab(r) } } const applyDraft = () => { const r = parseScale(draft) if (r != null) { setAppliedScale(r); appliedScaleRef.current = r; setMassstab(r) } setDraft('') setCustomMode(false) } const cancelDraft = () => { setDraft(''); setCustomMode(false) } const apply100 = () => { if (appliedScale && appliedScale > 0) setMassstab(appliedScale) } // Aktuelles View-Match. Orthogonale Standard-Views matchen via viewName. // 'Iso' und 'Perspective' werden via parallel-Flag unterschieden — die // Rhino-Viewport-Namen sind beide oft "Perspective". const matchView = (v) => { const name = (state.viewName || '').toLowerCase() const ortho = ['top', 'front', 'right', 'bottom', 'left', 'back'] if (v === 'Iso') { // Parallel-Projektion + kein orthogonaler Standardname → Iso return state.parallel === true && !ortho.includes(name) } if (v === 'Perspective') { return state.parallel === false } return name === v.toLowerCase() } // (Command-Bar wurde entfernt — Rhinos eigene Command-Line wird benutzt.) return (
{/* === Toolbar (View, Display, Massstab, Snap, Overrides) === */}
{/* Logo: DOSSIER. (Petrol-Punkt) — Klick = About-Fenster */}
{/* ====== VIEW (Top/Front/Right/Iso/Persp + Kamera) ====== Segmented-Pill-Gruppe analog Vectorworks. Active = accent fill. */}
{VIEWS.map((v, idx) => { const isFirst = idx === 0 const isLast = idx === VIEWS.length - 1 const isActive = matchView(v.value) return ( ) })}
openKameraPanel()} title="Kamera-Einstellungen (Position, Target, Linse, Presets)" />
{/* ====== 2-Reihen Preset-Block ====== Oben: Display | Kombi Unten: Overrides | Masse Gleiche Pill-Breiten, identische X-Positionen (Grid-Layout). */} {(() => { const PRESET_W = 150 return (
{/* Reihe 1, Spalte 1: Display */} setDisplayMode(v)} title="Display-Mode (Wireframe / Shaded / Rendered / etc.)" width={PRESET_W} > {!state.displayMode && } {(state.displayModes || []).map(dm => ( ))} {/* Reihe 1, Spalte 2: Ebenenkombination */} { if (v === '__configure__') { openLayerCombinationsDialog(); return } if (v === '__save__') { const suggested = state.layerCombinationActive || `Kombi ${(state.layerCombinations || []).length + 1}` const name = (window.prompt('Name für Ebenenkombination:', suggested) || '').trim() if (!name) return if ((state.layerCombinations || []).includes(name) && !window.confirm(`"${name}" überschreiben?`)) return saveLayerCombination(name) return } if (v === '__delete__') { if (state.layerCombinationActive && window.confirm(`Kombination "${state.layerCombinationActive}" löschen?`)) deleteLayerCombination(state.layerCombinationActive) return } pickLayerCombination(v === '__none__' ? null : v) }} title={state.layerCombinationActive ? `Aktive Kombi: ${state.layerCombinationActive}` : 'Keine Kombination — manuelle Sichtbarkeit'} width={PRESET_W} onGear={openLayerCombinationsDialog} gearTitle="Ebenenkombinationen bearbeiten" > {(state.layerCombinations || []).map(name => ( ))} {state.layerCombinationActive && ( )} {/* Reihe 2, Spalte 1: Overrides (Toggle als Icon links) */} toggleOverrides(!state.overridesEnabled)} iconTitle={state.overridesEnabled ? 'Grafische Overrides aktiv — klick zum Ausschalten' : 'Grafische Overrides ausgeschaltet'} value={state.overridesActivePreset || '__none__'} onChange={(v) => { if (v === '__configure__') { openOverridesPanel(); return } setOverridesPreset(v === '__none__' ? null : v) }} title={state.overridesActivePreset ? `Aktives Preset: ${state.overridesActivePreset} (${state.overridesCount} Regeln)` : `Kein Preset aktiv (${state.overridesCount} Regeln, frei editiert)`} width={PRESET_W} onGear={openOverridesPanel} gearTitle="Overrides-Regel-Editor öffnen" > {(state.overridesPresets || []).map(name => ( ))} {/* Reihe 2, Spalte 2: Masse */} setMasseActive(v)} title="Aktives Mass — Raum-Rundung + Mass-Linien-Format" width={PRESET_W} onGear={openMasseSettings} gearTitle="Masse bearbeiten / neues anlegen" > {(state.massePresets || []).length === 0 && } {(state.massePresets || []).map(p => ( ))}
) })()}
{/* ====== MASSSTAB 2x2 ====== Reihe 1: [Aktueller Massstab] [Massstab-Dropdown (gruen wenn gesetzt)] Reihe 2: [Zoom-Verhaeltnis %] [Buttons] */} {(() => { const SegBtn = ({ icon, onClick, title, disabled, active, isFirst, isLast }) => ( ) const ratio = (!isPerspective && appliedScale && scaleVal) ? appliedScale / scaleVal : null const ratioText = ratio == null ? '—' : ratio >= 1 ? Math.round(ratio * 100) + '%' : (ratio * 100).toFixed(ratio < 0.1 ? 1 : 0) + '%' const atScale = ratio != null && Math.abs(ratio - 1) < 0.005 const STAT_W = 80 // Breite der linken Stat-Chips (1:N / %) const statChipStyle = (accentTint) => ({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', height: BAR_H, width: STAT_W, background: accentTint ? 'var(--accent-dim)' : 'var(--bg-input)', color: accentTint ? 'var(--accent-light)' : 'var(--text-muted)', border: '1px solid var(--border)', borderRadius: 999, fontFamily: 'DM Mono, monospace', fontSize: 11, fontWeight: accentTint ? 600 : 500, flexShrink: 0, }) return (
{/* Reihe 1, Spalte 1: Aktueller Live-Massstab */}
{isPerspective ? '—' : fmtScale(scaleVal)}
{/* Reihe 1, Spalte 2: Gesetzter Massstab Dropdown */} {customMode ? ( setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') applyDraft() else if (e.key === 'Escape') cancelDraft() }} onBlur={applyDraft} style={{ height: BAR_H, width: 158, background: 'var(--bg-input)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: 999, padding: '0 12px', fontSize: 11, fontFamily: 'DM Mono, monospace', outline: 'none', }} title="Massstab eingeben (Enter = uebernehmen, Esc = abbrechen)" /> ) : ( applyDropdown(v)} disabled={isPerspective} width={140} valueAccent={appliedScale != null} title="Gesetzter Massstab" > {PRESETS.map(p => ( ))} {appliedScale != null && !PRESETS.some(p => p.value === appliedScale) && ( )} )} {/* Reihe 2, Spalte 1: Zoom-Verhaeltnis zum gesetzten Massstab */}
{ratioText}
{/* Reihe 2, Spalte 2: Buttons-Pill */}
setShowLineweights(!state.showLineweights)} isLast title={state.showLineweights ? 'Print-View aktiv — klick zum Ausschalten' : 'Strichstärken anzeigen (Print-View)'} />
) })()} {/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */} {/* Spacer am rechten Rand */}
) }