refactor: token viewer
This commit is contained in:
parent
8c33dc282b
commit
e92065d14c
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue