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 geomVertex
, geomEdge
, 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.
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).