@speckle/shared
Version:
Shared code between various Speckle JS packages
398 lines (371 loc) • 12.5 kB
text/typescript
import { has, intersection, isNumber, isObjectLike } from '#lodash'
import type { MaybeNullOrUndefined, Nullable } from '../../core/helpers/utilityTypes.js'
import type { PartialDeep } from 'type-fest'
import { UnformattableSerializedViewerStateError } from '../errors/index.js'
import { coerceUndefinedValuesToNull } from '../../core/index.js'
export const defaultViewModeEdgeColorValue = 'DEFAULT_EDGE_COLOR'
/** Redefining these is unfortunate. Especially since they are not part of viewer-core */
export enum MeasurementType {
PERPENDICULAR = 0,
POINTTOPOINT = 1,
AREA = 2,
POINT = 3
}
export interface MeasurementOptions {
visible: boolean
type?: MeasurementType
vertexSnap?: boolean
units?: string
precision?: number
chain?: boolean
}
export interface MeasurementData {
type: MeasurementType
startPoint: readonly [number, number, number] // vec3
endPoint: readonly [number, number, number] // vec3
startNormal: readonly [number, number, number] // vec3
endNormal: readonly [number, number, number] // vec3
value: number
innerPoints?: (readonly [number, number, number])[] // array of vec3
units?: string
precision?: number
uuid: string
}
export const defaultMeasurementOptions: Readonly<MeasurementOptions> = Object.freeze({
visible: true,
type: MeasurementType.POINTTOPOINT,
vertexSnap: false,
units: 'm',
precision: 2
})
export interface SectionBoxData {
min: number[]
max: number[]
rotation?: number[]
}
/**
* v1 -> v1.1
* - ui.filters.propertyFilter.isApplied field added
* - ui.spotlightUserId swapped for spotlightUserSessionId
* v1.1 -> v1.2
* - ui.diff added
* v1.2 -> v1.3
* - ui.filters.selectedObjectIds removed in favor of ui.filters.selectedObjectApplicationIds
* v1.3 -> 1.4
* - ui.viewMode -> ui.viewMode.mode
* - ui.viewMode has new keys: edgesEnabled, edgesWeight, outlineOpacity, edgesColor
* v1.4 -> 1.5
* - ui.measurement.measurements added
* v1.5 -> 1.6
* - ui.filters.propertyFilter -> propertyFilters
* - activeColorFilterId added
* v1.6 -> 1.7
* - ui.filters.filterLogic added
* - ui.filters.propertyFilters.condition updated
* v1.7 -> 1.8
* - ui.filters.propertyFilters.numericRange added
*/
export const SERIALIZED_VIEWER_STATE_VERSION = 1.8
export type SerializedViewerState = {
projectId: string
sessionId: string
viewer: {
metadata: {
filteringState: Nullable<{
passMin?: MaybeNullOrUndefined<number>
passMax?: MaybeNullOrUndefined<number>
}>
}
}
resources: {
request: {
resourceIdString: string
threadFilters: {
includeArchived?: MaybeNullOrUndefined<boolean>
loadedVersionsOnly?: MaybeNullOrUndefined<boolean>
}
}
}
ui: {
threads: {
openThread: {
threadId: Nullable<string>
isTyping: boolean
newThreadEditor: boolean
}
}
diff: {
command: Nullable<string>
time: number
mode: number
}
spotlightUserSessionId: Nullable<string>
filters: {
isolatedObjectIds: string[]
hiddenObjectIds: string[]
/** Map of object id => application id or null, if no application id */
selectedObjectApplicationIds: Record<string, string | null>
propertyFilters: Array<{
key: Nullable<string>
isApplied: boolean
selectedValues: string[]
id: string
condition: string
numericRange?: { min: number; max: number }
}>
activeColorFilterId: Nullable<string>
filterLogic: string
}
camera: {
position: number[]
target: number[]
isOrthoProjection: boolean
zoom: number
}
viewMode: {
mode: number
edgesEnabled: boolean
edgesWeight: number
outlineOpacity: number
edgesColor: typeof defaultViewModeEdgeColorValue | number
}
sectionBox: Nullable<SectionBoxData>
lightConfig: {
intensity?: number
indirectLightIntensity?: number
elevation?: number
azimuth?: number
}
explodeFactor: number
selection: Nullable<number[]>
measurement: {
enabled: boolean
options: Nullable<MeasurementOptions>
measurements: Array<MeasurementData>
}
}
}
export type VersionedSerializedViewerState = {
version: number
state: SerializedViewerState
}
type UnformattedState = PartialDeep<
SerializedViewerState & {
// Properties removed from earlier viewer state versions
ui: {
filters: {
selectedObjectIds: string[]
// Legacy single propertyFilter for migration
propertyFilter: {
key: Nullable<string>
isApplied: boolean
}
}
}
}
>
/**
* Note: This only does superficial validation. To really ensure that all of the keys are there, even if prefilled with default values, make sure you invoke
* formatSerializedViewerState() on the state afterwards
*/
export const isSerializedViewerState = (val: unknown): val is SerializedViewerState => {
if (!val) return false
const keys: Array<keyof SerializedViewerState> = [
'projectId',
'sessionId',
'resources',
'ui',
'viewer'
]
if (!isObjectLike(val)) return false
const valKeys = Object.keys(val as Record<string, unknown>)
if (intersection(valKeys, keys).length !== keys.length) return false
return true
}
const initializeMissingData = (state: UnformattedState): SerializedViewerState => {
const throwInvalidError = (missingPath: string): never => {
throw new UnformattableSerializedViewerStateError(
'Required data missing from SerializedViewerState: ' + missingPath
)
}
const measurementOptions = {
...defaultMeasurementOptions,
...state.ui?.measurement?.options
}
const selectedObjectApplicationIds = {
// Parse legacy object ids array as object
...(state.ui?.filters?.selectedObjectIds?.reduce((ret, id) => {
ret[id] = null
return ret
}, {} as Record<string, string | null>) ?? {}),
// Sanitize incoming object
...coerceUndefinedValuesToNull(
state.ui?.filters?.selectedObjectApplicationIds || {}
)
}
const viewModeType = isNumber(state.ui?.viewMode)
? state.ui.viewMode
: state.ui?.viewMode?.mode
const viewModeSettings = isNumber(state.ui?.viewMode) ? {} : state.ui?.viewMode
return {
projectId: state.projectId || throwInvalidError('projectId'),
sessionId: state.sessionId || `nullSessionId-${Math.random() * 1000}`,
viewer: {
...(state.viewer || {}),
metadata: {
...(state.viewer?.metadata || {}),
filteringState: state.viewer?.metadata?.filteringState || null
}
},
resources: {
...(state.resources || {}),
request: {
...(state.resources?.request || {}),
resourceIdString:
state.resources?.request?.resourceIdString ||
throwInvalidError('resources.request.resourceIdString'),
threadFilters: {
...(state.resources?.request?.threadFilters || {}),
includeArchived:
state.resources?.request?.threadFilters?.includeArchived || false,
loadedVersionsOnly:
state.resources?.request?.threadFilters?.loadedVersionsOnly || false
}
}
},
ui: {
...(state.ui || {}),
threads: {
...(state.ui?.threads || {}),
openThread: {
...(state.ui?.threads?.openThread || {}),
threadId: state.ui?.threads?.openThread?.threadId || null,
isTyping: state.ui?.threads?.openThread?.isTyping || false,
newThreadEditor: state.ui?.threads?.openThread?.newThreadEditor || false
}
},
diff: {
...(state.ui?.diff || {}),
command: state.ui?.diff?.command || null,
time: state.ui?.diff?.time || 0.5,
mode: state.ui?.diff?.mode || 1
},
spotlightUserSessionId: state.ui?.spotlightUserSessionId || null,
filters: (() => {
const baseFilters = {
...(state.ui?.filters || {}),
isolatedObjectIds: state.ui?.filters?.isolatedObjectIds || [],
hiddenObjectIds: state.ui?.filters?.hiddenObjectIds || [],
selectedObjectApplicationIds,
activeColorFilterId: state.ui?.filters?.activeColorFilterId || null
}
// Migration logic: handle legacy propertyFilter and new propertyFilters
let propertyFilters: Array<{
key: Nullable<string>
isApplied: boolean
selectedValues: string[]
id: string
condition: string
numericRange?: { min: number; max: number }
}> = []
// If new propertyFilters exist and are not empty, use them
if (
state.ui?.filters?.propertyFilters &&
Array.isArray(state.ui.filters.propertyFilters) &&
state.ui.filters.propertyFilters.length > 0
) {
// Map legacy condition values to new format
propertyFilters = state.ui.filters.propertyFilters.map((filter) => ({
...filter,
condition:
filter.condition === 'AND'
? 'is'
: filter.condition === 'OR'
? 'is'
: filter.condition
}))
}
// If legacy propertyFilter exists but no propertyFilters (or empty propertyFilters), migrate it
else if (state.ui?.filters?.propertyFilter?.key) {
propertyFilters = [
{
key: state.ui.filters.propertyFilter.key,
isApplied: state.ui.filters.propertyFilter.isApplied || false,
selectedValues: [], // Legacy didn't have selectedValues
id: 'legacy-filter', // Generate a consistent ID for legacy filter
condition: 'is'
}
]
}
return {
...baseFilters,
propertyFilters,
filterLogic: state.ui?.filters?.filterLogic || 'all'
}
})(),
camera: {
...(state.ui?.camera || {}),
position: state.ui?.camera?.position || throwInvalidError('ui.camera.position'),
target: state.ui?.camera?.target || throwInvalidError('ui.camera.target'),
isOrthoProjection: state.ui?.camera?.isOrthoProjection || false,
zoom: state.ui?.camera?.zoom || 1
},
viewMode: {
mode: viewModeType ?? 0,
edgesEnabled: viewModeSettings?.edgesEnabled ?? true,
edgesWeight: viewModeSettings?.edgesWeight ?? 1,
outlineOpacity: viewModeSettings?.outlineOpacity ?? 0.75,
edgesColor: viewModeSettings?.edgesColor ?? defaultViewModeEdgeColorValue
},
sectionBox:
state.ui?.sectionBox?.min?.length && state.ui?.sectionBox.max?.length
? // Complains otherwise
(state.ui.sectionBox as SectionBoxData)
: null,
lightConfig: {
...(state.ui?.lightConfig || {}),
intensity: state.ui?.lightConfig?.intensity,
indirectLightIntensity: state.ui?.lightConfig?.indirectLightIntensity,
elevation: state.ui?.lightConfig?.elevation,
azimuth: state.ui?.lightConfig?.azimuth
},
explodeFactor: state.ui?.explodeFactor || 0,
selection: state.ui?.selection || null,
measurement: {
enabled: state.ui?.measurement?.enabled ?? false,
options: measurementOptions,
measurements: state.ui?.measurement?.measurements || []
}
}
}
}
/**
* Formats SerializedViewerState by bringing it up to date with the structure of the latest version
* and ensuring missing keys are initialized with default values
*/
export const formatSerializedViewerState = (
state: UnformattedState
): SerializedViewerState => {
const finalState = initializeMissingData(state)
return finalState
}
export const inputToVersionedState = (
inputSerializedViewerState: unknown
): Nullable<VersionedSerializedViewerState> => {
const state = isSerializedViewerState(inputSerializedViewerState)
? formatSerializedViewerState(inputSerializedViewerState)
: null
if (!state) return null
return {
version: SERIALIZED_VIEWER_STATE_VERSION,
state
}
}
export const isVersionedSerializedViewerState = (
data: unknown
): data is VersionedSerializedViewerState => {
if (!data || !isObjectLike(data)) return false
if (!has(data, 'version')) return false
const stateRaw = (data as Record<string, unknown>).state
return isSerializedViewerState(stateRaw)
}