refactor: token viewer

This commit is contained in:
hypercross 2026-03-15 19:14:39 +08:00
parent 8c33dc282b
commit e92065d14c
3 changed files with 213 additions and 175 deletions

View File

@ -8,6 +8,7 @@ import './md-deck';
import './md-commander/index'; import './md-commander/index';
import './md-yarn-spinner'; import './md-yarn-spinner';
import './md-token'; import './md-token';
import './md-token-viewer';
// 导出组件 // 导出组件
export { Article } from './Article'; export { Article } from './Article';

View File

@ -0,0 +1,211 @@
import { customElement, noShadowDOM } from "solid-element";
import {
createSignal,
onMount,
onCleanup,
Show,
} from "solid-js";
import * as THREE from "three";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
export interface TokenViewerProps {
stlUrl: string;
}
customElement("md-token-viewer", {}, (props, { element }) => {
noShadowDOM();
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;
let animationId: number | null = null;
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
// 加载 STL 用于预览
const loadSTL = async (url: string) => {
const viewerEl = viewerRef();
if (!viewerEl) return;
// 清理旧的场景
if (mesh) {
scene?.remove(mesh);
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
mesh = null;
}
try {
const response = await fetch(url);
const stlText = await response.text();
// 解析 STL
const loader = new STLLoader();
const geometry = loader.parse(stlText);
// 计算边界并居中
geometry.center();
// 创建材质
const material = new THREE.MeshStandardMaterial({
color: 0x808080,
metalness: 0.3,
roughness: 0.7,
side: THREE.DoubleSide,
});
mesh = new THREE.Mesh(geometry, material);
scene?.add(mesh);
// 调整相机
if (camera && scene) {
const box = new THREE.Box3().setFromObject(mesh);
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) {
console.error("加载 STL 预览失败:", e);
setError(e instanceof Error ? e.message : "加载 STL 失败");
}
};
// 初始化 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) => {
if (!isDragging || !mesh) return;
const deltaX = e.clientX - previousMousePosition.x;
const deltaY = e.clientY - previousMousePosition.y;
mesh.rotation.y += deltaX * 0.01;
mesh.rotation.x += deltaY * 0.01;
previousMousePosition = { x: e.clientX, y: e.clientY };
};
const handleMouseUp = () => {
isDragging = false;
};
const handleWheel = (e: WheelEvent) => {
if (!camera) return;
const zoomSpeed = 0.5;
camera.position.multiplyScalar(e.deltaY > 0 ? 1 + zoomSpeed : 1 - zoomSpeed);
};
viewerEl.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
viewerEl.addEventListener("wheel", handleWheel);
// 动画循环
const animate = () => {
if (scene && camera && renderer && mesh) {
if (!isDragging) {
mesh.rotation.y += 0.005; // 自动旋转
}
renderer.render(scene, camera);
}
animationId = requestAnimationFrame(animate);
};
animate();
// 加载 STL 模型
const stlUrl = element.getAttribute("stl-url");
if (stlUrl) {
loadSTL(stlUrl);
}
// 清理函数
onCleanup(() => {
if (animationId) cancelAnimationFrame(animationId);
viewerEl.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
viewerEl.removeEventListener("wheel", handleWheel);
if (renderer) {
renderer.dispose();
viewerEl.removeChild(renderer.domElement);
}
if (scene) scene.clear();
});
});
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">
|
</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>
);
});

View File

