@ccp-nc/crystvis-js
Version:
A Three.js based crystallographic visualisation tool
573 lines (476 loc) • 17.4 kB
JavaScript
'use strict';
/**
* @fileoverview Classes and methods for rendering using THREE.js
* @module
*/
// NPM imports
import $ from 'jquery';
import _ from 'lodash';
import * as THREE from 'three';
// Internal imports
import {
OrbitControls
} from './orbit.js';
import {
SelectionBox,
SelectionHelper
} from './selbox.js';
import * as Primitives from './primitives/index.js';
import {
TextSprite
} from './primitives/index.js';
// Ratio of secondary directional light intensity to primary
const SECONDARY_LIGHT_INTENSITY_RATIO = 0.4;
// themes:
const themes = {
dark: {
background: 0x000000,
foreground: 0xffffff,
highlight: 0x00ff00,
cell_line_color: 0xffffff,
label_color: 0xffffff,
},
light: {
background: 0xffffff,
foreground: 0x000000,
highlight: 0x00ff00,
cell_line_color: 0x000000,
label_color: 0x000000,
}
}
class Renderer {
/**
* An object representing the THREE.js graphical renderer for atomic models
* @class
* @param {string} target CSS selector for the target HTML element in which to put the renderer
* @param {int} width Desired width for the renderer
* @param {int} height Desired height for the renderer. If both this and width are zero, automatically
* resizes with the container
* @param {object} options Optional parameters for the WebGLRenderer
*/
constructor(target, width, height, options = {}) {
// Grab the target element
this._div = $(target);
this._autoResize = (width == 0) && (height == 0);
this._w = width;
this._h = height;
this._updateSize();
// combine options with defaults
this._options = Object.assign({
antialias: true,
alpha: true,
}, options);
// Renderer
this._r = new THREE.WebGLRenderer(this._options);
this._r.autoClear = true;
this._r.setPixelRatio(window.devicePixelRatio);
this._r.setSize(this._w, this._h);
this._div.append(this._r.domElement);
// Scene
this._s = new THREE.Scene();
// Camera
var ratio = this._w * 1.0 / this._h;
this._depth = 1000; // For now we use a constant
this._c = new THREE.OrthographicCamera(-20, 20,
20 / ratio, -20 / ratio,
0.1, 2 * this._depth);
this._s.add(this._c);
// Lights
this._l = {}
// Increase ambient light intensity to mimic older Three.js behavior
this._l._amb = new THREE.AmbientLight(0xffffff, 0.3);
this._l._amb.name = 'ambLight';
// Add more intensity to directional light for better highlights
this._l._dir = new THREE.DirectionalLight(0xffffff, 0.6);
this._l._dir.position.set(0, 1, -1);
this._l._dir.name = 'dirLight';
// Add a second directional light from opposite direction for better illumination
this._l._dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
this._l._dir2.position.set(0, -1, 1);
this._l._dir2.name = 'dirLight2';
this._s.add(this._l._amb); // Added to scene
this._c.add(this._l._dir); // Added to camera (rotates with it)
this._c.add(this._l._dir2); // Added to camera (rotates with it)
// Controls
this._oc = new OrbitControls(this._c, this._r.domElement);
this.resetOrbitCenter(0, 0, 0);
// Raycast for clicks
this._rcastlist = [];
this._r.domElement.addEventListener('pointerdown', this._raycastClick.bind(this));
// Groups
this._groups = {
model: new THREE.Group(),
primitives: new THREE.Group(),
notifications: new THREE.Group(),
};
this._s.add(this._groups.model);
this._s.add(this._groups.primitives);
this._s.add(this._groups.notifications);
// Selection box (multiple raycast)
this._sboxlist = [];
this._sbox = new SelectionBox(this._c, this._s);
this._sboxhelp = new SelectionHelper(this._sbox, this, 'crystvis-selbox-helper');
this._sboxhelp.selectOverCallback = this._selectBoxEnd.bind(this);
// Color scheme
this.theme = themes.dark;
this._cell_x_color = 0xff0000;
this._cell_y_color = 0x00ff00;
this._cell_z_color = 0x0000ff;
this.selbox_bkg_color = 0x1111aa;
this.selbox_border_color = 0x5555dd;
this.selbox_opacity = 0.5;
// Set up the animation
this._animate();
// Resizing in case it's required
if (this._autoResize) {
this._resizeObs = new ResizeObserver(this._resize.bind(this)
);
this._resizeObs.observe(this._div[0]);
}
}
_render() {
this._r.render(this._s, this._c);
}
_animate() {
requestAnimationFrame(this._animate.bind(this));
this._render();
}
_updateSize() {
this._w = this._w || this._div.innerWidth();
this._h = this._h || this._div.innerHeight();
this._offset = this._div.offset();
}
_resize() {
// Resize event handler
this._w = 0;
this._h = 0;
this._updateSize()
this._r.setSize(this._w, this._h);
var ratio = this._w * 1.0 / this._h;
this._c.top = 20/ratio;
this._c.bottom = -20/ratio;
this._c.updateProjectionMatrix();
}
_raycastClick(e) {
// We create a 2D vector
var vector = this.documentToWorld(e.clientX, e.clientY);
// We create a raycaster, which is some kind of laser going through your scene
var raycaster = new THREE.Raycaster();
// We apply two parameters to the 'laser', its origin (where the user clicked)
// and the direction (what the camera 'sees')
raycaster.setFromCamera(vector, this._c);
// We get all the objects the 'laser' find on its way (it returns an array containing the objects)
for (var i = 0; i < this._rcastlist.length; ++i) {
var func = this._rcastlist[i][0];
var targ = this._rcastlist[i][1];
var filter = this._rcastlist[i][2];
targ = targ || this._s;
var intersects = raycaster.intersectObjects(targ.children);
var objects = [];
for (var j = 0; j < intersects.length; ++j) {
var o = intersects[j].object;
if (!filter || intersects[j].object instanceof filter) {
objects.push(o);
}
}
func(objects, e);
}
}
_selectBoxEnd(p1, p2) {
for (var i = 0; i < this._sboxlist.length; ++i) {
var func = this._sboxlist[i][0];
var targ = this._sboxlist[i][1];
var filter = this._sboxlist[i][2];
var selected = this._sbox.select(p1, p2, targ);
selected = _.filter(selected, function(o) {
return (!filter || o instanceof filter)
});
func(selected);
}
}
/**
* Draw list of notifications
* @param {Array} messages - List of (strings) messages to display
* @param {Object} parameters - Parameters for the messages
*
*/
addNotifications(messages, parameters={}) {
// TODO: adjust position of messages when camera is rotated
// currently this is only correct when camera is looking down the z-axis
//top of message should be the bottom left corner of the canvas
var position = [this._c.left, this._c.bottom, this._c.near];
// calculate height of messages
const messages_height = 0.05 * messages.length;
// todo: this should really be the x projection of the unit cell
// rather than the length of the 0th unit cell vector
// but it works for now...
const xshift = this.getOrbitCenter().x * 2;
const yshift = 0.05 * messages_height + this.getOrbitCenter().y + 0.5;
const defaults = {
position: position,
height: messages_height,
color: this.theme.label_color,
faceCamera: true,
fixScale: true,
shift: [xshift,yshift,0.0],
onOverlay: true,
};
parameters = _.merge(defaults, parameters);
// add text sprite to scene
// Create a TextSprite to hold the message
var messageSprite = new TextSprite(messages.join('\n'), parameters);
this._groups.notifications.add(messageSprite);
}
/**
* Remove all notifications from the scene
*/
clearNotifications() {
this._groups.notifications.remove(...this._groups.notifications.children);
}
add(object, group = 'primitives') {
if (!(this._groups[group].children.includes(object)))
this._groups[group].add(object);
}
remove(object, group = 'primitives') {
this._groups[group].remove(object);
}
/**
* Add a listener for click events on a given group
*
* @param {Function} listener Listener function. Will receive a list of objects, sorted by distance.
* @param {THREE.Group} group Group on which to detect clicks
* @param {Function} filtertype If present, only pass objects of this class
*
* @returns {Array} Reference to the created listener
*/
addClickListener(listener, group, filtertype = null) {
var cl = [listener, group, filtertype];
this._rcastlist.push(cl);
return cl;
}
/**
* Remove a listener
* @param {Array} cl Reference to the listener to remove (as returned by addClickListener)
*/
removeClickListener(cl) {
_.pull(this._rcastlist, cl);
}
/**
* Add a listener for selection box events
*
* @param {Function} listener Listener function
* @param {THREE.Group} group Group on which to detect clicks
* @param {Function} filtertype If present, only pass objects of this class
*
* @returns {Array} Reference to the created listener
*/
addSelBoxListener(listener, group, filtertype = null) {
var sbl = [listener, group, filtertype];
this._sboxlist.push(sbl);
return sbl;
}
/**
* Remove a selection box listener
* @param {Array} sbl Reference to the listener to remove (as returned by addSelBoxListener)
*/
removeSelBoxListener(sbl) {
_.pull(this._sboxlist, sbl);
}
/**
* Set properties of ambient light
*
* @param {float} intensity Intensity
*/
setAmbientLight(intensity) {
this._l._amb.intensity = intensity;
}
/**
* Set properties of directional light
*
* @param {float} intensity Intensity
* @param {float} px Direction, x
* @param {float} py Direction, y
* @param {float} pz Direction, z
*/
setDirectionalLight(intensity, px, py, pz) {
px = px === null ? this._l._dir.position.x : px;
py = py === null ? this._l._dir.position.y : py;
pz = pz === null ? this._l._dir.position.z : pz;
this._l._dir.intensity = intensity;
this._l._dir.position.set(px, py, pz);
// Automatically update the second light to maintain opposite direction
if (this._l._dir2) {
this._l._dir2.intensity = intensity * SECONDARY_LIGHT_INTENSITY_RATIO;
this._l._dir2.position.set(-px, -py, -pz);
}
}
/**
* Reset the camera's view so its central axis is rendered
* with an offset from the center of the canvas
*
* @param {float} fx Horizontal offset in the canvas (in fraction of its width, -0.5 to 0.5)
* @param {float} fy Vertical offset in the canvas (in fraction of its height, -0.5 to 0.5)
*/
resetCameraCenter(fx=0, fy=0) {
this._c.setViewOffset(this._w, this._h, -fx*this._w, -fy*this._h,
this._w, this._h);
}
/**
* Reset the camera so it orbits around a given point
*
* @param {float} x X coordinate of the point
* @param {float} y Y coordinate of the point
* @param {float} z Z coordinate of the point
*/
resetOrbitCenter(x=0, y=0, z=0) {
this._oc.reset();
var p = new THREE.Vector3(x, y, z);
// difference between the oc target and the camera position
var d = new THREE.Vector3();
d.subVectors(this._c.position, this._oc.target);
// if d is small, return (no need to move the camera)
if (d.length() < 0.001) {
d = new THREE.Vector3(0, 0, 1);
this._oc.target= d;
this._oc.update();
}
if (p.distanceTo(this._oc.target) > 0.001) {
this._c.position.set(x, y, this._depth + 0.1);
// this._oc.lookAlong(d);
this._oc.target= p;
this._oc.update();
}
}
getOrbitCenter() {
return this._oc.target;
}
/**
* Remove all currently rendered objects.
*/
clear(model = true, primitives = true) {
if (model)
this._groups.model.clear();
if (primitives)
this._groups.primitives.clear();
// Reset camera position
this._oc.reset();
}
/**
* Convert coordinates in the dom element frame to coordinates in the world frame
*/
documentToWorld(x, y) {
// We create a 2D vector
var vector = new THREE.Vector2();
// We set its position where the user clicked and we convert it to a number between -1 & 1
vector.set(
2 * ((x - this._offset.left) / this._w) - 1,
1 - 2 * ((y - this._offset.top) / this._h)
);
return vector;
}
// Style
get selbox_bkg_color() {
return this._selbox_bkg_color;
}
set selbox_bkg_color(c) {
c = new THREE.Color(c).getStyle();
this._selbox_bkg_color = c;
this._sboxhelp.element.css({
'background-color': c
});
}
get selbox_border_color() {
return this._selbox_border_color;
}
set selbox_border_color(c) {
c = new THREE.Color(c).getStyle();
this._selbox_border_color = c;
this._sboxhelp.element.css({
'border-color': c
});
}
get selbox_opacity() {
return this._selbox_opacity;
}
set selbox_opacity(o) {
this._selbox_opacity = o;
this._sboxhelp.element.css({
'opacity': o
});
}
// themes
get theme() {
return this._theme;
}
set theme(t) {
this._theme = t;
// background color
this._r.setClearColor(t.background, 1);
// Cell color scheme
this._r._cell_line_color = t.cell_line_color;
// labels
this._label_color = t.label_color;
}
// This convenient wrapper is useful to keep the Primitives out of the Model's logic.
// This way, we don't suffer problems when testing purely mathematical/logical stuff
// by command line with mocha. Primitives are messy and don't build well for CLI with
// esbuild, for some reason.
get Primitives() {
return Primitives;
}
}
/**
* Add a vector field representation to the model
*
* @param {Array} points List of origins for the vectors
* @param {Array} vectors Vectors to plot
* @param {*} colors Colors for each vector. Can be a single color, an array of colors, or a function that takes
* origin, vector, and index, and returns a color.
* @param {float} scale Scaling factor
*
* @returns {THREE.Group} Rendered object
*/
/*
_addVectorField: function(points, vectors, colors, scale) {
var N = points.length;
if (vectors.length != N)
throw 'Points and vectors arrays not matching for vector field';
// We always reduce colors to a function with signature (p, v, i) but
// it can also be an array or a single scalar.
colors = colors || 0xffffff;
switch (typeof colors) {
case 'function':
break;
case 'object':
if (colors.length != N)
throw 'Colors array not matching for vector field';
var colors_arr = colors.slice();
colors = function(p, v, i) {
return colors_arr[i];
}
break;
default:
var colors_val = colors;
colors = function(p, v, i) {
return colors_val;
}
}
scale = scale || 1;
var vfield = new THREE.Group();
for (var i = 0; i < N; ++i) {
var p = points[i];
var v = vectors[i];
var c = colors(p, v, i);
var l = v.length();
v.normalize();
var arr = new THREE.ArrowHelper(v, p, l, c, 0.2 * l, 0.1 * l);
vfield.add(arr);
}
this._g._plots.add(vfield);
return vfield;
},
*/
export {
Renderer,
themes
};