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,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 & 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user