Files
Genarrative/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx
T
k88936 93002f1bd2 统一 model3d 查看器状态类型命名:Model3DViewerStatus 改回 Model3dViewerStatus
- packages/model3d-viewer/src/status.ts:状态类型改名,与同文件的 Model3dViewerFailureReason / Model3dViewerStatusDetail 以及仓库既有 Model3d* 口径一致
- packages/model3d-viewer/src/Model3DViewer.tsx、packages/model3d-viewer/src/index.ts:跟随改名更新导入与引用
- src/components/image-editor/model3d-preview/Model3dViewerModal.tsx:跟随改名更新导入与状态类型引用

验证:npx tsc -p tsconfig.typecheck-guardrails.json --noEmit、npm run lint:eslint(改动文件)
2026-09-22 13:16:05 +08:00

177 lines
6.0 KiB
TypeScript

import {
Model3DViewer,
type Model3DViewerHandle,
type Model3DViewerSource,
type Model3dViewerStatus,
type Model3dViewerStatusDetail,
} from '@genarrative/model3d-viewer';
import { useRef, useState } from 'react';
import { useResolvedAssetReadUrl } from '../../../hooks/useResolvedAssetReadUrl';
import { PlatformActionButton } from '../../common/PlatformActionButton';
import { UnifiedModal } from '../../common/UnifiedModal';
import type { CanvasLayer } from '../ImageCanvasEditorTypes';
import { resolveModel3dViewerFailureText } from './Model3dViewerModel';
export type Model3dViewerModalProps = {
layer: CanvasLayer | null;
onClose: () => void;
};
/**
* 面板该显示的提示文案;返回 null 表示可以进查看器。用早返回而不是嵌套三元,
* 每个分支对应一个前置条件,读起来是清单而不是括号树。
*/
function resolveModel3dViewerHint({
hasModelSource,
isResolving,
resolvedUrl,
}: {
hasModelSource: boolean;
isResolving: boolean;
resolvedUrl: string;
}) {
if (!hasModelSource) {
return '该资源没有可预览的 3D 模型文件。';
}
if (isResolving) {
return '正在准备模型地址…';
}
if (!resolvedUrl) {
return '模型地址暂不可用,请稍后重试。';
}
return null;
}
type Model3dViewerUrlSource = Extract<Model3DViewerSource, { kind: 'url' }>;
/** 查看器来源:模型对象与它的签名地址。格式由查看器按读响应与字节判定。 */
function resolveModel3dViewerSource({
objectKey,
resolvedUrl,
}: {
objectKey: string;
resolvedUrl: string;
}): Model3dViewerUrlSource | null {
if (!objectKey || !resolvedUrl) {
return null;
}
return { kind: 'url', url: resolvedUrl };
}
const MODEL3D_VIEWER_FAILURE_FALLBACK_TEXT = '模型加载失败,请关闭后重试。';
/**
* 3D 模型预览模态:只做「取地址 + 交给查看器」,不解析模型、不产出缩略图。
*
* 模型本体只认 `objectKey`:投影后的 `imageSrc` 是预览图,拿它当模型地址会得到一个
* 图片文件。`objectKey` 缺失就直接判不可预览 —— 宁可少一次"也许能开",也不让同一份
* 字段在同一期承担两种语义。
*
* 格式不在这一层判定:查看器读模型时按「响应内容类型 → 字节魔数 → 地址扩展名」判定,
* 判不出来就报 unsupported-format,这里把失败原因翻译成用户能读的文案。
*/
export function Model3dViewerModal({
layer,
onClose,
}: Model3dViewerModalProps) {
const modelObjectKey = layer?.objectKey?.trim() ?? '';
const { resolvedUrl, isResolving } = useResolvedAssetReadUrl(modelObjectKey, {
objectKey: layer?.objectKey,
refreshKey: `${layer?.taskId ?? layer?.resourceId ?? ''}:model3d`,
});
const viewerRef = useRef<Model3DViewerHandle>(null);
const layerIdentity = layer?.id ?? null;
// 查看器状态连同"它属于哪个模型"一起存:换了模型,旧状态自动失效。
// 用 effect 在切换时重置不行 —— 它在挂载后运行,会盖掉查看器刚上报的 ready。
const [viewerStatus, setViewerStatus] = useState<{
identity: string;
status: Model3dViewerStatus;
detail: Model3dViewerStatusDetail | null;
} | null>(null);
const modelSource = resolveModel3dViewerSource({
objectKey: modelObjectKey,
resolvedUrl,
});
const viewerIdentity = modelSource
? `${layerIdentity ?? ''}:${modelSource.url}`
: null;
const canResetView =
Boolean(viewerIdentity) &&
viewerStatus?.identity === viewerIdentity &&
viewerStatus.status === 'ready';
const hintText = resolveModel3dViewerHint({
hasModelSource: Boolean(modelObjectKey),
isResolving,
resolvedUrl,
});
const errorText = resolveModel3dViewerFailureText(
viewerStatus?.identity === viewerIdentity ? viewerStatus.detail : null,
MODEL3D_VIEWER_FAILURE_FALLBACK_TEXT,
);
return (
<UnifiedModal
open={Boolean(layer)}
title="3D模型预览"
size="lg"
portalTheme="light"
closeLabel="关闭3D模型预览"
onClose={onClose}
// 面板强制浅色:`platform-modal-shell` 是层外手写规则,工具类必须带 `!` 才压得住。
panelClassName="!border-slate-200 !bg-white !text-slate-800 !shadow-[0_24px_70px_rgba(15,23,42,0.18)]"
bodyClassName="!p-0"
footer={
<PlatformActionButton
type="button"
tone="secondary"
size="sm"
disabled={!canResetView}
onClick={() => viewerRef.current?.resetView()}
>
重置视角
</PlatformActionButton>
}
>
{/* 查看器要一个非零尺寸的容器才能量出画布尺寸;画布编辑器只支持桌面端,按视口限高。 */}
<div className="flex h-[min(58vh,30rem)] min-h-56 items-center justify-center bg-slate-50">
{hintText ? (
<p
className="m-0 px-6 text-center text-sm leading-6 text-slate-500"
role="status"
>
{hintText}
</p>
) : null}
{modelSource ? (
<Model3DViewer
ref={viewerRef}
source={modelSource}
className="flex-auto self-stretch"
errorText={errorText}
// 幂等更新:同一个回调每次渲染都会换新引用,直接塞新对象会让"上报 → 重渲染
// → 再上报"循环下去;值没变时必须返回原状态对象。
onStatusChange={(status, detail) =>
setViewerStatus((current) => {
if (!viewerIdentity) {
return current;
}
if (
current?.identity === viewerIdentity &&
current.status === status &&
current.detail?.reason === detail?.reason &&
current.detail?.message === detail?.message
) {
return current;
}
return { identity: viewerIdentity, status, detail };
})
}
/>
) : null}
</div>
</UnifiedModal>
);
}