Files
DOSSIER/src/MassstabApp.jsx
T
karim 9dc191be4f Initial commit — Dossier Rhino 8 Plugin
OpenStudio-Suite Architektur-Plugin fuer Rhino 8 (Mac):
- Smart-Elemente: Wand, Decke, Dach (Pult/Sattel/Walm/Mansarde),
  Oeffnungen (Fenster/Tueren mit Rahmen + Sims + Glas + Fluegel),
  Treppen (gerade · L · Wendel mit Schrittmass-Validierung)
- Live-Previews mit Step-Lines + Soll-Range-Clamping
- Bidirektionale Selection-Sync zwischen Source-Linie und Volume
- Geschoss-/Ebenen-Verwaltung mit OKFF-Persistenz
- Layouts mit PDF-Export
- Ausschnitte / Massstab / Override-Regeln
- Petrol-Gruen Theme (Rapport-konform)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 04:27:41 +02:00

297 lines
11 KiB
React

import { useState, useEffect, useRef } from 'react'
import Icon from './components/Icon'
import {
onMessage, notifyReady,
requestMassstab, setMassstab,
zoomOneToOne, zoomExtents, zoomSelection,
setMassstabDpi, detectMassstabDpi, setShowLineweights,
} 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'},
]
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)
const pb = parseFloat(b)
if (pa > 0 && pb > 0) return pb / pa
return null
}
}
const n = parseFloat(s)
return n > 0 ? n : null
}
// ---------------------------------------------------------------------------
export default function MassstabApp() {
const [state, setState] = useState({
viewName: null, parallel: false, scale: null,
pixelWidth: null, pixelHeight: null, unitSystem: '?',
dpi: 96, dpiSource: 'default',
showLineweights: false,
})
const [appliedScale, setAppliedScale] = useState(null)
const appliedScaleRef = useRef(null)
const [draft, setDraft] = useState('')
const [dpiOpen, setDpiOpen] = useState(false)
const [dpiDraft, setDpiDraft] = useState('96')
useEffect(() => {
onMessage('STATE', (s) => {
setState((prev) => ({ ...prev, ...s }))
// Backend appliedScale (gilt nur fuer aktuellen Viewport) > Live-Snap > roher Live-Wert
let next = null
if (typeof s?.appliedScale === 'number' && s.appliedScale > 0) {
next = s.appliedScale
} else if (s?.parallel && typeof s?.scale === 'number' && s.scale > 0) {
const snap = snapToPreset(s.scale)
next = snap != null ? snap : Math.round(s.scale * 10) / 10
}
if (next != null && next > 0 && 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
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('')
}
}
const commitDpi = () => {
const v = parseFloat(dpiDraft)
if (v >= 30 && v <= 600) setMassstabDpi(v)
setDpiOpen(false)
}
// "100%" = Viewport-Zoom auf den aktuell eingestellten Massstab snappen
// (nicht: Massstab auf 1:1 setzen). Praktisch nach Pan/Zoom, um wieder
// zur Soll-Skala zu kommen.
const apply100 = () => {
if (appliedScale && appliedScale > 0) {
setMassstab(appliedScale)
}
}
// --- Style-Bausteine ------------------------------------------------------
const cellBtn = {
fontSize: 11, padding: '0 8px', height: 24,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4,
background: 'var(--bg-item)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', color: 'var(--text-primary)', cursor: 'pointer',
whiteSpace: 'nowrap',
}
const cellInput = {
fontSize: 11, padding: '0 6px', height: 24, minWidth: 0,
background: 'var(--bg-input)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', color: 'var(--text-primary)',
}
return (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 8px',
fontFamily: 'var(--font)', color: 'var(--text-primary)',
background: 'var(--bg-base)',
borderTop: '1px solid var(--border)',
boxSizing: 'border-box',
overflow: 'hidden',
}}>
{/* Live-Zoom Anzeige */}
<div style={{
fontSize: 13, fontWeight: 600,
fontFamily: 'DM Mono, monospace',
minWidth: 56, textAlign: 'right',
color: isPerspective ? 'var(--text-muted)' : 'var(--text-primary)',
}} title={isPerspective ? 'Perspective — kein Massstab'
: `Aktueller Zoom (live)${appliedScale!=null ? ` · gesetzt 1:${appliedScale}` : ''}`}>
{isPerspective ? '—' : fmtScale(scaleVal)}
</div>
<div style={{ width: 1, height: 18, background: 'var(--border)' }} />
{/* Skala-Dropdown */}
<select
disabled={isPerspective}
value={dropdownValue}
onChange={(e) => applyDropdown(e.target.value)}
style={{ ...cellInput, width: 80 }}
title="Massstab waehlen"
>
<option value="__none__">1:?</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>
)}
</select>
{/* Freitext */}
<input
disabled={isPerspective}
type="text"
placeholder="1:N"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') applyDraft() }}
onBlur={() => { if (draft) applyDraft() }}
style={{ ...cellInput, width: 64 }}
title="Eigenen Massstab eingeben (Enter)"
/>
<div style={{ width: 1, height: 18, background: 'var(--border)' }} />
{/* Aktions-Buttons */}
<button
disabled={isPerspective || !appliedScale}
onClick={apply100}
style={cellBtn}
title={appliedScale
? `Zoom auf eingestellten Massstab snappen (1:${appliedScale >= 10 ? Math.round(appliedScale) : appliedScale})`
: 'Erst einen Massstab waehlen'}
>100%</button>
<button onClick={zoomExtents} style={cellBtn} title="Auf gesamten Inhalt zoomen">
<Icon name="fit_screen" size={14} />
</button>
<button onClick={zoomSelection} style={cellBtn} title="Auf Selektion zoomen">
<Icon name="center_focus_strong" size={14} />
</button>
<div style={{ width: 1, height: 18, background: 'var(--border)' }} />
{/* Print-View / Strichstaerken-Toggle
Beide Icons werden permanent gerendert; nur display: none togglet,
damit die Font-Ligatur nicht neu aufgeloest wird (sonst Flackern). */}
<button
onClick={() => setShowLineweights(!state.showLineweights)}
style={{
...cellBtn,
background: state.showLineweights ? 'var(--accent)' : 'var(--bg-item)',
color: state.showLineweights ? '#fff' : 'var(--text-primary)',
borderColor: state.showLineweights ? 'var(--accent)' : 'var(--border)',
}}
title={state.showLineweights
? 'Strichstaerken werden angezeigt (Print-View) — klicken zum Ausschalten'
: 'Strichstaerken als Hairlines (Edit-View) — klicken um Print-View zu zeigen'}
>
<Icon name="edit" size={14} style={{ display: state.showLineweights ? 'none' : 'inline-block' }} />
<Icon name="print" size={14} style={{ display: state.showLineweights ? 'inline-block' : 'none' }} />
<span style={{ fontSize: 10 }}>{state.showLineweights ? 'Print' : 'Edit'}</span>
</button>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* View-Name */}
<div style={{
fontSize: 10, color: 'var(--text-muted)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
maxWidth: 140,
}} title={state.viewName || ''}>
{state.viewName || '?'}{isPerspective ? ' · Persp.' : ''}
</div>
{/* DPI-Popover */}
<div style={{ position: 'relative' }}>
<button
onClick={() => { setDpiDraft(String(state.dpi || 96)); setDpiOpen(o => !o) }}
style={{ ...cellBtn, fontSize: 10, padding: '0 6px', height: 22,
color: state.dpiSource === 'auto' ? 'var(--accent)'
: state.dpiSource === 'manual' ? 'var(--text-primary)'
: 'var(--text-muted)' }}
title={`DPI Kalibrierung — aktuell ${Math.round(state.dpi || 96)} dpi (${state.dpiSource || 'default'})`}
>
{Math.round(state.dpi || 96)}dpi
{state.dpiSource === 'auto' && (
<span style={{ marginLeft: 3, fontSize: 8, opacity: 0.8 }}>auto</span>
)}
</button>
{dpiOpen && (
<div style={{
position: 'absolute', right: 0, bottom: '100%', marginBottom: 4,
background: 'var(--bg-panel)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', padding: 8, display: 'flex',
flexDirection: 'column', gap: 6,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', zIndex: 10, minWidth: 220,
}}>
<div style={{ fontSize: 10, color: 'var(--text-muted)' }}>
Bildschirm-Aufloesung fuer Massstab-Berechnung
</div>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="number" min={30} max={600}
value={dpiDraft}
onChange={(e) => setDpiDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') commitDpi() }}
autoFocus
style={{ ...cellInput, flex: 1 }}
/>
<button onClick={commitDpi} style={{ ...cellBtn, fontSize: 10 }}>OK</button>
</div>
<button
onClick={() => { detectMassstabDpi(); setDpiOpen(false) }}
style={{ ...cellBtn, fontSize: 10, justifyContent: 'flex-start' }}
title="DPI automatisch ueber EDID des Bildschirms ermitteln"
>
<Icon name="auto_fix_high" size={12} /> Auto-Detect (EDID)
</button>
</div>
)}
</div>
</div>
)
}