export const SHAREABLE_ANIMATION_STATE_VERSION = 1; export type ShareableRenderMode = 'solid' | 'wireframe' | 'xray' | 'cross-section'; export type ShareableAxis = 'x' | 'y' | 'z'; export type ShareableVector3 = readonly [number, number, number]; export interface ShareableCameraState { preset?: string; position?: ShareableVector3; target?: ShareableVector3; zoom?: number; } export interface ShareableCrossSectionState { axis?: ShareableAxis; offset?: number; inverted?: boolean; } export interface ShareableAnimationState { version?: number; machineId?: string; animationId?: string; componentId?: string; tourId?: string; tourStep?: number; timeSeconds?: number; rpm?: number; timeScale?: number; playing?: boolean; loop?: boolean; stepMode?: boolean; reducedMotion?: boolean; exploded?: number; renderMode?: ShareableRenderMode; camera?: ShareableCameraState; hiddenComponents?: readonly string[]; isolatedComponents?: readonly string[]; componentOpacity?: Readonly>; labels?: boolean; crossSection?: ShareableCrossSectionState; } export interface ShareableAnimationStateLimits { minRpm: number; maxRpm: number; minTimeScale: number; maxTimeScale: number; maxTimeSeconds: number; maxTourStep: number; minExploded: number; maxExploded: number; minZoom: number; maxZoom: number; maxCoordinateMagnitude: number; maxIdLength: number; maxComponentListLength: number; maxOpacityEntries: number; maxSchemaVersion: number; } export interface ShareableAnimationStateValidationOptions { limits?: Partial; knownMachineIds?: Iterable; knownAnimationIds?: Iterable; knownComponentIds?: Iterable; knownTourIds?: Iterable; knownCameraPresetIds?: Iterable; } export interface EncodeShareableAnimationStateOptions extends ShareableAnimationStateValidationOptions { includeQuestionMark?: boolean; includeVersion?: boolean; } export interface CanonicalizeShareableAnimationStateOptions extends ShareableAnimationStateValidationOptions, EncodeShareableAnimationStateOptions {} export type ShareableStateWarningCode = | 'ambiguous-query-alias' | 'clamped-number' | 'conflicting-component-visibility' | 'duplicate-query-key' | 'invalid-axis' | 'invalid-boolean' | 'invalid-camera-vector' | 'invalid-cross-section' | 'invalid-identifier' | 'invalid-list' | 'invalid-list-token' | 'invalid-number' | 'invalid-opacity-map' | 'invalid-render-mode' | 'rounded-number' | 'sanitized-identifier' | 'too-many-components' | 'too-many-opacity-entries' | 'unknown-identifier' | 'unsafe-identifier' | 'unsupported-version'; export interface ShareableStateWarning { code: ShareableStateWarningCode; key?: string; value?: string; message: string; } export interface NormalizedShareableAnimationState { state: ShareableAnimationState; warnings: ShareableStateWarning[]; } export interface DecodedShareableAnimationState extends NormalizedShareableAnimationState { unknownKeys: string[]; } export type ShareableStateRecordInput = Record< string, string | number | boolean | null | undefined | readonly (string | number | boolean)[] >; export type ShareableStateInput = string | URL | URLSearchParams | ShareableStateRecordInput; export const DEFAULT_SHAREABLE_ANIMATION_STATE_LIMITS: Readonly = Object.freeze({ minRpm: 0, maxRpm: 12_000, minTimeScale: 0.05, maxTimeScale: 4, maxTimeSeconds: 86_400, maxTourStep: 500, minExploded: 0, maxExploded: 1, minZoom: 0.05, maxZoom: 200, maxCoordinateMagnitude: 100_000, maxIdLength: 160, maxComponentListLength: 200, maxOpacityEntries: 200, maxSchemaVersion: 99, }); const URL_KEY_ALIASES = { version: ['v', 'version'], machineId: ['m', 'machine', 'machineId'], animationId: ['a', 'animation', 'animationId'], timeSeconds: ['t', 'time', 'timeSeconds'], rpm: ['rpm'], timeScale: ['s', 'scale', 'timeScale'], playing: ['play', 'playing'], loop: ['loop'], stepMode: ['step', 'stepMode'], reducedMotion: ['rm', 'reducedMotion'], exploded: ['exp', 'exploded'], renderMode: ['mode', 'renderMode'], componentId: ['part', 'component', 'componentId', 'partId'], tourId: ['tour', 'tourId'], tourStep: ['tourStep', 'tour-step', 'tourstep', 'stepIndex'], cameraPreset: ['cam', 'camera', 'cameraPreset'], cameraPosition: ['pos', 'position', 'cameraPosition'], cameraTarget: ['target', 'cameraTarget'], cameraZoom: ['zoom', 'cameraZoom'], hiddenComponents: ['hide', 'hidden', 'hiddenComponents'], isolatedComponents: ['iso', 'isolated', 'isolatedComponents'], componentOpacity: ['op', 'opacity', 'componentOpacity'], labels: ['labels', 'label'], crossSection: ['cut', 'section', 'crossSection'], } as const; type CanonicalUrlField = keyof typeof URL_KEY_ALIASES; const RENDER_MODE_ALIASES: Readonly> = Object.freeze({ solid: 'solid', shaded: 'solid', default: 'solid', wire: 'wireframe', wireframe: 'wireframe', 'wire-frame': 'wireframe', xray: 'xray', 'x-ray': 'xray', transparent: 'xray', section: 'cross-section', cross: 'cross-section', crosssection: 'cross-section', 'cross-section': 'cross-section', }); const TRUE_STRINGS = new Set(['1', 'true', 'yes', 'on']); const FALSE_STRINGS = new Set(['0', 'false', 'no', 'off']); const RESERVED_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']); const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/g; export const RECOGNIZED_SHAREABLE_ANIMATION_STATE_KEYS: readonly string[] = Object.freeze( collectRecognizedKeys(), ); const RECOGNIZED_SHAREABLE_ANIMATION_STATE_KEY_SET = new Set( RECOGNIZED_SHAREABLE_ANIMATION_STATE_KEYS, ); interface NormalizationContext { limits: ShareableAnimationStateLimits; warnings: ShareableStateWarning[]; knownMachineIds?: ReadonlySet; knownAnimationIds?: ReadonlySet; knownComponentIds?: ReadonlySet; knownTourIds?: ReadonlySet; knownCameraPresetIds?: ReadonlySet; } export function normalizeShareableAnimationState( input: Partial | Record, options: ShareableAnimationStateValidationOptions = {}, ): NormalizedShareableAnimationState { const ctx = createNormalizationContext(options); const source = input as Record; const state: ShareableAnimationState = {}; const version = normalizeNumberValue(source.version, 'v', ctx, 1, ctx.limits.maxSchemaVersion, true); if (version !== undefined) { state.version = version; if (version > SHAREABLE_ANIMATION_STATE_VERSION) { addWarning( ctx.warnings, 'unsupported-version', 'v', version, `Share state version ${version} is newer than this runtime's version ${SHAREABLE_ANIMATION_STATE_VERSION}; known fields were decoded conservatively.`, ); } } const machineId = normalizeIdentifier(source.machineId, 'm', ctx, ctx.knownMachineIds, 'machine'); if (machineId !== undefined) state.machineId = machineId; const animationId = normalizeIdentifier( source.animationId, 'a', ctx, ctx.knownAnimationIds, 'animation', ); if (animationId !== undefined) state.animationId = animationId; const componentId = normalizeIdentifier( source.componentId, 'part', ctx, ctx.knownComponentIds, 'component', ); if (componentId !== undefined) state.componentId = componentId; const tourId = normalizeIdentifier(source.tourId, 'tour', ctx, ctx.knownTourIds, 'tour'); if (tourId !== undefined) state.tourId = tourId; const tourStep = normalizeNumberValue( source.tourStep, 'tourStep', ctx, 0, ctx.limits.maxTourStep, true, ); if (tourStep !== undefined) state.tourStep = tourStep; const timeSeconds = normalizeNumberValue(source.timeSeconds, 't', ctx, 0, ctx.limits.maxTimeSeconds); if (timeSeconds !== undefined) state.timeSeconds = timeSeconds; const rpm = normalizeNumberValue(source.rpm, 'rpm', ctx, ctx.limits.minRpm, ctx.limits.maxRpm); if (rpm !== undefined) state.rpm = rpm; const timeScale = normalizeNumberValue( source.timeScale, 's', ctx, ctx.limits.minTimeScale, ctx.limits.maxTimeScale, ); if (timeScale !== undefined) state.timeScale = timeScale; const playing = normalizeBooleanValue(source.playing, 'play', ctx); if (playing !== undefined) state.playing = playing; const loop = normalizeBooleanValue(source.loop, 'loop', ctx); if (loop !== undefined) state.loop = loop; const stepMode = normalizeBooleanValue(source.stepMode, 'step', ctx); if (stepMode !== undefined) state.stepMode = stepMode; const reducedMotion = normalizeBooleanValue(source.reducedMotion, 'rm', ctx); if (reducedMotion !== undefined) state.reducedMotion = reducedMotion; const exploded = normalizeNumberValue( source.exploded, 'exp', ctx, ctx.limits.minExploded, ctx.limits.maxExploded, ); if (exploded !== undefined) state.exploded = exploded; const renderMode = normalizeRenderModeValue(source.renderMode, 'mode', ctx); if (renderMode !== undefined) state.renderMode = renderMode; const camera = normalizeCameraState(source.camera, ctx); if (camera !== undefined) state.camera = camera; let hiddenComponents = normalizeIdentifierList( source.hiddenComponents, 'hide', ctx, ctx.knownComponentIds, 'component', ); const isolatedComponents = normalizeIdentifierList( source.isolatedComponents, 'iso', ctx, ctx.knownComponentIds, 'component', ); if (hiddenComponents !== undefined && isolatedComponents !== undefined) { const isolatedSet = new Set(isolatedComponents); const filteredHiddenComponents = hiddenComponents.filter((id) => !isolatedSet.has(id)); if (filteredHiddenComponents.length !== hiddenComponents.length) { addWarning( ctx.warnings, 'conflicting-component-visibility', 'hide', hiddenComponents.join(','), 'A component cannot be both hidden and isolated; isolated components were kept visible.', ); } hiddenComponents = filteredHiddenComponents; } if (hiddenComponents !== undefined && hiddenComponents.length > 0) { state.hiddenComponents = hiddenComponents; } if (isolatedComponents !== undefined && isolatedComponents.length > 0) { state.isolatedComponents = isolatedComponents; } const componentOpacity = normalizeOpacityMap(source.componentOpacity, ctx); if (componentOpacity !== undefined && Object.keys(componentOpacity).length > 0) { state.componentOpacity = componentOpacity; } const labels = normalizeBooleanValue(source.labels, 'labels', ctx); if (labels !== undefined) state.labels = labels; const crossSection = normalizeCrossSectionState(source.crossSection, ctx); if (crossSection !== undefined) state.crossSection = crossSection; return { state, warnings: ctx.warnings, }; } export function encodeShareableAnimationState( input: Partial, options: EncodeShareableAnimationStateOptions = {}, ): string { const { state } = normalizeShareableAnimationState(input, options); const params = new URLSearchParams(); if (options.includeVersion !== false) { params.set('v', String(SHAREABLE_ANIMATION_STATE_VERSION)); } if (state.machineId !== undefined) params.set('m', state.machineId); if (state.animationId !== undefined) params.set('a', state.animationId); if (state.timeSeconds !== undefined) setNumberParam(params, 't', state.timeSeconds, 3); if (state.rpm !== undefined) setNumberParam(params, 'rpm', state.rpm, 2); if (state.timeScale !== undefined) setNumberParam(params, 's', state.timeScale, 3); if (state.playing !== undefined) setBooleanParam(params, 'play', state.playing); if (state.loop !== undefined) setBooleanParam(params, 'loop', state.loop); if (state.stepMode !== undefined) setBooleanParam(params, 'step', state.stepMode); if (state.reducedMotion !== undefined) setBooleanParam(params, 'rm', state.reducedMotion); if (state.exploded !== undefined) setNumberParam(params, 'exp', state.exploded, 3); if (state.renderMode !== undefined) params.set('mode', state.renderMode); if (state.componentId !== undefined) params.set('part', state.componentId); if (state.tourId !== undefined) params.set('tour', state.tourId); if (state.tourStep !== undefined) params.set('tourStep', String(state.tourStep)); if (state.camera !== undefined) { if (state.camera.preset !== undefined) params.set('cam', state.camera.preset); if (state.camera.position !== undefined) params.set('pos', formatVector3(state.camera.position)); if (state.camera.target !== undefined) params.set('target', formatVector3(state.camera.target)); if (state.camera.zoom !== undefined) setNumberParam(params, 'zoom', state.camera.zoom, 3); } if (state.hiddenComponents !== undefined && state.hiddenComponents.length > 0) { params.set('hide', encodeIdentifierList(state.hiddenComponents)); } if (state.isolatedComponents !== undefined && state.isolatedComponents.length > 0) { params.set('iso', encodeIdentifierList(state.isolatedComponents)); } if (state.componentOpacity !== undefined && Object.keys(state.componentOpacity).length > 0) { params.set('op', encodeOpacityMap(state.componentOpacity)); } if (state.labels !== undefined) setBooleanParam(params, 'labels', state.labels); if (state.crossSection !== undefined) { params.set('cut', encodeCrossSection(state.crossSection)); } const query = params.toString(); return options.includeQuestionMark === true && query.length > 0 ? `?${query}` : query; } export function decodeShareableAnimationState( input: ShareableStateInput, options: ShareableAnimationStateValidationOptions = {}, ): DecodedShareableAnimationState { const params = toShareableAnimationSearchParams(input); const warnings: ShareableStateWarning[] = []; const unknownKeys = findUnknownKeys(params); const raw: Record = {}; setRawParam(raw, 'version', readParam(params, 'version', warnings)); setRawParam(raw, 'machineId', readParam(params, 'machineId', warnings)); setRawParam(raw, 'animationId', readParam(params, 'animationId', warnings)); setRawParam(raw, 'timeSeconds', readParam(params, 'timeSeconds', warnings)); setRawParam(raw, 'rpm', readParam(params, 'rpm', warnings)); setRawParam(raw, 'timeScale', readParam(params, 'timeScale', warnings)); setRawParam(raw, 'playing', readParam(params, 'playing', warnings)); setRawParam(raw, 'loop', readParam(params, 'loop', warnings)); setRawParam(raw, 'stepMode', readParam(params, 'stepMode', warnings)); setRawParam(raw, 'reducedMotion', readParam(params, 'reducedMotion', warnings)); setRawParam(raw, 'exploded', readParam(params, 'exploded', warnings)); setRawParam(raw, 'renderMode', readParam(params, 'renderMode', warnings)); setRawParam(raw, 'componentId', readParam(params, 'componentId', warnings)); setRawParam(raw, 'tourId', readParam(params, 'tourId', warnings)); setRawParam(raw, 'tourStep', readParam(params, 'tourStep', warnings)); setRawParam(raw, 'labels', readParam(params, 'labels', warnings)); const camera: Record = {}; setRawParam(camera, 'preset', readParam(params, 'cameraPreset', warnings)); const cameraPosition = readParam(params, 'cameraPosition', warnings); if (cameraPosition !== undefined) { const parsed = parseVectorParam(cameraPosition, 'pos', warnings); if (parsed !== undefined) camera.position = parsed; } const cameraTarget = readParam(params, 'cameraTarget', warnings); if (cameraTarget !== undefined) { const parsed = parseVectorParam(cameraTarget, 'target', warnings); if (parsed !== undefined) camera.target = parsed; } setRawParam(camera, 'zoom', readParam(params, 'cameraZoom', warnings)); if (Object.keys(camera).length > 0) { raw.camera = camera; } const hiddenComponents = readParam(params, 'hiddenComponents', warnings); if (hiddenComponents !== undefined) { raw.hiddenComponents = parseIdentifierListParam(hiddenComponents, 'hide', warnings); } const isolatedComponents = readParam(params, 'isolatedComponents', warnings); if (isolatedComponents !== undefined) { raw.isolatedComponents = parseIdentifierListParam(isolatedComponents, 'iso', warnings); } const componentOpacity = readParam(params, 'componentOpacity', warnings); if (componentOpacity !== undefined) { raw.componentOpacity = parseOpacityParam(componentOpacity, 'op', warnings); } const crossSection = readParam(params, 'crossSection', warnings); if (crossSection !== undefined) { raw.crossSection = parseCrossSectionParam(crossSection, 'cut', warnings); } const normalized = normalizeShareableAnimationState(raw, options); return { state: normalized.state, warnings: warnings.concat(normalized.warnings), unknownKeys, }; } export function canonicalizeShareableAnimationState( input: ShareableStateInput, options: CanonicalizeShareableAnimationStateOptions = {}, ): string { const decoded = decodeShareableAnimationState(input, options); return encodeShareableAnimationState(decoded.state, options); } export function hasShareableAnimationState(input: ShareableStateInput): boolean { const params = toShareableAnimationSearchParams(input); for (const key of RECOGNIZED_SHAREABLE_ANIMATION_STATE_KEYS) { if (params.has(key)) return true; } return false; } export function createShareableAnimationSearchParams( existing: ShareableStateInput, state: Partial, options: EncodeShareableAnimationStateOptions = {}, ): URLSearchParams { const params = toShareableAnimationSearchParams(existing); for (const key of RECOGNIZED_SHAREABLE_ANIMATION_STATE_KEYS) { params.delete(key); } const encoded = new URLSearchParams( encodeShareableAnimationState(state, { ...options, includeQuestionMark: false, }), ); encoded.forEach((value, key) => { params.set(key, value); }); return params; } export function toShareableAnimationSearchParams(input: ShareableStateInput = ''): URLSearchParams { if (isUrlSearchParams(input)) { return new URLSearchParams(input.toString()); } if (isUrl(input)) { return searchParamsFromUrl(input); } if (typeof input === 'string') { return searchParamsFromString(input); } const params = new URLSearchParams(); for (const [key, value] of Object.entries(input)) { if (value === null || value === undefined) continue; if (Array.isArray(value)) { for (const item of value) { params.append(key, String(item)); } } else { params.set(key, String(value)); } } return params; } function collectRecognizedKeys(): string[] { const keys: string[] = []; for (const aliases of Object.values(URL_KEY_ALIASES)) { for (const key of aliases) { if (!keys.includes(key)) keys.push(key); } } return keys; } function createNormalizationContext( options: ShareableAnimationStateValidationOptions, ): NormalizationContext { return { limits: sanitizeLimits(options.limits), warnings: [], knownMachineIds: toKnownSet(options.knownMachineIds), knownAnimationIds: toKnownSet(options.knownAnimationIds), knownComponentIds: toKnownSet(options.knownComponentIds), knownTourIds: toKnownSet(options.knownTourIds), knownCameraPresetIds: toKnownSet(options.knownCameraPresetIds), }; } function sanitizeLimits( overrides: Partial | undefined, ): ShareableAnimationStateLimits { const defaults = DEFAULT_SHAREABLE_ANIMATION_STATE_LIMITS; const limits: ShareableAnimationStateLimits = { minRpm: finiteOrDefault(overrides?.minRpm, defaults.minRpm), maxRpm: finiteOrDefault(overrides?.maxRpm, defaults.maxRpm), minTimeScale: finiteOrDefault(overrides?.minTimeScale, defaults.minTimeScale), maxTimeScale: finiteOrDefault(overrides?.maxTimeScale, defaults.maxTimeScale), maxTimeSeconds: finiteOrDefault(overrides?.maxTimeSeconds, defaults.maxTimeSeconds), maxTourStep: finiteOrDefault(overrides?.maxTourStep, defaults.maxTourStep), minExploded: finiteOrDefault(overrides?.minExploded, defaults.minExploded), maxExploded: finiteOrDefault(overrides?.maxExploded, defaults.maxExploded), minZoom: finiteOrDefault(overrides?.minZoom, defaults.minZoom), maxZoom: finiteOrDefault(overrides?.maxZoom, defaults.maxZoom), maxCoordinateMagnitude: finiteOrDefault( overrides?.maxCoordinateMagnitude, defaults.maxCoordinateMagnitude, ), maxIdLength: finiteOrDefault(overrides?.maxIdLength, defaults.maxIdLength), maxComponentListLength: finiteOrDefault( overrides?.maxComponentListLength, defaults.maxComponentListLength, ), maxOpacityEntries: finiteOrDefault(overrides?.maxOpacityEntries, defaults.maxOpacityEntries), maxSchemaVersion: finiteOrDefault(overrides?.maxSchemaVersion, defaults.maxSchemaVersion), }; limits.minRpm = Math.max(0, limits.minRpm); limits.maxRpm = Math.max(limits.minRpm, limits.maxRpm); limits.minTimeScale = Math.max(0, limits.minTimeScale); limits.maxTimeScale = Math.max(limits.minTimeScale, limits.maxTimeScale); limits.maxTimeSeconds = Math.max(0, limits.maxTimeSeconds); limits.maxTourStep = Math.max(0, Math.floor(limits.maxTourStep)); limits.minExploded = Math.max(0, limits.minExploded); limits.maxExploded = Math.max(limits.minExploded, limits.maxExploded); limits.minZoom = Math.max(0.0001, limits.minZoom); limits.maxZoom = Math.max(limits.minZoom, limits.maxZoom); limits.maxCoordinateMagnitude = Math.max(1, limits.maxCoordinateMagnitude); limits.maxIdLength = Math.max(1, Math.floor(limits.maxIdLength)); limits.maxComponentListLength = Math.max(0, Math.floor(limits.maxComponentListLength)); limits.maxOpacityEntries = Math.max(0, Math.floor(limits.maxOpacityEntries)); limits.maxSchemaVersion = Math.max(1, Math.floor(limits.maxSchemaVersion)); return limits; } function finiteOrDefault(value: number | undefined, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } function toKnownSet(values: Iterable | undefined): ReadonlySet | undefined { if (values === undefined) return undefined; const set = new Set(); for (const value of values) { const trimmed = value.trim(); if (trimmed.length > 0) set.add(trimmed); } return set; } function normalizeIdentifier( value: unknown, key: string, ctx: NormalizationContext, knownSet: ReadonlySet | undefined, noun: string, ): string | undefined { if (value === undefined || value === null) return undefined; if (typeof value !== 'string') { addWarning( ctx.warnings, 'invalid-identifier', key, value, `Expected ${noun} identifier to be a string.`, ); return undefined; } const trimmed = value.trim(); if (trimmed.length === 0) { addWarning(ctx.warnings, 'invalid-identifier', key, value, `Empty ${noun} identifier ignored.`); return undefined; } const withoutControlCharacters = trimmed.replace(CONTROL_CHARACTER_PATTERN, ''); if (withoutControlCharacters !== trimmed) { addWarning( ctx.warnings, 'sanitized-identifier', key, value, `Control characters were stripped from the ${noun} identifier.`, ); } if (withoutControlCharacters.length === 0) { addWarning( ctx.warnings, 'invalid-identifier', key, value, `Identifier for ${noun} became empty after sanitization.`, ); return undefined; } if (withoutControlCharacters.length > ctx.limits.maxIdLength) { addWarning( ctx.warnings, 'invalid-identifier', key, value, `${noun} identifier exceeds the maximum length of ${ctx.limits.maxIdLength} characters.`, ); return undefined; } if (RESERVED_OBJECT_KEYS.has(withoutControlCharacters)) { addWarning( ctx.warnings, 'unsafe-identifier', key, value, `Reserved object key "${withoutControlCharacters}" cannot be used as a share-state identifier.`, ); return undefined; } if (knownSet !== undefined && !knownSet.has(withoutControlCharacters)) { addWarning( ctx.warnings, 'unknown-identifier', key, value, `Unknown ${noun} identifier "${withoutControlCharacters}" ignored.`, ); return undefined; } return withoutControlCharacters; } function normalizeIdentifierList( value: unknown, key: string, ctx: NormalizationContext, knownSet: ReadonlySet | undefined, noun: string, ): string[] | undefined { if (value === undefined || value === null) return undefined; if (!Array.isArray(value)) { addWarning( ctx.warnings, 'invalid-list', key, value, `Expected ${noun} visibility list to be an array.`, ); return undefined; } const seen = new Set(); let limitWarningEmitted = false; for (const item of value as readonly unknown[]) { const id = normalizeIdentifier(item, key, ctx, knownSet, noun); if (id === undefined || seen.has(id)) continue; if (seen.size >= ctx.limits.maxComponentListLength) { if (!limitWarningEmitted) { addWarning( ctx.warnings, 'too-many-components', key, value.length, `Component list was truncated to ${ctx.limits.maxComponentListLength} entries.`, ); limitWarningEmitted = true; } continue; } seen.add(id); } if (seen.size === 0) return []; return Array.from(seen).sort(compareLexicographic); } function normalizeOpacityMap( value: unknown, ctx: NormalizationContext, ): Record | undefined { if (value === undefined || value === null) return undefined; if (!isRecord(value)) { addWarning( ctx.warnings, 'invalid-opacity-map', 'op', value, 'Expected component opacity to be an object map of component id to opacity.', ); return undefined; } const entries: Array = []; let limitWarningEmitted = false; for (const [rawId, rawOpacity] of Object.entries(value)) { const id = normalizeIdentifier(rawId, 'op', ctx, ctx.knownComponentIds, 'component'); if (id === undefined) continue; if (entries.length >= ctx.limits.maxOpacityEntries) { if (!limitWarningEmitted) { addWarning( ctx.warnings, 'too-many-opacity-entries', 'op', Object.keys(value).length, `Component opacity map was truncated to ${ctx.limits.maxOpacityEntries} entries.`, ); limitWarningEmitted = true; } continue; } const opacity = normalizeNumberValue(rawOpacity, `op.${id}`, ctx, 0, 1); if (opacity !== undefined) entries.push([id, opacity]); } if (entries.length === 0) return {}; entries.sort(([a], [b]) => compareLexicographic(a, b)); const output: Record = {}; for (const [id, opacity] of entries) { output[id] = opacity; } return output; } function normalizeNumberValue( value: unknown, key: string, ctx: NormalizationContext, min: number, max: number, integer = false, ): number | undefined { if (value === undefined || value === null) return undefined; let numberValue: number; if (typeof value === 'number') { numberValue = value; } else if (typeof value === 'string') { const trimmed = value.trim(); if (trimmed.length === 0) { addWarning(ctx.warnings, 'invalid-number', key, value, `Empty numeric value for "${key}" ignored.`); return undefined; } numberValue = Number(trimmed); } else { addWarning(ctx.warnings, 'invalid-number', key, value, `Expected "${key}" to be a finite number.`); return undefined; } if (!Number.isFinite(numberValue)) { addWarning(ctx.warnings, 'invalid-number', key, value, `Non-finite numeric value for "${key}" ignored.`); return undefined; } if (integer && !Number.isInteger(numberValue)) { const rounded = Math.floor(numberValue); addWarning( ctx.warnings, 'rounded-number', key, value, `Expected "${key}" to be an integer; rounded down to ${rounded}.`, ); numberValue = rounded; } const clamped = clamp(numberValue, min, max); if (clamped !== numberValue) { addWarning( ctx.warnings, 'clamped-number', key, value, `Value for "${key}" was clamped to the safe range ${min}–${max}.`, ); } return Object.is(clamped, -0) ? 0 : clamped; } function normalizeBooleanValue( value: unknown, key: string, ctx: NormalizationContext, ): boolean | undefined { if (value === undefined || value === null) return undefined; if (typeof value === 'boolean') return value; if (typeof value === 'number') { if (value === 1) return true; if (value === 0) return false; } if (typeof value === 'string') { const normalized = value.trim().toLowerCase(); if (TRUE_STRINGS.has(normalized)) return true; if (FALSE_STRINGS.has(normalized)) return false; } addWarning( ctx.warnings, 'invalid-boolean', key, value, `Expected "${key}" to be a boolean-like value.`, ); return undefined; } function normalizeRenderModeValue( value: unknown, key: string, ctx: NormalizationContext, ): ShareableRenderMode | undefined { if (value === undefined || value === null) return undefined; if (typeof value !== 'string') { addWarning(ctx.warnings, 'invalid-render-mode', key, value, 'Render mode must be a string.'); return undefined; } const normalized = value.trim().toLowerCase(); const renderMode = RENDER_MODE_ALIASES[normalized]; if (renderMode === undefined) { addWarning( ctx.warnings, 'invalid-render-mode', key, value, `Unknown render mode "${value}" ignored.`, ); return undefined; } return renderMode; } function normalizeCameraState( value: unknown, ctx: NormalizationContext, ): ShareableCameraState | undefined { if (value === undefined || value === null) return undefined; if (!isRecord(value)) { addWarning(ctx.warnings, 'invalid-camera-vector', 'camera', value, 'Camera state must be an object.'); return undefined; } const camera: ShareableCameraState = {}; const preset = normalizeIdentifier( value.preset, 'cam', ctx, ctx.knownCameraPresetIds, 'camera preset', ); if (preset !== undefined) camera.preset = preset; const position = normalizeVector3Value(value.position, 'pos', ctx); if (position !== undefined) camera.position = position; const target = normalizeVector3Value(value.target, 'target', ctx); if (target !== undefined) camera.target = target; const zoom = normalizeNumberValue(value.zoom, 'zoom', ctx, ctx.limits.minZoom, ctx.limits.maxZoom); if (zoom !== undefined) camera.zoom = zoom; return Object.keys(camera).length > 0 ? camera : undefined; } function normalizeVector3Value( value: unknown, key: string, ctx: NormalizationContext, ): ShareableVector3 | undefined { if (value === undefined || value === null) return undefined; if (!Array.isArray(value) || value.length !== 3) { addWarning(ctx.warnings, 'invalid-camera-vector', key, value, `"${key}" must contain exactly 3 coordinates.`); return undefined; } const min = -ctx.limits.maxCoordinateMagnitude; const max = ctx.limits.maxCoordinateMagnitude; const x = normalizeNumberValue(value[0], `${key}.x`, ctx, min, max); const y = normalizeNumberValue(value[1], `${key}.y`, ctx, min, max); const z = normalizeNumberValue(value[2], `${key}.z`, ctx, min, max); if (x === undefined || y === undefined || z === undefined) { addWarning( ctx.warnings, 'invalid-camera-vector', key, value, `"${key}" contained an invalid coordinate and was ignored.`, ); return undefined; } return [x, y, z]; } function normalizeCrossSectionState( value: unknown, ctx: NormalizationContext, ): ShareableCrossSectionState | undefined { if (value === undefined || value === null) return undefined; if (!isRecord(value)) { addWarning( ctx.warnings, 'invalid-cross-section', 'cut', value, 'Cross-section state must be an object.', ); return undefined; } const crossSection: ShareableCrossSectionState = {}; const axis = normalizeAxis(value.axis, ctx); if (axis !== undefined) crossSection.axis = axis; const offset = normalizeNumberValue( value.offset, 'cut.offset', ctx, -ctx.limits.maxCoordinateMagnitude, ctx.limits.maxCoordinateMagnitude, ); if (offset !== undefined) crossSection.offset = offset; const inverted = normalizeBooleanValue(value.inverted, 'cut.inverted', ctx); if (inverted !== undefined) crossSection.inverted = inverted; return Object.keys(crossSection).length > 0 ? crossSection : undefined; } function normalizeAxis(value: unknown, ctx: NormalizationContext): ShareableAxis | undefined { if (value === undefined || value === null) return undefined; if (typeof value !== 'string') { addWarning(ctx.warnings, 'invalid-axis', 'cut.axis', value, 'Cross-section axis must be x, y, or z.'); return undefined; } const normalized = value.trim().toLowerCase(); if (normalized === 'x' || normalized === 'y' || normalized === 'z') { return normalized; } addWarning( ctx.warnings, 'invalid-axis', 'cut.axis', value, `Unknown cross-section axis "${value}" ignored.`, ); return undefined; } function readParam( params: URLSearchParams, field: CanonicalUrlField, warnings: ShareableStateWarning[], ): string | undefined { const aliases = URL_KEY_ALIASES[field]; const presentKeys: string[] = []; let chosenValue: string | undefined; for (const alias of aliases) { const values = params.getAll(alias); if (values.length === 0) continue; presentKeys.push(alias); if (values.length > 1) { addWarning( warnings, 'duplicate-query-key', aliases[0], values.join(','), `Multiple "${alias}" query parameters were supplied; the last value was used.`, ); } const lastValue = values[values.length - 1]; if (lastValue === undefined) continue; if (chosenValue === undefined || alias === aliases[0]) { chosenValue = lastValue; } } if (presentKeys.length > 1) { addWarning( warnings, 'ambiguous-query-alias', aliases[0], presentKeys.join(','), `Multiple aliases were supplied for "${aliases[0]}"; the canonical key takes precedence when present.`, ); } return chosenValue; } function setRawParam(target: Record, key: string, value: string | undefined): void { if (value !== undefined) target[key] = value; } function findUnknownKeys(params: URLSearchParams): string[] { const unknownKeys = new Set(); params.forEach((_value, key) => { if (!RECOGNIZED_SHAREABLE_ANIMATION_STATE_KEY_SET.has(key)) { unknownKeys.add(key); } }); return Array.from(unknownKeys).sort(compareLexicographic); } function parseIdentifierListParam( value: string, key: string, warnings: ShareableStateWarning[], ): unknown[] { const trimmed = value.trim(); if (trimmed.length === 0) return []; if (trimmed.startsWith('[')) { try { const parsed = JSON.parse(trimmed) as unknown; if (Array.isArray(parsed)) { return parsed; } } catch { addWarning(warnings, 'invalid-list', key, value, `Could not parse "${key}" JSON list.`); return []; } addWarning(warnings, 'invalid-list', key, value, `Expected "${key}" JSON payload to be an array.`); return []; } const ids: string[] = []; for (const token of trimmed.split(',')) { if (token.length === 0) continue; ids.push(decodeListToken(token, key, warnings)); } return ids; } function parseOpacityParam( value: string, key: string, warnings: ShareableStateWarning[], ): Record { const trimmed = value.trim(); const map = Object.create(null) as Record; if (trimmed.length === 0) return map; if (trimmed.startsWith('{')) { try { const parsed = JSON.parse(trimmed) as unknown; if (isRecord(parsed)) { return parsed; } } catch { addWarning(warnings, 'invalid-opacity-map', key, value, `Could not parse "${key}" JSON opacity map.`); return map; } addWarning( warnings, 'invalid-opacity-map', key, value, `Expected "${key}" JSON payload to be an object.`, ); return map; } for (const entry of trimmed.split(',')) { if (entry.length === 0) continue; const separatorIndex = entry.indexOf(':'); if (separatorIndex < 0) { addWarning( warnings, 'invalid-opacity-map', key, entry, `Opacity entry "${entry}" is missing an id:value separator.`, ); continue; } const rawId = entry.slice(0, separatorIndex); const rawOpacity = entry.slice(separatorIndex + 1); const id = decodeListToken(rawId, key, warnings); map[id] = rawOpacity; } return map; } function parseCrossSectionParam( value: string, key: string, warnings: ShareableStateWarning[], ): Record { const trimmed = value.trim(); if (trimmed.length === 0) return {}; if (trimmed.startsWith('{')) { try { const parsed = JSON.parse(trimmed) as unknown; if (isRecord(parsed)) { return parsed; } } catch { addWarning( warnings, 'invalid-cross-section', key, value, `Could not parse "${key}" JSON cross-section state.`, ); return {}; } addWarning( warnings, 'invalid-cross-section', key, value, `Expected "${key}" JSON payload to be an object.`, ); return {}; } const delimiter = trimmed.includes(':') ? ':' : ','; const parts = trimmed.split(delimiter); const crossSection: Record = {}; const axis = parts[0]?.trim(); const offset = parts[1]?.trim(); const inverted = parts[2]?.trim(); if (axis !== undefined && axis.length > 0) crossSection.axis = axis; if (offset !== undefined && offset.length > 0) crossSection.offset = offset; if (inverted !== undefined && inverted.length > 0) crossSection.inverted = inverted; return crossSection; } function parseVectorParam( value: string, key: string, warnings: ShareableStateWarning[], ): ShareableVector3 | undefined { const parts = value.split(','); if (parts.length !== 3) { addWarning(warnings, 'invalid-camera-vector', key, value, `"${key}" must contain 3 comma-separated values.`); return undefined; } const x = parseVectorNumber(parts[0], key, 'x', warnings); const y = parseVectorNumber(parts[1], key, 'y', warnings); const z = parseVectorNumber(parts[2], key, 'z', warnings); if (x === undefined || y === undefined || z === undefined) { addWarning(warnings, 'invalid-camera-vector', key, value, `"${key}" contained a non-finite coordinate.`); return undefined; } return [x, y, z]; } function parseVectorNumber( value: string | undefined, key: string, axis: string, warnings: ShareableStateWarning[], ): number | undefined { const trimmed = value?.trim() ?? ''; if (trimmed.length === 0) { addWarning( warnings, 'invalid-camera-vector', `${key}.${axis}`, value, `Missing "${axis}" coordinate for "${key}".`, ); return undefined; } const parsed = Number(trimmed); if (!Number.isFinite(parsed)) { addWarning( warnings, 'invalid-camera-vector', `${key}.${axis}`, value, `Non-finite "${axis}" coordinate for "${key}".`, ); return undefined; } return parsed; } function decodeListToken( token: string, key: string, warnings: ShareableStateWarning[], ): string { try { return decodeURIComponent(token); } catch { addWarning( warnings, 'invalid-list-token', key, token, `Could not URI-decode list token "${token}"; raw token was used.`, ); return token; } } function searchParamsFromString(input: string): URLSearchParams { const trimmed = input.trim(); if (trimmed.length === 0) return new URLSearchParams(); if (isPlainQueryString(trimmed)) { return new URLSearchParams(stripQueryPrefix(trimmed)); } try { return searchParamsFromUrl(new URL(trimmed, 'https://mechanica.local')); } catch { return new URLSearchParams(stripQueryPrefix(trimmed)); } } function searchParamsFromUrl(url: URL): URLSearchParams { const params = new URLSearchParams(url.search); const hashQuery = extractHashQuery(url.hash); if (hashQuery.length > 0) { appendSearchParams(params, new URLSearchParams(hashQuery)); } return params; } function extractHashQuery(hash: string): string { if (hash.length === 0) return ''; const normalizedHash = hash.startsWith('#') ? hash.slice(1) : hash; if (normalizedHash.startsWith('?')) { return normalizedHash.slice(1); } const queryIndex = normalizedHash.indexOf('?'); if (queryIndex < 0) return ''; return normalizedHash.slice(queryIndex + 1); } function appendSearchParams(target: URLSearchParams, source: URLSearchParams): void { source.forEach((value, key) => { target.append(key, value); }); } function isPlainQueryString(value: string): boolean { if (value.startsWith('?') || value.startsWith('#?')) return true; if (/^[a-z][a-z\d+\-.]*:/i.test(value)) return false; if (value.startsWith('/') || value.startsWith('#/')) return false; if (value.includes('?')) return false; const candidate = stripQueryPrefix(value); return candidate.includes('=') || candidate.includes('&'); } function stripQueryPrefix(value: string): string { if (value.startsWith('#?')) return value.slice(2); if (value.startsWith('?') || value.startsWith('#')) return value.slice(1); return value; } function setNumberParam( params: URLSearchParams, key: string, value: number, fractionDigits: number, ): void { params.set(key, formatNumber(value, fractionDigits)); } function setBooleanParam(params: URLSearchParams, key: string, value: boolean): void { params.set(key, value ? '1' : '0'); } function formatVector3(vector: ShareableVector3): string { return [vector[0], vector[1], vector[2]].map((value) => formatNumber(value, 3)).join(','); } function encodeIdentifierList(values: readonly string[]): string { return values.map((value) => encodeURIComponent(value)).join(','); } function encodeOpacityMap(map: Readonly>): string { return Object.keys(map) .sort(compareLexicographic) .map((id) => `${encodeURIComponent(id)}:${formatNumber(map[id] ?? 0, 3)}`) .join(','); } function encodeCrossSection(crossSection: ShareableCrossSectionState): string { const parts = [ crossSection.axis ?? '', crossSection.offset !== undefined ? formatNumber(crossSection.offset, 3) : '', crossSection.inverted !== undefined ? (crossSection.inverted ? '1' : '0') : '', ]; while (parts.length > 0 && parts[parts.length - 1] === '') { parts.pop(); } return parts.join(':'); } function formatNumber(value: number, fractionDigits: number): string { const factor = 10 ** fractionDigits; const rounded = Math.round(value * factor) / factor; if (Object.is(rounded, -0)) return '0'; return String(rounded); } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function addWarning( warnings: ShareableStateWarning[], code: ShareableStateWarningCode, key: string | undefined, value: unknown, message: string, ): void { const warning: ShareableStateWarning = { code, message, }; if (key !== undefined) warning.key = key; const stringValue = stringifyWarningValue(value); if (stringValue !== undefined) warning.value = stringValue; warnings.push(warning); } function stringifyWarningValue(value: unknown): string | undefined { if (value === undefined) return undefined; if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { return String(value); } try { return JSON.stringify(value); } catch { return String(value); } } function isUrlSearchParams(value: unknown): value is URLSearchParams { return typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams; } function isUrl(value: unknown): value is URL { return typeof URL !== 'undefined' && value instanceof URL; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function compareLexicographic(a: string, b: string): number { if (a < b) return -1; if (a > b) return 1; return 0; }