Compare commits
3 Commits
a144af6015
...
c3f71f8be1
| Author | SHA1 | Date |
|---|---|---|
|
|
c3f71f8be1 | |
|
|
940caaf8e4 | |
|
|
2476659fc6 |
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,72 @@ 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();
|
||||
// 计算边界框并居中
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
group.position.x = -center.x;
|
||||
group.position.y = -center.y;
|
||||
group.position.z = -center.z;
|
||||
|
||||
// 创建材质
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: 0x808080,
|
||||
metalness: 0.3,
|
||||
roughness: 0.7,
|
||||
side: THREE.DoubleSide,
|
||||
group.rotateX(Math.PI);
|
||||
|
||||
// 为每个 mesh 启用原始颜色
|
||||
group.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const childMesh = child as THREE.Mesh;
|
||||
const material = childMesh.material as THREE.MeshStandardMaterial;
|
||||
// 保留原始顶点颜色或材质颜色
|
||||
if (childMesh.geometry.attributes.color) {
|
||||
material.vertexColors = true;
|
||||
}
|
||||
// 确保材质是标准材质以支持光照
|
||||
if (!(childMesh.material instanceof THREE.MeshStandardMaterial)) {
|
||||
childMesh.material = new THREE.MeshStandardMaterial({
|
||||
color: 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 +96,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 : "加载模型失败");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -124,13 +147,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 };
|
||||
};
|
||||
|
|
@ -145,9 +168,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue