8f691e37c4
Schema-Erweiterung: - _PROJECT_SETTINGS_DEFAULTS hat jetzt 'project'-Block mit name / number / address / bauherr / architekt / notes / projectZeroMum - _normalize_project_meta stripped Strings + clampt mum als float - load/save_project_settings handeln das 'project'-feld - save_project_settings spiegelt projectZeroMum auch in den Legacy-Key dossier_project_zero_mum (fuer Geschoss-Settings-Dialog) - load_project_settings liest Legacy-Key als Fallback wenn neuer Wert noch nicht gesetzt UI: - InlineTextField + TextareaField Helpers (Pill-Stil) - Projektdaten-Section in Voreinstellungen-Tab: Name, Projekt-Nr., Adresse, Bauherrschaft, Architekt:in, EG-Nullpunkt m.ü.M (mit Hinweis auf Swisstopo-Nutzung), Notizen Swisstopo: - _cmd_open_swisstopo_dialog laedt Projekt-Adresse + sendet projectAddress im SWISSTOPO_STATE - SwisstopoApp: vorbelegt searchText mit projectAddress wenn Feld leer ist (User-Input wird nicht ueberschrieben) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1094 lines
45 KiB
React
1094 lines
45 KiB
React
import { useState, useEffect } from 'react'
|
|
import Icon from './Icon'
|
|
import { BarToggle, BarButton, BAR_H } from './BarControls'
|
|
import {
|
|
openLibrary, pickTextureFile, onMessage,
|
|
renameLinetype, deleteLinetype, loadLinetypeDefaults, importLinetypeFile,
|
|
renameHatch, deleteHatch, importHatchFile,
|
|
} 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>
|
|
)
|
|
}
|
|
|
|
/* InlineTextField — Label links, Text-Input rechts (kompakt) */
|
|
function InlineTextField({ label, value, onChange, placeholder, width = 240 }) {
|
|
return (
|
|
<div style={{ padding: '5px 0',
|
|
display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<span style={{ flex: 1, fontSize: 11, color: 'var(--text-primary)' }}>
|
|
{label}
|
|
</span>
|
|
<input type="text" value={value || ''}
|
|
placeholder={placeholder || ''}
|
|
onChange={(ev) => onChange(ev.target.value)}
|
|
style={{ width, height: BAR_H, padding: '0 12px',
|
|
fontSize: 11 }} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* TextareaField — Label oben, mehrzeiliges Input darunter (full-width) */
|
|
function TextareaField({ label, value, onChange, rows = 3, placeholder }) {
|
|
return (
|
|
<div style={{ padding: '6px 0' }}>
|
|
<div style={{ fontSize: 11, color: 'var(--text-primary)',
|
|
marginBottom: 4 }}>{label}</div>
|
|
<textarea value={value || ''}
|
|
placeholder={placeholder || ''}
|
|
rows={rows}
|
|
onChange={(ev) => onChange(ev.target.value)}
|
|
style={{ width: '100%', boxSizing: 'border-box',
|
|
padding: '6px 12px',
|
|
fontSize: 11, fontFamily: 'var(--font)',
|
|
background: 'var(--bg-input)',
|
|
color: 'var(--text-primary)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 12,
|
|
resize: 'vertical', outline: 'none',
|
|
lineHeight: 1.5 }} />
|
|
</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>
|
|
)
|
|
}
|
|
|
|
/* LinetypePreview — SVG-Linie mit Strich-Segmenten. segments = [{length,type}]
|
|
type ∈ Line/Space (manchmal auch Continuous-Ableitungen). Width in px;
|
|
wir skalieren die Segmente damit das Gesamtmuster in width passt. */
|
|
function LinetypePreview({ segments, width = 120, height = 12 }) {
|
|
if (!segments || segments.length === 0) {
|
|
return (
|
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}
|
|
style={{ color: 'var(--text-primary)', display: 'block' }}>
|
|
<line x1="0" y1={height/2} x2={width} y2={height/2}
|
|
stroke="currentColor" strokeWidth="1" />
|
|
</svg>
|
|
)
|
|
}
|
|
// Dot hat length=0 — fuer Layout-Zwecke kleinen Pseudo-Wert
|
|
const DOT_DRAW_LEN = 0.6
|
|
const lenOf = (seg) => {
|
|
const l = Math.abs(seg.length || 0)
|
|
return seg.type === 'Dot' ? DOT_DRAW_LEN : l
|
|
}
|
|
const patternLen = segments.reduce((s, seg) => s + lenOf(seg), 0)
|
|
if (patternLen <= 0) {
|
|
return (
|
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}
|
|
style={{ color: 'var(--text-primary)', display: 'block' }}>
|
|
<line x1="0" y1={height/2} x2={width} y2={height/2}
|
|
stroke="currentColor" strokeWidth="1" />
|
|
</svg>
|
|
)
|
|
}
|
|
// Statt das Pattern auf die ganze Breite zu strecken: tilen mit fixer
|
|
// Skala. Ziel: ca. 4-5 Pattern-Repetitions sichtbar, dann ist die
|
|
// Sequenz auch bei kurzen Patterns (Dots) deutlich erkennbar.
|
|
const TARGET_REPETITIONS = 4
|
|
const scale = width / (patternLen * TARGET_REPETITIONS)
|
|
const scaledPatternLen = patternLen * scale
|
|
const repetitions = Math.ceil(width / scaledPatternLen) + 1
|
|
const parts = []
|
|
let x = 0
|
|
let key = 0
|
|
for (let r = 0; r < repetitions && x < width; r++) {
|
|
for (const seg of segments) {
|
|
const drawLen = lenOf(seg) * scale
|
|
if (x >= width) break
|
|
const x2 = Math.min(x + drawLen, width)
|
|
if (seg.type === 'Line' && drawLen > 0) {
|
|
parts.push(<line key={key++} x1={x} y1={height/2}
|
|
x2={x2} y2={height/2}
|
|
stroke="currentColor" strokeWidth="1.4"
|
|
strokeLinecap="square" />)
|
|
} else if (seg.type === 'Dot') {
|
|
parts.push(<circle key={key++} cx={x + drawLen/2} cy={height/2}
|
|
r={1.2} fill="currentColor" />)
|
|
}
|
|
x += drawLen
|
|
}
|
|
}
|
|
return (
|
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}
|
|
style={{ color: 'var(--text-primary)', display: 'block' }}>
|
|
{parts}
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/* LinetypeListRow — Zeile mit Name + Preview + selected-state */
|
|
function LinetypeListRow({ lt, isSelected, onSelect }) {
|
|
return (
|
|
<div onClick={onSelect}
|
|
style={{
|
|
display: 'flex', flexDirection: 'column',
|
|
gap: 2,
|
|
padding: '6px 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'
|
|
}}>
|
|
<span style={{ fontSize: 11, color: 'var(--text-primary)',
|
|
overflow: 'hidden', textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap' }}>{lt.name || 'Unbenannt'}</span>
|
|
<LinetypePreview segments={lt.segments} width={140} height={10} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* HatchPreview — rendert das ECHTE Hatch-Pattern (alle HatchLines mit
|
|
Winkel/Offset/Dashes) als SVG-Tile. Solid/Gradient haben keine Lines.
|
|
pixelsPerUnit skaliert das Pattern damit's im Tile gut sichtbar ist. */
|
|
function HatchPreview({ pattern, size = 28, pixelsPerUnit = 8 }) {
|
|
const ft = (pattern.fillType || '').toLowerCase()
|
|
const stroke = 'var(--text-primary)'
|
|
const bg = 'var(--bg-input)'
|
|
if (ft === 'solid') {
|
|
return (
|
|
<svg width={size} height={size}
|
|
style={{ display: 'block', color: stroke }}>
|
|
<rect x="0" y="0" width={size} height={size}
|
|
fill="currentColor"
|
|
stroke="var(--border-light)" strokeWidth="0.5" rx="2" />
|
|
</svg>
|
|
)
|
|
}
|
|
if (ft === 'gradient') {
|
|
const id = 'gr_' + (pattern.index ?? 0)
|
|
return (
|
|
<svg width={size} height={size}
|
|
style={{ display: 'block', color: stroke }}>
|
|
<defs>
|
|
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1">
|
|
<stop offset="0%" stopColor="currentColor" />
|
|
<stop offset="100%" stopColor="currentColor" stopOpacity="0.2" />
|
|
</linearGradient>
|
|
</defs>
|
|
<rect x="0" y="0" width={size} height={size}
|
|
fill={`url(#${id})`}
|
|
stroke="var(--border-light)" strokeWidth="0.5" rx="2" />
|
|
</svg>
|
|
)
|
|
}
|
|
// Lines: jede HatchLine ist eine Familie paralleler Linien, mit
|
|
// Angle, BasePoint, Offset (zwischen Linien), Dashes (Strich/Lücke).
|
|
const hls = pattern.hatchLines || []
|
|
const clipId = 'hp_clip_' + (pattern.index ?? 0)
|
|
const pieces = []
|
|
let lineKey = 0
|
|
// Tile-Mitte im SVG-Koordinatensystem
|
|
const cx = size / 2
|
|
const cy = size / 2
|
|
hls.forEach((hl, hi) => {
|
|
// SVG hat Y nach unten — Rhino-Pattern definiert Y nach oben.
|
|
// Wir spiegeln nicht, weil's nur ein Visual ist; Rotation reicht.
|
|
const a = hl.angle || 0 // rad
|
|
const cosA = Math.cos(a)
|
|
const sinA = Math.sin(a)
|
|
// Perpendicular zur Linie:
|
|
const perpX = -sinA
|
|
const perpY = cosA
|
|
// Offset entlang perp + along — Rhino's Offset ist {X, Y} im
|
|
// Pattern-Koord-System. Die Komponente PARALLEL zur Linien-Richtung
|
|
// (entlang) entscheidet uebers Stagger (Strich-Versatz).
|
|
const offX = hl.offX || 0
|
|
const offY = hl.offY || 0
|
|
// Spacing = Komponente von Offset SENKRECHT zur Linien-Richtung
|
|
const spacing = Math.abs(offX * perpX + offY * perpY)
|
|
const drawSpacing = Math.max(spacing * pixelsPerUnit, 1.5)
|
|
// Stagger = Komponente von Offset PARALLEL zur Linien-Richtung
|
|
const stagger = offX * cosA + offY * sinA
|
|
const drawStagger = stagger * pixelsPerUnit
|
|
// Dashes in Pixel
|
|
const dashes = (hl.dashes || []).map(d => Math.abs(d) * pixelsPerUnit)
|
|
const dashLen = dashes.reduce((s, d) => s + d, 0)
|
|
const strokeDasharray = (dashes.length >= 2 && dashLen > 0.1)
|
|
? dashes.join(',') : undefined
|
|
// Basis-Punkt in Pixel (von Tile-Center)
|
|
const baseX = (hl.baseX || 0) * pixelsPerUnit
|
|
const baseY = (hl.baseY || 0) * pixelsPerUnit
|
|
// Wir zeichnen genug parallele Linien um Tile zu fuellen
|
|
const halfDiag = size * 1.5 // overlap fuer rotate-clip
|
|
const nLines = Math.ceil((size * 2.0) / drawSpacing) + 4
|
|
const start = -Math.floor(nLines / 2)
|
|
for (let i = 0; i < nLines; i++) {
|
|
const k = start + i
|
|
// Mittelpunkt dieser Linie: base + k*offset, vom Tile-Center aus
|
|
// Wir setzen die Pattern-Origin in die Tile-Mitte.
|
|
const mx = cx + baseX + k * (offX * pixelsPerUnit)
|
|
const my = cy + baseY + k * (offY * pixelsPerUnit)
|
|
// Linie streckt sich +-halfDiag entlang Linien-Richtung
|
|
const x1 = mx - cosA * halfDiag
|
|
const y1 = my - sinA * halfDiag
|
|
const x2 = mx + cosA * halfDiag
|
|
const y2 = my + sinA * halfDiag
|
|
pieces.push(<line key={'l' + lineKey++}
|
|
x1={x1} y1={y1} x2={x2} y2={y2}
|
|
stroke="currentColor"
|
|
strokeWidth="0.7"
|
|
strokeDasharray={strokeDasharray} />)
|
|
}
|
|
})
|
|
// Fallback wenn keine HatchLines kamen (Hatch ist 'Lines' aber leer)
|
|
const hasLines = pieces.length > 0
|
|
return (
|
|
<svg width={size} height={size}
|
|
style={{ display: 'block', color: stroke }}
|
|
viewBox={`0 0 ${size} ${size}`}>
|
|
<defs>
|
|
<clipPath id={clipId}>
|
|
<rect x="0" y="0" width={size} height={size} />
|
|
</clipPath>
|
|
</defs>
|
|
<rect x="0" y="0" width={size} height={size}
|
|
fill={bg}
|
|
stroke="var(--border-light)" strokeWidth="0.5" rx="2" />
|
|
<g clipPath={`url(#${clipId})`}>
|
|
{hasLines ? pieces : (
|
|
// Fallback: einzelne Diagonale alle 4 px
|
|
Array.from({length: Math.ceil(size * 2 / 4)}, (_, i) => i * 4 - size)
|
|
.map(i => (
|
|
<line key={i} x1={i} y1="0" x2={i + size} y2={size}
|
|
stroke="currentColor" strokeWidth="0.8" />
|
|
))
|
|
)}
|
|
</g>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/* HatchListRow */
|
|
function HatchListRow({ hp, isSelected, onSelect }) {
|
|
return (
|
|
<div onClick={onSelect}
|
|
style={{
|
|
display: 'grid', gridTemplateColumns: '32px 1fr',
|
|
alignItems: 'center', gap: 8,
|
|
padding: '6px 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'
|
|
}}>
|
|
<HatchPreview pattern={hp} />
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ fontSize: 11, color: 'var(--text-primary)',
|
|
overflow: 'hidden', textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap' }}>{hp.name || 'Unbenannt'}</div>
|
|
<div style={{ fontSize: 9, color: 'var(--text-muted)' }}>{hp.fillType}</div>
|
|
</div>
|
|
</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. Material = REIN 3D (Color + PBR + Texturen).
|
|
Section-Hatch (2D-Schnitt) wird via Ebenen-Settings am Layer gesetzt. */
|
|
function MaterialDetail({ mat, isBuiltin, 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="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 || [])],
|
|
project: { ...(initial.project || {}) },
|
|
}))
|
|
const setProject = (k, v) =>
|
|
setDraft(d => ({ ...d, project: { ...(d.project || {}), [k]: v } }))
|
|
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('')
|
|
// Linetype + Hatch-Tabellen — initial aus params, danach via
|
|
// TABLES_UPDATED Message vom Backend nach jeder CRUD-Op.
|
|
const [linetypes, setLinetypes] = useState(initial.linetypes || [])
|
|
const [hatches, setHatches] = useState(initial.hatchPatternsFull || [])
|
|
const [selLt, setSelLt] = useState(null)
|
|
const [selHt, setSelHt] = useState(null)
|
|
const builtin = initial.builtinMaterials || []
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Linetype/Hatch-Tabelle nach Backend-CRUD aktualisieren
|
|
useEffect(() => {
|
|
onMessage('TABLES_UPDATED', ({ linetypes: lts, hatchPatternsFull: hps }) => {
|
|
if (lts) setLinetypes(lts)
|
|
if (hps) setHatches(hps)
|
|
})
|
|
}, [])
|
|
|
|
// 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' },
|
|
{ key: 'linetypes', label: 'Linientypen' },
|
|
{ key: 'hatches', label: 'Schraffuren' },
|
|
]} active={tab} onChange={setTab} />
|
|
|
|
{/* Body */}
|
|
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto',
|
|
padding: '8px 14px' }}>
|
|
{tab === 'defaults' && (
|
|
<div style={{ maxWidth: 560 }}>
|
|
<DetailSection title="Projektdaten">
|
|
<InlineTextField label="Projektname"
|
|
value={draft.project?.name}
|
|
placeholder="z.B. Wohnhaus Müller"
|
|
onChange={(v) => setProject('name', v)} />
|
|
<InlineTextField label="Projekt-Nr."
|
|
value={draft.project?.number}
|
|
placeholder="z.B. 2026-014"
|
|
width={140}
|
|
onChange={(v) => setProject('number', v)} />
|
|
<TextareaField label="Adresse"
|
|
value={draft.project?.address}
|
|
placeholder="Strasse, PLZ Ort"
|
|
rows={2}
|
|
onChange={(v) => setProject('address', v)} />
|
|
<InlineTextField label="Bauherrschaft"
|
|
value={draft.project?.bauherr}
|
|
onChange={(v) => setProject('bauherr', v)} />
|
|
<InlineTextField label="Architekt:in"
|
|
value={draft.project?.architekt}
|
|
onChange={(v) => setProject('architekt', v)} />
|
|
<InlineNumberField label="EG-Nullpunkt m.ü.M"
|
|
value={draft.project?.projectZeroMum ?? 0.0}
|
|
step={0.01} suffix="m"
|
|
onChange={(v) => setProject('projectZeroMum', v || 0.0)}
|
|
hint="Höhe von Rhino z=0 (= EG OKFF) über Meeresspiegel. Wird vom Swisstopo-Terrain-Import zur Kalibrierung verwendet." />
|
|
<TextareaField label="Notizen"
|
|
value={draft.project?.notes}
|
|
rows={2}
|
|
onChange={(v) => setProject('notes', v)} />
|
|
</DetailSection>
|
|
<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}
|
|
onChange={updateSelected}
|
|
onDelete={selMat?.kind === 'local' ? deleteSelected : null} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'linetypes' && (
|
|
<div style={{ display: 'flex', height: '100%',
|
|
margin: '-8px -14px', minHeight: 0 }}>
|
|
<div style={{
|
|
width: 240, flexShrink: 0,
|
|
display: 'flex', flexDirection: 'column',
|
|
borderRight: '1px solid var(--border)',
|
|
background: 'var(--bg-dialog)',
|
|
}}>
|
|
<div style={{ flex: 1, overflowY: 'auto',
|
|
padding: '4px 0' }}>
|
|
{linetypes.length === 0 && (
|
|
<div style={{ padding: 20, textAlign: 'center',
|
|
color: 'var(--text-muted)', fontSize: 10 }}>
|
|
Keine Linientypen geladen.
|
|
</div>
|
|
)}
|
|
{linetypes.map((lt) => (
|
|
<LinetypeListRow key={lt.index}
|
|
lt={lt}
|
|
isSelected={selLt === lt.index}
|
|
onSelect={() => setSelLt(lt.index)} />
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4,
|
|
padding: '6px 8px',
|
|
borderTop: '1px solid var(--border-light)' }}>
|
|
<BarToggle icon="upload_file" onClick={importLinetypeFile}
|
|
title="Aus .lin-Datei importieren" />
|
|
<BarToggle icon="refresh" onClick={loadLinetypeDefaults}
|
|
title="Rhino-Defaults laden" />
|
|
</div>
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden',
|
|
padding: '12px 14px', overflowY: 'auto' }}>
|
|
{(() => {
|
|
const lt = linetypes.find(x => x.index === selLt)
|
|
if (!lt) return (
|
|
<div style={{ padding: 40, textAlign: 'center',
|
|
color: 'var(--text-muted)', fontSize: 11 }}>
|
|
Linientyp links auswählen.
|
|
</div>
|
|
)
|
|
return (
|
|
<>
|
|
<DetailSection title="Identität">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input type="text" value={lt.name}
|
|
onChange={(ev) => {
|
|
const newName = ev.target.value
|
|
setLinetypes(arr => arr.map(x =>
|
|
x.index === lt.index ? { ...x, name: newName } : x))
|
|
}}
|
|
onBlur={(ev) => renameLinetype(lt.index, ev.target.value)}
|
|
style={{ flex: 1, height: BAR_H, padding: '0 12px',
|
|
fontSize: 12, fontWeight: 500 }} />
|
|
{!lt.isContinuous && (
|
|
<BarButton icon="delete"
|
|
onClick={() => { deleteLinetype(lt.index); setSelLt(null) }}
|
|
title="Linientyp löschen" />
|
|
)}
|
|
</div>
|
|
</DetailSection>
|
|
<DetailSection title="Vorschau">
|
|
<div style={{ padding: '8px 12px',
|
|
background: 'var(--bg-section)',
|
|
borderRadius: 4 }}>
|
|
<LinetypePreview segments={lt.segments}
|
|
width={280} height={16} />
|
|
</div>
|
|
</DetailSection>
|
|
{lt.segments.length > 0 && (
|
|
<DetailSection title="Segmente">
|
|
<div style={{ fontSize: 10, fontFamily: 'var(--font-mono)',
|
|
color: 'var(--text-muted)',
|
|
lineHeight: 1.6 }}>
|
|
{lt.segments.map((s, i) => (
|
|
<div key={i}>
|
|
{String(i+1).padStart(2, '0')} —
|
|
{' '}{s.type.padEnd(10)}
|
|
{' '}{s.length.toFixed(3)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</DetailSection>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'hatches' && (
|
|
<div style={{ display: 'flex', height: '100%',
|
|
margin: '-8px -14px', minHeight: 0 }}>
|
|
<div style={{
|
|
width: 240, flexShrink: 0,
|
|
display: 'flex', flexDirection: 'column',
|
|
borderRight: '1px solid var(--border)',
|
|
background: 'var(--bg-dialog)',
|
|
}}>
|
|
<div style={{ flex: 1, overflowY: 'auto',
|
|
padding: '4px 0' }}>
|
|
{hatches.length === 0 && (
|
|
<div style={{ padding: 20, textAlign: 'center',
|
|
color: 'var(--text-muted)', fontSize: 10 }}>
|
|
Keine Schraffuren geladen.
|
|
</div>
|
|
)}
|
|
{hatches.map((hp) => (
|
|
<HatchListRow key={hp.index}
|
|
hp={hp}
|
|
isSelected={selHt === hp.index}
|
|
onSelect={() => setSelHt(hp.index)} />
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4,
|
|
padding: '6px 8px',
|
|
borderTop: '1px solid var(--border-light)' }}>
|
|
<BarToggle icon="upload_file" onClick={importHatchFile}
|
|
title="Aus .pat-Datei importieren" />
|
|
</div>
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden',
|
|
padding: '12px 14px', overflowY: 'auto' }}>
|
|
{(() => {
|
|
const hp = hatches.find(x => x.index === selHt)
|
|
if (!hp) return (
|
|
<div style={{ padding: 40, textAlign: 'center',
|
|
color: 'var(--text-muted)', fontSize: 11 }}>
|
|
Schraffur links auswählen.
|
|
</div>
|
|
)
|
|
return (
|
|
<>
|
|
<DetailSection title="Identität">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<HatchPreview pattern={hp} size={36} />
|
|
<input type="text" value={hp.name}
|
|
onChange={(ev) => {
|
|
const newName = ev.target.value
|
|
setHatches(arr => arr.map(x =>
|
|
x.index === hp.index ? { ...x, name: newName } : x))
|
|
}}
|
|
onBlur={(ev) => renameHatch(hp.index, ev.target.value)}
|
|
style={{ flex: 1, height: BAR_H, padding: '0 12px',
|
|
fontSize: 12, fontWeight: 500 }} />
|
|
<BarButton icon="delete"
|
|
onClick={() => { deleteHatch(hp.index); setSelHt(null) }}
|
|
title="Schraffur löschen" />
|
|
</div>
|
|
<div style={{ fontSize: 9, color: 'var(--text-muted)',
|
|
marginTop: 4 }}>
|
|
Typ: {hp.fillType}
|
|
{hp.isReference && ' · importiert'}
|
|
</div>
|
|
</DetailSection>
|
|
{hp.description && (
|
|
<DetailSection title="Beschreibung">
|
|
<div style={{ fontSize: 10,
|
|
color: 'var(--text-muted)',
|
|
lineHeight: 1.5 }}>
|
|
{hp.description}
|
|
</div>
|
|
</DetailSection>
|
|
)}
|
|
<DetailSection title="Verwendung">
|
|
<div style={{ fontSize: 10, color: 'var(--text-muted)',
|
|
lineHeight: 1.5 }}>
|
|
Schraffuren werden ueber das Gestaltung-Panel
|
|
oder via Layer Section-Hatch im Ebenen-Editor
|
|
auf Objekte/Layer angewendet.
|
|
</div>
|
|
</DetailSection>
|
|
</>
|
|
)
|
|
})()}
|
|
</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>
|
|
)
|
|
}
|