@ -5,15 +5,11 @@ import {
createResource, createResource,
createMemo, createMemo,
createSignal, createSignal,
onMount,
onCleanup, onCleanup,
createEffect,
} from "solid-js"; } from "solid-js";
import { resolvePath } from "./utils/path"; import { resolvePath } from "./utils/path";
import { traceImage, type TracedLayer } from "./utils/image-tracer"; import { traceImage, type TracedLayer } from "./utils/image-tracer";
import { generateSTL, type ExtrusionSettings } from "./utils/stl-generator"; import { generateSTL, type ExtrusionSettings } from "./utils/stl-generator";
import * as THREE from "three";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
export interface TokenProps { export interface TokenProps {
size?: number; // 模型整体尺寸 (mm), 默认 50 size?: number; // 模型整体尺寸 (mm), 默认 50
@ -36,15 +32,6 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
const [stlUrl, setStlUrl] = createSignal<string | null>(null); const [stlUrl, setStlUrl] = createSignal<string | null>(null);
const [isGenerating, setIsGenerating] = createSignal(false); const [isGenerating, setIsGenerating] = createSignal(false);
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
const [viewerRef, setViewerRef] = createSignal<HTMLDivElement | 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;
let animationId: number | null = null;
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
// 从 element 的 textContent 获取图片路径 // 从 element 的 textContent 获取图片路径
const rawSrc = element?.textContent?.trim() || ""; const rawSrc = element?.textContent?.trim() || "";
@ -129,9 +116,6 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
const stlBlob = await generateSTL(image()!, traceResult()!, settings); const stlBlob = await generateSTL(image()!, traceResult()!, settings);
const url = URL.createObjectURL(stlBlob); const url = URL.createObjectURL(stlBlob);
setStlUrl(url); setStlUrl(url);
// 更新 3D 预览
loadSTLForPreview(url);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "生成模型失败"); setError(e instanceof Error ? e.message : "生成模型失败");
} finally { } finally {
@ -139,156 +123,6 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
} }
}; };
// 加载 STL 用于预览
const loadSTLForPreview = async (url: string) => {
const viewerEl = viewerRef();
if (!viewerEl) return;
// 清理旧的场景
if (mesh) {
scene?.remove(mesh);
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
mesh = null;
}
try {
const response = await fetch(url);
const stlText = await response.text();
// 解析 STL
const loader = new STLLoader();
const geometry = loader.parse(stlText);
// 计算边界并居中
geometry.center();
// 创建材质
const material = new THREE.MeshStandardMaterial({
color: 0x808080,
metalness: 0.3,
roughness: 0.7,
side: THREE.DoubleSide,
});
mesh = new THREE.Mesh(geometry, material);
scene?.add(mesh);
// 调整相机
if (camera && scene) {
const box = new THREE.Box3().setFromObject(mesh);
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);
}
} catch (e) {
console.error("加载 STL 预览失败:", e);
}
};
// 初始化 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) => {
if (!isDragging || !mesh) return;
const deltaX = e.clientX - previousMousePosition.x;
const deltaY = e.clientY - previousMousePosition.y;
mesh.rotation.y += deltaX * 0.01;
mesh.rotation.x += deltaY * 0.01;
previousMousePosition = { x: e.clientX, y: e.clientY };
};
const handleMouseUp = () => {
isDragging = false;
};
const handleWheel = (e: WheelEvent) => {
if (!camera) return;
const zoomSpeed = 0.5;
camera.position.multiplyScalar(e.deltaY > 0 ? 1 + zoomSpeed : 1 - zoomSpeed);
};
viewerEl.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
viewerEl.addEventListener("wheel", handleWheel);
// 动画循环
const animate = () => {
if (scene && camera && renderer && mesh) {
if (!isDragging) {
mesh.rotation.y += 0.005; // 自动旋转
}
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);
viewerEl.removeEventListener("wheel", handleWheel);
if (renderer) {
renderer.dispose();
viewerEl.removeChild(renderer.domElement);
}
if (scene) scene.clear();
});
});
// 更新图层厚度 // 更新图层厚度
const updateLayerThickness = (layerId: string, thickness: number) => { const updateLayerThickness = (layerId: string, thickness: number) => {
setLayers((prev) => setLayers((prev) =>
@ -348,15 +182,7 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
<h4 class="text-sm font-semibold mb-2 text-gray-700"> <h4 class="text-sm font-semibold mb-2 text-gray-700">
3D 3D
</h4> </h4>
<div <md-token-viewer stl-url={stlUrl()!} />
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">
|
</div>
</div>
</div> </div>
</Show> </Show>
</div> </div>