16 Apr 2020

Custom tools in Forge Viewer

When browsing through the Forge Viewer API reference, you may have stumbled upon the ToolController and ToolInterface classes under the Viewing namespace. What could those be good for I hear you ask? Well, sit down and make yourself comfortable 'cause have I got a story for you!

Tool stack

The viewer uses something called a "tool stack" where multiple tools - for example, camera controls, hotkey manager, or the first person navigation - can be active (stacked) at the same time, handling input events based on their priorities, and manipulating the viewer's state and content. The ToolController class manages the tool stack and distributes events to the right tools. It is instantiated by Viewer3D, and can be accessed via viewer.toolController. The ToolInterface is then, well, an interface you can implement when creating your own customized tool. Let's take a look at how to do that!

Tool interface

Before we start coding our custom tool, let's take a quick look at the ToolInterface code:

/**
 * Base class for new interaction tools.
 *
 * Can also be used simply as a template for creating a new tool.
 * @constructor
 * @see Autodesk.Viewing.ToolController
 * @alias Autodesk.Viewing.ToolInterface
 */
export function ToolInterface()
{
    this.names = [ "unnamed" ];
 
    /**
     * This method should return an array containing the names of all tools implemented by this class.
     * Often this would be a single name but it is possible to support multiple interactions with a single tool.
     * When this tool is registered with the ToolController each name gets registered as an available tool.
     * @returns {array} Array of strings. Should not be empty.
     */
    this.getNames = function() {
        return this.names;
    };
 
    /**
     * This is an optional convenience method to obtain the first name of this tool.
     * @returns {string} The tools default name.
     */
    this.getName = function() {
        return this.names[0];
    };
 
    /**
     * This method should return the priority of the tool inside the tool stack.
     * A tool with higher priority will get events first.
     * @returns {number} The tool's priority.
     */
    this.getPriority = function() {
        return 0;
    };
 
    /**
     * This method is called by {@link Autodesk.Viewing.ToolController#registerTool}.
     * Use this for initialization.
     */
    this.register = function() {
    };
 
    /**
     * This method is called by {@link Autodesk.Viewing.ToolController#deregisterTool}.
     * Use this to clean up your tool.
     */
    this.deregister = function() {
    };
 
    /**
     * The activate method is called by the ToolController when it adds this tool to the list of those
     * to receive event handling calls. Once activated, a tool's "handle*" methods may be called
     * if no other higher priority tool handles the given event. Each active tool's "update" method also gets
     * called once during each redraw loop.
     * @param {string} name - The name under which the tool has been activated.
     * @param {Autodesk.Viewing.Viewer3D} viewerApi - Viewer instance.
     */
    this.activate = function(name, viewerApi) {
    };
 
    /**
     * The deactivate method is called by the ToolController when it removes this tool from the list of those
     * to receive event handling calls. Once deactivated, a tool's "handle*" methods and "update" method
     * will no longer be called.
     * @param {string} name - The name under which the tool has been deactivated.
     */
    this.deactivate = function(name) {
    };
 
    /**
     * The update method is called by the ToolController once per frame and provides each tool
     * with the oportunity to make modifications to the scene or the view.
     * @param {number} highResTimestamp - The process timestamp passed to requestAnimationFrame by the web browser.
     * @returns {boolean} A state value indicating whether the tool has modified the view or the scene
     * and a full refresh is required.
     */
    this.update = function(highResTimestamp) {
        return false;
    };
 
    /**
     * This method is called when a single mouse button click occurs.
     * @param {MouseEvent} event - The event object that triggered this call.
     * @param {number} button - The button number that was clicked (0, 1, 2 for Left, Middle, Right respectively).
     * Note that the button parameter value may be different that the button value indicated in the event
     * object due to button re-mapping preferences that may be applied. This value should be respected
     * over the value in the event object.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass
     * the event to lower priority active tools.
     */
    this.handleSingleClick = function( event, button ) {
        return false;
    };
 
    /**
     * This method is called when a double mouse button click occurs.
     * @param {MouseEvent} event - The event object that triggered this call.
     * @param {number} button - The button number that was clicked (0, 1, 2 for Left, Middle, Right respectively).
     * Note that the button parameter value may be different that the button value indicated in the event
     * object due to button re-mapping preferences that may be applied. This value should be respected
     * over the value in the event object.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleDoubleClick = function( event, button ) {
        return false;
    };
 
    /**
     * This method is called when a single tap on a touch device occurs.
     * @param {Event} event - The triggering event. For tap events the canvasX, canvasY properties contain
     * the canvas relative device coordinates of the tap and the normalizedX, normalizedY properties contain
     * the tap coordinates in the normalized [-1, 1] range. The event.pointers array will contain
     * either one or two touch events depending on whether the tap used one or two fingers.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleSingleTap = function( event ) {
        return false;
    };
 
    /**
     * This method is called when a double tap on a touch device occurs.
     * @param {Event} event - The triggering event. For tap events the canvasX, canvasY properties contain
     * the canvas relative device coordinates of the tap and the normalizedX, normalizedY properties contain
     * the tap coordinates in the normalized [-1, 1] range. The event.pointers array will contain
     * either one or two touch events depending on whether the tap used one or two fingers.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleDoubleTap = function( event ) {
        return false;
    };
 
    /**
     * This method is called when a keyboard button is depressed.
     * @param {KeyboardEvent} event - The event object that triggered this call.
     * @param {number} keyCode - The numerical key code identifying the key that was depressed.
     * Note that the keyCode parameter value may be different that the value indicated in the event object
     * due to key re-mapping preferences that may be applied. This value should be respected
     * over the value in the event object.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleKeyDown = function( event, keyCode ) {
        return false;
    };
 
    /**
     * This method is called when a keyboard button is released.
     * @param {KeyboardEvent} event - The event object that triggered this call.
     * @param {number} keyCode - The numerical key code identifying the key that was released.
     * Note that the keyCode parameter value may be different that the value indicated in the event object
     * due to key re-mapping preferences that may be applied. This value should be respected
     * over the value in the event object.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleKeyUp = function( event, keyCode ) {
        return false;
    };
 
    /**
     * This method is called when a mouse wheel event occurs.
     * @param {number} delta - A numerical value indicating the amount of wheel motion applied.
     * Note that this value may be modified from the orignal event values so as to provide consistent results
     * across browser families.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleWheelInput = function(delta) {
        return false;
    };
 
    /**
     * This method is called when a mouse button is depressed.
     * @param {MouseEvent} event - The event object that triggered this call.
     * @param {Number} button - The button number that was depressed (0, 1, 2 for Left, Middle, Right respectively).
     * Note that the button parameter value may be different that the button value indicated in the event object
     * due to button re-mapping preferences that may be applied. This value should be respected
     * over the value in the event object.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleButtonDown = function(event, button) {
        return false;
    };
 
    /**
     * This method is called when a mouse button is released.
     * @param {MouseEvent} event - The event object that triggered this call.
     * @param {number} button - The button number that was released (0, 1, 2 for Left, Middle, Right respectively).
     * Note that the button parameter value may be different that the button value indicated in the event object
     * due to button re-mapping preferences that may be applied. This value should be respected
     * over the value in the event object.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleButtonUp = function(event, button) {
        return false;
    };
 
    /**
     * This method is called when a mouse motion event occurs.
     * @param {MouseEvent} event - The event object that triggered this call.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleMouseMove = function(event) {
        return false;
    };
 
    /**
     * This method is called when a touch gesture event occurs.
     * @param {Event} event - The event object that triggered this call. The event.type attribute will indicate
     * the gesture event type. This will be one of: dragstart, dragmove, dragend, panstart, panmove, panend,
     * pinchstart, pinchmove, pinchend, rotatestart, rotatemove, rotateend, drag3start, drag3move, drag3end.
     * The event.canvas[XY] attributes will contain the coresponding touch position.
     * The event.scale and event.rotation attributes contain pinch scaling and two finger rotation quantities
     * respectively. The deltaX and deltaY attributes will contain drag offsets.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleGesture = function(event) {
        return false;
    };
 
    /**
     * This method is called when the canvas area loses focus.
     * @param {FocusEvent} event - The event object that triggered this call.
     * @returns {boolean} True if this tool wishes to consume the event and false to continue to pass the event
     * to lower priority active tools.
     */
    this.handleBlur = function(event) {
        return false;
    };
 
    /**
     * This method is called on every active tool whenever the screen area changes.
     * The new canvas area can be obtained from the Navigation interface via the getScreenViewport method.
     * @see Autodesk.Viewing.Navigation
     */
    this.handleResize = function() {
    };
}

