ttrpg-tools/src/components/utils/stl-generator.ts

173 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// 创建挤压几何体 - Three.js 支持直接传入 shape 数组
const extrudeSettings: THREE.ExtrudeGeometryOptions = {
depth: layerSetting.thickness,
curveSegments: 12,
bevelEnabled: false,
};
// 直接将所有 shape 传给 ExtrudeGeometry它会自动处理多个 shape
const geometry = new THREE.ExtrudeGeometry(layer.paths, extrudeSettings);
// 创建网格并设置位置
const material = new THREE.MeshBasicMaterial({ color: 0x808080 });
const mesh = new THREE.Mesh(geometry, 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;
}
/**
* 将 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;
}