增加转格式

This commit is contained in:
yinsx
2025-12-20 11:51:35 +08:00
parent 5315a97613
commit d9abc57b0b
32 changed files with 4339 additions and 229 deletions

View File

@ -8,7 +8,11 @@
"Bash(timeout 2 node:*)", "Bash(timeout 2 node:*)",
"Bash(npm ls:*)", "Bash(npm ls:*)",
"Bash(timeout 1 node:*)", "Bash(timeout 1 node:*)",
"Bash(node -e:*)" "Bash(node -e:*)",
"Bash(node --check:*)",
"Bash(npm search:*)",
"Bash(mkdir:*)",
"Bash(./bin/assimp.exe:*)"
] ]
} }
} }

6
.stats.json Normal file
View File

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

BIN
bin/assimp-vc143-mt.dll Normal file

Binary file not shown.

BIN
bin/assimp.exe Normal file

Binary file not shown.

BIN
cuba.fbx Normal file

Binary file not shown.

BIN
cuba.glb Normal file

Binary file not shown.

296
cuba.stp Normal file
View File

@ -0,0 +1,296 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('STEP AP214'),'1');
FILE_NAME('cuba.stp','2025-12-20T11:13:44',(' '),(' '),'Spatial InterOp 3D',' ',' ');
FILE_SCHEMA(('automotive_design'));
ENDSEC;
DATA;
#1=MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION(' ',(#148,#183,#218,#253,#288,#323),#6);
#2=PRODUCT_DEFINITION_CONTEXT('',#7,'design');
#3=APPLICATION_PROTOCOL_DEFINITION('INTERNATIONAL STANDARD','automotive_design',1994,#7);
#4=PRODUCT_CATEGORY_RELATIONSHIP('NONE','NONE',#8,#9);
#5=SHAPE_DEFINITION_REPRESENTATION(#10,#11);
#6= (GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#12))GLOBAL_UNIT_ASSIGNED_CONTEXT((#13,#14,#15))REPRESENTATION_CONTEXT('NONE','WORKSPACE'));
#7=APPLICATION_CONTEXT(' ');
#8=PRODUCT_CATEGORY('part','NONE');
#9=PRODUCT_RELATED_PRODUCT_CATEGORY('detail',' ',(#17));
#10=PRODUCT_DEFINITION_SHAPE('NONE','NONE',#18);
#11=MANIFOLD_SURFACE_SHAPE_REPRESENTATION('Root',(#16,#19),#6);
#12=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.0E-006),#13,'','');
#13=(CONVERSION_BASED_UNIT('METRE',#20)LENGTH_UNIT()NAMED_UNIT(#21));
#14=(NAMED_UNIT(#22)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));
#15=(NAMED_UNIT(#22)SOLID_ANGLE_UNIT()SI_UNIT($,.STERADIAN.));
#16=SHELL_BASED_SURFACE_MODEL('Root',(#29));
#17=PRODUCT('Root','Root','Root',(#23));
#18=PRODUCT_DEFINITION('NONE','NONE',#24,#2);
#19=AXIS2_PLACEMENT_3D('',#25,#26,#27);
#20=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.0),#28);
#21=DIMENSIONAL_EXPONENTS(1.0,0.0,0.0,0.0,0.0,0.0,0.0);
#22=DIMENSIONAL_EXPONENTS(0.0,0.0,0.0,0.0,0.0,0.0,0.0);
#23=PRODUCT_CONTEXT('',#7,'mechanical');
#24=PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE(' ','NONE',#17,.NOT_KNOWN.);
#25=CARTESIAN_POINT('',(0.0,0.0,0.0));
#26=DIRECTION('',(0.0,0.0,1.0));
#27=DIRECTION('',(1.0,0.0,0.0));
#28= (NAMED_UNIT(#21)LENGTH_UNIT()SI_UNIT(.MILLI.,.METRE.));
#29=CLOSED_SHELL('',(#156,#191,#226,#261,#296,#331));
#100=CARTESIAN_POINT('',(-100.000000000,-99.999984741,100.000015259));
#101=VERTEX_POINT('',#100);
#102=CARTESIAN_POINT('',(-100.000000000,100.000015259,99.999984741));
#103=VERTEX_POINT('',#102);
#104=CARTESIAN_POINT('',(-100.000000000,99.999984741,-100.000015259));
#105=VERTEX_POINT('',#104);
#106=CARTESIAN_POINT('',(-100.000000000,-100.000015259,-99.999984741));
#107=VERTEX_POINT('',#106);
#108=CARTESIAN_POINT('',(-100.000000000,-100.000015259,-99.999984741));
#109=VERTEX_POINT('',#108);
#110=CARTESIAN_POINT('',(-100.000000000,99.999984741,-100.000015259));
#111=VERTEX_POINT('',#110);
#112=CARTESIAN_POINT('',(100.000000000,99.999984741,-100.000015259));
#113=VERTEX_POINT('',#112);
#114=CARTESIAN_POINT('',(100.000000000,-100.000015259,-99.999984741));
#115=VERTEX_POINT('',#114);
#116=CARTESIAN_POINT('',(100.000000000,-100.000015259,-99.999984741));
#117=VERTEX_POINT('',#116);
#118=CARTESIAN_POINT('',(100.000000000,99.999984741,-100.000015259));
#119=VERTEX_POINT('',#118);
#120=CARTESIAN_POINT('',(100.000000000,100.000015259,99.999984741));
#121=VERTEX_POINT('',#120);
#122=CARTESIAN_POINT('',(100.000000000,-99.999984741,100.000015259));
#123=VERTEX_POINT('',#122);
#124=CARTESIAN_POINT('',(100.000000000,-99.999984741,100.000015259));
#125=VERTEX_POINT('',#124);
#126=CARTESIAN_POINT('',(100.000000000,100.000015259,99.999984741));
#127=VERTEX_POINT('',#126);
#128=CARTESIAN_POINT('',(-100.000000000,100.000015259,99.999984741));
#129=VERTEX_POINT('',#128);
#130=CARTESIAN_POINT('',(-100.000000000,-99.999984741,100.000015259));
#131=VERTEX_POINT('',#130);
#132=CARTESIAN_POINT('',(-100.000000000,-100.000015259,-99.999984741));
#133=VERTEX_POINT('',#132);
#134=CARTESIAN_POINT('',(100.000000000,-100.000015259,-99.999984741));
#135=VERTEX_POINT('',#134);
#136=CARTESIAN_POINT('',(100.000000000,-99.999984741,100.000015259));
#137=VERTEX_POINT('',#136);
#138=CARTESIAN_POINT('',(-100.000000000,-99.999984741,100.000015259));
#139=VERTEX_POINT('',#138);
#140=CARTESIAN_POINT('',(100.000000000,99.999984741,-100.000015259));
#141=VERTEX_POINT('',#140);
#142=CARTESIAN_POINT('',(-100.000000000,99.999984741,-100.000015259));
#143=VERTEX_POINT('',#142);
#144=CARTESIAN_POINT('',(-100.000000000,100.000015259,99.999984741));
#145=VERTEX_POINT('',#144);
#146=CARTESIAN_POINT('',(100.000000000,100.000015259,99.999984741));
#147=VERTEX_POINT('',#146);
#148=STYLED_ITEM('',(#149),#156);
#149=PRESENTATION_STYLE_ASSIGNMENT((#150));
#150=SURFACE_STYLE_USAGE(.BOTH.,#151);
#151=SURFACE_SIDE_STYLE('',(#152));
#152=SURFACE_STYLE_FILL_AREA(#153);
#153=FILL_AREA_STYLE('',(#154));
#154=FILL_AREA_STYLE_COLOUR('',#155);
#155=COLOUR_RGB('',0.800000012,0.800000012,0.800000012);
#156=FACE_SURFACE('',(#161),#157,.T.);
#157=PLANE('',#158);
#158=AXIS2_PLACEMENT_3D('',#100,#159,#160);
#159=DIRECTION('',(1.000000000,0.000000000,0.000000000));
#160=DIRECTION('',(0.000000000,1.000000000,0.000000000));
#161=FACE_BOUND('',#162,.T.);
#162=EDGE_LOOP('',(#163,#164,#165,#166));
#163=ORIENTED_EDGE('',*,*,#167,.T.);
#164=ORIENTED_EDGE('',*,*,#168,.T.);
#165=ORIENTED_EDGE('',*,*,#169,.T.);
#166=ORIENTED_EDGE('',*,*,#170,.T.);
#167=EDGE_CURVE('',#101,#103,#171,.F.);
#168=EDGE_CURVE('',#103,#105,#172,.T.);
#169=EDGE_CURVE('',#105,#107,#173,.T.);
#170=EDGE_CURVE('',#107,#101,#174,.T.);
#171=LINE('',#100,#175);
#172=LINE('',#102,#176);
#173=LINE('',#104,#177);
#174=LINE('',#106,#178);
#175=VECTOR('',#179,1.0);
#176=VECTOR('',#180,1.0);
#177=VECTOR('',#181,1.0);
#178=VECTOR('',#182,1.0);
#179=DIRECTION('',(0.000000000,0.000000000,1.000000000));
#180=DIRECTION('',(0.000000000,1.000000000,0.000000000));
#181=DIRECTION('',(0.000000000,0.000000000,-1.000000000));
#182=DIRECTION('',(0.000000000,-1.000000000,0.000000000));
#183=STYLED_ITEM('',(#184),#191);
#184=PRESENTATION_STYLE_ASSIGNMENT((#185));
#185=SURFACE_STYLE_USAGE(.BOTH.,#186);
#186=SURFACE_SIDE_STYLE('',(#187));
#187=SURFACE_STYLE_FILL_AREA(#188);
#188=FILL_AREA_STYLE('',(#189));
#189=FILL_AREA_STYLE_COLOUR('',#190);
#190=COLOUR_RGB('',0.800000012,0.800000012,0.800000012);
#191=FACE_SURFACE('',(#196),#192,.T.);
#192=PLANE('',#193);
#193=AXIS2_PLACEMENT_3D('',#108,#194,#195);
#194=DIRECTION('',(0.000000000,-1.000000000,0.000000000));
#195=DIRECTION('',(1.000000000,0.000000000,0.000000000));
#196=FACE_BOUND('',#197,.T.);
#197=EDGE_LOOP('',(#198,#199,#200,#201));
#198=ORIENTED_EDGE('',*,*,#202,.T.);
#199=ORIENTED_EDGE('',*,*,#203,.T.);
#200=ORIENTED_EDGE('',*,*,#204,.T.);
#201=ORIENTED_EDGE('',*,*,#205,.T.);
#202=EDGE_CURVE('',#109,#111,#206,.F.);
#203=EDGE_CURVE('',#111,#113,#207,.T.);
#204=EDGE_CURVE('',#113,#115,#208,.T.);
#205=EDGE_CURVE('',#115,#109,#209,.T.);
#206=LINE('',#108,#210);
#207=LINE('',#110,#211);
#208=LINE('',#112,#212);
#209=LINE('',#114,#213);
#210=VECTOR('',#214,1.0);
#211=VECTOR('',#215,1.0);
#212=VECTOR('',#216,1.0);
#213=VECTOR('',#217,1.0);
#214=DIRECTION('',(0.000000000,0.000000000,1.000000000));
#215=DIRECTION('',(1.000000000,0.000000000,0.000000000));
#216=DIRECTION('',(0.000000000,0.000000000,-1.000000000));
#217=DIRECTION('',(-1.000000000,0.000000000,0.000000000));
#218=STYLED_ITEM('',(#219),#226);
#219=PRESENTATION_STYLE_ASSIGNMENT((#220));
#220=SURFACE_STYLE_USAGE(.BOTH.,#221);
#221=SURFACE_SIDE_STYLE('',(#222));
#222=SURFACE_STYLE_FILL_AREA(#223);
#223=FILL_AREA_STYLE('',(#224));
#224=FILL_AREA_STYLE_COLOUR('',#225);
#225=COLOUR_RGB('',0.800000012,0.800000012,0.800000012);
#226=FACE_SURFACE('',(#231),#227,.T.);
#227=PLANE('',#228);
#228=AXIS2_PLACEMENT_3D('',#116,#229,#230);
#229=DIRECTION('',(-1.000000000,0.000000000,0.000000000));
#230=DIRECTION('',(0.000000000,-1.000000000,0.000000000));
#231=FACE_BOUND('',#232,.T.);
#232=EDGE_LOOP('',(#233,#234,#235,#236));
#233=ORIENTED_EDGE('',*,*,#237,.T.);
#234=ORIENTED_EDGE('',*,*,#238,.T.);
#235=ORIENTED_EDGE('',*,*,#239,.T.);
#236=ORIENTED_EDGE('',*,*,#240,.T.);
#237=EDGE_CURVE('',#117,#119,#241,.F.);
#238=EDGE_CURVE('',#119,#121,#242,.T.);
#239=EDGE_CURVE('',#121,#123,#243,.T.);
#240=EDGE_CURVE('',#123,#117,#244,.T.);
#241=LINE('',#116,#245);
#242=LINE('',#118,#246);
#243=LINE('',#120,#247);
#244=LINE('',#122,#248);
#245=VECTOR('',#249,1.0);
#246=VECTOR('',#250,1.0);
#247=VECTOR('',#251,1.0);
#248=VECTOR('',#252,1.0);
#249=DIRECTION('',(0.000000000,0.000000000,1.000000000));
#250=DIRECTION('',(0.000000000,-1.000000000,0.000000000));
#251=DIRECTION('',(0.000000000,0.000000000,-1.000000000));
#252=DIRECTION('',(0.000000000,1.000000000,0.000000000));
#253=STYLED_ITEM('',(#254),#261);
#254=PRESENTATION_STYLE_ASSIGNMENT((#255));
#255=SURFACE_STYLE_USAGE(.BOTH.,#256);
#256=SURFACE_SIDE_STYLE('',(#257));
#257=SURFACE_STYLE_FILL_AREA(#258);
#258=FILL_AREA_STYLE('',(#259));
#259=FILL_AREA_STYLE_COLOUR('',#260);
#260=COLOUR_RGB('',0.800000012,0.800000012,0.800000012);
#261=FACE_SURFACE('',(#266),#262,.T.);
#262=PLANE('',#263);
#263=AXIS2_PLACEMENT_3D('',#124,#264,#265);
#264=DIRECTION('',(0.000000000,1.000000000,-0.000000000));
#265=DIRECTION('',(-1.000000000,0.000000000,0.000000000));
#266=FACE_BOUND('',#267,.T.);
#267=EDGE_LOOP('',(#268,#269,#270,#271));
#268=ORIENTED_EDGE('',*,*,#272,.T.);
#269=ORIENTED_EDGE('',*,*,#273,.T.);
#270=ORIENTED_EDGE('',*,*,#274,.T.);
#271=ORIENTED_EDGE('',*,*,#275,.T.);
#272=EDGE_CURVE('',#125,#127,#276,.F.);
#273=EDGE_CURVE('',#127,#129,#277,.T.);
#274=EDGE_CURVE('',#129,#131,#278,.T.);
#275=EDGE_CURVE('',#131,#125,#279,.T.);
#276=LINE('',#124,#280);
#277=LINE('',#126,#281);
#278=LINE('',#128,#282);
#279=LINE('',#130,#283);
#280=VECTOR('',#284,1.0);
#281=VECTOR('',#285,1.0);
#282=VECTOR('',#286,1.0);
#283=VECTOR('',#287,1.0);
#284=DIRECTION('',(0.000000000,0.000000000,1.000000000));
#285=DIRECTION('',(-1.000000000,0.000000000,0.000000000));
#286=DIRECTION('',(0.000000000,0.000000000,-1.000000000));
#287=DIRECTION('',(1.000000000,0.000000000,0.000000000));
#288=STYLED_ITEM('',(#289),#296);
#289=PRESENTATION_STYLE_ASSIGNMENT((#290));
#290=SURFACE_STYLE_USAGE(.BOTH.,#291);
#291=SURFACE_SIDE_STYLE('',(#292));
#292=SURFACE_STYLE_FILL_AREA(#293);
#293=FILL_AREA_STYLE('',(#294));
#294=FILL_AREA_STYLE_COLOUR('',#295);
#295=COLOUR_RGB('',0.800000012,0.800000012,0.800000012);
#296=FACE_SURFACE('',(#301),#297,.T.);
#297=PLANE('',#298);
#298=AXIS2_PLACEMENT_3D('',#132,#299,#300);
#299=DIRECTION('',(-0.000000000,0.000000000,1.000000000));
#300=DIRECTION('',(0.000000000,-1.000000000,0.000000000));
#301=FACE_BOUND('',#302,.T.);
#302=EDGE_LOOP('',(#303,#304,#305,#306));
#303=ORIENTED_EDGE('',*,*,#307,.T.);
#304=ORIENTED_EDGE('',*,*,#308,.T.);
#305=ORIENTED_EDGE('',*,*,#309,.T.);
#306=ORIENTED_EDGE('',*,*,#310,.T.);
#307=EDGE_CURVE('',#133,#135,#311,.F.);
#308=EDGE_CURVE('',#135,#137,#312,.T.);
#309=EDGE_CURVE('',#137,#139,#313,.T.);
#310=EDGE_CURVE('',#139,#133,#314,.T.);
#311=LINE('',#132,#315);
#312=LINE('',#134,#316);
#313=LINE('',#136,#317);
#314=LINE('',#138,#318);
#315=VECTOR('',#319,1.0);
#316=VECTOR('',#320,1.0);
#317=VECTOR('',#321,1.0);
#318=VECTOR('',#322,1.0);
#319=DIRECTION('',(1.000000000,0.000000000,0.000000000));
#320=DIRECTION('',(0.000000000,-1.000000000,0.000000000));
#321=DIRECTION('',(-1.000000000,0.000000000,0.000000000));
#322=DIRECTION('',(0.000000000,1.000000000,0.000000000));
#323=STYLED_ITEM('',(#324),#331);
#324=PRESENTATION_STYLE_ASSIGNMENT((#325));
#325=SURFACE_STYLE_USAGE(.BOTH.,#326);
#326=SURFACE_SIDE_STYLE('',(#327));
#327=SURFACE_STYLE_FILL_AREA(#328);
#328=FILL_AREA_STYLE('',(#329));
#329=FILL_AREA_STYLE_COLOUR('',#330);
#330=COLOUR_RGB('',0.800000012,0.800000012,0.800000012);
#331=FACE_SURFACE('',(#336),#332,.T.);
#332=PLANE('',#333);
#333=AXIS2_PLACEMENT_3D('',#140,#334,#335);
#334=DIRECTION('',(-0.000000000,-0.000000000,-1.000000000));
#335=DIRECTION('',(0.000000000,-1.000000000,0.000000000));
#336=FACE_BOUND('',#337,.T.);
#337=EDGE_LOOP('',(#338,#339,#340,#341));
#338=ORIENTED_EDGE('',*,*,#342,.T.);
#339=ORIENTED_EDGE('',*,*,#343,.T.);
#340=ORIENTED_EDGE('',*,*,#344,.T.);
#341=ORIENTED_EDGE('',*,*,#345,.T.);
#342=EDGE_CURVE('',#141,#143,#346,.F.);
#343=EDGE_CURVE('',#143,#145,#347,.T.);
#344=EDGE_CURVE('',#145,#147,#348,.T.);
#345=EDGE_CURVE('',#147,#141,#349,.T.);
#346=LINE('',#140,#350);
#347=LINE('',#142,#351);
#348=LINE('',#144,#352);
#349=LINE('',#146,#353);
#350=VECTOR('',#354,1.0);
#351=VECTOR('',#355,1.0);
#352=VECTOR('',#356,1.0);
#353=VECTOR('',#357,1.0);
#354=DIRECTION('',(-1.000000000,0.000000000,0.000000000));
#355=DIRECTION('',(0.000000000,-1.000000000,0.000000000));
#356=DIRECTION('',(1.000000000,0.000000000,0.000000000));
#357=DIRECTION('',(0.000000000,1.000000000,0.000000000));
ENDSEC;
END-ISO-10303-21;

