Initial commit: Terrafier v0.1.0
- Core world model (World, Dimension, Tile, Terrain, Platform) - NBT reader/writer with full tag support - Anvil region (.mca) format reader with chunk parsing - Block palette compression and export - Biome database with Minecraft 1.21 biomes - CLI commands: new, import, export, info, render - GUI application with egui (viewport, tools, undo/redo) - Noise-based heightmap generation and editing operations License: GPL-3.0-or-later
This commit is contained in:
commit
91835402b3
94 changed files with 12372 additions and 0 deletions
15
cli/Cargo.toml
Executable file
15
cli/Cargo.toml
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "terrafier-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "CLI tool for Terrafier — create, edit, export Minecraft worlds from the command line"
|
||||
|
||||
[dependencies]
|
||||
terrafier-core = { path = "../core" }
|
||||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
indicatif.workspace = true
|
||||
log.workspace = true
|
||||
env_logger.workspace = true
|
||||
serde_json.workspace = true
|
||||
64
cli/src/cmds/export.rs
Executable file
64
cli/src/cmds/export.rs
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
use clap::Args;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::util::output::{self, OutputFormat};
|
||||
use crate::util::{load_world, progress};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ExportArgs {
|
||||
/// Path to Terrafier world directory
|
||||
pub world: String,
|
||||
/// Output directory for Minecraft save
|
||||
#[arg(short, long)]
|
||||
pub output: String,
|
||||
/// Minecraft data version (default: 3954 for 1.21)
|
||||
#[arg(long, default_value = "3954")]
|
||||
pub data_version: i32,
|
||||
}
|
||||
|
||||
pub fn cmd_export(
|
||||
args: ExportArgs,
|
||||
format: &OutputFormat,
|
||||
dry_run: bool,
|
||||
validate: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let world_path = Path::new(&args.world);
|
||||
log::info!("Exporting world from: {}", world_path.display());
|
||||
|
||||
let world = load_world(world_path)?;
|
||||
terrafier_core::io::export::validate_export(&world)?;
|
||||
|
||||
let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
|
||||
|
||||
if validate || dry_run {
|
||||
output::print_result(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"status": "validated",
|
||||
"input": args.world,
|
||||
"output": args.output,
|
||||
"tiles": tile_count,
|
||||
}),
|
||||
);
|
||||
if dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let bar = progress::new_bar(tile_count as u64, "Exporting...");
|
||||
terrafier_core::io::export::export_to_save(&world, Path::new(&args.output))?;
|
||||
progress::finish_with("Export complete", bar);
|
||||
|
||||
log::info!("World exported to: {}", args.output);
|
||||
output::print_result(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"status": "exported",
|
||||
"input": args.world,
|
||||
"output": args.output,
|
||||
"tiles": tile_count,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
78
cli/src/cmds/import.rs
Executable file
78
cli/src/cmds/import.rs
Executable file
|
|
@ -0,0 +1,78 @@
|
|||
use clap::Args;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::util::output::{self, OutputFormat};
|
||||
use crate::util::progress;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ImportArgs {
|
||||
/// Path to Minecraft save directory
|
||||
pub input: String,
|
||||
/// Output Terrafier world path
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
pub fn cmd_import(
|
||||
args: ImportArgs,
|
||||
format: &OutputFormat,
|
||||
dry_run: bool,
|
||||
validate: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let input_path = Path::new(&args.input);
|
||||
log::info!("Importing Minecraft save from: {}", input_path.display());
|
||||
|
||||
if validate || dry_run {
|
||||
terrafier_core::io::import::validate_save(input_path)?;
|
||||
output::print_result(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"status": "validated",
|
||||
"input": args.input,
|
||||
}),
|
||||
);
|
||||
if dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
terrafier_core::io::import::validate_save(input_path)?;
|
||||
|
||||
// Count regions for progress display
|
||||
let region_dir = input_path.join("region");
|
||||
let region_count = if region_dir.is_dir() {
|
||||
std::fs::read_dir(®ion_dir)
|
||||
.map(|e| e.count())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let bar = progress::new_bar(region_count.max(1) as u64, "Importing regions...");
|
||||
let world = terrafier_core::io::import::import(input_path)?;
|
||||
progress::finish_with("Import complete", bar);
|
||||
|
||||
let output_path = Path::new(&args.output);
|
||||
std::fs::create_dir_all(output_path)?;
|
||||
|
||||
let world_json = serde_json::to_string_pretty(&world)?;
|
||||
std::fs::write(output_path.join("world.tfw"), &world_json)?;
|
||||
|
||||
let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
|
||||
log::info!(
|
||||
"Import complete: {} dimensions, {} tiles",
|
||||
world.dimensions.len(),
|
||||
tile_count
|
||||
);
|
||||
output::print_result(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"status": "imported",
|
||||
"name": world.name,
|
||||
"seed": world.seed,
|
||||
"dimensions": world.dimensions.len(),
|
||||
"tiles": tile_count,
|
||||
"output": output_path.display().to_string(),
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
52
cli/src/cmds/info.rs
Executable file
52
cli/src/cmds/info.rs
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
use clap::Args;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::util::load_world;
|
||||
use crate::util::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct InfoArgs {
|
||||
/// Path to Terrafier world or Minecraft save
|
||||
pub world: String,
|
||||
}
|
||||
|
||||
pub fn cmd_info(
|
||||
args: InfoArgs,
|
||||
format: &OutputFormat,
|
||||
_dry_run: bool,
|
||||
_validate: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let world_path = Path::new(&args.world);
|
||||
|
||||
let world = match load_world(world_path) {
|
||||
Ok(w) => w,
|
||||
Err(_) => terrafier_core::io::import::import(world_path)?,
|
||||
};
|
||||
|
||||
let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
|
||||
|
||||
output::print_result(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"name": world.name,
|
||||
"seed": world.seed,
|
||||
"platform": world.platform.display_name,
|
||||
"platform_id": world.platform.id,
|
||||
"height_range": {
|
||||
"min": world.platform.min_height,
|
||||
"max": world.platform.max_height,
|
||||
},
|
||||
"dimensions": world.dimensions.iter().map(|d| serde_json::json!({
|
||||
"name": d.name,
|
||||
"tiles": d.tiles.len(),
|
||||
"seed": d.seed,
|
||||
"min_height": d.min_height,
|
||||
"max_height": d.max_height,
|
||||
})).collect::<Vec<_>>(),
|
||||
"total_tiles": tile_count,
|
||||
"path": world_path.display().to_string(),
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
5
cli/src/cmds/mod.rs
Executable file
5
cli/src/cmds/mod.rs
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod info;
|
||||
pub mod new;
|
||||
pub mod render;
|
||||
69
cli/src/cmds/new.rs
Executable file
69
cli/src/cmds/new.rs
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
use clap::Args;
|
||||
use std::path::PathBuf;
|
||||
use terrafier_core::World;
|
||||
|
||||
use crate::util::output::{self, OutputFormat};
|
||||
use crate::util::progress;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct NewArgs {
|
||||
/// World name
|
||||
pub name: String,
|
||||
/// Minecraft seed
|
||||
#[arg(long, default_value = "0")]
|
||||
pub seed: u64,
|
||||
/// Output directory
|
||||
#[arg(short, long, default_value = ".")]
|
||||
pub output: PathBuf,
|
||||
}
|
||||
|
||||
pub fn cmd_new(
|
||||
args: NewArgs,
|
||||
format: &OutputFormat,
|
||||
dry_run: bool,
|
||||
validate: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
log::info!("Creating new world: {} (seed: {})", args.name, args.seed);
|
||||
|
||||
if validate || dry_run {
|
||||
if args.name.is_empty() {
|
||||
anyhow::bail!("World name cannot be empty");
|
||||
}
|
||||
output::print_result(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"status": "validated",
|
||||
"name": args.name,
|
||||
"seed": args.seed,
|
||||
}),
|
||||
);
|
||||
if dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let spinner = progress::new_spinner("Generating world...");
|
||||
let world = World::new(&args.name, args.seed);
|
||||
progress::finish_with("World generated", spinner);
|
||||
|
||||
let world_dir = args.output.join(&args.name);
|
||||
std::fs::create_dir_all(&world_dir)?;
|
||||
|
||||
let world_json = serde_json::to_string_pretty(&world)?;
|
||||
std::fs::write(world_dir.join("world.tfw"), &world_json)?;
|
||||
|
||||
let dim = &world.dimensions[0];
|
||||
log::info!("World saved to: {}", world_dir.display());
|
||||
output::print_result(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"status": "created",
|
||||
"name": args.name,
|
||||
"seed": args.seed,
|
||||
"path": world_dir.display().to_string(),
|
||||
"tiles": dim.tiles.len(),
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
63
cli/src/cmds/render.rs
Executable file
63
cli/src/cmds/render.rs
Executable file
|
|
@ -0,0 +1,63 @@
|
|||
use clap::Args;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::util::output::{self, OutputFormat};
|
||||
use crate::util::{load_world, progress};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RenderArgs {
|
||||
/// Path to Terrafier world directory
|
||||
pub world: String,
|
||||
/// Output image path
|
||||
#[arg(short, long, default_value = "preview.png")]
|
||||
pub output: PathBuf,
|
||||
/// Scale factor (1 = full resolution, 2 = half, 4 = quarter)
|
||||
#[arg(long, default_value = "4")]
|
||||
pub scale: u32,
|
||||
}
|
||||
|
||||
pub fn cmd_render(
|
||||
args: RenderArgs,
|
||||
format: &OutputFormat,
|
||||
dry_run: bool,
|
||||
validate: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let world_path = std::path::Path::new(&args.world);
|
||||
log::info!("Rendering world: {}", world_path.display());
|
||||
|
||||
let world = load_world(world_path)?;
|
||||
terrafier_core::io::export::validate_export(&world)?;
|
||||
|
||||
let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
|
||||
|
||||
if validate || dry_run {
|
||||
output::print_result(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"status": "validated",
|
||||
"world": args.world,
|
||||
"tiles": tile_count,
|
||||
}),
|
||||
);
|
||||
if dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let bar = progress::new_bar(tile_count as u64, "Rendering...");
|
||||
terrafier_core::io::export::render_to_image(&world, &args.output, args.scale)?;
|
||||
progress::finish_with("Render complete", bar);
|
||||
|
||||
log::info!("Preview rendered to: {}", args.output.display());
|
||||
output::print_result(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"status": "rendered",
|
||||
"world": args.world,
|
||||
"output": args.output.display().to_string(),
|
||||
"scale": args.scale,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
69
cli/src/main.rs
Executable file
69
cli/src/main.rs
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
use clap::Parser;
|
||||
|
||||
mod cmds;
|
||||
mod util;
|
||||
|
||||
use crate::util::output::OutputFormat;
|
||||
|
||||
/// Terrafier — high-performance Minecraft world painter
|
||||
#[derive(Parser)]
|
||||
#[command(name = "terrafier")]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
|
||||
/// Output format: json or human
|
||||
#[arg(global = true, long, default_value = "json")]
|
||||
format: String,
|
||||
|
||||
/// Dry run — validate without making changes
|
||||
#[arg(global = true, long)]
|
||||
dry_run: bool,
|
||||
|
||||
/// Validate input without executing
|
||||
#[arg(global = true, long)]
|
||||
validate: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
enum Commands {
|
||||
/// Create a new world from noise
|
||||
New(cmds::new::NewArgs),
|
||||
/// Export world to Minecraft save
|
||||
Export(cmds::export::ExportArgs),
|
||||
/// Import Minecraft save to Terrafier world
|
||||
Import(cmds::import::ImportArgs),
|
||||
/// Show world information
|
||||
Info(cmds::info::InfoArgs),
|
||||
/// Render world preview to an image
|
||||
Render(cmds::render::RenderArgs),
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||
.format_timestamp(None)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let format = OutputFormat::from_str(&cli.format);
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::New(args) => cmds::new::cmd_new(args, &format, cli.dry_run, cli.validate),
|
||||
Commands::Export(args) => {
|
||||
cmds::export::cmd_export(args, &format, cli.dry_run, cli.validate)
|
||||
}
|
||||
Commands::Import(args) => {
|
||||
cmds::import::cmd_import(args, &format, cli.dry_run, cli.validate)
|
||||
}
|
||||
Commands::Info(args) => cmds::info::cmd_info(args, &format, cli.dry_run, cli.validate),
|
||||
Commands::Render(args) => {
|
||||
cmds::render::cmd_render(args, &format, cli.dry_run, cli.validate)
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
crate::util::output::print_error(&format, &e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
17
cli/src/util/mod.rs
Executable file
17
cli/src/util/mod.rs
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
pub mod output;
|
||||
pub mod progress;
|
||||
|
||||
use std::path::Path;
|
||||
use terrafier_core::World;
|
||||
|
||||
/// Load a Terrafier world from JSON format (.tfw).
|
||||
pub fn load_world(path: &Path) -> anyhow::Result<World> {
|
||||
let tfw_path = if path.is_dir() {
|
||||
path.join("world.tfw")
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
let content = std::fs::read_to_string(&tfw_path)?;
|
||||
let world: World = serde_json::from_str(&content)?;
|
||||
Ok(world)
|
||||
}
|
||||
126
cli/src/util/output.rs
Executable file
126
cli/src/util/output.rs
Executable file
|
|
@ -0,0 +1,126 @@
|
|||
use serde_json::Value;
|
||||
|
||||
pub enum OutputFormat {
|
||||
Json,
|
||||
Human,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"human" => Self::Human,
|
||||
_ => Self::Json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_result(format: &OutputFormat, result: &Value) {
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
println!("{}", serde_json::to_string(result).unwrap());
|
||||
}
|
||||
OutputFormat::Human => print_human(result),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_human(result: &Value) {
|
||||
if let Some(status) = result.get("status").and_then(|v| v.as_str()) {
|
||||
match status {
|
||||
"created" => {
|
||||
let name = result["name"].as_str().unwrap_or("?");
|
||||
let seed = result["seed"].as_u64().unwrap_or(0);
|
||||
let tiles = result["tiles"].as_u64().unwrap_or(0);
|
||||
println!(
|
||||
"Created world '{}' (seed: {}, tiles: {})",
|
||||
name, seed, tiles
|
||||
);
|
||||
}
|
||||
"exported" => {
|
||||
let input = result["input"].as_str().unwrap_or("?");
|
||||
let output = result["output"].as_str().unwrap_or("?");
|
||||
let tiles = result["tiles"].as_u64().unwrap_or(0);
|
||||
println!("Exported {} tiles from '{}' to '{}'", tiles, input, output);
|
||||
}
|
||||
"imported" => {
|
||||
let name = result["name"].as_str().unwrap_or("?");
|
||||
let seed = result["seed"].as_u64().unwrap_or(0);
|
||||
let dims = result["dimensions"].as_u64().unwrap_or(0);
|
||||
let tiles = result["tiles"].as_u64().unwrap_or(0);
|
||||
println!(
|
||||
"Imported world '{}' (seed: {}, {} dimensions, {} tiles)",
|
||||
name, seed, dims, tiles
|
||||
);
|
||||
}
|
||||
"rendered" => {
|
||||
let world = result["world"].as_str().unwrap_or("?");
|
||||
let output = result["output"].as_str().unwrap_or("?");
|
||||
println!("Rendered world '{}' to '{}'", world, output);
|
||||
}
|
||||
"validated" => {
|
||||
println!("Validation passed: {:?}", result);
|
||||
}
|
||||
_ => {
|
||||
println!("{}", serde_json::to_string_pretty(result).unwrap());
|
||||
}
|
||||
}
|
||||
} else if result.get("name").is_some() {
|
||||
// Info output
|
||||
println!("World: {}", result["name"].as_str().unwrap_or("?"));
|
||||
if let Some(seed) = result.get("seed").and_then(|v| v.as_u64()) {
|
||||
println!(" Seed: {}", seed);
|
||||
}
|
||||
if let Some(platform) = result.get("platform").and_then(|v| v.as_str()) {
|
||||
println!(" Platform: {}", platform);
|
||||
}
|
||||
if let Some(height) = result.get("height_range") {
|
||||
let min = height["min"].as_i64().unwrap_or(0);
|
||||
let max = height["max"].as_i64().unwrap_or(0);
|
||||
println!(" Height range: {} to {}", min, max);
|
||||
}
|
||||
if let Some(dims) = result.get("dimensions").and_then(|v| v.as_array()) {
|
||||
println!(" Dimensions:");
|
||||
for dim in dims {
|
||||
let name = dim["name"].as_str().unwrap_or("?");
|
||||
let tiles = dim["tiles"].as_u64().unwrap_or(0);
|
||||
println!(" - {} ({} tiles)", name, tiles);
|
||||
}
|
||||
}
|
||||
if let Some(tiles) = result.get("total_tiles").and_then(|v| v.as_u64()) {
|
||||
println!(" Total tiles: {}", tiles);
|
||||
}
|
||||
if let Some(path) = result.get("path").and_then(|v| v.as_str()) {
|
||||
println!(" Path: {}", path);
|
||||
}
|
||||
} else {
|
||||
println!("{}", serde_json::to_string_pretty(result).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_error(format: &OutputFormat, error: &anyhow::Error) {
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({"error": error.to_string()})).unwrap()
|
||||
);
|
||||
}
|
||||
OutputFormat::Human => {
|
||||
eprintln!("Error: {}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn print_info(format: &OutputFormat, msg: &str) {
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({"info": msg})).unwrap()
|
||||
);
|
||||
}
|
||||
OutputFormat::Human => {
|
||||
println!("{}", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
cli/src/util/progress.rs
Executable file
36
cli/src/util/progress.rs
Executable file
|
|
@ -0,0 +1,36 @@
|
|||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn new_spinner(msg: &str) -> ProgressBar {
|
||||
let pb = ProgressBar::new_spinner();
|
||||
pb.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner:.green} {msg}")
|
||||
.unwrap()
|
||||
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
|
||||
);
|
||||
pb.set_message(msg.to_string());
|
||||
pb.enable_steady_tick(Duration::from_millis(100));
|
||||
pb
|
||||
}
|
||||
|
||||
pub fn new_bar(total: u64, msg: &str) -> ProgressBar {
|
||||
let pb = ProgressBar::new(total);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
|
||||
.unwrap()
|
||||
.progress_chars("##-"),
|
||||
);
|
||||
pb.set_message(msg.to_string());
|
||||
pb
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn new_multi() -> MultiProgress {
|
||||
MultiProgress::new()
|
||||
}
|
||||
|
||||
pub fn finish_with(msg: &str, pb: ProgressBar) {
|
||||
pb.finish_with_message(msg.to_string());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue