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);
});
});
});