feat(bbmodel): 支持转换 Blockbench 方块模型

新增 bbmodel 子命令,将 .bbmodel 转成网易 netease:block_geometry,并支持独立输出目录。

同时导出内嵌 PNG 贴图并在资源包模式下更新 terrain_texture.json,减少手工导入方块模型和材质的重复操作。
This commit is contained in:
2026-05-22 01:37:42 +08:00
parent 37cd8cc160
commit 410b2e64e9
5 changed files with 807 additions and 0 deletions

7
Cargo.lock generated
View File

@@ -86,6 +86,12 @@ dependencies = [
"derive_arbitrary", "derive_arbitrary",
] ]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -308,6 +314,7 @@ dependencies = [
name = "emod-cli" name = "emod-cli"
version = "0.1.0-dev" version = "0.1.0-dev"
dependencies = [ dependencies = [
"base64",
"clap", "clap",
"rand", "rand",
"regex", "regex",

View File

@@ -12,6 +12,7 @@ clap = { version = "4.5.32", features = ["derive"] }
uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] } uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
base64 = "0.22"
toml = "0.8" toml = "0.8"
zip = "2.2.2" zip = "2.2.2"
walkdir = "2" walkdir = "2"

776
src/commands/bbmodel.rs Normal file
View 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)))
}

View File

@@ -1,5 +1,6 @@
use clap::{Args, Parser, Subcommand, arg}; use clap::{Args, Parser, Subcommand, arg};
pub mod bbmodel;
pub mod components; pub mod components;
pub mod create; pub mod create;
pub mod debug; pub mod debug;
@@ -32,6 +33,8 @@ pub enum Commands {
Components(ComponentsArgs), Components(ComponentsArgs),
/// Launch NetEase Minecraft with debug MOD, IPC logging, and hot reload /// Launch NetEase Minecraft with debug MOD, IPC logging, and hot reload
Debug(DebugArgs), Debug(DebugArgs),
/// Convert Blockbench .bbmodel to NetEase block geometry
Bbmodel(BbmodelArgs),
} }
#[derive(Args)] #[derive(Args)]
@@ -87,6 +90,25 @@ pub struct ComponentsArgs {
pub identifier: Option<String>, 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)] #[derive(Args)]
pub struct DebugArgs { pub struct DebugArgs {
/// The path of the project (default: current directory) /// The path of the project (default: current directory)

View File

@@ -16,5 +16,6 @@ fn main() {
Commands::Init(args) => commands::init::execute(args), Commands::Init(args) => commands::init::execute(args),
Commands::Components(args) => commands::components::execute(args), Commands::Components(args) => commands::components::execute(args),
Commands::Debug(args) => commands::debug::execute(args), Commands::Debug(args) => commands::debug::execute(args),
Commands::Bbmodel(args) => commands::bbmodel::execute(args),
} }
} }