// nvdocument.ts (updated) // --- imports import { vec3, vec4 } from 'gl-matrix' import { NVSerializer } from '@/nvserializer' // adjust path if needed import { NVUtilities } from '@/nvutilities' import { ImageFromUrlOptions, NVIMAGE_TYPE, NVImage } from '@/nvimage' import { NVMesh } from '@/nvmesh' import { NVLabel3D } from '@/nvlabel' import { NVConnectome } from '@/nvconnectome' /** * Represents a completed measurement between two points */ export interface CompletedMeasurement { startMM: vec3 // World coordinates in mm for start point endMM: vec3 // World coordinates in mm for end point distance: number // Distance between points in mm sliceIndex: number sliceType: SLICE_TYPE slicePosition: number } /** * Represents a completed angle measurement between two lines */ export interface CompletedAngle { firstLineMM: { start: vec3; end: vec3 } // World coordinates in mm for first line secondLineMM: { start: vec3; end: vec3 } // World coordinates in mm for second line angle: number // Angle in degrees sliceIndex: number sliceType: SLICE_TYPE slicePosition: number } /** * Slice Type * @ignore */ export enum SLICE_TYPE { AXIAL = 0, CORONAL = 1, SAGITTAL = 2, MULTIPLANAR = 3, RENDER = 4 } export enum PEN_TYPE { PEN = 0, RECTANGLE = 1, ELLIPSE = 2 } 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, angle = 7, crosshair = 8, windowing = 9 } export interface MouseEventConfig { leftButton: { primary: DRAG_MODE withShift?: DRAG_MODE withCtrl?: DRAG_MODE } rightButton: DRAG_MODE centerButton: DRAG_MODE } export interface TouchEventConfig { singleTouch: DRAG_MODE doubleTouch: DRAG_MODE } /** * NVConfigOptions */ export type NVConfigOptions = { // ... (kept unchanged for brevity — same as your original file) textHeight: number fontSizeScaling: number fontMinPx: number colorbarHeight: number colorbarWidth: number showColorbarBorder: boolean crosshairWidth: number crosshairWidthUnit: 'voxels' | 'mm' | 'percent' crosshairGap: number rulerWidth: number show3Dcrosshair: boolean backColor: number[] crosshairColor: number[] fontColor: Float32List selectionBoxColor: number[] clipPlaneColor: number[] isClipPlanesCutaway: boolean paqdUniforms: number[] rulerColor: number[] colorbarMargin: number trustCalMinMax: boolean clipPlaneHotKey: string cycleClipPlaneHotKey: string viewModeHotKey: string doubleTouchTimeout: number longTouchTimeout: number keyDebounceTime: number isNearestInterpolation: boolean atlasOutline: number atlasActiveIndex: number isRuler: boolean isColorbar: boolean isOrientCube: boolean tileMargin: number multiplanarPadPixels: number multiplanarForceRender: boolean multiplanarEqualSize: boolean multiplanarShowRender: SHOW_RENDER isRadiologicalConvention: boolean meshThicknessOn2D: number | string dragMode: DRAG_MODE dragModePrimary: DRAG_MODE mouseEventConfig?: MouseEventConfig touchEventConfig?: TouchEventConfig yoke3Dto2DZoom: boolean isDepthPickMesh: boolean isCornerOrientationText: boolean isOrientationTextVisible: boolean showAllOrientationMarkers: boolean heroImageFraction: number heroSliceType: SLICE_TYPE sagittalNoseLeft: boolean isSliceMM: boolean isV1SliceShader: boolean forceDevicePixelRatio: number logLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent' loadingText: string isForceMouseClickToVoxelCenters: boolean dragAndDropEnabled: boolean drawingEnabled: boolean penValue: number penType: PEN_TYPE floodFillNeighbors: number isFilledPen: boolean thumbnail: string maxDrawUndoBitmaps: number sliceType: SLICE_TYPE isAntiAlias: boolean | null isAdditiveBlend: boolean isResizeCanvas: boolean meshXRay: number limitFrames4D: number showLegend: boolean legendBackgroundColor: number[] legendTextColor: number[] multiplanarLayout: MULTIPLANAR_TYPE renderOverlayBlend: number sliceMosaicString: string centerMosaic: boolean interactive: boolean penSize: number clickToSegment: boolean clickToSegmentRadius: number clickToSegmentBright: boolean clickToSegmentAutoIntensity: boolean clickToSegmentIntensityMax: number clickToSegmentIntensityMin: number clickToSegmentPercent: number clickToSegmentMaxDistanceMM: number clickToSegmentIs2D: boolean selectionBoxLineThickness: number selectionBoxIsOutline: boolean scrollRequiresFocus: boolean showMeasureUnits: boolean measureTextJustify: 'start' | 'center' | 'end' measureTextColor: number[] measureLineColor: number[] measureTextHeight: number isAlphaClipDark: boolean gradientOrder: number gradientOpacity: number renderSilhouette: number gradientAmount: number invertScrollDirection: boolean is2DSliceShader: boolean bounds: [[number, number], [number, number]] | null showBoundsBorder?: boolean boundsBorderColor?: number[] // [r,g,b,a] // Zarr options /** Chunk cache size for zarr viewing (default 500) */ zarrCacheSize: number /** Number of chunk rings to prefetch around the visible region for zarr viewing (0 disables, default 1) */ zarrPrefetchRings: number } export const DEFAULT_OPTIONS: NVConfigOptions = { // ... (same defaults as your original file) textHeight: -1.0, fontSizeScaling: 0.4, fontMinPx: 13, colorbarHeight: 0.05, colorbarWidth: -1, showColorbarBorder: true, 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], isClipPlanesCutaway: false, paqdUniforms: [0.3, 0.5, 0.5, 1.0], rulerColor: [1, 0, 0, 0.8], colorbarMargin: 0.05, trustCalMinMax: true, clipPlaneHotKey: 'KeyC', cycleClipPlaneHotKey: 'KeyP', viewModeHotKey: 'KeyV', doubleTouchTimeout: 500, longTouchTimeout: 1000, keyDebounceTime: 50, isNearestInterpolation: false, isResizeCanvas: true, atlasOutline: 0, atlasActiveIndex: 0, isRuler: false, isColorbar: false, isOrientCube: false, tileMargin: 0, multiplanarPadPixels: 0, multiplanarForceRender: false, multiplanarEqualSize: false, multiplanarShowRender: SHOW_RENDER.AUTO, isRadiologicalConvention: false, meshThicknessOn2D: Infinity, dragMode: DRAG_MODE.contrast, dragModePrimary: DRAG_MODE.crosshair, mouseEventConfig: undefined, touchEventConfig: undefined, yoke3Dto2DZoom: false, isDepthPickMesh: false, isCornerOrientationText: false, isOrientationTextVisible: true, showAllOrientationMarkers: 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, penType: PEN_TYPE.PEN, 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, interactive: true, clickToSegment: false, clickToSegmentRadius: 3, clickToSegmentBright: true, clickToSegmentAutoIntensity: false, clickToSegmentIntensityMax: NaN, clickToSegmentIntensityMin: NaN, clickToSegmentPercent: 0, clickToSegmentMaxDistanceMM: Number.POSITIVE_INFINITY, clickToSegmentIs2D: false, selectionBoxLineThickness: 4, selectionBoxIsOutline: false, scrollRequiresFocus: false, showMeasureUnits: true, measureTextJustify: 'center', measureTextColor: [1, 0, 0, 1], measureLineColor: [1, 0, 0, 1], measureTextHeight: 0.06, isAlphaClipDark: false, gradientOrder: 1, gradientOpacity: 0.0, renderSilhouette: 0.0, gradientAmount: 0.0, invertScrollDirection: false, is2DSliceShader: false, bounds: null, showBoundsBorder: false, boundsBorderColor: [1, 1, 1, 1], // white border by default // Zarr options zarrCacheSize: 1000, zarrPrefetchRings: 10 } // // -- NEW: Recursive encoded type for NVConfigOptions JSON-safe form // type EncodeNumbersIn = T extends number ? number | string : T extends Array ? Array> : T extends object ? { [K in keyof T]: EncodeNumbersIn } : T type EncodedNVConfigOptions = EncodeNumbersIn // // Utility encode/decode helpers // export const DEFAULT_SCENE_DATA = {} // placeholder if needed elsewhere (kept for completeness) type SceneData = { gamma: number azimuth: number elevation: number crosshairPos: vec3 clipPlanes: number[][] clipPlaneDepthAziElevs: number[][] volScaleMultiplier: number pan2Dxyzmm: vec4 } export const INITIAL_SCENE_DATA = { gamma: 1.0, azimuth: 110, elevation: 10, crosshairPos: vec3.fromValues(0.5, 0.5, 0.5), clipPlanes: [[0, 0, 0, 0]], clipPlaneDepthAziElevs: [[2, 0, 0]], volScaleMultiplier: 1.0, pan2Dxyzmm: vec4.fromValues(0, 0, 0, 1) } 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[] clipPlanes: number[][] clipPlaneDepthAziElevs: number[][] pan2Dxyzmm: vec4 _elevation?: number _azimuth?: number gamma?: number } /** * DocumentData / ExportDocumentData types (kept minimal here) */ export type DocumentData = { title?: string imageOptionsArray?: ImageFromUrlOptions[] meshOptionsArray?: unknown[] opts?: Partial | Partial previewImageDataURL?: string labels?: NVLabel3D[] encodedImageBlobs?: string[] encodedDrawingBlob?: string meshesString?: string sceneData?: Partial connectomes?: string[] customData?: string completedMeasurements?: CompletedMeasurement[] completedAngles?: CompletedAngle[] } export type ExportDocumentData = { title?: string encodedImageBlobs: string[] encodedDrawingBlob: string previewImageDataURL: string imageOptionsMap: Map imageOptionsArray: ImageFromUrlOptions[] sceneData: Partial opts: EncodedNVConfigOptions | Partial meshesString: string meshOptionsArray?: unknown[] labels: NVLabel3D[] connectomes: string[] customData: string completedMeasurements: CompletedMeasurement[] completedAngles: CompletedAngle[] } /** * Returns a partial configuration object containing only the fields in the provided * options that differ from the DEFAULT_OPTIONS. */ // function diffOptions(opts: NVConfigOptions, defaults: NVConfigOptions): Partial { // const diff: Partial = {} // for (const key in opts) { // const value = opts[key] // const def = defaults[key] // const isArray = Array.isArray(value) && Array.isArray(def) // if ((isArray && value.some((v, i) => v !== def[i])) || (!isArray && value !== def)) { // diff[key] = value // } // } // return diff // } /** * NVDocument class (main) */ export class NVDocument { data: DocumentData = { title: 'Untitled document', imageOptionsArray: [], meshOptionsArray: [], opts: { ...DEFAULT_OPTIONS } as any, previewImageDataURL: '', labels: [], encodedImageBlobs: [], encodedDrawingBlob: '' } scene: Scene volumes: NVImage[] = [] meshDataObjects?: Array meshes: Array = [] drawBitmap: Uint8Array | null = null imageOptionsMap = new Map() meshOptionsMap = new Map() completedMeasurements: CompletedMeasurement[] = [] completedAngles: CompletedAngle[] = [] private _optsProxy: NVConfigOptions | null = null private _optsChangeCallback: ((propertyName: keyof NVConfigOptions, newValue: NVConfigOptions[keyof NVConfigOptions], oldValue: NVConfigOptions[keyof NVConfigOptions]) => void) | null = null 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.clipPlanes[0] ?? [] }, set clipPlane(clipPlane) { this.sceneData.clipPlanes[0] = clipPlane }, get clipPlanes(): number[][] { return this.sceneData.clipPlanes }, set clipPlanes(planes: number[][]) { this.sceneData.clipPlanes = planes }, get clipPlaneDepthAziElevs(): number[][] { return this.sceneData.clipPlaneDepthAziElevs }, set clipPlaneDepthAziElevs(values: number[][]) { this.sceneData.clipPlaneDepthAziElevs = values }, get pan2Dxyzmm(): vec4 { return this.sceneData.pan2Dxyzmm }, set pan2Dxyzmm(pan2Dxyzmm) { this.sceneData.pan2Dxyzmm = pan2Dxyzmm }, get gamma(): number { return this.sceneData.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 */ get encodedDrawingBlob(): string { return this.data.encodedDrawingBlob } /** * Gets the options of the {@link Niivue} instance */ get opts(): NVConfigOptions { if (!this._optsProxy) { this._createOptsProxy() } return this._optsProxy as NVConfigOptions } /** * Sets the options of the {@link Niivue} instance */ set opts(opts) { this.data.opts = { ...opts } as any this._optsProxy = null // Force recreation of proxy } /** * 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) } /** * Fetch any image data that is missing from this document. */ async fetchLinkedData(): Promise { this.data.encodedImageBlobs = [] if (!this.imageOptionsArray?.length) { return } for (const imgOpt of this.imageOptionsArray) { if (!imgOpt.url) { continue } try { const response = await fetch(imgOpt.url) if (!response.ok) { console.warn('Failed to fetch image:', imgOpt.url) continue } const buffer = await response.arrayBuffer() const uint8Array = new Uint8Array(buffer) const b64 = NVUtilities.uint8tob64(uint8Array) this.data.encodedImageBlobs.push(b64) console.info('fetch linked data fetched from ', imgOpt.url) } catch (err) { console.warn(`Failed to fetch/encode image from ${imgOpt.url}:`, err) } } } /** * 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 } /** * Serialise the document by delegating to NVSerializer. */ json(embedImages = true, embedDrawing = true): ExportDocumentData { // NVSerializer is responsible for converting typed arrays, encoding special numbers, // producing meshesString, and returning an ExportDocumentData object. return NVSerializer.serializeDocument(this, embedImages, embedDrawing) } async download(fileName: string, compress: boolean, opts: { embedImages: boolean } = { embedImages: true }): Promise { const data = this.json(opts.embedImages) const jsonTxt = JSON.stringify(data) const mime = compress ? 'application/gzip' : 'application/json' const payload = compress ? await NVUtilities.compressStringToArrayBuffer(jsonTxt) : jsonTxt NVUtilities.download(payload, fileName, mime) } /** * Factory method to return an instance of NVDocument from a URL */ static async loadFromUrl(url: string): Promise { const response = await fetch(url) const buffer = await response.arrayBuffer() let documentData: DocumentData if (NVUtilities.isArrayBufferCompressed(buffer)) { 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) } static async loadFromFile(file: Blob): Promise { const arrayBuffer = await NVUtilities.readFileAsync(file) let dataString: string if (NVUtilities.isArrayBufferCompressed(arrayBuffer)) { dataString = await NVUtilities.decompressArrayBuffer(arrayBuffer) } else { const utf8decoder = new TextDecoder() dataString = utf8decoder.decode(arrayBuffer) } const documentData = JSON.parse(dataString) as DocumentData return NVDocument.loadFromJSON(documentData) } /** * Factory method to return an instance of NVDocument from JSON. * Delegates the main parsing to NVSerializer, then applies NVDocument-specific * post-processing (opts decode, scene defaults, clone measurements/angles). */ static async loadFromJSON(data: DocumentData): Promise { return await NVSerializer.deserializeDocument(data) } /** * Sets the callback function to be called when opts properties change */ setOptsChangeCallback(callback: (propertyName: keyof NVConfigOptions, newValue: NVConfigOptions[keyof NVConfigOptions], oldValue: NVConfigOptions[keyof NVConfigOptions]) => void): void { this._optsChangeCallback = callback this._optsProxy = null // Force recreation with new callback } /** * Removes the opts change callback */ removeOptsChangeCallback(): void { this._optsChangeCallback = null this._optsProxy = null // Force recreation without callback } /** * Creates a Proxy wrapper around the opts object to detect changes */ private _createOptsProxy(): void { const target = this.data.opts as NVConfigOptions this._optsProxy = new Proxy(target, { set: (obj: any, prop: string | symbol, value: any): boolean => { const oldValue = obj[prop] // Only proceed if the value actually changed if (oldValue !== value) { obj[prop] = value // Call the change callback if one is registered if (this._optsChangeCallback && typeof prop === 'string' && prop in DEFAULT_OPTIONS) { this._optsChangeCallback(prop as keyof NVConfigOptions, value, oldValue) } } return true }, get: (obj: any, prop: string | symbol): any => { return obj[prop] } }) } }