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

View File

@ -16,6 +16,8 @@ VITE_SPACE_BASE_PATH="${VITE_SPACE_BASE_PATH:-/spaces}" \
VITE_LIVE_BASE_URL="${VITE_LIVE_BASE_URL-}" \ VITE_LIVE_BASE_URL="${VITE_LIVE_BASE_URL-}" \
VITE_LIVE_BASE_PATH="${VITE_LIVE_BASE_PATH:-/live}" \ VITE_LIVE_BASE_PATH="${VITE_LIVE_BASE_PATH:-/live}" \
VITE_WEB_BASE_URL="${VITE_WEB_BASE_URL-}" \ VITE_WEB_BASE_URL="${VITE_WEB_BASE_URL-}" \
VITE_BEAM_VIEWER_BASE_URL="${VITE_BEAM_VIEWER_BASE_URL:-http://localhost:8080}" \
VITE_BEAM_API_BASE_URL="${VITE_BEAM_API_BASE_URL:-http://localhost:8080}" \
VITE_NODEDC_LAUNCHER_URL="${VITE_NODEDC_LAUNCHER_URL:-http://launcher.local.nodedc}" \ VITE_NODEDC_LAUNCHER_URL="${VITE_NODEDC_LAUNCHER_URL:-http://launcher.local.nodedc}" \
VITE_NODEDC_OIDC_LOGIN_ENABLED="${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}" \ VITE_NODEDC_OIDC_LOGIN_ENABLED="${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}" \
pnpm --filter web build pnpm --filter web build

View File

