diff --git a/src/components/UI/PartsBar/index.jsx b/src/components/UI/PartsBar/index.jsx index 4680ec1..c09ca9e 100644 --- a/src/components/UI/PartsBar/index.jsx +++ b/src/components/UI/PartsBar/index.jsx @@ -1,10 +1,12 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import "./styles.css"; import { useStore } from "../../../store"; import { LDRAW_PARTS, PART_CATEGORIES, LDRAW_COLORS, + renderPartThumbnail, + disposeThumbnailRenderer, } from "../../../utils"; // LeoCAD-style parts bin: a colour row + category tabs + the curated LDraw @@ -19,6 +21,27 @@ export const PartsBar = () => { const category = PART_CATEGORIES.find((c) => c.id === activeCat) || PART_CATEGORIES[0]; + // Real 3D previews: render each curated part once into a small offscreen + // canvas and cache the PNG data URL by part key. Falls back to the flat glyph + // when WebGL is unavailable or a render fails. + const [thumbs, setThumbs] = useState({}); + + useEffect(() => { + let cancelled = false; + for (const key of Object.keys(LDRAW_PARTS)) { + renderPartThumbnail(key).then((url) => { + if (cancelled || !url) return; + setThumbs((prev) => (prev[key] ? prev : { ...prev, [key]: url })); + }); + } + return () => { + cancelled = true; + }; + }, []); + + // Dispose the shared offscreen renderer + canvases when the bar unmounts. + useEffect(() => () => disposeThumbnailRenderer(), []); + return (
@@ -55,6 +78,7 @@ export const PartsBar = () => { {category.keys.map((key) => { const def = LDRAW_PARTS[key]; if (!def) return null; + const thumb = thumbs[key]; return ( ); diff --git a/src/components/UI/PartsBar/styles.css b/src/components/UI/PartsBar/styles.css index 62b08b8..66fd87c 100644 --- a/src/components/UI/PartsBar/styles.css +++ b/src/components/UI/PartsBar/styles.css @@ -123,6 +123,14 @@ line-height: 1; } +.PartThumb { + width: 44px; + height: 44px; + object-fit: contain; + image-rendering: auto; + pointer-events: none; +} + .PartLabel { font-size: 0.6rem; font-weight: 700; diff --git a/src/utils/ldraw.js b/src/utils/ldraw.js index faf78db..075b462 100644 --- a/src/utils/ldraw.js +++ b/src/utils/ldraw.js @@ -8,7 +8,18 @@ * function returns null on failure so the builder never throws. */ import { LDrawLoader } from "three/examples/jsm/loaders/LDrawLoader.js"; -import { Group, Box3, Vector3, Color } from "three"; +import { + Group, + Box3, + Vector3, + Color, + Scene, + PerspectiveCamera, + WebGLRenderer, + HemisphereLight, + DirectionalLight, + AmbientLight, +} from "three"; import { base } from "./constants"; // ── LDraw scale constants ─────────────────────────────────────────────────── @@ -210,3 +221,124 @@ export function clearPartCache() { _loaderPromise = null; _manifest = null; } + +// ── Offscreen 3/4-view thumbnails for the parts bin ────────────────────────── +// A single shared offscreen WebGL renderer renders each curated part once into +// a small canvas, exported as a PNG data URL and cached by part key. The render +// is one-shot (no live loop). Everything degrades to null on failure so the +// palette can fall back to its flat glyph. + +const THUMB_SIZE = 96; +let _thumbCtx = null; // { renderer, scene, camera } | null | "failed" +const _thumbCache = new Map(); // partKey -> Promise (data URL) + +function _thumbContext() { + if (_thumbCtx !== null) return _thumbCtx === "failed" ? null : _thumbCtx; + try { + const canvas = document.createElement("canvas"); + canvas.width = THUMB_SIZE; + canvas.height = THUMB_SIZE; + const renderer = new WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }); + renderer.setSize(THUMB_SIZE, THUMB_SIZE, false); + + const scene = new Scene(); + scene.add(new HemisphereLight(0xffffff, 0xffffff, 0.55)); + scene.add(new AmbientLight(0xffffff, 0.35)); + const key = new DirectionalLight(0xffffff, 0.9); + key.position.set(40, 60, 30); + scene.add(key); + + const camera = new PerspectiveCamera(35, 1, 0.1, 5000); + + _thumbCtx = { renderer, scene, camera }; + return _thumbCtx; + } catch { + _thumbCtx = "failed"; + return null; + } +} + +/** + * Render (and cache) a 3/4-view PNG thumbnail for a part type. Reuses the + * cached LDraw template via {@link buildPartObject} (no extra .dat fetch) and a + * single shared offscreen renderer. Returns null on any failure. + * @returns {Promise} a `data:image/png` URL, or null + */ +export function renderPartThumbnail(partKey, colorHex = "#C91A09") { + const cacheKey = `${partKey}|${colorHex}`; + if (_thumbCache.has(cacheKey)) return _thumbCache.get(cacheKey); + + const promise = (async () => { + const ctx = _thumbContext(); + if (!ctx) return null; + let obj = null; + try { + obj = await buildPartObject(partKey, colorHex); + if (!obj) return null; + + const { renderer, scene, camera } = ctx; + + // Centre the part at the origin so the camera framing is stable. + const box = new Box3().setFromObject(obj); + const center = box.getCenter(new Vector3()); + const size = box.getSize(new Vector3()); + obj.position.sub(center); + + // Frame the part: place the camera on a 3/4 view and pull back to fit. + const radius = Math.max(size.x, size.y, size.z) || 1; + const dist = radius * 2.4; + camera.position.set(dist, dist * 0.82, dist); + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + + scene.add(obj); + renderer.render(scene, camera); + const url = renderer.domElement.toDataURL("image/png"); + return url || null; + } catch { + return null; + } finally { + if (obj) { + ctx.scene.remove(obj); + _disposeObject(obj); + } + } + })(); + + _thumbCache.set(cacheKey, promise); + return promise; +} + +/** Recursively dispose geometries + materials of a throwaway render object. */ +function _disposeObject(root) { + root.traverse((obj) => { + if (obj.geometry && typeof obj.geometry.dispose === "function") { + obj.geometry.dispose(); + } + if (obj.material) { + const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; + for (const m of mats) { + if (m && typeof m.dispose === "function") m.dispose(); + } + } + }); +} + +/** Tear down the shared offscreen thumbnail renderer and drop the cache. */ +export function disposeThumbnailRenderer() { + if (_thumbCtx && _thumbCtx !== "failed") { + try { + _thumbCtx.renderer.dispose(); + _thumbCtx.renderer.forceContextLoss(); + } catch { + /* ignore */ + } + } + _thumbCtx = null; + _thumbCache.clear(); +}