15 Sep 2020

Snappy viewer tools

Have you ever thought: "man, I like how the Forge Viewer tools like Markups or Measure snap to different parts of 2D drawings, I wish my viewer extension could do that, too"? Well, wonder no more! The snapping functionality can be reused, and in this blog post we're going to show you how.

The snapping logic is encapsulated in a class called Autodesk.Viewing.Extensions.Snapping.Snapper available in the Autodesk.Snapping extension, and it is actually implemented as a tool.

To learn more about the concept of tools in Forge Viewer, please refer to this blog post: https://forge.autodesk.com/blog/custom-tools-forge-viewer.

Once the snapper is put on the tool stack and activated, the viewer automatically handles all the user input for it, and the only thing you need to do is ask it for any "snapping results":

// ...
 
viewer.loadExtension('Autodesk.Snapping');
 
// ...
 
let snapper = new Autodesk.Viewing.Extensions.Snapping.Snapper(viewer);
viewer.toolController.registerTool(snapper);
viewer.toolController.activateTool(snapper.getName());
 
// ...
 
if (snapper.isSnapped()) {
    const result = snapper.getSnapResult();
    const { SnapType } = Autodesk.Viewing.MeasureCommon;
    switch (result.geomType) {
        case SnapType.SNAP_VERTEX:
            console.log('Snapped to vertex', result.geomVertex);
            break;
        case SnapType.SNAP_MIDPOINT:
            console.log('Snapped to midpoint', result.geomVertex);
            break;
        case SnapType.SNAP_INTERSECTION:
            console.log('Snapped to intersection', result.geomVertex);
            break;
        case SnapType.SNAP_CIRCLE_CENTER:
            console.log('Snapped to circle center', result.geomVertex);
            break;
        case SnapType.RASTER_PIXEL:
            console.log('Snapped to pixel', result.geomVertex);
            break;
        case SnapType.SNAP_EDGE:
            console.log('Snapped to edge', result.geomEdge);
            break;
        case SnapType.SNAP_CIRCULARARC:
            console.log('Snapped to circular arc', result.geomEdge);
            break;
        case SnapType.SNAP_CURVEDEDGE:
            console.log('Snapped to curved edge', result.geomEdge);
            break;
        case SnapType.SNAP_FACE:
            console.log('Snapped to face', result.geomFace);
            break;
        case SnapType.SNAP_CURVEDFACE:
            console.log('Snapped to curved face', result.geomFace);
            break;
    }
}
 
// ...

The object returned by snapper.getSnapResult() will always include a geomType property that can be used to identify the actual type of snapping (see Autodesk.Viewing.MeasureCommon.SnapType for all the available values). Based on the type, you can then query additional properties from the result such as geomVertexgeomEdge, or geomFace.

The snapper object also includes a property called indicator that can be used to control the visual feedback:

snapper.indicator.render(); // render a custom mesh in an overlay, indicating the current snapping result
snapper.indicator.clearOverlays(); // clear any visual feedback

Now, to show this functionality in a practical example, let's build a custom viewer tool for drawing "3D areas" with THREE.ExtrudeGeometry, using our new know-how to snap the outlines of the extruded geometry to vertices, midpoints, and intersections. The tool will keep a record of points the user clicked on, and it will continuously update an extruded geometry in an overlay scene based on these points, and including the currently hovered snapped vertex if there is one. The outline of the extruded geometry will be computed from the X,Y coordinates of each point, and the height of the geometry will be determined based on the minimum and maximum Z values. After hitting the esc key, the extruded geometry will be "baked" into the overlay scene, and a new geometry will be initialized.

Drawing an area in 3D model.

Note that while our custom tool is designed for 3D models, the snapping functionality is available for 2D drawings as well.

The implementation could look something like this:

const DrawBoundsToolName = 'draw-bounds-tool';
const DrawBoundsOverlayName = 'draw-bounds-overlay';
 
class DrawBoundsTool extends Autodesk.Viewing.ToolInterface {
    constructor(viewer) {
        super();
        this.viewer = viewer;
        this.names = [DrawBoundsToolName];
        this.active = false;
        this.snapper = null;
        this.points = []; // Points of the currently drawn outline
        this.mesh = null; // Mesh representing the currently drawn area
        // Hack: delete functions defined on the *instance* of a ToolInterface (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.handleSingleClick;
        delete this.handleKeyUp;
    }
 
    register() {
        this.snapper = new Autodesk.Viewing.Extensions.Snapping.Snapper(this.viewer, { renderSnappedGeometry: true, renderSnappedTopology: true });
        this.viewer.toolController.registerTool(this.snapper);
        this.viewer.toolController.activateTool(this.snapper.getName());
        console.log('DrawBoundsTool registered.');
    }
 
    deregister() {
        this.viewer.toolController.deactivateTool(this.snapper.getName());
        this.viewer.toolController.deregisterTool(this.snapper);
        this.snapper = null;
        console.log('DrawBoundsTool unregistered.');
    }
 
    activate(name, viewer) {
        if (!this.active) {
            this.viewer.overlays.addScene(DrawBoundsOverlayName);
            console.log('DrawBoundsTool activated.');
            this.active = true;
        }
    }
 
    deactivate(name) {
        if (this.active) {
            this.viewer.overlays.removeScene(DrawBoundsOverlayName);
            console.log('DrawBoundsTool deactivated.');
            this.active = false;
        }
    }
 
    getPriority() {
        return 42; // Feel free to use any number higher than 0 (which is the priority of all the default viewer tools)
    }
 
