Initial commit — Dossier Rhino 8 Plugin
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>
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
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 im Illustrator-Stil: sichtbarer BBox-Rahmen,
|
||||
// die Punkte sitzen AUF Ecken / Kantenmitten / Zentrum.
|
||||
function RefPointGrid({ ref, onChange }) {
|
||||
const SIZE = 26 // Aussenkanten-Quadrat (px)
|
||||
const DOT = 5 // Punkt-Durchmesser (px)
|
||||
// Position pro Code: 0% (min), 50% (mid), 100% (max)
|
||||
const pct = (c) => c === 'min' ? '0%' : c === 'max' ? '100%' : '50%'
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: SIZE, height: SIZE,
|
||||
border: '1px solid var(--text-muted)',
|
||||
background: 'transparent',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{REF_CODES.map(yc => REF_CODES.map(xc => {
|
||||
const active = ref.x === xc && ref.y === yc
|
||||
// yc 'max' = top in user mental model (Vectorworks/Illustrator)
|
||||
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: 0, // eckig wie Illustrator
|
||||
background: active ? 'var(--accent)' : 'var(--text-muted)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'var(--text-primary)' }}
|
||||
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'var(--text-muted)' }}
|
||||
/>
|
||||
)
|
||||
}))}
|
||||
</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', padding: 6 }}>
|
||||
|
||||
{/* Header: Selektions-Info + World/CPlane */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6,
|
||||
padding: '5px 8px',
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
}}>
|
||||
<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,
|
||||
border: '1px dashed var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<Icon name="aspect_ratio" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||
<div style={{ marginTop: 8 }}>Keine Selektion.</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10 }}>
|
||||
In Rhino ein oder mehrere Objekte auswaehlen.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Referenzpunkt — kompakte einzeilige Card */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 8px', marginBottom: 6,
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
}}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
Ref
|
||||
</span>
|
||||
<RefPointGrid ref={ref} onChange={onRefChange} />
|
||||
<RefZSelector z={ref.z} onChange={(z) => onRefChange({ ...ref, z })} />
|
||||
</div>
|
||||
|
||||
{/* Position + Abmessungen nebeneinander */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column', gap: 4,
|
||||
padding: '6px 8px',
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Position</span>
|
||||
<span style={{ fontWeight: 400, textTransform: 'none',
|
||||
fontFamily: 'DM Mono, monospace', fontSize: 9 }}>
|
||||
{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={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column', gap: 4,
|
||||
padding: '6px 8px',
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
BBox
|
||||
</div>
|
||||
<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: '6px 8px', marginBottom: 6,
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--accent)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
}}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--accent)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
{shape.type === 'circle' && 'Kreis'}
|
||||
{shape.type === 'rectangle' && 'Rechteck'}
|
||||
{shape.type === 'line' && 'Linie'}
|
||||
</div>
|
||||
{shape.type === 'circle' && (
|
||||
<Field label="R"><NumInput value={shape.radius} onCommit={(v) => setCircleRadius(v)} /></Field>
|
||||
)}
|
||||
{shape.type === 'rectangle' && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<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: 6 }}>
|
||||
<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 — kompakt einzeilig */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 8px', marginBottom: 6,
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
}}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
Drehen
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<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>
|
||||
{[-90, -45, 45, 90].map(a => (
|
||||
<button
|
||||
key={a}
|
||||
className="btn-outlined"
|
||||
onClick={() => setDimRotationZ(a)}
|
||||
style={{ padding: '3px 5px', fontSize: 9, minWidth: 28 }}
|
||||
title={`${a}°`}
|
||||
>
|
||||
{a > 0 ? '+' : ''}{a}°
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user