diff --git a/lib/index-gl3d.js b/lib/index-gl3d.js index 8a64c3fc8d6..2a2abd01c64 100644 --- a/lib/index-gl3d.js +++ b/lib/index-gl3d.js @@ -15,7 +15,8 @@ Plotly.register([ require('./surface'), require('./mesh3d'), require('./cone'), - require('./streamtube') + require('./streamtube'), + require('./volume') ]); module.exports = Plotly; diff --git a/lib/index.js b/lib/index.js index 9687411028d..e026a35b29e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -28,6 +28,7 @@ Plotly.register([ require('./mesh3d'), require('./cone'), require('./streamtube'), + require('./volume'), require('./scattergeo'), require('./choropleth'), diff --git a/lib/volume.js b/lib/volume.js new file mode 100644 index 00000000000..59f8579d548 --- /dev/null +++ b/lib/volume.js @@ -0,0 +1,11 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +module.exports = require('../src/traces/volume'); diff --git a/package-lock.json b/package-lock.json index 1fe9536c5c3..d1e799f944f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4940,6 +4940,26 @@ "resolved": "https://registry.npmjs.org/gl-vec4/-/gl-vec4-1.0.1.tgz", "integrity": "sha1-l9loeCgbFLUyy84QF4Xf0cs0CWQ=" }, + "gl-volume3d": { + "version": "git://github.com/gl-vis/gl-volume3d.git#6e28d3d432f67c4da968e68d5ff474f4c49b4412", + "from": "git://github.com/gl-vis/gl-volume3d.git#6e28d3d432f67c4da968e68d5ff474f4c49b4412", + "requires": { + "barycentric": "^1.0.1", + "colormap": "^2.1.0", + "gl-buffer": "^2.0.8", + "gl-mat4": "^1.0.0", + "gl-shader": "^4.2.1", + "gl-texture2d": "^2.0.8", + "gl-vao": "^1.1.3", + "glsl-specular-cook-torrance": "^2.0.1", + "glslify": "^6.1.1", + "ndarray": "^1.0.15", + "normals": "^1.0.1", + "polytope-closest-point": "^1.0.0", + "simplicial-complex-contour": "^1.0.0", + "typedarray-pool": "^1.1.0" + } + }, "glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", diff --git a/package.json b/package.json index 04ec71bc0e9..f81360c9b9d 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "gl-streamtube3d": "^1.1.0", "gl-surface3d": "^1.3.6", "gl-text": "^1.1.6", + "gl-volume3d": "git://github.com/gl-vis/gl-volume3d#6e28d3d432f67c4da968e68d5ff474f4c49b4412", "glslify": "^6.3.1", "has-hover": "^1.0.1", "has-passive-events": "^1.0.0", diff --git a/src/traces/volume/attributes.js b/src/traces/volume/attributes.js new file mode 100644 index 00000000000..02fa2e7cadd --- /dev/null +++ b/src/traces/volume/attributes.js @@ -0,0 +1,119 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var colorscaleAttrs = require('../../components/colorscale/attributes'); +var colorbarAttrs = require('../../components/colorbar/attributes'); +var mesh3dAttrs = require('../mesh3d/attributes'); +var baseAttrs = require('../../plots/attributes'); + +var extendFlat = require('../../lib/extend').extendFlat; + +var attrs = { + x: { + valType: 'data_array', + role: 'info', + editType: 'calc+clearAxisTypes', + description: [ + 'Sets the x coordinates of the volume' + ].join(' ') + }, + y: { + valType: 'data_array', + role: 'info', + editType: 'calc+clearAxisTypes', + description: [ + 'Sets the y coordinates of the volume' + ].join(' ') + }, + z: { + valType: 'data_array', + role: 'info', + editType: 'calc+clearAxisTypes', + description: [ + 'Sets the z coordinates of the volume' + ].join(' ') + }, + + values: { + valType: 'data_array', + role: 'info', + editType: 'calc', + description: 'Sets the intensity values of the volume.' + }, + + opacityscale: { + valType: 'data_array', + role: 'info', + editType: 'calc', + description: [ + 'Sets the opacity scale of the volume.', + 'Defines which opacity to use for which intensity.', + 'Multiplied with trace.opacity to obtain the final opacity.', + 'Colorscale-like array of [[0, opacity0], [v1, opacity1], ..., [1, opacityN]].' + ].join(' ') + }, + + vmin: { + valType: 'number', + role: 'info', + editType: 'calc', + description: 'Sets the minimum intensity bound of the volume. Defaults to the smallest value in values.' + }, + + vmax: { + valType: 'number', + role: 'info', + editType: 'calc', + description: 'Sets the maximum intensity bound of the volume. Defaults to the largest value in values.' + }, + + cmin: { + valType: 'number', + role: 'info', + editType: 'calc', + description: 'Sets the colorscale start intensity of the volume. Defaults to the smallest value in values.' + }, + + cmax: { + valType: 'number', + role: 'info', + editType: 'calc', + description: 'Sets the colorscale end intensity of the volume. Defaults to the largest value in values.' + }, + + text: { + valType: 'string', + role: 'info', + dflt: '', + arrayOk: true, + editType: 'calc', + description: [ + 'Sets the text elements associated with the volume points.', + 'If trace `hoverinfo` contains a *text* flag and *hovertext* is not set,', + 'these elements will be seen in the hover labels.' + ].join(' ') + } +}; + +extendFlat(attrs, colorscaleAttrs('', { + colorAttr: 'value', + showScaleDflt: true, + editTypeOverride: 'calc' +}), { + colorbar: colorbarAttrs +}); + +var fromMesh3d = ['opacity', 'lightposition', 'lighting']; + +fromMesh3d.forEach(function(k) { + attrs[k] = mesh3dAttrs[k]; +}); + +module.exports = attrs; diff --git a/src/traces/volume/calc.js b/src/traces/volume/calc.js new file mode 100644 index 00000000000..8753690ab94 --- /dev/null +++ b/src/traces/volume/calc.js @@ -0,0 +1,27 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var colorscaleCalc = require('../../components/colorscale/calc'); + +module.exports = function calc(gd, trace) { + var values = trace.values; + var len = values.length; + var vMax = -Infinity; + var vMin = Infinity; + + for(var i = 0; i < len; i++) { + var v = values[i]; + vMax = Math.max(vMax, v); + vMin = Math.min(vMin, v); + } + + trace._vMax = vMax; + colorscaleCalc(trace, [vMin, vMax], '', 'c'); +}; diff --git a/src/traces/volume/convert.js b/src/traces/volume/convert.js new file mode 100644 index 00000000000..ac8f698ffd8 --- /dev/null +++ b/src/traces/volume/convert.js @@ -0,0 +1,251 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +/* + Usage example: + + var width = 128 + var height = 128 + var depth = 128 + + var xs = [] + var ys = [] + var zs = [] + + var data = [] + for (var z=0; z [1,2,3] // steps through the first half of the array, bails on the second 1 + getSequence([1,1,2,2,3,3,1,1,2,2,3,3], 1) -> [1,2,3] // steps through every element in the first half of the array + getSequence([1,1,2,2,3,3,1,1,2,2,3,3], 2) -> [1,2,3] // skips every other element + + getSequence([1,1,1, 1,1,1, 1,1,1, 2,2,2, 2,2,2, 2,2,2], 9) -> [1,2] // skips from seq[0] to seq[9] to end of array +*/ +function getSequence(src, stride) { + var xs = [src[0]]; + for(var i = 0, last = xs[0]; i < src.length; i += stride) { + var p = src[i]; + if(p >= last) { + if(p > last) { + xs.push(p); + } + last = p; + } else { + break; + } + } + return xs; +} + +function convert(gl, scene, trace) { + var sceneLayout = scene.fullSceneLayout; + var dataScale = scene.dataScale; + var volumeOpts = {}; + + function toDataCoords(arr, axisName) { + var ax = sceneLayout[axisName]; + var scale = dataScale[axisName2scaleIndex[axisName]]; + return simpleMap(arr, function(v) { return ax.d2l(v) * scale; }); + } + + var xs = getSequence(trace.x, 1); + var ys = getSequence(trace.y, xs.length); + var zs = getSequence(trace.z, xs.length * ys.length); + + volumeOpts.dimensions = [xs.length, ys.length, zs.length]; + volumeOpts.meshgrid = [ + toDataCoords(xs, 'xaxis'), + toDataCoords(ys, 'yaxis'), + toDataCoords(zs, 'zaxis') + ]; + + volumeOpts.values = trace.values; + + volumeOpts.colormap = parseColorScale(trace.colorscale); + + volumeOpts.opacity = trace.opacity; + + if(trace.opacityscale) { + volumeOpts.alphamap = parseOpacityScale(trace.opacityscale); + } + + var vmin = trace.vmin; + var vmax = trace.vmax; + + if(vmin === undefined || vmax === undefined) { + var minV = trace.values[0], maxV = trace.values[0]; + for(var i = 1; i < trace.values.length; i++) { + var v = trace.values[v]; + if(v > maxV) { + maxV = v; + } else if(v < minV) { + minV = v; + } + } + if(vmin === undefined) { + vmin = minV; + } + if(vmax === undefined) { + vmax = maxV; + } + } + + volumeOpts.isoBounds = [vmin, vmax]; + volumeOpts.intensityBounds = [trace.cmin, trace.cmax]; + + var bounds = [[0, 0, 0], volumeOpts.dimensions]; + + var volume = volumePlot(gl, volumeOpts, bounds); + + // pass gl-mesh3d lighting attributes + var lp = trace.lightposition; + for(var i = 0; i < volume.meshes.length; i++) { + var meshData = volume.meshes[i]; + meshData.lightPosition = [lp.x, lp.y, lp.z]; + meshData.ambient = trace.lighting.ambient; + meshData.diffuse = trace.lighting.diffuse; + meshData.specular = trace.lighting.specular; + meshData.roughness = trace.lighting.roughness; + meshData.fresnel = trace.lighting.fresnel; + meshData.opacity = trace.opacity; + + } + + return volume; +} + +proto.update = function(data) { + this.data = data; + + this.dispose(); + + var gl = this.scene.glplot.gl; + var mesh = convert(gl, this.scene, data); + this.mesh = mesh; + this.mesh._trace = this; + + this.scene.glplot.add(mesh); +}; + +proto.dispose = function() { + this.scene.glplot.remove(this.mesh); + this.mesh.dispose(); +}; + +function createVolumeTrace(scene, data) { + var gl = scene.glplot.gl; + + var mesh = convert(gl, scene, data); + + var volume = new Volume(scene, data.uid); + volume.mesh = mesh; + volume.data = data; + mesh._trace = volume; + + scene.glplot.add(mesh); + + return volume; +} + +module.exports = createVolumeTrace; diff --git a/src/traces/volume/defaults.js b/src/traces/volume/defaults.js new file mode 100644 index 00000000000..bd6350933f9 --- /dev/null +++ b/src/traces/volume/defaults.js @@ -0,0 +1,57 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Lib = require('../../lib'); + +var colorscaleDefaults = require('../../components/colorscale/defaults'); +var attributes = require('./attributes'); + +module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) { + function coerce(attr, dflt) { + return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); + } + + var values = coerce('values'); + + var x = coerce('x'); + var y = coerce('y'); + var z = coerce('z'); + + if( + !values || !values.length || + !x || !x.length || !y || !y.length || !z || !z.length + ) { + traceOut.visible = false; + return; + } + + coerce('vmin'); + coerce('vmax'); + coerce('cmin'); + coerce('cmax'); + coerce('opacityscale'); + + coerce('lighting.ambient'); + coerce('lighting.diffuse'); + coerce('lighting.specular'); + coerce('lighting.roughness'); + coerce('lighting.fresnel'); + coerce('lightposition.x'); + coerce('lightposition.y'); + coerce('lightposition.z'); + + colorscaleDefaults(traceIn, traceOut, layout, coerce, {prefix: '', cLetter: 'c'}); + + coerce('text'); + + // disable 1D transforms (for now) + traceOut._length = null; +}; diff --git a/src/traces/volume/index.js b/src/traces/volume/index.js new file mode 100644 index 00000000000..65246c1d892 --- /dev/null +++ b/src/traces/volume/index.js @@ -0,0 +1,35 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +module.exports = { + moduleType: 'trace', + name: 'volume', + basePlotModule: require('../../plots/gl3d'), + categories: ['gl3d'], + + attributes: require('./attributes'), + supplyDefaults: require('./defaults'), + colorbar: { + min: 'cmin', + max: 'cmax' + }, + calc: require('./calc'), + plot: require('./convert'), + + meta: { + description: [ + 'Use volumes to visualize volumetric data.', + '', + 'Specify a volume using 4 1D arrays,', + '3 position arrays `x`, `y` and `z`', + 'and intensity array `u`.' + ].join(' ') + } +}; diff --git a/test/image/mocks/gl3d_volume-simple.json b/test/image/mocks/gl3d_volume-simple.json new file mode 100644 index 00000000000..a27ad28fc27 --- /dev/null +++ b/test/image/mocks/gl3d_volume-simple.json @@ -0,0 +1,66 @@ +{ + "data": [ + { + "type": "volume", + "x": [1, 2, 3], + "y": [1, 2, 3], + "z": [1, 2, 3], + "values": [ + 0,1,2, + 3,4,5, + 6,7,8, + + 0,1,2, + 3,4,5, + 6,7,8, + + 0,1,2, + 3,4,5, + 6,7,8 + ], + "sizemode": "absolute", + "sizeref": 2, + "anchor": "tip", + "colorbar": { + "x": 0, + "xanchor": "right", + "side": "left" + } + }, + { + "type": "volume", + "x": [1, 2, 3], + "y": [1, 2, 3], + "z": [1, 2, 3], + "values": [ + 0,1,2, + 3,4,5, + 6,7,8, + + 0,1,2, + 3,4,5, + 6,7,8, + + 0,1,2, + 3,4,5, + 6,7,8 + ], + "scene": "scene2" + } + ], + "layout": { + "scene": { + "domain": {"x": [0, 0.5]}, + "camera": { + "eye": {"x": -1.57, "y": 1.36, "z": 0.58} + } + }, + "scene2": { + "domain": {"x": [0.5, 1]}, + "camera": { + "eye": {"x": -1.57, "y": 1.36, "z": 0.58} + } + }, + "width": 800 + } +} diff --git a/test/jasmine/tests/volume_test.js b/test/jasmine/tests/volume_test.js new file mode 100644 index 00000000000..657a8b78628 --- /dev/null +++ b/test/jasmine/tests/volume_test.js @@ -0,0 +1,126 @@ +var Plotly = require('@lib'); +var Lib = require('@src/lib'); + +var supplyAllDefaults = require('../assets/supply_defaults'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var failTest = require('../assets/fail_test'); +var delay = require('../assets/delay'); +var mouseEvent = require('../assets/mouse_event'); + +var customAssertions = require('../assets/custom_assertions'); +var assertHoverLabelContent = customAssertions.assertHoverLabelContent; + +describe('Test volume defaults', function() { + var gd; + + function makeGD() { + return { + data: [{ + type: 'volume', + x: [1, 2], + y: [1, 2], + z: [1, 2], + values: [1, 2, 1, 2, 1, 2, 1, 2], + }], + layout: {} + }; + } + + it('should not set `visible: false` for traces with x,y,z,values arrays', function() { + gd = makeGD(); + supplyAllDefaults(gd); + expect(gd._fullData[0].visible).toBe(true); + }); + + it('should set `visible: false` for traces missing x,y,z,values arrays', function() { + var keysToDelete = ['x', 'y', 'z', 'values']; + + keysToDelete.forEach(function(k) { + gd = makeGD(); + delete gd.data[0][k]; + + supplyAllDefaults(gd); + expect(gd._fullData[0].visible).toBe(!k, 'missing array ' + k); + }); + }); + + it('should set `visible: false` for traces empty x,y,z,values arrays', function() { + var keysToEmpty = ['x', 'y', 'z', 'values']; + + keysToEmpty.forEach(function(k) { + gd = makeGD(); + gd.data[0][k] = []; + + supplyAllDefaults(gd); + expect(gd._fullData[0].visible).toBe(!k, 'empty array ' + k); + }); + }); +}); + +describe('Test volume autorange:', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + function _assertAxisRanges(msg, xrng, yrng, zrng) { + var sceneLayout = gd._fullLayout.scene; + expect(sceneLayout.xaxis.range).toBeCloseToArray(xrng, 2, 'xaxis range - ' + msg); + expect(sceneLayout.yaxis.range).toBeCloseToArray(yrng, 2, 'yaxis range - ' + msg); + expect(sceneLayout.zaxis.range).toBeCloseToArray(zrng, 2, 'zaxis range - ' + msg); + } + + +}); + +describe('Test volume interactions', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + it('@gl should add/clear gl objects correctly', function(done) { + var fig = Lib.extendDeep({}, require('@mocks/gl3d_volume-simple.json')); + // put traces on same subplot + delete fig.data[1].scene; + + Plotly.plot(gd, fig).then(function() { + var scene = gd._fullLayout.scene._scene; + var objs = scene.glplot.objects; + + expect(objs.length).toBe(2); + + return Plotly.deleteTraces(gd, [0]); + }) + .then(function() { + var scene = gd._fullLayout.scene._scene; + var objs = scene.glplot.objects; + + expect(objs.length).toBe(1); + + return Plotly.deleteTraces(gd, [0]); + }) + .then(function() { + var scene = gd._fullLayout.scene; + + expect(scene).toBeUndefined(); + }) + .catch(failTest) + .then(done); + }); + + +});