21 Sep 2017
Custom Window Selection in the Forge Viewer - Part II

In my previous post - Custom Window Selection in the Forge Viewer - Part I - I started to tackle custom window selection in the Forge Viewer. Here is the second part of that series.
I was showing previously how to build a projected pyramidal shape from the selected corners on the screen in order to build our selection logic. The next step today is to implement the selection logic itself. The approach is rather straightforward: we are going to use the five planes defined by the pyramid plus the camera plane - nothing behind the camera can be logically selected - to perform a first filtering on each component bounding box in the model. If a bounding box is fully contained inside the pyramid, the component is selected, if it is fully outside, the component is not selected and finally if the bounding box intersects with the pyramid, a finer analysis is required.
First thing to do is to compute a set of oriented planes based on the pyramid faces, to be consistent we want the planes normal to always points inside the pyramid, then by computing the distance from the bounding box corners to a plane, we know instantly on which side of the plane that point is located. As soon as a corner is outside, we discard that box and early exit our loop.
Here is a screenshot representing the projected pyramid along with the plane normals which I used to visually debug the command while implementing it:
We then have to iterate through each leaf component bounding box - only leaf components own geometry in the model and therefore can be selected directly - and check whether the box is inside, outside or intersects our pyramid. In this second version I did not yet implemented the intersection logic as it will require a bit more work, but the current state of my command is already rather satisfying. I will ultimately add a switch to let the user control whether partially selected components have to be included or not in the selection.
The command also supports working with multiple models in the same scene. It currently performs detection on a single model at a time for performance reasons but could easily be extended to scan all models in the scene if needed.
You can see below a recording where I am testing the selection in a scene containing three models, including our Toronto office with a non-trivial number of components. The performance of the selection is quite acceptable on my machine.
Live version is available for you to play with here and the selection logic code is below:
/////////////////////////////////////////////////////////// | |
// SelectSet util for Selection Window in Forge Viewer | |
// By Philippe Leefsma, September 2017 | |
// | |
/////////////////////////////////////////////////////////// | |
import BoxGeometryIntersect from './BoxGeometryIntersect' | |
import Toolkit from 'Viewer.Toolkit' | |
export default class SelectSet { | |
///////////////////////////////////////////////////////// | |
// | |
// | |
///////////////////////////////////////////////////////// | |
constructor (viewer) { | |
this.viewer = viewer | |
} | |
///////////////////////////////////////////////////////// | |
// Set model: required to compute the bounding boxes | |
// | |
///////////////////////////////////////////////////////// | |
async setModel (model) { | |
this.model = model | |
const instanceTree = model.getData().instanceTree | |
const rootId = instanceTree.getRootId() | |
const bbox = | |
await this.getComponentBoundingBox( | |
model, rootId) | |
this.boundingSphere = bbox.getBoundingSphere() | |
const leafIds = await Toolkit.getLeafNodes (model) | |
this.boundingBoxInfo = leafIds.map((dbId) => { | |
const bbox = this.getLeafComponentBoundingBox( | |
model, dbId) | |
return { | |
bbox, | |
dbId | |
} | |
}) | |
} | |
///////////////////////////////////////////////////////// | |
// 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 fragment list | |
// | |
///////////////////////////////////////////////////////// | |
async getComponentBoundingBox (model, dbId) { | |
const fragIds = await Toolkit.getFragIds( | |
model, dbId) | |
const fragList = model.getFragmentList() | |
return this.getModifiedWorldBoundingBox( | |
fragIds, fragList) | |
} | |
getLeafComponentBoundingBox (model, dbId) { | |
const fragIds = Toolkit.getLeafFragIds( | |
model, dbId) | |
const fragList = model.getFragmentList() | |
return this.getModifiedWorldBoundingBox( | |
fragIds, fragList) | |
} | |
///////////////////////////////////////////////////////// | |
// Creates Raycaster object from the mouse pointer | |
// | |
///////////////////////////////////////////////////////// | |
pointerToRay (pointer) { | |
const camera = this.viewer.navigation.getCamera() | |
const pointerVector = new THREE.Vector3() | |
const rayCaster = new THREE.Raycaster() | |
const pointerDir = new THREE.Vector3() | |
const domElement = this.viewer.canvas | |
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 | |
} | |
///////////////////////////////////////////////////////// | |
// Returns true if the box is contained inside the | |
// closed volume defined the the input planes | |
// | |
///////////////////////////////////////////////////////// | |
containsBox (planes, box) { | |
const {min, max} = box | |
const vertices = [ | |
new THREE.Vector3(min.x, min.y, min.z), | |
new THREE.Vector3(min.x, min.y, max.z), | |
new THREE.Vector3(min.x, max.y, max.z), | |
new THREE.Vector3(max.x, max.y, max.z), | |
new THREE.Vector3(max.x, max.y, min.z), | |
new THREE.Vector3(max.x, min.y, min.z), | |
new THREE.Vector3(min.x, max.y, min.z), | |
new THREE.Vector3(max.x, min.y, max.z) | |
] | |
for (let vertex of vertices) { | |
for (let plane of planes) { | |
if (plane.distanceToPoint(vertex) < 0) { | |
return false | |
} | |
} | |
} | |
return true | |
} | |
///////////////////////////////////////////////////////// | |
// Returns the oriented camera plane | |
// | |
///////////////////////////////////////////////////////// | |
getCameraPlane () { | |
const camera = this.viewer.navigation.getCamera() | |
const normal = camera.target.clone().sub( | |
camera.position).normalize() | |
const pos = camera.position | |
const dist = | |
- normal.x * pos.x | |
- normal.y * pos.y | |
- normal.z * pos.z | |
return new THREE.Plane (normal, dist) | |
} | |
///////////////////////////////////////////////////////// | |
// Creates pyramid geometry to perform tri-box | |
// intersection analysis | |
// | |
///////////////////////////////////////////////////////// | |
createPyramidGeometry (vertices) { | |
var geometry = new THREE.Geometry() | |
geometry.vertices = vertices | |
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) | |
] | |
return geometry | |
} | |
///////////////////////////////////////////////////////// | |
// Determine if the bounding boxes are | |
// inside, outside or intersect with the selection window | |
// | |
///////////////////////////////////////////////////////// | |
filterBoundingBoxes (planes, vertices) { | |
const geometry = this.createPyramidGeometry(vertices) | |
const intersect = [] | |
const outside = [] | |
const inside = [] | |
for (let bboxInfo of this.boundingBoxInfo) { | |
// if bounding box inside, then we can be sure | |
// the mesh is inside too | |
if (this.containsBox (planes, bboxInfo.bbox)) { | |
inside.push(bboxInfo) | |
} else { | |
// otherwise need a more precise tri-box | |
// analysis to determine if the bbox intersect | |
// the pyramid geometry | |
BoxGeometryIntersect(bboxInfo.bbox, geometry) | |
? intersect.push(bboxInfo) | |
: outside.push(bboxInfo) | |
} | |
} | |
return { | |
intersect, | |
outside, | |
inside | |
} | |
} | |
///////////////////////////////////////////////////////// | |
// 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, partialSelect) { | |
// build 4 rays to project the 4 corners | |
// of the selection window | |
const xMin = Math.min(pointer1.clientX, pointer2.clientX) | |
const xMax = Math.max(pointer1.clientX, pointer2.clientX) | |
const yMin = Math.min(pointer1.clientY, pointer2.clientY) | |
const yMax = Math.max(pointer1.clientY, pointer2.clientY) | |
const ray1 = this.pointerToRay({ | |
clientX: xMin, | |
clientY: yMin | |
}) | |
const ray2 = this.pointerToRay({ | |
clientX: xMax, | |
clientY: yMin | |
}) | |
const ray3 = this.pointerToRay({ | |
clientX: xMax, | |
clientY: yMax | |
}) | |
const ray4 = this.pointerToRay({ | |
clientX: xMin, | |
clientY: yMax | |
}) | |
// 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) | |
// create planes | |
const plane1 = new THREE.Plane() | |
const plane2 = new THREE.Plane() | |
const plane3 = new THREE.Plane() | |
const plane4 = new THREE.Plane() | |
const plane5 = new THREE.Plane() | |
plane1.setFromCoplanarPoints(top, v1, v2) | |
plane2.setFromCoplanarPoints(top, v2, v3) | |
plane3.setFromCoplanarPoints(top, v3, v4) | |
plane4.setFromCoplanarPoints(top, v4, v1) | |
plane5.setFromCoplanarPoints( v3, v2, v1) | |
const planes = [ | |
plane1, plane2, | |
plane3, plane4, | |
plane5, this.getCameraPlane() | |
] | |
const vertices = [ | |
v1, v2, v3, v4, top | |
] | |
// filter all bounding boxes to determine | |
// if inside, outside or intersect | |
const result = this.filterBoundingBoxes( | |
planes, vertices) | |
// all inside bboxes need to be part of the selection | |
const dbIdsInside = result.inside.map((bboxInfo) => { | |
return bboxInfo.dbId | |
}) | |
// if partialSelect = true | |
// we need to return the intersect bboxes | |
if (partialSelect) { | |
const dbIdsIntersect = result.intersect.map((bboxInfo) => { | |
return bboxInfo.dbId | |
}) | |
// At this point we could perform a finer analysis | |
// to determine if the mesh vertices are inside | |
// or outside the selection window but it would | |
// be a much more expensive computation | |
return [...dbIdsInside, ...dbIdsIntersect] | |
} | |
return dbIdsInside | |
} | |
} |