From 8c33dc282ba626f801e1a5468245dec25e354437 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 15 Mar 2026 18:45:20 +0800 Subject: [PATCH] feat: md-token --- package-lock.json | 75 +++- package.json | 5 +- src/components/index.ts | 2 + src/components/md-token.tsx | 486 ++++++++++++++++++++++++++ src/components/utils/image-tracer.ts | 312 +++++++++++++++++ src/components/utils/stl-generator.ts | 258 ++++++++++++++ 6 files changed, 1136 insertions(+), 2 deletions(-) create mode 100644 src/components/md-token.tsx create mode 100644 src/components/utils/image-tracer.ts create mode 100644 src/components/utils/stl-generator.ts diff --git a/package-lock.json b/package-lock.json index 2783cf6..5e238de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@solidjs/router": "^0.15.0", + "@types/three": "^0.183.1", "chokidar": "^5.0.0", "commander": "^14.0.3", "csv-parse": "^6.1.0", @@ -20,12 +21,14 @@ "marked-gfm-heading-id": "^4.1.3", "mermaid": "^11.0.0", "solid-element": "^1.9.1", - "solid-js": "^1.9.3" + "solid-js": "^1.9.3", + "three": "^0.183.2" }, "bin": { "ttrpg": "dist/cli/index.js" }, "devDependencies": { + "@image-tracer-ts/core": "^1.0.2", "@rsbuild/core": "^1.1.8", "@rsbuild/plugin-babel": "^1.1.0", "@rsbuild/plugin-solid": "^1.1.0", @@ -836,6 +839,12 @@ "@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": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -1355,6 +1364,13 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3475,6 +3491,12 @@ "dev": true, "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": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3882,6 +3904,27 @@ "dev": true, "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": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -3896,6 +3939,12 @@ "license": "MIT", "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": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3913,6 +3962,12 @@ "dev": true, "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": { "version": "2.0.6", "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": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -8607,6 +8668,12 @@ "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": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -9685,6 +9752,12 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", diff --git a/package.json b/package.json index ce8f712..3c8e89e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "license": "MIT", "dependencies": { "@solidjs/router": "^0.15.0", + "@types/three": "^0.183.1", "chokidar": "^5.0.0", "commander": "^14.0.3", "csv-parse": "^6.1.0", @@ -42,9 +43,11 @@ "marked-gfm-heading-id": "^4.1.3", "mermaid": "^11.0.0", "solid-element": "^1.9.1", - "solid-js": "^1.9.3" + "solid-js": "^1.9.3", + "three": "^0.183.2" }, "devDependencies": { + "@image-tracer-ts/core": "^1.0.2", "@rsbuild/core": "^1.1.8", "@rsbuild/plugin-babel": "^1.1.0", "@rsbuild/plugin-solid": "^1.1.0", diff --git a/src/components/index.ts b/src/components/index.ts index faa9abf..ebf8434 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,6 +7,7 @@ import './md-bg'; import './md-deck'; import './md-commander/index'; import './md-yarn-spinner'; +import './md-token'; // 导出组件 export { Article } from './Article'; @@ -20,6 +21,7 @@ export type { DiceProps } from './md-dice'; export type { TableProps } from './md-table'; export type { BgProps } from './md-bg'; export type { YarnSpinnerProps } from './md-yarn-spinner'; +export type { TokenProps } from './md-token'; // 导出 md-commander 相关 export type { diff --git a/src/components/md-token.tsx b/src/components/md-token.tsx new file mode 100644 index 0000000..de70f15 --- /dev/null +++ b/src/components/md-token.tsx @@ -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([]); + const [stlUrl, setStlUrl] = createSignal(null); + const [isGenerating, setIsGenerating] = createSignal(false); + const [error, setError] = createSignal(null); + const [viewerRef, setViewerRef] = createSignal(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 => { + 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 ( +
+ +
+ {/* 预览区域 */} +
+ {/* 原始图片预览 */} +
+

原始图片

+
+ Token source +
+
+ + {/* STL 预览 (如果有) */} + +
+

+ 3D 模型预览 +

+
+
+ 拖动旋转 | 滚动缩放 +
+
+
+
+
+ + {/* 错误信息 */} + +
+ ⚠️ {error()} +
+
+ + {/* 控制面板 */} +
+
+

+ 🎯 Token 设置 +

+ +
+ + +
+ {/* 整体尺寸设置 */} +
+ + { + const newSize = parseInt(e.target.value); + element?.setAttribute("size", newSize.toString()); + }} + /> + + {props.size || 50}mm + +
+ + {/* 图层列表 */} +
+

+ 图层设置 +

+
+ + {(layer) => ( +
+ toggleLayer(layer.id)} + class="w-4 h-4" + /> +
+ + {layer.name} + + + + updateLayerThickness( + layer.id, + parseFloat(e.target.value) || 2 + ) + } + /> + mm +
+ )} + +
+
+ + {/* 生成按钮 */} +
+ + + + +
+
+ +
+
+ + + {/* 加载状态 */} + +
+
+ 加载图片中... +
+ +
+ ); +}); diff --git a/src/components/utils/image-tracer.ts b/src/components/utils/image-tracer.ts new file mode 100644 index 0000000..6b35098 --- /dev/null +++ b/src/components/utils/image-tracer.ts @@ -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 +): Promise { + // 创建 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.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(); + 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 = /]*\/?>/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(); + 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)}`; +} diff --git a/src/components/utils/stl-generator.ts b/src/components/utils/stl-generator.ts new file mode 100644 index 0000000..89f944e --- /dev/null +++ b/src/components/utils/stl-generator.ts @@ -0,0 +1,258 @@ +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 { + // 创建 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, + 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; +}