    handleMouseMove(event) {
        if (!this.active) {
            return false;
        }
 
        this.snapper.indicator.clearOverlays();
        if (this.snapper.isSnapped()) {
            const result = this.snapper.getSnapResult();
            const { SnapType } = Autodesk.Viewing.MeasureCommon;
            switch (result.geomType) {
                case SnapType.SNAP_VERTEX:
                case SnapType.SNAP_MIDPOINT:
                case SnapType.SNAP_INTERSECTION:
                case SnapType.SNAP_CIRCLE_CENTER:
                case SnapType.RASTER_PIXEL:
                    // console.log('Snapped to vertex', result.geomVertex);
                    this.snapper.indicator.render(); // Show indicator when snapped to a vertex
                    this._update(result.geomVertex);
                    break;
                case SnapType.SNAP_EDGE:
                case SnapType.SNAP_CIRCULARARC:
                case SnapType.SNAP_CURVEDEDGE:
                    // console.log('Snapped to edge', result.geomEdge);
                    break;
                case SnapType.SNAP_FACE:
                case SnapType.SNAP_CURVEDFACE:
                    // console.log('Snapped to face', result.geomFace);
                    break;
            }
        }
        return false;
    }
 
    handleSingleClick(event, button) {
        if (!this.active) {
            return false;
        }
 
        if (button === 0 && this.snapper.isSnapped()) {
            const result = this.snapper.getSnapResult();
            const { SnapType } = Autodesk.Viewing.MeasureCommon;
            switch (result.geomType) {
                case SnapType.SNAP_VERTEX:
                case SnapType.SNAP_MIDPOINT:
                case SnapType.SNAP_INTERSECTION:
                case SnapType.SNAP_CIRCLE_CENTER:
                case SnapType.RASTER_PIXEL:
                    this.points.push(result.geomVertex.clone());
                    this._update();
                    break;
                default:
                    // Do not snap to other types
                    break;
            }
            return true; // Stop the event from going to other tools in the stack
        }
        return false;
    }
 
    handleKeyUp(event, keyCode) {
        if (this.active) {
            if (keyCode === 27) {
                // Finalize the extrude mesh and initialie a new one
                this.points = [];
                this.mesh = null;
                return true;
            }
        }
        return false;
    }
 
    _update(intermediatePoint = null) {
        if ((this.points.length + (intermediatePoint ? 1 : 0)) > 2) {
            if (this.mesh) {
                this.viewer.overlays.removeMesh(this.mesh, DrawBoundsOverlayName);
            }
            let minZ = this.points[0].z, maxZ = this.points[0].z;
            let shape = new THREE.Shape();
            shape.moveTo(this.points[0].x, this.points[0].y);
            for (let i = 1; i < this.points.length; i++) {
                shape.lineTo(this.points[i].x, this.points[i].y);
                minZ = Math.min(minZ, this.points[i].z);
                maxZ = Math.max(maxZ, this.points[i].z);
            }
            if (intermediatePoint) {
                shape.lineTo(intermediatePoint.x, intermediatePoint.y);
                minZ = Math.min(minZ, intermediatePoint.z);
                maxZ = Math.max(maxZ, intermediatePoint.z);
            }
            let geometry = new THREE.BufferGeometry().fromGeometry(new THREE.ExtrudeGeometry(shape, { steps: 1, amount: maxZ - minZ, bevelEnabled: false }));
            let material = new THREE.MeshBasicMaterial({ color: 0xff0000, opacity: 0.5, transparent: true });
            this.mesh = new THREE.Mesh(geometry, material);
            this.mesh.position.z = minZ;
            this.viewer.overlays.addMesh(this.mesh, DrawBoundsOverlayName);
            this.viewer.impl.sceneUpdated(true);
        }
    }
}

Next, let's wrap our new tool into a viewer extension to make it easily reusable in other projects, and make sure to load the Autodesk.Snapping extension containing the Autodesk.Viewing.Extensions.Snapping.Snapper class:

class DrawBoundsToolExtension extends Autodesk.Viewing.Extension {
    constructor(viewer, options) {
        super(viewer, options);
        this.tool = new DrawBoundsTool(viewer);
        this.button = null;
    }
 
    async load() {
        await this.viewer.loadExtension('Autodesk.Snapping');
        this.viewer.toolController.registerTool(this.tool);
        console.log('DrawBoundsToolExtension has been loaded.');
        return true;
    }
 
    async unload() {
        this.viewer.toolController.deregisterTool(this.tool);
        console.log('DrawBoundsToolExtension has been unloaded.');
        return true;
    }
 
    onToolbarCreated(toolbar) {
        const controller = this.viewer.toolController;
        this.button = new Autodesk.Viewing.UI.Button('draw-bounds-tool-button');
        this.button.onClick = (ev) => {
            if (controller.isToolActivated(DrawBoundsToolName)) {
                controller.deactivateTool(DrawBoundsToolName);
                this.button.setState(Autodesk.Viewing.UI.Button.State.INACTIVE);
            } else {
                controller.activateTool(DrawBoundsToolName);
                this.button.setState(Autodesk.Viewing.UI.Button.State.ACTIVE);
            }
        };
        this.button.setToolTip('Draw Bounds Tool');
        this.group = new Autodesk.Viewing.UI.ControlGroup('draw-tool-group');
        this.group.addControl(this.button);
        toolbar.addControl(this.group);
    }
}
 
Autodesk.Viewing.theExtensionManager.registerExtension('DrawBoundsToolExtension', DrawBoundsToolExtension);

And that's it! If you're interested, you can find a fully functioning sample in https://github.com/petrbroz/forge-basic-app/tree/sample/snapper (specifically, in the DrawBoundsToolExtension.js script).

Related Article