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
+273
View File
@@ -0,0 +1,273 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import GeschossManager from './components/GeschossManager'
import EbenenManager from './components/EbenenManager'
import AusschnittLayerDialog from './components/AusschnittLayerDialog'
import {
applyAll, setActiveZeichnungsebene, setActiveEbene,
onMessage, notifyReady, applyVisibility,
getCombination, applyCombination,
saveCurrentAsCombination, deleteCombinationPreset,
saveCombinationPreset,
} from './lib/rhinoBridge'
export function recalcOkff(list) {
let acc = 0
return list.map(z => {
if (z.isGeschoss) {
const next = { ...z, okff: parseFloat(acc.toFixed(3)) }
acc += (z.hoehe ?? 3.0)
return next
}
return { ...z, okff: undefined }
})
}
const INITIAL_ZEICHNUNGSEBENEN = recalcOkff([
{ id: 'eg', name: 'EG', isGeschoss: true, hoehe: 3.50, schnitthoehe: 1.00, visible: true },
{ id: '1og', name: '1OG', isGeschoss: true, hoehe: 3.00, schnitthoehe: 1.00, visible: true },
{ id: '2og', name: '2OG', isGeschoss: true, hoehe: 3.00, schnitthoehe: 1.00, visible: true },
])
const INITIAL_EBENEN = [
{ code: '00', name: 'RASTER', color: '#484850', lw: 0.13, visible: true, locked: false },
{ code: '01', name: 'VERMESSUNG', color: '#707078', lw: 0.18, visible: true, locked: false },
{ code: '10', name: 'SITUATION', color: '#909090', lw: 0.18, visible: true, locked: false },
{ code: '11', name: 'STRASSE', color: '#a89070', lw: 0.18, visible: true, locked: false },
{ code: '12', name: 'GEBÄUDE', color: '#888888', lw: 0.25, visible: true, locked: false },
{ code: '13', name: 'BÄUME', color: '#50a050', lw: 0.13, visible: true, locked: false },
{ code: '14', name: 'HÖHENLINIEN', color: '#909050', lw: 0.18, visible: true, locked: false },
{ code: '20', name: 'WÄNDE', color: '#0a0a0a', lw: 0.50, visible: true, locked: false },
{ code: '21', name: 'TÜREN_FENSTER', color: '#5080c8', lw: 0.25, visible: true, locked: false },
{ code: '22', name: 'MÖBEL', color: '#909090', lw: 0.13, visible: true, locked: false },
{ code: '25', name: 'STÜTZEN', color: '#c87050', lw: 0.50, visible: true, locked: false },
{ code: '30', name: 'DECKEN', color: '#605850', lw: 0.35, visible: true, locked: false },
{ code: '31', name: 'DÄCHER', color: '#7a4a3a', lw: 0.35, visible: true, locked: false },
{ code: '35', name: 'TRÄGER', color: '#a87858', lw: 0.50, visible: true, locked: false },
{ code: '50', name: 'TEXT', color: '#d0d0d0', lw: 0.13, visible: true, locked: false },
{ code: '60', name: 'PLANGRAFIK', color: '#c0a040', lw: 0.13, visible: true, locked: false },
{ code: '90', name: 'REFERENZEN', color: '#585860', lw: 0.13, visible: true, locked: false },
{ code: '99', name: 'KONSTRUKTION', color: '#404048', lw: 0.13, visible: true, locked: false },
]
export default function App() {
const [zeichnungsebenen, setZeichnungsebenen] = useState(INITIAL_ZEICHNUNGSEBENEN)
const [ebenen, setEbenen] = useState(INITIAL_EBENEN)
const [activeId, setActiveId] = useState('eg')
const [activeCode, setActiveCode] = useState('20')
const [appliedZ, setAppliedZ] = useState(INITIAL_ZEICHNUNGSEBENEN)
const [appliedE, setAppliedE] = useState(INITIAL_EBENEN)
const [zMode, setZMode] = useState('active')
const [eMode, setEMode] = useState('all')
const [hatchPatterns, setHatchPatterns] = useState(['Solid', 'Hatch1', 'Hatch2', 'Hatch3', 'Plus', 'Squares', 'Grid', 'Grid60'])
// Ebenenkombinationen (geteilter Store mit Ausschnitten)
const [combinations, setCombinations] = useState([]) // Liste { name, layers }
const [activeCombName, setActiveCombName] = useState(null) // null = "Eigene"
// Dialog fuer "alle bearbeiten" (Pencil-Button)
const [combDialog, setCombDialog] = useState(null) // { layers, presets } oder null
const wantCombDialogRef = useRef(false)
useEffect(() => {
onMessage('STATE_SYNC', ({ zeichnungsebenen: z, ebenen: e, hatchPatterns: hp }) => {
if (z) {
const r = recalcOkff(z); setZeichnungsebenen(r); setAppliedZ(r)
const active = r.find(zz => zz.id === activeId) || r[0]
if (active) {
setActiveZeichnungsebene(active)
// Auch den Sublayer-Code aktiv setzen, damit Rhino's Current-Layer
// beim Panel-Start sofort auf der Wahl im Panel landet (sonst bleibt
// "Default" und neue Objekte landen dort).
if (activeCode) setActiveEbene(activeCode)
}
}
if (e) { setEbenen(e); setAppliedE(e) }
if (Array.isArray(hp) && hp.length > 0) setHatchPatterns(hp)
})
onMessage('COMBINATION_DATA', ({ layers, presets }) => {
setCombinations(presets || [])
if (wantCombDialogRef.current) {
wantCombDialogRef.current = false
setCombDialog({ layers: layers || [], presets: presets || [] })
} else if (combDialog) {
// Dialog ist offen — Layer-Liste live aktualisieren (z.B. nach Preset-Save)
setCombDialog(d => d ? { ...d, layers: layers || d.layers, presets: presets || [] } : d)
}
})
onMessage('FIRST_RUN', () => {
applyAll(INITIAL_ZEICHNUNGSEBENEN, INITIAL_EBENEN)
setAppliedZ(INITIAL_ZEICHNUNGSEBENEN)
setAppliedE(INITIAL_EBENEN)
const active = INITIAL_ZEICHNUNGSEBENEN.find(zz => zz.id === activeId) || INITIAL_ZEICHNUNGSEBENEN[0]
if (active) {
setActiveZeichnungsebene(active)
if (activeCode) setActiveEbene(activeCode)
}
})
notifyReady()
// Initial Liste der Kombinationen holen
setTimeout(() => getCombination(), 200)
// Native Browser-Context-Menu global unterdruecken
const blockContext = (ev) => ev.preventDefault()
document.addEventListener('contextmenu', blockContext)
return () => document.removeEventListener('contextmenu', blockContext)
}, [])
// Sichtbarkeit live anwenden — bei relevanten Aenderungen
const visibilityKey = useMemo(() => (
activeId + '|' + activeCode + '|' + zMode + '|' + eMode + '|' +
zeichnungsebenen.map(z => `${z.id}:${z.visible !== false ? 1 : 0}`).join(',') + '|' +
ebenen.map(e => `${e.code}:${e.visible !== false ? 1 : 0}:${e.locked === true ? 1 : 0}`).join(',')
), [activeId, activeCode, zMode, eMode, zeichnungsebenen, ebenen])
useEffect(() => {
const activeZ = zeichnungsebenen.find(z => z.id === activeId)
if (activeZ) applyVisibility(activeZ, zeichnungsebenen, activeCode, ebenen, zMode, eMode)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibilityKey])
// Auto-Apply bei strukturellen Aenderungen (add/remove/rename) UND
// wenn die Ebenen-Settings (z.B. fill) sich aendern — Python braucht den
// neuen Stand in doc.Strings damit Auto-Fill und 'Nach Ebene' korrekt lesen.
// Kanonische Signatur: leere/None-Fills sind alle aequivalent — sonst loest
// schon das blosse Oeffnen+Schliessen des Settings-Dialogs ein applyAll aus
// (Dialog initialisiert fill mit Default-Werten).
const fillSig = (e) => {
const f = e.fill
if (!f || !f.pattern || f.pattern === 'None') return ''
return [f.pattern, f.source || 'layer', f.color || '', f.scale ?? 1, f.rotation ?? 0].join('|')
}
const structureKey = useMemo(() => (
zeichnungsebenen.map(z => `${z.id}:${z.name}:${z.isGeschoss ? 1 : 0}`).join(',') + '|' +
ebenen.map(e => `${e.code}:${e.name}:${fillSig(e)}`).join(',')
), [zeichnungsebenen, ebenen])
const appliedStructureKey = useMemo(() => (
appliedZ.map(z => `${z.id}:${z.name}:${z.isGeschoss ? 1 : 0}`).join(',') + '|' +
appliedE.map(e => `${e.code}:${e.name}:${fillSig(e)}`).join(',')
), [appliedZ, appliedE])
useEffect(() => {
if (structureKey === appliedStructureKey) return
const t = setTimeout(() => {
applyAll(zeichnungsebenen, ebenen)
setAppliedZ(zeichnungsebenen)
setAppliedE(ebenen)
}, 200)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [structureKey, appliedStructureKey])
// --- Ebenen-Kombinationen ----------------------------------------------
const handlePickCombination = (name) => {
if (!name) { setActiveCombName(null); return }
const preset = combinations.find(p => p.name === name)
if (!preset) return
// Eye-State bevorzugen wenn im Preset vorhanden (= verlustfreie Wiederherstellung,
// beruecksichtigt z_mode/e_mode); fallback auf doc.Layer-Liste fuer alte Presets.
applyCombination({
layers: preset.layers || [],
dossierEbenen: preset.dossierEbenen,
dossierZeichnungsebenen: preset.dossierZeichnungsebenen,
})
setActiveCombName(name)
}
const handleSaveCurrentCombination = () => {
const suggested = activeCombName || `Kombi ${combinations.length + 1}`
const name = (window.prompt('Name für Ebenenkombination:', suggested) || '').trim()
if (!name) return
if (combinations.some(p => p.name === name) &&
!window.confirm(`"${name}" ueberschreiben?`)) return
saveCurrentAsCombination(name)
setActiveCombName(name)
}
const handleDeleteCombination = (name) => {
if (!name) return
if (!window.confirm(`Ebenenkombination "${name}" löschen?`)) return
deleteCombinationPreset(name)
if (activeCombName === name) setActiveCombName(null)
}
const handleOpenCombDialog = () => {
wantCombDialogRef.current = true
getCombination()
}
// Wenn der User Sichtbarkeit/Lock manuell aendert -> "Eigene".
// Wird direkt von EbenenManager aufgerufen, kein Effect-Race.
const handleUserVisibilityChange = () => {
if (activeCombName !== null) setActiveCombName(null)
}
const handleActiveChange = (id) => {
setActiveId(id)
const z = zeichnungsebenen.find(x => x.id === id)
if (z) {
setActiveZeichnungsebene(z)
if (activeCode) setActiveEbene(activeCode)
}
}
return (
<div style={{
display: 'flex', flexDirection: 'column',
height: '100vh', overflow: 'hidden',
background: 'var(--bg-base)',
position: 'relative',
}}>
<div style={{ flex: 1, overflowY: 'auto' }}>
<GeschossManager
zeichnungsebenen={zeichnungsebenen}
activeId={activeId}
onActiveChange={handleActiveChange}
onChange={(updated) => setZeichnungsebenen(recalcOkff(updated))}
recalcOkff={recalcOkff}
mode={zMode}
onModeChange={setZMode}
/>
<EbenenManager
ebenen={ebenen}
activeCode={activeCode}
onActiveChange={(code) => { setActiveCode(code); setActiveEbene(code) }}
onChange={setEbenen}
mode={eMode}
onModeChange={setEMode}
hatchPatterns={hatchPatterns}
combinations={combinations}
activeCombName={activeCombName}
onPickCombination={handlePickCombination}
onSaveCurrentCombination={handleSaveCurrentCombination}
onDeleteCombination={handleDeleteCombination}
onEditCombinations={handleOpenCombDialog}
onUserVisibilityChange={handleUserVisibilityChange}
/>
</div>
{combDialog && (
<AusschnittLayerDialog
snapName="Ebenenkombinationen bearbeiten"
layers={combDialog.layers}
presets={combDialog.presets}
onClose={() => setCombDialog(null)}
onSave={(layers) => {
applyCombination(layers)
setActiveCombName(null)
setCombDialog(null)
}}
onSavePreset={(name, layers) => {
saveCombinationPreset(name, layers)
setCombDialog(d => d ? {
...d,
presets: [...d.presets.filter(p => p.name !== name), { name, layers }],
} : d)
}}
onDeletePreset={(name) => {
deleteCombinationPreset(name)
setCombDialog(d => d ? {
...d,
presets: d.presets.filter(p => p.name !== name),
} : d)
if (activeCombName === name) setActiveCombName(null)
}}
/>
)}
</div>
)
}
+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>
)
}
+377
View File
@@ -0,0 +1,377 @@
import { useEffect, useState, useRef } from 'react'
import Icon from './components/Icon'
import {
onMessage, notifyReady,
setRefPoint, setCoordSystem,
setDimPosition, setDimDimension, setDimRotationZ,
setCircleRadius, setLineLength, setRectangleDims,
} from './lib/rhinoBridge'
// ---- Helpers --------------------------------------------------------------
const REF_CODES = ['min', 'mid', 'max'] // row/col mapping
const REF_LABELS = { min: 'min', mid: 'mid', max: 'max' }
function fmtNum(v) {
if (v == null) return ''
if (typeof v !== 'number') return String(v)
// 4 Nachkommastellen, aber unnoetige Nullen weg
return Number(v.toFixed(4)).toString()
}
// Input-Komponente: zeigt formatierten Wert, sendet onCommit bei Enter/Blur.
// Verhindert Update waehrend des Tippens, damit der Cursor nicht springt.
function NumInput({ value, onCommit, disabled, suffix, width }) {
const [text, setText] = useState(fmtNum(value))
const [focused, setFocused] = useState(false)
useEffect(() => { if (!focused) setText(fmtNum(value)) }, [value, focused])
const commit = () => {
const v = parseFloat(text.replace(',', '.'))
if (!isNaN(v) && v !== value) onCommit(v)
else setText(fmtNum(value)) // ungueltig → zurueck auf alten Wert
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flex: width ? 0 : 1, width }}>
<input
type="text"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onFocus={(e) => { setFocused(true); e.target.select() }}
onBlur={() => { setFocused(false); commit() }}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.target.blur() }
else if (e.key === 'Escape') { setText(fmtNum(value)); e.target.blur() }
}}
style={{ flex: 1, width: '100%', fontFamily: 'DM Mono, monospace', fontSize: 11, textAlign: 'right' }}
/>
{suffix && <span style={{ fontSize: 10, color: 'var(--text-muted)', flexShrink: 0 }}>{suffix}</span>}
</div>
)
}
// 9-Punkt-Referenzpunkt-Selektor im Illustrator-Stil: sichtbarer BBox-Rahmen,
// die Punkte sitzen AUF Ecken / Kantenmitten / Zentrum.
function RefPointGrid({ ref, onChange }) {
const SIZE = 26 // Aussenkanten-Quadrat (px)
const DOT = 5 // Punkt-Durchmesser (px)
// Position pro Code: 0% (min), 50% (mid), 100% (max)
const pct = (c) => c === 'min' ? '0%' : c === 'max' ? '100%' : '50%'
return (
<div style={{
position: 'relative',
width: SIZE, height: SIZE,
border: '1px solid var(--text-muted)',
background: 'transparent',
flexShrink: 0,
}}>
{REF_CODES.map(yc => REF_CODES.map(xc => {
const active = ref.x === xc && ref.y === yc
// yc 'max' = top in user mental model (Vectorworks/Illustrator)
const topPct = yc === 'max' ? '0%' : yc === 'min' ? '100%' : '50%'
return (
<button
key={`${xc}-${yc}`}
onClick={() => onChange({ ...ref, x: xc, y: yc })}
title={`X=${xc}, Y=${yc}`}
style={{
position: 'absolute',
left: pct(xc), top: topPct,
transform: 'translate(-50%, -50%)',
width: DOT, height: DOT, padding: 0,
borderRadius: 0, // eckig wie Illustrator
background: active ? 'var(--accent)' : 'var(--text-muted)',
border: 'none',
cursor: 'pointer',
transition: 'background 0.1s',
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'var(--text-primary)' }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'var(--text-muted)' }}
/>
)
}))}
</div>
)
}
// Z-Referenz-Selektor (Bottom / Mid / Top) — kompakt, nur Icons.
function RefZSelector({ z, onChange }) {
return (
<div style={{ display: 'flex', gap: 2 }}>
{[
{ code: 'max', icon: 'vertical_align_top', title: 'Z = Top' },
{ code: 'mid', icon: 'vertical_align_center', title: 'Z = Mid' },
{ code: 'min', icon: 'vertical_align_bottom', title: 'Z = Bottom' },
].map(opt => (
<button
key={opt.code}
onClick={() => onChange(opt.code)}
className={z === opt.code ? 'btn-contained' : 'btn-outlined'}
style={{
padding: '2px 5px', fontSize: 10,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
title={opt.title}
>
<Icon name={opt.icon} size={12} />
</button>
))}
</div>
)
}
// Inline-Label vor einem Input — minimal, knackig
function Field({ label, children, style }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, ...style }}>
<span style={{
width: 12, fontSize: 10, fontWeight: 600,
color: 'var(--text-muted)', textAlign: 'center',
fontFamily: 'DM Mono, monospace', flexShrink: 0,
}}>
{label}
</span>
{children}
</div>
)
}
// ---- Hauptkomponente ------------------------------------------------------
export default function DimensionenApp() {
const [state, setState] = useState({
selection: { count: 0, type: 'none' },
refPoint: { x: 'min', y: 'min', z: 'mid' },
coordSystem: 'world',
position: null,
dimensions: null,
shape: null,
planeName: 'Welt',
})
const [rotationDelta, setRotationDelta] = useState(0)
useEffect(() => {
onMessage('STATE', (s) => setState((prev) => ({ ...prev, ...s })))
notifyReady()
}, [])
const sel = state.selection || { count: 0, type: 'none' }
const ref = state.refPoint || { x: 'min', y: 'min', z: 'mid' }
const pos = state.position
const dims = state.dimensions
const shape = state.shape
const hasSelection = sel.count > 0 && pos != null
const onRefChange = (next) => setRefPoint(next.x, next.y, next.z)
const onCoordChange = (mode) => setCoordSystem(mode)
// Selektions-Beschriftung
const selLabel = () => {
if (sel.count === 0) return 'Keine Selektion'
if (sel.count === 1) {
const map = {
curve: 'Kurve', brep: 'Brep', mesh: 'Mesh', extrusion: 'Extrusion',
block: 'Block', point: 'Punkt', text: 'Text', other: 'Objekt',
}
let base = map[sel.type] || '1 Objekt'
if (shape?.type === 'circle') base = 'Kreis'
if (shape?.type === 'rectangle') base = 'Rechteck'
if (shape?.type === 'line') base = 'Linie'
return '1 ' + base
}
return `${sel.count} Objekte`
}
return (
<div style={{
display: 'flex', flexDirection: 'column',
height: '100vh', overflow: 'hidden',
background: 'var(--bg-base)', color: 'var(--text-primary)',
fontFamily: 'var(--font)', fontSize: 11,
}}>
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: 6 }}>
{/* Header: Selektions-Info + World/CPlane */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6,
padding: '5px 8px',
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
}}>
<Icon name="select_all" size={14} style={{ color: 'var(--text-muted)' }} />
<span style={{ flex: 1, fontWeight: 500 }}>{selLabel()}</span>
<div style={{ display: 'flex', gap: 2 }}>
<button
onClick={() => onCoordChange('world')}
className={state.coordSystem === 'world' ? 'btn-contained' : 'btn-outlined'}
style={{ fontSize: 10, padding: '3px 8px' }}
title="Weltkoordinaten"
>Welt</button>
<button
onClick={() => onCoordChange('cplane')}
className={state.coordSystem === 'cplane' ? 'btn-contained' : 'btn-outlined'}
style={{ fontSize: 10, padding: '3px 8px' }}
title="Aktive Konstruktionsebene"
>CPlane</button>
</div>
</div>
{!hasSelection ? (
<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="aspect_ratio" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
<div style={{ marginTop: 8 }}>Keine Selektion.</div>
<div style={{ marginTop: 4, fontSize: 10 }}>
In Rhino ein oder mehrere Objekte auswaehlen.
</div>
</div>
) : (
<>
{/* Referenzpunkt — kompakte einzeilige Card */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 8px', marginBottom: 6,
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
}}>
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
Ref
</span>
<RefPointGrid ref={ref} onChange={onRefChange} />
<RefZSelector z={ref.z} onChange={(z) => onRefChange({ ...ref, z })} />
</div>
{/* Position + Abmessungen nebeneinander */}
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
<div style={{
flex: 1, display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 8px',
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
minWidth: 0,
}}>
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
letterSpacing: '0.06em', textTransform: 'uppercase',
display: 'flex', justifyContent: 'space-between' }}>
<span>Position</span>
<span style={{ fontWeight: 400, textTransform: 'none',
fontFamily: 'DM Mono, monospace', fontSize: 9 }}>
{state.planeName}
</span>
</div>
<Field label="X"><NumInput value={pos.x} onCommit={(v) => setDimPosition('x', v)} /></Field>
<Field label="Y"><NumInput value={pos.y} onCommit={(v) => setDimPosition('y', v)} /></Field>
<Field label="Z"><NumInput value={pos.z} onCommit={(v) => setDimPosition('z', v)} /></Field>
</div>
<div style={{
flex: 1, display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 8px',
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
minWidth: 0,
}}>
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
BBox
</div>
<Field label="B"><NumInput value={dims?.width} onCommit={(v) => setDimDimension('width', v)} /></Field>
<Field label="T"><NumInput value={dims?.depth} onCommit={(v) => setDimDimension('depth', v)} /></Field>
<Field label="H"><NumInput value={dims?.height} onCommit={(v) => setDimDimension('height', v)} /></Field>
</div>
</div>
{/* Shape-spezifisch */}
{shape && (
<div style={{
display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 8px', marginBottom: 6,
background: 'var(--bg-section)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--accent)',
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
{shape.type === 'circle' && 'Kreis'}
{shape.type === 'rectangle' && 'Rechteck'}
{shape.type === 'line' && 'Linie'}
</div>
{shape.type === 'circle' && (
<Field label="R"><NumInput value={shape.radius} onCommit={(v) => setCircleRadius(v)} /></Field>
)}
{shape.type === 'rectangle' && (
<div style={{ display: 'flex', gap: 6 }}>
<Field label="W" style={{ flex: 1 }}>
<NumInput value={shape.width}
onCommit={(v) => setRectangleDims(v, shape.height)} />
</Field>
<Field label="H" style={{ flex: 1 }}>
<NumInput value={shape.height}
onCommit={(v) => setRectangleDims(shape.width, v)} />
</Field>
</div>
)}
{shape.type === 'line' && (
<div style={{ display: 'flex', gap: 6 }}>
<Field label="L" style={{ flex: 1 }}>
<NumInput value={shape.length} onCommit={(v) => setLineLength(v)} />
</Field>
<Field label="α" style={{ flex: 1 }}>
<NumInput value={shape.angle} onCommit={() => {}} disabled suffix="°" />
</Field>
</div>
)}
</div>
)}
{/* Rotation — kompakt einzeilig */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 8px', marginBottom: 6,
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
}}>
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
Drehen
</span>
<div style={{ flex: 1 }}>
<NumInput value={rotationDelta} onCommit={setRotationDelta} suffix="°" />
</div>
<button
className="btn-outlined"
onClick={() => { if (rotationDelta) setDimRotationZ(rotationDelta) }}
disabled={!rotationDelta}
title="Selektion um Z-Achse der aktiven Plane drehen"
style={{ padding: '3px 8px', fontSize: 11 }}
>
<Icon name="rotate_right" size={13} />
</button>
{[-90, -45, 45, 90].map(a => (
<button
key={a}
className="btn-outlined"
onClick={() => setDimRotationZ(a)}
style={{ padding: '3px 5px', fontSize: 9, minWidth: 28 }}
title={`${a}°`}
>
{a > 0 ? '+' : ''}{a}°
</button>
))}
</div>
</>
)}
</div>
</div>
)
}
+1391
View File
File diff suppressed because it is too large Load Diff
+516
View File
@@ -0,0 +1,516 @@
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>
)
}
+836
View File
@@ -0,0 +1,836 @@
import { useEffect, useState, useRef } from 'react'
import Icon from './components/Icon'
import ContextMenu from './components/ContextMenu'
import {
onMessage, notifyReady,
listLayouts, newLayout, deleteLayout, renameLayout, activateLayout,
addDetail, deleteDetail, bindAusschnitt, syncDetail, syncLayout,
setPageSize, exportPdf, exportPdfAll, exportPdfMany,
addLayoutFolder, removeLayoutFolder, setLayoutFolder,
} from './lib/rhinoBridge'
const PAPER_SIZES = ['A4', 'A3', 'A2', 'A1', 'A0', 'Letter']
const PAPER_FORMATS_MM = {
A0: [841, 1189], A1: [594, 841], A2: [420, 594], A3: [297, 420],
A4: [210, 297], Letter: [216, 279],
}
function detectFormat(wMm, hMm, tol = 2) {
if (!(wMm > 0) || !(hMm > 0)) return null
const a = Math.min(wMm, hMm), b = Math.max(wMm, hMm)
for (const [name, [sw, sh]] of Object.entries(PAPER_FORMATS_MM)) {
if (Math.abs(a - sw) <= tol && Math.abs(b - sh) <= tol) return name
}
return null
}
function formatLabel(wMm, hMm) {
if (!(wMm > 0) || !(hMm > 0)) return '—'
const f = detectFormat(wMm, hMm)
if (!f) return `${Math.round(wMm)}×${Math.round(hMm)}mm`
const landscape = wMm > hMm
return landscape ? `${f}` : `${f}`
}
function OrientationBadge({ landscape }) {
return (
<span title={landscape ? 'Querformat' : 'Hochformat'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, flexShrink: 0,
borderRadius: 999,
background: 'var(--bg-input)',
color: 'var(--accent)',
}}>
<Icon name={landscape ? 'crop_landscape' : 'crop_portrait'} size={14} />
</span>
)
}
const cardStyle = {
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
padding: 8,
marginBottom: 6,
}
const labelXs = {
fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
letterSpacing: '0.06em', textTransform: 'uppercase',
}
// Inline-editierbarer Name — Doppelklick aktiviert, Enter/Blur committet.
// forceEdit triggert Edit-Mode von aussen (z.B. Kontextmenue „Umbenennen").
function EditableName({ value, onCommit, style, title, forceEdit, onEditDone }) {
const [editing, setEditing] = useState(false)
const [text, setText] = useState(value || '')
const inputRef = useRef(null)
useEffect(() => { if (!editing) setText(value || '') }, [value, editing])
useEffect(() => { if (editing) { inputRef.current?.focus(); inputRef.current?.select() } }, [editing])
useEffect(() => { if (forceEdit) setEditing(true) }, [forceEdit])
if (editing) {
return (
<input
ref={inputRef}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={() => {
setEditing(false)
const t = text.trim()
if (t && t !== value) onCommit(t)
else setText(value || '')
onEditDone && onEditDone()
}}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur()
else if (e.key === 'Escape') {
setText(value || ''); setEditing(false); onEditDone && onEditDone()
}
}}
onClick={(e) => e.stopPropagation()}
style={{ flex: 1, fontSize: 'inherit', fontFamily: 'inherit', ...style }}
/>
)
}
return (
<span
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true) }}
title={title || 'Doppelklick zum Umbenennen'}
style={{
flex: 1, cursor: 'text',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
...style,
}}
>
{value || '(unbenannt)'}
</span>
)
}
export default function LayoutsApp() {
const [state, setState] = useState({ layouts: [], snapshots: [], details: {}, folders: [] })
const [selectedId, setSelectedId] = useState(null)
const [dialog, setDialog] = useState(null) // { mode: 'new' | 'edit', layout? }
const [checked, setChecked] = useState(new Set()) // Multi-Select IDs
const [collapsedFolders, setCollapsedFolders] = useState(new Set())
const [draggingId, setDraggingId] = useState(null)
const [dragTarget, setDragTarget] = useState(null) // '__root' | folderName
const [ctxMenu, setCtxMenu] = useState(null) // {x,y,kind,id}
const [renamingId, setRenamingId] = useState(null) // signal to inline-edit
useEffect(() => {
onMessage('STATE', (s) => setState((prev) => ({ ...prev, ...s })))
notifyReady()
}, [])
const layouts = state.layouts || []
const snaps = state.snapshots || []
const folders = state.folders || []
const details = (selectedId && state.details && state.details[selectedId]) || []
const selected = layouts.find(l => l.id === selectedId)
// Layouts gruppieren: Root + per Folder
const grouped = { __root: [] }
for (const f of folders) grouped[f] = []
for (const l of layouts) {
const f = l.folder || '__root'
if (!grouped[f]) grouped[f] = []
grouped[f].push(l)
}
const toggleCheck = (id) => {
setChecked(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const toggleFolderCollapse = (name) => {
setCollapsedFolders(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name); else next.add(name)
return next
})
}
const checkAllInFolder = (folderLayouts) => {
setChecked(prev => {
const next = new Set(prev)
const allIn = folderLayouts.every(l => next.has(l.id))
if (allIn) folderLayouts.forEach(l => next.delete(l.id))
else folderLayouts.forEach(l => next.add(l.id))
return next
})
}
const handleExportSelection = () => {
const ids = [...checked].filter(id => layouts.find(l => l.id === id))
if (ids.length === 0) return
exportPdfMany(ids, 300)
}
const handleExportFolder = (folderName) => {
const ids = (grouped[folderName] || []).map(l => l.id)
if (ids.length === 0) return
exportPdfMany(ids, 300)
}
const handleNewFolder = () => {
const name = window.prompt('Name fuer neuen Ordner:')
if (name && name.trim()) addLayoutFolder(name.trim())
}
// Kontextmenue-Items pro Layout
const layoutCtxItems = (l) => [
{ label: 'In Rhino oeffnen', icon: 'open_in_new',
onClick: () => activateLayout(l.id) },
{ label: 'Umbenennen', icon: 'edit',
onClick: () => setRenamingId(l.id) },
{ divider: true },
{ label: 'Als PDF exportieren', icon: 'picture_as_pdf',
onClick: () => exportPdf(l.id, 300) },
{ label: 'Papierformat aendern', icon: 'aspect_ratio',
onClick: () => setDialog({ mode: 'edit', layout: l }) },
{ divider: true },
...(folders.length > 0 ? [
...folders.map(f => ({
label: `Verschieben → ${f}`,
icon: 'folder',
disabled: l.folder === f,
onClick: () => setLayoutFolder(l.id, f),
})),
{ label: 'Aus Ordner entfernen',
icon: 'folder_off',
disabled: !l.folder,
onClick: () => setLayoutFolder(l.id, '') },
{ divider: true },
] : []),
{ label: 'Loeschen', icon: 'delete', danger: true,
onClick: () => {
if (window.confirm(`Layout "${l.name}" loeschen?`)) deleteLayout(l.id)
} },
]
// Kontextmenue-Items pro Ordner
const folderCtxItems = (folderName) => {
const items = grouped[folderName] || []
return [
{ label: collapsedFolders.has(folderName) ? 'Aufklappen' : 'Einklappen',
icon: collapsedFolders.has(folderName) ? 'expand_more' : 'expand_less',
onClick: () => toggleFolderCollapse(folderName) },
{ divider: true },
{ label: 'Alle ankreuzen / abwaehlen',
icon: 'check_box',
onClick: () => checkAllInFolder(items) },
{ label: `Ordner als PDF (${items.length})`,
icon: 'picture_as_pdf',
disabled: items.length === 0,
onClick: () => handleExportFolder(folderName) },
{ divider: true },
{ label: 'Ordner umbenennen',
icon: 'edit',
onClick: () => {
const next = window.prompt('Neuer Ordner-Name:', folderName)
if (next && next.trim() && next !== folderName) {
// Atomar via Server-Side waere besser; simpler: alle Layouts
// umhaengen + alten loeschen + neuen anlegen.
addLayoutFolder(next.trim())
items.forEach(l => setLayoutFolder(l.id, next.trim()))
removeLayoutFolder(folderName)
}
} },
{ label: 'Ordner loeschen',
icon: 'folder_off', danger: true,
onClick: () => {
if (window.confirm(`Ordner "${folderName}" loeschen? Layouts werden zur Wurzel verschoben.`))
removeLayoutFolder(folderName)
} },
]
}
// Drag&Drop-Handler — analog Ausschnitte-Pattern
const handleDragOver = (folderName) => (ev) => {
ev.preventDefault()
ev.dataTransfer.dropEffect = 'move'
setDragTarget(folderName || '__root')
}
const handleDragLeave = () => () => setDragTarget(null)
const handleDrop = (folderName) => (ev) => {
ev.preventDefault()
setDragTarget(null)
const id = ev.dataTransfer.getData('text/plain') || draggingId
if (id) setLayoutFolder(id, folderName || '')
setDraggingId(null)
}
// Auto-Select erstes Layout wenn nichts gewaehlt
useEffect(() => {
if (selectedId == null && layouts.length > 0) setSelectedId(layouts[0].id)
if (selectedId != null && !layouts.find(l => l.id === selectedId)) {
setSelectedId(layouts[0]?.id || null)
}
}, [layouts, selectedId])
return (
<div style={{
display: 'flex', flexDirection: 'column',
height: '100vh', overflow: 'hidden',
background: 'var(--bg-base)', color: 'var(--text-primary)',
fontFamily: 'var(--font)', fontSize: 11,
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 10px',
borderBottom: '1px solid var(--border)',
}}>
<span style={{ flex: 1, fontWeight: 600, letterSpacing: '0.08em' }}>LAYOUTS</span>
<button
onClick={handleExportSelection}
className="btn-icon-tonal"
disabled={checked.size === 0}
title={checked.size > 0
? `Auswahl (${checked.size}) als ein PDF exportieren`
: 'Erst Layouts ankreuzen'}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<Icon name="picture_as_pdf" size={14} />
{checked.size > 0 && <span style={{ fontSize: 10 }}>({checked.size})</span>}
</button>
<button
onClick={() => exportPdfAll(300)}
className="btn-icon-tonal"
disabled={layouts.length === 0}
title="Alle Layouts als ein PDF exportieren"
>
<Icon name="picture_as_pdf" size={14} />
<span style={{ fontSize: 9, marginLeft: 2 }}>·</span>
</button>
<button
onClick={handleNewFolder}
className="btn-icon-tonal"
title="Neuer Ordner"
>
<Icon name="create_new_folder" size={14} />
</button>
<button
onClick={() => setDialog({ mode: 'new' })}
className="btn-add"
title="Neues Layout erstellen"
>
<Icon name="add" size={16} />
</button>
<button
onClick={() => listLayouts()}
className="btn-icon-tonal"
title="Aktualisieren"
>
<Icon name="refresh" size={14} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: 8 }}>
{/* Layout-Liste */}
{layouts.length === 0 && folders.length === 0 ? (
<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="dashboard" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
<div style={{ marginTop: 8 }}>Noch keine Layouts.</div>
<div style={{ marginTop: 4, fontSize: 10 }}>
Oben <Icon name="add" size={11} /> klicken um ein neues Layout anzulegen.
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
{/* Ordner-Gruppen */}
{folders.map(folderName => {
const items = grouped[folderName] || []
const isCollapsed = collapsedFolders.has(folderName)
const allChecked = items.length > 0 && items.every(l => checked.has(l.id))
const isDropTarget = dragTarget === folderName
return (
<div key={folderName}
onContextMenu={(ev) => {
ev.preventDefault(); ev.stopPropagation()
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'folder', id: folderName })
}}
onDragOver={handleDragOver(folderName)}
onDragLeave={handleDragLeave()}
onDrop={handleDrop(folderName)}
style={{
border: `1px solid ${isDropTarget ? 'var(--accent-border)' : 'var(--border)'}`,
borderRadius: 'var(--r-lg)',
background: isDropTarget ? 'var(--accent-dim)' : 'var(--bg-section)',
marginBottom: 8,
padding: 8,
transition: 'background 0.14s, border-color 0.14s',
}}>
<div
onClick={() => toggleFolderCollapse(folderName)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
cursor: 'pointer', userSelect: 'none',
padding: 2,
}}
>
<input
type="checkbox"
checked={allChecked}
onClick={(e) => e.stopPropagation()}
onChange={() => checkAllInFolder(items)}
title="Alle in diesem Ordner ankreuzen"
style={{ margin: 0, flexShrink: 0 }}
/>
<span style={{
transform: isCollapsed ? '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)' }}>
{folderName}
</span>
<span className="chip" style={{ fontSize: 8 }}>{items.length}</span>
<button
className="btn-icon-sm"
onClick={(ev) => {
ev.stopPropagation()
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'folder', id: folderName })
}}
title="Ordner-Aktionen"
>
<Icon name="more_vert" size={14} />
</button>
</div>
{!isCollapsed && (
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column' }}>
{items.map(l => (
<LayoutRow key={l.id} l={l}
active={l.id === selectedId}
checked={checked.has(l.id)}
dragging={draggingId === l.id}
forceEditName={renamingId === l.id}
onEditDone={() => setRenamingId(null)}
onCheck={() => toggleCheck(l.id)}
onSelect={() => setSelectedId(l.id)}
onActivate={() => activateLayout(l.id)}
onRename={(n) => renameLayout(l.id, n)}
onContextMenu={(ev) => {
ev.preventDefault(); ev.stopPropagation()
setSelectedId(l.id)
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id })
}}
onMenuClick={(ev) => {
setSelectedId(l.id)
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id })
}}
onDragStart={(ev) => {
ev.dataTransfer.setData('text/plain', l.id)
ev.dataTransfer.effectAllowed = 'move'
setDraggingId(l.id)
}}
onDragEnd={() => { setDraggingId(null); setDragTarget(null) }}
/>
))}
{items.length === 0 && (
<div style={{
padding: '10px 6px', fontSize: 10, color: 'var(--text-muted)',
textAlign: 'center', fontStyle: 'italic',
}}>
Leer Layouts hier ablegen.
</div>
)}
</div>
)}
</div>
)
})}
{/* Root-Drop-Zone — auch wenn keine Root-Layouts existieren, damit
man ein Layout aus einem Ordner zurueck auf "kein Ordner"
ziehen kann. */}
<div
onDragOver={handleDragOver('')}
onDragLeave={handleDragLeave()}
onDrop={handleDrop('')}
style={{
display: 'flex', flexDirection: 'column', gap: 3,
padding: grouped.__root.length === 0 ? 8 : 0,
border: `1px ${dragTarget === '__root' ? 'solid var(--accent)' : 'dashed transparent'}`,
borderRadius: 'var(--r)',
background: dragTarget === '__root' ? 'var(--bg-item-active)' : 'transparent',
transition: 'all 0.1s',
minHeight: grouped.__root.length === 0 ? 32 : 0,
}}
>
{grouped.__root.map(l => (
<LayoutRow key={l.id} l={l}
active={l.id === selectedId}
checked={checked.has(l.id)}
dragging={draggingId === l.id}
forceEditName={renamingId === l.id}
onEditDone={() => setRenamingId(null)}
onCheck={() => toggleCheck(l.id)}
onSelect={() => setSelectedId(l.id)}
onActivate={() => activateLayout(l.id)}
onRename={(n) => renameLayout(l.id, n)}
onContextMenu={(ev) => {
ev.preventDefault(); ev.stopPropagation()
setSelectedId(l.id)
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id })
}}
onMenuClick={(ev) => {
setSelectedId(l.id)
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id })
}}
onDragStart={(ev) => {
ev.dataTransfer.setData('text/plain', l.id)
ev.dataTransfer.effectAllowed = 'move'
setDraggingId(l.id)
}}
onDragEnd={() => { setDraggingId(null); setDragTarget(null) }}
/>
))}
{grouped.__root.length === 0 && draggingId && (
<div style={{
textAlign: 'center', color: 'var(--text-muted)',
fontSize: 10, fontStyle: 'italic',
}}>
Hier ablegen um aus Ordner zu entfernen
</div>
)}
</div>
</div>
)}
{/* Details des aktuell gewaehlten Layouts */}
{selected && (
<div style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
<span style={{ ...labelXs, flex: 1 }}>
Details · {selected.name}
</span>
<button
onClick={() => addDetail(selected.id, null)}
className="btn-icon-tonal"
title="Neues Detail (zentriert auf Seite)"
style={{ marginRight: 4 }}
>
<Icon name="add" size={13} />
</button>
<button
onClick={() => syncLayout(selected.id)}
className="btn-icon-tonal"
disabled={details.length === 0}
title="Alle Details mit ihren Ausschnitten neu synchronisieren"
>
<Icon name="sync" size={13} />
</button>
</div>
{details.length === 0 ? (
<div style={{
padding: '20px 12px', textAlign: 'center',
color: 'var(--text-muted)', fontSize: 11,
border: '1px dashed var(--border)',
borderRadius: 'var(--r)',
background: 'var(--bg-input)',
}}>
<Icon name="crop_landscape" size={24} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
<div style={{ marginTop: 6 }}>Keine Details auf diesem Layout.</div>
<div style={{ marginTop: 4, fontSize: 10 }}>
Oben <Icon name="add" size={11} /> klicken um eines hinzuzufuegen.
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{details.map((d, i) => (
<div key={d.id} style={{
display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 8px',
background: 'var(--bg-input)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--r)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="chip" style={{ flexShrink: 0 }}>#{i + 1}</span>
<span style={{
flex: 1, fontWeight: 500,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{d.name || '(Detail)'}
</span>
<span style={{ fontSize: 10, color: 'var(--text-muted)', fontFamily: 'DM Mono, monospace' }}>
{Math.round(d.width)}×{Math.round(d.height)}
</span>
<button
className="btn-icon-sm btn-icon-danger"
onClick={() => {
if (window.confirm('Detail loeschen?')) deleteDetail(selected.id, d.id)
}}
title="Detail loeschen"
>
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<select
value={d.boundAusschnitt || ''}
onChange={(e) => bindAusschnitt(selected.id, d.id, e.target.value || null)}
style={{ flex: 1, fontSize: 11 }}
title="Welcher Ausschnitt auf diesem Detail liegt"
>
<option value=""> kein Ausschnitt </option>
{snaps.map(s => (
<option key={s.id} value={s.id}>
{s.name}{s.folder ? ` · ${s.folder}` : ''}{s.scale ? ` · ${s.scale}` : ''}
</option>
))}
</select>
<button
onClick={() => syncDetail(selected.id, d.id)}
disabled={!d.boundAusschnitt}
className="btn-icon-sm"
title="Gebundenen Ausschnitt neu anwenden"
>
<Icon name="sync" size={12} />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Kontextmenue */}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={
ctxMenu.kind === 'folder'
? folderCtxItems(ctxMenu.id)
: (() => {
const l = layouts.find(x => x.id === ctxMenu.id)
return l ? layoutCtxItems(l) : []
})()
}
onClose={() => setCtxMenu(null)}
/>
)}
{/* Layout-Dialog: New oder Edit (Papierformat aendern) */}
{dialog && (
<LayoutDialog
mode={dialog.mode}
layout={dialog.layout}
onCancel={() => setDialog(null)}
onSubmit={(p) => {
if (dialog.mode === 'new') {
newLayout(p.name, p.format, p.landscape, p.customWidth, p.customHeight)
} else {
setPageSize(dialog.layout.id, p.format, p.landscape, p.customWidth, p.customHeight)
}
setDialog(null)
}}
/>
)}
</div>
)
}
function LayoutDialog({ mode, layout, onCancel, onSubmit }) {
const editing = mode === 'edit'
const [name, setName] = useState('')
const [format, setFormat] = useState('A3')
const [landscape, setLandscape] = useState(true)
const [cw, setCw] = useState('420') // mm
const [ch, setCh] = useState('297') // mm
// Wenn editieren: aktuelle Layout-Groesse pre-fillen (custom-Mode default)
useEffect(() => {
if (editing && layout) {
// BBox in Doc-Einheiten — wir kennen die Einheit nicht direkt im
// Frontend. Fuer den Edit-Modus zeigen wir die Groesse als Zahlen an
// und schicken sie als "custom" mit der mm-Annahme. Wenn das Doc nicht
// auf mm steht, ergibt sich eine kleine Konvertier-Unschaerfe — das
// Backend rechnet mm-Werte konsistent in Doc-Units um.
setFormat('custom')
setCw(String(Math.round(layout.width)))
setCh(String(Math.round(layout.height)))
}
}, [editing, layout])
return (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 100,
}} onClick={(e) => { if (e.target === e.currentTarget) onCancel() }}>
<div style={{
width: 340, background: 'var(--bg-base)',
border: '1px solid var(--border)', borderRadius: 'var(--r-lg)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)',
fontWeight: 600 }}>
{editing ? `Papierformat: ${layout?.name}` : 'Neues Layout'}
</div>
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{!editing && (
<div>
<div style={labelXs}>Name</div>
<input type="text" value={name} onChange={(e) => setName(e.target.value)}
placeholder="z.B. Grundriss EG"
autoFocus
style={{ width: '100%', marginTop: 4 }} />
</div>
)}
<div>
<div style={labelXs}>Papierformat</div>
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{PAPER_SIZES.map(f => (
<button key={f}
onClick={() => setFormat(f)}
className={format === f ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11 }}>
{f}
</button>
))}
<button
onClick={() => setFormat('custom')}
className={format === 'custom' ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11 }}>
Eigene
</button>
</div>
</div>
{format === 'custom' ? (
<div>
<div style={labelXs}>Eigene Groesse (mm)</div>
<div style={{ display: 'flex', gap: 6, marginTop: 4, alignItems: 'center' }}>
<input type="text" value={cw} onChange={(e) => setCw(e.target.value)}
placeholder="Breite"
style={{ flex: 1, fontFamily: 'DM Mono, monospace', fontSize: 11 }} />
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>×</span>
<input type="text" value={ch} onChange={(e) => setCh(e.target.value)}
placeholder="Hoehe"
style={{ flex: 1, fontFamily: 'DM Mono, monospace', fontSize: 11 }} />
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>mm</span>
</div>
</div>
) : (
<div>
<div style={labelXs}>Ausrichtung</div>
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
<button
onClick={() => setLandscape(true)}
className={landscape ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11, display: 'flex', gap: 4, alignItems: 'center' }}>
<Icon name="crop_landscape" size={12} /> Quer
</button>
<button
onClick={() => setLandscape(false)}
className={!landscape ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11, display: 'flex', gap: 4, alignItems: 'center' }}>
<Icon name="crop_portrait" size={12} /> Hoch
</button>
</div>
</div>
)}
</div>
<div style={{ padding: 10, borderTop: '1px solid var(--border)',
display: 'flex', justifyContent: 'flex-end', gap: 6 }}>
<button onClick={onCancel}>Abbrechen</button>
<button className="btn-contained"
onClick={() => {
const payload = { name: name.trim(), format, landscape }
if (format === 'custom') {
const w = parseFloat(cw), h = parseFloat(ch)
if (!(w > 0) || !(h > 0)) { alert('Bitte gueltige Groesse eingeben.'); return }
payload.customWidth = w
payload.customHeight = h
}
onSubmit(payload)
}}>
{editing ? 'Anwenden' : 'Erstellen'}
</button>
</div>
</div>
</div>
)
}
function LayoutRow({ l, active, checked, dragging, forceEditName,
onCheck, onSelect, onActivate, onRename,
onContextMenu, onMenuClick,
onDragStart, onDragEnd, onEditDone }) {
const landscape = (l.widthMm || l.width) >= (l.heightMm || l.height)
return (
<div
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={onSelect}
onDoubleClick={onActivate}
onContextMenu={onContextMenu}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 8px',
border: '1px solid ' + (active ? 'var(--accent)' : 'var(--border-light)'),
borderRadius: 'var(--r)',
background: active ? 'var(--bg-item-active)' : '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) => { if (!active && !dragging) ev.currentTarget.style.background = 'var(--bg-item-hover)' }}
onMouseLeave={(ev) => { if (!active && !dragging) ev.currentTarget.style.background = 'var(--bg-input)' }}
>
<input
type="checkbox"
checked={checked}
onClick={(e) => e.stopPropagation()}
onChange={onCheck}
style={{ margin: 0, flexShrink: 0 }}
title="Fuer PDF-Export ankreuzen"
/>
<OrientationBadge landscape={landscape} />
<EditableName
value={l.name}
onCommit={onRename}
forceEdit={forceEditName}
onEditDone={onEditDone}
style={{
flex: 1, minWidth: 0,
color: 'var(--text-primary)', fontWeight: 500,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
/>
<span className="chip chip-accent" style={{
fontSize: 9, fontFamily: 'DM Mono, monospace', fontWeight: 600,
flexShrink: 0,
}} title={`${Math.round(l.widthMm || 0)}×${Math.round(l.heightMm || 0)} mm`}>
{formatLabel(l.widthMm, l.heightMm)}
</span>
<button
className="btn-icon-sm"
onClick={(ev) => { ev.stopPropagation(); onMenuClick(ev) }}
title="Aktionen"
>
<Icon name="more_vert" size={14} />
</button>
</div>
)
}
+296
View File
@@ -0,0 +1,296 @@
import { useState, useEffect, useRef } from 'react'
import Icon from './components/Icon'
import {
onMessage, notifyReady,
requestMassstab, setMassstab,
zoomOneToOne, zoomExtents, zoomSelection,
setMassstabDpi, detectMassstabDpi, setShowLineweights,
} from './lib/rhinoBridge'
const PRESETS = [
{ value: 1, label: '1:1' },
{ value: 5, label: '1:5' },
{ value: 10, label: '1:10' },
{ value: 20, label: '1:20' },
{ value: 25, label: '1:25' },
{ value: 50, label: '1:50' },
{ value: 100, label: '1:100' },
{ value: 200, label: '1:200' },
{ value: 500, label: '1:500' },
{ value: 1000, label: '1:1000'},
]
function fmtScale(s) {
if (s == null) return '—'
if (s >= 1) return '1:' + (s >= 10 ? s.toFixed(0) : s.toFixed(1))
return (1 / s).toFixed(2) + ':1'
}
function snapToPreset(s, tol = 0.03) {
if (s == null) return null
for (const p of PRESETS) {
if (Math.abs(s - p.value) / p.value <= tol) return p.value
}
return null
}
function parseScale(input) {
if (!input) return null
const s = String(input).trim()
for (const sep of [':', '=', '/']) {
if (s.includes(sep)) {
const [a, b] = s.split(sep, 2)
const pa = parseFloat(a)
const pb = parseFloat(b)
if (pa > 0 && pb > 0) return pb / pa
return null
}
}
const n = parseFloat(s)
return n > 0 ? n : null
}
// ---------------------------------------------------------------------------
export default function MassstabApp() {
const [state, setState] = useState({
viewName: null, parallel: false, scale: null,
pixelWidth: null, pixelHeight: null, unitSystem: '?',
dpi: 96, dpiSource: 'default',
showLineweights: false,
})
const [appliedScale, setAppliedScale] = useState(null)
const appliedScaleRef = useRef(null)
const [draft, setDraft] = useState('')
const [dpiOpen, setDpiOpen] = useState(false)
const [dpiDraft, setDpiDraft] = useState('96')
useEffect(() => {
onMessage('STATE', (s) => {
setState((prev) => ({ ...prev, ...s }))
// Backend appliedScale (gilt nur fuer aktuellen Viewport) > Live-Snap > roher Live-Wert
let next = null
if (typeof s?.appliedScale === 'number' && s.appliedScale > 0) {
next = s.appliedScale
} else if (s?.parallel && typeof s?.scale === 'number' && s.scale > 0) {
const snap = snapToPreset(s.scale)
next = snap != null ? snap : Math.round(s.scale * 10) / 10
}
if (next != null && next > 0 && next !== appliedScaleRef.current) {
setAppliedScale(next)
appliedScaleRef.current = next
}
})
notifyReady()
setTimeout(() => requestMassstab(), 50)
}, [])
const isPerspective = state.parallel === false
const scaleVal = state.scale
const dropdownValue = appliedScale != null ? String(appliedScale) : '__none__'
const applyDropdown = (val) => {
if (val === '__none__') return
const r = parseFloat(val)
if (r > 0) {
setAppliedScale(r)
appliedScaleRef.current = r
setMassstab(r)
}
}
const applyDraft = () => {
const r = parseScale(draft)
if (r != null) {
setAppliedScale(r)
appliedScaleRef.current = r
setMassstab(r)
setDraft('')
}
}
const commitDpi = () => {
const v = parseFloat(dpiDraft)
if (v >= 30 && v <= 600) setMassstabDpi(v)
setDpiOpen(false)
}
// "100%" = Viewport-Zoom auf den aktuell eingestellten Massstab snappen
// (nicht: Massstab auf 1:1 setzen). Praktisch nach Pan/Zoom, um wieder
// zur Soll-Skala zu kommen.
const apply100 = () => {
if (appliedScale && appliedScale > 0) {
setMassstab(appliedScale)
}
}
// --- Style-Bausteine ------------------------------------------------------
const cellBtn = {
fontSize: 11, padding: '0 8px', height: 24,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4,
background: 'var(--bg-item)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', color: 'var(--text-primary)', cursor: 'pointer',
whiteSpace: 'nowrap',
}
const cellInput = {
fontSize: 11, padding: '0 6px', height: 24, minWidth: 0,
background: 'var(--bg-input)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', color: 'var(--text-primary)',
}
return (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 8px',
fontFamily: 'var(--font)', color: 'var(--text-primary)',
background: 'var(--bg-base)',
borderTop: '1px solid var(--border)',
boxSizing: 'border-box',
overflow: 'hidden',
}}>
{/* Live-Zoom Anzeige */}
<div style={{
fontSize: 13, fontWeight: 600,
fontFamily: 'DM Mono, monospace',
minWidth: 56, textAlign: 'right',
color: isPerspective ? 'var(--text-muted)' : 'var(--text-primary)',
}} title={isPerspective ? 'Perspective — kein Massstab'
: `Aktueller Zoom (live)${appliedScale!=null ? ` · gesetzt 1:${appliedScale}` : ''}`}>
{isPerspective ? '—' : fmtScale(scaleVal)}
</div>
<div style={{ width: 1, height: 18, background: 'var(--border)' }} />
{/* Skala-Dropdown */}
<select
disabled={isPerspective}
value={dropdownValue}
onChange={(e) => applyDropdown(e.target.value)}
style={{ ...cellInput, width: 80 }}
title="Massstab waehlen"
>
<option value="__none__">1:?</option>
{PRESETS.map(p => (
<option key={p.value} value={String(p.value)}>{p.label}</option>
))}
{appliedScale != null && !PRESETS.some(p => p.value === appliedScale) && (
<option value={String(appliedScale)}>1:{appliedScale}</option>
)}
</select>
{/* Freitext */}
<input
disabled={isPerspective}
type="text"
placeholder="1:N"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') applyDraft() }}
onBlur={() => { if (draft) applyDraft() }}
style={{ ...cellInput, width: 64 }}
title="Eigenen Massstab eingeben (Enter)"
/>
<div style={{ width: 1, height: 18, background: 'var(--border)' }} />
{/* Aktions-Buttons */}
<button
disabled={isPerspective || !appliedScale}
onClick={apply100}
style={cellBtn}
title={appliedScale
? `Zoom auf eingestellten Massstab snappen (1:${appliedScale >= 10 ? Math.round(appliedScale) : appliedScale})`
: 'Erst einen Massstab waehlen'}
>100%</button>
<button onClick={zoomExtents} style={cellBtn} title="Auf gesamten Inhalt zoomen">
<Icon name="fit_screen" size={14} />
</button>
<button onClick={zoomSelection} style={cellBtn} title="Auf Selektion zoomen">
<Icon name="center_focus_strong" size={14} />
</button>
<div style={{ width: 1, height: 18, background: 'var(--border)' }} />
{/* Print-View / Strichstaerken-Toggle
Beide Icons werden permanent gerendert; nur display: none togglet,
damit die Font-Ligatur nicht neu aufgeloest wird (sonst Flackern). */}
<button
onClick={() => setShowLineweights(!state.showLineweights)}
style={{
...cellBtn,
background: state.showLineweights ? 'var(--accent)' : 'var(--bg-item)',
color: state.showLineweights ? '#fff' : 'var(--text-primary)',
borderColor: state.showLineweights ? 'var(--accent)' : 'var(--border)',
}}
title={state.showLineweights
? 'Strichstaerken werden angezeigt (Print-View) — klicken zum Ausschalten'
: 'Strichstaerken als Hairlines (Edit-View) — klicken um Print-View zu zeigen'}
>
<Icon name="edit" size={14} style={{ display: state.showLineweights ? 'none' : 'inline-block' }} />
<Icon name="print" size={14} style={{ display: state.showLineweights ? 'inline-block' : 'none' }} />
<span style={{ fontSize: 10 }}>{state.showLineweights ? 'Print' : 'Edit'}</span>
</button>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* View-Name */}
<div style={{
fontSize: 10, color: 'var(--text-muted)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
maxWidth: 140,
}} title={state.viewName || ''}>
{state.viewName || '?'}{isPerspective ? ' · Persp.' : ''}
</div>
{/* DPI-Popover */}
<div style={{ position: 'relative' }}>
<button
onClick={() => { setDpiDraft(String(state.dpi || 96)); setDpiOpen(o => !o) }}
style={{ ...cellBtn, fontSize: 10, padding: '0 6px', height: 22,
color: state.dpiSource === 'auto' ? 'var(--accent)'
: state.dpiSource === 'manual' ? 'var(--text-primary)'
: 'var(--text-muted)' }}
title={`DPI Kalibrierung — aktuell ${Math.round(state.dpi || 96)} dpi (${state.dpiSource || 'default'})`}
>
{Math.round(state.dpi || 96)}dpi
{state.dpiSource === 'auto' && (
<span style={{ marginLeft: 3, fontSize: 8, opacity: 0.8 }}>auto</span>
)}
</button>
{dpiOpen && (
<div style={{
position: 'absolute', right: 0, bottom: '100%', marginBottom: 4,
background: 'var(--bg-panel)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', padding: 8, display: 'flex',
flexDirection: 'column', gap: 6,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', zIndex: 10, minWidth: 220,
}}>
<div style={{ fontSize: 10, color: 'var(--text-muted)' }}>
Bildschirm-Aufloesung fuer Massstab-Berechnung
</div>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="number" min={30} max={600}
value={dpiDraft}
onChange={(e) => setDpiDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') commitDpi() }}
autoFocus
style={{ ...cellInput, flex: 1 }}
/>
<button onClick={commitDpi} style={{ ...cellBtn, fontSize: 10 }}>OK</button>
</div>
<button
onClick={() => { detectMassstabDpi(); setDpiOpen(false) }}
style={{ ...cellBtn, fontSize: 10, justifyContent: 'flex-start' }}
title="DPI automatisch ueber EDID des Bildschirms ermitteln"
>
<Icon name="auto_fix_high" size={12} /> Auto-Detect (EDID)
</button>
</div>
)}
</div>
</div>
)
}
+369
View File
@@ -0,0 +1,369 @@
import { useState, useEffect, useRef } from 'react'
import Icon from './components/Icon'
import {
onMessage, notifyReady,
requestMassstab, setMassstab,
zoomExtents, zoomSelection, setShowLineweights,
setMassstabDpi, detectMassstabDpi,
setView, setDisplayMode,
toggleOverrides, setOverridesPreset, openOverridesPanel,
} from './lib/rhinoBridge'
const PRESETS = [
{ value: 1, label: '1:1' }, { value: 5, label: '1:5' },
{ value: 10, label: '1:10' }, { value: 20, label: '1:20' },
{ value: 25, label: '1:25' }, { value: 50, label: '1:50' },
{ value: 100, label: '1:100' }, { value: 200, label: '1:200' },
{ value: 500, label: '1:500' }, { value: 1000, label: '1:1000' },
]
const VIEWS = [
{ value: 'Top', icon: 'north', label: 'Top' },
{ value: 'Front', icon: 'view_in_ar', label: 'Front' },
{ value: 'Right', icon: 'east', label: 'Right' },
{ value: 'Perspective', icon: 'view_quilt', label: 'Persp' },
]
function fmtScale(s) {
if (s == null) return '—'
if (s >= 1) return '1:' + (s >= 10 ? s.toFixed(0) : s.toFixed(1))
return (1 / s).toFixed(2) + ':1'
}
function snapToPreset(s, tol = 0.03) {
if (s == null) return null
for (const p of PRESETS) {
if (Math.abs(s - p.value) / p.value <= tol) return p.value
}
return null
}
function parseScale(input) {
if (!input) return null
const s = String(input).trim()
for (const sep of [':', '=', '/']) {
if (s.includes(sep)) {
const [a, b] = s.split(sep, 2)
const pa = parseFloat(a), pb = parseFloat(b)
if (pa > 0 && pb > 0) return pb / pa
return null
}
}
const n = parseFloat(s)
return n > 0 ? n : null
}
// ---------------------------------------------------------------------------
// Material-UI-Style: alle Pills auf einheitliche Hoehe (PILL_H)
// nutzt globale Klassen aus index.css fuer Look (border-radius, colors)
const PILL_H = 20 // Einheits-Hoehe fuer alle Bar-Elemente
const sep = {
width: 1, height: 18,
background: 'var(--border)', flexShrink: 0,
margin: '0 2px',
}
const groupLabel = {
fontSize: 9, color: 'var(--text-muted)', textTransform: 'uppercase',
letterSpacing: '0.08em', fontWeight: 600,
alignSelf: 'center', whiteSpace: 'nowrap',
padding: '0 4px',
}
const pillSelect = {
height: PILL_H, lineHeight: PILL_H + 'px',
padding: '0 20px 0 10px', boxSizing: 'border-box',
fontSize: 10,
}
const pillInput = {
height: PILL_H, lineHeight: PILL_H + 'px',
padding: '0 8px', boxSizing: 'border-box',
borderRadius: 999,
fontSize: 10,
}
const pillChip = {
height: PILL_H, lineHeight: PILL_H + 'px',
padding: '0 8px', boxSizing: 'border-box',
display: 'inline-flex', alignItems: 'center',
fontSize: 9,
}
const pillIconBtn = {
width: PILL_H, height: PILL_H,
borderRadius: '50%', boxSizing: 'border-box',
}
// Tonal button helper (filled when active, outlined when not)
function ToolButton({ active, onClick, icon, label, title, disabled }) {
return (
<button
onClick={onClick}
disabled={disabled}
className={active ? 'btn-contained' : 'btn-outlined'}
style={{ height: PILL_H, padding: '0 8px', boxSizing: 'border-box',
fontSize: 9,
opacity: disabled ? 0.4 : 1,
cursor: disabled ? 'not-allowed' : 'pointer' }}
title={title}
>
{icon && <Icon name={icon} size={12} />}
{label && <span style={{ fontSize: 9 }}>{label}</span>}
</button>
)
}
// ---------------------------------------------------------------------------
export default function OberleisteApp() {
const [state, setState] = useState({
viewName: null, parallel: false, scale: null,
pixelWidth: null, pixelHeight: null, unitSystem: '?',
dpi: 96, dpiSource: 'default',
showLineweights: false,
viewMode: null, displayMode: null, displayModes: [],
ortho: false, gridSnap: false, osnap: false,
overridesEnabled: false, overridesCount: 0,
cmdPrompt: '', cmdOptions: [],
overridesActivePreset: null, overridesPresets: [],
})
const [appliedScale, setAppliedScale] = useState(null)
const appliedScaleRef = useRef(null)
const [draft, setDraft] = useState('')
const [customMode, setCustomMode] = useState(false) // Dropdown -> Custom-Input switch
const customInputRef = useRef(null)
useEffect(() => {
onMessage('STATE', (s) => {
setState((prev) => ({ ...prev, ...s }))
// Dropdown spiegelt EXAKT den Backend-appliedScale fuer den aktuellen
// Viewport. Kein Live-Skala-Fallback — das Dropdown ist statisch und
// pro Viewport gebunden. Backend gibt null zurueck wenn der aktive
// Viewport noch keinen gesetzten Massstab hat → Dropdown zeigt "1:?".
const next = (typeof s?.appliedScale === 'number' && s.appliedScale > 0)
? s.appliedScale
: null
if (next !== appliedScaleRef.current) {
setAppliedScale(next)
appliedScaleRef.current = next
}
})
notifyReady()
setTimeout(() => requestMassstab(), 50)
}, [])
const isPerspective = state.parallel === false
const scaleVal = state.scale
const dropdownValue = appliedScale != null ? String(appliedScale) : '__none__'
const applyDropdown = (val) => {
if (val === '__none__') return
if (val === '__custom__') {
setDraft(appliedScale ? `1:${appliedScale}` : '')
setCustomMode(true)
// Nach dem Render: Focus + Selektion fuer schnelles Eintippen.
setTimeout(() => {
customInputRef.current?.focus()
customInputRef.current?.select()
}, 0)
return
}
const r = parseFloat(val)
if (r > 0) { setAppliedScale(r); appliedScaleRef.current = r; setMassstab(r) }
}
const applyDraft = () => {
const r = parseScale(draft)
if (r != null) {
setAppliedScale(r); appliedScaleRef.current = r; setMassstab(r)
}
setDraft('')
setCustomMode(false)
}
const cancelDraft = () => { setDraft(''); setCustomMode(false) }
const apply100 = () => {
if (appliedScale && appliedScale > 0) setMassstab(appliedScale)
}
// Aktuelles View-Match (manche User haben "Top" / "Right" als view name)
const matchView = (v) => {
if (!state.viewName) return false
return state.viewName === v || state.viewName.toLowerCase() === v.toLowerCase()
}
// (Command-Bar wurde entfernt — Rhinos eigene Command-Line wird benutzt.)
return (
<div style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
fontFamily: 'var(--font)', color: 'var(--text-primary)',
background: 'var(--bg-panel)',
borderBottom: '1px solid var(--border)',
boxSizing: 'border-box',
overflow: 'hidden',
}}>
{/* === Toolbar (View, Display, Massstab, Snap, Overrides) === */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 12px',
overflowX: 'auto', overflowY: 'hidden',
flexShrink: 0,
}}>
<span
style={{
fontSize: 11, fontWeight: 600, letterSpacing: '0.08em',
color: 'var(--text-muted)',
fontFamily: 'DM Mono, monospace',
flexShrink: 0, userSelect: 'none',
}}
title={`Dossier ${__APP_VERSION__} — Teil von OpenStudio`}
>
DOSSIER <span style={{ opacity: 0.55 }}>{__APP_VERSION__}</span>
</span>
<div style={sep} />
{/* ====== GRUPPE: VIEW ====== */}
<span style={groupLabel}>View</span>
{VIEWS.map(v => (
<ToolButton
key={v.value}
onClick={() => setView(v.value)}
active={matchView(v.value)}
icon={v.icon}
label={v.label}
title={`Ansicht ${v.label}`}
/>
))}
<div style={sep} />
{/* ====== GRUPPE: DISPLAY-MODE ====== */}
<span style={groupLabel}>Display</span>
<select
value={state.displayMode || ''}
onChange={(e) => setDisplayMode(e.target.value)}
style={pillSelect}
title="Display-Mode (Wireframe / Shaded / Rendered / etc.)"
>
{!state.displayMode && <option value=""></option>}
{(state.displayModes || []).map(dm => (
<option key={dm.id} value={dm.name}>{dm.name}</option>
))}
</select>
<div style={sep} />
{/* ====== GRUPPE: MASSSTAB ====== */}
<span style={groupLabel}>Massstab</span>
<span
className={isPerspective ? 'chip' : 'chip chip-accent'}
style={{
...pillChip,
minWidth: 56, justifyContent: 'center',
fontFamily: 'DM Mono, monospace', fontSize: 11, fontWeight: 600,
}}
title="Live-Zoom"
>
{/* Live-Zoom des Viewports — immer sichtbar bei Parallelprojektion,
unabhaengig davon ob ein Massstab im Dropdown gepinnt ist oder
nicht. Nur in Perspective ist die Anzeige nicht sinnvoll. */}
{isPerspective ? '—' : fmtScale(scaleVal)}
</span>
{customMode ? (
<input
ref={customInputRef}
disabled={isPerspective}
type="text" placeholder="1:N"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applyDraft()
else if (e.key === 'Escape') cancelDraft()
}}
onBlur={applyDraft}
style={{ ...pillInput, width: 92 }}
title="Massstab eingeben (Enter = uebernehmen, Esc = abbrechen)"
/>
) : (
<select
disabled={isPerspective}
value={dropdownValue}
onChange={(e) => applyDropdown(e.target.value)}
style={{ ...pillSelect, width: 92 }}
>
<option value="__none__"></option>
{PRESETS.map(p => (
<option key={p.value} value={String(p.value)}>{p.label}</option>
))}
{appliedScale != null && !PRESETS.some(p => p.value === appliedScale) && (
<option value={String(appliedScale)}>1:{appliedScale}</option>
)}
<option value="__custom__">Eigener</option>
</select>
)}
<ToolButton
onClick={apply100}
disabled={isPerspective || !appliedScale}
label="100%"
title={appliedScale ? `Zoom auf eingestellten Massstab snappen (1:${appliedScale >= 10 ? Math.round(appliedScale) : appliedScale})` : 'Erst einen Massstab waehlen'}
/>
<button className="btn-icon" onClick={zoomExtents}
style={pillIconBtn}
title="Auf gesamten Inhalt zoomen">
<Icon name="fit_screen" size={14} />
</button>
<button className="btn-icon" onClick={zoomSelection}
style={pillIconBtn}
title="Auf Selektion zoomen">
<Icon name="center_focus_strong" size={14} />
</button>
<ToolButton
onClick={() => setShowLineweights(!state.showLineweights)}
active={state.showLineweights}
label={state.showLineweights ? 'Print' : 'Edit'}
title={state.showLineweights ? 'Print-View aktiv — klick zum Ausschalten' : 'Strichstaerken anzeigen (Print-View)'}
icon={state.showLineweights ? 'print' : 'edit'}
/>
<div style={sep} />
{/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar
schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */}
{/* ====== GRUPPE: OVERRIDES ====== */}
<span style={groupLabel}>Overrides</span>
<ToolButton
onClick={() => toggleOverrides(!state.overridesEnabled)}
active={state.overridesEnabled}
icon="auto_fix_high"
label={state.overridesEnabled ? 'AN' : 'AUS'}
title={state.overridesEnabled
? `Grafische Overrides aktiv — klick zum Ausschalten`
: `Grafische Overrides ausgeschaltet`}
/>
{/* Preset-Dropdown: aktive Kombination waehlen. "—" = keine Kombination
(Doc-Rules sind frei editiert oder leer). "Konfigurieren…" oeffnet
den grossen Regel-Editor (OVERRIDES-Panel). */}
<select
value={state.overridesActivePreset || '__none__'}
onChange={(e) => {
const v = e.target.value
if (v === '__configure__') { openOverridesPanel(); return }
setOverridesPreset(v === '__none__' ? null : v)
}}
style={{ ...pillSelect, width: 140 }}
title={state.overridesActivePreset
? `Aktives Preset: ${state.overridesActivePreset} (${state.overridesCount} Regeln)`
: `Kein Preset aktiv (${state.overridesCount} Regeln, frei editiert)`}
>
<option value="__none__">{state.overridesCount > 0 ? `— (${state.overridesCount} Regeln)` : '—'}</option>
{(state.overridesPresets || []).map(name => (
<option key={name} value={name}>{name}</option>
))}
<option disabled></option>
<option value="__configure__">Konfigurieren</option>
</select>
{/* Spacer am rechten Rand */}
<div style={{ flex: 1 }} />
</div>
</div>
)
}
+601
View File
@@ -0,0 +1,601 @@
import { useState, useEffect } from 'react'
import Icon from './components/Icon'
import ContextMenu from './components/ContextMenu'
import {
onMessage, notifyReady,
setOverridesEnabled, addRule, updateRule, deleteRule,
reorderRules, duplicateRule, reapplyOverrides, clearOverrideRules,
savePreset, loadPreset, deletePreset,
} from './lib/rhinoBridge'
const COND_TYPES = [
{ value: 'layer_name', label: 'Layer-Name' },
{ value: 'user_string', label: 'UserString' },
{ value: 'object_name', label: 'Objekt-Name' },
]
const OPS = [
{ value: 'equals', label: '=' },
{ value: 'not_equals', label: '≠' },
{ value: 'contains', label: 'enthält' },
{ value: 'starts_with', label: 'beginnt mit' },
{ value: 'ends_with', label: 'endet mit' },
]
const labelXs = {
fontSize: 9, color: 'var(--text-muted)',
textTransform: 'uppercase', letterSpacing: '0.06em',
fontWeight: 600,
}
// ---------------------------------------------------------------------------
function ConditionLeaf({ cond, layers, onChange, onRemove, canRemove }) {
const t = cond?.type || 'layer_name'
const op = cond?.operator || 'equals'
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 6,
padding: 8,
border: '1px solid var(--border-light)', borderRadius: 'var(--r-lg)',
background: 'var(--bg-section)',
}}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<select
value={t}
onChange={(e) => onChange({ ...cond, type: e.target.value })}
style={{ flex: 1, minWidth: 0 }}
>
{COND_TYPES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
{canRemove && (
<button onClick={onRemove} className="btn-icon-danger"
title="Diese Bedingung entfernen">
<Icon name="close" size={14} />
</button>
)}
</div>
{t === 'user_string' && (
<input
type="text" placeholder="Key"
value={cond?.key || ''}
onChange={(e) => onChange({ ...cond, key: e.target.value })}
style={{ width: '100%' }}
/>
)}
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<select
value={op}
onChange={(e) => onChange({ ...cond, operator: e.target.value })}
style={{ flexShrink: 0 }}
>
{OPS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{t === 'layer_name' ? (
<select
value={cond?.value || ''}
onChange={(e) => onChange({ ...cond, value: e.target.value })}
style={{ flex: 1, minWidth: 0 }}
>
<option value=""></option>
{(layers || []).map(l => (
<option key={l.fullPath} value={l.fullPath}>{l.fullPath}</option>
))}
</select>
) : (
<input
type="text" placeholder="Wert"
value={cond?.value || ''}
onChange={(e) => onChange({ ...cond, value: e.target.value })}
style={{ flex: 1, minWidth: 0 }}
/>
)}
</div>
</div>
)
}
function ConditionsEditor({ rule, layers, onChange }) {
const conds = rule.conditions && rule.conditions.length > 0
? rule.conditions
: (rule.condition ? [rule.condition] : [{ type: 'layer_name', operator: 'equals', value: '' }])
const logic = (rule.conditionsLogic || 'and').toLowerCase()
const update = (i, newCond) => {
const next = conds.slice()
next[i] = newCond
onChange({ ...rule, conditions: next, condition: undefined })
}
const remove = (i) => {
const next = conds.slice()
next.splice(i, 1)
onChange({ ...rule, conditions: next.length ? next : [{ type: 'layer_name', operator: 'equals', value: '' }], condition: undefined })
}
const add = () => {
const next = conds.slice()
next.push({ type: 'layer_name', operator: 'equals', value: '' })
onChange({ ...rule, conditions: next, condition: undefined })
}
const setLogic = (l) => onChange({ ...rule, conditionsLogic: l })
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{conds.length > 1 && (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Logik:</span>
<button
onClick={() => setLogic('and')}
className={logic === 'and' ? 'btn-contained' : 'btn-outlined'}
title="Alle Bedingungen müssen zutreffen"
>AND</button>
<button
onClick={() => setLogic('or')}
className={logic === 'or' ? 'btn-contained' : 'btn-outlined'}
title="Mindestens eine Bedingung muss zutreffen"
>OR</button>
</div>
)}
{conds.map((c, i) => (
<ConditionLeaf
key={i}
cond={c}
layers={layers}
onChange={(nc) => update(i, nc)}
onRemove={() => remove(i)}
canRemove={conds.length > 1}
/>
))}
<button onClick={add} className="btn-outlined" style={{ alignSelf: 'flex-start' }}
title="Weitere Bedingung hinzufügen">
<Icon name="add" size={14} />
<span>Bedingung</span>
</button>
</div>
)
}
function ActionRow({ label, icon, active, onToggle, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{
display: 'flex', alignItems: 'center', gap: 8,
cursor: 'pointer', userSelect: 'none',
}}>
<input type="checkbox" checked={active} onChange={onToggle}
style={{ width: 'auto', height: 'auto', padding: 0 }} />
<Icon name={icon} size={14} style={{ color: 'var(--text-muted)' }} />
<span style={{ fontSize: 11, color: active ? 'var(--text-primary)' : 'var(--text-muted)' }}>
{label}
</span>
</label>
{active && (
<div style={{ marginLeft: 26, display: 'flex', gap: 6, alignItems: 'center' }}>
{children}
</div>
)}
</div>
)
}
function ActionsEditor({ actions, linetypes, hatchPatterns, onChange }) {
const a = actions || {}
const setProp = (key, val) => {
const next = { ...a }
if (val === '' || val === null || val === undefined) {
delete next[key]
} else {
next[key] = val
}
onChange(next)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<ActionRow
label="Farbe" icon="palette"
active={'color' in a}
onToggle={(e) => setProp('color', e.target.checked ? (a.color || '#888888') : '')}
>
<input
type="color"
value={a.color || '#888888'}
onChange={(e) => setProp('color', e.target.value)}
style={{ width: 36, height: 26, padding: 2, flexShrink: 0 }}
/>
<input
type="text"
value={a.color || ''}
placeholder="#rrggbb"
onChange={(e) => setProp('color', e.target.value)}
style={{ flex: 1, minWidth: 0 }}
/>
</ActionRow>
<ActionRow
label="Strichstärke" icon="line_weight"
active={'lineweight' in a}
onToggle={(e) => setProp('lineweight', e.target.checked ? (a.lineweight ?? 0.25) : '')}
>
<input
type="number" step={0.05} min={0}
value={a.lineweight ?? ''}
onChange={(e) => setProp('lineweight', parseFloat(e.target.value) || 0)}
style={{ width: 80 }}
/>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>mm</span>
</ActionRow>
<ActionRow
label="Linientyp" icon="more_horiz"
active={'linetype' in a}
onToggle={(e) => setProp('linetype', e.target.checked ? (a.linetype || 'Continuous') : '')}
>
<select
value={a.linetype || ''}
onChange={(e) => setProp('linetype', e.target.value)}
style={{ flex: 1, minWidth: 0 }}
>
<option value=""></option>
{(linetypes || []).map(lt => <option key={lt} value={lt}>{lt}</option>)}
</select>
</ActionRow>
<ActionRow
label="Schraffur" icon="grid_view"
active={'hatchPattern' in a}
onToggle={(e) => setProp('hatchPattern', e.target.checked ? (a.hatchPattern || 'Solid') : '')}
>
<select
value={a.hatchPattern || ''}
onChange={(e) => setProp('hatchPattern', e.target.value)}
style={{ flex: 1, minWidth: 0 }}
>
<option value=""></option>
{(hatchPatterns || []).map(hp => <option key={hp} value={hp}>{hp}</option>)}
</select>
</ActionRow>
<ActionRow
label="Schraffur-Skala" icon="aspect_ratio"
active={'hatchScale' in a}
onToggle={(e) => setProp('hatchScale', e.target.checked ? (a.hatchScale ?? 1.0) : '')}
>
<input
type="number" step={0.1} min={0.001}
value={a.hatchScale ?? ''}
onChange={(e) => setProp('hatchScale', parseFloat(e.target.value) || 1.0)}
style={{ width: 80 }}
/>
</ActionRow>
<div style={{ fontSize: 9, color: 'var(--text-muted)', fontStyle: 'italic', lineHeight: 1.4 }}>
Hatch-Override modifiziert nur existierende Schraffuren. Curves ohne Hatch bleiben unverändert.
</div>
</div>
)
}
function RuleCard({ rule, index, total, layers, linetypes, hatchPatterns, onPatch, onDelete, onDuplicate, onMoveUp, onMoveDown, onContextMenu }) {
const [open, setOpen] = useState(false)
const summarize = () => {
const conds = (rule.conditions && rule.conditions.length > 0)
? rule.conditions
: (rule.condition ? [rule.condition] : [])
const logic = (rule.conditionsLogic || 'and').toUpperCase()
const a = rule.actions || {}
const parts = []
const condTexts = conds.map(c => {
if (c.type === 'layer_name') return `Layer ${c.operator} "${c.value || '?'}"`
if (c.type === 'user_string') return `${c.key || '?'} ${c.operator} "${c.value || '?'}"`
if (c.type === 'object_name') return `Name ${c.operator} "${c.value || '?'}"`
return ''
}).filter(Boolean)
if (condTexts.length === 1) parts.push(condTexts[0])
else if (condTexts.length > 1) parts.push(condTexts.join(` ${logic} `))
const acts = Object.keys(a)
if (acts.length) parts.push('→ ' + acts.join(', '))
return parts.join(' ')
}
return (
<div
onContextMenu={(ev) => { if (onContextMenu) { ev.preventDefault(); onContextMenu(ev) } }}
style={{
border: '1px solid var(--border)', borderRadius: 'var(--r-lg)',
background: 'var(--bg-section)', padding: 8,
marginBottom: 8,
opacity: rule.enabled === false ? 0.5 : 1,
}}>
{/* Row 1: index + checkbox + name + edit-toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="chip" style={{ flexShrink: 0 }}>#{index + 1}</span>
<input
type="checkbox"
checked={rule.enabled !== false}
onChange={(e) => onPatch({ ...rule, enabled: e.target.checked })}
title="Regel aktiv"
style={{ flexShrink: 0, width: 'auto', height: 'auto', padding: 0 }}
/>
<input
type="text"
value={rule.name || ''}
placeholder="Regel-Name"
onChange={(e) => onPatch({ ...rule, name: e.target.value })}
style={{ flex: 1, minWidth: 0 }}
/>
<button onClick={() => setOpen(!open)} className="btn-icon"
title={open ? 'Einklappen' : 'Bearbeiten'}>
<Icon name={open ? 'expand_less' : 'edit'} size={14} />
</button>
</div>
{!open && (
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, marginLeft: 4,
overflowWrap: 'break-word', wordBreak: 'break-word' }}>
{summarize() || '(leer)'}
</div>
)}
{open && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, marginTop: 8 }}>
<button onClick={() => onMoveUp()} disabled={index === 0}
className="btn-icon-sm" title="Prio höher (nach oben)">
<Icon name="arrow_upward" size={14} />
</button>
<button onClick={() => onMoveDown()} disabled={index === total - 1}
className="btn-icon-sm" title="Prio tiefer (nach unten)">
<Icon name="arrow_downward" size={14} />
</button>
<button onClick={() => onDuplicate()} className="btn-icon-sm" title="Duplizieren">
<Icon name="content_copy" size={14} />
</button>
<div style={{ flex: 1 }} />
<button onClick={() => { if (confirm(`Regel "${rule.name}" löschen?`)) onDelete() }}
className="btn-icon-danger" title="Löschen">
<Icon name="delete" size={14} />
</button>
</div>
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div>
<div style={{ ...labelXs, marginBottom: 6 }}>Bedingungen</div>
<ConditionsEditor rule={rule} layers={layers} onChange={onPatch} />
</div>
<div>
<div style={{ ...labelXs, marginBottom: 6 }}>Überschreibungen</div>
<ActionsEditor
actions={rule.actions}
linetypes={linetypes}
hatchPatterns={hatchPatterns}
onChange={(a) => onPatch({ ...rule, actions: a })}
/>
</div>
</div>
</>
)}
</div>
)
}
// ---------------------------------------------------------------------------
export default function OverridesApp() {
const [state, setState] = useState({
enabled: false, rules: [], layers: [], linetypes: [], hatchPatterns: [], presets: [],
activePreset: null,
})
const [selectedPreset, setSelectedPreset] = useState('')
const [ctxMenu, setCtxMenu] = useState(null) // {x, y, ruleId}
useEffect(() => {
onMessage('STATE', (s) => {
setState((prev) => ({ ...prev, ...s }))
// Dropdown synct sich auf das Backend-activePreset nur wenn der wert
// gesetzt ist (z.B. nachdem Topbar eine Kombination geladen hat).
// Wenn activePreset null wird (Rules wurden gerade editiert -> variant C),
// BEHALTEN wir die lokale Auswahl — sonst weiss der Save-Button nicht
// mehr in welche Kombination der User gerade editiert.
if (s && s.activePreset) {
setSelectedPreset(s.activePreset)
}
})
notifyReady()
}, [])
const onPatch = (rule) => updateRule(rule.id, rule)
const onMove = (id, delta) => {
const ids = state.rules.map(r => r.id)
const i = ids.indexOf(id)
const j = i + delta
if (i < 0 || j < 0 || j >= ids.length) return
const next = ids.slice()
next.splice(j, 0, next.splice(i, 1)[0])
reorderRules(next)
}
// Kontextmenue fuer eine Regel — analog Ausschnitte/Ebenen.
const ruleCtxItems = (ruleId) => {
const rule = (state.rules || []).find(r => r.id === ruleId)
if (!rule) return []
const i = state.rules.findIndex(r => r.id === ruleId)
const enabled = rule.enabled !== false
return [
{ label: enabled ? 'Deaktivieren' : 'Aktivieren',
icon: enabled ? 'visibility_off' : 'visibility',
onClick: () => updateRule(ruleId, { ...rule, enabled: !enabled }) },
{ divider: true },
{ label: 'Prio hoeher (nach oben)',
icon: 'arrow_upward', disabled: i <= 0,
onClick: () => onMove(ruleId, -1) },
{ label: 'Prio tiefer (nach unten)',
icon: 'arrow_downward', disabled: i >= state.rules.length - 1,
onClick: () => onMove(ruleId, +1) },
{ divider: true },
{ label: 'Duplizieren',
icon: 'content_copy',
onClick: () => duplicateRule(ruleId) },
{ divider: true },
{ label: 'Loeschen',
icon: 'delete', danger: true,
onClick: () => {
if (window.confirm(`Regel "${rule.name || '(ohne Name)'}" loeschen?`)) deleteRule(ruleId)
} },
]
}
return (
<div style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
padding: 10, gap: 10,
fontFamily: 'var(--font)', color: 'var(--text-primary)',
background: 'var(--bg-base)',
boxSizing: 'border-box',
}}>
{/* Header — globaler Toggle + Refresh + FAB neue Regel */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
onClick={() => setOverridesEnabled(!state.enabled)}
className={state.enabled ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1 }}
title="Overrides global an/aus"
>
<Icon name={state.enabled ? 'visibility' : 'visibility_off'} size={14} />
<span>{state.enabled ? 'Overrides AN' : 'Overrides AUS'}</span>
</button>
<button onClick={reapplyOverrides} className="btn-icon-tonal" title="Regeln neu anwenden">
<Icon name="refresh" size={14} />
</button>
<button
onClick={() => addRule({})}
className="btn-add"
title="Neue Regel oben einfügen (höchste Priorität)"
>
<Icon name="add" size={16} />
</button>
</div>
{/* Override-Kombinationen — Dropdown plus kontextabhaengiger Save. */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 6,
padding: 10,
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
}}>
<span style={labelXs}>Override-Kombinationen</span>
<select
value={selectedPreset}
onChange={(e) => {
const v = e.target.value
setSelectedPreset(v)
if (v) {
loadPreset(v, 'replace')
} else {
// "— neu / keine —" → Editor wirklich leeren, sonst bleiben
// die Regeln der vorigen Kombination stehen und der User
// baut versehentlich auf altem Stand weiter.
clearOverrideRules()
}
}}
style={{ width: '100%' }}
title="Kombination zum Bearbeiten oeffnen"
>
<option value=""> neu / keine </option>
{(state.presets || []).map(p => (
<option key={p.name} value={p.name}>
{p.name} ({p.ruleCount})
</option>
))}
</select>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => {
if (selectedPreset) { savePreset(selectedPreset); return }
const existing = (state.presets || []).map(p => p.name)
const def = `Kombination ${existing.length + 1}`
const name = window.prompt('Name fuer neue Kombination:', def)
if (!name || !name.trim()) return
const t = name.trim()
if (existing.includes(t) && !window.confirm(`Kombination "${t}" ueberschreiben?`)) return
savePreset(t)
setSelectedPreset(t)
}}
disabled={state.rules.length === 0}
className="btn-outlined"
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
title={selectedPreset
? `Aenderungen in "${selectedPreset}" speichern`
: 'Aktuelle Regeln als neue Kombination speichern'}
>
<Icon name="save" size={14} />
<span>{selectedPreset ? 'Speichern' : 'Als Kombination speichern…'}</span>
</button>
<button
onClick={() => {
if (!selectedPreset) return
if (!window.confirm(`Kombination "${selectedPreset}" dauerhaft loeschen?`)) return
deletePreset(selectedPreset)
setSelectedPreset('')
}}
disabled={!selectedPreset}
className="btn-icon-danger"
title="Gewaehlte Kombination dauerhaft loeschen"
>
<Icon name="delete" size={14} />
</button>
</div>
</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', fontStyle: 'italic', lineHeight: 1.4 }}>
Regeln sind additiv. Bei Konflikt gewinnt die <b>oberste</b>.
</div>
{/* Rule list */}
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', minHeight: 0 }}>
{(state.rules || []).length === 0 && (
<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="auto_fix_high" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
<div style={{ marginTop: 8 }}>Noch keine Regeln.</div>
<div style={{ marginTop: 4, fontSize: 10 }}>
Oben <Icon name="add" size={11} /> klicken um eine neue Regel zu erstellen.
</div>
</div>
)}
{(state.rules || []).map((r, i) => (
<RuleCard
key={r.id}
rule={r}
index={i}
total={state.rules.length}
layers={state.layers}
linetypes={state.linetypes}
hatchPatterns={state.hatchPatterns}
onPatch={onPatch}
onDelete={() => deleteRule(r.id)}
onDuplicate={() => duplicateRule(r.id)}
onMoveUp={() => onMove(r.id, -1)}
onMoveDown={() => onMove(r.id, +1)}
onContextMenu={(ev) => setCtxMenu({ x: ev.clientX, y: ev.clientY, ruleId: r.id })}
/>
))}
</div>
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={ruleCtxItems(ctxMenu.ruleId)}
onClose={() => setCtxMenu(null)}
/>
)}
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect } from 'react'
import Icon from './components/Icon'
import { notifyReady, runRhinoCommand } from './lib/rhinoBridge'
// Tool-Definitionen: [icon, label, rhino-command, tooltip]
// Material-Symbol-Namen siehe https://fonts.google.com/icons
const TOOLS = {
'2D Zeichnen': [
['horizontal_rule', 'Line', '_Line', 'Linie zwischen zwei Punkten'],
['polyline', 'Polyline', '_Polyline', 'Polylinie'],
['rectangle', 'Rect', '_Rectangle', 'Rechteck'],
['radio_button_unchecked', 'Circle', '_Circle', 'Kreis'],
['network_intelligence', 'Arc', '_Arc', 'Bogen'],
['gesture', 'Curve', '_Curve', 'Freie Kurve (Spline)'],
['text_fields', 'Text', '_Text', 'Text'],
['grid_view', 'Hatch', '_Hatch', 'Schraffur'],
['straighten', 'Dim', '_Dim', 'Linearbemassung'],
],
'2D Editieren': [
['open_with', 'Move', '_Move', 'Verschieben'],
['content_copy', 'Copy', '_Copy', 'Kopieren'],
['rotate_right', 'Rotate', '_Rotate', 'Drehen'],
['aspect_ratio', 'Scale', '_Scale', 'Skalieren'],
['flip', 'Mirror', '_Mirror', 'Spiegeln'],
['padding', 'Offset', '_Offset', 'Parallelversatz'],
['content_cut', 'Trim', '_Trim', 'Stutzen'],
['swipe_right_alt', 'Extend', '_Extend', 'Verlaengern'],
['link', 'Join', '_Join', 'Verbinden'],
['scatter_plot', 'Explode', '_Explode', 'Aufloesen'],
['rounded_corner', 'Fillet', '_Fillet', 'Verrunden (Ecke abrunden)'],
['apps', 'Array', '_ArrayPolar','Polar-Array'],
],
'3D Modellieren': [
['vertical_align_top','Extrude', '_ExtrudeCrv', 'Kurve zu 3D extrudieren'],
['square', 'Box', '_Box', 'Quader'],
['join_inner', 'Union', '_BooleanUnion', 'Boolean-Vereinigung'],
['remove', 'Diff', '_BooleanDifference', 'Boolean-Differenz'],
['gradient', 'Intersect','_BooleanIntersection','Boolean-Schnittmenge'],
['roofing', 'Cap', '_Cap', 'Planare Loecher schliessen'],
['cut', 'Section', '_Section', 'Schnittlinien erzeugen'],
['unfold_more', 'Loft', '_Loft', 'Loft (Kurven verbinden)'],
],
'Auswahl': [
['add_link', 'Chain', '_SelChain', 'Tangentiale Kurvenkette waehlen'],
['filter_alt', 'Dup', '_SelDup', 'Doppelte Objekte waehlen'],
['loop', 'Closed', '_SelClosedCrv', 'Geschlossene Kurven waehlen'],
['compare_arrows', 'Invert', '_Invert', 'Auswahl invertieren'],
['select_all', 'All', '_SelAll', 'Alle auswaehlen'],
['deselect', 'None', '_SelNone', 'Auswahl aufheben'],
],
}
// ---------------------------------------------------------------------------
function ToolButton({ icon, label, cmd, tip }) {
return (
<button
onClick={() => runRhinoCommand(cmd)}
title={`${tip} (${cmd})`}
style={{
width: '100%',
padding: '6px 8px',
display: 'flex', alignItems: 'center', gap: 8,
background: 'var(--bg-item)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', color: 'var(--text-primary)',
cursor: 'pointer', textAlign: 'left',
}}
>
<Icon name={icon} size={16} style={{ flexShrink: 0 }} />
<span style={{ fontSize: 11, fontWeight: 500 }}>{label}</span>
</button>
)
}
function GroupLabel({ children }) {
return (
<div style={{
fontSize: 9, color: 'var(--text-muted)', textTransform: 'uppercase',
letterSpacing: '0.05em',
padding: '8px 4px 4px',
borderTop: '1px solid var(--border)',
}}>{children}</div>
)
}
// ---------------------------------------------------------------------------
export default function WerkzeugeApp() {
useEffect(() => { notifyReady() }, [])
const groups = Object.entries(TOOLS)
return (
<div style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column', gap: 0,
padding: 6,
fontFamily: 'var(--font)', color: 'var(--text-primary)',
background: 'var(--bg-base)',
boxSizing: 'border-box',
overflowY: 'auto', overflowX: 'hidden',
}}>
{groups.map(([title, items], gi) => (
<div key={title} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<GroupLabel>{title}</GroupLabel>
{items.map(([icon, label, cmd, tip]) => (
<ToolButton key={cmd} icon={icon} label={label} cmd={cmd} tip={tip} />
))}
</div>
))}
</div>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+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>
)
}
+33
View File
@@ -0,0 +1,33 @@
export default function BottomBar({ onApply, dirty }) {
return (
<div style={{
flexShrink: 0,
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
padding: '10px 14px',
}}>
<button
onClick={onApply}
disabled={!dirty}
style={{
width: '100%',
padding: '8px 0',
borderRadius: 'var(--r)',
fontWeight: 600,
fontSize: 11,
letterSpacing: '0.07em',
textTransform: 'uppercase',
background: dirty ? 'var(--accent)' : 'var(--bg-item)',
color: dirty ? '#fff' : 'var(--text-muted)',
border: dirty ? 'none' : '1px solid var(--border)',
cursor: dirty ? 'pointer' : 'default',
transition: 'background 0.18s, color 0.18s',
}}
onMouseEnter={e => { if (dirty) e.currentTarget.style.background = 'var(--accent-light)' }}
onMouseLeave={e => { if (dirty) e.currentTarget.style.background = dirty ? 'var(--accent)' : 'var(--bg-item)' }}
>
{dirty ? 'Auf Rhino anwenden' : 'Keine Änderungen'}
</button>
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { useState } from 'react'
import Icon from './Icon'
export default function ConfirmDeleteEbene({ ebene, otherEbenen, onConfirm, onCancel }) {
const [target, setTarget] = useState(otherEbenen[0]?.code ?? '_delete')
const isDelete = target === '_delete'
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'var(--bg-overlay)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 16,
}}>
<div style={{
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
boxShadow: 'var(--shadow-3)',
width: 320, maxWidth: '100%',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
<div style={{ padding: '16px 18px 6px', display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: '50%',
background: 'var(--warn-dim)', color: 'var(--warn)',
flexShrink: 0,
}}>
<Icon name="warning" size={18} />
</span>
<div>
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>
Ebene löschen
</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
<b>{ebene.code}_{ebene.name}</b> wird in allen Zeichnungsebenen entfernt.
</div>
</div>
</div>
<div style={{ padding: '10px 18px 14px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<span className="label-xs">Inhalte auf der Ebene</span>
<select value={target} onChange={ev => setTarget(ev.target.value)}>
{otherEbenen.map(e => (
<option key={e.code} value={e.code}> Verschieben nach {e.code}_{e.name}</option>
))}
<option value="_delete"> Inhalte ebenfalls löschen</option>
</select>
</div>
<div style={{
display: 'flex', gap: 4, padding: '10px 14px',
justifyContent: 'flex-end',
borderTop: '1px solid var(--border-light)',
background: 'var(--bg-section)',
}}>
<button className="btn-text" onClick={onCancel}>Abbrechen</button>
<button
className="btn-contained"
style={isDelete ? { background: 'var(--danger)' } : undefined}
onClick={() => onConfirm(isDelete ? null : target)}
>
Löschen
</button>
</div>
</div>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react'
import Icon from './Icon'
export default function ContextMenu({ x, y, items, onClose }) {
const ref = useRef(null)
const [pos, setPos] = useState({ left: x, top: y })
// Falls Menue rechts/unten ueberlaufen wuerde, links/oben verschieben
useEffect(() => {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
let left = x, top = y
if (left + rect.width > vw - 4) left = vw - rect.width - 4
if (top + rect.height > vh - 4) top = vh - rect.height - 4
if (left < 4) left = 4
if (top < 4) top = 4
setPos({ left, top })
}, [x, y])
useEffect(() => {
const handleClick = (ev) => {
if (ref.current && !ref.current.contains(ev.target)) onClose()
}
const handleKey = (ev) => { if (ev.key === 'Escape') onClose() }
const handleContext = (ev) => { ev.preventDefault(); onClose() }
const t = setTimeout(() => {
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleKey)
document.addEventListener('contextmenu', handleContext)
}, 0)
return () => {
clearTimeout(t)
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleKey)
document.removeEventListener('contextmenu', handleContext)
}
}, [onClose])
return (
<div
ref={ref}
style={{
position: 'fixed',
top: pos.top, left: pos.left,
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 'var(--r)',
boxShadow: 'var(--shadow-3)',
padding: '4px 0',
minWidth: 180,
zIndex: 300,
}}
>
{items.map((it, i) => (
it.divider ? (
<div key={i} style={{ height: 1, background: 'var(--border-light)', margin: '4px 0' }} />
) : (
<button
key={i}
disabled={it.disabled}
onClick={() => { if (!it.disabled) { it.onClick(); onClose() } }}
onMouseEnter={(ev) => { if (!it.disabled) ev.currentTarget.style.background = 'var(--overlay-hover)' }}
onMouseLeave={(ev) => { ev.currentTarget.style.background = 'transparent' }}
style={{
width: '100%', textAlign: 'left',
padding: '6px 14px', fontSize: 11,
color: it.disabled ? 'var(--text-muted)'
: it.danger ? 'var(--danger)'
: 'var(--text-primary)',
display: 'flex', alignItems: 'center', gap: 10,
borderRadius: 0,
cursor: it.disabled ? 'default' : 'pointer',
background: 'transparent',
}}
>
{it.icon
? <Icon name={it.icon} size={14} style={{ color: it.disabled ? 'var(--text-muted)' : it.danger ? 'var(--danger)' : 'var(--text-secondary)' }} />
: <span style={{ width: 14 }} />}
<span style={{ flex: 1 }}>{it.label}</span>
{it.shortcut && <span style={{ fontSize: 9, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>{it.shortcut}</span>}
</button>
)
))}
</div>
)
}
+587
View File
@@ -0,0 +1,587 @@
import { useState, useRef, useMemo, useEffect } from 'react'
import Section from './Section'
import Icon from './Icon'
import ConfirmDeleteEbene from './ConfirmDeleteEbene'
import ContextMenu from './ContextMenu'
import EbenenSettingsDialog from './EbenenSettingsDialog'
import { setLayerStyle, deleteEbene, moveSelectionToEbene } from '../lib/rhinoBridge'
const MODES = [
{ value: 'all', label: 'Alle anzeigen' },
{ value: 'active', label: 'Nur aktive' },
{ value: 'grey', label: 'Andere grau' },
{ value: 'grey_locked', label: 'Andere grau & gesperrt' },
]
const LW_PRESETS = [0.02, 0.10, 0.13, 0.18, 0.25, 0.35, 0.50, 0.70, 1.00]
function ColorPicker({ color, onChange }) {
const ref = useRef(null)
return (
<>
<div
onClick={(ev) => { ev.stopPropagation(); ref.current?.click() }}
title="Farbe ändern"
style={{
width: 12, height: 12, borderRadius: 2,
background: color, flexShrink: 0,
border: '1px solid var(--border)',
cursor: 'pointer',
}}
/>
<input
ref={ref}
type="color"
value={color}
onChange={(ev) => onChange(ev.target.value)}
onClick={(ev) => ev.stopPropagation()}
style={{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }}
/>
</>
)
}
function LwCell({ lw, onChange }) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(lw.toFixed(2))
const commit = () => {
const v = parseFloat(value)
if (!isNaN(v) && v > 0) onChange(v)
else setValue(lw.toFixed(2))
setEditing(false)
}
const step = (dir) => {
let next
if (dir > 0) next = LW_PRESETS.find(p => p > lw + 0.001)
else next = [...LW_PRESETS].reverse().find(p => p < lw - 0.001)
if (next !== undefined) onChange(next)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 2, width: 42, justifyContent: 'flex-end', flexShrink: 0 }}>
{editing ? (
<input
type="number" step="0.01" min="0.01" max="2.0"
value={value}
autoFocus
onChange={(ev) => setValue(ev.target.value)}
onBlur={commit}
onKeyDown={(ev) => { if (ev.key === 'Enter') commit(); if (ev.key === 'Escape') { setValue(lw.toFixed(2)); setEditing(false) } }}
onClick={(ev) => ev.stopPropagation()}
style={{ width: 28, fontSize: 9, padding: '1px 2px', textAlign: 'right' }}
/>
) : (
<span
onDoubleClick={(ev) => { ev.stopPropagation(); setEditing(true); setValue(lw.toFixed(2)) }}
title="Doppelklick zum Bearbeiten"
style={{ fontSize: 9, fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', cursor: 'text', minWidth: 22, textAlign: 'right' }}
>{lw.toFixed(2)}</span>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(+1) }} style={{ width: 10, height: 7 }}>
<Icon name="arrow_drop_up" size={10} />
</button>
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(-1) }} style={{ width: 10, height: 7 }}>
<Icon name="arrow_drop_down" size={10} />
</button>
</div>
</div>
)
}
function EditableText({ value, onCommit, style, fontWeight, fontSize, autoEditTrigger }) {
const [editing, setEditing] = useState(false)
const [val, setVal] = useState(value)
const lastTrigger = useRef(null)
const inputRef = useRef(null)
// External trigger -> Edit-Modus aktivieren
useEffect(() => {
if (autoEditTrigger && autoEditTrigger !== lastTrigger.current) {
lastTrigger.current = autoEditTrigger
setVal(value)
setEditing(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoEditTrigger])
// Beim Edit-Start: Inhalt selektieren
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus()
try { inputRef.current.select() } catch (e) {}
}
}, [editing])
const commit = () => {
const trimmed = (val ?? '').trim()
if (trimmed && trimmed !== value) onCommit(trimmed)
else setVal(value)
setEditing(false)
}
if (editing) {
return (
<input
ref={inputRef}
value={val}
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, fontWeight, fontSize, padding: '1px 4px' }}
/>
)
}
return (
<span
onDoubleClick={(ev) => { ev.stopPropagation(); setVal(value); setEditing(true) }}
title="Doppelklick zum Umbenennen"
style={{ ...style, fontWeight, fontSize, cursor: 'text' }}
>{value}</span>
)
}
function EbeneRow({ e, active, mode, onClick, onContextMenu, onToggleVisible, onToggleLock, onColorChange, onLwChange, onNameChange, onCodeChange, onDelete, autoEditCode, autoEditName, rowRef }) {
// Auge zeigt den Eye-State (User-Intention) — auch fuer die aktive Ebene.
// So sieht man auf einen Blick ob sie "normalerweise" sichtbar waere.
// Aktive Ebene rendert Rhino zwar immer sichtbar, das visible-Flag bleibt
// aber die gespeicherte Intention bis sie wieder de-aktiviert wird.
const eyeShown = mode !== 'active'
return (
<div
ref={rowRef}
data-ebene-code={e.code}
onClick={onClick}
onContextMenu={onContextMenu}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '3px 12px',
margin: active ? '1px 6px' : '0',
background: active ? 'var(--active-dim)'
: (e.visible !== false) ? 'var(--bg-item)'
: 'var(--bg-panel)',
// Pill-Form fuer die aktive Ebene, sonst Standard-Zeile mit Bottom-Border
borderRadius: active ? 999 : 0,
borderLeft: active ? 'none' : '3px solid transparent',
borderBottom: active ? 'none' : '1px solid var(--border-light)',
boxShadow: active ? 'inset 0 0 0 1px var(--active-light)' : 'none',
opacity: (!active && e.visible === false && mode !== 'all') ? 0.45 : 1,
cursor: 'pointer',
userSelect: 'none',
}}
>
{eyeShown ? (
<button
className={`btn-icon-sm ${e.visible !== false ? 'is-on' : ''}`}
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
title={
active
? (e.visible !== false
? 'Normalerweise sichtbar (aktive Ebene wird trotzdem gezeigt)'
: 'Normalerweise ausgeblendet — wird nur sichtbar weil aktiv')
: (e.visible !== false ? 'Ausblenden' : 'Einblenden')
}
><Icon name={e.visible !== false ? 'visibility' : 'visibility_off'} size={14} /></button>
) : (
<span style={{ width: 18, flexShrink: 0 }} />
)}
<EditableText
value={e.code}
onCommit={onCodeChange}
autoEditTrigger={autoEditCode}
fontSize={9}
style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', width: 24, textAlign: 'left', flexShrink: 0 }}
/>
<ColorPicker color={e.color} onChange={onColorChange} />
<div style={{ flex: 1, minWidth: 0 }}>
<EditableText
value={e.name}
onCommit={onNameChange}
autoEditTrigger={autoEditName}
fontWeight={active ? 700 : 400}
fontSize={11}
style={{
color: active ? 'var(--active-light)'
: (e.visible !== false) ? 'var(--text-label)'
: 'var(--text-muted)',
display: 'inline-block', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
/>
</div>
<LwCell lw={e.lw} onChange={onLwChange} />
<button
className="btn-icon-xs"
onClick={(ev) => { ev.stopPropagation(); onToggleLock() }}
title={e.locked ? 'Entsperren' : 'Sperren'}
style={{ color: e.locked ? 'var(--warn)' : undefined }}
><Icon name={e.locked ? 'lock' : 'lock_open'} size={12} /></button>
<button
className="btn-icon-xs"
onClick={(ev) => { ev.stopPropagation(); onDelete() }}
title="Löschen"
><Icon name="close" size={12} /></button>
</div>
)
}
function SortHeader({ label, sortKey, sortBy, sortDir, onSort, style }) {
const active = sortBy === sortKey
return (
<span
className="label-xs"
onClick={() => onSort(sortKey)}
style={{
cursor: 'pointer', userSelect: 'none',
display: 'inline-flex', alignItems: 'center', gap: 2,
color: active ? 'var(--text-secondary)' : undefined, ...style,
}}
>
{label}
{active && <Icon name={sortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'} size={11} />}
</span>
)
}
export default function EbenenManager({
ebenen, activeCode, onActiveChange, onChange, mode, onModeChange, hatchPatterns,
combinations = [], activeCombName = null,
onPickCombination, onSaveCurrentCombination, onDeleteCombination,
onEditCombinations, onUserVisibilityChange,
}) {
const [sortBy, setSortBy] = useState('code')
const [sortDir, setSortDir] = useState('asc')
const [deleteTarget, setDeleteTarget] = useState(null)
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, code }
const [clipboard, setClipboard] = useState(null) // { color, lw }
const [autoEdit, setAutoEdit] = useState(null) // { code, field, token }
const [settingsCode, setSettingsCode] = useState(null) // code der Ebene deren Einstellungen offen sind
// Bei Auto-Edit: in View scrollen
useEffect(() => {
if (!autoEdit) return
const el = document.querySelector(`[data-ebene-code="${autoEdit.code}"]`)
if (el && el.scrollIntoView) {
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
}, [autoEdit])
const toggleSort = (key) => {
if (sortBy === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortBy(key); setSortDir('asc') }
}
const sortedEbenen = useMemo(() => {
const arr = [...ebenen]
arr.sort((a, b) => {
let cmp = 0
if (sortBy === 'code') {
const ca = parseInt(a.code, 10), cb = parseInt(b.code, 10)
cmp = (!isNaN(ca) && !isNaN(cb)) ? ca - cb : a.code.localeCompare(b.code)
} else if (sortBy === 'name') {
cmp = a.name.localeCompare(b.name)
} else if (sortBy === 'lw') {
cmp = a.lw - b.lw
}
return sortDir === 'desc' ? -cmp : cmp
})
return arr
}, [ebenen, sortBy, sortDir])
const updateByCode = (code, patch) => {
onChange(ebenen.map(e => e.code === code ? { ...e, ...patch } : e))
}
const handleToggleVisible = (code) => {
const cur = ebenen.find(e => e.code === code)
if (cur) updateByCode(code, { visible: !(cur.visible !== false) })
if (onUserVisibilityChange) onUserVisibilityChange()
}
const handleToggleLock = (code) => {
const cur = ebenen.find(e => e.code === code)
if (cur) updateByCode(code, { locked: !cur.locked })
if (onUserVisibilityChange) onUserVisibilityChange()
}
const handleColorChange = (code, color) => {
updateByCode(code, { color })
setLayerStyle(code, color, undefined)
}
const handleLwChange = (code, lw) => {
updateByCode(code, { lw })
setLayerStyle(code, undefined, lw)
}
const handleNameChange = (code, name) => {
updateByCode(code, { name })
// Wenn wir gerade neue Ebene anlegen (Phase 'name') — fertig
if (autoEdit && autoEdit.code === code && autoEdit.field === 'name') {
setAutoEdit(null)
}
}
const handleCodeChange = (oldCode, newCode) => {
if (ebenen.some(e => e.code === newCode && e.code !== oldCode)) return
onChange(ebenen.map(e => e.code === oldCode ? { ...e, code: newCode } : e))
// Phase weiterschalten: Code -> Name
if (autoEdit && autoEdit.code === oldCode && autoEdit.field === 'code') {
setAutoEdit({ code: newCode, field: 'name', token: Date.now() })
}
}
const handleDelete = (code) => {
if (ebenen.length <= 1) return
setDeleteTarget(code)
}
const confirmDelete = (moveToCode) => {
const code = deleteTarget
deleteEbene(code, moveToCode)
onChange(ebenen.filter(e => e.code !== code))
if (activeCode === code) {
const next = ebenen.find(e => e.code !== code)
if (next) onActiveChange(next.code)
}
setDeleteTarget(null)
}
const nextFreeCode = () => {
const codes = ebenen.map(e => parseInt(e.code, 10)).filter(n => !isNaN(n))
const next = codes.length ? Math.max(...codes) + 1 : 50
return String(next).padStart(2, '0')
}
const addNew = () => {
const code = nextFreeCode()
onChange([...ebenen, {
code, name: 'NEU',
color: '#888888', lw: 0.18, visible: true, locked: false,
}])
// Code-Feld der neuen Ebene fokussieren
setAutoEdit({ code, field: 'code', token: Date.now() })
}
const duplicateEbene = (code) => {
const src = ebenen.find(e => e.code === code)
if (!src) return
onChange([...ebenen, {
...src, code: nextFreeCode(), name: src.name + ' KOPIE',
}])
}
const copyProps = (code) => {
const e = ebenen.find(x => x.code === code)
if (e) setClipboard({ color: e.color, lw: e.lw })
}
const pasteProps = (code) => {
if (!clipboard) return
onChange(ebenen.map(e => e.code === code ? { ...e, ...clipboard } : e))
setLayerStyle(code, clipboard.color, clipboard.lw)
}
const openContextMenu = (ev, code) => {
ev.preventDefault()
ev.stopPropagation()
setCtxMenu({ x: ev.clientX, y: ev.clientY, code })
}
const ctxItems = (code) => [
{ label: 'Ebeneneinstellungen…', icon: 'settings', onClick: () => setSettingsCode(code) },
{ divider: true },
{ label: 'Selektion hierher übertragen', icon: 'move_down', onClick: () => moveSelectionToEbene(code) },
{ divider: true },
{ label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicateEbene(code) },
{ label: 'Eigenschaften kopieren', icon: 'colorize', onClick: () => copyProps(code) },
{ label: 'Eigenschaften einfügen', icon: 'format_paint', onClick: () => pasteProps(code), disabled: !clipboard },
{ divider: true },
{ label: 'Löschen', icon: 'delete', onClick: () => handleDelete(code), danger: true,
disabled: ebenen.length <= 1 },
]
const actionBtns = (
<button className="btn-add" onClick={addNew} title="Ebene hinzufügen">
<Icon name="add" size={16} />
</button>
)
return (
<Section title="Ebenen" badge={ebenen.length} action={actionBtns}>
{/* Ebenenkombinationen — Label + Dropdown + Save-As-Plus */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border-light)',
}}>
<span className="label-xs">Ebenenkombination</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<select
value={activeCombName || '__custom__'}
onChange={(ev) => {
const v = ev.target.value
if (v === '__custom__') return
if (v === '__delete__') {
if (activeCombName && onDeleteCombination) onDeleteCombination(activeCombName)
return
}
if (onPickCombination) onPickCombination(v)
}}
style={{ flex: 1, minWidth: 0 }}
title={activeCombName ? `Aktiv: ${activeCombName}` : 'Eigene Sichtbarkeit (keine Kombination)'}
>
<option value="__custom__">{activeCombName ? activeCombName : 'Eigene'}</option>
{combinations.length > 0 && <option disabled></option>}
{combinations.map(p => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
{activeCombName && combinations.some(p => p.name === activeCombName) && (
<>
<option disabled></option>
<option value="__delete__">🗑 Aktuelle löschen</option>
</>
)}
</select>
<button
className="btn-icon-sm"
onClick={() => onSaveCurrentCombination && onSaveCurrentCombination()}
title="Aktuelle Sichtbarkeit als neue Kombination speichern"
>
<Icon name="add" size={14} />
</button>
<button
className="btn-icon-sm"
onClick={() => onEditCombinations && onEditCombinations()}
title="Alle Kombinationen bearbeiten (Dialog)"
>
<Icon name="edit" size={13} />
</button>
</div>
</div>
<div style={{
display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border-light)',
}}>
<span className="label-xs">Sichtbarkeit</span>
<select value={mode} onChange={ev => onModeChange(ev.target.value)} style={{ width: '100%' }}>
{MODES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '2px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border)',
}}>
{/* Master-Eye: alle Ebenen sichtbar/unsichtbar */}
<button
className="btn-icon-xs"
onClick={() => {
const anyVisible = ebenen.some(e => e.visible !== false)
// Wenn irgendeine sichtbar -> alle aus. Wenn keine sichtbar -> alle an.
onChange(ebenen.map(e => ({ ...e, visible: !anyVisible })))
if (onUserVisibilityChange) onUserVisibilityChange()
}}
title={ebenen.every(e => e.visible !== false)
? 'Alle Ebenen ausblenden'
: 'Alle Ebenen einblenden'}
style={{ width: 18, height: 18 }}
>
<Icon
name={ebenen.every(e => e.visible !== false) ? 'visibility' : 'visibility_off'}
size={12}
/>
</button>
<SortHeader label="Cd" sortKey="code" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} style={{ width: 24 }} />
<div style={{ width: 12 }} />
<SortHeader label="Name" sortKey="name" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} style={{ flex: 1 }} />
<SortHeader label="Lw" sortKey="lw" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} style={{ width: 42, textAlign: 'right', display: 'block' }} />
{/* Master-Lock: alle Ebenen sperren/entsperren */}
<button
className="btn-icon-xs"
onClick={() => {
const anyLocked = ebenen.some(e => e.locked === true)
onChange(ebenen.map(e => ({ ...e, locked: !anyLocked })))
if (onUserVisibilityChange) onUserVisibilityChange()
}}
title={ebenen.every(e => e.locked === true)
? 'Alle Ebenen entsperren'
: 'Alle Ebenen sperren'}
style={{ width: 18, height: 18 }}
>
<Icon
name={ebenen.every(e => e.locked === true) ? 'lock' : 'lock_open'}
size={11}
/>
</button>
<div style={{ width: 18 }} />
</div>
{sortedEbenen.map(e => (
<EbeneRow
key={e.code}
e={e}
active={e.code === activeCode}
mode={mode}
onClick={() => onActiveChange(e.code)}
onContextMenu={(ev) => openContextMenu(ev, e.code)}
onToggleVisible={() => handleToggleVisible(e.code)}
onToggleLock={() => handleToggleLock(e.code)}
onColorChange={(c) => handleColorChange(e.code, c)}
onLwChange={(lw) => handleLwChange(e.code, lw)}
onNameChange={(n) => handleNameChange(e.code, n)}
onCodeChange={(c) => handleCodeChange(e.code, c)}
onDelete={() => handleDelete(e.code)}
autoEditCode={autoEdit && autoEdit.code === e.code && autoEdit.field === 'code' ? autoEdit.token : null}
autoEditName={autoEdit && autoEdit.code === e.code && autoEdit.field === 'name' ? autoEdit.token : null}
/>
))}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x} y={ctxMenu.y}
items={ctxItems(ctxMenu.code)}
onClose={() => setCtxMenu(null)}
/>
)}
{deleteTarget && (
<ConfirmDeleteEbene
ebene={ebenen.find(e => e.code === deleteTarget)}
otherEbenen={ebenen.filter(e => e.code !== deleteTarget)}
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
)}
{settingsCode && (() => {
const target = ebenen.find(e => e.code === settingsCode)
if (!target) { setSettingsCode(null); return null }
return (
<EbenenSettingsDialog
ebene={target}
hatchPatterns={hatchPatterns}
onSave={(updated) => {
// Code-Wechsel handhaben (eindeutiger key)
onChange(ebenen.map(e => e.code === settingsCode ? updated : e))
setSettingsCode(null)
}}
onClose={() => setSettingsCode(null)}
/>
)
})()}
</Section>
)
}
+252
View File
@@ -0,0 +1,252 @@
import { useState } from 'react'
import Icon from './Icon'
function Field({ label, hint, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '5px 0' }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', fontWeight: 500, letterSpacing: 0.2 }}>
{label}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>{children}</div>
{hint && (
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4 }}>{hint}</span>
)}
</div>
)
}
function SectionLabel({ children }) {
return (
<div style={{
fontSize: 9, color: 'var(--text-muted)', fontWeight: 600,
letterSpacing: 0.5, textTransform: 'uppercase',
padding: '8px 0 2px',
borderTop: '1px solid var(--border-light)',
marginTop: 6,
}}>{children}</div>
)
}
export default function EbenenSettingsDialog({ ebene, hatchPatterns = ['Solid'], onSave, onClose }) {
const [draft, setDraft] = useState({
...ebene,
fill: {
pattern: 'None',
source: 'layer',
color: null,
scale: 1.0,
rotation: 0,
lw: null, // Stiftstaerke der Hatch in mm; null = wie Stift der Ebene
...(ebene.fill || {}),
},
})
const set = (patch) => setDraft({ ...draft, ...patch })
const setFill = (patch) => setDraft({ ...draft, fill: { ...draft.fill, ...patch } })
const fill = draft.fill
const isFilled = fill.pattern !== 'None'
const isPattern = isFilled && fill.pattern !== 'Solid'
const fillFromLayer = fill.source === 'layer'
const previewColor = (fillFromLayer || !fill.color) ? draft.color : fill.color
// Pattern-Optionen: None + Solid + Patterns
const patternOptions = [
'None', 'Solid',
...hatchPatterns.filter(p => p !== 'Solid' && p !== 'None'),
]
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% - 16px)', maxWidth: 300,
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
maxHeight: 'calc(100vh - 60px)',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 12px',
borderBottom: '1px solid var(--border)',
}}>
<Icon name="settings" size={14} style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
<span style={{
flex: 1, fontWeight: 600, fontSize: 11,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{ebene.code} {ebene.name}
</span>
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px', lineHeight: 1 }}>×</button>
</div>
{/* Body */}
<div style={{ padding: '6px 12px 4px', overflowY: 'auto' }}>
<Field label="CODE">
<input
value={draft.code}
onChange={(ev) => set({ code: ev.target.value })}
maxLength={4}
style={{ flex: 1, fontSize: 11, fontFamily: 'var(--font-mono)', fontWeight: 600, minWidth: 0 }}
/>
</Field>
<Field label="NAME">
<input
value={draft.name}
onChange={(ev) => set({ name: ev.target.value })}
style={{ flex: 1, fontSize: 11, fontWeight: 600, minWidth: 0 }}
/>
</Field>
<Field label="FARBE (Stift)">
<input
type="color"
value={draft.color}
onChange={(ev) => set({ color: ev.target.value })}
style={{
width: 32, height: 22, padding: 0,
border: '1px solid var(--border)', borderRadius: 'var(--r)',
cursor: 'pointer', background: 'transparent',
}}
/>
<input
value={draft.color}
onChange={(ev) => set({ color: ev.target.value })}
style={{ flex: 1, fontSize: 10, fontFamily: 'var(--font-mono)', minWidth: 0 }}
/>
</Field>
<Field label="STRICHSTÄRKE (mm)">
<input
type="number" step="0.01" min="0.01" max="2.0"
value={draft.lw}
onChange={(ev) => set({ lw: parseFloat(ev.target.value) || draft.lw })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
<SectionLabel>Schraffur Nach Ebene"</SectionLabel>
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4, display: 'block', marginBottom: 4 }}>
Wird auf neue geschlossene Kurven angewandt die auf dieser Ebene gezeichnet werden.
</span>
<Field label="PATTERN">
<select
value={fill.pattern}
onChange={(ev) => setFill({ pattern: ev.target.value })}
style={{ flex: 1, fontSize: 11, minWidth: 0 }}
>
{patternOptions.map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</Field>
{isFilled && (
<>
<Field label="FARBE-QUELLE">
<select
value={fill.source}
onChange={(ev) => setFill({ source: ev.target.value })}
style={{ flex: 1, fontSize: 11, minWidth: 0 }}
>
<option value="layer">Nach Stift (Layerfarbe)</option>
<option value="object">Eigene Farbe</option>
</select>
</Field>
<Field label="VORSCHAU / FARBE">
<input
type="color"
value={previewColor}
disabled={fillFromLayer}
onChange={(ev) => setFill({ color: ev.target.value, source: 'object' })}
style={{
width: 32, height: 22, padding: 0,
border: '1px solid var(--border)', borderRadius: 'var(--r)',
cursor: fillFromLayer ? 'default' : 'pointer',
background: 'transparent',
opacity: fillFromLayer ? 0.6 : 1,
}}
/>
<span style={{ fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text-muted)' }}>
{previewColor}
</span>
</Field>
{isPattern && (
<>
<Field label="SKALIERUNG">
<input
type="number" step="0.05" min="0.001"
value={fill.scale}
onChange={(ev) => setFill({ scale: parseFloat(ev.target.value) || 1.0 })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
<Field label="DREHUNG (°)">
<input
type="number" step="5"
value={fill.rotation}
onChange={(ev) => setFill({ rotation: parseFloat(ev.target.value) || 0 })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
</>
)}
<Field
label="STIFTSTÄRKE (mm)"
hint="Leer = wie Stift der Ebene. Eigener Wert ueberschreibt die Strichstaerke der Hatch-Linien."
>
<input
type="number" step="0.01" min="0"
value={fill.lw == null ? '' : fill.lw}
placeholder=""
onChange={(ev) => {
const v = ev.target.value
if (v === '' || v === null) setFill({ lw: null })
else {
const f = parseFloat(v)
if (!isNaN(f) && f >= 0) setFill({ lw: f })
}
}}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
<button
className="btn-text"
style={{ fontSize: 10, padding: '2px 6px' }}
onClick={() => setFill({ lw: null })}
title="Auf 'wie Stift der Ebene' zuruecksetzen"
>Default</button>
</Field>
</>
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 12px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
}}>
<div style={{ flex: 1 }} />
<button className="btn-text" onClick={onClose}>Abbrechen</button>
<button className="btn-contained" onClick={() => onSave(draft)}>Übernehmen</button>
</div>
</div>
</div>
)
}
+195
View File
@@ -0,0 +1,195 @@
import { useState } from 'react'
import Icon from './Icon'
export default function GeschossDialog({ zeichnungsebenen, recalcOkff, onSave, onClose }) {
const [draft, setDraft] = useState(zeichnungsebenen.map(z => ({ ...z })))
const update = (i, field, val) => {
const next = draft.map((z, idx) => idx === i ? { ...z, [field]: val } : z)
setDraft(recalcOkff(next))
}
const toggleGeschoss = (i) => {
const next = draft.map((z, idx) => {
if (idx !== i) return z
if (z.isGeschoss) {
return { ...z, isGeschoss: false, hoehe: undefined, schnitthoehe: undefined, okff: undefined }
} else {
return { ...z, isGeschoss: true, hoehe: z.hoehe ?? 3.00, schnitthoehe: z.schnitthoehe ?? 1.00 }
}
})
setDraft(recalcOkff(next))
}
const add = (isGeschoss) => {
const n = draft.length
const newZ = isGeschoss
? { id: `z_${Date.now()}`, name: `${n}OG`, isGeschoss: true, hoehe: 3.00, schnitthoehe: 1.00, visible: true }
: { id: `z_${Date.now()}`, name: `Neu ${n + 1}`, isGeschoss: false, visible: true }
setDraft(recalcOkff([...draft, newZ]))
}
const remove = (i) => {
if (draft.length <= 1) return
setDraft(recalcOkff(draft.filter((_, idx) => idx !== i)))
}
const move = (i, dir) => {
const next = [...draft]
const t = i + dir
if (t < 0 || t >= next.length) return
;[next[i], next[t]] = [next[t], next[i]]
setDraft(recalcOkff(next))
}
const gesamthoehe = draft
.filter(z => z.isGeschoss)
.reduce((s, z) => s + (z.hoehe ?? 0), 0)
const col = {
move: { width: 28, flexShrink: 0 },
geschoss:{ width: 24, flexShrink: 0 },
name: { flex: 1, minWidth: 60 },
okff: { width: 50, flexShrink: 0 },
hoehe: { width: 64, flexShrink: 0 },
schnitt: { width: 64, flexShrink: 0 },
del: { width: 22, flexShrink: 0 },
}
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'var(--bg-overlay)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: 40,
}}>
<div style={{
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
width: 'calc(100% - 24px)',
boxShadow: 'var(--shadow)',
display: 'flex', flexDirection: 'column',
maxHeight: 'calc(100vh - 80px)',
overflow: 'hidden',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px', borderBottom: '1px solid var(--border)', flexShrink: 0,
}}>
<span style={{ flex: 1, fontWeight: 600, fontSize: 12, color: 'var(--text-primary)' }}>
Zeichnungsebenen
</span>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
Gebäude {gesamthoehe.toFixed(2)} m
</span>
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px' }}>×</button>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 14px', background: 'var(--bg-section)',
borderBottom: '1px solid var(--border)', flexShrink: 0,
}}>
<div style={col.move} />
<span className="label-xs" style={col.geschoss}>G</span>
<span className="label-xs" style={col.name}>Name</span>
<span className="label-xs" style={col.okff}>OKFF</span>
<span className="label-xs" style={col.hoehe}>Höhe</span>
<span className="label-xs" style={col.schnitt}>Schnitt h</span>
<div style={col.del} />
</div>
<div style={{ overflowY: 'auto', flex: 1 }}>
{draft.map((z, i) => (
<div key={z.id} style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '5px 14px',
borderBottom: '1px solid var(--border-light)',
background: i % 2 === 0 ? 'var(--bg-item)' : 'var(--bg-dialog)',
}}>
<div style={{ ...col.move, display: 'flex', flexDirection: 'column', gap: 0 }}>
<button className="btn-step" onClick={() => move(i, -1)} disabled={i === 0}>
<Icon name="arrow_drop_up" size={14} />
</button>
<button className="btn-step" onClick={() => move(i, 1)} disabled={i === draft.length - 1}>
<Icon name="arrow_drop_down" size={14} />
</button>
</div>
<div style={col.geschoss}>
<input
type="checkbox"
checked={!!z.isGeschoss}
onChange={() => toggleGeschoss(i)}
title="Ist ein Geschoss"
style={{ cursor: 'pointer' }}
/>
</div>
<input
value={z.name}
onChange={ev => update(i, 'name', ev.target.value)}
style={{ ...col.name, fontWeight: 600, fontSize: 11 }}
/>
<div style={{ ...col.okff, color: z.isGeschoss ? 'var(--text-muted)' : 'transparent', fontSize: 11, fontFamily: 'var(--font-mono)', textAlign: 'right' }}>
{z.isGeschoss ? `+${(z.okff ?? 0).toFixed(2)}` : '—'}
</div>
<div style={{ ...col.hoehe, display: 'flex', alignItems: 'center', gap: 2 }}>
{z.isGeschoss ? (
<>
<input type="number" step="0.05" min="0.5" max="20"
value={z.hoehe ?? 3.0}
onChange={ev => update(i, 'hoehe', parseFloat(ev.target.value) || z.hoehe || 3.0)}
style={{ width: 44, textAlign: 'right' }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>m</span>
</>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}></span>
)}
</div>
<div style={{ ...col.schnitt, display: 'flex', alignItems: 'center', gap: 2 }}>
{z.isGeschoss ? (
<>
<input type="number" step="0.05" min="0.1"
value={z.schnitthoehe ?? 1.0}
onChange={ev => update(i, 'schnitthoehe', parseFloat(ev.target.value) || 1.0)}
style={{ width: 44, textAlign: 'right' }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>m</span>
</>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}></span>
)}
</div>
<div style={col.del}>
<button className="btn-icon-sm" onClick={() => remove(i)} title="Löschen">
<Icon name="close" size={14} />
</button>
</div>
</div>
))}
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 14px', borderTop: '1px solid var(--border)',
background: 'var(--bg-section)', flexShrink: 0,
}}>
<button className="btn-outlined" onClick={() => add(true)} style={{
color: 'var(--accent-light)', borderColor: 'var(--accent-border)',
}}>+ Geschoss</button>
<button className="btn-outlined" onClick={() => add(false)}>+ Zeichnung</button>
<div style={{ flex: 1 }} />
<button className="btn-text" onClick={onClose}>Abbrechen</button>
<button className="btn-contained" onClick={() => onSave(draft)}>Übernehmen</button>
</div>
</div>
</div>
)
}
+187
View File
@@ -0,0 +1,187 @@
import { useState } from 'react'
import Section from './Section'
import Icon from './Icon'
import GeschossDialog from './GeschossDialog'
import GeschossSettingsDialog from './GeschossSettingsDialog'
function GeschossBadge({ name }) {
return <span className="chip chip-info">{name}</span>
}
function ZeichnungsebeneRow({ z, active, mode, onClick, onToggleVisible, onSettings }) {
// Eye-State auch fuer die aktive Zeichnungsebene anzeigen (User-Intention)
const eyeShown = mode !== 'active'
const isGeschoss = !!z.isGeschoss
return (
<div
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 12px',
margin: active ? '1px 6px' : '0',
background: active ? 'var(--active-dim)' : 'var(--bg-item)',
// Pill-Form fuer die aktive Zeichnungsebene
borderRadius: active ? 999 : 0,
borderLeft: active ? 'none' : '3px solid transparent',
borderBottom: active ? 'none' : '1px solid var(--border-light)',
boxShadow: active ? 'inset 0 0 0 1px var(--active-light)' : 'none',
cursor: 'pointer',
userSelect: 'none',
opacity: (!active && z.visible === false && mode !== 'all') ? 0.45 : 1,
}}
>
<span style={{
fontWeight: active ? 700 : 500,
fontSize: 12,
color: active ? 'var(--active-light)' : 'var(--text-label)',
flex: 1,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{z.name}</span>
{isGeschoss && (
<span style={{ fontSize: 10, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
+{(z.okff ?? 0).toFixed(2)}
</span>
)}
{isGeschoss && <GeschossBadge name={z.name} />}
{isGeschoss && z.hasClipping && (
<Icon name="content_cut" size={12} style={{ color: 'var(--accent)', flexShrink: 0 }} title="Clipping Plane aktiv" />
)}
{eyeShown ? (
<button
className={`btn-icon-sm ${z.visible !== false ? 'is-on' : ''}`}
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
title={
active
? (z.visible !== false
? 'Normalerweise sichtbar (aktive Zeichnungsebene wird trotzdem gezeigt)'
: 'Normalerweise ausgeblendet — wird nur sichtbar weil aktiv')
: (z.visible !== false ? 'Ausblenden' : 'Einblenden')
}
><Icon name={z.visible !== false ? 'visibility' : 'visibility_off'} size={14} /></button>
) : (
<span style={{ width: 18, flexShrink: 0 }} />
)}
<button
className="btn-icon-xs"
onClick={(ev) => { ev.stopPropagation(); onSettings() }}
title="Einstellungen"
><Icon name="settings" size={12} /></button>
</div>
)
}
const MODES = [
{ value: 'all', label: 'Alle anzeigen' },
{ value: 'active', label: 'Nur aktive' },
{ value: 'grey', label: 'Andere grau' },
{ value: 'grey_locked', label: 'Andere grau & gesperrt' },
]
export default function GeschossManager({
zeichnungsebenen, activeId, onActiveChange, onChange, recalcOkff,
mode, onModeChange,
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [settingsFor, setSettingsFor] = useState(null) // Geschoss-Objekt oder null
const sorted = [...zeichnungsebenen].reverse()
const gesamthoehe = zeichnungsebenen
.filter(z => z.isGeschoss)
.reduce((s, z) => s + (z.hoehe ?? 0), 0)
const addQuick = () => {
const newZ = {
id: `z_${Date.now()}`,
name: `Neu ${zeichnungsebenen.length + 1}`,
isGeschoss: false,
visible: true,
}
onChange([...zeichnungsebenen, newZ])
}
const actions = (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button className="btn-add" onClick={addQuick} title="Zeichnungsebene hinzufügen">
<Icon name="add" size={16} />
</button>
<button className="btn-icon-tonal" onClick={() => setDialogOpen(true)} title="Bearbeiten">
<Icon name="edit" size={14} />
</button>
</div>
)
const toggleVisible = (id) => {
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z))
}
return (
<>
<Section title="Zeichnungsebenen" badge={zeichnungsebenen.length} action={actions}>
<div style={{
display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border-light)',
}}>
<span className="label-xs">Sichtbarkeit</span>
<select value={mode} onChange={ev => onModeChange(ev.target.value)} style={{ width: '100%' }}>
{MODES.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '3px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border-light)',
}}>
<span className="label-xs">Gebäudehöhe</span>
<span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)' }}>
{gesamthoehe.toFixed(2)} m
</span>
</div>
<div>
{sorted.map(z => (
<ZeichnungsebeneRow
key={z.id}
z={z}
active={z.id === activeId}
mode={mode}
onClick={() => onActiveChange(z.id)}
onToggleVisible={() => toggleVisible(z.id)}
onSettings={() => setSettingsFor(z)}
/>
))}
</div>
</Section>
{dialogOpen && (
<GeschossDialog
zeichnungsebenen={zeichnungsebenen}
recalcOkff={recalcOkff}
onSave={(updated) => { onChange(updated); setDialogOpen(false) }}
onClose={() => setDialogOpen(false)}
/>
)}
{settingsFor && (
<GeschossSettingsDialog
geschoss={settingsFor}
onSave={(updated) => {
onChange(zeichnungsebenen.map(z => z.id === updated.id ? updated : z))
setSettingsFor(null)
}}
onClose={() => setSettingsFor(null)}
/>
)}
</>
)
}
+150
View File
@@ -0,0 +1,150 @@
import { useState } from 'react'
import Icon from './Icon'
/** Vertikales Feld-Layout: Label oben, Input darunter — passt in schmale Panels. */
function Field({ label, hint, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '5px 0' }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', fontWeight: 500, letterSpacing: 0.2 }}>
{label}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>{children}</div>
{hint && (
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4 }}>
{hint}
</span>
)}
</div>
)
}
/** Toggle-Reihe: Checkbox + Label inline, Hint darunter wenn vorhanden. */
function Toggle({ label, checked, onChange, hint }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '5px 0' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={checked} onChange={(ev) => onChange(ev.target.checked)} />
<span style={{ fontSize: 11, color: 'var(--text-primary)' }}>{label}</span>
</label>
{hint && (
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4, marginLeft: 22 }}>
{hint}
</span>
)}
</div>
)
}
export default function GeschossSettingsDialog({ geschoss, onSave, onClose }) {
const [draft, setDraft] = useState({ ...geschoss })
const set = (patch) => setDraft({ ...draft, ...patch })
const isG = !!draft.isGeschoss
const hoehe = draft.hoehe ?? 3.0
const schnitt = draft.schnitthoehe ?? 1.0
const hasClip = !!draft.hasClipping
const okff = draft.okff ?? 0
const clipZ = (okff + schnitt).toFixed(2)
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% - 16px)', maxWidth: 280,
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 12px',
borderBottom: '1px solid var(--border)',
}}>
<Icon name="settings" size={14} style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
<span style={{
flex: 1, fontWeight: 600, fontSize: 11,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{geschoss.name}
</span>
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px', lineHeight: 1 }}>×</button>
</div>
{/* Body */}
<div style={{ padding: '6px 12px 4px' }}>
<Field label="NAME">
<input
value={draft.name}
onChange={(ev) => set({ name: ev.target.value })}
style={{ flex: 1, fontSize: 11, fontWeight: 600, minWidth: 0 }}
/>
</Field>
<Toggle
label="Ist Geschoss"
checked={isG}
onChange={(v) => set({ isGeschoss: v })}
hint={isG ? 'Höhe & Clipping verfügbar' : 'reines Zeichenblatt'}
/>
{isG && (
<>
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
<Field label="HÖHE (m)">
<input
type="number" step="0.05" min="0.5" max="30"
value={hoehe}
onChange={(ev) => set({ hoehe: parseFloat(ev.target.value) || hoehe })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
<Field label="SCHNITTHÖHE (m)" hint="über Geschossboden">
<input
type="number" step="0.05" min="0.1"
value={schnitt}
onChange={(ev) => set({ schnitthoehe: parseFloat(ev.target.value) || 1.0 })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
<Toggle
label="Clipping Plane"
checked={hasClip}
onChange={(v) => set({ hasClipping: v })}
hint={
hasClip
? `Horizontaler Schnitt bei +${clipZ}m (OKFF + Schnitthöhe). Sichtbar in der Top-Ansicht wenn dieses Geschoss aktiv ist.`
: 'aus'
}
/>
</>
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 12px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
}}>
<div style={{ flex: 1 }} />
<button className="btn-text" onClick={onClose}>Abbrechen</button>
<button className="btn-contained" onClick={() => onSave(draft)}>Übernehmen</button>
</div>
</div>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
export default function Icon({ name, size = 18, fill = 0, weight = 400, style }) {
return (
<span
className="material-symbols-outlined"
style={{
fontSize: size,
lineHeight: 1,
fontVariationSettings: `'FILL' ${fill}, 'wght' ${weight}, 'opsz' 20`,
...style,
}}
>{name}</span>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react'
import Icon from './Icon'
export default function Section({ title, badge, action, defaultOpen = true, children }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div>
<div
onClick={() => setOpen(o => !o)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px 4px',
cursor: 'pointer',
userSelect: 'none',
}}
>
<span style={{
transform: open ? 'rotate(0deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
color: 'var(--text-muted)',
display: 'inline-flex',
marginLeft: -6,
}}>
<Icon name="arrow_drop_down" size={18} />
</span>
<span style={{
fontSize: 11, fontWeight: 500,
color: 'var(--text-primary)',
letterSpacing: '0.02em',
}}>{title}</span>
{badge != null && <span className="chip" style={{ fontSize: 8 }}>{badge}</span>}
<div style={{ flex: 1, height: 1, background: 'var(--border)', marginLeft: 4 }} />
{action && <div onClick={e => e.stopPropagation()}>{action}</div>}
</div>
{open && children}
</div>
)
}
+426
View File
@@ -0,0 +1,426 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
user-select: none;
font-variation-settings: 'FILL' 0, 'wght' 400, 'opsz' 20;
}
:root {
/* === LIGHT MODE (RAPPORT cream + olive + deep green) === */
--bg-base: #e0dbd4;
--bg-panel: #e0dbd4;
--bg-section: #e0dbd4;
--bg-item: #e0dbd4;
--bg-item-hover: #d4cfc8;
--bg-input: #ece8e2;
--bg-dialog: #e4dfd7;
--bg-overlay: rgba(26, 26, 24, 0.36);
--border: #c8c2ba;
--border-light: #d4cfc8;
--border-focus: #2f5d54;
--text-primary: #1a1a18;
--text-secondary:#555550;
--text-muted: #8a8580;
--text-label: #1a1a18;
/* Petrol-Gruen (von Rapport-HTML uebernommen) */
--accent: #2f5d54;
--accent-light: #4a8a7c;
--accent-dim: #e6efed;
--accent-border: rgba(47, 93, 84, 0.35);
/* Active-Marker (aktives Geschoss / aktive Ebene) — saturiertes Petrol */
--active: #1a655a;
--active-light: #2f8275;
--active-dim: rgba(26, 101, 90, 0.10);
--warn: #b5621e;
--warn-light: #d47a30;
--warn-dim: #fdf0e8;
--danger: #8a1a1a;
--danger-light: #b03030;
--overlay-hover: rgba(26, 26, 24, 0.05);
--overlay-active:rgba(26, 26, 24, 0.10);
--r: 4px;
--r-lg: 8px;
--shadow-1: 0 1px 2px rgba(26,26,24,0.08);
--shadow-2: 0 2px 8px rgba(26,26,24,0.12);
--shadow-3: 0 6px 24px rgba(26,26,24,0.18);
--select-arrow: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M0 0l5 6 5-6z' fill='%23555550'/></svg>");
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'DM Mono', 'SF Mono', monospace;
}
/* === DARK MODE (Rhino-tonig) === */
@media (prefers-color-scheme: dark) {
:root {
--bg-base: #2a2a2a;
--bg-panel: #2a2a2a;
--bg-section: #2a2a2a;
--bg-item: #2a2a2a;
--bg-item-hover: #353535;
--bg-input: #1f1f1f;
--bg-dialog: #2c2c2c;
--bg-overlay: rgba(0,0,0,0.55);
--border: #4a4a4a;
--border-light: #3d3d3d;
--border-focus: #5fa896;
--text-primary: #e8e8e8;
--text-secondary:#a8a8a8;
--text-muted: #707070;
--text-label: #c8c8c8;
/* Petrol-Gruen (von Rapport-HTML uebernommen) */
--accent: #5fa896;
--accent-light: #6db5a4;
--accent-dim: rgba(95, 168, 150, 0.15);
--accent-border: rgba(95, 168, 150, 0.4);
/* Active-Marker — saturiertes Petrol, distinct vom accent */
--active: #7dc8b8;
--active-light: #8ad1bf;
--active-dim: rgba(125, 200, 184, 0.16);
--warn: #d48030;
--warn-light: #e09040;
--warn-dim: rgba(212, 128, 48, 0.18);
--danger: #c85050;
--danger-light: #d86060;
--overlay-hover: rgba(255,255,255,0.06);
--overlay-active:rgba(255,255,255,0.10);
--shadow-1: 0 1px 2px rgba(0,0,0,0.30);
--shadow-2: 0 2px 8px rgba(0,0,0,0.40);
--shadow-3: 0 6px 24px rgba(0,0,0,0.55);
--select-arrow: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M0 0l5 6 5-6z' fill='%23a8a8a8'/></svg>");
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
font-size: 12px;
background: var(--bg-base);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
letter-spacing: 0.01em;
}
#root { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
button {
font-family: var(--font);
cursor: pointer;
border: none;
outline: none;
background: none;
color: inherit;
position: relative;
transition:
background 0.18s cubic-bezier(0.4, 0, 0.2, 1),
color 0.14s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
input, select {
font-family: var(--font-mono);
font-size: 11px;
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 5px 8px;
outline: none;
transition: border-color 0.16s, box-shadow 0.16s;
}
input:hover { border-color: var(--text-muted); }
input:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
input[type="number"]::-webkit-inner-spin-button { opacity: 0.3; }
/* Pill-shaped select */
select {
appearance: none;
-webkit-appearance: none;
background-color: var(--bg-item);
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 10px center;
padding: 5px 26px 5px 14px;
border-radius: 999px;
border: 1px solid var(--border);
font-family: var(--font);
font-weight: 500;
font-size: 10px;
letter-spacing: 0.04em;
cursor: pointer;
color: var(--text-primary);
}
select:hover {
background-color: var(--bg-item-hover);
border-color: var(--text-muted);
}
/* Utility */
.label-xs {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
/* --- Buttons --- */
.btn-outlined {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-item);
color: var(--text-secondary);
height: 26px;
display: inline-flex; align-items: center; justify-content: center;
gap: 4px;
}
.btn-outlined:hover {
background: var(--bg-item-hover);
border-color: var(--text-secondary);
color: var(--text-primary);
}
.btn-outlined:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-contained {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 5px 16px;
border-radius: 999px;
border: none;
background: var(--accent);
color: #fff;
box-shadow: var(--shadow-1);
height: 28px;
display: inline-flex; align-items: center; justify-content: center;
gap: 4px;
}
.btn-contained:hover {
background: var(--accent-light);
box-shadow: var(--shadow-2);
}
.btn-text {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 5px 12px;
border-radius: 999px;
background: transparent;
color: var(--text-secondary);
height: 28px;
display: inline-flex; align-items: center; justify-content: center;
}
.btn-text:hover {
background: var(--overlay-hover);
color: var(--text-primary);
}
/* Icon button (circular hover) */
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
flex-shrink: 0;
}
.btn-icon:hover {
background: var(--overlay-hover);
color: var(--text-primary);
}
.btn-icon:active {
background: var(--overlay-active);
}
.btn-icon-sm {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: transparent;
color: var(--text-muted);
flex-shrink: 0;
}
.btn-icon-sm:hover {
background: var(--overlay-hover);
color: var(--text-primary);
}
.btn-icon-sm.is-on {
color: var(--text-primary);
}
.btn-icon-xs {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: transparent;
color: var(--text-muted);
flex-shrink: 0;
}
.btn-icon-xs:hover {
background: var(--overlay-hover);
color: var(--text-primary);
}
/* Icon button — danger variant */
.btn-icon-danger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: transparent;
color: var(--danger);
flex-shrink: 0;
}
.btn-icon-danger:hover {
background: var(--danger-light);
color: #fff;
}
.btn-icon-danger:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* Tonal icon button (subtle filled) */
.btn-icon-tonal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--bg-item);
color: var(--text-secondary);
border: 1px solid var(--border);
flex-shrink: 0;
}
.btn-icon-tonal:hover {
background: var(--bg-item-hover);
color: var(--text-primary);
border-color: var(--text-secondary);
}
/* FAB-style add button */
.btn-add {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent);
color: #fff;
border: none;
box-shadow: var(--shadow-1);
}
.btn-add:hover {
background: var(--accent-light);
box-shadow: var(--shadow-2);
}
.btn-add:active {
box-shadow: var(--shadow-1);
}
/* Tiny stepper for lw etc. */
.btn-step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 10px;
border-radius: 2px;
background: transparent;
color: var(--text-muted);
line-height: 1;
}
.btn-step:hover {
background: var(--overlay-hover);
color: var(--text-primary);
}
/* Chip-style badge */
.chip {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-item);
color: var(--text-secondary);
display: inline-flex; align-items: center;
}
.chip-accent {
background: var(--accent-dim);
border-color: var(--accent-border);
color: var(--accent);
}
.chip-info {
background: var(--active-dim);
border-color: rgba(26, 78, 138, 0.3);
color: var(--active);
}
.chip-warn {
background: var(--warn-dim);
border-color: rgba(181, 98, 30, 0.3);
color: var(--warn);
}
+289
View File
@@ -0,0 +1,289 @@
/**
* rhinoBridge.js — Kommunikation React ↔ Rhino/Python
* Lange Payloads werden in Chunks <700 Zeichen aufgeteilt (document.title-Limit).
*/
const CHUNK_SIZE = 700
let _queue = []
let _busy = false
function _flush() {
if (_busy || _queue.length === 0) return
_busy = true
document.title = 'RHINOMSG::' + _queue.shift()
setTimeout(() => { _busy = false; _flush() }, 80)
}
// Mac WKWebView -> .NET String-Bruecke kann mit non-ASCII-Bytes haengen
// (Umlaute wie A-Umlaut/O-Umlaut/U-Umlaut -> "Unable to translate bytes from specified code page").
// Vor Transport ASCII-eskapen, Python's json.loads dekodiert die \uXXXX wieder.
const _NON_ASCII = /[^\x00-\x7f]/g
function _asciiEscape(s) {
return s.replace(_NON_ASCII, (c) =>
'\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4))
}
function send(type, payload = {}) {
if (!window.RHINO_MODE) { console.log('[Bridge] →', type, payload); return }
const json = _asciiEscape(JSON.stringify({ type, payload }))
if (json.length <= CHUNK_SIZE) {
_queue.push(json)
} else {
const total = Math.ceil(json.length / CHUNK_SIZE)
for (let i = 0; i < total; i++) {
_queue.push(_asciiEscape(JSON.stringify({
_chunk: { i, n: total },
d: json.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
})))
}
}
_flush()
}
const _handlers = {}
export function onMessage(type, handler) { _handlers[type] = handler }
if (typeof window !== 'undefined') {
window.onRhinoMessage = (data) => {
const h = _handlers[data.type]
if (h) h(data.payload)
else console.log('[Bridge] ←', data.type, data.payload)
}
}
export function notifyReady() {
const tryReady = () => {
if (window.RHINO_MODE) send('READY', {})
else setTimeout(tryReady, 50)
}
tryReady()
}
export function applyAll(zeichnungsebenen, ebenen) {
send('APPLY', { zeichnungsebenen, ebenen })
}
export function setLayerVisibility(code, visible) {
send('LAYER_VISIBILITY', { code, visible })
}
export function setLayerLock(code, locked) {
send('LAYER_LOCK', { code, locked })
}
export function setLayerStyle(code, color, lw) {
send('LAYER_STYLE', { code, color, lw })
}
export function setActiveZeichnungsebene(z) {
send('SET_ACTIVE', { id: z.id, name: z.name, isGeschoss: !!z.isGeschoss, okff: z.okff ?? null })
}
export function setActiveEbene(code) {
send('SET_ACTIVE_LAYER', { code })
}
export function deleteEbene(code, moveTo) {
send('DELETE_EBENE', { code, moveTo: moveTo || null })
}
export function moveSelectionToEbene(code) {
send('MOVE_SELECTION_TO_LAYER', { code })
}
export function setClippingEnabled(enabled) {
send('SET_CLIPPING', { enabled: !!enabled })
}
// --- Ausschnitte ---
export function listAusschnitte() { send('LIST', {}) }
export function saveAusschnitt(name) { send('SAVE', { name }) }
export function updateAusschnitt(id) { send('UPDATE', { id }) }
export function restoreAusschnitt(id) { send('RESTORE', { id }) }
export function applyAusschnittToDetail(id) { send('APPLY_TO_DETAIL', { id }) }
export function renameAusschnitt(id, name) { send('RENAME', { id, name }) }
export function deleteAusschnitt(id) { send('DELETE', { id }) }
export function setAusschnittFolder(id, folder) { send('SET_FOLDER', { id, folder }) }
export function setAusschnittScale(id, scale) { send('SET_SCALE', { id, scale }) }
export function duplicateAusschnitt(id) { send('DUPLICATE', { id }) }
export function addAusschnittFolder(name) { send('ADD_FOLDER', { name }) }
export function removeAusschnittFolder(name) { send('REMOVE_FOLDER', { name }) }
export function getAusschnittLayers(id) { send('GET_LAYERS', { id }) }
export function updateAusschnittLayers(id, layers) { send('UPDATE_LAYERS', { id, layers }) }
export function saveLayerPreset(name, layers) { send('SAVE_PRESET', { name, layers }) }
export function deleteLayerPreset(name) { send('DELETE_PRESET', { name }) }
// --- Gestaltung-Panel ---
export function requestSelection() {
send('GET_SELECTION', {})
}
export function setColorSource(source, color) {
send('SET_COLOR_SOURCE', { source, color: color || null })
}
export function setLwSource(source, lw) {
send('SET_LW_SOURCE', { source, lw: lw == null ? null : Number(lw) })
}
export function setLinetypeSource(source, name) {
send('SET_LINETYPE_SOURCE', { source, name: name || null })
}
export function setLinetypeScale(scale) {
send('SET_LINETYPE_SCALE', { scale: scale == null ? null : Number(scale) })
}
export function setFill(enabled, source, color, pattern, scale, rotation) {
send('SET_FILL', {
enabled: !!enabled,
source: source || 'object',
color: color || null,
pattern: pattern || null,
scale: scale == null ? null : Number(scale),
rotation: rotation == null ? null : Number(rotation),
})
}
// --- Massstab-Panel ---
export function requestMassstab() { send('REQUEST_STATE', {}) }
export function setMassstab(ratio) { send('SET_SCALE', { ratio: Number(ratio) }) }
export function zoomOneToOne() { send('ZOOM_ONE_TO_ONE', {}) }
export function zoomExtents() { send('ZOOM_EXTENTS', {}) }
export function zoomSelection() { send('ZOOM_SELECTION', {}) }
export function setMassstabDpi(dpi) { send('SET_DPI', { dpi: Number(dpi) }) }
export function detectMassstabDpi() { send('DETECT_DPI', {}) }
export function setShowLineweights(on) { send('SET_LINEWEIGHTS',{ enabled: !!on }) }
// --- Werkzeuge-Panel ---
export function runRhinoCommand(cmd) { send('RUN', { cmd }) }
// --- Oberleiste-Panel ---
export function setView(view) { send('SET_VIEW', { view }) }
export function setDisplayMode(name) { send('SET_DISPLAY_MODE', { name }) }
export function toggleOrtho(on) { send('TOGGLE_ORTHO', { enabled: !!on }) }
export function toggleGridSnap(on) { send('TOGGLE_GRID_SNAP', { enabled: !!on }) }
export function toggleOsnap(on) { send('TOGGLE_OSNAP', { enabled: !!on }) }
export function toggleOverrides(on) { send('TOGGLE_OVERRIDES', { enabled: !!on }) }
export function setOverridesPreset(name) { send('SET_OVERRIDES_PRESET', { name: name || null }) }
export function saveOverridesPreset(name) { send('SAVE_OVERRIDES_PRESET', { name }) }
export function openOverridesPanel() { send('OPEN_OVERRIDES_PANEL', {}) }
export function runCommand(cmd) { send('RUN_COMMAND', { cmd }) }
export function sendKeys(text, enter) { send('SEND_KEYS', { text, enter: enter !== false }) }
export function cancelCommand() { send('CANCEL_COMMAND', {}) }
export function toggleRhinoCmdLine() { send('TOGGLE_RHINO_CMD_LINE', {}) }
// --- Overrides-Panel ---
export function setOverridesEnabled(on) { send('SET_ENABLED', { enabled: !!on }) }
export function addRule(rule) { send('ADD_RULE', { rule }) }
export function updateRule(id, rule) { send('UPDATE_RULE', { id, rule }) }
export function deleteRule(id) { send('DELETE_RULE', { id }) }
export function reorderRules(order) { send('REORDER_RULES', { order }) }
export function duplicateRule(id) { send('DUPLICATE_RULE', { id }) }
export function reapplyOverrides() { send('REAPPLY', {}) }
export function clearOverrideRules() { send('CLEAR_RULES', {}) }
export function savePreset(name) { send('SAVE_PRESET', { name }) }
export function loadPreset(name, mode) { send('LOAD_PRESET', { name, mode: mode || 'replace' }) }
export function deletePreset(name) { send('DELETE_PRESET', { name }) }
// --- Dimensionen-Panel ---
export function setRefPoint(x, y, z) { send('SET_REF_POINT', { x, y, z }) }
export function setCoordSystem(mode) { send('SET_COORD_SYSTEM', { mode }) }
export function setDimPosition(axis, value) { send('SET_POSITION', { axis, value }) }
export function setDimDimension(axis, value) { send('SET_DIMENSION', { axis, value }) }
export function setDimRotationZ(angle) { send('SET_ROTATION_Z', { angle }) }
export function setCircleRadius(value) { send('SET_CIRCLE_RADIUS', { value }) }
export function setLineLength(value) { send('SET_LINE_LENGTH', { value }) }
export function setRectangleDims(width, height) { send('SET_RECTANGLE', { width, height }) }
// --- Layouts-Panel ---
export function listLayouts() { send('LIST', {}) }
export function newLayout(name, format, landscape, customWidth, customHeight) {
send('NEW_LAYOUT', { name, format, landscape: !!landscape,
customWidth, customHeight })
}
export function deleteLayout(id) { send('DELETE_LAYOUT', { id }) }
export function renameLayout(id, name) { send('RENAME_LAYOUT', { id, name }) }
export function activateLayout(id) { send('ACTIVATE_LAYOUT', { id }) }
export function addDetail(pageId, ausschnittId) {
send('ADD_DETAIL', { pageId, ausschnittId: ausschnittId || null })
}
export function deleteDetail(pageId, detailId) {
send('DELETE_DETAIL', { pageId, detailId })
}
export function bindAusschnitt(pageId, detailId, ausschnittId) {
send('BIND_AUSSCHNITT', { pageId, detailId, ausschnittId: ausschnittId || null })
}
export function syncDetail(pageId, detailId) { send('SYNC_DETAIL', { pageId, detailId }) }
export function syncLayout(id) { send('SYNC_LAYOUT', { id }) }
export function setPageSize(id, format, landscape, customWidth, customHeight) {
send('SET_PAGE_SIZE', { id, format, landscape: !!landscape,
customWidth, customHeight })
}
export function exportPdf(id, dpi) { send('EXPORT_PDF', { id, dpi: dpi || 300 }) }
export function exportPdfAll(dpi) { send('EXPORT_PDF', { dpi: dpi || 300 }) }
export function exportPdfMany(ids, dpi) { send('EXPORT_PDF', { ids, dpi: dpi || 300 }) }
export function addLayoutFolder(name) { send('ADD_FOLDER', { name }) }
export function removeLayoutFolder(name) { send('REMOVE_FOLDER', { name }) }
export function setLayoutFolder(id, folder) { send('SET_FOLDER', { id, folder }) }
// --- Elemente-Panel (Smart Architektur-Elemente) ---
export function listElemente() { send('LIST', {}) }
export function createWall(p) { send('CREATE_WALL', p || {}) }
export function createDecke(p) { send('CREATE_DECKE', p || {}) }
export function createDach(p) { send('CREATE_DACH', p || {}) }
export function createFenster(p) { send('CREATE_FENSTER', p || {}) }
export function createTuer(p) { send('CREATE_TUER', p || {}) }
export function createTreppe(p) { send('CREATE_TREPPE', p || {}) }
export function updateElement(id, patch) { send('UPDATE_ELEMENT', { id, ...(patch || {}) }) }
export function deleteElement(id) { send('DELETE_ELEMENT', { id }) }
// Backwards-Compat-Aliases
export function updateWall(id, patch) { send('UPDATE_WALL', { id, ...(patch || {}) }) }
export function deleteWall(id) { send('DELETE_WALL', { id }) }
export function regenerateAllWalls() { send('REGENERATE_ALL', {}) }
export function regenerateAllElements() { send('REGENERATE_ALL', {}) }
let _visTimer = null
let _visArgs = null
// --- EBENEN: Ebenenkombinationen (geteilter Store mit AUSSCHNITTE) ---
export function getCombination() { send('GET_COMBINATION', {}) }
export function applyCombination(layersOrPreset) {
// Akzeptiert entweder ein Array von Layer-States (alt) oder ein Preset-
// Objekt {layers, dossierEbenen, dossierZeichnungsebenen} (neu). Backend kommt
// mit beiden Formaten klar.
if (Array.isArray(layersOrPreset)) {
send('APPLY_COMBINATION', { layers: layersOrPreset })
} else if (layersOrPreset && typeof layersOrPreset === 'object') {
send('APPLY_COMBINATION', layersOrPreset)
} else {
send('APPLY_COMBINATION', { layers: [] })
}
}
export function saveCombinationPreset(name, layers) { send('SAVE_PRESET', { name, layers }) }
export function saveCurrentAsCombination(name) { send('SAVE_CURRENT_AS_PRESET', { name }) }
export function deleteCombinationPreset(name) { send('DELETE_PRESET', { name }) }
export function applyVisibility(activeZ, zeichnungsebenen, activeCode, ebenen, zMode, eMode) {
_visArgs = { activeZ, zeichnungsebenen, activeCode, ebenen, zMode, eMode }
if (_visTimer) clearTimeout(_visTimer)
_visTimer = setTimeout(() => {
_visTimer = null
const a = _visArgs
const slimZ = a.zeichnungsebenen.map(z => ({
id: z.id, name: z.name, visible: z.visible !== false,
}))
const slimE = a.ebenen.map(e => ({
code: e.code, visible: e.visible !== false, locked: e.locked === true,
}))
send('SET_VISIBILITY', {
activeZ: { id: a.activeZ.id },
activeCode: a.activeCode,
zeichnungsebenen: slimZ,
ebenen: slimE,
zMode: a.zMode,
eMode: a.eMode,
})
}, 30)
}
+47
View File
@@ -0,0 +1,47 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import GestaltungApp from './GestaltungApp.jsx'
import AusschnitteApp from './AusschnitteApp.jsx'
import MassstabApp from './MassstabApp.jsx'
import WerkzeugeApp from './WerkzeugeApp.jsx'
import OberleisteApp from './OberleisteApp.jsx'
import OverridesApp from './OverridesApp.jsx'
import DimensionenApp from './DimensionenApp.jsx'
import LayoutsApp from './LayoutsApp.jsx'
import ElementeApp from './ElementeApp.jsx'
const mode = (typeof window !== 'undefined' && window.PANEL_MODE) || 'ebenen'
const RootApp = mode === 'gestaltung' ? GestaltungApp
: mode === 'ausschnitte' ? AusschnitteApp
: mode === 'massstab' ? MassstabApp
: mode === 'werkzeuge' ? WerkzeugeApp
: mode === 'oberleiste' ? OberleisteApp
: mode === 'overrides' ? OverridesApp
: mode === 'dimensionen' ? DimensionenApp
: mode === 'layouts' ? LayoutsApp
: mode === 'elemente' ? ElementeApp
: App
window.onerror = function (msg, src, line, col, err) {
document.body.style.cssText = 'background:#1c1c1e;margin:0;padding:12px'
document.body.innerHTML =
'<pre style="color:#ff6b6b;font-size:10px;font-family:monospace;white-space:pre-wrap">' +
'JS ERROR:\n' + msg + '\n' + src + ':' + line + '\n' +
(err ? err.stack : '') + '</pre>'
return false
}
window.onunhandledrejection = function (e) {
document.body.style.cssText = 'background:#1c1c1e;margin:0;padding:12px'
document.body.innerHTML =
'<pre style="color:#ff6b6b;font-size:10px;font-family:monospace;white-space:pre-wrap">' +
'PROMISE ERROR:\n' + (e.reason ? e.reason.stack || e.reason : e) + '</pre>'
}
createRoot(document.getElementById('root')).render(
<StrictMode>
<RootApp />
</StrictMode>,
)