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
+515
View File
@@ -0,0 +1,515 @@
import { useState, useEffect, useMemo } from 'react'
import Icon from './components/Icon'
import ContextMenu from './components/ContextMenu'
import AusschnittLayerDialog from './components/AusschnittLayerDialog'
import {
onMessage, notifyReady,
listAusschnitte, saveAusschnitt, updateAusschnitt,
restoreAusschnitt, applyAusschnittToDetail,
renameAusschnitt, deleteAusschnitt,
setAusschnittFolder, setAusschnittScale,
duplicateAusschnitt, addAusschnittFolder, removeAusschnittFolder,
getAusschnittLayers, updateAusschnittLayers,
saveLayerPreset, deleteLayerPreset,
} from './lib/rhinoBridge'
function EditableInline({ value, onCommit, autoEdit, style, fontSize }) {
const [editing, setEditing] = useState(autoEdit || false)
const [val, setVal] = useState(value)
useEffect(() => { setVal(value) }, [value])
useEffect(() => { if (autoEdit) setEditing(true) }, [autoEdit])
const commit = () => {
const trimmed = (val ?? '').trim()
if (trimmed && trimmed !== value) onCommit(trimmed)
else setVal(value)
setEditing(false)
}
if (editing) {
return (
<input
value={val}
autoFocus
onChange={(ev) => setVal(ev.target.value)}
onBlur={commit}
onKeyDown={(ev) => {
if (ev.key === 'Enter') commit()
if (ev.key === 'Escape') { setVal(value); setEditing(false) }
}}
onClick={(ev) => ev.stopPropagation()}
style={{ ...style, fontSize, padding: '2px 6px' }}
/>
)
}
return (
<span
onDoubleClick={(ev) => { ev.stopPropagation(); setVal(value); setEditing(true) }}
style={{ ...style, fontSize, cursor: 'text' }}
>{value}</span>
)
}
function ScaleCell({ snap, onChange }) {
const [editing, setEditing] = useState(false)
const [val, setVal] = useState(snap.scale || '')
useEffect(() => { setVal(snap.scale || '') }, [snap.scale])
const commit = () => {
onChange(snap.id, val.trim())
setEditing(false)
}
if (editing) {
return (
<input
value={val}
autoFocus
onChange={(ev) => setVal(ev.target.value)}
onBlur={commit}
onKeyDown={(ev) => {
if (ev.key === 'Enter') commit()
if (ev.key === 'Escape') { setVal(snap.scale || ''); setEditing(false) }
}}
onClick={(ev) => ev.stopPropagation()}
placeholder="1:50"
style={{ width: 64, fontSize: 10, padding: '2px 6px', textAlign: 'right' }}
/>
)
}
return (
<span
onDoubleClick={(ev) => { ev.stopPropagation(); setEditing(true) }}
className={snap.scale ? 'chip chip-accent' : 'chip'}
style={{
fontSize: 9, cursor: 'text',
fontFamily: 'var(--font-mono)',
flexShrink: 0,
}}
title={snap.scale ? `Maßstab ${snap.scale} — wird auf Detail angewendet` : 'Doppelklick um Maßstab einzutragen (z.B. 1:50)'}
>{snap.scale || '—:—'}</span>
)
}
function OrientationBadge({ orientation }) {
const variant = orientation === 'perspective' ? {
icon: 'view_in_ar', color: 'var(--accent)',
title: 'Perspektive',
} : orientation === 'horizontal' ? {
icon: 'align_horizontal_center', color: 'var(--active)',
title: 'Horizontaler Schnitt (Grundriss)',
} : {
icon: 'align_vertical_center', color: 'var(--warn)',
title: 'Vertikaler Schnitt (Schnitt / Ansicht)',
}
return (
<span
title={variant.title}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, flexShrink: 0,
borderRadius: 999,
background: 'var(--bg-input)',
color: variant.color,
}}
>
<Icon name={variant.icon} size={14} />
</span>
)
}
function AusschnittCard({ snap, onClick, onContextMenu, onMenuClick, onRename, onScaleChange, onDragStart, onDragEnd, dragging }) {
return (
<div
draggable
onClick={onClick}
onContextMenu={onContextMenu}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 8px',
border: '1px solid var(--border-light)',
borderRadius: 'var(--r)',
background: 'var(--bg-input)',
cursor: 'grab', userSelect: 'none',
marginBottom: 4,
opacity: dragging ? 0.4 : 1,
transition: 'background 0.14s, border-color 0.14s, opacity 0.14s',
}}
onMouseEnter={(ev) => { ev.currentTarget.style.background = 'var(--bg-item-hover)' }}
onMouseLeave={(ev) => { ev.currentTarget.style.background = 'var(--bg-input)' }}
>
<OrientationBadge orientation={snap.orientation} />
<EditableInline
value={snap.name}
onCommit={(n) => onRename(snap.id, n)}
fontSize={11}
style={{
flex: 1, minWidth: 0,
color: 'var(--text-primary)', fontWeight: 500,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
/>
<ScaleCell snap={snap} onChange={onScaleChange} />
<button
className="btn-icon-sm"
onClick={(ev) => { ev.stopPropagation(); onMenuClick(ev) }}
title="Aktionen"
>
<Icon name="more_vert" size={14} />
</button>
</div>
)
}
function FolderCard({
name, count, collapsed, onToggle, onContextMenu, onMenuClick,
onDragOver, onDragLeave, onDrop, dragOver, children,
}) {
return (
<div
onContextMenu={onContextMenu}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
style={{
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
background: dragOver ? 'var(--accent-dim)' : 'var(--bg-section)',
marginBottom: 8,
padding: 8,
transition: 'background 0.14s, border-color 0.14s',
borderColor: dragOver ? 'var(--accent-border)' : 'var(--border)',
}}
>
<div
onClick={onToggle}
style={{
display: 'flex', alignItems: 'center', gap: 6,
cursor: 'pointer', userSelect: 'none',
padding: 2,
}}
>
<span style={{
transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
color: 'var(--text-muted)', display: 'inline-flex',
}}>
<Icon name="arrow_drop_down" size={16} />
</span>
<Icon name="folder" size={14} style={{ color: 'var(--warn)' }} />
<span style={{ flex: 1, fontSize: 11, fontWeight: 500, color: 'var(--text-primary)' }}>{name}</span>
<span className="chip" style={{ fontSize: 8 }}>{count}</span>
<button
className="btn-icon-sm"
onClick={(ev) => { ev.stopPropagation(); onMenuClick(ev) }}
title="Ordner-Aktionen"
>
<Icon name="more_vert" size={14} />
</button>
</div>
{!collapsed && (
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column' }}>
{children || (
<div style={{
padding: '10px 6px', fontSize: 10, color: 'var(--text-muted)',
textAlign: 'center', fontStyle: 'italic',
}}>
Leer Ausschnitte hier ablegen.
</div>
)}
</div>
)}
</div>
)
}
function RootDropZone({ children, onDragOver, onDragLeave, onDrop, dragOver, empty }) {
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
style={{
background: dragOver ? 'var(--accent-dim)' : 'transparent',
border: '1px dashed transparent',
borderColor: dragOver ? 'var(--accent-border)' : 'transparent',
borderRadius: 'var(--r-lg)',
padding: dragOver || empty ? 6 : 0,
marginBottom: empty ? 0 : 8,
transition: 'background 0.14s, border-color 0.14s, padding 0.14s',
}}
>
{children}
</div>
)
}
export default function AusschnitteApp() {
const [snaps, setSnaps] = useState([])
const [extraFolders, setExtraFolders] = useState([])
const [presets, setPresets] = useState([])
const [newName, setNewName] = useState('')
const [ctxMenu, setCtxMenu] = useState(null)
const [collapsed, setCollapsed] = useState({})
const [draggingId, setDraggingId] = useState(null)
const [dragTarget, setDragTarget] = useState(null)
const [layerDialog, setLayerDialog] = useState(null)
useEffect(() => {
onMessage('LIST', ({ snapshots, folders, presets }) => {
setSnaps(snapshots || [])
setExtraFolders(folders || [])
setPresets(presets || [])
})
onMessage('LAYERS_DATA', ({ id, name, layers, presets }) => {
setLayerDialog({ id, name, layers: layers || [], presets: presets || [] })
})
notifyReady()
const blockContext = (ev) => ev.preventDefault()
document.addEventListener('contextmenu', blockContext)
return () => document.removeEventListener('contextmenu', blockContext)
}, [])
const groups = useMemo(() => {
const map = {}
snaps.forEach(s => {
const f = s.folder || ''
if (!map[f]) map[f] = []
map[f].push(s)
})
return map
}, [snaps])
const allFolders = useMemo(() => {
const set = new Set(extraFolders)
snaps.forEach(s => { if (s.folder) set.add(s.folder) })
return [...set].sort((a, b) => a.localeCompare(b))
}, [snaps, extraFolders])
const handleSave = () => {
const name = newName.trim() || `Ausschnitt ${snaps.length + 1}`
saveAusschnitt(name)
setNewName('')
}
const handleAddFolder = () => {
const name = window.prompt('Name für neuen Ordner:')
if (name && name.trim()) addAusschnittFolder(name.trim())
}
const ctxItems = (id) => [
{ label: 'Wiederherstellen', icon: 'restore', onClick: () => restoreAusschnitt(id) },
{ label: 'Auf Detail anwenden', icon: 'crop_landscape', onClick: () => applyAusschnittToDetail(id) },
{ divider: true },
{ label: 'Sichtbarkeit bearbeiten…', icon: 'layers', onClick: () => getAusschnittLayers(id) },
{ divider: true },
{ label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicateAusschnitt(id) },
{ label: 'Aktualisieren', icon: 'sync', onClick: () => updateAusschnitt(id) },
{ divider: true },
{ label: 'Löschen', icon: 'delete', danger: true, onClick: () => deleteAusschnitt(id) },
]
const folderCtxItems = (folderName) => [
{ label: 'Ordner umbenennen', icon: 'edit', onClick: () => {
const newName = window.prompt('Neuer Ordnername:', folderName)
if (newName && newName.trim() && newName !== folderName) {
snaps.filter(s => s.folder === folderName).forEach(s => setAusschnittFolder(s.id, newName.trim()))
addAusschnittFolder(newName.trim())
removeAusschnittFolder(folderName)
}
}},
{ divider: true },
{ label: 'Ordner löschen', icon: 'folder_off', danger: true, onClick: () => {
if (window.confirm(`Ordner "${folderName}" löschen? Ausschnitte werden zur Wurzel verschoben.`)) {
removeAusschnittFolder(folderName)
}
}},
]
const handleDrop = (folderName) => (ev) => {
ev.preventDefault()
setDragTarget(null)
const id = ev.dataTransfer.getData('text/plain') || draggingId
if (id) setAusschnittFolder(id, folderName || '')
setDraggingId(null)
}
const handleDragOver = (folderName) => (ev) => {
ev.preventDefault()
ev.dataTransfer.dropEffect = 'move'
setDragTarget(folderName || 'root')
}
const handleDragLeave = () => () => setDragTarget(null)
const renderSnapshot = (s) => (
<AusschnittCard
key={s.id}
snap={s}
dragging={draggingId === s.id}
onClick={() => restoreAusschnitt(s.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, id: s.id, kind: 'snap' }) }}
onMenuClick={(ev) => setCtxMenu({ x: ev.clientX, y: ev.clientY, id: s.id, kind: 'snap' })}
onRename={(id, name) => renameAusschnitt(id, name)}
onScaleChange={(id, scale) => setAusschnittScale(id, scale)}
onDragStart={(ev) => {
ev.dataTransfer.setData('text/plain', s.id)
ev.dataTransfer.effectAllowed = 'move'
setDraggingId(s.id)
}}
onDragEnd={() => { setDraggingId(null); setDragTarget(null) }}
/>
)
const actions = (
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn-icon-tonal" onClick={handleAddFolder} title="Neuer Ordner">
<Icon name="create_new_folder" size={14} />
</button>
<button className="btn-icon-tonal" onClick={() => listAusschnitte()} title="Aktualisieren">
<Icon name="refresh" size={14} />
</button>
</div>
)
const rootItems = groups[''] || []
const isEmpty = snaps.length === 0 && allFolders.length === 0
return (
<div style={{
display: 'flex', flexDirection: 'column',
height: '100vh', overflow: 'hidden',
background: 'var(--bg-base)',
position: 'relative',
}}>
{/* Fixed Header — wie Layouts/Overrides Pattern */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 10px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
}}>
<span style={{ flex: 1, fontWeight: 600, letterSpacing: '0.08em',
color: 'var(--text-primary)' }}>
AUSSCHNITTE
</span>
<span className="chip" style={{ fontSize: 8 }}>{snaps.length}</span>
{actions}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
{/* Save-Bar als Card */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: 8,
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
background: 'var(--bg-section)',
marginBottom: 8,
marginTop: 6,
}}>
<input
value={newName}
onChange={(ev) => setNewName(ev.target.value)}
onKeyDown={(ev) => { if (ev.key === 'Enter') handleSave() }}
placeholder="Name für neuen Ausschnitt…"
style={{ flex: 1, fontSize: 11, fontFamily: 'var(--font)', minWidth: 0 }}
/>
<button className="btn-add" onClick={handleSave} title="Ausschnitt speichern">
<Icon name="add" size={16} />
</button>
</div>
{isEmpty ? (
<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="photo_library" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
<div style={{ marginTop: 8 }}>Noch keine Ausschnitte.</div>
<div style={{ marginTop: 4, fontSize: 10 }}>Oben einen Namen eingeben und <Icon name="add" size={11} /> klicken.</div>
</div>
) : (
<>
{/* Root-Snapshots */}
<RootDropZone
dragOver={dragTarget === 'root'}
empty={rootItems.length === 0}
onDragOver={handleDragOver('')}
onDragLeave={handleDragLeave()}
onDrop={handleDrop('')}
>
{rootItems.map(s => renderSnapshot(s))}
{rootItems.length === 0 && draggingId && (
<div style={{
padding: '8px 14px', fontSize: 10, color: 'var(--text-muted)',
textAlign: 'center', fontStyle: 'italic',
}}>Hier ablegen für Wurzel</div>
)}
</RootDropZone>
{/* Ordner-Cards */}
{allFolders.map(folder => {
const isCollapsed = !!collapsed[folder]
const items = groups[folder] || []
return (
<FolderCard
key={folder}
name={folder}
count={items.length}
collapsed={isCollapsed}
dragOver={dragTarget === folder}
onToggle={() => setCollapsed(c => ({ ...c, [folder]: !c[folder] }))}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, name: folder, kind: 'folder' }) }}
onMenuClick={(ev) => setCtxMenu({ x: ev.clientX, y: ev.clientY, name: folder, kind: 'folder' })}
onDragOver={handleDragOver(folder)}
onDragLeave={handleDragLeave()}
onDrop={handleDrop(folder)}
>
{items.length > 0 ? items.map(s => renderSnapshot(s)) : null}
</FolderCard>
)
})}
</>
)}
<div style={{ padding: '12px 4px 0', fontSize: 9, color: 'var(--text-muted)',
lineHeight: 1.6, fontStyle: 'italic' }}>
Drag & Drop auf Ordner-Card zum Verschieben · Doppelklick auf Name/Maßstab = bearbeiten · für Aktionen
</div>
</div>
{ctxMenu && (
<ContextMenu
x={ctxMenu.x} y={ctxMenu.y}
items={ctxMenu.kind === 'folder' ? folderCtxItems(ctxMenu.name) : ctxItems(ctxMenu.id)}
onClose={() => setCtxMenu(null)}
/>
)}
{layerDialog && (
<AusschnittLayerDialog
snapName={layerDialog.name}
layers={layerDialog.layers}
presets={layerDialog.presets}
onSave={(layers) => {
updateAusschnittLayers(layerDialog.id,
layers.map(l => ({ id: l.id, visible: l.visible, locked: l.locked })))
setLayerDialog(null)
}}
onClose={() => setLayerDialog(null)}
onSavePreset={(name, layers) => {
saveLayerPreset(name, layers)
setLayerDialog(d => d ? { ...d, presets: [...d.presets.filter(p => p.name !== name), { name, layers }] } : d)
}}
onDeletePreset={(name) => {
deleteLayerPreset(name)
setLayerDialog(d => d ? { ...d, presets: d.presets.filter(p => p.name !== name) } : d)
}}
/>
)}
</div>
)
}