diff --git a/packages/lib-subject-viewers/README.md b/packages/lib-subject-viewers/README.md
index 2a59b1e876..c0919f26ea 100644
--- a/packages/lib-subject-viewers/README.md
+++ b/packages/lib-subject-viewers/README.md
@@ -13,7 +13,7 @@ npm i @zooniverse/subject-viewers
and use it
```
-import { ProtoViewer } from '@zooniverse/subject-viewers';
+import { VolumetricViewer } from '@zooniverse/subject-viewers';
```
## Run
@@ -28,7 +28,7 @@ import { ProtoViewer } from '@zooniverse/subject-viewers';
Components should be added to the `src/components` folder and an export to `src/index.js`. Each component should be tested, documented readme, and have a storybook example added.
-### Technologies and tools we use
+## Technologies and tools we use
All of our components are written using React, built on top of Grommet, a component UI library, and styled by our custom Grommet style theme (@zooniverse/grommet-theme) and styled-components.
@@ -40,3 +40,7 @@ Testing is done by
- [Mocha](https://mochajs.org/) - test runner
- [Chai](https://www.chaijs.com/) - BDD/TDD assertion library
- [Sinon](https://sinonjs.org) - test spies, mocks, and stubs
+
+## Troubleshooting
+
+The VolumetricViewer component uses `canvas`. If your Mac has a M1 or M2 chip, you'll likely need to do some manual `brew install` commands in order to bootstrap FEM: https://github.com/Automattic/node-canvas/issues/1511.
diff --git a/packages/lib-subject-viewers/package.json b/packages/lib-subject-viewers/package.json
index 1b7f6eaa52..76b43acd56 100644
--- a/packages/lib-subject-viewers/package.json
+++ b/packages/lib-subject-viewers/package.json
@@ -31,9 +31,14 @@
"storybook": "storybook dev -p 6008",
"build-storybook": "storybook build",
"test": "mocha --config ./test/.mocharc.json ./.storybook/specConfig.js \"./src/**/*.spec.js\"",
- "test:ci": "mocha --config ./test/.mocharc.json ./.storybook/specConfig.js --reporter=min \"./src/**/*.spec.js\""
+ "test:ci": "mocha --config ./test/.mocharc.json ./.storybook/specConfig.js --reporter=min \"./src/**/*.spec.js\"",
+ "watch": "watch 'yarn build' ./src",
+ "watch:test": "watch 'yarn test' ./src"
+ },
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "three": "^0.162.0"
},
- "dependencies": {},
"peerDependencies": {
"@zooniverse/grommet-theme": "3.x.x",
"grommet": "2.x.x",
@@ -45,13 +50,15 @@
"@storybook/addon-a11y": "~7.6.11",
"@storybook/addon-essentials": "~7.6.11",
"@storybook/react": "~7.6.11",
+ "canvas": "^2.11.2",
"chai": "~4.5.0",
"chai-dom": "~1.12.0",
"dirty-chai": "~2.0.1",
"mocha": "~10.7.3",
"sinon": "~17.0.0",
"sinon-chai": "~3.7.0",
- "storybook": "~7.6.11"
+ "storybook": "~7.6.11",
+ "watch": "^1.0.2"
},
"engines": {
"node": ">=20.5"
diff --git a/packages/lib-subject-viewers/src/components/ProtoViewer/ProtoViewer.js b/packages/lib-subject-viewers/src/components/ProtoViewer/ProtoViewer.js
deleted file mode 100644
index 2f52265cea..0000000000
--- a/packages/lib-subject-viewers/src/components/ProtoViewer/ProtoViewer.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export default function Home () {
- return (
-
-
ProtoViewer
-
Can we import?
-
- )
-}
diff --git a/packages/lib-subject-viewers/src/components/ProtoViewer/ProtoViewer.spec.js b/packages/lib-subject-viewers/src/components/ProtoViewer/ProtoViewer.spec.js
deleted file mode 100644
index 11d22aff9b..0000000000
--- a/packages/lib-subject-viewers/src/components/ProtoViewer/ProtoViewer.spec.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { render } from '@testing-library/react'
-import { composeStory } from '@storybook/react'
-import Meta, { Default } from './ProtoViewer.stories.js'
-
-describe('Component > AboutHeader', function () {
- const DefaultStory = composeStory(Default, Meta)
-
- before(function () {
- render(
-
- )
- })
-
- it('should load without errors', function () {
- expect(document.querySelector('h2')).to.be.ok()
- })
-})
diff --git a/packages/lib-subject-viewers/src/components/ProtoViewer/ProtoViewer.stories.js b/packages/lib-subject-viewers/src/components/ProtoViewer/ProtoViewer.stories.js
deleted file mode 100644
index ff70b91c5e..0000000000
--- a/packages/lib-subject-viewers/src/components/ProtoViewer/ProtoViewer.stories.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import ProtoViewer from './ProtoViewer'
-
-export default {
- title: 'Components / ProtoViewer',
- component: ProtoViewer
-}
-
-export const Default = () => {
- return
-}
diff --git a/packages/lib-subject-viewers/src/components/ProtoViewer/README.md b/packages/lib-subject-viewers/src/components/ProtoViewer/README.md
deleted file mode 100644
index acfa281e39..0000000000
--- a/packages/lib-subject-viewers/src/components/ProtoViewer/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# ProtoViewer
-
-Test component that ensures the package is setup correctly and importable into other peer packages.
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/README.md b/packages/lib-subject-viewers/src/components/VolumetricViewer/README.md
new file mode 100644
index 0000000000..5b63a55fc1
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/README.md
@@ -0,0 +1,6 @@
+# VolumetricViewer
+
+This directory holds all the relevant code for rendering the VolumetricViewer. There are two primary exports:
+
+- `VolumetricViewerComponent` - a React component for the VolumetricViewer
+- `VolumetricViewerData` - a function that returns the data with instantiated models along with the React Component
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/VolumetricViewer.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/VolumetricViewer.js
new file mode 100644
index 0000000000..e0f7716aea
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/VolumetricViewer.js
@@ -0,0 +1,73 @@
+import { object, string } from 'prop-types'
+import { useEffect, useState } from 'react'
+import { Buffer } from 'buffer'
+import { ComponentViewer } from './components/ComponentViewer.js'
+import { ModelViewer } from './models/ModelViewer.js'
+import { ModelAnnotations } from './models/ModelAnnotations.js'
+import { ModelTool } from './models/ModelTool.js'
+
+export default function VolumetricViewerComponent ({
+ config = {},
+ subjectData = '',
+ subjectUrl = '',
+ models
+}) {
+ const [data, setData] = useState(null)
+ if (!models) {
+ const [modelState] = useState({
+ annotations: ModelAnnotations(),
+ tool: ModelTool(),
+ viewer: ModelViewer()
+ })
+ models = modelState
+ }
+
+ // Figure out subject data
+ useEffect(() => {
+ if (subjectData !== '') {
+ setData(Buffer.from(subjectData, 'base64'))
+ } else if (subjectUrl !== '') {
+ fetch(subjectUrl)
+ .then((res) => res.json())
+ .then((data) => {
+ setData(Buffer.from(data, 'base64'))
+ })
+ } else {
+ console.log('No data to display')
+ }
+ }, [])
+
+ // Loading screen will always display if we have no subject data
+ if (!data || !models) return Loading...
+
+ return (
+
+ )
+}
+
+export const VolumetricViewerData = ({ subjectData = '', subjectUrl = '' }) => {
+ return {
+ data: {
+ config: {},
+ subjectData,
+ subjectUrl,
+ models: {
+ annotations: ModelAnnotations(),
+ tool: ModelTool(),
+ viewer: ModelViewer()
+ }
+ },
+ component: VolumetricViewerComponent
+ }
+}
+
+VolumetricViewerComponent.propTypes = {
+ config: object,
+ subjectData: string,
+ subjectUrl: string,
+ models: object
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/components/AnnotationView.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/AnnotationView.js
new file mode 100644
index 0000000000..82092459ee
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/AnnotationView.js
@@ -0,0 +1,32 @@
+import { array, number, object } from 'prop-types'
+
+export const AnnotationView = ({ annotation, annotations, index }) => {
+ function annotationActive () {
+ annotations.actions.annotation.active({ index })
+ }
+
+ function annotationDelete (e) {
+ e.stopPropagation()
+ annotations.actions.annotation.remove({ index })
+ }
+
+ const color = annotations.config.activeAnnotation === index ? '#555' : '#222'
+
+ return (
+
+ Label: {annotation.label}
+ Threshold: {annotation.threshold}
+ Points: {annotation.points.active.length}
+ Delete Annotation
+
+ )
+}
+
+AnnotationView.propTypes = {
+ annotation: object,
+ annotations: array,
+ index: number
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/components/ComponentViewer.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/ComponentViewer.js
new file mode 100644
index 0000000000..ba13925811
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/ComponentViewer.js
@@ -0,0 +1,65 @@
+import { object } from 'prop-types'
+import { AlgorithmAStar } from './../helpers/AlgorithmAStar.js'
+import { Cube } from './Cube.js'
+import { Plane } from './Plane.js'
+import { Box } from 'grommet'
+
+export const ComponentViewer = ({
+ data,
+ models
+}) => {
+ // Initialize Annotations
+ if (models.annotations) {
+ models.annotations.initialize({
+ algorithm: AlgorithmAStar,
+ data: [], // will come from Caesar if they exist
+ viewer: models.viewer
+ })
+ }
+
+ // Initialize Tool
+ if (models.tool) {
+ models.tool.initialize({
+ annotations: models.annotations
+ })
+ }
+
+ // Initialize Viewer
+ if (models.viewer) {
+ models.viewer.initialize({
+ annotations: models.annotations,
+ data,
+ tool: models.tool
+ })
+ }
+
+ return (
+
+
+ {models.viewer.dimensions.map((dimensionName, dimension) => {
+ return (
+
+ )
+ })}
+
+
+
+
+
+ )
+}
+
+ComponentViewer.propTypes = {
+ data: object,
+ models: object
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Config.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Config.js
new file mode 100644
index 0000000000..7864e9a931
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Config.js
@@ -0,0 +1,107 @@
+import { object } from 'prop-types'
+import { useEffect, useState } from 'react'
+import { AnnotationView } from './AnnotationView.js'
+import { InputRangeDual } from './InputRangeDual.js'
+
+export const Config = ({
+ annotations,
+ viewer
+}) => {
+ const [_annotations, setAnnotations] = useState(annotations.annotations)
+
+ function annotationsChange ({ annotations }) {
+ setAnnotations([...annotations])
+ }
+
+ // State Change Management through useEffect()
+ useEffect(() => {
+ // State Listeners to bypass React rerenders
+ annotations.on('active:annotation', annotationsChange)
+ annotations.on('add:annotation', annotationsChange)
+ annotations.on('update:annotation', annotationsChange)
+ annotations.on('remove:annotation', annotationsChange)
+
+ return () => {
+ annotations.off('active:annotation', annotationsChange)
+ annotations.off('add:annotation', annotationsChange)
+ annotations.off('update:annotation', annotationsChange)
+ annotations.off('remove:annotation', annotationsChange)
+ }
+ }, [])
+
+ function downloadPoints () {
+ const rows = annotations.annotations.map((annotation) => {
+ return [
+ annotation.label,
+ annotation.threshold,
+ annotation.points.active.join('|'),
+ annotation.points.all.data.join('|')
+ ]
+ })
+
+ rows.unshift([
+ 'annotation name',
+ 'annotation threshold',
+ 'control points',
+ 'connected points'
+ ])
+ const csvContent =
+ 'data:text/csv;charset=utf-8,' + rows.map((r) => r.join(',')).join('\n')
+ const encodedUri = encodeURI(csvContent)
+ const link = document.createElement('a')
+ link.setAttribute('href', encodedUri)
+ link.setAttribute('download', 'brainsweeper.csv')
+ document.body.appendChild(link)
+ link.click()
+ }
+
+ function saveScreenshot () {
+ viewer.saveScreenshot()
+ }
+
+ return (
+ <>
+ Volumetric File
+
+
+ Brightness Range
+ {
+ viewer.setThreshold({ min, max })
+ }}
+ />
+
+
+
+
+ Download Active Points
+
+
+
+ Save Screenshot
+
+
+
+ {_annotations.map((annotation, index) => {
+ return (
+
+ )
+ })}
+
+ >
+ )
+}
+
+Config.propTypes = {
+ annotations: object,
+ viewer: object
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Cube.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Cube.js
new file mode 100644
index 0000000000..28f2d50307
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Cube.js
@@ -0,0 +1,458 @@
+import { object } from 'prop-types'
+import {
+ AxesHelper,
+ BufferGeometry,
+ BoxGeometry,
+ Color,
+ HemisphereLight,
+ InstancedMesh,
+ Line,
+ LineBasicMaterial,
+ Matrix4,
+ MeshBasicMaterial,
+ Object3D,
+ PerspectiveCamera,
+ Raycaster,
+ Scene,
+ Vector2,
+ Vector3,
+ WebGLRenderer
+} from 'three'
+import { useEffect, useLayoutEffect, useRef } from 'react'
+import { Histogram } from './Histogram.js'
+import { pointColor } from './../helpers/pointColor.js'
+import { SortedSetUnion } from './../helpers/SortedSet.js'
+
+// Shim for test:ci in GH needs this to work
+let OrbitControls = null;
+import("three/addons/controls/OrbitControls.js").then((module) => {
+ OrbitControls = module.OrbitControls;
+})
+
+// Shim for node.js testing
+let glContext = null
+if (!process.browser) {
+ window.requestAnimationFrame = () => {
+ // needs to be stubbed out for animate() to work
+ }
+}
+
+export const Cube = ({ annotations, tool, viewer }) => {
+ const FPS_INTERVAL = 1000 / 60
+ const NUM_MESH_POINTS = Math.pow(viewer.base, 2) * 3 - viewer.base * 3 + 1
+
+ // We need to create internal refs so that resizing + animation loop works properly
+ const canvasRef = useRef(null)
+ const canvasAxesRef = useRef(null)
+ const meshPlaneSet = useRef(null)
+ const threeRef = useRef({})
+ const threeAxesRef = useRef({})
+
+ // State Change management through useEffect()
+ useEffect(() => {
+ setupCube()
+
+ // render mesh + add to scene so that raycasting works
+ renderPlanePoints()
+ threeRef.current.scene.add(threeRef.current.meshPlane)
+
+ if (annotations) renderAnnotations()
+ animate()
+
+ // Resize canvas
+ onWindowResize()
+
+ // Setup State Listeners
+ annotations.on('add:annotation', addAnnotation)
+ annotations.on('remove:annotation', removeAnnotation)
+ annotations.on('update:annotation', updateAnnotation)
+ viewer.on('change:dimension:frame', renderPlanePoints)
+ viewer.on('change:threshold', renderPlanePoints)
+ viewer.on('save:screenshot', saveScreenshot)
+
+ return () => {
+ annotations.off('add:annotation', addAnnotation)
+ annotations.off('remove:annotation', removeAnnotation)
+ annotations.off('update:annotation', updateAnnotation)
+ viewer.off('change:dimension:frame', renderPlanePoints)
+ viewer.off('change:threshold', renderPlanePoints)
+ viewer.off('save:screenshot', saveScreenshot)
+ }
+ }, [])
+
+ useLayoutEffect(() => {
+ window.addEventListener('resize', onWindowResize)
+ window.addEventListener('mousemove', onMouseMove)
+
+ return () => {
+ window.removeEventListener('resize', onWindowResize)
+ window.removeEventListener('mousemove', onMouseMove)
+ }
+ }, [])
+
+ // Save the viewer as a screenshot
+ function saveScreenshot () {
+ const encodedUri = encodeURI(canvasRef.current.toDataURL())
+ const link = document.createElement('a')
+ link.setAttribute('href', encodedUri)
+ link.setAttribute('download', 'brainsweeper.png')
+ document.body.appendChild(link)
+ link.click()
+ }
+
+ // Functions that do the actual work
+ function setupCube () {
+ const { width } =
+ canvasRef.current.parentElement.getBoundingClientRect()
+
+ // Setup Ref object once DOM is rendered
+ threeRef.current = {
+ canvas: null,
+ camera: new PerspectiveCamera(100, 1, 0.01, 3000),
+ cubes: new Object3D(),
+ isShift: false,
+ isClicked: -1,
+ lastRender: 0,
+ light: new HemisphereLight(0xffffff, 0x888888, 3),
+ matrix: new Matrix4(),
+ mouse: new Vector2(1, 1),
+ mouseDown: 0,
+ meshPlane: new InstancedMesh(
+ new BoxGeometry(1, 1, 1),
+ new MeshBasicMaterial({ color: 0xffffff }),
+ NUM_MESH_POINTS
+ ),
+ meshAnnotations: [],
+ orbit: null,
+ raycaster: new Raycaster(),
+ renderer: null,
+ scene: new Scene()
+ }
+
+ // Setup camera, light, scene, and orbit controls
+ threeRef.current.camera.position.set(viewer.base, viewer.base, viewer.base)
+ threeRef.current.camera.lookAt(0, 0, 0)
+
+ threeRef.current.light.position.set(0, 1, 0)
+
+ threeRef.current.meshPlane.name = 'plane'
+
+ threeRef.current.scene.background = new Color(0x000000)
+ threeRef.current.scene.add(threeRef.current.light)
+
+ threeRef.current.renderer = new WebGLRenderer({
+ context: glContext,
+ canvas: canvasRef.current,
+ preserveDrawingBuffer: true
+ })
+ threeRef.current.renderer.setPixelRatio(window.devicePixelRatio)
+ threeRef.current.renderer.setSize(width, width)
+
+ if (OrbitControls) {
+ threeRef.current.orbit = new OrbitControls(
+ threeRef.current.camera,
+ threeRef.current.renderer.domElement,
+ );
+ threeRef.current.orbit.enableDamping = false;
+ threeRef.current.orbit.enableZoom = true;
+ threeRef.current.orbit.enablePan = false;
+ } else {
+ console.log('OrbitControls are not available')
+ }
+
+ // View Axes
+ const half = viewer.base / 2
+
+ const colors = [
+ 0xffff00, // yellow
+ 0x00ffff, // cyan
+ 0xff00ff // magenta
+ ]
+
+ const points = [
+ [
+ [1, 1, -1],
+ [-1, 1, -1],
+ [-1, 1, 1]
+ ],
+ [
+ [-1, 1, 1],
+ [-1, -1, 1],
+ [1, -1, 1]
+ ],
+ [
+ [1, -1, 1],
+ [1, -1, -1],
+ [1, 1, -1]
+ ]
+ ]
+
+ points.forEach((pointArr, index) => {
+ const _points = []
+ pointArr.forEach((point) => {
+ _points.push(
+ new Vector3(point[0] * half, point[1] * half, point[2] * half)
+ )
+ })
+
+ const geometry = new BufferGeometry().setFromPoints(_points)
+ const material = new LineBasicMaterial({ color: colors[index] })
+ const line = new Line(geometry, material)
+ threeRef.current.scene.add(line)
+ })
+
+ // Axes setup
+ threeAxesRef.current = {
+ axis: new AxesHelper(100),
+ canvas: null,
+ light: new HemisphereLight(0xffffff, 0x888888, 3),
+ matrix: new Matrix4(),
+ mouse: new Vector2(1, 1),
+ orbit: null,
+ renderer: null,
+ scene: new Scene()
+ }
+
+ // Setup Axes viewer details
+ const xColor = new Color(0xff00ff)
+ const yColor = new Color(0xffff00)
+ const zColor = new Color(0x00ffff)
+
+ threeAxesRef.current.axis.setColors(xColor, yColor, zColor)
+ threeAxesRef.current.scene.background = new Color(0x000000)
+ threeAxesRef.current.renderer = new WebGLRenderer({
+ context: glContext,
+ canvas: canvasAxesRef.current
+ })
+ threeAxesRef.current.renderer.setPixelRatio(window.devicePixelRatio)
+ threeAxesRef.current.renderer.setSize(75, 75)
+ threeAxesRef.current.scene.add(threeAxesRef.current.axis)
+ }
+
+ function animate () {
+ const lastRender = Date.now() - threeRef.current.lastRender
+ window.requestAnimationFrame(animate)
+
+ if (lastRender > FPS_INTERVAL) {
+ // throttle to 60fps
+ render()
+ threeRef.current.lastRender = Date.now()
+ }
+ }
+
+ function render () {
+ threeRef.current.raycaster.setFromCamera(
+ threeRef.current.mouse,
+ threeRef.current.camera
+ )
+
+ // Because the render loop is called every frame, we minimize work to only that needed for click
+ if (threeRef.current.isClicked !== -1) {
+ const button = threeRef.current.isClicked
+ const shiftKey = threeRef.current.isShift
+
+ const intersectionScene = threeRef.current.raycaster.intersectObject(
+ threeRef.current.scene
+ )
+
+ // reset modifiers
+ threeRef.current.isClicked = -1
+ threeRef.current.isShift = false
+ if (intersectionScene.length > 0) {
+ const point =
+ meshPlaneSet.current.data[intersectionScene[0].instanceId]
+
+ if (tool.events.click) {
+ tool.events.click({
+ button,
+ point,
+ shiftKey
+ })
+ }
+ }
+ }
+
+ threeRef.current.renderer.render(
+ threeRef.current.scene,
+ threeRef.current.camera
+ )
+
+ threeAxesRef.current.renderer.render(
+ threeAxesRef.current.scene,
+ threeRef.current.camera
+ )
+ }
+
+ function renderPlanePoints () {
+ // const t0 = performance.now()
+
+ const frames = viewer.planeFrameActive
+ const sets = frames.map((frame, dimension) =>
+ viewer.getPlaneSet({ dimension, frame })
+ )
+
+ meshPlaneSet.current = SortedSetUnion({ sets })
+
+ meshPlaneSet.current.data.forEach((point, index) => {
+ drawMeshPoint({
+ mesh: threeRef.current.meshPlane,
+ meshPointIndex: index,
+ point
+ })
+ })
+ // console.log("Performance: renderPlanePoints()", performance.now() - t0);
+
+ threeRef.current.meshPlane.instanceMatrix.needsUpdate = true
+ threeRef.current.meshPlane.instanceColor.needsUpdate = true
+ }
+
+ /** ********* ANNOTATIONS *******************/
+ function renderAnnotations () {
+ annotations.annotations.forEach((annotation, annotationIndex) => {
+ addAnnotation({ annotation, annotationIndex })
+ })
+ }
+
+ function addAnnotation ({ annotation, annotationIndex }) {
+ // Create the mesh
+ const mesh = new InstancedMesh(
+ new BoxGeometry(1, 1, 1),
+ new MeshBasicMaterial({ color: 0xffffff }),
+ annotation.points.all.data.length
+ )
+ threeRef.current.meshAnnotations[annotationIndex] = mesh
+
+ // Add points to the mesh
+ annotation.points.all.data.forEach((point, pointIndex) => {
+ drawMeshPoint({
+ annotationIndex,
+ mesh,
+ meshPointIndex: pointIndex,
+ point
+ })
+ })
+
+ // Add mesh to the scene
+ mesh.name = annotation.label
+ threeRef.current.scene.add(mesh)
+ mesh.instanceMatrix.needsUpdate = true
+ }
+
+ function updateAnnotation ({ annotation, annotationIndex }) {
+ removeAnnotation({ annotationIndex })
+ addAnnotation({ annotation, annotationIndex })
+ }
+
+ function removeAnnotation ({ annotationIndex }) {
+ const mesh = threeRef.current.meshAnnotations[annotationIndex]
+ threeRef.current.scene.remove(mesh)
+ threeRef.current.meshAnnotations.splice(annotationIndex, 1)
+ }
+
+ /** ********* MESH *******************/
+ function drawMeshPoint ({
+ annotationIndex = -1,
+ mesh,
+ meshPointIndex,
+ point
+ }) {
+ const pointValue = viewer.getPointValue({ point })
+ const isVisible = viewer.isPointInThreshold({ point })
+
+ const position = isVisible
+ ? getPositionInSpace({ coors: viewer.getPointCoordinates({ point }) })
+ : [50000, 50000, 50000] // basically remove from view
+
+ threeRef.current.matrix.setPosition(...position)
+ mesh.setMatrixAt(meshPointIndex, threeRef.current.matrix)
+ mesh.setColorAt(
+ meshPointIndex,
+ pointColor({
+ isThree: true,
+ annotationIndex,
+ pointValue
+ })
+ )
+ }
+
+ // Calculate the physical position in space
+ function getPositionInSpace ({ coors }) {
+ const [x, y, z] = coors
+ const numPointsAdjustment = viewer.base - 1
+ const positionOffset = (numPointsAdjustment / 2) * -1
+
+ return [
+ numPointsAdjustment + positionOffset - x,
+ numPointsAdjustment + positionOffset - y,
+ numPointsAdjustment + positionOffset - z
+ ]
+ }
+
+ function onMouseMove (e) {
+ // Update the base ref() so that the animation loop handles the mouse move
+ const { height, left, top, width } =
+ canvasRef.current.parentElement.getBoundingClientRect()
+ threeRef.current.mouse.x = 2 * ((e.clientX - left) / width) - 1
+ threeRef.current.mouse.y = 1 - 2 * ((e.clientY - top) / height)
+ }
+
+ function onPointerDown () {
+ // detect click through pointer down + up since we can rotate the Cube
+ threeRef.current.mouseDown = Date.now()
+ }
+
+ function onPointerUp (e) {
+ const duration = Date.now() - threeRef.current.mouseDown
+ if (duration < 150) {
+ // ms to call it a click
+ if (e.shiftKey) threeRef.current.isShift = true
+ threeRef.current.isClicked = e.button
+ }
+ }
+
+ function onWindowResize () {
+ // constrain based on parent element width and height
+ const { width } =
+ canvasRef.current.parentElement.getBoundingClientRect()
+
+ threeRef.current.camera.aspect = 1
+ threeRef.current.camera.updateProjectionMatrix()
+ threeRef.current.renderer.setSize(width, width)
+
+ canvasAxesRef.current.width = canvasAxesRef.current.clientWidth
+ canvasAxesRef.current.height = canvasAxesRef.current.clientHeight
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+Cube.propTypes = {
+ annotations: object,
+ tool: object,
+ viewer: object
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Histogram.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Histogram.js
new file mode 100644
index 0000000000..89febaf652
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Histogram.js
@@ -0,0 +1,80 @@
+import { object } from 'prop-types'
+import { useEffect, useRef } from 'react'
+
+export const Histogram = ({ viewer }) => {
+ // canvas ref
+ const canvasRef = useRef(null)
+
+ // setup defaults
+ const histogram = []
+ let maxValue = 0
+ let maxCount = 0
+ let minValue = 255
+
+ for (let i = 0; i < 256; i++) {
+ histogram[i] = 0
+ if (viewer.data[i] < minValue) minValue = viewer.data[i]
+ if (viewer.data[i] > maxValue) maxValue = viewer.data[i]
+ }
+
+ viewer.data.forEach((point) => {
+ const newCount = histogram[point] + 1
+ if (newCount > maxCount) maxCount = newCount
+ histogram[point] = newCount
+ })
+
+ const histogramMin = []
+ histogram.forEach((val, i) => {
+ if (val !== 0) {
+ histogramMin.push({ x: i, y: val })
+ }
+ })
+
+ useEffect(() => {
+ const ctx = canvasRef.current.getContext('2d')
+
+ // reset dimensions because screen pixel-density depends on this
+ const crc = canvasRef.current
+ crc.width = crc.clientWidth
+ crc.height = crc.clientHeight
+ const { width, height } = crc
+
+ const range = maxValue - minValue
+ const w = width / range
+ const h = height / maxCount
+
+ ctx.fillStyle = 'grey'
+ ctx.strokeStyle = 'white'
+ ctx.beginPath()
+ ctx.moveTo(0, height)
+
+ // SMOOTHS OUT THE LINE
+ histogramMin.forEach(({ x, y }) => {
+ ctx.lineTo(
+ (x - minValue) * w,
+ height - (y * h)
+ )
+ })
+
+ ctx.lineTo(width, height)
+ ctx.fill()
+ }, [])
+
+ return (
+
+ )
+}
+
+Histogram.propTypes = {
+ viewer: object
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/components/InputRange.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/InputRange.js
new file mode 100644
index 0000000000..6f80a80605
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/InputRange.js
@@ -0,0 +1,71 @@
+'use client'
+
+import { func, number } from 'prop-types'
+import { useState } from 'react'
+
+export const InputRange = ({
+ valueMax = 100,
+ valueMin = 0,
+ valueCurrent = 50,
+ onChange = () => {}
+}) => {
+ const [state, setState] = useState({
+ _id: Math.random().toString(36).slice(2),
+ valueMax,
+ valueMin,
+ valueCurrent
+ })
+
+ const inChange = (ev) => {
+ const value = parseInt(ev.target.value, 10)
+ const newObj = {
+ ...state,
+ valueCurrent: value
+ }
+
+ setState(newObj)
+ onChange(newObj.valueCurrent)
+ }
+
+ return (
+
+
+
{state.valueMin}
+
+
{state.valueMax}
+
+
+
+
+ Current Value:{' '}
+
+
+
+
+
+ )
+}
+
+InputRange.propTypes = {
+ valueMax: number,
+ valueMin: number,
+ valueCurrent: number,
+ onChange: func
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/components/InputRangeDual.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/InputRangeDual.js
new file mode 100644
index 0000000000..7abc1bf5e2
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/InputRangeDual.js
@@ -0,0 +1,121 @@
+'use client'
+
+import { func, number } from 'prop-types'
+import { useState } from 'react'
+
+export const InputRangeDual = ({
+ valueMax = 100,
+ valueMin = 0,
+ valueMaxCurrent = 75,
+ valueMinCurrent = 25,
+ onChange = () => {}
+}) => {
+ const [state, setState] = useState({
+ valueMax,
+ valueMin,
+ valueMaxCurrent,
+ valueMinCurrent
+ })
+
+ function fillSlider () {
+ const rangeDistance = state.valueMax - state.valueMin
+ const fromPosition = state.valueMinCurrent - state.valueMin
+ const toPosition = state.valueMaxCurrent - state.valueMin
+
+ return `linear-gradient(
+ to right,
+ var(--grey) 0%,
+ var(--grey) ${(fromPosition / rangeDistance) * 100}%,
+ var(--primary-accent) ${(fromPosition / rangeDistance) * 100}%,
+ var(--primary-accent) ${(toPosition / rangeDistance) * 100}%,
+ var(--grey) ${(toPosition / rangeDistance) * 100}%,
+ var(--grey) 100%)`
+ }
+
+ const inChange = (ev) => {
+ const name = ev.target.name
+ let value = parseInt(ev.target.value, 10)
+
+ if (name === 'valueMaxCurrent' && value < state.valueMinCurrent) {
+ value = state.valueMinCurrent
+ } else if (name === 'valueMinCurrent' && value > state.valueMaxCurrent) {
+ value = state.valueMaxCurrent
+ }
+
+ const newObj = {
+ ...state,
+ [name]: value
+ }
+
+ setState(newObj)
+ onChange(newObj.valueMinCurrent, newObj.valueMaxCurrent)
+ }
+
+ return (
+
+
+
{state.valueMin}
+
+
+
+
+
{state.valueMax}
+
+
+
+ )
+}
+
+InputRangeDual.propTypes = {
+ valueMax: number,
+ valueMin: number,
+ valueMaxCurrent: number,
+ valueMinCurrent: number,
+ onChange: func
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Plane.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Plane.js
new file mode 100644
index 0000000000..31c9ed2afd
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/components/Plane.js
@@ -0,0 +1,163 @@
+import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { number, object } from 'prop-types'
+import { pointColor } from './../helpers/pointColor.js'
+
+const BACKGROUND_COLOR = '#222'
+
+export const Plane = ({ annotations, dimension, tool, viewer }) => {
+ const [frame, setFrame] = useState(viewer.getPlaneFrame({ dimension }))
+ const canvasRef = useRef(null)
+ const canvasLength = useRef(0)
+ const frameCanvas = document.createElement('canvas')
+ frameCanvas.width = viewer.base
+ frameCanvas.height = viewer.base
+
+ // State Change Management through useEffect()
+ useEffect(() => {
+ setupFrame()
+
+ // State Listeners to bypass React rerenders
+ annotations.on('add:annotation', drawFrame)
+ annotations.on('remove:annotation', drawFrame)
+ annotations.on('update:annotation', drawFrame)
+ viewer.on(`change:dimension-${dimension}:frame`, drawFrame)
+ viewer.on('change:threshold', drawFrame)
+
+ return () => {
+ annotations.off('add:annotation', drawFrame)
+ annotations.off('remove:annotation', drawFrame)
+ annotations.off('update:annotation', drawFrame)
+ viewer.off(`change:dimension-${dimension}:frame`, drawFrame)
+ viewer.off('change:threshold', drawFrame)
+ }
+ }, [])
+
+ // Layout Effects allows us to listen for window resize
+ useLayoutEffect(() => {
+ window.addEventListener('resize', setupFrame)
+ return () => window.removeEventListener('resize', setupFrame)
+ }, [])
+
+ function setupFrame () {
+ // Use parent element to infer frame size
+ const { width } =
+ canvasRef.current.parentElement.getBoundingClientRect()
+
+ canvasLength.current = width
+ const ctx = canvasRef.current.getContext('2d')
+ ctx.canvas.width = canvasLength.current
+ ctx.canvas.height = canvasLength.current
+
+ // (re)draw the current frame
+ drawFrame()
+ }
+
+ // Functions that do the actual work
+ async function drawFrame (e) {
+ // catches events and sets relevant frame if necessary
+ if (e && e.frame !== undefined) {
+ setFrame(e.frame)
+ }
+
+ // draw to offscreen canvas
+ const context = frameCanvas.getContext('2d')
+ const frame = viewer.getPlaneFrame({ dimension })
+ frame.forEach((lines, x) => {
+ lines.forEach((point, y) => {
+ drawPoint({ context, point, x, y })
+ })
+ })
+
+ // transfer to screen
+ const data = await window.createImageBitmap(frameCanvas, {
+ resizeWidth: canvasLength.current,
+ resizeHeight: canvasLength.current,
+ resizeQuality: 'pixelated'
+ })
+ canvasRef.current.getContext('2d').drawImage(data, 0, 0)
+ }
+
+ function drawPoint ({ context, point, x, y }) {
+ // Draw points that are not in threshold same color as background
+ if (viewer.isPointInThreshold({ point })) {
+ context.fillStyle = pointColor({
+ annotationIndex: viewer.getPointAnnotationIndex({ point }),
+ pointValue: viewer.getPointValue({ point })
+ })
+ } else {
+ context.fillStyle = BACKGROUND_COLOR
+ }
+ context.fillRect(x, y, 1, 1)
+ }
+
+ // Interaction Functions
+ function onClick (e) {
+ if (!tool.events.click) return // no tool, no interaction on click
+
+ const { button, clientX, clientY, shiftKey } = e
+ const { left, top } = canvasRef.current.getBoundingClientRect()
+ const pixelLength = canvasLength.current / viewer.base
+
+ const x = Math.floor((clientX - left) / pixelLength)
+ const y = Math.floor((clientY - top) / pixelLength)
+ const frame = viewer.getPlaneFrame({ dimension })
+ const point = frame[x][y]
+
+ if (tool.events.click) {
+ tool.events.click({
+ button,
+ point,
+ shiftKey
+ })
+ }
+
+ e.preventDefault()
+ }
+
+ function onWheel (e) {
+ const frameCurrent = viewer.getPlaneFrame({ dimension })
+ const frameNew =
+ e.deltaY > 0 && frameCurrent > 0
+ ? frameCurrent - 1
+ : e.deltaY < 0 && frameCurrent < viewer.base - 1
+ ? frameCurrent + 1
+ : frameCurrent
+
+ viewer.setPlaneFrameActive({ dimension, frame: frameNew })
+ }
+
+ function inChange (e) {
+ viewer.setPlaneFrameActive({ dimension, frame: e.target.value })
+ }
+
+ return (
+
+
+
+
+ )
+}
+
+Plane.propTypes = {
+ annotations: object,
+ dimension: number,
+ tool: object,
+ viewer: object
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/css/globals.css b/packages/lib-subject-viewers/src/components/VolumetricViewer/css/globals.css
new file mode 100644
index 0000000000..8ed2e01ca9
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/css/globals.css
@@ -0,0 +1,174 @@
+:root {
+ --primary: #008080;
+ --secondary: #f0b200;
+ --tertiary: #d47811;
+ --dark: #005d69;
+ --dark-text: #2d2d2d;
+ --bright: #f71735;
+ --bright-text: #edffec;
+ --grey: #c6c6c6;
+ --primary-accent: #25daa5;
+}
+
+* {
+ box-sizing: border-box;
+ padding: 0;
+ margin: 0;
+}
+
+html,
+body {
+ max-width: 100vw;
+ overflow-x: hidden;
+ background-color: #000;
+ color: #ddd;
+}
+
+/****************************/
+
+.container {
+ display: flex;
+ flex-direction: row;
+}
+
+.sidebar {
+ min-width: 300px;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ margin: 0px 20px;
+}
+
+.viewer {
+ flex: 1;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ height: 800px;
+}
+
+.viewer-histogram {
+ position: absolute;
+ left: 200px;
+ top: 0px;
+}
+
+.viewer-cube {
+ position: relative;
+ flex: 2;
+}
+
+.viewer-planes {
+ flex: 2;
+ margin: 2em 0;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+}
+
+.viewer-planes > div {
+ flex: 1;
+ position: relative;
+}
+
+/* Plane Canvas Styles */
+.plane-canvas-0 {
+ border: 1px solid magenta;
+}
+
+.plane-canvas-1 {
+ border: 1px solid yellow;
+}
+
+.plane-canvas-2 {
+ border: 1px solid cyan;
+}
+
+input[type="range"][orient="vertical"] {
+ writing-mode: vertical-lr;
+ direction: rtl;
+ appearance: slider-vertical;
+ width: 16px;
+ vertical-align: bottom;
+
+ position: absolute;
+ top: 0px;
+ bottom: 0px;
+ right: -30px;
+}
+
+/* InputRangeDual Styles */
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ opacity: 1;
+}
+
+.flex-1 {
+ flex: 1;
+}
+
+.range-input-control .spacer {
+ flex-grow: 1;
+}
+
+.range-flex {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.range-slider-control {
+ min-height: 30px;
+ margin: 10px 0px;
+}
+
+.range-slider-control .range-slider {
+ flex-grow: 1;
+ position: relative;
+}
+
+.range-slider-min-value {
+ padding: 5px 10px 0px 0px;
+}
+.range-slider-max-value {
+ padding: 5px 0px 0px 10px;
+}
+
+.range-slider-dual {
+ -webkit-appearance: none;
+ appearance: none;
+ height: 2px;
+ width: 100%;
+ position: absolute;
+ background-color: var(--grey);
+ pointer-events: none;
+}
+
+.range-slider-dual::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ pointer-events: all;
+ width: 24px;
+ height: 24px;
+ background-color: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px var(--grey);
+ cursor: pointer;
+}
+
+.range-slider-dual::-moz-range-thumb {
+ -webkit-appearance: none;
+ pointer-events: all;
+ width: 24px;
+ height: 24px;
+ background-color: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px var(--grey);
+ cursor: pointer;
+}
+
+.range-slider-dual.range-slider-lower-value {
+ height: 0;
+ z-index: 1;
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/data/4x4x4.json b/packages/lib-subject-viewers/src/components/VolumetricViewer/data/4x4x4.json
new file mode 100644
index 0000000000..dd5caa46d3
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/data/4x4x4.json
@@ -0,0 +1 @@
+"GRnI+hnIr5bIr5Z9r5Z9+uHIr5bIr5Z9r5Z9ZJZ9ZEv6r5Z9r5Z9ZJZ9ZEt9ZEsyGZZ9GRl9ZEt9ZEsyGUsyGQ=="
\ No newline at end of file
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/helpers/AlgorithmAStar.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/helpers/AlgorithmAStar.js
new file mode 100644
index 0000000000..5876062a7f
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/helpers/AlgorithmAStar.js
@@ -0,0 +1,58 @@
+import { SortedSet } from './SortedSet.js'
+
+export const AlgorithmAStar = ({
+ annotation,
+ point: pointOriginal,
+ viewer
+}) => {
+ const pointValueStart = viewer.getPointValue({ point: pointOriginal })
+ const traversedPoints = []
+ const connectedPoints = SortedSet({ data: [pointOriginal] })
+ const pointsToCheck = [pointOriginal]
+
+ function checkConnectedPoints () {
+ if (pointsToCheck.length === 0) return
+
+ const point = pointsToCheck.shift()
+ const pointValue = viewer.getPointValue({ point })
+ const isPointValid =
+ pointValue >= pointValueStart - annotation.threshold &&
+ pointValue <= pointValueStart + annotation.threshold
+
+ // if the point is not valid, we don't want to do anything else with it
+ if (!isPointValid) return
+
+ // point is a connected point
+ connectedPoints.add({ value: point })
+
+ // check all points around it
+ const [x, y, z] = viewer.getPointCoordinates({ point })
+ const pointsAdjascent = [
+ [x - 1, y, z],
+ [x + 1, y, z],
+ [x, y - 1, z],
+ [x, y + 1, z],
+ [x, y, z - 1],
+ [x, y, z + 1]
+ ]
+
+ for (let i = 0; i < pointsAdjascent.length; i++) {
+ const pointPotential = viewer.getPointFromStructured({
+ point: pointsAdjascent[i]
+ })
+
+ if (pointPotential === undefined) continue // ignore points that don't exist
+ if (traversedPoints[pointPotential]) continue // ignore points already checked
+ if (viewer.getPointAnnotationIndex({ point: pointPotential }) !== -1) { continue } // ignore points already annotated
+
+ traversedPoints[pointPotential] = true
+ pointsToCheck.push(pointPotential)
+ }
+ }
+
+ while (pointsToCheck.length > 0) {
+ checkConnectedPoints()
+ }
+
+ return connectedPoints
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/helpers/SortedSet.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/helpers/SortedSet.js
new file mode 100644
index 0000000000..6dffe987c4
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/helpers/SortedSet.js
@@ -0,0 +1,152 @@
+export const BinarySearch = ({ data, left, right, value }) => {
+ left = left ?? 0
+ right = right ?? data.length - 1
+
+ while (left <= right) {
+ const mid = Math.floor((left + right) / 2)
+
+ if (data[mid] === value) {
+ return { index: mid, left, right }
+ } else if (data[mid] < value) {
+ left = mid + 1
+ } else {
+ right = mid - 1
+ }
+ }
+
+ return { index: -1, left, right }
+}
+
+export const SortedSetIntersection = ({ sets }) => {
+ const [firstSet, ...restSets] = sets
+ .map((s) => s.data)
+ .sort((a, b) => a.length - b.length)
+ const results = []
+ let indexCurrent = 0
+ const indexMax = firstSet.length
+
+ while (indexCurrent < indexMax) {
+ // search first item, search n item
+ // if found, find in next
+ // if not found, finish searching and don't add
+ const currentValue = firstSet[indexCurrent]
+ let isIntersecting = true
+
+ // loop through the rest sets for intersection
+ // sets are sorted by length so we're searching the shortest sets first
+ for (let n = 0; n < restSets.length; n++) {
+ const comparisonSet = restSets[n]
+
+ const searchResults = BinarySearch({
+ data: comparisonSet,
+ left: 0,
+ right: comparisonSet.length - 1,
+ value: currentValue
+ })
+
+ // not intersecting with current set, so break
+ if (searchResults.index === -1) {
+ isIntersecting = false
+ break
+ }
+ }
+
+ if (isIntersecting) {
+ results.push(currentValue)
+ }
+ indexCurrent++
+ } // endwhile
+
+ return SortedSet({ data: results })
+}
+
+export const SortedSetUnion = ({ sets }) => {
+ const sortedSets = sets
+ .map((s) => s.data)
+ .sort((a, b) => a.length - b.length)
+
+ const results = []
+ const indexCurrent = []
+ const indexMax = []
+
+ // set the indexCurrent to 0 for all sets and
+ // set the indexMax to the length of each set
+ sortedSets.forEach((set, i) => {
+ indexCurrent[i] = 0
+ indexMax[i] = set.length - 1
+ })
+
+ // we need all index values to be greater than their length to terminate the while
+ function isInRange (indexCurrent, indexMax) {
+ let inRange = false
+ indexCurrent.forEach((val, i) => {
+ if (val <= indexMax[i]) {
+ inRange = true
+ }
+ })
+ return inRange
+ }
+
+ // iteratively removes the smallest value from all sets
+ // then, it increments the lowest index values in that set
+ let matches = []
+ while (isInRange(indexCurrent, indexMax)) {
+ matches.length = 0
+ let smallest = 16777217 // 256^3+1 (arbitrarily largest number)
+ indexCurrent.forEach((val, i) => {
+ const value = sortedSets[i][val]
+
+ if (value < smallest) {
+ smallest = value
+ matches = [i]
+ } else if (value === smallest) {
+ matches.push(i)
+ }
+ })
+
+ matches.forEach((index) => {
+ indexCurrent[index]++
+ })
+
+ results.push(smallest)
+ }
+
+ return SortedSet({ data: results })
+}
+
+export const SortedSet = ({ data } = { data: [] }) => {
+ if (data === null) data = []
+
+ const sortedSet = {
+ data,
+
+ // methods
+ add: ({ value }) => {
+ const { index, left } = BinarySearch({ data, value })
+ if (index !== -1) return { index, data }
+ data.splice(left, 0, value)
+ return { index: left, data }
+ },
+ has: ({ value }) => {
+ const resp = {
+ index: BinarySearch({ data, value }).index,
+ data
+ }
+
+ return resp.index !== -1
+ },
+ intersection: ({ sets }) => {
+ return SortedSetIntersection({ sets: [sortedSet, ...sets] })
+ },
+ remove: ({ value }) => {
+ const { index } = BinarySearch({ data, value })
+ if (index !== -1) data.splice(index, 1)
+ return { index, data }
+ },
+ union: ({ sets }) => {
+ return SortedSetUnion({ sets: [sortedSet, ...sets] })
+ }
+ }
+
+ return sortedSet
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/helpers/pointColor.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/helpers/pointColor.js
new file mode 100644
index 0000000000..4fb92ad193
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/helpers/pointColor.js
@@ -0,0 +1,42 @@
+// Generate a cache of all the colors used during render
+// This improves draw performance by avoiding re-calculating the same color every render
+
+import { Color } from 'three'
+
+const ColorHues = [
+ 205, 90, 60, 30, 0, 330, 300, 270, 240, 210, 180, 150, 120
+]
+const CanvasColors = []
+const ThreeColors = []
+
+const pixelToPercent = (value) => {
+ // 255 => 100%
+ // 0 => 0%
+ // 127.5 => 50%
+ return `${Math.round((value / 255) * 100)}%` // normalize is some way
+}
+
+export const pointColor = ({
+ annotationIndex = -1,
+ isThree = false,
+ pointValue
+}) => {
+ const ref = isThree ? ThreeColors : CanvasColors
+ return ref[annotationIndex + 1][pointValue]
+}
+
+// Generate the cached colors
+for (let i = 0; i < ColorHues.length; i++) {
+ const hue = ColorHues[i]
+
+ if (!CanvasColors[i]) CanvasColors[i] = []
+ if (!ThreeColors[i]) ThreeColors[i] = []
+
+ for (let ii = 0; ii < 256; ii++) {
+ const pointNormed = Math.floor(ii / 2) + 64
+ const hslColor = `hsl(${hue}, ${i === 0 ? 0 : 75}%, ${pixelToPercent(pointNormed)})`
+
+ CanvasColors[i][ii] = hslColor
+ ThreeColors[i][ii] = new Color(hslColor)
+ }
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/models/ModelAnnotations.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/models/ModelAnnotations.js
new file mode 100644
index 0000000000..6737b0ea01
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/models/ModelAnnotations.js
@@ -0,0 +1,244 @@
+import { SortedSet, SortedSetUnion } from './../helpers/SortedSet.js'
+
+const THRESHOLD_DEFAULT = 30
+let ANNOTATION_COUNT = 0
+
+// Creates the base object for an Annotation
+export const AnnotationBase = ({ point }) => {
+ return {
+ label: `Annotation ${++ANNOTATION_COUNT}`,
+ threshold: THRESHOLD_DEFAULT,
+ points: {
+ active: [point], // each individual point
+ connected: [], // SortedSet of points from each active point
+ all: SortedSet({ data: [] }) // SortedSet of all connected points
+ }
+ }
+}
+
+// Manages History at a Global level
+const History = {
+ state: [],
+ stateRedo: [], // only used for redo operations
+ add: (action) => {
+ History.state.push(action)
+ },
+ undo: ({ historyItem }) => {
+ // TRAVDO: Still to implement
+ console.log('History.undo() historyItem', historyItem)
+
+ if (historyItem.action === 'annotation.add') {
+ // console.log('TODO')
+ } else if (historyItem.action === 'annotation.remove') {
+ // console.log('TODO')
+ } else if (historyItem.action === 'point.add') {
+ // console.log('TODO')
+ }
+ },
+ redo: ({ historyItem }) => {
+ // TRAVDO: Still to implement
+ console.log('History.redo() historyItem', historyItem)
+
+ if (historyItem.action === 'annotation.add') {
+ // console.log('TODO')
+ } else if (historyItem.action === 'annotation.remove') {
+ // console.log('TODO')
+ } else if (historyItem.action === 'point.add') {
+ // console.log('TODO')
+ }
+ }
+}
+
+export const ModelAnnotations = () => {
+ const annotationModel = {
+ annotations: [],
+ config: {
+ activeAnnotation: null, // index of annotation that is currently active
+ algorithm: null,
+ viewer: false
+ },
+ initialize: (config) => {
+ annotationModel.config = {
+ ...annotationModel.config,
+ ...config
+ }
+ },
+ actions: {
+ annotation: {
+ add: ({ point }) => {
+ const annotationIndex = annotationModel.annotations.length
+ const annotation = AnnotationBase({
+ point
+ })
+
+ // if algorithm, get connected points
+ if (annotationModel.config.algorithm) {
+ annotation.points.all = annotationModel.config.algorithm({
+ annotation,
+ point,
+ viewer: annotationModel.config.viewer
+ })
+
+ annotation.points.connected[0] = annotation.points.all.data
+ } else {
+ annotation.points.connected[0] = []
+ }
+
+ // Update the Annotation Data
+ annotationModel.annotations.push(annotation)
+ annotationModel.config.activeAnnotation = annotationIndex
+
+ // Update the Viewer Annotation Data
+ annotationModel.config.viewer.setPointsAnnotationIndex({
+ points: annotation.points.connected[0],
+ index: annotationIndex
+ })
+
+ // Create the history object
+ History.add({
+ action: 'annotation.add',
+ data: annotation
+ })
+
+ // Publish the change
+ annotationModel.publish('add:annotation', {
+ annotation,
+ annotationIndex,
+ annotations: annotationModel.annotations
+ })
+ },
+ active: ({ index }) => {
+ // Update the Annotation Data
+ annotationModel.config.activeAnnotation = index
+
+ // Publish the change
+ annotationModel.publish('active:annotation', {
+ annotationIndex: index,
+ annotations: annotationModel.annotations
+ })
+ },
+ remove: ({ index }) => {
+ const annotation = annotationModel.annotations[index]
+ annotation.annotationIndex = index
+
+ // Update the Annotation Data
+ if (annotationModel.config.activeAnnotation === index) {
+ annotationModel.config.activeAnnotation = null
+ } else if (annotationModel.config.activeAnnotation > index) {
+ // we're removing an annotation that's earlier in the array
+ annotationModel.config.activeAnnotation =
+ annotationModel.config.activeAnnotation - 1
+ }
+
+ // Update the Viewer Annotation Data
+ annotationModel.config.viewer.setPointsAnnotationIndex({
+ points: annotation.points.all.data,
+ index: -1
+ })
+
+ // Create the history object
+ History.add({
+ action: 'annotation.remove',
+ data: annotation
+ })
+
+ // Add to the AnnotationsModel
+ annotationModel.annotations.splice(index, 1)
+
+ // Publish the change
+ annotationModel.publish('remove:annotation', {
+ annotation,
+ annotationIndex: index,
+ annotations: annotationModel.annotations
+ })
+ }
+ },
+ point: {
+ add: ({ annotationIndex = null, point }) => {
+ // check if we have an active annotation
+ if (
+ annotationIndex === null &&
+ annotationModel.config.activeAnnotation === null
+ ) {
+ annotationModel.actions.annotation.add({ point })
+ } else {
+ const _index = annotationIndex || annotationModel.config.activeAnnotation
+ const annotation = annotationModel.annotations[_index]
+ const pointIndex = annotation.points.active.length
+
+ // if algorithm, get connected points
+ const connectedPoints = annotationModel.config.algorithm
+ ? annotationModel.config.algorithm({
+ annotation,
+ point,
+ viewer: annotationModel.config.viewer
+ })
+ : SortedSet({ data: [] })
+
+ // Update the Annotation Data
+ annotation.points.active[pointIndex] = point
+ annotation.points.connected[pointIndex] = connectedPoints.data
+ annotation.points.all = SortedSetUnion({
+ sets: [annotation.points.all, connectedPoints]
+ })
+
+ // Update the Viewer Annotation Data
+ annotationModel.config.viewer.setPointsAnnotationIndex({
+ points: annotation.points.connected[pointIndex],
+ index: _index
+ })
+
+ // Create the history object
+ History.add({
+ action: 'point.add',
+ data: {
+ annotationIndex: _index,
+ points: {
+ active: point,
+ connected: connectedPoints.data
+ }
+ }
+ })
+
+ // Publish the change
+ annotationModel.publish('update:annotation', {
+ annotation,
+ annotationIndex: _index,
+ annotations: annotationModel.annotations
+ })
+ }
+ }
+ }
+ },
+ export: () => {
+ return annotationModel.annotations.map(annotation => {
+ return {
+ label: annotation.label,
+ points: {
+ active: [...annotation.points.active],
+ connected: [...annotation.points.connected]
+ },
+ threshold: annotation.threshold
+ }
+ })
+ },
+ // Listeners
+ _listeners: [],
+ publish: (eventName, data) => {
+ annotationModel._listeners.forEach((listener) => {
+ if (listener.eventName === eventName) listener.cb(data)
+ })
+ },
+ on: (eventName, cb) => {
+ annotationModel._listeners.push({ eventName, cb })
+ },
+ off: (eventName, cb) => {
+ const index = annotationModel._listeners.findIndex(
+ (listener) => listener.eventName === eventName && listener.cb === cb
+ )
+ if (index > -1) annotationModel._listeners.splice(index, 1)
+ }
+ }
+
+ return annotationModel
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/models/ModelTool.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/models/ModelTool.js
new file mode 100644
index 0000000000..429b435417
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/models/ModelTool.js
@@ -0,0 +1,27 @@
+export const ModelTool = () => {
+ const toolModel = {
+ annotations: null, // annotationsModel
+ initialize: ({ annotations }) => {
+ toolModel.annotations = annotations
+ },
+ events: {
+ click: ({
+ button = 0, // 0 = left, 1 = right, 2 = middle
+ point, // absolute point from ModelViewer
+ shiftKey = false // true/false
+ }) => {
+ // basically translates the interaction event to annotation data
+
+ if (button === 0 && shiftKey) {
+ // force creating a new annotation
+ toolModel.annotations.actions.annotation.add({ point })
+ } else if (button === 0) {
+ // add point or create new annotation with point depending on state
+ toolModel.annotations.actions.point.add({ point })
+ }
+ }
+ }
+ }
+
+ return toolModel
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/models/ModelViewer.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/models/ModelViewer.js
new file mode 100644
index 0000000000..9dea4de750
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/models/ModelViewer.js
@@ -0,0 +1,156 @@
+import { SortedSet } from './../helpers/SortedSet.js'
+
+export const ModelViewer = () => {
+ // Assumes 3D right now
+
+ const pointModel = {
+ // data
+ base: 0,
+ baseFrames: [[], [], []],
+ baseFrameMod: [0, 0, 0],
+ data: [],
+ dimensions: ['x', 'y', 'z'],
+ planesAbsoluteSets: [[], [], []],
+ planeFrameActive: [0, 0, 0],
+ points: [],
+ threshold: { min: 0, max: 255 },
+ // initialize
+ initialize: ({ data }) => {
+ pointModel.data = data
+ pointModel.base = Math.cbrt(data.length)
+ pointModel.baseFrameMod = [
+ Math.pow(pointModel.base, 2),
+ pointModel.base,
+ 1
+ ]
+ pointModel.planeFrameActive = [
+ pointModel.base - 1,
+ pointModel.base - 1,
+ pointModel.base - 1
+ ]
+
+ let i = 0
+ for (let x = 0; x < pointModel.base; x++) {
+ for (let y = 0; y < pointModel.base; y++) {
+ for (let z = 0; z < pointModel.base; z++) {
+ pointModel.points[i] = [
+ pointModel.data[i], // getPointValue
+ x,
+ y,
+ z,
+ true, // isPointInThreshold
+ -1 // getPointAnnotationIndex()
+ ]
+
+ // x = zy plane
+ if (x === 0) {
+ if (!pointModel.baseFrames[0][z]) { pointModel.baseFrames[0][z] = [] }
+ pointModel.baseFrames[0][z][y] = i
+ }
+
+ // y = xz plane
+ if (y === 0) {
+ const yz = pointModel.base - 1 - z
+ const yx = pointModel.base - 1 - x
+ if (!pointModel.baseFrames[1][yx]) { pointModel.baseFrames[1][yx] = [] }
+ pointModel.baseFrames[1][yx][yz] = i
+ }
+
+ // z = xy plane
+ if (z === 0) {
+ const zx = pointModel.base - 1 - x
+ if (!pointModel.baseFrames[2][zx]) { pointModel.baseFrames[2][zx] = [] }
+ pointModel.baseFrames[2][zx][y] = i
+ }
+
+ // planesAbsoluteSets
+ if (!pointModel.planesAbsoluteSets[0][x]) { pointModel.planesAbsoluteSets[0][x] = SortedSet({ data: [] }) }
+ if (!pointModel.planesAbsoluteSets[1][y]) { pointModel.planesAbsoluteSets[1][y] = SortedSet({ data: [] }) }
+ if (!pointModel.planesAbsoluteSets[2][z]) { pointModel.planesAbsoluteSets[2][z] = SortedSet({ data: [] }) }
+
+ pointModel.planesAbsoluteSets[0][x].add({ value: i })
+ pointModel.planesAbsoluteSets[1][y].add({ value: i })
+ pointModel.planesAbsoluteSets[2][z].add({ value: i })
+ i++
+ }
+ }
+ }
+
+ return pointModel
+ },
+ // getters & setters
+ getPlaneFrame: ({ dimension = 0, frame }) => {
+ frame = frame ?? pointModel.planeFrameActive[dimension]
+ // get the base frame, then mod each point to get the absolute plane view
+ const baseFrame = pointModel.baseFrames[dimension]
+ if (frame === 0) return baseFrame
+
+ const offset = pointModel.baseFrameMod[dimension] * frame
+ return baseFrame.map((r) => r.map((p) => p + offset))
+ },
+ getPlaneSet: ({ dimension = 0, frame = 0 }) => {
+ return pointModel.planesAbsoluteSets[dimension][frame]
+ },
+ getPointAnnotationIndex: ({ point }) => {
+ return pointModel.points[point][5]
+ },
+ getPointCoordinates: ({ point }) => {
+ return pointModel.points[point].slice(1, 4)
+ },
+ getPointFromStructured: ({ point }) => {
+ if (point.indexOf(-1) > -1) return undefined
+ if (point.indexOf(pointModel.base) > -1) return undefined
+
+ return point
+ .map((factor, dim) => pointModel.baseFrameMod[dim] * factor)
+ .reduce((acc, val) => acc + val, 0)
+ },
+ getPointValue: ({ point }) => {
+ return pointModel.points[point][0]
+ },
+ isPointInThreshold: ({ point }) => {
+ return pointModel.points[point][4]
+ },
+ saveScreenshot: () => {
+ pointModel.publish('save:screenshot')
+ },
+ setPlaneFrameActive: ({ dimension, frame }) => {
+ pointModel.planeFrameActive[dimension] = frame
+ pointModel.publish(`change:dimension-${dimension}:frame`, { frame })
+ pointModel.publish('change:dimension:frame', { dimension, frame })
+ },
+ setPointsAnnotationIndex: ({ points, index }) => {
+ // should be an array, even if its one point
+ points.forEach((point) => {
+ pointModel.points[point][5] = index
+ })
+ },
+ setThreshold: ({ min, max }) => {
+ pointModel.threshold.min = min
+ pointModel.threshold.max = max
+ pointModel.points.forEach((point, i) => {
+ point[4] = min <= point[0] && max >= point[0]
+ })
+ pointModel.publish('change:threshold', { min, max })
+ },
+
+ // Listeners
+ _listeners: [],
+ publish: (eventName, data) => {
+ pointModel._listeners.forEach((listener) => {
+ if (listener.eventName === eventName) listener.cb(data)
+ })
+ },
+ on: (eventName, cb) => {
+ pointModel._listeners.push({ eventName, cb })
+ },
+ off: (eventName, cb) => {
+ const index = pointModel._listeners.findIndex(
+ (listener) => listener.eventName === eventName && listener.cb === cb
+ )
+ if (index > -1) pointModel._listeners.splice(index, 1)
+ }
+ }
+
+ return pointModel
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/AlgorithmAStar.spec.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/AlgorithmAStar.spec.js
new file mode 100644
index 0000000000..48ad7b399d
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/AlgorithmAStar.spec.js
@@ -0,0 +1,21 @@
+import { AlgorithmAStar } from './../helpers/AlgorithmAStar'
+import { ModelViewer } from './../models/ModelViewer'
+import { AnnotationBase } from './../models/ModelAnnotations'
+import subjectData from './../data/4x4x4.json'
+
+describe('Component > VolumetricViewer > AlgorithmAStar', () => {
+ const data = Buffer.from(subjectData, 'base64')
+ const viewer = ModelViewer().initialize({ data })
+ const point = 1
+ const annotation = AnnotationBase({ point })
+
+ it('should generate the same connected points', () => {
+ const resultsP0 = AlgorithmAStar({ annotation, point: 0, viewer })
+ const resultsP1 = AlgorithmAStar({ annotation, point: 1, viewer })
+ const resultsP4 = AlgorithmAStar({ annotation, point: 4, viewer })
+
+ expect(resultsP0.data).deep.to.equal([0, 1, 4])
+ expect(resultsP1.data).deep.to.equal([0, 1, 4])
+ expect(resultsP4.data).deep.to.equal([0, 1, 4])
+ })
+})
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/ModelAnnotations.spec.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/ModelAnnotations.spec.js
new file mode 100644
index 0000000000..6fa8f8100a
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/ModelAnnotations.spec.js
@@ -0,0 +1,204 @@
+import { ModelAnnotations } from './../models/ModelAnnotations'
+
+describe('Component > VolumetricViewer > ModelAnnotations', () => {
+ const model = ModelAnnotations()
+ const viewerMock = {
+ setPointsAnnotationIndex: () => {}
+ }
+
+ it('should have initial state', () => {
+ expect(model).to.exist()
+ expect(model.annotations).to.exist()
+ expect(model.annotations.length).to.equal(0)
+ expect(model.config).deep.to.equal({
+ activeAnnotation: null,
+ algorithm: null,
+ viewer: false
+ })
+ expect(model._listeners.length).to.equal(0)
+ })
+
+ it('should initialize()', () => {
+ const configData = {
+ viewer: viewerMock
+ }
+
+ model.initialize(configData)
+
+ expect(model.config).deep.to.equal({
+ activeAnnotation: null,
+ algorithm: null,
+ viewer: viewerMock
+ })
+ })
+
+ it('should create a new annotation', (done) => {
+ const activePoint = 1
+
+ const addAnnotationListener = (obj) => {
+ model.off('add:annotation', addAnnotationListener)
+
+ expect(obj.annotationIndex).to.equal(0)
+
+ expect(obj.annotations.length).to.equal(1)
+ expect(obj.annotations[0]).to.equal(obj.annotation)
+
+ expect(obj.annotation.label).to.equal('Annotation 2')
+ expect(obj.annotation.threshold).to.equal(30)
+ expect(obj.annotation.points.active).deep.to.equal([activePoint])
+ expect(obj.annotation.points.connected).deep.to.equal([[]])
+ expect(obj.annotation.points.all.data).deep.to.equal([])
+
+ expect(model.config.activeAnnotation).to.equal(0)
+ expect(model.annotations.length).to.equal(1)
+ expect(model.annotations[0]).to.equal(obj.annotation)
+
+ done()
+ }
+
+ model.on('add:annotation', addAnnotationListener)
+ model.actions.annotation.add({ point: activePoint })
+ })
+
+ it('should create a second annotation', (done) => {
+ const pointToAdd = 2
+
+ const addAnnotationListener = (obj) => {
+ model.off('add:annotation', addAnnotationListener)
+
+ expect(obj.annotationIndex).to.equal(1)
+
+ expect(obj.annotations.length).to.equal(pointToAdd)
+ expect(obj.annotations[0]).not.to.equal(obj.annotation)
+ expect(obj.annotations[1]).to.equal(obj.annotation)
+
+ expect(obj.annotation.label).to.equal('Annotation 3')
+ expect(obj.annotation.threshold).to.equal(30)
+ expect(obj.annotation.points.active).deep.to.equal([pointToAdd])
+ expect(obj.annotation.points.connected).deep.to.equal([[]])
+ expect(obj.annotation.points.all.data).deep.to.equal([])
+
+ expect(model.config.activeAnnotation).to.equal(1)
+ expect(model.annotations.length).to.equal(2)
+ expect(model.annotations[0]).not.to.equal(obj.annotation)
+ expect(model.annotations[1]).to.equal(obj.annotation)
+
+ done()
+ }
+
+ model.on('add:annotation', addAnnotationListener)
+ model.actions.annotation.add({ point: pointToAdd })
+ })
+
+ it('should make the first annotation active', (done) => {
+ const activeIndex = 0
+
+ const activeAnnotationListener = (obj) => {
+ model.off('active:annotation', activeAnnotationListener)
+
+ expect(obj.annotationIndex).to.equal(activeIndex)
+ expect(model.config.activeAnnotation).to.equal(obj.annotationIndex)
+ done()
+ }
+
+ expect(model.config.activeAnnotation).not.to.equal(activeIndex)
+ model.on('active:annotation', activeAnnotationListener)
+ model.actions.annotation.active({ index: activeIndex })
+ })
+
+ it('should remove the first annotation', (done) => {
+ const activeIndex = 0
+
+ const removeAnnotationListener = (obj) => {
+ model.off('remove:annotation', removeAnnotationListener)
+
+ expect(obj.annotationIndex).to.equal(activeIndex)
+ expect(obj.annotation.label).to.equal('Annotation 2')
+ expect(obj.annotations.length).to.equal(1)
+ expect(obj.annotations[0]).not.to.equal(obj.annotation)
+
+ expect(model.config.activeAnnotation).to.equal(null)
+ expect(model.annotations.length).to.equal(1)
+ expect(model.annotations[0]).not.to.equal(obj.annotation)
+ done()
+ }
+
+ model.on('remove:annotation', removeAnnotationListener)
+ model.actions.annotation.remove({ index: activeIndex })
+ })
+
+ it('should add a point to no active annotation, creating a new annotation', (done) => {
+ const activePoint = 3
+
+ const addAnnotationListener = (obj) => {
+ model.off('add:annotation', addAnnotationListener)
+
+ expect(obj.annotationIndex).to.equal(1)
+
+ expect(obj.annotations.length).to.equal(2)
+ expect(obj.annotations[1]).to.equal(obj.annotation)
+
+ expect(obj.annotation.label).to.equal('Annotation 4')
+ expect(obj.annotation.threshold).to.equal(30)
+ expect(obj.annotation.points.active).deep.to.equal([activePoint])
+ expect(obj.annotation.points.connected).deep.to.equal([[]])
+ expect(obj.annotation.points.all.data).deep.to.equal([])
+
+ expect(model.config.activeAnnotation).to.equal(1)
+ expect(model.annotations.length).to.equal(2)
+ expect(model.annotations[1]).to.equal(obj.annotation)
+
+ done()
+ }
+
+ model.on('add:annotation', addAnnotationListener)
+ model.actions.point.add({ point: activePoint })
+ })
+
+ it('should add a point to an active annotation', (done) => {
+ const activePoint = 4
+ const activeIndex = model.config.activeAnnotation
+
+ const updateAnnotationListener = (obj) => {
+ model.off('update:annotation', updateAnnotationListener)
+
+ expect(obj.annotationIndex).to.equal(activeIndex)
+ expect(obj.annotations.length).to.equal(2)
+
+ expect(obj.annotation.label).to.equal('Annotation 4')
+ expect(obj.annotation.points.active).deep.to.equal([3, 4])
+ expect(obj.annotation.points.connected).deep.to.equal([[], []])
+ expect(obj.annotation.points.all.data).deep.to.equal([])
+
+ expect(model.config.activeAnnotation).to.equal(activeIndex)
+ expect(model.annotations.length).to.equal(2)
+ done()
+ }
+
+ model.on('update:annotation', updateAnnotationListener)
+ model.actions.point.add({ point: activePoint })
+ })
+
+ it('should export the current annotation data', () => {
+ const data = model.export()
+
+ expect(data).deep.to.equal([
+ {
+ label: 'Annotation 3',
+ points: {
+ active: [2],
+ connected: [[]]
+ },
+ threshold: 30
+ },
+ {
+ label: 'Annotation 4',
+ points: {
+ active: [3, 4],
+ connected: [[], []]
+ },
+ threshold: 30
+ }
+ ])
+ })
+})
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/ModelTool.spec.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/ModelTool.spec.js
new file mode 100644
index 0000000000..2814c3b054
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/ModelTool.spec.js
@@ -0,0 +1,70 @@
+import { ModelTool } from './../models/ModelTool'
+
+const ANNOTATIONS_MOCK = {
+ actions: {
+ annotation: {
+ add: () => {}
+ },
+ point: {
+ add: () => {}
+ }
+ }
+}
+
+describe('Component > VolumetricViewer > ModelTool', () => {
+ const model = ModelTool()
+
+ it('should have initial state', () => {
+ expect(model).to.exist()
+ expect(model.annotations).to.equal(null)
+ expect(model.initialize).to.exist()
+ expect(model.events).to.exist()
+ })
+
+ it('should initialize()', () => {
+ model.initialize({
+ annotations: ANNOTATIONS_MOCK
+ })
+ expect(model.annotations).deep.to.equal(ANNOTATIONS_MOCK)
+ })
+
+ it('should call to create a new annotation', (done) => {
+ const ev = {
+ button: 0,
+ point: 1,
+ shiftKey: true
+ }
+
+ const originalFunc = ANNOTATIONS_MOCK.actions.annotation.add
+ const spyFunc = (obj) => {
+ // Return to original func
+ ANNOTATIONS_MOCK.actions.annotation.add = originalFunc
+
+ expect(obj).deep.to.equal({ point: 1 })
+ done()
+ }
+
+ ANNOTATIONS_MOCK.actions.annotation.add = spyFunc
+ model.events.click(ev)
+ })
+
+ it('should call to add a point to an annotation', (done) => {
+ const ev = {
+ button: 0,
+ point: 1,
+ shiftKey: false
+ }
+
+ const originalFunc = ANNOTATIONS_MOCK.actions.point.add
+ const spyFunc = (obj) => {
+ // Return to original func
+ ANNOTATIONS_MOCK.actions.point.add = originalFunc
+
+ expect(obj).deep.to.equal({ point: 1 })
+ done()
+ }
+
+ ANNOTATIONS_MOCK.actions.point.add = spyFunc
+ model.events.click(ev)
+ })
+})
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/ModelViewer.spec.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/ModelViewer.spec.js
new file mode 100644
index 0000000000..dc3ce92327
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/ModelViewer.spec.js
@@ -0,0 +1,179 @@
+import { ModelViewer } from './../models/ModelViewer'
+import subjectData from './../data/4x4x4.json'
+
+describe('Component > VolumetricViewer > ModelViewer', () => {
+ const model = ModelViewer()
+ const numDimensions = 3
+ const numPoints = Math.pow(4, numDimensions) // subjectData is 4^3
+ const base64Data = Buffer.from(subjectData, 'base64')
+
+ it('should have initial state', () => {
+ expect(model).to.exist()
+ expect(model.base).to.equal(0)
+ expect(model.baseFrames).deep.to.equal([[], [], []])
+ expect(model.baseFrameMod).deep.to.equal([0, 0, 0])
+ expect(model.data).deep.to.equal([])
+ expect(model.dimensions).deep.to.equal(['x', 'y', 'z'])
+ expect(model.planesAbsoluteSets).deep.to.equal([[], [], []])
+ expect(model.planeFrameActive).deep.to.equal([0, 0, 0])
+ expect(model.points).deep.to.equal([])
+ expect(model.threshold).deep.to.equal({ min: 0, max: 255 })
+ expect(model._listeners.length).to.equal(0)
+ })
+
+ it('should initialize()', () => {
+ model.initialize({ data: base64Data })
+
+ expect(model.base).to.equal(4)
+
+ // should have 3 dimensions
+ expect(model.baseFrames.length).to.equal(numDimensions)
+
+ // each dimension has 4 frames
+ expect(model.baseFrames[0].length).to.equal(model.base)
+ expect(model.baseFrames[1].length).to.equal(model.base)
+ expect(model.baseFrames[2].length).to.equal(model.base)
+
+ // test that all the values are structured as expected
+ for (let i = 0; i < numDimensions; i++) {
+ expect(model.baseFrames[i].length).to.equal(model.base)
+ expect(model.planesAbsoluteSets[i].length).to.equal(model.base)
+
+ for (let ii = 0; ii < model.base; ii++) {
+ expect(model.baseFrames[i][ii].length).to.equal(model.base)
+ expect(model.planesAbsoluteSets[i][ii].data.length).to.equal(model.base * model.base)
+
+ for (let iii = 0; iii < model.base; iii++) {
+ expect(model.baseFrames[i][ii][iii]).to.be.a('number')
+ }
+ }
+ }
+
+ expect(model.baseFrameMod).deep.to.equal([16, 4, 1])
+ expect(model.data).not.to.be.empty()
+ expect(model.dimensions).deep.to.equal(['x', 'y', 'z'])
+ expect(model.planeFrameActive).deep.to.equal([3, 3, 3])
+ expect(model.points.length).to.equal(numPoints)
+
+ // check that all points are structured properly
+ for (let i = 0; i < numPoints; i++) {
+ const p = model.points[i]
+ const [value, x, y, z, isPointInThreshold, pointAnnotationIndex] = p
+
+ expect(p.length).to.equal(6)
+ expect(value >= 0 && value <= 255).to.be.true()
+ expect(x >= 0 && x <= (model.base - 1)).to.be.true()
+ expect(y >= 0 && y <= (model.base - 1)).to.be.true()
+ expect(z >= 0 && z <= (model.base - 1)).to.be.true()
+ expect(isPointInThreshold).to.be.true()
+ expect(pointAnnotationIndex).to.equal(-1)
+ }
+
+ expect(model.threshold).deep.to.equal({ min: 0, max: 255 })
+ expect(model._listeners.length).to.equal(0)
+ })
+
+ it('should get & set the point annotation index', () => {
+ const annotationIndex = 2
+
+ // ensure initial state
+ expect(model.getPointAnnotationIndex({ point: 5 })).to.equal(-1)
+ expect(model.getPointAnnotationIndex({ point: 6 })).to.equal(-1)
+
+ // update the index
+ model.setPointsAnnotationIndex({ points: [5, 6], index: annotationIndex })
+ expect(model.getPointAnnotationIndex({ point: 5 })).to.equal(annotationIndex)
+ expect(model.getPointAnnotationIndex({ point: 6 })).to.equal(annotationIndex)
+
+ // reset first point
+ model.setPointsAnnotationIndex({ points: [5], index: -1 })
+ expect(model.getPointAnnotationIndex({ point: 5 })).to.equal(-1)
+ expect(model.getPointAnnotationIndex({ point: 6 })).to.equal(annotationIndex)
+
+ // reset second point
+ model.setPointsAnnotationIndex({ points: [6], index: -1 })
+ expect(model.getPointAnnotationIndex({ point: 6 })).to.equal(-1)
+ })
+
+ it('should get a point\'s coordinates', () => {
+ expect(model.getPointCoordinates({ point: 0 })).deep.to.equal([0, 0, 0])
+ expect(model.getPointCoordinates({ point: 5 })).deep.to.equal([0, 1, 1])
+ expect(model.getPointCoordinates({ point: 63 })).deep.to.equal([3, 3, 3])
+ })
+
+ it('should get a point\'s absolute value from its structured value', () => {
+ expect(model.getPointFromStructured({ point: [0, 0, 0] })).deep.to.equal(0)
+ expect(model.getPointFromStructured({ point: [0, 1, 1] })).deep.to.equal(5)
+ expect(model.getPointFromStructured({ point: [3, 3, 3] })).deep.to.equal(63)
+ })
+
+ it('should see if a point is in threshold', () => {
+ const minValue = 25
+ const maxValue = 225
+
+ model.setThreshold({ min: minValue, max: maxValue })
+
+ for (let i = 0; i < numPoints; i++) {
+ const value = model.getPointValue({ point: i })
+ const isInThreshold = model.isPointInThreshold({ point: i })
+ expect(isInThreshold).to.equal(value >= minValue && value <= maxValue)
+ }
+
+ // reset
+ model.setThreshold({ min: 0, max: 255 })
+ })
+
+ it('should get and set an accurate plane frame', (done) => {
+ const planeFrameActive = [0, 3, 3]
+ const planeFrame = [
+ [0, 4, 8, 12],
+ [1, 5, 9, 13],
+ [2, 6, 10, 14],
+ [3, 7, 11, 15]
+ ]
+
+ // make sure the cb's are called
+ let cbs = 2
+ const onComplete = () => {
+ if (--cbs === 0) {
+ expect(model._listeners.length).to.equal(0)
+ done()
+ }
+ }
+
+ const changeDimensionXFrameListener = ({ frame }) => {
+ model.off('change:dimension-0:frame', changeDimensionXFrameListener)
+
+ expect(frame).to.equal(planeFrameActive[0])
+ onComplete()
+ }
+
+ const changeDimensionFrameListener = ({ dimension, frame }) => {
+ model.off('change:dimension:frame', changeDimensionFrameListener)
+ expect(dimension).to.equal(0)
+ expect(frame).to.equal(planeFrameActive[0])
+ onComplete()
+ }
+
+ model.on('change:dimension-0:frame', changeDimensionXFrameListener)
+ model.on('change:dimension:frame', changeDimensionFrameListener)
+ expect(model._listeners.length).to.equal(2)
+
+ // this is all the points that should be in x plane at frame 0
+ expect(model.planeFrameActive).not.deep.to.equal(planeFrameActive)
+ expect(model.getPlaneFrame({ dimension: 0 })).not.deep.to.equal(planeFrame)
+ expect(model.getPlaneFrame({ dimension: 0, frame: 0 })).deep.to.equal(planeFrame)
+
+ model.setPlaneFrameActive({ dimension: 0, frame: 0 })
+
+ expect(model.planeFrameActive).deep.to.equal(planeFrameActive)
+ expect(model.getPlaneFrame({ dimension: 0 })).deep.to.equal(planeFrame)
+ expect(model.getPlaneFrame({ dimension: 0, frame: 0 })).deep.to.equal(planeFrame)
+ })
+
+ it('should get an accurate plane set', () => {
+ // this is all the points that should be in x plane at frame 0
+ // because its a set all the points should be in increasing values
+ expect(model.getPlaneSet({ dimension: 0, frame: 0 }).data).deep.to.equal([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
+ })
+})
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/SortedSet.spec.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/SortedSet.spec.js
new file mode 100644
index 0000000000..ee638b9d64
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/SortedSet.spec.js
@@ -0,0 +1,148 @@
+import { SortedSet } from './../helpers/SortedSet'
+
+describe('Component > VolumetricViewer > SortedSet', () => {
+ const testSet1 = SortedSet()
+
+ // .add()
+ it('should add items in order', () => {
+ testSet1.add({ value: 5 })
+ expect(testSet1.data).deep.to.equal([5])
+
+ testSet1.add({ value: 3 })
+ expect(testSet1.data).deep.to.equal([3, 5])
+
+ testSet1.add({ value: 7 })
+ expect(testSet1.data).deep.to.equal([3, 5, 7])
+ })
+
+ it('should not add a value that already exists', () => {
+ testSet1.add({ value: 5 })
+ expect(testSet1.data).deep.to.equal([3, 5, 7])
+
+ testSet1.add({ value: 5 })
+ expect(testSet1.data).deep.to.equal([3, 5, 7])
+ })
+
+ // .remove()
+ it('should remove items in order', () => {
+ testSet1.remove({ value: 5 })
+ expect(testSet1.data).deep.to.equal([3, 7])
+
+ testSet1.remove({ value: 3 })
+ expect(testSet1.data).deep.to.equal([7])
+
+ testSet1.remove({ value: 7 })
+ expect(testSet1.data).deep.to.equal([])
+ })
+
+ // .add() & .remove()
+ it('should add and remove together', () => {
+ testSet1.add({ value: 5 })
+ testSet1.add({ value: 2 })
+ testSet1.add({ value: 12 })
+ testSet1.add({ value: 13 })
+ testSet1.add({ value: 7 })
+ testSet1.add({ value: 1 })
+ expect(testSet1.data).deep.to.equal([1, 2, 5, 7, 12, 13])
+
+ testSet1.remove({ value: 7 })
+ testSet1.remove({ value: 1 })
+ testSet1.remove({ value: 13 })
+ expect(testSet1.data).deep.to.equal([2, 5, 12])
+
+ testSet1.add({ value: 13 })
+ testSet1.add({ value: 1 })
+ testSet1.add({ value: 7 })
+ expect(testSet1.data).deep.to.equal([1, 2, 5, 7, 12, 13])
+ })
+
+ // .has()
+ it('should return "true" if the set .has the value', () => {
+ expect(testSet1.has({ value: 13 })).to.equal(true)
+ })
+
+ it('should return "false" if the set doesn\'t have the value', () => {
+ expect(testSet1.has({ value: 14 })).to.equal(false)
+ })
+
+ // MULTIPLE SETS
+ const testSet2 = SortedSet({ data: [1, 5, 9] })
+ const testSet3 = SortedSet({ data: [1, 3, 7, 9] })
+ const testSet4 = SortedSet({ data: [3, 5, 9, 11] })
+
+ // .intersection()
+ it('should return intersection of set 2 & 3', () => {
+ const intersection2n3 = testSet2.intersection({ sets: [testSet3] })
+ const intersection3n2 = testSet3.intersection({ sets: [testSet2] })
+ expect(intersection2n3.data).deep.to.equal([1, 9])
+ expect(intersection3n2.data).deep.to.equal([1, 9])
+ })
+
+ it('should return intersection of set 3 & 4', () => {
+ const intersection3n4 = testSet3.intersection({ sets: [testSet4] })
+ const intersection4n3 = testSet4.intersection({ sets: [testSet3] })
+ expect(intersection3n4.data).deep.to.equal([3, 9])
+ expect(intersection4n3.data).deep.to.equal([3, 9])
+ })
+
+ it('should return intersection of set 2 & 4', () => {
+ const intersection2n4 = testSet2.intersection({ sets: [testSet4] })
+ const intersection4n2 = testSet4.intersection({ sets: [testSet2] })
+ expect(intersection2n4.data).deep.to.equal([5, 9])
+ expect(intersection4n2.data).deep.to.equal([5, 9])
+ })
+
+ it('should return intersection of set 2, 3 & 4', () => {
+ const intersection2n3n4 = testSet2.intersection({ sets: [testSet3, testSet4] })
+ const intersection2n4n3 = testSet2.intersection({ sets: [testSet4, testSet3] })
+ const intersection3n2n4 = testSet3.intersection({ sets: [testSet2, testSet4] })
+ const intersection3n4n2 = testSet3.intersection({ sets: [testSet4, testSet2] })
+ const intersection4n2n3 = testSet4.intersection({ sets: [testSet2, testSet3] })
+ const intersection4n3n2 = testSet4.intersection({ sets: [testSet3, testSet2] })
+
+ expect(intersection2n3n4.data).deep.to.equal([9])
+ expect(intersection2n4n3.data).deep.to.equal([9])
+ expect(intersection3n2n4.data).deep.to.equal([9])
+ expect(intersection3n4n2.data).deep.to.equal([9])
+ expect(intersection4n2n3.data).deep.to.equal([9])
+ expect(intersection4n3n2.data).deep.to.equal([9])
+ })
+
+ // .union()
+ it('should return union of set 2 & 3', () => {
+ const union2n3 = testSet2.union({ sets: [testSet3] })
+ const union3n2 = testSet3.union({ sets: [testSet2] })
+ expect(union2n3.data).deep.to.equal([1, 3, 5, 7, 9])
+ expect(union3n2.data).deep.to.equal([1, 3, 5, 7, 9])
+ })
+
+ it('should return union of set 3 & 4', () => {
+ const union3n4 = testSet3.union({ sets: [testSet4] })
+ const union4n3 = testSet4.union({ sets: [testSet3] })
+ expect(union3n4.data).deep.to.equal([1, 3, 5, 7, 9, 11])
+ expect(union4n3.data).deep.to.equal([1, 3, 5, 7, 9, 11])
+ })
+
+ it('should return union of set 2 & 4', () => {
+ const union2n4 = testSet2.union({ sets: [testSet4] })
+ const union4n2 = testSet4.union({ sets: [testSet2] })
+ expect(union2n4.data).deep.to.equal([1, 3, 5, 9, 11])
+ expect(union4n2.data).deep.to.equal([1, 3, 5, 9, 11])
+ })
+
+ it('should return union of set 2, 3 & 4', () => {
+ const union2n3n4 = testSet2.union({ sets: [testSet3, testSet4] })
+ const union2n4n3 = testSet2.union({ sets: [testSet4, testSet3] })
+ const union3n2n4 = testSet3.union({ sets: [testSet2, testSet4] })
+ const union3n4n2 = testSet3.union({ sets: [testSet4, testSet2] })
+ const union4n2n3 = testSet4.union({ sets: [testSet2, testSet3] })
+ const union4n3n2 = testSet4.union({ sets: [testSet3, testSet2] })
+
+ expect(union2n3n4.data).deep.to.equal([1, 3, 5, 7, 9, 11])
+ expect(union2n4n3.data).deep.to.equal([1, 3, 5, 7, 9, 11])
+ expect(union3n2n4.data).deep.to.equal([1, 3, 5, 7, 9, 11])
+ expect(union3n4n2.data).deep.to.equal([1, 3, 5, 7, 9, 11])
+ expect(union4n2n3.data).deep.to.equal([1, 3, 5, 7, 9, 11])
+ expect(union4n3n2.data).deep.to.equal([1, 3, 5, 7, 9, 11])
+ })
+})
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/VolumetricViewer.spec.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/VolumetricViewer.spec.js
new file mode 100644
index 0000000000..9c74484612
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/VolumetricViewer.spec.js
@@ -0,0 +1,82 @@
+import { render } from '@testing-library/react'
+import { screen } from '@testing-library/dom'
+import { composeStory } from '@storybook/react'
+import Meta, { Default } from './VolumetricViewer.stories.js'
+import { VolumetricViewerData } from './../VolumetricViewer.js'
+import subjectData from './../data/4x4x4.json'
+
+// GH test:ci fails for this because of "gl" being needed
+
+xdescribe('Component > VolumetricViewer', () => {
+ const VolumetricViewer = composeStory(Default, Meta)
+
+ beforeEach(() => {
+ render( )
+ })
+
+ it('should load without errors', () => {
+ expect(document).to.be.ok()
+ })
+
+ it('should render the 3 planes', () => {
+ expect(screen.getByTestId('plane-canvas-0')).to.be.ok()
+ expect(screen.getByTestId('plane-canvas-1')).to.be.ok()
+ expect(screen.getByTestId('plane-canvas-2')).to.be.ok()
+
+ expect(screen.getByTestId('plane-input-0')).to.be.ok()
+ expect(screen.getByTestId('plane-input-1')).to.be.ok()
+ expect(screen.getByTestId('plane-input-2')).to.be.ok()
+ })
+
+ it('should render the cube', () => {
+ expect(screen.getByTestId('cube')).to.be.ok()
+ expect(screen.getByTestId('cube-axis')).to.be.ok()
+ expect(screen.getByTestId('cube-histogram')).to.be.ok()
+ })
+})
+
+// GH test:ci fails for this because of "gl" being needed
+xdescribe('Component > VolumetricViewerData', () => {
+ const VolumetricViewer = VolumetricViewerData({
+ subjectData,
+ subjectUrl: ''
+ })
+
+ describe('Component Rendering', () => {
+ const VVComponent = VolumetricViewer.component
+
+ beforeEach(() => {
+ render( )
+ })
+
+ it('should load without errors', () => {
+ expect(document).to.be.ok()
+ })
+
+ it('should render the 3 planes', () => {
+ expect(screen.getByTestId('plane-canvas-0')).to.be.ok()
+ expect(screen.getByTestId('plane-canvas-1')).to.be.ok()
+ expect(screen.getByTestId('plane-canvas-2')).to.be.ok()
+
+ expect(screen.getByTestId('plane-input-0')).to.be.ok()
+ expect(screen.getByTestId('plane-input-1')).to.be.ok()
+ expect(screen.getByTestId('plane-input-2')).to.be.ok()
+ })
+
+ it('should render the cube', () => {
+ expect(screen.getByTestId('cube')).to.be.ok()
+ expect(screen.getByTestId('cube-axis')).to.be.ok()
+ expect(screen.getByTestId('cube-histogram')).to.be.ok()
+ })
+ })
+
+ describe('Model Management', () => {
+ it('should load without errors', () => {
+ const { annotations, tool, viewer } = VolumetricViewer.data.models
+
+ expect(annotations).to.be.ok()
+ expect(tool).to.be.ok()
+ expect(viewer).to.be.ok()
+ })
+ })
+})
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/VolumetricViewer.stories.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/VolumetricViewer.stories.js
new file mode 100644
index 0000000000..406b12b0da
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/VolumetricViewer.stories.js
@@ -0,0 +1,11 @@
+import VolumetricViewer from './../VolumetricViewer'
+import subjectData from './../data/4x4x4.json'
+
+export default {
+ title: 'Components / VolumetricViewer',
+ component: VolumetricViewer
+}
+
+export const Default = () => {
+ return
+}
diff --git a/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/pointColor.spec.js b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/pointColor.spec.js
new file mode 100644
index 0000000000..a8ac5e3fc0
--- /dev/null
+++ b/packages/lib-subject-viewers/src/components/VolumetricViewer/tests/pointColor.spec.js
@@ -0,0 +1,33 @@
+import { pointColor } from './../helpers/pointColor'
+
+describe('Component > VolumetricViewer > pointColor', () => {
+ it('should generate the right color for non-annotated, non-three color', () => {
+ const color = pointColor({ annotationIndex: -1, isThree: false, pointValue: 0 })
+ expect(color).to.equal('hsl(205, 0%, 25%)')
+ })
+
+ it('should generate the right color for non-annotated, three color', () => {
+ const color = pointColor({ annotationIndex: -1, isThree: true, pointValue: 0 })
+ expect(color).deep.to.equal({
+ isColor: true,
+ r: 0.050876088164650994,
+ g: 0.050876088164650994,
+ b: 0.050876088164650994
+ })
+ })
+
+ it('should generate the right color for annotated, non-three color', () => {
+ const color = pointColor({ annotationIndex: 1, isThree: false, pointValue: 0 })
+ expect(color).to.equal('hsl(60, 75%, 25%)')
+ })
+
+ it('should generate the right black for annotated, three color', () => {
+ const color = pointColor({ annotationIndex: 1, isThree: true, pointValue: 0 })
+ expect(color).deep.to.equal({
+ isColor: true,
+ r: 0.16068267770835676,
+ g: 0.16068267770835684,
+ b: 0.005155668396761914
+ })
+ })
+})
diff --git a/packages/lib-subject-viewers/src/index.js b/packages/lib-subject-viewers/src/index.js
index 34b793294f..c2b83417a5 100644
--- a/packages/lib-subject-viewers/src/index.js
+++ b/packages/lib-subject-viewers/src/index.js
@@ -1 +1,2 @@
-export { default as ProtoViewer } from './components/ProtoViewer/ProtoViewer.js'
+export { default as VolumetricViewer } from './components/VolumetricViewer/VolumetricViewer.js'
+export { VolumetricViewerData } from './components/VolumetricViewer/VolumetricViewer.js'
diff --git a/yarn.lock b/yarn.lock
index 83826f29c8..09c8e67fd7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1910,6 +1910,21 @@
dependencies:
unist-util-visit "^1.4.1"
+"@mapbox/node-pre-gyp@^1.0.0":
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa"
+ integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==
+ dependencies:
+ detect-libc "^2.0.0"
+ https-proxy-agent "^5.0.0"
+ make-dir "^3.1.0"
+ node-fetch "^2.6.7"
+ nopt "^5.0.0"
+ npmlog "^5.0.1"
+ rimraf "^3.0.2"
+ semver "^7.3.5"
+ tar "^6.1.11"
+
"@mdx-js/react@^2.1.5":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-2.3.0.tgz#4208bd6d70f0d0831def28ef28c26149b03180b3"
@@ -5228,6 +5243,11 @@ JSONStream@^1.3.5:
jsonparse "^1.2.0"
through ">=2.2.7 <3"
+abbrev@1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+ integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
+
abbrev@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf"
@@ -5438,7 +5458,7 @@ append-transform@^2.0.0:
dependencies:
default-require-extensions "^3.0.0"
-aproba@2.0.0:
+aproba@2.0.0, "aproba@^1.0.3 || ^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc"
integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==
@@ -5448,6 +5468,14 @@ archy@^1.0.0:
resolved "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz"
integrity sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==
+are-we-there-yet@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c"
+ integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==
+ dependencies:
+ delegates "^1.0.0"
+ readable-stream "^3.6.0"
+
argparse@^1.0.7:
version "1.0.10"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
@@ -6366,6 +6394,15 @@ caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001646:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001653.tgz#b8af452f8f33b1c77f122780a4aecebea0caca56"
integrity sha512-XGWQVB8wFQ2+9NZwZ10GxTYC5hk0Fa+q8cSkr0tgvMhYhMHP/QC+WTgrePMDBWiWc/pV+1ik82Al20XOK25Gcw==
+canvas@^2.11.2:
+ version "2.11.2"
+ resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.11.2.tgz#553d87b1e0228c7ac0fc72887c3adbac4abbd860"
+ integrity sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==
+ dependencies:
+ "@mapbox/node-pre-gyp" "^1.0.0"
+ nan "^2.17.0"
+ simple-get "^3.0.3"
+
case-sensitive-paths-webpack-plugin@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4"
@@ -6685,7 +6722,7 @@ color-string@^1.9.0:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
-color-support@1.1.3:
+color-support@1.1.3, color-support@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
@@ -6841,7 +6878,7 @@ console-browserify@^1.2.0:
resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336"
integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==
-console-control-strings@^1.1.0:
+console-control-strings@^1.0.0, console-control-strings@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz"
integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==
@@ -7665,6 +7702,13 @@ decimal.js@^10.4.3:
resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz"
integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==
+decompress-response@^4.2.0:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986"
+ integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==
+ dependencies:
+ mimic-response "^2.0.0"
+
decompress-response@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc"
@@ -7844,6 +7888,11 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
+delegates@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+ integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
+
depd@2.0.0, depd@~2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz"
@@ -9023,6 +9072,13 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
md5.js "^1.3.4"
safe-buffer "^5.1.1"
+exec-sh@^0.2.0:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36"
+ integrity sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw==
+ dependencies:
+ merge "^1.2.0"
+
execa@5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz"
@@ -9635,6 +9691,21 @@ functions-have-names@^1.2.3:
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
+gauge@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395"
+ integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==
+ dependencies:
+ aproba "^1.0.3 || ^2.0.0"
+ color-support "^1.1.2"
+ console-control-strings "^1.0.0"
+ has-unicode "^2.0.1"
+ object-assign "^4.1.1"
+ signal-exit "^3.0.0"
+ string-width "^4.2.3"
+ strip-ansi "^6.0.1"
+ wide-align "^1.1.2"
+
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
@@ -10085,7 +10156,7 @@ has-tostringtag@^1.0.0, has-tostringtag@^1.0.1, has-tostringtag@^1.0.2:
dependencies:
has-symbols "^1.0.3"
-has-unicode@2.0.1:
+has-unicode@2.0.1, has-unicode@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz"
integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==
@@ -12213,7 +12284,7 @@ make-dir@^2.0.0, make-dir@^2.1.0:
pify "^4.0.1"
semver "^5.6.0"
-make-dir@^3.0.0, make-dir@^3.0.2:
+make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz"
integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
@@ -12464,6 +12535,11 @@ merge2@^1.3.0, merge2@^1.4.1:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+merge@^1.2.0:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145"
+ integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==
+
methods@^1.1.2, methods@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
@@ -12539,6 +12615,11 @@ mimic-fn@^4.0.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc"
integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==
+mimic-response@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43"
+ integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==
+
mimic-response@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
@@ -12866,7 +12947,7 @@ mute-stream@^1.0.0, mute-stream@~1.0.0:
resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz"
integrity sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==
-nan@^2.19.0:
+nan@^2.17.0, nan@^2.19.0:
version "2.20.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.20.0.tgz#08c5ea813dd54ed16e5bd6505bf42af4f7838ca3"
integrity sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==
@@ -13119,6 +13200,13 @@ node-releases@^2.0.18:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f"
integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==
+nopt@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"
+ integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==
+ dependencies:
+ abbrev "1"
+
nopt@^7.0.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.0.tgz#067378c68116f602f552876194fd11f1292503d7"
@@ -13295,6 +13383,16 @@ npm-run-path@^5.1.0:
dependencies:
path-key "^4.0.0"
+npmlog@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0"
+ integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==
+ dependencies:
+ are-we-there-yet "^2.0.0"
+ console-control-strings "^1.1.0"
+ gauge "^3.0.0"
+ set-blocking "^2.0.0"
+
nth-check@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
@@ -15725,7 +15823,7 @@ side-channel@^1.0.4, side-channel@^1.0.6:
get-intrinsic "^1.2.4"
object-inspect "^1.13.1"
-signal-exit@3.0.7, signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7:
+signal-exit@3.0.7, signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7:
version "3.0.7"
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz"
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
@@ -15752,6 +15850,15 @@ simple-concat@^1.0.0:
resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f"
integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==
+simple-get@^3.0.3:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.1.tgz#cc7ba77cfbe761036fbfce3d021af25fc5584d55"
+ integrity sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==
+ dependencies:
+ decompress-response "^4.2.0"
+ once "^1.3.1"
+ simple-concat "^1.0.0"
+
simple-get@^4.0.0, simple-get@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543"
@@ -16620,6 +16727,11 @@ third-party-capital@1.0.20:
resolved "https://registry.yarnpkg.com/third-party-capital/-/third-party-capital-1.0.20.tgz#e218a929a35bf4d2245da9addb8ab978d2f41685"
integrity sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==
+three@^0.162.0:
+ version "0.162.0"
+ resolved "https://registry.yarnpkg.com/three/-/three-0.162.0.tgz#b15a511f1498e0c42d4d00bbb411c7527b06097e"
+ integrity sha512-xfCYj4RnlozReCmUd+XQzj6/5OjDNHBy5nT6rVwrOKGENAvpXe2z1jL+DZYaMu4/9pNsjH/4Os/VvS9IrH7IOQ==
+
through2@^2.0.0, through2@^2.0.3:
version "2.0.5"
resolved "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz"
@@ -17418,6 +17530,14 @@ walker@^1.0.8:
dependencies:
makeerror "1.0.12"
+watch@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/watch/-/watch-1.0.2.tgz#340a717bde765726fa0aa07d721e0147a551df0c"
+ integrity sha512-1u+Z5n9Jc1E2c7qDO8SinPoZuHj7FgbgU1olSFoyaklduDvvtX7GMMtlE6OC9FTXq4KvNAOfj6Zu4vI1e9bAKA==
+ dependencies:
+ exec-sh "^0.2.0"
+ minimist "^1.2.0"
+
watchpack@^2.2.0, watchpack@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.1.tgz#29308f2cac150fa8e4c92f90e0ec954a9fed7fff"
@@ -17746,7 +17866,7 @@ which@^4.0.0:
dependencies:
isexe "^3.1.1"
-wide-align@1.1.5:
+wide-align@1.1.5, wide-align@^1.1.2:
version "1.1.5"
resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3"
integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==