diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0a445dc..f9a6dcb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,12 @@ "Bash(./bin/assimp.exe:*)", "Bash(npm run build)", "Bash(node:*)", - "Bash(npm uninstall:*)" + "Bash(npm uninstall:*)", + "Bash(cp:*)", + "Bash(for dir in gltf ktx2 model scaffold)", + "Bash(do cp lib/$dir/*.js lib/plugins/$dir/)", + "Bash(done)", + "Bash(timeout 3 node:*)" ] } } diff --git a/index.js b/index.js index 30ba6e7..6e69106 100644 --- a/index.js +++ b/index.js @@ -33,7 +33,11 @@ while (true) { console.log(color.cyan("\n正在启动: " + selected.name + "...\n")); try { - const result = await selected.tool.run(); + const runner = selected.run || selected.tool?.run; + if (typeof runner !== "function") { + throw new Error("当前菜单项缺少 run 方法"); + } + const result = await runner(); if (result === "back") continue; break; } catch (err) { diff --git a/lib/audio/index.js b/lib/audio/index.js deleted file mode 100644 index 0448cd3..0000000 --- a/lib/audio/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export async function run() { - throw new Error("尚未实现"); -} diff --git a/lib/grid.js b/lib/grid.js index 7c4192b..ddc2a2c 100644 --- a/lib/grid.js +++ b/lib/grid.js @@ -1,21 +1,6 @@ import color from "picocolors"; import { initKeypress, onKey, stopKeypress } from "./keyboard.js"; - -function clearScreen() { - process.stdout.write('\x1Bc'); -} - -function strWidth(str) { - let width = 0; - for (const char of str) { - width += char.charCodeAt(0) > 127 ? 2 : 1; - } - return width; -} - -function padEnd(str, width) { - return str + " ".repeat(Math.max(0, width - strWidth(str))); -} +import { clearScreen, padEnd, strWidth, centerPad } from "./utils/terminal.js"; export async function gridSelect(options) { const { @@ -25,15 +10,22 @@ export async function gridSelect(options) { title = "", renderHeader = null, updateInfo = null, - headerGap = 2, // header后的空行数 - menuGap = 2, // 菜单和提示文字的间隔 - rowGap = 1 // 每行菜单后的空行数 + headerGap = 2, + menuGap = 2, + rowGap = 1, + instructions = "←→ 选择 | Enter 确认 | Esc 退出", + onCancel = "exit", // "exit" | "back" + mapper, + showUpdateButton = true, } = options; + const hasUpdate = showUpdateButton && !!updateInfo; + const mapResult = mapper || ((item, isUpdate) => (isUpdate ? { isUpdate: true } : item)); + let current = 0; - let onUpdate = updateInfo ? true : false; // 如果有更新,默认选中更新按钮 + let onUpdate = hasUpdate; let resolved = false; - const rows = Math.ceil(items.length / cols); + const rows = Math.ceil((items?.length || 0) / cols); const termWidth = process.stdout.columns || 80; const totalWidth = cols * colWidth; const pad = " ".repeat(Math.max(0, Math.floor((termWidth - totalWidth) / 2))); @@ -48,13 +40,23 @@ export async function gridSelect(options) { for (let i = 0; i < headerGap; i++) console.log(""); if (title) { - const titlePad = " ".repeat(Math.max(0, Math.floor((termWidth - title.length - 4) / 2))); + const titlePad = centerPad(title, termWidth - 4); console.log(titlePad + color.bgMagenta(color.white(` ${title} `))); } - console.log(pad + color.dim("↑ ↓ ← → 选择 | Enter 确认 | Esc 退出")); + if (instructions) { + console.log("\n" + pad + color.dim(instructions)); + } else { + console.log(""); + } + for (let i = 0; i < menuGap; i++) console.log(""); + if (!items || items.length === 0) { + console.log(color.yellow("暂无可选项")); + return; + } + for (let row = 0; row < rows; row++) { let line = ""; let descLine = ""; @@ -68,7 +70,7 @@ export async function gridSelect(options) { } else { line += " " + item.name + " "; } - line += " ".repeat(colWidth - strWidth(item.name) - 2); + line += " ".repeat(Math.max(0, colWidth - strWidth(item.name) - 2)); descLine += padEnd(item.desc || "", colWidth); } } @@ -91,50 +93,66 @@ export async function gridSelect(options) { switch (key.name) { case "up": - if (onUpdate) { - // 已在更新按钮,不能再上 - } else if (row === 0 && updateInfo) { - onUpdate = true; render(); - } else if (row > 0) { - current -= cols; render(); + if (!onUpdate && row === 0 && hasUpdate) { + onUpdate = true; + render(); + } else if (!onUpdate && row > 0) { + current -= cols; + render(); } break; case "down": if (onUpdate) { - onUpdate = false; render(); + onUpdate = false; + render(); } else if (row < rows - 1 && current + cols < items.length) { - current += cols; render(); + current += cols; + render(); } break; case "left": - if (!onUpdate && col > 0) { current--; render(); } + if (!onUpdate && col > 0) { + current--; + render(); + } break; case "right": - if (!onUpdate && col < cols - 1 && current < items.length - 1) { current++; render(); } + if (!onUpdate && col < cols - 1 && current < items.length - 1) { + current++; + render(); + } break; case "return": resolved = true; stopKeypress(); - if (onUpdate) { - setImmediate(() => resolve({ isUpdate: true })); - } else { - setImmediate(() => resolve(items[current])); - } + setImmediate(() => { + const isUpdateSelection = onUpdate && hasUpdate; + const payload = isUpdateSelection ? null : items[current]; + resolve(mapResult(payload, isUpdateSelection)); + }); break; case "escape": resolved = true; stopKeypress(); clearScreen(); - console.log(color.yellow("再见!")); - process.exit(0); + if (onCancel === "back") { + setImmediate(() => resolve("back")); + } else { + console.log(color.yellow("再见!")); + process.exit(0); + } break; case "c": if (key.ctrl) { resolved = true; stopKeypress(); clearScreen(); - console.log(color.yellow("再见!")); - process.exit(0); + if (onCancel === "back") { + setImmediate(() => resolve("back")); + } else { + console.log(color.yellow("再见!")); + process.exit(0); + } } break; } diff --git a/lib/image/index.js b/lib/image/index.js deleted file mode 100644 index 0448cd3..0000000 --- a/lib/image/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export async function run() { - throw new Error("尚未实现"); -} diff --git a/lib/keyboard.js b/lib/keyboard.js index 7cbe6ac..e05cca6 100644 --- a/lib/keyboard.js +++ b/lib/keyboard.js @@ -36,13 +36,15 @@ export function stopKeypress() { ensureRawMode(false); } -export function waitForKey(message = "按任意键返回...") { +export function waitForKey(message = "按任意键返回...", predicate = () => true) { return new Promise(resolve => { console.log("\n" + message); initKeypress(); - onKey(() => { + onKey((str, key) => { + const pressed = key || {}; + if (!predicate(pressed)) return; stopKeypress(); - resolve(); + resolve(pressed); }); }); } diff --git a/lib/lod/index.js b/lib/lod/index.js deleted file mode 100644 index 0448cd3..0000000 --- a/lib/lod/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export async function run() { - throw new Error("尚未实现"); -} diff --git a/lib/menu.js b/lib/menu.js index 4e357f5..f3d22d3 100644 --- a/lib/menu.js +++ b/lib/menu.js @@ -1,18 +1,10 @@ import color from "picocolors"; import boxen from "boxen"; import { gridSelect } from "./grid.js"; -import * as convertTool from "./convert/index.js"; -import * as ktx2Tool from "./ktx2/index.js"; -import * as gltfTool from "./gltf/index.js"; -import * as modelTool from "./model/index.js"; -import * as imageTool from "./image/index.js"; -import * as spriteTool from "./sprite/index.js"; -import * as lodTool from "./lod/index.js"; -import * as audioTool from "./audio/index.js"; -import * as scaffoldTool from "./scaffold/index.js"; +import { getTools } from "./plugins/index.js"; let poemConfig = { - lines: ["你我皆牛马", "生在人世间", "终日奔波苦", "一刻不得闲"], + lines: ["你我皆牛马", "生在人世间", "终日赶波涛", "一刻不得闲"], perLine: 2, padding: { top: 2, bottom: 2, left: 6, right: 6 }, borderStyle: "double", @@ -21,18 +13,6 @@ let poemConfig = { let updateInfo = null; -const tools = [ - { name: "格式转换", desc: "支持多种模型格式转换", tool: convertTool }, - { name: "KTX2 纹理压缩", desc: "图片转KTX2格式", tool: ktx2Tool }, - { name: "glTF扩展", desc: "添加KHR_texture_basisu", tool: gltfTool }, - { name: "模型压缩", desc: "压缩glTF/GLB模型", tool: modelTool }, - { name: "图片批量处理", desc: "裁剪/缩放/转换", tool: imageTool }, - { name: "Sprite图集", desc: "合并精灵图集", tool: spriteTool }, - { name: "LOD生成器", desc: "生成多级细节", tool: lodTool }, - { name: "音频压缩", desc: "压缩音频文件", tool: audioTool }, - { name: "项目脚手架", desc: "快速创建项目模板", tool: scaffoldTool }, -]; - export function setPoem(lines, perLine = 2) { poemConfig.lines = lines; poemConfig.perLine = perLine; @@ -49,7 +29,7 @@ export function setUpdateInfo(info) { function renderPoem() { const merged = []; for (let i = 0; i < poemConfig.lines.length; i += poemConfig.perLine) { - merged.push(poemConfig.lines.slice(i, i + poemConfig.perLine).join(",")); + merged.push(poemConfig.lines.slice(i, i + poemConfig.perLine).join(" | ")); } return boxen(color.yellow(merged.join("\n")), { padding: poemConfig.padding, @@ -62,15 +42,18 @@ function renderPoem() { function renderUpdateInfo(selected) { if (!updateInfo) return ""; - const btn = selected ? color.cyan("[ 立即更新 ]") :color.white(" 立即更新 ") ; - const msg = `📦 发现新版本: ${color.red(updateInfo.current)} → ${color.green(updateInfo.latest)} ${btn}`; - return "\n" + boxen(color.yellow(msg), { - padding: { top: 0, bottom: 0, left: 2, right: 2 }, - borderStyle: "round", - borderColor: "green", - textAlignment: "center", - float: "center", - }); + const btn = selected ? color.cyan("[ 立即更新 ]") : color.white(" 立即更新 "); + const msg = `📦 发现新版本 ${color.red(updateInfo.current)} → ${color.green(updateInfo.latest)} ${btn}`; + return ( + "\n" + + boxen(color.yellow(msg), { + padding: { top: 0, bottom: 0, left: 2, right: 2 }, + borderStyle: "round", + borderColor: "green", + textAlignment: "center", + float: "center", + }) + ); } function renderHeader(onUpdate) { @@ -78,13 +61,18 @@ function renderHeader(onUpdate) { } export async function showMainMenu() { + const tools = getTools(); + return gridSelect({ items: tools, cols: 3, colWidth: 24, - renderHeader: renderHeader, - updateInfo: updateInfo, + renderHeader, + updateInfo, + instructions: "←→ 选择 | Enter 确认 | Esc 退出", + mapper: (item, isUpdate) => (isUpdate ? { isUpdate: true } : item), + showUpdateButton: !!updateInfo, }); } -export { tools, poemConfig }; +export { poemConfig }; diff --git a/lib/plugins/audio/index.js b/lib/plugins/audio/index.js new file mode 100644 index 0000000..4f0a735 --- /dev/null +++ b/lib/plugins/audio/index.js @@ -0,0 +1,10 @@ +async function run() { + throw new Error("尚未实现"); +} + +export default { + id: "audio", + name: "音频压缩", + desc: "压缩音频文件", + run, +}; diff --git a/lib/convert/config.js b/lib/plugins/convert/config.js similarity index 96% rename from lib/convert/config.js rename to lib/plugins/convert/config.js index 1f49bae..d62bded 100644 --- a/lib/convert/config.js +++ b/lib/plugins/convert/config.js @@ -1,6 +1,6 @@ import fs from "fs"; import { getImportExtensions, getExportFormats } from "./converters.js"; -import { sortByUsage } from "../stats.js"; +import { sortByUsage } from "../../stats.js"; const EXCLUDED_FORMATS = ["x", "glb", "gltf"]; diff --git a/lib/convert/converters.js b/lib/plugins/convert/converters.js similarity index 76% rename from lib/convert/converters.js rename to lib/plugins/convert/converters.js index 6caff0a..00cfc14 100644 --- a/lib/convert/converters.js +++ b/lib/plugins/convert/converters.js @@ -3,7 +3,7 @@ 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"); +const ASSIMP_PATH = path.join(__dirname, "../../../bin/model_converter.exe"); let importExts = null; let exportFormats = null; @@ -11,6 +11,7 @@ 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; @@ -19,6 +20,7 @@ export function getImportExtensions() { 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; diff --git a/lib/convert/index.js b/lib/plugins/convert/index.js similarity index 80% rename from lib/convert/index.js rename to lib/plugins/convert/index.js index cd0f229..39f07ec 100644 --- a/lib/convert/index.js +++ b/lib/plugins/convert/index.js @@ -3,13 +3,13 @@ import path from "path"; import color from "picocolors"; import { convert } from "./converters.js"; import { runInteractive, showSummary } from "./ui.js"; -import { stopKeypress, initKeypress, onKey } from "../keyboard.js"; -import { record } from "../stats.js"; +import { stopKeypress, waitForKey } from "../../keyboard.js"; +import { record } from "../../stats.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", + gltf2: ".gltf", glb2: ".glb", assbin: ".assbin", assxml: ".assxml", x3d: ".x3d", fbx: ".fbx", fbxa: ".fbx", "3mf": ".3mf", pbrt: ".pbrt", assjson: ".json" }; @@ -20,11 +20,12 @@ function getOutputExt(format) { async function processFile(file, config) { const cwd = process.cwd(); - const baseName = file.slice(0, file.lastIndexOf(".")); + const { dir, name } = path.parse(file); const outputExt = getOutputExt(config.outputFormat); - const outputFile = baseName + outputExt; + const outputFile = path.join(dir || "", `${name}${outputExt}`); + const absolutePath = path.join(cwd, file); - if (!fs.existsSync(path.join(cwd, file))) { + if (!fs.existsSync(absolutePath)) { return { ok: false, file, reason: "文件不存在" }; } @@ -33,21 +34,14 @@ async function processFile(file, config) { } async function waitForEsc() { - initKeypress(); - return new Promise(resolve => { - onKey((str, key) => { - if (key?.name === "escape" || (key?.ctrl && key?.name === "c")) { - resolve(); - } - }); - }); + return waitForKey(color.dim("按 Esc 返回"), key => key?.name === "escape" || (key?.ctrl && key?.name === "c")); } -export async function run() { +async function run() { const result = await runInteractive(); if (!result) return "back"; - const { steps, results } = result; + const { results } = result; const config = { files: results[0] || [], outputFormat: results[1] || "glb2" @@ -61,7 +55,6 @@ export async function run() { if (!config.files.length) { console.log(color.yellow("未选择任何模型文件")); - console.log(color.dim("\n按 Esc 返回")); await waitForEsc(); return "back"; } @@ -102,7 +95,13 @@ export async function run() { console.log(color.yellow(`失败: ${failed.length} 个`)); } - console.log(color.dim("\n按 Esc 返回")); await waitForEsc(); return "back"; } + +export default { + id: "convert", + name: "格式转换", + desc: "支持多种模型格式转换", + run, +}; diff --git a/lib/convert/ui.js b/lib/plugins/convert/ui.js similarity index 75% rename from lib/convert/ui.js rename to lib/plugins/convert/ui.js index 5b1b5cd..25488d9 100644 --- a/lib/convert/ui.js +++ b/lib/plugins/convert/ui.js @@ -1,4 +1,4 @@ -import { createStepUI } from "../utils/stepui.js"; +import { createStepUI } from "../../utils/stepui.js"; import { getSteps } from "./config.js"; const ui = createStepUI({ diff --git a/lib/gltf/config.js b/lib/plugins/gltf/config.js similarity index 94% rename from lib/gltf/config.js rename to lib/plugins/gltf/config.js index 68d0a95..e69b8c4 100644 --- a/lib/gltf/config.js +++ b/lib/plugins/gltf/config.js @@ -1,4 +1,4 @@ -import { listGltfFiles } from "../utils/gltf.js"; +import { listGltfFiles } from "../../utils/gltf.js"; const extensionStep = { name: "扩展选项", diff --git a/lib/gltf/index.js b/lib/plugins/gltf/index.js similarity index 91% rename from lib/gltf/index.js rename to lib/plugins/gltf/index.js index 83f4320..206732f 100644 --- a/lib/gltf/index.js +++ b/lib/plugins/gltf/index.js @@ -1,9 +1,9 @@ import fs from "fs"; import path from "path"; import color from "picocolors"; -import { checkRequiredFiles, modifyGltfContent, listGltfFiles, BACKUP_SUFFIX } from "../utils/gltf.js"; +import { checkRequiredFiles, modifyGltfContent, listGltfFiles, BACKUP_SUFFIX } from "../../utils/gltf.js"; import { runInteractive, showSummary } from "./ui.js"; -import { stopKeypress, waitForKey } from "../keyboard.js"; +import { stopKeypress, waitForKey } from "../../keyboard.js"; export function runGltfExtension(config = { files: [], extensions: ["textureBasisu"] }) { const cwd = process.cwd(); @@ -46,7 +46,7 @@ export function runGltfExtension(config = { files: [], extensions: ["textureBasi return { success: count > 0, count }; } -export async function run() { +async function run() { const result = await runInteractive(); if (!result) return "back"; @@ -71,3 +71,10 @@ export async function run() { await waitForKey(); return "back"; } + +export default { + id: "gltf", + name: "glTF扩展", + desc: "添加KTX2纹理扩展", + run, +}; diff --git a/lib/gltf/ui.js b/lib/plugins/gltf/ui.js similarity index 75% rename from lib/gltf/ui.js rename to lib/plugins/gltf/ui.js index 98c5dff..2839ff9 100644 --- a/lib/gltf/ui.js +++ b/lib/plugins/gltf/ui.js @@ -1,4 +1,4 @@ -import { createStepUI } from "../utils/stepui.js"; +import { createStepUI } from "../../utils/stepui.js"; import { getSteps } from "./config.js"; const ui = createStepUI({ diff --git a/lib/plugins/image/index.js b/lib/plugins/image/index.js new file mode 100644 index 0000000..341d3b2 --- /dev/null +++ b/lib/plugins/image/index.js @@ -0,0 +1,10 @@ +async function run() { + throw new Error("尚未实现"); +} + +export default { + id: "image", + name: "图片批量处理", + desc: "裁剪/缩放/转换", + run, +}; diff --git a/lib/plugins/index.js b/lib/plugins/index.js new file mode 100644 index 0000000..066a21a --- /dev/null +++ b/lib/plugins/index.js @@ -0,0 +1,14 @@ +import { registerTool, getTools } from "./registry.js"; +import convert from "./convert/index.js"; +import ktx2 from "./ktx2/index.js"; +import gltf from "./gltf/index.js"; +import model from "./model/index.js"; +import image from "./image/index.js"; +import sprite from "./sprite/index.js"; +import lod from "./lod/index.js"; +import audio from "./audio/index.js"; +import scaffold from "./scaffold/index.js"; + +[convert, ktx2, gltf, model, image, sprite, lod, audio, scaffold].forEach(registerTool); + +export { getTools, registerTool }; diff --git a/lib/ktx2/compressor.js b/lib/plugins/ktx2/compressor.js similarity index 97% rename from lib/ktx2/compressor.js rename to lib/plugins/ktx2/compressor.js index 9557ec3..f81acf4 100644 --- a/lib/ktx2/compressor.js +++ b/lib/plugins/ktx2/compressor.js @@ -6,7 +6,7 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const toktx = path.join(__dirname, "..", "..", "bin", "texture_tool.exe"); +const toktx = path.join(__dirname, "..", "..", "..", "bin", "texture_tool.exe"); // 检查 toktx 是否存在 export function checkToktx() { diff --git a/lib/ktx2/config.js b/lib/plugins/ktx2/config.js similarity index 100% rename from lib/ktx2/config.js rename to lib/plugins/ktx2/config.js diff --git a/lib/ktx2/gltf.js b/lib/plugins/ktx2/gltf.js similarity index 85% rename from lib/ktx2/gltf.js rename to lib/plugins/ktx2/gltf.js index 263e514..94abf7d 100644 --- a/lib/ktx2/gltf.js +++ b/lib/plugins/ktx2/gltf.js @@ -1,3 +1,3 @@ // 从 utils 导出,保持兼容 -export { hasGltfFile, checkRequiredFiles, modifyGltfContent } from "../utils/gltf.js"; +export { hasGltfFile, checkRequiredFiles, modifyGltfContent } from "../../utils/gltf.js"; export { runGltfExtension } from "../gltf/index.js"; diff --git a/lib/ktx2/index.js b/lib/plugins/ktx2/index.js similarity index 89% rename from lib/ktx2/index.js rename to lib/plugins/ktx2/index.js index cae45c1..9fcfa51 100644 --- a/lib/ktx2/index.js +++ b/lib/plugins/ktx2/index.js @@ -2,9 +2,9 @@ import color from "picocolors"; import { checkToktx, scanImages, compressAll } from "./compressor.js"; import { runInteractive, showSummary } from "./ui.js"; import { runGltfExtension } from "./gltf.js"; -import { stopKeypress, waitForKey } from "../keyboard.js"; +import { stopKeypress, waitForKey } from "../../keyboard.js"; -export async function run() { +async function run() { checkToktx(); const result = await runInteractive(); if (!result) return "back"; @@ -47,3 +47,10 @@ export async function run() { await waitForKey(); return "back"; } + +export default { + id: "ktx2", + name: "KTX2压缩", + desc: "纹理压缩为KTX2", + run, +}; diff --git a/lib/ktx2/ui.js b/lib/plugins/ktx2/ui.js similarity index 89% rename from lib/ktx2/ui.js rename to lib/plugins/ktx2/ui.js index 4c59a92..de7f175 100644 --- a/lib/ktx2/ui.js +++ b/lib/plugins/ktx2/ui.js @@ -1,4 +1,4 @@ -import { createStepUI } from "../utils/stepui.js"; +import { createStepUI } from "../../utils/stepui.js"; import { steps } from "./config.js"; import { hasGltfFile } from "./gltf.js"; diff --git a/lib/plugins/lod/index.js b/lib/plugins/lod/index.js new file mode 100644 index 0000000..7e29a1b --- /dev/null +++ b/lib/plugins/lod/index.js @@ -0,0 +1,10 @@ +async function run() { + throw new Error("尚未实现"); +} + +export default { + id: "lod", + name: "LOD生成器", + desc: "生成多级细节", + run, +}; diff --git a/lib/model/config.js b/lib/plugins/model/config.js similarity index 98% rename from lib/model/config.js rename to lib/plugins/model/config.js index a96c421..5de3deb 100644 --- a/lib/model/config.js +++ b/lib/plugins/model/config.js @@ -1,4 +1,4 @@ -import { listAllModelFiles } from "../utils/gltf.js"; +import { listAllModelFiles } from "../../utils/gltf.js"; const transformOptions = [ { value: "dedup", label: "dedup(去重)", hint: "删除重复的访问器、材质、网格" }, diff --git a/lib/model/index.js b/lib/plugins/model/index.js similarity index 96% rename from lib/model/index.js rename to lib/plugins/model/index.js index 45d2912..642a5cc 100644 --- a/lib/model/index.js +++ b/lib/plugins/model/index.js @@ -4,10 +4,10 @@ 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 { BACKUP_SUFFIX, needsConversion } from "../../utils/gltf.js"; import { convert } from "../convert/converters.js"; import { runInteractive, showSummary } from "./ui.js"; -import { stopKeypress, waitForKey } from "../keyboard.js"; +import { stopKeypress, waitForKey } from "../../keyboard.js"; const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); @@ -119,7 +119,7 @@ async function processFile(file, config, transforms) { return { ok: true, file, output: output.targetPath }; } -export async function run() { +async function run() { const result = await runInteractive(); if (!result) return "back"; @@ -193,3 +193,10 @@ export async function run() { await waitForKey(); return "back"; } + +export default { + id: "model", + name: "模型压缩", + desc: "glTF/GLB/OBJ/FBX压缩", + run, +}; diff --git a/lib/model/ui.js b/lib/plugins/model/ui.js similarity index 75% rename from lib/model/ui.js rename to lib/plugins/model/ui.js index 3aeb478..3ff47c4 100644 --- a/lib/model/ui.js +++ b/lib/plugins/model/ui.js @@ -1,4 +1,4 @@ -import { createStepUI } from "../utils/stepui.js"; +import { createStepUI } from "../../utils/stepui.js"; import { getSteps } from "./config.js"; const ui = createStepUI({ diff --git a/lib/plugins/registry.js b/lib/plugins/registry.js new file mode 100644 index 0000000..505a14c --- /dev/null +++ b/lib/plugins/registry.js @@ -0,0 +1,23 @@ +const tools = []; + +export function registerTool(definition) { + const { id, name, desc, run } = definition || {}; + if (!id || !name || !run) { + throw new Error("Tool definition must include id, name and run"); + } + + const existing = tools.findIndex(t => t.id === id); + const tool = { ...definition }; + + if (existing >= 0) { + tools[existing] = tool; + } else { + tools.push(tool); + } + + return tool; +} + +export function getTools() { + return [...tools]; +} diff --git a/lib/scaffold/config.js b/lib/plugins/scaffold/config.js similarity index 100% rename from lib/scaffold/config.js rename to lib/plugins/scaffold/config.js diff --git a/lib/plugins/scaffold/index.js b/lib/plugins/scaffold/index.js new file mode 100644 index 0000000..05b2861 --- /dev/null +++ b/lib/plugins/scaffold/index.js @@ -0,0 +1,42 @@ +import color from "picocolors"; +import { projectTypes } from "./config.js"; +import { gridSelect } from "../../grid.js"; +import { createScaffoldUI, formatResults, waitKey } from "./ui.js"; + +async function run() { + while (true) { + // 二级菜单 - 项目类型 + const typeResult = await gridSelect({ + items: projectTypes, + title: "项目搭建器 - 选择类型", + cols: Math.min(3, projectTypes.length), + colWidth: 20, + instructions: "←→ 选择 | Enter 确认 | Esc 返回", + onCancel: "back", + mapper: item => ({ action: "select", item }), + showUpdateButton: false, + }); + + if (typeResult === "back") return "back"; + + const projectType = typeResult.item.name; + + // 三级页面 - 框架+组件配置(合并) + const ui = createScaffoldUI(projectType); + const result = await ui.runInteractive(); + + if (result) { + const summary = formatResults(result.steps, result.results); + ui.showSummary(summary); + console.log(color.yellow("功能开发中,敬请期待...")); + await waitKey(); + } + } +} + +export default { + id: "scaffold", + name: "项目搭建", + desc: "快速创建项目模板", + run, +}; diff --git a/lib/plugins/scaffold/ui.js b/lib/plugins/scaffold/ui.js new file mode 100644 index 0000000..5e35644 --- /dev/null +++ b/lib/plugins/scaffold/ui.js @@ -0,0 +1,41 @@ +import color from "picocolors"; +import { initKeypress, onKey, stopKeypress } from "../../keyboard.js"; +import { createStepUI } from "../../utils/stepui.js"; +import { generateSteps } from "./config.js"; + +// 框架 + 组件配置 UI +export function createScaffoldUI(projectType) { + return createStepUI({ + title: `${projectType} - 项目配置`, + getSteps: () => generateSteps(projectType, null), + onStepChange: framework => generateSteps(projectType, framework), + }); +} + +// 解析配置结果 +export function formatResults(steps, results) { + const summary = []; + + results.forEach((val, i) => { + if (Array.isArray(val) && val.length > 0) { + summary.push(`${steps[i].name}: ${val.join(", ")}`); + } else if (val && val !== "none") { + summary.push(`${steps[i].name}: ${val}`); + } + }); + + return summary.length ? summary : ["未选择任何组件"]; +} + +// 等待按键 +export async function waitKey(message = "按任意键返回") { + console.log(color.dim(`\n${message}`)); + + return new Promise(resolve => { + initKeypress(); + onKey(() => { + stopKeypress(); + resolve(); + }); + }); +} diff --git a/lib/plugins/sprite/index.js b/lib/plugins/sprite/index.js new file mode 100644 index 0000000..be2fa34 --- /dev/null +++ b/lib/plugins/sprite/index.js @@ -0,0 +1,10 @@ +async function run() { + throw new Error("尚未实现"); +} + +export default { + id: "sprite", + name: "Sprite图集", + desc: "合并精灵图集", + run, +}; diff --git a/lib/scaffold/index.js b/lib/scaffold/index.js deleted file mode 100644 index 16e26ed..0000000 --- a/lib/scaffold/index.js +++ /dev/null @@ -1,24 +0,0 @@ -import color from "picocolors"; -import { projectTypes } from "./config.js"; -import { gridSelect, createScaffoldUI, formatResults, waitKey } from "./ui.js"; - -export async function run() { - while (true) { - // 二级菜单 - 项目类型 - const typeResult = await gridSelect(projectTypes, "项目脚手架 - 选择类型"); - if (typeResult.action === "back") return "back"; - - const projectType = typeResult.item.name; - - // 三级页面 - 框架+组件配置(合并) - const ui = createScaffoldUI(projectType); - const result = await ui.runInteractive(); - - if (result) { - const summary = formatResults(result.steps, result.results); - ui.showSummary(summary); - console.log(color.yellow("功能开发中,敬请期待...")); - await waitKey(); - } - } -} diff --git a/lib/scaffold/ui.js b/lib/scaffold/ui.js deleted file mode 100644 index 65bdf27..0000000 --- a/lib/scaffold/ui.js +++ /dev/null @@ -1,141 +0,0 @@ -import color from "picocolors"; -import { initKeypress, onKey, stopKeypress } from "../keyboard.js"; -import { createStepUI } from "../utils/stepui.js"; -import { frameworkOptions, generateSteps } from "./config.js"; - -function clearScreen() { - process.stdout.write('\x1Bc'); -} - -function strWidth(str) { - let width = 0; - for (const char of str) { - width += char.charCodeAt(0) > 127 ? 2 : 1; - } - return width; -} - -function padEnd(str, width) { - return str + " ".repeat(Math.max(0, width - strWidth(str))); -} - -// 网格选择器 -export async function gridSelect(items, title) { - let current = 0; - let resolved = false; - const termWidth = process.stdout.columns || 80; - const cols = Math.min(3, items.length); - const colWidth = 20; - const rows = Math.ceil(items.length / cols); - const totalWidth = cols * colWidth; - const pad = " ".repeat(Math.max(0, Math.floor((termWidth - totalWidth) / 2))); - - function render() { - clearScreen(); - console.log(""); - const titlePad = " ".repeat(Math.max(0, Math.floor((termWidth - strWidth(title) - 4) / 2))); - console.log(titlePad + color.bgMagenta(color.white(` ${title} `))); - console.log(""); - console.log(pad + color.dim("↑ ↓ ← → 选择 | Enter 确认 | Esc 返回")); - console.log("\n"); - - for (let row = 0; row < rows; row++) { - let line = ""; - let descLine = ""; - - for (let col = 0; col < cols; col++) { - const idx = row * cols + col; - if (idx < items.length) { - const item = items[idx]; - const isSelected = idx === current; - - if (isSelected) { - line += color.cyan("[" + item.name + "]"); - } else { - line += " " + item.name + " "; - } - line += " ".repeat(colWidth - strWidth(item.name) - 2); - descLine += padEnd(item.desc || "", colWidth); - } - } - - console.log(pad + line); - console.log(pad + color.dim(descLine)); - console.log("\n"); - } - } - - return new Promise(resolve => { - initKeypress(); - render(); - - onKey((str, key) => { - if (!key || resolved) return; - - const row = Math.floor(current / cols); - const col = current % cols; - - switch (key.name) { - case "up": - if (row > 0) { current -= cols; render(); } - break; - case "down": - if (row < rows - 1 && current + cols < items.length) { current += cols; render(); } - break; - case "left": - if (col > 0) { current--; render(); } - break; - case "right": - if (col < cols - 1 && current < items.length - 1) { current++; render(); } - break; - case "return": - resolved = true; - stopKeypress(); - setImmediate(() => resolve({ action: "select", item: items[current] })); - break; - case "escape": - resolved = true; - stopKeypress(); - setImmediate(() => resolve({ action: "back" })); - break; - } - }); - }); -} - -// 创建框架+组件配置UI -export function createScaffoldUI(projectType) { - return createStepUI({ - title: `${projectType} - 项目配置`, - getSteps: () => generateSteps(projectType, null), - onStepChange: (framework) => generateSteps(projectType, framework) - }); -} - -// 解析配置结果 -export function formatResults(steps, results) { - const summary = []; - - results.forEach((val, i) => { - if (Array.isArray(val) && val.length > 0) { - summary.push(`${steps[i].name}: ${val.join(", ")}`); - } else if (val && val !== "none") { - summary.push(`${steps[i].name}: ${val}`); - } - }); - - return summary.length ? summary : ["未选择任何组件"]; -} - -// 等待按键 -export async function waitKey(message = "按任意键返回") { - console.log(color.dim(`\n${message}`)); - - return new Promise(resolve => { - initKeypress(); - onKey(() => { - stopKeypress(); - resolve(); - }); - }); -} diff --git a/lib/sprite/index.js b/lib/sprite/index.js deleted file mode 100644 index 0448cd3..0000000 --- a/lib/sprite/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export async function run() { - throw new Error("尚未实现"); -} diff --git a/lib/utils/stepui.js b/lib/utils/stepui.js index 61dade9..c9c4267 100644 --- a/lib/utils/stepui.js +++ b/lib/utils/stepui.js @@ -1,9 +1,6 @@ import color from "picocolors"; import { initKeypress, onKey, stopKeypress } from "../keyboard.js"; - -function clearScreen() { - process.stdout.write('\x1Bc'); -} +import { clearScreen } from "./terminal.js"; export function createStepUI(options) { const { title, getSteps, onStepChange } = options; diff --git a/lib/utils/terminal.js b/lib/utils/terminal.js new file mode 100644 index 0000000..1d7fe28 --- /dev/null +++ b/lib/utils/terminal.js @@ -0,0 +1,21 @@ +export function clearScreen() { + process.stdout.write("\x1Bc"); +} + +export function strWidth(str = "") { + let width = 0; + for (const char of String(str)) { + width += char.charCodeAt(0) > 127 ? 2 : 1; + } + return width; +} + +export function padEnd(str, width) { + const value = String(str); + return value + " ".repeat(Math.max(0, width - strWidth(value))); +} + +export function centerPad(text, totalWidth) { + const pad = Math.max(0, Math.floor((totalWidth - strWidth(text)) / 2)); + return " ".repeat(pad); +} diff --git a/tmp.txt b/tmp.txt new file mode 100644 index 0000000..e69de29