// 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 (
)
}
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 */}
{/* 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" />
))}
)}
)
}