Compare commits

...

3 Commits

Author SHA1 Message Date
hypercross a4e8e31921 fix: render issues 2026-03-15 19:24:40 +08:00
hypercross e92065d14c refactor: token viewer 2026-03-15 19:14:39 +08:00
hypercross 8c33dc282b feat: md-token 2026-03-15 19:01:39 +08:00
7 changed files with 1176 additions and 2 deletions

75
package-lock.json generated
View File

@ -10,6 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@solidjs/router": "^0.15.0", "@solidjs/router": "^0.15.0",
"@types/three": "^0.183.1",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"commander": "^14.0.3", "commander": "^14.0.3",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
@ -20,12 +21,14 @@
"marked-gfm-heading-id": "^4.1.3", "marked-gfm-heading-id": "^4.1.3",
"mermaid": "^11.0.0", "mermaid": "^11.0.0",
"solid-element": "^1.9.1", "solid-element": "^1.9.1",
"solid-js": "^1.9.3" "solid-js": "^1.9.3",
"three": "^0.183.2"
}, },
"bin": { "bin": {
"ttrpg": "dist/cli/index.js" "ttrpg": "dist/cli/index.js"
}, },
"devDependencies": { "devDependencies": {
"@image-tracer-ts/core": "^1.0.2",
"@rsbuild/core": "^1.1.8", "@rsbuild/core": "^1.1.8",
"@rsbuild/plugin-babel": "^1.1.0", "@rsbuild/plugin-babel": "^1.1.0",
"@rsbuild/plugin-solid": "^1.1.0", "@rsbuild/plugin-solid": "^1.1.0",
@ -836,6 +839,12 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@jridgewell/sourcemap-codec": "^1.4.10"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@ -1355,6 +1364,13 @@
"mlly": "^1.8.0" "mlly": "^1.8.0"
} }
}, },
"node_modules/@image-tracer-ts/core": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@image-tracer-ts/core/-/core-1.0.2.tgz",
"integrity": "sha512-IY1/AMqvu6444dEwaFwJXwskp2fyKTFFVKvKHXBCT7hGz7OLRLHrWGHJrCsVw6HPSeucoHD5j/gtieWVSzocEw==",
"dev": true,
"license": "MIT"
},
"node_modules/@istanbuljs/load-nyc-config": { "node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@ -3475,6 +3491,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@ -3882,6 +3904,27 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.183.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~1.0.1"
}
},
"node_modules/@types/tough-cookie": { "node_modules/@types/tough-cookie": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@ -3896,6 +3939,12 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@ -3913,6 +3962,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"license": "BSD-3-Clause"
},
"node_modules/abab": { "node_modules/abab": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@ -5731,6 +5786,12 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -8607,6 +8668,12 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/meshoptimizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
"license": "MIT"
},
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -9685,6 +9752,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"license": "MIT"
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",

View File

@ -32,6 +32,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@solidjs/router": "^0.15.0", "@solidjs/router": "^0.15.0",
"@types/three": "^0.183.1",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"commander": "^14.0.3", "commander": "^14.0.3",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
@ -42,9 +43,11 @@
"marked-gfm-heading-id": "^4.1.3", "marked-gfm-heading-id": "^4.1.3",
"mermaid": "^11.0.0", "mermaid": "^11.0.0",
"solid-element": "^1.9.1", "solid-element": "^1.9.1",
"solid-js": "^1.9.3" "solid-js": "^1.9.3",
"three": "^0.183.2"
}, },
"devDependencies": { "devDependencies": {
"@image-tracer-ts/core": "^1.0.2",
"@rsbuild/core": "^1.1.8", "@rsbuild/core": "^1.1.8",
"@rsbuild/plugin-babel": "^1.1.0", "@rsbuild/plugin-babel": "^1.1.0",
"@rsbuild/plugin-solid": "^1.1.0", "@rsbuild/plugin-solid": "^1.1.0",

View File

@ -7,6 +7,8 @@ import './md-bg';
import './md-deck'; 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-viewer';
// 导出组件 // 导出组件
export { Article } from './Article'; export { Article } from './Article';
@ -20,6 +22,7 @@ export type { DiceProps } from './md-dice';
export type { TableProps } from './md-table'; export type { TableProps } from './md-table';
export type { BgProps } from './md-bg'; export type { BgProps } from './md-bg';
export type { YarnSpinnerProps } from './md-yarn-spinner'; export type { YarnSpinnerProps } from './md-yarn-spinner';
export type { TokenProps } from './md-token';
// 导出 md-commander 相关 // 导出 md-commander 相关
export type { export type {

View File

@ -0,0 +1,211 @@
import {
createSignal,
onMount,
onCleanup,
Show, createEffect,
} from "solid-js";
import * as THREE from "three";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
export interface TokenViewerProps {
stlUrl: string | null;
}
export default function MdTokenViewer(props: TokenViewerProps) {
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);
mesh.rotateX(Math.PI);
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();
console.log("Viewer element:", viewerEl);
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();
});
});
createEffect(() => {
if (props.stlUrl) {
loadSTL(props.stlUrl);
}
});
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>
);
}

