Files
ttrpg-tools/src/components/md-token.tsx
T
2026-03-17 12:38:01 +08:00

314 lines
10 KiB
TypeScript

import { customElement, noShadowDOM } from "solid-element";
import {
Show,
For,
createResource,
createMemo,
createSignal,
onCleanup,
} from "solid-js";
import { resolvePath } from "./utils/path";
import { traceImage, type TracedLayer } from "./utils/image-tracer";
import { generate3MF, type ExtrusionSettings } from "./utils/3mf-generator";
import MdTokenViewer from "./md-token-viewer";
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 [modelUrl, setModelUrl] = createSignal<string | null>(null);
const [isGenerating, setIsGenerating] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
// 从 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.getHexString()}` || `hsl(${(index * 60) % 360}, 70%, 50%)`,
}));
setLayers(layerSettings);
}
});
// 生成 3MF 模型
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 modelBlob = await generate3MF(image()!, traceResult()!, settings);
const url = URL.createObjectURL(modelBlob);
setModelUrl(url);
} catch (e) {
setError(e instanceof Error ? e.message : "生成模型失败");
} finally {
setIsGenerating(false);
}
};
// 更新图层厚度
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))
);
};
// 下载 3MF 文件
const downloadModel = () => {
const url = modelUrl();
if (!url) return;
const a = document.createElement("a");
a.href = url;
a.download = `token-${Date.now()}.3mf`;
a.click();
};
// 清理 URL
onCleanup(() => {
const url = modelUrl();
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>
{/* 3D 模型预览 */}
<Show when={modelUrl()}>
<div class="flex-1 min-w-[200px]">
<h4 class="text-sm font-semibold mb-2 text-gray-700">
3D 模型预览
</h4>
<MdTokenViewer url={modelUrl()}/>
</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={modelUrl()}>
<button
onClick={downloadModel}
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded font-medium transition-colors"
>
📥 下载 3MF
</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>
);
});