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
+302
View File
@@ -0,0 +1,302 @@
import { useState, useMemo, useEffect } from 'react'
import Icon from './Icon'
// Erzeugt ein vollstaendiges Draft-Array fuer einen Preset.
// Layer die im Preset nicht enthalten sind werden auf Default (visible=true,
// locked=false) gesetzt — sonst gibt es Schmutz wenn man zwischen Presets
// hin- und herwechselt.
function draftFromPreset(allLayers, preset) {
if (!preset || !preset.layers) {
return allLayers.map(l => ({ ...l }))
}
const map = {}
;(preset.layers || []).forEach(l => { map[l.id] = l })
return allLayers.map(l => {
const ps = map[l.id]
if (ps) return { ...l, visible: !!ps.visible, locked: !!ps.locked }
return { ...l, visible: true, locked: false }
})
}
export default function AusschnittLayerDialog({
snapName, layers, presets,
onSave, onClose,
onSavePreset, onDeletePreset,
}) {
// Welche Kombination wird gerade angezeigt? null = aktueller Doc-State
const [selectedPreset, setSelectedPreset] = useState(null)
const [draft, setDraft] = useState(() => layers.map(l => ({ ...l })))
const [dirty, setDirty] = useState(false)
const [filter, setFilter] = useState('')
const [newName, setNewName] = useState('')
// Wenn die Layer-Liste (von Backend) sich aendert wegen Doc-Update,
// resetten wir den Draft — aber nur wenn nicht dirty.
useEffect(() => {
if (dirty) return
if (selectedPreset === null) {
setDraft(layers.map(l => ({ ...l })))
} else {
const p = presets.find(p => p.name === selectedPreset)
setDraft(draftFromPreset(layers, p))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layers])
const filtered = useMemo(() => {
const f = filter.trim().toLowerCase()
if (!f) return draft
return draft.filter(l => (l.fullPath || l.name || '').toLowerCase().includes(f))
}, [draft, filter])
const pickPreset = (name) => {
if (dirty && !window.confirm('Ungespeicherte Änderungen verwerfen?')) return
setSelectedPreset(name || null)
setDirty(false)
if (!name) {
setDraft(layers.map(l => ({ ...l })))
} else {
const p = presets.find(p => p.name === name)
setDraft(draftFromPreset(layers, p))
}
}
const toggle = (id, field) => {
setDraft(d => d.map(l => l.id === id ? { ...l, [field]: !l[field] } : l))
setDirty(true)
}
const setAll = (field, value) => {
setDraft(d => d.map(l => filtered.find(f => f.id === l.id) ? { ...l, [field]: value } : l))
setDirty(true)
}
const savePresetChanges = () => {
if (!selectedPreset) return
if (!window.confirm(`Änderungen an Kombination "${selectedPreset}" speichern?`)) return
onSavePreset(selectedPreset, draft.map(l => ({ id: l.id, visible: l.visible, locked: l.locked })))
setDirty(false)
}
const saveAsNew = () => {
const name = newName.trim()
if (!name) return
if (presets.some(p => p.name === name) &&
!window.confirm(`Kombination "${name}" überschreiben?`)) return
onSavePreset(name, draft.map(l => ({ id: l.id, visible: l.visible, locked: l.locked })))
setSelectedPreset(name)
setDirty(false)
setNewName('')
}
const deleteSelected = () => {
if (!selectedPreset) return
if (!window.confirm(`Kombination "${selectedPreset}" löschen?`)) return
onDeletePreset(selectedPreset)
setSelectedPreset(null)
setDirty(false)
setDraft(layers.map(l => ({ ...l })))
}
const applyToDoc = () => {
if (dirty && selectedPreset &&
!window.confirm('Änderungen an dieser Kombination sind nicht gespeichert. Trotzdem auf das Dokument anwenden?')) return
onSave(draft)
}
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 150,
background: 'var(--bg-overlay)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: 30,
}}>
<div style={{
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
boxShadow: 'var(--shadow-3)',
width: 'calc(100% - 24px)', maxWidth: 480,
maxHeight: 'calc(100vh - 60px)',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
}}>
<Icon name="layers" size={16} style={{ color: 'var(--text-secondary)' }} />
<span style={{ flex: 1, fontWeight: 600, fontSize: 12 }}>
{snapName}
</span>
{dirty && (
<span style={{ fontSize: 10, color: 'var(--warn)',
padding: '2px 6px', borderRadius: 'var(--r)',
background: 'var(--warn-dim)' }}
title="Ungespeicherte Änderungen"></span>
)}
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px' }}>×</button>
</div>
{/* Preset-Auswahl */}
<div style={{
padding: '10px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border-light)',
display: 'flex', flexDirection: 'column', gap: 8,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="label-xs" style={{ flexShrink: 0 }}>Kombination</span>
<select
value={selectedPreset || ''}
onChange={(ev) => pickPreset(ev.target.value || null)}
style={{ flex: 1, fontSize: 11 }}
>
<option value=""> Aktueller Zustand </option>
{presets.length > 0 && <option disabled></option>}
{presets.map(p => (
<option key={p.name} value={p.name}>
{p.name} ({p.layers?.length || 0} Ebenen)
</option>
))}
</select>
{selectedPreset && (
<button
className="btn-icon-sm"
onClick={deleteSelected}
title="Diese Kombination löschen"
style={{ color: 'var(--danger)' }}
>
<Icon name="delete" size={13} />
</button>
)}
</div>
{selectedPreset && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
className="btn-contained"
onClick={savePresetChanges}
disabled={!dirty}
style={{ fontSize: 10, padding: '3px 10px',
opacity: dirty ? 1 : 0.5 }}
title={dirty ? `Änderungen in "${selectedPreset}" speichern` : 'Keine Änderungen'}
>
<Icon name="save" size={12} />
<span>Speichern</span>
</button>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
Änderungen werden NICHT automatisch gespeichert.
</span>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
value={newName}
onChange={(ev) => setNewName(ev.target.value)}
onKeyDown={(ev) => { if (ev.key === 'Enter') saveAsNew() }}
placeholder="Aktuelle Auswahl als neue Kombination speichern…"
style={{ flex: 1, fontSize: 10 }}
/>
<button
className="btn-outlined"
onClick={saveAsNew}
disabled={!newName.trim()}
title="Aktuelle Auswahl unter diesem Namen speichern"
style={{ fontSize: 10, padding: '3px 8px' }}
>
<Icon name="add" size={12} /> Neu
</button>
</div>
</div>
{/* Such- und Bulk-Zeile */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 14px',
borderBottom: '1px solid var(--border-light)',
}}>
<input
value={filter}
onChange={(ev) => setFilter(ev.target.value)}
placeholder="Filter..."
style={{ flex: 1, fontSize: 10, padding: '3px 6px' }}
/>
<button className="btn-icon-xs" onClick={() => setAll('visible', true)} title="Alle (gefiltert) sichtbar">
<Icon name="visibility" size={12} />
</button>
<button className="btn-icon-xs" onClick={() => setAll('visible', false)} title="Alle (gefiltert) ausblenden">
<Icon name="visibility_off" size={12} />
</button>
<button className="btn-icon-xs" onClick={() => setAll('locked', false)} title="Alle (gefiltert) entsperren">
<Icon name="lock_open" size={12} />
</button>
</div>
{/* Layer-Liste */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 200, maxHeight: '50vh' }}>
{filtered.length === 0 ? (
<div style={{ padding: '30px 14px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>
Keine Ebenen gefunden.
</div>
) : (
filtered.map(l => (
<div
key={l.id}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 14px',
borderBottom: '1px solid var(--border-light)',
background: 'var(--bg-item)',
opacity: l.visible ? 1 : 0.5,
}}
>
<span style={{
width: 10, height: 10, borderRadius: 2,
background: l.color || '#888',
flexShrink: 0,
border: '1px solid var(--border-light)',
}} />
<span style={{
flex: 1, fontSize: 11,
color: 'var(--text-label)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontFamily: 'var(--font-mono)',
}} title={l.fullPath}>{l.fullPath || l.name}</span>
<button
className={`btn-icon-xs ${l.visible ? 'is-on' : ''}`}
onClick={() => toggle(l.id, 'visible')}
title={l.visible ? 'Ausblenden' : 'Einblenden'}
><Icon name={l.visible ? 'visibility' : 'visibility_off'} size={12} /></button>
<button
className={`btn-icon-xs ${l.locked ? 'is-on' : ''}`}
onClick={() => toggle(l.id, 'locked')}
title={l.locked ? 'Entsperren' : 'Sperren'}
><Icon name={l.locked ? 'lock' : 'lock_open'} size={12} /></button>
</div>
))
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 14px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
}}>
<div style={{ flex: 1, fontSize: 10, color: 'var(--text-muted)' }}>
{draft.filter(l => l.visible).length} / {draft.length} sichtbar
</div>
<button className="btn-text" onClick={onClose}>Schliessen</button>
<button className="btn-contained" onClick={applyToDoc}
title="Aktuelle Auswahl auf das Dokument anwenden">
<Icon name="check" size={12} />
<span>Auf Doc anwenden</span>
</button>
</div>
</div>
</div>
)
}