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 ( +
+
{title}
+ {children} +
+ ) +} + +/* 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 }