Real WRL 3D models in a white studio lightbox
This commit is contained in:
parent
bd0e7fd298
commit
cf903e29d7
3 changed files with 75 additions and 59 deletions
File diff suppressed because one or more lines are too long
|
|
@ -68,7 +68,7 @@ nav.main a:hover,nav.main a.active{color:var(--text)}
|
||||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px;padding-bottom:20px}
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px;padding-bottom:20px}
|
||||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:3px;overflow:hidden;cursor:pointer;transition:border-color .18s,transform .18s;display:flex;flex-direction:column}
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:3px;overflow:hidden;cursor:pointer;transition:border-color .18s,transform .18s;display:flex;flex-direction:column}
|
||||||
.card:hover{border-color:var(--acc);transform:translateY(-4px)}
|
.card:hover{border-color:var(--acc);transform:translateY(-4px)}
|
||||||
.thumb{aspect-ratio:16/11;background:#08080a;position:relative;display:flex;align-items:center;justify-content:center;border-bottom:1px solid var(--line)}
|
.thumb{aspect-ratio:16/11;background:#f3f3f0;position:relative;display:flex;align-items:center;justify-content:center;border-bottom:1px solid var(--line)}
|
||||||
.thumb img,.thumb canvas{width:100%;height:100%;object-fit:contain}
|
.thumb img,.thumb canvas{width:100%;height:100%;object-fit:contain}
|
||||||
.thumb .cls{position:absolute;top:12px;left:12px;font-family:var(--mono);font-size:11px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
.thumb .cls{position:absolute;top:12px;left:12px;font-family:var(--mono);font-size:11px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
||||||
.thumb .ser{position:absolute;top:12px;right:12px;font-family:var(--mono);font-size:10px;color:var(--acc-ink);background:var(--acc);padding:2px 7px;border-radius:2px;font-weight:600}
|
.thumb .ser{position:absolute;top:12px;right:12px;font-family:var(--mono);font-size:10px;color:var(--acc-ink);background:var(--acc);padding:2px 7px;border-radius:2px;font-weight:600}
|
||||||
|
|
@ -82,7 +82,7 @@ nav.main a:hover,nav.main a.active{color:var(--text)}
|
||||||
/* detail */
|
/* detail */
|
||||||
.detail{display:grid;grid-template-columns:1.02fr 1fr;gap:40px;padding:44px 0 90px}
|
.detail{display:grid;grid-template-columns:1.02fr 1fr;gap:40px;padding:44px 0 90px}
|
||||||
@media(max-width:900px){.detail{grid-template-columns:1fr}}
|
@media(max-width:900px){.detail{grid-template-columns:1fr}}
|
||||||
.viewer{position:sticky;top:92px;height:min(64vh,540px);background:#08080a;border:1px solid var(--line2);border-radius:3px;overflow:hidden}
|
.viewer{position:sticky;top:92px;height:min(64vh,540px);background:#f3f3f0;border:1px solid var(--line2);border-radius:3px;overflow:hidden}
|
||||||
.viewer canvas{width:100%;height:100%;display:block}
|
.viewer canvas{width:100%;height:100%;display:block}
|
||||||
.vbar{position:absolute;bottom:0;left:0;right:0;display:flex;gap:10px;padding:14px}
|
.vbar{position:absolute;bottom:0;left:0;right:0;display:flex;gap:10px;padding:14px}
|
||||||
.vtag{font-family:var(--mono);font-size:11px;color:var(--dim);border:1px solid var(--line2);padding:5px 11px;border-radius:2px;background:rgba(11,11,12,.6)}
|
.vtag{font-family:var(--mono);font-size:11px;color:var(--dim);border:1px solid var(--line2);padding:5px 11px;border-radius:2px;background:rgba(11,11,12,.6)}
|
||||||
|
|
|
||||||
128
assets/viewer.js
128
assets/viewer.js
|
|
@ -1,77 +1,93 @@
|
||||||
// viewer.js — PBR 3D rendering for the part store, same three.js + environment-lit PBR approach as the
|
// viewer.js — real manufacturer 3D models (EasyEDA/LCSC WRL, with their own colours) rendered in a
|
||||||
// flagship PromptPCB board renders. Two entry points: renderThumbnail() (one shared offscreen renderer
|
// clean white studio light room: soft studio lighting, a subtle contact shadow, ACES tone mapping and
|
||||||
// -> data URL, so a gallery of N parts uses ONE WebGL context) and mountViewer() (interactive orbit).
|
// environment reflections — the same PBR spirit as the flagship board renders. VRML is Z-up (KiCad
|
||||||
|
// convention) so we rotate it to sit flat, top up, and auto-fit the camera.
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
import { VRMLLoader } from 'three/addons/loaders/VRMLLoader.js';
|
||||||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||||
|
|
||||||
const objCache = new Map();
|
const cache = new Map();
|
||||||
function loadObj(url) {
|
function loadWRL(url) {
|
||||||
if (objCache.has(url)) return objCache.get(url);
|
if (cache.has(url)) return cache.get(url);
|
||||||
const p = new Promise((res, rej) => new OBJLoader().load(url, res, undefined, rej));
|
const p = new Promise((res, rej) => new VRMLLoader().load(url, res, undefined, rej));
|
||||||
objCache.set(url, p); return p;
|
cache.set(url, p); return p;
|
||||||
}
|
}
|
||||||
// dark epoxy / metal PBR material (IC body look); env reflections give the photoreal feel
|
// KiCad/EasyEDA WRL: Z-up. Rotate to Y-up, centre on origin, drop onto the ground plane, scale to a
|
||||||
function pbrMaterial() {
|
// consistent view size, and improve the loaded materials for studio light.
|
||||||
return new THREE.MeshPhysicalMaterial({ color: 0x1a2230, metalness: 0.55, roughness: 0.42,
|
function prepare(model) {
|
||||||
clearcoat: 0.5, clearcoatRoughness: 0.35, envMapIntensity: 1.1 });
|
model.rotation.x = -Math.PI / 2;
|
||||||
|
model.updateMatrixWorld(true);
|
||||||
|
let box = new THREE.Box3().setFromObject(model);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const s = 6 / (Math.max(size.x, size.y, size.z) || 1); // normalise biggest dim to ~6 units
|
||||||
|
model.scale.setScalar(s);
|
||||||
|
model.updateMatrixWorld(true);
|
||||||
|
box = new THREE.Box3().setFromObject(model);
|
||||||
|
const c = box.getCenter(new THREE.Vector3());
|
||||||
|
model.position.x -= c.x; model.position.z -= c.z; model.position.y -= box.min.y; // centre X/Z, sit on y=0
|
||||||
|
model.traverse((m) => {
|
||||||
|
if (m.isMesh && m.material) { m.castShadow = true;
|
||||||
|
const mats = Array.isArray(m.material) ? m.material : [m.material];
|
||||||
|
mats.forEach((mat) => { mat.envMapIntensity = 0.7; if ('metalness' in mat) mat.metalness = Math.min(mat.metalness ?? 0.2, 0.35); if ('roughness' in mat) mat.roughness = Math.max(mat.roughness ?? 0.6, 0.45); mat.needsUpdate = true; });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return new THREE.Box3().setFromObject(model).getSize(new THREE.Vector3());
|
||||||
}
|
}
|
||||||
function frame(obj, camera, controls, pad = 1.35) {
|
function studio(scene, renderer, shadow) {
|
||||||
const box = new THREE.Box3().setFromObject(obj);
|
scene.background = new THREE.Color(0xf3f3f0);
|
||||||
const size = box.getSize(new THREE.Vector3()), center = box.getCenter(new THREE.Vector3());
|
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||||
obj.position.sub(center);
|
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.02).texture;
|
||||||
const r = Math.max(size.x, size.y, size.z) * pad || 5;
|
const hemi = new THREE.HemisphereLight(0xffffff, 0xdcdcd6, 1.15); scene.add(hemi);
|
||||||
const d = r / Math.tan((camera.fov * Math.PI / 180) / 2);
|
const key = new THREE.DirectionalLight(0xffffff, 2.1); key.position.set(5, 9, 6); scene.add(key);
|
||||||
camera.position.set(d * 0.7, d * 0.75, d * 0.9);
|
const fill = new THREE.DirectionalLight(0xffffff, 0.7); fill.position.set(-6, 4, -3); scene.add(fill);
|
||||||
camera.near = d / 100; camera.far = d * 10; camera.updateProjectionMatrix();
|
if (shadow) {
|
||||||
camera.lookAt(0, 0, 0); if (controls) { controls.target.set(0, 0, 0); controls.update(); }
|
key.castShadow = true; key.shadow.mapSize.set(1024, 1024); key.shadow.bias = -0.0004;
|
||||||
|
const d = 9; Object.assign(key.shadow.camera, { left: -d, right: d, top: d, bottom: -d, near: 1, far: 40 });
|
||||||
|
const g = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), new THREE.ShadowMaterial({ opacity: 0.16 }));
|
||||||
|
g.rotation.x = -Math.PI / 2; g.position.y = -0.001; g.receiveShadow = true; scene.add(g);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
function place(cam, controls, size) {
|
||||||
|
const r = Math.max(size.x, size.y, size.z);
|
||||||
|
const d = r * 2.1;
|
||||||
|
cam.position.set(d * 0.62, d * 0.7, d * 0.85); cam.near = 0.05; cam.far = d * 12; cam.updateProjectionMatrix();
|
||||||
|
const t = new THREE.Vector3(0, size.y * 0.42, 0); cam.lookAt(t); if (controls) { controls.target.copy(t); controls.update(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- shared offscreen renderer for thumbnails ----
|
// ---- shared offscreen renderer for gallery thumbnails (one WebGL context for N cards) ----
|
||||||
let shared = null;
|
let shared = null;
|
||||||
function sharedRenderer(size) {
|
function sharedRenderer(size) {
|
||||||
if (!shared) {
|
if (!shared) { const r = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||||
const r = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
|
r.setPixelRatio(Math.min(devicePixelRatio, 2)); r.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
r.setPixelRatio(Math.min(devicePixelRatio, 2)); r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 1.05;
|
r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 1.15; shared = { r }; }
|
||||||
const pmrem = new THREE.PMREMGenerator(r); const env = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
shared.r.setSize(size, size, false); return shared.r;
|
||||||
shared = { r, env };
|
|
||||||
}
|
|
||||||
shared.r.setSize(size, size, false); return shared;
|
|
||||||
}
|
}
|
||||||
export async function renderThumbnail(url, size = 340) {
|
export async function renderThumbnail(url, size = 360) {
|
||||||
const { r, env } = sharedRenderer(size);
|
const r = sharedRenderer(size);
|
||||||
const scene = new THREE.Scene(); scene.environment = env;
|
const scene = new THREE.Scene(); const cam = new THREE.PerspectiveCamera(34, 1, 0.05, 1000);
|
||||||
const cam = new THREE.PerspectiveCamera(35, 1, 0.1, 1000);
|
studio(scene, r, false);
|
||||||
const key = new THREE.DirectionalLight(0xffffff, 1.7); key.position.set(4, 8, 6); scene.add(key);
|
|
||||||
scene.add(new THREE.AmbientLight(0x88aaff, 0.25));
|
|
||||||
try {
|
try {
|
||||||
const src = await loadObj(url); const obj = src.clone(true);
|
const src = await loadWRL(url); const model = src.clone(true);
|
||||||
obj.traverse((m) => { if (m.isMesh) m.material = pbrMaterial(); });
|
const dim = prepare(model); scene.add(model); place(cam, null, dim); r.render(scene, cam);
|
||||||
scene.add(obj); frame(obj, cam, null, 1.5);
|
|
||||||
r.render(scene, cam);
|
|
||||||
return r.domElement.toDataURL('image/png');
|
return r.domElement.toDataURL('image/png');
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- interactive viewer (one canvas = one context) ----
|
// ---- interactive detail viewer ----
|
||||||
export async function mountViewer(canvas, url) {
|
export async function mountViewer(canvas, url) {
|
||||||
const r = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
const r = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
r.setPixelRatio(Math.min(devicePixelRatio, 2)); r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 1.05;
|
r.setPixelRatio(Math.min(devicePixelRatio, 2)); r.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 1.15;
|
||||||
|
r.shadowMap.enabled = true; r.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
const scene = new THREE.Scene(); const cam = new THREE.PerspectiveCamera(34, 1, 0.05, 2000);
|
||||||
|
studio(scene, r, true);
|
||||||
|
const controls = new OrbitControls(cam, canvas); controls.enableDamping = true; controls.autoRotate = true; controls.autoRotateSpeed = 1.0;
|
||||||
const resize = () => { const w = canvas.clientWidth, h = canvas.clientHeight; r.setSize(w, h, false); cam.aspect = w / h; cam.updateProjectionMatrix(); };
|
const resize = () => { const w = canvas.clientWidth, h = canvas.clientHeight; r.setSize(w, h, false); cam.aspect = w / h; cam.updateProjectionMatrix(); };
|
||||||
const pmrem = new THREE.PMREMGenerator(r);
|
try { const src = await loadWRL(url); const model = src.clone(true); const dim = prepare(model); scene.add(model); resize(); place(cam, controls, dim); }
|
||||||
const scene = new THREE.Scene(); scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
catch (e) { /* ignore */ }
|
||||||
const cam = new THREE.PerspectiveCamera(35, 1, 0.1, 2000);
|
|
||||||
const key = new THREE.DirectionalLight(0xffffff, 1.9); key.position.set(5, 9, 7); scene.add(key);
|
|
||||||
const rim = new THREE.DirectionalLight(0x4db8ff, 0.6); rim.position.set(-6, 2, -4); scene.add(rim);
|
|
||||||
scene.add(new THREE.AmbientLight(0x8899bb, 0.3));
|
|
||||||
const controls = new OrbitControls(cam, canvas); controls.enableDamping = true; controls.autoRotate = true; controls.autoRotateSpeed = 1.1;
|
|
||||||
try {
|
|
||||||
const src = await loadObj(url); const obj = src.clone(true);
|
|
||||||
obj.traverse((m) => { if (m.isMesh) m.material = pbrMaterial(); });
|
|
||||||
scene.add(obj); resize(); frame(obj, cam, controls, 1.4);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
addEventListener('resize', resize);
|
addEventListener('resize', resize);
|
||||||
(function loop() { requestAnimationFrame(loop); controls.update(); r.render(scene, cam); })();
|
(function loop() { requestAnimationFrame(loop); controls.update(); r.render(scene, cam); })();
|
||||||
return { stop: () => (controls.autoRotate = !controls.autoRotate) };
|
return { toggle: () => (controls.autoRotate = !controls.autoRotate) };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue