@niivue/niivue
Version:
minimal webgl2 nifti image viewer
810 lines (729 loc) • 33.3 kB
text/typescript
// src/nvserializer.ts
import { vec3 } from 'gl-matrix'
import { NVUtilities } from '@/nvutilities'
import { NVImage, NVIMAGE_TYPE } from '@/nvimage'
import { NVMesh, MeshType } from '@/nvmesh'
import { NVConnectome } from '@/nvconnectome'
import { log } from '@/logger'
import { NVDocument, DEFAULT_OPTIONS, DocumentData, ExportDocumentData, INITIAL_SCENE_DATA } from '@/nvdocument'
import { migrateLegacyDocument, normalizeMeshForRehydrate } from '@/utils/legacy-migrate' // shared migration utility
/** Helpers for special numeric encoding */
function encodeNumberForJSON(v: number | null | undefined): number | string | undefined {
if (v === undefined) {
return undefined
}
if (v === null) {
return null
}
if (Number.isFinite(v)) {
return v
}
if (v === Infinity) {
return 'infinity'
}
if (v === -Infinity) {
return '-infinity'
}
return 'NaN'
}
function decodeNumberFromJSON(v: any): number | null | undefined {
if (v === undefined) {
return undefined
}
if (v === null) {
return null
}
if (typeof v === 'number') {
return v
}
if (v === 'NaN') {
return NaN
}
if (v === 'infinity') {
return Infinity
}
if (v === '-infinity') {
return -Infinity
}
const n = Number(v)
return Number.isNaN(n) ? NaN : n
}
/** Small safe conversion helper that handles arrays, typed arrays, iterables. */
function toPlainArray<T>(maybe: any): T[] | undefined {
if (maybe === undefined || maybe === null) {
return undefined
}
if (Array.isArray(maybe)) {
return maybe.slice()
}
try {
// cast via unknown to satisfy TS when given typed arrays / arraybufferviews
return Array.from(maybe as unknown as Iterable<T>)
} catch (e) {
return undefined
}
}
/**
* Convert array-like to Float32Array.
* Returns undefined if input is null/undefined.
*/
function toFloat32Array(maybe: any): Float32Array | undefined {
if (maybe === undefined || maybe === null) {
return undefined
}
if (maybe instanceof Float32Array) {
return maybe
}
if (Array.isArray(maybe)) {
return new Float32Array(maybe)
}
if (ArrayBuffer.isView(maybe)) {
return new Float32Array(Array.from(maybe as unknown as Iterable<number>))
}
try {
return new Float32Array(Array.from(maybe as unknown as Iterable<number>))
} catch (e) {
return undefined
}
}
/**
* Convert array-like to Uint32Array.
* Returns undefined if input is null/undefined.
*/
function toUint32Array(maybe: any): Uint32Array | undefined {
if (maybe === undefined || maybe === null) {
return undefined
}
if (maybe instanceof Uint32Array) {
return maybe
}
if (Array.isArray(maybe)) {
return new Uint32Array(maybe)
}
if (ArrayBuffer.isView(maybe)) {
return new Uint32Array(Array.from(maybe as unknown as Iterable<number>))
}
try {
return new Uint32Array(Array.from(maybe as unknown as Iterable<number>))
} catch (e) {
return undefined
}
}
/**
* Convert array-like to Uint8Array.
* Returns undefined if input is null/undefined.
*/
function toUint8Array(maybe: any): Uint8Array | undefined {
if (maybe === undefined || maybe === null) {
return undefined
}
if (maybe instanceof Uint8Array) {
return maybe
}
if (Array.isArray(maybe)) {
return new Uint8Array(maybe)
}
if (ArrayBuffer.isView(maybe)) {
return new Uint8Array(Array.from(maybe as unknown as Iterable<number>))
}
try {
return new Uint8Array(Array.from(maybe as unknown as Iterable<number>))
} catch (e) {
return undefined
}
}
/** replacer to convert typed arrays / array buffers to plain arrays for JSON.stringify */
function jsonReplacer(_key: string, value: any): any {
if (typeof value === 'function') {
return undefined
}
if (typeof value === 'symbol') {
return undefined
}
// Typed arrays & DataView -> array of numbers
if (ArrayBuffer.isView(value)) {
if (value instanceof DataView) {
return Array.from(new Uint8Array((value as DataView).buffer, (value as DataView).byteOffset, (value as DataView).byteLength))
}
// cast through unknown to avoid TS complaining about missing iterator
return Array.from(value as unknown as Iterable<number>)
}
if (value instanceof ArrayBuffer) {
return Array.from(new Uint8Array(value))
}
return value
}
export class NVSerializer {
/**
* Decode/normalize "opts" encoded form into NVConfigOptions shape.
* Move the decodeOptsFromJSON logic here (serializer owns encoding/decoding).
*/
static decodeOptsFromJSON(opts: any): any {
if (opts === undefined || opts === null) {
return undefined
}
const decodeRecursive = (v: any): any => {
if (v === undefined) {
return undefined
}
if (v === null) {
return null
}
if (typeof v === 'number') {
return v
}
if (typeof v === 'string') {
// try to decode special numeric strings
if (v === 'NaN') {
return NaN
}
if (v === 'infinity') {
return Infinity
}
if (v === '-infinity') {
return -Infinity
}
return v
}
if (Array.isArray(v)) {
return v.map((el) => decodeRecursive(el))
}
if (typeof v === 'object') {
const out: any = {}
for (const k of Object.keys(v)) {
out[k] = decodeRecursive(v[k])
}
return out
}
return v
}
const out: any = {}
for (const k of Object.keys(opts)) {
const val = opts[k]
if (val === undefined) {
continue
}
out[k] = decodeRecursive(val)
}
return out
}
/**
* Serialize an NVDocument-like object into ExportDocumentData (same shape as NVDocument.json)
* (This function mirrors the responsibilities of NVDocument.json in your code).
*/
static serializeDocument(document: any, embedImages = true, embedDrawing = true): ExportDocumentData {
const out: Partial<ExportDocumentData> = {
encodedImageBlobs: [],
previewImageDataURL: document.previewImageDataURL ?? '',
imageOptionsMap: new Map<string, number>()
}
out.sceneData = { ...(document.scene?.sceneData ?? {}) }
out.opts = document.opts ?? {}
out.labels = Array.isArray(document.labels)
? document.labels.map((l: any) => {
const copy = { ...(l || {}) }
delete copy.onClick
return copy
})
: []
out.customData = document.customData ?? ''
out.completedMeasurements = document.completedMeasurements ? document.completedMeasurements.slice() : []
out.completedAngles = document.completedAngles ? document.completedAngles.slice() : []
// images
const imageOptionsArray: any[] = []
const volumes = document.volumes ?? []
if (volumes.length) {
for (let i = 0; i < volumes.length; i++) {
const v = volumes[i]
let imageOptions = document.getImageOptions ? document.getImageOptions(v) : null
if (imageOptions === null) {
// fallback minimal options
imageOptions = {
name: v?.name ?? '',
colormap: v?._colormap ?? 'gray',
opacity: v?._opacity ?? 1.0,
cal_min: v?.cal_min ?? NaN,
cal_max: v?.cal_max ?? NaN,
trustCalMinMax: v?.trustCalMinMax ?? true,
percentileFrac: v?.percentileFrac ?? 0.02,
ignoreZeroVoxels: v?.ignoreZeroVoxels ?? false,
imageType: v?.imageType ?? NVIMAGE_TYPE.NII,
frame4D: v?.frame4D ?? 0,
limitFrames4D: v?.limitFrames4D ?? NaN,
url: v?.url ?? '',
urlImageData: v?.urlImgData ?? '',
cal_minNeg: v?.cal_minNeg ?? NaN,
cal_maxNeg: v?.cal_maxNeg ?? NaN,
colorbarVisible: v?.colorbarVisible ?? true,
colormapNegative: v?.colormapNegative ?? '',
colormapType: v?.colormapType ?? 0
}
} else {
if (!('imageType' in imageOptions)) {
imageOptions.imageType = NVIMAGE_TYPE.NII
}
}
imageOptions.colormap = v.colormap
imageOptions.colormapLabel = v.colormapLabel
imageOptions.opacity = v.opacity
imageOptions.cal_max = v.cal_max ?? NaN
imageOptions.cal_min = v.cal_min ?? NaN
imageOptions.colormapNegative = v.colormapNegative ?? ''
imageOptions.cal_minNeg = v.cal_minNeg ?? NaN
imageOptions.cal_maxNeg = v.cal_maxNeg ?? NaN
imageOptions.colorbarVisible = v.colorbarVisible ?? true
imageOptions.colormapType = v.colormapType ?? 0
imageOptionsArray.push(imageOptions)
if (embedImages) {
const blob = NVUtilities.uint8tob64(v.toUint8Array())
out.encodedImageBlobs!.push(blob)
}
out.imageOptionsMap!.set(v.id, i)
}
}
out.imageOptionsArray = imageOptionsArray
// meshes: build safe clones and convert typed arrays to plain arrays
const meshesForExport: any[] = []
const meshes = document.meshes || []
out.connectomes = []
for (const mesh of meshes) {
if (!mesh || typeof mesh !== 'object') {
continue
}
if (mesh.type === MeshType.CONNECTOME) {
try {
out.connectomes!.push(JSON.stringify((mesh as NVConnectome).json()))
} catch (e) {
log.warn('serializeDocument: failed connectome export', e)
}
continue
}
// build layers
const layersForExport = (mesh.layers || []).map((layer: any) => {
const exported: any = {
name: layer?.name,
key: layer?.key,
url: layer?.url,
headers: layer?.headers,
opacity: layer?.opacity,
colormap: layer?.colorMap ?? layer?.colormap,
colormapNegative: layer?.colorMapNegative ?? layer?.colormapNegative,
colormapInvert: layer?.colormapInvert,
colormapLabel: layer?.colormapLabel,
useNegativeCmap: layer?.useNegativeCmap,
// numeric meta
global_min: encodeNumberForJSON(layer?.global_min),
global_max: encodeNumberForJSON(layer?.global_max),
cal_min: encodeNumberForJSON(layer?.cal_min),
cal_max: encodeNumberForJSON(layer?.cal_max),
cal_minNeg: encodeNumberForJSON(layer?.cal_minNeg),
cal_maxNeg: encodeNumberForJSON(layer?.cal_maxNeg),
isAdditiveBlend: layer?.isAdditiveBlend,
frame4D: layer?.frame4D,
nFrame4D: layer?.nFrame4D,
outlineBorder: layer?.outlineBorder,
isTransparentBelowCalMin: layer?.isTransparentBelowCalMin,
colormapType: layer?.colormapType,
base64: layer?.base64,
colorbarVisible: layer?.colorbarVisible,
showLegend: layer?.showLegend
}
if (layer?.values != null) {
const vals = Array.isArray(layer.values) ? layer.values.slice() : Array.from((layer.values as unknown as Iterable<number>) || [])
exported.values = vals.map((v: any) => {
const num = Number(v)
return Number.isFinite(num) ? Math.round(num * 1e12) / 1e12 : num
})
}
if (layer?.atlasValues != null) {
exported.atlasValues = Array.isArray(layer.atlasValues) ? layer.atlasValues.slice() : Array.from((layer.atlasValues as unknown as Iterable<number>) || [])
}
if (Array.isArray(layer?.labels)) {
exported.labels = layer.labels.map((l: any) => ({ ...(l || {}) }))
} else if (layer?.labels != null) {
exported.labels = Array.from((layer.labels as unknown as Iterable<any>) || [])
}
return exported
})
const meshCopy: any = {
...Object.assign({}, mesh || {}),
rgba255: Array.from(mesh.rgba255 || []),
layers: layersForExport,
edges: Array.isArray(mesh?.edges) ? mesh.edges.map((e: any) => ({ ...(e || {}) })) : [],
nodes: Array.isArray(mesh?.nodes) ? mesh.nodes.map((n: any) => ({ ...(n || {}) })) : mesh?.nodes,
offsetPt0: Array.isArray(mesh?.offsetPt0) ? [...mesh.offsetPt0] : mesh?.offsetPt0
}
// fiber fields
if (Array.isArray(mesh.offsetPt0) && mesh.offsetPt0.length > 0) {
meshCopy.offsetPt0 = Array.isArray(mesh.offsetPt0) ? [...mesh.offsetPt0] : mesh.offsetPt0
if (Array.isArray(mesh.fiberGroupColormap)) {
meshCopy.fiberGroupColormap = [...mesh.fiberGroupColormap]
}
if (Array.isArray(mesh.fiberColor)) {
meshCopy.fiberColor = Array.from(mesh.fiberColor)
}
meshCopy.fiberDither = mesh.fiberDither
meshCopy.fiberRadius = mesh.fiberRadius
meshCopy.colormap = mesh.colormap
}
meshesForExport.push(meshCopy)
}
try {
out.meshesString = JSON.stringify(meshesForExport, jsonReplacer)
} catch (err) {
log.warn('serializeDocument: JSON.stringify failed for meshes', err)
// fallback: per-mesh serialization
const per: string[] = []
for (let i = 0; i < meshesForExport.length; i++) {
try {
per.push(JSON.stringify(meshesForExport[i], jsonReplacer))
} catch (e) {
log.error('serializeDocument: failed to serialize mesh at index', i, e, meshesForExport[i])
per.push(JSON.stringify({ name: meshesForExport[i]?.name ?? 'unknown', id: meshesForExport[i]?.id ?? null }))
}
}
out.meshesString = `[${per.join(',')}]`
}
if (embedDrawing && document.drawBitmap) {
out.encodedDrawingBlob = NVUtilities.uint8tob64(document.drawBitmap)
} else {
out.encodedDrawingBlob = document.encodedDrawingBlob ?? ''
}
out.title = document.title ?? 'untitled'
out.customData = document.customData ?? ''
out.labels = Array.isArray(document.labels)
? document.labels.map((l: any) => {
const copy = { ...(l || {}) }
delete copy.onClick
return copy
})
: []
// Export completed measurements & angles (clone vectors/objects so they survive JSON roundtrip)
out.completedMeasurements = (document.completedMeasurements || []).map((m: any) => ({
...m,
startMM: Array.isArray(m.startMM) ? [...m.startMM] : m.startMM,
endMM: Array.isArray(m.endMM) ? [...m.endMM] : m.endMM
}))
out.completedAngles = (document.completedAngles || []).map((a: any) => ({
...a,
firstLineMM: {
start: Array.isArray(a.firstLineMM?.start) ? [...a.firstLineMM.start] : a.firstLineMM?.start,
end: Array.isArray(a.firstLineMM?.end) ? [...a.firstLineMM.end] : a.firstLineMM?.end
},
secondLineMM: {
start: Array.isArray(a.secondLineMM?.start) ? [...a.secondLineMM.start] : a.secondLineMM?.start,
end: Array.isArray(a.secondLineMM?.end) ? [...a.secondLineMM.end] : a.secondLineMM?.end
}
}))
// Make sure sceneData and opts are included (you already had these, but double-check)
out.sceneData = out.sceneData ?? { ...(document.scene?.sceneData ?? {}) }
out.opts = out.opts ?? (document.opts ? { ...(document.opts as any) } : {})
return out as ExportDocumentData
}
/**
* Rehydrate images into NVImage instances using NVImage.new
*/
static async rehydrateImages(documentData: DocumentData): Promise<NVImage[]> {
const imgs: NVImage[] = []
// Normalize legacy shapes first
const migrated = await migrateLegacyDocument(documentData)
const encoded = migrated.encodedImageBlobs ?? []
const optsArr = migrated.imageOptionsArray ?? []
for (let i = 0; i < optsArr.length; i++) {
const imageOptions = { ...(optsArr[i] || {}) } as any
try {
const b64 = encoded[i]
if (!b64) {
log.debug('NVSerializer.rehydrateImages: no blob for index', i)
continue
}
const u8 = await NVUtilities.b64toUint8(b64)
if (!u8) {
log.warn('NVSerializer.rehydrateImages: b64toUint8 returned empty for index', i)
continue
}
const name = imageOptions.name ?? imageOptions.url ?? `image-${i}`
// Ensure colormap keys are strings when we call color-table lookups.
// Legacy data sometimes stores colormaps in typed-array / object shapes —
// guard against that so colormapFromKey(name) doesn't throw.
let colormap: string | any[] = imageOptions.colormap ?? imageOptions.colorMap ?? ''
if (typeof colormap !== 'string') {
// Prefer the explicitly provided colorMap string if present
if (typeof imageOptions.colorMap === 'string') {
colormap = imageOptions.colorMap
} else {
// otherwise treat as "no named colormap" so colour-table lookup won't be attempted
colormap = ''
}
}
const opacity = imageOptions.opacity ?? 1.0
const pairedImgData = imageOptions.pairedImgData
? typeof imageOptions.pairedImgData === 'string'
? (await NVUtilities.b64toUint8(imageOptions.pairedImgData)).buffer
: imageOptions.pairedImgData
: null
const cal_min = decodeNumberFromJSON(imageOptions.cal_min)
const cal_max = decodeNumberFromJSON(imageOptions.cal_max)
const trustCalMinMax = imageOptions.trustCalMinMax ?? true
const percentileFrac = imageOptions.percentileFrac ?? 0.02
const ignoreZeroVoxels = imageOptions.ignoreZeroVoxels ?? false
const useQFormNotSForm = imageOptions.useQFormNotSForm ?? false
const colormapNegative = imageOptions.colormapNegative ?? ''
const frame4D = imageOptions.frame4D ?? 0
const imageType = 'imageType' in imageOptions && typeof imageOptions.imageType === 'number' ? imageOptions.imageType : (imageOptions.imageType ?? NVIMAGE_TYPE.UNKNOWN)
const cal_minNeg = decodeNumberFromJSON(imageOptions.cal_minNeg)
const cal_maxNeg = decodeNumberFromJSON(imageOptions.cal_maxNeg)
const colorbarVisible = imageOptions.colorbarVisible ?? true
const colormapLabel = imageOptions.colormapLabel ?? null
const colormapType = imageOptions.colormapType ?? 0
const zarrData = imageOptions.zarrData ?? null
// ensure ArrayBuffer argument
console.log(`Image ${i} buffer length: ${u8.byteLength}`)
// Check if the data is actually all zeros before Niivue gets it
const isAllZeros = !u8.some((val) => val !== 0)
if (isAllZeros) {
console.error(`CRITICAL: Base64 decoding for ${name} resulted in an all-zero array!`)
}
const bufferArg = u8.slice().buffer
const img = await NVImage.new(
bufferArg,
name,
colormap as string,
opacity,
pairedImgData,
cal_min as any,
cal_max as any,
trustCalMinMax,
percentileFrac,
ignoreZeroVoxels,
useQFormNotSForm,
colormapNegative,
frame4D,
imageType,
cal_minNeg as any,
cal_maxNeg as any,
colorbarVisible,
colormapLabel,
colormapType,
zarrData
)
imgs.push(img)
} catch (err) {
log.warn('NVSerializer.rehydrateImages: failed for index', i, err)
}
}
return imgs
}
/**
* Rehydrate meshes into runtime NVMesh instances.
* If gl is provided and callUpdateMesh=true, updateMesh(gl) will be called.
*
* CRITICAL FIX: Properly convert arrays to TypedArrays before passing to NVMesh constructor
* to avoid "underspecified mesh" warnings.
*/
static async rehydrateMeshes(documentData: DocumentData, gl?: WebGL2RenderingContext, callUpdateMesh = false): Promise<Array<NVMesh | any>> {
const out: Array<NVMesh | any> = []
// Normalize legacy shapes first
const migrated = await migrateLegacyDocument(documentData)
const meshesString = migrated.meshesString ?? '[]'
let parsed: any[] = []
try {
parsed = JSON.parse(meshesString)
} catch (e) {
console.warn('NVSerializer.rehydrateMeshes: failed to parse meshesString', e)
parsed = []
}
for (let i = 0; i < parsed.length; i++) {
let m = parsed[i] || {}
try {
m = normalizeMeshForRehydrate(m)
// Normalize layer keys and numeric encodings (colorMap -> colormap etc.)
if (Array.isArray(m.layers)) {
for (const layer of m.layers) {
if (!layer) {
continue
}
if ('colorMap' in layer && !('colormap' in layer)) {
layer.colormap = layer.colorMap
delete layer.colorMap
}
if ('colorMapNegative' in layer && !('colormapNegative' in layer)) {
layer.colormapNegative = layer.colorMapNegative
delete layer.colorMapNegative
}
// decode special numeric strings (in case encodeNumberForJSON used)
layer.global_min = decodeNumberFromJSON(layer.global_min)
layer.global_max = decodeNumberFromJSON(layer.global_max)
layer.cal_min = decodeNumberFromJSON(layer.cal_min)
layer.cal_max = decodeNumberFromJSON(layer.cal_max)
layer.cal_minNeg = decodeNumberFromJSON(layer.cal_minNeg)
layer.cal_maxNeg = decodeNumberFromJSON(layer.cal_maxNeg)
// ensure values/atlasValues are plain arrays (migration already attempted to normalize)
if (layer.values != null && !Array.isArray(layer.values)) {
try {
layer.values = Array.from(layer.values)
} catch (e) {
/* leave as-is */
}
}
if (layer.atlasValues != null && !Array.isArray(layer.atlasValues)) {
try {
layer.atlasValues = Array.from(layer.atlasValues)
} catch (e) {
/* leave as-is */
}
}
}
}
// CRITICAL: Convert arrays to proper TypedArrays BEFORE creating NVMesh
// This prevents the "underspecified mesh" warning in updateMesh
// Convert rgba255 to Uint8Array
const rgba255 = toUint8Array(m.rgba255) ?? new Uint8Array([255, 255, 255, 255])
// Convert pts to Float32Array (vertex positions)
const pts = toFloat32Array(m.pts)
// Convert tris to Uint32Array (triangle indices)
let tris = toUint32Array(m.tris)
// ensure nodes/edges plain arrays
if (Array.isArray(m.nodes)) {
m.nodes = m.nodes.length > 0 && typeof m.nodes[0] === 'object' ? m.nodes.map((n: any) => ({ ...n })) : m.nodes.slice()
}
if (Array.isArray(m.edges)) {
m.edges = m.edges.length > 0 && typeof m.edges[0] === 'object' ? m.edges.map((e: any) => ({ ...e })) : m.edges.slice()
}
// preserve id if present (important)
const persistedId = m.id !== undefined ? m.id : null
// If no GL provided, keep as plain object (tests may parse/expect object form)
if (!gl) {
// For test environments, keep arrays as-is
out.push(m)
continue
}
// Handle fiber meshes: offsetPt0 becomes tris
if (Array.isArray(m.offsetPt0) && m.offsetPt0.length > 0) {
tris = new Uint32Array(m.offsetPt0)
// Set alpha to 0 for fibers
if (rgba255) {
rgba255[3] = 0
}
}
// produce a plain Uint8Array backed by ArrayBuffer
const finalizedRgba = new Uint8Array(Array.isArray(rgba255) ? rgba255 : Array.from(rgba255 as Uint8Array))
const meshInstance = new NVMesh(pts, tris, m.name, finalizedRgba, m.opacity, m.visible, gl, m.connectome, m.dpg, m.dps, m.dpv)
// restore preserved id (overwrite generated id)
if (persistedId !== null) {
;(meshInstance as any).id = persistedId
}
// fiber metadata
if (Array.isArray(m.offsetPt0) && m.offsetPt0.length > 0) {
meshInstance.fiberGroupColormap = m.fiberGroupColormap
meshInstance.fiberColor = m.fiberColor ? m.fiberColor : undefined
meshInstance.fiberDither = m.fiberDither
meshInstance.fiberRadius = m.fiberRadius
meshInstance.colormap = m.colormap
}
// layers & shader index
meshInstance.meshShaderIndex = m.meshShaderIndex
meshInstance.layers = m.layers || []
if (callUpdateMesh) {
try {
meshInstance.updateMesh(gl)
} catch (e) {
console.warn('NVSerializer.rehydrateMeshes: updateMesh failed for idx', i, e)
}
}
out.push(meshInstance)
} catch (err) {
console.warn('NVSerializer.rehydrateMeshes: failed idx', i, err)
// push the plain object as fallback so lengths/indices remain stable
out.push(m)
}
}
// Defensive: guarantee an array is returned for all (possibly-legacy) inputs.
try {
// Defensive: guarantee an array is returned for all (possibly-legacy) inputs.
if (!Array.isArray(out)) {
console.warn('NVSerializer.rehydrateMeshes: unexpected non-array output, coercing to array', {
typeofOut: typeof out,
isArray: Array.isArray(out),
outPreview: out && (typeof out === 'object' ? Object.keys(out).slice(0, 10) : out)
})
return out ? [out] : []
}
return out
} catch (err) {
console.error('NVSerializer.rehydrateMeshes: final-return error', err)
return []
}
}
static async deserializeDocument(documentData: DocumentData): Promise<NVDocument> {
// run migration to normalize legacy shapes up-front
const migrated = await migrateLegacyDocument(documentData)
// Create a fresh NVDocument
const document = new NVDocument()
// Basic top-level fields (preserve defaults where missing)
Object.assign(document.data, {
...migrated,
imageOptionsArray: migrated.imageOptionsArray ?? [],
encodedImageBlobs: migrated.encodedImageBlobs ?? [],
labels: migrated.labels ?? [],
meshOptionsArray: migrated.meshOptionsArray ?? [],
connectomes: migrated.connectomes ?? [],
encodedDrawingBlob: migrated.encodedDrawingBlob ?? '',
previewImageDataURL: migrated.previewImageDataURL ?? '',
customData: migrated.customData ?? '',
title: migrated.title ?? 'untitled'
})
// decode opts and merge with defaults
const decodedOpts = NVSerializer.decodeOptsFromJSON((migrated as any).opts as any)
document.data.opts = {
...DEFAULT_OPTIONS,
...(decodedOpts || {})
} as any
if ((document.data.opts as any).meshThicknessOn2D === 'infinity') {
;(document.data.opts as any).meshThicknessOn2D = Infinity
}
// scene data (merge with initial scene)
document.scene.sceneData = {
...INITIAL_SCENE_DATA,
...(migrated.sceneData || {})
}
// back-compat: single clipPlane fields -> arrays
const sceneDataAny: any = migrated.sceneData || {}
if (sceneDataAny.clipPlane && !sceneDataAny.clipPlanes) {
document.scene.sceneData.clipPlanes = [sceneDataAny.clipPlane]
}
if (sceneDataAny.clipPlaneDepthAziElev && !sceneDataAny.clipPlaneDepthAziElevs) {
document.scene.sceneData.clipPlaneDepthAziElevs = [sceneDataAny.clipPlaneDepthAziElev]
}
// restore completed measurements & angles (clone vectors)
if (migrated.completedMeasurements) {
document.completedMeasurements = migrated.completedMeasurements.map((m: any) => ({
...m,
startMM: vec3.clone(m.startMM),
endMM: vec3.clone(m.endMM)
}))
}
if (migrated.completedAngles) {
document.completedAngles = migrated.completedAngles.map((a: any) => ({
...a,
firstLineMM: {
start: vec3.clone(a.firstLineMM.start),
end: vec3.clone(a.firstLineMM.end)
},
secondLineMM: {
start: vec3.clone(a.secondLineMM.start),
end: vec3.clone(a.secondLineMM.end)
}
}))
}
// keep other runtime lists in sync
// preserve connectome strings (Niivue.loadDocument adds them as runtime meshes later)
document.data.connectomes = migrated.connectomes ?? []
// drawing blob stays as encoded base64 (consumer will decode)
document.data.encodedDrawingBlob = migrated.encodedDrawingBlob ?? ''
// preview image
document.data.previewImageDataURL = migrated.previewImageDataURL ?? ''
return document
}
}
export default NVSerializer