// SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Karim Gabriele Varano import { useState, useEffect } from 'react' import Icon from './components/Icon' import { BarToggle, BarButton, BAR_H } from './components/BarControls' import { onMessage, notifyReady, setKameraViewport, setKameraProjection, setKameraIso, kameraZoomExtents, saveKameraPreset, applyKameraPreset, deleteKameraPreset, setKameraNorthAngle, } from './lib/rhinoBridge' const labelXs = { fontSize: 9, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, } const pillInput = { height: BAR_H, background: 'var(--bg-input)', border: '1px solid var(--border)', borderRadius: 999, color: 'var(--text-primary)', fontSize: 11, fontFamily: 'var(--font)', padding: '0 10px', outline: 'none', boxSizing: 'border-box', } function NumberField({ label, value, onCommit, suffix, step = 0.1 }) { const [draft, setDraft] = useState(value != null ? value.toFixed(3) : '') useEffect(() => { setDraft(value != null ? value.toFixed(3) : '') }, [value]) const commit = () => { const n = parseFloat(draft) if (!isNaN(n)) onCommit(n) else setDraft(value != null ? value.toFixed(3) : '') } return (
{label}
setDraft(ev.target.value)} onBlur={commit} onKeyDown={(ev) => { if (ev.key === 'Enter') commit() if (ev.key === 'Escape') setDraft(value != null ? value.toFixed(3) : '') }} style={{ ...pillInput, flex: 1, fontFamily: 'var(--font-mono)' }} /> {suffix && ( {suffix} )}
) } function Vec3Field({ label, value, onCommit }) { const v = value || [0, 0, 0] return (
{label}
{['X', 'Y', 'Z'].map((axis, i) => ( { const next = [v[0], v[1], v[2]] next[i] = n onCommit(next) }} /> ))}
) } export default function KameraApp() { const [vp, setVp] = useState(null) const [presets, setPresets] = useState([]) const [presetName, setPresetName] = useState('') const [northAngle, setNorthAngleState] = useState(0) useEffect(() => { onMessage('STATE', (s) => { setVp(s.viewport || null) setPresets(s.presets || []) if (typeof s.northAngle === 'number') setNorthAngleState(s.northAngle) }) notifyReady() }, []) if (!vp) { return (
Kein aktiver Viewport.
) } const isPar = !!vp.parallel const updateLoc = (loc) => setKameraViewport({ loc }) const updateTgt = (target) => setKameraViewport({ target }) const updateLens = (lensMm) => setKameraViewport({ lensMm }) const updateFW = (frustumW) => setKameraViewport({ frustumW }) const saveCurrent = () => { const n = (presetName || '').trim() if (!n) return saveKameraPreset(n) setPresetName('') } return (
{/* Header: Viewport-Name + Projektion-Toggle */}
Viewport
{vp.name || 'Unnamed'}
setKameraProjection(false)} /> setKameraProjection(true)} />
{/* Plan-Norden — Rotations-Winkel im Uhrzeigersinn von +Y */}
Plan-Norden (Rotation von +Y, im Uhrzeigersinn)
{ const a = parseFloat(e.target.value) if (!isNaN(a)) { setNorthAngleState(a) setKameraNorthAngle(a) } }} style={{ ...pillInput, flex: 1, fontFamily: 'DM Mono, monospace' }} /> ° { setNorthAngleState(0); setKameraNorthAngle(0) }} title="Norden zurueck auf +Y (0°)" />
Norden = +Y bei 0°. Bei rotierten Projekten (z.B. swissBUILDINGS in LV95-Orientierung oder Sonnenberechnungen) hier den Plan-Norden in Grad eintragen. Wirkt auf TOP, ISO und N/O/S/W-Ansichten.
{/* Iso-Quick-Picker */}
Isometrie (Standard, true-iso 35°/45°)
{['NW', 'NE', 'SE', 'SW'].map(v => ( setKameraIso(v)} title={`Isometrie aus ${v} (Kamera blickt Richtung Szene)`} minWidth={48} /> ))}
{/* Kamera-Position + Target */} {/* Distance read-only */}
Distanz {vp.distance != null ? vp.distance.toFixed(2) + ' m' : '—'}
{/* Linse / Frustum je nach Projektion */} {isPar ? ( ) : ( )} kameraZoomExtents()} /> {/* Presets */}
Presets
setPresetName(ev.target.value)} onKeyDown={(ev) => { if (ev.key === 'Enter') saveCurrent() }} style={{ ...pillInput, flex: 1 }} />
{presets.length === 0 ? ( Keine Presets gespeichert. ) : (
{presets.map(p => (
applyKameraPreset(p.id)} title="Anwenden" /> {p.name} {p.parallel ? 'Par' : 'Persp'} deleteKameraPreset(p.id)} title="Loeschen" />
))}
)}
) }