As you can see, the interface describes a couple of things. There are methods for specifying the tool's name or names (because a single class can introduce multiple tools), and its priority. Tools with higher priorities will receive events before others. Then there are several methods for managing the tool's lifecycle: the register/deregister methods are called when the ToolController starts or stops controlling the tool, the activate/deactivate methods are called when the tool becomes active or inactive, and the update method is called repeatedly while the tool is active. The rest of the interface methods then handle various input events.

Note: you may have noticed that the interface methods are actually defined inside the constructor function, which means that we can't simply override them using the class syntax. There are different ways to work around that. In our sample we will delete the object methods in our tool's constructor so that the class/prototype methods will be called instead.

Custom tool

To keep things simple, let's build a simple tool that'll allow us to draw boxes and spheres. Here's how it would work:

  • on mouse button down event the tool will start "drawing" the geometry in the XY plane
  • on mouse button up event, the tool will start monitoring mouse move events to control the height of the geometry
  • on mouse click event, the geometry will be finalized

This is the basic skeleton of our tool class:

class DrawTool extends Autodesk.Viewing.ToolInterface {
    constructor() {
        super();
        this.names = ['box-drawing-tool', 'sphere-drawing-tool'];
 
        // Hack: delete functions defined *on the instance* of the tool.
        // We want the tool controller to call our class methods instead.
        delete this.register;
        delete this.deregister;
        delete this.activate;
        delete this.deactivate;
        delete this.getPriority;
        delete this.handleMouseMove;
        delete this.handleButtonDown;
        delete this.handleButtonUp;
        delete this.handleSingleClick;
    }
 
