export type ShareAxis = 'x' | 'y' | 'z'; export type ShareCameraPreset = | 'front' | 'back' | 'left' | 'right' | 'top' | 'bottom' | 'isometric'; export type Vector3Tuple = [number, number, number]; export interface ShareCameraState { position?: Vector3Tuple; target?: Vector3Tuple; zoom?: number; preset?: ShareCameraPreset; } export interface ShareCrossSectionState { enabled: boolean; axis: ShareAxis; offset: number; } export interface ShareAnimationState { playing?: boolean; rpm?: number; timeScale?: number; step?: number; } export interface ShareViewState { machineId?: string; camera?: ShareCameraState; explode?: number; hiddenParts?: string[]; visibleParts?: string[]; partOpacity?: Record; selectedPartId?: string; labels?: boolean; wireframe?: boolean; crossSection?: ShareCrossSectionState; animation?: ShareAnimationState; tourStep?: number; } export interface CreateShareUrlOptions { baseUrl?: string | URL; pathname?: string; hash?: string; preserveExistingParams?: boolean; } export const SHARE_PARAM_KEYS = [ 'anim', 'animation', 'clip', 'crossSection', 'cp', 'cameraPosition', 'ct', 'cameraTarget', 'cz', 'cameraZoom', 'ex', 'explode', 'hide', 'hidden', 'labels', 'm', 'machine', 'op', 'opacity', 'part', 'selected', 'preset', 'show', 'visible', 'tour', 'wire', 'wireframe', ] as const; const CAMERA_PRESETS = new Set([ 'front', 'back', 'left', 'right', 'top', 'bottom', 'isometric', ]); const AXES = new Set(['x', 'y', 'z']); const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/; const VECTOR_MIN = -10_000; const VECTOR_MAX = 10_000; const CROSS_SECTION_MIN = -10; const CROSS_SECTION_MAX = 10; const EXPLODE_MIN = 0; const EXPLODE_MAX = 10; const ZOOM_MIN = 0.01; const ZOOM_MAX = 100; const RPM_MIN = 0; const RPM_MAX = 50_000; const TIME_SCALE_MIN = 0.1; const TIME_SCALE_MAX = 3; const STEP_MIN = 0; const STEP_MAX = 10_000; export function isShareParamKey(key: string): boolean { return (SHARE_PARAM_KEYS as readonly string[]).includes(key); } export function sanitiseShareIdentifier(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); if (!trimmed || trimmed.length > 160 || !IDENTIFIER_PATTERN.test(trimmed)) { return undefined; } return trimmed; } export function normaliseShareState(state: ShareViewState): ShareViewState { const normalised: ShareViewState = {}; const machineId = sanitiseShareIdentifier(state.machineId); if (machineId) { normalised.machineId = machineId; } const camera = normaliseCameraState(state.camera); if (camera) { normalised.camera = camera; } const explode = normaliseNumber(state.explode, EXPLODE_MIN, EXPLODE_MAX, 3); if (explode !== undefined) { normalised.explode = explode; } const hiddenParts = normaliseIdentifierList(state.hiddenParts); if (hiddenParts.length > 0) { normalised.hiddenParts = hiddenParts; } const visibleParts = normaliseIdentifierList(state.visibleParts); if (visibleParts.length > 0) { normalised.visibleParts = visibleParts; } const partOpacity = normaliseOpacityMap(state.partOpacity); if (Object.keys(partOpacity).length > 0) { normalised.partOpacity = partOpacity; } const selectedPartId = sanitiseShareIdentifier(state.selectedPartId); if (selectedPartId) { normalised.selectedPartId = selectedPartId; } if (typeof state.labels === 'boolean') { normalised.labels = state.labels; } if (typeof state.wireframe === 'boolean') { normalised.wireframe = state.wireframe; } const crossSection = normaliseCrossSection(state.crossSection); if (crossSection) { normalised.crossSection = crossSection; } const animation = normaliseAnimationState(state.animation); if (animation) { normalised.animation = animation; } const tourStep = normaliseNumber(state.tourStep, STEP_MIN, STEP_MAX, 0); if (tourStep !== undefined) { normalised.tourStep = tourStep; } return normalised; } export function serializeShareState(state: ShareViewState): string { const normalised = normaliseShareState(state); const entries: Array<[string, string]> = []; if (normalised.animation) { entries.push(['anim', formatAnimationState(normalised.animation)]); } if (normalised.crossSection?.enabled) { entries.push([ 'clip', `${normalised.crossSection.axis}:${formatNumber(normalised.crossSection.offset, 3)}`, ]); } if (normalised.camera?.position) { entries.push(['cp', formatVector(normalised.camera.position)]); } if (normalised.camera?.target) { entries.push(['ct', formatVector(normalised.camera.target)]); } if (normalised.camera?.zoom !== undefined) { entries.push(['cz', formatNumber(normalised.camera.zoom, 3)]); } if (normalised.explode !== undefined) { entries.push(['ex', formatNumber(normalised.explode, 3)]); } if (normalised.hiddenParts && normalised.hiddenParts.length > 0) { entries.push(['hide', normalised.hiddenParts.join(',')]); } if (normalised.labels !== undefined) { entries.push(['labels', formatBoolean(normalised.labels)]); } if (normalised.machineId) { entries.push(['m', normalised.machineId]); } if (normalised.partOpacity && Object.keys(normalised.partOpacity).length > 0) { entries.push(['op', formatOpacityMap(normalised.partOpacity)]); } if (normalised.selectedPartId) { entries.push(['part', normalised.selectedPartId]); } if (normalised.camera?.preset) { entries.push(['preset', normalised.camera.preset]); } if (normalised.visibleParts && normalised.visibleParts.length > 0) { entries.push(['show', normalised.visibleParts.join(',')]); } if (normalised.tourStep !== undefined) { entries.push(['tour', formatNumber(normalised.tourStep, 0)]); } if (normalised.wireframe !== undefined) { entries.push(['wire', formatBoolean(normalised.wireframe)]); } entries.sort(([left], [right]) => left.localeCompare(right)); const params = new URLSearchParams(); for (const [key, value] of entries) { params.set(key, value); } return params.toString(); } export function parseShareState(input?: string | URL | URLSearchParams): ShareViewState { const params = toSearchParams(input); const state: ShareViewState = {}; const machineId = sanitiseShareIdentifier(getFirstParam(params, ['m', 'machine'])); if (machineId) { state.machineId = machineId; } const position = parseVector(getFirstParam(params, ['cp', 'cameraPosition'])); const target = parseVector(getFirstParam(params, ['ct', 'cameraTarget'])); const zoom = parseNumber(getFirstParam(params, ['cz', 'cameraZoom']), ZOOM_MIN, ZOOM_MAX, 3); const preset = parseCameraPreset(getFirstParam(params, ['preset'])); if (position || target || zoom !== undefined || preset) { state.camera = { ...(position ? { position } : {}), ...(target ? { target } : {}), ...(zoom !== undefined ? { zoom } : {}), ...(preset ? { preset } : {}), }; } const explode = parseNumber(getFirstParam(params, ['ex', 'explode']), EXPLODE_MIN, EXPLODE_MAX, 3); if (explode !== undefined) { state.explode = explode; } const hiddenParts = parseIdentifierList(getFirstParam(params, ['hide', 'hidden'])); if (hiddenParts.length > 0) { state.hiddenParts = hiddenParts; } const visibleParts = parseIdentifierList(getFirstParam(params, ['show', 'visible'])); if (visibleParts.length > 0) { state.visibleParts = visibleParts; } const partOpacity = parseOpacityMap(getFirstParam(params, ['op', 'opacity'])); if (Object.keys(partOpacity).length > 0) { state.partOpacity = partOpacity; } const selectedPartId = sanitiseShareIdentifier(getFirstParam(params, ['part', 'selected'])); if (selectedPartId) { state.selectedPartId = selectedPartId; } const labels = parseBoolean(getFirstParam(params, ['labels'])); if (labels !== undefined) { state.labels = labels; } const wireframe = parseBoolean(getFirstParam(params, ['wire', 'wireframe'])); if (wireframe !== undefined) { state.wireframe = wireframe; } const crossSection = parseCrossSection(getFirstParam(params, ['clip', 'crossSection'])); if (crossSection) { state.crossSection = crossSection; } const animation = parseAnimationState(getFirstParam(params, ['anim', 'animation'])); if (animation) { state.animation = animation; } const tourStep = parseNumber(getFirstParam(params, ['tour']), STEP_MIN, STEP_MAX, 0); if (tourStep !== undefined) { state.tourStep = tourStep; } return normaliseShareState(state); } export function createShareUrl(state: ShareViewState, options: CreateShareUrlOptions = {}): string { const url = resolveBaseUrl(options.baseUrl); if (options.pathname) { url.pathname = options.pathname; } if (options.hash !== undefined) { url.hash = options.hash; } const query = serializeShareState(state); if (!options.preserveExistingParams) { url.search = query; return url.toString(); } for (const key of SHARE_PARAM_KEYS) { url.searchParams.delete(key); } const nextParams = new URLSearchParams(query); nextParams.forEach((value, key) => { url.searchParams.set(key, value); }); url.searchParams.sort(); return url.toString(); } export function parseShareStateFromUrl(url: string | URL): ShareViewState { return parseShareState(url); } function normaliseCameraState(camera: ShareCameraState | undefined): ShareCameraState | undefined { if (!camera) { return undefined; } const position = normaliseVector(camera.position); const target = normaliseVector(camera.target); const zoom = normaliseNumber(camera.zoom, ZOOM_MIN, ZOOM_MAX, 3); const preset = parseCameraPreset(camera.preset); if (!position && !target && zoom === undefined && !preset) { return undefined; } return { ...(position ? { position } : {}), ...(target ? { target } : {}), ...(zoom !== undefined ? { zoom } : {}), ...(preset ? { preset } : {}), }; } function normaliseCrossSection( crossSection: ShareCrossSectionState | undefined, ): ShareCrossSectionState | undefined { if (!crossSection?.enabled) { return undefined; } const axis = AXES.has(crossSection.axis) ? crossSection.axis : 'x'; const offset = normaliseNumber(crossSection.offset, CROSS_SECTION_MIN, CROSS_SECTION_MAX, 3) ?? 0; return { enabled: true, axis, offset, }; } function normaliseAnimationState( animation: ShareAnimationState | undefined, ): ShareAnimationState | undefined { if (!animation) { return undefined; } const normalised: ShareAnimationState = {}; if (typeof animation.playing === 'boolean') { normalised.playing = animation.playing; } const rpm = normaliseNumber(animation.rpm, RPM_MIN, RPM_MAX, 0); if (rpm !== undefined) { normalised.rpm = rpm; } const timeScale = normaliseNumber(animation.timeScale, TIME_SCALE_MIN, TIME_SCALE_MAX, 2); if (timeScale !== undefined) { normalised.timeScale = timeScale; } const step = normaliseNumber(animation.step, STEP_MIN, STEP_MAX, 0); if (step !== undefined) { normalised.step = step; } return Object.keys(normalised).length > 0 ? normalised : undefined; } function parseCrossSection(value: string | undefined): ShareCrossSectionState | undefined { if (!value) { return undefined; } const booleanValue = parseBoolean(value); if (booleanValue === false) { return undefined; } const [axisValue = 'x', offsetValue = '0'] = value.split(':'); const axis = AXES.has(axisValue as ShareAxis) ? (axisValue as ShareAxis) : 'x'; const offset = parseNumber(offsetValue, CROSS_SECTION_MIN, CROSS_SECTION_MAX, 3) ?? 0; return { enabled: true, axis, offset, }; } function formatAnimationState(animation: ShareAnimationState): string { const entries: string[] = []; if (animation.playing !== undefined) { entries.push(`play:${formatBoolean(animation.playing)}`); } if (animation.rpm !== undefined) { entries.push(`rpm:${formatNumber(animation.rpm, 0)}`); } if (animation.timeScale !== undefined) { entries.push(`scale:${formatNumber(animation.timeScale, 2)}`); } if (animation.step !== undefined) { entries.push(`step:${formatNumber(animation.step, 0)}`); } return entries.join(','); } function parseAnimationState(value: string | undefined): ShareAnimationState | undefined { if (!value) { return undefined; } const state: ShareAnimationState = {}; for (const token of value.split(',')) { const [rawKey, rawValue] = token.split(':'); const key = rawKey?.trim(); const tokenValue = rawValue?.trim(); if (!key || tokenValue === undefined) { continue; } if (key === 'play') { const playing = parseBoolean(tokenValue); if (playing !== undefined) { state.playing = playing; } } if (key === 'rpm') { const rpm = parseNumber(tokenValue, RPM_MIN, RPM_MAX, 0); if (rpm !== undefined) { state.rpm = rpm; } } if (key === 'scale') { const timeScale = parseNumber(tokenValue, TIME_SCALE_MIN, TIME_SCALE_MAX, 2); if (timeScale !== undefined) { state.timeScale = timeScale; } } if (key === 'step') { const step = parseNumber(tokenValue, STEP_MIN, STEP_MAX, 0); if (step !== undefined) { state.step = step; } } } return normaliseAnimationState(state); } function formatOpacityMap(opacity: Record): string { return Object.entries(opacity) .sort(([left], [right]) => left.localeCompare(right)) .map(([partId, value]) => `${partId}:${formatNumber(value, 3)}`) .join(','); } function parseOpacityMap(value: string | undefined): Record { if (!value) { return {}; } const opacity: Record = {}; for (const token of value.split(',')) { const [rawPartId, rawValue] = token.includes(':') ? token.split(':') : token.split('='); const partId = sanitiseShareIdentifier(rawPartId); const numberValue = parseNumber(rawValue, 0, 1, 3); if (partId && numberValue !== undefined) { opacity[partId] = numberValue; } } return normaliseOpacityMap(opacity); } function normaliseOpacityMap(opacity: Record | undefined): Record { if (!opacity) { return {}; } const entries = Object.entries(opacity) .map(([partId, value]) => { const safePartId = sanitiseShareIdentifier(partId); const safeValue = normaliseNumber(value, 0, 1, 3); return safePartId && safeValue !== undefined ? ([safePartId, safeValue] as const) : undefined; }) .filter((entry): entry is readonly [string, number] => Boolean(entry)) .sort(([left], [right]) => left.localeCompare(right)); return Object.fromEntries(entries); } function parseIdentifierList(value: string | undefined): string[] { if (!value) { return []; } return normaliseIdentifierList(value.split(',')); } function normaliseIdentifierList(values: unknown): string[] { if (!Array.isArray(values)) { return []; } return [...new Set(values.map(sanitiseShareIdentifier).filter((value): value is string => Boolean(value)))] .sort((left, right) => left.localeCompare(right)) .slice(0, 500); } function parseCameraPreset(value: unknown): ShareCameraPreset | undefined { if (typeof value !== 'string') { return undefined; } return CAMERA_PRESETS.has(value as ShareCameraPreset) ? (value as ShareCameraPreset) : undefined; } function parseVector(value: string | undefined): Vector3Tuple | undefined { if (!value) { return undefined; } return normaliseVector(value.split(',').map((part) => Number(part.trim()))); } function normaliseVector(value: unknown): Vector3Tuple | undefined { if (!Array.isArray(value) || value.length !== 3) { return undefined; } const vector = value.map((part) => typeof part === 'number' && Number.isFinite(part) ? normaliseNumber(part, VECTOR_MIN, VECTOR_MAX, 4) : undefined, ); if (vector.some((part) => part === undefined)) { return undefined; } return vector as Vector3Tuple; } function formatVector(vector: Vector3Tuple): string { return vector.map((value) => formatNumber(value, 4)).join(','); } function parseBoolean(value: string | undefined): boolean | undefined { if (value === undefined) { return undefined; } const normalised = value.trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalised)) { return true; } if (['0', 'false', 'no', 'off'].includes(normalised)) { return false; } return undefined; } function formatBoolean(value: boolean): string { return value ? '1' : '0'; } function parseNumber( value: string | undefined, min: number, max: number, precision: number, ): number | undefined { if (value === undefined || value.trim() === '') { return undefined; } return normaliseNumber(Number(value), min, max, precision); } function normaliseNumber( value: unknown, min: number, max: number, precision: number, ): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value)) { return undefined; } const clamped = Math.min(max, Math.max(min, value)); const rounded = Number(clamped.toFixed(precision)); return Object.is(rounded, -0) ? 0 : rounded; } function formatNumber(value: number, precision: number): string { return String(Number(value.toFixed(precision))); } function getFirstParam(params: URLSearchParams, keys: string[]): string | undefined { for (const key of keys) { const value = params.get(key); if (value !== null) { return value; } } return undefined; } function toSearchParams(input?: string | URL | URLSearchParams): URLSearchParams { if (input instanceof URLSearchParams) { return new URLSearchParams(input); } if (input instanceof URL) { return new URLSearchParams(input.search); } if (typeof input === 'string') { const trimmed = input.trim(); if (!trimmed) { return new URLSearchParams(); } if (/^[A-Za-z][A-Za-z\d+\-.]*:/.test(trimmed)) { return new URLSearchParams(new URL(trimmed).search); } const queryStart = trimmed.indexOf('?'); if (queryStart >= 0) { const hashStart = trimmed.indexOf('#', queryStart); return new URLSearchParams( trimmed.slice(queryStart + 1, hashStart >= 0 ? hashStart : undefined), ); } return new URLSearchParams(trimmed.replace(/^[?#]/, '')); } if (typeof window !== 'undefined') { return new URLSearchParams(window.location.search); } return new URLSearchParams(); } function resolveBaseUrl(baseUrl?: string | URL): URL { const fallbackOrigin = 'https://mechanica.local'; const browserOrigin = typeof window !== 'undefined' ? window.location.origin : fallbackOrigin; const browserHref = typeof window !== 'undefined' ? window.location.href : fallbackOrigin; return new URL(baseUrl ? String(baseUrl) : browserHref, browserOrigin); }