beam: integrate model attachments with Beam viewer

This commit is contained in:
DCCONSTRUCTIONS
2026-06-04 19:39:36 +03:00
parent 6e74964853
commit 3e41c451c1
9 changed files with 583 additions and 7 deletions
+244
View File
@@ -0,0 +1,244 @@
import type { AxiosProgressEvent } from "axios";
import type { TIssueAttachment } from "@plane/types";
export type TBeamViewerAttachment = {
backend: "beam-viewer-local" | "beam-viewer";
conversion?: {
componentTreeRequired: boolean;
message?: string;
sourceFormat: string;
status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat: "glb" | "xkt";
};
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);
});