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:
2026-05-16 04:27:41 +02:00
commit 9dc191be4f
145 changed files with 32629 additions and 0 deletions
+377
View File
@@ -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>
)
}