Building a Clickable, Auto-Rotating PCB Viewer with Vue and Three.js
Implementation effects:
- Load a GLB-format circuit board model.
- Drag to rotate, zoom, and pan the model with the mouse.
- Clicking a chip or component makes that component highlighted in semi-transparent blue.
- One-click hide/show the PCB main board to view only internal components.
- After clicking the auto-rotate button, the model smoothly enters an orbiting rotation state.
Demo animation effect is shown below:
Core feature principle explanation:
1. Raycaster ray picking for component click highlighting:
Core flow:
- Listen for page click events and get the mouse screen coordinates.
- Correct coordinate offset based on the render canvas position, converting to Three.js normalized coordinates
[-1,1]. - Use
Raycasterto emit a ray from the camera position and perform collision detection with models in the scene. - Filter out the PCB substrate so only components trigger the highlight logic.
- When switching the selected object, automatically restore the previous component's original material.
2. Material caching mechanism to solve the inability to restore highlights:
The most common pitfall for beginners: directly modifying the Mesh's original material.
In Three.js, multiple Meshes can share the same material object. Directly modifying the original material will cause all objects sharing that material to change color synchronously, and the original color cannot be restored after exiting the highlight. The standard solution:
- After the model loads, traverse all Meshes, clone and cache the original material of each object.
- When a component is selected, clone a material copy to modify its color and transparency.
- When deselected, read the cache to restore the original material.
3. Tween camera easing + OrbitControls auto-rotation:
Directly toggling controls.autoRotate results in a stiff interaction and poor experience.
Optimization plan:
- Start rotation: the camera smoothly transitions to a viewing angle suitable for orbiting observation → delay start auto-rotation.
- Stop rotation: first turn off auto-rotation → camera eases back to the initial observation angle.
- Before switching states, forcefully clean up all animation frames, Tween instances, and timers from the previous round to avoid camera jitter caused by multiple parallel animations.
Complete Code
1. template structure:
<template>
<div class="container">
<div class="btn-group">
<!-- Show main board control button -->
<div class="btn" :class="{'btn-active': type=='0'}" @click="showVoid">Show Main Board</div>
<!-- Hide main board control button -->
<div class="btn" :class="{'btn-active': type=='1'}" @click="hideVoid">Hide Main Board</div>
<!-- Auto-rotate control button -->
<div class="btn" v-show="!isAutoRotate" @click="startRotate">Auto Rotate</div>
<!-- Stop rotation control button -->
<div class="btn" v-show="isAutoRotate" @click="stopRotate">Stop Rotation</div>
</div>
<!-- Three.js render canvas container -->
<div class="three" id="three" ref="content"></div>
</div>
</template>
2. script code:
<script>
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import Stats from 'three/examples/jsm/libs/stats.module.js'
import TWEEN from '@tweenjs/tween.js'
export default {
beforeDestroy() {
// Component destruction, release all 3D resources, cancel frame animations to prevent memory leaks
this.leaveDestory()
},
mounted() {
this.$nextTick(() => {
this.init()
// Listen for browser window resize events, adapt canvas
window.onresize = () => {
this.resize()
}
})
},
data () {
return {
// Main board display status flag 0=show 1=hide
type: '0',
// Model auto-rotation toggle flag
isAutoRotate: false
}
},
methods: {
/**
* Stop model auto-rotation
* First terminate existing animations and Tween easing, then camera eases back to default view, finally disable controller auto-rotation
*/
stopRotate () {
// Clean up rotation-related requestAnimationFrame
if (window.myMar2) {
cancelAnimationFrame(window.myMar2)
window.myMar2 = null
}
// Stop camera easing animation
if (window.myMarTween2) {
window.myMarTween2.stop()
window.myMarTween2 = null
}
// Clear delay timer
if (window.myMar22) {
clearTimeout(window.myMar22);
window.myMar22 = null;
}
this.isAutoRotate = false;
// Camera easing back to initial observation angle
const animate = function () {
if (TWEEN.update()) {
window.myMar3 = requestAnimationFrame(animate)
}
}
window.myMarTween3 = new TWEEN.Tween({
x: window.camera.position.x,
y: window.camera.position.y,
z: window.camera.position.z,
yControls: window.controls.target.y
})
.to({
x: -0.11506604766249938,
y: -4.556768355147646,
z: -1.635384307897266,
yControls: -1
}, 1500)
.onUpdate((val) => {
if (window.myMarTween3) {
window.camera.position.x = val.x
window.camera.position.y = val.y
window.camera.position.z = val.z
window.controls.target.y = val.yControls
}
}).start()
animate()
// Disable orbit controller auto-rotation after 2 seconds
window.myMar33 = setTimeout(() => {
window.controls.autoRotate = false;
window.myMar33 = null;
}, 2000);
},
/**
* Start model auto-rotation
* First clean up existing animations, camera eases to rotation observation angle, delay start OrbitControls auto-rotation
*/
startRotate () {
if (window.myMar3) {
cancelAnimationFrame(window.myMar3)
window.myMar3 = null
}
if (window.myMarTween3) {
window.myMarTween3.stop()
window.myMarTween3 = null
}
if (window.myMar33) {
clearTimeout(window.myMar33);
window.myMar33 = null;
}
this.isAutoRotate = true;
const animate = function () {
if (TWEEN.update()) {
window.myMar2 = requestAnimationFrame(animate)
}
}
// Camera smoothly transitions to a viewing angle suitable for rotation observation
window.myMarTween2 = new TWEEN.Tween({
x: window.camera.position.x,
y: window.camera.position.y,
z: window.camera.position.z,
yControls: window.controls.target.y
})
.to({
x: -0.17344362976419006,
y: -6.361258392715835,
z: -2.465079805757,
yControls: -1
}, 1500)
.onUpdate((val) => {
if (window.myMarTween2) {
window.camera.position.x = val.x
window.camera.position.y = val.y
window.camera.position.z = val.z
window.controls.target.y = val.yControls
}
}).start()
animate()
// Wait for camera transition to complete, then enable controller auto-rotation
window.myMar22 = setTimeout(() => {
window.controls.autoRotate = true;
window.controls.autoRotateSpeed = 2.0; // Rotation speed, default 1.0
window.myMar22 = null;
}, 2000);
},
/**
* Show main board PCB substrate (Mesh group)
*/
showVoid () {
const group = window.model.getObjectByName('Mesh');
if (group) {
group.visible = true;
this.type = '0';
}
},
/**
* Hide main board PCB substrate (Mesh group)
*/
hideVoid () {
const group = window.model.getObjectByName('Mesh');
if (group) {
group.visible = false;
this.type = '1';
}
},
/**
* Mouse click picking chip Mesh, implement selection highlighting (semi-transparent blue)
* event native click event
*/
choose(event) {
// Normalize mouse coordinates to range [-1, 1] for ray casting
const mouse = new THREE.Vector2();
// Get canvas position relative to viewport, correct mouse offset
const rect = window.renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// Create ray caster
var raycaster = new THREE.Raycaster();
// Generate ray based on mouse coordinates + camera
raycaster.setFromCamera(mouse, window.camera);
// Recursively traverse all objects in the scene for collision detection
const intersects = raycaster.intersectObjects(window.scene.children, true);
// Intersecting objects exist, meaning a model was clicked
if (intersects.length > 0) {
// Filter out main board substrate, clicking main board does not trigger highlight
if (intersects[0].object.name == 'Mesh') {
return;
}
// If there is a previously selected object, restore its original material
if (window.selectObj) {
let selectObjName = window.selectObj.object.name;
window.selectObj.object.material = window.materialObj[selectObjName];
window.selectObj = null
}
// Save the currently selected object
window.selectObj = intersects[0];
// Clone material to avoid modifying the original material affecting the global cache
let tempMaterial = window.selectObj.object.material.clone();
window.selectObj.object.material = tempMaterial;
// Enable transparency, set opacity, modify color
window.selectObj.object.material.transparent = true;
window.selectObj.object.material.opacity = 0.7;
window.selectObj.object.material.color = new THREE.Color('rgb(78, 110, 242)');
}
},
/**
* Component destruction resource recovery
* Clear animation frames, timers, release geometry/materials, destroy WebGL context to prevent memory leaks
*/
leaveDestory() {
window.airlinerLoad = null
// Clean up all requestAnimationFrame
if (window.myMar) {
cancelAnimationFrame(window.myMar)
window.myMar = null
}
if (window.myMar2) {
cancelAnimationFrame(window.myMar2)
window.myMar2 = null
}
if (window.myMarTween2) {
window.myMarTween2.stop()
window.myMarTween2 = null
}
if (window.myMar22) {
clearTimeout(window.myMar22);
window.myMar22 = null;
}
if (window.myMar3) {
cancelAnimationFrame(window.myMar3)
window.myMar3 = null
}
if (window.myMarTween3) {
window.myMarTween3.stop()
window.myMarTween3 = null
}
if (window.myMar33) {
clearTimeout(window.myMar33);
window.myMar33 = null
}
if (window.scene) {
// Traverse and release all Mesh geometry and material video memory
window.scene.traverse(function (v) {
if (v.type === 'Mesh') {
if (v.geometry && v.geometry.dispose) {
v.geometry.dispose()
}
if (v.material && v.material.dispose) {
v.material.dispose()
}
}
})
if (window.scene.children.length > 0) {
window.scene.remove(window.scene.children[0])
}
window.stats = null
// Destroy renderer and WebGL context
window.renderer.dispose()
window.renderer.forceContextLoss()
window.renderer.domElement = null
window.renderer = null
window.camera = null
// Destroy orbit controller
window.controls.dispose()
window.controls = null
window.onresize = null
window.scene.clear()
window.scene = null
window.model = null
window.selectObj = null
window.newMaterial = null
}
},
/**
* Window resize adaptation, update camera aspect ratio and render canvas size
*/
resize() {
let width = this.$refs.content.offsetWidth
let height = this.$refs.content.offsetHeight
window.camera.aspect = width / height
window.camera.updateProjectionMatrix()
window.renderer.setSize(width, height)
},
/**
* Initialize scene, camera, renderer, lights, load GLB circuit board model
*/
init() {
let width = this.$refs.content.offsetWidth
let height = this.$refs.content.offsetHeight
// Create scene container
window.scene = new THREE.Scene()
// Perspective camera
window.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
// WebGL renderer, enable anti-aliasing
window.renderer = new THREE.WebGLRenderer({ antialias: true })
window.renderer.setPixelRatio(window.devicePixelRatio)
window.renderer.setClearColor(0xdddddd, 1.0) // Canvas background light gray
window.renderer.setSize(width, height)
window.renderer.shadowMapEnabled = true
// Initialize camera position
window.camera.position.x = -0.11506604766249938
window.camera.position.y = -4.556768355147646
window.camera.position.z = -1.635384307897266
window.camera.lookAt(window.scene.position)
window.stats = new Stats()
// Orbit controller: drag to rotate, zoom, pan model
window.controls = new OrbitControls(window.camera, window.renderer.domElement);
window.controls.target.set(0, -1, 0);
window.controls.enablePan = true;
window.controls.enableDamping = true; // Enable damping for smooth dragging
window.controls.dampingFactor = 0.5;
window.controls.update();
// Ambient hemisphere light, soft basic illumination
let hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 0.3)
hemiLight.position.set(0, 500, 0)
window.scene.add(hemiLight)
// Spotlight, adds light and shadow layers to the model
let spotLight = new THREE.SpotLight(0xffffff)
spotLight.position.set(-40, 60, -10)
spotLight.castShadow = true
window.scene.add(spotLight)
// GLTF model loader
let loader = new GLTFLoader()
window.airlinerLoad = true
loader.load('static/models/chip.glb', (gltf) => {
if (window.airlinerLoad) {
// Get the circuit board model root node
window.model = gltf.scene.children[1];
// Pre-clone and save all Mesh original materials for restoration after selection
let materialObj = {};
window.model.traverse((child) => {
if (child.isMesh) {
materialObj[child.name] = child.material.clone();
}
});
window.materialObj = materialObj;
// Add model to scene
window.scene.add(window.model);
// Start render loop
this.animate()
// Global click listener, trigger ray picking
document.onclick = (e) => {
this.choose(e)
}
}
})
// Mount render canvas DOM to page container
document.getElementById('three').appendChild(window.renderer.domElement)
},
/**
* Main render loop
*/
animate() {
window.myMar = requestAnimationFrame(this.animate)
window.controls.update() // Update orbit controller damping
window.stats.update()
window.renderer.render(window.scene, window.camera)
}
}
}
</script>
3. CSS style code:
* {
margin: 0;
padding: 0;
}
.container {
width: 100%;
height: 100%;
position: relative;
}
.three {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
}
.btn-group {
position: absolute;
right: 0;
top: 15px;
z-index: 2;
display: flex;
justify-content: start;
align-items: center;
}
.btn {
width: 80px;
height: 32px;
line-height: 32px;
text-align: center;
border: 1px solid #dcdfe6;
border-radius: 3px;
margin-right: 15px;
margin-left: 0;
cursor: pointer;
font-size: 12px;
color: #606266;
}
.btn-active {
background-color: #409eff;
color: #FFF;
}
Model note: The 3D model used in this demonstration is sourced from the internet, and its copyright belongs to the original author. This article is only for Three.js front-end technology learning demonstration, and no modifications have been made to the model itself.
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
[Eating melon masses][Eating melon masses][Eating melon masses][Eating melon masses][Eating melon masses][Eating melon masses][Eating melon masses][Eating melon masses][Eating melon masses]
[Grin]