diff --git a/rhino/rhinopanel.py b/rhino/rhinopanel.py
index 535a45f..b08a2ff 100644
--- a/rhino/rhinopanel.py
+++ b/rhino/rhinopanel.py
@@ -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
diff --git a/src/components/ProjectSettingsDialog.jsx b/src/components/ProjectSettingsDialog.jsx
index 17bfad9..ea8a8d2 100644
--- a/src/components/ProjectSettingsDialog.jsx
+++ b/src/components/ProjectSettingsDialog.jsx
@@ -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 (
+
+ {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 (
@@ -39,70 +67,281 @@ 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 (
- { if (!isBuiltin) e.currentTarget.style.background = 'var(--bg-item-hover)' }}
- onMouseLeave={(e) => { if (!isBuiltin) e.currentTarget.style.background = 'transparent' }}
- >
-
onChange({ ...mat, color: ev.target.value })}
- title="Farbe"
- style={{ width: 18, height: 18, padding: 0, border: 'none',
- background: 'transparent', cursor: 'pointer' }} />
-
onChange({ ...mat, name: ev.target.value })}
- disabled={isBuiltin}
- placeholder="Name"
- style={{ minWidth: 0, height: BAR_H, padding: '0 10px',
- fontSize: 11,
- opacity: isBuiltin ? 0.7 : 1 }} />
-
-
onChange({ ...mat, scale: parseFloat(ev.target.value) || 1.0 })}
- title="Hatch-Skalierung"
- style={{ minWidth: 0, height: BAR_H, padding: '0 10px',
- fontSize: 11, textAlign: 'right' }} />
- {isLibrary ? (
-
L
- ) : isBuiltin ? (
-
B
- ) : (
-
- )}
+
{
+ 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 (
+
+
+
+ {label}
+
+
+ {display !== undefined ? display : v.toFixed(2)}
+
+
+
onChange(parseFloat(ev.target.value))}
+ style={{
+ width: '100%', marginTop: 4,
+ accentColor: 'var(--accent)',
+ opacity: disabled ? 0.5 : 1,
+ }} />
+
+ )
+}
+
+/* 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. Builtin: Name read-only. */
+function MaterialDetail({ mat, isBuiltin, hatchPatterns, onChange, onDelete }) {
+ if (!mat) {
+ return (
+
+ Kein Material ausgewaehlt.
+
+ Wähle links oder lege ein neues an.
+
+
+ )
+ }
+ const isLibrary = mat.source === 'library'
+ return (
+
+ {/* Identitaet */}
+
+
+
+
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 }} />
+
+ {isLibrary ? 'Aus Dossier-Library'
+ : isBuiltin ? 'Eingebaut (Builtin)'
+ : 'Lokales Material'}
+
+
+ {!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, scale: parseFloat(ev.target.value) || 1.0 })}
+ title="Skalierung"
+ style={{ width: 70, height: BAR_H, padding: '0 10px',
+ fontSize: 11, textAlign: 'right' }} />
+
+
+ Hatch-Pattern + Skalierung fuer die Sektion-Ansicht (Clipping Plane).
+
+
+
+
+ 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} />
+
)
}
@@ -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,18 +425,17 @@ export default function ProjectSettingsDialog({
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,
- }],
- }))
-
- const numberInputStyle = {
- flex: 1, height: BAR_H, padding: '0 12px',
- fontSize: 11, textAlign: 'right',
+ 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 ? {
@@ -166,82 +463,120 @@ export default function ProjectSettingsDialog({
{tab === 'defaults' && (
- <>
+
Voreinstellungen fuer neue Elemente. Pro-Element editierte
Werte bleiben davon unberuehrt.
-
-
+ setDefault('geschossHoehe', parseFloat(ev.target.value) || 3.0)}
- style={numberInputStyle} />
-
-
- setDefault('geschossHoehe', v || 3.0)}
+ hint="Vorgabe fuer neue Geschosse — pro Geschoss ueberschreibbar" />
+ setDefault('schnitthoehe', parseFloat(ev.target.value) || 1.0)}
- style={numberInputStyle} />
-
-
-
- setDefault('schnitthoehe', v || 1.0)}
+ hint="Höhe der horizontalen Schnitt-Plane über OKFF eines Geschosses" />
+
+
+ setDefault('schnittDepthBack', parseFloat(ev.target.value) || 8.0)}
- style={numberInputStyle} />
-
-
-
- setDefault('schnittHeightMin', parseFloat(ev.target.value))}
- style={numberInputStyle} />
-
-
- setDefault('schnittHeightMax', parseFloat(ev.target.value))}
- style={numberInputStyle} />
-
-
- >
+ step={0.5} min={0.5} suffix="m"
+ onChange={(v) => setDefault('schnittDepthBack', v || 8.0)}
+ hint="Default-Tiefe fuer neue Schnitte/Ansichten" />
+
setDefault('schnittHeightMin', v)} />
+ setDefault('schnittHeightMax', v)} />
+
+
)}
{tab === 'materials' && (
- <>
-
- Eingebaute Materialien (B) — Farbe + Hatch anpassbar, Name fix.
- Library-Materialien (L) aus Dossier-Library importiert.
- Lokale Materialien frei editierbar.
+
+ {/* 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.
+
+ )}
+
+
+
+
+
- {builtin.map((m) => (
-
+ {/* read-only Phase 1 */}} />
- ))}
- {draft.materials.map((m, i) => (
- setMat(i, nm)}
- onDelete={() => delMat(i)} />
- ))}
-
-
-
+ onChange={updateSelected}
+ onDelete={selMat?.kind === 'local' ? deleteSelected : null} />
- >
+
)}
diff --git a/src/lib/rhinoBridge.js b/src/lib/rhinoBridge.js
index 85af60d..fbf150e 100644
--- a/src/lib/rhinoBridge.js
+++ b/src/lib/rhinoBridge.js
@@ -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 }