Files
DOSSIER/src/GestaltungApp.jsx
T
karim 9dc191be4f 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>
2026-05-16 04:27:41 +02:00

517 lines
18 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}