beam: integrate model attachments with Beam viewer
This commit is contained in:
parent
6e74964853
commit
3e41c451c1
|
|
@ -16,6 +16,8 @@ VITE_SPACE_BASE_PATH="${VITE_SPACE_BASE_PATH:-/spaces}" \
|
|||
VITE_LIVE_BASE_URL="${VITE_LIVE_BASE_URL-}" \
|
||||
VITE_LIVE_BASE_PATH="${VITE_LIVE_BASE_PATH:-/live}" \
|
||||
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_OIDC_LOGIN_ENABLED="${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}" \
|
||||
pnpm --filter web build
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
|||
name = request.data.get("name")
|
||||
type = request.data.get("type", False)
|
||||
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:
|
||||
return Response(
|
||||
|
|
@ -120,6 +126,45 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
|||
workspace = Workspace.objects.get(slug=slug)
|
||||
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 = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
|
|||
ARG 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=""
|
||||
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
|
|||
ARG 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=""
|
||||
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getIconButtonStyling } from "@plane/propel/icon-button";
|
||||
|
|
@ -17,11 +17,18 @@ import { EIssueServiceType } from "@plane/types";
|
|||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
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
|
||||
//
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
import {
|
||||
buildBeamViewerUrl,
|
||||
fetchBeamConversionStatus,
|
||||
getBeamViewerAttachment,
|
||||
isBeamModelFile,
|
||||
type TBeamConversionStatus,
|
||||
} from "@/helpers/beam-viewer";
|
||||
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
|
||||
// helpers
|
||||
// hooks
|
||||
|
|
@ -54,6 +61,14 @@ const getPreviewType = (extension: string) => {
|
|||
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) {
|
||||
const { t } = useTranslation();
|
||||
// props
|
||||
|
|
@ -73,9 +88,53 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
const fileIcon = getFileIcon(fileExtension, 32);
|
||||
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
||||
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 [isModelViewerOpen, setIsModelViewerOpen] = useState(false);
|
||||
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
||||
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",
|
||||
action: () => {
|
||||
|
|
@ -88,6 +147,34 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
// hooks
|
||||
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 <></>;
|
||||
|
||||
return (
|
||||
|
|
@ -99,7 +186,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
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">
|
||||
|
|
@ -122,7 +210,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
<IssueAttachmentPdfThumbnail fileURL={previewURL} />
|
||||
) : (
|
||||
<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>
|
||||
|
|
@ -137,6 +231,11 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
<span className="size-1 rounded-full bg-layer-1" />
|
||||
<span>{convertBytesToSize(attachment.attributes.size)}</span>
|
||||
</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>
|
||||
|
||||
{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="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||
{fileURL && (
|
||||
{previewDownloadUrl && (
|
||||
<a
|
||||
href={fileURL}
|
||||
href={previewDownloadUrl}
|
||||
download={fullFileName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
|
@ -212,6 +311,32 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
</div>
|
||||
) : previewType === "pdf" && 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="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>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,12 +8,29 @@ import type { AxiosRequestConfig } from "axios";
|
|||
import { API_BASE_URL } from "@plane/constants";
|
||||
// plane types
|
||||
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";
|
||||
// services
|
||||
import {
|
||||
getBeamModelMimeType,
|
||||
isBeamModelFile,
|
||||
type TBeamViewerAttachment,
|
||||
uploadBeamModelFile,
|
||||
} from "@/helpers/beam-viewer";
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { FileUploadService } from "@/services/file-upload.service";
|
||||
|
||||
type TIssueAttachmentCreateResponse = {
|
||||
asset_id: string;
|
||||
asset_url: string;
|
||||
attachment: TIssueAttachment;
|
||||
};
|
||||
|
||||
export class IssueAttachmentService extends APIService {
|
||||
private fileUploadService: FileUploadService;
|
||||
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(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
|
|
@ -47,6 +83,22 @@ export class IssueAttachmentService extends APIService {
|
|||
file: File,
|
||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
||||
): 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);
|
||||
return this.post(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -345,6 +345,33 @@
|
|||
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 {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(9, 9, 12, 0.88);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,32 @@
|
|||
|
||||
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 = {
|
||||
id: string;
|
||||
attributes: {
|
||||
beamViewer?: TBeamViewerAttachment;
|
||||
name: string;
|
||||
size: number;
|
||||
type?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
asset_url: string;
|
||||
issue_id: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue