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, createText, setTextSettings, } 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' }, ] // Reihe 1: 3D-Ansichten (Top, Iso, Persp) + Kamera-Button // Reihe 2: 4 Gebaeudeansichten (Norden/Osten/Sueden/Westen) — Buchstaben // als Symbol, rotieren mit dossier_north_angle. const VIEWS_ROW1 = [ { value: 'Top', icon: 'crop_landscape', kind: 'icon' }, { value: 'Iso', icon: 'view_in_ar', kind: 'icon' }, { value: 'Perspective', icon: '3d_rotation', kind: 'icon' }, ] const VIEWS_ROW2 = [ { value: 'N', label: 'N', kind: 'letter' }, { value: 'O', label: 'O', kind: 'letter' }, { value: 'S', label: 'S', kind: 'letter' }, { value: 'W', label: 'W', kind: 'letter' }, ] 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, } // 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. Wenn icon=null/undefined wird kein Icon-Slot reserviert. */} {icon && (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 + 2, width, boxSizing: 'border-box', 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, textSettings: { font: 'Helvetica', size: 0.20, bold: false, italic: false }, textFonts: [], northAngle: 0, lastSetView: 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) } // Active-Highlight aus lastSetView (vom Backend getrackt — vermeidet // Race-Conditions zwischen ChangeProjection und Viewport-State-Lesen). // Fallback wenn noch nie geklickt: Viewport-State raten. const matchView = (v) => { if (state.lastSetView) return state.lastSetView === v const name = (state.viewName || '').toLowerCase() if (v === 'Top') return name === 'top' if (v === 'Perspective') return state.parallel === false if (v === 'Iso') { const ortho = ['top', 'front', 'right', 'bottom', 'left', 'back'] return state.parallel === true && !ortho.includes(name) } return false } // (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 2x4 Grid ====== Reihe 1: TOP / ISO / PERSP / 📷 (Kamera-Settings) Reihe 2: N / O / S / W (rotieren mit dossier_north_angle) */} {(() => { const VIEW_W = 140 // konsistent mit Massstab-Pills const CELL_W = Math.floor(VIEW_W / 4) const cellStyle = (isActive, isFirst) => ({ height: BAR_H, minHeight: BAR_H, maxHeight: BAR_H, width: CELL_W, background: isActive ? 'var(--accent)' : 'var(--bg-input)', color: isActive ? 'var(--bg-panel)' : 'var(--text-primary)', border: 'none', borderLeft: isFirst ? 'none' : '1px solid var(--border)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4, fontWeight: isActive ? 600 : 500, cursor: 'pointer', flexShrink: 0, padding: 0, appearance: 'none', WebkitAppearance: 'none', lineHeight: 1, boxSizing: 'border-box', transition: 'background 0.15s, color 0.15s', }) const hoverIn = (isActive) => (e) => { if (isActive) return e.currentTarget.style.background = 'var(--bg-item-hover)' e.currentTarget.style.color = 'var(--accent-light)' } const hoverOut = (isActive) => (e) => { if (isActive) return e.currentTarget.style.background = 'var(--bg-input)' e.currentTarget.style.color = 'var(--text-primary)' } // Reihe 1: 3 View-Icons + Kamera-Settings const row1 = [ ...VIEWS_ROW1, { value: '__camera__', icon: 'videocam', kind: 'icon' }, ] return (
{/* Reihe 1 */}
{row1.map((v, idx) => { const isActive = v.value !== '__camera__' && matchView(v.value) const label = v.value === '__camera__' ? 'Kamera-Einstellungen' : `Ansicht ${v.value}` return ( ) })}
{/* Reihe 2: N/O/S/W als Buchstaben */}
{VIEWS_ROW2.map((v, idx) => { const isActive = matchView(v.value) return ( ) })}
) })()}
{/* ====== 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] */} {(() => { // Buttons-Pill: gleiche Logik wie View-Toggle (weiss default, // grün on hover, accent-fill wenn active) const PILL_W = 140 // Gleiche Breite fuer Dropdown + Buttons-Pill const N_BTN = 4 const BTN_W = Math.floor(PILL_W / N_BTN) // jeder Button gleich breit 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 = 70 // Breite der gemeinsamen Stat-Box // Gesamte 2-Reihen-Hoehe: 2 × BAR_H + gap (4px) = ~48px return (
{/* Spalte 1, beide Reihen: EINE Pill mit Live-Massstab oben und Zoom-% unten. Text-Hoehen bleiben identisch zu den urspruenglichen 2 Chips — der Trennstrich sitzt in der Mitte des 4px-Gaps zwischen ihnen. */}
{isPerspective ? '—' : fmtScale(scaleVal)}
{ratioText}
{/* Reihe 1, Spalte 2: Gesetzter Massstab Dropdown — KEIN Icon, gleiche Breite wie Buttons-Pill darunter, exakt uebereinander */} {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: PILL_W, 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={PILL_W} title="Gesetzter Massstab" > {PRESETS.map(p => ( ))} {appliedScale != null && !PRESETS.some(p => p.value === appliedScale) && ( )} )} {/* Reihe 2, Spalte 2: Buttons-Pill — gleiche Breite wie Dropdown */}
setShowLineweights(!state.showLineweights)} isLast title={state.showLineweights ? 'Print-View aktiv — klick zum Ausschalten' : 'Strichstärken anzeigen (Print-View)'} />
) })()}
{/* ====== TEXT-Block 2-Reihen ====== Reihe 1: Font-Dropdown + Groesse (mm) Reihe 2: B/I-Toggles + "Text einfuegen"-Button */} {(() => { const ts = state.textSettings || {} const fonts = state.textFonts || [] const updateTs = (patch) => setTextSettings({ ...ts, ...patch }) const TEXT_W = 150 return (
{/* Reihe 1, Spalte 1: Font-Dropdown */} updateTs({ font: v })} width={TEXT_W} title="Schriftart" > {fonts.length === 0 && } {fonts.map(f => )} {/* Reihe 1, Spalte 2: Groesse (mm) */}
{ const v = parseFloat(e.target.value) if (!isNaN(v) && v > 0) updateTs({ size: v }) }} style={{ flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none', color: 'var(--text-primary)', fontSize: 11, fontFamily: 'DM Mono, monospace', padding: 0, textAlign: 'right', appearance: 'auto', }} title="Texthoehe in Model-Units" /> m
{/* Reihe 2, Spalte 1: B/I-Toggles */}
{/* Reihe 2, Spalte 2: "Text einfuegen" Button */}
) })()} {/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */} {/* Spacer am rechten Rand */}
) }