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

16
gui/Cargo.toml Executable file
View file

@ -0,0 +1,16 @@
[package]
name = "terrafier-gui"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Native GUI for Terrafier — interactive world painting with GPU rendering"
[dependencies]
terrafier-core = { path = "../core" }
anyhow.workspace = true
log.workspace = true
env_logger.workspace = true
egui = "0.31"
eframe = "0.31"
rfd = "0.15"

189
gui/src/app.rs Executable file
View file

@ -0,0 +1,189 @@
use std::collections::VecDeque;
use terrafier_core::model::terrain::Terrain;
use terrafier_core::ops::operations::Operation;
use terrafier_core::World;
#[derive(Clone, Copy, PartialEq)]
pub enum ToolMode {
Raise,
Lower,
Smooth,
Flatten,
Paint,
Inspect,
}
impl ToolMode {
pub fn name(&self) -> &'static str {
match self {
ToolMode::Raise => "Raise",
ToolMode::Lower => "Lower",
ToolMode::Smooth => "Smooth",
ToolMode::Flatten => "Flatten",
ToolMode::Paint => "Paint",
ToolMode::Inspect => "Inspect",
}
}
}
pub struct TerrafierApp {
pub world: Option<World>,
pub selected_tile: Option<(i32, i32)>,
pub tool_mode: ToolMode,
pub brush_radius: u32,
pub brush_strength: f64,
pub selected_terrain: Terrain,
pub target_height: i16,
pub smooth_iterations: u32,
pub undo_stack: VecDeque<Box<dyn Operation>>,
pub redo_stack: Vec<Box<dyn Operation>>,
pub brush_local_x: Option<u32>,
pub brush_local_z: Option<u32>,
pub show_heightmap: bool,
pub zoom: f32,
pub view_offset: (f32, f32),
pub show_new_world: bool,
pub show_export: bool,
pub status_message: String,
pub world_name: String,
pub world_seed: String,
pub export_path: String,
}
impl TerrafierApp {
pub fn new() -> Self {
Self {
world: None,
selected_tile: None,
tool_mode: ToolMode::Raise,
brush_radius: 16,
brush_strength: 0.5,
selected_terrain: Terrain::Grass,
target_height: 64,
smooth_iterations: 3,
undo_stack: VecDeque::new(),
redo_stack: Vec::new(),
brush_local_x: None,
brush_local_z: None,
show_heightmap: false,
zoom: 1.0,
view_offset: (0.0, 0.0),
show_new_world: false,
show_export: false,
status_message: "Ready".to_string(),
world_name: String::new(),
world_seed: String::new(),
export_path: String::new(),
}
}
pub fn save_for_undo(&mut self, op: Box<dyn Operation>) {
self.undo_stack.push_back(op);
if self.undo_stack.len() > 50 {
self.undo_stack.pop_front();
}
self.redo_stack.clear();
}
pub fn undo(&mut self) {
if let Some(op) = self.undo_stack.pop_back() {
if let Some(ref mut world) = self.world {
let dim = world.overworld_mut().expect("world has no overworld");
let inv = op.inverse();
if let Err(e) = inv.apply(dim) {
self.status_message = format!("Undo error: {:?}", e);
self.undo_stack.push_back(op);
return;
}
self.redo_stack.push(op);
self.status_message = "Undo".to_string();
}
} else {
self.status_message = "Nothing to undo".to_string();
}
}
pub fn redo(&mut self) {
if let Some(op) = self.redo_stack.pop() {
if let Some(ref mut world) = self.world {
let dim = world.overworld_mut().expect("world has no overworld");
if let Err(e) = op.apply(dim) {
self.status_message = format!("Redo error: {:?}", e);
self.redo_stack.push(op);
return;
}
self.undo_stack.push_back(op);
self.status_message = "Redo".to_string();
}
} else {
self.status_message = "Nothing to redo".to_string();
}
}
}
impl eframe::App for TerrafierApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::TopBottomPanel::top("menu").show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("New World").clicked() {
self.show_new_world = true;
}
if ui.button("Open").clicked() {
if let Some(path) = rfd::FileDialog::new().pick_folder() {
match terrafier_core::io::import::import(&path) {
Ok(world) => {
self.world = Some(world);
self.selected_tile = None;
self.undo_stack.clear();
self.redo_stack.clear();
self.status_message = format!("Opened world from {}", path.display());
}
Err(e) => {
self.status_message = format!("Open error: {}", e);
}
}
}
}
if ui.button("Export").clicked() {
self.show_export = true;
}
ui.separator();
if ui.button("Undo").clicked() {
self.undo();
}
if ui.button("Redo").clicked() {
self.redo();
}
});
});
egui::SidePanel::left("tools")
.resizable(false)
.default_width(200.0)
.show(ctx, |ui| {
crate::tools::show_tools_panel(ui, self);
});
egui::CentralPanel::default().show(ctx, |ui| {
crate::view::show_viewport(ui, self);
});
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(&self.status_message);
if let Some((tx, tz)) = self.selected_tile {
ui.separator();
ui.label(format!("Tile ({}, {})", tx, tz));
}
if self.world.is_some() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label("World loaded");
});
}
});
});
crate::dialogs::handle_dialogs(self, ctx);
}
}

