0bf891641f
UI:
- Voreinstellungen: InlineNumberField (Label links, schmaler Number-Input
rechts mit Einheit-Suffix) statt full-width pills
- Materialien-Tab: List/Detail-Layout im ArchiCAD-Stil
- Links (240px): suchbare Material-Liste mit Color-Swatch + Source-Badge
- Rechts: Detail-Panel mit collapsible Sections
- Slider-Component (Pill-Track mit accent-fill + Wert rechts)
- TextureSlot-Component (Preview-Tile + Filename + Picker- + Clear-Button)
Schema:
- Material erweitert: roughness, reflection, transparency, iorN, uvScaleM
- Texturen: diffuse / bump / roughness / transparency (je {path, ...})
- _normalize_material clamped 0..1, IoR 1.0..2.5
Backend:
- Neuer Handler PICK_TEXTURE_FILE oeffnet Eto.OpenFileDialog fuer
.jpg/.png/.tif/.bmp/.tga, antwortet mit TEXTURE_PICKED {slot, path}
Hinweis: PBR-Werte + Texturen landen aktuell nur in doc.Strings —
Anwendung auf Rhinos Render-Material kommt in Stufe 4.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
598 lines
24 KiB
React
598 lines
24 KiB
React
import { useState, useEffect } from 'react'
|
|
import Icon from './Icon'
|
|
import { BarToggle, BarButton, BAR_H } from './BarControls'
|
|
import { openLibrary, pickTextureFile, onMessage } from '../lib/rhinoBridge'
|
|
|
|
/* Field — Stack-Layout fuer komplexe Inputs (mehrere Felder nebeneinander) */
|
|
function Field({ label, hint, children, style }) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4,
|
|
padding: '6px 0', ...style }}>
|
|
<span style={{ fontSize: 9, color: 'var(--text-muted)',
|
|
fontWeight: 500, letterSpacing: '0.06em',
|
|
textTransform: 'uppercase' }}>{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>
|
|
)
|
|
}
|
|
|
|
/* InlineNumberField — Label links, schmales Number-Input rechts (kompakt) */
|
|
function InlineNumberField({ label, hint, value, onChange, step, min, max, suffix }) {
|
|
return (
|
|
<div style={{ padding: '5px 0' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<span style={{ flex: 1, fontSize: 11, color: 'var(--text-primary)',
|
|
letterSpacing: '0.01em' }}>{label}</span>
|
|
<input type="number" value={value ?? ''}
|
|
step={step} min={min} max={max}
|
|
onChange={(ev) => onChange(parseFloat(ev.target.value))}
|
|
style={{ width: 80, height: BAR_H, padding: '0 10px',
|
|
fontSize: 11, textAlign: 'right' }} />
|
|
{suffix && (
|
|
<span style={{ width: 24, fontSize: 10, color: 'var(--text-muted)' }}>
|
|
{suffix}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{hint && (
|
|
<div style={{ fontSize: 9, color: 'var(--text-muted)',
|
|
lineHeight: 1.4, marginTop: 3 }}>
|
|
{hint}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* Pill-Tabs — gleicher Stil wie BarToggle aus der Oberleiste */
|
|
function TabBar({ tabs, active, onChange }) {
|
|
return (
|
|
<div style={{
|
|
display: 'flex', gap: 4,
|
|
padding: '8px 12px',
|
|
borderBottom: '1px solid var(--border)',
|
|
}}>
|
|
{tabs.map(t => (
|
|
<BarToggle key={t.key}
|
|
label={t.label}
|
|
active={active === t.key}
|
|
onClick={() => onChange(t.key)} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* MaterialListRow — schmale Listen-Zeile links (ArchiCAD-Stil):
|
|
Color-Swatch + Name + Source-Badge. Click selektiert. */
|
|
function MaterialListRow({ mat, isBuiltin, isSelected, onSelect }) {
|
|
const isLibrary = mat.source === 'library'
|
|
return (
|
|
<div onClick={onSelect}
|
|
style={{
|
|
display: 'grid', gridTemplateColumns: '14px 1fr 14px',
|
|
alignItems: 'center', gap: 6,
|
|
padding: '4px 10px',
|
|
cursor: 'pointer',
|
|
background: isSelected ? 'var(--accent-dim)' : 'transparent',
|
|
borderLeft: '2px solid ' + (isSelected ? 'var(--accent)' : 'transparent'),
|
|
transition: 'background 0.12s',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isSelected) e.currentTarget.style.background = 'var(--bg-item-hover)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isSelected) e.currentTarget.style.background = 'transparent'
|
|
}}>
|
|
<div style={{
|
|
width: 14, height: 14, borderRadius: 3,
|
|
background: mat.color || '#888888',
|
|
border: '1px solid var(--border-light)',
|
|
}} title={mat.color} />
|
|
<span style={{
|
|
fontSize: 11,
|
|
color: isSelected ? 'var(--text-primary)' : 'var(--text-primary)',
|
|
fontWeight: isSelected ? 500 : 400,
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}>{mat.name || 'Unbenannt'}</span>
|
|
<span style={{
|
|
fontSize: 9, fontWeight: 600,
|
|
color: isLibrary ? 'var(--accent)'
|
|
: isBuiltin ? 'var(--text-muted)'
|
|
: 'transparent',
|
|
textAlign: 'center',
|
|
}} title={isLibrary ? 'Library' : isBuiltin ? 'Builtin' : 'Lokal'}>
|
|
{isLibrary ? 'L' : isBuiltin ? 'B' : ''}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* Slider — Pill-Track mit Accent-Fill, Wert rechts daneben */
|
|
function Slider({ label, value, onChange, min = 0, max = 1, step = 0.01,
|
|
display, disabled }) {
|
|
const v = value ?? min
|
|
const pct = ((v - min) / (max - min)) * 100
|
|
return (
|
|
<div style={{ padding: '4px 0' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<span style={{ flex: 1, fontSize: 11,
|
|
color: disabled ? 'var(--text-muted)' : 'var(--text-primary)' }}>
|
|
{label}
|
|
</span>
|
|
<span style={{ fontSize: 10, color: 'var(--text-muted)',
|
|
fontFamily: 'var(--font-mono)', minWidth: 40,
|
|
textAlign: 'right' }}>
|
|
{display !== undefined ? display : v.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
<input type="range"
|
|
min={min} max={max} step={step} value={v}
|
|
disabled={disabled}
|
|
onChange={(ev) => onChange(parseFloat(ev.target.value))}
|
|
style={{
|
|
width: '100%', marginTop: 4,
|
|
accentColor: 'var(--accent)',
|
|
opacity: disabled ? 0.5 : 1,
|
|
}} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* TextureSlot — Datei-Picker + Vorschau-Mini-Tile + Clear-Button.
|
|
tex: {path: string} oder null */
|
|
function TextureSlot({ label, slot, tex, onChange, disabled }) {
|
|
const hasPath = !!(tex && tex.path)
|
|
const filename = hasPath ? tex.path.split('/').pop() : ''
|
|
return (
|
|
<div style={{ padding: '5px 0' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<div style={{
|
|
width: 36, height: 36, borderRadius: 4,
|
|
background: hasPath
|
|
? `url(file://${tex.path}) center/cover, var(--bg-input)`
|
|
: 'var(--bg-input)',
|
|
border: '1px solid var(--border)',
|
|
flexShrink: 0,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
{!hasPath && (
|
|
<Icon name="image" size={14}
|
|
style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
|
)}
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 11, color: 'var(--text-primary)',
|
|
letterSpacing: '0.01em' }}>{label}</div>
|
|
<div style={{ fontSize: 9, color: 'var(--text-muted)',
|
|
marginTop: 2,
|
|
overflow: 'hidden', textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap' }}
|
|
title={hasPath ? tex.path : ''}>
|
|
{hasPath ? filename : 'Keine Textur'}
|
|
</div>
|
|
</div>
|
|
<BarButton icon="folder_open"
|
|
onClick={() => pickTextureFile(slot)}
|
|
title="Datei waehlen"
|
|
disabled={disabled} />
|
|
{hasPath && (
|
|
<BarButton icon="close"
|
|
onClick={() => onChange(null)}
|
|
title="Textur entfernen"
|
|
disabled={disabled} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* DetailSection — Section-Header + Body, immer offen (collapsible spaeter) */
|
|
function DetailSection({ title, children }) {
|
|
return (
|
|
<div style={{ marginBottom: 14 }}>
|
|
<div style={{
|
|
fontSize: 9, fontWeight: 600,
|
|
color: 'var(--text-muted)',
|
|
letterSpacing: '0.08em', textTransform: 'uppercase',
|
|
padding: '4px 0', marginBottom: 6,
|
|
borderBottom: '1px solid var(--border-light)',
|
|
}}>{title}</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* MaterialDetail — rechte Seite (ArchiCAD-Stil): editiert das aktuell
|
|
ausgewaehlte Material. Builtin: Name read-only. */
|
|
function MaterialDetail({ mat, isBuiltin, hatchPatterns, onChange, onDelete }) {
|
|
if (!mat) {
|
|
return (
|
|
<div style={{ padding: 40, textAlign: 'center',
|
|
color: 'var(--text-muted)', fontSize: 11 }}>
|
|
Kein Material ausgewaehlt.
|
|
<div style={{ marginTop: 8, fontSize: 10 }}>
|
|
Wähle links oder lege ein neues an.
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
const isLibrary = mat.source === 'library'
|
|
return (
|
|
<div style={{ padding: '12px 14px',
|
|
display: 'flex', flexDirection: 'column',
|
|
height: '100%', overflowY: 'auto' }}>
|
|
{/* Identitaet */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10,
|
|
marginBottom: 14 }}>
|
|
<div style={{
|
|
width: 56, height: 56, borderRadius: 8,
|
|
background: mat.color || '#888888',
|
|
border: '1px solid var(--border)',
|
|
flexShrink: 0,
|
|
}} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<input type="text" value={mat.name || ''}
|
|
onChange={(ev) => onChange({ ...mat, name: ev.target.value })}
|
|
disabled={isBuiltin}
|
|
placeholder="Material-Name"
|
|
style={{ width: '100%', height: BAR_H, padding: '0 12px',
|
|
fontSize: 12, fontWeight: 500,
|
|
opacity: isBuiltin ? 0.7 : 1 }} />
|
|
<div style={{ fontSize: 9, color: 'var(--text-muted)',
|
|
marginTop: 4, letterSpacing: '0.04em' }}>
|
|
{isLibrary ? 'Aus Dossier-Library'
|
|
: isBuiltin ? 'Eingebaut (Builtin)'
|
|
: 'Lokales Material'}
|
|
</div>
|
|
</div>
|
|
{!isBuiltin && onDelete && (
|
|
<BarButton icon="delete" onClick={onDelete}
|
|
title="Material loeschen" />
|
|
)}
|
|
</div>
|
|
|
|
<DetailSection title="Oberflächenfarbe">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input type="color" value={mat.color || '#888888'}
|
|
onChange={(ev) => onChange({ ...mat, color: ev.target.value })}
|
|
title="Farbe"
|
|
style={{ width: 32, height: BAR_H, padding: 0, border: 'none',
|
|
background: 'transparent', cursor: 'pointer' }} />
|
|
<input type="text"
|
|
value={mat.color || '#888888'}
|
|
onChange={(ev) => onChange({ ...mat, color: ev.target.value })}
|
|
style={{ flex: 1, height: BAR_H, padding: '0 12px',
|
|
fontSize: 11, fontFamily: 'var(--font-mono)' }} />
|
|
</div>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Schraffur (2D-Schnitt)">
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<select value={mat.hatch || 'Solid'}
|
|
onChange={(ev) => onChange({ ...mat, hatch: ev.target.value })}
|
|
style={{ flex: 1, height: BAR_H, fontSize: 11 }}>
|
|
{(hatchPatterns || []).map(h => (
|
|
<option key={h} value={h}>{h}</option>
|
|
))}
|
|
</select>
|
|
<input type="number" step="0.1" min="0.1"
|
|
value={mat.scale ?? 1.0}
|
|
onChange={(ev) => onChange({ ...mat, scale: parseFloat(ev.target.value) || 1.0 })}
|
|
title="Skalierung"
|
|
style={{ width: 70, height: BAR_H, padding: '0 10px',
|
|
fontSize: 11, textAlign: 'right' }} />
|
|
</div>
|
|
<div style={{ fontSize: 9, color: 'var(--text-muted)', marginTop: 4 }}>
|
|
Hatch-Pattern + Skalierung fuer die Sektion-Ansicht (Clipping Plane).
|
|
</div>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Texturen (3D-Render)">
|
|
<TextureSlot label="Diffuse" slot="diffuse"
|
|
tex={mat.textures?.diffuse}
|
|
onChange={(t) => onChange({ ...mat, textures: {
|
|
...(mat.textures || {}), diffuse: t } })}
|
|
disabled={isBuiltin} />
|
|
<TextureSlot label="Bump / Normal" slot="bump"
|
|
tex={mat.textures?.bump}
|
|
onChange={(t) => onChange({ ...mat, textures: {
|
|
...(mat.textures || {}), bump: t } })}
|
|
disabled={isBuiltin} />
|
|
<TextureSlot label="Roughness" slot="roughness"
|
|
tex={mat.textures?.roughness}
|
|
onChange={(t) => onChange({ ...mat, textures: {
|
|
...(mat.textures || {}), roughness: t } })}
|
|
disabled={isBuiltin} />
|
|
<TextureSlot label="Transparenz (Alpha)" slot="transparency"
|
|
tex={mat.textures?.transparency}
|
|
onChange={(t) => onChange({ ...mat, textures: {
|
|
...(mat.textures || {}), transparency: t } })}
|
|
disabled={isBuiltin} />
|
|
<div style={{ marginTop: 4 }}>
|
|
<InlineNumberField label="UV-Skalierung"
|
|
value={mat.uvScaleM ?? 1.0}
|
|
step={0.1} min={0.01} suffix="m"
|
|
onChange={(v) => onChange({ ...mat, uvScaleM: v || 1.0 })}
|
|
hint="1 Welt-Meter ≙ wieviel Textur-Tile" />
|
|
</div>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Oberflächen-Eigenschaften (PBR)">
|
|
<Slider label="Rauheit (Roughness)"
|
|
value={mat.roughness ?? 0.7}
|
|
onChange={(v) => onChange({ ...mat, roughness: v })}
|
|
disabled={isBuiltin} />
|
|
<Slider label="Reflexion"
|
|
value={mat.reflection ?? 0.1}
|
|
onChange={(v) => onChange({ ...mat, reflection: v })}
|
|
disabled={isBuiltin} />
|
|
<Slider label="Transparenz"
|
|
value={mat.transparency ?? 0.0}
|
|
onChange={(v) => onChange({ ...mat, transparency: v })}
|
|
disabled={isBuiltin} />
|
|
<Slider label="IoR (Brechungsindex)"
|
|
value={mat.iorN ?? 1.0}
|
|
min={1.0} max={2.5} step={0.01}
|
|
display={(mat.iorN ?? 1.0).toFixed(2)}
|
|
onChange={(v) => onChange({ ...mat, iorN: v })}
|
|
disabled={isBuiltin} />
|
|
</DetailSection>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function ProjectSettingsDialog({
|
|
initial, onSave, onClose, embedded = false,
|
|
}) {
|
|
const [tab, setTab] = useState('defaults')
|
|
const [draft, setDraft] = useState(() => ({
|
|
defaults: { ...(initial.defaults || {}) },
|
|
materials: [...(initial.materials || [])],
|
|
}))
|
|
const [selMat, setSelMat] = useState(() => {
|
|
// Default-Auswahl: erstes Builtin wenn vorhanden, sonst erstes Local
|
|
const b = initial.builtinMaterials || []
|
|
if (b.length) return { kind: 'builtin', name: b[0].name }
|
|
const m = initial.materials || []
|
|
if (m.length) return { kind: 'local', idx: 0 }
|
|
return null
|
|
})
|
|
const [matSearch, setMatSearch] = useState('')
|
|
const builtin = initial.builtinMaterials || []
|
|
const hatchPatterns = initial.hatchPatterns || ['Solid']
|
|
|
|
// Aktuell ausgewaehltes Material aus Selection ableiten
|
|
const selectedMat = (() => {
|
|
if (!selMat) return null
|
|
if (selMat.kind === 'builtin') return builtin.find(m => m.name === selMat.name) || null
|
|
if (selMat.kind === 'local') return draft.materials[selMat.idx] || null
|
|
return null
|
|
})()
|
|
const selectedIsBuiltin = selMat?.kind === 'builtin'
|
|
const updateSelected = (newMat) => {
|
|
if (!selMat) return
|
|
if (selMat.kind === 'local') {
|
|
setMat(selMat.idx, newMat)
|
|
}
|
|
// builtin: Schreibend in Phase 1 nur Color/Hatch — Backend ignoriert
|
|
// Name-Aenderungen. UI laesst diese sowieso disabled.
|
|
}
|
|
const deleteSelected = () => {
|
|
if (selMat?.kind !== 'local') return
|
|
delMat(selMat.idx)
|
|
setSelMat(null)
|
|
}
|
|
|
|
// Backend-File-Picker-Antwort: aktualisiert das Slot im aktuell
|
|
// selektierten Material. Wenn path leer = User abgebrochen → no-op.
|
|
useEffect(() => {
|
|
onMessage('TEXTURE_PICKED', ({ slot, path }) => {
|
|
if (!path || !selMat || selMat.kind !== 'local') return
|
|
setDraft(d => ({
|
|
...d,
|
|
materials: d.materials.map((m, i) => {
|
|
if (i !== selMat.idx) return m
|
|
const newTex = (m.textures && typeof m.textures === 'object')
|
|
? { ...m.textures } : {}
|
|
newTex[slot] = { path }
|
|
return { ...m, textures: newTex }
|
|
}),
|
|
}))
|
|
})
|
|
}, [selMat])
|
|
// Suchbar — case-insensitive substring auf Name
|
|
const matchSearch = (m) => {
|
|
const q = matSearch.trim().toLowerCase()
|
|
if (!q) return true
|
|
return (m.name || '').toLowerCase().includes(q)
|
|
}
|
|
const filteredBuiltin = builtin.filter(matchSearch)
|
|
const filteredLocal = draft.materials
|
|
.map((m, i) => ({ m, i }))
|
|
.filter(({ m }) => matchSearch(m))
|
|
|
|
const setDefault = (k, v) =>
|
|
setDraft(d => ({ ...d, defaults: { ...d.defaults, [k]: v } }))
|
|
|
|
const setMat = (i, newMat) => setDraft(d => ({
|
|
...d, materials: d.materials.map((m, idx) => idx === i ? newMat : m),
|
|
}))
|
|
const delMat = (i) => setDraft(d => ({
|
|
...d, materials: d.materials.filter((_, idx) => idx !== i),
|
|
}))
|
|
const addMat = () => {
|
|
setDraft(d => ({
|
|
...d,
|
|
materials: [...d.materials, {
|
|
name: 'Neues Material', color: '#aaaaaa',
|
|
hatch: 'Solid', scale: 1.0,
|
|
source: 'local', libraryId: null,
|
|
}],
|
|
}))
|
|
// Direkt selektieren — User kann gleich editieren
|
|
setSelMat({ kind: 'local', idx: draft.materials.length })
|
|
}
|
|
|
|
const wrapperStyle = embedded ? {
|
|
width: '100%', height: '100%',
|
|
background: 'var(--bg-dialog)',
|
|
display: 'flex', flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
fontFamily: 'var(--font)', color: 'var(--text-primary)', fontSize: 11,
|
|
} : {
|
|
position: 'absolute', inset: 0, zIndex: 150,
|
|
background: 'var(--bg-overlay)',
|
|
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
|
|
paddingTop: 40,
|
|
}
|
|
return (
|
|
<div style={wrapperStyle}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1,
|
|
minHeight: 0, overflow: 'hidden' }}>
|
|
<TabBar tabs={[
|
|
{ key: 'defaults', label: 'Voreinstellungen' },
|
|
{ key: 'materials', label: 'Materialien' },
|
|
]} active={tab} onChange={setTab} />
|
|
|
|
{/* Body */}
|
|
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto',
|
|
padding: '8px 14px' }}>
|
|
{tab === 'defaults' && (
|
|
<div style={{ maxWidth: 520 }}>
|
|
<div style={{ fontSize: 10, color: 'var(--text-muted)',
|
|
padding: '6px 0 10px', lineHeight: 1.5 }}>
|
|
Voreinstellungen fuer neue Elemente. Pro-Element editierte
|
|
Werte bleiben davon unberuehrt.
|
|
</div>
|
|
<DetailSection title="Geschoss">
|
|
<InlineNumberField label="Standard-Geschosshöhe"
|
|
value={draft.defaults.geschossHoehe ?? 3.0}
|
|
step={0.05} min={1.0} max={10} suffix="m"
|
|
onChange={(v) => setDefault('geschossHoehe', v || 3.0)}
|
|
hint="Vorgabe fuer neue Geschosse — pro Geschoss ueberschreibbar" />
|
|
<InlineNumberField label="Standard-Schnitthöhe"
|
|
value={draft.defaults.schnitthoehe ?? 1.0}
|
|
step={0.05} min={0.1} max={3} suffix="m"
|
|
onChange={(v) => setDefault('schnitthoehe', v || 1.0)}
|
|
hint="Höhe der horizontalen Schnitt-Plane über OKFF eines Geschosses" />
|
|
</DetailSection>
|
|
<DetailSection title="Schnitte / Ansichten">
|
|
<InlineNumberField label="Standard-Tiefe hinten"
|
|
value={draft.defaults.schnittDepthBack ?? 8.0}
|
|
step={0.5} min={0.5} suffix="m"
|
|
onChange={(v) => setDefault('schnittDepthBack', v || 8.0)}
|
|
hint="Default-Tiefe fuer neue Schnitte/Ansichten" />
|
|
<InlineNumberField label="Höhe unten"
|
|
value={draft.defaults.schnittHeightMin ?? -1.0}
|
|
step={0.1} suffix="m"
|
|
onChange={(v) => setDefault('schnittHeightMin', v)} />
|
|
<InlineNumberField label="Höhe oben"
|
|
value={draft.defaults.schnittHeightMax ?? 12.0}
|
|
step={0.1} suffix="m"
|
|
onChange={(v) => setDefault('schnittHeightMax', v)} />
|
|
</DetailSection>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'materials' && (
|
|
<div style={{ display: 'flex', height: '100%',
|
|
margin: '-8px -14px', /* Tab-Padding aufheben */
|
|
minHeight: 0 }}>
|
|
{/* Links: Liste */}
|
|
<div style={{
|
|
width: 240, flexShrink: 0,
|
|
display: 'flex', flexDirection: 'column',
|
|
borderRight: '1px solid var(--border)',
|
|
background: 'var(--bg-dialog)',
|
|
}}>
|
|
<div style={{ padding: '8px 10px',
|
|
borderBottom: '1px solid var(--border-light)' }}>
|
|
<input type="text" value={matSearch}
|
|
onChange={(ev) => setMatSearch(ev.target.value)}
|
|
placeholder="Suchen…"
|
|
style={{ width: '100%', height: BAR_H,
|
|
padding: '0 12px', fontSize: 11,
|
|
boxSizing: 'border-box' }} />
|
|
</div>
|
|
<div style={{ flex: 1, overflowY: 'auto',
|
|
padding: '4px 0' }}>
|
|
{filteredBuiltin.length > 0 && (
|
|
<div style={{ fontSize: 8, fontWeight: 600,
|
|
color: 'var(--text-muted)',
|
|
padding: '6px 12px 2px',
|
|
letterSpacing: '0.1em',
|
|
textTransform: 'uppercase' }}>
|
|
Eingebaut
|
|
</div>
|
|
)}
|
|
{filteredBuiltin.map((m) => (
|
|
<MaterialListRow key={'b_' + m.name}
|
|
mat={m} isBuiltin
|
|
isSelected={selMat?.kind === 'builtin' && selMat.name === m.name}
|
|
onSelect={() => setSelMat({ kind: 'builtin', name: m.name })} />
|
|
))}
|
|
{filteredLocal.length > 0 && (
|
|
<div style={{ fontSize: 8, fontWeight: 600,
|
|
color: 'var(--text-muted)',
|
|
padding: '10px 12px 2px',
|
|
letterSpacing: '0.1em',
|
|
textTransform: 'uppercase' }}>
|
|
Projekt
|
|
</div>
|
|
)}
|
|
{filteredLocal.map(({ m, i }) => (
|
|
<MaterialListRow key={'u_' + i}
|
|
mat={m}
|
|
isSelected={selMat?.kind === 'local' && selMat.idx === i}
|
|
onSelect={() => setSelMat({ kind: 'local', idx: i })} />
|
|
))}
|
|
{filteredBuiltin.length === 0 && filteredLocal.length === 0 && (
|
|
<div style={{ padding: 20, textAlign: 'center',
|
|
color: 'var(--text-muted)', fontSize: 10 }}>
|
|
Nichts gefunden.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4,
|
|
padding: '6px 8px',
|
|
borderTop: '1px solid var(--border-light)' }}>
|
|
<BarToggle icon="add" onClick={addMat}
|
|
title="Neues Material" />
|
|
<BarToggle icon="inventory_2" onClick={openLibrary}
|
|
title="Aus Library importieren" />
|
|
</div>
|
|
</div>
|
|
{/* Rechts: Detail */}
|
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
|
<MaterialDetail
|
|
mat={selectedMat}
|
|
isBuiltin={selectedIsBuiltin}
|
|
hatchPatterns={hatchPatterns}
|
|
onChange={updateSelected}
|
|
onDelete={selMat?.kind === 'local' ? deleteSelected : null} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer — Pill-Buttons */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
padding: '8px 12px',
|
|
borderTop: '1px solid var(--border)',
|
|
background: 'var(--bg-section)',
|
|
}}>
|
|
<div style={{ flex: 1 }} />
|
|
<BarToggle label="Abbrechen" onClick={onClose} />
|
|
<BarToggle label="Übernehmen" active onClick={() => onSave(draft)} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|