A com­mon­ly reques­ted fea­ture when working with digi­tal twins, is to show 3D data (like buil­ding models) and 2D data (like flo­or plans) along­side ano­ther. With the Auto­desk Plat­form Ser­vices (form­er­ly For­ge) it is easy enough to dis­play two sepa­ra­te view­ers on the same page.

Show­ing the sheet with the ground flo­or plan is easy enough — but whe­re is this door?

Often­ti­mes a fol­lo­wup request is about inter­ac­ti­vi­ty bet­ween tho­se two view­ers. In par­ti­cu­lar, when using the Sec­tion Ana­ly­sis fea­ture in 3D, users could feel quite lost in 2D, so our idea was to high­light the sec­tion box in 2D as well.

Oh no — iso­la­ting the door via a sec­tion box lea­ves me with no refe­rence points for ori­en­tee­ring on the sheet. Wouldn’t it be nice to see whe­re I am on the sheet like in the sketch on the right?


In the fol­lo­wing, we assu­me the rea­der to be fami­li­ar with the Auto­desk View­er SDK. We use Type­Script which is sad­ly not ful­ly sup­port­ed for what we want to do here, so plea­se bear this in mind when­ever you encoun­ter Type­Script war­nings while exe­cu­ting some of the fol­lo­wing code.

An Auto­desk blog post from 2020 ser­ves as a useful basis for our approach, by show­ing how to map sin­gle points bet­ween 3D space and 2D sheets.

For sim­pli­ci­ty most of our code is con­tai­ned within a sin­gle func­tion — which is an event hand­ler regis­tered for the Autodesk.Viewing.CUTPLANES_CHANGE_EVENT event of the 3D view­er (const handleCutPlanesChangedEvent = async (e: {planes: THREE.Vector4[]}) => {...}).

The rele­vant por­ti­ons out­side our hand­ler, which are left as an exer­cise for the reader:

  • v3D and v2D are both ins­tances of Autodesk.Viewing.GuiViewer3D and show 3D and 2D repre­sen­ta­ti­ons from the same Revit (.rvt) file hos­ted on the Auto­desk Con­s­truc­tion Cloud (ACC).
  • You have loa­ded AEC data for the model and stored it in a aec variable.
  • v2D shows a Revit sheet with at least one viewport.
  • v2D has an over­lay sce­ne with a con­stant, known iden­ti­fier (v2D.overlays.addScene(SHEET_OVERLAY)).


First we obtain an arbi­tra­ry view­port on the sheet — this can be extra­po­la­ted for any num­ber of view­ports if so desired.

const model2d = v2D.model
const sheetGuid = model2D.getDocumentNode().guid()
const vp = aec.viewports?.filter((v) => v.sheetGuid === sheetGuid)

For this view­port, we now obtain the matrix for the trans­for­ma­ti­on from 3D space to 2D space.

const inverseModelToViewerTransform = v3D.model.getInverseModelToViewerTransform()
const sheetUnitScale = model2d.getUnitScale()
const sheetMatrix: THREE.Matrix4 =
  // @ts-ignore Sadly Autodesk.AEC.AecModelData is not typed.
  Autodesk.AEC.AecModelData.get3DTo2DMatrix(vp, sheetUnitScale)

sheetMatrix is a 4x4 Matrix that can be used to trans­form a point, but we also need trans­for­ma­ti­on of direc­tion­al vec­tors (like a nor­mal vec­tor), so we gene­ra­te a 3x3 Matrix sheetMatrix3, sim­ply by drop­ping the last row and column of sheetMatrix.

We only want to draw onto the given view­port, and not the enti­re sheet, so we obtain the 4 lines deli­mi­ting the rec­tan­gu­lar viewport.

const bounds: { min: THREE.Vector2; max: THREE.Vector2 } =
  Autodesk.AEC.AecModelData.getViewportBounds(vp, sheetUnitScale)
