Files
DOSSIER/src/ToolbarApp.jsx
T
karim 375487c10c i18n DE/EN + DossierSettings panel + English file renames
i18n:
- src/i18n/de.json + en.json: 200+ keys covering all main panels
- src/i18n/index.js: t(key, vars) reads window.DOSSIER_LANG
- panel_base.py: injects window.DOSSIER_LANG from dossier_settings.json
- EbenenManager, GeschossManager, AusschnitteApp, LayoutsApp: all
  context menus and main labels use t()

DossierSettings panel:
- DossierSettingsApp.jsx: language toggle (DE/EN pill) + launcher status
- toolbar.py: OPEN_SETTINGS opens new Rhino-hosted satellite window,
  SAVE_LANG writes lang to dossier_settings.json + reloads all panels

File renames (JSX → English):
- ZeichnungsebenenApp → DrawingLevelsApp
- GeschossManager/Dialog/Settings → Floor*
- AusschnitteApp/Settings → Viewports*
- EbenenManager/Settings → Layer*
- GestaltungApp → StylesApp, OberleisteApp → ToolbarApp
- WerkzeugeApp → ToolsApp, DimensionenApp → DimensionsApp
- MassstabApp → ScaleApp, KameraApp → CameraApp
- MasseSettingsApp → UnitsSettingsApp
- ConfirmDeleteEbene → ConfirmDeleteLayer
- AusschnittLayerDialog → ViewportLayerDialog

Python module renames:
- rhinopanel.py → layers_panel.py
- oberleiste.py → toolbar.py
- gestaltung.py → styles.py
- werkzeuge.py → tools.py
- dimensionen.py → dimensions.py
- startup.py _MODULE_TO_PY updated, all cross-imports fixed
2026-06-06 11:09:33 +02:00

