feat: md-token
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
import { customElement, noShadowDOM } from "solid-element";
|
||||
import {
|
||||
Show,
|
||||
For,
|
||||
createResource,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onMount,
|
||||
onCleanup,
|
||||
createEffect,
|
||||
} from "solid-js";
|
||||
import { resolvePath } from "./utils/path";
|
||||
import { traceImage, type TracedLayer } from "./utils/image-tracer";
|
||||
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 {
|
||||
size?: number; // 模型整体尺寸 (mm), 默认 50
|
||||
defaultThickness?: number; // 默认图层厚度 (mm), 默认 2
|
||||
}
|
||||
|
||||
interface LayerSettings {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
thickness: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }) => {
|
||||
noShadowDOM();
|
||||
|
||||
const [showEditor, setShowEditor] = createSignal(false);
|
||||
const [layers, setLayers] = createSignal<LayerSettings[]>([]);
|
||||
const [stlUrl, setStlUrl] = createSignal<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||
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 获取图片路径
|
||||
const rawSrc = element?.textContent?.trim() || "";
|
||||
|
||||
// 隐藏原始文本内容
|
||||
if (element) {
|
||||
element.textContent = "";
|
||||
}
|
||||
|
||||
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
|
||||
const articleEl = element?.closest("article[data-src]");
|
||||
const articlePath = articleEl?.getAttribute("data-src") || "";
|
||||
|
||||
// 解析相对路径
|
||||
const resolvedSrc = resolvePath(articlePath, rawSrc);
|
||||
|
||||
// 加载图片
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
};
|
||||
|
||||
const [image] = createResource(resolvedSrc, loadImage);
|
||||
|
||||
// 图像加载完成后进行矢量追踪
|
||||
const [traceResult] = createResource(
|
||||
() => image(),
|
||||
async (img) => {
|
||||
if (!img) return null;
|
||||
try {
|
||||
const traced = await traceImage(img);
|
||||
return traced;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "矢量追踪失败");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 初始化图层设置
|
||||
createMemo(() => {
|
||||
const result = traceResult();
|
||||
if (result && result.layers.length > 0) {
|
||||
const layerSettings: LayerSettings[] = result.layers.map((layer, index) => ({
|
||||
id: layer.id,
|
||||
name: layer.name || `图层 ${index + 1}`,
|
||||
enabled: true,
|
||||
thickness: props.defaultThickness || 2,
|
||||
color: layer.color || `hsl(${(index * 60) % 360}, 70%, 50%)`,
|
||||
}));
|
||||
setLayers(layerSettings);
|
||||
}
|
||||
});
|
||||
|
||||
// 生成 STL 模型
|
||||
const generateModel = async () => {
|
||||
if (!image()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const enabledLayers = layers().filter((l) => l.enabled);
|
||||
if (enabledLayers.length === 0) {
|
||||
setError("请至少选择一个图层");
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings: ExtrusionSettings = {
|
||||
size: props.size || 50,
|
||||
layers: enabledLayers.map((l) => ({
|
||||
id: l.id,
|
||||
thickness: l.thickness,
|
||||
})),
|
||||
};
|
||||
|
||||
const stlBlob = await generateSTL(image()!, traceResult()!, settings);
|
||||
const url = URL.createObjectURL(stlBlob);
|
||||
setStlUrl(url);
|
||||
|
||||
// 更新 3D 预览
|
||||
loadSTLForPreview(url);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "生成模型失败");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载 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) => {
|
||||
setLayers((prev) =>
|
||||
prev.map((l) => (l.id === layerId ? { ...l, thickness } : l))
|
||||
);
|
||||
};
|
||||
|
||||
// 切换图层启用状态
|
||||
const toggleLayer = (layerId: string) => {
|
||||
setLayers((prev) =>
|
||||
prev.map((l) => (l.id === layerId ? { ...l, enabled: !l.enabled } : l))
|
||||
);
|
||||
};
|
||||
|
||||
// 下载 STL 文件
|
||||
const downloadSTL = () => {
|
||||
const url = stlUrl();
|
||||
if (!url) return;
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `token-${Date.now()}.stl`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
// 清理 URL
|
||||
onCleanup(() => {
|
||||
const url = stlUrl();
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
|
||||
const visible = createMemo(() => !image.loading && !!image());
|
||||
|
||||
return (
|
||||
<div class="md-token-component my-4">
|
||||
<Show when={visible()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
{/* 预览区域 */}
|
||||
<div class="flex flex-wrap gap-4 items-start">
|
||||
{/* 原始图片预览 */}
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<h4 class="text-sm font-semibold mb-2 text-gray-700">原始图片</h4>
|
||||
<div class="relative border rounded-lg overflow-hidden bg-gray-50">
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt="Token source"
|
||||
class="max-w-full h-auto max-h-[300px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STL 预览 (如果有) */}
|
||||
<Show when={stlUrl()}>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<h4 class="text-sm font-semibold mb-2 text-gray-700">
|
||||
3D 模型预览
|
||||
</h4>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
<Show when={error()}>
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded">
|
||||
⚠️ {error()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* 控制面板 */}
|
||||
<div class="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">
|
||||
🎯 Token 设置
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowEditor(!showEditor())}
|
||||
class="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{showEditor() ? "隐藏设置" : "显示设置"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={showEditor()}>
|
||||
<div class="space-y-4">
|
||||
{/* 整体尺寸设置 */}
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700 min-w-[100px]">
|
||||
模型尺寸 (mm)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
step="1"
|
||||
value={props.size || 50}
|
||||
class="flex-1"
|
||||
onChange={(e) => {
|
||||
const newSize = parseInt(e.target.value);
|
||||
element?.setAttribute("size", newSize.toString());
|
||||
}}
|
||||
/>
|
||||
<span class="text-sm text-gray-600 w-12 text-right">
|
||||
{props.size || 50}mm
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 图层列表 */}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">
|
||||
图层设置
|
||||
</h4>
|
||||
<div class="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
<For each={layers()}>
|
||||
{(layer) => (
|
||||
<div class="flex items-center gap-3 p-2 border rounded hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={layer.enabled}
|
||||
onChange={() => toggleLayer(layer.id)}
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<div
|
||||
class="w-4 h-4 rounded"
|
||||
style={{ "background-color": layer.color }}
|
||||
/>
|
||||
<span class="flex-1 text-sm text-gray-700">
|
||||
{layer.name}
|
||||
</span>
|
||||
<label class="text-xs text-gray-500">厚度:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0.5"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={layer.thickness}
|
||||
class="w-16 px-2 py-1 border rounded text-sm"
|
||||
onChange={(e) =>
|
||||
updateLayerThickness(
|
||||
layer.id,
|
||||
parseFloat(e.target.value) || 2
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span class="text-xs text-gray-500">mm</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={generateModel}
|
||||
disabled={isGenerating()}
|
||||
class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white px-4 py-2 rounded font-medium transition-colors"
|
||||
>
|
||||
{isGenerating() ? "生成中..." : "🔄 生成 3D 模型"}
|
||||
</button>
|
||||
<Show when={stlUrl()}>
|
||||
<button
|
||||
onClick={downloadSTL}
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded font-medium transition-colors"
|
||||
>
|
||||
📥 下载 STL
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* 加载状态 */}
|
||||
<Show when={image.loading}>
|
||||
<div class="text-center py-8 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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user