Back to Software

3D Model Viewer Improvements

28 Nov 2023
Progress: Complete

I have plenty of finished projects I need to write about, but for some reason I'm writing about the process of writing about projects.

I would very much like to embed 3D models onto these pages as easily as I can embed images – but I would settle for being able to drop them in as easily as I can embed videos.

WebGL is only a thin wrapper around OpenGL. Writing shaders can be fun, but it's not what I want to be doing right now. And a fair chunk of the work would just be parsing 3D model formats.

All I want is to drop a 3D model here with...

By far the simplest way is to make use of a third party service. There are several sites that offer a drop-in 3d model embed, with all the bells and whistles, some of which may even tick all my boxes, but it grates against my very being. Multimegabyte JS libraries from multiple domains. These things don't last forever, or the APIs change; it becomes a maintenance burden. So my other requirement is that the solution is self-contained. A bunch of small static JS files that can be hosted here, that might be out of date, but in a working state.

In addition to the above, we want to be able to drop lots of these embeds into a page with no overhead and no cross-interaction between them. That means the embed itself needs to be self-contained, and ideally only re-render when interacted with, no continuous animation loop.

First Embediment

I have a great aversion to using other people's code, particularly in the case of hobby projects. This feeling is totally irrational because there are so many layers underneath what you're writing that could constitute "other people's code". But the feeling comes from the number of situations I've had where trying to use a library, or example project, trying to understand their code and make it work, took longer than just writing it all myself from scratch.

To be clear, in a commercial setting, working with other people's code is absolutely a necessity, and I am capable and competent at doing so. This is called "work". But for my own projects the goal is to relax and have fun, and working with other people's code is the opposite of that.

With that out of the way, ThreeJS is the natural choice for 3D modeling on the web with the minimum of fuss. It sits a layer above WebGL and also comes with loaders for various 3D model formats. A couple years ago I concocted a basic model embedder. I could add it to a page by dropping in something like this:

<script src="/other/3d/loader.js"></script>
  makeVRMLViewer('/path/to/model.wrl', 800, 600)

Behind the scenes, the single script import brings in the other necessary scripts (threejs, the VRML loader, and the orbit controls), sets up a camera and a scene, adds some lighting, then – and this is the important part – figures out the size and centre of mass of the model and sits it in the middle at the right zoom level. The result is something like this:

Suzanne (the monkey) is of course my generation's Utah Teapot. (Incidentally, the creator of the Utah Teapot, Martin Newell, is also the creator of probably the most impressive remote control airplane I've ever seen, a 1:96 working scale model of a P51-D Mustang. Check out his 1:200 F-22 as well.)

Anyway, I considered this model viewer Good Enough and before accidentally spending any further time on it, made it my go-to 3D model embedding method for the next couple of years.

Second Embediment, of Gimbals Unlocked

The navigation provided by ThreeJS's OrbitControls is... sufficient. It offers rotate, pan, zoom and supports touches too. But the gimbal lock as you rotate to the poles can be jarring, particularly if the points of interest on the model are at the top and bottom.

The truth is that there are a lot of 3D navigation styles and they get as complex as you like. It can be very difficult to offer an intuitive rotation in all situations. CAD software often has multiple nav styles to choose from depending on what you're working on. And the most powerful nav styles for CAD work are often confusing for casual users.

KiCad, where I'm exporting many of my models from, has a perfectly intuitive default nav style in its 3D viewer. It is a kind of trackball control that allows any-angle viewing, with a middle click to select target. It definitely doesn't have gimbal lock, that would be frustrating.

ThreeJS offers TrackballControls, so naïvely I figured that swapping out one control scheme for the other would instantly fix my frustrations. Alas, this is not the world we live in.

In the years between my first and second embed-experiments (Embeddiments), most of ThreeJS has been rewritten. Parts have been deprecated, and now they've leaned hard into ES6 modules. My feeling is that ES6 modules are unnecessary, but I'm hardly one to talk given my loader.js script brings in code using a document.write. On the other hand, I think the current recommended way of "installing" ThreeJS through npm is just bonkers. It's just some static javascript files to host on a static site.

Even pinning ourselves to an older release of ThreeJS, TrackballControls doesn't work out of the box. The first problem is that it's written only for use with an animation loop, I had to add update() commands to each of the mouse and touch event handlers. Secondly it won't function at all unless the element has been appended to the DOM before initialising the script. This bizarre requirement took me a while to spot and is unique to the TrackballControls. That's somewhat counter to my loading approach above, so I have to append to the document once, then append again in the right place.

Once I got the trackball controls working, and this is really rather silly as I could have just played with the hosted example files they provide, I found the control scheme no more satisfying than the last one.

True, we don't have gimbal lock, but we also don't have that intuitive feeling of moving our pointer in one direction and the model following suit. I suppose it's most obvious when you click-and-drag in a circle. In KiCad, this rotates the model in the same direction as your mouse movement. In ThreeJS TrackballControls, it rotates in the opposite direction. Infuriating!

Digging deeper into navigation styles

The basic OrbitControls are extremely simple. We have a polar angle (phi, or latitude) and an azimuthal angle (theta, or longitude). When you click and drag, the up/down movement is added to phi and the left/right movement is added to theta.

TrackballControls uses quaternions to rotate the whole assembly without gimbal lock. This involves keeping track of which direction is "up" and "sideways" and turning your pointer movements into an angle by which to rotate.

There are many "improved" trackball controls that offer something that feels more natural. One ThreeJS project was called SpinControls. In this case, the mouse click is unprojected onto a virtual sphere. From there, the pointer "holds on" to the model and any movement is tied directly to it. This feels very natural to someone unfamiliar with obscure CAD navigation styles. It is unsurprising (in a good way). Unlike the trackball controls, and unlike CAD controls, no matter how much you drag your mouse, if you start and end the click in the same place then the model ends up where it started.

A more recent addition to ThreeJS is ArcballControls, which is an implementation very similar to SpinControls. Again it aims to grab on to the model with a mouse click and hold on to it. The implementation seemed very promising. Unlike TrackballControls, it didn't require any hacking to act as a replacement for OrbitControls. Even better, it handles requestAnimationFrame in a way that isn't stupid. Momentum and damping isn't something I really want, but if I did want it, I would want it implemented this way: no overhead when it's not being interacted with, and only when it's spun and released is an animation loop requested, which is then cancelled when it comes to rest.

I got it working, our third embeddiment, but for some reason the touch behaviour was glitchy. Two-touch panning caused the camera coordinates to become NaN. This doesn't happen on their hosted example, so it's most likely something I've broken. It could be a version mismatch between the files I've pasted into my lame, non-npm-managed directory. I don't know. At this point I'd wasted more than a day fiddling round with different navigation styles (including digging into the source code of various CAD programs to see how they approach it) and there comes a point, that terrible point that I hate, where it feels it would have been faster to write my own navigation system from scratch than spend all this time mucking about with other people's code.

Fourth Embeddiment

Returning to the original OrbitControls, which at the very least didn't glitch out from unusual touch input, I feel like the fastest route to enlightenment is to simply unlock the gimbals.

Let's look at the default navigation style of Blender. They refer to this as Turntable. Mouse input is directly fed into theta/phi as with OrbitControls, but when we hit the north pole we can simply keep moving, and now the model is upside down. OpenSCAD does something very similar. Unlike with TrackballControls, it's much harder to get "lost" on a model like this.

I modified OrbitControls to behave like this. All we need to do is replace the code that clamps phi to ±π with something that inverts the camera, inverts phi and rotates theta half a turn. With the camera inverted, mouse movements up and down now need to add in the opposite direction. When we wrap again, the camera flips back.

Following the behaviour of Blender, the left/right movement is inverted if you start the rotation from an inverted position. OpenSCAD doesn't do that, rotating upside-down means left/right behaviour is upside-down too.

That took all of 15 minutes to implement and I feel dumb for even trying the other navigation styles.

Sorry, your browser doesn't support embedded 3D models.

Final thoughts... ?

A remaining feature I wanted to add is an ability to reset the viewpoint. I attached this to the double-click event, or a three-finger touch on mobile.

To finish up, I updated my viewer to load the models lazily, or optionally not until clicked. Most CAD models I'm showing off are quite small, but some of the exported circuit boards end up multi-megabyte so it's nice to have an option not to push that on the user until they've clicked it.

I wrapped the script in an even simpler loader, so dropping in a 3D model now looked like this:

loadVRML('/path/to/model.wrl', 800, 600)

The goal of all this was to make it as easy as possible to sprinkle these pages with 3D models. At the very least, I've now sunk enough time into it that in future I'll feel compelled to embed as many 3D models as possible.

This is the point at which I stop fiddling with it and go and actually write up the projects that need it. Definitely... no, I can't help it, I need to augment it further! We can go deeper!

Custom Elements

The one biggest drawback with the current embeddiment mechanism is that there's no fallback. If the browser has scripts disabled or doesn't support WebGL, they get nothing, no indication of unloaded content. Attempting to hack some kind of noscript tag into the embed code would be disgusting. Couple that with the idea that a script tag to produce a 3D embed doesn't even make semantic sense, and thinking about what we said originally – about wanting it to be as easy as dropping in an image or a video – an obvious solution presents itself: custom HTML elements.

