95031ee2c0
- Ebenenkombination raus aus Ebenen-Panel, in Oberleiste-Topbar + Editor-Satellite (AusschnittLayerDialog embedded). doc.Strings haelt active_comb_name, auto-clear bei manueller Eye/Lock-Aenderung. - EbenenSettingsDialog jetzt Satellite mit Ebene-Picker-Dropdown (auto-save on switch via SAVE_KEEP). - Per-Ausschnitt Einstellungen-Satellite (Massstab, Display, Overrides, Ebenenkombi). Alte 'Sichtbarkeit bearbeiten'-Option entfernt. - Layouts/Ausschnitte: Top-Header weg, Sticky-Footer mit Anzahl + Aktionen. LayoutDialog ist jetzt Satellite mit Format-Live-Preview. - Panel-Captions + Default-Ebenen-Namen auf Mixed-Case (Ausschnitte, Ebenen, Waende ...). Nur DOSSIER bleibt caps. - DimensionenApp: Card-Optik raus, REF-Wuerfel mit Kreisen statt Quadraten + Hover-Scale. - GeschossManager angeglichen an EbenenManager: Rechtsklick-Menue, Lock-Button, Delete-X, Duplizieren. layer_builder honoriert z.locked. - Active Sublayer folgt jetzt dem Geschoss-Wechsel (gleicher Code unter neuem Parent). Performance Geschoss-Wechsel: - elemente._send_state() ersetzt durch _notify_active_geschoss() (Partial-Push statt 200+ Elements re-enumerieren). - _apply_visibility dedupe via sticky last-applied-signature (STATE_SYNC-Echo loopt nicht mehr durch alle Layer). - _update_clipping nur wenn alt oder neu hasClipping=True. - Redundante doc.Views.Redraw() im CPlane-Pfad entfernt — die folgende apply_visibility-Roundtrip redrawt 30ms spaeter ohnehin. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
352 lines
14 KiB
React
352 lines
14 KiB
React
import { useEffect, useState, useRef } from 'react'
|
||
import Icon from './components/Icon'
|
||
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: 2 }}>
|
||
{[
|
||
{ 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 => (
|
||
<button
|
||
key={opt.code}
|
||
onClick={() => onChange(opt.code)}
|
||
className={z === opt.code ? 'btn-contained' : 'btn-outlined'}
|
||
style={{
|
||
padding: '2px 5px', fontSize: 10,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}
|
||
title={opt.title}
|
||
>
|
||
<Icon name={opt.icon} size={12} />
|
||
</button>
|
||
))}
|
||
</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: 2 }}>
|
||
<button
|
||
onClick={() => onCoordChange('world')}
|
||
className={state.coordSystem === 'world' ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ fontSize: 10, padding: '3px 8px' }}
|
||
title="Weltkoordinaten"
|
||
>Welt</button>
|
||
<button
|
||
onClick={() => onCoordChange('cplane')}
|
||
className={state.coordSystem === 'cplane' ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ fontSize: 10, padding: '3px 8px' }}
|
||
title="Aktive Konstruktionsebene"
|
||
>CPlane</button>
|
||
</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>
|
||
<button
|
||
className="btn-outlined"
|
||
onClick={() => { if (rotationDelta) setDimRotationZ(rotationDelta) }}
|
||
disabled={!rotationDelta}
|
||
title="Selektion um Z-Achse der aktiven Plane drehen"
|
||
style={{ padding: '3px 8px', fontSize: 11 }}
|
||
>
|
||
<Icon name="rotate_right" size={13} />
|
||
</button>
|
||
<div style={{ flex: 1 }} />
|
||
{[-90, -45, 45, 90].map(a => (
|
||
<button
|
||
key={a}
|
||
className="btn-outlined"
|
||
onClick={() => setDimRotationZ(a)}
|
||
style={{ padding: '3px 6px', fontSize: 9, minWidth: 28 }}
|
||
title={`${a}°`}
|
||
>
|
||
{a > 0 ? '+' : ''}{a}°
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|