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 (
{icon && }
{label && {label} }
)
}
// ---------------------------------------------------------------------------
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 */}
openAbout()}
title="Über Dossier"
style={{
display: 'flex', alignItems: 'baseline', gap: 8,
flexShrink: 0, userSelect: 'none',
background: 'transparent', border: 'none', padding: 0,
cursor: 'pointer', color: 'inherit',
}}
>
DOSSIER.
openDossierSettings()}
title="Dossier-Einstellungen"
style={{
background: 'transparent', border: 'none', padding: '2px 4px',
cursor: 'pointer', color: 'var(--text-muted)',
display: 'flex', alignItems: 'center', flexShrink: 0,
}}
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-muted)'}
>
{/* ====== 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 (
v.value === '__camera__'
? openKameraPanel() : setView(v.value)}
title={label}
onMouseEnter={hoverIn(isActive)}
onMouseLeave={hoverOut(isActive)}
style={cellStyle(isActive, idx === 0)}>
)
})}
{/* Reihe 2: N/O/S/W als Buchstaben */}
{VIEWS_ROW2.map((v, idx) => {
const isActive = matchView(v.value)
return (
setView(v.value)}
title={`Ansicht aus ${v.value} (Norden = ${(state.northAngle || 0).toFixed(0)}°)`}
onMouseEnter={hoverIn(isActive)}
onMouseLeave={hoverOut(isActive)}
style={cellStyle(isActive, idx === 0)}>
{v.label}
)
})}
)
})()}
{/* ====== 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 => (
{dm.name}
))}
{/* Reihe 1, Spalte 2: Modelldarstellung (SIA-400 LoD) */}
setDarstellung(v)}
title="Darstellungs-Override fuer Fenster/Tueren (SIA-400 LoD)"
width={PRESET_W}
>
— per Element —
Einfach (1:100)
Standard (1:50)
Detail (1:20)
{/* 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.overridesCount > 0 ? `— (${state.overridesCount} Regeln)` : '—'}
{(state.overridesPresets || []).map(name => (
{name}
))}
──────────
Konfigurieren…
{/* 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 => (
{p.name}
))}
)
})()}
{/* ====== 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 }) => (
{
if (disabled || active) return
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
if (active) return
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
style={{
height: BAR_H, minHeight: BAR_H, maxHeight: BAR_H,
width: BTN_W,
background: active ? 'var(--accent)' : 'var(--bg-input)',
color: active ? 'var(--bg-panel)' : 'var(--text-primary)',
border: 'none',
borderLeft: isFirst ? 'none' : '1px solid var(--border)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.4 : 1, flexShrink: 0,
padding: 0,
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, boxSizing: 'border-box',
transition: 'background 0.15s, color 0.15s',
}}>
)
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 => (
{p.label}
))}
{appliedScale != null && !PRESETS.some(p => p.value === appliedScale) && (
1:{appliedScale}
)}
Eigener…
)}
{/* 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 }) => (
{
if (active) return
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
if (active) return
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
style={{
flex: 1, height: '100%',
background: active ? 'var(--accent)' : 'var(--bg-input)',
color: active ? 'var(--bg-panel)' : 'var(--text-primary)',
border: 'none',
borderLeft: borderLeft ? '1px solid var(--border)' : 'none',
cursor: 'pointer',
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, padding: 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.15s, color 0.15s',
}}>
{children}
)
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"
>
— Stil —
{styles.map(s => {s.name} )}
──────────
+ Speichern…
{activeStyleId && 🗑 Aktiven löschen }
{/* Font-Dropdown */}
updateTs({ font: v })}
width={FONT_W}
title={sel ? 'Schriftart (auf Selektion appliziert)' : 'Schriftart'}
>
{fonts.length === 0 && — Fonts laden — }
{fonts.map(f => {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 => (
{s.toFixed(2)} m
))}
{ts.size != null && !SIZE_PRESETS.some(s => Math.abs(s - ts.size) < 0.001) && (
{ts.size.toFixed(2)} m
)}
──────────
Eigene…
)}
{/* === 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 */}
createText()}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.borderColor = 'var(--accent)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
style={{
width: SIZE_W, height: BAR_H + 2, boxSizing: 'border-box',
background: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: 999,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, padding: 0, flexShrink: 0,
transition: 'background 0.15s, color 0.15s, border-color 0.15s',
}}
title="Neuen Text einfuegen — Position picken, Text eingeben"
>
)
})()}
{/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar
schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */}
{/* Spacer am rechten Rand */}
)
}