d5bcee2157
Pill-basierte Toolbar-Primitiven aus OberleisteApp extrahiert nach src/components/BarControls.jsx — BarCombo (Dropdown), BarButton (Icon-Button), BarToggle (Label/Icon mit Active-State), BAR_H=22. OberleisteApp nutzt jetzt die geteilten Komponenten (Verhalten unveraendert). EbenenManager + GeschossManager: - Sichtbarkeits-Toolbar: native <select> + btn-icon-sm → BarCombo (mit visibility-Icon links) + BarButton add/settings. - GeschossManager Stift-Icon (edit) → Settings-Icon. - Zeilen-Layout: eckig statt Pill (margin 0, borderRadius 0, 3px Accent-Strip links fuer aktive Zeile), minHeight 24, gap 4, kompaktere Padding/Icon-Sizes — Vectorworks-naeher. DimensionenApp: - Welt/CPlane: 2x BarToggle statt btn-contained/outlined - Z-Selektor: 3x BarToggle (icon-only) - Drehen-Apply + 90°-CCW/CW: BarButton mit rotate_*-Icons (4 Preset-Buttons -90/-45/45/90 ersetzt durch 2 schnelle 90°-Buttons — passt besser in die schmale Sidebar) README aktualisiert: Runtime jetzt CPython 3.9, TextEntity-RTF-Limit dokumentiert, BarControls + text_editor/text_create erwaehnt. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
344 lines
13 KiB
React
344 lines
13 KiB
React
import { useEffect, useState, useRef } from 'react'
|
||
import Icon from './components/Icon'
|
||
import { BarToggle, BarButton } from './components/BarControls'
|
||
import {
|
||
onMessage, notifyReady,
|
||
setRefPoint, setCoordSystem,
|
||
setDimPosition, setDimDimension, setDimRotationZ,
|
||
setCircleRadius, setLineLength, setRectangleDims,
|
||
} from './lib/rhinoBridge'
|
||
|
||
// ---- Helpers --------------------------------------------------------------
|
||
|
||
const REF_CODES = ['min', 'mid', 'max'] // row/col mapping
|
||
const REF_LABELS = { min: 'min', mid: 'mid', max: 'max' }
|
||
|
||
function fmtNum(v) {
|
||
if (v == null) return ''
|
||
if (typeof v !== 'number') return String(v)
|
||
// 4 Nachkommastellen, aber unnoetige Nullen weg
|
||
return Number(v.toFixed(4)).toString()
|
||
}
|
||
|
||
// Input-Komponente: zeigt formatierten Wert, sendet onCommit bei Enter/Blur.
|
||
// Verhindert Update waehrend des Tippens, damit der Cursor nicht springt.
|
||
function NumInput({ value, onCommit, disabled, suffix, width }) {
|
||
const [text, setText] = useState(fmtNum(value))
|
||
const [focused, setFocused] = useState(false)
|
||
useEffect(() => { if (!focused) setText(fmtNum(value)) }, [value, focused])
|
||
const commit = () => {
|
||
const v = parseFloat(text.replace(',', '.'))
|
||
if (!isNaN(v) && v !== value) onCommit(v)
|
||
else setText(fmtNum(value)) // ungueltig → zurueck auf alten Wert
|
||
}
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flex: width ? 0 : 1, width }}>
|
||
<input
|
||
type="text"
|
||
value={text}
|
||
disabled={disabled}
|
||
onChange={(e) => setText(e.target.value)}
|
||
onFocus={(e) => { setFocused(true); e.target.select() }}
|
||
onBlur={() => { setFocused(false); commit() }}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') { e.target.blur() }
|
||
else if (e.key === 'Escape') { setText(fmtNum(value)); e.target.blur() }
|
||
}}
|
||
style={{ flex: 1, width: '100%', fontFamily: 'DM Mono, monospace', fontSize: 11, textAlign: 'right' }}
|
||
/>
|
||
{suffix && <span style={{ fontSize: 10, color: 'var(--text-muted)', flexShrink: 0 }}>{suffix}</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 9-Punkt-Referenzpunkt-Selektor: sichtbarer BBox-Rahmen, Kreise auf den
|
||
// Eckpunkten / Kantenmitten / Zentrum.
|
||
function RefPointGrid({ ref, onChange }) {
|
||
const SIZE = 28 // Aussenkanten-Quadrat (px)
|
||
const DOT = 6 // Kreis-Durchmesser (px)
|
||
const pct = (c) => c === 'min' ? '0%' : c === 'max' ? '100%' : '50%'
|
||
return (
|
||
<div style={{
|
||
position: 'relative',
|
||
width: SIZE, height: SIZE,
|
||
border: '1px solid var(--border)',
|
||
background: 'transparent',
|
||
flexShrink: 0,
|
||
}}>
|
||
{REF_CODES.map(yc => REF_CODES.map(xc => {
|
||
const active = ref.x === xc && ref.y === yc
|
||
const topPct = yc === 'max' ? '0%' : yc === 'min' ? '100%' : '50%'
|
||
return (
|
||
<button
|
||
key={`${xc}-${yc}`}
|
||
onClick={() => onChange({ ...ref, x: xc, y: yc })}
|
||
title={`X=${xc}, Y=${yc}`}
|
||
style={{
|
||
position: 'absolute',
|
||
left: pct(xc), top: topPct,
|
||
transform: 'translate(-50%, -50%)',
|
||
width: DOT, height: DOT, padding: 0,
|
||
borderRadius: '50%',
|
||
background: active ? 'var(--accent)' : 'var(--text-muted)',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
transition: 'background 0.12s, transform 0.12s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!active) e.currentTarget.style.background = 'var(--text-primary)'
|
||
e.currentTarget.style.transform = 'translate(-50%, -50%) scale(1.25)'
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!active) e.currentTarget.style.background = 'var(--text-muted)'
|
||
e.currentTarget.style.transform = 'translate(-50%, -50%) scale(1)'
|
||
}}
|
||
/>
|
||
)
|
||
}))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Z-Referenz-Selektor (Bottom / Mid / Top) — kompakt, nur Icons.
|
||
function RefZSelector({ z, onChange }) {
|
||
return (
|
||
<div style={{ display: 'flex', gap: 3 }}>
|
||
{[
|
||
{ code: 'max', icon: 'vertical_align_top', title: 'Z = Top' },
|
||
{ code: 'mid', icon: 'vertical_align_center', title: 'Z = Mid' },
|
||
{ code: 'min', icon: 'vertical_align_bottom', title: 'Z = Bottom' },
|
||
].map(opt => (
|
||
<BarToggle
|
||
key={opt.code}
|
||
icon={opt.icon}
|
||
active={z === opt.code}
|
||
onClick={() => onChange(opt.code)}
|
||
title={opt.title}
|
||
/>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Inline-Label vor einem Input — minimal, knackig
|
||
function Field({ label, children, style }) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, ...style }}>
|
||
<span style={{
|
||
width: 12, fontSize: 10, fontWeight: 600,
|
||
color: 'var(--text-muted)', textAlign: 'center',
|
||
fontFamily: 'DM Mono, monospace', flexShrink: 0,
|
||
}}>
|
||
{label}
|
||
</span>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- Hauptkomponente ------------------------------------------------------
|
||
|
||
export default function DimensionenApp() {
|
||
const [state, setState] = useState({
|
||
selection: { count: 0, type: 'none' },
|
||
refPoint: { x: 'min', y: 'min', z: 'mid' },
|
||
coordSystem: 'world',
|
||
position: null,
|
||
dimensions: null,
|
||
shape: null,
|
||
planeName: 'Welt',
|
||
})
|
||
const [rotationDelta, setRotationDelta] = useState(0)
|
||
|
||
useEffect(() => {
|
||
onMessage('STATE', (s) => setState((prev) => ({ ...prev, ...s })))
|
||
notifyReady()
|
||
}, [])
|
||
|
||
const sel = state.selection || { count: 0, type: 'none' }
|
||
const ref = state.refPoint || { x: 'min', y: 'min', z: 'mid' }
|
||
const pos = state.position
|
||
const dims = state.dimensions
|
||
const shape = state.shape
|
||
const hasSelection = sel.count > 0 && pos != null
|
||
|
||
const onRefChange = (next) => setRefPoint(next.x, next.y, next.z)
|
||
const onCoordChange = (mode) => setCoordSystem(mode)
|
||
|
||
// Selektions-Beschriftung
|
||
const selLabel = () => {
|
||
if (sel.count === 0) return 'Keine Selektion'
|
||
if (sel.count === 1) {
|
||
const map = {
|
||
curve: 'Kurve', brep: 'Brep', mesh: 'Mesh', extrusion: 'Extrusion',
|
||
block: 'Block', point: 'Punkt', text: 'Text', other: 'Objekt',
|
||
}
|
||
let base = map[sel.type] || '1 Objekt'
|
||
if (shape?.type === 'circle') base = 'Kreis'
|
||
if (shape?.type === 'rectangle') base = 'Rechteck'
|
||
if (shape?.type === 'line') base = 'Linie'
|
||
return '1 ' + base
|
||
}
|
||
return `${sel.count} Objekte`
|
||
}
|
||
|
||
return (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column',
|
||
height: '100vh', overflow: 'hidden',
|
||
background: 'var(--bg-base)', color: 'var(--text-primary)',
|
||
fontFamily: 'var(--font)', fontSize: 11,
|
||
}}>
|
||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||
|
||
{/* Header: Selektions-Info + World/CPlane */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
padding: '8px 12px',
|
||
borderBottom: '1px solid var(--border-light)',
|
||
}}>
|
||
<Icon name="select_all" size={14} style={{ color: 'var(--text-muted)' }} />
|
||
<span style={{ flex: 1, fontWeight: 500 }}>{selLabel()}</span>
|
||
<div style={{ display: 'flex', gap: 3 }}>
|
||
<BarToggle
|
||
label="Welt"
|
||
active={state.coordSystem === 'world'}
|
||
onClick={() => onCoordChange('world')}
|
||
title="Weltkoordinaten"
|
||
/>
|
||
<BarToggle
|
||
label="CPlane"
|
||
active={state.coordSystem === 'cplane'}
|
||
onClick={() => onCoordChange('cplane')}
|
||
title="Aktive Konstruktionsebene"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{!hasSelection ? (
|
||
<div style={{
|
||
padding: '32px 16px', textAlign: 'center',
|
||
color: 'var(--text-muted)', fontSize: 11,
|
||
}}>
|
||
<Icon name="aspect_ratio" size={32} style={{ color: 'var(--text-muted)', opacity: 0.4 }} />
|
||
<div style={{ marginTop: 8 }}>Keine Selektion.</div>
|
||
<div style={{ marginTop: 4, fontSize: 10 }}>
|
||
In Rhino ein oder mehrere Objekte auswählen.
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Referenzpunkt */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
padding: '10px 12px',
|
||
borderBottom: '1px solid var(--border-light)',
|
||
}}>
|
||
<span className="label-xs" style={{ width: 30 }}>Ref</span>
|
||
<RefPointGrid ref={ref} onChange={onRefChange} />
|
||
<div style={{ flex: 1 }} />
|
||
<RefZSelector z={ref.z} onChange={(z) => onRefChange({ ...ref, z })} />
|
||
</div>
|
||
|
||
{/* Position + BBox nebeneinander */}
|
||
<div style={{
|
||
display: 'flex', gap: 16,
|
||
padding: '10px 12px',
|
||
borderBottom: '1px solid var(--border-light)',
|
||
}}>
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between',
|
||
alignItems: 'baseline', marginBottom: 2 }}>
|
||
<span className="label-xs">Position</span>
|
||
<span style={{ fontFamily: 'DM Mono, monospace', fontSize: 9,
|
||
color: 'var(--text-muted)' }}>
|
||
{state.planeName}
|
||
</span>
|
||
</div>
|
||
<Field label="X"><NumInput value={pos.x} onCommit={(v) => setDimPosition('x', v)} /></Field>
|
||
<Field label="Y"><NumInput value={pos.y} onCommit={(v) => setDimPosition('y', v)} /></Field>
|
||
<Field label="Z"><NumInput value={pos.z} onCommit={(v) => setDimPosition('z', v)} /></Field>
|
||
</div>
|
||
<div style={{ width: 1, background: 'var(--border-light)' }} />
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||
<span className="label-xs" style={{ marginBottom: 2 }}>BBox</span>
|
||
<Field label="B"><NumInput value={dims?.width} onCommit={(v) => setDimDimension('width', v)} /></Field>
|
||
<Field label="T"><NumInput value={dims?.depth} onCommit={(v) => setDimDimension('depth', v)} /></Field>
|
||
<Field label="H"><NumInput value={dims?.height} onCommit={(v) => setDimDimension('height', v)} /></Field>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Shape-spezifisch */}
|
||
{shape && (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column', gap: 4,
|
||
padding: '10px 12px',
|
||
borderBottom: '1px solid var(--border-light)',
|
||
}}>
|
||
<span className="label-xs" style={{ color: 'var(--accent)' }}>
|
||
{shape.type === 'circle' && 'Kreis'}
|
||
{shape.type === 'rectangle' && 'Rechteck'}
|
||
{shape.type === 'line' && 'Linie'}
|
||
</span>
|
||
{shape.type === 'circle' && (
|
||
<Field label="R"><NumInput value={shape.radius} onCommit={(v) => setCircleRadius(v)} /></Field>
|
||
)}
|
||
{shape.type === 'rectangle' && (
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<Field label="W" style={{ flex: 1 }}>
|
||
<NumInput value={shape.width}
|
||
onCommit={(v) => setRectangleDims(v, shape.height)} />
|
||
</Field>
|
||
<Field label="H" style={{ flex: 1 }}>
|
||
<NumInput value={shape.height}
|
||
onCommit={(v) => setRectangleDims(shape.width, v)} />
|
||
</Field>
|
||
</div>
|
||
)}
|
||
{shape.type === 'line' && (
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<Field label="L" style={{ flex: 1 }}>
|
||
<NumInput value={shape.length} onCommit={(v) => setLineLength(v)} />
|
||
</Field>
|
||
<Field label="α" style={{ flex: 1 }}>
|
||
<NumInput value={shape.angle} onCommit={() => {}} disabled suffix="°" />
|
||
</Field>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Rotation */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
padding: '10px 12px',
|
||
}}>
|
||
<span className="label-xs" style={{ width: 50 }}>Drehen</span>
|
||
<div style={{ width: 56 }}>
|
||
<NumInput value={rotationDelta} onCommit={setRotationDelta} suffix="°" />
|
||
</div>
|
||
<BarButton
|
||
icon="rotate_right"
|
||
onClick={() => { if (rotationDelta) setDimRotationZ(rotationDelta) }}
|
||
disabled={!rotationDelta}
|
||
title="Selektion um Z-Achse der aktiven Plane drehen"
|
||
/>
|
||
<div style={{ flex: 1 }} />
|
||
<BarButton
|
||
icon="rotate_90_degrees_ccw"
|
||
onClick={() => setDimRotationZ(-90)}
|
||
title="90° gegen den Uhrzeigersinn"
|
||
/>
|
||
<BarButton
|
||
icon="rotate_90_degrees_cw"
|
||
onClick={() => setDimRotationZ(90)}
|
||
title="90° im Uhrzeigersinn"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|