55
gui/src/dialogs/export.rs Executable file
View file

@ -0,0 +1,55 @@
pub fn show(app: &mut crate::app::TerrafierApp, ctx: &egui::Context) {
if !app.show_export {
return;
}
let mut keep_open = true;
egui::Window::new("Export").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("Output path:");
ui.text_edit_singleline(&mut app.export_path);
});
ui.separator();
if ui.button("Export as Save").clicked() {
let Some(ref world) = app.world else {
app.status_message = "No world to export".to_string();
return;
};
let path = std::path::Path::new(&app.export_path);
match terrafier_core::io::export::export_to_save(world, path) {
Ok(()) => {
app.status_message = format!("Exported to {}", app.export_path);
keep_open = false;
}
Err(e) => {
app.status_message = format!("Export error: {}", e);
}
}
}
if ui.button("Render as PNG").clicked() {
let Some(ref world) = app.world else {
app.status_message = "No world to render".to_string();
return;
};
let path = std::path::Path::new(&app.export_path);
match terrafier_core::io::export::render_to_image(world, path, 2) {
Ok(()) => {
app.status_message = format!("Rendered to {}", app.export_path);
keep_open = false;
}
Err(e) => {
app.status_message = format!("Render error: {}", e);
}
}
}
if ui.button("Cancel").clicked() {
keep_open = false;
}
});
if !keep_open {
app.show_export = false;
}
}

11
gui/src/dialogs/mod.rs Executable file
View file

@ -0,0 +1,11 @@
mod export;
mod new_world;
pub fn handle_dialogs(app: &mut crate::app::TerrafierApp, ctx: &egui::Context) {
if app.show_new_world {
new_world::show(app, ctx);
}
if app.show_export {
export::show(app, ctx);
}
}

43
gui/src/dialogs/new_world.rs Executable file
View file

@ -0,0 +1,43 @@
use terrafier_core::World;
pub fn show(app: &mut crate::app::TerrafierApp, ctx: &egui::Context) {
if !app.show_new_world {
return;
}
let mut keep_open = true;
egui::Window::new("New World").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("Name:");
ui.text_edit_singleline(&mut app.world_name);
});
ui.horizontal(|ui| {
ui.label("Seed:");
ui.text_edit_singleline(&mut app.world_seed);
});
ui.separator();
if ui.button("Create").clicked() {
let name = if app.world_name.is_empty() {
"World"
} else {
&app.world_name
};
let seed = app.world_seed.parse::<u64>().unwrap_or(0);
app.world = Some(World::new(name, seed));
app.selected_tile = None;
app.undo_stack.clear();
app.redo_stack.clear();
app.status_message = format!("Created world '{}' with seed {}", name, seed);
keep_open = false;
}
if ui.button("Cancel").clicked() {
keep_open = false;
}
});
if !keep_open {
app.show_new_world = false;
}
}

15
gui/src/main.rs Executable file
View file

@ -0,0 +1,15 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
let native_options = eframe::NativeOptions::default();
eframe::run_native(
"Terrafier",
native_options,
Box::new(|_cc| Ok(Box::new(app::TerrafierApp::new()))),
)?;
Ok(())
}
mod app;
mod dialogs;
mod tools;
mod view;

190
gui/src/tools.rs Executable file
View file

