Resource Manager: Materialien-Tab mit PBR-Kugel-Vorschau, Suche und Kategorie-Chips
Geteilter Offscreen-three.js-Renderer (ein Kontext, serielle Queue, Cache per Map-Signatur) rendert 128px-Kugeln lazy via IntersectionObserver. Kategorie-Chips aus der Bibliothek abgeleitet, uneinheitliche Manifest- Schreibweisen normalisiert; Suche und Kategorie kombinierbar. Kachel-Klick markiert aktiv (gleiche Sprache wie MaterialPicker). Puppeteer-Probe scripts/probe-material-tiles.mjs prüft Grid, Lazy-Render und Filter.
This commit is contained in:
@@ -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");
|
||||||
@@ -433,6 +433,16 @@ export const de = {
|
|||||||
"resources.tab.components": "Bauteile",
|
"resources.tab.components": "Bauteile",
|
||||||
"resources.tab.hatches": "Schraffuren",
|
"resources.tab.hatches": "Schraffuren",
|
||||||
"resources.tab.lines": "Linien",
|
"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.add": "Neu",
|
||||||
"resources.import.lin": "⤓ .lin importieren",
|
"resources.import.lin": "⤓ .lin importieren",
|
||||||
"resources.import.pat": "⤓ .pat importieren",
|
"resources.import.pat": "⤓ .pat importieren",
|
||||||
|
|||||||
@@ -430,6 +430,16 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.tab.components": "Components",
|
"resources.tab.components": "Components",
|
||||||
"resources.tab.hatches": "Hatches",
|
"resources.tab.hatches": "Hatches",
|
||||||
"resources.tab.lines": "Lines",
|
"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.add": "New",
|
||||||
"resources.import.lin": "⤓ Import .lin",
|
"resources.import.lin": "⤓ Import .lin",
|
||||||
"resources.import.pat": "⤓ Import .pat",
|
"resources.import.pat": "⤓ Import .pat",
|
||||||
|
|||||||
@@ -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<string, PreviewEntry>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<THREE.SphereGeometry, THREE.MeshStandardMaterial> | 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<string, THREE.Texture>();
|
||||||
|
|
||||||
|
function loadTexture(url: string, srgb: boolean): Promise<THREE.Texture> {
|
||||||
|
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<string> {
|
||||||
|
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<void> = 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);
|
||||||
|
}
|
||||||
@@ -3888,6 +3888,80 @@ body {
|
|||||||
font-size: 12px;
|
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,
|
/* Standort-Import-Dialog (ContextImportDialog) — nutzt das imp-*-Modal-Gerüst,
|
||||||
ergänzt nur die inneren Suchfelder/Checkboxen (cim-*-Namespace). */
|
ergänzt nur die inneren Suchfelder/Checkboxen (cim-*-Namespace). */
|
||||||
.cim-search-row {
|
.cim-search-row {
|
||||||
|
|||||||
+171
-1
@@ -29,6 +29,7 @@ import { PEN_WEIGHTS } from "../model/types";
|
|||||||
import { parseLin } from "../io/linParser";
|
import { parseLin } from "../io/linParser";
|
||||||
import { parsePat } from "../io/patParser";
|
import { parsePat } from "../io/patParser";
|
||||||
import { MATERIAL_LIBRARY } from "../materials/library";
|
import { MATERIAL_LIBRARY } from "../materials/library";
|
||||||
|
import type { MaterialAsset } from "../materials/library";
|
||||||
import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../materials/runtime";
|
import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../materials/runtime";
|
||||||
import {
|
import {
|
||||||
searchMaterials,
|
searchMaterials,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
type AmbientMaterial,
|
type AmbientMaterial,
|
||||||
type AmbientResolution,
|
type AmbientResolution,
|
||||||
} from "../materials/ambientcg";
|
} from "../materials/ambientcg";
|
||||||
|
import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview";
|
||||||
import { EyeIcon } from "./EyeIcon";
|
import { EyeIcon } from "./EyeIcon";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
@@ -1182,6 +1184,172 @@ function patToHatches(text: string): Omit<HatchStyle, "id">[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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<string, string>();
|
||||||
|
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<HTMLButtonElement | null>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | undefined>(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 (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={`mat-tile${active ? " active" : ""}`}
|
||||||
|
title={`${asset.name} — ${asset.category}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<span className="mat-sphere">
|
||||||
|
{previewUrl && <img src={previewUrl} alt="" draggable={false} />}
|
||||||
|
</span>
|
||||||
|
<span className="mat-tile-name">{asset.name}</span>
|
||||||
|
<span className="mat-tile-badge">{asset.category}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaterialsTab() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [category, setCategory] = useState<string | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||||
|
<div className="res-tab">
|
||||||
|
<div className="res-hint">{t("resources.materials.hint")}</div>
|
||||||
|
|
||||||
|
<div className="mat-lib-filterbar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="res-input mat-search"
|
||||||
|
value={query}
|
||||||
|
placeholder={t("resources.materials.searchPlaceholder")}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="mat-lib-chips"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("resources.materials.categoryLabel")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`mat-chip${category === null ? " active" : ""}`}
|
||||||
|
onClick={() => setCategory(null)}
|
||||||
|
>
|
||||||
|
{t("resources.materials.categoryAll")}
|
||||||
|
</button>
|
||||||
|
{MATERIAL_CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
className={`mat-chip${category === c ? " active" : ""}`}
|
||||||
|
onClick={() => setCategory((cur) => (cur === c ? null : c))}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div className="mat-lib-selected">
|
||||||
|
{t("resources.materials.selected", {
|
||||||
|
name: selected.name,
|
||||||
|
category: selected.category,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mat-grid mat-lib-grid">
|
||||||
|
{filtered.map((asset) => (
|
||||||
|
<MaterialLibraryTile
|
||||||
|
key={asset.id}
|
||||||
|
asset={asset}
|
||||||
|
active={selectedId === asset.id}
|
||||||
|
onSelect={() => setSelectedId((cur) => (cur === asset.id ? null : asset.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && <EmptyState text={t("resources.materials.empty")} />}
|
||||||
|
|
||||||
|
<div className="mat-browse-foot">
|
||||||
|
<span className="mat-browse-status">
|
||||||
|
{t("resources.materials.count", {
|
||||||
|
shown: filtered.length,
|
||||||
|
total: MATERIAL_LIBRARY.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Gemeinsame kleine Bausteine ────────────────────────────────────────────
|
// ── Gemeinsame kleine Bausteine ────────────────────────────────────────────
|
||||||
|
|
||||||
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||||
@@ -1200,12 +1368,13 @@ function EmptyState({ text }: { text: string }) {
|
|||||||
|
|
||||||
// ── Manager-Schublade ──────────────────────────────────────────────────────
|
// ── Manager-Schublade ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
type TabId = "components" | "hatches" | "lines";
|
type TabId = "components" | "hatches" | "lines" | "materials";
|
||||||
|
|
||||||
const TABS: { id: TabId; labelKey: string }[] = [
|
const TABS: { id: TabId; labelKey: string }[] = [
|
||||||
{ id: "components", labelKey: "resources.tab.components" },
|
{ id: "components", labelKey: "resources.tab.components" },
|
||||||
{ id: "hatches", labelKey: "resources.tab.hatches" },
|
{ id: "hatches", labelKey: "resources.tab.hatches" },
|
||||||
{ id: "lines", labelKey: "resources.tab.lines" },
|
{ id: "lines", labelKey: "resources.tab.lines" },
|
||||||
|
{ id: "materials", labelKey: "resources.tab.materials" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Bündel der immutablen Mutationen (von App über setProject bereitgestellt). */
|
/** Bündel der immutablen Mutationen (von App über setProject bereitgestellt). */
|
||||||
@@ -1305,6 +1474,7 @@ export function ResourceManager({
|
|||||||
onImportLineStyles={handlers.onImportLineStyles}
|
onImportLineStyles={handlers.onImportLineStyles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{tab === "materials" && <MaterialsTab />}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user