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