DEV Community

loading...
Cover image for Adding a Click Handler to a Three.js Model

Adding a Click Handler to a Three.js Model

Patrick Hund
Software engineer, cartoonist, electronic music producer. He/him.
・4 min read

I'm building a social media network and collaboration tool based on mind maps, documenting my work in this series of blog posts. Follow me if you're interested in what I've learned along the way about building web apps with React, Tailwind CSS, Firebase, Apollo/GraphQL, three.js and TypeScript.

Today's Goal

After spent a good part of the week procrastinating by adding Apollo State Management and TypeScript to my project, it's time to continue work on the actual mind map.

I want to start making the mind map interactive. As a first step, I want it, when a node is clicked, to zoom and pan to the clicked node, like this:

Animation: clicking a mind map node

Picking a Library

Surprisingly, adding a click handler to an object in a three.js scene is not built in to the library. As was also the case with the trackball controls I've previously added, there are a couple of libraries available that add this functionality and work more or less the same.

After a bit of research, I settle for three.interactive by Markus Lerner, for these reasons:

  • Available as npm package
  • Has been updated recently
  • Provides ES module imports
  • Doesn't pollute the THREE namespace or define any global variables
  • Supports handling clicks on overlapping objects

The only disadvantage is that it doesn't have TypeScript types. There was one other repo on GitHub for interactive three.js models that does provide types – threejs-interactive-object. But that is not available as npm package, so I decided against using it.

Logging on Click

To see if threejs-interactive-object does what it promises, I add the npm package to my library, then set it up to log a statement to the console when a mind map node is clicked.

For this, my initializeScene function now creates and returns an InteractionManager:

function initializeScene(div: HTMLDivElement) {
  const canvas = createCanvas(window.innerWidth, window.innerHeight);
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  div.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xffffff);

  const camera = new THREE.PerspectiveCamera(
    50,
    window.innerWidth / window.innerHeight,
    0.1,
    500000
  );
  camera.position.z = 1.5;

  const controls = new TrackballControls(camera, renderer.domElement);

  const interactionManager = new InteractionManager(renderer, camera, canvas);

  return { scene, renderer, camera, controls, interactionManager };
}
Enter fullscreen mode Exit fullscreen mode

The interaction manager is then passed to the constructor of my RenderCache, which “pre-renders” the mind map nodes, i.e. prepare them before they are displayed in the 3D model:

renderMindMap.tsx

async function renderMindMap(
  div: HTMLDivElement,
  data: MindMapData
) {
  const {
    scene,
    renderer,
    camera,
    controls,
    interactionManager
  } = initializeScene(div);
  const renderCache = new RenderCache({ interactionManager });
  await renderCache.preRender(data);
  const graph = initializeGraph(renderCache, data);
  scene.add(graph);
  camera.lookAt(graph.position);
  animate(() => {
    graph.tickFrame();
    controls.update();
    interactionManager.update();
    renderer.render(scene, camera);
  });
}
Enter fullscreen mode Exit fullscreen mode

Inside the animation function call at the end, I add interactionManager.update() to make sure the interaction manager updates with every animation loop iteration.

When the mind map node is rendered, a click event handler is added that, for now, just logs a statement so we know that it works:

RenderCache.tsx

interface Constructor {
  interactionManager: typeof InteractionManager;
}

class RenderCache {
  private preRendered: Map<
    string | number | NodeObject | undefined,
    PreRendered
  > = new Map();

  private interationManager: typeof InteractionManager;

  constructor({ interactionManager }: Constructor) {
    this.interationManager = interactionManager;
  }

  preRender(data: MindMapData) {
    return Promise.all(
      data.nodes.map(async ({ name, val, id }) => {
        const sprite = await renderToSprite(
          <MindMapNode label={name} level={val} />,
          { width: 128, height: 64 }
        );
        sprite.addEventListener('click', (event) => {
          event.stopPropagation();
          return console.log(`Mind map node clicked: #${id}${name}”`);
        });
        this.interationManager.add(sprite);
        const linkMaterial = new THREE.MeshBasicMaterial({
          color: colorsByLevel[val]
        });
        this.preRendered.set(id, { sprite, linkMaterial });
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Note the event.stopPropagation() – this makes sure, when mind maps nodes are overlapping, that a click only fires the event on the one closest to the camera.

Let's try it out:

👍🏻 Nice, it works!

Here's the code so far:

To Be Continued…

The next step will be to move the camera to put the mind map node that was clicked in the middle of the screen. I'll figure out how to do this in the next blog post.

I'm planning to turn my mind map into a social media network and collaboration tool and will continue to blog about my progress in follow-up articles. Stay tuned!

Discussion (0)

Forem Open with the Forem app