feat(bbmodel): 支持转换 Blockbench 方块模型
新增 bbmodel 子命令,将 .bbmodel 转成网易 netease:block_geometry,并支持独立输出目录。 同时导出内嵌 PNG 贴图并在资源包模式下更新 terrain_texture.json,减少手工导入方块模型和材质的重复操作。
This commit is contained in:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
776
src/commands/bbmodel.rs
Normal file
776
src/commands/bbmodel.rs
Normal file
@@ -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<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)))
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct BbmodelArgs {
|
||||
/// The path of the project
|
||||
#[arg(short, long)]
|
||||
pub path: Option<String>,
|
||||
/// Import the path of the .bbmodel file.
|
||||
#[arg(short, long)]
|
||||
pub input: Option<String>,
|
||||
/// 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<String>,
|
||||
/// 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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user