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 (
{label}
{children}
{hint && (
{hint}
)}
)
}
/* InlineTextField — Label links, Text-Input rechts (kompakt) */
function InlineTextField({ label, value, onChange, placeholder, width = 240 }) {
return (
{label}
onChange(ev.target.value)}
style={{ width, height: BAR_H, padding: '0 12px',
fontSize: 11 }} />
)
}
/* TextareaField — Label oben, mehrzeiliges Input darunter (full-width) */
function TextareaField({ label, value, onChange, rows = 3, placeholder }) {
return (
)
}
/* InlineNumberField — Label links, schmales Number-Input rechts (kompakt) */
function InlineNumberField({ label, hint, value, onChange, step, min, max, suffix }) {
return (
{label}
onChange(parseFloat(ev.target.value))}
style={{ width: 80, height: BAR_H, padding: '0 10px',
fontSize: 11, textAlign: 'right' }} />
{suffix && (
{suffix}
)}
{hint && (
{hint}
)}
)
}
/* Pill-Tabs — gleicher Stil wie BarToggle aus der Oberleiste */
function TabBar({ tabs, active, onChange }) {
return (
{tabs.map(t => (
onChange(t.key)} />
))}
)
}
/* 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 (
)
}
// 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 (
)
}
// 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( )
} else if (seg.type === 'Dot') {
parts.push( )
}
x += drawLen
}
}
return (
{parts}
)
}
/* LinetypeListRow — Zeile mit Name + Preview + selected-state */
function LinetypeListRow({ lt, isSelected, onSelect }) {
return (
{
if (!isSelected) e.currentTarget.style.background = 'var(--bg-item-hover)'
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.background = 'transparent'
}}>
{lt.name || 'Unbenannt'}
)
}
/* 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 (
)
}
if (ft === 'gradient') {
const id = 'gr_' + (pattern.index ?? 0)
return (
)
}
// 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( )
}
})
// Fallback wenn keine HatchLines kamen (Hatch ist 'Lines' aber leer)
const hasLines = pieces.length > 0
return (
{hasLines ? pieces : (
// Fallback: einzelne Diagonale alle 4 px
Array.from({length: Math.ceil(size * 2 / 4)}, (_, i) => i * 4 - size)
.map(i => (
))
)}
)
}
/* HatchListRow */
function HatchListRow({ hp, isSelected, onSelect }) {
return (
{
if (!isSelected) e.currentTarget.style.background = 'var(--bg-item-hover)'
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.background = 'transparent'
}}>
{hp.name || 'Unbenannt'}
{hp.fillType}
)
}
/* 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 (
{
if (!isSelected) e.currentTarget.style.background = 'var(--bg-item-hover)'
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.background = 'transparent'
}}>
{mat.name || 'Unbenannt'}
{isLibrary ? 'L' : isBuiltin ? 'B' : ''}
)
}
/* 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 (
)
}
/* 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 (
{!hasPath && (
)}
{label}
{hasPath ? filename : 'Keine Textur'}
pickTextureFile(slot)}
title="Datei waehlen"
disabled={disabled} />
{hasPath && (
onChange(null)}
title="Textur entfernen"
disabled={disabled} />
)}
)
}
/* DetailSection — Section-Header + Body, immer offen (collapsible spaeter) */
function DetailSection({ title, children }) {
return (
)
}
/* 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 (
Kein Material ausgewaehlt.
Wähle links oder lege ein neues an.
)
}
const isLibrary = mat.source === 'library'
return (
{/* Identitaet */}
{!isBuiltin && onDelete && (
)}
onChange({ ...mat, color: ev.target.value })}
title="Farbe"
style={{ width: 32, height: BAR_H, padding: 0, border: 'none',
background: 'transparent', cursor: 'pointer' }} />
onChange({ ...mat, color: ev.target.value })}
style={{ flex: 1, height: BAR_H, padding: '0 12px',
fontSize: 11, fontFamily: 'var(--font-mono)' }} />
onChange({ ...mat, textures: {
...(mat.textures || {}), diffuse: t } })}
disabled={isBuiltin} />
onChange({ ...mat, textures: {
...(mat.textures || {}), bump: t } })}
disabled={isBuiltin} />
onChange({ ...mat, textures: {
...(mat.textures || {}), roughness: t } })}
disabled={isBuiltin} />
onChange({ ...mat, textures: {
...(mat.textures || {}), transparency: t } })}
disabled={isBuiltin} />
onChange({ ...mat, uvScaleM: v || 1.0 })}
hint="1 Welt-Meter ≙ wieviel Textur-Tile" />
onChange({ ...mat, roughness: v })}
disabled={isBuiltin} />
onChange({ ...mat, reflection: v })}
disabled={isBuiltin} />
onChange({ ...mat, transparency: v })}
disabled={isBuiltin} />
onChange({ ...mat, iorN: v })}
disabled={isBuiltin} />
)
}
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 (
{/* Body */}
{tab === 'defaults' && (
setProject('name', v)} />
setProject('number', v)} />
setProject('address', v)} />
setProject('bauherr', v)} />
setProject('architekt', 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." />
setProject('notes', v)} />
Voreinstellungen fuer neue Elemente. Pro-Element editierte
Werte bleiben davon unberuehrt.
setDefault('geschossHoehe', v || 3.0)}
hint="Vorgabe fuer neue Geschosse — pro Geschoss ueberschreibbar" />
setDefault('schnitthoehe', v || 1.0)}
hint="Höhe der horizontalen Schnitt-Plane über OKFF eines Geschosses" />
setDefault('schnittDepthBack', v || 8.0)}
hint="Default-Tiefe fuer neue Schnitte/Ansichten" />
setDefault('schnittHeightMin', v)} />
setDefault('schnittHeightMax', v)} />
)}
{tab === 'materials' && (
{/* Links: Liste */}
setMatSearch(ev.target.value)}
placeholder="Suchen…"
style={{ width: '100%', height: BAR_H,
padding: '0 12px', fontSize: 11,
boxSizing: 'border-box' }} />
{filteredBuiltin.length > 0 && (
Eingebaut
)}
{filteredBuiltin.map((m) => (
setSelMat({ kind: 'builtin', name: m.name })} />
))}
{filteredLocal.length > 0 && (
Projekt
)}
{filteredLocal.map(({ m, i }) => (
setSelMat({ kind: 'local', idx: i })} />
))}
{filteredBuiltin.length === 0 && filteredLocal.length === 0 && (
Nichts gefunden.
)}
{/* Rechts: Detail */}
)}
{tab === 'linetypes' && (
{linetypes.length === 0 && (
Keine Linientypen geladen.
)}
{linetypes.map((lt) => (
setSelLt(lt.index)} />
))}
{(() => {
const lt = linetypes.find(x => x.index === selLt)
if (!lt) return (
Linientyp links auswählen.
)
return (
<>
{
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 && (
{ deleteLinetype(lt.index); setSelLt(null) }}
title="Linientyp löschen" />
)}
{lt.segments.length > 0 && (
{lt.segments.map((s, i) => (
{String(i+1).padStart(2, '0')} —
{' '}{s.type.padEnd(10)}
{' '}{s.length.toFixed(3)}
))}
)}
>
)
})()}
)}
{tab === 'hatches' && (
{hatches.length === 0 && (
Keine Schraffuren geladen.
)}
{hatches.map((hp) => (
setSelHt(hp.index)} />
))}
{(() => {
const hp = hatches.find(x => x.index === selHt)
if (!hp) return (
Schraffur links auswählen.
)
return (
<>
{
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 }} />
{ deleteHatch(hp.index); setSelHt(null) }}
title="Schraffur löschen" />
Typ: {hp.fillType}
{hp.isReference && ' · importiert'}
{hp.description && (
{hp.description}
)}
Schraffuren werden ueber das Gestaltung-Panel
oder via Layer Section-Hatch im Ebenen-Editor
auf Objekte/Layer angewendet.
>
)
})()}
)}
{/* Footer — Pill-Buttons */}
)
}