@chinhui/niivue
Version:
minimal webgl2 nifti image viewer
1,472 lines (1,378 loc) • 141 kB
JavaScript
import { Shader } from "./shader.js";
import * as mat from "gl-matrix";
import { vertSliceShader, fragSliceShader } from "./shader-srcs.js";
import { vertLineShader, fragLineShader } from "./shader-srcs.js";
import { vertRenderShader, fragRenderShader } from "./shader-srcs.js";
import { vertColorbarShader, fragColorbarShader } from "./shader-srcs.js";
import {
vertFontShader,
fragFontShader,
vertBmpShader,
fragBmpShader,
} from "./shader-srcs.js";
import {
vertOrientShader,
vertPassThroughShader,
fragPassThroughShader,
fragOrientShaderU,
fragOrientShaderI,
fragOrientShaderF,
fragOrientShader,
fragOrientShaderAtlas,
fragRGBOrientShader,
fragOrientShaderLookuptable,
vertMeshShader,
fragMeshShader,
fragMeshToonShader,
fragMeshOutlineShader,
fragMeshHemiShader,
fragMeshMatteShader,
fragMeshShaderSHBlue,
vertFiberShader,
fragFiberShader,
vertSurfaceShader,
fragSurfaceShader,
fragDepthPickingShader,
fragVolumePickingShader,
} from "./shader-srcs.js";
import { Subject } from "rxjs";
import { NiivueObject3D } from "./niivue-object3D.js";
import { NiivueShader3D } from "./niivue-shader3D";
import { NVImage } from "./nvimage.js";
import { NVMesh } from "./nvmesh.js";
export { NVMesh } from "./nvmesh.js";
export { NVImage } from "./nvimage";
import { Log } from "./logger";
import defaultFontPNG from "./fonts/Roboto-Regular.png";
import defaultFontMetrics from "./fonts/Roboto-Regular.json";
import { colortables } from "./colortables";
const log = new Log();
const cmapper = new colortables();
/**
* @typedef {Object} NiivueOptions
* @property {number} [options.textHeight=0.3] the text height for orientation labels (0 to 1). Zero for no text labels
* @property {number} [options.colorbarHeight=0.05] size of colorbar. 0 for no colorbars, fraction of Nifti j dimension
* @property {number} [options.colorBarMargin=0.05] padding around colorbar when displayed
* @property {number} [options.crosshairWidth=1] crosshair size. Zero for no crosshair
* @property {array} [options.backColor=[0,0,0,1]] the background color. RGBA values from 0 to 1. Default is black
* @property {array} [options.crosshairColor=[1,0,0,1]] the crosshair color. RGBA values from 0 to 1. Default is red
* @property {array} [options.selectionBoxColor=[1,1,1,0.5]] the selection box color when the intensty selection box is shown (right click and drag). RGBA values from 0 to 1. Default is transparent white
* @property {array} [options.clipPlaneColor=[1,1,1,0.5]] the color of the visible clip plane. RGBA values from 0 to 1. Default is white
* @property {boolean} [options.trustCalMinMax=true] true/false whether to trust the nifti header values for cal_min and cal_max. Trusting them results in faster loading because we skip computing these values from the data
* @property {string} [options.clipPlaneHotKey="KeyC"] the keyboard key used to cycle through clip plane orientations. The default is "c"
* @property {string} [options.viewModeHotKey="KeyV"] the keyboard key used to cycle through view modes. The default is "v"
* @property {number} [options.keyDebounceTime=50] the keyUp debounce time in milliseconds. The default is 50 ms. You must wait this long before a new hot-key keystroke will be registered by the event listener
* @property {boolean} [options.isRadiologicalConvention=false] whether or not to use radiological convention in the display
* @property {string} [options.logging=false] turn on logging or not (true/false)
* @property {string} [options.loadingText="waiting on images..."] the loading text to display when there is a blank canvas and no images
* @property {boolean} [options.dragAndDropEnabled=true] whether or not to allow file and url drag and drop on the canvas
*/
/**
* @class Niivue
* @type Niivue
* @description
* Niivue can be attached to a canvas. An instance of Niivue contains methods for
* loading and rendering NIFTI image data in a WebGL 2.0 context.
* @constructor
* @param {NiivueOptions} [options={}] options object to set modifiable Niivue properties
* @example
* let niivue = new Niivue({crosshairColor: [0,1,0,0.5], textHeight: 0.5}) // a see-through green crosshair, and larger text labels
*/
export function Niivue(options = {}) {
this.opts = {}; // will be populate with opts or defaults when a new Niivue object instance is created
this.defaults = {
textHeight: 0.06, // 0 for no text, fraction of canvas min(height,width)
colorbarHeight: 0.05, // 0 for no colorbars, fraction of Nifti j dimension
crosshairWidth: 1, // 0 for no crosshairs
show3Dcrosshair: false,
backColor: [0, 0, 0, 1],
crosshairColor: [1, 0, 0, 1],
selectionBoxColor: [1, 1, 1, 0.5],
clipPlaneColor: [0.7, 0, 0.7, 0.5],
colorBarMargin: 0.05, // x axis margin arount color bar, clip space coordinates
trustCalMinMax: true, // trustCalMinMax: if true do not calculate cal_min or cal_max if set in image header. If false, always calculate display intensity range.
clipPlaneHotKey: "KeyC", // keyboard short cut to activate the clip plane
viewModeHotKey: "KeyV", // keyboard shortcut to switch view modes
keyDebounceTime: 50, // default debounce time used in keyup listeners
isNearestInterpolation: false,
isAtlasOutline: false,
isRadiologicalConvention: false,
logging: false,
loadingText: "waiting for images...",
dragAndDropEnabled: true,
drawingEnabled: false, // drawing disabled by default
penValue: 1, // sets drawing color. see "drawPt"
isFilledPen: false,
thumbnail: "",
};
this.canvas = null; // the canvas element on the page
this.gl = null; // the gl context
this.colormapTexture = null;
this.volumeTexture = null;
this.drawTexture = null; //the GPU memory storage of the drawing
this.drawBitmap = null; //the CPU memory storage of the drawing
this.drawOpacity = 0.8;
this.drawPenLocation = [NaN, NaN, NaN];
this.drawPenAxCorSag = -1; //do not allow pen to drag between Sagittal/Coronal/Axial
this.drawPenFillPts = []; //store mouse points for filled pen
this.overlayTexture = null;
this.overlayTextureID = [];
this.sliceShader = null;
this.lineShader = null;
this.renderShader = null;
this.pickingShader = null;
this.colorbarShader = null;
this.fontShader = null;
this.bmpShader = null;
this.bmpTexture = null; //thumbnail WebGLTexture object
this.bmpTextureWH = 1.0; //thumbnail width/height ratio
this.passThroughShader = null;
this.orientShaderAtlasU = null;
this.orientShaderU = null;
this.orientShaderI = null;
this.orientShaderF = null;
this.orientShaderRGBU = null;
this.surfaceShader = null;
this.meshShader = null;
this.genericVAO = null; //used for 2D slices, 2D lines, 2D Fonts
this.unusedVAO = null;
this.crosshairs3D = null;
this.pickingSurfaceShader = null;
this.DEFAULT_FONT_GLYPH_SHEET = defaultFontPNG; //"/fonts/Roboto-Regular.png";
this.DEFAULT_FONT_METRICS = defaultFontMetrics; //"/fonts/Roboto-Regular.json";
this.fontMets = null;
this.sliceTypeAxial = 0;
this.sliceTypeCoronal = 1;
this.sliceTypeSagittal = 2;
this.sliceTypeMultiplanar = 3;
this.sliceTypeRender = 4;
this.sliceType = this.sliceTypeMultiplanar; // sets current view in webgl canvas
this.scene = {};
this.syncOpts = {};
this.readyForSync = false;
this.scene.renderAzimuth = 110; //-45;
this.scene.renderElevation = 10; //-165; //15;
this.scene.crosshairPos = [0.5, 0.5, 0.5];
this.scene.clipPlane = [0, 0, 0, 0];
this.scene.clipPlaneDepthAziElev = [2, 0, 0];
this.scene.mousedown = false;
this.scene.touchdown = false;
this.scene.mouseButtonLeft = 0;
this.scene.mouseButtonRight = 2;
this.scene.mouseButtonLeftDown = false;
this.scene.mouseButtonRightDown = false;
this.scene.mouseDepthPicker = false;
this.scene.prevX = 0;
this.scene.prevY = 0;
this.scene.currX = 0;
this.scene.currY = 0;
this.back = {}; // base layer; defines image space to work in. Defined as this.volumes[0] in Niivue.loadVolumes
this.overlays = []; // layers added on top of base image (e.g. masks or stat maps). Essentially everything after this.volumes[0] is an overlay. So is this necessary?
this.volumes = []; // all loaded images. Can add in the ability to push or slice as needed
this.meshes = [];
this.furthestVertexFromOrigin = 100;
this.volScaleMultiplier = 1.0;
this.volScale = [];
this.vox = [];
this.mousePos = [0, 0];
this.numScreenSlices = 0; // e.g. for multiplanar view, 3 simultaneous slices: axial, coronal, sagittal
this.screenSlices = [
//location and type of each 2D slice on screen, allows clicking to detect position
{ leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial },
{ leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial },
{ leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial },
{ leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial },
];
this.isDragging = false;
this.dragStart = [0.0, 0.0];
this.dragEnd = [0.0, 0.0];
this.dragClipPlaneStartDepthAziElev = [0, 0, 0];
this.lastTwoTouchDistance = 0;
this.otherNV = null; // another niivue instance that we wish to sync postion with
this.volumeObject3D = null;
this.pivot3D = [0, 0, 0]; //center for rendering rotation
this.furthestFromPivot = 10.0; //most distant point from pivot
this.intensityRange$ = new Subject(); // an array
this.scene.location$ = new Subject(); // object with properties: {mm: [N N N], vox: [N N N], frac: [N N N]}
this.scene.loading$ = new Subject(); // whether or not the scene is loading
this.imageLoaded$ = new Subject();
this.currentClipPlaneIndex = 0;
this.lastCalled = new Date().getTime();
this.multiTouchGesture = false;
this.gestureInterval = null;
this.selectedObjectId = -1;
this.CLIP_PLANE_ID = 1;
this.VOLUME_ID = 254;
this.DISTANCE_FROM_CAMERA = -0.54;
this.meshShaders = [
{
Name: "Phong",
Frag: fragMeshShader,
},
{
Name: "Matte",
Frag: fragMeshMatteShader,
},
{
Name: "Harmonic",
Frag: fragMeshShaderSHBlue,
},
{
Name: "Hemispheric",
Frag: fragMeshHemiShader,
},
{
Name: "Outline",
Frag: fragMeshOutlineShader,
},
{
Name: "Toon",
Frag: fragMeshToonShader,
},
];
this.initialized = false;
// loop through known Niivue properties
// if the user supplied opts object has a
// property listed in the known properties, then set
// Niivue.opts.<prop> to that value, else apply defaults.
for (let prop in this.defaults) {
this.opts[prop] =
options[prop] === undefined ? this.defaults[prop] : options[prop];
}
if (this.opts.drawingEnabled) {
this.createEmptyDrawing();
}
this.loadingText = this.opts.loadingText;
log.setLogLevel(this.opts.logging);
// maping of keys (event strings) to rxjs subjects
this.eventsToSubjects = {
location: this.scene.location$,
loading: this.scene.loading$,
imageLoaded: this.imageLoaded$,
intensityRange: this.intensityRange$,
};
// rxjs subscriptions. Keeping a reference array like this allows us to unsubscribe later
this.subscriptions = [];
}
/**
* attach the Niivue instance to the webgl2 canvas by element id
* @param {string} id the id of an html canvas element
* @example niivue = new Niivue().attachTo('gl')
* @example niivue.attachTo('gl')
*/
Niivue.prototype.attachTo = async function (id) {
await this.attachToCanvas(document.getElementById(id));
log.debug("attached to element with id: ", id);
return this;
}; // attachTo
// on handles matching event strings (event) with know rxjs subjects within NiiVue.
// if the event string exists (e.g. 'location') then the corrsponding rxjs subject reference
// is extracted from this.eventsToSubjects and the callback passed as the second argument to NiiVue.on
// is added to the subsciptions to the next method. These callbacks are called whenever subject.next is called within
// various NiiVue methods.
/**
* register a callback function to run when known Niivue events happen
* @param {("location")} event the name of the event to watch for. Event names are shown in the type column
* @param {function} callback the function to call when the event happens
* @example
* niivue = new Niivue()
*
* // 'location' update event is fired when the crosshair changes position via user input
* function doSomethingWithLocationData(data){
* // data has the shape {mm: [N, N, N], vox: [N, N, N], frac: [N, N, N], values: this.volumes.map(v => {return val})}
* //...
* }
* niivue.on('location', doSomethingWithLocationData)
* niivue.on('intensityRange', callback)
* niivue.on('imageLoaded', callback)
*/
Niivue.prototype.on = function (event, callback) {
let knownEvents = Object.keys(this.eventsToSubjects);
if (knownEvents.indexOf(event) == -1) {
return;
}
let subject = this.eventsToSubjects[event];
let subscription = subject.subscribe({
next: (data) => callback(data),
});
this.subscriptions.push({ [event]: subscription });
};
/**
* off unsubscribes events and subjects (the opposite of on)
* @param {("location")} event the name of the event to watch for. Event names are shown in the type column
* @example
* niivue = new Niivue()
* niivue.off('location')
*/
Niivue.prototype.off = function (event) {
let knownEvents = Object.keys(this.eventsToSubjects);
if (knownEvents.indexOf(event) == -1) {
return;
}
let nsubs = this.subscriptions.length;
for (let i = 0; i < nsubs; i++) {
let key = Object.keys(this.subscriptions[i])[0];
if (key === event) {
this.subscriptions[i][event].unsubscribe();
this.subscriptions.splice(i, 1);
return;
}
}
};
/**
* attach the Niivue instance to a canvas element directly
* @param {object} canvas the canvas element reference
* @example
* niivue = new Niivue()
* niivue.attachToCanvas(document.getElementById(id))
*/
Niivue.prototype.attachToCanvas = async function (canvas) {
this.canvas = canvas;
this.gl = this.canvas.getContext("webgl2");
if (!this.gl) {
alert(
"unable to get webgl2 context. Perhaps this browser does not support webgl2"
);
log.warn(
"unable to get webgl2 context. Perhaps this browser does not support webgl2"
);
}
// set parent background container to black (default empty canvas color)
// avoids white cube around image in 3D render mode
this.canvas.parentElement.style.backgroundColor = "black";
// fill all space in parent
this.canvas.style.width = "100%";
this.canvas.style.height = "100%";
this.canvas.width = this.canvas.offsetWidth;
this.canvas.height = this.canvas.offsetHeight;
window.addEventListener("resize", this.resizeListener.bind(this)); // must bind 'this' niivue object or else 'this' becomes 'window'
this.registerInteractions(); // attach mouse click and touch screen event handlers for the canvas
await this.init();
this.drawScene();
return this;
};
/**
* Sync the scene controls (orientation, crosshair location, etc.) from one Niivue instance to another. useful for using one canvas to drive another.
* @param {object} otherNV the other Niivue instance that is the main controller
* @example
* niivue1 = new Niivue()
* niivue2 = new Niivue()
* niivue2.syncWith(niivue1)
*/
Niivue.prototype.syncWith = function (
otherNV,
syncOpts = { "2d": true, "3d": true }
) {
// this.scene.renderAzimuth = 120;
// this.scene.renderElevation = 15;
// this.scene.crosshairPos = [0.5, 0.5, 0.5];
// this.scene.clipPlane = [0, 0, 0, 0];
this.otherNV = otherNV;
this.syncOpts = syncOpts;
};
Niivue.prototype.sync = function () {
if (!this.otherNV || typeof this.otherNV === "undefined") {
return;
}
if (!this.otherNV.readyForSync || !this.readyForSync) {
return;
}
let thisMM = this.frac2mm(this.scene.crosshairPos);
if (this.syncOpts["2d"]) {
this.otherNV.scene.crosshairPos = this.otherNV.mm2frac(thisMM);
}
if (this.syncOpts["3d"]) {
console.log("3d sync");
this.otherNV.scene.renderAzimuth = this.scene.renderAzimuth;
this.otherNV.scene.renderElevation = this.scene.renderElevation;
}
this.otherNV.drawScene();
};
/* Not documented publicly for now
* test if two arrays have equal values for each element
* @param {Array} a the first array
* @param {Array} b the second array
* @example Niivue.arrayEquals(a, b)
*/
Niivue.prototype.arrayEquals = function (a, b) {
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((val, index) => val === b[index])
);
};
//handle window resizing
// note: no test yet
Niivue.prototype.resizeListener = function () {
this.canvas.style.width = "100%";
this.canvas.style.height = "100%";
this.canvas.width = this.canvas.offsetWidth;
this.canvas.height = this.canvas.offsetHeight;
this.drawScene();
};
/* Not included in public docs
* The following two functions are to address offset issues
* https://stackoverflow.com/questions/42309715/how-to-correctly-pass-mouse-coordinates-to-webgl
* note: no test yet
*/
Niivue.prototype.getRelativeMousePosition = function (event, target) {
target = target || event.target;
var rect = target.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
};
// not included in public docs
// assumes target or event.target is canvas
// note: no test yet
Niivue.prototype.getNoPaddingNoBorderCanvasRelativeMousePosition = function (
event,
target
) {
target = target || event.target;
var pos = this.getRelativeMousePosition(event, target);
pos.x = (pos.x * target.width) / target.clientWidth;
pos.y = (pos.y * target.height) / target.clientHeight;
return pos;
};
// not included in public docs
// handler for context menu (right click)
// here, we disable the normal context menu so that
// we can use some custom right click events
// note: no test yet
Niivue.prototype.mouseContextMenuListener = function (e) {
e.preventDefault();
};
// not included in public docs
// handler for all mouse button presses
// note: no test yet
Niivue.prototype.mouseDownListener = function (e) {
e.preventDefault();
// var rect = this.canvas.getBoundingClientRect();
this.drawPenLocation = [NaN, NaN, NaN];
this.drawPenAxCorSag = -1;
this.scene.mousedown = true;
if (e.button === this.scene.mouseButtonLeft) {
this.scene.mouseButtonLeftDown = true;
this.mouseLeftButtonHandler(e);
} else if (e.button === this.scene.mouseButtonRight) {
this.scene.mouseButtonRightDown = true;
this.mouseRightButtonHandler(e);
}
};
// not included in public docs
// handler for mouse left button down
// note: no test yet
Niivue.prototype.mouseLeftButtonHandler = function (e) {
let pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(
e,
this.gl.canvas
);
this.mouseClick(pos.x, pos.y);
this.mouseDown(pos.x, pos.y);
};
// not included in public docs
// handler for mouse right button down
// note: no test yet
Niivue.prototype.mouseRightButtonHandler = function (e) {
this.isDragging = true;
let pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(
e,
this.gl.canvas
);
this.dragStart[0] = pos.x;
this.dragStart[1] = pos.y;
this.dragClipPlaneStartDepthAziElev = this.scene.clipPlaneDepthAziElev;
return;
};
// not included in public docs
Niivue.prototype.calculateMinMaxVoxIdx = function (array) {
if (array.length > 2) {
throw new Error("array must not contain more than two values");
}
return [
Math.floor(Math.min(array[0], array[1])),
Math.floor(Math.max(array[0], array[1])),
];
};
// not included in public docs
function intensityRaw2Scaled(hdr, raw) {
if (hdr.scl_slope === 0) hdr.scl_slope = 1.0;
return raw * hdr.scl_slope + hdr.scl_inter;
}
// not included in public docs
// note: no test yet
Niivue.prototype.calculateNewRange = function (volIdx = 0) {
if (this.sliceType === this.sliceTypeRender) {
return;
}
if (
this.dragStart[0] === this.dragEnd[0] &&
this.dragStart[1] === this.dragEnd[1]
)
return;
// calculate our box
let frac = this.canvasPos2frac([this.dragStart[0], this.dragStart[1]]);
let startVox = this.frac2vox(frac, volIdx);
frac = this.canvasPos2frac([this.dragEnd[0], this.dragEnd[1]]);
let endVox = this.frac2vox(frac, volIdx);
let hi = -Number.MAX_VALUE,
lo = Number.MAX_VALUE;
let xrange;
let yrange;
let zrange;
xrange = this.calculateMinMaxVoxIdx([startVox[0], endVox[0]]);
yrange = this.calculateMinMaxVoxIdx([startVox[1], endVox[1]]);
zrange = this.calculateMinMaxVoxIdx([startVox[2], endVox[2]]);
// for our constant dimension we add one so that the for loop runs at least once
if (startVox[0] - endVox[0] === 0) {
xrange[1] = startVox[0] + 1;
} else if (startVox[1] - endVox[1] === 0) {
yrange[1] = startVox[1] + 1;
} else if (startVox[2] - endVox[2] === 0) {
zrange[1] = startVox[2] + 1;
}
const hdr = this.volumes[volIdx].hdr;
const img = this.volumes[volIdx].img;
const xdim = hdr.dims[1];
const ydim = hdr.dims[2];
for (let z = zrange[0]; z < zrange[1]; z++) {
let zi = z * xdim * ydim;
for (let y = yrange[0]; y < yrange[1]; y++) {
let yi = y * xdim;
for (let x = xrange[0]; x < xrange[1]; x++) {
let index = zi + yi + x;
if (lo > img[index]) {
lo = img[index];
}
if (hi < img[index]) {
hi = img[index];
}
}
}
}
if (lo >= hi) return; //no variability or outside volume
var mnScale = intensityRaw2Scaled(hdr, lo);
var mxScale = intensityRaw2Scaled(hdr, hi);
this.volumes[volIdx].cal_min = mnScale;
this.volumes[volIdx].cal_max = mxScale;
this.intensityRange$.next(this.volumes[volIdx]); //reference to volume to access cal_min and cal_max
};
// not included in public docs
// handler for mouse button up (all buttons)
// note: no test yet
Niivue.prototype.mouseUpListener = function () {
this.scene.mousedown = false;
this.scene.mouseButtonRightDown = false;
this.scene.mouseButtonLeftDown = false;
if (this.drawPenFillPts.length > 0) this.drawPenFilled();
this.drawPenLocation = [NaN, NaN, NaN];
this.drawPenAxCorSag = -1;
if (this.isDragging) {
this.isDragging = false;
this.calculateNewRange();
this.refreshLayers(this.volumes[0], 0, this.volumes.length);
}
this.drawScene();
};
// not included in public docs
Niivue.prototype.checkMultitouch = function (e) {
if (this.scene.touchdown && !this.multiTouchGesture) {
var rect = this.canvas.getBoundingClientRect();
this.mouseClick(
e.touches[0].clientX - rect.left,
e.touches[0].clientY - rect.top
);
this.mouseDown(
e.touches[0].clientX - rect.left,
e.touches[0].clientY - rect.top
);
}
};
// not included in public docs
// handler for single finger touch event (like mouse down)
// note: no test yet
Niivue.prototype.touchStartListener = function (e) {
e.preventDefault();
this.scene.touchdown = true;
if (this.scene.touchdown && e.touches.length < 2) {
this.multiTouchGesture = false;
} else {
this.multiTouchGesture = true;
}
setTimeout(this.checkMultitouch.bind(this), 1, e);
};
// not included in public docs
// handler for touchend (finger lift off screen)
// note: no test yet
Niivue.prototype.touchEndListener = function () {
this.scene.touchdown = false;
this.lastTwoTouchDistance = 0;
this.multiTouchGesture = false;
};
// not included in public docs
// handler for mouse move over canvas
// note: no test yet
Niivue.prototype.mouseMoveListener = function (e) {
// move crosshair and change slices if mouse click and move
if (this.scene.mousedown) {
let pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(
e,
this.gl.canvas
);
if (this.scene.mouseButtonLeftDown) {
this.mouseClick(pos.x, pos.y);
this.mouseMove(pos.x, pos.y);
} else if (this.scene.mouseButtonRightDown) {
this.dragEnd[0] = pos.x;
this.dragEnd[1] = pos.y;
}
this.drawScene();
this.scene.prevX = this.scene.currX;
this.scene.prevY = this.scene.currY;
}
};
// not included in public docs
// note: should update this to accept a volume index to reset a selected volume rather than only the background (TODO)
// reset brightness and contrast to global min and max
// note: no test yet
Niivue.prototype.resetBriCon = function () {
//this.volumes[0].cal_min = this.volumes[0].global_min;
//this.volumes[0].cal_max = this.volumes[0].global_max;
// don't reset bri/con if the user is in 3D mode and double clicks
if (this.sliceType === this.sliceTypeRender) {
this.scene.mouseDepthPicker = true;
this.drawScene();
return;
}
this.volumes[0].cal_min = this.volumes[0].robust_min;
this.volumes[0].cal_max = this.volumes[0].robust_max;
this.intensityRange$.next(this.volumes[0]);
this.refreshLayers(this.volumes[0], 0, this.volumes.length);
this.drawScene();
};
// not included in public docs
// handler for touch move (moving finger on screen)
// note: no test yet
Niivue.prototype.touchMoveListener = function (e) {
if (this.scene.touchdown && e.touches.length < 2) {
var rect = this.canvas.getBoundingClientRect();
this.mouseClick(
e.touches[0].clientX - rect.left,
e.touches[0].clientY - rect.top
);
this.mouseMove(
e.touches[0].clientX - rect.left,
e.touches[0].clientY - rect.top
);
} else {
// Check this event for 2-touch Move/Pinch/Zoom gesture
this.handlePinchZoom(e);
}
};
// not included in public docs
Niivue.prototype.handlePinchZoom = function (e) {
if (e.targetTouches.length == 2 && e.changedTouches.length == 2) {
var dist = Math.hypot(
e.touches[0].pageX - e.touches[1].pageX,
e.touches[0].pageY - e.touches[1].pageY
);
var rect = this.canvas.getBoundingClientRect();
this.mousePos = [
e.touches[0].clientX - rect.left,
e.touches[0].clientY - rect.top,
];
// scroll 2D slices
if (dist < this.lastTwoTouchDistance) {
// this.volScaleMultiplier = Math.max(0.5, this.volScaleMultiplier * 0.95);
this.sliceScroll2D(
-0.01,
e.touches[0].clientX - rect.left,
e.touches[0].clientY - rect.top
);
} else {
// this.volScaleMultiplier = Math.min(2.0, this.volScaleMultiplier * 1.05);
this.sliceScroll2D(
0.01,
e.touches[0].clientX - rect.left,
e.touches[0].clientY - rect.top
);
}
// this.drawScene();
this.lastTwoTouchDistance = dist;
}
};
// not included in public docs
// handler for keyboard shortcuts
Niivue.prototype.keyUpListener = function (e) {
if (e.code === this.opts.clipPlaneHotKey) {
if (this.sliceType != this.sliceTypeRender) {
return;
}
let now = new Date().getTime();
let elapsed = now - this.lastCalled;
if (elapsed > this.opts.keyDebounceTime) {
this.currentClipPlaneIndex = (this.currentClipPlaneIndex + 1) % 7;
switch (this.currentClipPlaneIndex) {
case 0: //NONE
this.scene.clipPlaneDepthAziElev = [2, 0, 0];
break;
case 1: //left a 270 e 0
//this.scene.clipPlane = [1, 0, 0, 0];
this.scene.clipPlaneDepthAziElev = [0, 270, 0];
break;
case 2: //right a 90 e 0
this.scene.clipPlaneDepthAziElev = [0, 90, 0];
break;
case 3: //posterior a 0 e 0
this.scene.clipPlaneDepthAziElev = [0, 0, 0];
break;
case 4: //anterior a 0 e 0
this.scene.clipPlaneDepthAziElev = [0, 180, 0];
break;
case 5: //inferior a 0 e -90
this.scene.clipPlaneDepthAziElev = [0, 0, -90];
break;
case 6: //superior: a 0 e 90'
this.scene.clipPlaneDepthAziElev = [0, 0, 90];
break;
}
this.setClipPlane(this.scene.clipPlaneDepthAziElev);
// e.preventDefault();
}
this.lastCalled = now;
} else if (e.code === this.opts.viewModeHotKey) {
let now = new Date().getTime();
let elapsed = now - this.lastCalled;
if (elapsed > this.opts.keyDebounceTime) {
this.setSliceType((this.sliceType + 1) % 5); // 5 total slice types
this.lastCalled = now;
}
}
};
Niivue.prototype.keyDownListener = function (e) {
if (e.code === "KeyH" && this.sliceType === this.sliceTypeRender) {
this.setRenderAzimuthElevation(
this.scene.renderAzimuth - 1,
this.scene.renderElevation
);
} else if (e.code === "KeyL" && this.sliceType === this.sliceTypeRender) {
this.setRenderAzimuthElevation(
this.scene.renderAzimuth + 1,
this.scene.renderElevation
);
} else if (e.code === "KeyJ" && this.sliceType === this.sliceTypeRender) {
this.setRenderAzimuthElevation(
this.scene.renderAzimuth,
this.scene.renderElevation + 1
);
} else if (e.code === "KeyK" && this.sliceType === this.sliceTypeRender) {
this.setRenderAzimuthElevation(
this.scene.renderAzimuth,
this.scene.renderElevation - 1
);
} else if (e.code === "KeyH" && this.sliceType !== this.sliceTypeRender) {
this.scene.crosshairPos[0] = this.scene.crosshairPos[0] - 0.001;
this.drawScene();
} else if (e.code === "KeyL" && this.sliceType !== this.sliceTypeRender) {
this.scene.crosshairPos[0] = this.scene.crosshairPos[0] + 0.001;
this.drawScene();
} else if (
e.code === "KeyU" &&
this.sliceType !== this.sliceTypeRender &&
e.ctrlKey
) {
this.scene.crosshairPos[2] = this.scene.crosshairPos[2] + 0.001;
this.drawScene();
} else if (
e.code === "KeyD" &&
this.sliceType !== this.sliceTypeRender &&
e.ctrlKey
) {
this.scene.crosshairPos[2] = this.scene.crosshairPos[2] - 0.001;
this.drawScene();
} else if (e.code === "KeyJ" && this.sliceType !== this.sliceTypeRender) {
this.scene.crosshairPos[1] = this.scene.crosshairPos[1] - 0.001;
this.drawScene();
} else if (e.code === "KeyK" && this.sliceType !== this.sliceTypeRender) {
this.scene.crosshairPos[1] = this.scene.crosshairPos[1] + 0.001;
this.drawScene();
} else if (e.code === "ArrowLeft") {
// only works for background (first loaded image is index 0)
this.setFrame4D(this.volumes[0].id, this.volumes[0].frame4D - 1);
} else if (e.code === "ArrowRight") {
// only works for background (first loaded image is index 0)
this.setFrame4D(this.volumes[0].id, this.volumes[0].frame4D + 1);
}
};
// not included in public docs
// handler for scroll wheel events (slice scrolling)
// note: no test yet
Niivue.prototype.wheelListener = function (e) {
// scroll 2D slices
e.preventDefault();
e.stopPropagation();
var rect = this.canvas.getBoundingClientRect();
if (e.deltaY < 0) {
this.sliceScroll2D(-0.01, e.clientX - rect.left, e.clientY - rect.top);
} else {
this.sliceScroll2D(0.01, e.clientX - rect.left, e.clientY - rect.top);
}
};
// not included in public docs
// setup interactions with the canvas. Mouse clicks and touches
// note: no test yet
Niivue.prototype.registerInteractions = function () {
// add mousedown
this.canvas.addEventListener("mousedown", this.mouseDownListener.bind(this));
// add mouseup
this.canvas.addEventListener("mouseup", this.mouseUpListener.bind(this));
// add mouse move
this.canvas.addEventListener("mousemove", this.mouseMoveListener.bind(this));
// add touchstart
this.canvas.addEventListener(
"touchstart",
this.touchStartListener.bind(this)
);
// add touchend
this.canvas.addEventListener("touchend", this.touchEndListener.bind(this));
// add touchmove
this.canvas.addEventListener("touchmove", this.touchMoveListener.bind(this));
// add scroll wheel
this.canvas.addEventListener("wheel", this.wheelListener.bind(this));
// add context event disabler
this.canvas.addEventListener(
"contextmenu",
this.mouseContextMenuListener.bind(this)
);
// add double click
this.canvas.addEventListener("dblclick", this.resetBriCon.bind(this));
// drag and drop support
this.canvas.addEventListener(
"dragenter",
this.dragEnterListener.bind(this),
false
);
this.canvas.addEventListener(
"dragover",
this.dragOverListener.bind(this),
false
);
this.canvas.addEventListener("drop", this.dropListener.bind(this), false);
// add keyup
this.canvas.setAttribute("tabindex", 0);
this.canvas.addEventListener("keyup", this.keyUpListener.bind(this), false);
this.canvas.focus();
// keydown
this.canvas.addEventListener(
"keydown",
this.keyDownListener.bind(this),
false
);
};
// not included in public docs
Niivue.prototype.dragEnterListener = function (e) {
e.stopPropagation();
e.preventDefault();
};
// not included in public docs
Niivue.prototype.dragOverListener = function (e) {
e.stopPropagation();
e.preventDefault();
};
Niivue.prototype.getFileExt = function (fullname, upperCase = true) {
var re = /(?:\.([^.]+))?$/;
let ext = re.exec(fullname)[1];
ext = ext.toUpperCase();
if (ext === "GZ") {
ext = re.exec(fullname.slice(0, -3))[1]; //img.trk.gz -> img.trk
ext = ext.toUpperCase();
}
return upperCase ? ext : ext.toLowerCase(); // developer can choose to have extentions as upper or lower
}; // getFleExt
// not included in public docs
Niivue.prototype.dropListener = async function (e) {
e.stopPropagation();
e.preventDefault();
// don't do anything if drag and drop has been turned off
if (!this.opts.dragAndDropEnabled) {
return;
}
const filesToLoad = [];
const urlsToLoad = [];
const dt = e.dataTransfer;
const url = dt.getData("text/uri-list");
if (url) {
urlsToLoad.push(url);
let volume = await NVImage.loadFromUrl({ url: url });
this.setVolume(volume);
} else {
//const files = dt.files;
const items = dt.items;
if (items.length > 0) {
// adding or replacing
if (!e.shiftKey) {
this.volumes = [];
this.overlays = [];
this.meshes = [];
}
for (const item of items) {
const entry = item.getAsEntry || item.webkitGetAsEntry();
if (entry.isFile) {
let ext = this.getFileExt(entry.name);
if (ext === "PNG") {
entry.file((file) => {
this.loadBmpTexture(file);
});
continue;
}
let pairedImageData = "";
// check for afni HEAD BRIK pair
if (entry.name.lastIndexOf("HEAD") !== -1) {
for (const pairedItem of items) {
const pairedEntry =
pairedItem.getAsEntry || pairedItem.webkitGetAsEntry();
let fileBaseName = entry.name.substring(
0,
entry.name.lastIndexOf("HEAD")
);
let pairedItemBaseName = pairedEntry.name.substring(
0,
pairedEntry.name.lastIndexOf("BRIK")
);
if (fileBaseName === pairedItemBaseName) {
pairedImageData = pairedEntry;
}
}
}
if (entry.name.lastIndexOf("BRIK") !== -1) {
continue;
}
if (
ext === "ASC" ||
ext === "DFS" ||
ext === "FSM" ||
ext === "PIAL" ||
ext === "ORIG" ||
ext === "INFLATED" ||
ext === "SMOOTHWM" ||
ext === "SPHERE" ||
ext === "WHITE" ||
ext === "GII" ||
ext === "MZ3" ||
ext === "OBJ" ||
ext === "OFF" ||
ext === "PLY" ||
ext === "SRF" ||
ext === "STL" ||
ext === "TCK" ||
ext === "TRACT" ||
ext === "TRK" ||
ext === "TRX" ||
ext === "VTK"
) {
entry.file(async (file) => {
let mesh = await NVMesh.loadFromFile({
file: file,
gl: this.gl,
name: file.name,
});
this.scene.loading$.next(false);
this.addMesh(mesh);
});
continue;
}
entry.file(async (file) => {
// if we have paired header/img data
if (pairedImageData !== "") {
pairedImageData.file(async (imgfile) => {
let volume = await NVImage.loadFromFile({
file: file,
urlImgData: imgfile,
});
this.addVolume(volume);
});
} else {
// else, just a single file to load (not a pair)
let volume = await NVImage.loadFromFile({
file: file,
urlImgData: pairedImageData,
});
this.addVolume(volume);
}
});
} else if (entry.isDirectory) {
/*
let reader = entry.createReader();
var allFilesInDir = [];
let n = 0;
let readEntries = () => {
n = n + 1;
//console.log('called ', n, ' times')
reader.readEntries(async (entries) => {
//console.log(entries)
if (!entries.length) {
let volume = await NVImage.loadFromFile({
file: allFilesInDir, // an array of file objects
urlImgData: null, // nothing
isDICOMDIR: true, // signify that this is a dicom directory
});
this.addVolume(volume);
} else {
for (let i = 0; i < entries.length; i++) {
if (!entries[i].isFile) continue;
if (entries[i].size < 256) continue;
if (entries[i].name.startsWith(".")) continue; //hidden, e.g. .DS_Store
console.log("adding " + entries[i].name);
entries[i].file((file) => {
allFilesInDir.push(file);
});
}
readEntries();
}
});
};
readEntries();
*/
}
}
}
}
//this.createEmptyDrawing();
this.drawScene(); //<- this seems to be required if you drag and drop a mesh, not a volume
};
Niivue.prototype.setRadiologicalConvention = function (
isRadiologicalConvention
) {
this.opts.isRadiologicalConvention = isRadiologicalConvention;
};
Niivue.prototype.getRadiologicalConvention = function () {
return this.opts.isRadiologicalConvention;
};
/**
* add a new volume to the canvas
* @param {NVImage} volume the new volume to add to the canvas
* @example
* niivue = new Niivue()
* niivue.addVolume(NVImage.loadFromUrl({url:'./someURL.nii.gz'}))
*/
Niivue.prototype.addVolume = function (volume) {
this.volumes.push(volume);
let idx = this.volumes.length === 1 ? 0 : this.volumes.length - 1;
this.setVolume(volume, idx);
this.imageLoaded$.next(volume); // pass reference to the loaded NVImage (the volume)
};
/**
* add a new mesh to the canvas
* @param {NVMesh} mesh the new mesh to add to the canvas
* @example
* niivue = new Niivue()
* niivue.addMesh(NVMesh.loadFromUrl({url:'./someURL.gii'}))
*/
Niivue.prototype.addMesh = function (mesh) {
this.meshes.push(mesh);
let idx = this.meshes.length === 1 ? 0 : this.meshes.length - 1;
this.setMesh(mesh, idx);
this.imageLoaded$.next(mesh); // pass reference to the loaded NVImage (the volume)
};
/**
* get the index of a volume by its unique id. unique ids are assigned to the NVImage.id property when a new NVImage is created.
* @param {string} id the id string to search for
* @example
* niivue = new Niivue()
* niivue.getVolumeIndexByID(someVolume.id)
*/
Niivue.prototype.getVolumeIndexByID = function (id) {
let n = this.volumes.length;
for (let i = 0; i < n; i++) {
let id_i = this.volumes[i].id;
if (id_i === id) {
return i;
}
}
return -1; // -1 signals that no valid index was found for a volume with the given id
};
Niivue.prototype.saveImage = async function (fnm, isSaveDrawing = false) {
if (!this.back.hasOwnProperty("dims")) {
console.log("No voxelwise image open");
return false;
}
if (isSaveDrawing) {
if (!this.drawBitmap) {
console.log("No drawing open");
return false;
}
let perm = this.volumes[0].permRAS;
if (perm[0] === 1 && perm[1] === 2 && perm[2] === 3) {
await this.volumes[0].saveToDisk(fnm, this.drawBitmap); // createEmptyDrawing
return true;
} else {
let dims = this.volumes[0].hdr.dims; //reverse to original
//reverse RAS to native space, layout is mrtrix MIF format
// for details see NVImage.readMIF()
let layout = [0, 0, 0];
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (Math.abs(perm[i]) - 1 !== j) continue;
layout[j] = i * Math.sign(perm[i]);
}
}
let stride = 1;
let instride = [1, 1, 1];
let inflip = [false, false, false];
for (let i = 0; i < layout.length; i++) {
for (let j = 0; j < layout.length; j++) {
let a = Math.abs(layout[j]);
if (a != i) continue;
instride[j] = stride;
//detect -0: https://medium.com/coding-at-dawn/is-negative-zero-0-a-number-in-javascript-c62739f80114
if (layout[j] < 0 || Object.is(layout[j], -0)) inflip[j] = true;
stride *= dims[j + 1];
}
}
//lookup table for flips and stride offsets:
const range = (start, stop, step) =>
Array.from(
{ length: (stop - start) / step + 1 },
(_, i) => start + i * step
);
let xlut = range(0, dims[1] - 1, 1);
if (inflip[0]) xlut = range(dims[1] - 1, 0, -1);
for (let i = 0; i < dims[1]; i++) xlut[i] *= instride[0];
let ylut = range(0, dims[2] - 1, 1);
if (inflip[1]) ylut = range(dims[2] - 1, 0, -1);
for (let i = 0; i < dims[2]; i++) ylut[i] *= instride[1];
let zlut = range(0, dims[3] - 1, 1);
if (inflip[2]) zlut = range(dims[3] - 1, 0, -1);
for (let i = 0; i < dims[3]; i++) zlut[i] *= instride[2];
//convert data
let inVs = new Uint8Array(this.drawBitmap);
let outVs = new Uint8Array(dims[1] * dims[2] * dims[3]);
let j = 0;
for (let z = 0; z < dims[3]; z++) {
for (let y = 0; y < dims[2]; y++) {
for (let x = 0; x < dims[1]; x++) {
outVs[j] = inVs[xlut[x] + ylut[y] + zlut[z]];
j++;
} //for x
} //for y
} //for z
await this.volumes[0].saveToDisk(fnm, outVs);
return true;
} //if native image not RAS
} //save bitmap drawing
await this.volumes[0].saveToDisk(fnm);
return true;
};
Niivue.prototype.getMeshIndexByID = function (id) {
let n = this.meshes.length;
for (let i = 0; i < n; i++) {
let id_i = this.meshes[i].id;
if (id_i === id) {
return i;
}
}
return -1; // -1 signals that no valid index was found for a volume with the given id
};
Niivue.prototype.setMeshProperty = function (id, key, val) {
let idx = this.getMeshIndexByID(id);
if (idx < 0) {
log.warn("setMeshProperty() id not loaded", id);
return;
}
this.meshes[idx].setProperty(key, val, this.gl);
this.updateGLVolume();
};
Niivue.prototype.setMeshLayerProperty = function (mesh, layer, key, val) {
let idx = this.getMeshIndexByID(mesh);
if (idx < 0) {
log.warn("setMeshLayerProperty() id not loaded", mesh);
return;
}
this.meshes[idx].setLayerProperty(layer, key, val, this.gl);
this.updateGLVolume();
};
Niivue.prototype.setRenderAzimuthElevation = function (a, e) {
this.scene.renderAzimuth = a;
this.scene.renderElevation = e;
this.drawScene();
}; // mouseMove()
/**
* get the index of an overlay by its unique id. unique ids are assigned to the NVImage.id property when a new NVImage is created.
* @param {string} id the id string to search for
* @see NiiVue#getVolumeIndexByID
* @example
* niivue = new Niivue()
* niivue.getOverlayIndexByID(someVolume.id)
*/
Niivue.prototype.getOverlayIndexByID = function (id) {
let n = this.overlays.length;
for (let i = 0; i < n; i++) {
let id_i = this.overlays[i].id;
if (id_i === id) {
return i;
}
}
return -1; // -1 signals that no valid index was found for an overlay with the given id
};
/**
* set the index of a volume. This will change it's ordering and appearance if there are multiple volumes loaded.
* @param {NVImage} volume the volume to update
* @param {number} [toIndex=0] the index to move the volume to. The default is the background (0 index)
* @example
* niivue = new Niivue()
* niivue.setVolume(someVolume, 1) // move it to the second position in the array of loaded volumes (0 is the first position)
*/
Niivue.prototype.setVolume = function (volume, toIndex = 0) {
this.volumes.map((v) => {
log.debug(v.name);
});
let numberOfLoadedImages = this.volumes.length;
if (toIndex > numberOfLoadedImages) {
return;
}
let volIndex = this.getVolumeIndexByID(volume.id);
if (toIndex === 0) {
this.volumes.splice(volIndex, 1);
this.volumes.unshift(volume);
this.back = this.volumes[0];
this.overlays = this.volumes.slice(1);
} else if (toIndex < 0) {
// -1 to remove a volume
this.volumes.splice(this.getVolumeIndexByID(volume.id), 1);
//this.volumes = this.overlays
this.back = this.volumes[0];
if (this.volumes.length > 1) {
this.overlays = this.volumes.slice(1);
} else {
this.overlays = [];
}
} else {
this.volumes.splice(volIndex, 1);
this.volumes.splice(toIndex, 0, volume);
this.overlays = this.volumes.slice(1);
this.back = this.volumes[0];
}
this.updateGLVolume();
this.volumes.map((v) => {
log.debug(v.name);
});
};
Niivue.prototype.setMesh = function (mesh, toIndex = 0) {
this.meshes.map((m) => {
log.debug("MESH: ", m.name);
});
let numberOfLoadedMeshes = this.meshes.length;
if (toIndex > numberOfLoadedMeshes) {
return;
}
let meshIndex = this.getMeshIndexByID(mesh.id);
if (toIndex === 0) {
this.meshes.splice(meshIndex, 1);
this.meshes.unshift(mesh);
} else if (toIndex < 0) {
this.meshes.splice(this.getMeshIndexByID(mesh.id), 1);
} else {
this.meshes.splice(meshIndex, 1);
this.meshes.splice(toIndex, 0, mesh);
}
this.updateGLVolume();
this.meshes.map((m) => {
log.debug(m.name);
});
};
Niivue.prototype.removeVolume = function (volume) {
this.setVolume(volume, -1);
};
Niivue.prototype.removeMesh = function (mesh) {
this.setMesh(mesh, -1);
};
/**
* Move a volume to the bottom of the stack of loaded volumes. The volume will become the background
* @param {NVImage} volume the volume to move
* @example
* niivue = new Niivue()
* niivue.moveVolumeToBottom(this.volumes[3]) // move the 4th volume to the 0 position. It will be the new background
*/
Niivue.prototype.moveVolumeToBottom = function (volume) {
this.setVolume(volume, 0);
};
/**
* Move a volume up one index position in the stack of loaded volumes. This moves it up one layer
* @param {NVImage} volume the volume to move
* @example
* niivue = new Niivue()
* niivue.moveVolumeUp(this.volumes[0]) // move the background image to the second index position (it was 0 index, now will be 1)
*/
Niivue.prototype.moveVolumeUp = function (volume) {
let volIdx = this.getVolumeIndexByID(volume.id);
this.setVolume(volume, volIdx + 1);
};
/**
* Move a volume down one index position in the stack of loaded volumes. This moves it down one layer
* @param {NVImage} volume the volume to move
* @example
* niivue = new Niivue()
* niivue.moveVolumeDown(this.volumes[1]) // move the second image to the background position (it was 1 index, now will be 0)
*/
Niivue.prototype.moveVolumeDown = function (volume) {
let volIdx = this.getVolumeIndexByID(volume.id);
this.setVolume(volume, volIdx - 1);
};
/**
* Move a volume to the top position in the stack of loaded volumes. This will be the top layer
* @param {NVImage} volume the volume to move
* @example
* niivue = new Niivue()
* niivue.moveVolumeToTop(this.volumes[0]) // move the background image to the top layer position
*/
Niivue.prototype.moveVolumeToTop = function (volume) {
this.setVolume(volume, this.volumes.length - 1);
};
// not included in public docs
// update mouse position from new mouse down coordinates
// note: no test yet
Niivue.prototype.mouseDown = function mouseDown(x, y) {
if (this.sliceType != this.sliceTypeRender) return;
this.mousePos = [x, y];
}; // mouseDown()
// not included in public docs
// note: no test yet
Niivue.prototype.mouseMove = function mouseMove(x, y) {
if (this.sliceType != this.sliceTypeRender) return;
this.scene.renderAzimuth += x - this.mousePos[0];
this.scene.renderElevation += y - this.mousePos[1];
this.mousePos = [x, y];
this.drawScene();
}; // mouseMove()
/**
* convert spherical AZIMUTH, ELEVATION to Cartesian
* @param {number} azimuth azimuth number
* @param {number} elevation elevation number
* @returns {array} the converted [x, y, z] coordinates
* @example
* niivue = new Niivue()
* xyz = niivue.sph2cartDeg(42, 42)
*/
Niivue.prototype.sph2cartDeg = function sph2cartDeg(azimuth, elevation) {
//convert spherical AZIMUTH,ELEVATION,RANGE to Cartesion
//see Matlab's [x,y,z] = sph2cart(THETA,PHI,R)
// reverse with cart2sph
let Phi = -elevation * (Math.PI / 180);
let Theta = ((azimuth - 90) % 360) * (Math.PI / 180);
let ret = [
Math.cos(Phi) * Math.cos(Theta),
Math.cos(Phi) * Math.sin(Theta),
Math.sin(Phi),
];
let len = Math.sqrt(ret[0] * ret[0] + ret[1] * ret[1] + ret[2] * ret[2]);
if (len <= 0.0) return ret;
ret[0] /= len;
ret[1] /= len;
ret[2] /= len;
return ret;
}; // sph2cartDeg()
/**
*