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:
@@ -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 (
|
||||
<div className="PartsBar">
|
||||
<div className="ColorRow">
|
||||
@@ -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 (
|
||||
<button
|
||||
key={key}
|
||||
@@ -62,7 +86,11 @@ export const PartsBar = () => {
|
||||
onClick={() => setPartType(key)}
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+133
-1
@@ -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<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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user