9dc191be4f
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>
517 lines
18 KiB
React
517 lines
18 KiB
React
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>
|
||
)
|
||
}
|