重构成功

This commit is contained in:
yinsx
2025-12-22 15:01:10 +08:00
parent 1df41ac4ab
commit 1de2ac8491
25 changed files with 349 additions and 377 deletions

View File

@ -20,7 +20,8 @@
"Bash(for dir in gltf ktx2 model scaffold)", "Bash(for dir in gltf ktx2 model scaffold)",
"Bash(do cp lib/$dir/*.js lib/plugins/$dir/)", "Bash(do cp lib/$dir/*.js lib/plugins/$dir/)",
"Bash(done)", "Bash(done)",
"Bash(timeout 3 node:*)" "Bash(timeout 3 node:*)",
"Bash(find:*)"
] ]
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"convert_format": { "convert_format": {
"stp": 1, "stp": 1,
"glb2": 2 "glb2": 3
} }
} }

BIN
cuba.glb Normal file

Binary file not shown.

21
lib/paths.js Normal file
View File

@ -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");

View File

@ -1,9 +1,11 @@
import fs from "fs"; import fs from "fs";
import { getImportExtensions, getExportFormats } from "./converters.js"; import { getImportExtensions, getExportFormats } from "./service.js";
import { sortByUsage } from "../../stats.js"; import { sortByUsage } from "../../stats.js";
const EXCLUDED_FORMATS = ["x", "glb", "gltf"]; const EXCLUDED_FORMATS = ["x", "glb", "gltf"];
export const title = "格式转换工具";
export function listConvertibleFiles() { export function listConvertibleFiles() {
const cwd = process.cwd(); const cwd = process.cwd();
const exts = getImportExtensions(); const exts = getImportExtensions();

View File

@ -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;
}

View File

@ -1,61 +1,30 @@
import fs from "fs";
import path from "path"; import path from "path";
import color from "picocolors"; import color from "picocolors";
import { convert } from "./converters.js"; import { createStepUI } from "../../utils/stepui.js";
import { runInteractive, showSummary } from "./ui.js"; import { title, getSteps } from "./config.js";
import { stopKeypress, waitForKey } from "../../keyboard.js"; import { stopKeypress, waitForKey } from "../../keyboard.js";
import { record } from "../../stats.js"; import { record } from "../../stats.js";
import { processFile } from "./service.js";
const FORMAT_EXT = { const run = async () => {
collada: ".dae",stp: ".stp", obj: ".obj", objnomtl: ".obj", const ui = createStepUI({ title, getSteps });
stl: ".stl", stlb: ".stl", ply: ".ply", plyb: ".ply", "3ds": ".3ds", const result = await ui.runInteractive();
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();
if (!result) return "back"; if (!result) return "back";
const { results } = result;
const config = { const config = {
files: results[0] || [], files: result.results[0] || [],
outputFormat: results[1] || "glb2" outputFormat: result.results[1] || "glb2"
}; };
stopKeypress(); stopKeypress();
showSummary([ ui.showSummary([
"源文件: " + (config.files.length ? config.files.join(", ") : "未选择"), "源文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
"目标格式: " + config.outputFormat.toUpperCase() "目标格式: " + config.outputFormat.toUpperCase()
]); ]);
if (!config.files.length) { if (!config.files.length) {
console.log(color.yellow("未选择任何模型文件")); console.log(color.yellow("未选择任何模型文件"));
await waitForEsc(); await waitForKey(color.dim("按 Esc 返回"), key => key?.name === "escape" || (key?.ctrl && key?.name === "c"));
return "back"; return "back";
} }
@ -88,16 +57,12 @@ async function run() {
} }
console.log("\n" + color.bgGreen(color.black(" 转换完成 "))); console.log("\n" + color.bgGreen(color.black(" 转换完成 ")));
if (success) { if (success) console.log(color.green(`成功: ${success}`));
console.log(color.green(`成功: ${success}`)); if (failed.length) console.log(color.yellow(`失败: ${failed.length}`));
}
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"; return "back";
} };
export default { export default {
id: "convert", id: "convert",

View File

@ -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 };
};

View File

@ -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;

View File

