新增 3D 模型查看器独立包
- 新增 packages/model3d-viewer(@genarrative/model3d-viewer),含 Model3DViewer、scene、loader、framing、status 与 index - 组件只走 WebGL2 单一路径渲染 glb、单文件 gltf 与 fbx,未承诺格式或超过 32 MiB 时只渲染一行状态文案 - 取景按真实顶点做投影拟合,与模型尺度无关,复位回到打开时的取景 - 新增 framing 与 loader 的纯函数单测 - 根 package.json 的 workspaces 增加 packages/model3d-viewer - scripts/check-npm-workspaces.mjs 与 scripts/check-npm-workspaces.test.mjs 同步工作区清单 - vitest.config.ts 增加新包测试匹配 - 同步 package-lock.json 的工作区链接
This commit is contained in:
Generated
+67
-77
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"packages/image-canvas-core",
|
||||
"packages/image-canvas-react",
|
||||
"packages/agc-plugin-sdk",
|
||||
"packages/model3d-viewer",
|
||||
"packages/shared",
|
||||
"plugins/agc-cocos-editor",
|
||||
"tools/spine-json-export-validator"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@genarrative/model3d-viewer",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/three": "^0.184.1",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
describeModel3dViewerError,
|
||||
Model3dViewerAbortedError,
|
||||
Model3dViewerLoadError,
|
||||
type Model3DViewerSource,
|
||||
} from './loader';
|
||||
import { createModel3dViewerScene, type Model3dViewerScene } from './scene';
|
||||
import {
|
||||
MODEL3D_VIEWER_LOADING_TEXT,
|
||||
MODEL3D_VIEWER_STATUS_ATTRIBUTE,
|
||||
type Model3DViewerStatus,
|
||||
type Model3dViewerStatusDetail,
|
||||
resolveModel3dViewerErrorText,
|
||||
} from './status';
|
||||
|
||||
export type Model3DViewerHandle = {
|
||||
/** 回到打开模型时的归一化取景。 */
|
||||
resetView(): void;
|
||||
};
|
||||
|
||||
export type Model3DViewerProps = {
|
||||
/** 模型来源:宿主已解析好的签名地址,或已读入的字节。 */
|
||||
source: Model3DViewerSource;
|
||||
autoRotate?: boolean;
|
||||
/** 失败时那一行状态文案;不传用内置文案。 */
|
||||
errorText?: string;
|
||||
onStatusChange?: (
|
||||
status: Model3DViewerStatus,
|
||||
detail: Model3dViewerStatusDetail,
|
||||
) => void;
|
||||
className?: string;
|
||||
ref?: Ref<Model3DViewerHandle>;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebGL2 模型查看器:只负责加载与交互查看,不取业务数据、不产出缩略图。
|
||||
* 宿主要给容器一个非零尺寸,并在祖先上带 platform-theme 主题类才能取到平台色值。
|
||||
*/
|
||||
export function Model3DViewer({
|
||||
source,
|
||||
autoRotate = false,
|
||||
errorText,
|
||||
onStatusChange,
|
||||
className,
|
||||
ref,
|
||||
}: Model3DViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const sceneRef = useRef<Model3dViewerScene | null>(null);
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
const autoRotateRef = useRef(autoRotate);
|
||||
const [status, setStatus] = useState<Model3DViewerStatus>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
}, [onStatusChange]);
|
||||
|
||||
const reportStatus = useCallback(
|
||||
(next: Model3DViewerStatus, detail: Model3dViewerStatusDetail) => {
|
||||
setStatus(next);
|
||||
onStatusChangeRef.current?.(next, detail);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
resetView() {
|
||||
sceneRef.current?.resetView();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const sourceKind = source.kind;
|
||||
const sourceFormat = source.format;
|
||||
const sourceUrl = source.kind === 'url' ? source.url : null;
|
||||
const sourceData = source.kind === 'bytes' ? source.data : null;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
let createdScene: Model3dViewerScene | null = null;
|
||||
const activeSource: Model3DViewerSource = sourceData
|
||||
? { kind: 'bytes', data: sourceData, format: sourceFormat }
|
||||
: { kind: 'url', url: sourceUrl ?? '', format: sourceFormat };
|
||||
|
||||
reportStatus('loading', {});
|
||||
|
||||
createModel3dViewerScene({
|
||||
canvas,
|
||||
container,
|
||||
source: activeSource,
|
||||
autoRotate: autoRotateRef.current,
|
||||
isAborted: () => aborted,
|
||||
})
|
||||
.then((scene) => {
|
||||
if (aborted) {
|
||||
scene.dispose();
|
||||
return;
|
||||
}
|
||||
createdScene = scene;
|
||||
sceneRef.current = scene;
|
||||
reportStatus('ready', {});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (aborted || error instanceof Model3dViewerAbortedError) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof Model3dViewerLoadError) {
|
||||
reportStatus('failed', {
|
||||
reason: error.reason,
|
||||
message: error.message,
|
||||
byteLength: error.byteLength,
|
||||
});
|
||||
return;
|
||||
}
|
||||
reportStatus('failed', {
|
||||
reason: 'load-failed',
|
||||
message: describeModel3dViewerError(error),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
createdScene?.dispose();
|
||||
if (sceneRef.current === createdScene) {
|
||||
sceneRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sourceKind, sourceFormat, sourceUrl, sourceData, reportStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
autoRotateRef.current = autoRotate;
|
||||
sceneRef.current?.setAutoRotate(autoRotate);
|
||||
}, [autoRotate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={['relative h-full w-full overflow-hidden', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...{ [MODEL3D_VIEWER_STATUS_ATTRIBUTE]: status }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={[
|
||||
'absolute inset-0 block h-full w-full',
|
||||
status === 'ready' ? 'opacity-100' : 'opacity-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
{status === 'loading' ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-xs text-[var(--platform-text-soft)]">
|
||||
{MODEL3D_VIEWER_LOADING_TEXT}
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'failed' ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-rose-700">
|
||||
{resolveModel3dViewerErrorText(errorText)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
computeModel3dViewerBoxCenter,
|
||||
computeModel3dViewerBoxRadius,
|
||||
computeModel3dViewerFitDistance,
|
||||
computeModel3dViewerFraming,
|
||||
createModel3dViewerFitSolver,
|
||||
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
|
||||
MODEL3D_VIEWER_FIT_FRACTION,
|
||||
type Model3dViewerBox,
|
||||
} from './framing';
|
||||
|
||||
type Point = [number, number, number];
|
||||
|
||||
function box(min: Point, max: Point): Model3dViewerBox {
|
||||
return {
|
||||
min: { x: min[0], y: min[1], z: min[2] },
|
||||
max: { x: max[0], y: max[1], z: max[2] },
|
||||
};
|
||||
}
|
||||
|
||||
function scaleBox(source: Model3dViewerBox, factor: number): Model3dViewerBox {
|
||||
return {
|
||||
min: {
|
||||
x: source.min.x * factor,
|
||||
y: source.min.y * factor,
|
||||
z: source.min.z * factor,
|
||||
},
|
||||
max: {
|
||||
x: source.max.x * factor,
|
||||
y: source.max.y * factor,
|
||||
z: source.max.z * factor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function solveForPoints(
|
||||
source: Model3dViewerBox,
|
||||
points: Point[],
|
||||
aspect: number,
|
||||
) {
|
||||
const solver = createModel3dViewerFitSolver({
|
||||
center: computeModel3dViewerBoxCenter(source),
|
||||
aspect,
|
||||
fallbackRadius: computeModel3dViewerBoxRadius(source),
|
||||
});
|
||||
for (const [x, y, z] of points) {
|
||||
solver.add(x, y, z);
|
||||
}
|
||||
return solver.resolve();
|
||||
}
|
||||
|
||||
/** 旧口径的包围球拟合距离,作为「不该退回到这么远」的对照基线。 */
|
||||
function sphereBaselineDistance(source: Model3dViewerBox): number {
|
||||
const halfFovY = (MODEL3D_VIEWER_CAMERA_FOV_DEGREES * Math.PI) / 360;
|
||||
return (
|
||||
computeModel3dViewerBoxRadius(source) /
|
||||
(MODEL3D_VIEWER_FIT_FRACTION * Math.tan(halfFovY))
|
||||
);
|
||||
}
|
||||
|
||||
const CUBE = box([-1, -1, -1], [1, 1, 1]);
|
||||
const WINGS = box([-4, -0.4, -0.4], [4, 0.4, 0.4]);
|
||||
const CUBE_CORNERS: Point[] = [
|
||||
[-1, -1, -1],
|
||||
[-1, -1, 1],
|
||||
[-1, 1, -1],
|
||||
[-1, 1, 1],
|
||||
[1, -1, -1],
|
||||
[1, -1, 1],
|
||||
[1, 1, -1],
|
||||
[1, 1, 1],
|
||||
];
|
||||
|
||||
describe('computeModel3dViewerFitDistance', () => {
|
||||
it('与模型尺度成正比,任意尺度都占同样的视野比例', () => {
|
||||
const small = computeModel3dViewerFitDistance({ box: CUBE, aspect: 1.5 });
|
||||
const large = computeModel3dViewerFitDistance({
|
||||
box: scaleBox(CUBE, 10000),
|
||||
aspect: 1.5,
|
||||
});
|
||||
|
||||
expect(small).toBeGreaterThan(0);
|
||||
expect(large / small).toBeCloseTo(10000, 3);
|
||||
});
|
||||
|
||||
it('按包围盒投影取景,明显近于按包围球取景', () => {
|
||||
for (const source of [CUBE, WINGS]) {
|
||||
const boxFit = computeModel3dViewerFitDistance({
|
||||
box: source,
|
||||
aspect: 2,
|
||||
});
|
||||
expect(boxFit).toBeLessThan(sphereBaselineDistance(source) * 0.9);
|
||||
}
|
||||
});
|
||||
|
||||
it('竖屏时相机更远,避免横向出框', () => {
|
||||
const landscape = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 });
|
||||
const portrait = computeModel3dViewerFitDistance({
|
||||
box: CUBE,
|
||||
aspect: 0.5,
|
||||
});
|
||||
|
||||
expect(portrait).toBeGreaterThan(landscape);
|
||||
});
|
||||
|
||||
it('退化或非法包围盒回落到可用的默认取景', () => {
|
||||
const degenerate = [
|
||||
box([0, 0, 0], [0, 0, 0]),
|
||||
box([Number.NaN, 0, 0], [1, 1, 1]),
|
||||
box([Number.POSITIVE_INFINITY, 0, 0], [Number.NEGATIVE_INFINITY, 1, 1]),
|
||||
];
|
||||
|
||||
for (const candidate of degenerate) {
|
||||
const distance = computeModel3dViewerFitDistance({
|
||||
box: candidate,
|
||||
aspect: 1,
|
||||
});
|
||||
expect(Number.isFinite(distance)).toBe(true);
|
||||
expect(distance).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('fitFraction 越小相机越远', () => {
|
||||
const near = computeModel3dViewerFitDistance({ box: CUBE, aspect: 1 });
|
||||
const far = computeModel3dViewerFitDistance({
|
||||
box: CUBE,
|
||||
aspect: 1,
|
||||
fitFraction: 0.31,
|
||||
});
|
||||
|
||||
expect(far).toBeGreaterThan(near);
|
||||
});
|
||||
|
||||
it('传入已算好的距离时直接采用,不再按角点重算', () => {
|
||||
const distance = computeModel3dViewerFitDistance({
|
||||
box: CUBE,
|
||||
aspect: 1,
|
||||
distance: 7,
|
||||
});
|
||||
|
||||
expect(distance).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createModel3dViewerFitSolver', () => {
|
||||
it('喂包围盒角点时与包围盒口径一致', () => {
|
||||
const expected = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 });
|
||||
|
||||
expect(solveForPoints(CUBE, CUBE_CORNERS, 2)).toBeCloseTo(expected, 6);
|
||||
});
|
||||
|
||||
it('喂真实顶点时避开包围盒空角,取景更近', () => {
|
||||
// 斜向细杆:包围盒是 -1..1 的立方体,但 6 个角点都是空的。
|
||||
const rod: Point[] = [
|
||||
[-1, -1, -1],
|
||||
[1, 1, 1],
|
||||
];
|
||||
const boxFit = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 });
|
||||
const vertexFit = solveForPoints(CUBE, rod, 2);
|
||||
|
||||
expect(vertexFit).toBeLessThan(boxFit * 0.9);
|
||||
});
|
||||
|
||||
it('没有任何顶点时回落到与尺度相关的可用距离', () => {
|
||||
const fit = solveForPoints(CUBE, [], 1);
|
||||
|
||||
expect(Number.isFinite(fit)).toBe(true);
|
||||
expect(fit).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeModel3dViewerFraming', () => {
|
||||
it('推导出合法的裁剪面与缩放范围', () => {
|
||||
const framing = computeModel3dViewerFraming({
|
||||
box: box([-1, -1, -1], [3, 5, 7]),
|
||||
aspect: 1.6,
|
||||
});
|
||||
|
||||
expect(framing.center).toEqual({ x: 1, y: 2, z: 3 });
|
||||
expect(framing.near).toBeGreaterThan(0);
|
||||
expect(framing.near).toBeLessThan(framing.far);
|
||||
expect(framing.minDistance).toBeLessThan(framing.distance);
|
||||
expect(framing.maxDistance).toBeGreaterThan(framing.distance);
|
||||
});
|
||||
|
||||
it('复位方向是单位向量且带正向仰角', () => {
|
||||
const { resetDirection } = computeModel3dViewerFraming({
|
||||
box: CUBE,
|
||||
aspect: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
Math.hypot(resetDirection.x, resetDirection.y, resetDirection.z),
|
||||
).toBeCloseTo(1, 6);
|
||||
expect(resetDirection.y).toBeGreaterThan(0);
|
||||
expect(resetDirection.z).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeModel3dViewerBoxCenter', () => {
|
||||
it('非法包围盒回落到原点,避免相机拿到 NaN', () => {
|
||||
expect(
|
||||
computeModel3dViewerBoxCenter({
|
||||
min: { x: Number.POSITIVE_INFINITY, y: 0, z: 0 },
|
||||
max: { x: Number.NEGATIVE_INFINITY, y: 0, z: 0 },
|
||||
}),
|
||||
).toEqual({ x: 0, y: 0, z: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
export const MODEL3D_VIEWER_CAMERA_FOV_DEGREES = 45;
|
||||
|
||||
/**
|
||||
* 顶点在视野较短一边上占据的比例:与模型真实尺度无关的归一化取景。
|
||||
* 取 0.8 是因为包围盒对角线方向与屏幕上下轴并不重合,实际观感约为六成。
|
||||
*/
|
||||
export const MODEL3D_VIEWER_FIT_FRACTION = 0.8;
|
||||
|
||||
const FALLBACK_RADIUS = 1;
|
||||
const FALLBACK_ASPECT = 1;
|
||||
const NEAR_RADIUS_RATIO = 0.01;
|
||||
const FAR_RADIUS_RATIO = 12;
|
||||
const MIN_DISTANCE_RATIO = 0.2;
|
||||
const MAX_DISTANCE_RATIO = 8;
|
||||
/** 复位视角仰角:过度抬高会把「上轴投影范围」放大,导致模型看起来比目标比例小。 */
|
||||
const RESET_ELEVATION = 0.22;
|
||||
const FIT_SAFETY = 1.02;
|
||||
|
||||
export type Model3dViewerVector3 = {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
|
||||
export type Model3dViewerBox = {
|
||||
min: Model3dViewerVector3;
|
||||
max: Model3dViewerVector3;
|
||||
};
|
||||
|
||||
export type Model3dViewerFramingInput = {
|
||||
box: Model3dViewerBox;
|
||||
aspect: number;
|
||||
direction?: Model3dViewerVector3;
|
||||
fovDegrees?: number;
|
||||
fitFraction?: number;
|
||||
/** 已按实际顶点算好的取景距离;缺省时退回包围盒 8 个角点。 */
|
||||
distance?: number;
|
||||
};
|
||||
|
||||
export type Model3dViewerFitSolverInput = {
|
||||
center: Model3dViewerVector3;
|
||||
aspect: number;
|
||||
direction?: Model3dViewerVector3;
|
||||
fovDegrees?: number;
|
||||
fitFraction?: number;
|
||||
fallbackRadius?: number;
|
||||
};
|
||||
|
||||
export type Model3dViewerFitSolver = {
|
||||
add(x: number, y: number, z: number): void;
|
||||
resolve(): number;
|
||||
};
|
||||
|
||||
export type Model3dViewerFraming = {
|
||||
center: Model3dViewerVector3;
|
||||
radius: number;
|
||||
distance: number;
|
||||
near: number;
|
||||
far: number;
|
||||
minDistance: number;
|
||||
maxDistance: number;
|
||||
/** 复位视角时的相机方向(已归一化,带少量仰角)。 */
|
||||
resetDirection: Model3dViewerVector3;
|
||||
};
|
||||
|
||||
function normalizePositive(value: number, fallback: number): number {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function subtract(
|
||||
left: Model3dViewerVector3,
|
||||
right: Model3dViewerVector3,
|
||||
): Model3dViewerVector3 {
|
||||
return {
|
||||
x: left.x - right.x,
|
||||
y: left.y - right.y,
|
||||
z: left.z - right.z,
|
||||
};
|
||||
}
|
||||
|
||||
function dot(left: Model3dViewerVector3, right: Model3dViewerVector3): number {
|
||||
return left.x * right.x + left.y * right.y + left.z * right.z;
|
||||
}
|
||||
|
||||
function cross(
|
||||
left: Model3dViewerVector3,
|
||||
right: Model3dViewerVector3,
|
||||
): Model3dViewerVector3 {
|
||||
return {
|
||||
x: left.y * right.z - left.z * right.y,
|
||||
y: left.z * right.x - left.x * right.z,
|
||||
z: left.x * right.y - left.y * right.x,
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(
|
||||
value: Model3dViewerVector3,
|
||||
fallback: Model3dViewerVector3,
|
||||
): Model3dViewerVector3 {
|
||||
const length = Math.hypot(value.x, value.y, value.z);
|
||||
if (!Number.isFinite(length) || length <= Number.EPSILON) {
|
||||
return fallback;
|
||||
}
|
||||
return { x: value.x / length, y: value.y / length, z: value.z / length };
|
||||
}
|
||||
|
||||
/** 复位视角的默认方向:从正前方略微抬高,避免正投影视角。 */
|
||||
export function resolveModel3dViewerResetDirection(
|
||||
direction?: Model3dViewerVector3,
|
||||
): Model3dViewerVector3 {
|
||||
if (direction) {
|
||||
return normalize(direction, { x: 0, y: 0, z: 1 });
|
||||
}
|
||||
return normalize({ x: 0, y: RESET_ELEVATION, z: 1 }, { x: 0, y: 0, z: 1 });
|
||||
}
|
||||
|
||||
export function computeModel3dViewerBoxCenter(
|
||||
box: Model3dViewerBox,
|
||||
): Model3dViewerVector3 {
|
||||
const center = {
|
||||
x: (box.min.x + box.max.x) / 2,
|
||||
y: (box.min.y + box.max.y) / 2,
|
||||
z: (box.min.z + box.max.z) / 2,
|
||||
};
|
||||
const valid =
|
||||
Number.isFinite(center.x) &&
|
||||
Number.isFinite(center.y) &&
|
||||
Number.isFinite(center.z);
|
||||
return valid ? center : { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
|
||||
export function computeModel3dViewerBoxRadius(box: Model3dViewerBox): number {
|
||||
return (
|
||||
Math.hypot(
|
||||
box.max.x - box.min.x,
|
||||
box.max.y - box.min.y,
|
||||
box.max.z - box.min.z,
|
||||
) / 2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取景求解器:把每个采样点投影到相机右轴与上轴,取满足「点落在 fitFraction
|
||||
* 视野内」的最小距离。逐点求解而不是用包围球,因为包围球会把「翅膀斜向展开」
|
||||
* 这类形状的取景距离放大一倍以上;用真实顶点又可以避开包围盒空角。
|
||||
*/
|
||||
export function createModel3dViewerFitSolver(
|
||||
input: Model3dViewerFitSolverInput,
|
||||
): Model3dViewerFitSolver {
|
||||
const aspect = normalizePositive(input.aspect, FALLBACK_ASPECT);
|
||||
const fovDegrees = normalizePositive(
|
||||
input.fovDegrees ?? MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
|
||||
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
|
||||
);
|
||||
const fitFraction = normalizePositive(
|
||||
input.fitFraction ?? MODEL3D_VIEWER_FIT_FRACTION,
|
||||
MODEL3D_VIEWER_FIT_FRACTION,
|
||||
);
|
||||
const center = input.center;
|
||||
const fallbackRadius = normalizePositive(
|
||||
input.fallbackRadius ?? FALLBACK_RADIUS,
|
||||
FALLBACK_RADIUS,
|
||||
);
|
||||
const direction = resolveModel3dViewerResetDirection(input.direction);
|
||||
const forward = { x: -direction.x, y: -direction.y, z: -direction.z };
|
||||
const right = normalize(cross(forward, { x: 0, y: 1, z: 0 }), {
|
||||
x: 1,
|
||||
y: 0,
|
||||
z: 0,
|
||||
});
|
||||
const up = cross(right, forward);
|
||||
|
||||
const halfFovY = (fovDegrees * Math.PI) / 360;
|
||||
const limitY = fitFraction * Math.tan(halfFovY);
|
||||
const limitX = limitY * aspect;
|
||||
|
||||
let required = 0;
|
||||
const fallbackDistance = fallbackRadius / (fitFraction * Math.tan(halfFovY));
|
||||
|
||||
return {
|
||||
add(x: number, y: number, z: number) {
|
||||
const corner = subtract({ x, y, z }, center);
|
||||
const depth = dot(corner, forward);
|
||||
required = Math.max(
|
||||
required,
|
||||
Math.abs(dot(corner, up)) / limitY - depth,
|
||||
Math.abs(dot(corner, right)) / limitX - depth,
|
||||
);
|
||||
},
|
||||
resolve() {
|
||||
if (!Number.isFinite(required) || required <= 0) {
|
||||
return fallbackDistance;
|
||||
}
|
||||
return Math.max(required * FIT_SAFETY, fallbackRadius);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 没有顶点可用时,用包围盒 8 个角点求解取景距离。 */
|
||||
export function computeModel3dViewerFitDistance(
|
||||
input: Model3dViewerFramingInput,
|
||||
): number {
|
||||
if (input.distance !== undefined) {
|
||||
return input.distance;
|
||||
}
|
||||
const solver = createModel3dViewerFitSolver({
|
||||
center: computeModel3dViewerBoxCenter(input.box),
|
||||
aspect: input.aspect,
|
||||
direction: input.direction,
|
||||
fovDegrees: input.fovDegrees,
|
||||
fitFraction: input.fitFraction,
|
||||
fallbackRadius: computeModel3dViewerBoxRadius(input.box),
|
||||
});
|
||||
for (const x of [input.box.min.x, input.box.max.x]) {
|
||||
for (const y of [input.box.min.y, input.box.max.y]) {
|
||||
for (const z of [input.box.min.z, input.box.max.z]) {
|
||||
solver.add(x, y, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
return solver.resolve();
|
||||
}
|
||||
|
||||
/** 由包围盒推导取景、裁剪面与轨道缩放范围。 */
|
||||
export function computeModel3dViewerFraming(
|
||||
input: Model3dViewerFramingInput,
|
||||
): Model3dViewerFraming {
|
||||
const radius = normalizePositive(
|
||||
computeModel3dViewerBoxRadius(input.box),
|
||||
FALLBACK_RADIUS,
|
||||
);
|
||||
const distance = computeModel3dViewerFitDistance({
|
||||
...input,
|
||||
box: input.box,
|
||||
});
|
||||
return {
|
||||
center: computeModel3dViewerBoxCenter(input.box),
|
||||
radius,
|
||||
distance,
|
||||
near: Math.max(radius * NEAR_RADIUS_RATIO, distance / 1000),
|
||||
far: Math.max(distance + radius * FAR_RADIUS_RATIO, distance * 10),
|
||||
minDistance: radius * MIN_DISTANCE_RATIO,
|
||||
maxDistance: distance * MAX_DISTANCE_RATIO,
|
||||
resetDirection: resolveModel3dViewerResetDirection(input.direction),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type { Model3DViewerFormat, Model3DViewerSource } from './loader';
|
||||
export {
|
||||
isModel3dViewerFormatSupported,
|
||||
isModel3dViewerOverSize,
|
||||
MODEL3D_VIEWER_MAX_MODEL_BYTES,
|
||||
} from './loader';
|
||||
export type { Model3DViewerHandle, Model3DViewerProps } from './Model3DViewer';
|
||||
export { Model3DViewer } from './Model3DViewer';
|
||||
export type {
|
||||
Model3dViewerFailureReason,
|
||||
Model3DViewerStatus,
|
||||
Model3dViewerStatusDetail,
|
||||
} from './status';
|
||||
export {
|
||||
MODEL3D_VIEWER_ERROR_TEXT,
|
||||
MODEL3D_VIEWER_STATUS_ATTRIBUTE,
|
||||
resolveModel3dViewerErrorText,
|
||||
} from './status';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isModel3dViewerFormatSupported,
|
||||
isModel3dViewerOverSize,
|
||||
MODEL3D_VIEWER_MAX_MODEL_BYTES,
|
||||
} from './loader';
|
||||
|
||||
describe('模型格式承诺范围', () => {
|
||||
it('只承诺 glb / gltf / fbx', () => {
|
||||
for (const format of ['glb', 'gltf', 'fbx']) {
|
||||
expect(isModel3dViewerFormatSupported(format)).toBe(true);
|
||||
}
|
||||
for (const format of [
|
||||
'obj',
|
||||
'stl',
|
||||
'usdz',
|
||||
'3mf',
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
7,
|
||||
]) {
|
||||
expect(isModel3dViewerFormatSupported(format)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('模型体积上限', () => {
|
||||
it('超过 32 MiB 判定为超限', () => {
|
||||
expect(isModel3dViewerOverSize(0)).toBe(false);
|
||||
expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES)).toBe(false);
|
||||
expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES + 1)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('非有限值不算超限,由加载流程自行失败', () => {
|
||||
expect(isModel3dViewerOverSize(Number.NaN)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type * as ThreeTypes from 'three';
|
||||
|
||||
import type { Model3dViewerFailureReason } from './status';
|
||||
|
||||
export type Model3DViewerFormat = 'glb' | 'gltf' | 'fbx';
|
||||
|
||||
export type Model3DViewerSource =
|
||||
| { kind: 'url'; url: string; format: Model3DViewerFormat }
|
||||
| { kind: 'bytes'; data: ArrayBuffer; format: Model3DViewerFormat };
|
||||
|
||||
const SUPPORTED_FORMATS: readonly Model3DViewerFormat[] = [
|
||||
'glb',
|
||||
'gltf',
|
||||
'fbx',
|
||||
];
|
||||
|
||||
/** 单模型可渲染上限;超过后整体失败,不做半渲染。 */
|
||||
export const MODEL3D_VIEWER_MAX_MODEL_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
export function isModel3dViewerFormatSupported(
|
||||
value: unknown,
|
||||
): value is Model3DViewerFormat {
|
||||
return SUPPORTED_FORMATS.includes(value as Model3DViewerFormat);
|
||||
}
|
||||
|
||||
export function isModel3dViewerOverSize(byteLength: number): boolean {
|
||||
return (
|
||||
Number.isFinite(byteLength) && byteLength > MODEL3D_VIEWER_MAX_MODEL_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
export function describeModel3dViewerError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return typeof error === 'string' ? error : '未知错误';
|
||||
}
|
||||
|
||||
export class Model3dViewerLoadError extends Error {
|
||||
readonly reason: Model3dViewerFailureReason;
|
||||
readonly byteLength?: number;
|
||||
|
||||
constructor(
|
||||
reason: Model3dViewerFailureReason,
|
||||
message: string,
|
||||
byteLength?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'Model3dViewerLoadError';
|
||||
this.reason = reason;
|
||||
this.byteLength = byteLength;
|
||||
}
|
||||
}
|
||||
|
||||
export class Model3dViewerAbortedError extends Error {
|
||||
constructor() {
|
||||
super('模型加载已取消');
|
||||
this.name = 'Model3dViewerAbortedError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertModel3dViewerByteLength(byteLength: number) {
|
||||
if (isModel3dViewerOverSize(byteLength)) {
|
||||
throw new Model3dViewerLoadError(
|
||||
'too-large',
|
||||
`模型体积 ${byteLength} 字节,超过 ${MODEL3D_VIEWER_MAX_MODEL_BYTES} 字节上限`,
|
||||
byteLength,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取模型字节:url 走一次 fetch,bytes 直接复用调用方已读入的字节。 */
|
||||
export async function readModel3dViewerSourceBytes(
|
||||
source: Model3DViewerSource,
|
||||
): Promise<ArrayBuffer> {
|
||||
if (source.kind === 'bytes') {
|
||||
assertModel3dViewerByteLength(source.data.byteLength);
|
||||
return source.data;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(source.url);
|
||||
} catch (error) {
|
||||
throw new Model3dViewerLoadError(
|
||||
'load-failed',
|
||||
`模型地址不可读取:${describeModel3dViewerError(error)}`,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Model3dViewerLoadError(
|
||||
'load-failed',
|
||||
`模型地址返回 ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const declaredLength = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declaredLength)) {
|
||||
assertModel3dViewerByteLength(declaredLength);
|
||||
}
|
||||
|
||||
const data = await response.arrayBuffer();
|
||||
assertModel3dViewerByteLength(data.byteLength);
|
||||
return data;
|
||||
}
|
||||
|
||||
function resolveModel3dViewerBasePath(source: Model3DViewerSource): string {
|
||||
if (source.kind !== 'url') {
|
||||
return '';
|
||||
}
|
||||
const separatorIndex = source.url.lastIndexOf('/');
|
||||
return separatorIndex >= 0 ? source.url.slice(0, separatorIndex + 1) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型字节为 three 对象。glb / 单文件 gltf 走 GLTFLoader,fbx 走 FBXLoader;
|
||||
* 多文件 gltf 的兄弟资源按模型地址目录解析,取不到时按加载失败处理。
|
||||
*/
|
||||
export async function loadModel3dViewerObject(
|
||||
source: Model3DViewerSource,
|
||||
isAborted: () => boolean = () => false,
|
||||
): Promise<ThreeTypes.Object3D> {
|
||||
if (!isModel3dViewerFormatSupported(source.format)) {
|
||||
throw new Model3dViewerLoadError(
|
||||
'unsupported-format',
|
||||
`不支持的模型格式:${String(source.format)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await readModel3dViewerSourceBytes(source);
|
||||
if (isAborted()) {
|
||||
throw new Model3dViewerAbortedError();
|
||||
}
|
||||
|
||||
const basePath = resolveModel3dViewerBasePath(source);
|
||||
try {
|
||||
if (source.format === 'fbx') {
|
||||
const { FBXLoader } = await import(
|
||||
'three/examples/jsm/loaders/FBXLoader.js'
|
||||
);
|
||||
return new FBXLoader().parse(data, basePath);
|
||||
}
|
||||
|
||||
const { GLTFLoader } = await import(
|
||||
'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
);
|
||||
const loader = new GLTFLoader();
|
||||
const payload: string | ArrayBuffer =
|
||||
source.format === 'gltf'
|
||||
? new TextDecoder().decode(new Uint8Array(data))
|
||||
: data;
|
||||
const gltf = await new Promise<{ scene: ThreeTypes.Object3D }>(
|
||||
(resolve, reject) => {
|
||||
loader.parse(payload, basePath, resolve, reject);
|
||||
},
|
||||
);
|
||||
return gltf.scene;
|
||||
} catch (error) {
|
||||
if (error instanceof Model3dViewerLoadError) {
|
||||
throw error;
|
||||
}
|
||||
throw new Model3dViewerLoadError(
|
||||
'load-failed',
|
||||
`模型解析失败:${describeModel3dViewerError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import type * as ThreeTypes from 'three';
|
||||
|
||||
import {
|
||||
computeModel3dViewerBoxCenter,
|
||||
computeModel3dViewerBoxRadius,
|
||||
computeModel3dViewerFraming,
|
||||
createModel3dViewerFitSolver,
|
||||
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
|
||||
} from './framing';
|
||||
import {
|
||||
describeModel3dViewerError,
|
||||
loadModel3dViewerObject,
|
||||
Model3dViewerAbortedError,
|
||||
Model3dViewerLoadError,
|
||||
type Model3DViewerSource,
|
||||
} from './loader';
|
||||
|
||||
const MAX_PIXEL_RATIO = 2;
|
||||
/** 与共享组件同口径的半球光 / 主光,保证 PBR 材质可见;场景背景交给宿主。 */
|
||||
const HEMISPHERE_LIGHT_SKY = 0xffffff;
|
||||
const HEMISPHERE_LIGHT_GROUND = 0x445566;
|
||||
const HEMISPHERE_LIGHT_INTENSITY = 2.2;
|
||||
const KEY_LIGHT_INTENSITY = 2.0;
|
||||
const ENVIRONMENT_INTENSITY = 0.9;
|
||||
const TONE_MAPPING_EXPOSURE = 1.1;
|
||||
const DAMPING_FACTOR = 0.08;
|
||||
const AUTO_ROTATE_SPEED = 1.6;
|
||||
/** 单个几何最多采样多少顶点,避免超大模型在打开瞬间卡住主线程。 */
|
||||
const MAX_FIT_SAMPLES_PER_GEOMETRY = 200_000;
|
||||
|
||||
export type Model3dViewerScene = {
|
||||
resetView(): void;
|
||||
setAutoRotate(enabled: boolean): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
export type Model3dViewerSceneInput = {
|
||||
canvas: HTMLCanvasElement;
|
||||
container: HTMLElement;
|
||||
source: Model3DViewerSource;
|
||||
autoRotate: boolean;
|
||||
isAborted: () => boolean;
|
||||
};
|
||||
|
||||
/** 释放模型与场景里所有几何、材质、贴图,避免反复开关时显存只涨不降。 */
|
||||
export function disposeModel3dViewerObject(object: ThreeTypes.Object3D) {
|
||||
object.traverse((child) => {
|
||||
const mesh = child as ThreeTypes.Mesh;
|
||||
mesh.geometry?.dispose?.();
|
||||
const material = mesh.material;
|
||||
const materials = Array.isArray(material)
|
||||
? material
|
||||
: material
|
||||
? [material]
|
||||
: [];
|
||||
for (const entry of materials) {
|
||||
for (const value of Object.values(
|
||||
entry as unknown as Record<string, unknown>,
|
||||
)) {
|
||||
const texture = value as
|
||||
| { isTexture?: boolean; dispose?: () => void }
|
||||
| undefined;
|
||||
if (texture?.isTexture && typeof texture.dispose === 'function') {
|
||||
texture.dispose();
|
||||
}
|
||||
}
|
||||
(entry as { dispose?: () => void }).dispose?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 把模型真实顶点按世界坐标喂给取景求解器,避免包围球/包围盒空角带来的过度取景。 */
|
||||
function feedModel3dViewerFitSolver(
|
||||
object: ThreeTypes.Object3D,
|
||||
add: (x: number, y: number, z: number) => void,
|
||||
) {
|
||||
const readElement = (source: ArrayLike<number>, index: number) =>
|
||||
source[index] ?? 0;
|
||||
|
||||
object.updateMatrixWorld(true);
|
||||
object.traverse((child) => {
|
||||
const mesh = child as ThreeTypes.Mesh;
|
||||
const position = mesh.geometry?.attributes?.position;
|
||||
if (!position || position.count === 0) {
|
||||
return;
|
||||
}
|
||||
const elements = mesh.matrixWorld.elements;
|
||||
const m0 = readElement(elements, 0);
|
||||
const m1 = readElement(elements, 1);
|
||||
const m2 = readElement(elements, 2);
|
||||
const m4 = readElement(elements, 4);
|
||||
const m5 = readElement(elements, 5);
|
||||
const m6 = readElement(elements, 6);
|
||||
const m8 = readElement(elements, 8);
|
||||
const m9 = readElement(elements, 9);
|
||||
const m10 = readElement(elements, 10);
|
||||
const m12 = readElement(elements, 12);
|
||||
const m13 = readElement(elements, 13);
|
||||
const m14 = readElement(elements, 14);
|
||||
const push = (index: number) => {
|
||||
const x = position.getX(index);
|
||||
const y = position.getY(index);
|
||||
const z = position.getZ(index);
|
||||
add(
|
||||
m0 * x + m4 * y + m8 * z + m12,
|
||||
m1 * x + m5 * y + m9 * z + m13,
|
||||
m2 * x + m6 * y + m10 * z + m14,
|
||||
);
|
||||
};
|
||||
const stride = Math.max(
|
||||
1,
|
||||
Math.ceil(position.count / MAX_FIT_SAMPLES_PER_GEOMETRY),
|
||||
);
|
||||
for (let index = 0; index < position.count; index += stride) {
|
||||
push(index);
|
||||
}
|
||||
if (stride > 1) {
|
||||
// 抽样可能漏掉端点的极值,补最后一个顶点。
|
||||
push(position.count - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 WebGL2 交互场景:加载模型、归一化取景、轨道操作、按需释放。
|
||||
* 未挂载即完成加载时(React StrictMode 双调用)整体丢弃并释放,不留下第二个上下文。
|
||||
*/
|
||||
export async function createModel3dViewerScene(
|
||||
input: Model3dViewerSceneInput,
|
||||
): Promise<Model3dViewerScene> {
|
||||
const { canvas, container, source, isAborted } = input;
|
||||
|
||||
const [THREE, orbitControlsModule] = await Promise.all([
|
||||
import('three'),
|
||||
import('three/examples/jsm/controls/OrbitControls.js'),
|
||||
]);
|
||||
|
||||
let renderer: ThreeTypes.WebGLRenderer | null = null;
|
||||
let scene: ThreeTypes.Scene | null = null;
|
||||
let model: ThreeTypes.Object3D | null = null;
|
||||
let environmentTexture: ThreeTypes.Texture | null = null;
|
||||
let pmremGenerator: ThreeTypes.PMREMGenerator | null = null;
|
||||
let roomEnvironment: ThreeTypes.Scene | null = null;
|
||||
let controls: { dispose: () => void } | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let frameHandle = 0;
|
||||
let disposed = false;
|
||||
|
||||
function teardown() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
if (frameHandle !== 0) {
|
||||
window.cancelAnimationFrame(frameHandle);
|
||||
frameHandle = 0;
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
controls?.dispose();
|
||||
controls = null;
|
||||
if (model) {
|
||||
disposeModel3dViewerObject(model);
|
||||
model = null;
|
||||
}
|
||||
environmentTexture?.dispose();
|
||||
environmentTexture = null;
|
||||
pmremGenerator?.dispose();
|
||||
pmremGenerator = null;
|
||||
if (roomEnvironment) {
|
||||
disposeModel3dViewerObject(roomEnvironment);
|
||||
roomEnvironment = null;
|
||||
}
|
||||
if (renderer) {
|
||||
renderer.dispose();
|
||||
try {
|
||||
renderer.forceContextLoss();
|
||||
} catch {
|
||||
// 上下文已经丢失时忽略,释放流程继续。
|
||||
}
|
||||
renderer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function abortIfNeeded() {
|
||||
if (!isAborted()) {
|
||||
return;
|
||||
}
|
||||
teardown();
|
||||
throw new Model3dViewerAbortedError();
|
||||
}
|
||||
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Model3dViewerLoadError(
|
||||
'webgl-unavailable',
|
||||
`WebGL2 上下文不可用:${describeModel3dViewerError(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
|
||||
1,
|
||||
0.01,
|
||||
1000,
|
||||
);
|
||||
renderer.setPixelRatio(
|
||||
Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO),
|
||||
);
|
||||
renderer.toneMapping = THREE.NeutralToneMapping;
|
||||
renderer.toneMappingExposure = TONE_MAPPING_EXPOSURE;
|
||||
|
||||
const { OrbitControls } = orbitControlsModule;
|
||||
const orbitControls = new OrbitControls(camera, canvas);
|
||||
controls = orbitControls;
|
||||
orbitControls.enableDamping = true;
|
||||
orbitControls.dampingFactor = DAMPING_FACTOR;
|
||||
orbitControls.screenSpacePanning = true;
|
||||
orbitControls.autoRotate = input.autoRotate;
|
||||
orbitControls.autoRotateSpeed = AUTO_ROTATE_SPEED;
|
||||
|
||||
scene.add(
|
||||
new THREE.HemisphereLight(
|
||||
HEMISPHERE_LIGHT_SKY,
|
||||
HEMISPHERE_LIGHT_GROUND,
|
||||
HEMISPHERE_LIGHT_INTENSITY,
|
||||
),
|
||||
);
|
||||
const keyLight = new THREE.DirectionalLight(0xffffff, KEY_LIGHT_INTENSITY);
|
||||
keyLight.position.set(2, 3, 4);
|
||||
scene.add(keyLight);
|
||||
|
||||
try {
|
||||
const { RoomEnvironment } = await import(
|
||||
'three/examples/jsm/environments/RoomEnvironment.js'
|
||||
);
|
||||
roomEnvironment = new RoomEnvironment();
|
||||
pmremGenerator = new THREE.PMREMGenerator(renderer);
|
||||
environmentTexture = pmremGenerator.fromScene(
|
||||
roomEnvironment,
|
||||
0.04,
|
||||
).texture;
|
||||
scene.environment = environmentTexture;
|
||||
scene.environmentIntensity = ENVIRONMENT_INTENSITY;
|
||||
} catch {
|
||||
// 环境贴图只影响材质观感,取不到时退化为纯光照渲染。
|
||||
roomEnvironment = null;
|
||||
}
|
||||
|
||||
abortIfNeeded();
|
||||
|
||||
const model3d = await loadModel3dViewerObject(source, isAborted).catch(
|
||||
(error: unknown) => {
|
||||
teardown();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
model = model3d;
|
||||
scene.add(model3d);
|
||||
|
||||
abortIfNeeded();
|
||||
|
||||
const bounds = new THREE.Box3().setFromObject(model3d);
|
||||
const size = measureViewport();
|
||||
const fitBox = {
|
||||
min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
|
||||
max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
|
||||
};
|
||||
const fitCenter = computeModel3dViewerBoxCenter(fitBox);
|
||||
const fitSolver = createModel3dViewerFitSolver({
|
||||
center: fitCenter,
|
||||
aspect: size.aspect,
|
||||
fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
|
||||
fallbackRadius: computeModel3dViewerBoxRadius(fitBox),
|
||||
});
|
||||
feedModel3dViewerFitSolver(model3d, (x, y, z) => fitSolver.add(x, y, z));
|
||||
const framing = computeModel3dViewerFraming({
|
||||
box: fitBox,
|
||||
aspect: size.aspect,
|
||||
fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
|
||||
distance: fitSolver.resolve(),
|
||||
});
|
||||
|
||||
function measureViewport(): {
|
||||
width: number;
|
||||
height: number;
|
||||
aspect: number;
|
||||
} {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const width = Math.max(Math.round(rect.width), 0);
|
||||
const height = Math.max(Math.round(rect.height), 0);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
aspect: width > 0 && height > 0 ? width / height : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function applyViewport() {
|
||||
const current = measureViewport();
|
||||
if (current.width === 0 || current.height === 0 || !renderer) {
|
||||
return;
|
||||
}
|
||||
renderer.setSize(current.width, current.height, false);
|
||||
camera.aspect = current.aspect;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
camera.near = framing.near;
|
||||
camera.far = framing.far;
|
||||
camera.updateProjectionMatrix();
|
||||
orbitControls.minDistance = framing.minDistance;
|
||||
orbitControls.maxDistance = framing.maxDistance;
|
||||
orbitControls.target.set(
|
||||
framing.center.x,
|
||||
framing.center.y,
|
||||
framing.center.z,
|
||||
);
|
||||
camera.position.set(
|
||||
framing.center.x + framing.resetDirection.x * framing.distance,
|
||||
framing.center.y + framing.resetDirection.y * framing.distance,
|
||||
framing.center.z + framing.resetDirection.z * framing.distance,
|
||||
);
|
||||
orbitControls.update();
|
||||
}
|
||||
|
||||
resetView();
|
||||
applyViewport();
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
applyViewport();
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
function renderFrame() {
|
||||
if (disposed || !renderer || !scene) {
|
||||
return;
|
||||
}
|
||||
frameHandle = window.requestAnimationFrame(renderFrame);
|
||||
orbitControls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
frameHandle = window.requestAnimationFrame(renderFrame);
|
||||
|
||||
return {
|
||||
resetView,
|
||||
setAutoRotate(enabled: boolean) {
|
||||
orbitControls.autoRotate = enabled;
|
||||
},
|
||||
dispose: teardown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Model3DViewerStatus = 'idle' | 'loading' | 'ready' | 'failed';
|
||||
|
||||
export type Model3dViewerFailureReason =
|
||||
| 'unsupported-format'
|
||||
| 'too-large'
|
||||
| 'load-failed'
|
||||
| 'webgl-unavailable';
|
||||
|
||||
export type Model3dViewerStatusDetail = {
|
||||
reason?: Model3dViewerFailureReason;
|
||||
message?: string;
|
||||
byteLength?: number;
|
||||
};
|
||||
|
||||
/** 容器上的状态属性,宿主与测试按它判定渲染结果。 */
|
||||
export const MODEL3D_VIEWER_STATUS_ATTRIBUTE = 'data-3d-viewer-status';
|
||||
|
||||
export const MODEL3D_VIEWER_LOADING_TEXT = '加载中';
|
||||
|
||||
export const MODEL3D_VIEWER_ERROR_TEXT = '模型加载失败';
|
||||
|
||||
/** 失败时只渲染一行状态文案:宿主可覆盖,空串回落到内置文案。 */
|
||||
export function resolveModel3dViewerErrorText(value?: string | null): string {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : MODEL3D_VIEWER_ERROR_TEXT;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export const REQUIRED_WORKSPACES = Object.freeze([
|
||||
'packages/image-canvas-core',
|
||||
'packages/image-canvas-react',
|
||||
'packages/agc-plugin-sdk',
|
||||
'packages/model3d-viewer',
|
||||
'packages/shared',
|
||||
'plugins/agc-cocos-editor',
|
||||
'tools/spine-json-export-validator',
|
||||
@@ -27,6 +28,7 @@ const WORKSPACE_NAMES = Object.freeze({
|
||||
'packages/image-canvas-core': '@genarrative/image-canvas-core',
|
||||
'packages/image-canvas-react': '@genarrative/image-canvas-react',
|
||||
'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk',
|
||||
'packages/model3d-viewer': '@genarrative/model3d-viewer',
|
||||
'packages/shared': '@genarrative/shared',
|
||||
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
|
||||
'tools/spine-json-export-validator':
|
||||
|
||||
@@ -21,6 +21,7 @@ const workspaceNames = {
|
||||
'packages/image-canvas-core': '@genarrative/image-canvas-core',
|
||||
'packages/image-canvas-react': '@genarrative/image-canvas-react',
|
||||
'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk',
|
||||
'packages/model3d-viewer': '@genarrative/model3d-viewer',
|
||||
'packages/shared': '@genarrative/shared',
|
||||
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
|
||||
'tools/spine-json-export-validator':
|
||||
|
||||
@@ -142,6 +142,8 @@ export default defineConfig({
|
||||
'packages/image-canvas-react/src/**/*.test.ts',
|
||||
'packages/image-canvas-react/src/**/*.test.tsx',
|
||||
'packages/agc-plugin-sdk/src/**/*.test.ts',
|
||||
'packages/model3d-viewer/src/**/*.test.ts',
|
||||
'packages/model3d-viewer/src/**/*.test.tsx',
|
||||
],
|
||||
exclude: ['scripts/loadtest/**'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user