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 "./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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user