跪拜 Guibai
← Back to the summary

Building a Clickable, Auto-Rotating PCB Viewer with Vue and Three.js

Implementation effects:

  1. Load a GLB-format circuit board model.
  2. Drag to rotate, zoom, and pan the model with the mouse.
  3. Clicking a chip or component makes that component highlighted in semi-transparent blue.
  4. One-click hide/show the PCB main board to view only internal components.
  5. After clicking the auto-rotate button, the model smoothly enters an orbiting rotation state.

Demo animation effect is shown below:

clip.gif

Core feature principle explanation:

1. Raycaster ray picking for component click highlighting:

Core flow:

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:

3. Tween camera easing + OrbitControls auto-rotation:

Directly toggling controls.autoRotate results in a stiff interaction and poor experience.

Optimization plan:

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.

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

oqoxud

[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]

Tony费

[Grin]