diff --git a/package-lock.json b/package-lock.json index 5e238de..abef164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "mermaid": "^11.0.0", "solid-element": "^1.9.1", "solid-js": "^1.9.3", - "three": "^0.183.2" + "three": "^0.183.2", + "three-3mf-exporter": "^45.1.0" }, "bin": { "ttrpg": "dist/cli/index.js" @@ -4655,6 +4656,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -5757,6 +5764,41 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", + "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz", + "integrity": "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.3", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -6156,6 +6198,12 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -6202,7 +6250,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internmap": { @@ -6287,6 +6334,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8133,6 +8186,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/katex": { "version": "0.16.33", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz", @@ -8206,6 +8271,15 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -8946,6 +9020,12 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -8994,6 +9074,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -9188,6 +9283,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -9256,6 +9357,21 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -9417,6 +9533,12 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9467,6 +9589,12 @@ "seroval": "^1.0" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9602,6 +9730,15 @@ "node": ">=10" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9677,6 +9814,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -9758,6 +9907,19 @@ "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", "license": "MIT" }, + "node_modules/three-3mf-exporter": { + "version": "45.1.0", + "resolved": "https://registry.npmjs.org/three-3mf-exporter/-/three-3mf-exporter-45.1.0.tgz", + "integrity": "sha512-lNQSK+dHaHVBsviJsViMlniBjb/hRUKIHHf5Fjnppv/wqCtbJiseuzjpptJV5jiyY3zsdjLgnKTQ/g4COgMvmw==", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.0.9", + "jszip": "^3.10.1" + }, + "peerDependencies": { + "three": "*" + } + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -10104,7 +10266,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { diff --git a/package.json b/package.json index 3c8e89e..89ddfa5 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "mermaid": "^11.0.0", "solid-element": "^1.9.1", "solid-js": "^1.9.3", - "three": "^0.183.2" + "three": "^0.183.2", + "three-3mf-exporter": "^45.1.0" }, "devDependencies": { "@image-tracer-ts/core": "^1.0.2", diff --git a/src/components/md-token-viewer.tsx b/src/components/md-token-viewer.tsx index 5a06d47..5dbb529 100644 --- a/src/components/md-token-viewer.tsx +++ b/src/components/md-token-viewer.tsx @@ -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(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); } diff --git a/src/components/md-token.tsx b/src/components/md-token.tsx index 4d396b8..2da1ef1 100644 --- a/src/components/md-token.tsx +++ b/src/components/md-token.tsx @@ -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([]); - const [stlUrl, setStlUrl] = createSignal(null); + const [modelUrl, setModelUrl] = createSignal(null); const [isGenerating, setIsGenerating] = createSignal(false); const [error, setError] = createSignal(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 } - {/* STL 预览 (如果有) */} - + {/* 3D 模型预览 */} +

3D 模型预览

- +
@@ -286,12 +286,12 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element } > {isGenerating() ? "生成中..." : "🔄 生成 3D 模型"} - + diff --git a/src/components/utils/3mf-generator.ts b/src/components/utils/3mf-generator.ts new file mode 100644 index 0000000..3db2cc3 --- /dev/null +++ b/src/components/utils/3mf-generator.ts @@ -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 { + // 创建 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; +}