View File

@ -2,28 +2,21 @@
import color from "picocolors"; import color from "picocolors";
import { showMainMenu } from "./lib/menu.js"; import { showMainMenu } from "./lib/menu.js";
// 主循环
while (true) { while (true) {
const selected = await showMainMenu(); const selected = await showMainMenu();
console.clear(); console.clear();
console.log(color.cyan(`\n正在启动: ${selected.name}...\n`)); console.log(color.cyan("\n正在启动: " + selected.name + "...\n"));
try { try {
const tool = await import(selected.module); const tool = await import(selected.module);
const result = await tool.run(); const result = await tool.run();
// 返回主菜单
if (result === "back") continue; if (result === "back") continue;
// 工具完成后退出
break; break;
} catch (err) { } catch (err) {
console.log(color.yellow(`\n⚠️ ${selected.name} 模块尚未实现`)); console.log(color.yellow("\n⚠ " + selected.name + " 模块尚未实现"));
console.log(color.dim(err.message)); console.log(color.dim(err.message));
console.log(color.dim("\n按任意键返回主菜单...")); console.log(color.dim("\n按任意键返回主菜单..."));
// 等待按键
await new Promise(resolve => { await new Promise(resolve => {
process.stdin.setRawMode(true); process.stdin.setRawMode(true);
process.stdin.once("data", () => { process.stdin.once("data", () => {

35
lib/convert/config.js Normal file
View File

@ -0,0 +1,35 @@
import fs from "fs";
import { getImportExtensions, getExportFormats } from "./converters.js";
import { sortByUsage } from "../stats.js";
export function listConvertibleFiles() {
const cwd = process.cwd();
const exts = getImportExtensions();
return fs.readdirSync(cwd).filter(file => {
const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
return exts.includes(ext);
});
}
export function getSteps() {
const files = listConvertibleFiles();
const formats = sortByUsage("convert_format", getExportFormats());
return [
{
name: "源文件",
type: "multiselect",
message: files.length ? "选择要转换的模型文件" : "当前目录未找到可转换的模型文件",
options: files.map(file => ({ value: file, label: file })),
default: [],
emptyMessage: "请按 Esc 返回并放入模型文件后重试"
},
{
name: "输出格式",
type: "select",
message: "选择目标格式",
options: formats.map(f => ({ value: f, label: f })),
default: formats[0]
}
];
}

31
lib/convert/converters.js Normal file
View File

@ -0,0 +1,31 @@
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/assimp.exe");
let importExts = null;
let exportFormats = null;
export function getImportExtensions() {
if (!importExts) {
const r = spawnSync(ASSIMP_PATH, ["listext"], { encoding: "utf8" });
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" });
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;
}

108
lib/convert/index.js Normal file
View File

@ -0,0 +1,108 @@
import fs from "fs";
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";
const FORMAT_EXT = {
collada: ".dae", x: ".x", stp: ".stp", obj: ".obj", objnomtl: ".obj",
stl: ".stl", stlb: ".stl", ply: ".ply", plyb: ".ply", "3ds": ".3ds",
gltf2: ".gltf", glb2: ".glb", gltf: ".gltf", glb: ".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 baseName = file.slice(0, file.lastIndexOf("."));
const outputExt = getOutputExt(config.outputFormat);
const outputFile = baseName + outputExt;
if (!fs.existsSync(path.join(cwd, file))) {
return { ok: false, file, reason: "文件不存在" };
}
await convert(file, outputFile, config.outputFormat, cwd);
return { ok: true, file, output: outputFile };
}
async function waitForEsc() {
initKeypress();
return new Promise(resolve => {
onKey((str, key) => {
if (key?.name === "escape" || (key?.ctrl && key?.name === "c")) {
resolve();
}
});
});
}
export async function run() {
const result = await runInteractive();
if (!result) return "back";
const { steps, results } = result;
const config = {
files: results[0] || [],
outputFormat: results[1] || "glb2"
};
stopKeypress();
showSummary([
"源文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
"目标格式: " + config.outputFormat.toUpperCase()
]);
if (!config.files.length) {
console.log(color.yellow("未选择任何模型文件"));
console.log(color.dim("\n按 Esc 返回"));
await waitForEsc();
return "back";
}
const total = config.files.length;
let success = 0;
const failed = [];
console.log(color.cyan(`开始转换 ${total} 个文件...\n`));
for (let i = 0; i < config.files.length; i++) {
const file = config.files[i];
const progress = `[${i + 1}/${total}]`;
console.log(color.dim(`${progress} 处理中: ${file}`));
try {
const result = await processFile(file, config);
if (result.ok) {
success++;
record("convert_format", config.outputFormat);
console.log(color.green(`${progress}${file}${path.basename(result.output)}`));
} else {
failed.push({ file, reason: result.reason });
console.log(color.yellow(`${progress} ⊘ 跳过: ${file} (${result.reason})`));
}
} catch (err) {
failed.push({ file, reason: err?.message || String(err) });
console.log(color.red(`${progress} ✖ 失败: ${file}`));
console.log(color.dim(" " + String(err?.message || err)));
}
}
console.log("\n" + color.bgGreen(color.black(" 转换完成 ")));
if (success) {
console.log(color.green(`成功: ${success}`));
}
if (failed.length) {
console.log(color.yellow(`失败: ${failed.length}`));
}
console.log(color.dim("\n按 Esc 返回"));
await waitForEsc();
return "back";
}

9
lib/convert/ui.js Normal file
View File

@ -0,0 +1,9 @@
import { createStepUI } from "../utils/stepui.js";
import { getSteps } from "./config.js";
const ui = createStepUI({
title: "格式转换工具",
getSteps
});
export const { runInteractive, showSummary } = ui;

27
lib/gltf/config.js Normal file
View File

@ -0,0 +1,27 @@
import { listGltfFiles } from "../utils/gltf.js";
const extensionStep = {
name: "扩展选项",
type: "multiselect",
message: "请选择要添加的glTF扩展",
options: [
{ value: "textureBasisu", label: "KHR_texture_basisu纹理自动扩展", hint: "自动添加KTX2纹理扩展支持" },
{ value: "placeholder1", label: "预留选项1", hint: "功能开发中..." },
{ value: "placeholder2", label: "预留选项2", hint: "功能开发中..." },
{ value: "placeholder3", label: "预留选项3", hint: "功能开发中..." }
],
default: ["textureBasisu"]
};
export function getSteps() {
const files = listGltfFiles();
const fileStep = {
name: "文件选择",
type: "multiselect",
message: files.length ? "请选择要处理的glTF文件" : "当前目录未找到glTF文件",
options: files.map(file => ({ value: file, label: file })),
default: [...files]
};
return [fileStep, extensionStep];
}

73
lib/gltf/index.js Normal file
View File

@ -0,0 +1,73 @@
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 { stopKeypress, waitForKey } from "../keyboard.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 };
}
export async function run() {
const result = await runInteractive();
if (!result) return "back";
stopKeypress();
const { results } = result;
const config = {
files: results[0] || [],
extensions: results[1] || []
};
showSummary([
"处理文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
"扩展选项: " + (config.extensions.length ? config.extensions.join(", ") : "未选择")
]);
const { success, count } = runGltfExtension(config);
if (success) {
console.log(color.green("\n✓ 已修改 " + count + " 个 glTF 文件"));
}
await waitForKey();
return "back";
}

9
lib/gltf/ui.js Normal file
View File

@ -0,0 +1,9 @@
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,6 +1,16 @@
import color from "picocolors"; import color from "picocolors";
import { initKeypress, onKey } from "./keyboard.js"; import { initKeypress, onKey } from "./keyboard.js";
function clearScreen() {
if (process.stdout.isTTY) {
process.stdout.write("\x1b[2J"); // clear screen
process.stdout.write("\x1b[3J"); // clear scrollback
process.stdout.write("\x1b[H"); // move cursor home
} else {
console.clear();
}
}
// 计算字符串显示宽度 // 计算字符串显示宽度
function strWidth(str) { function strWidth(str) {
let width = 0; let width = 0;
@ -35,7 +45,7 @@ export async function gridSelect(options) {
const pad = " ".repeat(Math.max(0, Math.floor((termWidth - totalWidth) / 2))); const pad = " ".repeat(Math.max(0, Math.floor((termWidth - totalWidth) / 2)));
function render() { function render() {
console.clear(); clearScreen();
if (renderHeader) { if (renderHeader) {
console.log(renderHeader()); console.log(renderHeader());
@ -110,7 +120,7 @@ export async function gridSelect(options) {
setImmediate(() => resolve(items[current])); setImmediate(() => resolve(items[current]));
} else if (key.name === "escape" || (key.ctrl && key.name === "c")) { } else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
process.stdin.setRawMode(false); process.stdin.setRawMode(false);
console.clear(); clearScreen();
console.log(color.yellow("👋 再见!")); console.log(color.yellow("👋 再见!"));
process.exit(0); process.exit(0);
} }

View File

@ -16,3 +16,22 @@ export function initKeypress() {
export function onKey(handler) { export function onKey(handler) {
process.stdin.on("keypress", handler); process.stdin.on("keypress", handler);
} }
export function stopKeypress() {
process.stdin.removeAllListeners("keypress");
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
}
export function waitForKey(message = "按任意键返回...") {
return new Promise(resolve => {
console.log("\n" + message);
initKeypress();
const handler = () => {
stopKeypress();
resolve();
};
process.stdin.once("keypress", handler);
});
}

View File

@ -55,7 +55,8 @@ export const steps = [
{ value: "overwrite", label: "覆盖已存在文件(自动替换同名文件)" }, { value: "overwrite", label: "覆盖已存在文件(自动替换同名文件)" },
{ value: "keepOriginal", label: "保留原文件(压缩后不删除源文件)" }, { value: "keepOriginal", label: "保留原文件(压缩后不删除源文件)" },
{ value: "report", label: "生成压缩报告(输出详细的压缩统计信息)" }, { value: "report", label: "生成压缩报告(输出详细的压缩统计信息)" },
{ value: "silent", label: "静默模式(减少控制台输出信息)" } { value: "silent", label: "静默模式(减少控制台输出信息)" },
{ value: "gltfExtension", label: "修改glTF扩展添加KHR_texture_basisu", dynamic: true }
], ],
default: ["overwrite", "keepOriginal"] default: ["overwrite", "keepOriginal"]
} }

3
lib/ktx2/gltf.js Normal file
View File

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

View File

@ -1,40 +1,49 @@
import color from "picocolors"; import color from "picocolors";
import { checkToktx, scanImages, compressAll } from "./compressor.js"; import { checkToktx, scanImages, compressAll } from "./compressor.js";
import { runInteractive, showSummary } from "./ui.js"; import { runInteractive, showSummary } from "./ui.js";
import { runGltfExtension } from "./gltf.js";
import { stopKeypress, waitForKey } from "../keyboard.js";
export async function run() { export async function run() {
// 检查 toktx
checkToktx(); checkToktx();
// 运行交互界面
const result = await runInteractive(); const result = await runInteractive();
// ESC 返回主菜单
if (!result) return "back"; if (!result) return "back";
const [exts, quality, encoding, mipmap, outputOpts] = result; stopKeypress();
const { results } = result;
const [exts, quality, encoding, mipmap, outputOpts] = results;
const config = { exts, quality, encoding, mipmap, outputOpts }; const config = { exts, quality, encoding, mipmap, outputOpts };
showSummary([
"文件格式: " + config.exts.join(", "),
"压缩程度: " + config.quality,
"编码格式: " + config.encoding,
"Mipmap: " + config.mipmap,
"输出选项: " + config.outputOpts.join(", ")
]);
// 显示配置摘要
showSummary(config);
// 扫描文件
const { images, cwd } = scanImages(exts); const { images, cwd } = scanImages(exts);
if (images.length === 0) { if (images.length === 0) {
console.log(color.yellow("当前目录没有匹配的图片")); console.log(color.yellow("当前目录没有匹配的图片"));
return; await waitForKey();
return "back";
} }
console.log(`📁 找到 ${color.cyan(images.length)} 个待转换文件\n`); console.log("📁 找到 " + color.cyan(images.length) + " 个待转换文件\n");
// 执行压缩
const { total, failed } = await compressAll(images, config, cwd); const { total, failed } = await compressAll(images, config, cwd);
// 显示结果
if (failed > 0) { if (failed > 0) {
console.log(color.yellow(`\n⚠️ 完成,但有 ${failed} 个文件失败`)); console.log(color.yellow("\n⚠️ 完成,但有 " + failed + " 个文件失败"));
} else { } else {
console.log(color.green("\n🎉 全部文件压缩完成!")); console.log(color.green("\n🎉 全部文件压缩完成!"));
} }
if (outputOpts.includes("gltfExtension")) {
console.log(color.cyan("\n正在修改 glTF 文件..."));
const { success, count } = runGltfExtension();
if (success) console.log(color.green("✓ 已修改 " + count + " 个 glTF 文件"));
}
await waitForKey();
return "back";
} }

View File

@ -1,151 +1,20 @@
import color from "picocolors"; import { createStepUI } from "../utils/stepui.js";
import { steps } from "./config.js"; import { steps } from "./config.js";
import { initKeypress, onKey } from "../keyboard.js"; import { hasGltfFile } from "./gltf.js";
// 存储结果和状态 function getFilteredSteps() {
let results = []; const hasGltf = hasGltfFile();
let completed = new Set(); return steps.map(step => {
let currentStep = 0; if (step.name === "输出选项") {
let currentOption = 0; return { ...step, options: step.options.filter(opt => !opt.dynamic || hasGltf) };
// 初始化结果
export function initResults() {
results = steps.map(s => s.type === "multiselect" ? [...s.default] : s.default);
completed = new Set();
currentStep = 0;
currentOption = 0;
} }
return step;
// 渲染导航栏
function renderNav() {
const nav = steps.map((step, i) => {
if (completed.has(i)) {
return color.green(`${step.name}`);
} else if (i === currentStep) {
return color.bgCyan(color.black(` ${step.name} `));
} else {
return color.dim(`${step.name}`);
}
});
return `${nav.join(" ")} ${color.green("✓Submit")}`;
}
// 渲染选项列表
function renderOptions() {
const step = steps[currentStep];
const lines = [];
lines.push(color.cyan(step.message));
lines.push("");
step.options.forEach((opt, i) => {
const isCurrent = i === currentOption;
const isSelected = step.type === "multiselect"
? results[currentStep].includes(opt.value)
: results[currentStep] === opt.value;
let prefix;
if (step.type === "multiselect") {
prefix = isSelected ? color.green("◉ ") : "○ ";
} else {
prefix = isSelected ? color.green("● ") : "○ ";
}
const cursor = isCurrent ? color.cyan(" ") : " ";
const label = isCurrent ? color.cyan(opt.label) : opt.label;
const check = isSelected ? color.green(" ✓") : "";
lines.push(`${cursor}${prefix}${label}${check}`);
if (opt.hint) {
lines.push(` ${color.dim(opt.hint)}`);
}
});
return lines.join("\n");
}
// 渲染整个界面
function render() {
console.clear();
console.log(color.bgCyan(color.black(" KTX2 纹理压缩工具 ")));
console.log("\n" + renderNav());
console.log(color.dim("\n← → 切换步骤 | ↑ ↓ 选择 | Space 选中 | Tab 提交 | Esc 返回\n"));
console.log(renderOptions());
}
// 主交互循环
export async function runInteractive() {
initResults();
initKeypress();
return new Promise((resolve) => {
render();
onKey((str, key) => {
if (!key) return;
const step = steps[currentStep];
const optCount = step.options.length;
if (key.name === "left") {
if (currentStep > 0) {
currentStep--;
currentOption = 0;
}
render();
} else if (key.name === "right") {
if (currentStep < steps.length - 1) {
currentStep++;
currentOption = 0;
}
render();
} else if (key.name === "up") {
currentOption = (currentOption - 1 + optCount) % optCount;
render();
} else if (key.name === "down") {
currentOption = (currentOption + 1) % optCount;
render();
} else if (key.name === "space") {
const opt = step.options[currentOption];
if (step.type === "multiselect") {
const idx = results[currentStep].indexOf(opt.value);
if (idx >= 0) {
results[currentStep].splice(idx, 1);
} else {
results[currentStep].push(opt.value);
}
} else {
results[currentStep] = opt.value;
}
completed.add(currentStep);
render();
} else if (key.name === "return") {
if (step.type === "select") {
results[currentStep] = step.options[currentOption].value;
}
completed.add(currentStep);
if (currentStep < steps.length - 1) {
currentStep++;
currentOption = 0;
}
render();
} else if (key.name === "tab") {
resolve(results);
} else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
resolve(null);
}
});
}); });
} }
// 显示配置摘要 const ui = createStepUI({
export function showSummary(config) { title: "KTX2 纹理压缩工具",
console.clear(); getSteps: getFilteredSteps
console.log(color.bgCyan(color.black(" KTX2 纹理压缩工具 "))); });
console.log("\n" + color.green("配置完成!当前设置:"));
console.log(` 文件格式: ${config.exts.join(", ")}`); export const { runInteractive, showSummary } = ui;
console.log(` 压缩程度: ${config.quality}`);
console.log(` 编码格式: ${config.encoding}`);
console.log(` Mipmap: ${config.mipmap}`);
console.log(` 输出选项: ${config.outputOpts.join(", ")}\n`);
}

View File

@ -3,7 +3,6 @@ import boxen from "boxen";
import figlet from "figlet"; import figlet from "figlet";
import { gridSelect } from "./grid.js"; import { gridSelect } from "./grid.js";
// 古诗配置
let poemConfig = { let poemConfig = {
lines: ["你我皆牛马", "生在人世间", "终日奔波苦", "一刻不得闲"], lines: ["你我皆牛马", "生在人世间", "终日奔波苦", "一刻不得闲"],
perLine: 2, perLine: 2,
@ -12,16 +11,16 @@ let poemConfig = {
borderColor: "cyan", borderColor: "cyan",
}; };
// 标题配置
let titleConfig = { let titleConfig = {
text: "Zguiy Tool Box", text: "Zguiy Tool Box",
font: "Standard", font: "Standard",
color: "magenta", color: "magenta",
}; };
// 工具列表
const tools = [ const tools = [
{ name: "格式转换", desc: "OBJ/FBX转glTF", module: "./lib/convert/index.js" },
{ name: "KTX2 纹理压缩", desc: "图片转KTX2格式", module: "./lib/ktx2/index.js" }, { name: "KTX2 纹理压缩", desc: "图片转KTX2格式", module: "./lib/ktx2/index.js" },
{ name: "glTF扩展", desc: "添加KHR_texture_basisu", module: "./lib/gltf/index.js" },
{ name: "模型压缩", desc: "压缩glTF/GLB模型", module: "./lib/model/index.js" }, { name: "模型压缩", desc: "压缩glTF/GLB模型", module: "./lib/model/index.js" },
{ name: "图片批量处理", desc: "裁剪/缩放/转换", module: "./lib/image/index.js" }, { name: "图片批量处理", desc: "裁剪/缩放/转换", module: "./lib/image/index.js" },
{ name: "Sprite图集", desc: "合并精灵图集", module: "./lib/sprite/index.js" }, { name: "Sprite图集", desc: "合并精灵图集", module: "./lib/sprite/index.js" },
@ -29,25 +28,21 @@ const tools = [
{ name: "音频压缩", desc: "压缩音频文件", module: "./lib/audio/index.js" }, { name: "音频压缩", desc: "压缩音频文件", module: "./lib/audio/index.js" },
]; ];
// 设置古诗
export function setPoem(lines, perLine = 2) { export function setPoem(lines, perLine = 2) {
poemConfig.lines = lines; poemConfig.lines = lines;
poemConfig.perLine = perLine; poemConfig.perLine = perLine;
} }
// 设置古诗框样式
export function setPoemStyle(style) { export function setPoemStyle(style) {
Object.assign(poemConfig, style); Object.assign(poemConfig, style);
} }
// 设置标题
export function setTitle(text, font = "Standard", titleColor = "magenta") { export function setTitle(text, font = "Standard", titleColor = "magenta") {
titleConfig.text = text; titleConfig.text = text;
titleConfig.font = font; titleConfig.font = font;
titleConfig.color = titleColor; titleConfig.color = titleColor;
} }
// 渲染古诗
function renderPoem() { function renderPoem() {
const merged = []; const merged = [];
for (let i = 0; i < poemConfig.lines.length; i += poemConfig.perLine) { for (let i = 0; i < poemConfig.lines.length; i += poemConfig.perLine) {
@ -62,7 +57,6 @@ function renderPoem() {
}); });
} }
// 渲染标题
function renderTitle() { function renderTitle() {
const art = figlet.textSync(titleConfig.text, { font: titleConfig.font }); const art = figlet.textSync(titleConfig.text, { font: titleConfig.font });
const termWidth = process.stdout.columns || 80; const termWidth = process.stdout.columns || 80;
@ -72,12 +66,10 @@ function renderTitle() {
}).join("\n"); }).join("\n");
} }
// 渲染头部
function renderHeader() { function renderHeader() {
return renderPoem() + "\n\n" + renderTitle(); return renderPoem() + "\n\n" + renderTitle();
} }
// 主菜单
export async function showMainMenu() { export async function showMainMenu() {
return gridSelect({ return gridSelect({
items: tools, items: tools,

71
lib/model/config.js Normal file
View File

@ -0,0 +1,71 @@
import { listAllModelFiles } from "../utils/gltf.js";
const transformOptions = [
{ value: "dedup", label: "dedup去重", hint: "删除重复的访问器、材质、网格" },
{ value: "prune", label: "prune清理无用节点", hint: "移除未被引用的节点、材质、动画" },
{ value: "resample", label: "resample动画重采样", hint: "统一动画关键帧间隔,减少多余数据" },
{ value: "weld", label: "weld合并顶点", hint: "合并共享位置的顶点以减少面数" },
{ value: "quantize", label: "quantize量化顶点数据", hint: "降低顶点属性精度减小模型体积" }
];
const quantizePresets = [
{ value: "high", label: "高质量位置16位法线12位UV14位", hint: "尽量保证细节,适合高保真场景" },
{ value: "balanced", label: "均衡位置14位法线10位UV12位", hint: "默认推荐,兼顾体积与质量" },
{ value: "aggressive", label: "极限压缩位置12位法线8位UV10位", hint: "最小体积,但可能损失细节" },
{ value: "light", label: "轻量位置10位法线8位UV10位", hint: "适合移动端、卡通等对精度不敏感场景" }
];
const outputFormats = [
{ value: "auto", label: "保持原格式", hint: "glTF/GLB保持原格式OBJ/FBX转为GLB" },
{ value: "glb", label: "统一导出为 GLB二进制", hint: "单文件发布更方便" },
{ value: "gltf", label: "统一导出为 glTFJSON", hint: "可读性好,调试方便" }
];
const outputOptions = [
{ value: "overwrite", label: "覆盖原文件", hint: "直接把结果写回源文件" },
{ value: "backup", label: "保留备份 (_备份)", hint: "覆盖前在同目录生成 _备份 副本" },
{ value: "copy", label: "输出副本 (_compressed)", hint: "保留原文件不动,结果写入新文件" }
];
export function getSteps() {
const files = listAllModelFiles();
const fileStep = {
name: "模型选择",
type: "multiselect",
message: files.length ? "选择要处理的模型(支持 glTF/GLB/OBJ/FBX" : "当前目录未找到模型文件",
options: files.map(file => ({ value: file, label: file })),
default: [...files]
};
return [
fileStep,
{
name: "压缩命令",
type: "multiselect",
message: "请选择要执行的 gltf-transform 操作",
options: transformOptions,
default: ["dedup", "prune", "weld", "quantize"]
},
{
name: "量化级别",
type: "select",
message: "量化可显著减小体积(如未启用 quantize 可直接 Tab",
options: quantizePresets,
default: "balanced"
},
{
name: "输出格式",
type: "select",
message: "选择最终模型格式",
options: outputFormats,
default: "auto"
},
{
name: "输出选项",
type: "multiselect",
message: "请选择输出行为",
options: outputOptions,
default: ["overwrite", "backup"]
}
];
}

195
lib/model/index.js Normal file
View File

@ -0,0 +1,195 @@
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 { stopKeypress, waitForKey } from "../keyboard.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 };
}
export async function run() {
const result = await 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] || []
};
showSummary([
"模型文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
"gltf-transform 命令: " + (config.commands.length ? config.commands.join(", ") : "无"),
"量化级别: " + config.quantizePreset,
"输出格式: " + config.outputFormat,
"输出选项: " + (config.outputOptions.length ? config.outputOptions.join(", ") : "默认")
]);
if (!config.files.length) {
console.log(color.yellow("未选择任何模型文件"));
await waitForKey();
return "back";
}
const transforms = buildTransforms(config);
if (!transforms.length) {
console.log(color.yellow("未配置任何压缩命令,请至少选择一项 gltf-transform 操作"));
await waitForKey();
return "back";
}
const total = config.files.length;
let success = 0;
const failed = [];
console.log(color.cyan(`开始压缩 ${total} 个文件...\n`));
for (let i = 0; i < config.files.length; i++) {
const file = config.files[i];
const progress = `[${i + 1}/${total}]`;
console.log(color.dim(`${progress} 处理中: ${file}`));
try {
const result = await processFile(file, config, transforms);
if (result.ok) {
success++;
console.log(color.green(`${progress}${file}${path.basename(result.output)}`));
} else {
failed.push(file);
console.log(color.yellow(`${progress} ⊘ 跳过: ${file}`));
}
} catch (err) {
failed.push(file);
console.log(color.red(`${progress} ✖ 失败: ${file}`));
console.log(color.dim(" " + String(err?.message || err)));
}
}
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(", ")})`));
}
await waitForKey();
return "back";
}

9
lib/model/ui.js Normal file
View File

@ -0,0 +1,9 @@
import { createStepUI } from "../utils/stepui.js";
import { getSteps } from "./config.js";
const ui = createStepUI({
title: "模型压缩工具",
getSteps
});
export const { runInteractive, showSummary } = ui;

View File

@ -28,4 +28,4 @@ export function showPoem(lines, perLine = 2) {
} }
// 默认古诗 // 默认古诗
export const defaultPoem = ["你我皆牛马", "生在人世间", "终日奔波苦", "一刻不得闲"]; export const defaultPoem = ["你我皆牛马1", "生在人世间", "终日奔波苦", "一刻不得闲"];

35
lib/stats.js Normal file
View File

@ -0,0 +1,35 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STATS_FILE = path.join(__dirname, "../.stats.json");
let data = null;
function load() {
if (data) return data;
try {
data = JSON.parse(fs.readFileSync(STATS_FILE, "utf8"));
} catch {
data = {};
}
return data;
}
function save() {
fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2));
}
export function record(category, key) {
load();
if (!data[category]) data[category] = {};
data[category][key] = (data[category][key] || 0) + 1;
save();
}
export function sortByUsage(category, items, getKey = v => v) {
load();
const counts = data[category] || {};
return [...items].sort((a, b) => (counts[getKey(b)] || 0) - (counts[getKey(a)] || 0));
}

82
lib/utils/gltf.js Normal file
View File

@ -0,0 +1,82 @@
import fs from "fs";
export const BACKUP_SUFFIX = "_备份";
// 检查当前目录是否有 gltf 文件
export function hasGltfFile() {
return listGltfFiles().length > 0;
}
// 获取当前目录下可用的 gltf 文件列表
export function listGltfFiles() {
const cwd = process.cwd();
return fs.readdirSync(cwd).filter(f => f.toLowerCase().endsWith(".gltf") && !f.includes(BACKUP_SUFFIX));
}
// 获取 glTF / GLB 模型文件
export function listModelFiles() {
const cwd = process.cwd();
return fs
.readdirSync(cwd)
.filter(file => /\.(gltf|glb)$/i.test(file) && !file.includes(BACKUP_SUFFIX));
}
// 获取所有支持的模型文件(包括需要转换的格式)
export function listAllModelFiles() {
const cwd = process.cwd();
return fs
.readdirSync(cwd)
.filter(file => /\.(gltf|glb|obj|fbx)$/i.test(file) && !file.includes(BACKUP_SUFFIX));
}
// 判断是否需要先转换
export function needsConversion(file) {
return /\.(obj|fbx)$/i.test(file);
}
// 检查是否满足执行条件(有 ktx2、gltf、bin 文件)
export function checkRequiredFiles() {
const cwd = process.cwd();
const files = fs.readdirSync(cwd);
const hasKtx2 = files.some(f => f.toLowerCase().endsWith(".ktx2"));
const hasGltf = files.some(f => f.toLowerCase().endsWith(".gltf"));
const hasBin = files.some(f => f.toLowerCase().endsWith(".bin"));
const missing = [];
if (!hasKtx2) missing.push("ktx2");
if (!hasGltf) missing.push("gltf");
if (!hasBin) missing.push("bin");
return { ok: missing.length === 0, missing };
}
// 修改 gltf 文件,根据选项添加扩展
export function modifyGltfContent(gltfPath, options = ["textureBasisu"]) {
const content = fs.readFileSync(gltfPath, "utf-8");
const gltf = JSON.parse(content);
if (options.includes("textureBasisu")) {
if (!gltf.extensionsUsed) gltf.extensionsUsed = [];
if (!gltf.extensionsUsed.includes("KHR_texture_basisu")) {
gltf.extensionsUsed.push("KHR_texture_basisu");
}
if (gltf.textures) {
gltf.textures = gltf.textures.map(tex => ({
extensions: { KHR_texture_basisu: { source: tex.source } }
}));
}
if (gltf.images) {
gltf.images = gltf.images.map(img => {
const uri = img.uri || "";
const newUri = uri.replace(/\.(png|jpg|jpeg|webp|tga)$/i, ".ktx2");
return { uri: newUri, mimeType: "image/ktx2" };
});
}
}
// placeholder1, placeholder2, placeholder3 预留扩展点
return gltf;
}

124
lib/utils/stepui.js Normal file
View File

@ -0,0 +1,124 @@
import color from "picocolors";
import { initKeypress, onKey } from "../keyboard.js";
export function createStepUI(options) {
const { title, getSteps } = options;
let steps = [];
let results = [];
let completed = new Set();
let currentStep = 0;
let currentOption = 0;
function initResults() {
steps = typeof getSteps === "function" ? getSteps() : getSteps;
results = steps.map(s => s.type === "multiselect" ? [...(s.default || [])] : s.default);
completed = new Set();
currentStep = 0;
currentOption = 0;
}
function renderNav() {
const nav = steps.map((step, i) => {
if (completed.has(i)) return color.green("☑ " + step.name);
if (i === currentStep) return color.bgCyan(color.black(" " + step.name + " "));
return color.dim("□ " + step.name);
});
return "← " + nav.join(" ") + " " + color.green("✓Submit") + " →";
}
function renderOptions() {
const step = steps[currentStep];
const lines = [color.cyan(step.message), ""];
if (!step.options || !step.options.length) {
lines.push(color.dim(step.emptyMessage || "无可用选项"));
return lines.join("\n");
}
step.options.forEach((opt, i) => {
const isCurrent = i === currentOption;
const isSelected = step.type === "multiselect"
? results[currentStep]?.includes(opt.value)
: results[currentStep] === opt.value;
const prefix = step.type === "multiselect"
? (isSelected ? color.green("◉ ") : "○ ")
: (isSelected ? color.green("● ") : "○ ");
const cursor = isCurrent ? color.cyan(" ") : " ";
const label = isCurrent ? color.cyan(opt.label) : opt.label;
const check = isSelected ? color.green(" ✓") : "";
lines.push(cursor + prefix + label + check);
if (opt.hint) lines.push(" " + color.dim(opt.hint));
});
return lines.join("\n");
}
function render() {
console.clear();
console.log(color.bgCyan(color.black(` ${title} `)));
console.log("\n" + renderNav());
console.log(color.dim("\n← → 切换步骤 | ↑ ↓ 选择 | Space 选中 | Tab 提交 | Esc 返回\n"));
console.log(renderOptions());
}
function runInteractive() {
initResults();
initKeypress();
return new Promise(resolve => {
render();
onKey((str, key) => {
if (!key) return;
const step = steps[currentStep];
const optCount = step.options?.length || 0;
if (key.name === "left") {
if (currentStep > 0) { currentStep--; currentOption = 0; }
render();
} else if (key.name === "right") {
if (currentStep < steps.length - 1) { currentStep++; currentOption = 0; }
render();
} else if (key.name === "up") {
if (!optCount) return;
currentOption = (currentOption - 1 + optCount) % optCount;
render();
} else if (key.name === "down") {
if (!optCount) return;
currentOption = (currentOption + 1) % optCount;
render();
} else if (key.name === "space") {
if (!optCount) return;
const opt = step.options[currentOption];
if (step.type === "multiselect") {
const list = results[currentStep];
const idx = list.indexOf(opt.value);
if (idx >= 0) list.splice(idx, 1);
else list.push(opt.value);
} else {
results[currentStep] = opt.value;
}
completed.add(currentStep);
render();
} else if (key.name === "return") {
if (!optCount) return;
if (step.type === "select") {
results[currentStep] = step.options[currentOption].value;
}
completed.add(currentStep);
if (currentStep < steps.length - 1) { currentStep++; currentOption = 0; }
render();
} else if (key.name === "tab") {
resolve({ steps, results });
} else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
resolve(null);
}
});
});
}
function showSummary(lines) {
console.clear();
console.log(color.bgCyan(color.black(` ${title} `)));
console.log("\n" + color.green("配置完成!当前设置:"));
lines.forEach(line => console.log(" " + line));
console.log();
}
return { runInteractive, showSummary, initResults };
}

3062
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,19 @@
{ {
"name": "@yinshuangxi/yinx-cli", "name": "@yinshuangxi/yinx-cli",
"version": "1.0.0", "version": "1.0.2",
"description": "游戏资源工具箱 - KTX2纹理压缩、模型压缩等", "description": "游戏资源工具箱 - KTX2纹理压缩、模型压缩等",
"main": "index.js", "main": "dist/index.js",
"type": "module", "type": "module",
"scripts": {
"clean": "rimraf dist",
"build": "node scripts/build.js",
"prepublishOnly": "npm run clean && npm run build"
},
"bin": { "bin": {
"yinx": "./index.js" "yinx": "./dist/index.js"
}, },
"files": [ "files": [
"index.js", "dist"
"lib",
"bin"
], ],
"keywords": [ "keywords": [
"ktx2", "ktx2",
@ -30,8 +33,17 @@
"node": ">=16" "node": ">=16"
}, },
"dependencies": { "dependencies": {
"@cocos/fbx2gltf": "^1.0.8",
"@gltf-transform/core": "^3.10.1",
"@gltf-transform/extensions": "^3.10.1",
"@gltf-transform/functions": "^3.10.1",
"boxen": "^8.0.1", "boxen": "^8.0.1",
"figlet": "^1.9.4", "figlet": "^1.9.4",
"obj2gltf": "^3.2.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
},
"devDependencies": {
"javascript-obfuscator": "^5.1.0",
"rimraf": "^6.1.2"
} }
} }

100
scripts/build.js Normal file
View File

@ -0,0 +1,100 @@
#!/usr/bin/env node
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import JavaScriptObfuscator from "javascript-obfuscator";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, "..");
const distDir = path.join(rootDir, "dist");
const copyTargets = [
{ from: path.join(rootDir, "index.js"), to: path.join(distDir, "index.js") },
{ from: path.join(rootDir, "lib"), to: path.join(distDir, "lib") },
{ from: path.join(rootDir, "bin"), to: path.join(distDir, "bin") }
];
async function main() {
await fs.rm(distDir, { recursive: true, force: true });
await fs.mkdir(distDir, { recursive: true });
for (const target of copyTargets) {
await copy(target.from, target.to);
}
await obfuscateDirectory(distDir);
console.log("Obfuscated build written to", distDir);
}
async function copy(src, dest) {
const stat = await fs.stat(src);
if (stat.isDirectory()) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src);
for (const entry of entries) {
await copy(path.join(src, entry), path.join(dest, entry));
}
return;
}
await fs.copyFile(src, dest);
}
async function obfuscateDirectory(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
await Promise.all(
entries.map(async entry => {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await obfuscateDirectory(entryPath);
return;
}
if (entry.isFile() && entry.name.endsWith(".js")) {
await obfuscateFile(entryPath);
}
})
);
}
async function obfuscateFile(filePath) {
const original = await fs.readFile(filePath, "utf8");
const { shebang, body } = extractShebang(original);
const obfuscated = JavaScriptObfuscator.obfuscate(body, {
compact: true,
controlFlowFlattening: true,
stringArray: true,
stringArrayEncoding: ["base64"],
deadCodeInjection: true,
target: "node",
identifierNamesGenerator: "hexadecimal"
});
const content = shebang
? `${shebang}\n${obfuscated.getObfuscatedCode()}`
: obfuscated.getObfuscatedCode();
await fs.writeFile(filePath, content, "utf8");
}
function extractShebang(source) {
if (!source.startsWith("#!")) {
return { body: source };
}
const newlineIndex = source.indexOf("\n");
if (newlineIndex === -1) {
return { shebang: source, body: "" };
}
return {
shebang: source.slice(0, newlineIndex),
body: source.slice(newlineIndex + 1)
};
}
main().catch(err => {
console.error("Build failed:");
console.error(err);
process.exitCode = 1;
});