2026-03-15 19:14:39 +08:00
|
|
|
|
import {
|
|
|
|
|
|
createSignal,
|
|
|
|
|
|
onMount,
|
|
|
|
|
|
onCleanup,
|
2026-03-15 19:24:40 +08:00
|
|
|
|
Show, createEffect,
|
2026-03-15 19:14:39 +08:00
|
|
|
|
} from "solid-js";
|
|
|
|
|
|
import * as THREE from "three";
|
2026-03-15 22:35:38 +08:00
|
|
|
|
import { ThreeMFLoader } from "three/addons/loaders/3MFLoader.js";
|
2026-03-15 19:14:39 +08:00
|
|
|
|
|
|
|
|
|
|
export interface TokenViewerProps {
|
2026-03-17 12:38:01 +08:00
|
|
|
|
url: string | null;
|
2026-03-15 19:14:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 22:35:38 +08:00
|
|
|
|
export default function MdTokenViewer(props: TokenViewerProps) {
|
2026-03-15 19:14:39 +08:00
|
|
|
|
|
|
|
|
|
|
const [viewerRef, setViewerRef] = createSignal<HTMLDivElement | null>(null);
|
|
|
|
|
|
const [isLoaded, setIsLoaded] = createSignal(false);
|
|
|
|
|
|
const [error, setError] = createSignal<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
let scene: THREE.Scene | null = null;
|
|
|
|
|
|
let camera: THREE.PerspectiveCamera | null = null;
|
|
|
|
|
|
let renderer: THREE.WebGLRenderer | null = null;
|
|
|
|
|
|
let mesh: THREE.Mesh | null = null;
|
2026-03-15 22:35:38 +08:00
|
|
|
|
let group: THREE.Group | null = null;
|
2026-03-15 19:14:39 +08:00
|
|
|
|
let animationId: number | null = null;
|
|
|
|
|
|
let isDragging = false;
|
|
|
|
|
|
let previousMousePosition = { x: 0, y: 0 };
|
|
|
|
|
|
|
2026-03-15 22:35:38 +08:00
|
|
|
|
// 加载 3MF 用于预览
|
2026-03-17 12:38:01 +08:00
|
|
|
|
const load3mf = async (url: string) => {
|
2026-03-15 19:14:39 +08:00
|
|
|
|
const viewerEl = viewerRef();
|
|
|
|
|
|
if (!viewerEl) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 清理旧的场景
|
2026-03-15 22:35:38 +08:00
|
|
|
|
if (group) {
|
|
|
|
|
|
scene?.remove(group);
|
|
|
|
|
|
group.traverse((obj) => {
|
|
|
|
|
|
if ((obj as THREE.Mesh).isMesh) {
|
|
|
|
|
|
const meshObj = obj as THREE.Mesh;
|
|
|
|
|
|
meshObj.geometry.dispose();
|
|
|
|
|
|
(meshObj.material as THREE.Material).dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
group = null;
|
2026-03-15 19:14:39 +08:00
|
|
|
|
mesh = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-15 22:35:38 +08:00
|
|
|
|
const loader = new ThreeMFLoader();
|
|
|
|
|
|
const object = await loader.loadAsync(url);
|
|
|
|
|
|
|
|
|
|
|
|
// 3MF 文件可能返回一个 Group,包含多个 Mesh
|
|
|
|
|
|
group = object instanceof THREE.Group ? object : new THREE.Group().add(object);
|
|
|
|
|
|
group.rotateX(Math.PI);
|
|
|
|
|
|
|
|
|
|
|
|
// 为每个 mesh 启用原始颜色
|
|
|
|
|
|
group.traverse((child) => {
|
|
|
|
|
|
if ((child as THREE.Mesh).isMesh) {
|
|
|
|
|
|
const childMesh = child as THREE.Mesh;
|
2026-03-15 23:17:59 +08:00
|
|
|
|
const material = childMesh.material as THREE.MeshStandardMaterial;
|
2026-03-15 22:35:38 +08:00
|
|
|
|
// 保留原始顶点颜色或材质颜色
|
|
|
|
|
|
if (childMesh.geometry.attributes.color) {
|
2026-03-15 23:17:59 +08:00
|
|
|
|
material.vertexColors = true;
|
2026-03-15 22:35:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 确保材质是标准材质以支持光照
|
|
|
|
|
|
if (!(childMesh.material instanceof THREE.MeshStandardMaterial)) {
|
|
|
|
|
|
childMesh.material = new THREE.MeshStandardMaterial({
|
2026-03-15 23:17:59 +08:00
|
|
|
|
color: material.color,
|
2026-03-15 22:35:38 +08:00
|
|
|
|
metalness: 0.3,
|
|
|
|
|
|
roughness: 0.7,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-15 19:14:39 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-15 22:35:38 +08:00
|
|
|
|
scene?.add(group);
|
|
|
|
|
|
mesh = group.children[0] as THREE.Mesh;
|
2026-03-15 19:14:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 调整相机
|
|
|
|
|
|
if (camera && scene) {
|
2026-03-15 22:35:38 +08:00
|
|
|
|
const box = new THREE.Box3().setFromObject(group);
|
2026-03-15 19:14:39 +08:00
|
|
|
|
const size = box.getSize(new THREE.Vector3());
|
|
|
|
|
|
const maxDim = Math.max(size.x, size.y, size.z);
|
|
|
|
|
|
|
|
|
|
|
|
camera.position.set(maxDim * 2, maxDim * 2, maxDim * 2);
|
|
|
|
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsLoaded(true);
|
|
|
|
|
|
} catch (e) {
|
2026-03-15 22:35:38 +08:00
|
|
|
|
console.error("加载 3MF 预览失败:", e);
|
|
|
|
|
|
setError(e instanceof Error ? e.message : "加载模型失败");
|
2026-03-15 19:14:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化 Three.js 场景
|
|
|
|
|
|
onMount(() => {
|
|
|
|
|
|
const viewerEl = viewerRef();
|
|
|
|
|
|
if (!viewerEl) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 创建场景
|
|
|
|
|
|
scene = new THREE.Scene();
|
|
|
|
|
|
scene.background = new THREE.Color(0xf3f4f6);
|
|
|
|
|
|
|
|
|
|
|
|
// 创建相机
|
|
|
|
|
|
camera = new THREE.PerspectiveCamera(
|
|
|
|
|
|
45,
|
|
|
|
|
|
viewerEl.clientWidth / viewerEl.clientHeight,
|
|
|
|
|
|
0.1,
|
|
|
|
|
|
1000
|
|
|
|
|
|
);
|
|
|
|
|
|
camera.position.set(100, 100, 100);
|
|
|
|
|
|
|
|
|
|
|
|
// 创建渲染器
|
|
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
|
|
|
|
renderer.setSize(viewerEl.clientWidth, viewerEl.clientHeight);
|
|
|
|
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
|
|
|
|
renderer.shadowMap.enabled = true;
|
|
|
|
|
|
|
|
|
|
|
|
viewerEl.appendChild(renderer.domElement);
|
|
|
|
|
|
|
|
|
|
|
|
// 添加灯光
|
|
|
|
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
|
|
|
|
|
scene.add(ambientLight);
|
|
|
|
|
|
|
|
|
|
|
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
|
|
|
|
directionalLight.position.set(50, 100, 50);
|
|
|
|
|
|
directionalLight.castShadow = true;
|
|
|
|
|
|
scene.add(directionalLight);
|
|
|
|
|
|
|
|
|
|
|
|
// 添加坐标轴辅助
|
|
|
|
|
|
const axesHelper = new THREE.AxesHelper(10);
|
|
|
|
|
|
scene.add(axesHelper);
|
|
|
|
|
|
|
|
|
|
|
|
// 鼠标控制
|
|
|
|
|
|
const handleMouseDown = (e: MouseEvent) => {
|
|
|
|
|
|
isDragging = true;
|
|
|
|
|
|
previousMousePosition = { x: e.clientX, y: e.clientY };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
2026-03-15 22:35:38 +08:00
|
|
|
|
if (!isDragging || !group) return;
|
2026-03-15 19:14:39 +08:00
|
|
|
|
|
|
|
|
|
|
const deltaX = e.clientX - previousMousePosition.x;
|
|
|
|
|
|
const deltaY = e.clientY - previousMousePosition.y;
|
|
|
|
|
|
|
2026-03-15 22:35:38 +08:00
|
|
|
|
group.rotation.y += deltaX * 0.01;
|
|
|
|
|
|
group.rotation.x += deltaY * 0.01;
|
2026-03-15 19:14:39 +08:00
|
|
|
|
|
|
|
|
|
|
previousMousePosition = { x: e.clientX, y: e.clientY };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleMouseUp = () => {
|
|
|
|
|
|
isDragging = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
viewerEl.addEventListener("mousedown", handleMouseDown);
|
|
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
|
|
|
|
|
|
|
|
// 动画循环
|
|
|
|
|
|
const animate = () => {
|
2026-03-15 22:35:38 +08:00
|
|
|
|
if (scene && camera && renderer && group) {
|
2026-03-15 19:14:39 +08:00
|
|
|
|
if (!isDragging) {
|
2026-03-15 22:35:38 +08:00
|
|
|
|
group.rotation.y += 0.005; // 自动旋转
|
2026-03-15 19:14:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
renderer.render(scene, camera);
|
|
|
|
|
|
}
|
|
|
|
|
|
animationId = requestAnimationFrame(animate);
|
|
|
|
|
|
};
|
|
|
|
|
|
animate();
|
|
|
|
|
|
|
|
|
|
|
|
// 清理函数
|
|
|
|
|
|
onCleanup(() => {
|
|
|
|
|
|
if (animationId) cancelAnimationFrame(animationId);
|
|
|
|
|
|
viewerEl.removeEventListener("mousedown", handleMouseDown);
|
|
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
|
|
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
|
|
|
|
|
|
|
|
|
|
if (renderer) {
|
|
|
|
|
|
renderer.dispose();
|
|
|
|
|
|
viewerEl.removeChild(renderer.domElement);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (scene) scene.clear();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-03-15 19:24:40 +08:00
|
|
|
|
|
|
|
|
|
|
createEffect(() => {
|
2026-03-17 12:38:01 +08:00
|
|
|
|
if (props.url) {
|
|
|
|
|
|
load3mf(props.url);
|
2026-03-15 19:24:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-15 19:14:39 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={setViewerRef}
|
|
|
|
|
|
class="relative border rounded-lg overflow-hidden bg-gray-50 aspect-square"
|
|
|
|
|
|
style={{ "min-height": "200px" }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="absolute top-2 left-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
2026-03-16 09:34:15 +08:00
|
|
|
|
拖动旋转
|
2026-03-15 19:14:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<Show when={error()}>
|
|
|
|
|
|
<div class="absolute inset-0 flex items-center justify-center bg-black/50 text-white">
|
|
|
|
|
|
⚠️ {error()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
<Show when={!isLoaded() && !error()}>
|
|
|
|
|
|
<div class="absolute inset-0 flex items-center justify-center text-gray-500">
|
|
|
|
|
|
<div class="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mr-2" />
|
|
|
|
|
|
加载模型中...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-03-15 19:24:40 +08:00
|
|
|
|
}
|