It's a little known fact that custom HTML elements are both encouraged and easy to use. MDN gives a huge wall of text about custom elements as part of Web Components, which talks at length about defining javascript classes that extend HTMLElement, and templates and the shadow DOM, but all of that is unnecessary. Without writing any javascript at all, it is valid HTML to use tags of our own invention, so long as their name contains a hyphen.

We don't even have to define them, just start using them and they can be styled with CSS, and their attributes can be accessed through javascript. Following the syntax of the video tag, I settled on the following embed code:

<model-viewer src="model.wrl" width="800" height="600">
  Sorry, your browser doesn't support embedded 3D models.

Our imported script can now document.querySelectorAll and for each model-viewer tag replace the content with the 3D model viewer. The width and height are not interpreted by the browser, they are attributes read by our script. We can even add extra attributes in a way that makes sense, such as the starting viewpoint, or the lazy-loading preference.

If the script isn't loaded, or fails to load, or isn't supported, the fallback message remains. Everyone is happy. I can't deny that there's a certain satisfaction in this approach.

To celebrate, here's the proverbial teapot.

Sorry, your browser doesn't support embedded 3D models.

It should be just about obvious that these are lazy-loading by default. The teapot is also in STL format (that contains no colour information, so we set it to a gray material). There are plenty of loaders available from ThreeJS, though I don't want to bring in too many if we can help it, at least, not unless we only fetch them after determining the file type.

Here's Suzanne after two passes of subdivision surface:

Sorry, your browser doesn't support embedded 3D models.

To demonstrate delayed loading, here's a slightly more complex model, of a circuit board I threw together recently. Exported directly from KiCad, it's about 2.5MB uncompressed. Copying the syntax of the video tag, I added a poster attribute so we can display a thumbnail image before the model is loaded.

The gradient overlay is specifically there to make it more obvious once the model has loaded. I suppose another approach, if we'd kept the momentum and damping movements, would be to give it a quick spin after the loading is complete. Perhaps later.

Sorry, your browser doesn't support embedded 3D models.

The board above is a USB breakout for our new darling child the CH32V003. I've been having great fun with the chip, though I've yet to post any projects with it, but don't worry, they'll be turning up soon enough. Notice the QFN model isn't quite right, the pin pitch on the model is 0.5mm whereas the footprint (and the part in reality) has a pitch of 0.4mm.


Regarding the OrbitControls improvement, I considered sending ThreeJS a pull request. It's such a simple change but it means you can rotate freely. On the other hand, I really doubt I'm the first person to apply that fix here, so there's probably a reason they don't have or don't want it.

For the overall result, with custom elements and the loader script, if you want to make use of it yourself you will probably have to adapt it, there's some styling I've applied directly that's specific for this site's layout.

Now I'm heading off to, for real, write up one of the many completed projects I have awaiting publication. There will be 3D!

* * *

The full extent of my changes to OrbitControls (including doubleclick/3-finger tap reset):

$ diff --color OrbitControls.js mxOrbitControls.js
> // Modified to enable continuous rotation
> 			this.inverted = false;
> 			this.invertedControls = false;
> 			this.inverted0 = this.inverted;
> 				scope.inverted0 = scope.inverted;
> 				scope.inverted = scope.inverted0;
< 					} // restrict theta to be between desired limits
> 					}
> 					if ( spherical.phi > Math.PI) {
> 						scope.inverted = !scope.inverted;
> 						spherical.phi = twoPI - spherical.phi
> 						spherical.theta += Math.PI
> 					}
> 					if ( spherical.phi < 0) {
> 						scope.inverted = !scope.inverted;
> 						spherical.phi = - spherical.phi
> 						spherical.theta += Math.PI
> 					}
< 					spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) );
> 					scope.object.up.set(0, scope.inverted?-1:1, 0 )
> 				scope.domElement.removeEventListener( 'dblclick', scope.reset );
< 				sphericalDelta.theta -= angle;
> 				sphericalDelta.theta -= (scope.invertedControls ? -angle : angle);
< 				sphericalDelta.phi -= angle;
> 				sphericalDelta.phi -= (scope.inverted ? -angle : angle);
> 				scope.invertedControls = scope.inverted;
> 				scope.invertedControls = scope.inverted;
> 					event.preventDefault();
> 					case 3:
> 						scope.reset(); // and fall through
> 			scope.domElement.addEventListener( 'dblclick', scope.reset );


I've been made aware that there is another embedded model viewer, from Google, called "model-viewer". It's interesting that they took exactly the same approach as I did, making a custom tag, and what's spooky is that their custom tag has the exact same name that I chose! I guess it's a logical approach, and a logical choice of name.

The Google version brings in megabytes of external scripts, but does look very easy to use. If I had known about it beforehand I may not have bothered to make my own one.