import type { CameraPreset } from "../store/productionViewerStore"; export type Vector3Tuple = [number, number, number]; export type CrossSectionAxis = "none" | "x" | "y" | "z"; export interface ProductionShareState { machineId: string; cameraPosition?: Vector3Tuple; cameraTarget?: Vector3Tuple; cameraPreset?: CameraPreset; explodeDistance?: number; hiddenPartIds?: string[]; partOpacities?: Record; selectedPartId?: string | null; wireframe?: boolean; annotations?: boolean; crossSectionAxis?: CrossSectionAxis; crossSectionOffset?: number; rpm?: number; timeScale?: number; playing?: boolean; } const VECTOR_PRECISION = 3; const NUMBER_PRECISION = 3; function round(value: number, precision: number = NUMBER_PRECISION): number { const factor = 10 ** precision; return Math.round(value * factor) / factor; } function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } function parseNumber(value: string | null): number | undefined { if (value === null || value.trim() === "") { return undefined; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } function parseBoolean(value: string | null): boolean | undefined { if (value === null) { return undefined; } const normalized = value.trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(normalized)) { return true; } if (["0", "false", "no", "off"].includes(normalized)) { return false; } return undefined; } function serializeBoolean(value: boolean): string { return value ? "1" : "0"; } function serializeVector(vector: Vector3Tuple): string { return vector.map((component) => round(component, VECTOR_PRECISION)).join(","); } function parseVector(value: string | null): Vector3Tuple | undefined { if (!value) { return undefined; } const components = value .split(/[,\s|]+/) .map((component) => Number(component)) .filter((component) => Number.isFinite(component)); if (components.length !== 3) { return undefined; } return [components[0], components[1], components[2]]; } function serializeStringList(values: readonly string[]): string { return [...new Set(values)].filter(Boolean).sort().join(","); } function parseStringList(value: string | null): string[] | undefined { if (!value) { return undefined; } const parts = value .split(",") .map((part) => part.trim()) .filter(Boolean); return parts.length > 0 ? [...new Set(parts)] : undefined; } function serializeOpacities(opacities: Record): string | undefined { const entries = Object.entries(opacities) .filter(([, value]) => isFiniteNumber(value) && value >= 0 && value < 0.999) .sort(([left], [right]) => left.localeCompare(right)); if (entries.length === 0) { return undefined; } return entries.map(([id, value]) => `${id}:${round(value, 2)}`).join(","); } function parseOpacities(value: string | null): Record | undefined { if (!value) { return undefined; } const opacities: Record = {}; for (const entry of value.split(",")) { const [id, rawOpacity] = entry.split(":"); const opacity = Number(rawOpacity); if (id && Number.isFinite(opacity)) { opacities[id] = Math.min(1, Math.max(0.05, opacity)); } } return Object.keys(opacities).length > 0 ? opacities : undefined; } function normalizeCrossSectionAxis(value: string | null): CrossSectionAxis | undefined { if (value === "none" || value === "x" || value === "y" || value === "z") { return value; } return undefined; } function getSearchParams(input?: string | URLSearchParams | URL | Location): URLSearchParams { if (!input && typeof window !== "undefined") { return new URLSearchParams(window.location.search); } if (!input) { return new URLSearchParams(); } if (typeof input === "string") { return new URLSearchParams(input.startsWith("?") ? input.slice(1) : input); } if (input instanceof URLSearchParams) { return new URLSearchParams(input); } if (input instanceof URL) { return new URLSearchParams(input.search); } return new URLSearchParams(input.search); } export function encodeProductionShareState(state: ProductionShareState): string { const params = new URLSearchParams(); params.set("machine", state.machineId); if (state.cameraPreset) { params.set("preset", state.cameraPreset); } if (state.cameraPosition) { params.set("cam", serializeVector(state.cameraPosition)); } if (state.cameraTarget) { params.set("target", serializeVector(state.cameraTarget)); } if (isFiniteNumber(state.explodeDistance) && state.explodeDistance > 0) { params.set("explode", String(round(state.explodeDistance))); } if (state.hiddenPartIds && state.hiddenPartIds.length > 0) { params.set("hidden", serializeStringList(state.hiddenPartIds)); } const serializedOpacity = state.partOpacities ? serializeOpacities(state.partOpacities) : undefined; if (serializedOpacity) { params.set("opacity", serializedOpacity); } if (state.selectedPartId) { params.set("part", state.selectedPartId); } if (typeof state.wireframe === "boolean") { params.set("wire", serializeBoolean(state.wireframe)); } if (typeof state.annotations === "boolean") { params.set("labels", serializeBoolean(state.annotations)); } if (state.crossSectionAxis && state.crossSectionAxis !== "none") { const offset = round(state.crossSectionOffset ?? 0); params.set("section", `${state.crossSectionAxis}:${offset}`); } if (isFiniteNumber(state.rpm)) { params.set("rpm", String(round(state.rpm, 1))); } if (isFiniteNumber(state.timeScale) && state.timeScale !== 1) { params.set("time", String(round(state.timeScale, 2))); } if (typeof state.playing === "boolean") { params.set("play", serializeBoolean(state.playing)); } return params.toString(); } export function decodeProductionShareState( input?: string | URLSearchParams | URL | Location, ): Partial { const params = getSearchParams(input); const section = params.get("section") ?? params.get("cross"); const [sectionAxisRaw, sectionOffsetRaw] = section?.split(":") ?? []; const machineId = params.get("machine") ?? params.get("m") ?? undefined; const cameraPosition = parseVector(params.get("cam") ?? params.get("camera")); const cameraTarget = parseVector(params.get("target") ?? params.get("lookAt")); const explodeDistance = parseNumber(params.get("explode")); const hiddenPartIds = parseStringList(params.get("hidden") ?? params.get("hiddenParts")); const partOpacities = parseOpacities(params.get("opacity") ?? params.get("opacities")); const selectedPartId = params.get("part") ?? params.get("selected") ?? undefined; const crossSectionAxis = normalizeCrossSectionAxis(sectionAxisRaw ?? null); const crossSectionOffset = parseNumber(sectionOffsetRaw ?? null); const rpm = parseNumber(params.get("rpm")); const timeScale = parseNumber(params.get("time") ?? params.get("timeScale")); const decoded: Partial = {}; if (machineId) { decoded.machineId = machineId; } const cameraPreset = params.get("preset"); if ( cameraPreset === "front" || cameraPreset === "back" || cameraPreset === "left" || cameraPreset === "right" || cameraPreset === "top" || cameraPreset === "isometric" ) { decoded.cameraPreset = cameraPreset; } if (cameraPosition) { decoded.cameraPosition = cameraPosition; } if (cameraTarget) { decoded.cameraTarget = cameraTarget; } if (isFiniteNumber(explodeDistance)) { decoded.explodeDistance = Math.min(6, Math.max(0, explodeDistance)); } if (hiddenPartIds) { decoded.hiddenPartIds = hiddenPartIds; } if (partOpacities) { decoded.partOpacities = partOpacities; } if (selectedPartId) { decoded.selectedPartId = selectedPartId; } const wireframe = parseBoolean(params.get("wire") ?? params.get("wireframe")); if (typeof wireframe === "boolean") { decoded.wireframe = wireframe; } const annotations = parseBoolean(params.get("labels") ?? params.get("annotations")); if (typeof annotations === "boolean") { decoded.annotations = annotations; } if (crossSectionAxis) { decoded.crossSectionAxis = crossSectionAxis; decoded.crossSectionOffset = crossSectionOffset ?? 0; } if (isFiniteNumber(rpm)) { decoded.rpm = Math.min(12000, Math.max(1, rpm)); } if (isFiniteNumber(timeScale)) { decoded.timeScale = Math.min(3, Math.max(0.1, timeScale)); } const playing = parseBoolean(params.get("play") ?? params.get("playing")); if (typeof playing === "boolean") { decoded.playing = playing; } return decoded; } export function buildMachineShareUrl( state: ProductionShareState, baseUrl?: string | URL, ): string { const fallbackOrigin = "https://mechanica.local"; const runtimeHref = typeof window !== "undefined" ? window.location.href : `${fallbackOrigin}/machine/${state.machineId}`; const runtimeOrigin = typeof window !== "undefined" ? window.location.origin : fallbackOrigin; const url = new URL(baseUrl?.toString() ?? runtimeHref, runtimeOrigin); url.pathname = `/machine/${encodeURIComponent(state.machineId)}`; url.search = encodeProductionShareState(state); url.hash = ""; return url.toString(); } export function replaceUrlWithShareState(state: ProductionShareState): void { if (typeof window === "undefined") { return; } const nextUrl = new URL(buildMachineShareUrl(state, window.location.href)); const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; if (nextPath !== currentPath) { window.history.replaceState({ mechanica: true, sharedView: true }, "", nextPath); } }