const delimiters = [
  new THREE.Line3(
    new THREE.Vector3(bounds.min.x, bounds.min.y, 0),
    new THREE.Vector3(bounds.max.x, bounds.min.y, 0)
  new THREE.Line3(
    new THREE.Vector3(bounds.max.x, bounds.min.y, 0),
    new THREE.Vector3(bounds.max.x, bounds.max.y, 0)
  new THREE.Line3(
    new THREE.Vector3(bounds.max.x, bounds.max.y, 0),
    new THREE.Vector3(bounds.min.x, bounds.max.y, 0)
  new THREE.Line3(
    new THREE.Vector3(bounds.min.x, bounds.max.y, 0),
    new THREE.Vector3(bounds.min.x, bounds.min.y, 0)

The planes argu­ment pas­sed to the hand­ler con­ta­ins all cut­pla­nes, each repre­sen­ted in Hes­se nor­mal form by a 4 dimen­sio­nal vec­tor (the first 3 ele­ments being the nor­mal vec­tor, the 4th being the sca­lar distance). We split the­se com­pon­ents and map the pla­nes from 3D to 2D space, while also drop­ping all pla­nes that are par­al­lel to the sheet, sin­ce they are not inte­res­t­ing for us.

const cameraForward = new THREE.Vector3(
const planesOnSheet = planes
  .map((p) => {
    const normal = toV3(p) // toV3 is a helper function dropping the fourth element of a Vector4
    const origin = normal.clone().multiplyScalar(-p.w)
    const constant = normal.dot(origin)
    return { origin, normal, plane: new THREE.Plane(normal, -constant) }
  .filter(({ normal }) => Math.abs(normal.dot(cameraForward)) < 0.001)

Each pla­ne cor­re­sponds to one of the deli­mi­t­ers in the sen­se that it replaces this deli­mi­ter with its­elf. To find the cor­re­spon­ding deli­mi­ter, we con­sider only the lines par­al­lel to the pla­ne, and then find the one that the nor­mal vec­tor points towards.

We sepa­ra­te­ly check whe­ther any cut­pla­ne is moved out of the view­port in the direc­tion of its nor­mal vec­tor, in which case we con­sider the enti­re view­port hid­den and draw a rec­tang­le to hide it com­ple­te­ly. To iden­ti­fy this situa­ti­on, we check whe­ther both par­al­lel deli­mi­t­ers are behind the plane.

const material = new THREE.MeshBasicMaterial({
  color: 0x224444,
  side: THREE.DoubleSide,
  opacity: 0.5,
const sheetNormal = new THREE.Vector3(0, 0, 1)
if (
  planesOnSheet.some(({ plane, origin, normal }) => {
    const p1 = origin.clone().add(normal)
    const p2 = origin.clone().sub(normal)
    const parallelEdges = delimiters.filter((line) => !plane.intersectLine(line))
    return parallelEdges.every(
      (l) => p1.distanceToSquared(l.start) < p2.distanceToSquared(l.start)
) {
  const geometry = new THREE.Geometry()
  geometry.faces.push(new THREE.Face3(0, 1, 2, sheetNormal))
  geometry.faces.push(new THREE.Face3(0, 2, 3, sheetNormal))
  const mesh = new THREE.Mesh(geometry, material)
  v2D.impl.addOverlay(SHEET_OVERLAY, mesh)
  v2D.impl.invalidate(true, true, true)

On the other hand, if the pla­ne lies bet­ween the two deli­mi­t­ers, we want to draw the rec­tang­le bet­ween the line and the cor­re­spon­ding delimiter.

planesOnSheet.forEach(({ normal, origin, plane }) => {
  const geometry = new THREE.Geometry()
  // Find 4 corners to draw a rectangle.
  // First find 2 points where the plane intersects the bounds.
    .map((line) => plane.intersectLine(line))
    .filter((x) => x)
    .forEach((p) => geometry.vertices.push(p))
  // Only if those 2 points were found, find the 2 points of the bounding
  // line parallel to the plane, in the correct direction.
  if (geometry.vertices.length) {
    const p1 = origin.clone().add(normal)
    const p2 = origin.clone().sub(normal)
    const correspondingEdge = minBy(
      delimiters.filter((line) => !plane.intersectLine(line)),
      (l) => p1.distanceToSquared(l.start) - p2.distanceToSquared(l.start)
    // This check ensures we add the vertices in the correct order to obtain the desired triangles at the end.
    const p0 = geometry.vertices.findLast((_) => true)!
    if (
      p0.distanceToSquared(correspondingEdge.start) <
    ) {
      geometry.vertices.push(correspondingEdge.start, correspondingEdge.end)
    } else {
      geometry.vertices.push(correspondingEdge.end, correspondingEdge.start)

    geometry.faces.push(new THREE.Face3(0, 1, 2, sheetNormal))
    geometry.faces.push(new THREE.Face3(0, 2, 3, sheetNormal))

    const mesh = new THREE.Mesh(geometry, material)
    v2D.impl.addOverlay(SHEET_OVERLAY, mesh)

Final­ly we force a re-ren­der of the 2D view­er in order to show our changes.

v2D.impl.invalidate(true, true, true)

The­re we go, by mas­king the hid­den are­as we can easi­ly iden­ti­fy our door on the sheet.


We have shown how to use data pro­vi­ded by the APS View­er tog­e­ther with line­ar alge­bra and THREE.js in order to high­light cut­pla­nes on sheets, to allow the user to navi­ga­te through the model more easily.

WordPress Cookie Plugin von Real Cookie Banner