/** * Runtime catalogue utilities for the Phase 1 machine registry. * * The catalogue expansion gives the viewer rich machine records, component * hierarchies, dossiers, labels and animation metadata. These helpers make * that data immediately usable in production UI without coupling the catalogue * to a specific React component: * * - robust search / filtering / facets for browse pages and command palettes * - related-machine recommendations that combine explicit links with metadata * - defensive share-link encoding/decoding for specific machine viewer states */ export interface SearchableMachine { readonly id: string; readonly title: string; } export type CatalogueField = | "id" | "title" | "description" | "category" | "difficulty" | "facts" | "components" | "labels" | "tags" | "relatedMachines"; export interface MachineSearchDocument { readonly machine: TMachine; readonly id: string; readonly title: string; readonly category: string; readonly difficulty: string; readonly tags: readonly string[]; readonly componentIds: readonly string[]; readonly relatedMachineIds: readonly string[]; readonly fields: Readonly>; readonly fieldTokens: Readonly>; readonly tokens: readonly string[]; readonly searchText: string; readonly sortTitle: string; readonly order: number; } export type SearchSynonymMap = Readonly>; export interface MachineSearchFilters { readonly ids?: readonly string[]; readonly excludedIds?: readonly string[]; readonly categories?: readonly string[]; readonly difficulties?: readonly string[]; readonly tags?: readonly string[]; readonly matchAllTags?: boolean; readonly componentIds?: readonly string[]; readonly relatedTo?: string; readonly includeSelfRelated?: boolean; } export interface MachineSearchOptions { readonly filters?: MachineSearchFilters; readonly limit?: number; readonly minScore?: number; readonly includeEmptyQuery?: boolean; readonly sort?: "relevance" | "title" | "category" | "difficulty"; readonly sortDirection?: "asc" | "desc"; readonly weights?: Partial>; /** * Pass false to disable the built-in engineering synonyms. Pass a map to * merge project-specific aliases with the defaults. */ readonly synonyms?: SearchSynonymMap | false; } export interface MachineSearchResult { readonly machine: TMachine; readonly score: number; readonly matchedFields: readonly CatalogueField[]; readonly matchedTokens: readonly string[]; readonly reasons: readonly string[]; } export interface CatalogueFacetBucket { readonly value: string; readonly label: string; readonly count: number; } export interface CatalogueFacetSummary { readonly total: number; readonly categories: readonly CatalogueFacetBucket[]; readonly difficulties: readonly CatalogueFacetBucket[]; readonly tags: readonly CatalogueFacetBucket[]; } export interface CatalogueFacetOptions { readonly query?: string; readonly filters?: MachineSearchFilters; readonly synonyms?: SearchSynonymMap | false; } export interface RelatedMachineRecommendation { readonly machine: TMachine; readonly score: number; readonly explicit: boolean; readonly reciprocal: boolean; readonly reasons: readonly string[]; readonly sharedTags: readonly string[]; readonly sharedComponents: readonly string[]; } export interface RelatedMachineOptions { readonly limit?: number; readonly includeReciprocalLinks?: boolean; } export type ViewerRenderMode = "solid" | "wireframe" | "x-ray" | "cross-section"; export type PlaybackState = "playing" | "paused"; export type SectionAxis = "x" | "y" | "z"; export type Vector3Tuple = readonly [number, number, number]; export interface MachineViewerCameraState { readonly position?: Vector3Tuple; readonly target?: Vector3Tuple; readonly zoom?: number; readonly fov?: number; } export interface MachineViewerSectionState { readonly axis: SectionAxis; readonly offset: number; } export interface MachineViewerShareState { readonly machineId: string; readonly cameraPresetId?: string; readonly mode: ViewerRenderMode; readonly exploded: number; readonly playback: PlaybackState; readonly speed: number; readonly selectedComponentId?: string; readonly hiddenComponents: readonly string[]; readonly componentOpacity: Readonly>; readonly labels: boolean; readonly annotations: boolean; readonly camera?: MachineViewerCameraState; readonly section?: MachineViewerSectionState; readonly tourStepId?: string; } export interface MachineViewerStateInput { readonly [key: string]: unknown; readonly machineId?: unknown; readonly machine?: unknown; readonly id?: unknown; readonly cameraPresetId?: unknown; readonly cameraPreset?: unknown; readonly mode?: unknown; readonly exploded?: unknown; readonly playback?: unknown; readonly playing?: unknown; readonly play?: unknown; readonly speed?: unknown; readonly selectedComponentId?: unknown; readonly selectedComponent?: unknown; readonly selected?: unknown; readonly component?: unknown; readonly componentId?: unknown; readonly hiddenComponents?: unknown; readonly hidden?: unknown; readonly hide?: unknown; readonly componentOpacity?: unknown; readonly opacity?: unknown; readonly labels?: unknown; readonly annotations?: unknown; readonly ann?: unknown; readonly camera?: unknown; readonly section?: unknown; readonly tourStepId?: unknown; readonly tourStep?: unknown; readonly tour?: unknown; } export interface ViewerStateCodecOptions { readonly allowedMachineIds?: readonly string[]; readonly allowedComponentIds?: readonly string[]; readonly allowedCameraPresetIds?: readonly string[]; readonly allowedTourStepIds?: readonly string[]; readonly defaultMode?: ViewerRenderMode; readonly defaultExploded?: number; readonly defaultPlayback?: PlaybackState; readonly defaultSpeed?: number; readonly defaultLabels?: boolean; readonly defaultAnnotations?: boolean; readonly includeDefaults?: boolean; readonly maxExploded?: number; readonly maxPlaybackSpeed?: number; readonly maxCameraMagnitude?: number; readonly maxHiddenComponents?: number; readonly maxOpacityEntries?: number; readonly minSectionOffset?: number; readonly maxSectionOffset?: number; readonly numberPrecision?: number; } export interface ViewerStateParseResult { readonly state: MachineViewerShareState | null; readonly warnings: readonly string[]; } export interface MachineViewerUrlOptions extends ViewerStateCodecOptions { readonly hash?: string; readonly replaceExistingStateParams?: boolean; } const DEFAULT_SEARCH_LIMIT = 24; const DEFAULT_RELATED_LIMIT = 6; const DEFAULT_MAX_EXPLODED = 1; const DEFAULT_MAX_PLAYBACK_SPEED = 4; const DEFAULT_MAX_CAMERA_MAGNITUDE = 100_000; const DEFAULT_MAX_HIDDEN_COMPONENTS = 128; const DEFAULT_MAX_OPACITY_ENTRIES = 128; const DEFAULT_MIN_SECTION_OFFSET = -1; const DEFAULT_MAX_SECTION_OFFSET = 1; const DEFAULT_NUMBER_PRECISION = 4; const FALLBACK_URL_ORIGIN = "https://mechanica.local"; const SAFE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/; const DANGEROUS_RECORD_KEYS = new Set(["__proto__", "constructor", "prototype"]); const SEARCH_FIELD_ORDER: readonly CatalogueField[] = [ "id", "title", "tags", "category", "difficulty", "components", "labels", "facts", "description", "relatedMachines", ]; const DEFAULT_FIELD_WEIGHTS: Readonly> = { id: 12, title: 10, description: 3, category: 5, difficulty: 2, facts: 4, components: 7, labels: 5, tags: 6, relatedMachines: 2, }; const COMPONENT_ROOT_KEYS = [ "components", "parts", "partList", "componentHierarchy", "componentTree", "billOfMaterials", ] as const; const COMPONENT_CHILD_KEYS = ["children", "components", "parts", "subcomponents", "items"] as const; const LABEL_ROOT_KEYS = ["labels", "annotations", "callouts", "hotspots"] as const; const TEXT_KEYS = [ "id", "title", "name", "label", "text", "body", "value", "description", "summary", "overview", "role", "material", "note", "notes", "engineeringNote", "engineeringNotes", "fact", "facts", "principle", "principles", "whyItMatters", ] as const; const RELATED_TOKEN_STOP_WORDS = new Set([ "about", "after", "also", "and", "are", "can", "for", "from", "into", "its", "machine", "mechanical", "motion", "power", "system", "that", "the", "this", "through", "with", ]); export const DEFAULT_MACHINE_SEARCH_SYNONYMS: Readonly> = Object.freeze({ gearbox: ["transmission", "gear train", "drive train", "drivetrain"], transmission: ["gearbox", "gear train", "drive train", "drivetrain"], "gear train": ["gearbox", "transmission"], epicyclic: ["planetary", "planet gear", "sun gear", "ring gear"], planetary: ["epicyclic", "planet gear", "sun gear", "ring gear"], engine: ["motor", "prime mover", "combustion"], combustion: ["engine", "ignition", "piston"], piston: ["reciprocating", "cylinder", "crankshaft"], crankshaft: ["crank", "reciprocating", "throw"], pump: ["fluid", "pressure", "flow"], impeller: ["centrifugal", "pump", "rotor"], volute: ["centrifugal pump", "diffuser"], hydraulic: ["fluid power", "pressure", "actuator"], pneumatic: ["compressed air", "pressure"], cam: ["follower", "lobe", "valve timing"], follower: ["cam", "lifter"], geneva: ["indexing", "maltese cross", "intermittent motion"], "maltese cross": ["geneva", "indexing"], linkage: ["mechanism", "bars", "joints"], "four bar": ["linkage", "crank rocker"], bearing: ["rolling element", "race", "ball", "roller"], "u joint": ["universal joint", "cardan", "coupling"], "universal joint": ["u joint", "cardan", "coupling"], differential: ["final drive", "axle", "spider gears"], clutch: ["friction plate", "flywheel", "coupling"], turbocharger: ["turbine", "compressor", "boost"], turbine: ["rotor", "compressor", "jet"], brake: ["caliper", "disc", "friction"], }); export const MACHINE_VIEWER_STATE_QUERY_KEYS: readonly string[] = Object.freeze([ "m", "machine", "machineId", "id", "cp", "cameraPreset", "cameraPresetId", "mode", "viewMode", "renderMode", "exp", "explode", "exploded", "play", "playing", "playback", "anim", "speed", "rate", "sel", "selected", "selectedComponent", "selectedComponentId", "component", "componentId", "hide", "hidden", "hiddenComponents", "op", "opacity", "componentOpacity", "labels", "label", "annotations", "ann", "cam", "camera", "freeCamera", "sec", "section", "tour", "tourStep", "tourStepId", ]); type MachineSearchSource = | readonly TMachine[] | readonly MachineSearchDocument[]; interface InternalSearchResult extends MachineSearchResult { readonly document: MachineSearchDocument; } interface ScoreBreakdown { readonly score: number; readonly matchedFields: readonly CatalogueField[]; readonly matchedTokens: readonly string[]; readonly reasons: readonly string[]; } /** * Normalises user-facing engineering text for case-insensitive search. * Diacritics, punctuation and camelCase boundaries are folded to stable tokens. */ export function normalizeForSearch(value: unknown): string { return String(value ?? "") .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/&/g, " and ") .replace(/['’]/g, "") .replace(/[^A-Za-z0-9]+/g, " ") .toLowerCase() .trim() .replace(/\s+/g, " "); } /** * Tokenises search text and adds a small number of safe singular variants. */ export function tokenizeForSearch(value: unknown): string[] { const normalized = normalizeForSearch(value); if (!normalized) { return []; } const tokens: string[] = []; const seen = new Set(); for (const token of normalized.split(" ")) { addUniqueToken(tokens, seen, token); const singular = singularizeSearchToken(token); if (singular !== token) { addUniqueToken(tokens, seen, singular); } } return tokens; } export function labelFromCatalogueValue(value: string): string { const trimmed = String(value ?? "").trim(); if (!trimmed) { return ""; } return trimmed .replace(/[_-]+/g, " ") .replace(/\s+/g, " ") .replace(/\b([a-z])/gi, (match) => match.toUpperCase()); } export function buildMachineSearchIndex( machines: readonly TMachine[], ): MachineSearchDocument[] { return machines.map((machine, order) => { const metadata = extractMachineSearchMetadata(machine); const fields: Record = { id: normalizeForSearch(metadata.id), title: normalizeForSearch(metadata.title), description: normalizeForSearch(metadata.description), category: normalizeForSearch(metadata.category), difficulty: normalizeForSearch(metadata.difficulty), facts: normalizeForSearch(metadata.facts), components: normalizeForSearch(metadata.components), labels: normalizeForSearch(metadata.labels), tags: normalizeForSearch(metadata.tags.join(" ")), relatedMachines: normalizeForSearch(metadata.relatedMachineIds.join(" ")), }; const fieldTokens = SEARCH_FIELD_ORDER.reduce( (accumulator, field) => { accumulator[field] = tokenizeForSearch(fields[field]); return accumulator; }, {} as Record, ); const tokens = uniqueStrings(SEARCH_FIELD_ORDER.flatMap((field) => fieldTokens[field])); const searchText = SEARCH_FIELD_ORDER.map((field) => fields[field]).filter(Boolean).join(" "); return { machine, id: metadata.id, title: metadata.title, category: metadata.category, difficulty: metadata.difficulty, tags: metadata.tags, componentIds: metadata.componentIds, relatedMachineIds: metadata.relatedMachineIds, fields, fieldTokens, tokens, searchText, sortTitle: normalizeForSearch(metadata.title || metadata.id), order, }; }); } export function searchMachines( source: MachineSearchSource, query = "", options: MachineSearchOptions = {}, ): MachineSearchResult[] { const documents = toSearchDocuments(source); const normalizedQuery = normalizeForSearch(query); const includeEmptyQuery = options.includeEmptyQuery ?? true; if (!normalizedQuery && !includeEmptyQuery) { return []; } const synonymMap = createSynonymMap(options.synonyms); const rawQueryTokens = tokenizeForSearch(query); const queryTokens = normalizedQuery ? expandQueryTokens(rawQueryTokens, normalizedQuery, synonymMap) : []; const minScore = options.minScore ?? (normalizedQuery ? 1 : 0); const results: InternalSearchResult[] = []; for (const document of documents) { if (!matchesFilters(document, options.filters)) { continue; } const score = normalizedQuery ? scoreDocument(document, normalizedQuery, queryTokens, rawQueryTokens, options.weights) : { score: 1, matchedFields: [], matchedTokens: [], reasons: ["Included by catalogue filters."], }; if (score.score < minScore) { continue; } results.push({ document, machine: document.machine, score: roundScore(score.score), matchedFields: score.matchedFields, matchedTokens: score.matchedTokens, reasons: score.reasons, }); } results.sort((a, b) => compareInternalSearchResults(a, b, options)); const limit = options.limit ?? DEFAULT_SEARCH_LIMIT; const limitedResults = limit === Infinity ? results : results.slice(0, Math.max(0, Math.floor(limit))); return limitedResults.map((result) => ({ machine: result.machine, score: result.score, matchedFields: result.matchedFields, matchedTokens: result.matchedTokens, reasons: result.reasons, })); } export function getCatalogueFacets( source: MachineSearchSource, options: CatalogueFacetOptions = {}, ): CatalogueFacetSummary { const documents = toSearchDocuments(source); const normalizedQuery = normalizeForSearch(options.query); let scopedDocuments: readonly MachineSearchDocument[]; if (normalizedQuery) { const matchingIds = new Set( searchMachines(documents, normalizedQuery, { filters: options.filters, limit: Infinity, synonyms: options.synonyms, }).map((result) => result.machine.id), ); scopedDocuments = documents.filter((document) => matchingIds.has(document.id)); } else { scopedDocuments = documents.filter((document) => matchesFilters(document, options.filters)); } const categories = new Map(); const difficulties = new Map(); const tags = new Map(); for (const document of scopedDocuments) { addFacetCount(categories, document.category); if (document.difficulty) { addFacetCount(difficulties, document.difficulty); } for (const tag of document.tags) { addFacetCount(tags, tag); } } return { total: scopedDocuments.length, categories: sortFacetBuckets(categories), difficulties: sortFacetBuckets(difficulties), tags: sortFacetBuckets(tags), }; } export function getRelatedMachineRecommendations( source: MachineSearchSource, machineId: string, options: RelatedMachineOptions = {}, ): RelatedMachineRecommendation[] { const documents = toSearchDocuments(source); const seedId = normalizeForSearch(machineId); const seed = documents.find((document) => document.id === machineId || document.fields.id === seedId); if (!seed) { return []; } const explicitIds = new Set(seed.relatedMachineIds.map((id) => normalizeForSearch(id))); const recommendations: RelatedMachineRecommendation[] = []; for (const candidate of documents) { if (candidate.id === seed.id) { continue; } const candidateId = normalizeForSearch(candidate.id); const reciprocal = candidate.relatedMachineIds .map((id) => normalizeForSearch(id)) .includes(seed.fields.id); const explicit = explicitIds.has(candidateId); let score = 0; const reasons: string[] = []; if (explicit) { score += 1_000; reasons.push("Explicitly listed as related."); } if ((options.includeReciprocalLinks ?? true) && reciprocal) { score += 800; reasons.push("Links back to this machine."); } if (seed.fields.category && seed.fields.category === candidate.fields.category) { score += 80; reasons.push(`Same category: ${labelFromCatalogueValue(seed.category)}.`); } const sharedTags = intersectLabels(seed.tags, candidate.tags, 8); if (sharedTags.length > 0) { score += sharedTags.length * 35; reasons.push(`Shared tags: ${sharedTags.slice(0, 3).join(", ")}.`); } const sharedComponents = intersectUsefulTokens( seed.fieldTokens.components, candidate.fieldTokens.components, 8, ); if (sharedComponents.length > 0) { score += sharedComponents.length * 8; reasons.push(`Overlapping component language: ${sharedComponents.slice(0, 3).join(", ")}.`); } const sharedFacts = intersectUsefulTokens(seed.fieldTokens.facts, candidate.fieldTokens.facts, 6); if (sharedFacts.length > 0) { score += sharedFacts.length * 3; } const difficultyGap = Math.abs( difficultyRank(seed.difficulty) - difficultyRank(candidate.difficulty), ); if (Number.isFinite(difficultyGap) && difficultyGap <= 1) { score += 5; } if (score > 0) { recommendations.push({ machine: candidate.machine, score: roundScore(score), explicit, reciprocal, reasons, sharedTags, sharedComponents, }); } } recommendations.sort((a, b) => { const scoreDifference = b.score - a.score; if (Math.abs(scoreDifference) > 0.001) { return scoreDifference; } return a.machine.title.localeCompare(b.machine.title); }); const limit = options.limit ?? DEFAULT_RELATED_LIMIT; return limit === Infinity ? recommendations : recommendations.slice(0, Math.max(0, Math.floor(limit))); } export function sanitizeMachineViewerState( input: MachineViewerStateInput, options: ViewerStateCodecOptions = {}, ): ViewerStateParseResult { const warnings: string[] = []; const record = isRecord(input) ? input : {}; const machineId = sanitizeIdentifier( firstDefined(record.machineId, record.machine, record.id), "machine id", options.allowedMachineIds, warnings, ); if (!machineId) { warnings.push("Missing or invalid machine id."); return { state: null, warnings }; } const state: { machineId: string; cameraPresetId?: string; mode: ViewerRenderMode; exploded: number; playback: PlaybackState; speed: number; selectedComponentId?: string; hiddenComponents: string[]; componentOpacity: Record; labels: boolean; annotations: boolean; camera?: MachineViewerCameraState; section?: MachineViewerSectionState; tourStepId?: string; } = { machineId, mode: sanitizeRenderMode(record.mode, options.defaultMode ?? "solid", warnings), exploded: sanitizeNumberOrDefault( record.exploded, "exploded amount", options.defaultExploded ?? 0, 0, options.maxExploded ?? DEFAULT_MAX_EXPLODED, warnings, ), playback: sanitizePlaybackState( firstDefined(record.playback, record.playing, record.play), options.defaultPlayback ?? "paused", warnings, ), speed: sanitizeNumberOrDefault( record.speed, "playback speed", options.defaultSpeed ?? 1, 0, options.maxPlaybackSpeed ?? DEFAULT_MAX_PLAYBACK_SPEED, warnings, ), hiddenComponents: sanitizeIdentifierList( firstDefined(record.hiddenComponents, record.hidden, record.hide), "hidden component", options.allowedComponentIds, warnings, options.maxHiddenComponents ?? DEFAULT_MAX_HIDDEN_COMPONENTS, ), componentOpacity: sanitizeOpacityMap( firstDefined(record.componentOpacity, record.opacity), options.allowedComponentIds, warnings, options.maxOpacityEntries ?? DEFAULT_MAX_OPACITY_ENTRIES, ), labels: sanitizeBooleanOrDefault( record.labels, options.defaultLabels ?? true, "labels", warnings, ), annotations: sanitizeBooleanOrDefault( firstDefined(record.annotations, record.ann), options.defaultAnnotations ?? true, "annotations", warnings, ), }; const cameraPresetId = sanitizeIdentifier( firstDefined(record.cameraPresetId, record.cameraPreset), "camera preset id", options.allowedCameraPresetIds, warnings, true, ); if (cameraPresetId) { state.cameraPresetId = cameraPresetId; } const selectedComponentId = sanitizeIdentifier( firstDefined( record.selectedComponentId, record.selectedComponent, record.selected, record.component, record.componentId, ), "selected component id", options.allowedComponentIds, warnings, true, ); if (selectedComponentId) { state.selectedComponentId = selectedComponentId; } const cameraInput = firstDefined(record.camera); const parsedCamera = typeof cameraInput === "string" ? parseCameraParam(cameraInput, warnings) : cameraInput; const camera = sanitizeCameraState(parsedCamera, options, warnings); if (camera) { state.camera = camera; } const sectionInput = firstDefined(record.section); const parsedSection = typeof sectionInput === "string" ? parseSectionParam(sectionInput, warnings) : sectionInput; const section = sanitizeSectionState(parsedSection, options, warnings); if (section) { state.section = section; } const tourStepId = sanitizeIdentifier( firstDefined(record.tourStepId, record.tourStep, record.tour), "tour step id", options.allowedTourStepIds, warnings, true, ); if (tourStepId) { state.tourStepId = tourStepId; } return { state, warnings }; } export function decodeMachineViewerState( queryLike: | string | URLSearchParams | { readonly href?: string; readonly search?: string; readonly hash?: string; }, options: ViewerStateCodecOptions = {}, ): ViewerStateParseResult { const warnings: string[] = []; const params = searchParamsFromQueryLike(queryLike); const input: Record = Object.create(null); assignParam(input, "machineId", params, ["m", "machine", "machineId", "id"]); assignParam(input, "cameraPresetId", params, ["cp", "cameraPreset", "cameraPresetId"]); assignParam(input, "mode", params, ["mode", "viewMode", "renderMode"]); assignParam(input, "exploded", params, ["exp", "explode", "exploded"]); assignParam(input, "playback", params, ["play", "playing", "playback", "anim"]); assignParam(input, "speed", params, ["speed", "rate"]); assignParam(input, "selectedComponentId", params, [ "sel", "selected", "selectedComponent", "selectedComponentId", "component", "componentId", ]); assignParam(input, "labels", params, ["labels", "label"]); assignParam(input, "annotations", params, ["annotations", "ann"]); assignParam(input, "tourStepId", params, ["tour", "tourStep", "tourStepId"]); const hidden = getFirstParam(params, ["hide", "hidden", "hiddenComponents"]); if (hidden !== undefined) { input.hiddenComponents = parseListParam(hidden); } const opacity = getFirstParam(params, ["op", "opacity", "componentOpacity"]); if (opacity !== undefined) { input.componentOpacity = parseOpacityParam(opacity, warnings); } const camera = getFirstParam(params, ["cam", "camera", "freeCamera"]); if (camera !== undefined) { input.camera = parseCameraParam(camera, warnings); } const section = getFirstParam(params, ["sec", "section"]); if (section !== undefined) { input.section = parseSectionParam(section, warnings); } const sanitized = sanitizeMachineViewerState(input, options); return { state: sanitized.state, warnings: [...warnings, ...sanitized.warnings], }; } export function encodeMachineViewerState( stateInput: MachineViewerStateInput, options: ViewerStateCodecOptions = {}, ): URLSearchParams { const { state } = sanitizeMachineViewerState(stateInput, options); if (!state) { throw new Error("Cannot encode a machine viewer URL without a valid machine id."); } const includeDefaults = options.includeDefaults ?? false; const defaultMode = options.defaultMode ?? "solid"; const defaultExploded = options.defaultExploded ?? 0; const defaultPlayback = options.defaultPlayback ?? "paused"; const defaultSpeed = options.defaultSpeed ?? 1; const defaultLabels = options.defaultLabels ?? true; const defaultAnnotations = options.defaultAnnotations ?? true; const precision = sanitizedPrecision(options.numberPrecision); const params = new URLSearchParams(); params.set("m", state.machineId); if (state.cameraPresetId) { params.set("cp", state.cameraPresetId); } if (includeDefaults || state.mode !== defaultMode) { params.set("mode", state.mode); } if (includeDefaults || state.exploded !== defaultExploded) { params.set("exp", formatNumber(state.exploded, precision)); } if (includeDefaults || state.playback !== defaultPlayback) { params.set("play", state.playback === "playing" ? "1" : "0"); } if (includeDefaults || state.speed !== defaultSpeed) { params.set("speed", formatNumber(state.speed, precision)); } if (state.selectedComponentId) { params.set("sel", state.selectedComponentId); } if (state.hiddenComponents.length > 0) { params.set("hide", state.hiddenComponents.join(",")); } const opacityEntries = Object.entries(state.componentOpacity).sort(([a], [b]) => a.localeCompare(b), ); if (opacityEntries.length > 0) { params.set( "op", opacityEntries .map(([componentId, opacity]) => `${componentId}:${formatNumber(opacity, precision)}`) .join(","), ); } if (includeDefaults || state.labels !== defaultLabels) { params.set("labels", state.labels ? "1" : "0"); } if (includeDefaults || state.annotations !== defaultAnnotations) { params.set("ann", state.annotations ? "1" : "0"); } const camera = encodeCameraState(state.camera, precision); if (camera) { params.set("cam", camera); } if (state.section) { params.set( "sec", `${state.section.axis}:${formatNumber(state.section.offset, precision)}`, ); } if (state.tourStepId) { params.set("tour", state.tourStepId); } return params; } export function createMachineViewerUrl( baseUrl: string, state: MachineViewerStateInput, options: MachineViewerUrlOptions = {}, ): string { const isAbsolute = /^[A-Za-z][A-Za-z\d+\-.]*:/.test(baseUrl); const isProtocolRelative = baseUrl.startsWith("//"); const parsed = new URL(baseUrl || "/", isAbsolute ? undefined : FALLBACK_URL_ORIGIN); if (options.replaceExistingStateParams ?? true) { for (const key of MACHINE_VIEWER_STATE_QUERY_KEYS) { parsed.searchParams.delete(key); } } const encodedState = encodeMachineViewerState(state, options); encodedState.forEach((value, key) => { parsed.searchParams.set(key, value); }); if (options.hash !== undefined) { parsed.hash = options.hash ? `#${options.hash.replace(/^#/, "")}` : ""; } if (isAbsolute) { return parsed.toString(); } if (isProtocolRelative) { return `//${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`; } return `${parsed.pathname}${parsed.search}${parsed.hash}`; } export function readMachineViewerStateFromUrl( url: string, options: ViewerStateCodecOptions = {}, ): ViewerStateParseResult { return decodeMachineViewerState(url, options); } function extractMachineSearchMetadata(machine: TMachine): { id: string; title: string; description: string; category: string; difficulty: string; facts: string; components: string; labels: string; tags: string[]; componentIds: string[]; relatedMachineIds: string[]; } { const record = machine as unknown as Record; const id = String(machine.id ?? "").trim(); const title = String(machine.title ?? id).trim(); const category = firstString(record.category, record.group, record.family) ?? ""; const difficulty = firstString(record.difficulty, record.level, record.complexity) ?? ""; const description = joinNonEmpty([ record.description, record.summary, record.overview, record.longDescription, record.learningSummary, ]); const facts = joinNonEmpty([ record.engineeringFacts, record.facts, record.keyFacts, record.principles, record.learningObjectives, ]); const tags = uniqueStrings([ ...stringListFromUnknown(record.tags), ...stringListFromUnknown(record.keywords), ...stringListFromUnknown(record.concepts), ]); const relatedMachineIds = uniqueStrings([ ...stringListFromUnknown(record.relatedMachines), ...stringListFromUnknown(record.relatedMachineIds), ...stringListFromUnknown(record.related), ]); const componentRoots = COMPONENT_ROOT_KEYS.flatMap((key) => unknownArrayFromValue(record[key])); const componentRecords = collectComponentRecords(componentRoots); const componentIds: string[] = []; const componentTexts: string[] = []; for (const component of componentRecords) { const componentId = firstString(component.id, component.key, component.slug); if (componentId) { componentIds.push(componentId); } componentTexts.push(textFromValue(component)); } const labels = LABEL_ROOT_KEYS.flatMap((key) => unknownArrayFromValue(record[key])) .map((label) => textFromValue(label)) .join(" "); return { id, title, category, difficulty, description, facts, components: componentTexts.join(" "), labels, tags, componentIds: uniqueStrings(componentIds), relatedMachineIds, }; } function scoreDocument( document: MachineSearchDocument, normalizedQuery: string, queryTokens: readonly string[], rawQueryTokens: readonly string[], weights: Partial> | undefined, ): ScoreBreakdown { let score = 0; const matchedFields = new Set(); const matchedTokens = new Set(); const reasons = new Set(); if (normalizedQuery.length >= 3) { for (const field of ["title", "id", "components", "facts", "description"] as const) { if (document.fields[field].includes(normalizedQuery)) { score += fieldWeight(field, weights) * 18; matchedFields.add(field); reasons.add(`Matched ${fieldLabel(field)} phrase.`); } } } for (const token of queryTokens) { let bestScore = 0; let bestField: CatalogueField | null = null; for (const field of SEARCH_FIELD_ORDER) { const tokenScore = scoreTokenInField( token, document.fields[field], document.fieldTokens[field], field, fieldWeight(field, weights), ); if (tokenScore > bestScore) { bestScore = tokenScore; bestField = field; } } if (bestScore > 0 && bestField) { score += bestScore; matchedTokens.add(token); matchedFields.add(bestField); reasons.add(`Matched ${fieldLabel(bestField)}.`); } } if (score <= 0) { return { score: 0, matchedFields: [], matchedTokens: [], reasons: [], }; } const rawMatches = rawQueryTokens.filter((token) => matchedTokens.has(token)); const coverage = rawQueryTokens.length > 0 ? rawMatches.length / rawQueryTokens.length : 1; const coverageMultiplier = 0.65 + Math.min(1, coverage) * 0.35; return { score: score * coverageMultiplier, matchedFields: [...matchedFields], matchedTokens: [...matchedTokens], reasons: [...reasons], }; } function scoreTokenInField( token: string, fieldText: string, fieldTokens: readonly string[], field: CatalogueField, weight: number, ): number { if (!token || !fieldText) { return 0; } if (fieldTokens.includes(token)) { return weight * 10; } if (fieldText === token) { return weight * 12; } if (token.length >= 3 && fieldTokens.some((fieldToken) => fieldToken.startsWith(token))) { return weight * 7; } if ( token.length >= 4 && fieldTokens.some((fieldToken) => token.startsWith(fieldToken) && fieldToken.length >= 4) ) { return weight * 5; } if (token.length >= 3 && fieldText.includes(token)) { return weight * 4; } if (field === "title") { const acronym = makeAcronym(fieldText); if (token.length >= 2 && acronym.startsWith(token)) { return weight * 6; } } if ( token.length >= 4 && fieldTokens.some((fieldToken) => { if (fieldToken.length < 4) { return false; } const allowedDistance = token.length > 7 || fieldToken.length > 7 ? 2 : 1; return levenshteinDistanceWithin(token, fieldToken, allowedDistance) <= allowedDistance; }) ) { return weight * 3; } return 0; } function compareInternalSearchResults( a: InternalSearchResult, b: InternalSearchResult, options: MachineSearchOptions, ): number { const sort = options.sort ?? "relevance"; const direction = options.sortDirection ?? (sort === "relevance" ? "desc" : "asc"); let comparison = 0; if (sort === "relevance") { comparison = a.score - b.score; } else if (sort === "title") { comparison = a.document.sortTitle.localeCompare(b.document.sortTitle); } else if (sort === "category") { comparison = a.document.fields.category.localeCompare(b.document.fields.category) || a.document.sortTitle.localeCompare(b.document.sortTitle); } else if (sort === "difficulty") { comparison = difficultyRank(a.document.difficulty) - difficultyRank(b.document.difficulty) || a.document.sortTitle.localeCompare(b.document.sortTitle); } if (comparison !== 0) { return direction === "desc" ? -comparison : comparison; } return a.document.sortTitle.localeCompare(b.document.sortTitle) || a.document.order - b.document.order; } function matchesFilters( document: MachineSearchDocument, filters: MachineSearchFilters | undefined, ): boolean { if (!filters) { return true; } const ids = makeRawAndNormalizedSet(filters.ids); if (ids && !ids.has(document.id) && !ids.has(document.fields.id)) { return false; } const excludedIds = makeRawAndNormalizedSet(filters.excludedIds); if (excludedIds && (excludedIds.has(document.id) || excludedIds.has(document.fields.id))) { return false; } const categories = normalizeFilterSet(filters.categories); if (categories && !categories.has(document.fields.category)) { return false; } const difficulties = normalizeFilterSet(filters.difficulties); if (difficulties && !difficulties.has(document.fields.difficulty)) { return false; } const tags = normalizeFilterSet(filters.tags); if (tags) { const documentTags = new Set(document.tags.map((tag) => normalizeForSearch(tag))); const tagMatches = [...tags].filter((tag) => documentTags.has(tag)); if (filters.matchAllTags ? tagMatches.length !== tags.size : tagMatches.length === 0) { return false; } } const componentIds = makeRawAndNormalizedSet(filters.componentIds); if (componentIds) { const documentComponents = new Set([ ...document.componentIds, ...document.componentIds.map((id) => normalizeForSearch(id)), ...document.fieldTokens.components, ]); if (![...componentIds].some((componentId) => documentComponents.has(componentId))) { return false; } } if (filters.relatedTo) { const relatedTo = normalizeForSearch(filters.relatedTo); const relatedIds = new Set(document.relatedMachineIds.map((id) => normalizeForSearch(id))); if (!relatedIds.has(relatedTo) && !(filters.includeSelfRelated && document.fields.id === relatedTo)) { return false; } } return true; } function toSearchDocuments( source: MachineSearchSource, ): readonly MachineSearchDocument[] { return isMachineSearchIndex(source) ? source : buildMachineSearchIndex(source); } function isMachineSearchIndex( source: MachineSearchSource, ): source is readonly MachineSearchDocument[] { const first = (source as readonly unknown[])[0]; return isRecord(first) && isRecord(first.machine) && isRecord(first.fields) && Array.isArray(first.tokens); } function createSynonymMap(customSynonyms: SearchSynonymMap | false | undefined): Map { const map = new Map>(); if (customSynonyms === false) { return new Map(); } addSynonymSource(map, DEFAULT_MACHINE_SEARCH_SYNONYMS); if (customSynonyms) { addSynonymSource(map, customSynonyms); } return new Map([...map.entries()].map(([key, values]) => [key, [...values]])); } function addSynonymSource(map: Map>, source: SearchSynonymMap): void { for (const [term, aliases] of Object.entries(source)) { const normalizedTerm = normalizeForSearch(term); const normalizedAliases = aliases.map((alias) => normalizeForSearch(alias)).filter(Boolean); for (const alias of normalizedAliases) { addSynonym(map, normalizedTerm, alias); addSynonym(map, alias, normalizedTerm); for (const sibling of normalizedAliases) { if (sibling !== alias) { addSynonym(map, alias, sibling); } } } } } function addSynonym(map: Map>, term: string, alias: string): void { if (!term || !alias || term === alias) { return; } const existing = map.get(term) ?? new Set(); existing.add(alias); map.set(term, existing); } function expandQueryTokens( rawTokens: readonly string[], normalizedQuery: string, synonymMap: Map, ): string[] { const tokens: string[] = []; const seen = new Set(); for (const token of rawTokens) { addUniqueToken(tokens, seen, token); } for (const [term, aliases] of synonymMap) { const termMatches = term.includes(" ") ? normalizedQuery.includes(term) : rawTokens.includes(term); if (!termMatches) { continue; } for (const alias of aliases) { for (const aliasToken of tokenizeForSearch(alias)) { addUniqueToken(tokens, seen, aliasToken); } } } return tokens; } function addFacetCount(map: Map, rawValue: string): void { const value = String(rawValue ?? "").trim(); if (!value) { return; } const key = normalizeForSearch(value); const existing = map.get(key); if (existing) { map.set(key, { ...existing, count: existing.count + 1, }); return; } map.set(key, { value, label: labelFromCatalogueValue(value), count: 1, }); } function sortFacetBuckets(map: Map): CatalogueFacetBucket[] { return [...map.values()].sort( (a, b) => b.count - a.count || a.label.localeCompare(b.label), ); } function intersectLabels( left: readonly string[], right: readonly string[], limit: number, ): string[] { const rightByNormalized = new Map(right.map((value) => [normalizeForSearch(value), value])); const matches: string[] = []; const seen = new Set(); for (const value of left) { const normalized = normalizeForSearch(value); if (!normalized || seen.has(normalized) || !rightByNormalized.has(normalized)) { continue; } matches.push(labelFromCatalogueValue(value)); seen.add(normalized); if (matches.length >= limit) { break; } } return matches; } function intersectUsefulTokens( left: readonly string[], right: readonly string[], limit: number, ): string[] { const rightTokens = new Set( right.filter((token) => token.length >= 4 && !RELATED_TOKEN_STOP_WORDS.has(token)), ); const matches: string[] = []; const seen = new Set(); for (const token of left) { if ( token.length < 4 || RELATED_TOKEN_STOP_WORDS.has(token) || seen.has(token) || !rightTokens.has(token) ) { continue; } matches.push(token); seen.add(token); if (matches.length >= limit) { break; } } return matches; } function fieldWeight(field: CatalogueField, weights: Partial> | undefined): number { return weights?.[field] ?? DEFAULT_FIELD_WEIGHTS[field]; } function fieldLabel(field: CatalogueField): string { return labelFromCatalogueValue(field.replace(/([a-z])([A-Z])/g, "$1 $2")); } function difficultyRank(value: string): number { const normalized = normalizeForSearch(value); if (!normalized) { return Number.POSITIVE_INFINITY; } if (normalized.includes("beginner") || normalized.includes("intro") || normalized.includes("basic")) { return 1; } if (normalized.includes("intermediate")) { return 2; } if (normalized.includes("advanced")) { return 3; } if (normalized.includes("expert") || normalized.includes("professional")) { return 4; } return 99; } function makeRawAndNormalizedSet(values: readonly string[] | undefined): Set | null { if (!values || values.length === 0) { return null; } const set = new Set(); for (const value of values) { const raw = String(value ?? "").trim(); const normalized = normalizeForSearch(raw); if (raw) { set.add(raw); } if (normalized) { set.add(normalized); } } return set.size > 0 ? set : null; } function normalizeFilterSet(values: readonly string[] | undefined): Set | null { if (!values || values.length === 0) { return null; } const set = new Set(values.map((value) => normalizeForSearch(value)).filter(Boolean)); return set.size > 0 ? set : null; } function unknownArrayFromValue(value: unknown): unknown[] { if (Array.isArray(value)) { return value; } if (isRecord(value)) { if (looksLikeComponentRecord(value)) { return [value]; } return Object.values(value); } return []; } function collectComponentRecords( values: readonly unknown[], visited = new WeakSet(), depth = 0, ): Record[] { if (depth > 8) { return []; } const records: Record[] = []; for (const value of values) { if (!isRecord(value) || visited.has(value)) { continue; } visited.add(value); records.push(value); for (const childKey of COMPONENT_CHILD_KEYS) { records.push(...collectComponentRecords(unknownArrayFromValue(value[childKey]), visited, depth + 1)); } } return records; } function looksLikeComponentRecord(value: Record): boolean { return ( "id" in value || "name" in value || "title" in value || "description" in value || COMPONENT_CHILD_KEYS.some((key) => key in value) ); } function textFromValue(value: unknown, depth = 0, visited = new WeakSet()): string { if (value === undefined || value === null || depth > 4) { return ""; } if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return String(value); } if (Array.isArray(value)) { return value.map((item) => textFromValue(item, depth + 1, visited)).filter(Boolean).join(" "); } if (!isRecord(value) || visited.has(value)) { return ""; } visited.add(value); return TEXT_KEYS.map((key) => textFromValue(value[key], depth + 1, visited)) .filter(Boolean) .join(" "); } function stringListFromUnknown(value: unknown): string[] { if (Array.isArray(value)) { return value.flatMap((item) => stringListFromUnknown(item)); } if (typeof value === "string") { return splitDelimitedString(value); } if (typeof value === "number" || typeof value === "boolean") { return [String(value)]; } if (isRecord(value)) { const preferred = firstString(value.id, value.title, value.name, value.label, value.text, value.value); return preferred ? [preferred] : []; } return []; } function splitDelimitedString(value: string): string[] { const trimmed = value.trim(); if (!trimmed) { return []; } return trimmed .split(/[,|]/) .map((item) => item.trim()) .filter(Boolean); } function joinNonEmpty(values: readonly unknown[]): string { return values.map((value) => textFromValue(value)).filter(Boolean).join(" "); } function firstString(...values: readonly unknown[]): string | undefined { for (const value of values) { if (typeof value === "string" && value.trim()) { return value.trim(); } if (typeof value === "number" || typeof value === "boolean") { return String(value); } } return undefined; } function uniqueStrings(values: readonly unknown[]): string[] { const unique: string[] = []; const seen = new Set(); for (const value of values) { const text = firstString(value); if (!text || seen.has(text)) { continue; } unique.push(text); seen.add(text); } return unique; } function addUniqueToken(tokens: string[], seen: Set, token: string): void { if (!token || seen.has(token)) { return; } tokens.push(token); seen.add(token); } function singularizeSearchToken(token: string): string { if (token.length > 4 && token.endsWith("ies")) { return `${token.slice(0, -3)}y`; } if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) { return token.slice(0, -1); } return token; } function makeAcronym(text: string): string { return tokenizeForSearch(text) .filter((token) => !RELATED_TOKEN_STOP_WORDS.has(token)) .map((token) => token[0] ?? "") .join(""); } function levenshteinDistanceWithin(left: string, right: string, maxDistance: number): number { if (left === right) { return 0; } if (Math.abs(left.length - right.length) > maxDistance) { return maxDistance + 1; } let previous = Array.from({ length: right.length + 1 }, (_, index) => index); for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { const current = [leftIndex]; let rowMinimum = current[0]; for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; const insertion = current[rightIndex - 1] + 1; const deletion = previous[rightIndex] + 1; const substitution = previous[rightIndex - 1] + substitutionCost; const next = Math.min(insertion, deletion, substitution); current[rightIndex] = next; rowMinimum = Math.min(rowMinimum, next); } if (rowMinimum > maxDistance) { return maxDistance + 1; } previous = current; } return previous[right.length]; } function roundScore(value: number): number { return Math.round(value * 100) / 100; } function searchParamsFromQueryLike( queryLike: | string | URLSearchParams | { readonly href?: string; readonly search?: string; readonly hash?: string; }, ): URLSearchParams { if (queryLike instanceof URLSearchParams) { return new URLSearchParams(queryLike.toString()); } if (typeof queryLike === "string") { return searchParamsFromString(queryLike); } if (isRecord(queryLike)) { if (typeof queryLike.href === "string" && !queryLike.search && !queryLike.hash) { return searchParamsFromString(queryLike.href); } const params = new URLSearchParams(); if (typeof queryLike.search === "string") { appendParamsIfMissing(params, searchParamsFromString(queryLike.search)); } if (typeof queryLike.hash === "string") { appendParamsIfMissing(params, searchParamsFromString(queryLike.hash)); } if (params.toString() === "" && typeof queryLike.href === "string") { return searchParamsFromString(queryLike.href); } return params; } return new URLSearchParams(); } function searchParamsFromString(input: string): URLSearchParams { const trimmed = input.trim(); if (!trimmed) { return new URLSearchParams(); } if (/^[A-Za-z][A-Za-z\d+\-.]*:/.test(trimmed)) { try { const url = new URL(trimmed); const params = new URLSearchParams(url.search); if (url.hash.startsWith("#?")) { appendParamsIfMissing(params, new URLSearchParams(url.hash.slice(2))); } return params; } catch { return new URLSearchParams(); } } if (trimmed.startsWith("#")) { const hashQuery = trimmed.slice(1).replace(/^\?/, ""); return hashQuery.includes("=") ? new URLSearchParams(hashQuery) : new URLSearchParams(); } const hashIndex = trimmed.indexOf("#"); const beforeHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed; const hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex + 1) : ""; const queryIndex = beforeHash.indexOf("?"); let rawSearch = ""; if (queryIndex >= 0) { rawSearch = beforeHash.slice(queryIndex + 1); } else if (beforeHash.startsWith("?")) { rawSearch = beforeHash.slice(1); } else if (beforeHash.includes("=")) { rawSearch = beforeHash.replace(/^[?#]/, ""); } const params = new URLSearchParams(rawSearch); if (hashPart.startsWith("?") || (!rawSearch && hashPart.includes("="))) { appendParamsIfMissing(params, new URLSearchParams(hashPart.replace(/^\?/, ""))); } return params; } function appendParamsIfMissing(target: URLSearchParams, source: URLSearchParams): void { source.forEach((value, key) => { if (!target.has(key)) { target.set(key, value); } }); } function assignParam( target: Record, targetKey: string, params: URLSearchParams, aliases: readonly string[], ): void { const value = getFirstParam(params, aliases); if (value !== undefined) { target[targetKey] = value; } } function getFirstParam(params: URLSearchParams, aliases: readonly string[]): string | undefined { for (const alias of aliases) { const value = params.get(alias); if (value !== null && value.trim() !== "") { return value; } } return undefined; } function parseListParam(value: string): string[] { return value .split(/[,|]/) .map((item) => item.trim()) .filter(Boolean); } function parseOpacityParam(value: string, warnings: string[]): Record { const result: Record = Object.create(null); for (const segment of parseListParam(value)) { const delimiterIndex = Math.max( segment.lastIndexOf(":"), segment.lastIndexOf("~"), segment.lastIndexOf("="), ); if (delimiterIndex <= 0 || delimiterIndex >= segment.length - 1) { warnings.push(`Ignored malformed opacity segment "${segment}".`); continue; } const componentId = segment.slice(0, delimiterIndex).trim(); const opacity = segment.slice(delimiterIndex + 1).trim(); result[componentId] = opacity; } return result; } function parseCameraParam(value: string, warnings: string[]): MachineViewerCameraState | undefined { const numbers = value .split(/[;,|]/) .map((part) => Number(part.trim())) .filter((part) => part !== undefined); if (numbers.length < 6 || numbers.slice(0, 6).some((number) => !Number.isFinite(number))) { warnings.push("Ignored camera state: expected at least six finite numbers."); return undefined; } const camera: { position: Vector3Tuple; target: Vector3Tuple; zoom?: number; } = { position: [numbers[0], numbers[1], numbers[2]], target: [numbers[3], numbers[4], numbers[5]], }; if (numbers.length >= 7 && Number.isFinite(numbers[6])) { camera.zoom = numbers[6]; } return camera; } function parseSectionParam(value: string, warnings: string[]): Record | undefined { const parts = value.split(/[:,|]/).map((part) => part.trim()); if (parts.length < 1 || !parts[0]) { warnings.push("Ignored section state: missing section axis."); return undefined; } return { axis: parts[0], offset: parts[1] ?? 0, }; } function sanitizeIdentifier( value: unknown, label: string, allowedIds: readonly string[] | undefined, warnings: string[], optional = false, ): string | undefined { const text = firstString(value); if (!text) { if (!optional) { warnings.push(`Missing ${label}.`); } return undefined; } if (DANGEROUS_RECORD_KEYS.has(text) || !SAFE_ID_PATTERN.test(text)) { warnings.push(`Ignored invalid ${label} "${text}".`); return undefined; } if (allowedIds && !new Set(allowedIds).has(text)) { warnings.push(`Ignored unknown ${label} "${text}".`); return undefined; } return text; } function sanitizeIdentifierList( value: unknown, label: string, allowedIds: readonly string[] | undefined, warnings: string[], maxEntries: number, ): string[] { const values = stringListFromUnknown(value); const result: string[] = []; const seen = new Set(); for (const rawValue of values) { if (result.length >= maxEntries) { warnings.push(`Truncated ${label} list to ${maxEntries} entries.`); break; } const identifier = sanitizeIdentifier(rawValue, label, allowedIds, warnings, true); if (identifier && !seen.has(identifier)) { result.push(identifier); seen.add(identifier); } } return result; } function sanitizeOpacityMap( value: unknown, allowedComponentIds: readonly string[] | undefined, warnings: string[], maxEntries: number, ): Record { const result: Record = Object.create(null); const entries = opacityEntriesFromUnknown(value); for (const [componentIdInput, opacityInput] of entries) { if (Object.keys(result).length >= maxEntries) { warnings.push(`Truncated component opacity map to ${maxEntries} entries.`); break; } const componentId = sanitizeIdentifier( componentIdInput, "opacity component id", allowedComponentIds, warnings, true, ); if (!componentId) { continue; } const opacity = numberFromUnknown(opacityInput); if (opacity === undefined) { warnings.push(`Ignored opacity for "${componentId}": value is not numeric.`); continue; } result[componentId] = clampWithWarning(opacity, 0, 1, `opacity for "${componentId}"`, warnings); } return result; } function opacityEntriesFromUnknown(value: unknown): Array<[unknown, unknown]> { if (typeof value === "string") { return Object.entries(parseOpacityParam(value, [])).map(([key, entryValue]) => [key, entryValue]); } if (Array.isArray(value)) { return value.flatMap((entry): Array<[unknown, unknown]> => { if (Array.isArray(entry) && entry.length >= 2) { return [[entry[0], entry[1]]]; } if (isRecord(entry)) { return [[firstDefined(entry.id, entry.componentId, entry.component), firstDefined(entry.opacity, entry.value)]]; } return []; }); } if (isRecord(value)) { return Object.entries(value); } return []; } function sanitizeRenderMode(value: unknown, defaultMode: ViewerRenderMode, warnings: string[]): ViewerRenderMode { const normalized = normalizeForSearch(value); if (!normalized) { return defaultMode; } if (normalized === "solid" || normalized === "shaded") { return "solid"; } if (normalized === "wireframe" || normalized === "wire") { return "wireframe"; } if (normalized === "x ray" || normalized === "xray" || normalized === "transparent") { return "x-ray"; } if (normalized === "cross section" || normalized === "section" || normalized === "cutaway") { return "cross-section"; } warnings.push(`Unknown render mode "${String(value)}"; using ${defaultMode}.`); return defaultMode; } function sanitizePlaybackState( value: unknown, defaultPlayback: PlaybackState, warnings: string[], ): PlaybackState { if (typeof value === "boolean") { return value ? "playing" : "paused"; } const normalized = normalizeForSearch(value); if (!normalized) { return defaultPlayback; } if (["1", "true", "yes", "on", "play", "playing", "animate", "animating"].includes(normalized)) { return "playing"; } if (["0", "false", "no", "off", "pause", "paused", "stop", "stopped"].includes(normalized)) { return "paused"; } warnings.push(`Unknown playback state "${String(value)}"; using ${defaultPlayback}.`); return defaultPlayback; } function sanitizeBooleanOrDefault( value: unknown, defaultValue: boolean, label: string, warnings: string[], ): boolean { if (value === undefined || value === null || value === "") { return defaultValue; } if (typeof value === "boolean") { return value; } if (typeof value === "number") { return value !== 0; } const normalized = normalizeForSearch(value); if (["1", "true", "yes", "on", "show", "shown", "visible"].includes(normalized)) { return true; } if (["0", "false", "no", "off", "hide", "hidden"].includes(normalized)) { return false; } warnings.push(`Unknown ${label} value "${String(value)}"; using ${String(defaultValue)}.`); return defaultValue; } function sanitizeCameraState( value: unknown, options: ViewerStateCodecOptions, warnings: string[], ): MachineViewerCameraState | undefined { if (!isRecord(value)) { return undefined; } const limit = options.maxCameraMagnitude ?? DEFAULT_MAX_CAMERA_MAGNITUDE; const camera: { position?: Vector3Tuple; target?: Vector3Tuple; zoom?: number; fov?: number; } = {}; const position = sanitizeVector3(value.position, "camera position", limit, warnings); if (position) { camera.position = position; } const target = sanitizeVector3(value.target, "camera target", limit, warnings); if (target) { camera.target = target; } const zoom = sanitizeOptionalNumber(value.zoom, "camera zoom", 0.01, 100, warnings); if (zoom !== undefined) { camera.zoom = zoom; } const fov = sanitizeOptionalNumber(value.fov, "camera field of view", 1, 160, warnings); if (fov !== undefined) { camera.fov = fov; } return Object.keys(camera).length > 0 ? camera : undefined; } function sanitizeVector3( value: unknown, label: string, magnitudeLimit: number, warnings: string[], ): Vector3Tuple | undefined { let values: readonly unknown[] | undefined; if (Array.isArray(value)) { values = value; } else if (isRecord(value)) { values = [value.x, value.y, value.z]; } if (!values) { return undefined; } if (values.length < 3) { warnings.push(`Ignored ${label}: expected three coordinates.`); return undefined; } const parsed = values.slice(0, 3).map((item) => numberFromUnknown(item)); if (parsed.some((item) => item === undefined)) { warnings.push(`Ignored ${label}: coordinates must be finite numbers.`); return undefined; } return parsed.map((item) => clampWithWarning(item as number, -magnitudeLimit, magnitudeLimit, label, warnings), ) as unknown as Vector3Tuple; } function sanitizeSectionState( value: unknown, options: ViewerStateCodecOptions, warnings: string[], ): MachineViewerSectionState | undefined { if (!isRecord(value)) { return undefined; } const axis = normalizeForSearch(value.axis); if (axis !== "x" && axis !== "y" && axis !== "z") { warnings.push(`Ignored section state: unknown axis "${String(value.axis)}".`); return undefined; } return { axis, offset: sanitizeNumberOrDefault( value.offset, "section offset", 0, options.minSectionOffset ?? DEFAULT_MIN_SECTION_OFFSET, options.maxSectionOffset ?? DEFAULT_MAX_SECTION_OFFSET, warnings, ), }; } function sanitizeNumberOrDefault( value: unknown, label: string, defaultValue: number, min: number, max: number, warnings: string[], ): number { const parsed = numberFromUnknown(value); if (parsed === undefined) { if (value !== undefined && value !== null && value !== "") { warnings.push(`Invalid ${label} "${String(value)}"; using ${defaultValue}.`); } return clampWithWarning(defaultValue, min, max, label, warnings); } return clampWithWarning(parsed, min, max, label, warnings); } function sanitizeOptionalNumber( value: unknown, label: string, min: number, max: number, warnings: string[], ): number | undefined { if (value === undefined || value === null || value === "") { return undefined; } const parsed = numberFromUnknown(value); if (parsed === undefined) { warnings.push(`Ignored ${label}: value is not numeric.`); return undefined; } return clampWithWarning(parsed, min, max, label, warnings); } function numberFromUnknown(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string" && value.trim()) { const parsed = Number(value.trim()); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; } function clampWithWarning( value: number, min: number, max: number, label: string, warnings: string[], ): number { if (value < min) { warnings.push(`Clamped ${label} to minimum ${min}.`); return min; } if (value > max) { warnings.push(`Clamped ${label} to maximum ${max}.`); return max; } return value; } function encodeCameraState(camera: MachineViewerCameraState | undefined, precision: number): string | undefined { if (!camera?.position || !camera.target) { return undefined; } const values = [...camera.position, ...camera.target]; if (camera.zoom !== undefined) { values.push(camera.zoom); } return values.map((value) => formatNumber(value, precision)).join(","); } function formatNumber(value: number, precision: number): string { const factor = 10 ** precision; const rounded = Math.round((value + Number.EPSILON) * factor) / factor; if (Object.is(rounded, -0)) { return "0"; } return String(rounded); } function sanitizedPrecision(value: number | undefined): number { if (!Number.isFinite(value)) { return DEFAULT_NUMBER_PRECISION; } return Math.max(0, Math.min(8, Math.floor(value as number))); } function firstDefined(...values: readonly unknown[]): unknown { for (const value of values) { if (value === undefined || value === null) { continue; } if (typeof value === "string" && value.trim() === "") { continue; } return value; } return undefined; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }