Linientypen + Schraffuren-Tabs in Project-Settings + Datei-Import

Project-Settings hat jetzt 4 Tabs:
- Voreinstellungen (kompakte InlineNumberField, gruppiert in Sections)
- Materialien (List/Detail, ohne Hatch)
- Linientypen (List/Detail mit SVG-Strich-Vorschau)
- Schraffuren (List/Detail mit echtem HatchLine-Renderer)

Backend (rhinopanel.py):
- _list_linetypes_full liefert Segmente {length, type: Line/Space/Dot}
  (Mac Rhino 8 GetSegment returnt (length, isLine: bool))
- _list_hatch_patterns_full liefert HatchLines mit angle/base/offset/dashes
  (hl.Dashes optional ueber 3 API-Variants)
- CRUD: RENAME / DELETE / LOAD_DEFAULTS
- File-Import: IMPORT_LINETYPE_FILE (.lin), IMPORT_HATCH_FILE (.pat)
  via Eto.OpenFileDialog → Linetypes.Load / HatchPatterns.LoadFromFile

Frontend (ProjectSettingsDialog.jsx):
- LinetypePreview: SVG mit tile-fenster (4 Repetitions), Line als <line>,
  Dot als <circle>, currentColor fuer Renderer-Robustheit
- HatchPreview: rendert pro HatchLine alle parallelen Linien mit Angle,
  Offset (Spacing + Stagger), Dashes als stroke-dasharray
- TABLES_UPDATED Message vom Backend re-rendert Listen
- Import-Pills im List-Footer

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 03:14:28 +02:00
parent 8d3b3af882
commit a597b58c93
3 changed files with 764 additions and 1 deletions
+445 -1
View File
@@ -1,7 +1,11 @@
import { useState, useEffect } from 'react'
import Icon from './Icon'
import { BarToggle, BarButton, BAR_H } from './BarControls'
import { openLibrary, pickTextureFile, onMessage } from '../lib/rhinoBridge'
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 }) {
@@ -67,6 +71,248 @@ function TabBar({ tabs, active, onChange }) {
)
}
/* 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 }) {
@@ -343,6 +589,12 @@ export default function ProjectSettingsDialog({
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
@@ -367,6 +619,14 @@ export default function ProjectSettingsDialog({
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(() => {
@@ -436,6 +696,8 @@ export default function ProjectSettingsDialog({
<TabBar tabs={[
{ key: 'defaults', label: 'Voreinstellungen' },
{ key: 'materials', label: 'Materialien' },
{ key: 'linetypes', label: 'Linientypen' },
{ key: 'hatches', label: 'Schraffuren' },
]} active={tab} onChange={setTab} />
{/* Body */}
@@ -556,6 +818,188 @@ export default function ProjectSettingsDialog({
</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 */}