316 lines
9.7 KiB
TypeScript
316 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|