diff --git a/scripts/probe-material-tiles.mjs b/scripts/probe-material-tiles.mjs new file mode 100644 index 0000000..1330595 --- /dev/null +++ b/scripts/probe-material-tiles.mjs @@ -0,0 +1,109 @@ +// Probe (Materialien-Tab — PBR-Kugel-Vorschau + Filter): öffnet den Ressourcen- +// Manager, wechselt in den neuen „Materialien"-Tab, prüft dass Kacheln +// erscheinen, mindestens eine Kugel-Vorschau (Bild) gerendert wurde, und dass +// Such-/Kategorie-Filter die Trefferzahl verändern. +// +// Aufruf: PROBE_URL=http://localhost:5187/ node scripts/probe-material-tiles.mjs +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5187/"; +const OUT = "scripts"; + +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1360, height: 900, deviceScaleFactor: 2 }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const shot = (n) => page.screenshot({ path: `${OUT}/${n}` }); + +async function clickByText(selector, text) { + const handle = await page.evaluateHandle( + (sel, t) => + [...document.querySelectorAll(sel)].find((el) => el.textContent.includes(t)), + selector, + text, + ); + const el = handle.asElement(); + if (!el) throw new Error(`nicht gefunden: ${selector} ~ "${text}"`); + await el.click(); + return el; +} + +await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 20000 }); +await page.waitForSelector(".topbar", { timeout: 20000 }); +await sleep(600); + +// 1) Ressourcen öffnen → in den Materialien-Tab wechseln. +await page.waitForSelector('button[aria-label="Ressourcen"]', { timeout: 8000 }); +await page.click('button[aria-label="Ressourcen"]'); +await page.waitForSelector(".res-drawer", { timeout: 8000 }); +await clickByText(".res-tab-btn", "Materialien"); +await page.waitForSelector(".mat-lib-grid .mat-tile", { timeout: 8000 }); + +const tileCount = await page.$$eval(".mat-lib-grid .mat-tile", (els) => els.length); +console.log("Material-Kacheln (ungefiltert):", tileCount); + +// 2) Warten, bis mindestens eine Kugel-Vorschau gerendert ist (Bild im Tile). +await page.waitForFunction( + () => document.querySelectorAll(".mat-sphere img").length > 0, + { timeout: 10000 }, +); +// Sichtbare Kacheln lazy nachladen lassen (Warteschlange rendert seriell). +await sleep(2500); +const renderedCount = await page.$$eval(".mat-sphere img", (els) => els.length); +console.log("Gerenderte Kugel-Vorschauen (sichtbarer Bereich):", renderedCount); + +// Erstes Vorschaubild darf kein leeres/transparentes 1x1-Pixelbild sein. +const firstSrc = await page.$eval(".mat-sphere img", (img) => img.src); +console.log("Erste Vorschau-DataURL-Länge:", firstSrc.length); + +await shot("probe-material-tiles-1-grid.png"); + +// 3) Kategorie-Filter: erste konkrete Kategorie-Chip anklicken (nicht "Alle"). +const chipLabels = await page.$$eval(".mat-chip", (els) => els.map((e) => e.textContent)); +console.log("Kategorie-Chips:", chipLabels); +const firstCategoryChip = chipLabels[1]; // Index 0 = "Alle". +await clickByText(".mat-chip", firstCategoryChip); +await sleep(300); +const filteredByCategory = await page.$$eval(".mat-lib-grid .mat-tile", (els) => els.length); +console.log(`Kacheln nach Kategorie-Filter ("${firstCategoryChip}"):`, filteredByCategory); +await shot("probe-material-tiles-2-category.png"); + +// Filter zurücksetzen ("Alle"). +await clickByText(".mat-chip", "Alle"); +await sleep(200); + +// 4) Textsuche: nach "Holz" suchen (mehrere Bibliotheks-Einträge betroffen). +await page.type(".mat-search", "Holz"); +await sleep(400); +const filteredByText = await page.$$eval(".mat-lib-grid .mat-tile", (els) => els.length); +console.log('Kacheln nach Textsuche ("Holz"):', filteredByText); +await shot("probe-material-tiles-3-search.png"); + +// 5) Kachel auswählen → aktiv-Zustand + Statuszeile prüfen. +await page.click(".mat-lib-grid .mat-tile"); +await sleep(200); +const hasActive = await page.$(".mat-lib-grid .mat-tile.active"); +const selectedLine = await page.$eval(".mat-lib-selected", (el) => el.textContent).catch(() => null); +console.log("Aktive Kachel vorhanden:", !!hasActive, "| Auswahl-Zeile:", selectedLine); +await shot("probe-material-tiles-4-selected.png"); + +console.log("--- console ---"); +for (const l of logs.slice(-30)) console.log(l); + +if (tileCount === 0) throw new Error("Keine Material-Kacheln gefunden"); +if (renderedCount === 0) throw new Error("Keine Kugel-Vorschau gerendert"); +if (firstSrc.length < 200) throw new Error("Vorschau-DataURL wirkt leer"); +if (filteredByCategory >= tileCount) throw new Error("Kategorie-Filter hat nichts gefiltert"); +if (filteredByText >= tileCount) throw new Error("Textsuche hat nichts gefiltert"); +if (!hasActive) throw new Error("Kachel-Auswahl hat keinen aktiven Zustand gesetzt"); + +await browser.close(); +console.log("done"); diff --git a/src/i18n/de.ts b/src/i18n/de.ts index b38a75f..b3a5f61 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -433,6 +433,16 @@ export const de = { "resources.tab.components": "Bauteile", "resources.tab.hatches": "Schraffuren", "resources.tab.lines": "Linien", + "resources.tab.materials": "Materialien", + + "resources.materials.hint": + "Kugel-Vorschau der eingebauten PBR-Materialbibliothek — durchsuchen und vergleichen. Zuweisen an ein Bauteil weiterhin über dessen Spalte „Material …\".", + "resources.materials.searchPlaceholder": "Material suchen …", + "resources.materials.categoryLabel": "Kategorie", + "resources.materials.categoryAll": "Alle", + "resources.materials.selected": "Ausgewählt: {name} ({category})", + "resources.materials.empty": "Keine Materialien gefunden.", + "resources.materials.count": "{shown} von {total} Materialien", "resources.add": "Neu", "resources.import.lin": "⤓ .lin importieren", "resources.import.pat": "⤓ .pat importieren", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 341840c..e51a52a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -430,6 +430,16 @@ export const en: Record = { "resources.tab.components": "Components", "resources.tab.hatches": "Hatches", "resources.tab.lines": "Lines", + "resources.tab.materials": "Materials", + + "resources.materials.hint": + "Sphere preview of the built-in PBR material library — browse and compare. Assign to a component still via its “Material …” column.", + "resources.materials.searchPlaceholder": "Search materials …", + "resources.materials.categoryLabel": "Category", + "resources.materials.categoryAll": "All", + "resources.materials.selected": "Selected: {name} ({category})", + "resources.materials.empty": "No materials found.", + "resources.materials.count": "{shown} of {total} materials", "resources.add": "New", "resources.import.lin": "⤓ Import .lin", "resources.import.pat": "⤓ Import .pat", diff --git a/src/materials/spherePreview.ts b/src/materials/spherePreview.ts new file mode 100644 index 0000000..dad7b78 --- /dev/null +++ b/src/materials/spherePreview.ts @@ -0,0 +1,223 @@ +// PBR-Materialkugel-Vorschau (CAD/DCC-üblich) für den Ressourcen-Manager. +// EIN geteilter offscreen three.js-Renderer erzeugt nacheinander pro Material +// eine Kugel und cached das Ergebnis als PNG-DataURL — bewusst NICHT ein +// WebGL-Context je Kachel (Browser begrenzt gleichzeitige Kontexte). Aufträge +// laufen seriell über eine Warteschlange, damit Renderer/Szene/Material sich +// nie zwei Materialien gleichzeitig "gehören". +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import * as THREE from "three"; +import type { MaterialAsset, MaterialMapKind } from "./library"; + +/** Kantenlänge der gerenderten Vorschau in Pixeln (CSS skaliert die Kachel). */ +const PREVIEW_SIZE = 128; + +type PreviewStatus = "pending" | "ready" | "error"; + +interface PreviewEntry { + status: PreviewStatus; + url?: string; + subscribers: Set<(url: string) => void>; +} + +/** Fertige/aktive Vorschauen, Schlüssel = stabile Material-Signatur. */ +const cache = new Map(); + +/** + * Stabile Cache-Signatur eines Assets: ändert sich, sobald sich eine Karten- + * URL ändert (z. B. weil die Bibliothek neu generiert wurde) — dadurch wird + * eine veraltete Vorschau automatisch verworfen, auch bei gleicher ID. + */ +function signatureOf(asset: MaterialAsset): string { + const m = asset.maps; + return [ + asset.id, + m.color ?? "", + m.normal ?? "", + m.roughness ?? "", + m.metalness ?? "", + m.ao ?? "", + m.displacement ?? "", + ].join("|"); +} + +// ── Geteilte Render-Infrastruktur (eine Instanz für die gesamte App-Laufzeit) ─ + +let renderer: THREE.WebGLRenderer | null = null; +let scene: THREE.Scene | null = null; +let camera: THREE.PerspectiveCamera | null = null; +let sphereMesh: THREE.Mesh | null = + null; + +function ensureRenderer(): void { + if (renderer) return; + + renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + preserveDrawingBuffer: true, // nötig für toDataURL() nach dem Render. + }); + renderer.setSize(PREVIEW_SIZE, PREVIEW_SIZE); + renderer.setPixelRatio(1); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setClearColor(0x000000, 0); // transparent — die Kachel liefert den Hintergrund. + + scene = new THREE.Scene(); + camera = new THREE.PerspectiveCamera(32, 1, 0.1, 10); + camera.position.set(0, 0.12, 3.1); + camera.lookAt(0, 0, 0); + + // Neutrale Studio-Beleuchtung: Himmelslicht (füllt Schatten weich auf) plus + // ein Key-Light für Glanzlichter/Rauheits-Lesbarkeit, ein dezentes Fülllicht + // gegen zu harte Kernschatten. + scene.add(new THREE.HemisphereLight(0xffffff, 0x2c2c28, 1.15)); + const key = new THREE.DirectionalLight(0xffffff, 2.2); + key.position.set(2.2, 2.6, 2.4); + scene.add(key); + const fill = new THREE.DirectionalLight(0xffffff, 0.45); + fill.position.set(-2.2, 0.5, 1.3); + scene.add(fill); + + const geometry = new THREE.SphereGeometry(1, 48, 48); + const material = new THREE.MeshStandardMaterial({ roughness: 1, metalness: 0 }); + sphereMesh = new THREE.Mesh(geometry, material); + scene.add(sphereMesh); +} + +// ── Textur-Ladecache (geteilte Quell-Texturen je URL) ────────────────────── + +const textureLoader = new THREE.TextureLoader(); +const textureCache = new Map(); + +function loadTexture(url: string, srgb: boolean): Promise { + const key = `${srgb ? "s" : "l"}|${url}`; + const cached = textureCache.get(key); + if (cached) return Promise.resolve(cached); + return new Promise((resolve, reject) => { + textureLoader.load( + url, + (tex) => { + tex.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace; + textureCache.set(key, tex); + resolve(tex); + }, + undefined, + () => reject(new Error(`Textur nicht ladbar: ${url}`)), + ); + }); +} + +const MAP_SLOTS: { kind: MaterialMapKind; srgb: boolean }[] = [ + { kind: "color", srgb: true }, + { kind: "normal", srgb: false }, + { kind: "roughness", srgb: false }, + { kind: "metalness", srgb: false }, + { kind: "ao", srgb: false }, + { kind: "displacement", srgb: false }, +]; + +/** Rendert die Vorschau-Kugel eines Assets und liefert eine PNG-DataURL. */ +async function renderPreview(asset: MaterialAsset): Promise { + ensureRenderer(); + const mat = sphereMesh!.material; + + // Karten der zuvor gerenderten Kugel lösen (kein Überstand ins nächste Material). + mat.map = null; + mat.normalMap = null; + mat.roughnessMap = null; + mat.metalnessMap = null; + mat.aoMap = null; + mat.displacementMap = null; + mat.displacementScale = 0; + + const loaded = await Promise.allSettled( + MAP_SLOTS.map(async ({ kind, srgb }) => { + const url = asset.maps[kind]; + if (!url) return null; + const tex = await loadTexture(url, srgb); + return { kind, tex }; + }), + ); + for (const res of loaded) { + if (res.status !== "fulfilled" || !res.value) continue; + const { kind, tex } = res.value; + switch (kind) { + case "color": + mat.map = tex; + break; + case "normal": + mat.normalMap = tex; + break; + case "roughness": + mat.roughnessMap = tex; + break; + case "metalness": + mat.metalnessMap = tex; + break; + case "ao": + mat.aoMap = tex; + break; + case "displacement": + mat.displacementMap = tex; + // Dezent — die eigentliche Tiefe liest sich über die Normal-Map. + mat.displacementScale = 0.04; + break; + } + } + // Weiß als Albedo-Basis (Farb-Karte bleibt unverfälscht), sonst neutrales Grau. + mat.color.set(asset.maps.color ? 0xffffff : 0xcccccc); + mat.metalness = asset.maps.metalness ? 1 : 0; + mat.needsUpdate = true; + + renderer!.render(scene!, camera!); + return renderer!.domElement.toDataURL("image/png"); +} + +// ── Serielle Warteschlange (ein Render-Auftrag zur Zeit, geteilter Kontext) ─ + +let queue: Promise = Promise.resolve(); + +/** + * Fordert die Kugel-Vorschau eines Bibliotheks-Materials an. Liefert sofort + * die gecachte DataURL, falls schon fertig gerendert; andernfalls wird der + * Render-Auftrag (serialisiert über die geteilte Warteschlange) eingereiht und + * `onReady` genau einmal mit der fertigen URL aufgerufen. Wiederholte Aufrufe + * mit demselben Material treffen den Cache (kein erneutes Rendern) — außer die + * Karten-Signatur hat sich geändert (Bibliothek aktualisiert). + */ +export function requestMaterialPreview( + asset: MaterialAsset, + onReady: (url: string) => void, +): string | undefined { + const key = signatureOf(asset); + let entry = cache.get(key); + if (!entry) { + entry = { status: "pending", subscribers: new Set() }; + cache.set(key, entry); + queue = queue + .then(() => renderPreview(asset)) + .then((url) => { + entry!.status = "ready"; + entry!.url = url; + for (const cb of entry!.subscribers) cb(url); + entry!.subscribers.clear(); + }) + .catch((err) => { + entry!.status = "error"; + entry!.subscribers.clear(); + console.error("[spherePreview] Vorschau-Render fehlgeschlagen:", err); + }); + } + if (entry.status === "ready" && entry.url) return entry.url; + entry.subscribers.add(onReady); + return undefined; +} + +/** Meldet einen Abonnenten wieder ab (Aufräumen bei Unmount vor Fertigstellung). */ +export function cancelMaterialPreview( + asset: MaterialAsset, + onReady: (url: string) => void, +): void { + cache.get(signatureOf(asset))?.subscribers.delete(onReady); +} diff --git a/src/styles.css b/src/styles.css index 6b6276c..916d786 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3888,6 +3888,80 @@ body { font-size: 12px; } +/* ── Materialien-Tab: Bibliothek durchsuchen (Kugel-Vorschau, Filter) ────── */ +.mat-lib-filterbar { + display: flex; + flex-wrap: wrap; + gap: 8px 10px; + align-items: center; +} +.mat-lib-filterbar .mat-search { + flex: 1 1 200px; + min-width: 160px; +} +.mat-lib-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + flex: 1 1 100%; +} +.mat-chip { + padding: 4px 11px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-2); + color: var(--ink-2); + font-size: 11.5px; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +.mat-chip:hover { + border-color: var(--accent-border); + color: var(--ink); +} +.mat-chip.active { + background: var(--active-dim); + border-color: var(--active); + color: var(--ink); +} +.mat-lib-selected { + font-size: 12px; + color: var(--ink-2); +} +.mat-lib-grid { + grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); + gap: 10px; +} +.mat-sphere { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 999px; + background: var(--input); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.mat-sphere img { + width: 100%; + height: 100%; + object-fit: contain; +} +.mat-tile-badge { + align-self: center; + font-size: 9.5px; + letter-spacing: 0.02em; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 999px; + padding: 1px 7px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + /* Standort-Import-Dialog (ContextImportDialog) — nutzt das imp-*-Modal-Gerüst, ergänzt nur die inneren Suchfelder/Checkboxen (cim-*-Namespace). */ .cim-search-row { diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx index 2390e94..bd61696 100644 --- a/src/ui/ResourceManager.tsx +++ b/src/ui/ResourceManager.tsx @@ -29,6 +29,7 @@ import { PEN_WEIGHTS } from "../model/types"; import { parseLin } from "../io/linParser"; import { parsePat } from "../io/patParser"; import { MATERIAL_LIBRARY } from "../materials/library"; +import type { MaterialAsset } from "../materials/library"; import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../materials/runtime"; import { searchMaterials, @@ -37,6 +38,7 @@ import { type AmbientMaterial, type AmbientResolution, } from "../materials/ambientcg"; +import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview"; import { EyeIcon } from "./EyeIcon"; import { t } from "../i18n"; @@ -1182,6 +1184,172 @@ function patToHatches(text: string): Omit[] { })); } +// ── Materialien (PBR-Bibliothek durchsuchen) ─────────────────────────────── +// Eigenständiger Tab, der die eingebaute Materialbibliothek (MATERIAL_LIBRARY) +// als Kachel-Grid mit gerenderter PBR-Kugel-Vorschau zeigt — Name/Kategorie +// als Badge, Suche + Kategorie-Chips kombinierbar. Die Zuweisung an ein +// konkretes Bauteil bleibt der bestehende Weg (Spalte „Material" der Bauteil- +// Tabelle → MaterialPicker); dieser Tab dient dem Durchsuchen/Vergleichen der +// Bibliothek, Klick auf eine Kachel markiert sie (aktiv, wie `mat-tile.active` +// im Picker) und zeigt Name/Kategorie in der Statuszeile. + +/** + * Alle in der Bibliothek vorkommenden Kategorien, alphabetisch. Das Manifest + * schreibt Kategorien uneinheitlich („PavingStones" neben „Paving Stones") — + * für Chips und Filter zählt der normalisierte Schlüssel (ohne Leerzeichen, + * case-insensitiv); angezeigt wird die zuerst gesehene Schreibweise. + */ +const categoryKey = (c: string) => c.replace(/\s+/g, "").toLowerCase(); +const MATERIAL_CATEGORIES: string[] = (() => { + const byKey = new Map(); + for (const a of MATERIAL_LIBRARY) { + const k = categoryKey(a.category); + if (!byKey.has(k)) byKey.set(k, a.category); + } + return Array.from(byKey.values()).sort((a, b) => a.localeCompare(b)); +})(); + +/** + * Eine Bibliotheks-Kachel: rendert erst eine PBR-Kugel-Vorschau, sobald sie + * tatsächlich sichtbar ist (IntersectionObserver) — bei ~90 Materialien sonst + * unnötige GPU-Arbeit für Kacheln außerhalb des Sichtfensters. Der eigentliche + * Render läuft ohnehin seriell über einen einzigen geteilten WebGL-Kontext + * (spherePreview.ts), die Sichtbarkeitsprüfung drosselt zusätzlich die + * Warteschlange auf das gerade Interessante. + */ +function MaterialLibraryTile({ + asset, + active, + onSelect, +}: { + asset: MaterialAsset; + active: boolean; + onSelect: () => void; +}) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + const [previewUrl, setPreviewUrl] = useState(undefined); + + useEffect(() => { + const el = ref.current; + if (!el || visible) return; + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) setVisible(true); + }, + { rootMargin: "200px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, [visible]); + + useEffect(() => { + if (!visible) return; + const handleReady = (url: string) => setPreviewUrl(url); + const cached = requestMaterialPreview(asset, handleReady); + if (cached) setPreviewUrl(cached); + return () => cancelMaterialPreview(asset, handleReady); + }, [visible, asset]); + + return ( + + ); +} + +function MaterialsTab() { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState(null); + const [selectedId, setSelectedId] = useState(null); + + const filtered = MATERIAL_LIBRARY.filter((asset) => { + if (category && categoryKey(asset.category) !== categoryKey(category)) return false; + const q = query.trim().toLowerCase(); + if (q && !asset.name.toLowerCase().includes(q) && !asset.id.toLowerCase().includes(q)) { + return false; + } + return true; + }); + const selected = MATERIAL_LIBRARY.find((a) => a.id === selectedId); + + return ( +
+
{t("resources.materials.hint")}
+ +
+ setQuery(e.target.value)} + /> +
+ + {MATERIAL_CATEGORIES.map((c) => ( + + ))} +
+
+ + {selected && ( +
+ {t("resources.materials.selected", { + name: selected.name, + category: selected.category, + })} +
+ )} + +
+ {filtered.map((asset) => ( + setSelectedId((cur) => (cur === asset.id ? null : asset.id))} + /> + ))} +
+ {filtered.length === 0 && } + +
+ + {t("resources.materials.count", { + shown: filtered.length, + total: MATERIAL_LIBRARY.length, + })} + +
+
+ ); +} + // ── Gemeinsame kleine Bausteine ──────────────────────────────────────────── function AddButton({ label, onClick }: { label: string; onClick: () => void }) { @@ -1200,12 +1368,13 @@ function EmptyState({ text }: { text: string }) { // ── Manager-Schublade ────────────────────────────────────────────────────── -type TabId = "components" | "hatches" | "lines"; +type TabId = "components" | "hatches" | "lines" | "materials"; const TABS: { id: TabId; labelKey: string }[] = [ { id: "components", labelKey: "resources.tab.components" }, { id: "hatches", labelKey: "resources.tab.hatches" }, { id: "lines", labelKey: "resources.tab.lines" }, + { id: "materials", labelKey: "resources.tab.materials" }, ]; /** Bündel der immutablen Mutationen (von App über setProject bereitgestellt). */ @@ -1305,6 +1474,7 @@ export function ResourceManager({ onImportLineStyles={handlers.onImportLineStyles} /> )} + {tab === "materials" && }