export type Vector3Tuple = [number, number, number]; export type ViewerViewMode = | 'solid' | 'wireframe' | 'xray' | 'cross-section' | (string & {}); export interface CameraShareState { position?: Vector3Tuple; target?: Vector3Tuple; zoom?: number; fov?: number; preset?: string; } export interface CrossSectionShareState { enabled?: boolean; axis?: 'x' | 'y' | 'z'; offset?: number; } export interface ViewerShareState { machineId?: string; viewMode?: ViewerViewMode; selectedPartId?: string | null; camera?: CameraShareState; cameraPosition?: Vector3Tuple; cameraTarget?: Vector3Tuple; cameraPreset?: string; isExploded?: boolean; exploded?: boolean; explodeAmount?: number; hiddenPartIds?: string[]; isolatedPartIds?: string[]; partOpacities?: Record; showAnnotations?: boolean; showLabels?: boolean; crossSection?: CrossSectionShareState; } export interface ParsedViewerShareState { state: ViewerShareState; warnings: string[]; consumedKeys: string[]; } export interface ViewStateUrlOptions { /** * Number precision used for camera coordinates, opacity, FOV and explode * separation values. Keeping this bounded prevents noisy URL churn while * retaining enough fidelity to restore the same view. */ precision?: number; /** * Include values that match viewer defaults. This is mainly useful for tests * and debugging; public share URLs omit defaults to stay compact. */ includeDefaults?: boolean; /** * Encode the whole view state as a versioned base64url payload in `vs`. * Query-parameter encoding remains the default because it is inspectable and * easy to edit by hand. */ preferCompact?: boolean; } export const VIEW_STATE_SCHEMA_VERSION = 2; export const DEFAULT_VIEW_STATE = Object.freeze({ viewMode: 'solid' as ViewerViewMode, isExploded: false, explodeAmount: 0, showAnnotations: true, showLabels: true }); export const VIEW_STATE_URL_PARAM_KEYS = Object.freeze({ compact: 'vs', version: 'v', machine: 'machine', machineShort: 'm', mode: 'mode', cameraPosition: 'cam', cameraComposite: 'camera', cameraTarget: 'target', cameraZoom: 'zoom', cameraFov: 'fov', cameraPreset: 'preset', selectedPart: 'selected', hiddenParts: 'hidden', isolatedParts: 'isolated', opacities: 'opacity', exploded: 'exploded', explodeAmount: 'explode', annotations: 'annotations', labels: 'labels', crossSection: 'section' }); export const VIEWER_URL_STATE_KEYS = VIEW_STATE_URL_PARAM_KEYS; const DEFAULT_PRECISION = 3; const MIN_CAMERA_ZOOM = 0.05; const MAX_CAMERA_ZOOM = 100; const MIN_CAMERA_FOV = 10; const MAX_CAMERA_FOV = 90; const MAX_ABS_COORDINATE = 100_000; const MIN_EXPLODE_AMOUNT = 0; const MAX_EXPLODE_AMOUNT = 3; const MAX_TOKEN_LENGTH = 160; const STATE_PARAM_ALIASES = [ 'vs', 'v', 'machine', 'm', 'machineId', 'id', 'mode', 'view', 'renderMode', 'wireframe', 'wf', 'cam', 'camera', 'cameraPosition', 'position', 'px', 'py', 'pz', 'cameraX', 'cameraY', 'cameraZ', 'x', 'y', 'z', 'target', 'cameraTarget', 'lookAt', 'tx', 'ty', 'tz', 'targetX', 'targetY', 'targetZ', 'zoom', 'cameraZoom', 'fov', 'cameraFov', 'preset', 'cameraPreset', 'selected', 'selectedPart', 'selectedPartId', 'part', 'hidden', 'hiddenParts', 'hiddenPartIds', 'isolated', 'isolatedParts', 'isolatedPartIds', 'opacity', 'opacities', 'partOpacities', 'opacityByPartId', 'exploded', 'isExploded', 'explode', 'explodeAmount', 'separation', 'annotations', 'showAnnotations', 'labels', 'showLabels', 'section', 'crossSection', 'clip', 'clipping' ] as const; const TRUE_VALUES = new Set(['1', 'true', 'yes', 'y', 'on']); const FALSE_VALUES = new Set(['0', 'false', 'no', 'n', 'off']); const MODE_ALIASES: Record = { solid: 'solid', shaded: 'solid', default: 'solid', wire: 'wireframe', wires: 'wireframe', wireframe: 'wireframe', wf: 'wireframe', xray: 'xray', 'x-ray': 'xray', transparent: 'xray', section: 'cross-section', crosssection: 'cross-section', 'cross-section': 'cross-section', clipped: 'cross-section' }; type BufferLikeConstructor = { from(input: string, encoding: string): { toString(encoding: string): string; }; }; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function hasOwn(record: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(record, key); } function firstRecordValue( record: Record, keys: readonly string[] ): unknown { for (const key of keys) { if (hasOwn(record, key)) { return record[key]; } } return undefined; } function firstSearchValue( params: URLSearchParams, keys: readonly string[], consumedKeys?: string[] ): string | undefined { for (const key of keys) { const value = params.get(key); if (value !== null) { consumedKeys?.push(key); return value; } } return undefined; } function sanitizeToken(value: unknown, maxLength = MAX_TOKEN_LENGTH): string | undefined { if (typeof value !== 'string' && typeof value !== 'number') { return undefined; } const token = String(value) .trim() .replace(/[\u0000-\u001f\u007f]/g, ''); if (!token) { return undefined; } return token.slice(0, maxLength); } function normalizeMode(value: unknown): ViewerViewMode | undefined { const token = sanitizeToken(value); if (!token) { return undefined; } const canonical = token.toLowerCase().replace(/\s+/g, '-'); return MODE_ALIASES[canonical] ?? (token as ViewerViewMode); } function toFiniteNumber(value: unknown): number | undefined { if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined; } if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); if (!trimmed) { return undefined; } const parsed = Number(trimmed); return Number.isFinite(parsed) ? parsed : undefined; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function roundNumber(value: number, precision: number): number { const factor = 10 ** precision; const rounded = Math.round(value * factor) / factor; return Object.is(rounded, -0) ? 0 : rounded; } function normalizeNumber( value: unknown, { min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, maxAbs = Number.POSITIVE_INFINITY, precision = DEFAULT_PRECISION }: { min?: number; max?: number; maxAbs?: number; precision?: number; } = {} ): number | undefined { const parsed = toFiniteNumber(value); if (parsed === undefined || Math.abs(parsed) > maxAbs) { return undefined; } return roundNumber(clamp(parsed, min, max), precision); } function formatNumber(value: number, precision: number): string { const rounded = roundNumber(value, precision); if (Number.isInteger(rounded)) { return String(rounded); } return rounded.toFixed(precision).replace(/\.?0+$/, ''); } function splitVector(value: string): string[] { return value .trim() .split(/[,\s;]+/) .filter(Boolean); } function parseVector3(value: unknown, precision = DEFAULT_PRECISION): Vector3Tuple | undefined { if (Array.isArray(value) && value.length >= 3) { const vector = value.slice(0, 3).map((entry) => normalizeNumber(entry, { maxAbs: MAX_ABS_COORDINATE, precision }) ); if (vector.every((entry): entry is number => entry !== undefined)) { return vector as Vector3Tuple; } return undefined; } if (isRecord(value)) { return parseVector3([value.x, value.y, value.z], precision); } if (typeof value === 'string') { return parseVector3(splitVector(value), precision); } return undefined; } function formatVector3(vector: Vector3Tuple, precision: number): string { return vector.map((entry) => formatNumber(entry, precision)).join(','); } function normalizeBoolean(value: unknown): boolean | undefined { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { if (value === 1) return true; if (value === 0) return false; return undefined; } if (typeof value !== 'string') { return undefined; } const normalized = value.trim().toLowerCase(); if (TRUE_VALUES.has(normalized)) { return true; } if (FALSE_VALUES.has(normalized)) { return false; } return undefined; } function normalizeStringList(value: unknown): string[] | undefined { let entries: unknown[]; if (Array.isArray(value)) { entries = value; } else if (value instanceof Set) { entries = Array.from(value); } else if (typeof value === 'string') { if (!value.trim()) { return undefined; } entries = value.split(','); } else { return undefined; } const normalized = entries .map((entry) => { const token = sanitizeToken(entry); if (!token) { return undefined; } try { return decodeURIComponent(token); } catch { return token; } }) .filter((entry): entry is string => Boolean(entry)); const unique = Array.from(new Set(normalized)).sort((a, b) => a.localeCompare(b)); return unique.length > 0 ? unique : undefined; } function formatStringList(values: readonly string[]): string { return values.map((value) => encodeURIComponent(value)).join(','); } function parseOpacityMap(value: string): Record | undefined { const trimmed = value.trim(); if (!trimmed) { return undefined; } if (trimmed.startsWith('{')) { try { const parsed = JSON.parse(trimmed) as unknown; return normalizeOpacityMap(parsed); } catch { return undefined; } } const result: Record = {}; for (const pair of trimmed.split(',')) { const separatorIndex = pair.search(/[:=]/); if (separatorIndex < 0) { continue; } const rawId = pair.slice(0, separatorIndex); const rawOpacity = pair.slice(separatorIndex + 1); const id = sanitizeToken(rawId); const opacity = normalizeNumber(rawOpacity, { min: 0, max: 1, precision: DEFAULT_PRECISION }); if (!id || opacity === undefined) { continue; } try { result[decodeURIComponent(id)] = opacity; } catch { result[id] = opacity; } } return Object.keys(result).length > 0 ? result : undefined; } function normalizeOpacityMap( value: unknown, precision = DEFAULT_PRECISION ): Record | undefined { if (typeof value === 'string') { return parseOpacityMap(value); } if (!isRecord(value)) { return undefined; } const normalized: Record = {}; for (const [partId, opacityValue] of Object.entries(value)) { const safePartId = sanitizeToken(partId); const opacity = normalizeNumber(opacityValue, { min: 0, max: 1, precision }); if (!safePartId || opacity === undefined) { continue; } normalized[safePartId] = opacity; } const ordered = Object.fromEntries( Object.entries(normalized).sort(([left], [right]) => left.localeCompare(right)) ); return Object.keys(ordered).length > 0 ? ordered : undefined; } function formatOpacityMap( opacities: Record, precision: number, includeDefaults: boolean ): string | undefined { const entries = Object.entries(opacities) .filter(([, opacity]) => includeDefaults || opacity < 0.999) .sort(([left], [right]) => left.localeCompare(right)); if (entries.length === 0) { return undefined; } return entries .map(([partId, opacity]) => `${encodeURIComponent(partId)}:${formatNumber(opacity, precision)}`) .join(','); } function normalizeCrossSection( value: unknown, precision = DEFAULT_PRECISION ): CrossSectionShareState | undefined { if (typeof value === 'string') { const booleanValue = normalizeBoolean(value); if (booleanValue !== undefined) { return { enabled: booleanValue }; } const [rawAxis, rawOffset] = value.split(/[:,]/); const axis = normalizeAxis(rawAxis); const offset = normalizeNumber(rawOffset, { min: -MAX_ABS_COORDINATE, max: MAX_ABS_COORDINATE, precision }); if (axis || offset !== undefined) { return { enabled: true, ...(axis ? { axis } : {}), ...(offset !== undefined ? { offset } : {}) }; } try { return normalizeCrossSection(JSON.parse(value) as unknown, precision); } catch { return undefined; } } if (!isRecord(value)) { return undefined; } const enabled = normalizeBoolean(firstRecordValue(value, ['enabled', 'active', 'on'])); const axis = normalizeAxis(firstRecordValue(value, ['axis', 'normal'])); const offset = normalizeNumber(firstRecordValue(value, ['offset', 'position', 'distance']), { min: -MAX_ABS_COORDINATE, max: MAX_ABS_COORDINATE, precision }); if (enabled === undefined && !axis && offset === undefined) { return undefined; } return { ...(enabled !== undefined ? { enabled } : {}), ...(axis ? { axis } : {}), ...(offset !== undefined ? { offset } : {}) }; } function normalizeAxis(value: unknown): 'x' | 'y' | 'z' | undefined { const token = sanitizeToken(value, 1)?.toLowerCase(); return token === 'x' || token === 'y' || token === 'z' ? token : undefined; } function formatCrossSection(crossSection: CrossSectionShareState, precision: number): string | undefined { if (crossSection.enabled === false) { return '0'; } if (crossSection.axis || crossSection.offset !== undefined) { return `${crossSection.axis ?? 'x'}:${formatNumber(crossSection.offset ?? 0, precision)}`; } if (crossSection.enabled === true) { return '1'; } return undefined; } function parseCameraComposite(value: string, precision: number): CameraShareState | undefined { const chunks = value.split('|').map((chunk) => chunk.trim()); const position = parseVector3(chunks[0], precision); const target = parseVector3(chunks[1], precision); const thirdChunkNumber = normalizeNumber(chunks[2], { min: MIN_CAMERA_ZOOM, max: MAX_CAMERA_ZOOM, precision }); const fourthChunkNumber = normalizeNumber(chunks[3], { min: MIN_CAMERA_FOV, max: MAX_CAMERA_FOV, precision }); const camera: CameraShareState = {}; if (position) { camera.position = position; } if (target) { camera.target = target; } if (thirdChunkNumber !== undefined) { camera.zoom = thirdChunkNumber; } if (fourthChunkNumber !== undefined) { camera.fov = fourthChunkNumber; } const preset = sanitizeToken(chunks[4]); if (preset) { camera.preset = preset; } return Object.keys(camera).length > 0 ? camera : undefined; } function parseTripletParams( params: URLSearchParams, keys: readonly [readonly string[], readonly string[], readonly string[]], consumedKeys: string[], precision: number ): Vector3Tuple | undefined { const values = keys.map((axisKeys) => firstSearchValue(params, axisKeys, consumedKeys)); if (values.some((value) => value === undefined)) { return undefined; } return parseVector3(values, precision); } function toSearchParams(input: string | URL | URLSearchParams | Location | undefined): URLSearchParams { if (!input) { return new URLSearchParams(); } if (input instanceof URLSearchParams) { return new URLSearchParams(input); } if (input instanceof URL) { return new URLSearchParams(input.search); } if (typeof input === 'object' && 'search' in input && typeof input.search === 'string') { return new URLSearchParams(input.search); } const raw = String(input).trim(); if (!raw) { return new URLSearchParams(); } if (/^[a-z][a-z\d+\-.]*:\/\//i.test(raw) || raw.startsWith('/')) { return new URLSearchParams(new URL(raw, 'https://mechanica.local').search); } return new URLSearchParams(raw.startsWith('?') ? raw.slice(1) : raw); } function utf8ToBase64Url(input: string): string { const bufferConstructor = (globalThis as unknown as { Buffer?: BufferLikeConstructor }).Buffer; if (bufferConstructor) { return bufferConstructor .from(input, 'utf8') .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } const bytes = new TextEncoder().encode(input); let binary = ''; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } function base64UrlToUtf8(input: string): string { const base64 = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '='); const bufferConstructor = (globalThis as unknown as { Buffer?: BufferLikeConstructor }).Buffer; if (bufferConstructor) { return bufferConstructor.from(base64, 'base64').toString('utf8'); } const binary = atob(base64); const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); return new TextDecoder().decode(bytes); } function canonicalCompactPayload(state: ViewerShareState): Record { const payload: Record = { v: VIEW_STATE_SCHEMA_VERSION }; if (state.machineId) payload.m = state.machineId; if (state.viewMode) payload.mode = state.viewMode; if (state.selectedPartId) payload.sel = state.selectedPartId; if (state.camera) { const cameraPayload: Record = {}; if (state.camera.position) cameraPayload.p = state.camera.position; if (state.camera.target) cameraPayload.t = state.camera.target; if (state.camera.zoom !== undefined) cameraPayload.z = state.camera.zoom; if (state.camera.fov !== undefined) cameraPayload.f = state.camera.fov; if (state.camera.preset) cameraPayload.r = state.camera.preset; if (Object.keys(cameraPayload).length > 0) { payload.c = cameraPayload; } } if (state.isExploded !== undefined) payload.ex = state.isExploded; if (state.explodeAmount !== undefined) payload.exa = state.explodeAmount; if (state.hiddenPartIds?.length) payload.h = state.hiddenPartIds; if (state.isolatedPartIds?.length) payload.i = state.isolatedPartIds; if (state.partOpacities && Object.keys(state.partOpacities).length > 0) payload.o = state.partOpacities; if (state.showAnnotations !== undefined) payload.ann = state.showAnnotations; if (state.showLabels !== undefined) payload.lab = state.showLabels; if (state.crossSection) payload.cs = state.crossSection; return payload; } function expandCompactPayload(payload: unknown): ViewerShareState | undefined { if (!isRecord(payload)) { return undefined; } const cameraPayload = isRecord(payload.c) ? payload.c : undefined; return normalizeViewerShareState({ machineId: payload.m ?? payload.machine ?? payload.machineId, viewMode: payload.mode, selectedPartId: payload.sel ?? payload.selected ?? payload.selectedPartId, camera: cameraPayload ? { position: cameraPayload.p ?? cameraPayload.position, target: cameraPayload.t ?? cameraPayload.target, zoom: cameraPayload.z ?? cameraPayload.zoom, fov: cameraPayload.f ?? cameraPayload.fov, preset: cameraPayload.r ?? cameraPayload.preset } : payload.camera, isExploded: payload.ex ?? payload.exploded ?? payload.isExploded, explodeAmount: payload.exa ?? payload.explode ?? payload.explodeAmount, hiddenPartIds: payload.h ?? payload.hidden ?? payload.hiddenPartIds, isolatedPartIds: payload.i ?? payload.isolated ?? payload.isolatedPartIds, partOpacities: payload.o ?? payload.opacity ?? payload.partOpacities, showAnnotations: payload.ann ?? payload.annotations ?? payload.showAnnotations, showLabels: payload.lab ?? payload.labels ?? payload.showLabels, crossSection: payload.cs ?? payload.section ?? payload.crossSection }); } /** * Normalises arbitrary store-like or query-derived state into the canonical * share-state contract. This function intentionally accepts multiple alias * keys so older public links and slightly different Zustand store field names * keep working as the viewer evolves. */ export function normalizeViewerShareState( input: unknown, options: ViewStateUrlOptions = {} ): ViewerShareState { const precision = options.precision ?? DEFAULT_PRECISION; const record = isRecord(input) ? input : {}; const cameraRecord = isRecord(record.camera) ? record.camera : {}; const cameraPosition = parseVector3( firstRecordValue(cameraRecord, ['position', 'pos', 'p']) ?? firstRecordValue(record, ['cameraPosition', 'position', 'cam']), precision ); const cameraTarget = parseVector3( firstRecordValue(cameraRecord, ['target', 'lookAt', 't']) ?? firstRecordValue(record, ['cameraTarget', 'target', 'lookAt']), precision ); const cameraZoom = normalizeNumber( firstRecordValue(cameraRecord, ['zoom', 'z']) ?? firstRecordValue(record, ['cameraZoom', 'zoom']), { min: MIN_CAMERA_ZOOM, max: MAX_CAMERA_ZOOM, precision } ); const cameraFov = normalizeNumber( firstRecordValue(cameraRecord, ['fov', 'fieldOfView', 'f']) ?? firstRecordValue(record, ['cameraFov', 'fov', 'fieldOfView']), { min: MIN_CAMERA_FOV, max: MAX_CAMERA_FOV, precision } ); const cameraPreset = sanitizeToken( firstRecordValue(cameraRecord, ['preset', 'r']) ?? firstRecordValue(record, ['cameraPreset', 'preset', 'activeCameraPreset']) ); const camera: CameraShareState = {}; if (cameraPosition) camera.position = cameraPosition; if (cameraTarget) camera.target = cameraTarget; if (cameraZoom !== undefined) camera.zoom = cameraZoom; if (cameraFov !== undefined) camera.fov = cameraFov; if (cameraPreset) camera.preset = cameraPreset; const viewMode = normalizeMode(firstRecordValue(record, ['viewMode', 'mode', 'renderMode'])); const machineId = sanitizeToken(firstRecordValue(record, ['machineId', 'machine', 'm', 'id'])); const selectedInput = firstRecordValue(record, [ 'selectedPartId', 'selectedPart', 'selected', 'selection', 'part' ]); const selectedPartId = selectedInput === null ? null : sanitizeToken(selectedInput); const explodeAmount = normalizeNumber( firstRecordValue(record, ['explodeAmount', 'explode', 'separation']), { min: MIN_EXPLODE_AMOUNT, max: MAX_EXPLODE_AMOUNT, precision } ); const explicitExploded = normalizeBoolean(firstRecordValue(record, ['isExploded', 'exploded'])); const inferredExploded = explodeAmount !== undefined ? explodeAmount > 0 : undefined; const hiddenPartIds = normalizeStringList( firstRecordValue(record, ['hiddenPartIds', 'hiddenParts', 'hidden']) ); const isolatedPartIds = normalizeStringList( firstRecordValue(record, ['isolatedPartIds', 'isolatedParts', 'isolated']) ); const partOpacities = normalizeOpacityMap( firstRecordValue(record, ['partOpacities', 'opacityByPartId', 'componentOpacity', 'opacities', 'opacity']), precision ); const showAnnotations = normalizeBoolean( firstRecordValue(record, ['showAnnotations', 'annotations', 'annotationLabels']) ); const showLabels = normalizeBoolean(firstRecordValue(record, ['showLabels', 'labels', 'partLabels'])); const crossSection = normalizeCrossSection( firstRecordValue(record, ['crossSection', 'section', 'clip', 'clipping']), precision ); const state: ViewerShareState = {}; if (options.includeDefaults) { state.viewMode = DEFAULT_VIEW_STATE.viewMode; state.isExploded = DEFAULT_VIEW_STATE.isExploded; state.exploded = DEFAULT_VIEW_STATE.isExploded; state.explodeAmount = DEFAULT_VIEW_STATE.explodeAmount; state.showAnnotations = DEFAULT_VIEW_STATE.showAnnotations; state.showLabels = DEFAULT_VIEW_STATE.showLabels; } if (machineId) state.machineId = machineId; if (viewMode) state.viewMode = viewMode; if (selectedInput === null) state.selectedPartId = null; if (selectedPartId) state.selectedPartId = selectedPartId; if (Object.keys(camera).length > 0) { state.camera = camera; if (camera.position) state.cameraPosition = camera.position; if (camera.target) state.cameraTarget = camera.target; if (camera.preset) state.cameraPreset = camera.preset; } if (explicitExploded !== undefined || inferredExploded !== undefined) { state.isExploded = explicitExploded ?? inferredExploded; state.exploded = state.isExploded; } if (explodeAmount !== undefined) state.explodeAmount = explodeAmount; if (hiddenPartIds) state.hiddenPartIds = hiddenPartIds; if (isolatedPartIds) state.isolatedPartIds = isolatedPartIds; if (partOpacities) state.partOpacities = partOpacities; if (showAnnotations !== undefined) state.showAnnotations = showAnnotations; if (showLabels !== undefined) state.showLabels = showLabels; if (crossSection) state.crossSection = crossSection; return state; } export function encodeCompactViewState( state: unknown, options: ViewStateUrlOptions = {} ): string { const normalized = normalizeViewerShareState(state, options); const payload = canonicalCompactPayload(normalized); return utf8ToBase64Url(JSON.stringify(payload)); } export function decodeCompactViewState(value: string): ViewerShareState | undefined { try { const decoded = JSON.parse(base64UrlToUtf8(value)) as unknown; return expandCompactPayload(decoded); } catch { return undefined; } } export function serializeViewStateToSearchParams( state: unknown, options: ViewStateUrlOptions = {} ): URLSearchParams { const precision = options.precision ?? DEFAULT_PRECISION; const includeDefaults = options.includeDefaults ?? false; const normalized = normalizeViewerShareState(state, { ...options, includeDefaults }); const params = new URLSearchParams(); if (options.preferCompact) { params.set(VIEW_STATE_URL_PARAM_KEYS.compact, encodeCompactViewState(normalized, options)); return params; } if (normalized.machineId) { params.set(VIEW_STATE_URL_PARAM_KEYS.machine, normalized.machineId); } if ( normalized.viewMode && (includeDefaults || normalized.viewMode !== DEFAULT_VIEW_STATE.viewMode) ) { params.set(VIEW_STATE_URL_PARAM_KEYS.mode, normalized.viewMode); } if (normalized.camera?.position) { params.set( VIEW_STATE_URL_PARAM_KEYS.cameraPosition, formatVector3(normalized.camera.position, precision) ); } if (normalized.camera?.target) { params.set(VIEW_STATE_URL_PARAM_KEYS.cameraTarget, formatVector3(normalized.camera.target, precision)); } if (normalized.camera?.zoom !== undefined) { params.set(VIEW_STATE_URL_PARAM_KEYS.cameraZoom, formatNumber(normalized.camera.zoom, precision)); } if (normalized.camera?.fov !== undefined) { params.set(VIEW_STATE_URL_PARAM_KEYS.cameraFov, formatNumber(normalized.camera.fov, precision)); } if (normalized.camera?.preset) { params.set(VIEW_STATE_URL_PARAM_KEYS.cameraPreset, normalized.camera.preset); } if (normalized.selectedPartId) { params.set(VIEW_STATE_URL_PARAM_KEYS.selectedPart, normalized.selectedPartId); } if (normalized.hiddenPartIds?.length) { params.set(VIEW_STATE_URL_PARAM_KEYS.hiddenParts, formatStringList(normalized.hiddenPartIds)); } if (normalized.isolatedPartIds?.length) { params.set(VIEW_STATE_URL_PARAM_KEYS.isolatedParts, formatStringList(normalized.isolatedPartIds)); } const opacityMap = normalized.partOpacities ? formatOpacityMap(normalized.partOpacities, precision, includeDefaults) : undefined; if (opacityMap) { params.set(VIEW_STATE_URL_PARAM_KEYS.opacities, opacityMap); } if ( normalized.isExploded !== undefined && (includeDefaults || normalized.isExploded !== DEFAULT_VIEW_STATE.isExploded) ) { params.set(VIEW_STATE_URL_PARAM_KEYS.exploded, normalized.isExploded ? '1' : '0'); } if ( normalized.explodeAmount !== undefined && (includeDefaults || normalized.explodeAmount !== DEFAULT_VIEW_STATE.explodeAmount) ) { params.set(VIEW_STATE_URL_PARAM_KEYS.explodeAmount, formatNumber(normalized.explodeAmount, precision)); } if ( normalized.showAnnotations !== undefined && (includeDefaults || normalized.showAnnotations !== DEFAULT_VIEW_STATE.showAnnotations) ) { params.set(VIEW_STATE_URL_PARAM_KEYS.annotations, normalized.showAnnotations ? '1' : '0'); } if ( normalized.showLabels !== undefined && (includeDefaults || normalized.showLabels !== DEFAULT_VIEW_STATE.showLabels) ) { params.set(VIEW_STATE_URL_PARAM_KEYS.labels, normalized.showLabels ? '1' : '0'); } if (normalized.crossSection) { const crossSection = formatCrossSection(normalized.crossSection, precision); if (crossSection !== undefined) { params.set(VIEW_STATE_URL_PARAM_KEYS.crossSection, crossSection); } } return params; } export function encodeViewStateToQueryString( state: unknown, options: ViewStateUrlOptions = {} ): string { return serializeViewStateToSearchParams(state, options).toString(); } export function parseViewStateSearchParams( input: string | URL | URLSearchParams | Location | undefined, options: ViewStateUrlOptions = {} ): ParsedViewerShareState { const precision = options.precision ?? DEFAULT_PRECISION; const params = toSearchParams(input); const warnings: string[] = []; const consumedKeys: string[] = []; const compact = firstSearchValue(params, ['vs', 'viewState'], consumedKeys); const compactState = compact ? decodeCompactViewState(compact) : undefined; if (compact && !compactState) { warnings.push('Unable to decode compact viewer state payload.'); } const raw: ViewerShareState = compactState ? { ...compactState } : {}; const machineId = firstSearchValue(params, ['machine', 'm', 'machineId', 'id'], consumedKeys); if (machineId !== undefined) raw.machineId = machineId; const mode = firstSearchValue(params, ['mode', 'view', 'renderMode'], consumedKeys); if (mode !== undefined) raw.viewMode = mode; const wireframeFlag = firstSearchValue(params, ['wireframe', 'wf'], consumedKeys); if (mode === undefined && normalizeBoolean(wireframeFlag) === true) { raw.viewMode = 'wireframe'; } const selectedPartId = firstSearchValue( params, ['selected', 'selectedPart', 'selectedPartId', 'part'], consumedKeys ); if (selectedPartId !== undefined) raw.selectedPartId = selectedPartId; const hiddenPartIds = firstSearchValue( params, ['hidden', 'hiddenParts', 'hiddenPartIds'], consumedKeys ); if (hiddenPartIds !== undefined) raw.hiddenPartIds = normalizeStringList(hiddenPartIds); const isolatedPartIds = firstSearchValue( params, ['isolated', 'isolatedParts', 'isolatedPartIds'], consumedKeys ); if (isolatedPartIds !== undefined) raw.isolatedPartIds = normalizeStringList(isolatedPartIds); const opacities = firstSearchValue( params, ['opacity', 'opacities', 'partOpacities', 'opacityByPartId'], consumedKeys ); if (opacities !== undefined) raw.partOpacities = parseOpacityMap(opacities); const cameraComposite = firstSearchValue(params, ['camera'], consumedKeys); const cameraFromComposite = cameraComposite?.includes('|') ? parseCameraComposite(cameraComposite, precision) : undefined; const cameraPosition = parseVector3( firstSearchValue(params, ['cam', 'cameraPosition', 'position'], consumedKeys) ?? (!cameraComposite?.includes('|') ? cameraComposite : undefined), precision ) ?? parseTripletParams( params, [ ['px', 'cameraX', 'x'], ['py', 'cameraY', 'y'], ['pz', 'cameraZ', 'z'] ], consumedKeys, precision ); const cameraTarget = parseVector3(firstSearchValue(params, ['target', 'cameraTarget', 'lookAt'], consumedKeys), precision) ?? parseTripletParams( params, [ ['tx', 'targetX'], ['ty', 'targetY'], ['tz', 'targetZ'] ], consumedKeys, precision ); const cameraZoom = firstSearchValue(params, ['zoom', 'cameraZoom'], consumedKeys); const cameraFov = firstSearchValue(params, ['fov', 'cameraFov'], consumedKeys); const cameraPreset = firstSearchValue(params, ['preset', 'cameraPreset'], consumedKeys); raw.camera = { ...(cameraFromComposite ?? {}), ...(cameraPosition ? { position: cameraPosition } : {}), ...(cameraTarget ? { target: cameraTarget } : {}), ...(cameraZoom !== undefined ? { zoom: normalizeNumber(cameraZoom, { min: MIN_CAMERA_ZOOM, max: MAX_CAMERA_ZOOM, precision }) } : {}), ...(cameraFov !== undefined ? { fov: normalizeNumber(cameraFov, { min: MIN_CAMERA_FOV, max: MAX_CAMERA_FOV, precision }) } : {}), ...(cameraPreset !== undefined ? { preset: cameraPreset } : {}) }; if (Object.keys(raw.camera).length === 0) { delete raw.camera; } const exploded = firstSearchValue(params, ['exploded', 'isExploded'], consumedKeys); if (exploded !== undefined) raw.isExploded = normalizeBoolean(exploded); const explodeAmount = firstSearchValue(params, ['explode', 'explodeAmount', 'separation'], consumedKeys); if (explodeAmount !== undefined) { const explodeBoolean = normalizeBoolean(explodeAmount); if (explodeBoolean !== undefined && !/^-?\d+(\.\d+)?$/.test(explodeAmount.trim())) { raw.isExploded = explodeBoolean; } else { raw.explodeAmount = normalizeNumber(explodeAmount, { min: MIN_EXPLODE_AMOUNT, max: MAX_EXPLODE_AMOUNT, precision }); } } const annotations = firstSearchValue(params, ['annotations', 'showAnnotations'], consumedKeys); if (annotations !== undefined) raw.showAnnotations = normalizeBoolean(annotations); const labels = firstSearchValue(params, ['labels', 'showLabels'], consumedKeys); if (labels !== undefined) raw.showLabels = normalizeBoolean(labels); const crossSection = firstSearchValue(params, ['section', 'crossSection', 'clip', 'clipping'], consumedKeys); if (crossSection !== undefined) raw.crossSection = normalizeCrossSection(crossSection, precision); const state = normalizeViewerShareState(raw, options); if (cameraPosition === undefined && firstSearchValue(params, ['cam', 'cameraPosition', 'position']) !== undefined) { warnings.push('Ignoring malformed camera position.'); } if (cameraTarget === undefined && firstSearchValue(params, ['target', 'cameraTarget', 'lookAt']) !== undefined) { warnings.push('Ignoring malformed camera target.'); } return { state, warnings, consumedKeys: Array.from(new Set(consumedKeys)) }; } export function deserializeViewStateFromSearchParams( input: string | URL | URLSearchParams | Location | undefined, options: ViewStateUrlOptions = {} ): ViewerShareState { return parseViewStateSearchParams(input, options).state; } export function decodeViewStateFromQueryString( input: string | URL | URLSearchParams | Location | undefined, options: ViewStateUrlOptions = {} ): ViewerShareState { return deserializeViewStateFromSearchParams(input, options); } export function createShareUrl( baseUrlOrState?: string | URL | Location | unknown, stateOrOptions?: unknown, maybeOptions: ViewStateUrlOptions = {} ): string { const firstLooksLikeUrl = typeof baseUrlOrState === 'string' || baseUrlOrState instanceof URL || (isRecord(baseUrlOrState) && typeof baseUrlOrState.search === 'string' && typeof baseUrlOrState.href === 'string'); const baseUrl = firstLooksLikeUrl ? baseUrlOrState : typeof window !== 'undefined' ? window.location.href : 'https://mechanica.local/viewer'; const state = firstLooksLikeUrl ? stateOrOptions : baseUrlOrState; const options = firstLooksLikeUrl ? maybeOptions : isRecord(stateOrOptions) ? (stateOrOptions as ViewStateUrlOptions) : maybeOptions; const rawBase = typeof baseUrl === 'string' ? baseUrl : (baseUrl as URL | Location).href; const isAbsolute = /^[a-z][a-z\d+\-.]*:\/\//i.test(rawBase); const parsed = new URL(rawBase, 'https://mechanica.local'); const outgoing = serializeViewStateToSearchParams(state, options); for (const key of STATE_PARAM_ALIASES) { parsed.searchParams.delete(key); } for (const [key, value] of outgoing.entries()) { parsed.searchParams.set(key, value); } if (isAbsolute) { return parsed.toString(); } return `${parsed.pathname}${parsed.search}${parsed.hash}`; } export function readViewStateFromUrl( input: string | URL | URLSearchParams | Location | undefined = typeof window !== 'undefined' ? window.location : undefined, options: ViewStateUrlOptions = {} ): ViewerShareState { return deserializeViewStateFromSearchParams(input, options); } export function writeViewStateToUrl( state: unknown, { replace = true, baseUrl = typeof window !== 'undefined' ? window.location.href : 'https://mechanica.local/viewer', ...options }: ViewStateUrlOptions & { replace?: boolean; baseUrl?: string | URL | Location } = {} ): string { const nextUrl = createShareUrl(baseUrl, state, options); if (typeof window !== 'undefined') { const historyMethod = replace ? 'replaceState' : 'pushState'; window.history[historyMethod](window.history.state, '', nextUrl); } return nextUrl; } export const serializeViewerStateToSearchParams = serializeViewStateToSearchParams; export const viewerStateToSearchParams = serializeViewStateToSearchParams; export const serializeViewerState = encodeViewStateToQueryString; export const serializeViewState = encodeViewStateToQueryString; export const encodeViewerState = encodeViewStateToQueryString; export const encodeViewState = encodeViewStateToQueryString; export const parseViewerSearchParams = parseViewStateSearchParams; export const parseViewerStateFromUrl = deserializeViewStateFromSearchParams; export const parseViewStateFromUrl = deserializeViewStateFromSearchParams; export const deserializeViewerState = deserializeViewStateFromSearchParams; export const deserializeViewState = deserializeViewStateFromSearchParams; export const decodeViewerState = decodeViewStateFromQueryString; export const decodeViewState = decodeViewStateFromQueryString; export const createViewerShareUrl = createShareUrl; export const buildShareUrl = createShareUrl; export const readViewerStateFromUrl = readViewStateFromUrl; export const writeViewerStateToUrl = writeViewStateToUrl;