// viewer.js — real manufacturer 3D models (EasyEDA/LCSC WRL, with their own colours) rendered in a // clean white studio light room: soft studio lighting, a subtle contact shadow, ACES tone mapping and // 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 { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { VRMLLoader } from 'three/addons/loaders/VRMLLoader.js'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; const cache = new Map(); function loadWRL(url) { if (cache.has(url)) return cache.get(url); const p = new Promise((res, rej) => new VRMLLoader().load(url, res, undefined, rej)); cache.set(url, p); return p; } // KiCad/EasyEDA WRL: Z-up. Rotate to Y-up, centre on origin, drop onto the ground plane, scale to a // consistent view size, and improve the loaded materials for studio light. function prepare(model) { 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 studio(scene, renderer, shadow) { scene.background = new THREE.Color(0xf3f3f0); const pmrem = new THREE.PMREMGenerator(renderer); scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.02).texture; const hemi = new THREE.HemisphereLight(0xffffff, 0xdcdcd6, 1.15); scene.add(hemi); const key = new THREE.DirectionalLight(0xffffff, 2.1); key.position.set(5, 9, 6); scene.add(key); const fill = new THREE.DirectionalLight(0xffffff, 0.7); fill.position.set(-6, 4, -3); scene.add(fill); if (shadow) { 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 gallery thumbnails (one WebGL context for N cards) ---- let shared = null; function sharedRenderer(size) { if (!shared) { const r = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true }); r.setPixelRatio(Math.min(devicePixelRatio, 2)); r.outputColorSpace = THREE.SRGBColorSpace; r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 1.15; shared = { r }; } shared.r.setSize(size, size, false); return shared.r; } export async function renderThumbnail(url, size = 360) { const r = sharedRenderer(size); const scene = new THREE.Scene(); const cam = new THREE.PerspectiveCamera(34, 1, 0.05, 1000); studio(scene, r, false); try { const src = await loadWRL(url); const model = src.clone(true); const dim = prepare(model); scene.add(model); place(cam, null, dim); r.render(scene, cam); return r.domElement.toDataURL('image/png'); } catch { return null; } } // ---- interactive detail viewer ---- export async function mountViewer(canvas, url) { const r = new THREE.WebGLRenderer({ canvas, antialias: true }); 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(); }; try { const src = await loadWRL(url); const model = src.clone(true); const dim = prepare(model); scene.add(model); resize(); place(cam, controls, dim); } catch (e) { /* ignore */ } addEventListener('resize', resize); (function loop() { requestAnimationFrame(loop); controls.update(); r.render(scene, cam); })(); return { toggle: () => (controls.autoRotate = !controls.autoRotate) }; }