@ -0,0 +1,190 @@
use std::sync::Arc;
use terrafier_core::model::brush::SymmetricBrush;
use terrafier_core::model::tile::TILE_SIZE;
use terrafier_core::ops::operations::{
FlattenOperation, HeightOperation, MultiTileOperation, Operation, PaintOperation,
SmoothOperation,
};
use terrafier_core::Terrain;
use crate::app::{TerrafierApp, ToolMode};
pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) {
ui.heading("Tools");
ui.separator();
ui.label("Mode:");
ui.radio_value(&mut app.tool_mode, ToolMode::Raise, "Raise");
ui.radio_value(&mut app.tool_mode, ToolMode::Lower, "Lower");
ui.radio_value(&mut app.tool_mode, ToolMode::Smooth, "Smooth");
ui.radio_value(&mut app.tool_mode, ToolMode::Flatten, "Flatten");
ui.radio_value(&mut app.tool_mode, ToolMode::Paint, "Paint");
ui.radio_value(&mut app.tool_mode, ToolMode::Inspect, "Inspect");
ui.separator();
ui.add(egui::Slider::new(&mut app.brush_radius, 1..=64).text("Radius"));
ui.add(egui::Slider::new(&mut app.brush_strength, 0.0..=1.0).text("Strength"));
if app.tool_mode == ToolMode::Smooth {
ui.add(egui::Slider::new(&mut app.smooth_iterations, 1..=20).text("Iterations"));
}
if app.tool_mode == ToolMode::Flatten {
ui.add(egui::Slider::new(&mut app.target_height, -64..=320).text("Height"));
}
if app.tool_mode == ToolMode::Paint {
ui.label("Terrain:");
egui::ComboBox::from_label("Type")
.selected_text(app.selected_terrain.name())
.show_ui(ui, |ui| {
ui.selectable_value(&mut app.selected_terrain, Terrain::Desert, "Desert");
ui.selectable_value(&mut app.selected_terrain, Terrain::Grass, "Grass");
ui.selectable_value(&mut app.selected_terrain, Terrain::Forest, "Forest");
ui.selectable_value(&mut app.selected_terrain, Terrain::Rock, "Rock");
ui.selectable_value(&mut app.selected_terrain, Terrain::Sand, "Sand");
ui.selectable_value(&mut app.selected_terrain, Terrain::Swamp, "Swamp");
ui.selectable_value(&mut app.selected_terrain, Terrain::Water, "Water");
});
}
ui.separator();
ui.checkbox(&mut app.show_heightmap, "Heightmap view");
ui.separator();
if ui.button("Apply to selected tile").clicked() {
apply_tool(app);
}
}
fn apply_tool(app: &mut TerrafierApp) {
let Some((tx, tz)) = app.selected_tile else {
app.status_message = "No tile selected".to_string();
return;
};
if app.world.is_none() {
return;
}
let Some(ref mut world) = app.world else {
return;
};
let dim = world.overworld_mut().expect("no overworld");
let center = app.brush_local_x.unwrap_or(64);
let center_z = app.brush_local_z.unwrap_or(64);
// Handle Inspect separately — it doesn't modify the world
if app.tool_mode == ToolMode::Inspect {
if let Some(tile) = dim.tiles.get(&(tx, tz)) {
let count = tile.terrain.iter().filter(|&&t| t != 0).count();
let h_min = tile.heightmap.iter().min().unwrap_or(&0);
let h_max = tile.heightmap.iter().max().unwrap_or(&0);
app.status_message = format!(
"Tile ({},{}): {} blocks, height {}-{}",
tx, tz, count, h_min, h_max
);
}
return;
}
// Create the operation — always use MultiTileOperation for consistency
let radius = app.brush_radius as i32;
let global_cx = tx * TILE_SIZE as i32 + center as i32;
let global_cz = tz * TILE_SIZE as i32 + center_z as i32;
let min_tx = ((global_cx - radius) >> 7).min(tx);
let max_tx = ((global_cx + radius) >> 7).max(tx);
let min_tz = ((global_cz - radius) >> 7).min(tz);
let max_tz = ((global_cz + radius) >> 7).max(tz);
let mut ops: Vec<Box<dyn Operation>> = Vec::new();
for otx in min_tx..=max_tx {
for otz in min_tz..=max_tz {
let local_cx = (global_cx - otx * TILE_SIZE as i32).clamp(0, TILE_SIZE as i32 - 1) as u32;
let local_cz = (global_cz - otz * TILE_SIZE as i32).clamp(0, TILE_SIZE as i32 - 1) as u32;
if dim.tiles.contains_key(&(otx, otz)) {
let brush = Arc::new(SymmetricBrush::new(app.brush_radius as f64));
let op: Box<dyn Operation> = match app.tool_mode {
ToolMode::Raise => Box::new(HeightOperation {
tile_x: otx,
tile_z: otz,
center_x: local_cx,
center_z: local_cz,
radius: app.brush_radius,
delta: 5,
brush,
before_snapshot: Default::default(),
}),
ToolMode::Lower => Box::new(HeightOperation {
tile_x: otx,
tile_z: otz,
center_x: local_cx,
center_z: local_cz,
radius: app.brush_radius,
delta: -5,
brush,
before_snapshot: Default::default(),
}),
ToolMode::Smooth => Box::new(SmoothOperation {
tile_x: otx,
tile_z: otz,
center_x: local_cx,
center_z: local_cz,
radius: app.brush_radius,
iterations: app.smooth_iterations,
brush,
before_snapshot: Default::default(),
}),
ToolMode::Flatten => Box::new(FlattenOperation {
tile_x: otx,
tile_z: otz,
center_x: local_cx,
center_z: local_cz,
radius: app.brush_radius,
target_height: app.target_height,
brush,
before_snapshot: Default::default(),
}),
ToolMode::Paint => Box::new(PaintOperation {
tile_x: otx,
tile_z: otz,
center_x: local_cx,
center_z: local_cz,
radius: app.brush_radius,
terrain: app.selected_terrain,
brush,
before_snapshot: Default::default(),
}),
ToolMode::Inspect => unreachable!(),
};
ops.push(op);
}
}
}
if ops.is_empty() {
app.status_message = "No tiles found in brush range".to_string();
return;
}
let multi_op = MultiTileOperation { operations: ops };
// Apply the operation first (fills before_snapshot for Flatten/Paint/Smooth)
let result = multi_op.apply(dim);
// Save for undo after applying (op now contains the pre-apply snapshot)
app.save_for_undo(Box::new(multi_op));
match result {
Ok(()) => {
app.status_message = format!("Applied {} to ({},{})", app.tool_mode.name(), tx, tz);
}
Err(e) => {
app.status_message = format!("Error: {:?}", e);
}
}
}

