6 Mar 2018

Share Viewer state with websockets

The socket.io library wraps the websocket protocol into a super easy-to-use library, for server & client. Can we use it to share the Viewer state? Sure!

Kean Wamsley implemented a similar approach for vrok.it, which is quite nice. He uses a presenter & viewer approach, meaning only the presenter can propagate changes to the other clients (VR viewers, in his sample). This is quite nice, but what happens when both sides tries to change the view?

We do need, at a given time, someone presenting and others viewing. If we don't differentiate them, then it will enter a loop of changes. 

At this sample I'm trying a simple approach: when any client moves something, automatically all others clients enter in viewing mode (and cannot make changes). As soon as any other client clicks on the Viewer, it automatically becomes the new presenter.

To allow multiple files, socket.io allow creating rooms, and this sample uses the full URN+ViewableID as room name. 

Finally, to make it nicer, let's wrap everything into an extension (see code below) that you load at any time or by default during .registerViewer call. For a Basic Application viewer, should be like:

viewerApp.registerViewer(viewerApp.k3D, Autodesk.Viewing.Private.GuiViewer3D, { extensions: ['SharingViewer'] });

Time to see the code! Let's use the Extension Skeleton with a toolbar icon. No docking panel for this sample. The presenter is capturing several events, like camera, cut planes, hide/show, isolate, explode and selection. That should capture all desired behaviors. The viewers watching are getting the full Viewer state and restoring it. Create a SharingViewerExtension.js with: 

// *******************************************
// Sharing Viewer Extension
// *******************************************
function SharingViewer(viewer, options) {
    this.socket;
    this.lastSend = (new Date()).getTime();
    this.isPresenting = true;
    Autodesk.Viewing.Extension.call(this, viewer, options);
}

SharingViewer.prototype = Object.create(Autodesk.Viewing.Extension.prototype);
SharingViewer.prototype.constructor = SharingViewer;

SharingViewer.prototype.load = function () {
    if (this.viewer.toolbar) {
        // Toolbar is already available, create the UI
        this.createUI();
    } else {
        // Toolbar hasn't been created yet, wait until we get notification of its creation
        this.onToolbarCreatedBinded = this.onToolbarCreated.bind(this);
        this.viewer.addEventListener(av.TOOLBAR_CREATED_EVENT, this.onToolbarCreatedBinded);
    }
    return true;
};

SharingViewer.prototype.onToolbarCreated = function () {
    this.viewer.removeEventListener(av.TOOLBAR_CREATED_EVENT, this.onToolbarCreatedBinded);
    this.onToolbarCreatedBinded = null;
    this.createUI();
};

SharingViewer.prototype.createUI = function () {
    var viewer = this.viewer;
    var _this = this;

    // button to show the docking panel
    var btnStartStopSharing = new Autodesk.Viewing.UI.Button('sharingViewerButton');
    btnStartStopSharing.onClick = function (e) {
        if (btnStartStopSharing.getState()) {
            // connect socket
            socket = io.connect(location.host);
            // join sharing room
            socket.on('newstate', function (data) { _this.onNewState(data) });

            // change toolbar
            btnStartStopSharing.removeClass('sharingViewerButtonJoin');
            btnStartStopSharing.addClass('sharingViewerButtonExit');
            btnStartStopSharing.setToolTip('Leave Sharing Viewer');
            btnStartStopSharing.setState(0)
            _this.joinSharing();
        }
        else {
            // leave sharing room
            _this.leaveSharing();
            // disconnect
            socket.disconnect();

            // change toolbar
            btnStartStopSharing.removeClass('sharingViewerButtonExit');
            btnStartStopSharing.addClass('sharingViewerButtonJoin');
            btnStartStopSharing.setToolTip('Join Sharing Viewer');
            btnStartStopSharing.setState(1)
        }
    };
    // myAwesomeToolbarButton CSS class should be defined on your .css file
    btnStartStopSharing.addClass('sharingViewerButton');
    btnStartStopSharing.addClass('sharingViewerButtonJoin');
    btnStartStopSharing.setToolTip('Join Sharing Viewer');

    // SubToolbar
    this.subToolbar = new Autodesk.Viewing.UI.ControlGroup('sharingViewer');
    this.subToolbar.addControl(btnStartStopSharing);

    viewer.toolbar.addControl(this.subToolbar);
};

