import { CAMERA_PRESET_IDS, CROSS_SECTION_AXES, DISPLAY_MODES } from '../types/viewer'; import type { CameraPresetId, CrossSectionAxis, DisplayMode, UrlViewState, VectorTuple } from '../types/viewer'; const VECTOR_PRECISION = 3; function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function formatNumber(value: number): string { return Number(value.toFixed(VECTOR_PRECISION)).toString(); } function parseFiniteNumber(value: string | null): number | undefined { if (value === null || value.trim() === '') { return undefined; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } function encodeVector(vector: VectorTuple): string { return vector.map(formatNumber).join(','); } function decodeVector(value: string | null): VectorTuple | undefined { if (!value) { return undefined; } const parts = value.split(',').map((part) => Number(part)); if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) { return undefined; } return [parts[0], parts[1], parts[2]]; } function parseList(value: string | null): string[] { if (!value) { return []; } return value .split(',') .map((item) => item.trim()) .filter(Boolean); } function parseOpacityMap(value: string | null): Record { if (!value) { return {}; } return value.split(',').reduce>((accumulator, entry) => { const [partId, opacityValue] = entry.split(':'); const parsedOpacity = Number(opacityValue); if (partId && Number.isFinite(parsedOpacity)) { accumulator[partId] = clamp(parsedOpacity, 0.05, 1); } return accumulator; }, {}); } function serializeOpacityMap(value: Record): string | undefined { const entries = Object.entries(value) .filter(([, opacity]) => Number.isFinite(opacity) && opacity >= 0.05 && opacity < 0.995) .map(([partId, opacity]) => `${partId}:${formatNumber(clamp(opacity, 0.05, 1))}`); return entries.length > 0 ? entries.join(',') : undefined; } function parsePreset(value: string | null): CameraPresetId | undefined { return CAMERA_PRESET_IDS.includes(value as CameraPresetId) ? (value as CameraPresetId) : undefined; } function parseDisplayMode(value: string | null): DisplayMode | undefined { return DISPLAY_MODES.includes(value as DisplayMode) ? (value as DisplayMode) : undefined; } function parseCrossSectionAxis(value: string | null): CrossSectionAxis | undefined { return CROSS_SECTION_AXES.includes(value as CrossSectionAxis) ? (value as CrossSectionAxis) : undefined; } function parseBoolean(value: string | null): boolean | undefined { if (value === null) { return undefined; } if (value === '1' || value === 'true') { return true; } if (value === '0' || value === 'false') { return false; } return undefined; } export function parseViewerSearchParams(params: URLSearchParams): UrlViewState { const preset = parsePreset(params.get('preset')); const position = decodeVector(params.get('cam')); const target = decodeVector(params.get('target')); const explodedDistance = parseFiniteNumber(params.get('explode')); const crossSectionOffset = parseFiniteNumber(params.get('clipOffset')); const camera = preset || position || target ? { preset, position, target } : undefined; return { camera, explodedDistance: explodedDistance === undefined ? undefined : clamp(explodedDistance, 0, 5), displayMode: parseDisplayMode(params.get('mode')), crossSectionAxis: parseCrossSectionAxis(params.get('clip')), crossSectionOffset: crossSectionOffset === undefined ? undefined : clamp(crossSectionOffset, -3, 3), hiddenPartIds: parseList(params.get('hidden')), opacityByPartId: parseOpacityMap(params.get('opacity')), annotations: parseBoolean(params.get('labels')) }; } export function serializeViewerSearchParams(state: Partial): URLSearchParams { const params = new URLSearchParams(); if (state.camera?.preset) { params.set('preset', state.camera.preset); } if (state.camera?.position) { params.set('cam', encodeVector(state.camera.position)); } if (state.camera?.target) { params.set('target', encodeVector(state.camera.target)); } if (state.explodedDistance !== undefined && state.explodedDistance > 0) { params.set('explode', formatNumber(clamp(state.explodedDistance, 0, 5))); } if (state.displayMode && state.displayMode !== 'solid') { params.set('mode', state.displayMode); } if (state.crossSectionAxis && state.crossSectionAxis !== 'none') { params.set('clip', state.crossSectionAxis); } if ( state.crossSectionOffset !== undefined && state.crossSectionAxis && state.crossSectionAxis !== 'none' ) { params.set('clipOffset', formatNumber(clamp(state.crossSectionOffset, -3, 3))); } if (state.hiddenPartIds && state.hiddenPartIds.length > 0) { params.set('hidden', state.hiddenPartIds.join(',')); } if (state.opacityByPartId) { const serializedOpacity = serializeOpacityMap(state.opacityByPartId); if (serializedOpacity) { params.set('opacity', serializedOpacity); } } if (state.annotations === false) { params.set('labels', '0'); } return params; }