NODEDC_TASKMANAGER/plane-src/apps/web/helpers/beam-viewer.ts

250 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { AxiosProgressEvent } from "axios";
import type { TIssueAttachment } from "@plane/types";
export type TBeamViewerAttachment = {
backend: "beam-viewer-local" | "beam-viewer";
conversion?: {
artifactSrc?: string;
artifactType?: string;
componentTreeRequired: boolean;
message?: string;
metadataSrc?: string;
sourceFormat: string;
sourceSrc?: string;
status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat: "glb" | "xkt";
updatedAt?: string;
};
downloadUrl: string;
originalFilename: string;
previewAvailable: boolean;
src: string;
type: string;
uploadedAt: string;
viewerUrl?: string;
};
export type TBeamConversionStatus = {
artifactSrc?: string;
artifactType?: string;
artifactUrl?: string;
componentCount?: number;
componentTreeRequired?: boolean;
error?: string;
message?: string;
metadataSrc?: string;
metadataUrl?: string;
sourceFormat?: string;
sourceSrc?: string;
status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat?: "glb" | "xkt";
updatedAt?: string;
};
const DEFAULT_BEAM_VIEWER_BASE_URL = "http://localhost:8080";
const BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
bim: "bim",
glb: "gltf",
gltf: "gltf",
las: "las",
laz: "las",
obj: "obj",
stl: "stl",
xkt: "xkt",
};
const BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
step: "step",
stp: "step",
};
const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
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 | undefined>): 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; 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);
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;
};
export const fetchBeamConversionStatus = async (
beamViewer: TBeamViewerAttachment
): Promise<TBeamConversionStatus> => {
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
const response = await fetch(statusUrl.toString());
if (!response.ok) {
throw new Error(`Beam 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 uploadBeamModelFile = (
file: File,
options: {
issueId?: string;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
projectId?: string;
workspaceSlug?: string;
} = {}
): Promise<TBeamViewerAttachment> =>
new Promise((resolve, reject) => {
const type = getBeamModelTypeFromName(file.name);
if (!type) {
reject(new Error("Формат модели не поддерживается Beam 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]));
const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl.toString());
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable || !options.onUploadProgress) return;
options.onUploadProgress({
loaded: event.loaded,
progress: event.loaded / event.total,
total: event.total,
} as AxiosProgressEvent);
};
xhr.onerror = () => reject(new Error("Не удалось загрузить модель в Beam Viewer"));
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(xhr.responseText || `Beam Viewer upload failed: HTTP ${xhr.status}`));
return;
}
try {
const response = JSON.parse(xhr.responseText) as {
conversion?: TBeamViewerAttachment["conversion"];
src?: string;
};
if (!response.src) {
reject(new Error("Beam Viewer не вернул путь к загруженной модели"));
return;
}
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
const baseAttachment: TBeamViewerAttachment = {
backend: "beam-viewer-local",
downloadUrl: downloadUrl.toString(),
originalFilename: file.name,
previewAvailable: !!directViewerType,
src: downloadUrl.toString(),
type,
uploadedAt: new Date().toISOString(),
};
if (!directViewerType) {
resolve({
...baseAttachment,
conversion: {
...(response.conversion ?? {}),
componentTreeRequired: true,
message:
response.conversion?.message ??
"Оригинальный STEP загружен. Preview появится после подготовки GLB и дерева компонентов.",
sourceFormat: type,
status: response.conversion?.status ?? "conversion_required",
targetFormat: response.conversion?.targetFormat ?? "glb",
},
});
return;
}
resolve({
...baseAttachment,
viewerUrl: buildBeamViewerUrl({
name: file.name,
src: downloadUrl.toString(),
type: directViewerType,
}),
});
} catch (_error) {
reject(new Error("Beam Viewer вернул некорректный ответ"));
}
};
xhr.send(file);
});