From 410b2e64e9d8799ee2a887e76e0b195921c2d48a Mon Sep 17 00:00:00 2001 From: Blank038 Date: Fri, 22 May 2026 01:37:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(bbmodel):=20=E6=94=AF=E6=8C=81=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=20Blockbench=20=E6=96=B9=E5=9D=97=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 bbmodel 子命令,将 .bbmodel 转成网易 netease:block_geometry,并支持独立输出目录。 同时导出内嵌 PNG 贴图并在资源包模式下更新 terrain_texture.json,减少手工导入方块模型和材质的重复操作。 --- Cargo.lock | 7 + Cargo.toml | 1 + src/commands/bbmodel.rs | 776 ++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 22 ++ src/main.rs | 1 + 5 files changed, 807 insertions(+) create mode 100644 src/commands/bbmodel.rs diff --git a/Cargo.lock b/Cargo.lock index 3d63565..5fa1799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,6 +86,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "block-buffer" version = "0.10.4" @@ -308,6 +314,7 @@ dependencies = [ name = "emod-cli" version = "0.1.0-dev" dependencies = [ + "base64", "clap", "rand", "regex", diff --git a/Cargo.toml b/Cargo.toml index ae9771d..31c67d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ clap = { version = "4.5.32", features = ["derive"] } uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +base64 = "0.22" toml = "0.8" zip = "2.2.2" walkdir = "2" diff --git a/src/commands/bbmodel.rs b/src/commands/bbmodel.rs new file mode 100644 index 0000000..f3aa74f --- /dev/null +++ b/src/commands/bbmodel.rs @@ -0,0 +1,776 @@ +use crate::commands::BbmodelArgs; +use crate::entity; +use crate::error::{CliError, Result}; +use crate::utils::file; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use serde_json::{Map, Number, Value, json}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +const DEFAULT_INPUT: &str = "./model.bbmodel"; +const TEXTURE_DATA_PREFIX: &str = "data:image/png;base64,"; + +struct TextureDesc { + identifier: String, + width: u64, + height: u64, + terrain_texture: Option, +} + +struct CommandOutput { + model_path: PathBuf, + texture_count: usize, + terrain_updated: bool, +} + +pub fn execute(args: &BbmodelArgs) { + match run_bbmodel(args) { + Ok(output) => { + println!("成功: 方块模型已生成到 {}", output.model_path.display()); + if output.texture_count > 0 { + println!("成功: 已导出 {} 张贴图", output.texture_count); + } + if output.terrain_updated { + println!("成功: terrain_texture.json 已更新"); + } + } + Err(e) => { + eprintln!("错误: {}", e); + } + } +} + +fn run_bbmodel(args: &BbmodelArgs) -> Result { + let (namespace, short_name) = parse_identifier(&args.identifier)?; + let input_path = PathBuf::from(args.input.as_deref().unwrap_or(DEFAULT_INPUT)); + let doc = parse_bbmodel(&input_path)?; + let texture_count = texture_count(&doc)?; + let bones = flatten_bones(&doc, texture_count)?; + + let target = resolve_target(args, short_name)?; + + if let Some(parent) = target.model_path.parent() { + fs::create_dir_all(parent).map_err(|e| file::io_error("创建模型目录", parent, e))?; + } + + let texture_descs = if args.no_textures { + collect_texture_descriptions(&doc, namespace, short_name, None)? + } else { + let texture_dir = target + .texture_dir + .as_ref() + .ok_or_else(|| CliError::InvalidData("贴图输出目录未初始化".into()))?; + extract_textures( + &doc, + texture_dir, + namespace, + short_name, + target.terrain_base.as_deref(), + )? + }; + + let model_json = assemble_model_json(&args.identifier, bones, &texture_descs); + file::write_json_to_file(&target.model_path, &model_json)?; + + let mut terrain_updated = false; + if !args.no_textures { + if let Some(res_pack) = target.resource_pack.as_deref() { + update_terrain_texture(res_pack, &texture_descs)?; + terrain_updated = !texture_descs.is_empty(); + } + } + + Ok(CommandOutput { + model_path: target.model_path, + texture_count: if args.no_textures { + 0 + } else { + texture_descs.len() + }, + terrain_updated, + }) +} + +struct TargetPaths { + model_path: PathBuf, + texture_dir: Option, + resource_pack: Option, + terrain_base: Option, +} + +fn resolve_target(args: &BbmodelArgs, short_name: &str) -> Result { + if let Some(output) = args.output.as_deref() { + let output_dir = PathBuf::from(output); + return Ok(TargetPaths { + model_path: output_dir.join(format!("{}.json", short_name)), + texture_dir: Some(output_dir), + resource_pack: None, + terrain_base: None, + }); + } + + let project_path = file::find_project_dir(&args.path)?; + let project_info = entity::get_current_release_info(&project_path)?; + let res_pack = project_path.join(format!( + "resource_pack_{}", + project_info.resource_identifier + )); + + Ok(TargetPaths { + model_path: res_pack + .join("models") + .join("netease_block") + .join(format!("{}.json", short_name)), + texture_dir: Some(res_pack.join("textures").join("blocks")), + resource_pack: Some(res_pack), + terrain_base: Some("textures/blocks".to_string()), + }) +} + +fn parse_identifier(identifier: &str) -> Result<(&str, &str)> { + let (namespace, short_name) = identifier.split_once(':').ok_or_else(|| { + CliError::InvalidInput(format!( + "方块标识符 '{}' 无效,应为 :", + identifier + )) + })?; + + if namespace.is_empty() || short_name.is_empty() || short_name.contains(':') { + return Err(CliError::InvalidInput(format!( + "方块标识符 '{}' 无效,应为 :", + identifier + ))); + } + + if short_name.chars().any(|c| c == '/' || c == '\\') { + return Err(CliError::InvalidInput(format!( + "方块标识符 '{}' 不能包含路径分隔符", + identifier + ))); + } + + Ok((namespace, short_name)) +} + +fn parse_bbmodel(path: &Path) -> Result { + if !path.exists() { + return Err(CliError::NotFound(format!( + ".bbmodel 文件 {} 不存在", + path.display() + ))); + } + + let path_buf = path.to_path_buf(); + let doc = file::read_file_to_json(&path_buf)?; + + required_array_field(&doc, "outliner")?; + optional_array_field(&doc, "groups")?; + required_array_field(&doc, "elements")?; + + if doc + .get("meta") + .and_then(|meta| meta.get("box_uv")) + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Err(CliError::InvalidData( + "当前只支持 per-face UV,不支持 box_uv 模式".into(), + )); + } + + Ok(doc) +} + +fn texture_count(doc: &Value) -> Result { + Ok(optional_array_field(doc, "textures")?.map_or(0, Vec::len)) +} + +fn flatten_bones(doc: &Value, texture_count: usize) -> Result> { + let groups = match optional_array_field(doc, "groups")? { + Some(groups) => build_uuid_lookup(groups, "groups")?, + None => HashMap::new(), + }; + let elements = build_uuid_lookup(required_array_field(doc, "elements")?, "elements")?; + let outliner = required_array_field(doc, "outliner")?; + + let mut bones = Vec::new(); + for node in outliner { + visit_outliner_node( + node, + None, + None, + &groups, + &elements, + texture_count, + &mut bones, + )?; + } + + Ok(bones) +} + +fn build_uuid_lookup<'a>(items: &'a [Value], field: &str) -> Result> { + let mut lookup = HashMap::with_capacity(items.len()); + for item in items { + let uuid = item + .get("uuid") + .and_then(Value::as_str) + .ok_or_else(|| CliError::InvalidData(format!("{} 中存在缺少 uuid 的条目", field)))?; + lookup.insert(uuid, item); + } + Ok(lookup) +} + +fn visit_outliner_node<'a>( + node: &'a Value, + parent_name: Option<&'a str>, + current_bone_index: Option, + groups: &HashMap<&'a str, &'a Value>, + elements: &HashMap<&'a str, &'a Value>, + texture_count: usize, + bones: &mut Vec, +) -> Result<()> { + match node { + Value::String(uuid) => { + let element = elements.get(uuid.as_str()).ok_or_else(|| { + CliError::InvalidData(format!("outliner 引用了不存在的 element uuid {}", uuid)) + })?; + + if let Some(cube) = convert_element(element, texture_count)? { + let bone_index = current_bone_index.ok_or_else(|| { + CliError::InvalidData(format!( + "cube {} 不在任何 group/bone 下,无法生成 netease:block_geometry", + uuid + )) + })?; + append_cube(&mut bones[bone_index], cube)?; + } + } + Value::Object(outliner_group) => { + let uuid = outliner_group + .get("uuid") + .and_then(Value::as_str) + .ok_or_else(|| CliError::InvalidData("outliner group 缺少 uuid".into()))?; + let group = groups.get(uuid).copied().unwrap_or(node); + let group_name = required_string_field(group, "name")?; + + let mut bone = Map::new(); + bone.insert("name".to_string(), Value::String(group_name.to_string())); + if let Some(parent) = parent_name { + bone.insert("parent".to_string(), Value::String(parent.to_string())); + } + bone.insert("pivot".to_string(), convert_pivot(group, "group")?); + bone.insert("rotation".to_string(), convert_rotation(group, "group")?); + + bones.push(Value::Object(bone)); + let bone_index = bones.len() - 1; + + let children = outliner_group + .get("children") + .and_then(Value::as_array) + .ok_or_else(|| { + CliError::InvalidData(format!("outliner group {} 缺少 children", uuid)) + })?; + + for child in children { + match child { + Value::String(_) => visit_outliner_node( + child, + Some(group_name), + Some(bone_index), + groups, + elements, + texture_count, + bones, + )?, + Value::Object(_) => {} + _ => { + visit_outliner_node( + child, + Some(group_name), + Some(bone_index), + groups, + elements, + texture_count, + bones, + )?; + } + } + } + + for child in children.iter().rev() { + if child.is_object() { + visit_outliner_node( + child, + Some(group_name), + Some(bone_index), + groups, + elements, + texture_count, + bones, + )?; + } + } + } + _ => { + return Err(CliError::InvalidData( + "outliner 节点必须是 group 对象或 element uuid 字符串".into(), + )); + } + } + + Ok(()) +} + +fn append_cube(bone: &mut Value, cube: Value) -> Result<()> { + let bone_object = bone + .as_object_mut() + .ok_or_else(|| CliError::InvalidData("bone 不是 JSON 对象".into()))?; + + if !bone_object.contains_key("cubes") { + bone_object.insert("cubes".to_string(), Value::Array(Vec::new())); + } + + let cubes = bone_object + .get_mut("cubes") + .and_then(Value::as_array_mut) + .ok_or_else(|| CliError::InvalidData("bone.cubes 不是数组".into()))?; + cubes.push(cube); + + Ok(()) +} + +fn convert_element(element: &Value, texture_count: usize) -> Result> { + let element_type = required_string_field(element, "type")?; + if element_type != "cube" { + let name = element + .get("name") + .and_then(Value::as_str) + .unwrap_or(""); + eprintln!( + "警告: 跳过不支持的 Blockbench 元素 '{}',type={}", + name, element_type + ); + return Ok(None); + } + + if element + .get("box_uv") + .and_then(Value::as_bool) + .unwrap_or(false) + { + let name = element + .get("name") + .and_then(Value::as_str) + .unwrap_or(""); + return Err(CliError::InvalidData(format!( + "元素 '{}' 使用 box_uv,当前只支持 per-face UV", + name + ))); + } + + Ok(Some(convert_cube(element, texture_count)?)) +} + +fn convert_cube(element: &Value, texture_count: usize) -> Result { + let from = required_vec3(element, "from")?; + let to = required_vec3(element, "to")?; + let origin = required_vec3(element, "origin")?; + let rotation = optional_vec3(element, "rotation")?; + let faces = required_object_field(element, "faces")?; + + let mut cube = Map::new(); + cube.insert( + "origin".to_string(), + number_array(&[-to[0], from[1], from[2]])?, + ); + cube.insert( + "pivot".to_string(), + number_array(&[-origin[0], origin[1], origin[2]])?, + ); + cube.insert( + "rotation".to_string(), + number_array(&[-rotation[0], -rotation[1], rotation[2]])?, + ); + cube.insert( + "size".to_string(), + number_array(&[to[0] - from[0], to[1] - from[1], to[2] - from[2]])?, + ); + + let mut uv = Map::new(); + for (face_name, face) in faces { + uv.insert( + face_name.to_string(), + convert_face(face_name, face, texture_count)?, + ); + } + cube.insert("uv".to_string(), Value::Object(uv)); + + Ok(Value::Object(cube)) +} + +fn convert_face(face_name: &str, face: &Value, texture_count: usize) -> Result { + let uv_values = required_array_field(face, "uv")?; + if uv_values.len() != 4 { + return Err(CliError::InvalidData(format!( + "face.uv 长度应为 4,实际为 {}", + uv_values.len() + ))); + } + + let u1 = number_as_f64(&uv_values[0], "face.uv[0]")?; + let v1 = number_as_f64(&uv_values[1], "face.uv[1]")?; + let u2 = number_as_f64(&uv_values[2], "face.uv[2]")?; + let v2 = number_as_f64(&uv_values[3], "face.uv[3]")?; + + let texture = face + .get("texture") + .ok_or_else(|| CliError::InvalidData("face 缺少 texture 字段".into()))?; + let texture_index = number_as_u64(texture, "face.texture")?; + let texture_index_usize = usize::try_from(texture_index).map_err(|_| { + CliError::InvalidData(format!("face.texture {} 超出平台索引范围", texture_index)) + })?; + if texture_index_usize >= texture_count { + return Err(CliError::InvalidData(format!( + "face.texture {} 超出 textures 数组范围 0..{}", + texture_index, + texture_count.saturating_sub(1) + ))); + } + + let mut out = Map::new(); + out.insert( + "texture".to_string(), + Value::Number(Number::from(texture_index)), + ); + if face_name == "up" { + out.insert( + "uv".to_string(), + Value::Array(vec![uv_values[2].clone(), uv_values[3].clone()]), + ); + out.insert("uv_size".to_string(), number_array(&[u1 - u2, v1 - v2])?); + } else { + out.insert( + "uv".to_string(), + Value::Array(vec![uv_values[0].clone(), uv_values[1].clone()]), + ); + out.insert("uv_size".to_string(), number_array(&[u2 - u1, v2 - v1])?); + } + + if let Some(rotation) = face.get("rotation") { + let rot = number_as_i64(rotation, "face.rotation")?; + match rot { + 0 => {} + 90 | 180 | 270 => { + out.insert("rot".to_string(), Value::Number(Number::from(rot))); + } + _ => { + return Err(CliError::InvalidData(format!( + "face.rotation 只支持 0/90/180/270,实际为 {}", + rot + ))); + } + } + } + + Ok(Value::Object(out)) +} + +fn convert_pivot(value: &Value, owner: &str) -> Result { + let origin = required_vec3(value, "origin")?; + number_array(&[-origin[0], origin[1], origin[2]]) + .map_err(|e| CliError::InvalidData(format!("{} pivot 转换失败: {}", owner, e))) +} + +fn convert_rotation(value: &Value, owner: &str) -> Result { + let rotation = required_vec3(value, "rotation")?; + number_array(&[-rotation[0], -rotation[1], rotation[2]]) + .map_err(|e| CliError::InvalidData(format!("{} rotation 转换失败: {}", owner, e))) +} + +fn collect_texture_descriptions( + doc: &Value, + namespace: &str, + short_name: &str, + terrain_base: Option<&str>, +) -> Result> { + let Some(textures) = optional_array_field(doc, "textures")? else { + return Ok(Vec::new()); + }; + + let mut descs = Vec::with_capacity(textures.len()); + for (index, texture) in textures.iter().enumerate() { + let texture_index = index + 1; + let width = required_u64_field(texture, "width")?; + let height = required_u64_field(texture, "height")?; + let terrain_texture = + terrain_base.map(|base| format!("{}/{}_{}", base, short_name, texture_index)); + + descs.push(TextureDesc { + identifier: format!("{}:{}_{}", namespace, short_name, texture_index), + width, + height, + terrain_texture, + }); + } + + Ok(descs) +} + +fn extract_textures( + doc: &Value, + out_dir: &Path, + namespace: &str, + short_name: &str, + terrain_base: Option<&str>, +) -> Result> { + fs::create_dir_all(out_dir).map_err(|e| file::io_error("创建贴图目录", out_dir, e))?; + + let Some(textures) = optional_array_field(doc, "textures")? else { + return Ok(Vec::new()); + }; + + let mut descs = Vec::with_capacity(textures.len()); + for (index, texture) in textures.iter().enumerate() { + let texture_index = index + 1; + let width = required_u64_field(texture, "width")?; + let height = required_u64_field(texture, "height")?; + let source = required_string_field(texture, "source")?; + let encoded = source.strip_prefix(TEXTURE_DATA_PREFIX).ok_or_else(|| { + CliError::InvalidData(format!("textures[{}].source 不是内嵌 PNG data URL", index)) + })?; + let bytes = STANDARD.decode(encoded).map_err(|e| { + CliError::InvalidData(format!("textures[{}].source base64 解码失败: {}", index, e)) + })?; + validate_png(texture_index, &bytes, width, height)?; + + let png_path = out_dir.join(format!("{}_{}.png", short_name, texture_index)); + fs::write(&png_path, bytes).map_err(|e| file::io_error("写入贴图", &png_path, e))?; + + let terrain_texture = + terrain_base.map(|base| format!("{}/{}_{}", base, short_name, texture_index)); + + descs.push(TextureDesc { + identifier: format!("{}:{}_{}", namespace, short_name, texture_index), + width, + height, + terrain_texture, + }); + } + + Ok(descs) +} + +fn validate_png(texture_index: usize, bytes: &[u8], width: u64, height: u64) -> Result<()> { + const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + + if bytes.len() < 24 || &bytes[..8] != PNG_SIGNATURE || &bytes[12..16] != b"IHDR" { + return Err(CliError::InvalidData(format!( + "textures[{}].source 解码结果不是有效 PNG", + texture_index - 1 + ))); + } + + let actual_width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]) as u64; + let actual_height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]) as u64; + + if actual_width != width || actual_height != height { + return Err(CliError::InvalidData(format!( + "textures[{}] 尺寸不一致:字段为 {}x{},PNG IHDR 为 {}x{}", + texture_index - 1, + width, + height, + actual_width, + actual_height + ))); + } + + Ok(()) +} + +fn update_terrain_texture(res_pack: &Path, entries: &[TextureDesc]) -> Result<()> { + if entries.is_empty() { + return Ok(()); + } + + let terrain_path = res_pack.join("textures").join("terrain_texture.json"); + if let Some(parent) = terrain_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| file::io_error("创建 terrain_texture 目录", parent, e))?; + } + + let mut terrain = if terrain_path.exists() { + file::read_file_to_json(&terrain_path)? + } else { + json!({ + "resource_pack_name": "vanilla", + "texture_data": {}, + "texture_name": "atlas.terrain" + }) + }; + + let terrain_obj = terrain.as_object_mut().ok_or_else(|| { + CliError::InvalidData(format!("{} 顶层必须是 JSON 对象", terrain_path.display())) + })?; + if !terrain_obj.contains_key("texture_data") { + terrain_obj.insert("texture_data".to_string(), json!({})); + } + + let texture_data = terrain_obj + .get_mut("texture_data") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + CliError::InvalidData(format!( + "{} 的 texture_data 必须是 JSON 对象", + terrain_path.display() + )) + })?; + + for entry in entries { + let texture_path = entry.terrain_texture.as_ref().ok_or_else(|| { + CliError::InvalidData(format!("{} 缺少 terrain_texture 路径", entry.identifier)) + })?; + texture_data.insert( + entry.identifier.clone(), + json!({ + "textures": texture_path + }), + ); + } + + file::write_json_to_file(&terrain_path, &terrain) +} + +fn assemble_model_json( + identifier: &str, + bones: Vec, + texture_descs: &[TextureDesc], +) -> Value { + let textures = texture_descs + .iter() + .map(|texture| Value::String(texture.identifier.clone())) + .collect::>(); + let texture_descriptions = texture_descs + .iter() + .map(|texture| { + json!({ + "width": texture.width, + "length": texture.height + }) + }) + .collect::>(); + + json!({ + "format_version": "1.13.0", + "netease:block_geometry": { + "bones": bones, + "description": { + "identifier": identifier, + "textures": textures, + "textures_descriptions": texture_descriptions, + "use_ao": false + } + } + }) +} + +fn required_string_field<'a>(value: &'a Value, field: &str) -> Result<&'a str> { + value + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| CliError::InvalidData(format!("缺少字符串字段 {}", field))) +} + +fn required_u64_field(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_u64) + .ok_or_else(|| CliError::InvalidData(format!("缺少无符号整数字段 {}", field))) +} + +fn required_array_field<'a>(value: &'a Value, field: &str) -> Result<&'a Vec> { + value + .get(field) + .and_then(Value::as_array) + .ok_or_else(|| CliError::InvalidData(format!("缺少数组字段 {}", field))) +} + +fn optional_array_field<'a>(value: &'a Value, field: &str) -> Result>> { + match value.get(field) { + Some(Value::Array(items)) => Ok(Some(items)), + Some(_) => Err(CliError::InvalidData(format!("字段 {} 必须是数组", field))), + None => Ok(None), + } +} + +fn required_object_field<'a>(value: &'a Value, field: &str) -> Result<&'a Map> { + value + .get(field) + .and_then(Value::as_object) + .ok_or_else(|| CliError::InvalidData(format!("缺少对象字段 {}", field))) +} + +fn required_vec3(value: &Value, field: &str) -> Result<[f64; 3]> { + let array = required_array_field(value, field)?; + if array.len() != 3 { + return Err(CliError::InvalidData(format!( + "字段 {} 长度应为 3,实际为 {}", + field, + array.len() + ))); + } + + Ok([ + number_as_f64(&array[0], field)?, + number_as_f64(&array[1], field)?, + number_as_f64(&array[2], field)?, + ]) +} + +fn optional_vec3(value: &Value, field: &str) -> Result<[f64; 3]> { + match value.get(field) { + Some(_) => required_vec3(value, field), + None => Ok([0.0, 0.0, 0.0]), + } +} + +fn number_array(values: &[f64]) -> Result { + values + .iter() + .map(|value| number_value(*value)) + .collect::>>() + .map(Value::Array) +} + +fn number_value(value: f64) -> Result { + let value = if value == 0.0 { 0.0 } else { value }; + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| CliError::InvalidData(format!("无法写入非有限数字 {}", value))) +} + +fn number_as_f64(value: &Value, field: &str) -> Result { + value + .as_f64() + .ok_or_else(|| CliError::InvalidData(format!("字段 {} 必须是数字", field))) +} + +fn number_as_u64(value: &Value, field: &str) -> Result { + value + .as_u64() + .ok_or_else(|| CliError::InvalidData(format!("字段 {} 必须是非负整数", field))) +} + +fn number_as_i64(value: &Value, field: &str) -> Result { + if let Some(value) = value.as_i64() { + return Ok(value); + } + + let Some(value) = value.as_u64() else { + return Err(CliError::InvalidData(format!("字段 {} 必须是整数", field))); + }; + + i64::try_from(value).map_err(|_| CliError::InvalidData(format!("字段 {} 超出 i64 范围", field))) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 532d1c0..44e06a8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ use clap::{Args, Parser, Subcommand, arg}; +pub mod bbmodel; pub mod components; pub mod create; pub mod debug; @@ -32,6 +33,8 @@ pub enum Commands { Components(ComponentsArgs), /// Launch NetEase Minecraft with debug MOD, IPC logging, and hot reload Debug(DebugArgs), + /// Convert Blockbench .bbmodel to NetEase block geometry + Bbmodel(BbmodelArgs), } #[derive(Args)] @@ -87,6 +90,25 @@ pub struct ComponentsArgs { pub identifier: Option, } +#[derive(Args)] +pub struct BbmodelArgs { + /// The path of the project + #[arg(short, long)] + pub path: Option, + /// Import the path of the .bbmodel file. + #[arg(short, long)] + pub input: Option, + /// The block identifier, for example abyss:tree_spirit_block + #[arg(short = 'd', long)] + pub identifier: String, + /// Standalone output directory. When set, resource pack files are not updated. + #[arg(short, long)] + pub output: Option, + /// Skip texture export and terrain_texture.json registration. + #[arg(long)] + pub no_textures: bool, +} + #[derive(Args)] pub struct DebugArgs { /// The path of the project (default: current directory) diff --git a/src/main.rs b/src/main.rs index 843af33..edf8531 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,5 +16,6 @@ fn main() { Commands::Init(args) => commands::init::execute(args), Commands::Components(args) => commands::components::execute(args), Commands::Debug(args) => commands::debug::execute(args), + Commands::Bbmodel(args) => commands::bbmodel::execute(args), } }