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:
loki5512344 2026-06-15 19:14:11 +02:00
commit 91835402b3
94 changed files with 12372 additions and 0 deletions

15
cli/Cargo.toml Executable file
View 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
View 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
View 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(&region_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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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());
}