UNPKG

@niivue/niivue

Version:

minimal webgl2 nifti image viewer

893 lines (820 loc) 26.2 kB
import { serialize, deserialize } from '@ungap/structured-clone' import { vec3, vec4 } from 'gl-matrix' import { NVUtilities } from './nvutilities.js' import { ImageFromUrlOptions, NVIMAGE_TYPE, NVImage } from './nvimage/index.js' import { MeshType, NVMesh } from './nvmesh.js' import { NVLabel3D } from './nvlabel.js' import { NVConnectome } from './nvconnectome.js' import { log } from './logger.js' /** * Slice Type * @ignore */ export enum SLICE_TYPE { AXIAL = 0, CORONAL = 1, SAGITTAL = 2, MULTIPLANAR = 3, RENDER = 4 } export enum SHOW_RENDER { NEVER = 0, ALWAYS = 1, AUTO = 2 } /** * Multi-planar layout * @ignore */ export enum MULTIPLANAR_TYPE { AUTO = 0, COLUMN = 1, GRID = 2, ROW = 3 } /** * Drag mode * @ignore */ export enum DRAG_MODE { none = 0, contrast = 1, measurement = 2, pan = 3, slicer3D = 4, callbackOnly = 5, roiSelection = 6 } export enum DRAG_MODE_SECONDARY { none = 0, contrast = 1, measurement = 2, pan = 3, slicer3D = 4, callbackOnly = 5, roiSelection = 6 } export enum DRAG_MODE_PRIMARY { crosshair = 0, windowing = 1 } export enum COLORMAP_TYPE { MIN_TO_MAX = 0, ZERO_TO_MAX_TRANSPARENT_BELOW_MIN = 1, ZERO_TO_MAX_TRANSLUCENT_BELOW_MIN = 2 } // make mutable type type Mutable<T> = { -readonly [P in keyof T]: T[P] } /** * NVConfigOptions */ export type NVConfigOptions = { // 0 for no text, fraction of canvas min(height,width) textHeight: number // 0 for no colorbars, fraction of Nifti j dimension colorbarHeight: number // 0 for no crosshairs crosshairWidth: number crosshairWidthUnit: 'voxels' | 'mm' | 'percent' crosshairGap: number rulerWidth: number show3Dcrosshair: boolean backColor: number[] crosshairColor: number[] fontColor: Float32List selectionBoxColor: number[] clipPlaneColor: number[] clipThick: number clipVolumeLow: number[] clipVolumeHigh: number[] rulerColor: number[] // x axis margin around color bar, clip space coordinates colorbarMargin: number // if true do not calculate cal_min or cal_max if set in image header. If false, always calculate display intensity range. trustCalMinMax: boolean // keyboard short cut to activate the clip plane clipPlaneHotKey: string // keyboard shortcut to switch view modes viewModeHotKey: string doubleTouchTimeout: number longTouchTimeout: number // default debounce time used in keyup listeners keyDebounceTime: number isNearestInterpolation: boolean atlasOutline: number isRuler: boolean isColorbar: boolean isOrientCube: boolean tileMargin: number multiplanarPadPixels: number // @deprecated multiplanarForceRender: boolean multiplanarEqualSize: boolean multiplanarShowRender: SHOW_RENDER isRadiologicalConvention: boolean // string to allow infinity meshThicknessOn2D: number | string dragMode: DRAG_MODE | DRAG_MODE_SECONDARY dragModePrimary: DRAG_MODE_PRIMARY yoke3Dto2DZoom: boolean isDepthPickMesh: boolean isCornerOrientationText: boolean heroImageFraction: number heroSliceType: SLICE_TYPE // sagittal slices can have Y+ going left or right sagittalNoseLeft: boolean isSliceMM: boolean // V1 image overlays can show vectors as per-pixel lines isV1SliceShader: boolean forceDevicePixelRatio: number logLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent' loadingText: string isForceMouseClickToVoxelCenters: boolean dragAndDropEnabled: boolean // drawing disabled by default drawingEnabled: boolean // sets drawing color. see "drawPt" penValue: number // does a voxel have 6 (face), 18 (edge) or 26 (corner) neighbors floodFillNeighbors: number isFilledPen: boolean thumbnail: string maxDrawUndoBitmaps: number sliceType: SLICE_TYPE isAntiAlias: boolean | null isAdditiveBlend: boolean // TODO all following fields were previously not included in the typedef // Allow canvas width and height to resize (false for fixed size) isResizeCanvas: boolean meshXRay: number limitFrames4D: number // if a document has labels the default is to show them showLegend: boolean legendBackgroundColor: number[] legendTextColor: number[] multiplanarLayout: MULTIPLANAR_TYPE renderOverlayBlend: number sliceMosaicString: string centerMosaic: boolean // attach mouse click and touch screen event handlers for the canvas interactive: boolean penSize: number clickToSegment: boolean clickToSegmentRadius: number clickToSegmentBright: boolean clickToSegmentAutoIntensity: boolean // new option, but keep clickToSegmentBright for backwards compatibility clickToSegmentIntensityMax: number // also covers NaN clickToSegmentIntensityMin: number // also covers NaN clickToSegmentPercent: number clickToSegmentMaxDistanceMM: number // max distance in mm to consider for click to segment flood fill clickToSegmentIs2D: boolean // selection box outline thickness selectionBoxLineThickness: number selectionBoxIsOutline: boolean scrollRequiresFocus: boolean showMeasureUnits: boolean // measureTextJustify: "origin" | "terminus" | "center" measureTextJustify: 'start' | 'center' | 'end' // similar to flexbox justify start, end, center measureTextColor: number[] measureLineColor: number[] measureTextHeight: number isAlphaClipDark: boolean gradientOrder: number gradientOpacity: number invertScrollDirection: boolean } export const DEFAULT_OPTIONS: NVConfigOptions = { textHeight: 0.06, colorbarHeight: 0.05, crosshairWidth: 1, crosshairWidthUnit: 'voxels', crosshairGap: 0, rulerWidth: 4, show3Dcrosshair: false, backColor: [0, 0, 0, 1], crosshairColor: [1, 0, 0, 1], fontColor: [0.5, 0.5, 0.5, 1], selectionBoxColor: [1, 1, 1, 0.5], clipPlaneColor: [0.7, 0, 0.7, 0.5], clipThick: 2, clipVolumeLow: [0, 0, 0], clipVolumeHigh: [1.0, 1.0, 1.0], rulerColor: [1, 0, 0, 0.8], colorbarMargin: 0.05, trustCalMinMax: true, clipPlaneHotKey: 'KeyC', viewModeHotKey: 'KeyV', doubleTouchTimeout: 500, longTouchTimeout: 1000, keyDebounceTime: 50, isNearestInterpolation: false, isResizeCanvas: true, atlasOutline: 0, isRuler: false, isColorbar: false, isOrientCube: false, tileMargin: 0, multiplanarPadPixels: 0, // @deprecated multiplanarForceRender: false, multiplanarEqualSize: false, multiplanarShowRender: SHOW_RENDER.AUTO, // auto is the same behaviour as multiplanarForceRender: false isRadiologicalConvention: false, meshThicknessOn2D: Infinity, dragMode: DRAG_MODE_SECONDARY.contrast, dragModePrimary: DRAG_MODE_PRIMARY.crosshair, yoke3Dto2DZoom: false, isDepthPickMesh: false, isCornerOrientationText: false, heroImageFraction: 0, heroSliceType: SLICE_TYPE.RENDER, sagittalNoseLeft: false, isSliceMM: false, isV1SliceShader: false, forceDevicePixelRatio: 0, logLevel: 'info', loadingText: 'loading ...', isForceMouseClickToVoxelCenters: false, dragAndDropEnabled: true, drawingEnabled: false, penValue: 1, floodFillNeighbors: 6, isFilledPen: false, thumbnail: '', maxDrawUndoBitmaps: 8, sliceType: SLICE_TYPE.MULTIPLANAR, meshXRay: 0.0, isAntiAlias: null, limitFrames4D: NaN, isAdditiveBlend: false, showLegend: true, legendBackgroundColor: [0.3, 0.3, 0.3, 0.5], legendTextColor: [1.0, 1.0, 1.0, 1.0], multiplanarLayout: MULTIPLANAR_TYPE.AUTO, renderOverlayBlend: 1.0, sliceMosaicString: '', centerMosaic: false, penSize: 1, // in voxels, since all drawing is done using bitmap indices interactive: true, clickToSegment: false, clickToSegmentRadius: 3, // in mm clickToSegmentBright: true, clickToSegmentAutoIntensity: false, // new option, but keep clickToSegmentBright for backwards compatibility clickToSegmentIntensityMax: NaN, // NaN will use auto threshold (default flood fill behavior from before) clickToSegmentIntensityMin: NaN, // NaN will use auto threshold (default flood fill behavior from before) // 0 will use auto threshold (default flood fill behavior from before) // Take the voxel intensity at the click point and use this percentage +/- to threshold the flood fill operation. // If greater than 0, clickedVoxelIntensity +/- clickedVoxelIntensity * clickToSegmentPercent will be used // for the clickToSegmentIntensityMin and clickToSegmentIntensityMax values. clickToSegmentPercent: 0, clickToSegmentMaxDistanceMM: Number.POSITIVE_INFINITY, // default value is infinity for backwards compatibility with flood fill routine. clickToSegmentIs2D: false, selectionBoxLineThickness: 4, selectionBoxIsOutline: false, scrollRequiresFocus: false, // determines if the cavas need to be focused to scroll showMeasureUnits: true, // e.g. 20.2 vs 20.2 mm measureTextJustify: 'center', // start, center, end measureTextColor: [1, 0, 0, 1], // red measureLineColor: [1, 0, 0, 1], // red measureTextHeight: 0.03, isAlphaClipDark: false, gradientOrder: 1, gradientOpacity: 0.0, invertScrollDirection: false } type SceneData = { gamma: number azimuth: number elevation: number crosshairPos: vec3 clipPlane: number[] clipPlaneDepthAziElev: number[] volScaleMultiplier: number pan2Dxyzmm: vec4 clipThick: number clipVolumeLow: number[] clipVolumeHigh: number[] } export const INITIAL_SCENE_DATA = { gamma: 1.0, azimuth: 110, elevation: 10, crosshairPos: vec3.fromValues(0.5, 0.5, 0.5), clipPlane: [0, 0, 0, 0], clipPlaneDepthAziElev: [2, 0, 0], volScaleMultiplier: 1.0, pan2Dxyzmm: vec4.fromValues(0, 0, 0, 1), clipThick: 2.0, clipVolumeLow: [0, 0, 0], clipVolumeHigh: [1.0, 1.0, 1.0] } export type Scene = { onAzimuthElevationChange: (azimuth: number, elevation: number) => void onZoom3DChange: (scale: number) => void sceneData: SceneData renderAzimuth: number renderElevation: number volScaleMultiplier: number crosshairPos: vec3 clipPlane: number[] clipPlaneDepthAziElev: number[] pan2Dxyzmm: vec4 _elevation?: number _azimuth?: number gamma?: number } export type DocumentData = { title: string imageOptionsArray: ImageFromUrlOptions[] meshOptionsArray: unknown[] opts: NVConfigOptions previewImageDataURL: string labels: NVLabel3D[] encodedImageBlobs: string[] encodedDrawingBlob: string // TODO not sure if they should be here? They are needed for loadFromJSON meshesString?: string sceneData?: SceneData // TODO referenced in niivue/loadDocument connectomes?: string[] customData?: string } export type ExportDocumentData = { // base64 encoded images encodedImageBlobs: string[] // base64 encoded drawing encodedDrawingBlob: string // dataURL of the preview image previewImageDataURL: string // map of image ids to image options imageOptionsMap: Map<string, number> // array of image options to recreate images imageOptionsArray: ImageFromUrlOptions[] // data to recreate a scene sceneData: Partial<SceneData> // configuration options of {@link Niivue} instance opts: NVConfigOptions // encoded meshes meshesString: string // TODO the following fields were missing in the typedef labels: NVLabel3D[] connectomes: string[] customData: string } /** * Creates and instance of NVDocument * @ignore */ export class NVDocument { data: DocumentData = { title: 'Untitled document', imageOptionsArray: [], meshOptionsArray: [], opts: { ...DEFAULT_OPTIONS }, previewImageDataURL: '', labels: [], encodedImageBlobs: [], encodedDrawingBlob: '' } scene: Scene volumes: NVImage[] = [] meshDataObjects?: Array<NVMesh | NVConnectome> meshes: Array<NVMesh | NVConnectome> = [] drawBitmap: Uint8Array | null = null imageOptionsMap = new Map() meshOptionsMap = new Map() constructor() { this.scene = { onAzimuthElevationChange: (): void => {}, onZoom3DChange: (): void => {}, sceneData: { ...INITIAL_SCENE_DATA, pan2Dxyzmm: vec4.fromValues(0, 0, 0, 1), crosshairPos: vec3.fromValues(0.5, 0.5, 0.5) }, get renderAzimuth(): number { return this.sceneData.azimuth }, set renderAzimuth(azimuth: number) { this.sceneData.azimuth = azimuth if (this.onAzimuthElevationChange) { this.onAzimuthElevationChange(this.sceneData.azimuth, this.sceneData.elevation) } }, get renderElevation(): number { return this.sceneData.elevation }, set renderElevation(elevation: number) { this.sceneData.elevation = elevation if (this.onAzimuthElevationChange) { this.onAzimuthElevationChange(this.sceneData.azimuth, this.sceneData.elevation) } }, get volScaleMultiplier(): number { return this.sceneData.volScaleMultiplier }, set volScaleMultiplier(scale: number) { this.sceneData.volScaleMultiplier = scale this.onZoom3DChange(scale) }, get crosshairPos(): vec3 { return this.sceneData.crosshairPos }, set crosshairPos(crosshairPos: vec3) { this.sceneData.crosshairPos = crosshairPos }, get clipPlane(): number[] { return this.sceneData.clipPlane }, set clipPlane(clipPlane) { this.sceneData.clipPlane = clipPlane }, get clipPlaneDepthAziElev(): number[] { return this.sceneData.clipPlaneDepthAziElev }, set clipPlaneDepthAziElev(clipPlaneDepthAziElev: number[]) { this.sceneData.clipPlaneDepthAziElev = clipPlaneDepthAziElev }, get pan2Dxyzmm(): vec4 { return this.sceneData.pan2Dxyzmm }, /** * Sets current 2D pan in 3D mm */ set pan2Dxyzmm(pan2Dxyzmm) { this.sceneData.pan2Dxyzmm = pan2Dxyzmm }, get gamma(): number { return this.sceneData.gamma }, /** * Sets current gamma */ set gamma(newGamma) { this.sceneData.gamma = newGamma } } } /** * Title of the document */ get title(): string { return this.data.title } /** * Gets preview image blob * @returns dataURL of preview image */ get previewImageDataURL(): string { return this.data.previewImageDataURL } /** * Sets preview image blob * @param dataURL - encoded preview image */ set previewImageDataURL(dataURL: string) { this.data.previewImageDataURL = dataURL } /** * @param title - title of document */ set title(title: string) { this.data.title = title } get imageOptionsArray(): ImageFromUrlOptions[] { return this.data.imageOptionsArray } /** * Gets the base 64 encoded blobs of associated images */ get encodedImageBlobs(): string[] { return this.data.encodedImageBlobs } /** * Gets the base 64 encoded blob of the associated drawing * TODO the return type was marked as string[] here, was that an error? */ get encodedDrawingBlob(): string { return this.data.encodedDrawingBlob } /** * Gets the options of the {@link Niivue} instance */ get opts(): NVConfigOptions { return this.data.opts } /** * Sets the options of the {@link Niivue} instance */ set opts(opts) { this.data.opts = { ...opts } } /** * Gets the 3D labels of the {@link Niivue} instance */ get labels(): NVLabel3D[] { return this.data.labels } /** * Sets the 3D labels of the {@link Niivue} instance */ set labels(labels: NVLabel3D[]) { this.data.labels = labels } get customData(): string | undefined { return this.data.customData } set customData(data: string) { this.data.customData = data } /** * Checks if document has an image by id */ hasImage(image: NVImage): boolean { return this.volumes.find((i) => i.id === image.id) !== undefined } /** * Checks if document has an image by url */ hasImageFromUrl(url: string): boolean { return this.data.imageOptionsArray.find((i) => i.url === url) !== undefined } /** * Adds an image and the options an image was created with */ addImageOptions(image: NVImage, imageOptions: ImageFromUrlOptions): void { if (!this.hasImage(image)) { if (!imageOptions.name) { if (imageOptions.url) { const absoluteUrlRE = /^(?:[a-z+]+:)?\/\//i const url = absoluteUrlRE.test(imageOptions.url) ? new URL(imageOptions.url) : new URL(imageOptions.url, window.location.href) imageOptions.name = url.pathname.split('/').pop()! // TODO guaranteed? if (imageOptions.name.toLowerCase().endsWith('.gz')) { imageOptions.name = imageOptions.name.slice(0, -3) } if (!imageOptions.name.toLowerCase().endsWith('.nii')) { imageOptions.name += '.nii' } } else { imageOptions.name = 'untitled.nii' } } } imageOptions.imageType = NVIMAGE_TYPE.NII this.data.imageOptionsArray.push(imageOptions) this.imageOptionsMap.set(image.id, this.data.imageOptionsArray.length - 1) } /** * Removes image from the document as well as its options */ removeImage(image: NVImage): void { if (this.imageOptionsMap.has(image.id)) { const index = this.imageOptionsMap.get(image.id) if (this.data.imageOptionsArray.length > index) { this.data.imageOptionsArray.splice(index, 1) } this.imageOptionsMap.delete(image.id) } this.volumes = this.volumes.filter((i) => i.id !== image.id) } /** * Returns the options for the image if it was added by url */ getImageOptions(image: NVImage): ImageFromUrlOptions | null { return this.imageOptionsMap.has(image.id) ? this.data.imageOptionsArray[this.imageOptionsMap.get(image.id)] : null } /** * Converts NVDocument to JSON */ json(): ExportDocumentData { const data: Partial<ExportDocumentData> = { encodedImageBlobs: [], previewImageDataURL: this.data.previewImageDataURL, imageOptionsMap: new Map() } const imageOptionsArray = [] // save our scene object data.sceneData = { ...this.scene.sceneData } // save our options data.opts = { ...this.opts } // infinity is a symbol if (this.opts.meshThicknessOn2D === Infinity) { data.opts.meshThicknessOn2D = 'infinity' } data.labels = [...this.data.labels] // remove any handlers for (const label of data.labels) { delete label.onClick } data.customData = this.customData // volumes // TODO move this to a per-volume export function in NVImage? if (this.volumes.length) { for (let i = 0; i < this.volumes.length; i++) { const volume = this.volumes[i] let imageOptions = this.getImageOptions(volume) if (imageOptions === null) { log.warn('no options found for image, using default') imageOptions = { name: '', colormap: 'gray', opacity: 1.0, pairedImgData: null, cal_min: NaN, cal_max: NaN, trustCalMinMax: true, percentileFrac: 0.02, ignoreZeroVoxels: false, useQFormNotSForm: false, colormapNegative: '', colormapLabel: null, imageType: NVIMAGE_TYPE.NII, frame4D: 0, limitFrames4D: NaN, // TODO the following were missing url: '', urlImageData: '', alphaThreshold: false, cal_minNeg: NaN, cal_maxNeg: NaN, colorbarVisible: true } } else { if (!('imageType' in imageOptions)) { imageOptions.imageType = NVIMAGE_TYPE.NII } } // update image options on current image settings imageOptions.colormap = volume.colormap imageOptions.colormapLabel = volume.colormapLabel imageOptions.opacity = volume.opacity imageOptions.cal_max = volume.cal_max || NaN imageOptions.cal_min = volume.cal_min || NaN imageOptionsArray.push(imageOptions) const encodedImageBlob = NVUtilities.uint8tob64(volume.toUint8Array()) data.encodedImageBlobs!.push(encodedImageBlob) data.imageOptionsMap!.set(volume.id, i) } } // Add it even if it's empty data.imageOptionsArray = [...imageOptionsArray] // meshes const meshes = [] data.connectomes = [] for (const mesh of this.meshes) { if (mesh.type === MeshType.CONNECTOME) { data.connectomes.push(JSON.stringify((mesh as NVConnectome).json())) continue } const copyMesh: Mutable<any> = { pts: mesh.pts, tris: mesh.tris, name: mesh.name, rgba255: Uint8Array.from(mesh.rgba255), opacity: mesh.opacity, connectome: mesh.connectome, dpg: mesh.dpg, dps: mesh.dps, dpv: mesh.dpv, meshShaderIndex: mesh.meshShaderIndex, layers: mesh.layers.map((layer) => ({ values: layer.values, nFrame4D: layer.nFrame4D, frame4D: 0, outlineBorder: layer.outlineBorder, global_min: layer.global_min, global_max: layer.global_max, cal_min: layer.cal_min, cal_max: layer.cal_max, opacity: layer.opacity, colormap: layer.colormap, colormapNegative: layer.colormapNegative, colormapLabel: layer.colormapLabel, useNegativeCmap: layer.useNegativeCmap })), hasConnectome: mesh.hasConnectome, edgeColormap: mesh.edgeColormap, edgeColormapNegative: mesh.edgeColormapNegative, edgeMax: mesh.edgeMax, edgeMin: mesh.edgeMin, edges: mesh.edges && Array.isArray(mesh.edges) ? [...mesh.edges] : [], extentsMax: mesh.extentsMax, extentsMin: mesh.extentsMin, furthestVertexFromOrigin: mesh.furthestVertexFromOrigin, nodeColormap: mesh.nodeColormap, nodeColormapNegative: mesh.nodeColormapNegative, nodeMaxColor: mesh.nodeMaxColor, nodeMinColor: mesh.nodeMinColor, nodeScale: mesh.nodeScale, legendLineThickness: mesh.legendLineThickness, offsetPt0: mesh.offsetPt0, nodes: mesh.nodes } if (mesh.offsetPt0 && mesh.offsetPt0.length > 0) { copyMesh.offsetPt0 = mesh.offsetPt0 copyMesh.fiberGroupColormap = mesh.fiberGroupColormap copyMesh.fiberColor = mesh.fiberColor copyMesh.fiberDither = mesh.fiberDither copyMesh.fiberRadius = mesh.fiberRadius copyMesh.colormap = mesh.colormap } meshes.push(copyMesh) } data.meshesString = JSON.stringify(serialize(meshes)) // Serialize drawBitmap if (this.drawBitmap) { data.encodedDrawingBlob = NVUtilities.uint8tob64(this.drawBitmap) } return data as ExportDocumentData } /** * Downloads a JSON file with options, scene, images, meshes and drawing of {@link Niivue} instance */ async download(fileName: string, compress: boolean): Promise<void> { const data = this.json() const dataText = JSON.stringify(data) const contentType = compress ? 'application/gzip' : 'application/json' let content: string | ArrayBuffer if (compress) { content = await NVUtilities.compressStringToArrayBuffer(dataText) } else { content = dataText } NVUtilities.download(content, fileName, contentType) } /** * Deserialize mesh data objects */ static deserializeMeshDataObjects(document: NVDocument): void { if (document.data.meshesString) { document.meshDataObjects = deserialize(JSON.parse(document.data.meshesString)) for (const mesh of document.meshDataObjects!) { for (const layer of mesh.layers) { if ('colorMap' in layer) { layer.colormap = layer.colorMap as string delete layer.colorMap } if ('colorMapNegative' in layer) { layer.colormapNegative = layer.colorMapNegative as string delete layer.colorMapNegative } } } } } /** * Factory method to return an instance of NVDocument from a URL */ static async loadFromUrl(url: string): Promise<NVDocument> { const response = await fetch(url) const buffer = await response.arrayBuffer() let documentData: DocumentData if (NVUtilities.isArrayBufferCompressed(buffer)) { // The file is gzip compressed const documentText = await NVUtilities.decompressArrayBuffer(buffer) documentData = JSON.parse(documentText) } else { const utf8decoder = new TextDecoder() documentData = JSON.parse(utf8decoder.decode(buffer)) } return NVDocument.loadFromJSON(documentData) } /** * Factory method to return an instance of NVDocument from a File object */ static async loadFromFile(file: Blob): Promise<NVDocument> { const arrayBuffer = await NVUtilities.readFileAsync(file) let dataString: string const document = new NVDocument() if (NVUtilities.isArrayBufferCompressed(arrayBuffer)) { dataString = await NVUtilities.decompressArrayBuffer(arrayBuffer) } else { const utf8decoder = new TextDecoder() dataString = utf8decoder.decode(arrayBuffer) } document.data = JSON.parse(dataString) if (document.data.opts.meshThicknessOn2D === 'infinity') { document.data.opts.meshThicknessOn2D = Infinity } document.scene.sceneData = { ...INITIAL_SCENE_DATA, ...document.data.sceneData } NVDocument.deserializeMeshDataObjects(document) return document } /** * Factory method to return an instance of NVDocument from JSON */ static loadFromJSON(data: DocumentData): NVDocument { const document = new NVDocument() document.data = data if (document.data.opts.meshThicknessOn2D === 'infinity') { document.data.opts.meshThicknessOn2D = Infinity } document.scene.sceneData = { ...INITIAL_SCENE_DATA, ...data.sceneData } NVDocument.deserializeMeshDataObjects(document) return document } }