@@ -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();
+}