95031ee2c0
- Ebenenkombination raus aus Ebenen-Panel, in Oberleiste-Topbar + Editor-Satellite (AusschnittLayerDialog embedded). doc.Strings haelt active_comb_name, auto-clear bei manueller Eye/Lock-Aenderung. - EbenenSettingsDialog jetzt Satellite mit Ebene-Picker-Dropdown (auto-save on switch via SAVE_KEEP). - Per-Ausschnitt Einstellungen-Satellite (Massstab, Display, Overrides, Ebenenkombi). Alte 'Sichtbarkeit bearbeiten'-Option entfernt. - Layouts/Ausschnitte: Top-Header weg, Sticky-Footer mit Anzahl + Aktionen. LayoutDialog ist jetzt Satellite mit Format-Live-Preview. - Panel-Captions + Default-Ebenen-Namen auf Mixed-Case (Ausschnitte, Ebenen, Waende ...). Nur DOSSIER bleibt caps. - DimensionenApp: Card-Optik raus, REF-Wuerfel mit Kreisen statt Quadraten + Hover-Scale. - GeschossManager angeglichen an EbenenManager: Rechtsklick-Menue, Lock-Button, Delete-X, Duplizieren. layer_builder honoriert z.locked. - Active Sublayer folgt jetzt dem Geschoss-Wechsel (gleicher Code unter neuem Parent). Performance Geschoss-Wechsel: - elemente._send_state() ersetzt durch _notify_active_geschoss() (Partial-Push statt 200+ Elements re-enumerieren). - _apply_visibility dedupe via sticky last-applied-signature (STATE_SYNC-Echo loopt nicht mehr durch alle Layer). - _update_clipping nur wenn alt oder neu hasClipping=True. - Redundante doc.Views.Redraw() im CPlane-Pfad entfernt — die folgende apply_visibility-Roundtrip redrawt 30ms spaeter ohnehin. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
446 lines
16 KiB
React
446 lines
16 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,
|
|
} 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: 'north', label: 'Top' },
|
|
{ value: 'Front', icon: 'view_in_ar', label: 'Front' },
|
|
{ value: 'Right', icon: 'east', label: 'Right' },
|
|
{ value: 'Perspective', icon: 'view_quilt', 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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Material-UI-Style: alle Pills auf einheitliche Hoehe (PILL_H)
|
|
// nutzt globale Klassen aus index.css fuer Look (border-radius, colors)
|
|
|
|
const PILL_H = 20 // Einheits-Hoehe fuer alle Bar-Elemente
|
|
|
|
const sep = {
|
|
width: 1, height: 18,
|
|
background: 'var(--border)', flexShrink: 0,
|
|
margin: '0 2px',
|
|
}
|
|
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,
|
|
}
|
|
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,
|
|
})
|
|
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 (manche User haben "Top" / "Right" als view name)
|
|
const matchView = (v) => {
|
|
if (!state.viewName) return false
|
|
return state.viewName === v || state.viewName.toLowerCase() === 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: 8,
|
|
padding: '6px 12px',
|
|
overflowX: 'auto', overflowY: 'hidden',
|
|
flexShrink: 0,
|
|
}}>
|
|
<span
|
|
style={{
|
|
fontSize: 11, fontWeight: 600, letterSpacing: '0.08em',
|
|
color: 'var(--text-muted)',
|
|
fontFamily: 'DM Mono, monospace',
|
|
flexShrink: 0, userSelect: 'none',
|
|
}}
|
|
title={`Dossier ${__APP_VERSION__} — Teil von OpenStudio`}
|
|
>
|
|
DOSSIER <span style={{ opacity: 0.55 }}>{__APP_VERSION__}</span>
|
|
</span>
|
|
<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} />
|
|
{/* ====== GRUPPE: VIEW ====== */}
|
|
<span style={groupLabel}>View</span>
|
|
{VIEWS.map(v => (
|
|
<ToolButton
|
|
key={v.value}
|
|
onClick={() => setView(v.value)}
|
|
active={matchView(v.value)}
|
|
icon={v.icon}
|
|
label={v.label}
|
|
title={`Ansicht ${v.label}`}
|
|
/>
|
|
))}
|
|
|
|
<div style={sep} />
|
|
|
|
{/* ====== GRUPPE: DISPLAY-MODE ====== */}
|
|
<span style={groupLabel}>Display</span>
|
|
<select
|
|
value={state.displayMode || ''}
|
|
onChange={(e) => setDisplayMode(e.target.value)}
|
|
style={pillSelect}
|
|
title="Display-Mode (Wireframe / Shaded / Rendered / etc.)"
|
|
>
|
|
{!state.displayMode && <option value="">—</option>}
|
|
{(state.displayModes || []).map(dm => (
|
|
<option key={dm.id} value={dm.name}>{dm.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
<div style={sep} />
|
|
|
|
{/* ====== GRUPPE: MASSSTAB ====== */}
|
|
<span style={groupLabel}>Massstab</span>
|
|
<span
|
|
className={isPerspective ? 'chip' : 'chip chip-accent'}
|
|
style={{
|
|
...pillChip,
|
|
minWidth: 56, justifyContent: 'center',
|
|
fontFamily: 'DM Mono, monospace', fontSize: 11, fontWeight: 600,
|
|
}}
|
|
title="Live-Zoom"
|
|
>
|
|
{/* Live-Zoom des Viewports — immer sichtbar bei Parallelprojektion,
|
|
unabhaengig davon ob ein Massstab im Dropdown gepinnt ist oder
|
|
nicht. Nur in Perspective ist die Anzeige nicht sinnvoll. */}
|
|
{isPerspective ? '—' : fmtScale(scaleVal)}
|
|
</span>
|
|
{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={{ ...pillInput, width: 92 }}
|
|
title="Massstab eingeben (Enter = uebernehmen, Esc = abbrechen)"
|
|
/>
|
|
) : (
|
|
<select
|
|
disabled={isPerspective}
|
|
value={dropdownValue}
|
|
onChange={(e) => applyDropdown(e.target.value)}
|
|
style={{ ...pillSelect, width: 92 }}
|
|
>
|
|
<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>
|
|
</select>
|
|
)}
|
|
<ToolButton
|
|
onClick={apply100}
|
|
disabled={isPerspective || !appliedScale}
|
|
label="100%"
|
|
title={appliedScale ? `Zoom auf eingestellten Massstab snappen (1:${appliedScale >= 10 ? Math.round(appliedScale) : appliedScale})` : 'Erst einen Massstab wählen'}
|
|
/>
|
|
<button className="btn-icon" onClick={zoomExtents}
|
|
style={pillIconBtn}
|
|
title="Auf gesamten Inhalt zoomen">
|
|
<Icon name="fit_screen" size={14} />
|
|
</button>
|
|
<button className="btn-icon" onClick={zoomSelection}
|
|
style={pillIconBtn}
|
|
title="Auf Selektion zoomen">
|
|
<Icon name="center_focus_strong" size={14} />
|
|
</button>
|
|
<ToolButton
|
|
onClick={() => setShowLineweights(!state.showLineweights)}
|
|
active={state.showLineweights}
|
|
label={state.showLineweights ? 'Print' : 'Edit'}
|
|
title={state.showLineweights ? 'Print-View aktiv — klick zum Ausschalten' : 'Strichstärken anzeigen (Print-View)'}
|
|
icon={state.showLineweights ? 'print' : 'edit'}
|
|
/>
|
|
|
|
<div style={sep} />
|
|
|
|
{/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar
|
|
schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */}
|
|
|
|
{/* ====== GRUPPE: OVERRIDES ====== */}
|
|
<span style={groupLabel}>Overrides</span>
|
|
<ToolButton
|
|
onClick={() => toggleOverrides(!state.overridesEnabled)}
|
|
active={state.overridesEnabled}
|
|
icon="auto_fix_high"
|
|
label={state.overridesEnabled ? 'AN' : 'AUS'}
|
|
title={state.overridesEnabled
|
|
? `Grafische Overrides aktiv — klick zum Ausschalten`
|
|
: `Grafische Overrides ausgeschaltet`}
|
|
/>
|
|
{/* Preset-Dropdown: aktive Kombination waehlen. "—" = keine Kombination
|
|
(Doc-Rules sind frei editiert oder leer). "Konfigurieren…" oeffnet
|
|
den grossen Regel-Editor (OVERRIDES-Panel). */}
|
|
<select
|
|
value={state.overridesActivePreset || '__none__'}
|
|
onChange={(e) => {
|
|
const v = e.target.value
|
|
if (v === '__configure__') { openOverridesPanel(); return }
|
|
setOverridesPreset(v === '__none__' ? null : v)
|
|
}}
|
|
style={{ ...pillSelect, width: 140 }}
|
|
title={state.overridesActivePreset
|
|
? `Aktives Preset: ${state.overridesActivePreset} (${state.overridesCount} Regeln)`
|
|
: `Kein Preset aktiv (${state.overridesCount} Regeln, frei editiert)`}
|
|
>
|
|
<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>
|
|
</select>
|
|
<ToolButton
|
|
onClick={openOverridesPanel}
|
|
icon="settings"
|
|
title="Overrides-Regel-Editor öffnen"
|
|
/>
|
|
|
|
<div style={sep} />
|
|
|
|
{/* ====== GRUPPE: EBENENKOMBINATION ====== */}
|
|
<span style={groupLabel}>Kombi</span>
|
|
<select
|
|
value={state.layerCombinationActive || '__none__'}
|
|
onChange={(e) => {
|
|
const v = e.target.value
|
|
if (v === '__configure__') { openLayerCombinationsDialog(); return }
|
|
if (v === '__delete__') {
|
|
if (state.layerCombinationActive &&
|
|
window.confirm(`Kombination "${state.layerCombinationActive}" löschen?`))
|
|
deleteLayerCombination(state.layerCombinationActive)
|
|
return
|
|
}
|
|
pickLayerCombination(v === '__none__' ? null : v)
|
|
}}
|
|
style={{ ...pillSelect, width: 140 }}
|
|
title={state.layerCombinationActive
|
|
? `Aktive Kombi: ${state.layerCombinationActive}`
|
|
: 'Keine Kombination — manuelle Sichtbarkeit'}
|
|
>
|
|
<option value="__none__">— Eigene —</option>
|
|
{(state.layerCombinations || []).map(name => (
|
|
<option key={name} value={name}>{name}</option>
|
|
))}
|
|
{state.layerCombinationActive && (
|
|
<>
|
|
<option disabled>──────────</option>
|
|
<option value="__delete__">🗑 Aktuelle löschen</option>
|
|
</>
|
|
)}
|
|
<option disabled>──────────</option>
|
|
<option value="__configure__">Bearbeiten…</option>
|
|
</select>
|
|
<ToolButton
|
|
onClick={() => {
|
|
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)
|
|
}}
|
|
icon="add"
|
|
title="Aktuelle Sichtbarkeit als neue Kombination speichern"
|
|
/>
|
|
<ToolButton
|
|
onClick={openLayerCombinationsDialog}
|
|
icon="edit"
|
|
title="Ebenenkombinationen bearbeiten"
|
|
/>
|
|
|
|
{/* Spacer am rechten Rand */}
|
|
<div style={{ flex: 1 }} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|