0c5f8055a5
Fenster/Tueren: - 3-stufige SIA-400-Darstellung pro Element: einfach (1:100, flache Scheibe ohne Tiefe in Wand-Mittelebene), standard (1:50, Rahmen + Glas + Sims), detail (1:20, Doppelverglasung). - Aussenseite-Flag mit Auto-Detection aus der Click-Richtung beim Setzen — Sim sitzt automatisch aussen. Im Panel als Umkehren-Toggle. - Tueren-Rahmen-Typ Zarge|Block — Blockrahmen ragt seitlich raus. - Rahmen-Offset (m von Wand-Innenseite) ersetzt das 3-Preset Lage- Feld. Wirkt auch in der einfachen Darstellung (Pane sitzt auf der Rahmen-Mittelebene, nicht in Wand-Mitte). - Sims nur AUSSEN. Innen entfaellt — der Sim ist gleichzeitig der visuelle Indikator fuer die Aussenseite. - Oeffnungs-Stile: list/save/delete-API mit 6 Default-Presets (Fenster Standard/Gross/Bandlage, Tuer Innen/Eingang/Verglast). Style-ID per UserString am Objekt persistiert. Im Panel BarCombo mit "Aktuelle als Stil speichern…". Beim Rhino-Command "Stil"- Option zum Picken vor dem Klick. Ausschnitt-Darstellung (Phase 3): - Doc-Level Override dossier_aktive_darstellung gewinnt vor per- Object-Setting. Wechsel triggert Regen aller Oeffnungen via neuer regenerate_all_oeffnungen-API. - Ausschnitt-Capture speichert die Darstellung mit, Restore wendet sie an und regeneriert. - Oberleiste-Quick-Switch BarCombo mit 4 Optionen. - AusschnittSettings-Dialog: Darstellungs-Dropdown. Gestaltung (SectionStyle Phase 2): - _set_section_style schreibt per-Object SectionHatchIndex/Scale/ Rotation/Color mit Multi-Fallback (Property-Namen varieren je Rhino-Build). _selection_summary liest die selben zurueck. - HatchEditor als shared Component fuer Fill + Section. - geometryKind ignoriert DOSSIER-Source-Curves damit Wand-Selektion (Axis + Volume) als 3D klassifiziert wird. UI-Konsistenz Panels: - Ebenenkombi zurueck als eigene Section oben im Ebenen-Panel, Modelldarstellung-Dropdown an die freigewordene Position in der Oberleiste (Row 1 Col 2 im 2x2-Preset-Block). - BarCombo erweitert: stretch-Prop (Pill waechst auf Container- Breite), onSecond/secondIcon/secondTitle fuer 2. Trailing-Button, gearIcon-Prop. Plus-Slot immer ganz aussen rechts, Settings-Slot direkt nach dem Caret. - Ebenen + Zeichnungsebenen visuell kohaerent: identisches Padding (1px 12px 1px 0), Chevron/Spacer-Slot 12px, Master-Row mit Eye 16x16 + Lock 14x14, gleiche Border + Borderfarbe. Eye-Icons in beiden Panels untereinander ausgerichtet. - Properties-Container ohne Border (war zuvor accent-gruen, dann border — User wollte gar nichts mehr). - ElementList raus aus dem Elemente-Panel (Uebersicht via Tree- Window erreichbar). NeuesElement bleibt voll sichtbar bei Selektion (kein Collapse), Properties oben. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
873 lines
35 KiB
React
873 lines
35 KiB
React
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 (
|
||
<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. (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 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 || ''}
|
||
onChange={(v) => setDarstellung(v)}
|
||
title="Darstellungs-Override fuer Fenster/Tueren (SIA-400 LoD)"
|
||
width={PRESET_W}
|
||
>
|
||
<option value="">— per Element —</option>
|
||
<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 = 4
|
||
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 */}
|
||
{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>
|
||
)}
|
||
{/* Reihe 2, Spalte 2: Buttons-Pill — gleiche Breite wie Dropdown */}
|
||
<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, justifySelf: 'start',
|
||
}}>
|
||
<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>
|
||
</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>
|
||
)
|
||
}
|