export const CURRENT_VIEW_STATE_VERSION = 1 as const; export const VIEW_STATE_QUERY_KEYS = { version: 'v', machineId: 'm', cameraPreset: 'cam', cameraPosition: 'pos', cameraTarget: 'target', zoom: 'zoom', renderMode: 'mode', exploded: 'explode', playback: 'play', timeScale: 'speed', selectedComponent: 'select', hiddenComponents: 'hide', isolatedComponents: 'iso', componentOpacity: 'opacity', sectionPlane: 'section', labels: 'labels', dimensions: 'dims', } as const; export const VIEW_STATE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}$/; export type ViewerRenderMode = 'solid' | 'wireframe' | 'xray' | 'section'; export type ViewerPlaybackState = 'playing' | 'paused'; export type ShareVector3 = readonly [number, number, number]; export type SectionAxis = 'x' | 'y' | 'z'; export interface ViewerSectionPlaneState { readonly axis: SectionAxis; readonly offset: number; } export interface ViewerShareState { readonly schemaVersion?: number; readonly machineId?: string; readonly cameraPreset?: string; readonly cameraPosition?: ShareVector3; readonly cameraTarget?: ShareVector3; readonly zoom?: number; readonly renderMode?: ViewerRenderMode; readonly exploded?: number; readonly playback?: ViewerPlaybackState; readonly timeScale?: number; readonly selectedComponent?: string; readonly hiddenComponents?: readonly string[]; readonly isolatedComponents?: readonly string[]; readonly componentOpacity?: Readonly>; readonly sectionPlane?: ViewerSectionPlaneState; readonly labels?: boolean; readonly dimensions?: boolean; } export interface ViewStateClampConfig { readonly vectorAbsMax: number; readonly zoomMin: number; readonly zoomMax: number; readonly explodedMin: number; readonly explodedMax: number; readonly timeScaleMin: number; readonly timeScaleMax: number; readonly sectionOffsetMin: number; readonly sectionOffsetMax: number; readonly opacityMin: number; readonly opacityMax: number; } export const DEFAULT_VIEW_STATE_CLAMP_CONFIG: ViewStateClampConfig = { vectorAbsMax: 100_000, zoomMin: 0.05, zoomMax: 100, explodedMin: 0, explodedMax: 1, timeScaleMin: 0, timeScaleMax: 4, sectionOffsetMin: -1_000, sectionOffsetMax: 1_000, opacityMin: 0, opacityMax: 1, }; export type ViewStateDecodeWarningCode = | 'duplicate-param' | 'unsupported-version' | 'invalid-version' | 'invalid-id' | 'unknown-id' | 'invalid-number' | 'clamped-number' | 'invalid-vector' | 'invalid-enum' | 'invalid-boolean' | 'invalid-opacity' | 'invalid-section'; export interface ViewStateDecodeWarning { readonly code: ViewStateDecodeWarningCode; readonly message: string; readonly param?: string; readonly value?: string; } export interface ViewStateCodecOptions { readonly defaultState?: ViewerShareState; readonly allowedMachineIds?: readonly string[]; readonly allowedComponentIds?: readonly string[]; readonly clamp?: Partial; } export interface EncodeViewerShareStateOptions { readonly defaultState?: ViewerShareState; readonly includeDefaults?: boolean; readonly clamp?: Partial; } export interface DecodeViewerShareStateResult { readonly state: ViewerShareState; readonly warnings: readonly ViewStateDecodeWarning[]; readonly canonicalSearch: string; readonly migrated: boolean; } type QueryField = keyof typeof VIEW_STATE_QUERY_KEYS; type MutableViewerShareState = { -readonly [Property in keyof ViewerShareState]: ViewerShareState[Property]; }; interface ParamLookupFound { readonly found: true; readonly key: string; readonly value: string; readonly aliasUsed: boolean; } interface ParamLookupMissing { readonly found: false; readonly aliasUsed: false; } type ParamLookup = ParamLookupFound | ParamLookupMissing; const QUERY_ALIASES: Record = { version: ['version'], machineId: ['machine', 'machineId', 'system'], cameraPreset: ['preset', 'view', 'cameraPreset'], cameraPosition: ['camera', 'cameraPosition', 'eye'], cameraTarget: ['lookAt', 'look_at', 'cameraTarget'], zoom: ['distance', 'cameraZoom'], renderMode: ['render', 'display', 'viewMode'], exploded: ['exploded', 'explosion'], playback: ['playing', 'animation', 'animate'], timeScale: ['timeScale', 'animationSpeed'], selectedComponent: ['component', 'selected', 'selectedComponent'], hiddenComponents: ['hidden', 'hiddenComponents'], isolatedComponents: ['isolate', 'isolated', 'isolatedComponents'], componentOpacity: ['opacities', 'componentOpacity'], sectionPlane: ['clip', 'sectionPlane', 'crossSection'], labels: ['showLabels', 'componentLabels'], dimensions: ['measurements', 'showDimensions'], }; const RENDER_MODE_ALIASES: Readonly> = { solid: 'solid', shaded: 'solid', wire: 'wireframe', wireframe: 'wireframe', xray: 'xray', 'x-ray': 'xray', transparent: 'xray', section: 'section', crosssection: 'section', 'cross-section': 'section', cutaway: 'section', clip: 'section', }; const PLAYBACK_ALIASES: Readonly> = { '1': 'playing', true: 'playing', yes: 'playing', on: 'playing', play: 'playing', playing: 'playing', '0': 'paused', false: 'paused', no: 'paused', off: 'paused', pause: 'paused', paused: 'paused', }; const BOOLEAN_ALIASES: Readonly> = { '1': true, true: true, yes: true, on: true, show: true, visible: true, '0': false, false: false, no: false, off: false, hide: false, hidden: false, }; export function isSafeShareIdentifier(value: string): boolean { return VIEW_STATE_IDENTIFIER_PATTERN.test(value); } export function extractViewerShareQuery(input: string): string { const trimmed = input.trim(); if (trimmed.length === 0) { return ''; } try { const url = new URL(trimmed, 'https://mechanica.local'); if (url.search.length > 1) { return stripFragment(url.search.slice(1)); } const hashQuestionIndex = url.hash.indexOf('?'); if (hashQuestionIndex >= 0) { return stripFragment(url.hash.slice(hashQuestionIndex + 1)); } } catch { // Fall through to tolerant parsing for partial query strings. } const hashIndex = trimmed.indexOf('#'); if (hashIndex >= 0) { const hash = trimmed.slice(hashIndex + 1); const hashQuestionIndex = hash.indexOf('?'); if (hashQuestionIndex >= 0) { return stripFragment(hash.slice(hashQuestionIndex + 1)); } } const questionIndex = trimmed.indexOf('?'); if (questionIndex >= 0) { return stripFragment(trimmed.slice(questionIndex + 1).replace(/^[?&]+/u, '')); } const withoutPrefix = trimmed.replace(/^[?#&]+/u, ''); if (!withoutPrefix.includes('=') && !withoutPrefix.includes('&')) { return ''; } return stripFragment(withoutPrefix); } export function encodeViewerShareState( state: ViewerShareState, options: EncodeViewerShareStateOptions = {}, ): string { const clamp = getClampConfig(options.clamp); const normalized = normalizeShareStateForEncoding(state, clamp); const defaults = options.defaultState !== undefined ? normalizeShareStateForEncoding(options.defaultState, clamp) : undefined; const includeDefaults = options.includeDefaults ?? false; const pairs: Array = []; const appendEncodedValue = ( key: string, value: string | undefined, defaultValue: string | undefined, omitEmptyWithoutDefault = false, ): void => { if (value === undefined) { return; } if (!includeDefaults) { if (defaultValue !== undefined && value === defaultValue) { return; } if (defaultValue === undefined && omitEmptyWithoutDefault && value.length === 0) { return; } } pairs.push([key, value]); }; pairs.push([VIEW_STATE_QUERY_KEYS.version, String(CURRENT_VIEW_STATE_VERSION)]); appendEncodedValue( VIEW_STATE_QUERY_KEYS.machineId, normalized.machineId, defaults?.machineId, ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.cameraPreset, normalized.cameraPreset, defaults?.cameraPreset, ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.cameraPosition, vectorToParam(normalized.cameraPosition), vectorToParam(defaults?.cameraPosition), ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.cameraTarget, vectorToParam(normalized.cameraTarget), vectorToParam(defaults?.cameraTarget), ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.zoom, numberToParam(normalized.zoom), numberToParam(defaults?.zoom), ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.renderMode, normalized.renderMode, defaults?.renderMode, ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.exploded, numberToParam(normalized.exploded), numberToParam(defaults?.exploded), ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.playback, playbackToParam(normalized.playback), playbackToParam(defaults?.playback), ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.timeScale, numberToParam(normalized.timeScale), numberToParam(defaults?.timeScale), ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.selectedComponent, normalized.selectedComponent, defaults?.selectedComponent, ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.hiddenComponents, listToParam(normalized.hiddenComponents), listToParam(defaults?.hiddenComponents), true, ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.isolatedComponents, listToParam(normalized.isolatedComponents), listToParam(defaults?.isolatedComponents), true, ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.componentOpacity, opacityToParam(normalized.componentOpacity), opacityToParam(defaults?.componentOpacity), true, ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.sectionPlane, sectionPlaneToParam(normalized.sectionPlane), sectionPlaneToParam(defaults?.sectionPlane), ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.labels, booleanToParam(normalized.labels), booleanToParam(defaults?.labels), ); appendEncodedValue( VIEW_STATE_QUERY_KEYS.dimensions, booleanToParam(normalized.dimensions), booleanToParam(defaults?.dimensions), ); return toQueryString(pairs); } export function decodeViewerShareState( input: string | URLSearchParams, options: ViewStateCodecOptions = {}, ): DecodeViewerShareStateResult { const params = input instanceof URLSearchParams ? new URLSearchParams(input) : new URLSearchParams(extractViewerShareQuery(input)); const warnings: ViewStateDecodeWarning[] = []; const clamp = getClampConfig(options.clamp); const allowedMachineIds = toAllowedSet(options.allowedMachineIds); const allowedComponentIds = toAllowedSet(options.allowedComponentIds); const state = cloneShareState(options.defaultState); let migrated = false; state.schemaVersion = CURRENT_VIEW_STATE_VERSION; const readLookup = (field: QueryField): ParamLookup => { const lookup = getParamValue(params, field, warnings); if (lookup.found && lookup.aliasUsed) { migrated = true; } return lookup; }; const versionLookup = readLookup('version'); if (versionLookup.found) { const rawVersion = versionLookup.value.trim(); const version = Number(rawVersion); if (!Number.isInteger(version) || version < 1) { migrated = true; pushWarning( warnings, 'invalid-version', `Invalid viewer state version "${versionLookup.value}" was ignored.`, versionLookup.key, versionLookup.value, ); } else if (version !== CURRENT_VIEW_STATE_VERSION) { migrated = true; if (version > CURRENT_VIEW_STATE_VERSION) { pushWarning( warnings, 'unsupported-version', `Viewer state version ${version} is newer than supported version ${CURRENT_VIEW_STATE_VERSION}; known fields were parsed defensively.`, versionLookup.key, versionLookup.value, ); } } } else { migrated = true; } const machineLookup = readLookup('machineId'); if (machineLookup.found) { const machineId = parseIdentifier( machineLookup.value, machineLookup.key, warnings, allowedMachineIds, ); if (machineId !== undefined) { state.machineId = machineId; } } const cameraPresetLookup = readLookup('cameraPreset'); if (cameraPresetLookup.found) { if (cameraPresetLookup.value.trim().length === 0) { delete state.cameraPreset; } else { const cameraPreset = parseIdentifier( cameraPresetLookup.value, cameraPresetLookup.key, warnings, ); if (cameraPreset !== undefined) { state.cameraPreset = cameraPreset; } } } const cameraPositionLookup = readLookup('cameraPosition'); if (cameraPositionLookup.found) { const cameraPosition = parseVector( cameraPositionLookup.value, cameraPositionLookup.key, warnings, clamp, ); if (cameraPosition !== undefined) { state.cameraPosition = cameraPosition; } } const cameraTargetLookup = readLookup('cameraTarget'); if (cameraTargetLookup.found) { const cameraTarget = parseVector( cameraTargetLookup.value, cameraTargetLookup.key, warnings, clamp, ); if (cameraTarget !== undefined) { state.cameraTarget = cameraTarget; } } const zoomLookup = readLookup('zoom'); if (zoomLookup.found) { const zoom = parseNumberParam( zoomLookup.value, zoomLookup.key, warnings, clamp.zoomMin, clamp.zoomMax, ); if (zoom !== undefined) { state.zoom = zoom; } } const renderModeLookup = readLookup('renderMode'); if (renderModeLookup.found) { const renderMode = parseRenderMode(renderModeLookup.value, renderModeLookup.key, warnings); if (renderMode !== undefined) { state.renderMode = renderMode; } } const explodedLookup = readLookup('exploded'); if (explodedLookup.found) { const exploded = parseNumberParam( explodedLookup.value, explodedLookup.key, warnings, clamp.explodedMin, clamp.explodedMax, ); if (exploded !== undefined) { state.exploded = exploded; } } const playbackLookup = readLookup('playback'); if (playbackLookup.found) { const playback = parsePlayback(playbackLookup.value, playbackLookup.key, warnings); if (playback !== undefined) { state.playback = playback; } } const timeScaleLookup = readLookup('timeScale'); if (timeScaleLookup.found) { const timeScale = parseNumberParam( timeScaleLookup.value, timeScaleLookup.key, warnings, clamp.timeScaleMin, clamp.timeScaleMax, ); if (timeScale !== undefined) { state.timeScale = timeScale; } } const selectedComponentLookup = readLookup('selectedComponent'); if (selectedComponentLookup.found) { if (selectedComponentLookup.value.trim().length === 0) { delete state.selectedComponent; } else { const selectedComponent = parseIdentifier( selectedComponentLookup.value, selectedComponentLookup.key, warnings, allowedComponentIds, ); if (selectedComponent !== undefined) { state.selectedComponent = selectedComponent; } } } const hiddenComponentsLookup = readLookup('hiddenComponents'); if (hiddenComponentsLookup.found) { state.hiddenComponents = parseIdentifierList( hiddenComponentsLookup.value, hiddenComponentsLookup.key, warnings, allowedComponentIds, ); } const isolatedComponentsLookup = readLookup('isolatedComponents'); if (isolatedComponentsLookup.found) { state.isolatedComponents = parseIdentifierList( isolatedComponentsLookup.value, isolatedComponentsLookup.key, warnings, allowedComponentIds, ); } const componentOpacityLookup = readLookup('componentOpacity'); if (componentOpacityLookup.found) { state.componentOpacity = parseOpacityMap( componentOpacityLookup.value, componentOpacityLookup.key, warnings, allowedComponentIds, clamp, ); } const sectionPlaneLookup = readLookup('sectionPlane'); if (sectionPlaneLookup.found) { if (sectionPlaneLookup.value.trim().length === 0) { delete state.sectionPlane; } else { const sectionPlane = parseSectionPlane( sectionPlaneLookup.value, sectionPlaneLookup.key, warnings, clamp, ); if (sectionPlane !== undefined) { state.sectionPlane = sectionPlane; } } } const labelsLookup = readLookup('labels'); if (labelsLookup.found) { const labels = parseBoolean(labelsLookup.value, labelsLookup.key, warnings); if (labels !== undefined) { state.labels = labels; } } const dimensionsLookup = readLookup('dimensions'); if (dimensionsLookup.found) { const dimensions = parseBoolean(dimensionsLookup.value, dimensionsLookup.key, warnings); if (dimensions !== undefined) { state.dimensions = dimensions; } } if (warnings.length > 0) { migrated = true; } const encodeOptions: EncodeViewerShareStateOptions = { clamp }; if (options.defaultState !== undefined) { encodeOptions.defaultState = options.defaultState; } return { state, warnings, canonicalSearch: encodeViewerShareState(state, encodeOptions), migrated, }; } export function canonicalizeViewerShareSearch( input: string | URLSearchParams, options: ViewStateCodecOptions = {}, ): string { return decodeViewerShareState(input, options).canonicalSearch; } export function createViewerShareUrl( basePath: string, state: ViewerShareState, options: EncodeViewerShareStateOptions = {}, ): string { const search = encodeViewerShareState(state, options); const query = search.startsWith('?') ? search.slice(1) : search; if (query.length === 0) { return basePath; } const hashIndex = basePath.indexOf('#'); const pathAndQuery = hashIndex >= 0 ? basePath.slice(0, hashIndex) : basePath; const hash = hashIndex >= 0 ? basePath.slice(hashIndex) : ''; const separator = pathAndQuery.includes('?') ? pathAndQuery.endsWith('?') || pathAndQuery.endsWith('&') ? '' : '&' : '?'; return `${pathAndQuery}${separator}${query}${hash}`; } function stripFragment(value: string): string { const hashIndex = value.indexOf('#'); return hashIndex >= 0 ? value.slice(0, hashIndex) : value; } function getParamValue( params: URLSearchParams, field: QueryField, warnings: ViewStateDecodeWarning[], ): ParamLookup { const canonicalKey = VIEW_STATE_QUERY_KEYS[field]; const canonicalValues = params.getAll(canonicalKey); const canonicalValue = selectLastParamValue(canonicalValues, canonicalKey, warnings); if (canonicalValue !== undefined) { return { found: true, key: canonicalKey, value: canonicalValue, aliasUsed: false, }; } for (const alias of QUERY_ALIASES[field]) { const aliasValues = params.getAll(alias); const aliasValue = selectLastParamValue(aliasValues, alias, warnings); if (aliasValue !== undefined) { return { found: true, key: alias, value: aliasValue, aliasUsed: true, }; } } return { found: false, aliasUsed: false, }; } function selectLastParamValue( values: string[], key: string, warnings: ViewStateDecodeWarning[], ): string | undefined { if (values.length === 0) { return undefined; } if (values.length > 1) { pushWarning( warnings, 'duplicate-param', `Multiple "${key}" parameters were supplied; using the last value.`, key, ); } return values[values.length - 1]; } function parseIdentifier( raw: string, param: string, warnings: ViewStateDecodeWarning[], allowedIds?: ReadonlySet, ): string | undefined { const value = raw.trim(); if (value.length === 0) { pushWarning(warnings, 'invalid-id', `Empty identifier ignored for "${param}".`, param, raw); return undefined; } if (!isSafeShareIdentifier(value)) { pushWarning( warnings, 'invalid-id', `Unsafe identifier "${value}" ignored for "${param}".`, param, raw, ); return undefined; } if (allowedIds !== undefined && !allowedIds.has(value)) { pushWarning( warnings, 'unknown-id', `Unknown identifier "${value}" ignored for "${param}".`, param, raw, ); return undefined; } return value; } function parseIdentifierList( raw: string, param: string, warnings: ViewStateDecodeWarning[], allowedIds?: ReadonlySet, ): string[] { const trimmed = raw.trim(); if (trimmed.length === 0) { return []; } const values = trimmed .split(/[,;|]/u) .map((value) => value.trim()) .filter(Boolean); const ids = new Set(); for (const value of values) { const identifier = parseIdentifier(value, param, warnings, allowedIds); if (identifier !== undefined) { ids.add(identifier); } } return [...ids].sort(); } function parseVector( raw: string, param: string, warnings: ViewStateDecodeWarning[], clamp: ViewStateClampConfig, ): ShareVector3 | undefined { const parts = raw .trim() .split(/[,\s|]+/u) .filter(Boolean); if (parts.length !== 3) { pushWarning( warnings, 'invalid-vector', `"${param}" must contain exactly three finite numbers.`, param, raw, ); return undefined; } const [xRaw, yRaw, zRaw] = parts as [string, string, string]; const x = parseFiniteNumber(xRaw, param, warnings); const y = parseFiniteNumber(yRaw, param, warnings); const z = parseFiniteNumber(zRaw, param, warnings); if (x === undefined || y === undefined || z === undefined) { return undefined; } return [ clampDecodedNumber(x, -clamp.vectorAbsMax, clamp.vectorAbsMax, param, warnings), clampDecodedNumber(y, -clamp.vectorAbsMax, clamp.vectorAbsMax, param, warnings), clampDecodedNumber(z, -clamp.vectorAbsMax, clamp.vectorAbsMax, param, warnings), ]; } function parseNumberParam( raw: string, param: string, warnings: ViewStateDecodeWarning[], min: number, max: number, ): number | undefined { const parsed = parseFiniteNumber(raw, param, warnings); if (parsed === undefined) { return undefined; } return clampDecodedNumber(parsed, min, max, param, warnings); } function parseFiniteNumber( raw: string, param: string, warnings: ViewStateDecodeWarning[], ): number | undefined { const value = raw.trim(); if (value.length === 0) { pushWarning(warnings, 'invalid-number', `Empty number ignored for "${param}".`, param, raw); return undefined; } const parsed = Number(value); if (!Number.isFinite(parsed)) { pushWarning( warnings, 'invalid-number', `Non-finite number "${value}" ignored for "${param}".`, param, raw, ); return undefined; } return parsed; } function parseRenderMode( raw: string, param: string, warnings: ViewStateDecodeWarning[], ): ViewerRenderMode | undefined { const value = raw.trim().toLowerCase(); const mode = RENDER_MODE_ALIASES[value]; if (mode === undefined) { pushWarning( warnings, 'invalid-enum', `Unknown render mode "${raw}" ignored for "${param}".`, param, raw, ); return undefined; } return mode; } function parsePlayback( raw: string, param: string, warnings: ViewStateDecodeWarning[], ): ViewerPlaybackState | undefined { const value = raw.trim().toLowerCase(); const playback = PLAYBACK_ALIASES[value]; if (playback === undefined) { pushWarning( warnings, 'invalid-enum', `Unknown playback value "${raw}" ignored for "${param}".`, param, raw, ); return undefined; } return playback; } function parseBoolean( raw: string, param: string, warnings: ViewStateDecodeWarning[], ): boolean | undefined { const value = raw.trim().toLowerCase(); const parsed = BOOLEAN_ALIASES[value]; if (parsed === undefined) { pushWarning( warnings, 'invalid-boolean', `Unknown boolean value "${raw}" ignored for "${param}".`, param, raw, ); return undefined; } return parsed; } function parseOpacityMap( raw: string, param: string, warnings: ViewStateDecodeWarning[], allowedIds: ReadonlySet | undefined, clamp: ViewStateClampConfig, ): Record { const trimmed = raw.trim(); if (trimmed.length === 0) { return {}; } const opacityById = new Map(); for (const token of trimmed.split(/[;,]/u)) { const entry = token.trim(); if (entry.length === 0) { continue; } const separatorIndex = findOpacitySeparator(entry); if (separatorIndex <= 0 || separatorIndex >= entry.length - 1) { pushWarning( warnings, 'invalid-opacity', `Opacity entry "${entry}" must use "component:opacity" syntax.`, param, entry, ); continue; } const idRaw = entry.slice(0, separatorIndex).trim(); const opacityRaw = entry.slice(separatorIndex + 1).trim(); const id = parseIdentifier(idRaw, param, warnings, allowedIds); if (id === undefined) { continue; } const opacity = parseFiniteNumber(opacityRaw, param, warnings); if (opacity === undefined) { continue; } if (opacityById.has(id)) { pushWarning( warnings, 'duplicate-param', `Duplicate opacity value supplied for component "${id}"; using the last value.`, param, id, ); } opacityById.set( id, clampDecodedNumber(opacity, clamp.opacityMin, clamp.opacityMax, param, warnings), ); } return sortedRecordFromMap(opacityById); } function findOpacitySeparator(entry: string): number { const equalsIndex = entry.indexOf('='); if (equalsIndex >= 0) { return equalsIndex; } return entry.lastIndexOf(':'); } function parseSectionPlane( raw: string, param: string, warnings: ViewStateDecodeWarning[], clamp: ViewStateClampConfig, ): ViewerSectionPlaneState | undefined { const parts = raw .trim() .split(/[:=,]/u) .map((part) => part.trim()) .filter(Boolean); if (parts.length !== 2) { pushWarning( warnings, 'invalid-section', `"${param}" must use "axis:offset" syntax, for example "x:0.25".`, param, raw, ); return undefined; } const [axisRaw, offsetRaw] = parts as [string, string]; const axis = axisRaw.toLowerCase(); if (!isSectionAxis(axis)) { pushWarning( warnings, 'invalid-section', `Section axis "${axisRaw}" is invalid; expected x, y, or z.`, param, raw, ); return undefined; } const offset = parseFiniteNumber(offsetRaw, param, warnings); if (offset === undefined) { return undefined; } return { axis, offset: clampDecodedNumber( offset, clamp.sectionOffsetMin, clamp.sectionOffsetMax, param, warnings, ), }; } function isSectionAxis(value: string): value is SectionAxis { return value === 'x' || value === 'y' || value === 'z'; } function clampDecodedNumber( value: number, min: number, max: number, param: string, warnings: ViewStateDecodeWarning[], ): number { if (value < min) { pushWarning( warnings, 'clamped-number', `"${param}" value ${value} was below ${min}; clamped to ${min}.`, param, String(value), ); return min; } if (value > max) { pushWarning( warnings, 'clamped-number', `"${param}" value ${value} was above ${max}; clamped to ${max}.`, param, String(value), ); return max; } return value; } function cloneShareState(source: ViewerShareState | undefined): MutableViewerShareState { const clone: MutableViewerShareState = {}; if (source === undefined) { return clone; } if (source.schemaVersion !== undefined) { clone.schemaVersion = source.schemaVersion; } if (source.machineId !== undefined) { clone.machineId = source.machineId; } if (source.cameraPreset !== undefined) { clone.cameraPreset = source.cameraPreset; } if (source.cameraPosition !== undefined) { clone.cameraPosition = [...source.cameraPosition] as ShareVector3; } if (source.cameraTarget !== undefined) { clone.cameraTarget = [...source.cameraTarget] as ShareVector3; } if (source.zoom !== undefined) { clone.zoom = source.zoom; } if (source.renderMode !== undefined) { clone.renderMode = source.renderMode; } if (source.exploded !== undefined) { clone.exploded = source.exploded; } if (source.playback !== undefined) { clone.playback = source.playback; } if (source.timeScale !== undefined) { clone.timeScale = source.timeScale; } if (source.selectedComponent !== undefined) { clone.selectedComponent = source.selectedComponent; } if (source.hiddenComponents !== undefined) { clone.hiddenComponents = [...source.hiddenComponents]; } if (source.isolatedComponents !== undefined) { clone.isolatedComponents = [...source.isolatedComponents]; } if (source.componentOpacity !== undefined) { clone.componentOpacity = { ...source.componentOpacity }; } if (source.sectionPlane !== undefined) { clone.sectionPlane = { ...source.sectionPlane }; } if (source.labels !== undefined) { clone.labels = source.labels; } if (source.dimensions !== undefined) { clone.dimensions = source.dimensions; } return clone; } function normalizeShareStateForEncoding( state: ViewerShareState, clamp: ViewStateClampConfig, ): MutableViewerShareState { const normalized: MutableViewerShareState = { schemaVersion: CURRENT_VIEW_STATE_VERSION, }; const machineId = normalizeIdentifierForEncode(state.machineId, 'machineId'); if (machineId !== undefined) { normalized.machineId = machineId; } const cameraPreset = normalizeIdentifierForEncode(state.cameraPreset, 'cameraPreset'); if (cameraPreset !== undefined) { normalized.cameraPreset = cameraPreset; } if (state.cameraPosition !== undefined) { normalized.cameraPosition = normalizeVectorForEncode( state.cameraPosition, 'cameraPosition', clamp, ); } if (state.cameraTarget !== undefined) { normalized.cameraTarget = normalizeVectorForEncode(state.cameraTarget, 'cameraTarget', clamp); } const zoom = normalizeNumberForEncode(state.zoom, 'zoom', clamp.zoomMin, clamp.zoomMax); if (zoom !== undefined) { normalized.zoom = zoom; } const renderMode = normalizeRenderModeForEncode(state.renderMode); if (renderMode !== undefined) { normalized.renderMode = renderMode; } const exploded = normalizeNumberForEncode( state.exploded, 'exploded', clamp.explodedMin, clamp.explodedMax, ); if (exploded !== undefined) { normalized.exploded = exploded; } const playback = normalizePlaybackForEncode(state.playback); if (playback !== undefined) { normalized.playback = playback; } const timeScale = normalizeNumberForEncode( state.timeScale, 'timeScale', clamp.timeScaleMin, clamp.timeScaleMax, ); if (timeScale !== undefined) { normalized.timeScale = timeScale; } const selectedComponent = normalizeIdentifierForEncode( state.selectedComponent, 'selectedComponent', ); if (selectedComponent !== undefined) { normalized.selectedComponent = selectedComponent; } if (state.hiddenComponents !== undefined) { normalized.hiddenComponents = normalizeIdentifierListForEncode( state.hiddenComponents, 'hiddenComponents', ); } if (state.isolatedComponents !== undefined) { normalized.isolatedComponents = normalizeIdentifierListForEncode( state.isolatedComponents, 'isolatedComponents', ); } if (state.componentOpacity !== undefined) { normalized.componentOpacity = normalizeOpacityForEncode(state.componentOpacity, clamp); } if (state.sectionPlane !== undefined) { normalized.sectionPlane = normalizeSectionPlaneForEncode(state.sectionPlane, clamp); } const labels = normalizeBooleanForEncode(state.labels, 'labels'); if (labels !== undefined) { normalized.labels = labels; } const dimensions = normalizeBooleanForEncode(state.dimensions, 'dimensions'); if (dimensions !== undefined) { normalized.dimensions = dimensions; } return normalized; } function normalizeIdentifierForEncode(value: string | undefined, field: string): string | undefined { if (value === undefined) { return undefined; } const normalized = value.trim(); if (normalized.length === 0) { return undefined; } if (!isSafeShareIdentifier(normalized)) { throw new TypeError(`Cannot encode unsafe ${field} identifier "${value}".`); } return normalized; } function normalizeIdentifierListForEncode(values: readonly string[], field: string): string[] { const ids = new Set(); for (const value of values) { const normalized = normalizeIdentifierForEncode(value, field); if (normalized !== undefined) { ids.add(normalized); } } return [...ids].sort(); } function normalizeVectorForEncode( vector: ShareVector3, field: string, clamp: ViewStateClampConfig, ): ShareVector3 { const [x, y, z] = vector; if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { throw new TypeError(`Cannot encode non-finite ${field} vector.`); } return [ clampValue(x, -clamp.vectorAbsMax, clamp.vectorAbsMax), clampValue(y, -clamp.vectorAbsMax, clamp.vectorAbsMax), clampValue(z, -clamp.vectorAbsMax, clamp.vectorAbsMax), ]; } function normalizeNumberForEncode( value: number | undefined, field: string, min: number, max: number, ): number | undefined { if (value === undefined) { return undefined; } if (!Number.isFinite(value)) { throw new TypeError(`Cannot encode non-finite ${field} value.`); } return clampValue(value, min, max); } function normalizeRenderModeForEncode( value: ViewerRenderMode | undefined, ): ViewerRenderMode | undefined { if (value === undefined) { return undefined; } const renderMode = RENDER_MODE_ALIASES[value.trim().toLowerCase()]; if (renderMode === undefined) { throw new TypeError(`Cannot encode unsupported render mode "${value}".`); } return renderMode; } function normalizePlaybackForEncode( value: ViewerPlaybackState | undefined, ): ViewerPlaybackState | undefined { if (value === undefined) { return undefined; } const playback = PLAYBACK_ALIASES[value.trim().toLowerCase()]; if (playback === undefined) { throw new TypeError(`Cannot encode unsupported playback state "${value}".`); } return playback; } function normalizeBooleanForEncode(value: boolean | undefined, field: string): boolean | undefined { if (value === undefined) { return undefined; } if (typeof value !== 'boolean') { throw new TypeError(`Cannot encode non-boolean ${field} value.`); } return value; } function normalizeOpacityForEncode( opacity: Readonly>, clamp: ViewStateClampConfig, ): Record { const normalized = new Map(); for (const [id, value] of Object.entries(opacity)) { const normalizedId = normalizeIdentifierForEncode(id, 'componentOpacity'); if (normalizedId === undefined) { continue; } if (!Number.isFinite(value)) { throw new TypeError(`Cannot encode non-finite opacity for component "${id}".`); } normalized.set(normalizedId, clampValue(value, clamp.opacityMin, clamp.opacityMax)); } return sortedRecordFromMap(normalized); } function normalizeSectionPlaneForEncode( sectionPlane: ViewerSectionPlaneState, clamp: ViewStateClampConfig, ): ViewerSectionPlaneState { if (!isSectionAxis(sectionPlane.axis)) { throw new TypeError(`Cannot encode unsupported section axis "${sectionPlane.axis}".`); } if (!Number.isFinite(sectionPlane.offset)) { throw new TypeError('Cannot encode non-finite section offset.'); } return { axis: sectionPlane.axis, offset: clampValue( sectionPlane.offset, clamp.sectionOffsetMin, clamp.sectionOffsetMax, ), }; } function vectorToParam(vector: ShareVector3 | undefined): string | undefined { if (vector === undefined) { return undefined; } return vector.map((value) => formatShareNumber(value)).join(','); } function numberToParam(value: number | undefined): string | undefined { return value === undefined ? undefined : formatShareNumber(value); } function playbackToParam(value: ViewerPlaybackState | undefined): string | undefined { if (value === undefined) { return undefined; } return value === 'playing' ? '1' : '0'; } function booleanToParam(value: boolean | undefined): string | undefined { return value === undefined ? undefined : value ? '1' : '0'; } function listToParam(value: readonly string[] | undefined): string | undefined { return value === undefined ? undefined : value.join(','); } function opacityToParam(value: Readonly> | undefined): string | undefined { if (value === undefined) { return undefined; } return Object.entries(value) .sort(([a], [b]) => compareStrings(a, b)) .map(([id, opacity]) => `${id}:${formatShareNumber(opacity)}`) .join(';'); } function sectionPlaneToParam(value: ViewerSectionPlaneState | undefined): string | undefined { if (value === undefined) { return undefined; } return `${value.axis}:${formatShareNumber(value.offset)}`; } function formatShareNumber(value: number): string { const rounded = Math.round((Object.is(value, -0) ? 0 : value) * 10_000) / 10_000; const safe = Object.is(rounded, -0) ? 0 : rounded; if (Number.isInteger(safe)) { return String(safe); } return safe.toFixed(4).replace(/0+$/u, '').replace(/\.$/u, ''); } function clampValue(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function toQueryString(pairs: readonly (readonly [string, string])[]): string { if (pairs.length === 0) { return ''; } return `?${pairs .map(([key, value]) => `${encodeQueryComponent(key)}=${encodeQueryComponent(value)}`) .join('&')}`; } function encodeQueryComponent(value: string): string { return encodeURIComponent(value) .replace(/%2C/gu, ',') .replace(/%3A/gu, ':') .replace(/%3B/gu, ';'); } function sortedRecordFromMap(values: ReadonlyMap): Record { const record: Record = {}; for (const [id, value] of [...values.entries()].sort(([a], [b]) => compareStrings(a, b))) { record[id] = value; } return record; } function compareStrings(a: string, b: string): number { if (a < b) { return -1; } if (a > b) { return 1; } return 0; } function toAllowedSet(ids: readonly string[] | undefined): ReadonlySet | undefined { return ids === undefined ? undefined : new Set(ids); } function getClampConfig(overrides: Partial | undefined): ViewStateClampConfig { const vectorAbsMax = Math.max( 1, Math.abs(finiteOrDefault(overrides?.vectorAbsMax, DEFAULT_VIEW_STATE_CLAMP_CONFIG.vectorAbsMax)), ); const zoomMin = finiteOrDefault(overrides?.zoomMin, DEFAULT_VIEW_STATE_CLAMP_CONFIG.zoomMin); const zoomMax = Math.max( zoomMin, finiteOrDefault(overrides?.zoomMax, DEFAULT_VIEW_STATE_CLAMP_CONFIG.zoomMax), ); const explodedMin = finiteOrDefault( overrides?.explodedMin, DEFAULT_VIEW_STATE_CLAMP_CONFIG.explodedMin, ); const explodedMax = Math.max( explodedMin, finiteOrDefault(overrides?.explodedMax, DEFAULT_VIEW_STATE_CLAMP_CONFIG.explodedMax), ); const timeScaleMin = finiteOrDefault( overrides?.timeScaleMin, DEFAULT_VIEW_STATE_CLAMP_CONFIG.timeScaleMin, ); const timeScaleMax = Math.max( timeScaleMin, finiteOrDefault(overrides?.timeScaleMax, DEFAULT_VIEW_STATE_CLAMP_CONFIG.timeScaleMax), ); const sectionOffsetMin = finiteOrDefault( overrides?.sectionOffsetMin, DEFAULT_VIEW_STATE_CLAMP_CONFIG.sectionOffsetMin, ); const sectionOffsetMax = Math.max( sectionOffsetMin, finiteOrDefault( overrides?.sectionOffsetMax, DEFAULT_VIEW_STATE_CLAMP_CONFIG.sectionOffsetMax, ), ); const opacityMin = finiteOrDefault( overrides?.opacityMin, DEFAULT_VIEW_STATE_CLAMP_CONFIG.opacityMin, ); const opacityMax = Math.max( opacityMin, finiteOrDefault(overrides?.opacityMax, DEFAULT_VIEW_STATE_CLAMP_CONFIG.opacityMax), ); return { vectorAbsMax, zoomMin, zoomMax, explodedMin, explodedMax, timeScaleMin, timeScaleMax, sectionOffsetMin, sectionOffsetMax, opacityMin, opacityMax, }; } function finiteOrDefault(value: number | undefined, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } function pushWarning( warnings: ViewStateDecodeWarning[], code: ViewStateDecodeWarningCode, message: string, param?: string, value?: string, ): void { warnings.push({ code, message, ...(param !== undefined ? { param } : {}), ...(value !== undefined ? { value } : {}), }); }