Drawing on 3D Models and Terrain

by

In 2D maps, drawing tools are used to draw points, polylines, and polygons in a “John Madden” fashion. In 3D, drawing tools are generally used in the same manner - to annotate the globe’s surface.

With Cesium’s Scene.pick and Scene.pickPosition functions, we can reenvision drawing tools for 3D to allow users to draw on terrain, draw on any 3D surface including glTF models, and annotate precisely where a user selects in 3D.

In this short article, we explore building a 3D drawing tool with Cesium. In the screenshot below, notice how the polylines drawn on the models are in 3D space even when the model is hidden.

The full code example is shown below and can be copy and pasted into Sandcastle. To draw a polyline, left click, drag the mouse around, and then left click again. Right click to add an annotation with the height, in meters, of a particular point.

For polylines, the general approach is to use a LEFT_CLICK handler to track the drawing state (are we drawing a polyline or not?), and a MOUSE_MOVE handler to update a polyline when the state is drawing. Each time the polyline is updated, Scene.pick is called to identify the object that is being drawn on (because each object gets a different color polyline) and Scene.pickPosition is called to compute the 3D position for a new point in the polyline based on the 2D position of the mouse cursor.

To minimize depth buffer artifacts such as the polyline going slightly above or below the surface, a wide polyline is drawn. Since Scene.pickPosition requires a WebGL extension, we check Scene.pickPositionSupported to verify the system supports it.

Height annotations are implemented by calling Scene.pickPosition inside the RIGHT_CLICK handler, converting the returned Cartesian3 to Cartographic, and then adding a Entity with a label containing the height. To help avoid clutter, the label is offset and a polyline connects the label to the actual point clicked.

This example can be expanded into a full fledged 3D drawing tool with a UI for selecting color and line width, erasing, etc. This could allow users to annotate areas of interest on 3D buildings and vehicles. Let us know what you build!

Example Code

var viewer = new Cesium.Viewer('cesiumContainer', {
    terrainProvider : new Cesium.CesiumTerrainProvider({
        url : 'https://assets.agi.com/stk-terrain/world'
    }),
    selectionIndicator: false,
    baseLayerPicker : false,
});
viewer.scene.globe.depthTestAgainstTerrain = true;
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);

var milkTruck = viewer.entities.add({
    position : Cesium.Cartesian3.fromDegrees(
        -112.75475687882565,
        36.308766077235525,
        1187.4710985181116),
    model : {
        uri : '../../SampleData/models/CesiumMilkTruck/CesiumMilkTruck.gltf'
    }
});
var ground = viewer.entities.add({
    position : Cesium.Cartesian3.fromDegrees(
        -112.75475687882565,
        36.308806077235525,
        1187.4710985181116),
    model : {
        uri : '../../SampleData/models/CesiumGround/Cesium_Ground.gltf'
    }
});

var scene = viewer.scene;
var camera = viewer.camera;
var color;
var colors = [];
var polyline;
var drawing = false;
var positions = [];

var handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas);

handler.setInputAction(
    function (click) {
        var pickedObject = scene.pick(click.position);
        var length = colors.length;
        var lastColor = colors[length - 1];
        var cartesian = scene.pickPosition(click.position);
        
        if (scene.pickPositionSupported && Cesium.defined(cartesian)) {
            var cartographic = Cesium.Cartographic.fromCartesian(cartesian);
            var longitude = Cesium.Math.toDegrees(cartographic.longitude);
            var latitude = Cesium.Math.toDegrees(cartographic.latitude);
            var altitude = cartographic.height;
            var altitudeString = Math.round(altitude).toString();
                
            viewer.entities.add({
                polyline : {
                    positions : new Cesium.CallbackProperty(function() {
                        return [cartesian, Cesium.Cartesian3.fromDegrees(longitude, latitude, altitude + 9.5)];
                    }, false),
                    width : 2
                }
            });
            viewer.entities.add({
                position : Cesium.Cartesian3.fromDegrees(longitude, latitude, altitude + 10.0),
                label : {
                    heightReference : 1,
                    text : altitudeString,
                    eyeOffset : new Cesium.Cartesian3(0.0, 0.0, -25.0),
                    scale : 0.75
                }
            });
        }
    }, Cesium.ScreenSpaceEventType.RIGHT_CLICK);

