import type { AxiosProgressEvent } from "axios"; import type { TIssueAttachment } from "@plane/types"; export type TBeamViewerConversion = { artifactSrc?: string; artifactType?: string; componentTreeRequired: boolean; message?: string; metadataSrc?: string; size?: number; sourceFormat: string; sourceSrc?: string; status: "conversion_required" | "processing" | "ready" | "failed"; targetFormat: "glb" | "xkt"; updatedAt?: string; }; export type TBeamModelVersionRecord = { assetId?: string; conversion?: TBeamViewerConversion; downloadUrl: string; originalFilename: string; previewAvailable: boolean; projectId?: string; sha256?: string; size: number; sourceSrc?: string; src: string; status?: string; type: string; uploadedBy?: string; uploadedAt: string; version: number; versionId?: string; viewerUrl?: string | null; }; export type TBeamViewerAttachment = { assetId?: string; backend: "beam-viewer-local" | "beam-viewer"; conversion?: TBeamViewerConversion; downloadUrl: string; originalFilename: string; previewAvailable: boolean; projectId?: string; sha256?: string; src: string; type: string; uploadedBy?: string; uploadedAt: string; version?: number; versionId?: string; versions?: TBeamModelVersionRecord[]; viewerUrl?: string | null; }; export type TBeamConversionStatus = { assetId?: string; artifactSrc?: string; artifactType?: string; artifactUrl?: string; componentCount?: number; componentTreeRequired?: boolean; error?: string; message?: string; metadataSrc?: string; metadataUrl?: string; sha256?: string; sourceFormat?: string; sourceSrc?: string; size?: number; status: "conversion_required" | "processing" | "ready" | "failed"; targetFormat?: "glb" | "xkt"; updatedAt?: string; version?: number; versionId?: string; }; export type TBeamVersionHistoryResponse = { assetId?: string; currentVersion?: number; currentVersionId?: string; ok: boolean; projectId?: string; updatedAt?: string; versions: TBeamModelVersionRecord[]; }; export const BEAM_VIEWER_OPEN_EVENT = "nodedc:beam-viewer-open"; export type TBeamViewerOpenEventDetail = { downloadUrl?: string; fileExtension?: string; fileName: string; fileSize: number; issueId: string; viewerUrl: string; }; export const dispatchBeamViewerOpenEvent = (detail: TBeamViewerOpenEventDetail) => { window.dispatchEvent(new CustomEvent(BEAM_VIEWER_OPEN_EVENT, { detail })); }; const DEFAULT_BEAM_VIEWER_BASE_URL = "http://localhost:8080"; const BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION: Record = { bim: "bim", glb: "gltf", gltf: "gltf", las: "las", laz: "las", obj: "obj", stl: "stl", xkt: "xkt", }; const BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION: Record = { step: "step", stp: "step", }; const MIME_TYPE_BY_EXTENSION: Record = { glb: "model/gltf-binary", gltf: "model/gltf+json", }; const normalizeBaseUrl = (url: string | undefined): string => (url && url.trim() ? url.trim() : DEFAULT_BEAM_VIEWER_BASE_URL).replace(/\/+$/, ""); const getFileExtension = (name: string): string => { const parts = name.split("."); return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; }; const getUploadGroupId = (parts: Array): string => { const value = parts .filter(Boolean) .join("_") .replace(/[^a-zA-Z0-9_-]/g, "_"); return value || "tasker"; }; const toBeamRelativeUploadSrc = (src: string): string => { try { const url = new URL(src); return url.pathname.replace(/^\/+/, ""); } catch (_error) { return src.replace(/^\/+/, ""); } }; const toBeamAbsoluteUrl = (src: string | undefined, cacheKey?: string): string | undefined => { if (!src) return undefined; let url: URL; try { url = new URL(src); } catch (_error) { url = new URL(src.startsWith("/") ? src : `/${src}`, `${getBeamApiBaseUrl()}/`); } if (cacheKey) url.searchParams.set("v", cacheKey); return url.toString(); }; export const getBeamViewerBaseUrl = (): string => normalizeBaseUrl(process.env.VITE_BEAM_VIEWER_BASE_URL); export const getBeamApiBaseUrl = (): string => normalizeBaseUrl(process.env.VITE_BEAM_API_BASE_URL || process.env.VITE_BEAM_VIEWER_BASE_URL); export const getBeamModelTypeFromName = (name: string | undefined): string | null => { if (!name) return null; const extension = getFileExtension(name); return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[extension] ?? BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION[extension] ?? null; }; export const getBeamDirectModelTypeFromName = (name: string | undefined): string | null => { if (!name) return null; return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[getFileExtension(name)] ?? null; }; export const getBeamModelMimeType = (file: File): string => MIME_TYPE_BY_EXTENSION[getFileExtension(file.name)] || "application/octet-stream"; export const isBeamModelFile = (name: string | undefined): boolean => !!getBeamModelTypeFromName(name); export const isBeamDirectViewerFile = (name: string | undefined): boolean => !!getBeamDirectModelTypeFromName(name); export const buildBeamViewerUrl = (params: { name?: string; settingsSrc?: string; src: string; type?: string | null; }): string => { const viewerUrl = new URL(getBeamViewerBaseUrl()); viewerUrl.searchParams.set("url", params.src); if (params.type) viewerUrl.searchParams.set("type", params.type); if (params.type === "gltf" || params.type === "glb") viewerUrl.searchParams.set("edges", "false"); if (params.name) viewerUrl.searchParams.set("name", params.name); if (params.settingsSrc) viewerUrl.searchParams.set("settingsSrc", params.settingsSrc); return viewerUrl.toString(); }; export const getBeamViewerAttachment = (attachment: TIssueAttachment | undefined): TBeamViewerAttachment | null => { const beamViewer = (attachment?.attributes as { beamViewer?: TBeamViewerAttachment } | undefined)?.beamViewer; if (!beamViewer || typeof beamViewer !== "object") return null; return beamViewer; }; const getAttachmentVersionNumber = (attachment: TIssueAttachment | undefined): number => { const rawVersion = (attachment?.attributes as { version?: number | string } | undefined)?.version; if (typeof rawVersion === "number" && Number.isFinite(rawVersion)) return rawVersion; if (typeof rawVersion === "string") { const parsed = Number.parseInt(rawVersion.replace(/^v/i, ""), 10); if (Number.isFinite(parsed)) return parsed; } return 1; }; export const getBeamViewerVersionNumber = ( beamViewer: TBeamViewerAttachment | null | undefined, attachment?: TIssueAttachment ): number => { if (typeof beamViewer?.version === "number" && Number.isFinite(beamViewer.version)) return beamViewer.version; return getAttachmentVersionNumber(attachment); }; export const createBeamModelVersionRecord = ( beamViewer: TBeamViewerAttachment, options: { attachment?: TIssueAttachment; size?: number; uploadedBy?: string; } = {} ): TBeamModelVersionRecord => ({ assetId: beamViewer.assetId, conversion: beamViewer.conversion, downloadUrl: beamViewer.downloadUrl, originalFilename: beamViewer.originalFilename, previewAvailable: beamViewer.previewAvailable, projectId: beamViewer.projectId, sha256: beamViewer.sha256, size: options.size ?? beamViewer.conversion?.size ?? Number(options.attachment?.attributes.size ?? 0), sourceSrc: beamViewer.conversion?.sourceSrc || beamViewer.src, src: beamViewer.src, status: beamViewer.conversion?.status || "ready", type: beamViewer.type, uploadedBy: beamViewer.uploadedBy || options.uploadedBy || options.attachment?.created_by, uploadedAt: beamViewer.uploadedAt, version: getBeamViewerVersionNumber(beamViewer, options.attachment), versionId: beamViewer.versionId, viewerUrl: beamViewer.viewerUrl, }); const isSameBeamVersionRecord = (first: TBeamModelVersionRecord, second: TBeamModelVersionRecord): boolean => { if (first.versionId && second.versionId) return first.versionId === second.versionId; return first.version === second.version; }; export const getBeamModelVersionRecords = ( beamViewer: TBeamViewerAttachment | null | undefined, attachment?: TIssueAttachment ): TBeamModelVersionRecord[] => { if (!beamViewer) return []; const currentRecord = createBeamModelVersionRecord(beamViewer, { attachment }); const storedVersions = Array.isArray(beamViewer.versions) ? beamViewer.versions : []; const versions = storedVersions.some((version) => isSameBeamVersionRecord(version, currentRecord)) ? storedVersions : [...storedVersions, currentRecord]; return versions .filter((version) => typeof version?.version === "number" && Number.isFinite(version.version)) .sort((first, second) => first.version - second.version); }; export const mergeBeamModelVersionRecords = ( previousBeamViewer: TBeamViewerAttachment, nextBeamViewer: TBeamViewerAttachment, attachment?: TIssueAttachment ): TBeamModelVersionRecord[] => { const previousVersions = getBeamModelVersionRecords(previousBeamViewer, attachment); const nextRecord = nextBeamViewer.versions?.find((version) => nextBeamViewer.versionId ? version.versionId === nextBeamViewer.versionId : version.version === nextBeamViewer.version ) || nextBeamViewer.versions?.[0] || createBeamModelVersionRecord(nextBeamViewer); return [...previousVersions.filter((version) => !isSameBeamVersionRecord(version, nextRecord)), nextRecord].sort( (first, second) => first.version - second.version ); }; export const mergeBeamModelVersionRecordLists = ( firstVersions: TBeamModelVersionRecord[] = [], secondVersions: TBeamModelVersionRecord[] = [] ): TBeamModelVersionRecord[] => { const byKey = new Map(); [...firstVersions, ...secondVersions].forEach((version) => { const key = version.versionId || `version-${version.version}`; const previous = byKey.get(key); byKey.set(key, { ...(previous || {}), ...version }); }); return [...byKey.values()] .filter((version) => typeof version?.version === "number" && Number.isFinite(version.version)) .sort((first, second) => first.version - second.version); }; export const syncCurrentBeamVersionRecord = ( beamViewer: TBeamViewerAttachment, attachment?: TIssueAttachment ): TBeamViewerAttachment => { const currentRecord = createBeamModelVersionRecord(beamViewer, { attachment }); const versions = getBeamModelVersionRecords(beamViewer, attachment).map((version) => { if (!isSameBeamVersionRecord(version, currentRecord)) return version; return Object.assign({}, version, currentRecord); }); return { ...beamViewer, versions }; }; export const getBeamVersionViewerUrl = (version: TBeamModelVersionRecord): string | undefined => { if (version.viewerUrl) return version.viewerUrl; if (!version.conversion && version.previewAvailable && version.src) { return buildBeamViewerUrl({ name: version.originalFilename, settingsSrc: version.sourceSrc || version.src, src: version.src, type: version.type, }); } if (version.conversion?.status !== "ready" || !version.conversion.artifactSrc) return undefined; const artifactType = version.conversion.artifactType || version.conversion.targetFormat || "gltf"; return buildBeamViewerUrl({ name: version.originalFilename, settingsSrc: version.conversion.sourceSrc || version.src, src: toBeamAbsoluteUrl(version.conversion.artifactSrc, version.conversion.updatedAt) || version.conversion.artifactSrc, type: artifactType, }); }; export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachment): Promise => { const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`); statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src)); const response = await fetch(statusUrl.toString(), { credentials: "include" }); if (!response.ok) { throw new Error(`BIM conversion status failed: HTTP ${response.status}`); } const payload = (await response.json()) as TBeamConversionStatus; return { ...payload, artifactUrl: toBeamAbsoluteUrl(payload.artifactSrc, payload.updatedAt), metadataUrl: toBeamAbsoluteUrl(payload.metadataSrc), }; }; export const fetchBeamModelVersions = async ( beamViewer: TBeamViewerAttachment ): Promise => { const versionsUrl = new URL("/api/uploads/versions", `${getBeamApiBaseUrl()}/`); if (beamViewer.projectId && beamViewer.assetId) { versionsUrl.searchParams.set("projectId", beamViewer.projectId); versionsUrl.searchParams.set("assetId", beamViewer.assetId); } else { versionsUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src)); } const response = await fetch(versionsUrl.toString(), { credentials: "include" }); if (!response.ok) { throw new Error(`BIM version history failed: HTTP ${response.status}`); } return (await response.json()) as TBeamVersionHistoryResponse; }; export const uploadBeamModelFile = ( file: File, options: { assetId?: string; issueId?: string; onUploadProgress?: (progressEvent: AxiosProgressEvent) => void; projectId?: string; uploadedBy?: string; version?: number; workspaceSlug?: string; } = {} ): Promise => new Promise((resolve, reject) => { const type = getBeamModelTypeFromName(file.name); if (!type) { reject(new Error("Формат модели не поддерживается BIM Viewer")); return; } const directViewerType = getBeamDirectModelTypeFromName(file.name); const apiBaseUrl = getBeamApiBaseUrl(); const uploadUrl = new URL("/api/uploads", `${apiBaseUrl}/`); uploadUrl.searchParams.set("filename", file.name); uploadUrl.searchParams.set( "projectId", getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId]) ); if (options.assetId) uploadUrl.searchParams.set("assetId", options.assetId); if (options.version) uploadUrl.searchParams.set("version", String(options.version)); const xhr = new XMLHttpRequest(); xhr.open("POST", uploadUrl.toString()); xhr.withCredentials = true; xhr.setRequestHeader("Content-Type", "application/octet-stream"); xhr.upload.addEventListener("progress", (event) => { if (!event.lengthComputable || !options.onUploadProgress) return; options.onUploadProgress({ loaded: event.loaded, progress: event.loaded / event.total, total: event.total, } as AxiosProgressEvent); }); xhr.addEventListener("error", () => reject(new Error("Не удалось загрузить модель в BIM Viewer"))); xhr.addEventListener("load", () => { if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(xhr.responseText || `BIM Viewer upload failed: HTTP ${xhr.status}`)); return; } try { const response = JSON.parse(xhr.responseText) as { assetId?: string; conversion?: TBeamViewerAttachment["conversion"]; originalFilename?: string; projectId?: string; sha256?: string; src?: string; uploadedAt?: string; version?: number; versionId?: string; }; if (!response.src) { reject(new Error("BIM Viewer не вернул путь к загруженной модели")); return; } const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`); const baseAttachment: TBeamViewerAttachment = { assetId: response.assetId ?? options.assetId, backend: "beam-viewer-local", downloadUrl: downloadUrl.toString(), originalFilename: response.originalFilename || file.name, previewAvailable: !!directViewerType, projectId: response.projectId, sha256: response.sha256, src: downloadUrl.toString(), type, uploadedBy: options.uploadedBy, uploadedAt: response.uploadedAt || new Date().toISOString(), version: response.version ?? options.version ?? 1, versionId: response.versionId, }; if (!directViewerType) { const nextAttachment: TBeamViewerAttachment = { ...baseAttachment, conversion: { ...response.conversion, componentTreeRequired: true, message: response.conversion?.message ?? "Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.", sourceFormat: type, status: response.conversion?.status ?? "conversion_required", targetFormat: response.conversion?.targetFormat ?? "xkt", }, }; resolve({ ...nextAttachment, versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })], }); return; } const nextAttachment: TBeamViewerAttachment = { ...baseAttachment, viewerUrl: buildBeamViewerUrl({ name: file.name, settingsSrc: downloadUrl.toString(), src: downloadUrl.toString(), type: directViewerType, }), }; resolve({ ...nextAttachment, versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })], }); } catch (_error) { reject(new Error("BIM Viewer вернул некорректный ответ")); } }); xhr.send(file); }); const deleteBeamStorageResource = async ( endpoint: "/api/uploads/asset" | "/api/uploads/version", payload: unknown ): Promise => { const response = await fetch(new URL(endpoint, `${getBeamApiBaseUrl()}/`).toString(), { method: "DELETE", credentials: "include", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }); if (!response.ok) { throw new Error((await response.text()) || `BIM storage delete failed: HTTP ${response.status}`); } }; export const deleteBeamModelAsset = async (beamViewer: TBeamViewerAttachment): Promise => deleteBeamStorageResource("/api/uploads/asset", beamViewer); export const deleteBeamModelVersion = async (version: TBeamModelVersionRecord): Promise => deleteBeamStorageResource("/api/uploads/version", version);