    register() {
        console.log('DrawTool registered.');
    }
 
    deregister() {
        console.log('DrawTool unregistered.');
    }
 
    activate(name, viewer) {
        console.log('DrawTool activated.');
    }
 
    deactivate(name) {
        console.log('DrawTool deactivated.');
    }
 
    getPriority() {
        return 42; // Or feel free to use any number higher than 0 (which is the priority of all the default viewer tools)
    }
 
    update(highResTimestamp) {
        return false;
    }
 
    handleMouseMove(event) {
        return false;
    }
 
    handleButtonDown(event, button) {
        return false;
    }
 
    handleButtonUp(event, button) {
        return false;
    }
 
    handleSingleClick(event, button) {
        return false;
    }
 
    _update() {
        // Here we will be updating the actual geometry
    }
}

Now, let's update the activate and deactivate methods. When our tool is activated, we will want to store a reference to the viewer for later use, a "drawing mode" (box vs sphere) based on which of our two tool names was activated, and a "drawing state" that will help us keep track of what stage of the drawing we're currently in.

activate(name, viewer) {
    this.viewer = viewer;
    this.mode = (name === 'box-drawing-tool') ? 'box' : 'sphere';
    this.state = ''; // can be '' (not drawing anything), 'xy' (drawing in the XY plane), or 'z' (specifying the height)
    console.log('DrawTool', name, 'activated.');
}
 
deactivate(name) {
    this.viewer = null;
    this.state = '';
    console.log('DrawTool', name, 'deactivated.');
}

To implement the first stage of the drawing - specifying the geometry's extent in the XY plane - we will want to update the handleButtonDown and handleButtonUp methods:

handleButtonDown(event, button) {
    // If left button is pressed and we're not drawing already
    if (button === 0 && this.state === '') {
        // Create new geometry and add it to an overlay
        if (this.mode === 'box') {
            const geometry = new THREE.BufferGeometry().fromGeometry(new THREE.BoxGeometry(1, 1, 1));
            const material = new THREE.MeshPhongMaterial({ color: 0x00ff00 });
            this.mesh = new THREE.Mesh(geometry, material);
        } else {
            const geometry = new THREE.BufferGeometry().fromGeometry(new THREE.SphereGeometry(0.5, 16, 16));
            const material = new THREE.MeshPhongMaterial({ color: 0x0000ff });
            this.mesh = new THREE.Mesh(geometry, material);
        }
        this.viewer.impl.addOverlay('draw-tool-overlay', this.mesh);
 
        // Initialize the 3 values that will control the geometry's size (1st corner in the XY plane, 2nd corner in the XY plane, and height)
        this.corner1 = this.corner2 = this.viewer.impl.intersectGround(event.clientX, event.clientY);
        this.height = 0.1;
        this._update();
        this.state = 'xy'; // Now we're drawing in the XY plane
        return true; // Stop the event from going to other tools in the stack
    }
    // Otherwise let another tool handle the event, and make note that our tool is now bypassed
    this.bypassed = true;
    return false;
}
 
handleButtonUp(event, button) {
    // If left button is released and we're currently drawing in the XY plane
    if (button === 0 && this.state === 'xy') {
        // Update the 2nd corner in the XY plane and switch to the "height drawing"
        this.corner2 = this.viewer.impl.intersectGround(event.clientX, event.clientY);
        this._update();
        this.state = 'z';
        this.lastClientY = event.clientY; // Store the current mouse Y coordinate to compute height later on
        return true; // Stop the event from going to other tools in the stack
    }
    // Otherwise let another tool handle the event, and make note that our tool is no longer bypassed
    this.bypassed = false;
    return false;
}

Next, let's update the handleMouseMove method. In our case, depending on which drawing state we're in, we will want to either update the 2nd corner of our geometry in the XY plane, or its height:

handleMouseMove(event) {
    if (!this.bypassed && this.state === 'xy') {
        // If we're in the "XY plane drawing" state, and not bypassed by another tool
        this.corner2 = this.viewer.impl.intersectGround(event.clientX, event.clientY);
        this._update();
        return true;
    } else if (!this.bypassed && this.state === 'z') {
        // If we're in the "height drawing" state, and not bypassed by another tool
        this.height = this.lastClientY - event.clientY;
        this._update();
        return true;
    }
    // Otherwise let another tool handle the event
    return false;
}

Next, we will want to handle the mouse single click event in order to finalize the placement of a geometry:

handleSingleClick(event, button) {
    // If left button is clicked and we're currently in the "height drawing" state
    if (button === 0 && this.state === 'z') {
        this.state = '';
        return true; // Stop the event from going to other tools in the stack
    }
    // Otherwise let another tool handle the event
    return false;
}

Now that we're done with all the event handling methods, the last piece we need to implement is our _update method that positions and scales the current geometry based on the corner1corner2, and height values:

_update() {
    const { corner1, corner2, height, mesh } = this;
    const minX = Math.min(corner1.x, corner2.x), maxX = Math.max(corner1.x, corner2.x);
    const minY = Math.min(corner1.y, corner2.y), maxY = Math.max(corner1.y, corner2.y);
    mesh.position.x = minX + 0.5 * (maxX - minX);
    mesh.position.y = minY + 0.5 * (maxY - minY);
    mesh.position.z = 0.5 * height;
    mesh.scale.x = maxX - minX;
    mesh.scale.y = maxY - minY;
    mesh.scale.z = height;
    this.viewer.impl.invalidate(true, true, true);
}

And our tool is ready! You could now register and activate it directly with the viewer:

const drawTool = new DrawTool();
viewer.toolController.registerTool(drawTool);
viewer.toolController.activateTool('box-drawing-tool');

Or, to keep things nice and clean, you could wrap the tool into a viewer extension, for example, like so:

const BoxDrawToolName = 'box-draw-tool';
const SphereDrawToolName = 'sphere-draw-tool';
const DrawToolOverlay = 'draw-tool-overlay';
 
class DrawToolExtension extends Autodesk.Viewing.Extension {
    constructor(viewer, options) {
        super(viewer, options);
        this.tool = new DrawTool();
    }
 
    load() {
        this.viewer.toolController.registerTool(this.tool);
        this.viewer.impl.createOverlayScene(DrawToolOverlay);
        console.log('DrawToolExtension loaded.');
        return true;
    }
 
    unload() {
        this.viewer.toolController.deregisterTool(this.tool);
        this.viewer.impl.removeOverlayScene(DrawToolOverlay);
        console.log('DrawToolExtension unloaded.');
        return true;
    }
 
    onToolbarCreated(toolbar) {
        const controller = this.viewer.toolController;
        this.button1 = new Autodesk.Viewing.UI.Button('box-draw-tool-button');
        this.button1.onClick = (ev) => {
            if (controller.isToolActivated(BoxDrawToolName)) {
                controller.deactivateTool(BoxDrawToolName);
                this.button1.setState(Autodesk.Viewing.UI.Button.State.INACTIVE);
            } else {
                controller.deactivateTool(SphereDrawToolName);
                controller.activateTool(BoxDrawToolName);
                this.button2.setState(Autodesk.Viewing.UI.Button.State.INACTIVE);
                this.button1.setState(Autodesk.Viewing.UI.Button.State.ACTIVE);
            }
        };
        this.button1.setToolTip('Box Draw Tool');
 
        this.button2 = new Autodesk.Viewing.UI.Button('sphere-draw-tool-button');
        this.button2.onClick = (ev) => {
            if (controller.isToolActivated(SphereDrawToolName)) {
                controller.deactivateTool(SphereDrawToolName);
                this.button2.setState(Autodesk.Viewing.UI.Button.State.INACTIVE);
            } else {
                controller.deactivateTool(BoxDrawToolName);
                controller.activateTool(SphereDrawToolName);
                this.button1.setState(Autodesk.Viewing.UI.Button.State.INACTIVE);
                this.button2.setState(Autodesk.Viewing.UI.Button.State.ACTIVE);
            }
        };
        this.button2.setToolTip('Sphere Draw Tool');
 
        this.group = new Autodesk.Viewing.UI.ControlGroup('draw-tool-group');
        this.group.addControl(this.button1);
        this.group.addControl(this.button2);
        toolbar.addControl(this.group);
    }
}

When you're testing your new tool in the viewer, notice how it interacts with the other tools. For example, after finishing drawing the geometry in the XY plane and before setting its height with a single mouse click, you can actually use the mouse wheel to zoom in and out, or press and hold the left mouse button to orbit the camera. That's the magic of the tool stack!

And now we're really done. If you've made it all the way here, congratulations! As a reward for your patience, here's a link to a complete sample code: https://github.com/petrbroz/forge-hello-world/blob/experiment/custom-viewer-tool/public/DrawToolExtension.js. Cheers!

Related Article