@ -109,6 +109,12 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
name = request.data.get("name") name = request.data.get("name")
type = request.data.get("type", False) type = request.data.get("type", False)
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
beam_viewer = request.data.get("beamViewer")
is_beam_viewer_reference = (
isinstance(beam_viewer, dict)
and beam_viewer.get("src")
and beam_viewer.get("downloadUrl")
)
if not type or type not in settings.ATTACHMENT_MIME_TYPES: if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response( return Response(
@ -120,6 +126,45 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(id=project_id, workspace=workspace) project = Project.objects.get(id=project_id, workspace=workspace)
if is_beam_viewer_reference:
asset = FileAsset.objects.create(
attributes={
"name": name,
"type": type,
"size": size,
"beamViewer": beam_viewer,
},
asset=f"{workspace.id}/beam-viewer/{uuid.uuid4().hex}-{name}",
size=0,
workspace_id=workspace.id,
created_by=request.user,
issue_id=issue_id,
project_id=project_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
external_source="beam-viewer",
)
serializer = IssueAttachmentSerializer(asset)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response(
{
"asset_id": str(asset.id),
"attachment": serializer.data,
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
# asset key # asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"

View File

@ -67,6 +67,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
ARG VITE_WEB_BASE_URL="" ARG VITE_WEB_BASE_URL=""
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
ARG VITE_BEAM_VIEWER_BASE_URL=""
ENV VITE_BEAM_VIEWER_BASE_URL=$VITE_BEAM_VIEWER_BASE_URL
ARG VITE_BEAM_API_BASE_URL=""
ENV VITE_BEAM_API_BASE_URL=$VITE_BEAM_API_BASE_URL
ARG VITE_NODEDC_OIDC_LOGIN_ENABLED="" ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED

View File

@ -66,6 +66,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
ARG VITE_WEB_BASE_URL="" ARG VITE_WEB_BASE_URL=""
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
ARG VITE_BEAM_VIEWER_BASE_URL=""
ENV VITE_BEAM_VIEWER_BASE_URL=$VITE_BEAM_VIEWER_BASE_URL
ARG VITE_BEAM_API_BASE_URL=""
ENV VITE_BEAM_API_BASE_URL=$VITE_BEAM_API_BASE_URL
ARG VITE_NODEDC_OIDC_LOGIN_ENABLED="" ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED

View File

@ -5,7 +5,7 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button"; import { getIconButtonStyling } from "@plane/propel/icon-button";
@ -17,11 +17,18 @@ import { EIssueServiceType } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui"; import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils"; import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
import { Download, ImageIcon, Play, X } from "lucide-react"; import { Box, Download, Eye, ImageIcon, Play, X } from "lucide-react";
// components // components
// //
import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { getFileIcon } from "@/components/icons"; import { getFileIcon } from "@/components/icons";
import {
buildBeamViewerUrl,
fetchBeamConversionStatus,
getBeamViewerAttachment,
isBeamModelFile,
type TBeamConversionStatus,
} from "@/helpers/beam-viewer";
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview"; import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
// helpers // helpers
// hooks // hooks
@ -54,6 +61,14 @@ const getPreviewType = (extension: string) => {
return "file"; return "file";
}; };
const getBeamConversionStatusLabel = (status: string | undefined): string | null => {
if (!status) return null;
if (status === "ready") return "Модель готова";
if (status === "failed") return "Ошибка дерева";
if (status === "processing") return "Собираем дерево";
return "Ожидает дерево";
};
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) { export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
const { t } = useTranslation(); const { t } = useTranslation();
// props // props
@ -73,9 +88,53 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const fileIcon = getFileIcon(fileExtension, 32); const fileIcon = getFileIcon(fileExtension, 32);
const fileURL = getFileURL(attachment?.asset_url ?? ""); const fileURL = getFileURL(attachment?.asset_url ?? "");
const previewURL = appendSearchParam(fileURL, "preview", "true"); const previewURL = appendSearchParam(fileURL, "preview", "true");
const beamViewer = getBeamViewerAttachment(attachment);
const [beamConversionStatus, setBeamConversionStatus] = useState<TBeamConversionStatus | null>(null);
const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined;
const modelViewerUrl =
beamViewer?.viewerUrl ||
(resolvedArtifactUrl
? buildBeamViewerUrl({
name: fullFileName,
src: resolvedArtifactUrl,
type: beamConversionStatus?.artifactType || "gltf",
})
: undefined);
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
const previewDownloadUrl = modelDownloadUrl || fileURL;
const beamConversionStatusLabel = getBeamConversionStatusLabel(
beamConversionStatus?.status ?? beamViewer?.conversion?.status
);
const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl;
const isBeamModel = isBeamModelFile(fullFileName);
const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isModelViewerOpen, setIsModelViewerOpen] = useState(false);
const [isThumbnailError, setIsThumbnailError] = useState(false); const [isThumbnailError, setIsThumbnailError] = useState(false);
const menuItems: TContextMenuItem[] = [ const menuItems: TContextMenuItem[] = [
...(modelViewerUrl
? [
{
key: "view-model",
action: () => {
setIsModelViewerOpen(true);
},
title: "Посмотреть модель",
icon: Eye,
},
]
: []),
...(modelDownloadUrl
? [
{
key: "download-model-original",
action: () => {
window.open(modelDownloadUrl, "_blank", "noopener,noreferrer");
},
title: "Скачать оригинал",
icon: Download,
},
]
: []),
{ {
key: "delete", key: "delete",
action: () => { action: () => {
@ -88,6 +147,34 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
// hooks // hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
useEffect(() => {
if (!beamViewer || beamViewer.viewerUrl || !beamViewer.conversion) return;
let isMounted = true;
let timeoutId: ReturnType<typeof window.setTimeout> | undefined;
const pollStatus = async () => {
try {
const status = await fetchBeamConversionStatus(beamViewer);
if (!isMounted) return;
setBeamConversionStatus(status);
if (status.status !== "ready" && status.status !== "failed") {
timeoutId = window.setTimeout(pollStatus, 5000);
}
} catch (_error) {
if (!isMounted) return;
timeoutId = window.setTimeout(pollStatus, 8000);
}
};
pollStatus();
return () => {
isMounted = false;
if (timeoutId) window.clearTimeout(timeoutId);
};
}, [beamViewer]);
if (!attachment) return <></>; if (!attachment) return <></>;
return ( return (
@ -99,7 +186,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsPreviewOpen(true); if (modelViewerUrl) setIsModelViewerOpen(true);
else setIsPreviewOpen(true);
}} }}
> >
<div className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1"> <div className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1">
@ -122,7 +210,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
<IssueAttachmentPdfThumbnail fileURL={previewURL} /> <IssueAttachmentPdfThumbnail fileURL={previewURL} />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
{previewType === "file" ? fileIcon : <ImageIcon className="size-8 text-tertiary" />} {isBeamModel ? (
<Box className="size-9 text-[rgb(var(--nodedc-accent-rgb))]" />
) : previewType === "file" ? (
fileIcon
) : (
<ImageIcon className="size-8 text-tertiary" />
)}
</div> </div>
)} )}
</div> </div>
@ -137,6 +231,11 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
<span className="size-1 rounded-full bg-layer-1" /> <span className="size-1 rounded-full bg-layer-1" />
<span>{convertBytesToSize(attachment.attributes.size)}</span> <span>{convertBytesToSize(attachment.attributes.size)}</span>
</div> </div>
{beamConversionStatusLabel && (
<div className="mt-2 w-fit whitespace-nowrap rounded-full bg-[rgba(var(--nodedc-accent-rgb),0.12)] px-2 py-0.5 text-10 font-semibold text-[rgb(var(--nodedc-accent-rgb))]">
{beamConversionStatusLabel}
</div>
)}
</div> </div>
{attachment?.created_by && ( {attachment?.created_by && (
@ -173,9 +272,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
> >
<div className="relative flex h-full min-h-0 flex-col bg-surface-1 p-4"> <div className="relative flex h-full min-h-0 flex-col bg-surface-1 p-4">
<div className="absolute top-4 right-4 z-10 flex items-center gap-2"> <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
{fileURL && ( {previewDownloadUrl && (
<a <a
href={fileURL} href={previewDownloadUrl}
download={fullFileName} download={fullFileName}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -212,6 +311,32 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
</div> </div>
) : previewType === "pdf" && previewURL ? ( ) : previewType === "pdf" && previewURL ? (
<IssueAttachmentPdfPreview fileURL={previewURL} /> <IssueAttachmentPdfPreview fileURL={previewURL} />
) : isBeamAwaitingPreview ? (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="grid size-20 place-items-center rounded-3xl bg-[rgba(var(--nodedc-accent-rgb),0.12)] text-[rgb(var(--nodedc-accent-rgb))]">
<Box className="size-10" />
</div>
<div className="max-w-md">
<div className="text-15 font-semibold text-primary">
{beamConversionStatusLabel ?? "Ожидает дерево"}
</div>
<div className="mt-2 text-14 text-secondary">
Оригинальный STEP загружен в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов.
</div>
</div>
{modelDownloadUrl && (
<a
href={modelDownloadUrl}
download={fullFileName}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] px-4 py-2 text-13 font-semibold text-[rgb(var(--nodedc-on-accent-rgb))] transition hover:brightness-110"
>
<Download className="size-4" />
Скачать оригинал
</a>
)}
</div>
) : ( ) : (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center"> <div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="grid size-20 place-items-center rounded-3xl bg-surface-2">{fileIcon}</div> <div className="grid size-20 place-items-center rounded-3xl bg-surface-2">{fileIcon}</div>
@ -223,6 +348,54 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
</div> </div>
</div> </div>
</ModalCore> </ModalCore>
<ModalCore
isOpen={isModelViewerOpen && !!modelViewerUrl}
handleClose={() => setIsModelViewerOpen(false)}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="nodedc-beam-viewer-modal h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-hidden !border-0 !bg-black !shadow-none !ring-0 !outline-none focus:!outline-none"
>
<div className="relative h-full w-full bg-black">
<div className="pointer-events-none absolute top-4 right-4 left-4 z-10 grid grid-cols-[1fr_auto_1fr] items-start gap-3">
<div aria-hidden="true" />
<div className="min-w-0 max-w-[min(60vw,36rem)] text-center text-white drop-shadow-[0_2px_10px_rgba(0,0,0,0.75)]">
<div className="truncate text-13 font-semibold">{fullFileName}</div>
<div className="mt-0.5 text-11 text-white/70">{convertBytesToSize(attachment.attributes.size)}</div>
</div>
<div className="pointer-events-auto flex items-center justify-end gap-2">
{modelDownloadUrl && (
<a
href={modelDownloadUrl}
download={fullFileName}
target="_blank"
rel="noopener noreferrer"
className="grid size-10 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] shadow-[0_14px_30px_rgba(0,0,0,0.3)] transition hover:brightness-110"
onClick={(e) => e.stopPropagation()}
>
<Download className="size-5" />
</a>
)}
<button
type="button"
className="grid size-10 place-items-center rounded-full bg-black/45 text-white backdrop-blur-md transition hover:bg-black/60"
onClick={() => setIsModelViewerOpen(false)}
>
<X className="size-5" />
</button>
</div>
</div>
{modelViewerUrl && (
<iframe
src={modelViewerUrl}
title={`Beam Viewer: ${fullFileName}`}
tabIndex={-1}
className="h-full w-full border-0 outline-none ring-0 focus:outline-none focus:ring-0"
allow="fullscreen"
/>
)}
</div>
</ModalCore>
</> </>
); );
}); });

View File

@ -8,12 +8,29 @@ import type { AxiosRequestConfig } from "axios";
import { API_BASE_URL } from "@plane/constants"; import { API_BASE_URL } from "@plane/constants";
// plane types // plane types
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services"; import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
import type { TIssueAttachment, TIssueAttachmentUploadResponse, TIssueServiceType } from "@plane/types"; import type {
TFileMetaDataLite,
TIssueAttachment,
TIssueAttachmentUploadResponse,
TIssueServiceType,
} from "@plane/types";
import { EIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types";
// services // services
import {
getBeamModelMimeType,
isBeamModelFile,
type TBeamViewerAttachment,
uploadBeamModelFile,
} from "@/helpers/beam-viewer";
import { APIService } from "@/services/api.service"; import { APIService } from "@/services/api.service";
import { FileUploadService } from "@/services/file-upload.service"; import { FileUploadService } from "@/services/file-upload.service";
type TIssueAttachmentCreateResponse = {
asset_id: string;
asset_url: string;
attachment: TIssueAttachment;
};
export class IssueAttachmentService extends APIService { export class IssueAttachmentService extends APIService {
private fileUploadService: FileUploadService; private fileUploadService: FileUploadService;
private serviceType: TIssueServiceType; private serviceType: TIssueServiceType;
@ -40,6 +57,25 @@ export class IssueAttachmentService extends APIService {
}); });
} }
private async createBeamIssueAttachmentReference(
workspaceSlug: string,
projectId: string,
issueId: string,
payload: TFileMetaDataLite & { beamViewer: TBeamViewerAttachment }
): Promise<TIssueAttachment> {
return this.post(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
payload
)
.then((response) => {
const createResponse: TIssueAttachmentCreateResponse = response?.data;
return createResponse.attachment;
})
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async uploadIssueAttachment( async uploadIssueAttachment(
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -47,6 +83,22 @@ export class IssueAttachmentService extends APIService {
file: File, file: File,
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
): Promise<TIssueAttachment> { ): Promise<TIssueAttachment> {
if (isBeamModelFile(file.name)) {
const beamViewer = await uploadBeamModelFile(file, {
issueId,
onUploadProgress: uploadProgressHandler,
projectId,
workspaceSlug,
});
return this.createBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, {
beamViewer,
name: file.name,
size: file.size,
type: getBeamModelMimeType(file),
});
}
const fileMetaData = await getFileMetaDataForUpload(file); const fileMetaData = await getFileMetaDataForUpload(file);
return this.post( return this.post(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`, `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,

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);
});

View File

@ -345,6 +345,33 @@
0 8px 22px rgba(0, 0, 0, 0.24); 0 8px 22px rgba(0, 0, 0, 0.24);
} }
.nodedc-beam-viewer-modal {
background: #000 !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
.nodedc-beam-viewer-modal::before,
.nodedc-beam-viewer-modal::after {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
}
.nodedc-beam-viewer-modal:focus,
.nodedc-beam-viewer-modal:focus-visible,
.nodedc-beam-viewer-modal:focus-within,
.nodedc-beam-viewer-modal iframe,
.nodedc-beam-viewer-modal iframe:focus,
.nodedc-beam-viewer-modal iframe:focus-visible {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
}
.nodedc-glass-surface { .nodedc-glass-surface {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(9, 9, 12, 0.88); linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(9, 9, 12, 0.88);

View File

@ -6,11 +6,32 @@
import type { TFileSignedURLResponse } from "../file"; import type { TFileSignedURLResponse } from "../file";
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 TIssueAttachment = { export type TIssueAttachment = {
id: string; id: string;
attributes: { attributes: {
beamViewer?: TBeamViewerAttachment;
name: string; name: string;
size: number; size: number;
type?: string;
[key: string]: unknown;
}; };
asset_url: string; asset_url: string;
issue_id: string; issue_id: string;