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

525 lines
18 KiB
TypeScript
Raw Permalink 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 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<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 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<string, TBeamModelVersionRecord>();
[...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<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 fetchBeamModelVersions = async (
beamViewer: TBeamViewerAttachment
): Promise<TBeamVersionHistoryResponse> => {
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<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;
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<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);