feat: md-token
This commit is contained in:
@@ -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)}`;
|
||||
}
|
||||
@@ -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<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,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user