1075 lines
45 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Karim Gabriele Varano
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, openCheatsheet, createText, setTextSettings,
applyTextStyle, saveTextStyle, deleteTextStyle,
setDarstellung,
arrangeSelection,
toggleReferenzlinien,
toggleOsnap,
setOsnapMode, toggleGridVisible,
openProjectSettings,
} 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 (
<button
onClick={onClick}
disabled={disabled}
className={active ? 'btn-contained' : 'btn-outlined'}
style={{ height: PILL_H, padding: '0 8px', boxSizing: 'border-box',
fontSize: 9,
opacity: disabled ? 0.4 : 1,
cursor: disabled ? 'not-allowed' : 'pointer' }}
title={title}
>
{icon && <Icon name={icon} size={12} />}
{label && <span style={{ fontSize: 9 }}>{label}</span>}
</button>
)
}
// ---------------------------------------------------------------------------
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 (
<div style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
fontFamily: 'var(--font)', color: 'var(--text-primary)',
background: 'var(--bg-panel)',
borderBottom: '1px solid var(--border)',
boxSizing: 'border-box',
overflow: 'hidden',
}}>
{/* === Toolbar (View, Display, Massstab, Snap, Overrides) === */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 10px 6px',
overflowX: 'auto', overflowY: 'hidden',
flexShrink: 0,
}}>
{/* Logo: DOSSIER. + Version darunter (Klick = Cheatsheet, Shift+Klick = About) */}
<button
onClick={(e) => e.shiftKey ? openAbout() : openCheatsheet()}
title="Shortcuts (Shift+Klick = Über Dossier)"
style={{
display: 'flex', flexDirection: 'column',
alignItems: 'flex-start', gap: 0,
flexShrink: 0, userSelect: 'none',
background: 'transparent', border: 'none', padding: 0,
cursor: 'pointer', color: 'inherit',
}}
>
<span style={{
fontFamily: "Krungthep, 'Archivo Black', sans-serif",
fontSize: 17,
letterSpacing: '-0.02em',
color: 'var(--text-primary)',
lineHeight: 1,
}}>
DOSSIER<span style={{ color: 'var(--accent)' }}>.</span>
</span>
<span style={{
fontFamily: 'DM Mono, monospace',
fontSize: 8, letterSpacing: '0.05em',
color: 'var(--text-muted)', lineHeight: 1.4, marginTop: 2,
}}>
v{__APP_VERSION__}
</span>
</button>
{/* Settings-Icons: Launcher + Projekt — vertikal gestapelt */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 1,
flexShrink: 0 }}>
<button
onClick={() => openDossierSettings()}
title="Dossier-Einstellungen (App-Settings, Window-Layout)"
style={{
background: 'transparent', border: 'none', padding: '1px 4px',
cursor: 'pointer', color: 'var(--text-muted)',
display: 'flex', alignItems: 'center',
}}
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-muted)'}
>
<Icon name="settings" size={12} />
</button>
<button
onClick={() => openProjectSettings()}
title="Projekt-Einstellungen (Voreinstellungen Geschoss/Schnitt + Material-Library)"
style={{
background: 'transparent', border: 'none', padding: '1px 4px',
cursor: 'pointer', color: 'var(--text-muted)',
display: 'flex', alignItems: 'center',
}}
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-muted)'}
>
<Icon name="tune" size={12} />
</button>
</div>
<div style={sep} />
{/* ====== 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 (
<div style={{
display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0,
}}>
{/* Reihe 1 */}
<div style={{
display: 'inline-flex', width: VIEW_W,
height: BAR_H + 2, boxSizing: 'border-box',
border: '1px solid var(--border)', borderRadius: 999,
overflow: 'hidden', flexShrink: 0,
}}>
{row1.map((v, idx) => {
const isActive = v.value !== '__camera__' && matchView(v.value)
const label = v.value === '__camera__' ? 'Kamera-Einstellungen'
: `Ansicht ${v.value}`
return (
<button key={v.value}
onClick={() => v.value === '__camera__'
? openKameraPanel() : setView(v.value)}
title={label}
onMouseEnter={hoverIn(isActive)}
onMouseLeave={hoverOut(isActive)}
style={cellStyle(isActive, idx === 0)}>
<Icon name={v.icon} size={11} />
</button>
)
})}
</div>
{/* Reihe 2: N/O/S/W als Buchstaben */}
<div style={{
display: 'inline-flex', width: VIEW_W,
height: BAR_H + 2, boxSizing: 'border-box',
border: '1px solid var(--border)', borderRadius: 999,
overflow: 'hidden', flexShrink: 0,
}}>
{VIEWS_ROW2.map((v, idx) => {
const isActive = matchView(v.value)
return (
<button key={v.value}
onClick={() => 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)}>
<span style={{
fontFamily: 'DM Mono, monospace', fontSize: 10, fontWeight: 600,
}}>{v.label}</span>
</button>
)
})}
</div>
</div>
)
})()}
<div style={sep} />
{/* ====== 2-Reihen Preset-Block ======
Oben: Display | Kombi
Unten: Overrides | Masse
Gleiche Pill-Breiten, identische X-Positionen (Grid-Layout). */}
{(() => {
const PRESET_W = 150
return (
<div style={{
display: 'grid', gridTemplateColumns: 'auto auto',
gap: '4px 6px', flexShrink: 0,
}}>
{/* Reihe 1, Spalte 1: Display */}
<BarCombo
icon="lightbulb"
value={state.displayMode || ''}
onChange={(v) => setDisplayMode(v)}
title="Display-Mode (Wireframe / Shaded / Rendered / etc.)"
width={PRESET_W}
>
{!state.displayMode && <option value=""></option>}
{(state.displayModes || []).map(dm => (
<option key={dm.id} value={dm.name}>{dm.name}</option>
))}
</BarCombo>
{/* Reihe 1, Spalte 2: Modelldarstellung (SIA-400 LoD) */}
<BarCombo
icon="tune"
value={state.aktiveDarstellung || 'einfach'}
onChange={(v) => setDarstellung(v)}
title="Modelldarstellung — Default fuer Fenster/Tueren auf 'Auto'. Einzelobjekt-Override im Properties-Panel."
width={PRESET_W}
>
<option value="einfach">Einfach (1:100)</option>
<option value="standard">Standard (1:50)</option>
<option value="detail">Detail (1:20)</option>
</BarCombo>
{/* Reihe 2, Spalte 1: Overrides (Toggle als Icon links) */}
<BarCombo
icon="auto_fix_high"
iconClickable
iconActive={state.overridesEnabled}
onIconClick={() => 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"
>
<option value="__none__">{state.overridesCount > 0 ? `— (${state.overridesCount} Regeln)` : '—'}</option>
{(state.overridesPresets || []).map(name => (
<option key={name} value={name}>{name}</option>
))}
<option disabled></option>
<option value="__configure__">Konfigurieren</option>
</BarCombo>
{/* Reihe 2, Spalte 2: Masse */}
<BarCombo
icon="straighten"
value={state.masseActiveId || ''}
onChange={(v) => setMasseActive(v)}
title="Aktives Mass — Raum-Rundung + Mass-Linien-Format"
width={PRESET_W}
onGear={openMasseSettings}
gearTitle="Masse bearbeiten / neues anlegen"
>
{(state.massePresets || []).length === 0 && <option value=""></option>}
{(state.massePresets || []).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</BarCombo>
</div>
)
})()}
<div style={sep} />
{/* ====== 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 = 3 // ohne Lineweights — der sitzt jetzt oben neben Dropdown
const BTN_W = Math.floor(PILL_W / N_BTN) // jeder Button gleich breit
const SegBtn = ({ icon, onClick, title, disabled, active, isFirst, isLast }) => (
<button onClick={onClick} disabled={disabled} title={title}
onMouseEnter={(e) => {
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',
}}>
<Icon name={icon} size={11} />
</button>
)
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 (
<div style={{
display: 'grid', gridTemplateColumns: 'auto auto', gap: '4px 6px',
alignItems: 'center', flexShrink: 0,
}}>
{/* 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. */}
<div style={{
gridRow: '1 / span 2',
display: 'flex', flexDirection: 'column',
width: STAT_W, height: BAR_H * 2 + 6,
background: atScale ? 'var(--accent-dim)' : 'var(--bg-input)',
color: atScale ? 'var(--accent-light)' : 'var(--text-primary)',
border: '1px solid ' + (atScale ? 'var(--accent)' : 'var(--border)'),
borderRadius: 14,
overflow: 'hidden',
fontFamily: 'DM Mono, monospace', fontSize: 11,
fontWeight: atScale ? 600 : 500,
flexShrink: 0,
transition: 'border-color 0.15s, background 0.15s',
}}>
<div style={{
height: BAR_H,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} title={isPerspective ? 'Perspektive — kein Massstab' : 'Aktueller Live-Massstab'}>
{isPerspective ? '—' : fmtScale(scaleVal)}
</div>
<div style={{ height: 6, position: 'relative' }}>
<div style={{
position: 'absolute', left: 6, right: 6,
top: '50%', height: 1,
background: 'var(--border)',
}} />
</div>
<div style={{
height: BAR_H,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} title={ratio != null
? `Aktueller Zoom = ${ratioText} des gesetzten Massstabs`
: (isPerspective ? 'Perspektive' : 'Kein Massstab gesetzt')}>
{ratioText}
</div>
</div>
{/* Reihe 1, Spalte 2: Gesetzter Massstab Dropdown — KEIN Icon, gleiche
Breite wie Buttons-Pill darunter, exakt uebereinander */}
{/* Dropdown + Druck-Ansicht-Toggle in einer Flex-Reihe — der
Toggle sitzt jetzt oben statt unten im Zoom-Pill, weil er
massstabs-nah ist (Print-View = scale-korrekte Strichstaerken). */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{customMode ? (
<input
ref={customInputRef}
disabled={isPerspective}
type="text" placeholder="1:N"
value={draft}
onChange={(e) => 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)"
/>
) : (
<BarCombo
value={dropdownValue}
onChange={(v) => applyDropdown(v)}
disabled={isPerspective}
width={PILL_W}
title="Gesetzter Massstab"
>
<option value="__none__"></option>
{PRESETS.map(p => (
<option key={p.value} value={String(p.value)}>{p.label}</option>
))}
{appliedScale != null && !PRESETS.some(p => p.value === appliedScale) && (
<option value={String(appliedScale)}>1:{appliedScale}</option>
)}
<option value="__custom__">Eigener</option>
</BarCombo>
)}
<BarButton
icon={state.showLineweights ? 'print' : 'edit'}
active={state.showLineweights}
onClick={() => setShowLineweights(!state.showLineweights)}
title={state.showLineweights
? 'Print-View aktiv — klick zum Ausschalten'
: 'Strichstaerken anzeigen (Print-View)'} />
</div>
{/* Reihe 2, Spalte 2: Zoom-Pill + Referenzlinien-Toggle.
Symmetrisch zur Reihe 1 (Dropdown + Lineweights-Button). */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<div style={{
display: 'inline-flex', width: PILL_W,
height: BAR_H + 2, boxSizing: 'border-box',
border: '1px solid var(--border)', borderRadius: 999,
overflow: 'hidden', flexShrink: 0,
}}>
<SegBtn icon="percent" onClick={apply100} isFirst
disabled={isPerspective || !appliedScale}
title={appliedScale ? `Zoom auf 1:${appliedScale} snappen` : 'Erst einen Massstab wählen'} />
<SegBtn icon="fit_screen" onClick={zoomExtents}
title="Auf gesamten Inhalt zoomen" />
<SegBtn icon="center_focus_strong" onClick={zoomSelection}
isLast
title="Auf Selektion zoomen" />
</div>
<BarButton
icon={state.referenzlinienVisible === false ? 'visibility_off' : 'visibility'}
active={state.referenzlinienVisible !== false}
onClick={() => toggleReferenzlinien(state.referenzlinienVisible === false)}
title={state.referenzlinienVisible === false
? 'Referenzlinien einblenden (Wandachsen, Oeffnungs-Punkte)'
: 'Referenzlinien ausblenden (Wandachsen, Oeffnungs-Punkte)'} />
</div>
</div>
)
})()}
<div style={sep} />
{/* ====== ANORDNEN (2D-Z-Stack via Rhino-DisplayOrder) ======
2x2-Grid (quadratisch): oben Aufwaerts-Aktionen, unten Abwaerts.
Reihe 1: Vorderste | 1 hoch (vertical_align_top, expand_less)
Reihe 2: 1 runter | Hinterste (expand_more, vertical_align_bottom)
Selection-Check + Rhino-DisplayOrder im Backend, keine Z-Offsets. */}
{(() => {
const CELL = 26 // quadratisch: 2 * CELL Breite, ~2 * BAR_H Hoehe
const Btn = ({ icon, dir, title, isFirst }) => (
<button onClick={() => arrangeSelection(dir)} title={title}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
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: CELL,
background: 'var(--bg-input)', color: 'var(--text-primary)',
border: 'none',
borderLeft: isFirst ? 'none' : '1px solid var(--border)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0,
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, boxSizing: 'border-box',
transition: 'background 0.15s, color 0.15s',
}}>
<Icon name={icon} size={11} />
</button>
)
const rowStyle = {
display: 'inline-flex', width: CELL * 2,
height: BAR_H + 2, boxSizing: 'border-box',
border: '1px solid var(--border)', borderRadius: 999,
overflow: 'hidden', flexShrink: 0,
}
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0,
}}>
<div style={rowStyle}>
<Btn icon="vertical_align_top" dir="front" isFirst
title="In den Vordergrund (Bring to Front)" />
<Btn icon="expand_less" dir="forward"
title="Eine Stufe hoch (Bring Forward)" />
</div>
<div style={rowStyle}>
<Btn icon="expand_more" dir="backward" isFirst
title="Eine Stufe runter (Send Backward)" />
<Btn icon="vertical_align_bottom" dir="back"
title="In den Hintergrund (Send to Back)" />
</div>
</div>
)
})()}
<div style={sep} />
{/* ====== SNAP-BAR (Architektur-Osnaps + Grid) ======
Layout: links zwei einzelne Toggle-Buttons (Master-O / Grid)
stacked. Rechts zwei 3-Zellen-Pills mit den Osnap-Modi.
Master-O und Grid sind globale Toggles → eigene Buttons.
Die 6 Modi gehören zusammen → Pill-Gruppe.
Ortho + Grid-Snap sind in Rhinos Footer-Bar — hier nicht. */}
{(() => {
const om = state.osnapModes || {}
const osnapDisabled = !state.osnap
// Bar-Cell: Teil einer Segment-Pill (kein eigener Border-Radius)
const BarCell = ({ icon, active, disabled, onClick, isFirst, title }) => (
<button onClick={onClick} disabled={disabled} title={title}
onMouseEnter={(e) => {
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, width: BAR_H, padding: 0,
background: active ? 'var(--accent)' : 'var(--bg-input)',
color: active ? 'var(--bg-panel)' : 'var(--text-primary)',
border: 'none',
borderLeft: isFirst ? 'none' : '1px solid var(--border)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.35 : 1,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, boxSizing: 'border-box', flexShrink: 0,
transition: 'background 0.15s, color 0.15s',
}}>
<Icon name={icon} size={12} />
</button>
)
const pillRowStyle = {
display: 'inline-flex', width: BAR_H * 3,
height: BAR_H + 2, boxSizing: 'border-box',
border: '1px solid var(--border)', borderRadius: 999,
overflow: 'hidden', flexShrink: 0,
}
return (
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-start',
flexShrink: 0 }}>
{/* Linke Spalte: 2 einzelne Toggle-Buttons stacked */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<BarButton icon="gps_fixed"
active={!!state.osnap}
onClick={() => toggleOsnap(!state.osnap)}
title={state.osnap ? 'Object-Snap an — Klick zum Ausschalten'
: 'Object-Snap aus — Klick zum Einschalten'} />
<BarButton
icon={state.gridVisible === false ? 'grid_off' : 'grid_on'}
active={state.gridVisible !== false}
onClick={() => toggleGridVisible(state.gridVisible === false)}
title="Konstruktions-Raster ein-/ausblenden" />
</div>
{/* Rechte Spalte: 2 Pills mit den 6 Osnap-Modi */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={pillRowStyle}>
<BarCell icon="crop_square" isFirst disabled={osnapDisabled}
active={!!om.end}
onClick={() => setOsnapMode('end', !om.end)}
title="Endpunkt (End)" />
<BarCell icon="change_history" disabled={osnapDisabled}
active={!!om.mid}
onClick={() => setOsnapMode('mid', !om.mid)}
title="Mittelpunkt (Mid)" />
<BarCell icon="close" disabled={osnapDisabled}
active={!!om.int}
onClick={() => setOsnapMode('int', !om.int)}
title="Schnittpunkt (Intersection)" />
</div>
<div style={pillRowStyle}>
<BarCell icon="square_foot" isFirst disabled={osnapDisabled}
active={!!om.perp}
onClick={() => setOsnapMode('perp', !om.perp)}
title="Lotrecht (Perpendicular)" />
<BarCell icon="radio_button_unchecked" disabled={osnapDisabled}
active={!!om.cen}
onClick={() => setOsnapMode('cen', !om.cen)}
title="Kreis-/Bogen-Mittelpunkt (Center)" />
<BarCell icon="add" disabled={osnapDisabled}
active={!!om.near}
onClick={() => setOsnapMode('near', !om.near)}
title="Naechster Punkt (Near)" />
</div>
</div>
</div>
)
})()}
<div style={sep} />
{/* ====== 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 }) => (
<button onClick={onClick} title={title}
onMouseEnter={(e) => {
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}
</button>
)
return (
<div style={{
display: 'grid',
gridTemplateColumns: `${STYLE_W}px ${FONT_W}px ${SIZE_W}px`,
gap: '4px 6px', alignItems: 'center', flexShrink: 0,
}}>
{/* === Reihe 1 === */}
{/* Style-Dropdown */}
<BarCombo
value={activeStyleId || ''}
onChange={(v) => {
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"
>
<option value=""> Stil </option>
{styles.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
<option disabled></option>
<option value="__save__">+ Speichern</option>
{activeStyleId && <option value="__delete__">🗑 Aktiven löschen</option>}
</BarCombo>
{/* Font-Dropdown */}
<BarCombo
value={ts.font || ''}
onChange={(v) => updateTs({ font: v })}
width={FONT_W}
title={sel ? 'Schriftart (auf Selektion appliziert)' : 'Schriftart'}
>
{fonts.length === 0 && <option value=""> Fonts laden </option>}
{fonts.map(f => <option key={f} value={f}>{f}</option>)}
</BarCombo>
{/* Size-Dropdown mit "Eigene" / Custom-Input */}
{textSizeCustom ? (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
height: BAR_H + 2, padding: '0 8px', boxSizing: 'border-box',
background: 'var(--bg-input)',
border: '1px solid ' + ACTIVE_BORDER,
borderRadius: 999,
flexShrink: 0, width: SIZE_W,
}}>
<input
type="number" step="0.01" min="0.01" autoFocus
value={ts.size != null ? ts.size : 0.2}
onChange={(e) => {
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',
}}
/>
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>m</span>
</div>
) : (
<BarCombo
value={String(ts.size != null ? ts.size : 0.2)}
onChange={(v) => {
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 => (
<option key={s} value={String(s)}>{s.toFixed(2)} m</option>
))}
{ts.size != null && !SIZE_PRESETS.some(s => Math.abs(s - ts.size) < 0.001) && (
<option value={String(ts.size)}>{ts.size.toFixed(2)} m</option>
)}
<option disabled></option>
<option value="__custom__">Eigene</option>
</BarCombo>
)}
{/* === Reihe 2 === */}
{/* B/I/U Segmented */}
<div style={{
display: 'inline-flex',
border: '1px solid ' + ACTIVE_BORDER, borderRadius: 999,
overflow: 'hidden', flexShrink: 0, width: STYLE_W,
height: BAR_H + 2, boxSizing: 'border-box',
transition: 'border-color 0.15s',
}}>
<ToggleBtn active={!!ts.bold}
onClick={() => updateTs({ bold: !ts.bold })}
title={(sel ? 'Fett auf Selektion' : 'Fett') + ' (Default)'}>
<Icon name="format_bold" size={13} />
</ToggleBtn>
<ToggleBtn active={!!ts.italic} borderLeft
onClick={() => updateTs({ italic: !ts.italic })}
title={(sel ? 'Kursiv auf Selektion' : 'Kursiv') + ' (Default)'}>
<Icon name="format_italic" size={13} />
</ToggleBtn>
<ToggleBtn active={!!ts.underline} borderLeft
onClick={() => updateTs({ underline: !ts.underline })}
title="Unterstrichen (Settings — Rendering kommt mit RichText-Support)">
<Icon name="format_underlined" size={13} />
</ToggleBtn>
</div>
{/* L/C/R Align Segmented */}
<div style={{
display: 'inline-flex',
border: '1px solid ' + ACTIVE_BORDER, borderRadius: 999,
overflow: 'hidden', flexShrink: 0, width: FONT_W,
height: BAR_H + 2, boxSizing: 'border-box',
transition: 'border-color 0.15s',
}}>
<ToggleBtn active={(ts.align || 'left') === 'left'}
onClick={() => updateTs({ align: 'left' })}
title={(sel ? 'Linksbuendig auf Selektion' : 'Linksbuendig')}>
<Icon name="format_align_left" size={13} />
</ToggleBtn>
<ToggleBtn active={ts.align === 'center'} borderLeft
onClick={() => updateTs({ align: 'center' })}
title={(sel ? 'Zentriert auf Selektion' : 'Zentriert')}>
<Icon name="format_align_center" size={13} />
</ToggleBtn>
<ToggleBtn active={ts.align === 'right'} borderLeft
onClick={() => updateTs({ align: 'right' })}
title={(sel ? 'Rechtsbuendig auf Selektion' : 'Rechtsbuendig')}>
<Icon name="format_align_right" size={13} />
</ToggleBtn>
</div>
{/* + Neuen Text einfuegen */}
<button
onClick={() => 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"
>
<Icon name="add" size={14} />
</button>
</div>
)
})()}
{/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar
schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */}
{/* Spacer am rechten Rand */}
<div style={{ flex: 1 }} />
</div>
</div>
)
}