313
src/components/md-token.tsx Normal file
View File

@ -0,0 +1,313 @@
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 { generateSTL, type ExtrusionSettings } from "./utils/stl-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 [stlUrl, setStlUrl] = 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 || `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);
} 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))
);
};
// 下载 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>
<MdTokenViewer stlUrl={stlUrl()}/>
</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>
);
});

View File

@ -0,0 +1,312 @@
import { ImageTracer, type TraceData, type OutlinedArea, type SvgLineAttributes, Options } from "@image-tracer-ts/core";
export interface TracedLayer {
id: string;
name: string;
color: string;
paths: PathData[];
}
export interface PathData {
points: Point[];
isClosed: boolean;
}
export interface Point {
x: number;
y: number;
}
export interface TraceResult {
width: number;
height: number;
layers: TracedLayer[];
}
interface SvgPath {
color: string;
d: string;
path: PathData;
}
/**
*
* @param image -
* @param options -
* @returns
*/
export async function traceImage(
image: HTMLImageElement,
options?: Partial<Options>
): Promise<TraceResult> {
// 创建 canvas 来获取图片数据
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("无法创建 canvas 上下文");
}
// 设置 canvas 尺寸为图片原始尺寸
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
// 绘制图片到 canvas
ctx.drawImage(image, 0, 0);
// 获取图片数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 默认配置 - 使用 detailed preset 作为基础
const defaultOptions: Partial<Options> = {
...Options.Presets.detailed,
numberOfColors: 8, // 限制颜色数量以控制图层数
minColorQuota: 0.001, // 降低最小颜色占比阈值
strokeWidth: 0, // 不需要描边
lineFilter: false, // 保留所有线条
...options,
};
// 创建追踪器
const tracer = new ImageTracer(defaultOptions);
// 执行追踪,返回 SVG 字符串
const svgString = tracer.traceImage(imageData);
// 解析 SVG 字符串
const paths = parseSVGString(svgString);
// 将路径按颜色分组为图层
const colorMap = new Map<string, PathData[]>();
for (const path of paths) {
if (!colorMap.has(path.color)) {
colorMap.set(path.color, []);
}
colorMap.get(path.color)!.push(path.path);
}
const layers: TracedLayer[] = [];
let layerIndex = 0;
for (const [color, pathDatas] of colorMap.entries()) {
layers.push({
id: `layer-${layerIndex}`,
name: `颜色层 ${layerIndex + 1}`,
color: color,
paths: pathDatas,
});
layerIndex++;
}
return {
width: canvas.width,
height: canvas.height,
layers,
};
}
/**
* SVG
*/
function parseSVGString(svgString: string): SvgPath[] {
const paths: SvgPath[] = [];
// 匹配所有 path 元素
const pathRegex = /<path[^>]*\/?>/g;
let match;
while ((match = pathRegex.exec(svgString)) !== null) {
const pathTag = match[0];
// 提取 d 属性(路径数据)
const dMatch = pathTag.match(/d="([^"]+)"/);
if (!dMatch) continue;
const d = dMatch[1];
// 提取 fill 颜色
const fillMatch = pathTag.match(/fill="([^"]+)"/);
let color = '#000000';
if (fillMatch) {
color = fillMatch[1];
// 处理 rgb() 格式
if (color.startsWith('rgb(')) {
const rgbValues = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (rgbValues) {
const r = parseInt(rgbValues[1]);
const g = parseInt(rgbValues[2]);
const b = parseInt(rgbValues[3]);
color = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
}
}
// 解析路径数据
const pathDatas = parseSVGPathData(d);
// 为每个解析出的路径创建一个 SvgPath
for (const pathData of pathDatas) {
paths.push({ color, d, path: pathData });
}
}
return paths;
}
/**
* SVG
*/
function parseSVGPathData(d: string): PathData[] {
const paths: PathData[] = [];
// 分割路径命令
const commands = d.split(/(?=[MZLQCSHVTA])/g);
let currentPoints: Point[] = [];
let isClosed = false;
for (const cmd of commands) {
const type = cmd[0];
const values = cmd.slice(1)
.trim()
.split(/[\s,]+/)
.map(Number)
.filter(n => !isNaN(n));
if (type === 'M') {
// 如果有未完成的路径,保存它
if (currentPoints.length > 0) {
paths.push({ points: [...currentPoints], isClosed });
currentPoints = [];
isClosed = false;
}
// 移动到起点
for (let i = 0; i < values.length; i += 2) {
if (i + 1 < values.length) {
currentPoints.push({ x: values[i], y: values[i + 1] });
}
}
} else if (type === 'L') {
// 直线
for (let i = 0; i < values.length; i += 2) {
if (i + 1 < values.length) {
currentPoints.push({ x: values[i], y: values[i + 1] });
}
}
} else if (type === 'Q') {
// 二次贝塞尔曲线 - 简化处理
for (let i = 0; i < values.length; i += 4) {
if (i + 3 < values.length) {
const [cpX, cpY, endX, endY] = [values[i], values[i + 1], values[i + 2], values[i + 3]];
// 使用控制点和终点的中点作为近似
currentPoints.push({
x: (cpX + endX) / 2,
y: (cpY + endY) / 2,
});
}
}
} else if (type === 'C') {
// 三次贝塞尔曲线 - 简化处理
for (let i = 0; i < values.length; i += 6) {
if (i + 5 < values.length) {
const [cp1X, cp1Y, cp2X, cp2Y, endX, endY] = values.slice(i, i + 6);
// 使用两个控制点的中点作为近似
currentPoints.push({
x: (cp1X + cp2X) / 2,
y: (cp1Y + cp2Y) / 2,
});
currentPoints.push({ x: endX, y: endY });
}
}
} else if (type === 'Z' || type === 'z') {
isClosed = true;
}
}
// 保存最后一个路径
if (currentPoints.length > 0) {
paths.push({ points: currentPoints, isClosed });
}
return paths;
}
/**
* OutlinedArea
*/
function parseOutlinedArea(area: OutlinedArea): PathData | null {
const points: Point[] = [];
let isClosed = false;
if (!area.lineAttributes || area.lineAttributes.length === 0) {
return null;
}
// 收集所有唯一点
const pointMap = new Map<string, Point>();
const orderedPoints: Point[] = [];
for (const attr of area.lineAttributes) {
// 添加起点
const startKey = `${attr.x1},${attr.y1}`;
if (!pointMap.has(startKey)) {
const startPoint = { x: attr.x1, y: attr.y1 };
pointMap.set(startKey, startPoint);
orderedPoints.push(startPoint);
}
// 添加终点
const endKey = `${attr.x2},${attr.y2}`;
if (!pointMap.has(endKey)) {
const endPoint = { x: attr.x2, y: attr.y2 };
pointMap.set(endKey, endPoint);
orderedPoints.push(endPoint);
}
// 如果是 Q 类型,添加控制点相关的近似点
if (attr.type === 'Q' && 'x3' in attr) {
// 使用控制点和终点的中点作为近似
const midX = (attr.x1 + attr.x2 + (attr as any).x3) / 3;
const midY = (attr.y1 + attr.y2 + (attr as any).y3) / 3;
const midKey = `${midX},${midY}`;
if (!pointMap.has(midKey)) {
const midPoint = { x: midX, y: midY };
pointMap.set(midKey, midPoint);
orderedPoints.push(midPoint);
}
}
}
// 使用有序点
points.push(...orderedPoints);
// 检查是否闭合(起点和终点接近)
if (points.length >= 2) {
const first = points[0];
const last = points[points.length - 1];
const dist = Math.sqrt(
Math.pow(last.x - first.x, 2) + Math.pow(last.y - first.y, 2)
);
isClosed = dist < 5; // 距离小于 5 像素视为闭合
}
if (points.length === 0) {
return null;
}
return {
points,
isClosed,
};
}
function toHex(n: number): string {
const hex = Math.round(Math.min(255, Math.max(0, n))).toString(16);
return hex.length === 1 ? "0" + hex : hex;
}
/**
* RGB
*/
function rgbToHex(rgb: { r: number; g: number; b: number; a?: number }): string {
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
}

