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 }) + }} + /> +
    +
    + + + + + +
      + {_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}
    +
    +
    +
    + + +
    +
    +
    + ) +} + +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}
    +
    +
    +
    + + +
    +
    +
    +
    Max
    + +
    +
    +
    + ) +} + +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==