19 Sep 2017
Custom Window Selection in the Forge Viewer - Part I

Custom implementation of a much awaited feature: Window Selection in Forge Viewer!
A feature that has been asked by several developers about the Forge Viewer is the ability to perform window selection from the UI, that is defining a 2D rectangle on the screen and filter all components in the model which are contained inside a 3D projected shape defined by that selected area.
I decided to take up the challenge so here we are ...
Let's start with the low-hanging fruits: the first thing that we need is the ability to select a 2D window on the screen with a visual feedback for the user. This is pretty close to what the "ZoomWindow" extension is already doing:
This extension is not loaded in the viewer by default, you can load it programmatically using the code below:
viewer.loadExtension('Autodesk.Viewing.ZoomWindow')
Here is the ZoomWindow command in action:
Instead of reinventing the wheel, I shamelessly grabbed the implementation of that extension which is available directly in the current version of viewer3D.js (starts at line# 67289).
The idea is the following: project the 2D rectangle defined by the user on the screen in order to create a 3D pyramidal shape, then run some custom logic in order to determine which components are included inside that shape, those are the components we want to select ...
The complete workflow requires more than a little code, so in this first part I am tackling the user interaction part and creation of the pyramid shape. I customized a bit the implementation of the ZoomWindowTool so instead of changing the camera parameters it will perform computation of the shape based on the two corners selected on screen. For debugging purpose it is currently representing the shape as a custom mesh so it is easier to appreciate the accuracy of the logic.
You can see below a demo of the current code in action where I'm testing both camera types perspective and orthographic. The live version can be tested here and the complete code is available at Viewing.Extension.SelectionWindow. Next step will be to come up with an efficient way for filtering which components are located inside that shape, so stay tuned!
Here is the implementation of the core logic that will perform window selection and filtering of the components:
/////////////////////////////////////////////////////////// | |
// SelectSet util for Selection Window | |
// in Forge Viewer | |
// By Philippe Leefsma, September 2017 | |
// | |
/////////////////////////////////////////////////////////// | |
import Toolkit from 'Viewer.Toolkit' | |
export default class SelectSet { | |
///////////////////////////////////////////////////////// | |
// | |
// | |
///////////////////////////////////////////////////////// | |
constructor (viewer) { | |
this.viewer = viewer | |
//debug | |
this.material = this.createMaterial('select-set') | |
} | |
///////////////////////////////////////////////////////// | |
// Load model: required to compute the bounding box | |
// | |
///////////////////////////////////////////////////////// | |
async loadModel (model) { | |
this.model = model | |
const instanceTree = model.getData().instanceTree | |
const rootId = instanceTree.getRootId() | |
const bbox = | |
await this.getComponentBoundingBox( | |
model, rootId) | |
this.boundingSphere = bbox.getBoundingSphere() | |
} | |
///////////////////////////////////////////////////////// | |
// Returns bounding box as it appears in the viewer | |
// (transformations could be applied) | |
// | |
///////////////////////////////////////////////////////// | |
getModifiedWorldBoundingBox (fragIds, fragList) { | |
const fragbBox = new THREE.Box3() | |
const nodebBox = new THREE.Box3() | |
fragIds.forEach(function(fragId) { | |
fragList.getWorldBounds(fragId, fragbBox) | |
nodebBox.union(fragbBox) | |
}) | |
return nodebBox | |
} | |
///////////////////////////////////////////////////////// | |
// Returns bounding box for aggregated fragments | |
// | |
///////////////////////////////////////////////////////// | |
async getComponentBoundingBox (model, dbId) { | |
const fragIds = await Toolkit.getFragIds( | |
model, dbId) | |
const fragList = model.getFragmentList() | |
return this.getModifiedWorldBoundingBox( | |
fragIds, fragList) | |
} | |
///////////////////////////////////////////////////////// | |
// Creates Raycaster object from the mouse pointer | |
// | |
///////////////////////////////////////////////////////// | |
pointerToRay (domElement, camera, pointer) { | |
const pointerVector = new THREE.Vector3() | |
const rayCaster = new THREE.Raycaster() | |
const pointerDir = new THREE.Vector3() | |
const rect = domElement.getBoundingClientRect() | |
const x = ((pointer.clientX - rect.left) / rect.width) * 2 - 1 | |
const y = -((pointer.clientY - rect.top) / rect.height) * 2 + 1 | |
if (camera.isPerspective) { | |
pointerVector.set(x, y, 0.5) | |
pointerVector.unproject(camera) | |
rayCaster.set(camera.position, | |
pointerVector.sub( | |
camera.position).normalize()) | |
} else { | |
pointerVector.set(x, y, -15) | |
pointerVector.unproject(camera) | |
pointerDir.set(0, 0, -1) | |
rayCaster.set(pointerVector, | |
pointerDir.transformDirection( | |
camera.matrixWorld)) | |
} | |
return rayCaster.ray | |
} | |
///////////////////////////////////////////////////////// | |
// Runs the main logic of the select set: | |
// computes a pyramid shape from the selection window | |
// corners and determines enclosed meshes from the model | |
// | |
///////////////////////////////////////////////////////// | |
compute (pointer1, pointer2) { | |
const nav = this.viewer.navigation | |
const canvas = this.viewer.canvas | |
const camera = nav.getCamera() | |
// build 4 rays to project the 4 corners | |
// of the selection window | |
const ray1 = this.pointerToRay( | |
canvas, camera, pointer1) | |
const ray2 = this.pointerToRay( | |
canvas, camera, { | |
clientX: pointer2.clientX, | |
clientY: pointer1.clientY | |
}) | |
const ray3 = this.pointerToRay( | |
canvas, camera, pointer2) | |
const ray4 = this.pointerToRay( | |
canvas, camera, { | |
clientX: pointer1.clientX, | |
clientY: pointer2.clientY | |
}) | |
// first we compute the top of the pyramid | |
const top = new THREE.Vector3(0,0,0) | |
top.add(ray1.origin) | |
top.add(ray2.origin) | |
top.add(ray3.origin) | |
top.add(ray4.origin) | |
top.multiplyScalar(0.25) | |
// we use the bounding sphere to determine | |
// the height of the pyramid | |
const {center, radius} = this.boundingSphere | |
// compute distance from pyramid top to center | |
// of bounding sphere | |
const dist = new THREE.Vector3( | |
top.x - center.x, | |
top.y - center.y, | |
top.z - center.z) | |
// compute height of the pyramid: | |
// to make sure we go far enough, | |
// we add the radius of the bounding sphere | |
const height = radius + dist.length() | |
// compute the length of the side edges | |
const angle = ray1.direction.angleTo( | |
ray2.direction) | |
const length = height / Math.cos(angle * 0.5) | |
// compute bottom vertices | |
const v1 = new THREE.Vector3( | |
ray1.origin.x + ray1.direction.x * length, | |
ray1.origin.y + ray1.direction.y * length, | |
ray1.origin.z + ray1.direction.z * length) | |
const v2 = new THREE.Vector3( | |
ray2.origin.x + ray2.direction.x * length, | |
ray2.origin.y + ray2.direction.y * length, | |
ray2.origin.z + ray2.direction.z * length) | |
const v3 = new THREE.Vector3( | |
ray3.origin.x + ray3.direction.x * length, | |
ray3.origin.y + ray3.direction.y * length, | |
ray3.origin.z + ray3.direction.z * length) | |
const v4 = new THREE.Vector3( | |
ray4.origin.x + ray4.direction.x * length, | |
ray4.origin.y + ray4.direction.y * length, | |
ray4.origin.z + ray4.direction.z * length) | |
var geometry = new THREE.Geometry() | |
geometry.vertices = [ | |
v1, v2, v3, v4, top | |
] | |
geometry.faces = [ | |
new THREE.Face3(0, 1, 2), | |
new THREE.Face3(0, 2, 3), | |
new THREE.Face3(1, 0, 4), | |
new THREE.Face3(2, 1, 4), | |
new THREE.Face3(3, 2, 4), | |
new THREE.Face3(0, 3, 4) | |
] | |
geometry.computeFaceNormals() | |
return new THREE.Mesh(geometry, this.material) | |
} | |
///////////////////////////////////////////////////////// | |
// | |
// | |
///////////////////////////////////////////////////////// | |
createMaterial (name, color = 0xff0000) { | |
const material = new THREE.MeshPhongMaterial({ | |
side: THREE.DoubleSide, | |
color | |
}) | |
this.viewer.impl.matman().addMaterial( | |
name, material, true) | |
return material | |
} | |
} |