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:
+171
-1
@@ -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<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 ────────────────────────────────────────────
|
||||
|
||||
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" && <MaterialsTab />}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user