import { useState, useEffect, useRef } from 'react' import Icon from './components/Icon' import { BarCombo, BarButton, BAR_H } from './components/BarControls' 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, applyTextStyle, saveTextStyle, deleteTextStyle, setDarstellung, } 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 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 + BarButton + BAR_H jetzt zentral in ./components/BarControls.jsx — // werden auch in Ebenen/anderen Panels verwendet. 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, underline: false, align: 'left' }, textSelectionSettings: null, textFonts: [], textStyles: [], textStyleActiveId: null, 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) const [textSizeCustom, setTextSizeCustom] = useState(false) 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: Modelldarstellung (SIA-400 LoD) */} setDarstellung(v)} title="Darstellungs-Override fuer Fenster/Tueren (SIA-400 LoD)" width={PRESET_W} > {/* 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 (Vectorworks-Stil) ====== Reihe 1: Style ▼ | Font ▼ | Size ▼ Reihe 2: [B][I][U] | [L][C][R] | [+] Wenn TextEntity selektiert: Werte spiegeln Selektion, Aenderungen gehen direkt rauf und werden zusaetzlich als Default gespeichert. */} {(() => { const sel = state.textSelectionSettings const ts = sel || state.textSettings || {} const fonts = state.textFonts || [] const styles = state.textStyles || [] // Bei Selektion: Style-ID vom Text selber (falls per apply_style gesetzt), // sonst auf globalen Active-Style fallen const activeStyleId = (sel && sel.styleId) || state.textStyleActiveId const updateTs = (patch) => setTextSettings({ ...ts, ...patch }) const STYLE_W = 110 const FONT_W = 130 const SIZE_W = 80 const ACTIVE_BORDER = sel ? 'var(--accent)' : 'var(--border)' const SIZE_PRESETS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.50, 0.70, 1.00] // Toggle-Button-Helper fuer B/I/U/Align const ToggleBtn = ({ active, onClick, title, children, borderLeft, lastInRow }) => ( ) return (
{/* === Reihe 1 === */} {/* Style-Dropdown */} { if (v === '__save__') { const n = (window.prompt('Name für neuen Text-Stil:', 'Stil') || '').trim() if (n) saveTextStyle(n) return } if (v === '__delete__') { if (activeStyleId && window.confirm(`Stil "${styles.find(s => s.id === activeStyleId)?.name}" löschen?`)) deleteTextStyle(activeStyleId) return } if (v) applyTextStyle(v) }} width={STYLE_W} title="Text-Stil — gespeicherte Settings-Presets" > {styles.map(s => )} {activeStyleId && } {/* Font-Dropdown */} updateTs({ font: v })} width={FONT_W} title={sel ? 'Schriftart (auf Selektion appliziert)' : 'Schriftart'} > {fonts.length === 0 && } {fonts.map(f => )} {/* Size-Dropdown mit "Eigene" / Custom-Input */} {textSizeCustom ? (
{ const v = parseFloat(e.target.value) if (!isNaN(v) && v > 0) updateTs({ size: v }) }} onBlur={() => setTextSizeCustom(false)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === 'Escape') { e.target.blur() } }} 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', }} /> m
) : ( { if (v === '__custom__') { setTextSizeCustom(true); return } const n = parseFloat(v) if (!isNaN(n) && n > 0) updateTs({ size: n }) }} width={SIZE_W} title={sel ? 'Texthoehe (auf Selektion appliziert)' : 'Texthoehe (m)'} > {SIZE_PRESETS.map(s => ( ))} {ts.size != null && !SIZE_PRESETS.some(s => Math.abs(s - ts.size) < 0.001) && ( )} )} {/* === Reihe 2 === */} {/* B/I/U Segmented */}
updateTs({ bold: !ts.bold })} title={(sel ? 'Fett auf Selektion' : 'Fett') + ' (Default)'}> updateTs({ italic: !ts.italic })} title={(sel ? 'Kursiv auf Selektion' : 'Kursiv') + ' (Default)'}> updateTs({ underline: !ts.underline })} title="Unterstrichen (Settings — Rendering kommt mit RichText-Support)">
{/* L/C/R Align Segmented */}
updateTs({ align: 'left' })} title={(sel ? 'Linksbuendig auf Selektion' : 'Linksbuendig')}> updateTs({ align: 'center' })} title={(sel ? 'Zentriert auf Selektion' : 'Zentriert')}> updateTs({ align: 'right' })} title={(sel ? 'Rechtsbuendig auf Selektion' : 'Rechtsbuendig')}>
{/* + Neuen Text einfuegen */}
) })()} {/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */} {/* Spacer am rechten Rand */}
) }