This commit is contained in:
2026-05-11 16:15:48 +08:00
parent 0c9254502c
commit e30b733b17
87 changed files with 3527 additions and 1261 deletions

View File

@@ -0,0 +1,315 @@
import { Box, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { readAssetBytes } from '../../services/assetReadUrlService';
type ThreeModule = typeof import('three');
type GltfPayload = import('three/examples/jsm/loaders/GLTFLoader.js').GLTF;
type PreviewStatus = 'empty' | 'loading' | 'ready' | 'fallback';
type Match3DModelPreviewProps = {
modelSrc?: string | null;
className?: string;
};
function hasWebGLSupport() {
try {
const canvas = document.createElement('canvas');
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
} catch {
return false;
}
}
function disposeThreeObject(object: import('three').Object3D) {
object.traverse((child) => {
const mesh = child as import('three').Mesh;
mesh.geometry?.dispose();
const material = mesh.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
function applyCanvasLayout(canvas: HTMLCanvasElement) {
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.inset = '0';
canvas.style.position = 'absolute';
canvas.style.width = '100%';
}
function centerAndScaleModel(three: ThreeModule, model: import('three').Object3D) {
const bounds = new three.Box3().setFromObject(model);
const size = bounds.getSize(new three.Vector3());
const maxDimension = Math.max(size.x, size.y, size.z, 0.001);
const scale = 1.45 / maxDimension;
model.scale.setScalar(scale);
const centeredBounds = new three.Box3().setFromObject(model);
const center = centeredBounds.getCenter(new three.Vector3());
model.position.sub(center);
}
export function Match3DModelPreview({
modelSrc,
className = '',
}: Match3DModelPreviewProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasHostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<{
animationId: number | null;
cleanup: (() => void) | null;
renderer: import('three').WebGLRenderer;
} | null>(null);
const [status, setStatus] = useState<PreviewStatus>(
modelSrc ? 'loading' : 'empty',
);
useEffect(() => {
const container = containerRef.current;
const canvasHost = canvasHostRef.current;
if (!container || !canvasHost) {
return undefined;
}
const source = modelSrc?.trim() ?? '';
if (!source) {
setStatus('empty');
runtimeRef.current?.cleanup?.();
runtimeRef.current = null;
canvasHost.replaceChildren();
return undefined;
}
let cancelled = false;
let objectUrl: string | null = null;
const teardown = () => {
const runtime = runtimeRef.current;
if (runtime?.animationId != null) {
window.cancelAnimationFrame(runtime.animationId);
}
runtime?.cleanup?.();
runtime?.renderer.dispose();
runtime?.renderer.domElement.remove();
runtimeRef.current = null;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
canvasHost.replaceChildren();
};
const setup = async () => {
if (!hasWebGLSupport()) {
setStatus('fallback');
return;
}
setStatus('loading');
try {
const [three, loaderModule, response] = await Promise.all([
import('three'),
import('three/examples/jsm/loaders/GLTFLoader.js'),
readAssetBytes(source, { expireSeconds: 600 }),
]);
if (cancelled || !containerRef.current) {
return;
}
const bytes = await response.arrayBuffer();
if (bytes.byteLength === 0) {
throw new Error('empty model');
}
const blob = new Blob([bytes], {
type: 'model/gltf-binary',
});
objectUrl = URL.createObjectURL(blob);
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.outputColorSpace = three.SRGBColorSpace;
applyCanvasLayout(renderer.domElement);
canvasHost.appendChild(renderer.domElement);
const scene = new three.Scene();
scene.background = null;
const camera = new three.PerspectiveCamera(35, 1, 0.1, 100);
camera.position.set(0.06, 0.92, 2.8);
camera.lookAt(0, 0.1, 0);
scene.add(new three.AmbientLight(0xffffff, 1.55));
const keyLight = new three.DirectionalLight(0xffffff, 2.8);
keyLight.position.set(-3.6, 4.8, 3.5);
scene.add(keyLight);
const fillLight = new three.DirectionalLight(0xfef3c7, 0.62);
fillLight.position.set(2.5, 1.8, -3.2);
scene.add(fillLight);
const rimLight = new three.DirectionalLight(0xffffff, 0.8);
rimLight.position.set(1.8, 3.6, -4.4);
scene.add(rimLight);
const modelRoot = new three.Group();
modelRoot.rotation.set(0.2, 0.45, 0.02);
scene.add(modelRoot);
const loader = new loaderModule.GLTFLoader();
const gltf = await new Promise<GltfPayload>(
(resolve, reject) => {
loader.load(
objectUrl as string,
(loaded: GltfPayload) => resolve(loaded),
undefined,
(error) => reject(error),
);
},
);
if (cancelled) {
const cancelledModel = gltf.scene ?? gltf.scenes[0];
if (cancelledModel) {
disposeThreeObject(cancelledModel);
}
return;
}
const model = gltf.scene ?? gltf.scenes[0];
if (!model) {
throw new Error('missing model scene');
}
modelRoot.add(model);
centerAndScaleModel(three, model);
const resize = () => {
const rect = container.getBoundingClientRect();
const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
};
const resizeObserver = window.ResizeObserver
? new window.ResizeObserver(resize)
: null;
resizeObserver?.observe(container);
resize();
const pointerState = {
dragging: false,
lastX: 0,
lastY: 0,
};
const handlePointerDown = (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
pointerState.dragging = true;
pointerState.lastX = event.clientX;
pointerState.lastY = event.clientY;
if (typeof container.setPointerCapture === 'function') {
container.setPointerCapture(event.pointerId);
}
};
const handlePointerMove = (event: PointerEvent) => {
if (!pointerState.dragging) {
return;
}
const deltaX = event.clientX - pointerState.lastX;
const deltaY = event.clientY - pointerState.lastY;
pointerState.lastX = event.clientX;
pointerState.lastY = event.clientY;
modelRoot.rotation.y += deltaX * 0.01;
modelRoot.rotation.x = Math.max(
-1.15,
Math.min(1.15, modelRoot.rotation.x + deltaY * 0.01),
);
renderer.render(scene, camera);
};
const handlePointerEnd = (event: PointerEvent) => {
pointerState.dragging = false;
if (
typeof container.hasPointerCapture === 'function' &&
container.hasPointerCapture(event.pointerId)
) {
container.releasePointerCapture(event.pointerId);
}
};
container.addEventListener('pointerdown', handlePointerDown);
container.addEventListener('pointermove', handlePointerMove);
container.addEventListener('pointerup', handlePointerEnd);
container.addEventListener('pointercancel', handlePointerEnd);
const animate = () => {
renderer.render(scene, camera);
if (runtimeRef.current) {
runtimeRef.current.animationId = window.requestAnimationFrame(animate);
}
};
runtimeRef.current = {
animationId: window.requestAnimationFrame(animate),
cleanup: () => {
resizeObserver?.disconnect();
container.removeEventListener('pointerdown', handlePointerDown);
container.removeEventListener('pointermove', handlePointerMove);
container.removeEventListener('pointerup', handlePointerEnd);
container.removeEventListener('pointercancel', handlePointerEnd);
disposeThreeObject(modelRoot);
},
renderer,
};
setStatus('ready');
} catch {
if (!cancelled) {
setStatus('fallback');
}
}
};
void setup();
return () => {
cancelled = true;
teardown();
};
}, [modelSrc]);
return (
<div
ref={containerRef}
aria-label="3D 模型预览"
className={`relative overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/78 ${className}`}
data-testid="match3d-model-preview"
>
<div ref={canvasHostRef} aria-hidden="true" className="absolute inset-0" />
{status !== 'ready' ? (
<div className="absolute inset-0 grid place-items-center text-[var(--platform-text-soft)]">
{status === 'loading' ? (
<Loader2 className="h-8 w-8 animate-spin" />
) : (
<Box className="h-8 w-8" />
)}
</div>
) : null}
<div className="pointer-events-none absolute inset-0 z-10" />
</div>
);
}

View File

@@ -21,14 +21,14 @@ vi.mock('../ResolvedAssetImage', () => ({
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src?.startsWith('/generated-')
? `https://signed.example.com${src}`
: (src ?? ''),
isResolving: false,
shouldResolve: Boolean(src?.startsWith('/generated-')),
}),
vi.mock('./Match3DModelPreview', () => ({
Match3DModelPreview: ({
modelSrc,
}: {
modelSrc?: string | null;
}) => (
<div data-model-src={modelSrc ?? ''} data-testid="match3d-model-preview" />
),
}));
vi.mock('../../services/assetReadUrlService', () => ({
@@ -45,6 +45,7 @@ vi.mock('../../services/assetReadUrlService', () => ({
}));
vi.mock('../../services/match3d-works', () => ({
generateMatch3DWorkTags: vi.fn(),
publishMatch3DWork: vi.fn(),
updateMatch3DWork: vi.fn(),
}));
@@ -53,7 +54,6 @@ vi.mock('../../services/hyper3dModelGenerationService', () => ({
getHyper3dDownloads: vi.fn(),
getHyper3dTaskStatus: vi.fn(),
submitHyper3dImageToModel: vi.fn(),
submitHyper3dTextToModel: vi.fn(),
}));
afterEach(() => {
@@ -71,8 +71,8 @@ function createProfile(
sourceSessionId: 'match3d-session-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '水果主题的经典消除玩法。',
tags: ['水果'],
summary: '',
tags: ['水果', '抓大鹅', '经典消除'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 4,
@@ -87,6 +87,68 @@ function createProfile(
}
describe('Match3DResultView', () => {
test('作品信息 Tab 字段命名对齐拼图草稿且描述可为空', async () => {
const profile = createProfile();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: profile,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.getByLabelText('作品名称')).toHaveProperty(
'value',
'水果抓大鹅',
);
expect(screen.getByLabelText('作品描述')).toHaveProperty('value', '');
expect(screen.getByText('作品标签')).toBeTruthy();
expect(screen.getByText('水果')).toBeTruthy();
expect(screen.getByText('抓大鹅')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
gameName: '水果抓大鹅',
summary: '',
tags: ['水果', '抓大鹅', '经典消除'],
}),
);
});
});
test('作品标签支持 AI 生成并写回标签编辑区', async () => {
vi.mocked(match3dWorksService.generateMatch3DWorkTags).mockResolvedValue({
tags: ['果园', '抓大鹅', '经典消除', '轻量休闲'],
});
render(
<Match3DResultView
profile={createProfile({ tags: [] })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
await waitFor(() => {
expect(match3dWorksService.generateMatch3DWorkTags).toHaveBeenCalledWith({
gameName: '水果抓大鹅',
themeText: '水果',
});
expect(screen.getByText('果园')).toBeTruthy();
expect(screen.getByText('轻量休闲')).toBeTruthy();
});
});
test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => {
const profile = createProfile();
const onStartTestRun = vi.fn();
@@ -121,7 +183,7 @@ describe('Match3DResultView', () => {
test('发布仍要求封面和标签数量满足门槛', () => {
render(
<Match3DResultView
profile={createProfile()}
profile={createProfile({ tags: ['水果', '抓大鹅'] })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
@@ -150,22 +212,15 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByText('素材名称')).toBeTruthy();
expect(screen.getByRole('button', { name: '文生模型' })).toBeTruthy();
expect(screen.getByRole('button', { name: '图生模型' })).toBeTruthy();
expect(screen.getByTestId('match3d-model-preview')).toBeTruthy();
expect(screen.getByRole('button', { name: '重新生成' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '文生模型' })).toBeNull();
expect(screen.queryByRole('button', { name: '图生模型' })).toBeNull();
expect(screen.queryByText('用途')).toBeNull();
expect(screen.queryByText('提示词')).toBeNull();
});
test('Rodin 文生模型提交使用 Hyper3D 代理', async () => {
vi.mocked(hyper3dService.submitHyper3dTextToModel).mockResolvedValue({
ok: true,
provider: 'hyper3d-rodin',
mode: 'text-to-model',
taskUuid: 'task-1',
subscriptionKey: 'sub-1',
jobUuids: ['job-1'],
message: 'submitted',
tier: 'Gen-2',
});
test('重新生成缺少参考图时阻止提交', async () => {
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
@@ -176,42 +231,15 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(hyper3dService.submitHyper3dTextToModel).toHaveBeenCalledWith(
expect.objectContaining({
geometryFileFormat: 'glb',
material: 'PBR',
meshMode: 'Quad',
prompt: expect.stringContaining('水果核心物件'),
}),
);
});
await waitFor(() => {
expect(screen.getAllByText('排队中').length).toBeGreaterThan(0);
});
});
test('Rodin 图生模型没有参考图时阻止提交', async () => {
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '图生模型' }));
const generateButton = screen.getByRole('button', { name: '生成' });
const generateButton = screen.getByRole('button', { name: '重新生成' });
expect(generateButton).toHaveProperty('disabled', true);
expect(hyper3dService.submitHyper3dImageToModel).not.toHaveBeenCalled();
});
test('结果页优先预览生成出来的物品图片和模型文件', () => {
test('结果页优先生成出来的模型文件交给模型预览', () => {
const modelSrc =
'/generated-match3d-assets/session/profile/items/strawberry/model/model.glb';
render(
<Match3DResultView
profile={createProfile({
@@ -223,7 +251,7 @@ describe('Match3DResultView', () => {
imageSrc: '/generated-match3d-assets/session/profile/items/strawberry/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/image.png',
modelSrc: '/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
modelSrc,
modelObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
modelFileName: 'strawberry.glb',
@@ -244,13 +272,12 @@ describe('Match3DResultView', () => {
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('已完成').length).toBeGreaterThan(0);
const modelLink = screen.getByRole('link', { name: /strawberry\.glb/u });
expect(modelLink.getAttribute('href')).toBe(
'https://signed.example.com/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
expect(screen.getByTestId('match3d-model-preview').getAttribute('data-model-src')).toBe(
modelSrc,
);
});
test('草稿阶段仅有切割图片时展示图片已就绪,不要求模型文件', () => {
test('草稿阶段仅有切割图片时模型预览为空', () => {
render(
<Match3DResultView
profile={createProfile({
@@ -282,7 +309,7 @@ describe('Match3DResultView', () => {
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('图片已就绪').length).toBeGreaterThan(0);
expect(screen.getByText('0 文件')).toBeTruthy();
expect(screen.getByTestId('match3d-model-preview').getAttribute('data-model-src')).toBe('');
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
});
@@ -348,6 +375,24 @@ describe('Match3DResultView', () => {
message: 'submitted',
tier: 'Gen-2',
});
vi.mocked(hyper3dService.getHyper3dTaskStatus).mockResolvedValue({
ok: true,
provider: 'hyper3d-rodin',
status: 'done',
jobs: [],
raw: {},
});
vi.mocked(hyper3dService.getHyper3dDownloads).mockResolvedValue({
ok: true,
provider: 'hyper3d-rodin',
files: [
{
name: 'strawberry.glb',
url: 'https://cdn.example.com/strawberry.glb',
},
],
raw: {},
});
vi.stubGlobal('fetch', vi.fn());
render(
@@ -378,7 +423,7 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
fireEvent.click(screen.getByRole('button', { name: '重新生成' }));
await waitFor(() => {
expect(assetReadUrlService.readAssetBytes).toHaveBeenCalledWith(
@@ -392,6 +437,9 @@ describe('Match3DResultView', () => {
prompt: expect.stringContaining('草莓'),
}),
);
expect(hyper3dService.getHyper3dDownloads).toHaveBeenCalledWith({
taskUuid: 'task-image',
});
});
});
});

File diff suppressed because it is too large Load Diff