Files
emod-cli/src/commands/bbmodel.rs
Blank038 410b2e64e9 feat(bbmodel): 支持转换 Blockbench 方块模型
新增 bbmodel 子命令,将 .bbmodel 转成网易 netease:block_geometry,并支持独立输出目录。

同时导出内嵌 PNG 贴图并在资源包模式下更新 terrain_texture.json,减少手工导入方块模型和材质的重复操作。
2026-05-22 01:37:42 +08:00

777 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
}
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<CommandOutput> {
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<PathBuf>,
resource_pack: Option<PathBuf>,
terrain_base: Option<String>,
}
fn resolve_target(args: &BbmodelArgs, short_name: &str) -> Result<TargetPaths> {
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!(
"方块标识符 '{}' 无效,应为 <namespace>:<name>",
identifier
))
})?;
if namespace.is_empty() || short_name.is_empty() || short_name.contains(':') {
return Err(CliError::InvalidInput(format!(
"方块标识符 '{}' 无效,应为 <namespace>:<name>",
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<Value> {
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<usize> {
Ok(optional_array_field(doc, "textures")?.map_or(0, Vec::len))
}
fn flatten_bones(doc: &Value, texture_count: usize) -> Result<Vec<Value>> {
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<HashMap<&'a str, &'a Value>> {
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<usize>,
groups: &HashMap<&'a str, &'a Value>,
elements: &HashMap<&'a str, &'a Value>,
texture_count: usize,
bones: &mut Vec<Value>,
) -> 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<Option<Value>> {
let element_type = required_string_field(element, "type")?;
if element_type != "cube" {
let name = element
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unnamed>");
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("<unnamed>");
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Vec<TextureDesc>> {
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<Vec<TextureDesc>> {
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<Value>,
texture_descs: &[TextureDesc],
) -> Value {
let textures = texture_descs
.iter()
.map(|texture| Value::String(texture.identifier.clone()))
.collect::<Vec<_>>();
let texture_descriptions = texture_descs
.iter()
.map(|texture| {
json!({
"width": texture.width,
"length": texture.height
})
})
.collect::<Vec<_>>();
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<u64> {
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>> {
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<Option<&'a Vec<Value>>> {
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<String, Value>> {
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<Value> {
values
.iter()
.map(|value| number_value(*value))
.collect::<Result<Vec<_>>>()
.map(Value::Array)
}
fn number_value(value: f64) -> Result<Value> {
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<f64> {
value
.as_f64()
.ok_or_else(|| CliError::InvalidData(format!("字段 {} 必须是数字", field)))
}
fn number_as_u64(value: &Value, field: &str) -> Result<u64> {
value
.as_u64()
.ok_or_else(|| CliError::InvalidData(format!("字段 {} 必须是非负整数", field)))
}
fn number_as_i64(value: &Value, field: &str) -> Result<i64> {
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)))
}