diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f9a6dcb..fa67392 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,8 @@ "Bash(for dir in gltf ktx2 model scaffold)", "Bash(do cp lib/$dir/*.js lib/plugins/$dir/)", "Bash(done)", - "Bash(timeout 3 node:*)" + "Bash(timeout 3 node:*)", + "Bash(find:*)" ] } } diff --git a/.stats.json b/.stats.json index 801c389..7269b8c 100644 --- a/.stats.json +++ b/.stats.json @@ -1,6 +1,6 @@ { "convert_format": { "stp": 1, - "glb2": 2 + "glb2": 3 } } \ No newline at end of file diff --git a/cuba.glb b/cuba.glb new file mode 100644 index 0000000..b9b79c1 Binary files /dev/null and b/cuba.glb differ diff --git a/lib/paths.js b/lib/paths.js new file mode 100644 index 0000000..82eb1d5 --- /dev/null +++ b/lib/paths.js @@ -0,0 +1,21 @@ +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// 项目根目录 +export const ROOT_DIR = path.join(__dirname, ".."); + +// bin 目录 +export const BIN_DIR = path.join(ROOT_DIR, "bin"); + +// 工具路径 +export const MODEL_CONVERTER = path.join(BIN_DIR, "model_converter.exe"); +export const TEXTURE_TOOL = path.join(BIN_DIR, "texture_tool.exe"); + +// lib 子目录 +export const LIB_DIR = __dirname; +export const PLUGINS_DIR = path.join(LIB_DIR, "plugins"); +export const UTILS_DIR = path.join(LIB_DIR, "utils"); +export const TOOLS_DIR = path.join(LIB_DIR, "tools"); diff --git a/lib/plugins/convert/config.js b/lib/plugins/convert/config.js index d62bded..205b157 100644 --- a/lib/plugins/convert/config.js +++ b/lib/plugins/convert/config.js @@ -1,9 +1,11 @@ import fs from "fs"; -import { getImportExtensions, getExportFormats } from "./converters.js"; +import { getImportExtensions, getExportFormats } from "./service.js"; import { sortByUsage } from "../../stats.js"; const EXCLUDED_FORMATS = ["x", "glb", "gltf"]; +export const title = "格式转换工具"; + export function listConvertibleFiles() { const cwd = process.cwd(); const exts = getImportExtensions(); diff --git a/lib/plugins/convert/converters.js b/lib/plugins/convert/converters.js deleted file mode 100644 index 00cfc14..0000000 --- a/lib/plugins/convert/converters.js +++ /dev/null @@ -1,33 +0,0 @@ -import { spawnSync } from "child_process"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ASSIMP_PATH = path.join(__dirname, "../../../bin/model_converter.exe"); - -let importExts = null; -let exportFormats = null; - -export function getImportExtensions() { - if (!importExts) { - const r = spawnSync(ASSIMP_PATH, ["listext"], { encoding: "utf8" }); - if (!r.stdout) throw new Error("无法获取支持的导入格式,请检查 model_converter.exe 是否存在"); - importExts = r.stdout.trim().split(";").map(e => e.replace("*", "").toLowerCase()); - } - return importExts; -} - -export function getExportFormats() { - if (!exportFormats) { - const r = spawnSync(ASSIMP_PATH, ["listexport"], { encoding: "utf8" }); - if (!r.stdout) throw new Error("无法获取支持的导出格式,请检查 model_converter.exe 是否存在"); - exportFormats = r.stdout.trim().split(/\r?\n/).filter(Boolean); - } - return exportFormats; -} - -export async function convert(inputFile, outputFile, format, cwd = process.cwd()) { - const r = spawnSync(ASSIMP_PATH, ["export", inputFile, outputFile, `-f${format}`], { encoding: "utf8", cwd }); - if (r.status !== 0) throw new Error(r.stderr || r.stdout || "转换失败"); - return outputFile; -} diff --git a/lib/plugins/convert/index.js b/lib/plugins/convert/index.js index 39f07ec..0c4de98 100644 --- a/lib/plugins/convert/index.js +++ b/lib/plugins/convert/index.js @@ -1,61 +1,30 @@ -import fs from "fs"; import path from "path"; import color from "picocolors"; -import { convert } from "./converters.js"; -import { runInteractive, showSummary } from "./ui.js"; +import { createStepUI } from "../../utils/stepui.js"; +import { title, getSteps } from "./config.js"; import { stopKeypress, waitForKey } from "../../keyboard.js"; import { record } from "../../stats.js"; +import { processFile } from "./service.js"; -const FORMAT_EXT = { - collada: ".dae",stp: ".stp", obj: ".obj", objnomtl: ".obj", - stl: ".stl", stlb: ".stl", ply: ".ply", plyb: ".ply", "3ds": ".3ds", - gltf2: ".gltf", glb2: ".glb", - assbin: ".assbin", assxml: ".assxml", x3d: ".x3d", - fbx: ".fbx", fbxa: ".fbx", "3mf": ".3mf", pbrt: ".pbrt", assjson: ".json" -}; - -function getOutputExt(format) { - return FORMAT_EXT[format] || "." + format; -} - -async function processFile(file, config) { - const cwd = process.cwd(); - const { dir, name } = path.parse(file); - const outputExt = getOutputExt(config.outputFormat); - const outputFile = path.join(dir || "", `${name}${outputExt}`); - const absolutePath = path.join(cwd, file); - - if (!fs.existsSync(absolutePath)) { - return { ok: false, file, reason: "文件不存在" }; - } - - await convert(file, outputFile, config.outputFormat, cwd); - return { ok: true, file, output: outputFile }; -} - -async function waitForEsc() { - return waitForKey(color.dim("按 Esc 返回"), key => key?.name === "escape" || (key?.ctrl && key?.name === "c")); -} - -async function run() { - const result = await runInteractive(); +const run = async () => { + const ui = createStepUI({ title, getSteps }); + const result = await ui.runInteractive(); if (!result) return "back"; - const { results } = result; const config = { - files: results[0] || [], - outputFormat: results[1] || "glb2" + files: result.results[0] || [], + outputFormat: result.results[1] || "glb2" }; stopKeypress(); - showSummary([ + ui.showSummary([ "源文件: " + (config.files.length ? config.files.join(", ") : "未选择"), "目标格式: " + config.outputFormat.toUpperCase() ]); if (!config.files.length) { console.log(color.yellow("未选择任何模型文件")); - await waitForEsc(); + await waitForKey(color.dim("按 Esc 返回"), key => key?.name === "escape" || (key?.ctrl && key?.name === "c")); return "back"; } @@ -88,16 +57,12 @@ async function run() { } console.log("\n" + color.bgGreen(color.black(" 转换完成 "))); - if (success) { - console.log(color.green(`成功: ${success} 个`)); - } - if (failed.length) { - console.log(color.yellow(`失败: ${failed.length} 个`)); - } + if (success) console.log(color.green(`成功: ${success} 个`)); + if (failed.length) console.log(color.yellow(`失败: ${failed.length} 个`)); - await waitForEsc(); + await waitForKey(color.dim("按 Esc 返回"), key => key?.name === "escape" || (key?.ctrl && key?.name === "c")); return "back"; -} +}; export default { id: "convert", diff --git a/lib/plugins/convert/service.js b/lib/plugins/convert/service.js new file mode 100644 index 0000000..a185a94 --- /dev/null +++ b/lib/plugins/convert/service.js @@ -0,0 +1,65 @@ +import fs from "fs"; +import path from "path"; +import { spawnSync } from "child_process"; +import { MODEL_CONVERTER } from "../../paths.js"; + +// 格式映射 +const FORMAT_EXT = { + collada: ".dae", stp: ".stp", obj: ".obj", objnomtl: ".obj", + stl: ".stl", stlb: ".stl", ply: ".ply", plyb: ".ply", "3ds": ".3ds", + gltf2: ".gltf", glb2: ".glb", + assbin: ".assbin", assxml: ".assxml", x3d: ".x3d", + fbx: ".fbx", fbxa: ".fbx", "3mf": ".3mf", pbrt: ".pbrt", assjson: ".json" +}; + +// 缓存 +let importExts = null; +let exportFormats = null; + +// 获取支持的导入格式 +export const getImportExtensions = () => { + if (!importExts) { + const r = spawnSync(MODEL_CONVERTER, ["listext"], { encoding: "utf8" }); + if (!r.stdout) throw new Error("无法获取支持的导入格式,请检查 model_converter.exe 是否存在"); + importExts = r.stdout.trim().split(";").map(e => e.replace("*", "").toLowerCase()); + } + return importExts; +}; + +// 获取支持的导出格式 +export const getExportFormats = () => { + if (!exportFormats) { + const r = spawnSync(MODEL_CONVERTER, ["listexport"], { encoding: "utf8" }); + if (!r.stdout) throw new Error("无法获取支持的导出格式,请检查 model_converter.exe 是否存在"); + exportFormats = r.stdout.trim().split(/\r?\n/).filter(Boolean); + } + return exportFormats; +}; + +// 转换文件 +export const convert = async (inputFile, outputFile, format, cwd = process.cwd()) => { + const r = spawnSync(MODEL_CONVERTER, ["export", inputFile, outputFile, `-f${format}`], { encoding: "utf8", cwd }); + if (r.status !== 0) throw new Error(r.stderr || r.stdout || "转换失败"); + return outputFile; +}; + +// 获取输出文件扩展名 +const getOutputExt = (format) => { + return FORMAT_EXT[format] || "." + format; +}; + +// 处理单个文件 +export const processFile = async (file, config) => { + const cwd = process.cwd(); + const { dir, name } = path.parse(file); + const outputExt = getOutputExt(config.outputFormat); + const outputFile = path.join(dir || "", `${name}${outputExt}`); + const absolutePath = path.join(cwd, file); + + if (!fs.existsSync(absolutePath)) { + return { ok: false, file, reason: "文件不存在" }; + } + + await convert(file, outputFile, config.outputFormat, cwd); + return { ok: true, file, output: outputFile }; +}; diff --git a/lib/plugins/convert/ui.js b/lib/plugins/convert/ui.js deleted file mode 100644 index 25488d9..0000000 --- a/lib/plugins/convert/ui.js +++ /dev/null @@ -1,9 +0,0 @@ -import { createStepUI } from "../../utils/stepui.js"; -import { getSteps } from "./config.js"; - -const ui = createStepUI({ - title: "格式转换工具", - getSteps -}); - -export const { runInteractive, showSummary } = ui; diff --git a/lib/plugins/gltf/config.js b/lib/plugins/gltf/config.js index e69b8c4..df2a65a 100644 --- a/lib/plugins/gltf/config.js +++ b/lib/plugins/gltf/config.js @@ -1,5 +1,7 @@ import { listGltfFiles } from "../../utils/gltf.js"; +export const title = "glTF 扩展工具"; + const extensionStep = { name: "扩展选项", type: "multiselect", @@ -13,7 +15,7 @@ const extensionStep = { default: ["textureBasisu"] }; -export function getSteps() { +export const getSteps = () => { const files = listGltfFiles(); const fileStep = { name: "文件选择", @@ -24,4 +26,4 @@ export function getSteps() { }; return [fileStep, extensionStep]; -} +}; diff --git a/lib/plugins/gltf/index.js b/lib/plugins/gltf/index.js index 206732f..07d81ab 100644 --- a/lib/plugins/gltf/index.js +++ b/lib/plugins/gltf/index.js @@ -1,76 +1,36 @@ -import fs from "fs"; -import path from "path"; import color from "picocolors"; -import { checkRequiredFiles, modifyGltfContent, listGltfFiles, BACKUP_SUFFIX } from "../../utils/gltf.js"; -import { runInteractive, showSummary } from "./ui.js"; +import { createStepUI } from "../../utils/stepui.js"; +import { title, getSteps } from "./config.js"; import { stopKeypress, waitForKey } from "../../keyboard.js"; +import { runGltfExtension } from "./service.js"; -export function runGltfExtension(config = { files: [], extensions: ["textureBasisu"] }) { - const cwd = process.cwd(); - const { files = [], extensions = ["textureBasisu"] } = config; - const selectedExtensions = extensions.length ? extensions : ["textureBasisu"]; - - const { ok, missing } = checkRequiredFiles(); - if (!ok) { - console.log(color.red("\n✖ 缺少必要文件: " + missing.join(", "))); - console.log(color.dim("请确保当前目录包含 .ktx2、.gltf 和 .bin 文件\n")); - return { success: false, count: 0 }; - } - - const fallbackFiles = listGltfFiles(); - const gltfFiles = (files.length ? files : fallbackFiles).filter(f => f.toLowerCase().endsWith(".gltf")); - if (!gltfFiles.length) { - console.log(color.yellow("未选择可处理的 glTF 文件")); - return { success: false, count: 0 }; - } - - let count = 0; - - for (const file of gltfFiles) { - const fullPath = path.join(cwd, file); - if (!fs.existsSync(fullPath)) { - console.log(color.yellow("跳过缺失的文件: " + file)); - continue; - } - const baseName = file.replace(/\.gltf$/i, ""); - const backupName = baseName + BACKUP_SUFFIX + ".gltf"; - const backupPath = path.join(cwd, backupName); - - fs.copyFileSync(fullPath, backupPath); - const modified = modifyGltfContent(fullPath, selectedExtensions); - fs.writeFileSync(fullPath, JSON.stringify(modified, null, 2), "utf-8"); - console.log(color.green("✓ " + file + " (备份: " + backupName + ")")); - count++; - } - - return { success: count > 0, count }; -} - -async function run() { - const result = await runInteractive(); +const run = async () => { + const ui = createStepUI({ title, getSteps }); + const result = await ui.runInteractive(); if (!result) return "back"; stopKeypress(); - const { results } = result; const config = { - files: results[0] || [], - extensions: results[1] || [] + files: result.results[0] || [], + extensions: result.results[1] || [] }; - showSummary([ + ui.showSummary([ "处理文件: " + (config.files.length ? config.files.join(", ") : "未选择"), "扩展选项: " + (config.extensions.length ? config.extensions.join(", ") : "未选择") ]); - const { success, count } = runGltfExtension(config); - if (success) { + const { success, count, error } = runGltfExtension(config); + if (error) { + console.log(color.red("\n✖ " + error)); + } else if (success) { console.log(color.green("\n✓ 已修改 " + count + " 个 glTF 文件")); } await waitForKey(); return "back"; -} +}; export default { id: "gltf", diff --git a/lib/plugins/gltf/service.js b/lib/plugins/gltf/service.js new file mode 100644 index 0000000..3325919 --- /dev/null +++ b/lib/plugins/gltf/service.js @@ -0,0 +1,39 @@ +import fs from "fs"; +import path from "path"; +import { checkRequiredFiles, modifyGltfContent, listGltfFiles, BACKUP_SUFFIX } from "../../utils/gltf.js"; + +export const runGltfExtension = (config = { files: [], extensions: ["textureBasisu"] }) => { + const cwd = process.cwd(); + const { files = [], extensions = ["textureBasisu"] } = config; + const selectedExtensions = extensions.length ? extensions : ["textureBasisu"]; + + const { ok, missing } = checkRequiredFiles(); + if (!ok) { + return { success: false, count: 0, error: "缺少必要文件: " + missing.join(", ") }; + } + + const fallbackFiles = listGltfFiles(); + const gltfFiles = (files.length ? files : fallbackFiles).filter(f => f.toLowerCase().endsWith(".gltf")); + if (!gltfFiles.length) { + return { success: false, count: 0, error: "未选择可处理的 glTF 文件" }; + } + + let count = 0; + + for (const file of gltfFiles) { + const fullPath = path.join(cwd, file); + if (!fs.existsSync(fullPath)) { + continue; + } + const baseName = file.replace(/\.gltf$/i, ""); + const backupName = baseName + BACKUP_SUFFIX + ".gltf"; + const backupPath = path.join(cwd, backupName); + + fs.copyFileSync(fullPath, backupPath); + const modified = modifyGltfContent(fullPath, selectedExtensions); + fs.writeFileSync(fullPath, JSON.stringify(modified, null, 2), "utf-8"); + count++; + } + + return { success: count > 0, count }; +}; diff --git a/lib/plugins/gltf/ui.js b/lib/plugins/gltf/ui.js deleted file mode 100644 index 2839ff9..0000000 --- a/lib/plugins/gltf/ui.js +++ /dev/null @@ -1,9 +0,0 @@ -import { createStepUI } from "../../utils/stepui.js"; -import { getSteps } from "./config.js"; - -const ui = createStepUI({ - title: "glTF 扩展工具", - getSteps -}); - -export const { runInteractive, showSummary } = ui; diff --git a/lib/plugins/ktx2/config.js b/lib/plugins/ktx2/config.js index 1efcbda..9a9e158 100644 --- a/lib/plugins/ktx2/config.js +++ b/lib/plugins/ktx2/config.js @@ -1,5 +1,9 @@ +import { hasGltfFile } from "../../utils/gltf.js"; + +export const title = "KTX2 纹理压缩工具"; + // 步骤配置 -export const steps = [ +const steps = [ { name: "文件格式", type: "multiselect", @@ -61,3 +65,13 @@ export const steps = [ default: ["overwrite", "keepOriginal"] } ]; + +export function getSteps() { + const hasGltf = hasGltfFile(); + return steps.map(step => { + if (step.name === "输出选项") { + return { ...step, options: step.options.filter(opt => !opt.dynamic || hasGltf) }; + } + return step; + }); +} diff --git a/lib/plugins/ktx2/gltf.js b/lib/plugins/ktx2/gltf.js deleted file mode 100644 index 94abf7d..0000000 --- a/lib/plugins/ktx2/gltf.js +++ /dev/null @@ -1,3 +0,0 @@ -// 从 utils 导出,保持兼容 -export { hasGltfFile, checkRequiredFiles, modifyGltfContent } from "../../utils/gltf.js"; -export { runGltfExtension } from "../gltf/index.js"; diff --git a/lib/plugins/ktx2/index.js b/lib/plugins/ktx2/index.js index 9fcfa51..b903d9a 100644 --- a/lib/plugins/ktx2/index.js +++ b/lib/plugins/ktx2/index.js @@ -1,20 +1,22 @@ import color from "picocolors"; -import { checkToktx, scanImages, compressAll } from "./compressor.js"; -import { runInteractive, showSummary } from "./ui.js"; -import { runGltfExtension } from "./gltf.js"; +import { createStepUI } from "../../utils/stepui.js"; +import { title, getSteps } from "./config.js"; import { stopKeypress, waitForKey } from "../../keyboard.js"; +import { checkToktx, scanImages, compressAll, runGltfExtension } from "./service.js"; -async function run() { +const run = async () => { checkToktx(); - const result = await runInteractive(); + + const ui = createStepUI({ title, getSteps }); + const result = await ui.runInteractive(); if (!result) return "back"; stopKeypress(); - const { results } = result; - const [exts, quality, encoding, mipmap, outputOpts] = results; + const [exts, quality, encoding, mipmap, outputOpts] = result.results; const config = { exts, quality, encoding, mipmap, outputOpts }; - showSummary([ + + ui.showSummary([ "文件格式: " + config.exts.join(", "), "压缩程度: " + config.quality, "编码格式: " + config.encoding, @@ -46,7 +48,7 @@ async function run() { await waitForKey(); return "back"; -} +}; export default { id: "ktx2", diff --git a/lib/plugins/ktx2/compressor.js b/lib/plugins/ktx2/service.js similarity index 82% rename from lib/plugins/ktx2/compressor.js rename to lib/plugins/ktx2/service.js index f81acf4..f3866a9 100644 --- a/lib/plugins/ktx2/compressor.js +++ b/lib/plugins/ktx2/service.js @@ -2,32 +2,29 @@ import fs from "fs"; import path from "path"; import { spawn } from "child_process"; import color from "picocolors"; -import { fileURLToPath } from "url"; +import { TEXTURE_TOOL } from "../../paths.js"; +import { runGltfExtension } from "../gltf/service.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const toktx = path.join(__dirname, "..", "..", "..", "bin", "texture_tool.exe"); - -// 检查 toktx 是否存在 -export function checkToktx() { - if (!fs.existsSync(toktx)) { +// 检查工具是否存在 +export const checkToktx = () => { + if (!fs.existsSync(TEXTURE_TOOL)) { console.error("❌ 找不到 texture_tool.exe"); process.exit(1); } -} +}; // 扫描图片文件 -export function scanImages(exts) { +export const scanImages = (exts) => { console.log("🔍 扫描目标文件中..."); const cwd = process.cwd(); const images = fs.readdirSync(cwd).filter(f => exts.some(ext => f.toLowerCase().endsWith("." + ext)) ); return { images, cwd }; -} +}; // 构建压缩参数 -export function buildArgs(input, output, config) { +const buildArgs = (input, output, config) => { const args = ["--t2"]; if (config.encoding === "uastc") { @@ -48,14 +45,13 @@ export function buildArgs(input, output, config) { args.push(output, input); return args; -} +}; // 压缩单个文件 -export function compressFile(img, config, cwd, progress) { +const compressFile = (img, config, cwd, progress) => { const baseName = img.replace(/\.[^.]+$/, ""); const out = baseName + ".ktx2"; - // 点动画 let dots = 0; const dotAnim = setInterval(() => { const dotStr = ".".repeat(dots); @@ -68,7 +64,7 @@ export function compressFile(img, config, cwd, progress) { const args = buildArgs(img, out, config); return new Promise((resolve) => { - const proc = spawn(toktx, args, { cwd }); + const proc = spawn(TEXTURE_TOOL, args, { cwd }); let stderr = ""; proc.stderr?.on("data", data => { @@ -96,10 +92,10 @@ export function compressFile(img, config, cwd, progress) { resolve({ success: false, error: err.message }); }); }); -} +}; // 批量压缩 -export async function compressAll(images, config, cwd) { +export const compressAll = async (images, config, cwd) => { const total = images.length; let finished = 0; let failed = 0; @@ -112,4 +108,7 @@ export async function compressAll(images, config, cwd) { } return { total, failed }; -} +}; + +// 导出 gltf 扩展功能 +export { runGltfExtension }; diff --git a/lib/plugins/ktx2/ui.js b/lib/plugins/ktx2/ui.js deleted file mode 100644 index de7f175..0000000 --- a/lib/plugins/ktx2/ui.js +++ /dev/null @@ -1,20 +0,0 @@ -import { createStepUI } from "../../utils/stepui.js"; -import { steps } from "./config.js"; -import { hasGltfFile } from "./gltf.js"; - -function getFilteredSteps() { - const hasGltf = hasGltfFile(); - return steps.map(step => { - if (step.name === "输出选项") { - return { ...step, options: step.options.filter(opt => !opt.dynamic || hasGltf) }; - } - return step; - }); -} - -const ui = createStepUI({ - title: "KTX2 纹理压缩工具", - getSteps: getFilteredSteps -}); - -export const { runInteractive, showSummary } = ui; diff --git a/lib/plugins/model/config.js b/lib/plugins/model/config.js index 5de3deb..264cc80 100644 --- a/lib/plugins/model/config.js +++ b/lib/plugins/model/config.js @@ -1,5 +1,7 @@ import { listAllModelFiles } from "../../utils/gltf.js"; +export const title = "模型压缩工具"; + const transformOptions = [ { value: "dedup", label: "dedup(去重)", hint: "删除重复的访问器、材质、网格" }, { value: "prune", label: "prune(清理无用节点)", hint: "移除未被引用的节点、材质、动画" }, @@ -27,7 +29,7 @@ const outputOptions = [ { value: "copy", label: "输出副本 (_compressed)", hint: "保留原文件不动,结果写入新文件" } ]; -export function getSteps() { +export const getSteps = () => { const files = listAllModelFiles(); const fileStep = { name: "模型选择", @@ -68,4 +70,4 @@ export function getSteps() { default: ["overwrite", "backup"] } ]; -} +}; diff --git a/lib/plugins/model/index.js b/lib/plugins/model/index.js index 642a5cc..82ab6dc 100644 --- a/lib/plugins/model/index.js +++ b/lib/plugins/model/index.js @@ -1,140 +1,26 @@ -import fs from "fs"; import path from "path"; import color from "picocolors"; -import { NodeIO } from "@gltf-transform/core"; -import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; -import { dedup, prune, resample, weld, quantize } from "@gltf-transform/functions"; -import { BACKUP_SUFFIX, needsConversion } from "../../utils/gltf.js"; -import { convert } from "../convert/converters.js"; -import { runInteractive, showSummary } from "./ui.js"; +import { createStepUI } from "../../utils/stepui.js"; +import { title, getSteps } from "./config.js"; import { stopKeypress, waitForKey } from "../../keyboard.js"; +import { buildTransforms, processFile } from "./service.js"; -const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); - -const QUANTIZE_PRESETS = { - high: { position: 16, normal: 12, texcoord: 14, color: 10, generic: 12 }, - balanced: { position: 14, normal: 10, texcoord: 12, color: 8, generic: 12 }, - aggressive: { position: 12, normal: 8, texcoord: 10, color: 8, generic: 10 }, - light: { position: 10, normal: 8, texcoord: 10, color: 8, generic: 10 } -}; - -function buildTransforms(config) { - const transforms = []; - const selected = new Set(config.commands); - if (selected.has("dedup")) transforms.push(dedup()); - if (selected.has("prune")) transforms.push(prune()); - if (selected.has("resample")) transforms.push(resample()); - if (selected.has("weld")) transforms.push(weld()); - if (selected.has("quantize")) { - const preset = QUANTIZE_PRESETS[config.quantizePreset] || QUANTIZE_PRESETS.balanced; - transforms.push(quantize({ - quantizePosition: preset.position, - quantizeNormal: preset.normal, - quantizeTexcoord: preset.texcoord, - quantizeColor: preset.color, - quantizeGeneric: preset.generic - })); - } - return transforms; -} - -function resolveOutput(file, config) { - const cwd = process.cwd(); - const originalExt = path.extname(file).toLowerCase() || ".gltf"; - const baseName = originalExt ? file.slice(0, -originalExt.length) : file; - const isConvertible = needsConversion(file); - - // OBJ/FBX 默认输出 GLB,除非指定了 gltf - const targetExt = config.outputFormat === "gltf" ? ".gltf" - : (config.outputFormat === "glb" || isConvertible) ? ".glb" - : originalExt; - - const wantsCopy = config.outputOptions.includes("copy"); - const wantsOverwrite = config.outputOptions.includes("overwrite"); - const mode = wantsCopy ? "copy" : (wantsOverwrite ? "overwrite" : "copy"); - const backup = config.outputOptions.includes("backup") && mode === "overwrite"; - - const targetName = mode === "copy" - ? `${baseName}_compressed${targetExt}` - : `${baseName}${targetExt}`; - - return { - cwd, - mode, - backup, - sourcePath: path.join(cwd, file), - sourceExt: originalExt, - targetExt, - targetPath: path.join(cwd, targetName), - baseName, - needsConversion: isConvertible - }; -} - -async function processFile(file, config, transforms) { - const output = resolveOutput(file, config); - if (!fs.existsSync(output.sourcePath)) { - console.log(color.yellow("跳过,文件不存在: " + file)); - return { ok: false, file, reason: "missing" }; - } - - if (!transforms.length) { - console.log(color.yellow("未选择任何 gltf-transform 操作,已中止")); - return { ok: false, file, reason: "no-transform" }; - } - - if (output.backup) { - const backupName = `${output.baseName}${BACKUP_SUFFIX}${output.sourceExt}`; - const backupPath = path.join(output.cwd, backupName); - fs.copyFileSync(output.sourcePath, backupPath); - } - - let inputPath = output.sourcePath; - - // 如果是 OBJ/FBX,先转换为临时 GLB - if (output.needsConversion) { - const tempPath = path.join(output.cwd, `${output.baseName}_temp.glb`); - console.log(color.dim("转换中: " + file + " → GLB")); - await convert(output.sourcePath, tempPath, { binary: true }); - inputPath = tempPath; - } - - const document = io.read(inputPath); - await document.transform(...transforms); - io.write(output.targetPath, document); - - // 清理临时文件 - if (output.needsConversion) { - fs.rmSync(inputPath, { force: true }); - } - - if (output.mode === "overwrite" && output.targetPath !== output.sourcePath) { - fs.rmSync(output.sourcePath, { force: true }); - } - - if (output.mode === "overwrite" && output.targetPath !== output.sourcePath) { - console.log(color.dim("已生成新文件: " + path.basename(output.targetPath))); - } - - return { ok: true, file, output: output.targetPath }; -} - -async function run() { - const result = await runInteractive(); +const run = async () => { + const ui = createStepUI({ title, getSteps }); + const result = await ui.runInteractive(); if (!result) return "back"; stopKeypress(); - const { results } = result; const config = { - files: results[0] || [], - commands: results[1] || [], - quantizePreset: results[2] || "balanced", - outputFormat: results[3] || "auto", - outputOptions: results[4] || [] + files: result.results[0] || [], + commands: result.results[1] || [], + quantizePreset: result.results[2] || "balanced", + outputFormat: result.results[3] || "auto", + outputOptions: result.results[4] || [] }; - showSummary([ + ui.showSummary([ "模型文件: " + (config.files.length ? config.files.join(", ") : "未选择"), "gltf-transform 命令: " + (config.commands.length ? config.commands.join(", ") : "无"), "量化级别: " + config.quantizePreset, @@ -183,16 +69,12 @@ async function run() { } console.log("\n" + color.bgGreen(color.black(" 压缩完成 "))); - if (success) { - console.log(color.green(`成功: ${success} 个`)); - } - if (failed.length) { - console.log(color.yellow(`失败: ${failed.length} 个 (${failed.join(", ")})`)); - } + if (success) console.log(color.green(`成功: ${success} 个`)); + if (failed.length) console.log(color.yellow(`失败: ${failed.length} 个 (${failed.join(", ")})`)); await waitForKey(); return "back"; -} +}; export default { id: "model", diff --git a/lib/plugins/model/service.js b/lib/plugins/model/service.js new file mode 100644 index 0000000..becc59b --- /dev/null +++ b/lib/plugins/model/service.js @@ -0,0 +1,107 @@ +import fs from "fs"; +import path from "path"; +import { NodeIO } from "@gltf-transform/core"; +import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import { dedup, prune, resample, weld, quantize } from "@gltf-transform/functions"; +import { BACKUP_SUFFIX, needsConversion } from "../../utils/gltf.js"; +import { convert } from "../convert/service.js"; + +const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); + +const QUANTIZE_PRESETS = { + high: { position: 16, normal: 12, texcoord: 14, color: 10, generic: 12 }, + balanced: { position: 14, normal: 10, texcoord: 12, color: 8, generic: 12 }, + aggressive: { position: 12, normal: 8, texcoord: 10, color: 8, generic: 10 }, + light: { position: 10, normal: 8, texcoord: 10, color: 8, generic: 10 } +}; + +export const buildTransforms = (config) => { + const transforms = []; + const selected = new Set(config.commands); + if (selected.has("dedup")) transforms.push(dedup()); + if (selected.has("prune")) transforms.push(prune()); + if (selected.has("resample")) transforms.push(resample()); + if (selected.has("weld")) transforms.push(weld()); + if (selected.has("quantize")) { + const preset = QUANTIZE_PRESETS[config.quantizePreset] || QUANTIZE_PRESETS.balanced; + transforms.push(quantize({ + quantizePosition: preset.position, + quantizeNormal: preset.normal, + quantizeTexcoord: preset.texcoord, + quantizeColor: preset.color, + quantizeGeneric: preset.generic + })); + } + return transforms; +}; + +const resolveOutput = (file, config) => { + const cwd = process.cwd(); + const originalExt = path.extname(file).toLowerCase() || ".gltf"; + const baseName = originalExt ? file.slice(0, -originalExt.length) : file; + const isConvertible = needsConversion(file); + + const targetExt = config.outputFormat === "gltf" ? ".gltf" + : (config.outputFormat === "glb" || isConvertible) ? ".glb" + : originalExt; + + const wantsCopy = config.outputOptions.includes("copy"); + const wantsOverwrite = config.outputOptions.includes("overwrite"); + const mode = wantsCopy ? "copy" : (wantsOverwrite ? "overwrite" : "copy"); + const backup = config.outputOptions.includes("backup") && mode === "overwrite"; + + const targetName = mode === "copy" + ? `${baseName}_compressed${targetExt}` + : `${baseName}${targetExt}`; + + return { + cwd, + mode, + backup, + sourcePath: path.join(cwd, file), + sourceExt: originalExt, + targetExt, + targetPath: path.join(cwd, targetName), + baseName, + needsConversion: isConvertible + }; +}; + +export const processFile = async (file, config, transforms) => { + const output = resolveOutput(file, config); + if (!fs.existsSync(output.sourcePath)) { + return { ok: false, file, reason: "missing" }; + } + + if (!transforms.length) { + return { ok: false, file, reason: "no-transform" }; + } + + if (output.backup) { + const backupName = `${output.baseName}${BACKUP_SUFFIX}${output.sourceExt}`; + const backupPath = path.join(output.cwd, backupName); + fs.copyFileSync(output.sourcePath, backupPath); + } + + let inputPath = output.sourcePath; + + if (output.needsConversion) { + const tempPath = path.join(output.cwd, `${output.baseName}_temp.glb`); + await convert(output.sourcePath, tempPath, { binary: true }); + inputPath = tempPath; + } + + const document = io.read(inputPath); + await document.transform(...transforms); + io.write(output.targetPath, document); + + if (output.needsConversion) { + fs.rmSync(inputPath, { force: true }); + } + + if (output.mode === "overwrite" && output.targetPath !== output.sourcePath) { + fs.rmSync(output.sourcePath, { force: true }); + } + + return { ok: true, file, output: output.targetPath }; +}; diff --git a/lib/plugins/model/ui.js b/lib/plugins/model/ui.js deleted file mode 100644 index 3ff47c4..0000000 --- a/lib/plugins/model/ui.js +++ /dev/null @@ -1,9 +0,0 @@ -import { createStepUI } from "../../utils/stepui.js"; -import { getSteps } from "./config.js"; - -const ui = createStepUI({ - title: "模型压缩工具", - getSteps -}); - -export const { runInteractive, showSummary } = ui; diff --git a/lib/plugins/scaffold/config.js b/lib/plugins/scaffold/config.js index 22e9d62..2b6d49d 100644 --- a/lib/plugins/scaffold/config.js +++ b/lib/plugins/scaffold/config.js @@ -155,7 +155,7 @@ const commonComponents = { }; // 根据框架获取组件配置 -export function getComponentsByFramework(framework) { +export const getComponentsByFramework = (framework) => { if (!framework || framework === "none") return {}; const isReact = ["react", "nextjs"].includes(framework); @@ -174,16 +174,16 @@ export function getComponentsByFramework(framework) { } return {}; -} +}; // 获取默认框架 -function getDefaultFramework(projectType) { +const getDefaultFramework = (projectType) => { const opts = frameworkOptions[projectType]; return opts?.[0]?.value || "none"; -} +}; // 生成前端/全栈项目的步骤配置 -export function generateSteps(projectType, selectedFramework) { +export const generateSteps = (projectType, selectedFramework) => { const defaultFramework = getDefaultFramework(projectType); const framework = selectedFramework || defaultFramework; @@ -213,4 +213,4 @@ export function generateSteps(projectType, selectedFramework) { }); return steps; -} +}; diff --git a/lib/plugins/scaffold/index.js b/lib/plugins/scaffold/index.js index 05b2861..0989ee2 100644 --- a/lib/plugins/scaffold/index.js +++ b/lib/plugins/scaffold/index.js @@ -1,9 +1,9 @@ import color from "picocolors"; import { projectTypes } from "./config.js"; import { gridSelect } from "../../grid.js"; -import { createScaffoldUI, formatResults, waitKey } from "./ui.js"; +import { createScaffoldUI, formatResults, waitKey } from "./service.js"; -async function run() { +const run = async () => { while (true) { // 二级菜单 - 项目类型 const typeResult = await gridSelect({ @@ -32,7 +32,7 @@ async function run() { await waitKey(); } } -} +}; export default { id: "scaffold", diff --git a/lib/plugins/scaffold/ui.js b/lib/plugins/scaffold/service.js similarity index 79% rename from lib/plugins/scaffold/ui.js rename to lib/plugins/scaffold/service.js index 5e35644..cd7c4b2 100644 --- a/lib/plugins/scaffold/ui.js +++ b/lib/plugins/scaffold/service.js @@ -1,21 +1,18 @@ import color from "picocolors"; -import { initKeypress, onKey, stopKeypress } from "../../keyboard.js"; import { createStepUI } from "../../utils/stepui.js"; import { generateSteps } from "./config.js"; +import { initKeypress, onKey, stopKeypress } from "../../keyboard.js"; -// 框架 + 组件配置 UI -export function createScaffoldUI(projectType) { +export const createScaffoldUI = (projectType) => { return createStepUI({ title: `${projectType} - 项目配置`, getSteps: () => generateSteps(projectType, null), onStepChange: framework => generateSteps(projectType, framework), }); -} +}; -// 解析配置结果 -export function formatResults(steps, results) { +export const formatResults = (steps, results) => { const summary = []; - results.forEach((val, i) => { if (Array.isArray(val) && val.length > 0) { summary.push(`${steps[i].name}: ${val.join(", ")}`); @@ -23,14 +20,11 @@ export function formatResults(steps, results) { summary.push(`${steps[i].name}: ${val}`); } }); - return summary.length ? summary : ["未选择任何组件"]; -} +}; -// 等待按键 -export async function waitKey(message = "按任意键返回") { +export const waitKey = async (message = "按任意键返回") => { console.log(color.dim(`\n${message}`)); - return new Promise(resolve => { initKeypress(); onKey(() => { @@ -38,4 +32,4 @@ export async function waitKey(message = "按任意键返回") { resolve(); }); }); -} +};