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
+516
View File
@@ -0,0 +1,516 @@
import { useState, useEffect, useRef } from 'react'
import Icon from './components/Icon'
import {
onMessage, notifyReady,
requestSelection, setColorSource, setLwSource, setLinetypeSource, setLinetypeScale, setFill,
} from './lib/rhinoBridge'
const LW_PRESETS = [0.02, 0.10, 0.13, 0.18, 0.25, 0.35, 0.50, 0.70, 1.00]
// ---------------------------------------------------------------------------
function SectionHead({ title }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px 6px' }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)' }}>
{title}
</span>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
</div>
)
}
/** Source-Dropdown im Vectorworks-Stil: Link-Icon + Label + Caret */
function SourceSelect({ source, onChange, overrideLabel = 'Eigene' }) {
return (
<div style={{ position: 'relative', display: 'flex' }}>
<Icon
name={source === 'layer' ? 'link' : 'link_off'}
size={14}
style={{
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
color: source === 'layer' ? 'var(--text-primary)' : 'var(--text-muted)',
pointerEvents: 'none',
}}
/>
<select
value={source}
onChange={(ev) => onChange(ev.target.value)}
style={{
paddingLeft: 30, flex: 1,
fontFamily: 'var(--font)',
fontWeight: 500,
fontSize: 11,
textTransform: 'none',
/* borderRadius/background uebernehmen wir von der globalen <select>-
Pille (index.css) damit's optisch konsistent mit dem Fill-Dropdown
wirkt. Nur paddingLeft ueberschreiben wegen Link-Icon. */
}}
>
<option value="layer">Nach Ebene</option>
<option value="object">{overrideLabel}</option>
</select>
</div>
)
}
function ColorBar({ color, onChange, height = 22 }) {
// Readonly-Variante als reines Anzeige-Rechteck
if (!onChange) {
return (
<div
style={{
width: '100%', height,
background: color,
borderRadius: 'var(--r)',
border: '1px solid var(--border)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.08)',
}}
/>
)
}
// Editierbar: <label> umschliesst einen sichtbaren Swatch-Div + einen
// unsichtbaren color-input. Click aufs Label routet automatisch zum Input
// und oeffnet den nativen Picker. Die Anzeige (div background) ist voll
// unter unserer Kontrolle und wird durch local-State live aktualisiert.
// Debounce 150ms verhindert Python-Spam waehrend Picker-Drag.
const [local, setLocal] = useState(color || '#888888')
const timer = useRef(null)
useEffect(() => {
if (color && color !== local) {
// Selektion wechselt -> evtl. noch laufenden Debounce abbrechen,
// sonst feuert die alte Pick-Farbe auf das neue Objekt.
if (timer.current) { clearTimeout(timer.current); timer.current = null }
setLocal(color)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [color])
const handle = (ev) => {
const v = ev.target.value
if (!v || !/^#[0-9a-fA-F]{6}$/.test(v)) return
setLocal(v)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => onChange(v), 150)
}
return (
<label
style={{
position: 'relative', display: 'block',
width: '100%', height,
borderRadius: 'var(--r)',
border: '1px solid var(--border)',
overflow: 'hidden',
cursor: 'pointer',
}}
>
<div
style={{
position: 'absolute', inset: 0,
background: local,
pointerEvents: 'none',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.08)',
}}
/>
<input
type="color"
value={local}
onInput={handle}
onChange={handle}
style={{
position: 'absolute', inset: 0,
width: '100%', height: '100%',
opacity: 0,
border: 'none', padding: 0, margin: 0,
cursor: 'pointer',
background: 'transparent',
}}
/>
</label>
)
}
/** Number-Input mit Commit-on-Blur/Enter und externer Sync. */
function NumInput({ value, onCommit, step = 0.01, min, max, width = 56, mono = true }) {
const [val, setVal] = useState(String(value))
useEffect(() => { setVal(typeof value === 'number' ? value.toFixed(2) : String(value)) }, [value])
const commit = () => {
const v = parseFloat(val)
if (!isNaN(v) && (min == null || v >= min) && (max == null || v <= max)) onCommit(v)
else setVal(typeof value === 'number' ? value.toFixed(2) : String(value))
}
return (
<input
type="number" step={step} min={min} max={max}
value={val}
onChange={(ev) => setVal(ev.target.value)}
onBlur={commit}
onKeyDown={(ev) => {
if (ev.key === 'Enter') ev.target.blur()
if (ev.key === 'Escape') { setVal(typeof value === 'number' ? value.toFixed(2) : String(value)); ev.target.blur() }
}}
style={{ width, textAlign: 'right', fontSize: 11, fontFamily: mono ? 'var(--font-mono)' : 'var(--font)' }}
/>
)
}
function LwPreview({ lw }) {
return (
<div style={{
flex: 1, height: 22,
background: 'var(--bg-item)',
border: '1px solid var(--border)',
borderRadius: 'var(--r)',
display: 'flex', alignItems: 'center',
padding: '0 8px', overflow: 'hidden',
}}>
<div style={{
width: '100%',
height: Math.max(1, Math.min(8, lw * 6)),
background: 'var(--text-primary)',
borderRadius: 1,
}} />
</div>
)
}
// ---------------------------------------------------------------------------
// Pen color
function PenColor({ sel }) {
const source = sel.colorSource === 'mixed' ? 'object' : (sel.colorSource || 'layer')
const effective = source === 'object'
? (sel.color || '#888888')
: (sel.layerColor || '#888888')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<SourceSelect
source={source}
onChange={(v) => setColorSource(v, v === 'object' ? effective : null)}
/>
<ColorBar
color={effective}
onChange={(c) => setColorSource('object', c)}
/>
</div>
)
}
// LW row inline
function PenLw({ sel }) {
const source = sel.lwSource === 'mixed' ? 'object' : (sel.lwSource || 'layer')
const effective = source === 'object'
? (sel.lw ?? 0.18)
: (sel.layerLw ?? 0.18)
const step = (dir) => {
let next
if (dir > 0) next = LW_PRESETS.find(p => p > effective + 0.001)
else next = [...LW_PRESETS].reverse().find(p => p < effective - 0.001)
if (next !== undefined) setLwSource('object', next)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
className="btn-icon-sm"
onClick={() => setLwSource(source === 'object' ? 'layer' : 'object', source === 'object' ? null : effective)}
title={source === 'object' ? 'Nach Ebene' : 'Uebersteuern'}
style={{ color: source === 'layer' ? 'var(--text-primary)' : 'var(--text-muted)' }}
>
<Icon name={source === 'layer' ? 'link' : 'link_off'} size={14} />
</button>
<LwPreview lw={effective} />
<NumInput
value={effective}
onCommit={(v) => setLwSource('object', v)}
step={0.01} min={0.01} max={2.0}
width={52}
/>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<button className="btn-step" onClick={() => step(+1)}><Icon name="arrow_drop_up" size={12}/></button>
<button className="btn-step" onClick={() => step(-1)}><Icon name="arrow_drop_down" size={12}/></button>
</div>
</div>
)
}
function PenLinetype({ sel }) {
const source = sel.linetypeSource === 'mixed' ? 'object' : (sel.linetypeSource || 'layer')
const effective = source === 'object'
? (sel.linetype || sel.layerLinetype || (sel.linetypes?.[0] || ''))
: (sel.layerLinetype || (sel.linetypes?.[0] || ''))
const options = sel.linetypes || []
const hasOption = options.includes(effective)
const ltScale = sel.linetypeScale ?? 1.0
const isSolid = (effective || '').toLowerCase() === 'continuous'
// Spezialwert "__layer__" damit dieselbe Pill wie bei Color/Fill — Klick
// auf den Wert "Nach Ebene" wechselt zurueck auf source=layer.
const ltCurrent = source === 'layer' ? '__layer__' : effective
const ltChange = (v) => {
if (v === '__layer__') {
setLinetypeSource('layer', null)
} else {
setLinetypeSource('object', v)
}
}
return (
<>
<div style={{ position: 'relative', display: 'flex' }}>
<Icon
name={source === 'layer' ? 'link' : 'link_off'}
size={14}
style={{
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
color: source === 'layer' ? 'var(--text-primary)' : 'var(--text-muted)',
pointerEvents: 'none',
}}
/>
<select
value={ltCurrent}
onChange={(ev) => ltChange(ev.target.value)}
style={{
paddingLeft: 30, flex: 1,
fontFamily: 'var(--font)',
fontWeight: 500,
fontSize: 11,
textTransform: 'none',
}}
>
<option value="__layer__">Nach Ebene</option>
{!hasOption && effective && <option value={effective}>{effective}</option>}
{options.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
{!isSolid && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="open_in_full" size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} title="Linientyp-Skalierung" />
<NumInput
value={ltScale} step={0.1} min={0.001} max={1000}
onCommit={(v) => setLinetypeScale(v)}
width={70}
/>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>× Pattern</span>
</div>
)}
</>
)
}
function PenBlock({ sel }) {
return (
<div style={{ padding: '0 14px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<PenColor sel={sel} />
<PenLw sel={sel} />
<PenLinetype sel={sel} />
{sel.layerName && (
<div style={{ fontSize: 9, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
Ebene: {sel.layerName}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
function FillBlock({ sel }) {
if (sel.canFill !== true) {
return (
<div style={{ padding: '0 14px 12px', fontSize: 10, color: 'var(--text-muted)', fontStyle: 'italic' }}>
Keine geschlossenen 2D-Kurven in der Auswahl.
</div>
)
}
const enabled = sel.fillEnabled === true
const source = sel.fillSource || 'layer'
const color = sel.fillColor || sel.layerColor || '#cccccc'
const objectPat = enabled ? (sel.fillPattern || 'Solid') : 'None'
const scale = sel.fillScale ?? 1.0
const rotation = sel.fillRotation ?? 0.0
const patternList = sel.hatchPatterns || ['Solid']
// Special-Value im kombinierten Dropdown:
// "__layer__" = Nach Ebene -> Pattern/Scale/Rot/Color aus Ebenen-Einstellungen
// "None" = keine Fuellung
// "Solid" = volle Flaeche (Eigene Quelle)
// sonst = Hatch-Pattern (Eigene Quelle)
// source=='layer' -> immer "Nach Ebene" zeigen, auch wenn (noch) keine Hatch
// existiert (Ebene hat halt aktuell kein Pattern -> wird gefuellt, sobald
// eines definiert wird).
const currentValue = source === 'layer'
? '__layer__'
: (!enabled ? 'None' : objectPat)
const dropdownOptions = [
{ value: '__layer__', label: 'Nach Ebene' },
{ value: 'None', label: 'None' },
{ value: 'Solid', label: 'Solid' },
...patternList
.filter(p => p !== 'Solid' && p !== 'None')
.map(p => ({ value: p, label: p })),
]
// Falls aktueller Pattern-Name nicht in der Liste, anhaengen damit's nicht verloren geht
if (currentValue !== '__layer__' && currentValue !== 'None'
&& !dropdownOptions.some(o => o.value === currentValue)) {
dropdownOptions.push({ value: currentValue, label: currentValue })
}
const applyPattern = (newValue) => {
if (newValue === '__layer__') {
// Source -> layer, Python liest Pattern/Scale/Rot/Color aus Layer.SectionStyle
setFill(true, 'layer', null, null, null, null)
} else if (newValue === 'None') {
setFill(false, source, null, null, scale, rotation)
} else {
// Eigene Quelle mit gewaehltem Pattern
setFill(true, 'object', color, newValue, scale, rotation)
}
}
// Anpassungen wenn schon im "Eigene"-Modus (Scale/Rotation/Color/Source-Toggle)
const apply = (over) => setFill(
true,
over.source ?? (source === 'layer' ? 'object' : source),
(over.source ?? source) === 'layer' ? null : (over.color ?? color),
over.pattern ?? (objectPat === 'None' ? 'Solid' : objectPat),
over.scale ?? scale,
over.rotation ?? rotation,
)
const isLayerSource = source === 'layer'
return (
<div style={{ padding: '0 14px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Pill mit Link-Icon im Dropdown (analog zur Pen-Source-Pill).
Link-Icon wenn "Nach Ebene", sonst link_off. */}
<div style={{ position: 'relative', display: 'flex' }}>
<Icon
name={isLayerSource ? 'link' : 'link_off'}
size={14}
style={{
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
color: isLayerSource ? 'var(--text-primary)' : 'var(--text-muted)',
pointerEvents: 'none',
}}
/>
<select
value={currentValue}
onChange={(ev) => applyPattern(ev.target.value)}
style={{
paddingLeft: 30, flex: 1,
fontFamily: 'var(--font)',
fontWeight: 500,
fontSize: 11,
textTransform: 'none',
}}
>
{dropdownOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{currentValue !== 'None' && (
<>
<ColorBar
color={color}
onChange={isLayerSource ? undefined : ((c) => apply({ source: 'object', color: c }))}
/>
{!isLayerSource && objectPat !== 'Solid' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="aspect_ratio" size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} title="Skalierung" />
<NumInput
value={scale} step={0.05} min={0.001}
onCommit={(v) => apply({ scale: v })}
width={70}
/>
<Icon name="rotate_right" size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} title="Winkel (Grad)" />
<NumInput
value={rotation} step={5}
onCommit={(v) => apply({ rotation: v })}
width={56}
/>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>°</span>
</div>
)}
{isLayerSource && (
<div style={{ fontSize: 10, color: 'var(--text-muted)', fontStyle: 'italic' }}>
Pattern, Skalierung &amp; Farbe folgen Layer-Section-Style
</div>
)}
</>
)}
</div>
)
}
function EmptyState() {
return (
<div style={{
padding: '60px 24px', textAlign: 'center',
color: 'var(--text-muted)', fontSize: 11,
display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center',
}}>
<Icon name="touch_app" size={36} style={{ color: 'var(--text-muted)' }} />
<div>Waehle ein oder mehrere Objekte in Rhino aus.</div>
</div>
)
}
// ---------------------------------------------------------------------------
export default function GestaltungApp() {
const [sel, setSel] = useState({ count: 0, linetypes: [] })
useEffect(() => {
onMessage('SELECTION', (data) => setSel(data || { count: 0, linetypes: [] }))
notifyReady()
const blockContext = (ev) => ev.preventDefault()
document.addEventListener('contextmenu', blockContext)
return () => document.removeEventListener('contextmenu', blockContext)
}, [])
const empty = sel.count === 0
return (
<div style={{
display: 'flex', flexDirection: 'column',
height: '100vh', overflow: 'hidden',
background: 'var(--bg-base)',
position: 'relative',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px',
borderBottom: '1px solid var(--border-light)',
}}>
<Icon name="tune" size={16} style={{ color: 'var(--text-muted)' }} />
<span style={{ flex: 1, fontWeight: 500, fontSize: 12, color: 'var(--text-primary)' }}>
Attribute
</span>
{sel.count > 0 && <span className="chip">{sel.count}</span>}
<button className="btn-icon-sm" onClick={() => requestSelection()} title="Aktualisieren">
<Icon name="refresh" size={14} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{empty ? <EmptyState /> : (
<>
<SectionHead title="Fill" />
<FillBlock sel={sel} />
<SectionHead title="Pen" />
<PenBlock sel={sel} />
<SectionHead title="Effects" />
<div style={{ padding: '0 14px 14px', fontSize: 10, color: 'var(--text-muted)', fontStyle: 'italic' }}>
Schatten / Transparenz folgen spaeter.
</div>
</>
)}
</div>
</div>
)
}