Project-Settings Material-List/Detail + PBR-UI (Phase B Stufe 2+3)

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>
This commit is contained in:
2026-05-24 17:11:23 +02:00
parent f760d1c54b
commit 0bf891641f
3 changed files with 561 additions and 144 deletions
+82 -5
View File
@@ -141,21 +141,62 @@ _PROJECT_SETTINGS_DEFAULTS = {
def _normalize_material(m):
"""Garantiert Material-Schema: name + color + hatch + scale + source
+ libraryId. source: 'local' | 'library' | 'builtin'. libraryId: UUID
wenn aus Library, sonst null. Felder fehlen bei alten Eintraegen — wir
setzen Defaults damit Frontend nicht null-checken muss."""
"""Garantiert Material-Schema. Felder:
- Identitaet: name, source ('local'|'library'|'builtin'), libraryId
- Section-Hatch (2D): color, hatch, scale
- PBR (3D-Render): roughness (0..1), reflection (0..1),
transparency (0..1), iorN (1.0..2.5)
- UV: uvScaleM (= 1 Welt-Meter ≙ wieviel der Textur)
- Texturen: textures = { diffuse, bump, roughness, transparency }
pro Slot {path: absolute string} oder null. Strength fuer Bump."""
if not isinstance(m, dict): return None
textures = m.get("textures") or {}
if not isinstance(textures, dict): textures = {}
def _tex(slot):
t = textures.get(slot)
if not isinstance(t, dict): return None
p = t.get("path")
if not p: return None
out = {"path": str(p)}
# Bump hat zusaetzlich strength (-1..1, default 0.5)
if slot == "bump":
try: out["strength"] = float(t.get("strength", 0.5))
except Exception: out["strength"] = 0.5
return out
return {
"name": m.get("name") or "Unbenannt",
"color": m.get("color") or "#888888",
"hatch": m.get("hatch") or "Solid",
"scale": float(m.get("scale", 1.0) or 1.0),
"source": m.get("source") or "local",
"libraryId": m.get("libraryId"), # None wenn nicht aus Library
"libraryId": m.get("libraryId"),
# PBR (3D-Render) — alle 0..1 ausser iorN
"roughness": _clamp01(m.get("roughness", 0.7)),
"reflection": _clamp01(m.get("reflection", 0.1)),
"transparency": _clamp01(m.get("transparency", 0.0)),
"iorN": _clamp(m.get("iorN", 1.0), 1.0, 2.5),
# UV-Skalierung (1 m = uvScaleM Textur-Tiles)
"uvScaleM": float(m.get("uvScaleM", 1.0) or 1.0),
# Texturen
"textures": {
"diffuse": _tex("diffuse"),
"bump": _tex("bump"),
"roughness": _tex("roughness"),
"transparency": _tex("transparency"),
},
}
def _clamp01(v):
try: return max(0.0, min(1.0, float(v)))
except Exception: return 0.0
def _clamp(v, lo, hi):
try: return max(lo, min(hi, float(v)))
except Exception: return lo
def load_project_settings(doc):
"""Liefert die Project-Settings als dict — mit Defaults-Merge wenn
Felder fehlen. Garantiert dass `defaults` und `materials` immer da
@@ -617,6 +658,12 @@ class EbenenBridge(panel_base.BaseBridge):
try: self._open_library()
except Exception as ex:
print("[EBENEN] open library:", ex)
elif t == "PICK_TEXTURE_FILE":
# Oeffnet macOS-File-Picker fuer Bild-Dateien. Antwort an
# Frontend via TEXTURE_PICKED-Message.
try: self._pick_texture_file(p)
except Exception as ex:
print("[EBENEN] pick texture:", ex)
# ---- Helpers ----
@@ -672,6 +719,36 @@ class EbenenBridge(panel_base.BaseBridge):
size=(440, 540),
on_save=on_save)
def _pick_texture_file(self, payload):
"""Oeffnet macOS-File-Picker (via Eto.Forms.OpenFileDialog) und
schickt den ausgewaehlten Pfad zurueck ans Frontend.
Payload: {slot: 'diffuse'|'bump'|...} — slot wird mit zurueckgegeben
damit das Frontend weiss welches Slot-Field aktualisieren."""
slot = payload.get("slot") or "diffuse"
try:
import Eto.Forms as forms
dlg = forms.OpenFileDialog()
dlg.Title = "Textur waehlen ({})".format(slot)
dlg.MultiSelect = False
f = forms.FileFilter("Bilder",
".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".tga")
dlg.Filters.Add(f)
dlg.Filters.Add(forms.FileFilter("Alle", ".*"))
try:
parent_form = sc.sticky.get("_dossier_active_settings_form")
except Exception:
parent_form = None
res = (dlg.ShowDialog(parent_form) if parent_form
else dlg.ShowDialog(None))
if str(res) != "Ok":
self.send("TEXTURE_PICKED", {"slot": slot, "path": None})
return
path = dlg.FileName or ""
self.send("TEXTURE_PICKED", {"slot": slot, "path": path})
except Exception as ex:
print("[EBENEN] pick texture:", ex)
self.send("TEXTURE_PICKED", {"slot": slot, "path": None})
def _open_library(self):
"""Oeffnet den Library-Browser als Satellite. Bridge bleibt offen
damit User mehrere Items hintereinander importieren kann; nach
+448 -113
View File
@@ -1,9 +1,9 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Icon from './Icon'
import { BarToggle, BarButton, BAR_H } from './BarControls'
import { openLibrary } from '../lib/rhinoBridge'
import { openLibrary, pickTextureFile, onMessage } from '../lib/rhinoBridge'
/* Pill-Stil Field — Label klein in Caps, Inhalt darunter, optional Hint */
/* Field — Stack-Layout fuer komplexe Inputs (mehrere Felder nebeneinander) */
function Field({ label, hint, children, style }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4,
@@ -21,6 +21,34 @@ function Field({ label, hint, children, style }) {
)
}
/* 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 (
@@ -39,36 +67,215 @@ function TabBar({ tabs, active, onChange }) {
)
}
function MaterialRow({ mat, hatchPatterns, onChange, onDelete, builtin }) {
const isBuiltin = builtin || mat.source === 'builtin'
/* 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 style={{
display: 'grid', gridTemplateColumns: '18px 1fr 92px 56px 22px',
<div onClick={onSelect}
style={{
display: 'grid', gridTemplateColumns: '14px 1fr 14px',
alignItems: 'center', gap: 6,
padding: '4px 8px',
borderRadius: 6,
background: isBuiltin ? 'var(--bg-section)' : 'transparent',
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 (!isBuiltin) e.currentTarget.style.background = 'var(--bg-item-hover)' }}
onMouseLeave={(e) => { if (!isBuiltin) e.currentTarget.style.background = 'transparent' }}
>
<input type="color" value={mat.color || '#888888'}
onChange={(ev) => onChange({ ...mat, color: ev.target.value })}
title="Farbe"
style={{ width: 18, height: 18, padding: 0, border: 'none',
background: 'transparent', cursor: 'pointer' }} />
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="Name"
style={{ minWidth: 0, height: BAR_H, padding: '0 10px',
fontSize: 11,
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={{ minWidth: 0, height: BAR_H, fontSize: 10 }}>
style={{ flex: 1, height: BAR_H, fontSize: 11 }}>
{(hatchPatterns || []).map(h => (
<option key={h} value={h}>{h}</option>
))}
@@ -76,33 +283,65 @@ function MaterialRow({ mat, hatchPatterns, onChange, onDelete, builtin }) {
<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="Hatch-Skalierung"
style={{ minWidth: 0, height: BAR_H, padding: '0 10px',
title="Skalierung"
style={{ width: 70, height: BAR_H, padding: '0 10px',
fontSize: 11, textAlign: 'right' }} />
{isLibrary ? (
<span style={{ fontSize: 9, fontWeight: 600,
color: 'var(--accent)',
display: 'inline-flex', alignItems: 'center',
justifyContent: 'center' }}
title="Aus Dossier-Library">L</span>
) : isBuiltin ? (
<span style={{ fontSize: 9, fontWeight: 600,
color: 'var(--text-muted)',
display: 'inline-flex', alignItems: 'center',
justifyContent: 'center' }}
title="Eingebaut">B</span>
) : (
<button onClick={onDelete} title="Loeschen"
style={{ background: 'transparent', border: 'none',
padding: 0, cursor: 'pointer',
color: 'var(--text-muted)',
display: 'inline-flex', alignItems: 'center',
justifyContent: 'center' }}
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--accent)'}
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-muted)'}>
<Icon name="close" size={14} />
</button>
)}
</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>
)
}
@@ -115,9 +354,68 @@ export default function ProjectSettingsDialog({
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 } }))
@@ -127,7 +425,8 @@ export default function ProjectSettingsDialog({
const delMat = (i) => setDraft(d => ({
...d, materials: d.materials.filter((_, idx) => idx !== i),
}))
const addMat = () => setDraft(d => ({
const addMat = () => {
setDraft(d => ({
...d,
materials: [...d.materials, {
name: 'Neues Material', color: '#aaaaaa',
@@ -135,10 +434,8 @@ export default function ProjectSettingsDialog({
source: 'local', libraryId: null,
}],
}))
const numberInputStyle = {
flex: 1, height: BAR_H, padding: '0 12px',
fontSize: 11, textAlign: 'right',
// Direkt selektieren — User kann gleich editieren
setSelMat({ kind: 'local', idx: draft.materials.length })
}
const wrapperStyle = embedded ? {
@@ -166,82 +463,120 @@ export default function ProjectSettingsDialog({
<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>
<Field label="Standard-Geschosshöhe (m)"
hint="Vorgabe fuer neue Geschosse — pro Geschoss ueberschreibbar">
<input type="number" step="0.05" min="1.0" max="10"
<DetailSection title="Geschoss">
<InlineNumberField label="Standard-Geschosshöhe"
value={draft.defaults.geschossHoehe ?? 3.0}
onChange={(ev) => setDefault('geschossHoehe', parseFloat(ev.target.value) || 3.0)}
style={numberInputStyle} />
</Field>
<Field label="Standard-Schnitthöhe (m)"
hint="Höhe der horizontalen Schnitt-Plane über OKFF eines Geschosses">
<input type="number" step="0.05" min="0.1" max="3"
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}
onChange={(ev) => setDefault('schnitthoehe', parseFloat(ev.target.value) || 1.0)}
style={numberInputStyle} />
</Field>
<div style={{ height: 1, background: 'var(--border-light)',
margin: '12px 0' }} />
<Field label="Standard-Schnitt-Tiefe hinten (m)"
hint="Default-Tiefe fuer neue Schnitte/Ansichten">
<input type="number" step="0.5" min="0.5"
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}
onChange={(ev) => setDefault('schnittDepthBack', parseFloat(ev.target.value) || 8.0)}
style={numberInputStyle} />
</Field>
<div style={{ display: 'flex', gap: 8 }}>
<Field label="Höhe unten (m)" style={{ flex: 1 }}>
<input type="number" step="0.1"
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}
onChange={(ev) => setDefault('schnittHeightMin', parseFloat(ev.target.value))}
style={numberInputStyle} />
</Field>
<Field label="Höhe oben (m)" style={{ flex: 1 }}>
<input type="number" step="0.1"
step={0.1} suffix="m"
onChange={(v) => setDefault('schnittHeightMin', v)} />
<InlineNumberField label="Höhe oben"
value={draft.defaults.schnittHeightMax ?? 12.0}
onChange={(ev) => setDefault('schnittHeightMax', parseFloat(ev.target.value))}
style={numberInputStyle} />
</Field>
step={0.1} suffix="m"
onChange={(v) => setDefault('schnittHeightMax', v)} />
</DetailSection>
</div>
</>
)}
{tab === 'materials' && (
<>
<div style={{ fontSize: 10, color: 'var(--text-muted)',
padding: '6px 0 10px', lineHeight: 1.5 }}>
Eingebaute Materialien (B) Farbe + Hatch anpassbar, Name fix.
Library-Materialien (L) aus Dossier-Library importiert.
Lokale Materialien frei editierbar.
<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>
{builtin.map((m) => (
<MaterialRow key={'b_' + m.name}
mat={m}
builtin
hatchPatterns={hatchPatterns}
onChange={() => {/* read-only Phase 1 */}} />
))}
{draft.materials.map((m, i) => (
<MaterialRow key={'u_' + i}
mat={m}
hatchPatterns={hatchPatterns}
onChange={(nm) => setMat(i, nm)}
onDelete={() => delMat(i)} />
))}
<div style={{ display: 'flex', gap: 6, marginTop: 12 }}>
<BarToggle icon="add" label="Material" onClick={addMat}
title="Neues Material anlegen" />
<BarToggle icon="inventory_2" label="Library"
onClick={openLibrary}
title="Material aus Dossier-Library importieren" />
</div>
</>
)}
</div>
+5
View File
@@ -189,6 +189,11 @@ export function toggleGridVisible(on) { send('TOGGLE_GRID_VISIBLE', { visible: !
export function openProjectSettings() { send('OPEN_PROJECT_SETTINGS', {}) }
// Dossier-Library (Material-/Symbol-/Object-Templates, Phase A: lokal+material)
export function openLibrary() { send('OPEN_LIBRARY', {}) }
// Material-Textur: macOS-File-Picker oeffnen, Antwort kommt via
// TEXTURE_PICKED-Message mit {slot, path}.
export function pickTextureFile(slot) {
send('PICK_TEXTURE_FILE', { slot: slot || 'diffuse' })
}
// Schnitt/Ansicht — interaktiver 2-Punkt-Pick im Rhino-Viewport. Erzeugt
// eine neue Zeichnungsebene type=schnitt + 2D-Plan-Symbol + aktiviert sie.
// opts: { cutAtLine: bool, depthBack: m, heightMin: m, heightMax: m, namePrefix }