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

259 lines
6.7 KiB
TypeScript
Raw Normal View History

2026-03-15 18:45:20 +08:00
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;
}