ea4c891b98
User-Wunsch: ueber dem aktuellen Live-Zoom soll der gesetzte Massstab in gruen oben sein, unten das Verhaeltnis des aktuellen Zooms zum gesetzten. Layout: - Buttons-Pill links (vertikal zentriert ueber beide Reihen) - Rechts oben: Massstab-Dropdown — BarCombo mit neuem valueAccent prop, faerbt Select-Text in accent-light + bold wenn ein Massstab gesetzt ist - Rechts unten: Zoom-Ratio-Chip (% des gesetzten Massstabs). 100% = at-scale → accent-dim Hintergrund + accent-light Text. <100% = rausgezoomt, >100% = reingezoomt → bg-input + muted text. Ratio-Formel: appliedScale / liveScale * 100. - 1:50 gesetzt, live 1:50 → 100% (exakt, accent) - 1:50 gesetzt, live 1:100 → 50% (rausgezoomt) - 1:50 gesetzt, live 1:25 → 200% (reingezoomt) Per Klick auf das "%" Button (Reihe 1) snappt der Zoom auf 100%. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
700 lines
28 KiB
React
700 lines
28 KiB
React
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 <select>. Vectorworks-Stil —
|
|
// dunkler Pill-Container, Caret rechts, joinedRight macht die rechte Kante
|
|
// flach fuer die Verkettung mit BarButton.
|
|
function BarSelect({ icon, value, onChange, title, disabled, width, children, joinedRight }) {
|
|
return (
|
|
<div title={title} style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
|
opacity: disabled ? 0.5 : 1, flexShrink: 0,
|
|
}}>
|
|
{icon && (
|
|
<Icon name={icon} size={13}
|
|
style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
|
)}
|
|
<select
|
|
value={value || ''}
|
|
disabled={disabled}
|
|
onChange={(e) => 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}</select>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<div style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
|
opacity: disabled ? 0.5 : 1, flexShrink: 0,
|
|
}}>
|
|
{/* Icon links — fixe Breite fuer X-Axis-Alignment zwischen Reihen */}
|
|
{iconClickable ? (
|
|
<button onClick={onIconClick} title={iconTitle}
|
|
style={{
|
|
width: 18, height: BAR_H,
|
|
background: 'transparent', border: 'none',
|
|
cursor: 'pointer', flexShrink: 0, padding: 0,
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<Icon name={icon} size={13}
|
|
style={{ color: iconActive ? 'var(--accent)' : 'var(--text-muted)' }} />
|
|
</button>
|
|
) : (
|
|
<span style={{
|
|
width: 18, height: BAR_H, flexShrink: 0,
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<Icon name={icon} size={13} style={{ color: 'var(--text-muted)' }} />
|
|
</span>
|
|
)}
|
|
{/* Combined pill: select + optional gear, gemeinsamer bg + border */}
|
|
<div title={title} style={{
|
|
display: 'inline-flex', alignItems: 'stretch',
|
|
height: BAR_H, width,
|
|
background: 'var(--bg-input)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 999,
|
|
overflow: 'hidden',
|
|
}}>
|
|
<select
|
|
value={value || ''}
|
|
disabled={disabled}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={{
|
|
flex: 1, minWidth: 0,
|
|
background: 'transparent',
|
|
color: valueAccent ? 'var(--accent-light)' : 'var(--text-primary)',
|
|
border: 'none', outline: 'none',
|
|
padding: '0 22px 0 12px',
|
|
fontSize: 11, fontFamily: 'var(--font)',
|
|
fontWeight: valueAccent ? 600 : 500,
|
|
appearance: 'none', WebkitAppearance: 'none',
|
|
backgroundImage: 'var(--select-arrow)',
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundPosition: 'right 9px center',
|
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
letterSpacing: 0,
|
|
}}
|
|
>{children}</select>
|
|
{onGear && (
|
|
<button onClick={onGear} title={gearTitle}
|
|
style={{
|
|
background: 'transparent', border: 'none',
|
|
padding: '0 8px', cursor: 'pointer',
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}>
|
|
<Icon name="settings" size={12}
|
|
style={{ color: 'var(--text-muted)' }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<button onClick={onClick} disabled={disabled} title={title}
|
|
style={{
|
|
height: BAR_H, width: BAR_H,
|
|
background: active ? 'var(--accent)' : 'var(--bg-input)',
|
|
border: '1px solid var(--border)',
|
|
borderTopLeftRadius: joinedLeft ? 0 : 999,
|
|
borderBottomLeftRadius: joinedLeft ? 0 : 999,
|
|
borderTopRightRadius: 999, borderBottomRightRadius: 999,
|
|
borderLeft: joinedLeft ? 'none' : '1px solid var(--border)',
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
opacity: disabled ? 0.5 : 1, flexShrink: 0,
|
|
padding: 0,
|
|
}}>
|
|
<Icon name={icon} size={13}
|
|
style={{ color: active ? 'var(--bg-panel)' : 'var(--text-muted)' }} />
|
|
</button>
|
|
)
|
|
}
|
|
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,
|
|
})
|
|
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 (
|
|
<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: '3px 10px 6px',
|
|
overflowX: 'auto', overflowY: 'hidden',
|
|
flexShrink: 0,
|
|
}}>
|
|
{/* Logo: DOSSIER. (Petrol-Punkt) — Klick = About-Fenster */}
|
|
<button
|
|
onClick={() => 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',
|
|
}}
|
|
>
|
|
<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>
|
|
</button>
|
|
<button
|
|
onClick={() => 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)'}
|
|
>
|
|
<Icon name="settings" size={14} />
|
|
</button>
|
|
<div style={sep} />
|
|
{/* ====== VIEW (Top/Front/Right/Iso/Persp + Kamera) ======
|
|
Segmented-Pill-Gruppe analog Vectorworks. Active = accent fill. */}
|
|
<div style={{ display: 'inline-flex', flexShrink: 0 }}>
|
|
{VIEWS.map((v, idx) => {
|
|
const isFirst = idx === 0
|
|
const isLast = idx === VIEWS.length - 1
|
|
const isActive = matchView(v.value)
|
|
return (
|
|
<button
|
|
key={v.value}
|
|
onClick={() => setView(v.value)}
|
|
title={`Ansicht ${v.label}`}
|
|
style={{
|
|
height: BAR_H, padding: '0 10px',
|
|
background: isActive ? 'var(--accent)' : 'var(--bg-input)',
|
|
color: isActive ? 'var(--bg-panel)' : 'var(--text-primary)',
|
|
border: '1px solid var(--border)',
|
|
borderLeft: isFirst ? '1px solid var(--border)' : 'none',
|
|
borderTopLeftRadius: isFirst ? 999 : 0,
|
|
borderBottomLeftRadius: isFirst ? 999 : 0,
|
|
borderTopRightRadius: isLast ? 999 : 0,
|
|
borderBottomRightRadius: isLast ? 999 : 0,
|
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
fontSize: 10, fontWeight: isActive ? 600 : 500,
|
|
cursor: 'pointer', flexShrink: 0,
|
|
}}
|
|
>
|
|
<Icon name={v.icon} size={13} />
|
|
<span>{v.label}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<BarButton icon="videocam" onClick={() => openKameraPanel()}
|
|
title="Kamera-Einstellungen (Position, Target, Linse, Presets)" />
|
|
|
|
<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: Ebenenkombination */}
|
|
<BarCombo
|
|
icon="layers"
|
|
value={state.layerCombinationActive || '__none__'}
|
|
onChange={(v) => {
|
|
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"
|
|
>
|
|
<option value="__none__">— Eigene —</option>
|
|
{(state.layerCombinations || []).map(name => (
|
|
<option key={name} value={name}>{name}</option>
|
|
))}
|
|
<option disabled>──────────</option>
|
|
<option value="__save__">+ Aktuelle speichern…</option>
|
|
{state.layerCombinationActive && (
|
|
<option value="__delete__">🗑 Aktuelle löschen</option>
|
|
)}
|
|
<option value="__configure__">Bearbeiten…</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 2-Reihen ======
|
|
Links: Segmented-Pill mit 4 Tools (zentriert ueber beide Reihen)
|
|
Rechts oben: Massstab-Dropdown (gesetzt = green)
|
|
Rechts unten: Zoom-Verhaeltnis zum gesetzten Massstab (% Anzeige)
|
|
*/}
|
|
{(() => {
|
|
const SegBtn = ({ icon, onClick, title, disabled, active, isFirst, isLast }) => (
|
|
<button onClick={onClick} disabled={disabled} title={title}
|
|
style={{
|
|
height: BAR_H, width: 30,
|
|
background: active ? 'var(--accent)' : 'var(--bg-input)',
|
|
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,
|
|
}}>
|
|
<Icon name={icon} size={13}
|
|
style={{ color: active ? 'var(--bg-panel)' : 'var(--text-muted)' }} />
|
|
</button>
|
|
)
|
|
// Zoom-Verhaeltnis: setScale / liveScale → %
|
|
// 100% = perfekt am gesetzten Massstab; >100 = reingezoomt; <100 = rausgezoomt
|
|
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
|
|
return (
|
|
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-start', flexShrink: 0 }}>
|
|
{/* Buttons-Pill — sitzt auf Reihe 1, vertikal zentriert ueber beide Reihen */}
|
|
<div style={{
|
|
display: 'inline-flex', alignSelf: 'center',
|
|
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}
|
|
title="Auf Selektion zoomen" />
|
|
<SegBtn
|
|
icon={state.showLineweights ? 'print' : 'edit'}
|
|
active={state.showLineweights}
|
|
onClick={() => setShowLineweights(!state.showLineweights)}
|
|
isLast
|
|
title={state.showLineweights
|
|
? 'Print-View aktiv — klick zum Ausschalten'
|
|
: 'Strichstärken anzeigen (Print-View)'} />
|
|
</div>
|
|
{/* Dropdown + Zoom-Ratio gestapelt */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{/* Reihe 1: Set-Massstab Dropdown (grün wenn gesetzt) */}
|
|
{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: 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)"
|
|
/>
|
|
) : (
|
|
<BarCombo
|
|
icon="straighten"
|
|
value={dropdownValue}
|
|
onChange={(v) => applyDropdown(v)}
|
|
disabled={isPerspective}
|
|
width={140}
|
|
valueAccent={appliedScale != null}
|
|
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>
|
|
)}
|
|
{/* Reihe 2: Zoom-Verhaeltnis zum gesetzten Massstab.
|
|
Aligned mit dem Pill (nicht mit dem Icon links davon). */}
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
<div style={{
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
height: BAR_H, padding: '0 10px', minWidth: 140,
|
|
background: atScale ? 'var(--accent-dim)' : 'var(--bg-input)',
|
|
color: atScale ? 'var(--accent-light)' : 'var(--text-muted)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 999,
|
|
fontFamily: 'DM Mono, monospace', fontSize: 11,
|
|
fontWeight: atScale ? 600 : 500,
|
|
flexShrink: 0,
|
|
}} title={ratio != null
|
|
? `Aktueller Zoom = ${ratioText} des gesetzten Massstabs (${atScale ? 'auf Massstab' : 'zoom mit % um zu snappen'})`
|
|
: (isPerspective ? 'Perspektive — kein Massstab' : 'Kein Massstab gesetzt')}>
|
|
{ratioText}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
)
|
|
}
|