2 Jun 2023

Grouping Labels in 3D scene

labels cluster

Introduction

When we start adding labels to our scenes, we might get to a point where it gets too crowded. It is at this point that we need to think of a way to improve visualization without limiting the number of annotations added to the scene. This is the goal of this sample. It shows a way to achieve that grouping of sprites depending on the camera orientation.

The approach

We choose to implement this approach with sprites, but the same logic can also be applied to markups or any other type of annotation, as long as it relies on points (in the scene or in the client). With sprites, we can easily specify a size for our labels (in pixels), so they can adjust themselves based on the camera orientation (looking bigger in comparison with our scene elements). So, to summarize, we'll take advantage of sprites to add our labels and adjust our scene so that it doesn't get too populated with labels, grouping them when necessary.

The math behind the scene

To make it work, we'll need a way to group the labels that are overlapping.

We'll need to reapply this strategy each time the camera changes since its orientation defines the way we see the labels.

This comparison will consider the distance between the labels in a 2D context (client coordinates).

From 3D scene points to 2D points

The first thing we need to do is convert the 3D points into 2D points relative to the client units in pixels. This can be achieved with the help of the viewer's worldToClient method.

Grouping the points

For this part performance matters, since we'll be grouping on a camera change basis.

We can take a look at similar label clustering approaches done in other contexts to serve as an inspiration.

There's a really interesting implementation done based on the leaflet repo (thanks to Preston Garno for pointing this out!)

Check out this grid cluster approach from this repo:

grid cluster

Adapting these ideas to the Viewer's context, we can loop through the points to separate them into groups that are close to each other (depending on the defined threshold). The logic works like we're dividing the screen into a grid of regions with the specified width and height. Inside any area, we can have only one single label displayed (representing either a group or a single sprite).

treshold

In this sample we're doing that with the help of the snippet below:

findIndexGroups() {
  let indexGroups = [];
  for (let i = 0; i < spritesPositions.length; i++) {
    let currentPosition = this.viewer.worldToClient(spritesPositions[i]);
    const currentIndexColumn = Math.floor(currentPosition.x / this.treshold);
    const currentIndexRow = Math.floor(currentPosition.y / this.treshold);
    if (!indexGroups[currentIndexRow]) {
      indexGroups[currentIndexRow] = [];
    }
    if (!indexGroups[currentIndexRow][currentIndexColumn]) {
      indexGroups[currentIndexRow][currentIndexColumn] = [];
    }
    indexGroups[currentIndexRow][currentIndexColumn].push(i);
  }
  return indexGroups.flat();
}

The findIndexGroups method returns an array of arrays with indexes that should be displayed as a single label. Dividing a point clientX by the area width we can know in which column this point is located. Dividing a point clientY by the area heigth we can know in which row this point is located. Then we can add this point (in this case just its index) into the proper group.

Once the groups are defined, we can render themm using a specific icon for groups, while we use a different one for individual points.

areagroup

We handle this part with the snippet below:

replaceLabels(dbId, indexGroups) {
  let DataVizCore = Autodesk.DataVisualization.Core;
  this.dataVizExtn.removeAllViewables();
  this.viewableData = new DataVizCore.ViewableData();
  this.viewableData.spriteSize = 32;

  for(let indexGroup of indexGroups){
    dbId++;
    let spritePoint;
    let spriteStyle;
    if(indexGroup.length > 1){
      spritePoint = this.findMiddlePoint(indexGroup);
      spriteStyle = this.pointStyles[1];
    }
    else{
      spritePoint =  spritesPositions[indexGroup[0]];
      spriteStyle = this.pointStyles[0];
    }
    let viewable = new DataVizCore.SpriteViewable(spritePoint, spriteStyle, dbId);
    this.viewableData.addViewable(viewable);
  }

  this.viewableData.finish().then(() => {
    this.dataVizExtn.addViewables(this.viewableData);
  });
}

Regroup on camera change

We need to repeat this process every time the camera changes, so we're basically triggering this through the CAMERA_CHANGE_EVENT

But there's a trick involved in this process since during a camera move this event is called multiple times (and we don't want to trigger this process unnecessarily).

To avoid that we have logic to 'schedule' this grouping 200 ms after the first time the CAMERA_CHANGE_EVENT gets triggered, just like in the snippet below:

groupAndRenderLabels() {
  if (!this._changeInProgress) {
    this._changeInProgress = true;
    this.scheduleChange();
  }
}

scheduleChange() {
  setTimeout(() => {
    let indexGroups;
    if (this._button.getState() == Autodesk.Viewing.UI.Button.State.ACTIVE) {
      indexGroups = this.findIndexGroups();
    }
    else {
      indexGroups = document.labelsJSON.labelsPositions.map((p, i) => [i]);
    }
    this.replaceLabels(9999, indexGroups);
    this._changeInProgress = false;
  }, 200);
}

The result from that can be seen in the gif below:

labels cluster

Feel free to try our live demo and check its source code:

DEMO

SOURCE

Related Article