464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
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;
|
||
sha256?: string;
|
||
size: number;
|
||
src: 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;
|
||
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 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<TBeamViewerOpenEventDetail>(BEAM_VIEWER_OPEN_EVENT, { detail }));
|
||
};
|
||
|
||
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;
|
||
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.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,
|
||
sha256: beamViewer.sha256,
|
||
size: options.size ?? beamViewer.conversion?.size ?? Number(options.attachment?.attributes.size ?? 0),
|
||
src: beamViewer.src,
|
||
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))
|
||
.toSorted((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].toSorted(
|
||
(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?.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<TBeamConversionStatus> => {
|
||
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 uploadBeamModelFile = (
|
||
file: File,
|
||
options: {
|
||
assetId?: string;
|
||
issueId?: string;
|
||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
|
||
projectId?: string;
|
||
uploadedBy?: string;
|
||
version?: number;
|
||
workspaceSlug?: string;
|
||
} = {}
|
||
): Promise<TBeamViewerAttachment> =>
|
||
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;
|
||
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,
|
||
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<void> => {
|
||
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<void> =>
|
||
deleteBeamStorageResource("/api/uploads/asset", beamViewer);
|
||
|
||
export const deleteBeamModelVersion = async (version: TBeamModelVersionRecord): Promise<void> =>
|
||
deleteBeamStorageResource("/api/uploads/version", version);
|