SharingViewer.prototype.leaveSharing = function () {
    socket.emit('leave', {
        modelView: viewerApp.myCurrentViewer.model.getData().basePath
    });
};

SharingViewer.prototype.joinSharing = function () {
    socket.emit('join', {
        modelView: viewerApp.myCurrentViewer.model.getData().basePath
    });

    var _this = this;
    document.getElementById('forgeViewer').addEventListener('click', function (event) {
        console.log('taking control')
        _this.isPresenting = true;
    });

    var viewer = this.viewer;
    var _this = this;

    viewer.addEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, function () { _this.onStateChanged() });
    viewer.addEventListener(Autodesk.Viewing.EXPLODE_CHANGE_EVENT, function () { _this.onStateChanged() });
    viewer.addEventListener(Autodesk.Viewing.ISOLATE_EVENT, function () { _this.onStateChanged() });
    viewer.addEventListener(Autodesk.Viewing.CUTPLANES_CHANGE_EVENT, function () { _this.onStateChanged() });
    viewer.addEventListener(Autodesk.Viewing.HIDE_EVENT, function () { _this.onStateChanged() });
    viewer.addEventListener(Autodesk.Viewing.SHOW_EVENT, function () { _this.onStateChanged() });
    viewer.addEventListener(Autodesk.Viewing.SELECTION_CHANGED_EVENT, function () { _this.onStateChanged() });
}

SharingViewer.prototype.onStateChanged = function (e) {
    if (!this.isPresenting) return;
    // this 200 ms latency should reduce traffic, but still good
    if (this.lastSend + 200 > (new Date()).getTime()) return;
    this.lastSend = (new Date()).getTime();

    console.log('sending new state...');
    socket.emit('statechanged', {
        modelView: this.viewer.model.getData().basePath,
        state: this.viewer.getState()
    });
}

SharingViewer.prototype.onNewState = function (data) {
    this.isPresenting = false; // now this browser is just watching
    console.log('receiving new state...');
    viewerApp.myCurrentViewer.restoreState(data, null, false);
}

SharingViewer.prototype.unload = function () {
    this.viewer.toolbar.removeControl(this.subToolbar);
    socket.disconnect();
    return true;
};

Autodesk.Viewing.theExtensionManager.registerExtension('SharingViewer', SharingViewer);

Change the index.html file to include the Extension and Socket JS files:

<script src="js/SharingViewerExtension.js"></script>
<script language="JavaScript" src="/socket.io/socket.io.js"></script>

The CSS defines the toolbar icon:

.sharingViewerButton {
  background-size: 24px;
  background-repeat: no-repeat;
  background-position: center;
}

.sharingViewerButtonJoin {
  background-image: url(https://github.com/encharm/Font-Awesome-SVG-PNG/raw/master/white/png/24/slideshare.png);
}

.sharingViewerButtonExit {
  background-image: url(https://github.com/encharm/Font-Awesome-SVG-PNG/raw/master/white/png/24/sign-out.png);
}

Now the server side needs to use socket.io. Don't have a NodeJS server yet? Use the Learn Forge tutorial! When you have your project running, install the package with:

npm install socket.io --save

Then inside the .listen callback (the start.js file from the tutorial), add the following code. It just allow join, leave from clients, and statechanged propagates changes (i.e. Viewer state). Note that the tutorial already has the app.listen, so just adjust to:

var server = app.listen(PORT, () => {
    console.log(`Server listening on port ${PORT}`);

    var io = require('socket.io').listen(server);
    io.on('connection', function (socket) {
        // any custom action here?

        socket.on('disconnect', function () {
            // Any custom action?

        });

        socket.on('join', function (data) {
            socket.join(data.modelView);
        });

        socket.on('leave', function (data) {
            socket.leave(data.modelView);
        });

        socket.on('statechanged', function (data) {
            socket.to(data.modelView).emit('newstate', data.state);
        });
    });
});

 

Related Article