refactor: stl->3mf

This commit is contained in:
hyper
2026-03-15 23:17:16 +08:00
parent 7be206581d
commit 2476659fc6
5 changed files with 464 additions and 52 deletions
+48 -31
View File
@@ -5,13 +5,13 @@ import {
Show, createEffect,
} from "solid-js";
import * as THREE from "three";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import { ThreeMFLoader } from "three/addons/loaders/3MFLoader.js";
export interface TokenViewerProps {
stlUrl: string | null;
}
export default function MdTokenViewer(props: TokenViewerProps) {
export default function MdTokenViewer(props: TokenViewerProps) {
const [viewerRef, setViewerRef] = createSignal<HTMLDivElement | null>(null);
const [isLoaded, setIsLoaded] = createSignal(false);
@@ -21,49 +21,66 @@ export default function MdTokenViewer(props: TokenViewerProps) {
let camera: THREE.PerspectiveCamera | null = null;
let renderer: THREE.WebGLRenderer | null = null;
let mesh: THREE.Mesh | null = null;
let group: THREE.Group | null = null;
let animationId: number | null = null;
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
// 加载 STL 用于预览
// 加载 3MF 用于预览
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();
if (group) {
scene?.remove(group);
group.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
const meshObj = obj as THREE.Mesh;
meshObj.geometry.dispose();
(meshObj.material as THREE.Material).dispose();
}
});
group = null;
mesh = null;
}
try {
const response = await fetch(url);
const stlText = await response.text();
const loader = new ThreeMFLoader();
const object = await loader.loadAsync(url);
// 解析 STL
const loader = new STLLoader();
const geometry = loader.parse(stlText);
// 3MF 文件可能返回一个 Group,包含多个 Mesh
group = object instanceof THREE.Group ? object : new THREE.Group().add(object);
// 计算边界并居中
geometry.center();
// 居中并调整方向
group.center();
group.rotateX(Math.PI);
// 创建材质
const material = new THREE.MeshStandardMaterial({
color: 0x808080,
metalness: 0.3,
roughness: 0.7,
side: THREE.DoubleSide,
// 为每个 mesh 启用原始颜色
group.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const childMesh = child as THREE.Mesh;
// 保留原始顶点颜色或材质颜色
if (childMesh.geometry.attributes.color) {
childMesh.material.vertexColors = true;
}
// 确保材质是标准材质以支持光照
if (!(childMesh.material instanceof THREE.MeshStandardMaterial)) {
childMesh.material = new THREE.MeshStandardMaterial({
color: childMesh.material.color,
metalness: 0.3,
roughness: 0.7,
});
}
}
});
mesh = new THREE.Mesh(geometry, material);
mesh.rotateX(Math.PI);
scene?.add(mesh);
scene?.add(group);
mesh = group.children[0] as THREE.Mesh;
// 调整相机
if (camera && scene) {
const box = new THREE.Box3().setFromObject(mesh);
const box = new THREE.Box3().setFromObject(group);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
@@ -73,8 +90,8 @@ export default function MdTokenViewer(props: TokenViewerProps) {
setIsLoaded(true);
} catch (e) {
console.error("加载 STL 预览失败:", e);
setError(e instanceof Error ? e.message : "加载 STL 失败");
console.error("加载 3MF 预览失败:", e);
setError(e instanceof Error ? e.message : "加载模型失败");
}
};
@@ -125,13 +142,13 @@ export default function MdTokenViewer(props: TokenViewerProps) {
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !mesh) return;
if (!isDragging || !group) 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;
group.rotation.y += deltaX * 0.01;
group.rotation.x += deltaY * 0.01;
previousMousePosition = { x: e.clientX, y: e.clientY };
};
@@ -154,9 +171,9 @@ export default function MdTokenViewer(props: TokenViewerProps) {
// 动画循环
const animate = () => {
if (scene && camera && renderer && mesh) {
if (scene && camera && renderer && group) {
if (!isDragging) {
mesh.rotation.y += 0.005; // 自动旋转
group.rotation.y += 0.005; // 自动旋转
}
renderer.render(scene, camera);
}
+17 -17
View File
@@ -9,7 +9,7 @@ import {
} 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 { generate3MF, type ExtrusionSettings } from "./utils/3mf-generator";
import MdTokenViewer from "./md-token-viewer";
export interface TokenProps {
@@ -30,7 +30,7 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
const [showEditor, setShowEditor] = createSignal(false);
const [layers, setLayers] = createSignal<LayerSettings[]>([]);
const [stlUrl, setStlUrl] = createSignal<string | null>(null);
const [modelUrl, setModelUrl] = createSignal<string | null>(null);
const [isGenerating, setIsGenerating] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
@@ -91,7 +91,7 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
}
});
// 生成 STL 模型
// 生成 3MF 模型
const generateModel = async () => {
if (!image()) return;
@@ -114,9 +114,9 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
})),
};
const stlBlob = await generateSTL(image()!, traceResult()!, settings);
const url = URL.createObjectURL(stlBlob);
setStlUrl(url);
const modelBlob = await generate3MF(image()!, traceResult()!, settings);
const url = URL.createObjectURL(modelBlob);
setModelUrl(url);
} catch (e) {
setError(e instanceof Error ? e.message : "生成模型失败");
} finally {
@@ -138,20 +138,20 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
);
};
// 下载 STL 文件
const downloadSTL = () => {
const url = stlUrl();
// 下载 3MF 文件
const downloadModel = () => {
const url = modelUrl();
if (!url) return;
const a = document.createElement("a");
a.href = url;
a.download = `token-${Date.now()}.stl`;
a.download = `token-${Date.now()}.3mf`;
a.click();
};
// 清理 URL
onCleanup(() => {
const url = stlUrl();
const url = modelUrl();
if (url) {
URL.revokeObjectURL(url);
}
@@ -177,13 +177,13 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
</div>
</div>
{/* STL 预览 (如果有) */}
<Show when={stlUrl()}>
{/* 3D 模型预览 */}
<Show when={modelUrl()}>
<div class="flex-1 min-w-[200px]">
<h4 class="text-sm font-semibold mb-2 text-gray-700">
3D
</h4>
<MdTokenViewer stlUrl={stlUrl()}/>
<MdTokenViewer stlUrl={modelUrl()}/>
</div>
</Show>
</div>
@@ -286,12 +286,12 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
>
{isGenerating() ? "生成中..." : "🔄 生成 3D 模型"}
</button>
<Show when={stlUrl()}>
<Show when={modelUrl()}>
<button
onClick={downloadSTL}
onClick={downloadModel}
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded font-medium transition-colors"
>
📥 STL
📥 3MF
</button>
</Show>
</div>
+233
View File
@@ -0,0 +1,233 @@
import * as THREE from "three";
import { exportTo3MF } from "three-3mf-exporter";
import type { TraceResult, PathData } 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;
color: number;
}
/**
* 从图层索引生成颜色(使用黄金角确保颜色分散)
*/
function generateLayerColor(index: number): number {
const hue = (index * 137.508) % 360; // 黄金角
return new THREE.Color(`hsl(${hue}, 70%, 50%)`).getHex();
}
/**
* 将矢量路径生成 3MF 文件
* @param image - 原始图片
* @param traceResult - 矢量追踪结果
* @param settings - 挤压设置
* @returns 3MF Blob
*/
export async function generate3MF(
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[] = [];
let layerIndex = 0;
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 color = generateLayerColor(layerIndex);
const material = new THREE.MeshStandardMaterial({
color,
metalness: 0.3,
roughness: 0.7,
});
const mesh = new THREE.Mesh(combinedGeometry, material);
// 设置图层高度(堆叠)
mesh.position.y = currentHeight;
scene.add(mesh);
meshes.push({
id: layerSetting.id,
mesh,
thickness: layerSetting.thickness,
color,
});
currentHeight += layerSetting.thickness;
layerIndex++;
}
if (meshes.length === 0) {
throw new Error("没有可生成的图层");
}
// 导出为 3MF
const data = await exportTo3MF(scene);
const blob = new Blob([data], { type: "model/3mf" });
// 清理
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 {
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;
}