代码拉取完成,页面将自动刷新
const fs = require('fs')
const path = require('path')
const { format } = require('date-fns')
const express = require('express')
const chokidar = require('chokidar')
const http = require('http')
const WebSocket = require('ws')
const { NodeIO } = require('@gltf-transform/core')
const { ALL_EXTENSIONS } = require('@gltf-transform/extensions')
const { mat4, vec3, quat } = require('gl-matrix')
const draco3d = require('draco3d')
let MODELS_DIR = path.join(__dirname, 'models')
let SERVE_MODE = false
let PORT = 3000
const args = process.argv.slice(2)
for (let i = 0; i < args.length; i++) {
if (args[i] === '-p' && args[i + 1]) {
MODELS_DIR = path.resolve(args[i + 1])
}
if (args[i] === '-serve') {
SERVE_MODE = true
}
if (args[i] === '-port' && args[i + 1]) {
PORT = parseInt(args[i + 1])
}
}
const timestamp = format(new Date(), 'yyyyMMdd_HHmmss')
const OUTPUT_FILE = path.join(__dirname, `output_${timestamp}.json`)
let result = {}
const warnings = []
async function getModelSize(filePath) {
try {
const encoderModule = await draco3d.createEncoderModule()
const decoderModule = await draco3d.createDecoderModule()
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({
'draco3d.decoder': decoderModule,
'draco3d.encoder': encoderModule,
})
const document = await io.read(filePath)
const root = document.getRoot()
const scenes = root.listScenes()
let globalMin = [Infinity, Infinity, Infinity]
let globalMax = [-Infinity, -Infinity, -Infinity]
for (const scene of scenes) {
for (const node of scene.listChildren()) {
await traverseNode(node, mat4.create())
}
}
const size = [
Number((globalMax[0] - globalMin[0]).toFixed(3)),
Number((globalMax[1] - globalMin[1]).toFixed(3)),
Number((globalMax[2] - globalMin[2]).toFixed(3)),
]
return size
// ⛏️ 自己构造局部矩阵,并递归传递
async function traverseNode(node, parentMatrix) {
const translation = node.getTranslation() || [0, 0, 0]
const rotation = node.getRotation() || [0, 0, 0, 1]
const scale = node.getScale() || [1, 1, 1]
const localMatrix = mat4.fromRotationTranslationScale(
mat4.create(),
quat.fromValues(...rotation),
vec3.fromValues(...translation),
vec3.fromValues(...scale)
)
const worldMatrix = mat4.multiply(mat4.create(), parentMatrix, localMatrix)
if (node.getMesh()) {
const mesh = node.getMesh()
for (const prim of mesh.listPrimitives()) {
const position = prim.getAttribute('POSITION')
if (!position) continue
const array = position.getArray()
const itemSize = position.getElementSize()
const count = array.length / itemSize
for (let i = 0; i < count; i++) {
const vertex = vec3.fromValues(
array[i * itemSize],
array[i * itemSize + 1],
array[i * itemSize + 2]
)
vec3.transformMat4(vertex, vertex, worldMatrix)
for (let j = 0; j < 3; j++) {
globalMin[j] = Math.min(globalMin[j], vertex[j])
globalMax[j] = Math.max(globalMax[j], vertex[j])
}
}
}
}
for (const child of node.listChildren()) {
await traverseNode(child, worldMatrix)
}
}
} catch (e) {
console.warn(`❌ 获取模型尺寸失败: ${filePath}\n→ ${e.message}`)
return { x: null, y: null, z: null }
}
}
async function generateConfig() {
result = {}
warnings.length = 0
const categories = fs.readdirSync(MODELS_DIR).filter((dir) => {
if (dir === '.DS_Store') return false
const fullPath = path.join(MODELS_DIR, dir)
return fs.statSync(fullPath).isDirectory()
})
for (const category of categories) {
const categoryPath = path.join(MODELS_DIR, category)
const modelsPath = path.join(categoryPath, 'models')
const previewsPath = path.join(categoryPath, 'previews')
if (!fs.existsSync(modelsPath) || !fs.existsSync(previewsPath)) {
console.warn(
`⚠️ Skipping "${category}" due to missing models or previews folder.`
)
return
}
const modelFiles = fs
.readdirSync(modelsPath)
.filter(
(f) =>
f !== '.DS_Store' && fs.statSync(path.join(modelsPath, f)).isFile()
)
const previewFiles = fs
.readdirSync(previewsPath)
.filter(
(f) =>
f !== '.DS_Store' && fs.statSync(path.join(previewsPath, f)).isFile()
)
const previewMap = {}
previewFiles.forEach((file) => {
const name = path.parse(file).name
previewMap[name] = file
})
result[category] = []
for (const modelFile of modelFiles) {
const modelName = path.parse(modelFile).name
const previewFile = previewMap[modelName]
if (!previewFile) {
warnings.push(
`⚠️ Missing preview image for model: ${category}/models/${modelFile}`
)
}
const modelPathAbs = path.join(modelsPath, modelFile)
const size = await getModelSize(modelPathAbs)
result[category].push({
name: modelName,
category,
modelPath: path.join(category, 'models', modelFile),
previewPath: previewFile
? path.join(category, 'previews', previewFile)
: null,
size,
})
}
}
}
const { makeMCPconfig } = require('./utils.js')
function startServer() {
const app = express()
const server = http.createServer(app)
const wss = new WebSocket.Server({ server })
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
next()
})
app.use('/static', express.static(MODELS_DIR))
app.get('/config', (req, res) => {
res.json({
isGenerateModeServe: true,
prePath: `http://localhost:${PORT}/static/`,
data: result,
})
})
app.get('/MCPconfig', (req, res) => {
res.json({
isGenerateModeServe: true,
data: makeMCPconfig(result),
})
})
app.get('/', (req, res) => {
res.send(generateHTML(result))
})
server.listen(PORT, () => {
console.log(`🚀 本地模型浏览服务已启动:http://localhost:${PORT}`)
console.log(`📦 配置文件接口:http://localhost:${PORT}/config`)
console.log(`📦 MCP配置文件:http://localhost:${PORT}/MCPconfig`)
})
chokidar.watch(MODELS_DIR, { ignoreInitial: true }).on('all', () => {
console.log('📦 模型目录变动,重新生成配置...')
generateConfig().then(() => {
const payload = JSON.stringify({ type: 'update' })
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(payload)
}
})
})
})
}
function generateHTML(data) {
const categories = Object.keys(data)
const defaultCategory = categories[0]
return `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>模型服务</title>
<style>
body {
font-family: system-ui, sans-serif;
background-color: #f5f5f5;
margin: 0;
padding: 2em;
}
h1 {
text-align: center;
}
.info-bar {
text-align: center;
margin-bottom: 1em;
}
.categories {
text-align: center;
margin-bottom: 2em;
}
.categories button {
margin: 0 5px;
padding: 0.5em 1em;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
cursor: pointer;
}
.categories button.active {
background: #0056b3;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5em;
}
.card {
background: white;
padding: 1em;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.05);
}
.card img {
max-width: 100%;
max-height: 200px;
object-fit: contain;
margin-top: 0.5em;
border-radius: 4px;
}
</style>
</head>
<body>
<h1>🗂 模型浏览服务</h1>
<div class="info-bar">
📄 配置接口:<a href="/config" target="_blank">/config</a> · 共 ${
categories.length
} 个分类
</div>
<div class="categories" id="categoryBar">
${categories
.map(
(cat, i) =>
`<button onclick="renderCategory('${cat}')" id="btn-${cat}" ${
i === 0 ? 'class="active"' : ''
}>${cat}</button>`
)
.join('')}
</div>
<div class="grid" id="modelGrid"></div>
<script>
const allData = ${JSON.stringify(data)};
const categories = Object.keys(allData);
function renderCategory(category) {
// 更新按钮样式
categories.forEach(cat => {
document.getElementById('btn-' + cat).classList.remove('active');
});
document.getElementById('btn-' + category).classList.add('active');
// 渲染模型卡片
const grid = document.getElementById('modelGrid');
grid.innerHTML = '';
allData[category].forEach(item => {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = \`
<strong>模型名称:\${item.name}</strong><br/>
\${item.previewPath
? '<img src="/static/' + item.previewPath + '" alt="预览图">'
: '<span style="color:red;">❌ 缺少预览图</span>'}
<br/>
<small>模型路径:<br/></small><a href="\${'/static/'+item.modelPath}" target="_blank">\${'/static/'+item.modelPath}</a><br/>
<small>预览图路径:<br/></small><code> \${item.previewPath
? '/static/'+item.previewPath
: '❌ 缺少预览图'}
</code><br/>
<small>模型尺寸:</small>
<code>\${item.size && item.size[0] !== null ? \`\${item.size[0]} × \${item.size[1]} × \${item.size[2]}\` : '❌ 无尺寸数据'}</code>
\`;
grid.appendChild(card);
});
}
// 初始化默认分类
renderCategory("${defaultCategory}");
</script>
</body>
</html>`
}
generateConfig().then(() => {
if (SERVE_MODE) {
startServer()
} else {
const jsonConfig = {
isGenerateModeServe: true,
prePath: `./models/`,
data: result,
}
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(jsonConfig, null, 2), 'utf-8')
console.log(`✅ 已写入模型目录配置: ${OUTPUT_FILE}`)
if (warnings.length) {
console.warn('\n=== 缺失预览图 ===')
warnings.forEach((w) => console.warn(w))
} else {
console.log('🎉 所有模型都有预览图。')
}
}
})
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。