9dc191be4f
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>
297 lines
11 KiB
React
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>
|
|
)
|
|
}
|