@ -1,5 +1,7 @@
import { listGltfFiles } from "../../utils/gltf.js"; import { listGltfFiles } from "../../utils/gltf.js";
export const title = "glTF 扩展工具";
const extensionStep = { const extensionStep = {
name: "扩展选项", name: "扩展选项",
type: "multiselect", type: "multiselect",
@ -13,7 +15,7 @@ const extensionStep = {
default: ["textureBasisu"] default: ["textureBasisu"]
}; };
export function getSteps() { export const getSteps = () => {
const files = listGltfFiles(); const files = listGltfFiles();
const fileStep = { const fileStep = {
name: "文件选择", name: "文件选择",
@ -24,4 +26,4 @@ export function getSteps() {
}; };
return [fileStep, extensionStep]; return [fileStep, extensionStep];
} };

View File

@ -1,76 +1,36 @@
import fs from "fs";
import path from "path";
import color from "picocolors"; import color from "picocolors";
import { checkRequiredFiles, modifyGltfContent, listGltfFiles, BACKUP_SUFFIX } from "../../utils/gltf.js"; import { createStepUI } from "../../utils/stepui.js";
import { runInteractive, showSummary } from "./ui.js"; import { title, getSteps } from "./config.js";
import { stopKeypress, waitForKey } from "../../keyboard.js"; import { stopKeypress, waitForKey } from "../../keyboard.js";
import { runGltfExtension } from "./service.js";
export function runGltfExtension(config = { files: [], extensions: ["textureBasisu"] }) { const run = async () => {
const cwd = process.cwd(); const ui = createStepUI({ title, getSteps });
const { files = [], extensions = ["textureBasisu"] } = config; const result = await ui.runInteractive();
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();
if (!result) return "back"; if (!result) return "back";
stopKeypress(); stopKeypress();
const { results } = result;
const config = { const config = {
files: results[0] || [], files: result.results[0] || [],
extensions: results[1] || [] extensions: result.results[1] || []
}; };
showSummary([ ui.showSummary([
"处理文件: " + (config.files.length ? config.files.join(", ") : "未选择"), "处理文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
"扩展选项: " + (config.extensions.length ? config.extensions.join(", ") : "未选择") "扩展选项: " + (config.extensions.length ? config.extensions.join(", ") : "未选择")
]); ]);
const { success, count } = runGltfExtension(config); const { success, count, error } = runGltfExtension(config);
if (success) { if (error) {
console.log(color.red("\n✖ " + error));
} else if (success) {
console.log(color.green("\n✓ 已修改 " + count + " 个 glTF 文件")); console.log(color.green("\n✓ 已修改 " + count + " 个 glTF 文件"));
} }
await waitForKey(); await waitForKey();
return "back"; return "back";
} };
export default { export default {
id: "gltf", id: "gltf",

View File

@ -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 };
};

View File

@ -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;

View File

@ -1,5 +1,9 @@
import { hasGltfFile } from "../../utils/gltf.js";
export const title = "KTX2 纹理压缩工具";
// 步骤配置 // 步骤配置
export const steps = [ const steps = [
{ {
name: "文件格式", name: "文件格式",
type: "multiselect", type: "multiselect",
@ -61,3 +65,13 @@ export const steps = [
default: ["overwrite", "keepOriginal"] 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;
});
}

View File

@ -1,3 +0,0 @@
// 从 utils 导出,保持兼容
export { hasGltfFile, checkRequiredFiles, modifyGltfContent } from "../../utils/gltf.js";
export { runGltfExtension } from "../gltf/index.js";

View File

@ -1,20 +1,22 @@
import color from "picocolors"; import color from "picocolors";
import { checkToktx, scanImages, compressAll } from "./compressor.js"; import { createStepUI } from "../../utils/stepui.js";
import { runInteractive, showSummary } from "./ui.js"; import { title, getSteps } from "./config.js";
import { runGltfExtension } from "./gltf.js";
import { stopKeypress, waitForKey } from "../../keyboard.js"; import { stopKeypress, waitForKey } from "../../keyboard.js";
import { checkToktx, scanImages, compressAll, runGltfExtension } from "./service.js";
async function run() { const run = async () => {
checkToktx(); checkToktx();
const result = await runInteractive();
const ui = createStepUI({ title, getSteps });
const result = await ui.runInteractive();
if (!result) return "back"; if (!result) return "back";
stopKeypress(); stopKeypress();
const { results } = result; const [exts, quality, encoding, mipmap, outputOpts] = result.results;
const [exts, quality, encoding, mipmap, outputOpts] = results;
const config = { exts, quality, encoding, mipmap, outputOpts }; const config = { exts, quality, encoding, mipmap, outputOpts };
showSummary([
ui.showSummary([
"文件格式: " + config.exts.join(", "), "文件格式: " + config.exts.join(", "),
"压缩程度: " + config.quality, "压缩程度: " + config.quality,
"编码格式: " + config.encoding, "编码格式: " + config.encoding,
@ -46,7 +48,7 @@ async function run() {
await waitForKey(); await waitForKey();
return "back"; return "back";
} };
export default { export default {
id: "ktx2", id: "ktx2",

View File

@ -2,32 +2,29 @@ import fs from "fs";
import path from "path"; import path from "path";
import { spawn } from "child_process"; import { spawn } from "child_process";
import color from "picocolors"; 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); export const checkToktx = () => {
const toktx = path.join(__dirname, "..", "..", "..", "bin", "texture_tool.exe"); if (!fs.existsSync(TEXTURE_TOOL)) {
// 检查 toktx 是否存在
export function checkToktx() {
if (!fs.existsSync(toktx)) {
console.error("❌ 找不到 texture_tool.exe"); console.error("❌ 找不到 texture_tool.exe");
process.exit(1); process.exit(1);
} }
} };
// 扫描图片文件 // 扫描图片文件
export function scanImages(exts) { export const scanImages = (exts) => {
console.log("🔍 扫描目标文件中..."); console.log("🔍 扫描目标文件中...");
const cwd = process.cwd(); const cwd = process.cwd();
const images = fs.readdirSync(cwd).filter(f => const images = fs.readdirSync(cwd).filter(f =>
exts.some(ext => f.toLowerCase().endsWith("." + ext)) exts.some(ext => f.toLowerCase().endsWith("." + ext))
); );
return { images, cwd }; return { images, cwd };
} };
// 构建压缩参数 // 构建压缩参数
export function buildArgs(input, output, config) { const buildArgs = (input, output, config) => {
const args = ["--t2"]; const args = ["--t2"];
if (config.encoding === "uastc") { if (config.encoding === "uastc") {
@ -48,14 +45,13 @@ export function buildArgs(input, output, config) {
args.push(output, input); args.push(output, input);
return args; return args;
} };
// 压缩单个文件 // 压缩单个文件
export function compressFile(img, config, cwd, progress) { const compressFile = (img, config, cwd, progress) => {
const baseName = img.replace(/\.[^.]+$/, ""); const baseName = img.replace(/\.[^.]+$/, "");
const out = baseName + ".ktx2"; const out = baseName + ".ktx2";
// 点动画
let dots = 0; let dots = 0;
const dotAnim = setInterval(() => { const dotAnim = setInterval(() => {
const dotStr = ".".repeat(dots); const dotStr = ".".repeat(dots);
@ -68,7 +64,7 @@ export function compressFile(img, config, cwd, progress) {
const args = buildArgs(img, out, config); const args = buildArgs(img, out, config);
return new Promise((resolve) => { return new Promise((resolve) => {
const proc = spawn(toktx, args, { cwd }); const proc = spawn(TEXTURE_TOOL, args, { cwd });
let stderr = ""; let stderr = "";
proc.stderr?.on("data", data => { proc.stderr?.on("data", data => {
@ -96,10 +92,10 @@ export function compressFile(img, config, cwd, progress) {
resolve({ success: false, error: err.message }); resolve({ success: false, error: err.message });
}); });
}); });
} };
// 批量压缩 // 批量压缩
export async function compressAll(images, config, cwd) { export const compressAll = async (images, config, cwd) => {
const total = images.length; const total = images.length;
let finished = 0; let finished = 0;
let failed = 0; let failed = 0;
@ -112,4 +108,7 @@ export async function compressAll(images, config, cwd) {
} }
return { total, failed }; return { total, failed };
} };
// 导出 gltf 扩展功能
export { runGltfExtension };

View File

@ -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;

View File

@ -1,5 +1,7 @@
import { listAllModelFiles } from "../../utils/gltf.js"; import { listAllModelFiles } from "../../utils/gltf.js";
export const title = "模型压缩工具";
const transformOptions = [ const transformOptions = [
{ value: "dedup", label: "dedup去重", hint: "删除重复的访问器、材质、网格" }, { value: "dedup", label: "dedup去重", hint: "删除重复的访问器、材质、网格" },
{ value: "prune", label: "prune清理无用节点", hint: "移除未被引用的节点、材质、动画" }, { value: "prune", label: "prune清理无用节点", hint: "移除未被引用的节点、材质、动画" },
@ -27,7 +29,7 @@ const outputOptions = [
{ value: "copy", label: "输出副本 (_compressed)", hint: "保留原文件不动,结果写入新文件" } { value: "copy", label: "输出副本 (_compressed)", hint: "保留原文件不动,结果写入新文件" }
]; ];
export function getSteps() { export const getSteps = () => {
const files = listAllModelFiles(); const files = listAllModelFiles();
const fileStep = { const fileStep = {
name: "模型选择", name: "模型选择",
@ -68,4 +70,4 @@ export function getSteps() {
default: ["overwrite", "backup"] default: ["overwrite", "backup"]
} }
]; ];
} };

View File

@ -1,140 +1,26 @@
import fs from "fs";
import path from "path"; import path from "path";
import color from "picocolors"; import color from "picocolors";
import { NodeIO } from "@gltf-transform/core"; import { createStepUI } from "../../utils/stepui.js";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { title, getSteps } from "./config.js";
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 { stopKeypress, waitForKey } from "../../keyboard.js"; import { stopKeypress, waitForKey } from "../../keyboard.js";
import { buildTransforms, processFile } from "./service.js";
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); const run = async () => {
const ui = createStepUI({ title, getSteps });
const QUANTIZE_PRESETS = { const result = await ui.runInteractive();
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();
if (!result) return "back"; if (!result) return "back";
stopKeypress(); stopKeypress();
const { results } = result;
const config = { const config = {
files: results[0] || [], files: result.results[0] || [],
commands: results[1] || [], commands: result.results[1] || [],
quantizePreset: results[2] || "balanced", quantizePreset: result.results[2] || "balanced",
outputFormat: results[3] || "auto", outputFormat: result.results[3] || "auto",
outputOptions: results[4] || [] outputOptions: result.results[4] || []
}; };
showSummary([ ui.showSummary([
"模型文件: " + (config.files.length ? config.files.join(", ") : "未选择"), "模型文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
"gltf-transform 命令: " + (config.commands.length ? config.commands.join(", ") : "无"), "gltf-transform 命令: " + (config.commands.length ? config.commands.join(", ") : "无"),
"量化级别: " + config.quantizePreset, "量化级别: " + config.quantizePreset,
@ -183,16 +69,12 @@ async function run() {
} }
console.log("\n" + color.bgGreen(color.black(" 压缩完成 "))); console.log("\n" + color.bgGreen(color.black(" 压缩完成 ")));
if (success) { if (success) console.log(color.green(`成功: ${success}`));
console.log(color.green(`成功: ${success}`)); if (failed.length) console.log(color.yellow(`失败: ${failed.length} 个 (${failed.join(", ")})`));
}
if (failed.length) {
console.log(color.yellow(`失败: ${failed.length} 个 (${failed.join(", ")})`));
}
await waitForKey(); await waitForKey();
return "back"; return "back";
} };
export default { export default {
id: "model", id: "model",

View File

@ -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 };
};

View File

@ -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;

View File

@ -155,7 +155,7 @@ const commonComponents = {
}; };
// 根据框架获取组件配置 // 根据框架获取组件配置
export function getComponentsByFramework(framework) { export const getComponentsByFramework = (framework) => {
if (!framework || framework === "none") return {}; if (!framework || framework === "none") return {};
const isReact = ["react", "nextjs"].includes(framework); const isReact = ["react", "nextjs"].includes(framework);
@ -174,16 +174,16 @@ export function getComponentsByFramework(framework) {
} }
return {}; return {};
} };
// 获取默认框架 // 获取默认框架
function getDefaultFramework(projectType) { const getDefaultFramework = (projectType) => {
const opts = frameworkOptions[projectType]; const opts = frameworkOptions[projectType];
return opts?.[0]?.value || "none"; return opts?.[0]?.value || "none";
} };
// 生成前端/全栈项目的步骤配置 // 生成前端/全栈项目的步骤配置
export function generateSteps(projectType, selectedFramework) { export const generateSteps = (projectType, selectedFramework) => {
const defaultFramework = getDefaultFramework(projectType); const defaultFramework = getDefaultFramework(projectType);
const framework = selectedFramework || defaultFramework; const framework = selectedFramework || defaultFramework;
@ -213,4 +213,4 @@ export function generateSteps(projectType, selectedFramework) {
}); });
return steps; return steps;
} };

View File

@ -1,9 +1,9 @@
import color from "picocolors"; import color from "picocolors";
import { projectTypes } from "./config.js"; import { projectTypes } from "./config.js";
import { gridSelect } from "../../grid.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) { while (true) {
// 二级菜单 - 项目类型 // 二级菜单 - 项目类型
const typeResult = await gridSelect({ const typeResult = await gridSelect({
@ -32,7 +32,7 @@ async function run() {
await waitKey(); await waitKey();
} }
} }
} };
export default { export default {
id: "scaffold", id: "scaffold",

View File

@ -1,21 +1,18 @@
import color from "picocolors"; import color from "picocolors";
import { initKeypress, onKey, stopKeypress } from "../../keyboard.js";
import { createStepUI } from "../../utils/stepui.js"; import { createStepUI } from "../../utils/stepui.js";
import { generateSteps } from "./config.js"; import { generateSteps } from "./config.js";
import { initKeypress, onKey, stopKeypress } from "../../keyboard.js";
// 框架 + 组件配置 UI export const createScaffoldUI = (projectType) => {
export function createScaffoldUI(projectType) {
return createStepUI({ return createStepUI({
title: `${projectType} - 项目配置`, title: `${projectType} - 项目配置`,
getSteps: () => generateSteps(projectType, null), getSteps: () => generateSteps(projectType, null),
onStepChange: framework => generateSteps(projectType, framework), onStepChange: framework => generateSteps(projectType, framework),
}); });
} };
// 解析配置结果 export const formatResults = (steps, results) => {
export function formatResults(steps, results) {
const summary = []; const summary = [];
results.forEach((val, i) => { results.forEach((val, i) => {
if (Array.isArray(val) && val.length > 0) { if (Array.isArray(val) && val.length > 0) {
summary.push(`${steps[i].name}: ${val.join(", ")}`); summary.push(`${steps[i].name}: ${val.join(", ")}`);
@ -23,14 +20,11 @@ export function formatResults(steps, results) {
summary.push(`${steps[i].name}: ${val}`); summary.push(`${steps[i].name}: ${val}`);
} }
}); });
return summary.length ? summary : ["未选择任何组件"]; return summary.length ? summary : ["未选择任何组件"];
} };
// 等待按键 export const waitKey = async (message = "按任意键返回") => {
export async function waitKey(message = "按任意键返回") {
console.log(color.dim(`\n${message}`)); console.log(color.dim(`\n${message}`));
return new Promise(resolve => { return new Promise(resolve => {
initKeypress(); initKeypress();
onKey(() => { onKey(() => {
@ -38,4 +32,4 @@ export async function waitKey(message = "按任意键返回") {
resolve(); resolve();
}); });
}); });
} };