View File

@ -0,0 +1,259 @@
import * as THREE from "three";
import type { TraceResult, PathData, Point } from "./image-tracer";
export interface ExtrusionSettings {
size: number; // 模型整体尺寸 (mm)
layers: Array<{
id: string;
thickness: number; // 图层厚度 (mm)
}>;
}
export interface LayerMesh {
id: string;
mesh: THREE.Mesh;
thickness: number;
}
/**
* STL
* @param image -
* @param traceResult -
* @param settings -
* @returns STL Blob
*/
export async function generateSTL(
image: HTMLImageElement,
traceResult: TraceResult,
settings: ExtrusionSettings
): Promise<Blob> {
// 创建 Three.js 场景
const scene = new THREE.Scene();
// 计算缩放比例,使模型适应指定尺寸
const maxDimension = Math.max(traceResult.width, traceResult.height);
const scale = settings.size / maxDimension;
// 中心偏移
const offsetX = -traceResult.width / 2;
const offsetY = -traceResult.height / 2;
// 为每个启用的图层创建网格
let currentHeight = 0;
const meshes: LayerMesh[] = [];
for (const layerSetting of settings.layers) {
const layer = traceResult.layers.find((l) => l.id === layerSetting.id);
if (!layer) continue;
// 为该图层的所有路径创建形状
const shapes: THREE.Shape[] = [];
for (const path of layer.paths) {
if (path.points.length < 2) continue;
const shape = createShapeFromPath(path, scale, offsetX, offsetY);
if (shape) {
shapes.push(shape);
}
}
if (shapes.length === 0) continue;
// 创建挤压几何体
const extrudeSettings: THREE.ExtrudeGeometryOptions = {
depth: layerSetting.thickness,
curveSegments: 36,
bevelEnabled: false,
};
// 如果有多个形状,创建多个几何体并合并
const geometries: THREE.ExtrudeGeometry[] = [];
for (const shape of shapes) {
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
geometries.push(geometry);
}
// 合并同一图层的几何体
let combinedGeometry;
if (geometries.length === 1) {
combinedGeometry = geometries[0];
} else {
combinedGeometry = mergeGeometries(geometries);
}
// 创建网格并设置位置
const material = new THREE.MeshBasicMaterial({ color: 0x808080 });
const mesh = new THREE.Mesh(combinedGeometry, material);
// 设置图层高度(堆叠)
mesh.position.y = currentHeight;
scene.add(mesh);
meshes.push({
id: layerSetting.id,
mesh,
thickness: layerSetting.thickness,
});
currentHeight += layerSetting.thickness;
}
if (meshes.length === 0) {
throw new Error("没有可生成的图层");
}
// 导出为 STL
const stlString = exportToSTL(scene);
const blob = new Blob([stlString], { type: "model/stl" });
// 清理
scene.clear();
meshes.forEach(({ mesh }) => {
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
});
return blob;
}
/**
* Three.js
*/
function createShapeFromPath(
path: PathData,
scale: number,
offsetX: number,
offsetY: number
): THREE.Shape | null {
if (path.points.length < 2) return null;
const shape = new THREE.Shape();
// 移动到起点
const startPoint = path.points[0];
shape.moveTo(
(startPoint.x + offsetX) * scale,
(startPoint.y + offsetY) * scale
);
// 绘制线段到后续点
for (let i = 1; i < path.points.length; i++) {
const point = path.points[i];
shape.lineTo(
(point.x + offsetX) * scale,
(point.y + offsetY) * scale
);
}
// 如果是闭合路径,闭合形状
if (path.isClosed) {
shape.closePath();
}
return shape;
}
/**
*
*/
function mergeGeometries(
geometries: THREE.ExtrudeGeometry[]
): THREE.ExtrudeGeometry {
// 使用 Three.js 的 mergeGeometries 工具
const mergedGeometry = geometries[0].clone();
for (let i = 1; i < geometries.length; i++) {
// 手动合并顶点数据
const geometry = geometries[i];
const positionAttribute = geometry.getAttribute("position");
const normalAttribute = geometry.getAttribute("normal");
const uvAttribute = geometry.getAttribute("uv");
if (positionAttribute) {
const positions = mergedGeometry.getAttribute("position");
const newPositions = new Float32Array(
positions.array.length + positionAttribute.array.length
);
newPositions.set(positions.array);
newPositions.set(positionAttribute.array, positions.array.length);
mergedGeometry.setAttribute(
"position",
new THREE.BufferAttribute(newPositions, 3)
);
}
if (normalAttribute) {
const normals = mergedGeometry.getAttribute("normal");
const newNormals = new Float32Array(
normals.array.length + normalAttribute.array.length
);
newNormals.set(normals.array);
newNormals.set(normalAttribute.array, normals.array.length);
mergedGeometry.setAttribute(
"normal",
new THREE.BufferAttribute(newNormals, 3)
);
}
if (uvAttribute) {
const uvs = mergedGeometry.getAttribute("uv");
const newUvs = new Float32Array(
uvs.array.length + uvAttribute.array.length
);
newUvs.set(uvs.array);
newUvs.set(uvAttribute.array, uvs.array.length);
mergedGeometry.setAttribute(
"uv",
new THREE.BufferAttribute(newUvs, 2)
);
}
}
mergedGeometry.computeVertexNormals();
return mergedGeometry;
}
/**
* Three.js ASCII STL
*/
function exportToSTL(scene: THREE.Scene): string {
let output = "solid token\n";
scene.traverse((object) => {
if (object instanceof THREE.Mesh && object.geometry) {
const geometry = object.geometry as THREE.BufferGeometry;
const positions = geometry.getAttribute("position") as THREE.BufferAttribute;
const normals = geometry.getAttribute("normal") as THREE.BufferAttribute;
for (let i = 0; i < positions.count; i += 3) {
// 获取法线
let normalStr = "";
if (normals) {
const nx = normals.getX(i);
const ny = normals.getY(i);
const nz = normals.getZ(i);
normalStr = ` normal ${nx.toFixed(6)} ${ny.toFixed(6)} ${nz.toFixed(6)}`;
}
output += ` facet${normalStr}\n`;
output += " outer loop\n";
// 获取三个顶点
for (let j = 0; j < 3; j++) {
const x = positions.getX(i + j);
const y = positions.getY(i + j);
const z = positions.getZ(i + j);
output += ` vertex ${x.toFixed(6)} ${y.toFixed(6)} ${z.toFixed(6)}\n`;
}
output += " endloop\n";
output += " endfacet\n";
}
}
});
output += "endsolid token\n";
return output;
}