handler.setInputAction(
    function (click) {
        if (drawing) {
            reset(color, positions);
        } else {
            polyline = viewer.entities.add({
                polyline : {
                    positions : new Cesium.CallbackProperty(function(){
                        return positions;
                    }, false),
                    material : color,
                    width : 10
                }
            });
        }
        drawing = !drawing;
    }, Cesium.ScreenSpaceEventType.LEFT_CLICK);

handler.setInputAction(
    function (movement) {
        var pickedObject = scene.pick(movement.endPosition);
        var length = colors.length;
        var lastColor = colors[length - 1];
        var cartesian = scene.pickPosition(movement.endPosition);

        if (scene.pickPositionSupported && Cesium.defined(cartesian)) {
            var cartographic = Cesium.Cartographic.fromCartesian(cartesian);
        
            // are we drawing on the globe
            if (!Cesium.defined(pickedObject)) {
                color = Cesium.Color.BLUE;
        
                if (!Cesium.defined(lastColor) || !lastColor.equals(Cesium.Color.BLUE)) {
                    colors.push(Cesium.Color.BLUE);
                }
                if (drawing) {
                    if (Cesium.defined(lastColor) && lastColor.equals(Cesium.Color.BLUE)) {
                        positions.push(cartesian);
                    } else {
                        reset(lastColor, positions);
                        draw(color, positions);
                    }
                }
            }
        
            // are we drawing on one of the 3D models
            if (Cesium.defined(pickedObject) &&
                 ((pickedObject.id === ground) || (pickedObject.id === milkTruck))) {
                var penultimateColor = colors[length - 2];
                    
                if (pickedObject.id === ground) {
                    color = Cesium.Color.GREEN;
                } else {
                    color = Cesium.Color.ORANGE;
                }
                pushColor(color, colors);
        
                if (drawing) {
                    if (lastColor.equals(Cesium.Color.BLUE)) {
                        reset(lastColor, positions);
                        draw(color, positions);
                    } else if ((Cesium.Color.GREEN.equals(lastColor) &&
                                Cesium.Color.ORANGE.equals(penultimateColor)) ||
                                (Cesium.Color.ORANGE.equals(lastColor) &&
                                Cesium.Color.GREEN.equals(penultimateColor))) {
                        positions.pop();
                        reset(penultimateColor, positions);
                        draw(lastColor, positions);
                        colors.push(color);
                    } else {
                        positions.push(cartesian);
                    }
                }
            }
        }
    }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);

function pushColor(color, colors) {
    var lastColor = colors[colors.length - 1];
    if (!Cesium.defined(lastColor) || !color.equals(lastColor)) {
        colors.push(color);
    }
}

function reset(color, currentPositions) {
    viewer.entities.add({
        polyline : {
            positions : new Cesium.CallbackProperty(function() {
                return currentPositions;
            }, false),
            material : color,
            width : 10
        }
    });
    positions = [];
    viewer.entities.remove(polyline);
}

function draw(color, currentPositions) {
    polyline = viewer.entities.add({
        polyline : {
            positions : new Cesium.CallbackProperty(function() {
                return currentPositions;
            }, false),
            material : color,
            width : 10
        }
    });
}

camera.flyTo({
    destination : new Cesium.Cartesian3(
        -1990688.2412034054,
        -4746189.37573292,
        3756554.691309811),
    orientation : {
        heading : 5.57769772317312,
        pitch : -0.4357678642191547,
        roll : 6.28089541612804
    }
});

Sandcastle.addToolbarButton('Hide/Show Entities', function() {
    milkTruck.show = !milkTruck.show;
    ground.show = !ground.show;
});

// adding help text
var paragraph0 = document.createElement('p');
paragraph0.id = 'help0';
var node0 = document.createTextNode("To start drawing, left click and move mouse. Left click");
paragraph0.appendChild(node0);

var paragraph1 = document.createElement('p');
paragraph1.id = 'help1';
var node1 = document.createTextNode("again to stop drawing. Right click to mark the altitude.");
paragraph1.appendChild(node1);

var element = document.getElementById('toolbar');
element.appendChild(paragraph0);
element.appendChild(paragraph1);
document.getElementById('help0').style.fontSize = '16px';
document.getElementById('help1').style.fontSize = '16px';