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))) }