feat: 3d part previews in parts bar

Context: the parts bin showed flat coloured glyphs for each piece,
unlike the vanilla builder which previews the real LDraw geometry.

Approach: add a single shared offscreen WebGLRenderer in ldraw.js
(renderPartThumbnail) that reuses the cached LDraw template via
buildPartObject, renders the piece once into a 96x96 canvas from a
3/4 view with soft lighting, and exports a PNG data URL cached per
part key. No live mini-scene, one render per type.

Changes:
- ldraw.js: shared offscreen renderer/scene/camera built lazily,
  per-type data-URL cache, throwaway-object disposal, and a
  disposeThumbnailRenderer teardown. Degrades to null on failure.
- PartsBar: fetch thumbnails on mount, show <img> when ready, keep
  the glyph as fallback, dispose the renderer on unmount.
- styles.css: .PartThumb sizing inside the part card.

Impact: each piece now shows a real 3D preview; names and the active
border are preserved; no WebGL leak on unmount; graceful fallback
when WebGL is unavailable.
This commit is contained in:
L'électron rare
2026-06-13 18:56:55 +02:00
parent e31c9095e7
commit a4a4f16641
3 changed files with 171 additions and 3 deletions
+30 -2
View File
@@ -1,10 +1,12 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import "./styles.css"; import "./styles.css";
import { useStore } from "../../../store"; import { useStore } from "../../../store";
import { import {
LDRAW_PARTS, LDRAW_PARTS,
PART_CATEGORIES, PART_CATEGORIES,
LDRAW_COLORS, LDRAW_COLORS,
renderPartThumbnail,
disposeThumbnailRenderer,
} from "../../../utils"; } from "../../../utils";
// LeoCAD-style parts bin: a colour row + category tabs + the curated LDraw // LeoCAD-style parts bin: a colour row + category tabs + the curated LDraw
@@ -19,6 +21,27 @@ export const PartsBar = () => {
const category = const category =
PART_CATEGORIES.find((c) => c.id === activeCat) || PART_CATEGORIES[0]; 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 ( return (
<div className="PartsBar"> <div className="PartsBar">
<div className="ColorRow"> <div className="ColorRow">
@@ -55,6 +78,7 @@ export const PartsBar = () => {
{category.keys.map((key) => { {category.keys.map((key) => {
const def = LDRAW_PARTS[key]; const def = LDRAW_PARTS[key];
if (!def) return null; if (!def) return null;
const thumb = thumbs[key];
return ( return (
<button <button
key={key} key={key}
@@ -62,7 +86,11 @@ export const PartsBar = () => {
onClick={() => setPartType(key)} onClick={() => setPartType(key)}
title={def.label} title={def.label}
> >
<span className="PartGlyph">{def.glyph}</span> {thumb ? (
<img className="PartThumb" src={thumb} alt={def.label} />
) : (
<span className="PartGlyph">{def.glyph}</span>
)}
<span className="PartLabel">{def.label}</span> <span className="PartLabel">{def.label}</span>
</button> </button>
); );
+8
View File
@@ -123,6 +123,14 @@
line-height: 1; line-height: 1;
} }
.PartThumb {
width: 44px;
height: 44px;
object-fit: contain;
image-rendering: auto;
pointer-events: none;
}
.PartLabel { .PartLabel {
font-size: 0.6rem; font-size: 0.6rem;
font-weight: 700; font-weight: 700;
+133 -1
View File
@@ -8,7 +8,18 @@
* function returns null on failure so the builder never throws. * function returns null on failure so the builder never throws.
*/ */
import { LDrawLoader } from "three/examples/jsm/loaders/LDrawLoader.js"; 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"; import { base } from "./constants";
// ── LDraw scale constants ─────────────────────────────────────────────────── // ── LDraw scale constants ───────────────────────────────────────────────────
@@ -210,3 +221,124 @@ export function clearPartCache() {
_loaderPromise = null; _loaderPromise = null;
_manifest = 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<string|null> (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<string|null>} 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();
}