Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx
T
lhk229 70271b408e
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m35s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m39s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m13s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Backend tests (push) Successful in 9m16s
Project CI / Repository checks (push) Successful in 8m11s
Project CI / Native shell tests (push) Successful in 11m56s
Project CI / Frontend tests (push) Successful in 11m49s
Project CI / AI game creator shell web tests (push) Successful in 6m37s
删除策划agent v2,与退役功能解耦 (#355)
Reviewed-on: #355
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-20 14:49:57 +08:00

193 lines
6.3 KiB
TypeScript
Raw 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 { useEffect, useRef, useState } from 'react';
import {
addResourceModelLights,
disposeResourceModelObject,
frameResourceModelInCamera,
loadResourceModelObject,
type ResourceModelSource,
} from './resourceModelScene';
export type ResourceModelViewerHandle = {
/** 复位视角(回到打开时的取景);供浮层上的「复位视角」按钮调用。 */
resetView: () => void;
};
type ResourceModelViewerProps = {
source: ResourceModelSource;
/** 复位视角按钮的点击计数:变化即复位,避免把 three 对象提到 React 状态里。 */
resetSignal: number;
onReady: (handle: ResourceModelViewerHandle) => void;
};
/**
* 交互式模型预览画布:视角操作与 3D 建模软件一致。
*
* - 左键拖拽:旋转(Orbit)
* - 右键 / 中键拖拽:平移(Pan)
* - 滚轮:推拉(Zoom)
*
* 与缩略图共用 `resourceModelScene` 的加载 / 取景 / 释放口径,所以「卡片上看得见、放大后
* 看不见」这类分叉不会出现。渲染器是本浮层自己的 WebGL 上下文,关闭时立即 `dispose`:
* 资源画布上十几张模型卡仍然只共用缩略图那一个上下文(见 `resourceModelThumbnail`)。
*/
export function ResourceModelViewer({
source,
resetSignal,
onReady,
}: ResourceModelViewerProps) {
const hostRef = useRef<HTMLDivElement>(null);
const resetRef = useRef<(() => void) | null>(null);
/**
* 用 ref 保存 source:调用方通常直接传对象字面量,把它放进依赖数组会让模型每次渲染
* 都重建一次 WebGL 上下文;真正的重建条件只有「换了模型」。
*/
const sourceRef = useRef(source);
sourceRef.current = source;
const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>(
'loading',
);
const [error, setError] = useState('');
useEffect(() => {
let disposed = false;
let cleanup: (() => void) | undefined;
setStatus('loading');
setError('');
const host = hostRef.current;
if (!host) {
return undefined;
}
void (async () => {
try {
const THREE = await import('three');
const { OrbitControls } = await import(
'three/examples/jsm/controls/OrbitControls.js'
);
if (disposed) {
return;
}
const width = Math.max(240, Math.round(host.clientWidth));
const height = Math.max(200, Math.round(host.clientHeight));
const modelSource = {
sourceUrl: sourceRef.current.sourceUrl,
mediaType: sourceRef.current.mediaType,
};
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(width, height, false);
renderer.domElement.className = 'game-resource-model-viewer__canvas';
host.appendChild(renderer.domElement);
const scene = new THREE.Scene();
addResourceModelLights(THREE, scene);
const camera = new THREE.PerspectiveCamera(
35,
width / height,
0.01,
10_000,
);
const controls = new OrbitControls(camera, renderer.domElement);
// 阻尼让拖动有惯性,手感与建模软件一致;它要求每帧 update,因此下面常驻 rAF。
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.screenSpacePanning = true;
controls.zoomSpeed = 0.9;
// 允许贴到模型内部看细节,但不允许转到背面时被裁剪面切掉模型。
controls.minDistance = 0.01;
controls.maxDistance = Number.POSITIVE_INFINITY;
const object = await loadResourceModelObject(modelSource);
if (disposed) {
disposeResourceModelObject(object);
renderer.dispose();
renderer.domElement.remove();
return;
}
scene.add(object);
const frame = () => {
const { center } = frameResourceModelInCamera(THREE, object, camera);
// 轨道中心 = 模型包围盒中心:缩放与旋转都绕着模型本身,和建模软件的取景一致。
controls.target.copy(center);
controls.update();
};
frame();
controls.update();
const resizeObserver = new ResizeObserver(() => {
const nextWidth = Math.max(240, Math.round(host.clientWidth));
const nextHeight = Math.max(200, Math.round(host.clientHeight));
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight, false);
});
resizeObserver.observe(host);
let frameHandle = 0;
const renderLoop = () => {
controls.update();
renderer.render(scene, camera);
frameHandle = window.requestAnimationFrame(renderLoop);
};
frameHandle = window.requestAnimationFrame(renderLoop);
resetRef.current = () => {
frame();
};
onReady({ resetView: () => resetRef.current?.() });
setStatus('ready');
cleanup = () => {
window.cancelAnimationFrame(frameHandle);
resizeObserver.disconnect();
controls.dispose();
scene.remove(object);
disposeResourceModelObject(object);
renderer.dispose();
renderer.domElement.remove();
resetRef.current = null;
};
} catch (viewerError) {
if (!disposed) {
setStatus('failed');
setError(
viewerError instanceof Error
? viewerError.message
: String(viewerError),
);
}
}
})();
return () => {
disposed = true;
cleanup?.();
};
}, [onReady, source.mediaType, source.sourceUrl]);
useEffect(() => {
if (resetSignal > 0) {
resetRef.current?.();
}
}, [resetSignal]);
return (
<div
ref={hostRef}
className="game-resource-model-viewer"
data-model-viewer-status={status}
>
{status === 'ready' ? null : (
<p className="game-resource-model-viewer__notice" role="status">
{status === 'loading'
? '正在加载模型…'
: `当前环境无法渲染三维预览${error ? `:${error}` : ''}`}
</p>
)}
</div>
);
}