158
gui/src/view.rs Executable file
View file

@ -0,0 +1,158 @@
use egui::{Color32, ColorImage, TextureOptions, Vec2};
use terrafier_core::model::tile::TILE_SIZE;
const TERRAIN_COLORS: [Color32; 7] = [
Color32::from_rgb(194, 178, 128), // Desert
Color32::from_rgb(124, 189, 107), // Grass
Color32::from_rgb(86, 140, 74), // Forest
Color32::from_rgb(128, 128, 128), // Rock
Color32::from_rgb(227, 212, 160), // Sand
Color32::from_rgb(72, 107, 75), // Swamp
Color32::from_rgb(64, 128, 255), // Water
];
pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
let Some(world) = &app.world else {
ui.centered_and_justified(|ui| {
ui.heading("No world loaded. Click 'New World' to begin.");
});
return;
};
let dim = &world.dimensions[0];
let mut min_tx = i32::MAX;
let mut max_tx = i32::MIN;
let mut min_tz = i32::MAX;
let mut max_tz = i32::MIN;
for &(tx, tz) in dim.tiles.keys() {
min_tx = min_tx.min(tx);
max_tx = max_tx.max(tx);
min_tz = min_tz.min(tz);
max_tz = max_tz.max(tz);
}
let display_size = 64u32;
let grid_w = ((max_tx - min_tx + 1) * display_size as i32) as usize;
let grid_h = ((max_tz - min_tz + 1) * display_size as i32) as usize;
let mut img_data = vec![0u8; grid_w * grid_h * 4];
for (&(tx, tz), tile) in &dim.tiles {
let px = ((tx - min_tx) * display_size as i32) as usize;
let pz = ((tz - min_tz) * display_size as i32) as usize;
for ly in 0..display_size as usize {
for lx in 0..display_size as usize {
let sx = (lx * TILE_SIZE / display_size as usize).min(TILE_SIZE - 1);
let sz = (ly * TILE_SIZE / display_size as usize).min(TILE_SIZE - 1);
let idx = sz * TILE_SIZE + sx;
let terrain_type = tile.terrain[idx] as usize;
let base = TERRAIN_COLORS[terrain_type.min(6)];
let h = tile.heightmap[idx];
let height_factor = 0.7 + 0.3 * ((h + 64) as f32 / 384.0);
let (r, g, b) = if app.show_heightmap {
// Heat map: map height to color gradient
let norm = ((h + 64) as f32 / 384.0).clamp(0.0, 1.0);
if norm < 0.25 {
// Blue (low) -> Cyan
let t = norm / 0.25;
((t * 255.0) as u8, (t * 128.0) as u8, 255)
} else if norm < 0.5 {
// Cyan -> Green
let t = (norm - 0.25) / 0.25;
(0, (128.0 + t * 127.0) as u8, ((1.0 - t) * 255.0) as u8)
} else if norm < 0.75 {
// Green -> Yellow
let t = (norm - 0.5) / 0.25;
((t * 255.0) as u8, 255, 0)
} else {
// Yellow -> Red
let t = (norm - 0.75) / 0.25;
(255, ((1.0 - t) * 255.0) as u8, 0)
}
} else {
let r = (base.r() as f32 * height_factor).min(255.0) as u8;
let g = (base.g() as f32 * height_factor).min(255.0) as u8;
let b = (base.b() as f32 * height_factor).min(255.0) as u8;
(r, g, b)
};
let di = (pz + ly) * grid_w + (px + lx);
img_data[di * 4] = r;
img_data[di * 4 + 1] = g;
img_data[di * 4 + 2] = b;
img_data[di * 4 + 3] = 255;
}
}
}
let color_image = ColorImage::from_rgba_unmultiplied([grid_w, grid_h], &img_data);
let texture_id = ui
.ctx()
.load_texture("world_map", color_image, TextureOptions::LINEAR);
let desired_size = Vec2::new(grid_w as f32, grid_h as f32);
let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click());
let rect = response.rect;
painter.image(
texture_id.id(),
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
// Draw selection border
if let Some((sel_tx, sel_tz)) = app.selected_tile {
if sel_tx >= min_tx && sel_tx <= max_tx && sel_tz >= min_tz && sel_tz <= max_tz {
let bx = rect.min.x + (sel_tx - min_tx) as f32 * display_size as f32;
let bz = rect.min.y + (sel_tz - min_tz) as f32 * display_size as f32;
let border_rect =
egui::Rect::from_min_size(egui::pos2(bx, bz), Vec2::splat(display_size as f32));
painter.rect_stroke(
border_rect,
0.0,
egui::Stroke::new(3.0, Color32::WHITE),
egui::StrokeKind::Middle,
);
}
}
// Draw brush position marker
if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) = (app.selected_tile, app.brush_local_x, app.brush_local_z) {
let tile_x_in_pixels = (sel_tx - min_tx) as f32 * display_size as f32;
let tile_z_in_pixels = (sel_tz - min_tz) as f32 * display_size as f32;
let brush_x = rect.min.x + tile_x_in_pixels + (bx as f32 * display_size as f32 / TILE_SIZE as f32);
let brush_z = rect.min.y + tile_z_in_pixels + (bz as f32 * display_size as f32 / TILE_SIZE as f32);
let brush_radius_px = app.brush_radius as f32 * display_size as f32 / TILE_SIZE as f32;
painter.circle_stroke(
egui::pos2(brush_x, brush_z),
brush_radius_px.max(2.0),
egui::Stroke::new(1.5, Color32::YELLOW),
);
}
// Handle click to select tile
if response.clicked() {
if let Some(pos) = response.interact_pointer_pos() {
let lx = pos.x - rect.min.x;
let lz = pos.y - rect.min.y;
if lx >= 0.0 && lz >= 0.0 {
let tx = (lx / display_size as f32).floor() as i32 + min_tx;
let tz = (lz / display_size as f32).floor() as i32 + min_tz;
if dim.tiles.contains_key(&(tx, tz)) {
app.selected_tile = Some((tx, tz));
let local_x = ((lx as u32 % display_size) * TILE_SIZE as u32 / display_size).min(127);
let local_z = ((lz as u32 % display_size) * TILE_SIZE as u32 / display_size).min(127);
app.brush_local_x = Some(local_x);
app.brush_local_z = Some(local_z);
app.status_message = format!("Selected tile ({}, {}) at local ({}, {})", tx, tz, local_x, local_z);
}
}
}
}
}